Skip to content

fix(calibration): discard a multiplier learned under another pricing epoch - #114

Open
ian-de-marcellus wants to merge 5 commits into
anima-research:mainfrom
ian-de-marcellus:fix/calibration-pricing-epoch
Open

ian-de-marcellus wants to merge 5 commits into
anima-research:mainfrom
ian-de-marcellus:fix/calibration-pricing-epoch

Conversation

@ian-de-marcellus

Copy link
Copy Markdown
Contributor

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 OverBudgetError on 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:

  • a multiplier persisted without a stamp (1.8) is discarded on reopen (red without the fix);
  • a multiplier learned under the current epoch is saved with the stamp and survives reopen.

Full suite: 820/820.

🤖 Generated with Claude Code

@Anarchid Anarchid left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. The regression test doesn't reproduce the failure it names. (Sol) calibration-band.test.ts:80-92 adds 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 throw OverBudgetError at 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 that compile() resolves.
  2. 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 after initialize() → 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). KnowledgeStrategy inherits both. kv-unified is a FoldingSolver inside AutobiographicalStrategy.selectAdaptive, not a separate calibration owner. AF only reports usage (framework.ts:9587) and persists nothing of its own.
  • loadCalibration runs 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.

ian-de-marcellus and others added 3 commits September 25, 2026 17:24
…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>
@ian-de-marcellus

Copy link
Copy Markdown
Contributor Author

Thanks for the careful review. All three points are addressed in d1361d6. The branch is rebased onto current main (d1d17b0).

  1. Persisted discard: the mismatch branch now writes {multiplier: 1, at, pricing: CALIBRATION_PRICING_EPOCH} for records from an older or unstamped epoch, so a rollback can't reload the stale multiplier. A record from a newer epoch is left on disk untouched and simply not used. There's a test for that case too.
  2. The test reproduces the wedge: the budget is sized from a measured OverBudgetError, so the context fits at 1.0 but not at 1.8, with a current-epoch 1.8 control that must throw. Verified both ways: on main it throws OverBudgetError (10,776 > 8,245); on the previous head it fails the new persistence assertion.
  3. Changelog: it now notes the one-time reset for stores that ran 0.11.0.

The inherited load ordering (calibration loaded after rebuildChunks at open): agreed it's separate. I'm happy to open an issue for it or send a follow-up PR.

Full suite: 849/849 (node 22).

🤖 Generated with Claude Code

@ian-de-marcellus
ian-de-marcellus force-pushed the fix/calibration-pricing-epoch branch from ebf5e65 to d1361d6 Compare September 25, 2026 15:40
@ian-de-marcellus

ian-de-marcellus commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor Author

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:

  • [estimator-calibration] discarding multiplier 0.60 … restarting from 1.00
  • every estimate was about 1.67× too high. Even with the budget raised from 700k to 850k, the first compile rendered only 3.89M characters of context, against 5.29M for the old code at 700k: far coarser folding than needed. It would have crept back over many turns, re-busting the cache each time;
  • the one log line was the only signal. We found it only because the switch was gated on an exact offline prediction, which failed, so we rolled back and investigated.

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 /healthz shows it. I'm also looking at whether the discard can be limited to stores that actually contain signed thinking.

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>
@ian-de-marcellus

Copy link
Copy Markdown
Contributor Author

Follow-up pushed in 5ab0617: a discard is now a console.warn saying how much every estimate changes and how to re-stamp the old value. It is also recorded as reset: {discardedMultiplier, fromEpoch, at} in the persisted calibration state and exposed as RenderStats.calibration ({multiplier, pricingEpoch, reset?}), so /healthz shows it. The record survives restarts and later learning until an operator re-stamps. Changelog fragment updated with the operator note. 851/851 tests; the new test is mutation-checked.

… 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>
@ian-de-marcellus

Copy link
Copy Markdown
Contributor Author

One more, e979974, following up the check promised above: epoch 1 repriced only signed thinking blocks. Reasoning from OpenAI Responses (and so Codex) arrives as redacted_thinking or unsigned thinking, so for those stores the old multiplier's residual never changed meaning. An older-epoch multiplier is now discarded only if the store holds content repriced since its epoch (CALIBRATION_EPOCH_REPRICES, per epoch; a missing entry counts as repricing everything, so future bumps fail safe). Otherwise it is kept and re-stamped. The wedge test's fixtures now carry signed thinking, which is the wedge's real cause. New test with control; mutation-checked; 852/852.

@greptile-apps

greptile-apps Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 1/5 Tier: apex

[Medium risk] Fixes token-estimate calibration across pricing changes.

The PR does not appear safe to merge until calibration migration preserves valid estimates, handles stale history, and respects preview and rollback behavior.

Findings

  1. P1 Stale multiplier survives message removal ▶
  2. P1 Stamped blocks trigger needless resets ▶
  3. P1 Preview writes calibration state ▶
  4. P1 Usage report overwrites future calibration ▶
Fix with agent prompt
### Issue 1
src/strategies/autobiographical.ts:8574
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.

### Issue 2
src/strategies/autobiographical.ts:8542-8544
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.

### Issue 3
src/strategies/autobiographical.ts:8583
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.

### Issue 4
src/strategies/autobiographical.ts:8419-8423
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.

Summary

This PR stamps persisted estimator calibration with a pricing epoch, conditionally keeps or discards older multipliers, exposes reset history in render stats, and adds reopen and budget regression tests.

  • The keep decision can preserve a historically stale multiplier or discard an unchanged, stamped one.
  • Dry-run previews can persist the migration, and a usage report can overwrite a preserved newer-epoch record.

Reviews (1) · Last reviewed commit: "fix(calibration): keep an old-epoch mult..."

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())) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Comment on lines +8542 to +8544
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Comment on lines +8419 to +8423
this.store?.setStateJson(this.calibrationStateId, {
multiplier: this._calibration,
at: Date.now(),
pricing: AutobiographicalStrategy.CALIBRATION_PRICING_EPOCH,
...(this._calibrationReset ? { reset: this._calibrationReset } : {}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

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