Skip to content

Commit 77d1ff3

Browse files
anshssclaude
andcommitted
fix(relearn): price the recovery turn from measurement, and draw the line against resend
relearn priced a pothole at a flat 1,500 tokens — an unmeasured guess the code described as 'roughly one extra assistant turn's overhead'. Measured across 271,442 real calls on a live corpus, one claude-code turn costs ~140k tokens (836 fresh + 138,087 cache-read + 795 out), because a coding turn re-sends the whole context. The constant was ~15-20x low, which is why relearn read as noise: ~70% of the Review inbox's cards carrying 0.8% of its money. The constant was standing in for TWO different quantities. Split them, each on the basis it earned: * HEAD — the forced retry. A failed tool call makes the model emit a recovery turn a successful call would not have needed. Now measured per cluster from the sessions it actually occurred in (median billed cost per call, divided back through those sessions' own input rate; falls back to the prompt's input-token equivalents when a corpus records no cost). Floored at the text constant — a retry carries the error text, so it cannot cost less. * TAIL — the error TEXT re-read on later calls. ~1,500 tokens really is the right size for a block of error text, so the constant stays where it was earned. THE LINE BETWEEN THE TWO ANALYZERS, now encoded rather than left to prose: resend prices redundant context inside calls that HAD to happen; relearn prices a call that should never have happened at all. So relearn CLAIMS the head only. The tail is re-sent context resend already prices in full, so it stays in the observed figure, broken out as past_reread_*, and is claimed by resend alone. A test asserts claim + tail == observed, so neither can drift. Also fixes an invariant this change would otherwise have broken silently: the below-threshold residue documents itself as being 'on the same head-term basis as past_overspend_tokens' and was still multiplying by the text constant. It now moves with the head, and its test asserts the invariant instead of pinning the old literal. Measured effect on a real corpus (55 clusters, unchanged — no new detection): observed $46.45 -> $186.32 ($32.21 tail disclosed, $146.23 claimed) residue $5.16 -> $86.30 total $51.61 -> $272.62 (5.3x) The cross-analyzer rollup is unaffected: relearn's CostProposal carries past_overspend_usd=None by design and reports observed_cost_* only, so it contributes $0.00 to the headline and cannot double-count against resend there. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 62cf470 commit 77d1ff3

2 files changed

Lines changed: 259 additions & 15 deletions

File tree

tests/unit/test_relearn_archive_and_cost.py

Lines changed: 125 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,19 @@ def test_recurrence_gate_residue_is_counted_not_silently_dropped(db):
265265
assert finding.clusters == []
266266
assert finding.below_threshold_clusters == 1
267267
assert finding.below_threshold_occurrences == 1
268-
assert finding.below_threshold_past_overspend_tokens == GROUNDED_TOKENS_PER_OCCURRENCE
268+
# Priced on the SAME head basis the kept clusters use — the measured cost
269+
# of the recovery turn, not the error text's size. Asserted as the
270+
# invariant rather than a literal, because the head is corpus-derived now:
271+
# pinning it to the constant is what let the residue silently keep the old
272+
# ~15-20x-low basis when the head term moved.
273+
from tokenjam.core.optimize.analyzers.relearn import _measured_turn_tokens
274+
from tokenjam.core.optimize.rate_profile import blended_rate_profile
275+
276+
expected = _measured_turn_tokens(
277+
db.conn, {"solo"}, blended_rate_profile(db.conn, session_ids={"solo"}),
278+
)
279+
assert expected is not None and expected >= GROUNDED_TOKENS_PER_OCCURRENCE
280+
assert finding.below_threshold_past_overspend_tokens == expected
269281

270282

271283
# --------------------------------------------------------------------------- #
@@ -638,3 +650,115 @@ def test_relearn_finding_round_trips_its_observed_dollar_figure():
638650
# defaults, never to None on a non-optional int.
639651
legacy = report_from_dict({"findings": {"relearn": {"sessions_scanned": 1}}})
640652
assert legacy.findings["relearn"].past_overspend_tokens == 0
653+
654+
655+
# --------------------------------------------------------------------------- #
656+
# The head term is MEASURED, and the claim is disjoint from `resend`.
657+
#
658+
# relearn priced a pothole at a flat 1,500 tokens -- an unmeasured guess for
659+
# "one extra assistant turn's overhead". Measured on a real corpus, one turn
660+
# costs ~140k tokens (836 fresh + 138k cache-read + 795 out), because a coding
661+
# turn re-sends the whole context. The constant was ~15-20x low, which is why
662+
# relearn read as noise: 70% of the inbox cards carrying 0.8% of the money.
663+
#
664+
# The fix splits one conflated constant into the two quantities it was standing
665+
# in for: the forced RETRY TURN (measured, and relearn's own claim) and the
666+
# error TEXT re-read on later calls (~1,500 tokens really is right for a block
667+
# of error text, and it is `resend`'s money, so it is observed but not claimed).
668+
# --------------------------------------------------------------------------- #
669+
670+
671+
def test_head_term_is_measured_from_the_corpus_not_a_constant(db):
672+
"""The recovery turn is priced from what a call actually cost in the
673+
contributing sessions, so a corpus of expensive calls yields a bigger
674+
figure than one of cheap calls off the identical failure count."""
675+
from tokenjam.core.optimize.analyzers.relearn import (
676+
_measured_turn_tokens,
677+
cluster_failures,
678+
)
679+
from tokenjam.core.optimize.rate_profile import blended_rate_profile
680+
681+
failures = []
682+
for i in range(MIN_RECURRING_SESSIONS):
683+
session_id = f"m{i}"
684+
_priced_session(db, session_id)
685+
failures.append(_episode(
686+
session_id,
687+
"File has not been read yet. Read it first before writing to it.",
688+
ts=(BASE + timedelta(days=i)).isoformat(), tool="Edit",
689+
))
690+
sessions = {f.session_id for f in failures}
691+
profile = blended_rate_profile(db.conn, session_ids=sessions)
692+
measured = _measured_turn_tokens(db.conn, sessions, profile)
693+
694+
assert measured is not None, "a priced corpus must yield a measured turn"
695+
# Never BELOW the error-text constant: a forced retry carries the error
696+
# text, so it cannot cost less than the text alone.
697+
assert measured >= GROUNDED_TOKENS_PER_OCCURRENCE
698+
# And it must actually be derived, not the constant echoed back: these
699+
# sessions carry 2,000-token calls, well above the 1,500 floor.
700+
assert measured > GROUNDED_TOKENS_PER_OCCURRENCE
701+
702+
clusters = list(cluster_failures(failures).values())
703+
proposals, _ = build_proposals(
704+
clusters, conn=db.conn, window_days=30.0, persona="claude-code",
705+
repo_cwd_map={"demo": "/tmp/demo"},
706+
)
707+
p = proposals[0]
708+
# The head alone already exceeds what the OLD flat model charged for head
709+
# plus tail combined.
710+
assert p.past_overspend_tokens > p.occurrences * GROUNDED_TOKENS_PER_OCCURRENCE
711+
712+
713+
def test_relearn_claims_the_retry_turn_and_never_the_reread_tail(db):
714+
"""The disjointness rule between the two analyzers, asserted.
715+
716+
`resend` prices redundant context inside calls that had to happen.
717+
`relearn` prices a call that should not have happened at all. The error
718+
text's re-read tail belongs to the first, so it appears in relearn's
719+
OBSERVED figure and never in its CLAIM -- otherwise the same tokens are
720+
priced on two cards (CLAUDE.md rule 27).
721+
"""
722+
from tokenjam.core.optimize.analyzers.relearn import cluster_failures
723+
724+
failures = []
725+
for i in range(MIN_RECURRING_SESSIONS + 2):
726+
session_id = f"d{i}"
727+
_priced_session(db, session_id)
728+
failures.append(_episode(
729+
session_id,
730+
"File has not been read yet. Read it first before writing to it.",
731+
ts=(BASE + timedelta(days=i)).isoformat(), tool="Edit",
732+
))
733+
clusters = list(cluster_failures(failures).values())
734+
proposals, _ = build_proposals(
735+
clusters, conn=db.conn, window_days=30.0, persona="claude-code",
736+
repo_cwd_map={"demo": "/tmp/demo"},
737+
)
738+
p = proposals[0]
739+
assert p.write_offered, "this family has a real fix; expected a claim"
740+
741+
# The observation is the whole cost: retry turns + the text's re-read tail.
742+
# The claim is the retry turns alone, so it is strictly the smaller of the
743+
# two whenever a tail exists at all.
744+
assert p.past_overspend_tokens >= p.estimated_recoverable_tokens
745+
if p.past_reread_tokens:
746+
assert p.estimated_recoverable_tokens < p.past_overspend_tokens, (
747+
"a cluster with a re-read tail must not claim that tail"
748+
)
749+
# The claim plus the tail reconstructs the observation: nothing is
750+
# invented and nothing goes missing between the two figures.
751+
assert (
752+
p.estimated_recoverable_tokens + p.past_reread_tokens
753+
== p.past_overspend_tokens
754+
)
755+
# And both basis strings say which figure is which, in the user's words:
756+
# the observed one discloses the overlap, the claim one says it is excluded.
757+
from tokenjam.core.optimize.analyzers.relearn import (
758+
ESTIMATE_BASIS,
759+
PAST_OVERSPEND_BASIS,
760+
)
761+
762+
assert "must never be added together" in PAST_OVERSPEND_BASIS
763+
assert "NOT claimed here" in ESTIMATE_BASIS
764+
assert "should not have happened" in ESTIMATE_BASIS

tokenjam/core/optimize/analyzers/relearn.py

Lines changed: 134 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -122,12 +122,22 @@
122122
COMPACTION_PROMPT_DROP_RATIO = 0.5
123123

124124
ESTIMATE_BASIS = (
125-
"occurrences x a conservative per-turn token cost (one re-issued tool "
126-
"call + re-narration) — never the inflated whole-session footprint — "
127-
"then x the occurrence's own re-read tail: a failure's text is re-sent on "
128-
"every later call that still carries it, billed at the cache-read rate, so "
129-
"one occurrence is worth 1 + cache_read_ratio x tail input-token "
130-
"equivalents. The tail is truncated at the first compaction (a prompt-size "
125+
"occurrences x the MEASURED cost of one extra assistant turn in the "
126+
"sessions the cluster actually occurred in (median billed cost per call, "
127+
"divided back through those sessions' own input rate) — never the inflated "
128+
"whole-session footprint, and never a fixed guess: a failed tool call "
129+
"forces the model to emit a recovery turn a successful call would not have "
130+
"needed, and in a coding session a turn re-sends the whole context. That "
131+
"forced turn is the CLAIM, and it is the part no other analyzer prices: "
132+
"the context re-send analyzer measures redundant context inside calls that "
133+
"had to happen, whereas this measures a call that should not have happened "
134+
"at all. Observed here but NOT claimed here: the error text's own re-read "
135+
"tail, priced at ~1,500 tokens (the size of a block of error text) x the "
136+
"occurrence's tail, billed at the cache-read rate. Those are re-sent "
137+
"context tokens the context re-send analyzer already prices in full, so "
138+
"they are shown on this card as part of what the recurrence cost and left "
139+
"out of what this card claims, rather than counted twice. The tail is "
140+
"truncated at the first compaction (a prompt-size "
131141
"collapse), not run to end-of-session, and the cluster takes the MEDIAN of "
132142
"its occurrences' multipliers rather than the mean — a handful of very long "
133143
"uncompacted sessions otherwise dominate. Reported NET of what the proposed "
@@ -139,9 +149,11 @@
139149
#: `ESTIMATE_BASIS` above and must never be described with it: that one is a
140150
#: forward, netted, gated CLAIM; this is a backward, ungated OBSERVATION.
141151
PAST_OVERSPEND_BASIS = (
142-
"observed occurrences x a conservative per-turn token cost (one re-issued "
143-
"tool call + re-narration) x the occurrence's own measured re-read tail, "
144-
"priced at the rate the contributing sessions actually billed at. "
152+
"observed occurrences x the MEASURED cost of one extra assistant turn in "
153+
"the sessions the cluster occurred in (the recovery turn a failed call "
154+
"forces and a successful one does not), PLUS the error text's own measured "
155+
"re-read tail, priced at the rate the contributing sessions actually "
156+
"billed at. "
145157
"Accumulated over the scanned corpus, NOT paced to 30 days. Deliberately "
146158
"ungated: a cluster with no fix template in our library and a cluster "
147159
"whose rule is uneconomic to keep both still cost this, and no future "
@@ -578,9 +590,18 @@ def _below_threshold_residue(
578590
if not dropped:
579591
return {"clusters": 0, "occurrences": 0, "tokens": 0, "usd": None}
580592
occurrences = sum(len(c.failures) for c in dropped)
581-
tokens = occurrences * GROUNDED_TOKENS_PER_OCCURRENCE
582593
sessions = {f.session_id for c in dropped for f in c.failures}
583594
profile = blended_rate_profile(conn, session_ids=sessions)
595+
# The SAME head basis the kept clusters use -- the measured cost of the
596+
# recovery turn these occurrences forced, not the error text's size. This
597+
# has to move whenever the head term moves or the docstring's "same
598+
# head-term basis" promise silently becomes false, and the residue starts
599+
# understating itself by the same ~15-20x the head term used to.
600+
turn_tokens = (
601+
_measured_turn_tokens(conn, sessions, profile)
602+
or GROUNDED_TOKENS_PER_OCCURRENCE
603+
)
604+
tokens = occurrences * turn_tokens
584605
return {
585606
"clusters": len(dropped),
586607
"occurrences": occurrences,
@@ -1150,6 +1171,71 @@ def _monthly_scale(window_days: float | None) -> float:
11501171
return 30.0 / window_days
11511172

11521173

1174+
def _measured_turn_tokens(
1175+
conn: Any, session_ids: set[str], profile: RateProfile | None,
1176+
) -> int | None:
1177+
"""What ONE extra assistant turn actually cost in these sessions, in
1178+
input-token equivalents. ``None`` when it cannot be measured.
1179+
1180+
This is the head term's basis, and it replaces a guess. A failed tool call
1181+
forces a retry: the harness hands the error back to the model, the model
1182+
has to emit another turn to recover. A SUCCESSFUL call would not have
1183+
needed that turn, so it is the marginal cost of the failure, and it is one
1184+
whole round trip -- not the ~1,500 tokens of error text that ride along in
1185+
it.
1186+
1187+
Measured from ``cost_usd``, the rate the contributing sessions were
1188+
actually billed at, then divided back through the cluster's own input rate
1189+
so the result lands in the same input-token-equivalent unit every other
1190+
figure here uses. Going through the billed cost rather than summing token
1191+
columns means the cache-read / cache-write / output rate mix is whatever
1192+
those calls really paid, with no ratio assumed locally.
1193+
1194+
The MEDIAN, never the mean: per-call cost in a coding corpus is heavily
1195+
right-skewed (a handful of near-context-limit calls dwarf the rest), and a
1196+
mean would let those set the price of every occurrence.
1197+
"""
1198+
if conn is None or not session_ids or profile is None:
1199+
return None
1200+
if not profile.input_rate_per_token:
1201+
return None
1202+
ids = sorted(session_ids)
1203+
placeholders = ", ".join(f"${i + 1}" for i in range(len(ids)))
1204+
try:
1205+
row = conn.execute(
1206+
f"SELECT median(cost_usd), "
1207+
f"median(COALESCE(input_tokens, 0) "
1208+
f" + COALESCE(cache_tokens, 0) * {profile.cache_read_ratio}) "
1209+
f"FROM spans WHERE session_id IN ({placeholders}) "
1210+
f"AND name = 'gen_ai.llm.call'",
1211+
ids,
1212+
).fetchone()
1213+
except Exception:
1214+
return None
1215+
if not row:
1216+
return None
1217+
billed, from_tokens = row[0], row[1]
1218+
if billed:
1219+
# Preferred: the rate these calls were ACTUALLY billed at, divided back
1220+
# through the cluster's input rate so the result is an input-token
1221+
# equivalent. Assumes no rate mix locally -- the bill already knows it.
1222+
tokens = float(billed) / profile.input_rate_per_token
1223+
elif from_tokens:
1224+
# Fallback for a corpus with no cost recorded (a partial ingest): the
1225+
# prompt's own size in input-token equivalents, on the same
1226+
# `input + cache_read x ratio` convention `_prompt_timelines` uses.
1227+
# Conservative -- it counts the re-sent prompt and not the output.
1228+
tokens = float(from_tokens)
1229+
else:
1230+
return None
1231+
if tokens <= 0:
1232+
return None
1233+
# Floor at the text constant: a measured turn is always the larger of the
1234+
# two, and a pathologically cheap corpus must never price a forced retry
1235+
# BELOW the error text it carries.
1236+
return max(int(round(tokens)), GROUNDED_TOKENS_PER_OCCURRENCE)
1237+
1238+
11531239
def _prompt_timelines(conn: Any, session_ids: set[str]) -> dict[str, list[tuple[Any, int]]]:
11541240
"""``session_id -> [(start_time, prompt_size), ...]`` in wall-clock order.
11551241
@@ -1360,8 +1446,27 @@ def build_proposals(
13601446
# re-read at the cache-read rate, expressed on the head's basis so the
13611447
# token and dollar figures stay proportional. See the constants above.
13621448
scale = _monthly_scale(window_days)
1363-
head_tokens = occurrences * GROUNDED_TOKENS_PER_OCCURRENCE
1364-
gross_tokens = round(head_tokens * multiplier)
1449+
# TWO DIFFERENT QUANTITIES, two different bases. They used to share the
1450+
# `GROUNDED_TOKENS_PER_OCCURRENCE` constant, which is right for one and
1451+
# wrong for the other by a measured ~15-20x:
1452+
#
1453+
# HEAD -- the forced retry. A failed call makes the model emit a turn
1454+
# it would not have emitted had the call succeeded. That turn costs
1455+
# a whole round trip, and in a coding session a round trip re-sends
1456+
# the entire context (this product's own central measurement). On
1457+
# this corpus a real turn measured ~24k input-token equivalents
1458+
# against the 1,500 that were being charged.
1459+
# TAIL -- the error TEXT, re-sent on every later call that still
1460+
# carries it. ~1,500 tokens IS the right size for a block of error
1461+
# text, so the constant stays exactly where it was earned.
1462+
#
1463+
# Conflating them priced the retry as though it were the text.
1464+
turn_tokens = _measured_turn_tokens(conn, sessions, profile)
1465+
head_tokens = occurrences * (turn_tokens or GROUNDED_TOKENS_PER_OCCURRENCE)
1466+
# `multiplier - 1` is the tail's own share (`cache_read_ratio x tail`),
1467+
# kept on the TEXT basis rather than rescaled by the head.
1468+
text_tokens = occurrences * GROUNDED_TOKENS_PER_OCCURRENCE
1469+
gross_tokens = head_tokens + round(text_tokens * max(multiplier - 1.0, 0.0))
13651470
gross_monthly_tokens = round(gross_tokens * scale)
13661471
gross_monthly_usd = (
13671472
round(gross_monthly_tokens * profile.input_rate_per_token, 6)
@@ -1381,8 +1486,23 @@ def build_proposals(
13811486
round(reread_tokens * profile.input_rate_per_token, 6)
13821487
if profile is not None else None
13831488
)
1384-
# The CLAIM, as distinct from the observation above.
1385-
recoverable_tokens = gross_tokens if has_real_fix else 0
1489+
# The CLAIM, as distinct from the observation above -- and deliberately
1490+
# the HEAD ONLY, which is what makes it disjoint from `resend`.
1491+
#
1492+
# THE LINE BETWEEN THE TWO ANALYZERS:
1493+
# `resend` targets context re-sent across calls that HAD to happen.
1494+
# Its fix makes each necessary call carry less.
1495+
# `relearn` targets calls that should never have happened at all.
1496+
# Its fix stops the failure, so the retry turn never occurs.
1497+
#
1498+
# The head is a turn that would not exist if the pothole were fixed, so
1499+
# eliminating it takes its whole cost with it and no other analyzer is
1500+
# claiming that. The tail is re-sent context inside calls that happen
1501+
# regardless -- that is `resend`'s population by definition, and
1502+
# claiming it here would price the same tokens on two cards
1503+
# (CLAUDE.md rule 27). So the tail stays in the OBSERVED figure, broken
1504+
# out as `past_reread_*`, and is claimed by `resend` alone.
1505+
recoverable_tokens = head_tokens if has_real_fix else 0
13861506
recoverable_usd = (
13871507
round(recoverable_tokens * profile.input_rate_per_token, 6)
13881508
if profile is not None else None

0 commit comments

Comments
 (0)