Skip to content

Port JIT costing from sigmastate-interpreter#854

Draft
mwaddip wants to merge 46 commits into
ergoplatform:developfrom
mwaddip:jit-costing
Draft

Port JIT costing from sigmastate-interpreter#854
mwaddip wants to merge 46 commits into
ergoplatform:developfrom
mwaddip:jit-costing

Conversation

@mwaddip
Copy link
Copy Markdown

@mwaddip mwaddip commented Apr 9, 2026

Ports the JIT cost model from sigmastate-interpreter to ergotree-interpreter. validate() sums cost across all inputs against the tx cap via a shared Context, closing the gap where #846's per-input check lets a malicious tx distribute cost across sub-cap inputs.

Beware that there's some conflicts with #858, explained in comments below.

mwaddip and others added 9 commits April 9, 2026 16:31
11 tasks covering cost types, Context changes, eval integration for ~70
operations, type-based costs, method costs, cost propagation, transaction
validation, tests, and cleanup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add CostLimitExceeded error and jit_cost/jit_cost_limit fields to Context
with add_jit_cost() and add_per_item_jit_cost() methods. Replace stub
Cost/Costs types with JitCost newtype. Wire CostLimitExceeded into EvalError.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add fixed, per-item, type-based, and method-level costs to ~70 eval
implementations. Fixed costs added before operation, per-item costs
added after computing item count, type-based costs check for BigInt.
Method dispatch functions in sbox, sgroup_elem, savltree, scoll,
scontext, soption, sheader, spreheader, sglobal all get their costs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
reduce_to_crypto now resets jit_cost, evaluates, and reads accumulated
cost into ReductionResult.cost. Verifier forwards reduction_result.cost
to VerificationResult.cost.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…l cost

validate() now returns Result<u64, TxValidationError> with total script
cost. Sets per-script jit_cost_limit from MaxBlockCost parameter. Resets
cost between inputs. Add MaxBlockCost to default Parameters.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three tests: trivial prop cost (Constant=5, block cost 0), SELF.value
script cost (58 JitCost, block cost 5), and cost limit exceeded error.
Also fix reduce_to_crypto to short-circuit on CostError instead of
retrying with spanned expressions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@mwaddip
Copy link
Copy Markdown
Author

mwaddip commented Apr 12, 2026

Note: relationship to #858 (lazy constant resolution)

#858 eliminates the deep clone + tree walk in reduce_to_crypto by resolving ConstPlaceholder nodes lazily during evaluation. It touches some of the same files as this PR (context.rs, eval/expr.rs, eval.rs) but the two are independent.

Whichever merges first, the other needs a small mechanical rebase:

mwaddip and others added 12 commits April 21, 2026 17:37
validate() previously called context.reset_jit_cost() at the top of
every per-input verify loop, and reduce_to_crypto::inner did the same
at each invocation. Combined, this meant jit_cost_limit was checked
only against a single input's cost — a tx with N inputs each just
under the limit would pass despite the total cost exceeding MaxBlockCost,
diverging from Scala's sum-then-check semantics (JIT_COSTING_FIX_PLAN.md
gap S4).

Both resets are removed. reduce_to_crypto now tracks cost as a delta
from the caller's accumulator state (cost_before snapshot), so
ReductionResult::cost stays per-call for existing consumers while the
accumulator grows cumulatively across calls on the same Context.
The diagnostic retry path rolls the accumulator back to cost_before
so the re-invocation cannot spuriously trip the limit.

Regression: test_validate_enforces_cumulative_jit_cost_across_inputs
builds a 3-input Const(true) tx (15 JitCost cumulative) with
max_block_cost=1 (→10 JitCost limit) and asserts the cumulative
check fires on the third input.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ports estimate_crypto_cost from PR 846 as a new pure module and wires it
into validate() so the JIT cost accumulator (Phase 1) also includes
sigma-protocol verification cost alongside ErgoTree evaluation cost.
Addresses JIT_COSTING_FIX_PLAN.md gap S3 (missing crypto cost).

ergotree-interpreter/src/sigma_protocol/crypto_cost.rs is a verbatim
port from PR 846 (arkadianet/jit-costing). Constants match Scala
sigmastate-interpreter's estimateCryptoVerifyCost: ProveDlog=3980,
ProveDhTuple=7140, conjunction=15, threshold polynomial terms per the
n-k secret-sharing scheme.

validate() no longer delegates to verify_tx_input_proof; it inlines the
per-input path (update_context → storage-rent bypass → reduce_to_crypto
→ charge crypto cost → verify_signature → ReducedToFalse detection).
This gives us a reference to reduction.sigma_prop between reduction and
verification so we can cost the sigma proof. The public helper
verify_tx_input_proof is unchanged for external callers (e.g. WASM).

Crypto cost is charged through context.add_jit_cost, so it flows through
the same cumulative limit check as eval cost — preserving Phase 1's S4
fix. estimate_crypto_cost returns u64 while add_jit_cost takes u32;
realistic sigma props are well under u32::MAX (deep thresholds bounded
by SigmaConjectureItems' BoundedVec cap of 255), but any overflow is
surfaced as a CostLimitExceeded error rather than a silent wrap.

Verification: 6 new unit tests in crypto_cost.rs (TrivialProp,
ProveDlog, ProveDhTuple, Cand, Cor, Cthreshold), plus the Phase 1
regression test continues to pass since it uses Const(true) scripts
that reduce to TrivialProp(true) (crypto_cost = 0, so the cumulative
limit check still fires on the third input's eval step).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ports INTERPRETER_INIT_COST + compute_tx_init_cost from PR 846 and wires
them into validate() so the per-tx JIT budget also includes the
per-input, per-data-input, per-output, and per-token structural costs
that Scala charges once before any script runs. Addresses
JIT_COSTING_FIX_PLAN.md gaps S1 (INTERPRETER_INIT_COST) and S2
(compute_tx_init_cost).

compute_tx_init_cost is a port of PR 846's formula (validated against
19,549 mainnet txs, zero mismatches), which in turn matches Scala's
ErgoTransaction.computeInitiationCost. Returns block cost units; the
caller multiplies by 10 to arrive at JitCost scale.

Worst-case init cost on pathological txs (32767 inputs × 255 tokens)
exceeds u32::MAX JitCost, so a new Context::add_jit_cost_u64 method is
added alongside the existing add_jit_cost(u32). Per-opcode arms keep the
u32 variant; only tx-structural cost uses u64. A new
TxValidationError::InitCostExceeded(u64) variant surfaces the case where
the init cost alone exceeds the per-tx limit — these txs can't honestly
blame any specific input for the overflow.

The Phase 1 regression test is updated to accommodate the init-cost
floor: structural Parameters are zeroed so init reduces to the fixed
INTERPRETER_INIT_COST baseline (100,000 JitCost), then max_block_cost is
sized to 10,001 — putting the cumulative limit at init + 2×5 JitCost.
Two Const(true) inputs fit, the third overflows on the shared
accumulator exactly as S4 requires.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ports DataValueComparer from PR 846 as a new module and wires it into
BinOp's Eq/NEq dispatch. Closes JIT_COSTING_FIX_PLAN.md bugs 4 and 5.

Before this change, Eq/NEq charged no cost (the arm was literally `{}`
with a "Dynamic — no cost" comment), so an attacker could bytecode a
compare of two arbitrarily-large collections for free and the resulting
eval would diverge from Scala on cost numbers. This is bug 4.
Additionally, there was no length-mismatch short-circuit for collection
equality, so the per-item cost would be charged for mismatched-length
colls that Scala rejects immediately after MatchType dispatch — bug 5.

eq_with_cost charges per-type fixed costs (EQ_PRIM=3, EQ_BIGINT=5,
EQ_GROUP_ELEMENT=172, EQ_BOX=6, EQ_AVL_TREE=6, EQ_TUPLE=4, EQ_OPTION=4,
EQ_HEADER=6, EQ_PREHEADER=4) from Scala's DataValueComparer. For
collections it charges MatchType(1) first — paid always, even on length
mismatch — then short-circuits if lengths differ, else charges per-item
cost selected by element type (EQ_COLL_BYTE=(15,2,128), EQ_COLL_INT=
(15,2,64), …). Tuples and Options recurse on their children.

The cost constants live inline in data_value_comparer.rs as raw u32
rather than porting PR 846's FixedCost/PerItemCost typed wrapper system
(which would be a 400-LOC global `costs.rs` rewrite touching every
opcode). This keeps Phase 4's surface to 3 files (+~200, -3).

PR 854's existing arithmetic/Gt/Lt/Ge/Le/logical/bit costs in bin_op.rs
already match PR 846's values numerically, so no changes there.

Regression tests:
 - primitive_eq_charges_prim_cost: Int == Int pays exactly 3 JitCost.
 - coll_eq_charges_match_type_plus_per_item: Coll[Int] of 3 items pays
   MatchType(1) + SInt per-item (15+2*ceil(3/64)=17) = 18 JitCost.
 - coll_eq_length_mismatch_short_circuits: Coll[Int] of mismatched
   lengths pays only MatchType(1) = 1 JitCost (bug 5 signal).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Before this change, SubstConstants charged per-item JIT cost based on
the caller-supplied `positions.len()`, but the actual work (deserializing
the template tree and walking its constants list during substitution) is
proportional to the template's total constants_len. Closes
JIT_COSTING_FIX_PLAN.md bug 3.

Scala's ErgoTreeSerializer.substituteConstants walks every constant in
the template regardless of how many positions the caller asks to swap,
so mirror that: move the cost charge to after the tree is parsed and
num_constants is known, and drive it from num_constants instead of
positions.len(). Coefficients (base=100, per_chunk=100, chunk_size=1)
are unchanged — PR 846's SUBST_CONSTANTS_COST uses the same values.

Regression test (subst_const_cost_uses_template_count_not_positions_len):
build a 3-constant template, run SubstConstants twice on identical input
but with positions=[0] vs positions=[0,1,2]; assert total JIT cost is
identical. Before the fix the charge diverged by 200 (positions.len()
= 1 vs 3, each chunk at cost 100). Since Expr::Const eval is fixed-cost
regardless of payload (eval/expr.rs:22), the two runs have no other
divergence source.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Map, Filter, Fold, ForAll, and Exists all invoke a lambda per
collection item, which binds the item into the environment. Scala's
reference JIT costing charges ADD_TO_ENV_COST (5 JIT) on each of those
bindings; sigma-rust previously charged only the collection's
per-chunk cost, underpricing scripts that iterate over large
collections.

Insert `ctx.add_jit_cost(5)?` inside each op's lambda-call closure,
between the env-capture and env-insert sites. Charging per invocation
(rather than pre-charging 5 * n_items) preserves short-circuit
correctness for ForAll and Exists: when the predicate terminates
iteration early, the remaining items are neither evaluated nor
charged.

Add `forall_charges_add_to_env_per_iteration` regression test proving
the delta between a 4-invocation full eval and a 1-invocation
short-circuit is exactly 3 * (5 env + 5 Const body) = 30 JIT.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Slice previously charged `add_per_item_jit_cost(10, 2, 100, input_vec.len())`
before extracting `from`/`until`. That made cost scale with the input
collection size — so slicing a 2-item window from a 1000-item input
was as expensive as touching all 1000 items, which can push
otherwise-cheap scripts past the JIT cost limit.

Scala's reference charges the slice cost based on the requested range
(`max(0, until - from)`) before clipping it against the input bounds —
this is "the work you asked for", regardless of how it ends up clipped.
PR 846 follows the same formula.

Move the charge to AFTER `from`/`until` extraction and use
`max(0, until - from)` for the per-item count. The clipping for the
actual returned slice is unchanged.

Add `slice_charges_output_not_input_size` regression test asserting
(a) the cost is identical for `slice(0, 2)` of a 5-elem vs a 1000-elem
input and (b) the cost grows with the requested range size.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A tree whose proposition resolves to a plain `Expr::Const(SSigmaProp)`
(bare P2PK — the most common input script on mainnet) was previously
evaluated through the generic `Expr::Const` arm and paid only 5
JitCost. Scala's reference interpreter prices this case as a unit via
`EvalSigmaPropConstant = 50` — a 10× undercharge on every P2PK input.

Add a `trivial_reduce` short-circuit in `reduce_to_crypto` that
recognises this pattern after `proposition()` and `substitute_deserialize`
run, charges the flat 50 JitCost, and returns the SigmaBoolean directly
without calling the full `inner` evaluator. The short-circuit only
catches non-segregated SigmaProp constants; segregated trees (where
the top-level is still `Expr::ConstPlaceholder`) fall through until
the IR carries resolved constants.

Add `p2pk_trivial_reduce_charges_50` regression test asserting the
JitCost delta for a bare P2PK tree is exactly 50 and the returned
SigmaBoolean round-trips the P2PK's ProveDlog.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Scala's reference interpreter costs `ConstantPlaceholder` nodes at 1
JitCost each, distinct from inline `Constant` at 5 JitCost. Pre-fix,
sigma-rust erased this distinction at proposition time by rewriting
placeholders into `Expr::Const` (via `substitute_constants`), so every
segregated tree ended up paying 5 JitCost per placeholder — overcharging
relative to Scala by 4 JitCost per constant. On mainnet, many scripts
use constant segregation, so this overcharge is common.

Lay the IR plumbing to preserve the distinction:

* Add `ConstantPlaceholder::resolved: Option<Constant>` so the node can
  carry its resolved value without being rewritten into a Const.
* Populate `resolved = None` on parse and at serialization-write time;
  it is filled in later by `Expr::resolve_placeholders`.
* Add `Expr::resolve_placeholders(constants)` that sets the `resolved`
  field on every placeholder node in a tree without substituting the
  node itself — the dual of the existing `substitute_constants`.
* Add `ErgoTree::proposition_for_cost_eval()` that routes segregated
  trees through `resolve_placeholders` instead of `substitute_constants`,
  preserving the `ConstPlaceholder` nodes for downstream costing.

No behavior change yet: all existing callers still use
`ErgoTree::proposition()`, which keeps calling `substitute_constants`.
The interpreter-side switch to `proposition_for_cost_eval()` lands in
Phase 9b.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Scala's reference interpreter costs `ConstantPlaceholder` nodes at 1
JitCost each, distinct from inline `Constant` at 5 JitCost. The 9a
commit laid the IR plumbing to preserve that distinction; this commit
flips the interpreter over to use it.

* `reduce_to_crypto` now calls `tree.proposition_for_cost_eval()`,
  which routes segregated trees through `resolve_placeholders` instead
  of `substitute_constants`. The root expression carries
  `Expr::ConstPlaceholder { resolved: Some(_) }` nodes through to eval.
* The `Expr::ConstPlaceholder` eval arm (previously an error) now
  reads `cp.resolved`, charges 1 JitCost, and returns the underlying
  value.
* `trivial_reduce` is extended to match segregated P2PK —
  `Expr::ConstPlaceholder { resolved: Some(c) }` where
  `c.tpe == SSigmaProp` — so both segregated and non-segregated P2PK
  scripts short-circuit to `EvalSigmaPropConstant = 50`.
* The pretty-printer now renders resolved placeholders as their
  underlying constant, so diagnostic output (e.g. for the
  trivial-false retry path) remains readable after the switch.

Test retunes for the arithmetic shift:

* `jit_cost_limit_exceeded` previously set `jit_cost_limit = Some(1)`
  and expected the 5-JIT Const eval to trip it. Post-fix the tree's
  placeholder costs 1 JIT, which equals (but does not exceed) the
  limit. Lowered the limit to 0 so the very first cost addition
  trips it.
* `test_validate_enforces_cumulative_jit_cost_across_inputs` hardcoded
  `PER_INPUT_JIT = 5` against a segregated Boolean tree. Switched the
  tree to non-segregated `SigmaProp(TrivialProp(true))`, which
  trivial-reduces at `EVAL_SIGMA_PROP_CONSTANT = 50` — a clean multiple
  of 10, so the JIT→block-cost round-trip in `Parameters` lands on the
  overflow boundary without integer-division loss.
* `jit_cost_trivial_prop`'s comment updated for the new cost number.

New regression test:

* `segregated_constants_charge_1_not_5_per_placeholder` asserts a
  segregated Boolean tree's reduction charges exactly 1 JitCost and
  contrasts it with the non-segregated SigmaProp path (50 JitCost via
  `trivial_reduce`), locking in the per-node cost distinction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ports PR 846's cost-parity smoke harness and reference vectors for
mainnet heights 700000-700050, cross-validating sigma-rust's JIT cost
totals against Scala-computed block costs for 78 real transactions.

smoke_cost_parity replays the Scala block-cost pipeline explicitly
(init + per-input eval + crypto + snap) through reduce_to_crypto,
while smoke_validate_parity exercises the shipped
TransactionContext::validate() path end-to-end. Both assert zero
mismatches against the reference costs in
tx_costs_700000_700050.json. Cost deltas are read via
ctx.jit_cost_value() (not reduction.cost) to avoid precision loss
from sigma-rust storing block cost (JitCost/10 floor) rather than
the raw JitCost PR 846 carried in ReductionResult.

A #[ignore]d full_corpus_cost_parity harness auto-discovers
tx_costs_START_END.json / transactions_START_END.json / headers_..json
triples under ERGO_COST_VECTORS_DIR for running the larger corpora
PR 846 shipped separately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each ValDef insertion into the interpreter environment now pays a
flat 5 JitCost on top of the BlockValue per-item base, matching
Scala's AddToEnvironment step. The ADD_TO_ENV_COST constant is
already charged per lambda invocation in Map/Filter/Fold/ForAll/Exists
(per-iteration env bind); extending it to ValDef closes the remaining
source of env-bind undercounting.

Observed on mainnet tx
518acecdd776ca50508f122abeec9f888f7efbc5ffa5fe7cb0729c446b533bf4
at height 700032: 4 ValDefs in its top-level BlockValue produced a
-20 JitCost (-2 block cost) delta against Scala prior to this fix.
The cost_parity harness (heights 700000-700050) now reports 78/78
exact matches on both smoke_cost_parity and smoke_validate_parity.

Adds block_value_charges_add_to_env_per_val_def regression: asserts
the per-ValDef delta equals Const-rhs (5) + ADD_TO_ENV (5) = 10 JIT,
comparing a 4-ValDef block against a 1-ValDef block to isolate the
per-item contribution from the (constant) BlockValue base.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@mwaddip
Copy link
Copy Markdown
Author

mwaddip commented Apr 21, 2026

Update to the #858 rebase guidance

The integration branch (ergo-node-integration @ 50f44685) now carries both PRs layered together and can serve as the reference for either rebase direction. Pinning the conflict points here since this PR's Phase 9 shifted the shape of the clash vs. the pre-Phase-9 note on #858.

If #858 merges first → this PR rebases as follows:

  • Context: add jit_cost: Cell<u64> + jit_cost_limit: Option<u64> alongside constants: Option<&[Constant]>. The Cell is cloned by with_constants, not shared — see below.
  • reduce_to_crypto (Phase 1): keep perf: lazy constant resolution in ErgoTree evaluation #858's dual-path structure (deserialize vs. lazy-constants common path), but add a cost_before = ctx.jit_cost_value() snapshot before dispatch, and on the common path sync the accumulator back to the caller's ctx after inner returns:
    let ctx_with_c = ctx.with_constants(constants);
    let res = inner(root, &ctx_with_c, cost_before);
    ctx.jit_cost.set(ctx_with_c.jit_cost_value());
    Without this sync, the per-tx cumulative limit in validate() loses every input's cost to perf: lazy constant resolution in ErgoTree evaluation #858's clone.
  • Phase 8 trivial_reduce: extend the Expr::ConstPlaceholder arm to resolve via ctx.constants (not cp.resolved) so segregated P2PK still short-circuits at 50 JIT without depending on Phase 9a.
  • Phase 9a (resolved field) becomes redundantperf: lazy constant resolution in ErgoTree evaluation #858's ctx.constants already resolves placeholders lazily. Drop Phase 9a entirely.
  • Phase 9b reduces to a cost-only change — keep perf: lazy constant resolution in ErgoTree evaluation #858's Expr::ConstPlaceholder eval arm, just change ctx.add_jit_cost(5) to ctx.add_jit_cost(1). Retune jit_cost_limit_exceeded (limit 1→0, since 1 JIT no longer exceeds 1) and test_validate_enforces_cumulative_jit_cost_across_inputs (switch the segregated Boolean tree to non-segregated SigmaProp(TrivialProp(true)) so per-input JIT lands cleanly on 50 via trivial_reduce). Keep the segregated_constants_charge_1_not_5_per_placeholder regression but adapt it to the ctx.constants path.

The integration commit that captures this delta: 9094f693 (fix: charge ConstantPlaceholder at JitCost(1), not 5).

If this PR merges first → #858 rebase: unchanged from the pre-existing note on that PR, with one correction: the ConstPlaceholder handler should charge 1 JitCost (matching Phase 9), not 5.

mwaddip and others added 4 commits April 21, 2026 23:29
Consolidates `add_jit_cost` (u32) and `add_jit_cost_u64` into a single
`add_jit_cost(amount: u64)` with saturating addition. Since this API was
introduced in PR 854 and has no pre-existing external users, the u32/u64
split carried no real benefit — it was just unnecessary ceremony around
"is this contribution bounded by input structure or by tx structure".
The u64 version's saturating-add is safer than the u32 version's
wrapping-add (well-formed inputs can't hit it, but pathological cases
could), so unify on that semantics.

Ripple changes:

* `Context::add_per_item_jit_cost` internally computes cost as u32
  (base + chunks * per_chunk — the product is bounded by small cost
  constants × collection sizes) and widens to u64 at the final
  `add_jit_cost(u64::from(cost))` call. Keeps the u32 argument types
  for call-site ergonomics.
* Per-opcode cost constants that were `u32` are now `u64`:
  `EVAL_SIGMA_PROP_CONSTANT` in `eval.rs`, and the ten
  `EQ_*_COST` / `COLL_MATCH_TYPE_COST` constants in `data_value_comparer.rs`.
  No literal call sites change — integer literals infer as u64 from the
  param type.
* `tx_context.rs::validate()`: `add_jit_cost_u64(init_cost_jit)` becomes
  plain `add_jit_cost(init_cost_jit)`; the crypto-cost path drops the
  `u32::try_from` dance entirely and passes `estimate_crypto_cost`'s
  u64 return value straight through. The overflow-to-CostLimitExceeded
  fallback is now covered by the unified `add_jit_cost`'s own limit
  check — if the sum exceeds the limit, it returns `CostLimitExceeded`
  directly. Removes two unused imports (`CostLimitExceeded`, `EvalError`).

Behavior-preserving: cost-parity smoke harness still 78/78 vs Scala,
Phase 1 cumulative-limit regression passes unchanged, full workspace
green, C bindings build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…temCost

Context::add_per_item_jit_cost computed chunks with ceiling division
`(n + cs - 1) / cs`, which yields 0 chunks for an empty collection and so
charged only `base`. Scala consensus PerItemCost.chunks is
`(n - 1) / chunkSize + 1` with signed Int division: at n=0 that is 1 for
chunkSize>=2 and 0 for chunkSize==1, and CErgoTreeEvaluator.addSeqCost
charges `cost(nItems)` with no empty guard. So the JVM charges
`base + per_chunk` for an empty Coll[T] of a chunkSize>=2 element type while
sigma-rust charged only `base`.

JIT cost feeds exactly one consensus check, the MaxBlockCost limit, so an
undercharge lets a node accept a block the JVM rejects (miner-constructible
fork risk). Observed on mainnet block 1,520,814 tx[12] input 0 (BigInt DEX
script): 2 empty Coll[Long] equalities charged 15 each (base only); the JVM
charges 17 each (base 15 + per_chunk 2).

Replicate Scala's signed `(n-1)/cs + 1` using i64 to avoid the u32 underflow
at n=0. For every n>=1 this equals `ceil(n/cs)`, so all non-empty per-item
charges are byte-identical and only n=0 moves. Corrects all 29
add_per_item_jit_cost call sites at once (Fold / ForAll / Filter / Map /
Slice / Append / AtLeast / XOR / hashing / sigma ops / per-type equality).
Apply the same formula to the dead-code per_item_cost helper to keep it in
lockstep, and drop a stale useless #[allow(dead_code)] on costs.rs's extern
crate (clippy useless_attribute).

Regression tests in data_value_comparer: empty Coll of a chunkSize>=2 type
(Long/Int/Short/Boolean/BigInt/AvlTree/Byte) charges one chunk; empty Coll
of a chunkSize==1 type (Box/GroupElement/Header/PreHeader) charges base only;
non-empty Coll[Long] across a chunk boundary (len 1/48/49) unchanged.

Verification: cost-parity smoke harness 78/78 vs Scala, full ergotree-ir +
ergotree-interpreter suites green (358 + 8 signing), lib clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lock the n=0 chunks fix (e11d418) on two more per-item op classes,
cross-validated by the santa eval fixtures against sigma-state 6.0.3
under activated=3:

- `and_empty` (tree 00960d00): And over an empty Coll[Boolean] constant.
  Const(empty) 5 + And per-item base 10 + 1 chunk * per_chunk 5 = 20
  (was 15 before the n=0 fix; cs=32 >= 2 so chunks(0) moved 0 -> 1).
- `calc_blake2b256_empty` (tree 00cb0e00): hash of an empty Coll[Byte]
  constant. Const(empty) 5 + per-item base 20 + 1 chunk * per_chunk 7 = 32
  (was 25 before the n=0 fix; cs=128 >= 2).

Same root cause as the empty-Coll equality regressions already covered in
data_value_comparer; these pin the And and hashing call sites end-to-end.
Zero-tolerance vs the JVM.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A ConcreteCollection of N boolean constants undercharged JIT cost vs the
JVM/Scala consensus: sigma-rust charged only the ConcreteCollection
Fixed(20), while the JVM also charges Constant.costKind = FixedCost(5)
per BooleanConstant element. In sigma `values.scala`,
ConcreteCollection.eval charges Fixed(20) then evaluates each item via
`evalTo`, and Constant.costKind = FixedCost(JitCost(5)) (:380), so an
N-bool collection costs 20 + 5n on the JVM but 20 in sigma-rust.

This affected only the packed `BoolConstants` representation: the `Exprs`
arm already pays 20 + 5n because each element `Expr::Const` eval charges
5 (expr.rs). The packed form converts the bits straight to a Value
(`bools.clone().into()`) without per-element eval, skipping the N*5.

NOT a ConcreteCollection-PerItemCost divergence: ConcreteCollection is
FixedCost(JitCost(20)) in Scala (values.scala:878), confirmed; making it
per-item would double-count the Exprs arm. Fix at the source: charge
`5 * bools.len()` in the BoolConstants arm so both representations and the
JVM agree (empty -> 20, n -> 20 + 5n). Undercharging feeds the
MaxBlockCost consensus check (same fork class as the n=0 chunks bug).

Surfaced by the santa eval fixture `coll_bool_constants_3` (tree
00850305): JVM 35 vs sigma-rust 20. Regression vectors: empty -> 20,
3 -> 35, 5 -> 45.

Verification: cost-parity smoke harness 78/78 vs Scala, full interpreter
suite green (361 + 8 signing), lib clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@mwaddip
Copy link
Copy Markdown
Author

mwaddip commented May 30, 2026

Pushed 3 commits aligning per-item JIT costing with the Scala reference at empty/edge inputs:

  • e11d418add_per_item_jit_cost chunks now match Scala PerItemCost.chunks = (n-1)/cs + 1 (signed division). Fixes the n=0 undercharge: an empty Coll[T] of a chunkSize≥2 type now pays base + per_chunk (the JVM charges this; addSeqCost has no empty guard). Every n≥1 is byte-identical to the old ceil(n/cs).
  • 66b7ef0 — packed boolean-constant collections now charge the per-Constant cost (5/elem) the JVM applies; ConcreteCollection itself stays FixedCost(20). empty→20, n→20+5n.
  • 9d2f183 — regression vectors (empty And / Blake2b256).

Cost-parity smoke harness still 78/78 vs Scala; full suite green.

@mwaddip
Copy link
Copy Markdown
Author

mwaddip commented May 31, 2026

Integration note: the mismatched-numeric arithmetic coercion (#869 vs develop — coerce Plus(Int,Long) etc. to the wider type instead of rejecting) needs a matching JIT cost here. This PR costs only same-type arith (dispatch on left, no Upcast cost), so once #869 lands and this rebases onto it, mismatched arith would be coerced but undercharged (Plus(Int,Long) 25 vs JVM 35, missing the +10 Upcast cost).

Cost-bearing version staged: https://github.com/mwaddip/sigma-rust/tree/jit-cost-arith-coercion (= this PR + the coercion + its cost: per-op cost keyed on the wider type + the Upcast cost, bigint=30 else 10). Verified — 14 sweep vectors bit-exact (value + cost 35/25/60 + accept/reject), interpreter suite + parity smoke green. Fold in at integration, or fork it for a PR.

mwaddip and others added 5 commits June 1, 2026 00:55
Apply::eval binds each of a lambda's arguments into the interpreter
environment but charged nothing for those insertions -- only the
Apply=Fixed(30) base. Scala's reference JIT costing charges
ADD_TO_ENV_COST (5 JIT) on every env insertion (in FuncValue.eval's
returned closure: env1 = env + (arg -> vArg)), the same charge already
applied to BlockValue ValDefs and per-item coll-op lambda bindings.
Every user-lambda application therefore undercharged by 5 per bound
argument: a single generic Apply was 5 JIT short, and a higher-order
lambda chaining 5 applications was 25 short.

Convert the binding loop from .for_each to a for loop so the
per-insertion cost-limit error propagates via `?`, and charge
ctx.add_jit_cost(5) before each env.insert, mirroring block.rs. The
save/restore of shadowed variables is unchanged. The per-item coll-op
path (Map/Filter/Fold/ForAll/Exists) binds its lambda arg through its
own closure and never routes through Apply::eval, so it is not
double-charged.

Add apply_charges_add_to_env_per_arg_binding regression test proving
the JIT delta between a 2-arg and a 1-arg application is exactly
arg Const (5) + ADD_TO_ENV_COST (5) = 10 (pre-fix would be 5).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SigmaPropBytes charged a flat PerItemCost(35, 6, 1) with n hardcoded to
1, before evaluating its input. sigma-state's `SigmaPropBytes.eval`
evaluates the input first, then charges that PerItemCost over
`numNodes = wrappedValue.size` -- the SigmaBoolean node count. Every
multi-node proposition was therefore undercharged: a ProveDHTuple alone
counts as 4 nodes (one per EcPoint), and CAND/COR/CTHRESHOLD scale with
their children. Surfaced by SANTA's v5 propBytes vectors (sigma-rust
flat 111 vs JVM 111->321 across ProveDHTuple/CAND/CTHRESHOLD/COR).

Add `SigmaBoolean::size` mirroring sigma-state's `SigmaBoolean.size`
(ProveDlog/TrivialProp = 1, ProveDHTuple = 4, conjecture = 1 + children
sizes) and charge `add_per_item_jit_cost(35, 6, 1, size)` after the
input eval, matching the JVM's order.

Tests: `SigmaBoolean::size` proptests (ProveDlog = 1, ProveDHTuple = 4)
+ a conjecture node-count case; `propbytes_cost_scales_with_node_count`
asserts the ProveDHTuple-vs-ProveDlog cost delta is 6*(4-1) = 18.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Coll.flatMap charged PerItemCost(60, 10, 8) over the input collection
length, before running the per-item lambda. sigma-state's `flatMap_eval`
charges that PerItemCost over `res.length` -- the flattened OUTPUT
length -- after building the result. flatMap output is typically much
larger than its input, so sigma-rust under-charged (SANTA v5 flatMap
vectors: 164 vs 329, delta up to -190).

Build the flattened result first, then charge over its length, matching
the JVM's count and ordering (after the per-item lambda calls). Value is
unchanged (existing `eval_flatmap` test); cost parity is covered by the
SANTA flatMap vectors + the cost-parity harness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Coll.indexOf charged PerItemCost(20, 10, 2) over the full collection
length, up-front, ignoring `from` and where the element is found.
sigma-state's `indexOf_eval` charges over `i - start` -- the iterations
actually performed, scanning from `max(from, 0)` to the found index
(inclusive) or the end. sigma-rust therefore over-charged when the
element was found early and mischarged for non-zero `from` (SANTA v5
indexOf vectors: 12 entries, mixed-sign delta +/-3..16).

Scan first, then charge over the iterations performed: `found - start +
1`, or `len - start` when not found. Values are unchanged (existing
`eval_index_of` test); new `index_of_cost_scales_with_iterations`
asserts the found-at-15 vs found-at-0 delta is (20+10*8)-(20+10*1) = 70
(was 0 pre-fix).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`eq_with_cost`'s Coll arm bulk-compared all elements via PartialEq after
charging the collection's per-item cost. sigma-state's
`DataValueComparer` only bulk-compares COA leaf-element collections
(`equalCOA_*`); collections of composite elements (Coll/Tuple/Option/
SigmaProp) fall to generic `equalColls`, which recurses `equalDataValues`
per element -- charging the nested MatchType + per-item each time, and
short-circuiting on the first inequal element. sigma-rust therefore
under-charged nested collection/tuple equality (SANTA v5 NEQ vectors:
delta -1..-18).

Recurse `eq_with_cost` per element for composite-element colls (the
`coll_eq_cost` default arm, via new `is_coa_coll`); keep the bulk compare
for COA leaves. Uses only the existing per-type cost constants, applied
recursively. Mechanism cross-checked against the ergots fix (same COA
set + recursion, JVM-validated by SANTA).

New `nested_coll_eq_recurses_element_cost`: Coll[Coll[Int]]
[[1,2,3],[4,5,6]] vs itself charges 51 (outer 15 + two inner 18); pre-fix
(no recursion) was 15.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@mwaddip mwaddip marked this pull request as draft June 1, 2026 16:00
@mwaddip
Copy link
Copy Markdown
Author

mwaddip commented Jun 1, 2026

PR 854 update notes — running log

Accumulated notes for the PR description update after all fix-plan phases are done.
Assembled from session work on jit-costing starting 2026-04-21.

Architectural findings

PR 846 does NOT enforce cumulative jit_cost_limit across inputs. It creates a
fresh Context per input in validate() and only enforces the per-input limit via
ctx.jit_cost_limit. There is no running_jit > total_limit check anywhere in PR
846's diff. Its mainnet validation (19,549 txs, zero mismatches) proves cost-number
parity on benign txs — it does not prove S4 is closed.

PR 854 + Phase 1 is strictly more correct on S4 than PR 846. After Phase 1,
sigma-rust shares one Context across all inputs in validate() and enforces
jit_cost_limit cumulatively via ctx.add_jit_cost(...) → matches Scala's
sum-then-check semantics.

Known caveats to call out in the final PR description

  • verify_tx_input_proof is intentionally retained even though
    validate() stops using it — the WASM binding at
    bindings/ergo-lib-wasm/src/transaction.rs:430 still delegates to it for external
    callers that need per-input verification without full-tx cost accounting.
  • The Phase 1 regression test
    (test_validate_enforces_cumulative_jit_cost_across_inputs) is load-bearing.

    After Phase 2 introduces estimate_crypto_cost and Phase 3 adds
    INTERPRETER_INIT_COST + compute_tx_init_cost, the 3-input Const(true)
    threshold and max_block_cost = 1 tuning may need tightening to keep the test
    isolated to the cumulative-limit regression signal (not spuriously triggered by
    init+crypto overheads on a 1-input tx).
  • Context::add_jit_cost takes u64 with saturating addition. Per-opcode
    cost literals continue to work unchanged (integer literals infer as u64
    from the param type); add_per_item_jit_cost widens its internal u32
    product to u64 at the final call site. The u32/u64 split introduced
    during Phases 2-3 (separate add_jit_cost_u64 for the per-tx init cost)
    has been reverted — the API is PR 854's own creation with no pre-existing
    external users, so there was no benefit to carrying two methods.
  • ADD_TO_ENV_COST (5 JIT) is charged per lambda invocation, inside
    each coll op's closure (Map/Filter/Fold/ForAll/Exists), rather than
    pre-charged as 5 × n_items before the iteration loop. This matches
    Scala's per-invocation semantics and preserves short-circuit
    correctness: ForAll/Exists stop charging env cost when the predicate
    terminates iteration early. PR 846 uses the same per-invocation
    placement.
  • Slice cost is computed from the requested range max(0, until - from),
    not from the clipped output length. Costing is pre-clipping (based on
    what the script asked for); the returned slice is post-clipping
    (intersection with collection bounds). The difference is only
    observable when until > input.len() — such a script pays for the
    unclipped work even though only the in-bounds portion is returned.
    PR 846 uses the same formula.
  • P2PK is priced as a unit, not piecewise. Trees whose proposition
    reduces to a plain SigmaProp constant now short-circuit through
    trivial_reduce and pay a flat 50 JitCost (Scala's
    EvalSigmaPropConstant), instead of flowing through the generic
    constant-evaluation arm. This covers both the non-segregated form
    (Expr::Const(SSigmaProp)) and the segregated form
    (Expr::ConstPlaceholder { resolved: Some(SigmaProp) }), so every
    P2PK input pays the same cost regardless of how the script was
    serialized.
  • Placeholder and constant evaluation are priced separately. A
    ConstantPlaceholder in a segregated tree is now evaluated in place
    at 1 JitCost (Scala's ConstantPlaceholder = Fixed(1)), not
    substituted into Expr::Const and charged 5 JitCost (Scala's
    Constant = Fixed(5)). The distinction exists in Scala's cost model
    so that segregation produces cheaper per-use references to repeated
    constants; sigma-rust previously erased the distinction at
    proposition time, overcharging every segregated script by 4 JitCost
    per constant reference. ErgoTree::proposition_for_cost_eval()
    preserves the placeholder nodes through reduction to restore this.
  • BlockValue charges ADD_TO_ENV_COST (5 JitCost) per ValDef
    insertion.
    Each let binding pays the per-item base cost
    (PerItemCost(1, 1, 10) for the BlockValue itself) plus a flat 5
    JitCost for the environment extension, matching Scala's
    AddToEnvironment step. Prior to this, sigma-rust paid only the
    base and the rhs eval, silently undercounting every non-trivial
    script by 5 × n_val_defs. Observed on mainnet tx
    518acec… @ height 700032: 4 ValDefs short-counted by 20 JitCost
    (2 block cost) before the fix; exact parity after. The same
    ADD_TO_ENV_COST constant is already charged per lambda invocation
    in Map/Filter/Fold/ForAll/Exists since Phase 6.
  • Apply charges ADD_TO_ENV_COST (5 JitCost) per lambda-arg
    binding.
    Apply::eval binds each applied lambda argument into the
    interpreter env; each insertion now pays the flat 5 JitCost
    environment-extension cost, matching Scala's FuncValue.eval closure
    (env1 = env + (arg -> vArg), AddToEnvironmentDesc_CostKind = FixedCost(JitCost(5))). Previously Apply paid only its Fixed(30)
    base and charged nothing for the bindings, undercounting every
    user-lambda application by 5 × n_args: a single generic Apply ran
    5 JitCost short, a higher-order lambda chaining 5 applications ran 25
    short. Same ADD_TO_ENV_COST already charged for BlockValue ValDefs
    (above) and per-item coll-op lambdas (Phase 6); the coll-op path binds
    through its own closure and never routes through Apply::eval, so it
    is not double-charged.
  • SigmaPropBytes cost scales with the proposition's node count.
    Now charges PerItemCost(35, 6, 1) over the SigmaBoolean node count
    (Scala's numNodes = wrappedValue.size), after the input eval, instead
    of a hardcoded n = 1. SigmaBoolean::size: ProveDlog/TrivialProp = 1,
    ProveDHTuple = 4 (one node per EcPoint), conjecture = 1 + children sizes. The old n = 1 undercharged every multi-node proposition.
  • Coll.flatMap cost is charged over the OUTPUT (flattened) length,
    matching Scala's flatMap_eval (addSeqCost over res.length after
    building the result). sigma-rust previously charged over the input
    length before the map; flatMap output is typically ≫ input, so it
    undercounted.
  • Coll.indexOf cost reflects iterations performed, not the full
    length. Scala's indexOf_eval charges PerItemCost(20, 10, 2) over
    i - start (from max(from, 0) to the found index inclusive, or the
    end). sigma-rust now scans first, then charges over found - start + 1
    (or len - start when not found).
  • Nested-collection equality recurses per element. Scala's
    DataValueComparer bulk-compares COA leaf-element colls (equalCOA_*)
    but recurses equalDataValues per element for composite-element colls
    (Coll/Tuple/Option/SigmaProp — generic equalColls), charging the
    nested MatchType + per-item each time (short-circuiting on the first
    inequal element). eq_with_cost now recurses for the coll_eq_cost
    default arm (via is_coa_coll) and keeps the bulk compare for COA
    leaves — only the existing per-type constants, applied recursively.

Phase-by-phase status

Phase Status Commit
1 — S4 per-tx accumulator merged 5e8cedcd
2 — S3 crypto cost merged 7ed2ba82
3 — S1+S2 init cost merged 40056345
4 — Bug 4+5 DataValueComparer merged 807e2c87
5 — Bug 3 SubstConstants count merged 285d8ba0
6 — Bug 6 lambda env cost merged e1faa848
7 — Bug 7 Slice output size merged 5586f013
8 — Bug 2 trivial_reduce merged 0daa18a1
9a — Bug 1 IR plumbing (resolved field, proposition_for_cost_eval) merged dc78df43
9b — Bug 1 interpreter switch + placeholder eval arm merged 233b66f9
10 — Test vectors / cost_parity harness merged pending commit
11 — BlockValue ADD_TO_ENV_COST per ValDef merged pending commit
12 — Apply ADD_TO_ENV_COST per lambda-arg binding merged 1e8d5845
13 — SigmaPropBytes cost scales with SigmaBoolean node count merged c0c40ee6
14 — Coll.flatMap cost over output length merged d404de55
15 — Coll.indexOf cost over iterations performed merged 6b48aff4
16 — nested-collection equality recurses per element merged 84676c07

mwaddip and others added 16 commits June 1, 2026 19:20
flatmap_eval bound the lambda argument (env.insert) without charging the ADD_TO_ENV_COST(5) that every FuncValue application pays on the JVM (AddToEnvironmentDesc, values.scala:1047). coll_map and the other HOFs (filter/fold/exists/forall) all charge it per input element; flatMap was the lone omission, under-charging by 5 per input element. Separate from the output-length per-item charge (d404de5) already landed.

Charge ctx.add_jit_cost(5)? before the binding, mirroring coll_map.rs.

New flatmap_charges_add_to_env_per_input: the cost delta between a 1- and 2-element input equals one lambda-body eval plus 5 (the per-input ADD_TO_ENV); pre-fix it was just the body eval. Values unchanged (existing eval_flatmap). cost-parity smoke 2/2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The jit-costing cost-model commits were never run through `cargo fmt --all` or `cargo clippy --all-features --all-targets`, so the branch failed the CI rustfmt and clippy jobs. Fixed here with no production logic changes.

rustfmt --all: bin_op, coll_slice, data_value_comparer, scoll, subst_const, bindings/ergo-lib-wasm/transaction.

clippy --all-targets -D warnings: add #[allow(clippy::unwrap_used)] to the data_value_comparer / crypto_cost / block / ergotree-ir sigma_boolean test modules (matching the existing test-mod convention); drop two redundant `as u64` casts and two useless `.into()`; enumerate the VerifierError arms in the tx_context cost-limit proptest; fix needless-borrow, needless-range-loop and orphaned-doc lints in the new cost_parity test.

No production logic changed. Suites green: ergotree-interpreter, ergotree-ir, ergo-lib cost_parity 2/2, tx_context proptests 8/8. fmt --all --check and clippy --all-features --all-targets -D warnings both clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
INDEX_OF_EVAL_FN scanned with a bare `==` (PartialEq), leaving the per-comparison cost uncharged. sigma-state indexOf_eval calls DataValueComparer.equalDataValues(xs(i), elem) on each scan step, charging the element-type equality cost (EQ_PRIM=3, EQ_BIGINT=5, EQ_GroupElement=172, ...). sigma-rust therefore under-charged indexOf by that per-element eq cost (SANTA v5 Coll_indexOf vectors).

Route each scan comparison through the cost-charging eq_with_cost (data_value_comparer, also used by Eq/NEq), which post-84676c07 recurses correctly for composite elements. The PerItemCost(20,10,2) iteration charge (6b48aff) is unchanged -- JVM charges both.

index_of_cost_scales_with_iterations now reflects the added per-compare eq cost (delta 70 -> 115 = PerItemCost 70 + EQ_PRIM 3*15). New index_of_charges_element_eq_cost isolates the eq charge by element type (Coll[Long] vs Coll[BigInt], 1 comparison): delta = EQ_BIGINT - EQ_PRIM = 2. Values unchanged (eval_index_of); cost-parity smoke 2/2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
updateMany's PerItemCost charged perChunkCost=1, but Scala consensus (sigma-state 6.0.3, SCollectionMethods.UpdateManyMethod, ast/methods.scala) specifies PerItemCost(baseCost=20, perChunkCost=2, chunkSize=10). The arm was copy-pasted from UPDATED_EVAL_FN (which is legitimately perChunk=1) -- the leftover "updated:" error strings in the arm confirm the origin.

Undercharged by chunks(n)=(n-1)/10+1 per call: 1 for n<=10, 2 for 11<=n<=20. Matches the SANTA v5 blessed vectors (Coll_updateMany_method_equivalence) expected.cost: 158 for n<=3 and 160 for n=14 (sigma-rust previously emitted 157/158).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The numeric 6.0 method eval fns (toBytes/toBits/bitwiseInverse/Or/And/Xor/shiftLeft/Right/toUnsigned) computed their value but never charged the per-method cost, undercharging every call by 5 JitCost.

Scala assigns each FixedCost(JitCost(5)) (SNumericTypeMethods {ToBytes,ToBits,BitwiseOp}_CostKind; SBigIntMethods ToUnsignedCostKind), and every other method family already charges its costKind inside its own eval fn -- which is why v5 cost stayed exact. Closes the systematic v6 cost undercount across the Long/Int/Short/Byte/BigInt 6.0 methods (and the Global.fromBigEndianBytes vectors, which compose x.toBytes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Both charged a flat 10 JitCost. Scala assigns Global.encodeNbits EncodeNBitsCost = FixedCost(JitCost(25)) and Global.decodeNbits DecodeNBitsCost = FixedCost(JitCost(50)) -- the v6 -15 / -40 undercharges SANTA surfaced. Includes a regression test isolating each method's costKind (decode-minus-encode delta = 25).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Global.powHit charged a flat 900 JitCost; Scala's PowHitCostKind is input-dependent: baseCost(500) + (k+1) * (totalLen/chunkSize + 1) * perChunkCost, where chunkSize=128 and perChunkCost=7 come from CalcBlake2b256's costKind (the heaviest part is k+1 Blake2b256 invocations over msg||nonce||h). For Autolykos k=32 / short message this is 731, matching the JVM -- the flat 900 was the +169 overcharge SANTA surfaced. Cost is now charged after extracting the args it depends on; adds a k-delta regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ndsWith

reverse charged a flat JitCost(20); Scala reverse_eval charges Append.costKind = PerItemCost(20, 2, 100) over the receiver xs.length (methods.scala 1124), a -2 undercharge on the v6 cost vectors.

startsWith/endsWith likewise charged a flat 20; Scala uses Zip_CostKind = PerItemCost(10, 1, 10) over xs.length (methods.scala 1143/1163), a +9 overcharge each. Charge the per-item costKind over the receiver length to match addSeqCost; add_per_item_jit_cost already replicates Scala's chunks() incl. chunks(0)=1.

Adds scoll_methods_charge_scala_costkinds, isolating each method's MethodCall Fixed(4) + costKind via the subtract-receiver/arg-eval pattern.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The UnsignedBigInt modular eval fns (toUnsignedMod, modInverse, plusMod, subtractMod, multiplyMod, mod, toSigned) took _ctx and charged nothing. Scala assigns each a FixedCost (methods.scala 551-609): toUnsignedMod 15, modInverse 150, plusMod 30, subtractMod 30, multiplyMod 40, mod 20, toSigned 10. Charge each per call, mirroring the numeric NUMERIC_METHOD_COST_KIND pattern.

Latent until now: the v6 UnsignedBigInt surface was unrepresentable to the conformance runner until the SValue bridge landed, so these never surfaced in the 182 measured cost divergences.

Adds unsigned_bigint_modular_methods_charge_scala_costkinds, isolating each method's MethodCall Fixed(4) + costKind via the subtract-receiver/arg-eval pattern.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SERIALIZE_EVAL_FN charged a flat JitCost(10) -- exactly Scala SigmaByteWriter.StartWriterCost -- and dropped every per-put cost accumulated during DataSerializer.serialize. Mirror Scala serialize_eval per-put callbacks (SigmaByteWriter.scala:235-262): StartWriterCost 10, PutByteCost 1, Put{Signed,Unsigned}NumericCost 3, PutChunkCost PerItemCost(3,1,1) => 3 + n.

Add an optional cost sink on SigmaByteWriter (gated -- None for all non-serialize callers, so normal serialization is unaffected) and record each put cost at its site in DataSerializer::sigma_serialize and BigInt256::sigma_serialize. SERIALIZE_EVAL_FN enables tracking, then charges the accumulated per-put total on top of StartWriterCost.

Covers directly-serialized types (Boolean/Byte/Short/Int/Long/String/Coll[Byte]/Coll[Bool]/Coll[_]/Tuple/Option) plus BigInt. Delegated nested serializers (GroupElement, SigmaProp, UnsignedBigInt, AvlTree, Box, Header) stay unmetered pending blessed JVM vectors -- latent only (v6 off mainnet).

Matches blessed JVM v6 vectors: Byte 90, Short/Int/Long 92, (Long,Long) 95, Coll[Byte] 95 empty / 98 for 3 bytes, BigInt roundtrip 247/310. Adds serialize_charges_writer_costkinds, isolating each value serialize cost via the subtract-arg-eval pattern.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…UnsignedBigInt

Option-2 Phase A1 (after Option 1 direct types): meter the delegated nested serializers reachable at the ergotree-ir level. EcPoint (ergo-chain-types, ScorexSerializable) writes one GROUP_SIZE(33)-byte block -> PutChunkCost(33)=36, recorded at the data.rs GroupElement arm and in ProveDlog/ProveDhTuple (EcPoint cannot reach the cost sink itself). SigmaBoolean adds a 1-byte op code (PutByteCost) for every variant. UnsignedBigInt mirrors the BigInt256 fix (putU16 + putBytes). All gated by the cost sink, so non-serialize serialization is unaffected.

Reproduces SANTA blessed JVM v6 totals: GroupElement 125, SigmaProp 126, UnsignedBigInt 95/96/127, Coll[GroupElement] 92/128/164 (+36/element). Extends serialize_charges_writer_costkinds with GroupElement + UnsignedBigInt deltas.

Box/Header (Phase A2/B) remain unmetered pending their own pinning + (for AvlTree/Header) blitzen runner representability.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Option-2 Phase A2a: meter ErgoBox box-level puts during Global.serialize. serialize_box_with_indexed_digests + ErgoBox::sigma_serialize: BoxValue put_u64 (numeric), ergoTree pre-serialized block (PutChunkCost over its length), creation height put_u32 (numeric), token count put_u8 (byte), register count put_u8 (byte), txId Digest32 block (chunk 32), index put_u16 (numeric). All gated by the cost sink, so normal box/tx serialization is unaffected.

Reproduces SANTA blessed minimal Box (139); (Box,Int) follows via tuple recursion + Option-1 Int metering (142). Absolute cost verified by ./conform (no self-contained box fixture in the unit test -- box/ErgoTree construction is heavyweight; the per-put mechanism is already unit-tested in Option 1 / A1).

Deferred (latent, no blessed vector): the tokens-loop body (tokenId chunk + amount) and the register type prefix (SType serialization). The type prefix is Phase A2b, which closes withR4 (143) / (Box,Int) withR4 (146).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Option-2 Phase A2b: meter the type-code byte in TypeCode::sigma_serialize (every serialized type code is one PutByteCost byte). During Global.serialize this fires for Constant type prefixes -- i.e. box register types -- completing the withR4 box cost (the register data was already Option-1 metered; the type prefix was the missing +1). Gated by the cost sink, so all non-serialize type serialization is unaffected.

Closes SANTA blessed Box withR4 (143) + (Box,Int) withR4 (146); verified via ./conform.

Residual latent gap (no blessed vector): embedded-type fast paths in SType::sigma_serialize (Option[prim]/Coll[prim]/tuple-pair) emit a combined type code via a direct put_u8 not routed through TypeCode, so such register types undercharge by 1 byte.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Meter AvlTreeData::sigma_serialize to match Scala's AvlTreeData.serializer under Global.serialize (CoreByteWriter): the 33-byte ADDigest putBytes is PutChunkCost(33)=36, the treeFlags putUByte is PutByteCost=1 (charged via the virtual put), and the valueLengthOpt putOption tag byte is PutByteCost=1. keyLength and the Some valueLength use Scala's no-info putUInt, which writes to the underlying writer directly and is unmetered. Total put cost is 38 for both the empty and with-value-length AvlTree, matching the blessed v6 cost 127.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SType::sigma_serialize emits a single combined type-code byte for embedded compound register types via a direct put_u8 that bypasses the metered TypeCode::sigma_serialize, leaving that byte unmetered — so a box register of such a type undercharges Global.serialize by 1. Meter the five reachable fast-path sites (COLL+prim, NESTED_COLL+prim, TUPLE_PAIR_SYMMETRIC, TUPLE_PAIR1, TUPLE_PAIR2) with add_put_byte_cost, matching the blessed v6 Global.serialize[Box] register entries: Coll[Byte] 178, Coll[Int] 152, Coll[Coll[Byte]] 151, (Int,Int) 146, (Int,Long) 147, (Coll[Byte],Int) 154. The OPTION/OPTION_COLL fast paths are left unmetered: Options are not data-serializable (CheckSerializableTypeCode), so unreachable via serialize.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ErgoBoxCandidate serialization charged a numeric put cost for creationHeight, but Scala writes it via the no-info putUInt (ErgoBoxCandidate.scala:143) which delegates to the underlying writer and is unmetered (unlike putULong/putUByte). This was a +3 base over-count on every serialized box, making all Global.serialize[Box] and [(Box,Int)] entries cost-coal (minimal 142 vs blessed 139). Removing the charge — together with the already-landed fast-path register type-code metering — lands the box family on the blessed costs. Adds serialize_box_body_does_not_charge_creation_height since box cost was previously only checked via ./conform.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant