From 8aa8cc9b65a9915aa647bc5c698fce152f945378 Mon Sep 17 00:00:00 2001 From: Node9 Date: Wed, 2 Sep 2026 08:15:28 +0300 Subject: [PATCH] fix(monitor): the Report headline states what it counted, not a made-up cost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It read `272 loops · ~$9.73 wasted`, from Σ(count) × COST_PER_LOOP_ITER_USD. Wrong three ways at once: - billed the first, legitimate call as waste (no threshold) - counted long-iteration findings as loops — 174 of the 272 are sustained work on one file - used the flat constant, measured ~170x below real per-session rates Two of those inflate and one deflates, so they partly cancel and land on $9.73 — while `node9 scan` reports $48.66 from exactly the same data. A number that looks reasonable and is built from three mistakes does not invite the scrutiny a visibly wrong one would. Now: `272 repeated patterns · 98 stuck`. Both figures are counts, both are true, and "loops" is dropped because most of these are not loops. Consistent with the `repeat calls` rename already shipped — say what was measured, not what someone might infer. Pricing it properly needs per-session rates, and this screen has no access to them. Threading perSession through three dashboard layers for one headline buys a third dollar figure in a product where two already exist and agree; a third is a third chance to disagree. The money stays on the screen that computes it correctly. Side effect worth naming: COST_PER_LOOP_ITER_USD now has ZERO production consumers. It is still exported from the engine, but "one place money is attached to a count" is enforced by the compiler rather than by discipline for the first time. The existing test asserted `$9.00` with the old arithmetic spelled out in a comment — flagged yesterday as pinning the bug. Rewritten to assert counts and the absence of a cost, scoped to the headline since the banner has its own spend indicator. Mutation-tested: dropping the long-iteration filter or moving the >100 threshold both turn the new tests red. --- .../dashboard-score-banner.unit.test.tsx | 20 +++-- src/__tests__/report-headline.unit.test.ts | 80 +++++++++++++++++++ src/tui/dashboard/views/report/index.tsx | 23 +++++- 3 files changed, 114 insertions(+), 9 deletions(-) create mode 100644 src/__tests__/report-headline.unit.test.ts diff --git a/src/__tests__/dashboard-score-banner.unit.test.tsx b/src/__tests__/dashboard-score-banner.unit.test.tsx index 01bf8aec..d5ff4b6a 100644 --- a/src/__tests__/dashboard-score-banner.unit.test.tsx +++ b/src/__tests__/dashboard-score-banner.unit.test.tsx @@ -377,7 +377,7 @@ describe('ScoreBanner headline cascade', () => { expect(lastFrame()).toContain('3 leaks this period'); }); - it('headline tier 3 — loops > 100 with cost estimate', () => { + it('headline tier 3 — loops > 100, counts and no cost estimate', () => { const loops = Array.from({ length: 150 }, () => ({ toolName: 'Edit', commandPreview: '/x', @@ -399,10 +399,20 @@ describe('ScoreBanner headline cascade', () => { filtered={filtered} /> ); - expect(lastFrame()).toContain('150 loops'); - expect(lastFrame()).toContain('wasted'); - // 150 loops * count 10 * COST_PER_LOOP_ITER_USD (0.006) = $9 → "$9.00" - expect(lastFrame()).toContain('$9.00'); + // Was: `150 loops · ~$9.00 wasted`, from Σ(count) × COST_PER_LOOP_ITER_USD. + // Wrong three ways — it billed the first legitimate call, counted + // long-iteration findings as loops, and used a constant measured ~170x + // low. Two inflations and one deflation landed on a believable number + // while `node9 scan` reported five times more from the same data. + // + // The headline now states what it counted. Dollars live on the one screen + // that prices them per session. + expect(lastFrame()).toContain('150 repeated patterns'); + expect(lastFrame()).not.toContain('wasted'); + // Scoped to the headline: the banner has its own spend indicator, so a + // bare "no $ anywhere" assertion would fail on unrelated output. + const headline = (lastFrame() ?? '').split('📌')[1]?.split('💰')[0] ?? ''; + expect(headline).not.toContain('$'); }); it('headline tier 4 — exposed blast paths (when no leaks/loops/early-secrets)', () => { diff --git a/src/__tests__/report-headline.unit.test.ts b/src/__tests__/report-headline.unit.test.ts new file mode 100644 index 00000000..b9fe9f7e --- /dev/null +++ b/src/__tests__/report-headline.unit.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from 'vitest'; +import { computeHeadline } from '../tui/dashboard/views/report/index.js'; +import { EMPTY_FILTERED_SCAN } from '../tui/dashboard/views/report/derive.js'; +import type { LoopFinding } from '../cli/commands/scan'; + +// The headline is the first sentence a user reads on the Report screen. It +// used to price loops itself and got three things wrong at once, landing on a +// plausible $9.73 while `node9 scan` said $48.66 from the same data. + +const loop = (over: Partial = {}): LoopFinding => ({ + toolName: 'Edit', + commandPreview: '/tmp/x.ts', + count: 50, + timestamp: '2026-09-01T00:00:00Z', + project: 'p', + sessionId: 's1', + agent: 'claude', + kind: 'loop', + ...over, +}); + +const ready = { status: 'ready' } as Parameters[0]; + +const scan = (loops: LoopFinding[]) => ({ + ...EMPTY_FILTERED_SCAN, + loops, +}); + +describe('computeHeadline — loops', () => { + it('reports counts and never a dollar figure', () => { + const h = computeHeadline(ready, scan(Array.from({ length: 150 }, () => loop())), null); + expect(h?.text).not.toMatch(/\$/); + }); + + it('separates sustained work from genuinely stuck patterns', () => { + // 120 long-iteration + 30 real. The old line called all 150 "loops" and + // billed every iteration of all of them. + const loops = [ + ...Array.from({ length: 120 }, () => loop({ kind: 'long-iteration' })), + ...Array.from({ length: 30 }, () => loop({ kind: 'loop' })), + ]; + const h = computeHeadline(ready, scan(loops), null); + expect(h?.text).toContain('150'); + expect(h?.text).toContain('30'); + }); + + it('says zero stuck when every finding is sustained work', () => { + const loops = Array.from({ length: 150 }, () => loop({ kind: 'long-iteration' })); + const h = computeHeadline(ready, scan(loops), null); + expect(h?.text).toMatch(/\b0\b/); + }); + + it('counts a finding with no kind as stuck, matching the rest of the codebase', () => { + // `kind` is optional for legacy data; excluding those would under-report, + // which is the direction this product keeps failing in. + const legacy = loop(); + delete (legacy as { kind?: unknown }).kind; + const h = computeHeadline(ready, scan(Array.from({ length: 150 }, () => legacy)), null); + expect(h?.text).toContain('150'); + }); + + it('leaves the >100 threshold alone', () => { + const under = computeHeadline(ready, scan(Array.from({ length: 100 }, () => loop())), null); + const over = computeHeadline(ready, scan(Array.from({ length: 101 }, () => loop())), null); + expect(under?.text ?? '').not.toContain('repeated patterns'); + expect(over?.text).toContain('repeated patterns'); + }); + + it('still yields to leaks, which outrank loops', () => { + const h = computeHeadline( + ready, + { + ...scan(Array.from({ length: 150 }, () => loop())), + leaks: [{} as never], + }, + null + ); + expect(h?.text).toContain('leak'); + }); +}); diff --git a/src/tui/dashboard/views/report/index.tsx b/src/tui/dashboard/views/report/index.tsx index c9115ada..2f1ff100 100644 --- a/src/tui/dashboard/views/report/index.tsx +++ b/src/tui/dashboard/views/report/index.tsx @@ -19,7 +19,6 @@ import { COL } from '../../panels.js'; import { computeProtection } from '../../data.js'; import type { BlastSnapshot, ReportPeriod, ScanCache, ShieldStatus } from '../../types.js'; import type { AggregateResult } from '../../../../cli/aggregate/report-audit.js'; -import { COST_PER_LOOP_ITER_USD } from '@node9/policy-engine'; import { Protection } from './panels/Protection.js'; import { Cost } from './panels/Cost.js'; @@ -295,7 +294,7 @@ interface Headline { /** Priority cascade — first match wins. Returns null while there's no * data to assess (e.g. scan still loading and audit/blast both empty). */ -function computeHeadline( +export function computeHeadline( scanCache: ScanCache, filtered: FilteredScan, blast: BlastSnapshot | null @@ -326,9 +325,25 @@ function computeHeadline( }; } if (filtered.loops.length > 100) { - const wasted = filtered.loops.reduce((s, l) => s + (l.count ?? 0) * COST_PER_LOOP_ITER_USD, 0); + // Counts, no dollars. This line used to read + // `Σ(count) × COST_PER_LOOP_ITER_USD`, which was wrong three ways at once: + // it priced the first legitimate call as waste, it counted + // `long-iteration` findings (sustained work on one file) as loops, and it + // used the flat constant measured at ~170x too low. Two errors inflated + // and one deflated, landing on a plausible number — $9.73 against the + // $48.66 `node9 scan` reports from the same data. A number that looks + // reasonable and is built from three mistakes gets believed. + // + // Priced correctly needs per-session rates (computeLoopWaste), which this + // screen has no access to. Rather than thread that through three layers + // for one headline, the money lives on the one screen that computes it + // properly. A third dollar figure is a third chance to disagree with + // itself. + // + // "repeated patterns", not "loops": most of these are not loops. + const stuck = filtered.loops.filter((l) => l.kind !== 'long-iteration').length; return { - text: `📌 ${filtered.loops.length} loops · ~${fmtCost(wasted)} wasted`, + text: `📌 ${filtered.loops.length} repeated patterns · ${stuck} stuck`, color: 'yellow', }; }