fix(calibration): discard a multiplier learned under another pricing epoch - #114
ian-de-marcellus wants to merge 5 commits into
Conversation
Anarchid
left a comment
There was a problem hiding this comment.
Verdict: approve after one fix
Reviewed at ebf5e65 against main eca70da (0.11.0).
This is the right hotfix for a real wedge. The multiplier is a residual over per-class rates, so stamping it with the pricing epoch is the correct model. The guard runs before the first budget rejection in selectAdaptive, and it covers every path that persists a multiplier. One gap remains: the discard happens only in memory, which leaves the hotfix open to a rollback and to repeated log lines.
Major
1. The discard is never persisted, so rolling back re-arms the wedge. loadCalibration (autobiographical.ts:8519-8524) only logs and keeps 1.0 in memory. The stale {multiplier: 1.8} record stays on disk until the first successful usage sample overwrites it (reportRealInputTokens, :8398). Rolling back to 0.11.0, the current release, loads that unstamped 1.8 unconditionally (main :8492-8494). That reinstates the same OverBudget-before-inference crash loop this PR exists to break. Until a sample lands, the [estimator-calibration] discarding … line also prints on every restart.
Fix: in the mismatch branch, write {multiplier: 1, at, pricing: CALIBRATION_PRICING_EPOCH} (best-effort, like the existing write). Only do this for records whose epoch is lower than the current one or missing. A record from a newer epoch should be left alone, so a later binary still owns it.
Minor
- The regression test doesn't reproduce the failure it names. (Sol)
calibration-band.test.ts:80-92adds six short messages under a 100k budget. That fits easily even at 1.8, so the test checks the loaded factor but never shows that a first compile which would throwOverBudgetErrorat 1.8 now succeeds at 1.0. It is still red on main. Fix: size the context so it fits at 1.0 and exceeds the hard budget at 1.8, seed the stale record, reopen, and assert thatcompile()resolves. - Release note. ae05853 first shipped in v0.11.0 (today). So every store that has run 0.11.0 has a multiplier that is honest but unstamped, and this PR resets it once. The body says so. The changelog fragment should say it too, so operators aren't surprised by a few turns of re-learning.
Inherited (not this PR)
- Calibration is loaded only from
selectAdaptive(main:7557), which runs afterinitialize()→rebuildChunks()(:1658). So chunk boundaries rebuilt at open are always estimated at factor 1.0, whatever the store has learned. The ordering is identical before and after this PR, so it's worth its own issue. (Sol)
Verified fine
- There is a single persistence site (
calibrationStateId,:1081) and a single apply site (applyCalibration→MessageStore.setTokenCalibration).KnowledgeStrategyinherits both. kv-unified is aFoldingSolverinsideAutobiographicalStrategy.selectAdaptive, not a separate calibration owner. AF only reports usage (framework.ts:9587) and persists nothing of its own. loadCalibrationruns at:7557, before the adaptive estimates and the first budget rejection (:7593-7604), so a stale record can't affect that first compile.- Nothing else needs resetting: the estimator state is just
_calibration,_calibrationArmed,_calibrationLoaded. The recall-pair cache key includes calibration (:9050). - main + #114–#118 merged together builds and passes 852/852 (node 22). CI is green.
Method
Two reviewers in parallel. Sol (Codex gpt-5.6-sol, xhigh, read-only, no network; ~10 min, 41 commands, 5.5M input tokens (5.3M cached), 24k output) and Claude (builds, the combined-tree run, release history, and a check of every Sol citation). Both found the non-persisted discard; Sol took it through to the rollback consequence. Sol only: #2, the inherited load ordering. Claude only: the release timing in #3. Sol filed the load ordering as major against this PR; it was reclassified as inherited because main has the identical order.
…epoch The persisted estimator-calibration multiplier is a residual over the per-class token rates, so it is meaningless once those rates change. After ae05853 (signed thinking priced by signature), a store whose multiplier pinned at 1.8 under the flat-600 price reopens estimating ~1.8x its real size. When that inflated plan exceeds the hard budget, the first compile throws OverBudgetError at startup: no inference runs, no sample is reported, and the band fix in reportRealInputTokens never gets to decay it. That leaves a permanent crash loop. Observed on a production resident: 821k real / 1.46M estimated against a 680k budget, restarting every 10s. With the multiplier reset, the same store estimates 822k, matching the 825k the provider actually billed. Stamp the persisted multiplier with CALIBRATION_PRICING_EPOCH and start from 1.0 when the stamp differs (unstamped = epoch 0). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review follow-up (anima-research#114): - Persist {multiplier: 1, pricing: <epoch>} when discarding a record from an older or unstamped epoch, so a rollback to a binary that ignores the stamp can't reload the stale 1.8, and the discard isn't re-logged every restart. Records from a newer epoch are left on disk untouched. - The wedge test now sizes the budget from a measured OverBudgetError so the context fits at 1.0 and not at 1.8, with a current-epoch control that must throw. On main it reproduces the OverBudgetError; on the previous head it fails the new persistence assertion. - Changelog: operators see a one-time reset after upgrading from 0.11.0. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Thanks for the careful review. All three points are addressed in d1361d6. The branch is rebased onto current main (d1d17b0).
The inherited load ordering (calibration loaded after Full suite: 849/849 (node 22). 🤖 Generated with Claude Code |
ebf5e65 to
d1361d6
Compare
|
Migration note from a deployment: the reset also discards multipliers that were still correct. We deployed this on six residents. On five (Anthropic models) it did what it says. On the sixth it caused exactly the "silent failure" described above, but on a multiplier that wasn't stale. That resident runs on OpenAI's tokenizer via Codex. Its unstamped multiplier was 0.6, and that value mostly corrects for the tokenizer difference, not for the signed-thinking price that ae05853 changed. Provider-billed input for its recent requests was about 540k–575k tokens, consistent with 0.6 and not with 1.0. After the upgrade:
What worked: with the host stopped, check the old multiplier against provider-billed input for recent requests. If it holds, re-stamp it under the current epoch before the first compile, with one state write: store.setStateJson(`${ns}/autobio:calibration`, { multiplier: 0.6, at: Date.now(), pricing: 1 });With that, the live first compile matched an offline compile of the same data exactly: 603,779 tokens at a 700k budget, the same frontier, no reset. Suggestion for operators upgrading across this change: treat the discard as a decision, not a formality. It's right for a multiplier learned against the old flat thinking price (the wedge case), and wrong for one that is still accurate. I'll follow up with a small change that makes the reset visible beyond one log line: the reset recorded in the calibration state and exposed in render stats, so |
A discarded multiplier changes every token estimate by its ratio and
refolds the window to match. Sometimes that's right: the multiplier was
learned against the old thinking price. Sometimes it's wrong: e.g. a
store on another provider's tokenizer, where the old value was still
accurate. Either way the operator should see it happened.
- console.warn with the size of the change and how to re-stamp;
- the reset is recorded as `reset: {discardedMultiplier, fromEpoch, at}`
in the persisted calibration state, kept through later learning and
restarts, cleared by an operator re-stamp;
- RenderStats.calibration = {multiplier, pricingEpoch, reset?}, so hosts
surfacing render stats (connectome-host /healthz) show it.
Tests: discard visible in render stats, kept across learning and a
restart, cleared by re-stamp; no-history store reports no reset.
Mutation-checked (dropping the carry-forward fails the test). 851/851.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Follow-up pushed in 5ab0617: a discard is now a |
… was repriced The epoch discard assumed an unstamped multiplier is stale. It is only stale if the store holds content a later epoch repriced: epoch 1 changed the price of signed `thinking` blocks and nothing else. A store without them (e.g. a resident on OpenAI via Codex, whose reasoning arrives as redacted_thinking or unsigned thinking) keeps a residual that still means what it did; discarding it refolded one production window ~27% coarser. - CALIBRATION_EPOCH_REPRICES: per epoch, a predicate for the content it repriced (1: signed thinking). An epoch without an entry counts as repricing everything, so a future bump fails safe (discard). - On load, an older-epoch multiplier is kept and re-stamped when no epoch since then repriced anything the store holds (one warn); otherwise it is discarded and recorded as before. - Tests: the wedge and visibility fixtures now carry signed thinking (the wedge's actual cause); new test: an unstamped 0.6 in a store without signed thinking is kept and re-stamped, with a signed-thinking control that discards. Mutation-checked. 852/852. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
One more, e979974, following up the check promised above: epoch 1 repriced only signed |
|
| if (epoch === current) { | ||
| this._calibration = Math.min(1.8, Math.max(0.6, saved.multiplier!)); | ||
| this._calibrationReset = isCalibrationReset(saved.reset) ? saved.reset : null; | ||
| } else if (epoch < current && !this.calibrationRepricedSince(epoch, store.getAll())) { |
There was a problem hiding this comment.
Stale multiplier survives message removal If signed-thinking messages are removed after an old multiplier was learned, removing them does not clear the calibration record. On upgrade, this check sees no signed thinking and re-stamps the old value as current. A stale 1.8 can then put the plan over the hard budget, causing compilation to fail before a sample can correct it.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/strategies/autobiographical.ts
Line: 8574
Comment:
**Stale multiplier survives message removal** If signed-thinking messages are removed after an old multiplier was learned, removing them does not clear the calibration record. On upgrade, this check sees no signed thinking and re-stamps the old value as current. A stale 1.8 can then put the plan over the hard budget, causing compilation to fail before a sample can correct it.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| 1: (messages) => messages.some((m) => m.content.some((b) => { | ||
| const block = b as { type?: string; signature?: unknown }; | ||
| return block.type === 'thinking' && typeof block.signature === 'string' && block.signature.length > 0; |
There was a problem hiding this comment.
Stamped blocks trigger needless resets A signed-thinking block with
tokenEstimate has the same price under both epochs because its stamped value takes precedence over signature pricing. This predicate still treats it as repriced, so a store containing only such blocks discards an accurate old multiplier. Resetting an accurate value such as 0.6 to 1 can make a tight plan exceed the hard budget.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/strategies/autobiographical.ts
Line: 8542-8544
Comment:
**Stamped blocks trigger needless resets** A signed-thinking block with `tokenEstimate` has the same price under both epochs because its stamped value takes precedence over signature pricing. This predicate still treats it as repriced, so a store containing only such blocks discards an accurate old multiplier. Resetting an accurate value such as 0.6 to 1 can make a tight plan exceed the hard budget.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| `(current ${current}): this store holds no content repriced since then; re-stamped`, | ||
| ); | ||
| try { | ||
| this.store?.setStateJson(this.calibrationStateId, { multiplier: this._calibration, at: Date.now(), pricing: current }); |
There was a problem hiding this comment.
Preview writes calibration state On the first preview after reopening,
previewContext() calls selection with dryRun: true, but calibration loading can still write this re-stamp or persist a reset at line 8604. Merely previewing hypothetical settings can therefore change durable calibration state, despite the preview API's promise to commit nothing.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/strategies/autobiographical.ts
Line: 8583
Comment:
**Preview writes calibration state** On the first preview after reopening, `previewContext()` calls selection with `dryRun: true`, but calibration loading can still write this re-stamp or persist a reset at line 8604. Merely previewing hypothetical settings can therefore change durable calibration state, despite the preview API's promise to commit nothing.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| this.store?.setStateJson(this.calibrationStateId, { | ||
| multiplier: this._calibration, | ||
| at: Date.now(), | ||
| pricing: AutobiographicalStrategy.CALIBRATION_PRICING_EPOCH, | ||
| ...(this._calibrationReset ? { reset: this._calibrationReset } : {}), |
There was a problem hiding this comment.
Usage report overwrites future calibration When a temporarily downgraded binary loads a newer-epoch record, it leaves that record untouched, but its first valid token-usage report writes to the same slot with the current epoch. One ordinary inference thus erases the newer binary's learned multiplier and reset history, leaving them unavailable when it is restored.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/strategies/autobiographical.ts
Line: 8419-8423
Comment:
**Usage report overwrites future calibration** When a temporarily downgraded binary loads a newer-epoch record, it leaves that record untouched, but its first valid token-usage report writes to the same slot with the current epoch. One ordinary inference thus erases the newer binary's learned multiplier and reset history, leaving them unavailable when it is restored.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
Problem
After ae05853 (signed thinking priced by signature length, not a flat 600), a store whose persisted estimator-calibration multiplier had pinned at the 1.8 ceiling under the old price can wedge permanently at startup.
Observed on a production resident: the store estimated 1.46M tokens against a 680k hard budget, threw
OverBudgetErroron the first compile, and crash-looped every ~10 s. With the multiplier reset, the same store estimates ~822k, close to the ~825k the provider actually billed for its recent requests.Cause
The multiplier is a residual over the per-class token rates, so it means nothing once those rates change. ae05853's band fix lets a pinned multiplier decay, but only by learning from real samples. If the inflated plan is over the hard budget, the first compile throws before any inference runs. No sample is ever reported, so the multiplier never gets the chance to recover.
A resident that is not over budget has the opposite, silent failure. A second production resident started fine, but with estimates ~1.8× too high it folded its window down to roughly 40% of its real previous size, then crept back up over many turns, busting the cache at each step.
Fix
Stamp the persisted multiplier with
CALIBRATION_PRICING_EPOCH(1 = signature-priced thinking; an unstamped record counts as epoch 0). On load, a multiplier from a different epoch is discarded with one[estimator-calibration] discarding …line, and the strategy starts from 1.0. Bump the epoch whenever the MessageStore/ContextLog pricing changes shape.The cost is a one-time reset for stores whose multiplier was already learned honestly after ae05853. They re-learn it through the same EMA.
Tests
test/calibration-band.test.ts:Full suite: 820/820.
🤖 Generated with Claude Code