Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion aidd_docs/product/usage-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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
Expand All @@ -111,7 +112,7 @@ Directory: `AIDD_TELEMETRY_DIR`, else `AIDD_USER_CONFIG_DIR/telemetry`, else `XD

| Store | Writer | Readers | Holds |
| --- | --- | --- | --- |
| `ledger/<YYYY-MM-DD>.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 `<YYYY-MM>.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/<YYYY-MM-DD>.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 `<YYYY-MM>.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 |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
---
status: done
---

# 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,<br/>and every monthly file of a month it touches,<br/>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 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

`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 `<YYYY-MM>.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 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 |
| 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 |
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# 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` |

## 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.

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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
---
status: done
---

# 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 |
Loading
Loading