fix(process-metrics): count model tokens only for the report window (#761)

tokenUsage summed the usage line of every review document in HEAD: the
report had no window, and every week repeated the whole history.

A document now enters a week's tokens when the commit that added it to
dev falls in [since, until]. fetchSnapshot reads the committer date from
git log -M --diff-filter=AR over docs/reviews and legacy/reviews:
an add sets the date, a rename (the #682 archive move) carries it to the
new path instead of adding the document again. The "missing" count of
#737 (hp:usage-none) follows the same window. ship findings keep reading
every SHIP-REVIEW document; only the token sum is windowed. Without the
date map (a unit over ready documents) there is no window, as before.

Proof is a temporary git repository with dated commits: a document
outside the window, one inside, an hp:usage-none pair on both sides and
an archive move inside the window; only the inside documents count.

Issue: #761
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
Claude
2026-10-01 14:35:55 +00:00
committed by claude[bot]
parent aa910c31a3
commit a22f6f5411
2 changed files with 133 additions and 6 deletions
+63 -5
View File
@@ -19,6 +19,9 @@
// у конвейера есть константа; где её нет (`validate-red`/`conflict`), копию
// текста держит контрактный тест на шаблонах `_process.yml`.
//
// #761: токены — только за окно отчёта: документ ревью входит в неделю, если
// коммит, добавивший его в `dev`, попал в окно (перенос в архив — не добавление).
//
// node scripts/process-metrics.mjs --repo=<owner/repo> --days=7 [--until=ISO] [--output=path.md] [--json=path.json]
// [--compare=2026-09-28] [--compare-days=28]
import { execFileSync } from 'node:child_process';
@@ -457,6 +460,7 @@ export function reviewDocUsage(doc) {
* данными, `totals` — суммы по ключам строки (`null`, пока данных нет),
* `missing` — документы с `hp:usage-none`. «Нет данных» — не ноль: в суммы
* не входит. Документы без строки (до #737) не считаются ни тем, ни другим.
* Окно недели (#761) применяет вызывающий: `tokenDocs`.
*/
export function tokenUsage(reviewDocs = []) {
const totals = Object.fromEntries(USAGE_KEYS.map((key) => [key, 0]));
@@ -472,6 +476,45 @@ export function tokenUsage(reviewDocs = []) {
return { docs, totals: docs ? totals : null, missing };
}
/**
* #761: дата добавления документа ревью в `dev` по выводу
* `git log -M --diff-filter=AR --reverse --name-status --format=%x1e%cI`.
* Добавление (`A`) ставит дату коммиттера; переименование (`R`, перенос в
* архив `legacy/reviews/` #682) переносит дату на новый путь, а не добавляет
* документ заново. → Map путь в `HEAD` → дата (ISO).
*/
export function reviewDocAddedAt(text = '') {
const added = new Map();
for (const record of String(text).split('\x1e')) {
const [date = '', ...rows] = record.split('\n');
for (const row of rows) {
const [status = '', from, to] = row.split('\t');
if (status === 'A' && from) added.set(from, date.trim());
else if (/^R\d*$/.test(status) && to && added.has(from)) {
added.set(to, added.get(from));
added.delete(from);
}
}
}
return added;
}
/**
* #761: документы для токенов недели — добавленные в `dev` в `[since, until]`
* (`reviewDocAddedAt`). Документ, чьё добавление не найдено, в окно не входит.
* Без карты дат (`null`: снимок без git, юнит над готовыми документами) окна
* нет, как до #761.
*/
export function tokenDocs(reviewDocs = [], { added = null, since, until } = {}) {
if (!added) return reviewDocs || [];
const from = toMs(since);
const to = toMs(until);
return (reviewDocs || []).filter((doc) => {
const moment = at(added.get(String(doc?.path ?? '')));
return moment >= from && moment <= to;
});
}
/** `git log --format=%x1e%H%x1f%cI%x1f%B%x1f --numstat` → коммиты с трейлерами и строками. */
export function parseGitLog(text = '') {
return String(text).split('\x1e').filter((record) => record.trim()).map((record) => {
@@ -759,6 +802,8 @@ export function buildReport({
// #728: задачи для разделов по трекам (по умолчанию — те же `issues`), усечённые
// таймлайны, тексты документов ревью, коммиты dev и окно сравнения.
allIssues = null, timelineTruncated = new Set(), reviewDocs = [], commits = [], compare = {},
// #761: дата добавления документов ревью в dev — окно токенов; `null` — окна нет.
reviewDocAdded = null,
}) {
const perIssue = issues.map((issue) => issueMetrics(issue, timelines.get(Number(issue.number)) || []));
const rounds = reviewRounds(reviewFiles);
@@ -788,12 +833,12 @@ export function buildReport({
runs: runMetrics(runs),
pipeline: pipelineMetrics(runs),
jobs: jobsByRun ? jobMinutes(jobsByRun) : null,
...trackReport({ since, until, issues: allIssues || issues, timelines, timelineTruncated, reviewDocs, runs, jobsByRun, commits, compare }),
...trackReport({ since, until, issues: allIssues || issues, timelines, timelineTruncated, reviewDocs, reviewDocAdded, runs, jobsByRun, commits, compare }),
};
}
/** #728: разделы по трекам, job-минуты по стадиям, токены и сравнение до/после. */
function trackReport({ since, until, issues, timelines, timelineTruncated, reviewDocs, runs, jobsByRun, commits, compare }) {
function trackReport({ since, until, issues, timelines, timelineTruncated, reviewDocs, reviewDocAdded, runs, jobsByRun, commits, compare }) {
const changes = issueChanges(commits);
const ship = shipFindings(reviewDocs);
const seen = new Set();
@@ -816,7 +861,10 @@ function trackReport({ since, until, issues, timelines, timelineTruncated, revie
return {
tracks: trackSection(rows.filter((row) => row.s8At >= from && row.s8At <= to), { shipDocs: ship.docs }),
stages: jobsByRun ? stageMinutes({ runs, jobsByRun, trackOf }) : null,
tokens: tokenUsage(reviewDocs),
// #761: токены и `missing` — по документам, добавленным в dev за окно; `window` — окно применено.
tokens: reviewDocAdded
? { ...tokenUsage(tokenDocs(reviewDocs, { added: reviewDocAdded, since, until })), window: { since, until } }
: tokenUsage(reviewDocs),
compare: compareCohorts(rows, { cutover: compare.cutover ?? TRACKS_CUTOVER, days: compare.days ?? COMPARE_DAYS, until }),
};
}
@@ -966,6 +1014,10 @@ function renderTokens(lines, tokens) {
lines.push('');
lines.push('### Токены');
lines.push('');
if (tokens?.window) {
lines.push('Документы ревью, добавленные в `dev` за окно отчёта: дата коммиттера добавившего коммита, перенос в архив `legacy/reviews/` — не добавление.');
lines.push('');
}
lines.push(tokens?.docs
? `Токены по ${tokens.docs} документам ревью: ${USAGE_KEYS.map((key) => `${key} ${tokens.totals[key]}`).join(' · ')}.`
: `${TOKENS_NO_DATA}.`);
@@ -1019,7 +1071,8 @@ function ghJson(args) {
* иначе задача помечается «таймлайн усечён»;
* - jobs (К5) — для `jobRuns`, недоступные jobs — `null`;
* - имена документов ревью, тексты `SHIP-REVIEW-*.md` и документов со строкой
* расхода, коммиты `origin/dev` с `--numstat` за окно сравнения.
* расхода, дата добавления каждого документа в dev (#761), коммиты
* `origin/dev` с `--numstat` за окно сравнения.
*/
export function fetchSnapshot({ repo, since, until, gh = ghJson, git = null, compare = {} }) {
const sinceIso = new Date(since).toISOString();
@@ -1082,6 +1135,7 @@ export function fetchSnapshot({ repo, since, until, gh = ghJson, git = null, com
}
let reviewFiles = [];
const reviewDocs = [];
let reviewDocAdded = null;
let commits = [];
if (git) {
const listing = git(['ls-tree', '-r', '--name-only', 'HEAD', '--', 'docs/reviews', 'legacy/reviews']);
@@ -1095,12 +1149,16 @@ export function fetchSnapshot({ repo, since, until, gh = ghJson, git = null, com
for (const path of new Set([...paths.filter((p) => SHIP_DOC.test(p)), ...usage])) {
reviewDocs.push({ path, text: git(['show', `HEAD:${path}`]) });
}
// #761: окно токенов — по коммиту, добавившему документ в dev; с -M перенос
// в архив (#682) — переименование, а не добавление.
reviewDocAdded = reviewDocAddedAt(git(['log', '-M', '--diff-filter=AR', '--reverse', '--name-status',
'--format=%x1e%cI', 'HEAD', '--', 'docs/reviews', 'legacy/reviews']));
let ref = 'origin/dev';
try { git(['rev-parse', '--verify', '-q', ref]); } catch { ref = 'HEAD'; }
// Коммиты задачи бывают раньше её S8: запас в одно окно до начала сравнения.
commits = readCommits(git, { ref, since: at(earliestIso) - days * DAY_MS });
}
return { issues, allIssues, timelines, timelineTruncated, runs, jobsByRun, reviewFiles, reviewDocs, commits };
return { issues, allIssues, timelines, timelineTruncated, runs, jobsByRun, reviewFiles, reviewDocs, reviewDocAdded, commits };
}
if (isMainModule(import.meta.url)) {
+70 -1
View File
@@ -9,7 +9,7 @@ import {
buildReport, issueMetrics, jobMinutes, pipelineMetrics, renderMarkdown, reviewDocNames, reviewRounds, runMetrics,
NOT_RUN_CONFLICT_RE, NOT_RUN_VALIDATE_RE, TIMELINE_PAGE_CAP, TOKENS_NO_DATA, TRACKS_CUTOVER,
compareCohorts, fetchSnapshot, isInfra, issueChanges, issueSegments, issueTrackMetrics, jobRuns, jobStage,
readCommits, returnSignal, shipFindings, stageMinutes, tokenUsage, trackAt, trackPath, trackSection, volumeBucket,
readCommits, returnSignal, reviewDocAddedAt, shipFindings, stageMinutes, tokenDocs, tokenUsage, trackAt, trackPath, trackSection, volumeBucket,
} from '../scripts/process-metrics.mjs';
import { labelTrack } from '../scripts/process-track.mjs';
import { commentFor } from '../scripts/merge-candidate.mjs';
@@ -612,3 +612,72 @@ test('#728 fetchSnapshot: state=all, таймлайн до 10 страниц, jo
assert.deepEqual(snap.reviewDocs, [{ path: 'docs/reviews/SHIP-REVIEW-v1.79.0-beta.2.md', text: 'ship doc' }]);
assert.equal(issueChanges(snap.commits).get(701).lines, 4);
});
// ---------------------------------------------------------------------------
// #761: токены — только за окно отчёта, по коммиту, добавившему документ в dev.
test('#761 токены за окно: документ вне окна, перенос в архив в окне и hp:usage-none вне окна не считаются (AC1–AC3)', (t) => {
const dir = mkdtempSync(join(tmpdir(), 'hp-761-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
const git = (args, env = {}) => {
const r = spawnSync('git', args, { cwd: dir, encoding: 'utf8', env: { ...process.env, ...env } });
assert.equal(r.status, 0, r.stderr);
return r.stdout;
};
git(['init', '-q', '-b', 'dev']);
git(['config', 'user.email', 't@t']); git(['config', 'user.name', 't']);
git(['config', 'core.hooksPath', '/dev/null']);
const usage = (input, output) => formatUsage({ input_tokens: input, output_tokens: output, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, num_turns: 1 });
const doc = (title, line) => withMaterialAnchors(`# ${title}\n\nВердикт: зелёный.\n`, { tree: 'a'.repeat(40), verdict: 'green', high: 0, usage: line });
const commitAt = (iso, message, files) => {
for (const [path, text] of Object.entries(files)) {
mkdirSync(join(dir, dirname(path)), { recursive: true });
writeFileSync(join(dir, path), text);
}
git(['add', '-A']);
git(['commit', '-q', '-m', message], { GIT_AUTHOR_DATE: iso, GIT_COMMITTER_DATE: iso });
};
const since = '2026-09-24T00:00:00Z';
const until = '2026-10-01T00:00:00Z';
// Вне окна: документ с расходом и документ без данных (hp:usage-none).
commitAt('2026-09-10T10:00:00Z', 'docs: review document for #700', {
'docs/reviews/CODE-REVIEW-700-r1.md': doc('CODE-REVIEW-700-r1', usage(1000, 100)),
'docs/reviews/SPEC-REVIEW-701-r1.md': doc('SPEC-REVIEW-701-r1', ''),
});
// Внутри окна: документ с расходом и документ без данных.
commitAt('2026-09-26T10:00:00Z', 'docs: review document for #702', {
'docs/reviews/CODE-REVIEW-702-r1.md': doc('CODE-REVIEW-702-r1', usage(20, 2)),
'docs/reviews/SPEC-REVIEW-703-r1.md': doc('SPEC-REVIEW-703-r1', ''),
});
// Внутри окна: перенос документов вне окна в архив (#682) — переименование, не добавление.
mkdirSync(join(dir, 'legacy/reviews/v1.0.0'), { recursive: true });
git(['mv', 'docs/reviews/CODE-REVIEW-700-r1.md', 'legacy/reviews/v1.0.0/CODE-REVIEW-700-r1.md']);
git(['mv', 'docs/reviews/SPEC-REVIEW-701-r1.md', 'legacy/reviews/v1.0.0/SPEC-REVIEW-701-r1.md']);
commitAt('2026-09-28T10:00:00Z', 'chore(legacy): archive the v1.0.0 review documents', {});
// После окна: документ с расходом (отчёт за прошлую неделю его не видит).
commitAt('2026-10-02T10:00:00Z', 'docs: review document for #704', { 'docs/reviews/CODE-REVIEW-704-r1.md': doc('CODE-REVIEW-704-r1', usage(5, 5)) });
const added = reviewDocAddedAt(git(['log', '-M', '--diff-filter=AR', '--reverse', '--name-status', '--format=%x1e%cI', 'HEAD', '--', 'docs/reviews', 'legacy/reviews']));
assert.equal(Date.parse(added.get('legacy/reviews/v1.0.0/CODE-REVIEW-700-r1.md')), Date.parse('2026-09-10T10:00:00Z'), 'перенос в архив сохраняет дату добавления');
assert.equal(added.has('docs/reviews/CODE-REVIEW-700-r1.md'), false, 'прежнего пути в HEAD нет');
const gh = (args) => (String(args[1]).includes('/actions/runs?') ? { workflow_runs: [] } : []);
const snap = fetchSnapshot({ repo: 'o/r', since, until, gh, git: (args) => git(args) });
assert.equal(snap.reviewDocs.length, 5, 'тексты читаются из HEAD, как раньше');
const report = buildReport({ since, until, ...snap });
assert.equal(report.tokens.docs, 1, 'AC1: только документ, добавленный в окне');
assert.deepEqual([report.tokens.totals.input_tokens, report.tokens.totals.output_tokens], [20, 2]);
assert.equal(report.tokens.missing, 1, 'AC3: hp:usage-none — по тому же окну');
const md = renderMarkdown(report);
const section = md.slice(md.indexOf('### Токены'), md.indexOf('###', md.indexOf('### Токены') + 3));
assert.match(section, /^Документы ревью, добавленные в `dev` за окно отчёта/m);
assert.match(section, /^Токены по 1 документам ревью: input_tokens 20 · output_tokens 2 /m);
assert.match(section, /^Без данных о расходе: 1\.$/m);
// Неделя, в которую документ #700 был добавлен, его видит — в архиве он или нет.
const early = buildReport({ since: '2026-09-07T00:00:00Z', until: '2026-09-14T00:00:00Z', ...snap });
assert.deepEqual([early.tokens.docs, early.tokens.totals.input_tokens, early.tokens.missing], [1, 1000, 1]);
// Без карты дат (юнит над готовыми документами) окна нет — как до #761.
assert.equal(tokenDocs(snap.reviewDocs).length, 5);
assert.equal(tokenDocs(snap.reviewDocs, { added: new Map(), since, until }).length, 0, 'добавление не найдено — вне окна');
});