Skip to content

Describe each function's cost with a single model - #2

Open
impartialstate wants to merge 2 commits into
claude/cost-move-to-common-e1a2dpfrom
claude/cost-unified-model-e1a2dp
Open

impartialstate wants to merge 2 commits into
claude/cost-move-to-common-e1a2dpfrom
claude/cost-unified-model-e1a2dp

Conversation

@impartialstate

Copy link
Copy Markdown
Owner

Second of three. Base: #1 — review the two commits on this branch, not the whole diff against master.

Why

The estimator and the tracker each had their own vocabulary and their own switch statement over the standard library, and every extension overload needed a hand-written estimator plus a matching hand-written tracker. Keeping the halves in agreement was manual, and they had already drifted:

  • startsWith was estimated from the affix but tracked from the target
  • distinct charged for comparing string elements at runtime but not when estimating
  • regex.extract charged a base call cost at runtime that estimation did not

What replaces it

A cost.Model declares how the cost of one overload relates to the sizes of its operands:

Base + Alloc + Traversed(operands) [+ size of the result]

Both halves evaluate the same model. Estimation supplies operand sizes derived from ast.Expr, types.Type, and any hint the caller gave; tracking supplies the exact sizes of the ref.Val values the call received, and measures the result rather than predicting it. The seam is cost.Operands, which reads operands lazily — types.String.Size() is len([]rune(s)), so a model only pays to measure what it names. The standard library becomes a table of models consulted by both, retiring the pair of switch statements and the warning comment that guarded them.

Operands are receiver-first on both sides. Estimation used to hand a function its target separately from its arguments while tracking passed one flat list with the receiver first, which is exactly what let startsWith read a different operand in each half.

Estimation

  • cost.Node reports an expression's type, path, size, and the type and size of the values it holds, resolving hints itself, so a cost function no longer threads an estimator through to look them up.
  • cost.Hints is a ready-made Estimator for callers whose knowledge is "this path, or this type, is at most this large".
  • SizeEstimate and CostEstimate collapse into cost.Estimate.

Tracking

  • The shadow stack of evaluated values is gone. Steps are observed depth-first, so a call recovers its arguments by expression id from values recorded as they are produced — no stack search, no heuristics about which entries to drop.
  • cost.AggregateSize is the exported size function, and it accounts for optional values everywhere rather than only in the interpreter.
  • cost.Comprehension exposes an evaluated comprehension — its range, iteration count, and accumulator — so a tracker can charge for iteration. The default remains free.

Extensions

Each overload's cost is declared on one line, and the models are registered with both halves in one line each:

cost.Function("string_lower_ascii", scanString(cost.Operand(0))),
cost.Function("string_index_of_string", searchString(0, 1)),
cost.Function("list_distinct", compareElements(0, cost.Operand(0).UpTo())),

That replaced roughly 400 lines of paired estimator and tracker functions.

Included bug fix (first commit, separately cherry-pickable)

Cost tracking reported zero whenever OptTrackState or OptExhaustiveEval was enabled alongside it. Both features observe every step and each installs its own decorator; the second decorator found the step already wrapped and returned it untouched, so its observer never heard anything. Watchers now hold a set of observers, registered by the identity of the decorator that introduced it so a step decorated twice during planning is not counted twice.

Consequences for reported costs

Test expectations were updated for each; the estimated range still brackets the actual cost everywhere.

Change Effect
startsWith/endsWith cost the shorter of the two operands in both halves matches what the comparison operators already did
Comparing possibly-empty values has a minimum estimate of 0, not 1 matches what evaluation charges
distinct/sort estimates include the string/bytes element penalty tracking already charged it
Regex applies both cost factors together instead of rounding each separately, and charges the base call cost when estimating estimate and actual now agree
Optional sizes are measured through the optional regex.extract costs more than it did

Performance

Cost-tracked evaluation on a filter/map benchmark: 14.9µs / 5483 B / 133 allocs versus 13.4µs / 4562 B / 140 allocs before. ~10% slower, fewer allocations. The first cut was ~40% slower; a reusable operand view and id-indexed operand storage closed most of it.

Testing

go test ./... and go test -race pass. New unit tests in common/cost cover the estimate arithmetic, the size combinators, AggregateSize, Hints, tracker precedence and limits, and that a model evaluated over exact sizes agrees with the same model evaluated over an estimate that knows those sizes. New tests cover comprehension tracking and the observer fix.


Generated by Claude Code

claude added 2 commits August 19, 2026 05:11
Cost tracking reported a cost of zero whenever state tracking or
exhaustive evaluation was enabled alongside it. Both features observe
every step of an evaluation, and each installs its own decorator over
the plan. The second decorator to run found the step already wrapped in
a watcher and returned it untouched, so its observer never heard
anything.

Watchers now hold a set of observers rather than a single one, and a
decorator adds itself to the set of a step which is already watched.
The set is held behind a pointer because an attribute acquires its
qualifiers after it has been decorated, and those qualifiers report
through the same set.

A step may be decorated more than once by the same decorator, which is
what happens as attributes acquire qualifiers during planning, so each
observer is registered under the identity of the decorator which
introduced it and a repeat registration is ignored. Without that, a
select would be counted twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXaZqU8aNuMfJnsxCvYZoJ
The estimator and the tracker each had their own vocabulary and their
own switch statement over the standard library, and every extension
overload needed a hand written estimator and a matching hand written
tracker. Keeping the two halves in agreement was manual, and they had
already drifted: startsWith was estimated from the affix but tracked
from the target, distinct charged for comparing string elements at
runtime but not when estimating, and regex extract charged a base call
cost at runtime that estimation did not.

A cost.Model now declares how the cost of one overload relates to the
sizes of its operands:

    Base + Alloc + Traversed(operands) [+ size of the result]

and both halves evaluate it. Estimation supplies operand sizes derived
from ast.Expr, types.Type, and any hint the caller provided; tracking
supplies the exact sizes of the ref.Val values the call received, and
measures the result rather than predicting it. The seam between them is
cost.Operands, which reads operands lazily, so a model only pays to
measure what it names. The standard library is a table of models
consulted by both, which retires the pair of switch statements and the
warning comment that guarded them.

Operands are receiver first on both sides. Estimation used to hand a
function its target separately from its arguments while tracking passed
one flat list with the receiver first, which is what let startsWith
read a different operand in each half.

Estimation:
  - cost.Node reports an expression's type, path, size, and the type
    and size of the values it holds, resolving hints itself, so a cost
    function no longer threads an estimator through to look them up.
  - cost.Hints is an Estimator for callers whose knowledge is limited
    to "this path, or this type, is at most this large".
  - SizeEstimate and CostEstimate become cost.Estimate, since sizes and
    costs are converted into one another constantly.

Tracking:
  - The shadow stack of evaluated values is gone. Steps are observed
    depth first, so a call recovers its arguments by expression id from
    values recorded as they are produced, with no stack search and no
    heuristics about which entries to drop.
  - cost.AggregateSize is the exported size function, and it accounts
    for optional values everywhere rather than only in the interpreter.
  - cost.Comprehension exposes an evaluated comprehension, so a tracker
    can charge for iteration. The default remains free.

The extension libraries declare each overload's cost on one line and
register the models with both halves in one line each, replacing around
400 lines of paired estimator and tracker functions.

Consequences for reported costs:
  - startsWith and endsWith cost the shorter of the two operands in
    both halves, as the comparison operators already did.
  - Comparing values which may be empty has a minimum estimate of zero
    rather than one, matching what evaluation charges.
  - distinct and sort estimates include the cost of comparing string
    and bytes elements, which tracking already charged.
  - Regex costs apply their two cost factors together rather than
    rounding each up separately, and charge the base call cost when
    estimating.
  - Sizes of optional values are measured through the optional, so
    regex extract costs more than it did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXaZqU8aNuMfJnsxCvYZoJ
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.

2 participants