From 3fbae490385ff04fd65f18c7bcf786573368116d Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Thu, 15 Oct 2026 16:59:30 +0200 Subject: [PATCH 1/4] docs(telemetry): a report reads its period, the contract Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- .../contract.md | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/contract.md diff --git a/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/contract.md b/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/contract.md new file mode 100644 index 000000000..7c6a9a38a --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/contract.md @@ -0,0 +1,84 @@ +--- +status: in-progress +--- + +# A report reads its period + +## Need + +`aidd telemetry report` reads, folds and settles the whole ledger, then keeps the calls of its period (`report-usage-use-case.ts`). A report on the last 7 days pays for every day ever measured, and that cost grows with the history. + +Follow-up of #1001, whose contract left report cost out of scope. + +## Measured breakdown, unbounded report + +Synthetic ledger of 265,000 calls over 28 days, already by day, `next` at `fcb6022b`, two runs: + +| Stage | Time | +| --- | --- | +| read the partitions | 39-43 ms | +| split into lines | 17 ms | +| `JSON.parse` alone | 181-203 ms | +| `parseStoredUsage` (parse and validation) | 303-304 ms | +| `foldUsage` | 143-156 ms | +| `ledger.load()` in all | 603-631 ms | +| `settleLedger` (load, nothing to save) | 615-681 ms | +| `buildReport`, axis `task` / `session` / `day` | 175-197 / 190-219 / 279-298 ms | + +The 2.8 s quoted by #1001 was the monthly layout. On the daily layout an unbounded report costs about 0.9 s at this size. No stage dominates: parsing, validation, fold and aggregation each take 0.1-0.3 s. No local change gives 2x without dropping validation, so this change keeps the unbounded read as it is and records the breakdown. + +## Decision + +A report whose period has a bound reads only the partitions that can hold a call of the period: from the day before `from` to the day after `to`, an open side staying open. A report with no bound reads everything, as today. + +```mermaid +flowchart LR + P["period from..to"] --> W["window: from-1 .. to+1"] + W --> L["load: days in window,
and every monthly file of a month it touches,
with all that month's days"] + L --> S["settle what was read"] + S --> F["keep calls of the period"] + F --> R["buildReport"] +``` + +The port's `load` takes a test on the day instead of a set of days, so a window costs the partitions on disk, never the length of the period: `--from 0001-01-01` builds no set of three million days. + +## Why the window gives the same figures + +Let `K` be one call, with its copies on disk, and `w` the copy the fold keeps from all of them. A full read keeps `K` when `day(w)` is in the period. A bounded read folds only the copies it loaded. + +| Case | Full read | Bounded read | +| --- | --- | --- | +| No copy of `K` falls in the period | `day(w)` outside: `K` left out | whichever copy wins, its day is outside: `K` left out | +| A copy of `K` falls in the period, and all copies are less than a day apart | `K` kept or left out by `day(w)` | every copy is within one day of that copy, so in the window: same copies, same fold, same `w` | + +Copies less than a day apart are within one calendar day of each other. The fold is a function of the set of copies, not of their order (#1031, property on ingest order). So under that bound a bounded report prints the same envelope as the full one filtered. + +**Evidence for the bound**, from #1001 (`2026_10_15_ledger-by-day/contract.md`): of 262,631 real keys, the largest spread between a key's first and last line is 0.3 h, and none spans a day. + +**Outside the bound**, never observed: a call whose copies are more than a day apart can sit in two partitions only if an ingest window missed one. A bounded report may then fold fewer copies than a full one. An unbounded report still folds every copy and settles the whole ledger. + +## Attribution is per call + +`buildReport` attributes each call alone: `attribute(record, facts)` reads the call's session, time, branch and repository, and the declarations, carries and branch snapshots, none of which comes from the ledger (`domain/report/attribution.ts:146`). No call's task or ticket depends on another call, so leaving calls out of the window changes no attribution. + +## Contract + +- **Window.** A bounded period reads days from `from - 1` to `to + 1`. `--days` and `--from` with `--to` bound both sides. `--from` alone or `--to` alone bounds one side. No option reads everything. +- **Monthly files.** A monthly `.jsonl` is read when one of its days is in the window, with every day of that month, as ingest already does. A report then splits it. +- **Settle.** A report settles what it read. A bounded report rewrites only the partitions it read that are damaged, monthly, or share a call. An unbounded report settles the whole ledger: after it, the ledger is byte-identical to a single ingest of the same calls, as before. +- **`skipped_ledger_lines`** counts the damaged lines of the partitions read. A bounded report does not see damage outside its window. +- **Unchanged.** The fold rule, the attribution, every axis and the envelope `version`. Every axis still adds up to `totals`. +- **Out of scope.** The unbounded report's 0.9 s. A summary kept by day is a second state; the breakdown above does not justify it. + +## Acceptance + +| # | Criterion | Proof | +| --- | --- | --- | +| 1 | `--days 7` on the 265k-call ledger answers in well under 1 s, before and after measured | bench on the use case | +| 2 | For any ledger whose copies of a call are less than a day apart, any period and any axis, the bounded envelope equals the full one filtered, byte for byte | fast-check property | +| 3 | A bounded report reads no partition outside its window, and reads a monthly file the window touches | tests on the adapter and the in-memory ledger | +| 4 | A bounded report never loses or doubles a call, from a monthly ledger or not, crash or not | tests | +| 5 | An unbounded report still settles the whole ledger | existing tests | +| 6 | Tests are written first, and their red run is recorded in the plan | red output in `plan.md` | +| 7 | Telemetry mutation stays at 96 or above, and every mutant on the changed lines is killed or explained | stryker | +| 8 | Contract, code map and envelope documentation say what a bounded report reads | grep, plus a fresh checker | From 4eabc58da24010e8ae2bf0b25fae3dccfe16fb38 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Thu, 15 Oct 2026 17:18:02 +0200 Subject: [PATCH 2/4] perf(telemetry): a report reads its period, not the whole history A report with --days, --from or --to now loads only the days of its period and the day on each side, plus any monthly file one of them falls in, and settles only what it read. A report with no period reads and settles the whole ledger, as before. The copies of one call are less than a day apart (0.3 h at most over 262,631 real keys), so a call with a copy in the period has every copy in the window and folds to the same record: a fast-check property checks the bounded envelope equals the full report filtered, byte for byte. UsageLedger.load takes a test on the day instead of a set of days, so a window costs the partitions on disk, not the length of the period. One year at 3,400 calls a day: --days 7 goes from about 4.2 s to 95 ms. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- aidd_docs/product/usage-contract.md | 3 +- .../contract.md | 2 +- .../evidence.md | 100 ++++++++ .../phase-1.md | 76 ++++++ .../phase-2.md | 86 +++++++ .../phase-3.md | 67 +++++ .../plan.md | 28 +++ cli/aidd_docs/memory/telemetry.md | 2 +- .../telemetry/application/ledger-upkeep.ts | 20 +- .../application/report/report-envelope.ts | 6 +- .../report/report-usage-use-case.ts | 16 +- .../telemetry/domain/ports/usage-ledger.ts | 9 +- .../telemetry/domain/report/period.ts | 21 ++ .../infrastructure/usage-ledger-adapter.ts | 40 ++- .../ingest-usage-use-case.unit.test.ts | 6 +- .../ledger-upkeep.integration.test.ts | 69 ++++++ .../report/report-usage-use-case.unit.test.ts | 28 +++ ...report-window.property.integration.test.ts | 232 ++++++++++++++++++ .../domain/report/period.unit.test.ts | 76 ++++++ .../usage-ledger-adapter.integration.test.ts | 73 +++++- .../helpers/ports/in-memory-telemetry.ts | 10 +- 21 files changed, 924 insertions(+), 46 deletions(-) create mode 100644 aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/evidence.md create mode 100644 aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/phase-1.md create mode 100644 aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/phase-2.md create mode 100644 aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/phase-3.md create mode 100644 aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/plan.md create mode 100644 cli/tests/contexts/telemetry/application/report/report-window.property.integration.test.ts diff --git a/aidd_docs/product/usage-contract.md b/aidd_docs/product/usage-contract.md index 5999dc419..5ab13ba32 100644 --- a/aidd_docs/product/usage-contract.md +++ b/aidd_docs/product/usage-contract.md @@ -103,6 +103,7 @@ Row and `totals`: `records`, `input`, `output`, `cache_read`, `cache_write` (eac - Every axis partitions the same calls: each adds up to `totals`, counter by counter. - `AIDD_TELEMETRY=0`: nothing read, object is `{"version": 1, "refused": "AIDD_TELEMETRY=0"}`. - A report ingests first. `files_read` and `unrecognised_shapes` describe that run; `records` the period. +- A report with `--days`, `--from` or `--to` reads only the days of its period and the day on each side, plus any monthly file one of them falls in; with none it reads the whole ledger. The copies of one call are less than a day apart, so both print the same figures. `skipped_ledger_lines` counts the lines of the partitions read. - No path, working directory or branch. `session` keys are full session ids, `repository` keys are hashes. ## Stores @@ -111,7 +112,7 @@ Directory: `AIDD_TELEMETRY_DIR`, else `AIDD_USER_CONFIG_DIR/telemetry`, else `XD | Store | Writer | Readers | Holds | | --- | --- | --- | --- | -| `ledger/.jsonl` | `ingest`, `report` | `report`, `forget` | `StoredUsage` by UTC day of `at`, in time then key order, one call per line. Ingest reads its calls' days and the day on each side, and rewrites only a changed day, whole, by rename; no new bytes, no write. `report` rewrites, folded, the partitions that share a call or hold a line that is not a record. A monthly `.jsonl` from an earlier version is still read, split into its days by the first ingest whose window touches it or by a report, then removed | +| `ledger/.jsonl` | `ingest`, `report` | `report`, `forget` | `StoredUsage` by UTC day of `at`, in time then key order, one call per line. Ingest reads its calls' days and the day on each side, and rewrites only a changed day, whole, by rename; no new bytes, no write. `report` rewrites, folded, the partitions it read that share a call or hold a line that is not a record; a report with no period reads every partition, so after it each call is on disk once. A monthly `.jsonl` from an earlier version is still read, split into its days by the first ingest whose window touches it or by a report, then removed | | `ledger/offsets.json` | `ingest`, `report` | same | per transcript: bytes consumed, size, file identity. Shrunk or changed identity: read whole | | `ledger/roots.json` | `ingest`, `on` | `ingest`, `forget` | `{version: 2, directories}`: per directory and per clone seen alive there, `repository_id`, `root`, `clone`, `seen_at`. Other format holds nothing: a live directory is remembered again, a gone one counts `never-seen-alive`. `on` records its own root for `forget` | | `ledger/consents.jsonl` | `on`, `off`, `ingest`, hooks (close only) | same, plus `forget` | consent intervals, see [Consent](#consent). Outlives the clone; removed with the ledger. Hooks parse it strictly, pinned by the shared fixture | diff --git a/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/contract.md b/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/contract.md index 7c6a9a38a..064ad7827 100644 --- a/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/contract.md +++ b/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/contract.md @@ -79,6 +79,6 @@ Copies less than a day apart are within one calendar day of each other. The fold | 3 | A bounded report reads no partition outside its window, and reads a monthly file the window touches | tests on the adapter and the in-memory ledger | | 4 | A bounded report never loses or doubles a call, from a monthly ledger or not, crash or not | tests | | 5 | An unbounded report still settles the whole ledger | existing tests | -| 6 | Tests are written first, and their red run is recorded in the plan | red output in `plan.md` | +| 6 | Tests are written first, and their red run is recorded | red output in `evidence.md` | | 7 | Telemetry mutation stays at 96 or above, and every mutant on the changed lines is killed or explained | stryker | | 8 | Contract, code map and envelope documentation say what a bounded report reads | grep, plus a fresh checker | diff --git a/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/evidence.md b/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/evidence.md new file mode 100644 index 000000000..669f36e0a --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/evidence.md @@ -0,0 +1,100 @@ +# Evidence + +## Red runs + +### Phase 1 + +`pnpm exec vitest run tests/contexts/telemetry/infrastructure/usage-ledger-adapter.integration.test.ts`, before the port change: the adapter still iterates its argument as a set of days, so every window given as a test fails. + +```txt +× reads only the days of a closed window, and no partition outside it +× reads every day from a date on, and none before it, for a window open on one side +× reads a monthly file the window touches by one day, with every day of its month +× does not read a monthly file the window touches on none of its days +TypeError: days is not iterable +Tests 10 failed | 28 passed (38) +``` + +The six other failures are the existing window cases, converted from a set to a test, failing the same way. + +### Phase 2 + +`pnpm exec vitest run tests/contexts/telemetry/domain/report/period.unit.test.ts tests/contexts/telemetry/application/report tests/contexts/telemetry/application/ledger-upkeep.integration.test.ts`, before `windowOf` and the bounded settle: + +```txt +period.unit > the window a report reads > (6 cases) + TypeError: windowOf is not a function +report-usage-use-case.unit > loads and settles only a bounded period and the day on each side of it + AssertionError: expected undefined to deeply equal [ '2026-10-02', '2026-10-03', …(7) ] +ledger-upkeep.integration > settling only a window > splits a monthly file the window touches, loses and doubles no call, and leaves the rest alone + AssertionError: expected { …(5) } to deeply equal { …(4) } +ledger-upkeep.integration > settling only a window > gives the same figures after a crash mid-split, once settled again + AssertionError: expected { …(5) } to deeply equal { …(4) } +ledger-upkeep.integration > settling only a window > neither reads nor repairs a damaged day outside the window + AssertionError: expected 1 to be +0 // Object.is equality +Tests 10 failed | 43 passed (53) +``` + +The settle cases fail because today's settle reads and splits October too (five partitions instead of four), and counts the torn line outside the window. + +Green before the change, by construction, so not a red run: the property `report-window.property.integration.test.ts` (today's report reads everything, so bounded equals full filtered) and the use-case case "loads and settles the whole ledger when the period has no bound". The property's mutation run is recorded below once the window exists. + +#### Mutation runs on the property + +The property is green on the old code by construction, so it is proven a guard by breaking the window, each mutation reverted after its run. + +`windowOf` without the day on each side (`shift(period.from, 0)`, `shift(period.to, 0)`): + +```txt +× prints the envelope of the full ledger filtered to its period, byte for byte, ... +Error: Property failed after 40 tests +Expected: ..."period":{"from":"2026-03-01","to":null},"axis":"total","rows":[],... +Received: ..."period":{"from":"2026-03-01","to":null},"axis":"total","rows":[{"kind":"value","key":"total",...,"records":1,... +``` + +The adapter reading a day of a splitting month only when the test holds it (`: holds(partition)`): + +```txt +× reads a monthly file the window touches by one day, with every day of its month +× reads a monthly partition, and the daily ones of its month, when a day of the window falls in it +× keeps the better snapshot a half-finished split already wrote, and finishes it +× prints the envelope of the full ledger filtered to its period, byte for byte, and loses no call, ... +Error: Property failed after 6 tests +``` + +The figures alone do not see this second mutation (a day outside the window holds no call of the period); the property's check that the settled ledger folds to what a full read keeps does, since the split deletes the unread days of the month. + +### Phase 1, addendum + +A monthly name no calendar holds (`2026-13.jsonl`) made the window enumerate the days of an invalid month. Test added first, against the phase 1 adapter: + +```txt +× passes over a monthly name no calendar holds, as it did before windows were tests +RangeError: Invalid time value +Tests 1 failed | 38 passed (39) +``` + +## Bench + +`ReportUsageUseCase` on a real `UsageLedgerAdapter` in a temp dir, ingest stubbed (no new transcripts), axis `task`, three runs each. Before: `fcb6022b`. After: this branch. Same totals before and after in every row. + +| Ledger | Report | Before | After | +| --- | --- | --- | --- | +| 265,000 calls over 28 days (dense, the #1001 bench) | `--days 7` (53,800 calls) | 810-845 ms | 205-215 ms | +| same | no period | 932-952 ms | 935-977 ms | +| 1,241,000 calls over 365 days (3,400 a day, the real rate measured on a local history) | `--days 7` (23,800 calls) | 4,171-4,271 ms | 93-97 ms | +| same | no period | 4,899-4,967 ms | 4,934-6,325 ms | + +A bounded report now costs its window, not the history. The unbounded report is unchanged; its 6.3 s first run is a garbage-collection outlier, the next two match "before". + +## Gates + +| Gate | Decisive line | +| --- | --- | +| `pnpm typecheck`, `pnpm lint` | exit 0 | +| `pnpm test` | `Tests 6512 passed \| 1 skipped (6513)` | +| `pnpm smoke` | `PASS: 132 FAIL: 0 SKIP: 1 · coverage 100%` | +| bundle budget | `Bundle size: 683.1 KB / budget: 695 KB` | +| `pnpm knip` | exit 0 | +| `check-claude-tokens-end-to-end.cjs` | `2 calls, 393 tokens, all on task demo, ticket T-1` | +| same, `--attended` | `2 calls, 393 tokens, all on task demo, ticket T-1; asked, answered by the hook, kept across /clear` | diff --git a/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/phase-1.md b/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/phase-1.md new file mode 100644 index 000000000..9550879d5 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/phase-1.md @@ -0,0 +1,76 @@ +--- +status: pending +--- + +# Instruction: The ledger loads by a test on the day + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +cli/ +├── src/contexts/telemetry/ +│ ├── domain/ports/usage-ledger.ts ✏️ load(holds?: (day: string) => boolean) +│ ├── application/ledger-upkeep.ts ✏️ storeCalls passes the window as a test +│ └── infrastructure/usage-ledger-adapter.ts ✏️ inWindow keeps a day the test holds, and a month one of whose days it holds +└── tests/ + ├── helpers/ports/in-memory-telemetry.ts ✏️ same signature + └── contexts/telemetry/infrastructure/usage-ledger-adapter.integration.test.ts ✏️ window cases +``` + +## User Journey + +```mermaid +flowchart TD + A[caller gives a test on the day] --> B[adapter lists partitions on disk] + B --> C{daily or monthly} + C -->|daily| D[read when the test holds its day, or its month is being split] + C -->|monthly| E[read with all its days when the test holds one of its days] +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + seed day and month partitions in a temp dir => ledger on disk: 5: system + section Happy path + load with a test holding two days => only those two days are read: 5: system + section Edge case - open window + test holds every day from a date on => load => every later day read, no earlier one: 1: system + section Edge case - monthly file touched + month file and the test holds one day of it => load => month and every day of it read: 1: system + section Edge case - monthly file not touched + month file and the test holds no day of it => load => month not read: 1: system + section Teardown + remove temp dir => baseline restored: 5: system +``` + +## Tasks to do + +### `1)` Red first + +> Tests that fail on today's signature, run and record their red output in `evidence.md`. + +1. Adapter tests: closed window, window open on one side, monthly file touched by one day, monthly file untouched. +2. Run them, copy the failing lines to `evidence.md`. + +### `2)` Port and adapter + +> `load(holds?)` reads what the test holds; ingest behaves exactly as before. + +1. Port doc and signature. +2. `inWindow(names, holds)`: a day when `holds(day)` or its month is splitting; a month when `holds` is true for one of its calendar days. +3. `storeCalls`: `const days = daysAround(incoming); ledger.load((day) => days.has(day))`. +4. In-memory ledger: filter by `holds(dayOf(record.at))`. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | -------------------------------- | +| 1 | the new tests fail before the change, for the reason they name | +| 2 | they pass after it; every existing ingest and ledger test passes unchanged in what it asserts | diff --git a/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/phase-2.md b/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/phase-2.md new file mode 100644 index 000000000..edc186cf8 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/phase-2.md @@ -0,0 +1,86 @@ +--- +status: pending +--- + +# Instruction: A bounded report loads and settles its window + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +cli/ +├── src/contexts/telemetry/ +│ ├── domain/report/period.ts ✏️ windowOf(period): the day test from from-1 to to+1, or undefined when unbounded +│ ├── application/ledger-upkeep.ts ✏️ settleLedger(ledger, holds?) settles what it read +│ └── application/report/report-usage-use-case.ts ✏️ settles the period's window +│ └── application/report/report-envelope.ts ✏️ skipped_ledger_lines: of the partitions read +└── tests/contexts/telemetry/ + ├── domain/report/period.unit.test.ts ✏️ windowOf cases + ├── application/ledger-upkeep.integration.test.ts ✏️ bounded settle, monthly, crash + ├── application/report/report-usage-use-case.unit.test.ts ✏️ bounded load asked, unbounded unchanged + └── application/report/report-window.property.integration.test.ts ✅ bounded envelope == full filtered +``` + +## User Journey + +```mermaid +flowchart TD + A["report --days 7"] --> B[ingest new lines] + B --> C["windowOf(period)"] + C -->|bounded| D[load and settle the window] + C -->|unbounded| E[load and settle everything] + D --> F[keep the period's calls] + E --> F + F --> G[buildReport] +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + generate a ledger whose copies of a call are less than a day apart, daily and monthly partitions => ledger on disk: 5: system + section Happy path + report a bounded period on any axis => envelope equals the full report filtered, byte for byte: 5: system + section Edge case - monthly ledger + month file overlapping the window => bounded report => no call lost or doubled, month split: 1: system + section Edge case - crash in settle + save fails mid-way => bounded report again => same figures: 1: system + section Edge case - damage outside window + torn line in a day outside the window => bounded report => partition untouched, not counted: 1: system + section Edge case - unbounded + no period option => report => whole ledger loaded and settled as before: 1: system + section Teardown + remove temp dirs => baseline restored: 5: system +``` + +## Tasks to do + +### `1)` Red first + +> Tests and the property, run against today's code, red output recorded in `evidence.md`. + +1. `windowOf` unit cases: both bounds, one bound, none, month and year edges. +2. Use case: a bounded report asks the ledger for its window; unbounded asks for everything. +3. Property: bounded envelope equals the full report filtered, for every axis, from daily or monthly partitions. +4. Settle cases: monthly overlap, crash, damage outside. + +### `2)` Implement + +> Smallest change making them green. + +1. `windowOf` in `period.ts`, next to `inPeriod`. +2. `settleLedger(ledger, holds?)` passes `holds` to `load`. +3. Use case passes `windowOf(request.period)`. +4. Envelope doc: `skipped_ledger_lines` counts the partitions read. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | -------------------------------- | +| 1 | each new test fails before the change, for the reason it names | +| 2 | all pass; the property holds over its runs; no existing assertion is weakened | diff --git a/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/phase-3.md b/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/phase-3.md new file mode 100644 index 000000000..cbd0e4996 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/phase-3.md @@ -0,0 +1,67 @@ +--- +status: pending +--- + +# Instruction: Docs, measures and gates + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +. +├── aidd_docs/product/usage-contract.md ✏️ what a bounded report reads and settles +├── cli/aidd_docs/memory/telemetry.md ✏️ same, one line +└── aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/ + ├── evidence.md ✅ red runs, bench before and after, mutation, gates + └── review.md ✅ checker review +``` + +## User Journey + +```mermaid +flowchart TD + A[docs say what a report reads] --> B[bench before and after] + B --> C[mutation, suite, build, smoke, e2e] + C --> D[fresh checker] +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + seed a 265k-call ledger in a temp dir => bench ready: 5: system + section Happy path + run the bench on next and on the branch => --days 7 well under 1 s, unbounded unchanged: 5: system + section Teardown + remove temp dirs => baseline restored: 5: system +``` + +## Tasks to do + +### `1)` Docs + +> The contract and code map say what a bounded report reads. + +1. Usage contract: ledger row and the report section. +2. `cli/aidd_docs/memory/telemetry.md`. + +### `2)` Gates + +> Every gate run, its output recorded. + +1. Bench on `fcb6022b` and on the branch. +2. Full `cli` suite, typecheck, lint, knip, build budget, `pnpm smoke`. +3. Stryker on the telemetry scope; list surviving mutants on changed lines. +4. `scripts/check-claude-tokens-end-to-end.cjs`, headless and attended. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | -------------------------------- | +| 1 | a grep for what a report reads finds no sentence saying it reads every partition when bounded | +| 2 | `evidence.md` holds each gate's decisive line; mutation 96 or above | diff --git a/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/plan.md b/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/plan.md new file mode 100644 index 000000000..124269c29 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/plan.md @@ -0,0 +1,28 @@ +--- +objective: "A report with a bounded period reads only the partitions that can hold a call of it, and prints the same figures as a full read." +status: pending +--- + +# Plan: A report reads its period + +## Overview + +| Field | Value | +| ---------- | ----------------------- | +| **Goal** | `--days 7` costs its days, not the whole history; no figure changes | +| **Source** | [`contract.md`](./contract.md) | + +## Phases + +| # | Phase | File | +| --- | ------------ | ---------------------------- | +| 1 | The ledger loads by a test on the day | [`phase-1.md`](./phase-1.md) | +| 2 | A bounded report loads and settles its window | [`phase-2.md`](./phase-2.md) | +| 3 | Docs, measures and gates | [`phase-3.md`](./phase-3.md) | + +## Decisions + +| Decision | Why | +| ---------- | ----- | +| `UsageLedger.load` takes `(day) => boolean` instead of a set of days | a window open on one side, or `--from 0001-01-01 --to 9999-12-31`, would otherwise build a set as long as the period; a test costs the partitions on disk | +| No summary or cache | the breakdown in the contract shows no dominant stage; a second state is not justified | diff --git a/cli/aidd_docs/memory/telemetry.md b/cli/aidd_docs/memory/telemetry.md index 1ce471288..f6b86a096 100644 --- a/cli/aidd_docs/memory/telemetry.md +++ b/cli/aidd_docs/memory/telemetry.md @@ -54,7 +54,7 @@ flowchart LR | What a clone is | `domain/consent/clone-identity.ts` | | When a clone consented | `domain/consent/consent-history.ts`, `application/consent-log.ts` | | Which transcripts are read | `application/ingest-usage-use-case.ts`, `application/read-usage-use-case.ts` | -| How the ledger lies on disk, and what an ingest reads | `infrastructure/usage-ledger-adapter.ts`, `domain/stored-usage.ts`, `application/ledger-upkeep.ts` | +| How the ledger lies on disk, and what an ingest or a report reads | `infrastructure/usage-ledger-adapter.ts`, `domain/stored-usage.ts`, `application/ledger-upkeep.ts`, `domain/report/period.ts` | | What `on` and `forget` clean up from an earlier measurement | `domain/legacy/`, `application/switch/`, `application/forget/` | | The report axes and the envelope | `domain/report/usage-report.ts`, `application/report/report-envelope.ts` | diff --git a/cli/src/contexts/telemetry/application/ledger-upkeep.ts b/cli/src/contexts/telemetry/application/ledger-upkeep.ts index 0fdfa8feb..e11c95261 100644 --- a/cli/src/contexts/telemetry/application/ledger-upkeep.ts +++ b/cli/src/contexts/telemetry/application/ledger-upkeep.ts @@ -16,19 +16,25 @@ export async function storeCalls( ledger: UsageLedger, incoming: readonly StoredUsage[] ): Promise { - const held = await ledger.load(daysAround(incoming)); + const days = daysAround(incoming); + const held = await ledger.load((day) => days.has(day)); const merged = upsertUsage(held.records, incoming); const partitions = new Set([...merged.days, ...held.damaged, ...held.unsettled]); if (partitions.size > 0) await ledger.save(merged.records, partitions); return { added: merged.added, updated: merged.updated, skippedLines: held.skippedLines }; } -/** Reads every partition and rewrites, folded, each one that is damaged or not in daily form: - * a monthly one, or one holding a call another also holds. After it, the ledger is - * byte-identical to a single ingest of the same calls; a settled day is not touched. Runs - * under the caller's lock. */ -export async function settleLedger(ledger: UsageLedger): Promise { - const held = await ledger.load(); +/** Reads the partitions and rewrites, folded, each one read that is damaged or not in daily + * form: a monthly one, or one holding a call another read also holds. With no test, it reads + * every partition, and after it the ledger is byte-identical to a single ingest of the same + * calls. With a test on the day, it reads and settles only what the ledger loads for it (see + * `UsageLedger.load`), and leaves every other partition as it is. A settled day is not + * touched. Runs under the caller's lock. */ +export async function settleLedger( + ledger: UsageLedger, + holds?: (day: string) => boolean +): Promise { + const held = await ledger.load(holds); const partitions = new Set([...held.unsettled, ...held.damaged]); if (partitions.size > 0) await ledger.save(held.records, partitions); return held; diff --git a/cli/src/contexts/telemetry/application/report/report-envelope.ts b/cli/src/contexts/telemetry/application/report/report-envelope.ts index d97771ca9..b94d82ddc 100644 --- a/cli/src/contexts/telemetry/application/report/report-envelope.ts +++ b/cli/src/contexts/telemetry/application/report/report-envelope.ts @@ -38,8 +38,10 @@ export interface EnvelopeRow extends EnvelopeTally { * this run alone, `records` of the period, `not_stored` counts calls this run read and left * out by reason, `consent_log_damaged` is true while `ledger/consents.jsonl` cannot be trusted * and nothing is stored, and `oldest_transcript_at` is when the oldest transcript still on disk was - * last written (`null` when there is none). Nothing here is a path, a working directory or - * a branch name. + * last written (`null` when there is none), and `skipped_ledger_lines` counts the lines that + * were not records in the ledger partitions the report read: a bounded report reads its period + * and the day on each side, so damage outside them is not counted. Nothing here is a path, a + * working directory or a branch name. * * Under `AIDD_TELEMETRY=0` nothing is read and the envelope is `{ "version": 1, "refused": * "AIDD_TELEMETRY=0" }` (`REFUSED_ENVELOPE`). diff --git a/cli/src/contexts/telemetry/application/report/report-usage-use-case.ts b/cli/src/contexts/telemetry/application/report/report-usage-use-case.ts index 1e9ed1b38..f79e45a8d 100644 --- a/cli/src/contexts/telemetry/application/report/report-usage-use-case.ts +++ b/cli/src/contexts/telemetry/application/report/report-usage-use-case.ts @@ -1,6 +1,6 @@ import type { PersonIdentityStore } from "../../domain/ports/identity/person-identity-store.js"; import type { UsageLedger } from "../../domain/ports/usage-ledger.js"; -import { inPeriod, type Period } from "../../domain/report/period.js"; +import { inPeriod, type Period, windowOf } from "../../domain/report/period.js"; import { buildReport, type ReportAxis, @@ -30,7 +30,8 @@ export interface ReportCoverage { readonly consentLogDamaged: boolean; /** When the oldest transcript still on disk was last written: nothing older can be read. */ readonly oldestTranscriptAt: string | null; - /** Lines of the ledger that were not records. */ + /** Lines of the partitions read that were not records: a bounded report reads its period + * and the day on each side. */ readonly skippedLedgerLines: number; } @@ -45,8 +46,11 @@ export type ReportResult = /** Reads what is new, then answers what the stored calls consumed in a period, split along one * axis. The ledger holds facts only: what a call belongs to is worked out here, every time, from - * what people declared, so a declaration made later moves earlier work. It also leaves the - * ledger settled: one call in one partition, by day, whatever order the ingests ran in. */ + * what people declared, so a declaration made later moves earlier work. It also settles what it + * read: one call in one partition, by day, whatever order the ingests ran in. A report with a + * bounded period reads and settles only the partitions that can hold a call of it (see + * `windowOf`), so its cost follows its period and not the history; one with no bound reads and + * settles the whole ledger. */ export class ReportUsageUseCase { constructor( private readonly ingest: IngestUsageUseCase, @@ -58,7 +62,9 @@ export class ReportUsageUseCase { async execute(request: ReportRequest): Promise { const ingested = await this.ingest.execute(); if (ingested.refused) return { status: "refused" }; - const held = await this.ledger.exclusively(() => settleLedger(this.ledger)); + const held = await this.ledger.exclusively(() => + settleLedger(this.ledger, windowOf(request.period)) + ); const records = held.records.filter((record) => inPeriod(record.at, request.period)); const report = buildReport( records, diff --git a/cli/src/contexts/telemetry/domain/ports/usage-ledger.ts b/cli/src/contexts/telemetry/domain/ports/usage-ledger.ts index d1a53f6e4..a41a7ea6e 100644 --- a/cli/src/contexts/telemetry/domain/ports/usage-ledger.ts +++ b/cli/src/contexts/telemetry/domain/ports/usage-ledger.ts @@ -20,10 +20,11 @@ export interface LoadedLedger { export interface UsageLedger { /** Runs `work` while no other process writes the ledger. */ exclusively(work: () => Promise): Promise; - /** Every partition, daily and monthly. Given `days`, only those days, plus, for each month - * one of them falls in that still has its monthly partition, that partition and every day - * of the month: a half-finished split may hold a better snapshot in a day. */ - load(days?: ReadonlySet): Promise; + /** Every partition, daily and monthly. Given `holds`, a test on a `YYYY-MM-DD` day, only the + * days it holds, plus every monthly partition it holds one of the calendar days of, read with + * every day of that month: a half-finished split may hold a better snapshot in a day. What a + * load costs is the partitions on disk, never how many days the test holds. */ + load(holds?: (day: string) => boolean): Promise; /** Makes the ledger hold exactly these records, every other partition removed. A partition * that already holds what it should is not touched. Given `partitions`, a day is written * from `records`, or removed when they hold none for it; a month has each of its days diff --git a/cli/src/contexts/telemetry/domain/report/period.ts b/cli/src/contexts/telemetry/domain/report/period.ts index e0c820b7c..950874175 100644 --- a/cli/src/contexts/telemetry/domain/report/period.ts +++ b/cli/src/contexts/telemetry/domain/report/period.ts @@ -21,6 +21,27 @@ export function inPeriod(at: string, period: Period): boolean { return (period.from === null || day >= period.from) && (period.to === null || day <= period.to); } +/** The day `days` after a `YYYY-MM-DD` day, or `null` past what four digits can name. */ +function shift(day: string, days: number): string | null { + const shifted = dayOf(new Date(Date.parse(`${day}T00:00:00.000Z`) + days * DAY_MS).toISOString()); + return DAY.test(shifted) ? shifted : null; +} + +/** The days whose partitions can hold a call of the period, as a test on a `YYYY-MM-DD` day: + * from the day before `from` to the day after `to`, an open side, or one past four-digit + * years, staying open. `undefined` when the period has no bound: every partition. + * + * One day on each side, because the copies of one call are less than a day apart (of 262,631 + * real keys, the widest spread is 0.3 h), so they sit within a calendar day of each other: a + * call with a copy in the period has all of them in the window, and folds to the same one as a + * full read would. */ +export function windowOf(period: Period): ((day: string) => boolean) | undefined { + if (period.from === null && period.to === null) return undefined; + const first = period.from === null ? null : shift(period.from, -1); + const last = period.to === null ? null : shift(period.to, 1); + return (day) => (first === null || day >= first) && (last === null || day <= last); +} + function isCalendarDay(text: string): boolean { const instant = Date.parse(`${text}T00:00:00.000Z`); return ( diff --git a/cli/src/contexts/telemetry/infrastructure/usage-ledger-adapter.ts b/cli/src/contexts/telemetry/infrastructure/usage-ledger-adapter.ts index 573693a6e..cd021fba5 100644 --- a/cli/src/contexts/telemetry/infrastructure/usage-ledger-adapter.ts +++ b/cli/src/contexts/telemetry/infrastructure/usage-ledger-adapter.ts @@ -18,6 +18,7 @@ const MONTHLY = /^\d{4}-\d{2}\.jsonl$/; const EXTENSION = ".jsonl"; const OFFSETS_FILE = "offsets.json"; const LOCK_FILE = ".lock"; +const DAY_MS = 86_400_000; function partitionOf(name: string): string { return name.slice(0, -EXTENSION.length); @@ -32,9 +33,9 @@ function partitionOf(name: string): string { * reads the days around its calls, plus every day of a month it splits, and writes only what * changed, so its cost does not grow with the ledger. Between reports, a call whose snapshots * fell outside that window may sit in two days, and a crashed split leaves it in a month and a - * day: every reader folds at read, so it is still counted once. Once a report has settled the - * ledger, each call is on disk once and the bytes depend on what the ledger holds and not on - * how it got there. + * day: every reader folds at read, so it is still counted once. Once a report with no bound + * has settled the ledger, each call is on disk once and the bytes depend on what the ledger + * holds and not on how it got there. * * `.jsonl` is a month as an earlier version wrote it: still read, and split into its * days, its own file removed after them, so a crash in between leaves each call in both and @@ -52,14 +53,14 @@ export class UsageLedgerAdapter implements UsageLedger { return new DirectoryLock(this.dir, LOCK_FILE, this.storage, this.lockOptions).exclusively(work); } - async load(days?: ReadonlySet): Promise { + async load(holds?: (day: string) => boolean): Promise { const names = await this.partitions(); const found: StoredUsage[] = []; let skippedLines = 0; const damaged = new Set(); const unsettled = new Set(); const heldIn = new Map(); - for (const name of days === undefined ? names : inWindow(names, days)) { + for (const name of holds === undefined ? names : inWindow(names, holds)) { const partition = partitionOf(name); if (MONTHLY.test(name)) unsettled.add(partition); const text = (await readTextIfPresent(join(this.dir, name))) ?? ""; @@ -125,16 +126,31 @@ export class UsageLedgerAdapter implements UsageLedger { } } -/** The days asked for, and every partition of a month one of them falls in that still has its - * monthly file: a half-finished split may hold a better snapshot in any day of that month. */ -function inWindow(names: readonly string[], days: ReadonlySet): string[] { - const touched = new Set([...days].map((day) => day.slice(0, 7))); - // A day's key never equals a month's, so only a monthly file can match a touched month. - const splitting = new Set(names.map(partitionOf).filter((month) => touched.has(month))); +/** Whether the test holds one of the calendar days of a `YYYY-MM` month; never, for a month + * no calendar has. */ +function holdsADayOf(month: string, holds: (day: string) => boolean): boolean { + const first = Date.parse(`${month}-01T00:00:00.000Z`); + if (Number.isNaN(first)) return false; + for (let instant = first; new Date(instant).toISOString().startsWith(month); instant += DAY_MS) { + if (holds(new Date(instant).toISOString().slice(0, 10))) return true; + } + return false; +} + +/** The days the test holds, and every partition of a month it holds a day of that still has + * its monthly file: a half-finished split may hold a better snapshot in any day of that month. + * Each partition on disk is tested once, and a month at most once per calendar day. */ +function inWindow(names: readonly string[], holds: (day: string) => boolean): string[] { + const splitting = new Set( + names + .filter((name) => MONTHLY.test(name)) + .map(partitionOf) + .filter((month) => holdsADayOf(month, holds)) + ); return names.filter((name) => { const partition = partitionOf(name); return MONTHLY.test(name) ? splitting.has(partition) - : days.has(partition) || splitting.has(partition.slice(0, 7)); + : splitting.has(partition.slice(0, 7)) || holds(partition); }); } diff --git a/cli/tests/contexts/telemetry/application/ingest-usage-use-case.unit.test.ts b/cli/tests/contexts/telemetry/application/ingest-usage-use-case.unit.test.ts index b50f31bbe..4de8fd0f4 100644 --- a/cli/tests/contexts/telemetry/application/ingest-usage-use-case.unit.test.ts +++ b/cli/tests/contexts/telemetry/application/ingest-usage-use-case.unit.test.ts @@ -324,7 +324,11 @@ describe("ingesting usage into the ledger", () => { line("B", 20, {}, "2026-10-08T10:00:00.000Z"), ]); await s.ingest.execute(); - expect([...(s.ledger.loadedDays ?? [])].sort()).toEqual([ + const window = s.ledger.loadedWindow; + const autumn = Array.from({ length: 91 }, (_, n) => + new Date(Date.parse("2026-09-01T00:00:00.000Z") + n * 86_400_000).toISOString().slice(0, 10) + ); + expect(window && autumn.filter(window)).toEqual([ "2026-10-06", "2026-10-07", "2026-10-08", diff --git a/cli/tests/contexts/telemetry/application/ledger-upkeep.integration.test.ts b/cli/tests/contexts/telemetry/application/ledger-upkeep.integration.test.ts index cce892cb8..d500971d5 100644 --- a/cli/tests/contexts/telemetry/application/ledger-upkeep.integration.test.ts +++ b/cli/tests/contexts/telemetry/application/ledger-upkeep.integration.test.ts @@ -6,6 +6,7 @@ import { settleLedger, storeCalls, } from "../../../../src/contexts/telemetry/application/ledger-upkeep.js"; +import type { PrivateStorage } from "../../../../src/contexts/telemetry/domain/ports/private-storage.js"; import { buildReport } from "../../../../src/contexts/telemetry/domain/report/usage-report.js"; import { partitionByDay, @@ -152,3 +153,71 @@ describe("migrating a ledger kept by month", () => { expect(await bytes(dir)).toEqual(Object.fromEntries([...days].sort())); }); }); + +/** Storage whose `n`-th replace fails, as a crash between two writes would leave it. */ +class FailingStorage implements PrivateStorage { + private readonly inner = new PrivateStorageAdapter(); + private calls = 0; + constructor(private readonly failAt: number) {} + ensureDirectory(path: string): Promise { + return this.inner.ensureDirectory(path); + } + async replace(path: string, content: string): Promise { + this.calls += 1; + if (this.calls === this.failAt) throw new Error("disk full"); + await this.inner.replace(path, content); + } + append(path: string, content: string): Promise { + return this.inner.append(path, content); + } +} + +describe("settling only a window", () => { + /** Mid-September, so the window touches September and no day of October. */ + const window = (day: string): boolean => day >= "2026-09-14" && day <= "2026-09-16"; + const october = [...months].filter(([name]) => name.startsWith("2026-10")); + + async function linesOf(dir: string): Promise { + const files = Object.values(await bytes(dir)); + return files.flatMap((text) => text.split("\n").slice(0, -1)).length; + } + + it("splits a monthly file the window touches, loses and doubles no call, and leaves the rest alone", async () => { + const { dir, ledger } = await ledgerAt("bounded", months); + const held = await totals(ledger); + await settleLedger(ledger, window); + const after = await bytes(dir); + expect(after).toEqual({ + ...Object.fromEntries(septemberDays), + ...Object.fromEntries(october), + }); + expect(await linesOf(dir)).toBe(CALLS.length); + expect(await totals(ledger)).toEqual(held); + }); + + it("gives the same figures after a crash mid-split, once settled again", async () => { + const { dir, ledger } = await ledgerAt("crashed", months); + const held = await totals(ledger); + const crashing = new UsageLedgerAdapter(dir, new FailingStorage(2)); + await expect(settleLedger(crashing, window)).rejects.toThrow("disk full"); + expect(await totals(ledger)).toEqual(held); + await settleLedger(ledger, window); + expect(await totals(ledger)).toEqual(held); + expect(await linesOf(dir)).toBe(CALLS.length); + expect(await bytes(dir)).toEqual({ + ...Object.fromEntries(septemberDays), + ...Object.fromEntries(october), + }); + }); + + it("neither reads nor repairs a damaged day outside the window", async () => { + const damaged = new Map(days); + const torn = `${damaged.get("2026-10-20.jsonl")}{torn\n`; + damaged.set("2026-10-20.jsonl", torn); + const { dir, ledger } = await ledgerAt("damaged-outside", damaged); + const settled = await settleLedger(ledger, window); + expect(settled.skippedLines).toBe(0); + expect(settled.records.map((record) => record.key)).toEqual(["b"]); + expect((await bytes(dir))["2026-10-20.jsonl"]).toBe(torn); + }); +}); diff --git a/cli/tests/contexts/telemetry/application/report/report-usage-use-case.unit.test.ts b/cli/tests/contexts/telemetry/application/report/report-usage-use-case.unit.test.ts index 25e5e06d8..8352ae7b9 100644 --- a/cli/tests/contexts/telemetry/application/report/report-usage-use-case.unit.test.ts +++ b/cli/tests/contexts/telemetry/application/report/report-usage-use-case.unit.test.ts @@ -134,6 +134,34 @@ describe("reporting usage", () => { expect((await s.ledger.load()).skippedLines).toBe(0); }); + it("loads and settles only a bounded period and the day on each side of it", async () => { + const s = setup(); + s.transcripts.files.set("/t/1.jsonl", [line("A", 10, "2026-10-07T10:00:00.000Z")]); + await s.report.execute({ axis: "total", period: { from: "2026-10-03", to: "2026-10-09" } }); + const window = s.ledger.loadedWindow; + const autumn = Array.from({ length: 91 }, (_, n) => + new Date(Date.parse("2026-09-01T00:00:00.000Z") + n * 86_400_000).toISOString().slice(0, 10) + ); + expect(window && autumn.filter(window)).toEqual([ + "2026-10-02", + "2026-10-03", + "2026-10-04", + "2026-10-05", + "2026-10-06", + "2026-10-07", + "2026-10-08", + "2026-10-09", + "2026-10-10", + ]); + }); + + it("loads and settles the whole ledger when the period has no bound", async () => { + const s = setup(); + s.transcripts.files.set("/t/1.jsonl", [line("A", 10, "2026-10-07T10:00:00.000Z")]); + await s.report.execute({ axis: "total", period: ANY }); + expect(s.ledger.loadedWindow).toBeUndefined(); + }); + it("reads new transcripts first, so the report holds what was just written", async () => { const s = setup(); s.transcripts.files.set("/t/1.jsonl", [line("A", 10, "2026-10-07T10:00:00.000Z")]); diff --git a/cli/tests/contexts/telemetry/application/report/report-window.property.integration.test.ts b/cli/tests/contexts/telemetry/application/report/report-window.property.integration.test.ts new file mode 100644 index 000000000..c7d7d469c --- /dev/null +++ b/cli/tests/contexts/telemetry/application/report/report-window.property.integration.test.ts @@ -0,0 +1,232 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as fc from "fast-check"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { DirectoryResolver } from "../../../../../src/contexts/telemetry/application/directory-resolver.js"; +import { + type IngestResult, + IngestUsageUseCase, +} from "../../../../../src/contexts/telemetry/application/ingest-usage-use-case.js"; +import { DeclaredBindings } from "../../../../../src/contexts/telemetry/application/report/declared-bindings.js"; +import { reportEnvelopeOf } from "../../../../../src/contexts/telemetry/application/report/report-envelope.js"; +import { ReportUsageUseCase } from "../../../../../src/contexts/telemetry/application/report/report-usage-use-case.js"; +import { SnapshotBindingsUseCase } from "../../../../../src/contexts/telemetry/application/snapshot-bindings-use-case.js"; +import type { UsageLedger } from "../../../../../src/contexts/telemetry/domain/ports/usage-ledger.js"; +import { + inPeriod, + type Period, +} from "../../../../../src/contexts/telemetry/domain/report/period.js"; +import { + buildReport, + REPORT_AXES, +} from "../../../../../src/contexts/telemetry/domain/report/usage-report.js"; +import type { StoredUsage } from "../../../../../src/contexts/telemetry/domain/stored-usage.js"; +import { USAGE_AGENTS } from "../../../../../src/contexts/telemetry/domain/usage-record.js"; +import { UsageLedgerAdapter } from "../../../../../src/contexts/telemetry/infrastructure/usage-ledger-adapter.js"; +import { PrivateStorageAdapter } from "../../../../../src/runtime/filesystem/private-storage-adapter.js"; +import { + FakeBindings, + FakeConsents, + FakeLocator, + InMemoryBindingsLock, + InMemoryConsentHistory, + InMemoryIdentity, + InMemoryResolutions, + InMemorySessions, + InMemorySnapshots, +} from "../../../../helpers/ports/in-memory-telemetry.js"; + +let root: string; +let runs = 0; + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "aidd-report-window-")); +}); +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +const DAY_MS = 86_400_000; +/** Just under half a day: two copies of one call around an anchor are under a day apart. */ +const HALF_DAY = DAY_MS / 2 - 1; +/** Midnights where a window's edge meets a month or a year end. */ +const ANCHORS = [ + "2025-12-31T23:59:00.000Z", + "2026-02-28T23:30:00.000Z", + "2026-03-01T00:10:00.000Z", + "2026-06-15T12:00:00.000Z", + "2026-06-16T00:00:00.000Z", + "2026-10-31T23:00:00.000Z", +].map((at) => Date.parse(at)); + +const dayAt = (instant: number): string => new Date(instant).toISOString().slice(0, 10); +/** Days a period may start or end on: each anchor's day and the two on each side. */ +const EDGES = [ + ...new Set(ANCHORS.flatMap((at) => [-2, -1, 0, 1, 2].map((n) => dayAt(at + n * DAY_MS)))), +].sort(); + +const copy = fc.record({ + offset: fc.integer({ min: -HALF_DAY, max: HALF_DAY }), + output: fc.nat(5), + session: fc.constantFrom("s-1", "s-2"), + model: fc.constantFrom("m-1", "m-2"), + agent: fc.constantFrom(...USAGE_AGENTS), + repository: fc.constantFrom("repo-1", "repo-2"), + // Where this copy sits on disk: its day, its month as an earlier version wrote it, or both, + // as a split that crashed half-way leaves it. + placed: fc.constantFrom("day", "month", "both"), +}); + +const call = fc.record({ + anchor: fc.nat(ANCHORS.length - 1), + tool: fc.constantFrom("claude-code" as const, "codex" as const), + copies: fc.array(copy, { minLength: 1, maxLength: 3 }), +}); + +type Call = typeof call extends fc.Arbitrary ? T : never; + +const edge = fc.option(fc.constantFrom(...EDGES), { nil: null }); +const period = fc + .tuple(edge, edge) + .map( + ([a, b]): Period => + a !== null && b !== null && a > b ? { from: b, to: a } : { from: a, to: b } + ); + +const scenario = fc.record({ + calls: fc.array(call, { minLength: 1, maxLength: 10 }), + period, + axis: fc.constantFrom(...REPORT_AXES), +}); + +/** Every copy of every call, as the partition files that hold them. */ +function filesOf(calls: readonly Call[]): Map { + const files = new Map(); + const put = (name: string, record: StoredUsage) => + files.set(name, `${files.get(name) ?? ""}${JSON.stringify(record)}\n`); + calls.forEach(({ anchor, tool, copies }, index) => { + for (const { offset, output, session, model, agent, repository, placed } of copies) { + const at = new Date((ANCHORS[anchor] as number) + offset).toISOString(); + const record: StoredUsage = { + tool, + tool_version: "1.0.0", + key: `k${index}`, + session_id: session, + agent, + at, + model, + input: 1, + output, + cache_read: 0, + cache_write: 0, + cache_write_1h: null, + reasoning: null, + cwd: null, + git_branch: null, + repository_id: repository, + }; + if (placed !== "month") put(`${at.slice(0, 10)}.jsonl`, record); + if (placed !== "day") put(`${at.slice(0, 7)}.jsonl`, record); + } + }); + return files; +} + +async function ledgerWith(files: ReadonlyMap): Promise { + runs += 1; + const dir = join(root, `ledger-${runs}`); + await mkdir(dir, { recursive: true }); + for (const [name, text] of files) await writeFile(join(dir, name), text); + return new UsageLedgerAdapter(dir, new PrivateStorageAdapter()); +} + +/** An ingest that finds nothing new, so the report reads the ledger as laid out. */ +const NOTHING_NEW: IngestResult = { + refused: false, + filesRead: 0, + added: 0, + updated: 0, + unrecognised: 0, + skippedLedgerLines: 0, + snapshots: 0, + notStored: { + "outside-repo": 0, + "never-seen-alive": 0, + "no-consent": 0, + "consent-closed": 0, + "unreadable-consent": 0, + "no-cwd": 0, + undated: 0, + }, + consentLogDamaged: false, + oldestTranscriptAt: null, +}; +/** An ingest that reads no tool, so it finds nothing new: `NOTHING_NEW`. */ +function ingestOf(ledger: UsageLedger): IngestUsageUseCase { + const now = () => new Date("2026-10-09T00:00:00.000Z"); + return new IngestUsageUseCase( + [], + ledger, + new DirectoryResolver( + new FakeLocator(), + new FakeConsents(), + new InMemoryResolutions(), + new InMemoryConsentHistory(), + { caseInsensitiveFileSystem: false, now } + ), + new SnapshotBindingsUseCase( + new FakeBindings(), + new InMemorySnapshots(), + new InMemoryBindingsLock(), + now + ), + { refusedByEnvironment: false } + ); +} + +describe("a report on a bounded period", () => { + it("prints the envelope of the full ledger filtered to its period, byte for byte, and loses no call, when a call's copies are under a day apart", async () => { + await fc.assert( + fc.asyncProperty(scenario, async ({ calls, period, axis }) => { + const files = filesOf(calls); + const declared = new DeclaredBindings(new InMemorySessions(), new InMemorySnapshots()); + const identity = new InMemoryIdentity(); + identity.personId = "person-a"; + + const settled = await ledgerWith(files); + const bounded = await new ReportUsageUseCase( + ingestOf(settled), + settled, + declared, + identity + ).execute({ + axis, + period, + }); + if (bounded.status !== "reported") throw new Error("refused"); + + const full = await (await ledgerWith(files)).load(); + const kept = full.records.filter((record) => inPeriod(record.at, period)); + const reference = reportEnvelopeOf({ + status: "reported", + period, + report: buildReport(kept, await declared.read(), await identity.read(), axis), + coverage: { + filesRead: NOTHING_NEW.filesRead, + records: kept.length, + unrecognised: NOTHING_NEW.unrecognised, + notStored: NOTHING_NEW.notStored, + consentLogDamaged: NOTHING_NEW.consentLogDamaged, + oldestTranscriptAt: NOTHING_NEW.oldestTranscriptAt, + skippedLedgerLines: full.skippedLines, + }, + }); + expect(JSON.stringify(reportEnvelopeOf(bounded))).toBe(JSON.stringify(reference)); + // What it settled lost no call, doubled none, and kept the snapshot a full read keeps. + expect((await settled.load()).records).toEqual(full.records); + }), + { numRuns: 100 } + ); + }, 60_000); +}); diff --git a/cli/tests/contexts/telemetry/domain/report/period.unit.test.ts b/cli/tests/contexts/telemetry/domain/report/period.unit.test.ts index 94459d21d..b143fc132 100644 --- a/cli/tests/contexts/telemetry/domain/report/period.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/report/period.unit.test.ts @@ -3,6 +3,7 @@ import { dayOf, inPeriod, periodOf, + windowOf, } from "../../../../../src/contexts/telemetry/domain/report/period.js"; const NOW = new Date("2026-10-09T15:00:00.000Z"); @@ -114,3 +115,78 @@ describe("the period of a report", () => { expect(dayOf("2026-10-02T23:59:59.999Z")).toBe("2026-10-02"); }); }); + +describe("the window a report reads", () => { + /** The days of a window among those given, so a test names exactly what it holds. */ + const held = (period: { from: string | null; to: string | null }, days: readonly string[]) => { + const window = windowOf(period); + if (window === undefined) throw new Error("no window"); + return days.filter(window); + }; + + it("is every partition when the period has no bound", () => { + expect(windowOf({ from: null, to: null })).toBeUndefined(); + }); + + it("holds the period and the day on each side of it, and nothing beyond", () => { + expect( + held({ from: "2026-10-03", to: "2026-10-09" }, [ + "2026-10-01", + "2026-10-02", + "2026-10-03", + "2026-10-06", + "2026-10-09", + "2026-10-10", + "2026-10-11", + ]) + ).toEqual(["2026-10-02", "2026-10-03", "2026-10-06", "2026-10-09", "2026-10-10"]); + }); + + it("stays open on the side the period leaves open", () => { + const days = ["0001-01-01", "2026-10-01", "2026-10-02", "2026-10-03", "9999-12-31"]; + expect(held({ from: "2026-10-02", to: null }, days)).toEqual([ + "2026-10-01", + "2026-10-02", + "2026-10-03", + "9999-12-31", + ]); + expect(held({ from: null, to: "2026-10-02" }, days)).toEqual([ + "0001-01-01", + "2026-10-01", + "2026-10-02", + "2026-10-03", + ]); + }); + + it("crosses a month and a year end", () => { + expect( + held({ from: "2026-03-01", to: "2026-12-31" }, [ + "2026-02-27", + "2026-02-28", + "2027-01-01", + "2027-01-02", + ]) + ).toEqual(["2026-02-28", "2027-01-01"]); + }); + + it("knows a leap day", () => { + const days = ["2024-02-27", "2024-02-28", "2024-02-29", "2024-03-01", "2024-03-02"]; + expect(held({ from: "2024-03-01", to: null }, days)).toEqual([ + "2024-02-29", + "2024-03-01", + "2024-03-02", + ]); + expect(held({ from: null, to: "2024-02-28" }, days)).toEqual([ + "2024-02-27", + "2024-02-28", + "2024-02-29", + ]); + }); + + it("stays open past the first and last day a partition can be named", () => { + expect(held({ from: "0000-01-01", to: "9999-12-31" }, ["0000-01-01", "9999-12-31"])).toEqual([ + "0000-01-01", + "9999-12-31", + ]); + }); +}); diff --git a/cli/tests/contexts/telemetry/infrastructure/usage-ledger-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/usage-ledger-adapter.integration.test.ts index d36d16592..6588afa6b 100644 --- a/cli/tests/contexts/telemetry/infrastructure/usage-ledger-adapter.integration.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/usage-ledger-adapter.integration.test.ts @@ -74,6 +74,12 @@ class FailingStorage implements PrivateStorage { } } +/** A window holding exactly these days. */ +const only = + (...days: string[]) => + (day: string): boolean => + days.includes(day); + const NOTHING = { records: [], skippedLines: 0, damaged: new Set(), unsettled: new Set() }; describe("the ledger on disk", () => { @@ -273,15 +279,68 @@ describe("reading a window of days", () => { stored({ key: "c", at: "2026-10-08T10:00:00Z" }), ]); await writeFile(join(dir, "2026-10-06.jsonl"), "{torn\n"); - expect(await ledger.load(new Set(["2026-10-07"]))).toEqual({ + expect(await ledger.load(only("2026-10-07"))).toEqual({ ...NOTHING, records: [stored({ key: "b", at: "2026-10-07T10:00:00Z" })], }); }); + it("reads only the days of a closed window, and no partition outside it", async () => { + await ledger.save( + ["2026-10-04", "2026-10-05", "2026-10-06", "2026-10-07", "2026-10-08"].map((day) => + stored({ key: day, at: `${day}T10:00:00Z` }) + ) + ); + await writeFile(join(dir, "2026-10-04.jsonl"), "{torn\n"); + await writeFile(join(dir, "2026-10-08.jsonl"), "{torn\n"); + const loaded = await ledger.load((day) => day >= "2026-10-05" && day <= "2026-10-07"); + expect(loaded.records.map((r) => r.key)).toEqual(["2026-10-05", "2026-10-06", "2026-10-07"]); + expect(loaded.skippedLines).toBe(0); + }); + + it("reads every day from a date on, and none before it, for a window open on one side", async () => { + const days = ["2025-12-31", "2026-01-01", "2026-06-15", "2031-03-02"]; + await ledger.save(days.map((day) => stored({ key: day, at: `${day}T10:00:00Z` }))); + await writeFile(join(dir, "2025-12-31.jsonl"), "{torn\n"); + const loaded = await ledger.load((day) => day >= "2026-01-01"); + expect(loaded.records.map((r) => r.key)).toEqual(["2026-01-01", "2026-06-15", "2031-03-02"]); + expect(loaded.skippedLines).toBe(0); + }); + + it("reads a monthly file the window touches by one day, with every day of its month", async () => { + const early = stored({ key: "early", at: "2026-02-01T10:00:00Z" }); + const late = stored({ key: "late", at: "2026-02-27T10:00:00Z" }); + const daily = stored({ key: "daily", at: "2026-02-03T10:00:00Z" }); + await writePartition("2026-02.jsonl", lines([early, late])); + await writePartition("2026-02-03.jsonl", lines([daily])); + await writePartition( + "2026-03-01.jsonl", + lines([stored({ key: "march", at: "2026-03-01T10:00:00Z" })]) + ); + const loaded = await ledger.load((day) => day >= "2026-02-28" && day <= "2026-03-01"); + expect(loaded.records.map((r) => r.key)).toEqual(["daily", "early", "late", "march"]); + expect([...loaded.unsettled]).toEqual(["2026-02"]); + }); + + it("does not read a monthly file the window touches on none of its days", async () => { + await writePartition( + "2026-02.jsonl", + `${lines([stored({ at: "2026-02-28T10:00:00Z" })])}{torn\n` + ); + await writePartition("2026-02-28.jsonl", "{torn\n"); + expect(await ledger.load((day) => day >= "2026-03-01")).toEqual(NOTHING); + expect(await ledger.load((day) => day <= "2026-01-31")).toEqual(NOTHING); + }); + + it("passes over a monthly name no calendar holds, as it did before windows were tests", async () => { + await ledger.save([stored()]); + await writePartition("2026-13.jsonl", lines([stored({ key: "never" })])); + expect((await ledger.load(only("2026-10-07"))).records).toEqual([stored()]); + }); + it("reads nothing for an empty window", async () => { await ledger.save([stored()]); - expect(await ledger.load(new Set())).toEqual(NOTHING); + expect(await ledger.load(() => false)).toEqual(NOTHING); }); it("reads a monthly partition, and the daily ones of its month, when a day of the window falls in it", async () => { @@ -290,16 +349,16 @@ describe("reading a window of days", () => { const daily = stored({ key: "daily", at: "2026-09-25T10:00:00Z" }); await writePartition("2026-09.jsonl", lines([early, late])); await writePartition("2026-09-25.jsonl", lines([daily])); - for (const loaded of [await ledger.load(), await ledger.load(new Set(["2026-09-10"]))]) { + for (const loaded of [await ledger.load(), await ledger.load(only("2026-09-10"))]) { expect(loaded.records.map((r) => r.key)).toEqual(["daily", "early", "late"]); expect([...loaded.unsettled]).toEqual(["2026-09"]); } - expect(await ledger.load(new Set(["2026-10-07"]))).toEqual(NOTHING); + expect(await ledger.load(only("2026-10-07"))).toEqual(NOTHING); }); it("reads a monthly partition its window reaches by the day before", async () => { await writePartition("2026-09.jsonl", lines([stored({ at: "2026-09-30T10:00:00Z" })])); - const loaded = await ledger.load(new Set(["2026-09-30", "2026-10-01"])); + const loaded = await ledger.load(only("2026-09-30", "2026-10-01")); expect(loaded.records).toHaveLength(1); expect([...loaded.unsettled]).toEqual(["2026-09"]); }); @@ -310,7 +369,7 @@ describe("reading a window of days", () => { lines([stored({ output: 1, at: "2026-10-06T23:59:00Z" })]) ); await writePartition("2026-10-07.jsonl", lines([stored({ output: 9 })])); - const loaded = await ledger.load(new Set(["2026-10-06", "2026-10-07", "2026-10-08"])); + const loaded = await ledger.load(only("2026-10-06", "2026-10-07", "2026-10-08")); expect(loaded.records.map((r) => r.output)).toEqual([9]); expect([...loaded.unsettled].sort()).toEqual(["2026-10-06", "2026-10-07"]); }); @@ -375,7 +434,7 @@ describe("splitting a monthly partition into its days", () => { const other = stored({ key: "b", at: "2026-09-20T10:00:00Z" }); await writePartition("2026-09.jsonl", lines([worse, other])); await writePartition("2026-09-10.jsonl", lines([better])); - const loaded = await ledger.load(new Set(["2026-09-20"])); + const loaded = await ledger.load(only("2026-09-20")); expect(loaded.records).toEqual([better, other]); expect([...loaded.unsettled].sort()).toEqual(["2026-09", "2026-09-10"]); await ledger.save(loaded.records, loaded.unsettled); diff --git a/cli/tests/helpers/ports/in-memory-telemetry.ts b/cli/tests/helpers/ports/in-memory-telemetry.ts index 0e61bffe8..d8aa760bf 100644 --- a/cli/tests/helpers/ports/in-memory-telemetry.ts +++ b/cli/tests/helpers/ports/in-memory-telemetry.ts @@ -114,15 +114,15 @@ export class InMemoryLedger implements UsageLedger { } /** The window the last load was given; `undefined` when it read everything. */ - loadedDays: ReadonlySet | undefined; + loadedWindow: ((day: string) => boolean) | undefined; - async load(days?: ReadonlySet): Promise { + async load(holds?: (day: string) => boolean): Promise { this.events.push("load"); - this.loadedDays = days; + this.loadedWindow = holds; const read = - days === undefined + holds === undefined ? this.records - : this.records.filter((record) => days.has(dayOf(record.at) ?? "")); + : this.records.filter((record) => holds(dayOf(record.at) ?? "")); return { records: foldUsage(read), skippedLines: this.damaged.size, From 280edb4dafacc03f8373bb7fc4a2af9e2f524487 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Thu, 15 Oct 2026 17:31:43 +0200 Subject: [PATCH 3/4] refactor(telemetry): one day arithmetic, one failing storage, one autumn Review round 1: shiftDay in the stored usage domain replaces three copies of the day length and its arithmetic; the failing storage and the September to November days are shared test helpers. The usage skill's envelope reference, the usage contract and the task contract say what a bounded report reads, and evidence.md records the mutation run. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- aidd_docs/product/usage-contract.md | 4 +- .../contract.md | 6 +-- .../evidence.md | 15 ++++++ .../phase-1.md | 2 +- .../phase-2.md | 2 +- .../phase-3.md | 2 +- .../plan.md | 2 +- .../review.md | 51 +++++++++++++++++++ .../telemetry/domain/report/period.ts | 9 ++-- .../contexts/telemetry/domain/stored-usage.ts | 13 +++-- .../infrastructure/usage-ledger-adapter.ts | 15 +++--- .../ingest-usage-use-case.unit.test.ts | 6 +-- .../ledger-upkeep.integration.test.ts | 20 +------- .../report/report-usage-use-case.unit.test.ts | 6 +-- .../usage-ledger-adapter.integration.test.ts | 20 +------- .../helpers/ports/failing-private-storage.ts | 20 ++++++++ .../helpers/ports/in-memory-telemetry.ts | 5 ++ .../skills/01-usage/references/envelope.md | 2 +- 18 files changed, 128 insertions(+), 72 deletions(-) create mode 100644 aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/review.md create mode 100644 cli/tests/helpers/ports/failing-private-storage.ts diff --git a/aidd_docs/product/usage-contract.md b/aidd_docs/product/usage-contract.md index 5ab13ba32..58bde991e 100644 --- a/aidd_docs/product/usage-contract.md +++ b/aidd_docs/product/usage-contract.md @@ -103,7 +103,7 @@ Row and `totals`: `records`, `input`, `output`, `cache_read`, `cache_write` (eac - Every axis partitions the same calls: each adds up to `totals`, counter by counter. - `AIDD_TELEMETRY=0`: nothing read, object is `{"version": 1, "refused": "AIDD_TELEMETRY=0"}`. - A report ingests first. `files_read` and `unrecognised_shapes` describe that run; `records` the period. -- A report with `--days`, `--from` or `--to` reads only the days of its period and the day on each side, plus any monthly file one of them falls in; with none it reads the whole ledger. The copies of one call are less than a day apart, so both print the same figures. `skipped_ledger_lines` counts the lines of the partitions read. +- A report with `--days`, `--from` or `--to` reads only the days of its period and the day on each side, plus any monthly file one of them falls in, with every day of its month; with none it reads the whole ledger. The copies of one call have been observed less than a day apart, and under that bound both print the same figures. `skipped_ledger_lines` counts the lines of the partitions read. - No path, working directory or branch. `session` keys are full session ids, `repository` keys are hashes. ## Stores @@ -112,7 +112,7 @@ Directory: `AIDD_TELEMETRY_DIR`, else `AIDD_USER_CONFIG_DIR/telemetry`, else `XD | Store | Writer | Readers | Holds | | --- | --- | --- | --- | -| `ledger/.jsonl` | `ingest`, `report` | `report`, `forget` | `StoredUsage` by UTC day of `at`, in time then key order, one call per line. Ingest reads its calls' days and the day on each side, and rewrites only a changed day, whole, by rename; no new bytes, no write. `report` rewrites, folded, the partitions it read that share a call or hold a line that is not a record; a report with no period reads every partition, so after it each call is on disk once. A monthly `.jsonl` from an earlier version is still read, split into its days by the first ingest whose window touches it or by a report, then removed | +| `ledger/.jsonl` | `ingest`, `report` | `report`, `forget` | `StoredUsage` by UTC day of `at`, in time then key order, one call per line. Ingest reads its calls' days and the day on each side, and rewrites only a changed day, whole, by rename; no new bytes, no write. `report` rewrites, folded, the partitions it read that share a call or hold a line that is not a record; a report with no period reads every partition, so after it each call is on disk once. A monthly `.jsonl` from an earlier version is still read, split into its days by the first ingest or report whose window touches it, or by a report with no period, then removed | | `ledger/offsets.json` | `ingest`, `report` | same | per transcript: bytes consumed, size, file identity. Shrunk or changed identity: read whole | | `ledger/roots.json` | `ingest`, `on` | `ingest`, `forget` | `{version: 2, directories}`: per directory and per clone seen alive there, `repository_id`, `root`, `clone`, `seen_at`. Other format holds nothing: a live directory is remembered again, a gone one counts `never-seen-alive`. `on` records its own root for `forget` | | `ledger/consents.jsonl` | `on`, `off`, `ingest`, hooks (close only) | same, plus `forget` | consent intervals, see [Consent](#consent). Outlives the clone; removed with the ledger. Hooks parse it strictly, pinned by the shared fixture | diff --git a/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/contract.md b/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/contract.md index 064ad7827..42c54b925 100644 --- a/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/contract.md +++ b/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/contract.md @@ -1,5 +1,5 @@ --- -status: in-progress +status: done --- # A report reads its period @@ -55,7 +55,7 @@ Copies less than a day apart are within one calendar day of each other. The fold **Evidence for the bound**, from #1001 (`2026_10_15_ledger-by-day/contract.md`): of 262,631 real keys, the largest spread between a key's first and last line is 0.3 h, and none spans a day. -**Outside the bound**, never observed: a call whose copies are more than a day apart can sit in two partitions only if an ingest window missed one. A bounded report may then fold fewer copies than a full one. An unbounded report still folds every copy and settles the whole ledger. +**Outside the bound**, never observed: a call whose copies are more than a day apart can sit in two partitions only if an ingest window missed one. A bounded report then folds only the copies in its window: it can count a call the full report leaves out of the period, or count another snapshot of it. An unbounded report still folds every copy and settles the whole ledger, and a bounded one never changes what a later full read keeps: it replaces only the copies it loaded with their winner. ## Attribution is per call @@ -75,7 +75,7 @@ Copies less than a day apart are within one calendar day of each other. The fold | # | Criterion | Proof | | --- | --- | --- | | 1 | `--days 7` on the 265k-call ledger answers in well under 1 s, before and after measured | bench on the use case | -| 2 | For any ledger whose copies of a call are less than a day apart, any period and any axis, the bounded envelope equals the full one filtered, byte for byte | fast-check property | +| 2 | For any ledger with no damaged line whose copies of a call are less than a day apart, any period and any axis, the bounded envelope equals the full one filtered, byte for byte | fast-check property | | 3 | A bounded report reads no partition outside its window, and reads a monthly file the window touches | tests on the adapter and the in-memory ledger | | 4 | A bounded report never loses or doubles a call, from a monthly ledger or not, crash or not | tests | | 5 | An unbounded report still settles the whole ledger | existing tests | diff --git a/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/evidence.md b/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/evidence.md index 669f36e0a..ac5a58a41 100644 --- a/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/evidence.md +++ b/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/evidence.md @@ -98,3 +98,18 @@ A bounded report now costs its window, not the history. The unbounded report is | `pnpm knip` | exit 0 | | `check-claude-tokens-end-to-end.cjs` | `2 calls, 393 tokens, all on task demo, ticket T-1` | | same, `--attended` | `2 calls, 393 tokens, all on task demo, ticket T-1; asked, answered by the hook, kept across /clear` | + +## Mutation + +`pnpm test:mutation:telemetry` on `4eabc58d`: `Report: reports/mutation/telemetry/ (score 96.2, floor 96)`. + +Survivors in the changed files, against the lines this change touched: + +| File | Survivors | On a changed line | Verdict | +| --- | --- | --- | --- | +| `application/ledger-upkeep.ts` | 0 of 14 | - | - | +| `application/report/report-usage-use-case.ts` | 0 of 11 | - | - | +| `domain/report/period.ts` | 6 of 139 | none: the `DAY` regex and the `--from`/`--to` order check predate this change | - | +| `infrastructure/usage-ledger-adapter.ts` | 3 of 131 | one: `inWindow` without `.filter((name) => MONTHLY.test(name))` | equivalent: a day's name is not a month, so `holdsADayOf` already answers false for it (`Date.parse` of `YYYY-MM-DD-01` fails) | + +The day test that always catches a window without its day on each side is `period.unit.test.ts` ("the window a report reads"), on every run; the property found it after 40 cases in its recorded run. diff --git a/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/phase-1.md b/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/phase-1.md index 9550879d5..b24f831fd 100644 --- a/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/phase-1.md +++ b/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/phase-1.md @@ -1,5 +1,5 @@ --- -status: pending +status: done --- # Instruction: The ledger loads by a test on the day diff --git a/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/phase-2.md b/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/phase-2.md index edc186cf8..1929b9ec4 100644 --- a/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/phase-2.md +++ b/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/phase-2.md @@ -1,5 +1,5 @@ --- -status: pending +status: done --- # Instruction: A bounded report loads and settles its window diff --git a/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/phase-3.md b/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/phase-3.md index cbd0e4996..14f92d47f 100644 --- a/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/phase-3.md +++ b/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/phase-3.md @@ -1,5 +1,5 @@ --- -status: pending +status: done --- # Instruction: Docs, measures and gates diff --git a/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/plan.md b/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/plan.md index 124269c29..664a6c66a 100644 --- a/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/plan.md +++ b/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/plan.md @@ -1,6 +1,6 @@ --- objective: "A report with a bounded period reads only the partitions that can hold a call of it, and prints the same figures as a full read." -status: pending +status: done --- # Plan: A report reads its period diff --git a/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/review.md b/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/review.md new file mode 100644 index 000000000..937914c10 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/review.md @@ -0,0 +1,51 @@ +# Review: A report reads its period + +- Rounds: 1 +- Started: 2026-10-15 + +## Round 1 · r-7c3e + +- Date: 2026-10-15 +- By: Baptiste LAFOURCADE +- Diff: `fcb6022b...4eabc58d` +- Axes: code, functional, relevancy +- Verdict: request changes (no correctness deal breaker; one shipped doc stale, one gate without evidence, one criterion overclaimed) +- Score: 5/8 met, 2 unmet, 1 unverified at runtime + +### Criteria + +- [x] 1. `--days 7` well under 1 s, before and after measured: `evidence.md` Bench. Rerun of `rb-after.mjs` (load average 28, stryker running): `--days 7` 311-340 ms, unbounded 2,606-3,516 ms, same 53,800 / 265,000 records as recorded. Absolute times not comparable under that load; the bounded/unbounded ratio (~0.11) agrees with the recorded one (~0.22) in direction. Plausible, not reproduced at the recorded figures. The bundle runs the reviewed code (`rb-after.mjs:377`, `:653`). +- [ ] 2. Bounded envelope equals full one filtered, byte for byte, for any ledger under the bound: holds for undamaged ledgers only. The generator (`report-window.property.integration.test.ts:101-133`) never writes a damaged line, and the reference takes `skipped_ledger_lines` from the full load; with a torn line outside the window the two envelopes differ by design (`ledger-upkeep.integration.test.ts` "neither reads nor repairs a damaged day outside the window"). The criterion as written is false; the property proves the narrower one. +- [x] 3. Bounded report reads no partition outside its window, and reads a monthly file it touches: `usage-ledger-adapter.integration.test.ts` "reads only the days of a closed window…", "reads every day from a date on…", "reads a monthly file the window touches by one day…", "does not read a monthly file…", "passes over a monthly name no calendar holds…"; `report-usage-use-case.unit.test.ts:137`. +- [x] 4. Never loses or doubles a call, monthly or not, crash or not: `ledger-upkeep.integration.test.ts` "splits a monthly file…" and "gives the same figures after a crash mid-split…" (line count == `CALLS.length`, totals unchanged); property second assertion `(await settled.load()).records` equals `full.records`. Reasoning in Findings/Notes below confirms it in general. +- [x] 5. Unbounded report still settles the whole ledger: `report-usage-use-case.unit.test.ts:159` (`loadedWindow` undefined), `ledger-upkeep.property.integration.test.ts:155-166` unchanged and green. +- [x] 6. Tests first, red runs recorded: `evidence.md` Red runs. Failure messages are consistent with the pre-change code (`[...days]` on a function → "days is not iterable"; `new Date(NaN).toISOString()` → "Invalid time value"; settle reading October → 5 partitions vs 4). Not reproduced (would need a second worktree while stryker runs). +- Unverified at runtime: 7. Telemetry mutation ≥ 96, changed-line mutants killed or explained — `evidence.md` Gates has no stryker line; a run is in progress. The two hand mutations are not a substitute. +- [ ] 8. Contract, code map and envelope documentation say what a bounded report reads — the user-facing envelope reference shipped in the plugin still describes the old meaning (finding 1), and `usage-contract.md` under-describes the read set (finding 2). + +Targeted run, this round: `pnpm exec vitest run` on the 7 touched or related telemetry test files → `Test Files 7 passed (7)`, `Tests 175 passed (175)`. + +### Findings + +- `plugins/aidd-telemetry/skills/01-usage/references/envelope.md:47` : `skipped_ledger_lines` still "Stored lines that were not records"; for a bounded report it now counts only the partitions read, and `answer.md:26` relays it to the person as if ledger-wide → "Lines that were not records in the ledger partitions this report read: a report with a period reads its days and the day on each side" — 🟡 major, doc drift (criterion 8) +- `aidd_docs/product/usage-contract.md:106` : "plus any monthly file one of them falls in" omits that every daily file of that month is read too (`usage-ledger-adapter.ts` `inWindow`), and "The copies of one call are less than a day apart, so both print the same figures" states an observation (#1001, 0.3 h over 262,631 keys) as a guarantee → append "with every day of its month"; reword to "Copies of one call have been observed less than a day apart (#1001), and under that bound both print the same figures; only a report with no period folds every copy" — 🟡 major, doc accuracy +- `aidd_docs/product/usage-contract.md:115` : "split into its days by the first ingest whose window touches it or by a report" — a bounded report whose window does not touch the month no longer splits it → "…or by a report whose window touches it, or one with no period" — 🟢 minor, doc accuracy +- `aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/contract.md:58` : "may then fold fewer copies" understates the failure mode. Outside the bound a bounded report can count a call the full report leaves out (in-period copy loaded, better copy outside the window, full winner outside the period), or count a different snapshot; nothing but an unbounded report repairs it → state both outcomes explicitly — 🟢 minor, contract precision +- `aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/contract.md:78` : criterion 2 says "any ledger", the property covers undamaged ones only → restrict to "any ledger with no damaged line", or add torn lines to the generator and build the reference's `skipped_ledger_lines` from the partitions read — 🟡 major, criterion overclaim +- `aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/evidence.md` (Gates) : no stryker result for criterion 7 → record the run's score and every surviving mutant on the changed lines (`period.ts:24-43`, `usage-ledger-adapter.ts:129-156`, `ledger-upkeep.ts:33-41`) with a kill or a reason — 🟡 major, missing gate evidence +- `cli/tests/contexts/telemetry/application/ledger-upkeep.integration.test.ts:158` : `FailingStorage` copied verbatim from `usage-ledger-adapter.integration.test.ts:60` → move it to `cli/tests/helpers/` and import it in both — 🟢 minor, DRY +- `cli/tests/contexts/telemetry/application/report/report-usage-use-case.unit.test.ts:142` : the 91-day `autumn` array duplicates `ingest-usage-use-case.unit.test.ts:328` → one helper (e.g. `daysHeld(window, from, count)`) in `tests/helpers` — 🟢 minor, DRY +- `cli/src/contexts/telemetry/infrastructure/usage-ledger-adapter.ts:21` : a third `DAY_MS` (also `period.ts:12`, `stored-usage.ts:39`), and `shift` (`period.ts:25`) re-derives the neighbour-day arithmetic of `daysAround` (`stored-usage.ts:43-54`) → export one day-shift helper from the domain and use it in `daysAround`, `windowOf`, `periodOf` and `holdsADayOf` — 🟢 minor, DRY +- `cli/tests/contexts/telemetry/application/report/report-window.property.integration.test.ts:229` : unseeded, 100 runs; the recorded `shift(…, 0)` mutant died only after 40 runs, so one CI run may not kill a similar mutant. Matches repo convention (`ledger-upkeep.property` is unseeded too) and `period.unit.test.ts` "holds the period and the day on each side" kills that mutant deterministically → say in `evidence.md` that the unit test is the deterministic guard and the property the second one; optionally raise `numRuns` — 🟢 minor, determinism +- `aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/plan.md:3` : `status: pending` (and `contract.md:2` `in-progress`) while both commits are in → update once criterion 7 is recorded; `aidd kanban` reads these — 🟢 minor, coherence + +### Notes (verified, no finding) + +- Fold order: `foldUsage` (`domain/usage-fold.ts`) keeps the minimum of `compareWinner`, a total order (total, time, session, serialised record), so the winner is a function of the set of copies. +- Attribution per call: `attribute(record, facts)` reads only the record and declared facts, no other ledger call. +- Same day notion: stored records require a zoned `at` (`stored-usage.ts` `dayOf`, used by `parseStoredUsage` and `partitionByDay`); for those, `period.ts` `dayOf` gives the same UTC day. Old monthly files were cut by UTC month (`fcb6022b~1` `monthOf`), so a month file holds only its own days. +- Edges: `--to 9999-12-31` → `shift` yields `+010000-01`, rejected, side open; `--from 0001-01-01` → `0000-12-31`; `--from 0000-01-01` → open; one-sided periods open; `2026-13.jsonl` → `holdsADayOf` false. Huge `--days` (swept 739,000 to 1e8 days): `windowOf` never throws, and open side matches how `inPeriod` already compared those strings. +- Q2: a bounded save names only partitions read; a splitting month's days are all read by `inWindow` and all named by `save`'s `day.slice(0, 7)`, so each rewritten partition is written from complete records. Days are written before empty ones are removed, and the monthly file last, so a crash leaves every call in at least one file. +- Q3: a bounded settle replaces the loaded copies of a call by their winner and leaves unloaded copies alone, so the global winner, and every later full or in-bound figure, is unchanged. The property's second assertion checks exactly this. +- Pre-existing, not introduced here: a record filed in a partition not of its own day (hand edit only) is dropped when that partition is rewritten and its real day is not named by `save`. +- Design: a day predicate in place of a set is the simplest shape that serves both callers (ingest's scattered days, a report's open range); not over-engineered. A damaged or unsettled partition outside every window now waits for an unbounded report or an ingest; documented in the adapter doc comment. diff --git a/cli/src/contexts/telemetry/domain/report/period.ts b/cli/src/contexts/telemetry/domain/report/period.ts index 950874175..7d9fb9645 100644 --- a/cli/src/contexts/telemetry/domain/report/period.ts +++ b/cli/src/contexts/telemetry/domain/report/period.ts @@ -1,3 +1,5 @@ +import { shiftDay } from "../stored-usage.js"; + /** A span of whole UTC days, both ends included; `null` is open. */ export interface Period { readonly from: string | null; @@ -9,7 +11,6 @@ export type PeriodOutcome = | { readonly ok: false; readonly message: string }; const DAY = /^\d{4}-\d{2}-\d{2}$/; -const DAY_MS = 86_400_000; /** The UTC day of an instant, `YYYY-MM-DD`. */ export function dayOf(at: string): string { @@ -23,7 +24,7 @@ export function inPeriod(at: string, period: Period): boolean { /** The day `days` after a `YYYY-MM-DD` day, or `null` past what four digits can name. */ function shift(day: string, days: number): string | null { - const shifted = dayOf(new Date(Date.parse(`${day}T00:00:00.000Z`) + days * DAY_MS).toISOString()); + const shifted = shiftDay(day, days); return DAY.test(shifted) ? shifted : null; } @@ -62,9 +63,7 @@ export function periodOf( const days = /^[1-9]\d*$/.test(asked.days) ? Number(asked.days) : 0; if (days === 0) return { ok: false, message: "--days takes a positive whole number." }; const today = dayOf(now.toISOString()); - const first = dayOf( - new Date(Date.parse(`${today}T00:00:00.000Z`) - (days - 1) * DAY_MS).toISOString() - ); + const first = shiftDay(today, 1 - days); return { ok: true, period: { from: first, to: today } }; } for (const [flag, value] of [ diff --git a/cli/src/contexts/telemetry/domain/stored-usage.ts b/cli/src/contexts/telemetry/domain/stored-usage.ts index f0a48e21a..916bef0ed 100644 --- a/cli/src/contexts/telemetry/domain/stored-usage.ts +++ b/cli/src/contexts/telemetry/domain/stored-usage.ts @@ -36,7 +36,13 @@ export function dayOf(at: string): string | null { return day.startsWith("+") || day.startsWith("-") ? null : day; } -const DAY_MS = 24 * 60 * 60 * 1000; +const DAY_MS = 86_400_000; + +/** The UTC day `days` after a `YYYY-MM-DD` day, as `toISOString` writes its first ten + * characters: a day past four-digit years comes out in a shape no `YYYY-MM-DD` test accepts. */ +export function shiftDay(day: string, days: number): string { + return new Date(Date.parse(`${day}T00:00:00.000Z`) + days * DAY_MS).toISOString().slice(0, 10); +} /** The days an ingest of these records reads: each record's day and the day on each side. A * call's snapshots are written minutes apart, so a neighbour day holds any earlier one. */ @@ -45,10 +51,7 @@ export function daysAround(records: readonly UsageRecord[]): ReadonlySet for (const record of records) { const day = dayOf(record.at); if (day === null) continue; - const midnight = Date.parse(`${day}T00:00:00.000Z`); - for (const instant of [midnight - DAY_MS, midnight, midnight + DAY_MS]) { - days.add(new Date(instant).toISOString().slice(0, 10)); - } + for (const offset of [-1, 0, 1]) days.add(shiftDay(day, offset)); } return days; } diff --git a/cli/src/contexts/telemetry/infrastructure/usage-ledger-adapter.ts b/cli/src/contexts/telemetry/infrastructure/usage-ledger-adapter.ts index cd021fba5..abda099ad 100644 --- a/cli/src/contexts/telemetry/infrastructure/usage-ledger-adapter.ts +++ b/cli/src/contexts/telemetry/infrastructure/usage-ledger-adapter.ts @@ -4,7 +4,12 @@ import { isErrnoException } from "../../../kernel/reading/json-file.js"; import { readTextIfPresent } from "../../../kernel/reading/text-file.js"; import type { PrivateStorage } from "../domain/ports/private-storage.js"; import type { LoadedLedger, UsageLedger } from "../domain/ports/usage-ledger.js"; -import { parseStoredUsage, partitionByDay, type StoredUsage } from "../domain/stored-usage.js"; +import { + parseStoredUsage, + partitionByDay, + type StoredUsage, + shiftDay, +} from "../domain/stored-usage.js"; import { parsePositions, renderPositions, @@ -18,7 +23,6 @@ const MONTHLY = /^\d{4}-\d{2}\.jsonl$/; const EXTENSION = ".jsonl"; const OFFSETS_FILE = "offsets.json"; const LOCK_FILE = ".lock"; -const DAY_MS = 86_400_000; function partitionOf(name: string): string { return name.slice(0, -EXTENSION.length); @@ -129,10 +133,9 @@ export class UsageLedgerAdapter implements UsageLedger { /** Whether the test holds one of the calendar days of a `YYYY-MM` month; never, for a month * no calendar has. */ function holdsADayOf(month: string, holds: (day: string) => boolean): boolean { - const first = Date.parse(`${month}-01T00:00:00.000Z`); - if (Number.isNaN(first)) return false; - for (let instant = first; new Date(instant).toISOString().startsWith(month); instant += DAY_MS) { - if (holds(new Date(instant).toISOString().slice(0, 10))) return true; + if (Number.isNaN(Date.parse(`${month}-01T00:00:00.000Z`))) return false; + for (let day = `${month}-01`; day.startsWith(month); day = shiftDay(day, 1)) { + if (holds(day)) return true; } return false; } diff --git a/cli/tests/contexts/telemetry/application/ingest-usage-use-case.unit.test.ts b/cli/tests/contexts/telemetry/application/ingest-usage-use-case.unit.test.ts index 4de8fd0f4..1ed81ec63 100644 --- a/cli/tests/contexts/telemetry/application/ingest-usage-use-case.unit.test.ts +++ b/cli/tests/contexts/telemetry/application/ingest-usage-use-case.unit.test.ts @@ -9,6 +9,7 @@ import { readClaudeUsageLine } from "../../../../src/contexts/telemetry/domain/f import type { LocatedDirectory } from "../../../../src/contexts/telemetry/domain/ports/repository-locator.js"; import { resolutionKey } from "../../../../src/contexts/telemetry/domain/repository-resolution.js"; import { + AUTUMN, cloneOf, FakeBindings, FakeConsents, @@ -325,10 +326,7 @@ describe("ingesting usage into the ledger", () => { ]); await s.ingest.execute(); const window = s.ledger.loadedWindow; - const autumn = Array.from({ length: 91 }, (_, n) => - new Date(Date.parse("2026-09-01T00:00:00.000Z") + n * 86_400_000).toISOString().slice(0, 10) - ); - expect(window && autumn.filter(window)).toEqual([ + expect(window && AUTUMN.filter(window)).toEqual([ "2026-10-06", "2026-10-07", "2026-10-08", diff --git a/cli/tests/contexts/telemetry/application/ledger-upkeep.integration.test.ts b/cli/tests/contexts/telemetry/application/ledger-upkeep.integration.test.ts index d500971d5..e83c24e56 100644 --- a/cli/tests/contexts/telemetry/application/ledger-upkeep.integration.test.ts +++ b/cli/tests/contexts/telemetry/application/ledger-upkeep.integration.test.ts @@ -6,7 +6,6 @@ import { settleLedger, storeCalls, } from "../../../../src/contexts/telemetry/application/ledger-upkeep.js"; -import type { PrivateStorage } from "../../../../src/contexts/telemetry/domain/ports/private-storage.js"; import { buildReport } from "../../../../src/contexts/telemetry/domain/report/usage-report.js"; import { partitionByDay, @@ -14,6 +13,7 @@ import { } from "../../../../src/contexts/telemetry/domain/stored-usage.js"; import { UsageLedgerAdapter } from "../../../../src/contexts/telemetry/infrastructure/usage-ledger-adapter.js"; import { PrivateStorageAdapter } from "../../../../src/runtime/filesystem/private-storage-adapter.js"; +import { FailingStorage } from "../../../helpers/ports/failing-private-storage.js"; let root: string; @@ -154,24 +154,6 @@ describe("migrating a ledger kept by month", () => { }); }); -/** Storage whose `n`-th replace fails, as a crash between two writes would leave it. */ -class FailingStorage implements PrivateStorage { - private readonly inner = new PrivateStorageAdapter(); - private calls = 0; - constructor(private readonly failAt: number) {} - ensureDirectory(path: string): Promise { - return this.inner.ensureDirectory(path); - } - async replace(path: string, content: string): Promise { - this.calls += 1; - if (this.calls === this.failAt) throw new Error("disk full"); - await this.inner.replace(path, content); - } - append(path: string, content: string): Promise { - return this.inner.append(path, content); - } -} - describe("settling only a window", () => { /** Mid-September, so the window touches September and no day of October. */ const window = (day: string): boolean => day >= "2026-09-14" && day <= "2026-09-16"; diff --git a/cli/tests/contexts/telemetry/application/report/report-usage-use-case.unit.test.ts b/cli/tests/contexts/telemetry/application/report/report-usage-use-case.unit.test.ts index 8352ae7b9..193464379 100644 --- a/cli/tests/contexts/telemetry/application/report/report-usage-use-case.unit.test.ts +++ b/cli/tests/contexts/telemetry/application/report/report-usage-use-case.unit.test.ts @@ -13,6 +13,7 @@ import { readClaudeUsageLine } from "../../../../../src/contexts/telemetry/domai import type { Period } from "../../../../../src/contexts/telemetry/domain/report/period.js"; import type { ReportAxis } from "../../../../../src/contexts/telemetry/domain/report/usage-report.js"; import { + AUTUMN, cloneOf, FakeBindings, FakeConsents, @@ -139,10 +140,7 @@ describe("reporting usage", () => { s.transcripts.files.set("/t/1.jsonl", [line("A", 10, "2026-10-07T10:00:00.000Z")]); await s.report.execute({ axis: "total", period: { from: "2026-10-03", to: "2026-10-09" } }); const window = s.ledger.loadedWindow; - const autumn = Array.from({ length: 91 }, (_, n) => - new Date(Date.parse("2026-09-01T00:00:00.000Z") + n * 86_400_000).toISOString().slice(0, 10) - ); - expect(window && autumn.filter(window)).toEqual([ + expect(window && AUTUMN.filter(window)).toEqual([ "2026-10-02", "2026-10-03", "2026-10-04", diff --git a/cli/tests/contexts/telemetry/infrastructure/usage-ledger-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/usage-ledger-adapter.integration.test.ts index 6588afa6b..5698101e1 100644 --- a/cli/tests/contexts/telemetry/infrastructure/usage-ledger-adapter.integration.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/usage-ledger-adapter.integration.test.ts @@ -2,10 +2,10 @@ import { mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promise import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import type { PrivateStorage } from "../../../../src/contexts/telemetry/domain/ports/private-storage.js"; import type { StoredUsage } from "../../../../src/contexts/telemetry/domain/stored-usage.js"; import { UsageLedgerAdapter } from "../../../../src/contexts/telemetry/infrastructure/usage-ledger-adapter.js"; import { PrivateStorageAdapter } from "../../../../src/runtime/filesystem/private-storage-adapter.js"; +import { FailingStorage } from "../../../helpers/ports/failing-private-storage.js"; let root: string; let dir: string; @@ -56,24 +56,6 @@ async function writePartition(name: string, content: string): Promise { await writeFile(join(dir, name), content); } -/** Storage whose `n`-th replace fails, as a crash between two writes would leave it. */ -class FailingStorage implements PrivateStorage { - private readonly inner = new PrivateStorageAdapter(); - private calls = 0; - constructor(private readonly failAt: number) {} - ensureDirectory(path: string): Promise { - return this.inner.ensureDirectory(path); - } - async replace(path: string, content: string): Promise { - this.calls += 1; - if (this.calls === this.failAt) throw new Error("disk full"); - await this.inner.replace(path, content); - } - append(path: string, content: string): Promise { - return this.inner.append(path, content); - } -} - /** A window holding exactly these days. */ const only = (...days: string[]) => diff --git a/cli/tests/helpers/ports/failing-private-storage.ts b/cli/tests/helpers/ports/failing-private-storage.ts new file mode 100644 index 000000000..1531bc438 --- /dev/null +++ b/cli/tests/helpers/ports/failing-private-storage.ts @@ -0,0 +1,20 @@ +import type { PrivateStorage } from "../../../src/contexts/telemetry/domain/ports/private-storage.js"; +import { PrivateStorageAdapter } from "../../../src/runtime/filesystem/private-storage-adapter.js"; + +/** Storage whose `n`-th replace fails, as a crash between two writes would leave it. */ +export class FailingStorage implements PrivateStorage { + private readonly inner = new PrivateStorageAdapter(); + private calls = 0; + constructor(private readonly failAt: number) {} + ensureDirectory(path: string): Promise { + return this.inner.ensureDirectory(path); + } + async replace(path: string, content: string): Promise { + this.calls += 1; + if (this.calls === this.failAt) throw new Error("disk full"); + await this.inner.replace(path, content); + } + append(path: string, content: string): Promise { + return this.inner.append(path, content); + } +} diff --git a/cli/tests/helpers/ports/in-memory-telemetry.ts b/cli/tests/helpers/ports/in-memory-telemetry.ts index d8aa760bf..748228de3 100644 --- a/cli/tests/helpers/ports/in-memory-telemetry.ts +++ b/cli/tests/helpers/ports/in-memory-telemetry.ts @@ -53,6 +53,11 @@ import { import type { TranscriptPosition } from "../../../src/contexts/telemetry/domain/transcript-position.js"; import { foldUsage } from "../../../src/contexts/telemetry/domain/usage-fold.js"; +/** Every UTC day of an autumn, September to November, to read a window as the days it holds. */ +export const AUTUMN: readonly string[] = Array.from({ length: 91 }, (_, n) => + new Date(Date.parse("2026-09-01T00:00:00.000Z") + n * 86_400_000).toISOString().slice(0, 10) +); + /** Transcripts held as lines; a position is the number of lines consumed. */ export class InMemoryTranscripts implements TranscriptSource { readonly files = new Map(); diff --git a/plugins/aidd-telemetry/skills/01-usage/references/envelope.md b/plugins/aidd-telemetry/skills/01-usage/references/envelope.md index c30d49d5b..d3f6e0a0a 100644 --- a/plugins/aidd-telemetry/skills/01-usage/references/envelope.md +++ b/plugins/aidd-telemetry/skills/01-usage/references/envelope.md @@ -44,7 +44,7 @@ What `aidd telemetry report --json` prints, and what each part means. Version `1 | `not_stored` | Calls read and left out, by reason: `outside-repo`, `never-seen-alive`, `no-consent`, `consent-closed`, `unreadable-consent`, `no-cwd`, `undated` | | `consent_log_damaged` | `true` while `ledger/consents.jsonl` cannot be trusted and nothing is stored | | `oldest_transcript_at` | When the oldest session file still on disk was last written, `null` when there is none | -| `skipped_ledger_lines` | Stored lines that were not records | +| `skipped_ledger_lines` | Lines that were not records, in the stored days this report read: with a period, those days and the day on each side | ## When there is nothing From 162c464a60d3f4147dd8f3c765e811ca35096374 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Thu, 15 Oct 2026 17:48:03 +0200 Subject: [PATCH 4/4] docs(telemetry): the window's bound is observed, not guaranteed Review round 2: say outside the observed bound only an unbounded report folds every copy, and that skipped_ledger_lines covers a month kept in one file. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- aidd_docs/product/usage-contract.md | 2 +- .../evidence.md | 2 + .../review.md | 39 ++++++++++++++++++- .../telemetry/domain/report/period.ts | 9 +++-- .../skills/01-usage/references/envelope.md | 2 +- 5 files changed, 47 insertions(+), 7 deletions(-) diff --git a/aidd_docs/product/usage-contract.md b/aidd_docs/product/usage-contract.md index 58bde991e..384fb62a9 100644 --- a/aidd_docs/product/usage-contract.md +++ b/aidd_docs/product/usage-contract.md @@ -103,7 +103,7 @@ Row and `totals`: `records`, `input`, `output`, `cache_read`, `cache_write` (eac - Every axis partitions the same calls: each adds up to `totals`, counter by counter. - `AIDD_TELEMETRY=0`: nothing read, object is `{"version": 1, "refused": "AIDD_TELEMETRY=0"}`. - A report ingests first. `files_read` and `unrecognised_shapes` describe that run; `records` the period. -- A report with `--days`, `--from` or `--to` reads only the days of its period and the day on each side, plus any monthly file one of them falls in, with every day of its month; with none it reads the whole ledger. The copies of one call have been observed less than a day apart, and under that bound both print the same figures. `skipped_ledger_lines` counts the lines of the partitions read. +- A report with `--days`, `--from` or `--to` reads only the days of its period and the day on each side, plus any monthly file one of them falls in, with every day of its month; with none it reads the whole ledger. The copies of one call have been observed less than a day apart, and under that bound both print the same figures. Outside it, only a report with no period folds every copy of a call. `skipped_ledger_lines` counts the lines of the partitions read. - No path, working directory or branch. `session` keys are full session ids, `repository` keys are hashes. ## Stores diff --git a/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/evidence.md b/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/evidence.md index ac5a58a41..eabcd78f2 100644 --- a/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/evidence.md +++ b/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/evidence.md @@ -113,3 +113,5 @@ Survivors in the changed files, against the lines this change touched: | `infrastructure/usage-ledger-adapter.ts` | 3 of 131 | one: `inWindow` without `.filter((name) => MONTHLY.test(name))` | equivalent: a day's name is not a month, so `holdsADayOf` already answers false for it (`Date.parse` of `YYYY-MM-DD-01` fails) | The day test that always catches a window without its day on each side is `period.unit.test.ts` ("the window a report reads"), on every run; the property found it after 40 cases in its recorded run. + +Rerun on `280edb4d`, after the review's refactor (`shiftDay`): `Report: reports/mutation/telemetry/ (score 96.2, floor 96)`. `domain/stored-usage.ts` survivors sit on lines 26, 120 and 128, none in `shiftDay` or `daysAround`; the others are the same as above. diff --git a/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/review.md b/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/review.md index 937914c10..f97a8570b 100644 --- a/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/review.md +++ b/aidd_docs/tasks/2026_10/2026_10_15_report-reads-its-period/review.md @@ -1,6 +1,6 @@ # Review: A report reads its period -- Rounds: 1 +- Rounds: 2 - Started: 2026-10-15 ## Round 1 · r-7c3e @@ -49,3 +49,40 @@ Targeted run, this round: `pnpm exec vitest run` on the 7 touched or related tel - Q3: a bounded settle replaces the loaded copies of a call by their winner and leaves unloaded copies alone, so the global winner, and every later full or in-bound figure, is unchanged. The property's second assertion checks exactly this. - Pre-existing, not introduced here: a record filed in a partition not of its own day (hand edit only) is dropped when that partition is rewritten and its real day is not named by `save`. - Design: a day predicate in place of a set is the simplest shape that serves both callers (ingest's scattered days, a report's open range); not over-engineered. A damaged or unsettled partition outside every window now waits for an unbounded report or an ingest; documented in the adapter doc comment. + +## Round 2 · r-b41d + +- Date: 2026-10-15 +- By: Baptiste LAFOURCADE +- Diff: `fcb6022b...280edb4d` +- Axes: code, functional, relevancy +- Verdict: approve (every round-1 finding addressed; no deal breaker; three minor doc precisions left; mutation rerun on `280edb4d` pending) +- Score: 7/8 met, 0 unmet, 1 unverified at runtime + +### Criteria + +- [x] 1. `--days 7` well under 1 s — unchanged since round 1; `280edb4d` changes no arithmetic (see Notes), so the bench stands. +- [x] 2. Bounded envelope equals full filtered, now scoped "for any ledger with no damaged line whose copies of a call are less than a day apart" (`contract.md:78`), which is what `report-window.property.integration.test.ts` generates. +- [x] 3. Bounded report reads no partition outside its window — adapter tests unchanged; `holdsADayOf` rewrite visits the same days (Notes). +- [x] 4. Never loses or doubles a call — crash tests now share `tests/helpers/ports/failing-private-storage.ts`, same behaviour as both deleted copies (byte-identical body). +- [x] 5. Unbounded report settles the whole ledger — unchanged. +- [x] 6. Tests first, red runs recorded — `evidence.md` Red runs, unchanged. +- Unverified at runtime: 7. Mutation ≥ 96, changed-line mutants explained — on `4eabc58d` verified from `cli/reports/mutation/telemetry/mutation.json` (17:31, `period.ts` survivors at that commit's line 11, so the report is of `4eabc58d`): score 96.19, survivors in changed files `usage-ledger-adapter.ts` 66 (NoCoverage), 123, 145; `period.ts` 11 ×2, 79 ×4. Only adapter 145 sits in a changed hunk (`@@ -128,6 +129,21`), and its equivalence holds (Notes). `280edb4d` adds `shiftDay` (`stored-usage.ts:41-45`) and rewrites `holdsADayOf`; their mutants are in the rerun the orchestrator is adding — pending. +- [x] 8. Docs say what a bounded report reads — `envelope.md:47`, `usage-contract.md:106`, `:115`, `report-envelope.ts:41-43`, `report-usage-use-case.ts:47-53`, `usage-ledger.ts:23-26`, `ledger-upkeep.ts:26-31`, `cli/aidd_docs/memory/telemetry.md:57`. Grep of `plugins/aidd-telemetry`, `aidd_docs/product`, `cli/aidd_docs`, `cli/src/contexts/telemetry` for whole-ledger/settle claims: none left that says every report reads or settles everything; `frame.md:18` ("No period named … reads every recorded day") is correct. The `envelope.md:47` finding below is under the bar: it concerns legacy monthly ledgers only, and only `skipped_ledger_lines`, never the figures. + +This round: `tsc --noEmit` exit 0; `biome check` on the 15 changed `.ts` files "No fixes applied"; one `vitest run tests/contexts/telemetry` completed, but the summary lines were cut by `tail`, the one-run limit (stryker in progress) forbade a rerun, and vitest's `results.json` cache has since been overwritten by stryker, so the pass on `280edb4d` is unverified by this checker. The pending stryker rerun on `280edb4d` starts with a dry run of the suite and fails on any red test, so it closes that gap. + +### Findings + +- `aidd_docs/product/usage-contract.md:106` : round-1 fix half applied: the bound is now "observed", but the canonical contract still does not say what happens outside it (task `contract.md` does) → append "Outside it, only a report with no period folds every copy of a call." — 🟢 minor, doc accuracy +- `cli/src/contexts/telemetry/domain/report/period.ts:35-38` : `windowOf` doc still states "the copies of one call are less than a day apart" as a guarantee, the same overclaim fixed in the product contract → "have been observed less than a day apart (#1001: …), and under that bound a call with a copy in the period has all of them in the window" — 🟢 minor, doc/code coherence +- `plugins/aidd-telemetry/skills/01-usage/references/envelope.md:47` : "with a period, those days and the day on each side" omits every day of a monthly file the window touches (legacy ledgers only) → "…and the day on each side, plus every day of a month still kept in one file" — 🟢 minor, doc precision + +### Notes (verified, no finding) + +- Round 1 fixes checked against the repo, not the message: `envelope.md:47` reworded; `evidence.md` Mutation section added (score, survivor table, deterministic-guard sentence); `contract.md:58` names both outcomes outside the bound; `contract.md:78` scoped; `usage-contract.md:106` adds "with every day of its month" and "observed"; `:115` "first ingest or report whose window touches it, or by a report with no period"; one `DAY_MS` left (`stored-usage.ts:39`); `FailingStorage` and `AUTUMN` shared, each used by the two former copies; `plan.md`, `contract.md`, `phase-1..3.md` `status: done`. +- `shiftDay` behaviour, old vs new code side by side in Node: `9999-12-31 +1` → `null` both; `0000-01-01 -1` → `null` both; `0001-01-01 -1` → `0000-12-31` both; `holdsADayOf` on `2026-13`, `2026-00`, `0000-00`, `2026-99` → false both, 0 days visited; `2024-02` 29 days, `2026-02` 28, `9999-12` 31, `0000-01` 31, identical day lists; `--days` 1, 2, 7, 739000, 740000, 1e6, 1e8, 100020000 → identical `from`; 1e9, 2^53+5, 1e300 → `RangeError` both. No behaviour change. +- `AUTUMN` (`in-memory-telemetry.ts:57`) builds its days with raw `Date` arithmetic, not `shiftDay`, so the window tests do not check `shiftDay` against itself. +- Equivalence of the adapter 145 survivor (`inWindow` without `.filter(MONTHLY.test)`): `Date.parse("2026-10-07-01T00:00:00.000Z")` is `NaN`, so `holdsADayOf` rejects every daily name; and even if it did not, a 10-character day in `splitting` can never equal a monthly partition or a daily name's 7-character prefix. Equivalent; only cost differs. +- Pre-existing, not introduced by this branch: `--days` ≥ about 1e8 + 20,700 throws `RangeError` in `periodOf`, identical on `fcb6022b`. +- Introduced by `4eabc58d`, unchanged by `280edb4d`: `--days 1000000` yields `from: "-000712-11"`, which `windowOf` treats as an open side; safe, since it reads more, never less. diff --git a/cli/src/contexts/telemetry/domain/report/period.ts b/cli/src/contexts/telemetry/domain/report/period.ts index 7d9fb9645..367a87952 100644 --- a/cli/src/contexts/telemetry/domain/report/period.ts +++ b/cli/src/contexts/telemetry/domain/report/period.ts @@ -32,10 +32,11 @@ function shift(day: string, days: number): string | null { * from the day before `from` to the day after `to`, an open side, or one past four-digit * years, staying open. `undefined` when the period has no bound: every partition. * - * One day on each side, because the copies of one call are less than a day apart (of 262,631 - * real keys, the widest spread is 0.3 h), so they sit within a calendar day of each other: a - * call with a copy in the period has all of them in the window, and folds to the same one as a - * full read would. */ + * One day on each side, because the copies of one call have been observed less than a day + * apart (of 262,631 real keys, the widest spread is 0.3 h). Under that bound they sit within a + * calendar day of each other: a call with a copy in the period has all of them in the window, + * and folds to the same one as a full read would. Outside it, only an unbounded read folds + * every copy. */ export function windowOf(period: Period): ((day: string) => boolean) | undefined { if (period.from === null && period.to === null) return undefined; const first = period.from === null ? null : shift(period.from, -1); diff --git a/plugins/aidd-telemetry/skills/01-usage/references/envelope.md b/plugins/aidd-telemetry/skills/01-usage/references/envelope.md index d3f6e0a0a..ffcd03ecf 100644 --- a/plugins/aidd-telemetry/skills/01-usage/references/envelope.md +++ b/plugins/aidd-telemetry/skills/01-usage/references/envelope.md @@ -44,7 +44,7 @@ What `aidd telemetry report --json` prints, and what each part means. Version `1 | `not_stored` | Calls read and left out, by reason: `outside-repo`, `never-seen-alive`, `no-consent`, `consent-closed`, `unreadable-consent`, `no-cwd`, `undated` | | `consent_log_damaged` | `true` while `ledger/consents.jsonl` cannot be trusted and nothing is stored | | `oldest_transcript_at` | When the oldest session file still on disk was last written, `null` when there is none | -| `skipped_ledger_lines` | Lines that were not records, in the stored days this report read: with a period, those days and the day on each side | +| `skipped_ledger_lines` | Lines that were not records, in the stored days this report read: with a period, those days and the day on each side, plus every day of a month still kept in one file | ## When there is nothing