evidence/StatsSumFix.md
Rama master · réplica de lectura del repositorio original.
# StatsSumFix — account totals vs per-account rows (352 vs 393)
Worktree: `C:/Users/dwgx1/.dwgx/ops/artifacts/windsurfapi-autonomous-20261022/w91-statssum`
Branch: `fix/stats-sum` (from `d338b36`, release/3.10.0). Lane: StatsSumFix.
Scope: `src/dashboard/stats.js`, `src/dashboard/index.html` (stats aggregates only),
`src/dashboard/index-sketch.html` (account table note), i18n, two new test files, one
contract assertion in an existing stats test.
## Diagnosis — the 41 rows are structural, not lost
`recordRequest(model, success, ms, accountId)` increments `totalRequests` for every
row, but the per-account block only runs `if (accountId)`:
- `src/dashboard/stats.js:345-352` (base `d338b36`; cited as `:198-205` in 3.9.60) —
no `accountId` → no `accountCounts` entry.
- Call sites that legitimately pass `null`: `src/handlers/chat.js:2561,2577,4039,4404,5022,6489`
(global/unauthenticated/global-model paths) and the log-scope tests. These are
requests the proxy served without an account identity, so they cannot be keyed to
one.
- Not failures: failures with an `accountId` are counted in `accountCounts.errors`
(`stats.js:351-352`). The fixture keeps 27 errors total and reconciles 22
attributed + 5 unattributed.
- Not dropped ring keys: `accountCounts` is lifetime, independent of the 500-row
`recentRequests` ring. In the request-log view both `summary` and `byAccount` are
computed from the same filtered row set, so ring drops cannot create the delta.
So the delta is exactly the count of requests recorded without an account id
(41 in the live snapshot; 352 + 41 = 393). Model counts already summed to 393.
## Fix — one denominator, stated explicitly
Backend (`src/dashboard/stats.js`):
- `getStats()` now returns `accountAttribution: { total, attributed, unattributed }`,
derived at read time from `totalRequests` and the sum of `accountCounts.requests`.
Derived (not persisted) so pre-fix `stats.json` snapshots reconcile with no
migration; for any state produced by `recordRequest` or by importing consistent
snapshots, `attributed + unattributed === total`.
- `getRequestLog()` now returns an `unattributed` aggregate (`account: null`) built
from rows with no account. `sum(byAccount.requests) + unattributed.requests ===
summary.requests`, and the same holds for success/errors/tokens/credits.
UI:
- Main dashboard, stats → 聚合统计 → 按账号: a footnote under the table states
"{{count}} of {{total}} requests in this window have no account attribution
(global/unauthenticated paths). They are included in the log total and excluded
from the account rows above." Hidden on the model/day tabs and when the remainder
is zero. New keys `section.statsAgg.unattributedNote` in `en.json` and `zh-CN.json`.
- Sketch skin, 账号维度统计: the same remainder is printed under the account table
from `accountAttribution`, so the lifetime total card and the account rows are no
longer silently inconsistent.
- The footnote deliberately says "in this window": the account table is the
request-log window (cap 500), while the headline cards are lifetime counters. That
second denominator distinction is pre-existing and now labelled rather than hidden.
## Red-first evidence
`test/stats-attribution.test.js` seeds the live shape through the real recording
path: 200 + 100 + 52 attributed requests (20 + 0 + 2 errors) and 41 requests with
`accountId = null` → 393 total, 352 attributed, 41 unattributed.
Before the implementation (observed):
```
✖ derives the unattributed remainder from lifetime counters
actual: undefined
expected: { total: 393, attributed: 352, unattributed: 41 }
✖ reports the same remainder for the request-log window
actual: undefined
expected: { total: 393, attributed: 352, unattributed: 41 }
```
After:
```
▶ account attribution — the 352 vs 393 gap
✔ derives the unattributed remainder from lifetime counters
✔ reports the same remainder for the request-log window
✔ derives the remainder for pre-fix persisted snapshots too
✔ reports zero unattributed when every request carries an account
ℹ tests 4 ℹ pass 4 ℹ fail 0
```
`test/dashboard-stats-attribution-render.test.js` executes the shipped
`_renderStatsAgg` (brace-sliced from `index.html`) against a DOM stub and asserts the
footnote shows `41` of `393` on the account tab, and is hidden on the model tab and
when the remainder is zero.
## Test-harness consequence
`test/dashboard-escape-callsites.test.js` keys its reviewed non-sink allowlist by
line number. Inserting the footnote markup and JS into `index.html` shifted 28
reviewed sites, so the allowlist line numbers were re-derived from the shipped file
(expressions and reasons unchanged; the guard's own "stale exemption" test is what
caught it). No new unescaped sink was introduced.
## Verification run
- Targeted: `node --import ./test/setup-env.mjs --test test/stats-attribution.test.js
test/dashboard-stats-attribution-render.test.js test/stats-import.test.js
test/dashboard-stats-log.test.js test/check-i18n.test.js test/dashboard-syntax.test.js
test/dashboard-api.test.js test/dashboard-a11y.test.js`
→ `tests 78, pass 78, fail 0`.
- Full suite: `node scripts/run-tests.mjs` → `tests 6555, pass 6458, fail 0,
skipped 97` (53.5 s). One earlier full run reported two timing flakes in
`test/cascade-turn-flag.test.js` (poll-window counts); that file passes 8/8 in
isolation and passed in the next full run, so it is concurrency-sensitive, not
caused by this change (the change touches stats aggregation and dashboard
templates only).
## Limits
- The 41 historical rows have no account id in the persisted state; no code can
reconstruct which account (if any) served them. The fix reports them as
unattributed instead of inventing an attribution.
- `accountAttribution` is a read-time view; `exportStats()` still exports only the
raw counters. Imported snapshots with hand-edited inconsistent totals clamp
`unattributed` at 0 rather than reporting a negative remainder.