Skip to content

fix(turn-budget): one turn, one number — hook, bell and panel parity - #68

Merged
axisrow merged 5 commits into
mainfrom
fix/reread-parity
Oct 8, 2026
Merged

axisrow merged 5 commits into
mainfrom
fix/reread-parity

Conversation

@axisrow

@axisrow axisrow commented Oct 8, 2026

Copy link
Copy Markdown
Owner

Problem

Session 67105434 showed Turn budget · 15.1M / 15.0M in the notification bell, while the Visible Context panel showed Re-read · Turn 13 · 60 rq · 7 425 348 tok. Half the spend seemed to vanish between the two surfaces, and there was no way to tell which turn each number belonged to.

Live-file audit (scripts/turn-accounting canon): both numbers were correct — for different turns. 15.1M was the running total at the moment turn 3/8 crossed the 15M budget (final sums 15.46M / 15.63M, both in earlier context phases, so the panel — which shows the current phase — couldn't show them). 7.43M is the panel's Turn 13, exact to the token. The notification simply never named its turn.

Changes

  • Main-chain filter in the canon (scripts/turn-accounting.mjs): new isMainChainAssistantLine (same semantics as loopDetection.ts:314); analyzeTurn applies it, the CLI walker (turnSpendStats.ts feedLine) imports it — the hook, the detector and the panel now exclude sidechain/synthetic lines by the same definition.
  • Notification names its turn (loopDetection.ts, FileWatcher.ts): TurnBudgetIncident carries turnNumber + turnStartTs; the message reads Turn budget · turn 8 (07:08) · 15.1M / 15.0M — … (falls back to current turn when the file opened mid-turn).
  • Panel matches (contextTracker.ts, RereadSection.tsx): RereadInjection.turnStartTs → each Re-read row shows the turn's start time next to Turn N, so a notification and a panel row can be matched unambiguously.
  • T2 — parity pinned on real-shaped data: turnAccounting.parity.test.ts now asserts analyzeTurn (hook) == TurnBudgetDetector.feed fed in batches of 1/3/5 at every prefix (the live FileWatcher shape) == sumTurnReread (panel), plus sidechain exclusion everywhere. Detector got a currentTurnTotal seam for it.
  • T3 — --audit CLI: pnpm turn-spend:stats -- --audit <file.jsonl> prints the three accountings side by side per turn and a parity verdict. Live runs: parity OK on 19 + 278 + 82 turns across three sessions, including the 15.46M / 15.63M turns.

Number inventory (what is covered where)

Number Shown at Computed by Parity
turn re-read hook deny, bell notification, panel Re-read, chat pill turn-accounting.mjs (single canon) parity test + --audit (this PR)
turn budget spent-at-crossing bell notification TurnBudgetDetector (same canon, edge snapshot) covered by the incident test
per-turn billed chips, session totalTokens/turnCount chat chips, sidebar, CLI separate consumers of the canon not covered yet — candidates for follow-up issues

Fixes the "where did 15M go" confusion; the numbers themselves were never wrong.

axisrow and others added 4 commits October 9, 2026 00:12
Live audit of session 67105434 showed both numbers were correct for different turns; the notification never named its turn. Canon gains isMainChainAssistantLine (hook/CLI/panel exclude sidechain+synthetic by one definition), the incident carries turnNumber+turnStartTs so the bell reads 'Turn budget · turn 8 (07:08) · 15.1M / 15.0M', the panel's Re-read rows show the turn start time, and parity is pinned by a batch-feed test plus a new --audit CLI (parity OK on 19+278+82 live turns).

Co-Authored-By: Claude Code <noreply@anthropic.com>
Top rows (the biggest turns — the ones worth matching against a bell notification) were exactly the ones whose turn start time got truncated away.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Red→green: a session with a compaction boundary lost its pre-compact reread rows on 'Current' (the 15M turns of session 67105434 were invisible — phase snapshot only), and the bell's 15.1M read as a mismatch against the panel's final 15.46M. Now phaseInfo.rereadAll keeps every reread injection session-wide (tagged phaseNumber, rendered with a ph badge), ChatHistory merges it into the panel input, and the notification says '15.1M at crossing / 15.0M' so the edge-triggered snapshot is not mistaken for the turn's final sum. Tests 955/955; CLI audit parity OK (19 turns).

Co-Authored-By: Claude Code <noreply@anthropic.com>
toLocaleTimeString without hour12 renders "05:03 PM" under en-US (CI)
but "17:03" under ru, so the startup catch-up message broke the
/turn N (\d{2}:\d{2})/ assertions only in CI. hour12: false makes the
bell message deterministic in every locale.

Co-Authored-By: Claude Code <noreply@anthropic.com>

@axisrow axisrow left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Verdict: changes requested

The accounting core is solid. Verified: the canon filter matches the detector predicate; the --audit indexing is correct (detTotals[0] is the pre-first-boundary prefix, one push per boundary plus the final push, so detTotals[i+1] is the final total of turn i+1); the batch-feed parity test exercises real prefixes; the ChatHistory merge of rereadAll cannot double-count (reread is excluded from totalEstimatedTokens, the pill total comes from lastAssistantTotalTokens). The jsonl.test.ts fixture change is correct and required: after #67 mixed relay+text is user text (the whole-content rule makes isUserChunkLine true), so both edited tests fail on current main — the pure-relay fixture restores their original intent.

Two findings, both on the core deliverable of this PR — matching a bell notification to a panel row:

  1. Turn numbering bases diverge (inline, loopDetection.ts). The detector increments turnNumber on canon isTurnBoundary = user messages + compaction summaries. The panel and chat Turn N number only user messages + teammate relays (aiTurnIndex(userCount + relayCount); jsonl.ts turnCount explicitly gives compaction no number). A compaction before the crossing turn inflates the bell number by the compaction count; teammate relays deflate it. Session 67105434 — the session that motivated this PR — had compaction phases, so the mismatch is guaranteed in exactly the target scenario. The canon already provides isTranscriptTurnLine as the chat Turn N semantics; number the incident with it while keeping the spend-bucket reset on isTurnBoundary.

  2. Row time not locale-pinned (inline, RereadSection.tsx). Commit d92e999 pinned the bell time to 24h with hour12:false for a locale-independent message, but the panel row renders without it — in a 12h locale the panel shows 7:08 PM against the bell 07:08. Reuse the same options.

Notes, no change required: the --audit comment claims the detector path is exactly what FileWatcher streams, but it feeds raw JSONL lines rather than ParsedMessage — accounting-equivalent, wording only. The new parity tests cannot catch finding 1: the fixture has one turn and no compaction or relay, where both numbering bases coincide. Consider a preview-layer test that mixed relay+text IS picked as firstUserMessage — the #59 canon is currently untested at that layer after this fixture swap.

Comment thread src/main/utils/loopDetection.ts Outdated
…inned to 24h

Review findings on PR #68:

1. loopDetection.ts numbered incidents with isTurnBoundary (user+compact),
   but the chat Turn N chips count transcript turns (user+relay) minus
   sidechain and compact summaries — a compaction before the crossing
   inflated the bell number, a relay deflated it. Number with the chip
   canon (isTranscriptTurnLine && !isSidechain && !isCompactSummary);
   the spend bucket still resets on the hook boundary. Regression tests
   cover both directions.

2. RereadSection rendered row time without hour12:false while d92e999
   pinned the bell time to 24h — in a 12h locale the panel showed
   7:08 PM against the bell 07:08. Same options now.

Co-Authored-By: Claude Code <noreply@anthropic.com>

@axisrow axisrow left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Verdict: approved

Re-review of 7feaa7b. Both findings from the previous round are addressed exactly along the suggested direction, and the new tests pin the semantics.

  1. Turn numbering (loopDetection.ts): the detector now numbers turns with isTranscriptTurnLine minus isSidechain/isCompactSummary guards — the jsonl turnCount / chat chip canon — while the spend bucket still resets on isTurnBoundary, carrying the number and start timestamp across the reset. Verified the ordering: numbering increments before the bucket swap so the fresh bucket carries the incremented number; a relay advances the number without resetting the bucket; a compaction resets spend without advancing the number (the guards are needed because isUserChunkLine returns true for masquerading compact summaries — the code comment documents this). Compaction no longer inflates the bell number, relays no longer deflate it; the number now matches the panel Turn N and chat chips in the compaction and team-relay scenarios that motivated the PR.

  2. Row time (RereadSection.tsx): hour12:false added — the panel row and the bell now render identical locale-independent times.

New tests cover both semantics with correct arithmetic: the relay test pins number 2 with spend flowing across it (6M + 6M = 12M in one bucket), the compaction test pins number 1 with the pre-compact 9.5M dropped and the crossing at 10.5M post-compact. Shared re-export of isTranscriptTurnLine verified.

Residual notes from the previous round remain optional and non-blocking: the --audit wording about the detector path, and a preview-layer test that mixed relay+text is picked as firstUserMessage. No changes required.

@axisrow
axisrow merged commit a80fd95 into main Oct 8, 2026
1 check passed
axisrow added a commit that referenced this pull request Oct 10, 2026
… bell's

Post-#68-merge the detector (bell) numbers by transcript canon — teammate
relays advance it — while buildLedger and the calibration CLIs number real
user lines only. The slice-builder comment claimed detector parity; state
the canon it actually implements.

Co-Authored-By: Claude Code <noreply@anthropic.com>
axisrow added a commit that referenced this pull request Oct 10, 2026
* fix(turn): compaction is not a turn — one numbering everywhere

- red: TurnBudgetDetector numbered the post-compact segment as a new turn
  (expected 3 to be 2) — the bell's turn number diverged from the app's
  UserChunk numbering after the first compaction; turnSpendStats slices
  and session turn indexes drifted the same way
- fix: isTurnNumberLine (real user lines only) in the canonical accounting
  core (turn-accounting.mjs + .d.mts declarations + hook re-export +
  eslint import allowlist); TurnBudgetDetector keeps the number across a
  compaction; turnSpendStats prints bucket numbers; buildLedger and the
  deep-dive preview already skip compact markers
- audit: pnpm turn-spend:stats --audit <hhru session> → parity OK (47 turns)
- mutation: manual — reverting the detector fix turns the parity test red
- includes the cycle diagnostics built on this numbering (pnpm
  analyze:session <file> --turn N): cycle_motif / probe_no_progress
  findings and the «цикл или длинный ход» verdict

Co-Authored-By: Claude Code <noreply@anthropic.com>

* chore(ci): retrigger workflow

* feat(session:sums): machine-readable rollup — session → turns → rounds

- pnpm session:sums <file.jsonl> prints raw-number JSON: session totals,
  per-turn sums (rounds count, inputSide/output/billedTotal, hookSpent
  via the hook's analyzeTurn — GUI/bell parity), per-round sums, explicit
  checksums (rounds=turns=session, ledger-vs-hook mismatches listed) and
  cycle attribution (tragedy: input-side of cycle-verdict turns)
- splitSessionPath exported from analyzeSession — reused, not copied
- audit: hhru session 3766d55e — checksums all true, hookParity 0
  mismatches across 47 turns, jq sum 60 259 545 == session total; turn 46
  15 495 691 (cycle-churning); tragedy 31.6M / 52.5% of the session

Co-Authored-By: Claude Code <noreply@anthropic.com>

* chore(ci): add workflow_dispatch trigger

* fix(watcher): deterministic 24h clock in turn-budget label — CI locale printed AM/PM

* docs(turn): turnSpendStats slices number by the ledger canon, not the bell's

Post-#68-merge the detector (bell) numbers by transcript canon — teammate
relays advance it — while buildLedger and the calibration CLIs number real
user lines only. The slice-builder comment claimed detector parity; state
the canon it actually implements.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix(cli): guard session:sums main() with isDirectRun — no side effects on import

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix(cli): deep-dive --turn resolves the turn by ledger index, not array position

Co-Authored-By: Claude Code <noreply@anthropic.com>

* test(turn): a sidechain user line opens no number — parity with chat chips

Co-Authored-By: Claude Code <noreply@anthropic.com>

---------

Co-authored-by: axisrow <axisrow@users.noreply.github.com>
Co-authored-by: Claude Code <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