Skip to content

session:sums checksums: rounds=turns is always true, hookParity is one-sided and off by one #102

Description

@axisrow

Part of #94.

⚠️ Выводы не проверены человеком. Ишью заведено по аудиту, который сделал Claude Code. Перед исправлением достаточно один раз выполнить блок «Как проверить» и сравнить вывод с ожидаемым.

Что не так

pnpm session:sums (из #69) печатает checksums, которые должны доказывать, что числа сходятся. Две проверки не проверяют того, что обещают:

  1. roundsSumEqualsTurnsSum всегда true. Обе стороны — одна и та же сумма по roundsByTurn. Если испортить раунды, она остаётся true; порчу видит только turnsSumEqualsSession. Ревью fix(turn): compaction is not a turn — one numbering everywhere #69 отмечало это дважды — не исправлено.
  2. hookParity односторонняя и сдвинута на 1. Сверяются только ходы ledger, а ходы хука без пары в ledger не проверяются. К тому же buildLedger открывает неявный ход для раундов до первого user-сообщения, а hookSpentByTurn даёт им номер 0. Для сессии, которая начинается с релея или сводки компакта, получается ложное match: false со сдвигом на один ход, хотя числа одни и те же.

Как проверить (один раз, ~1 мин)

Из корня репозитория:

cat > /tmp/repro-sums.mts <<'EOF'
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const R = process.cwd(); // запускать из корня репозитория
const load = (p: string) => import(`${R}/${p}`);
let t = Date.parse('2026-10-01T07:00:00Z'), n = 0;
const at = (ms = 20_000) => new Date((t += ms)).toISOString();
const base = (x = {}) => ({ uuid: `u${++n}`, parentUuid: null, sessionId: 's', cwd: '/p', isSidechain: false, ...x });
const user = (content: unknown, x = {}, gap?: number) => ({ ...base(x), type: 'user', timestamp: at(gap), message: { role: 'user', content } });
const ai = (block: object, ctx: number, out: number, ids: { rid?: string; mid: string }, x = {}, gap?: number) => ({ ...base(x), type: 'assistant', timestamp: at(gap), ...(ids.rid ? { requestId: ids.rid } : {}), message: { id: ids.mid, role: 'assistant', model: 'claude-x', content: [block], usage: { input_tokens: 0, cache_read_input_tokens: ctx, cache_creation_input_tokens: 0, output_tokens: out } } });
const text = (s: string) => ({ type: 'text', text: s });
const write = (lines: object[]) => { const f = join(mkdtempSync(join(tmpdir(), 'repro-')), 's.jsonl'); writeFileSync(f, lines.map((l) => JSON.stringify(l)).join('\n') + '\n'); return f; };
const jsonl = await load('src/main/utils/jsonl.ts');
const cli = await load('src/cli/analyzeSession.ts');
const sums = await load('src/cli/sessionSums.ts');
const run = async (lines: object[], corrupt = false) => {
  const f = write(lines);
  const msgs = await jsonl.parseJsonlFile(f);
  const ledger = cli.buildLedger(msgs);
  if (corrupt) for (const r of ledger.rounds) r.inputTokens += 777; // испортить раунды ПОСЛЕ построения итогов
  return sums.buildSums(ledger, cli.computeFindings(msgs, ledger), sums.hookSpentByTurn(readFileSync(f, 'utf8').split('\n'))).checksums;
};
const plain = [user('q1'), ai(text('a'), 100, 1, { rid: 'q1', mid: 'm1' }), user('q2'), ai(text('b'), 400, 1, { rid: 'q2', mid: 'm2' })];
// 1) roundsSumEqualsTurnsSum не может упасть: обе стороны — одна и та же сумма
const c = await run(plain, true);
console.log(`испорченные раунды → roundsSumEqualsTurnsSum = ${c.roundsSumEqualsTurnsSum}, turnsSumEqualsSession = ${c.turnsSumEqualsSession} (порчу видит только вторая: первая сравнивает сумму саму с собой)`);
// 2) hookParity: сессия начинается с релея (или со сводки компакта) — номера ходов сдвинуты на 1
const relayFirst = [user('<teammate-message teammate_id="lead">do X</teammate-message>', { isMeta: false }), ai(text('a'), 100, 1, { rid: 'r1', mid: 'n1' }), ai(text('b'), 200, 1, { rid: 'r2', mid: 'n2' }), user('real question'), ai(text('c'), 400, 1, { rid: 'r3', mid: 'n3' })];
const hp = (await run(relayFirst)).hookParity;
console.log('сессия с релеем в начале → hookParity =', JSON.stringify(hp), '(ожидаемо match: true — числа одни и те же)');
console.log(!hp.match && c.roundsSumEqualsTurnsSum ? 'ВОСПРОИЗВЕДЕНО: контрольные суммы session:sums не проверяют то, что обещают' : 'OK');
EOF
pnpm exec tsx /tmp/repro-sums.mts

Правильно: после порчи раундов roundsSumEqualsTurnsSum = false; для сессии с релеем в начале hookParity.match = true. Сейчас: roundsSumEqualsTurnsSum = true и hookParity = {"match":false,"mismatches":[{"turn":1,"ledger":300,"hook":400},{"turn":2,"ledger":400,"hook":0}]}, строка ВОСПРОИЗВЕДЕНО.

Где в коде (гипотеза)

  • src/cli/sessionSums.ts:104 — roundsEqualTurns пересчитывает ту же сумму, из которой построен turns.
  • src/cli/sessionSums.ts:111 — hookMismatches перебирает только ходы ledger.
  • Нумерация: неявный ход в buildLedger (currentTurn ?? newTurn) против хода 0 в hookSpentByTurn (isTurnNumberLine).

Что сделать

  1. Убрать roundsSumEqualsTurnsSum или заменить настоящей проверкой — например, сумма раундов, посчитанная из сырых строк, против суммы ходов.
  2. hookParity: сверять в обе стороны (объединение номеров ходов) и одинаково нумеровать раунды до первого user-сообщения (общее решение с остальными ишью эпика Epic: one number on every surface — cross-surface parity (audit follow-ups) #94 про нумерацию ходов).

Acceptance

  • Негативный тест: испорченные раунды — проверка падает
  • Тест: сессия, начинающаяся с релея или сводки компакта, — hookParity.match = true

  • Проверено человеком: воспроизводится / не воспроизводится

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions