@gonrocca/zero-pi 0.1.11 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,520 +1,520 @@
1
- // zero-pi — adaptive model profiles, pure-logic module.
2
- //
3
- // zero learns which Claude model fits each SDD phase by accumulating a local,
4
- // append-only outcome log (`~/.pi/zero-runs.jsonl`) and tuning `~/.pi/zero.json`
5
- // from aggregated statistics. Every *decision* — parsing, aggregation, tier
6
- // math, adjustment — lives here in plain, dependency-free TypeScript so it is
7
- // testable and reproducible. The pi wiring lives in `autotune-extension.ts`.
8
- //
9
- // This file has no pi imports and no side-effecting top-level code; it only
10
- // touches the filesystem through the explicit `readRunRecords` reader.
11
-
12
- import { readFileSync } from "node:fs";
13
-
14
- /** Schema version of one `~/.pi/zero-runs.jsonl` record. A record carrying any
15
- * other `v` is dropped by `parseRunLine` rather than mis-aggregated. v2 adds
16
- * the per-round `verdicts` sequence; `parseRunLine` still accepts v1 records. */
17
- export const RUN_SCHEMA_VERSION = 2;
18
-
19
- /** The SDD phases a run record carries a model for, in pipeline order. */
20
- const RECORD_PHASES = ["explore", "plan", "build", "veredicto"] as const;
21
-
22
- /** Terminal states a run can be recorded with. `pasa` = success;
23
- * `cap-reached` = the round cap was hit without a `pasa`. */
24
- const VERDICTS = ["pasa", "cap-reached"] as const;
25
-
26
- /** A run's terminal verdict. */
27
- export type RunVerdict = (typeof VERDICTS)[number];
28
-
29
- /** Per-round verdicts the `veredicto` phase can return. `corregir` re-runs
30
- * build, `replantear` re-runs plan, `pasa` ends the run. `cap-reached` is a
31
- * run-level terminal state and is deliberately *not* a round verdict. */
32
- const ROUND_VERDICTS = ["corregir", "replantear", "pasa"] as const;
33
-
34
- /** A single round's verdict from the `veredicto` phase. */
35
- export type RunRoundVerdict = (typeof ROUND_VERDICTS)[number];
36
-
37
- /** The model a single phase ran on for a given run. An object (not a bare
38
- * string) so the schema can grow additive fields without a version bump. */
39
- export interface PhaseRun {
40
- model: string;
41
- }
42
-
43
- /**
44
- * One line of `~/.pi/zero-runs.jsonl` — a single completed SDD run. The
45
- * orchestrator prompt emits exactly this shape at run end.
46
- */
47
- export interface RunRecord {
48
- /** Schema version — always `RUN_SCHEMA_VERSION`. */
49
- v: number;
50
- /** ISO 8601 run-end timestamp. */
51
- ts: string;
52
- /** SDD feature slug. */
53
- feature: string;
54
- /** The model each phase ran on for this run. */
55
- phases: Record<(typeof RECORD_PHASES)[number], PhaseRun>;
56
- /** The run's terminal verdict. */
57
- verdict: RunVerdict;
58
- /** Count of build/veredicto rounds — `1` for a clean first-pass run. */
59
- rounds: number;
60
- /** The chronological per-round verdict sequence. Present on `v:2` records
61
- * (`verdicts.length === rounds`); `undefined` on `v:1` records, which carry
62
- * only the run-level verdict and so cannot be phase-attributed. Its
63
- * presence is the single discriminator `aggregate` uses for v2 attribution. */
64
- verdicts?: RunRoundVerdict[];
65
- }
66
-
67
- /** How aggressively zero applies a learned profile — stored in `zero.json`.
68
- * `auto` applies and notifies; `ask` records a pending suggestion; `off`
69
- * changes nothing. */
70
- export type AutotuneMode = "auto" | "ask" | "off";
71
-
72
- /** Whether a value is a non-null object (and not an array). */
73
- function isObject(value: unknown): value is Record<string, unknown> {
74
- return typeof value === "object" && value !== null && !Array.isArray(value);
75
- }
76
-
77
- /**
78
- * Validate a parsed `verdicts` value against the v2 invariants.
79
- *
80
- * Returns the typed array when the value is a non-empty `RunRoundVerdict[]`
81
- * with `length === rounds` and a sequence consistent with the run-level
82
- * `verdict`: a `pasa` run ends with exactly one `pasa` (last entry), a
83
- * `cap-reached` run contains no `pasa`. Returns `null` for any violation —
84
- * the caller drops the whole record rather than mis-attribute.
85
- */
86
- function validateVerdicts(
87
- value: unknown,
88
- verdict: RunVerdict,
89
- rounds: number,
90
- ): RunRoundVerdict[] | null {
91
- if (!Array.isArray(value)) return null;
92
- if (value.length < 1) return null;
93
- if (value.length !== rounds) return null;
94
-
95
- const verdicts: RunRoundVerdict[] = [];
96
- for (const entry of value) {
97
- if (typeof entry !== "string") return null;
98
- if (!(ROUND_VERDICTS as readonly string[]).includes(entry)) return null;
99
- verdicts.push(entry as RunRoundVerdict);
100
- }
101
-
102
- const passCount = verdicts.filter((e) => e === "pasa").length;
103
- if (verdict === "pasa") {
104
- // Exactly one `pasa`, and it is the final entry.
105
- if (passCount !== 1) return null;
106
- if (verdicts[verdicts.length - 1] !== "pasa") return null;
107
- } else {
108
- // `cap-reached`: the run never passed.
109
- if (passCount !== 0) return null;
110
- }
111
-
112
- return verdicts;
113
- }
114
-
115
- /**
116
- * Parse one JSONL line into a `RunRecord`, validating the shape.
117
- *
118
- * Accepts both `v:1` records (no `verdicts`) and `v:2` records (a fully
119
- * validated per-round `verdicts` sequence). Returns `null` — never throws —
120
- * for anything off-shape: invalid JSON, a non-object, a `v` outside
121
- * `{1, 2}`, a missing/non-string `feature` or `ts`, a non-integer `rounds`, a
122
- * `verdict` outside the enum, a `phases` map missing any of the four phases
123
- * or carrying a non-string model, or — for `v:2` — a missing/non-array/
124
- * inconsistent `verdicts`. This is the deliberate defense against malformed
125
- * LLM-emitted records: a bad emission degrades to "one missing sample",
126
- * never a crash.
127
- */
128
- export function parseRunLine(line: string): RunRecord | null {
129
- let parsed: unknown;
130
- try {
131
- parsed = JSON.parse(line);
132
- } catch {
133
- return null;
134
- }
135
-
136
- if (!isObject(parsed)) return null;
137
-
138
- if (parsed.v !== 1 && parsed.v !== 2) return null;
139
- if (typeof parsed.ts !== "string" || parsed.ts === "") return null;
140
- if (typeof parsed.feature !== "string" || parsed.feature === "") return null;
141
- if (typeof parsed.rounds !== "number" || !Number.isInteger(parsed.rounds)) return null;
142
- if (typeof parsed.verdict !== "string") return null;
143
- if (!(VERDICTS as readonly string[]).includes(parsed.verdict)) return null;
144
- const verdict = parsed.verdict as RunVerdict;
145
-
146
- if (!isObject(parsed.phases)) return null;
147
- const phases = {} as RunRecord["phases"];
148
- for (const phase of RECORD_PHASES) {
149
- const phaseRun = parsed.phases[phase];
150
- if (!isObject(phaseRun)) return null;
151
- if (typeof phaseRun.model !== "string" || phaseRun.model === "") return null;
152
- phases[phase] = { model: phaseRun.model };
153
- }
154
-
155
- // v2 records must carry a fully consistent `verdicts` sequence; v1 records
156
- // legitimately lack the field (the discriminator is `v`, not its presence).
157
- let verdicts: RunRoundVerdict[] | undefined;
158
- if (parsed.v === 2) {
159
- const validated = validateVerdicts(parsed.verdicts, verdict, parsed.rounds);
160
- if (validated === null) return null;
161
- verdicts = validated;
162
- }
163
-
164
- return {
165
- v: parsed.v,
166
- ts: parsed.ts,
167
- feature: parsed.feature,
168
- phases,
169
- verdict,
170
- rounds: parsed.rounds,
171
- ...(verdicts !== undefined ? { verdicts } : {}),
172
- };
173
- }
174
-
175
- /**
176
- * Read `~/.pi/zero-runs.jsonl` (or any path) into a list of valid `RunRecord`s.
177
- *
178
- * A missing or unreadable file yields `[]`. The file is split on `\n`; empty
179
- * lines are skipped, and any line `parseRunLine` rejects (a malformed or
180
- * half-written record) is dropped. Never throws.
181
- */
182
- export function readRunRecords(path: string): RunRecord[] {
183
- let contents: string;
184
- try {
185
- contents = readFileSync(path, "utf8");
186
- } catch {
187
- return [];
188
- }
189
-
190
- const records: RunRecord[] = [];
191
- for (const line of contents.split("\n")) {
192
- if (line.trim() === "") continue;
193
- const record = parseRunLine(line);
194
- if (record !== null) records.push(record);
195
- }
196
- return records;
197
- }
198
-
199
- // ---------------------------------------------------------------------------
200
- // Aggregation
201
- // ---------------------------------------------------------------------------
202
-
203
- /**
204
- * Aggregated outcome statistics for one `(phase, model)` pair.
205
- */
206
- export interface PhaseModelStat {
207
- /** SDD phase this stat is for. */
208
- phase: (typeof RECORD_PHASES)[number];
209
- /** Model id this stat is for. */
210
- model: string;
211
- /** Total runs recorded for the pair. */
212
- samples: number;
213
- /** Fraction of runs that reached `pasa` — `0` when `samples` is `0`. */
214
- passRate: number;
215
- /** Mean `rounds` averaged ONLY over runs that reached `pasa`; `null` when no
216
- * `pasa` run exists for the pair. */
217
- avgRounds: number | null;
218
- // --- v2 phase attribution ---
219
- /** Count of `v:2` records contributing to this pair — the dormancy-gate
220
- * denominator. v1 records do not increment this. */
221
- v2Samples: number;
222
- /** Mean count of `corregir` verdicts per v2 run for this pair — only
223
- * meaningful for `phase === "build"`. `null` when `v2Samples === 0`. */
224
- avgCorregir: number | null;
225
- /** Mean count of `replantear` verdicts per v2 run for this pair — only
226
- * meaningful for `phase === "plan"`. `null` when `v2Samples === 0`. */
227
- avgReplantear: number | null;
228
- }
229
-
230
- /** Map key for a `(phase, model)` bucket. */
231
- function statKey(phase: string, model: string): string {
232
- return `${phase} ${model}`;
233
- }
234
-
235
- /**
236
- * Aggregate run records into per-`(phase, model)` statistics.
237
- *
238
- * For every record (v1 and v2 alike), each of the four phases contributes one
239
- * sample to the bucket of the model that phase ran on (a phase whose model is
240
- * missing or non-string is skipped). `avgRounds` is averaged exclusively over
241
- * runs that reached `pasa`; non-positive `rounds` values (which `parseRunLine`
242
- * does not reject) are ignored when computing that average so a bad
243
- * `0`/negative entry never skews or breaks the math.
244
- *
245
- * Only records carrying a `verdicts` sequence (v2) contribute phase
246
- * attribution: each such record increments `v2Samples` for every phase, and
247
- * folds its per-run `corregir` count into the `build` model's `avgCorregir`
248
- * accumulator and its per-run `replantear` count into the `plan` model's
249
- * `avgReplantear` accumulator. `avgCorregir`/`avgReplantear` are `null` when a
250
- * pair has no v2 evidence. An empty input yields an empty map.
251
- */
252
- export function aggregate(records: RunRecord[]): Map<string, PhaseModelStat> {
253
- interface Acc {
254
- samples: number;
255
- passes: number;
256
- passRounds: number;
257
- passRoundCount: number;
258
- v2Samples: number;
259
- corregirTotal: number;
260
- replantearTotal: number;
261
- }
262
- const acc = new Map<string, { phase: (typeof RECORD_PHASES)[number]; model: string; data: Acc }>();
263
-
264
- for (const record of records) {
265
- const isPass = record.verdict === "pasa";
266
- const isV2 = record.verdicts !== undefined;
267
- const corregirCount = isV2
268
- ? (record.verdicts as RunRoundVerdict[]).filter((v) => v === "corregir").length
269
- : 0;
270
- const replantearCount = isV2
271
- ? (record.verdicts as RunRoundVerdict[]).filter((v) => v === "replantear").length
272
- : 0;
273
-
274
- for (const phase of RECORD_PHASES) {
275
- const phaseRun = record.phases[phase];
276
- const model = phaseRun?.model;
277
- if (typeof model !== "string" || model === "") continue;
278
-
279
- const key = statKey(phase, model);
280
- let bucket = acc.get(key);
281
- if (bucket === undefined) {
282
- bucket = {
283
- phase,
284
- model,
285
- data: {
286
- samples: 0,
287
- passes: 0,
288
- passRounds: 0,
289
- passRoundCount: 0,
290
- v2Samples: 0,
291
- corregirTotal: 0,
292
- replantearTotal: 0,
293
- },
294
- };
295
- acc.set(key, bucket);
296
- }
297
- bucket.data.samples += 1;
298
- if (isPass) {
299
- bucket.data.passes += 1;
300
- // Guard against non-positive `rounds`: a clean run is `>= 1`, so a
301
- // `0`/negative value is malformed and must not enter the average.
302
- if (typeof record.rounds === "number" && record.rounds > 0) {
303
- bucket.data.passRounds += record.rounds;
304
- bucket.data.passRoundCount += 1;
305
- }
306
- }
307
- if (isV2) {
308
- bucket.data.v2Samples += 1;
309
- // `corregir` blames the build phase's model, `replantear` the plan
310
- // phase's. The mapping is the pipeline contract, fixed and known.
311
- if (phase === "build") bucket.data.corregirTotal += corregirCount;
312
- if (phase === "plan") bucket.data.replantearTotal += replantearCount;
313
- }
314
- }
315
- }
316
-
317
- const stats = new Map<string, PhaseModelStat>();
318
- for (const [key, bucket] of acc) {
319
- const { samples, passes, passRounds, passRoundCount, v2Samples, corregirTotal, replantearTotal } =
320
- bucket.data;
321
- stats.set(key, {
322
- phase: bucket.phase,
323
- model: bucket.model,
324
- samples,
325
- passRate: samples > 0 ? passes / samples : 0,
326
- avgRounds: passRoundCount > 0 ? passRounds / passRoundCount : null,
327
- v2Samples,
328
- avgCorregir: v2Samples > 0 ? corregirTotal / v2Samples : null,
329
- avgReplantear: v2Samples > 0 ? replantearTotal / v2Samples : null,
330
- });
331
- }
332
- return stats;
333
- }
334
-
335
- // ---------------------------------------------------------------------------
336
- // Model tier ladder
337
- // ---------------------------------------------------------------------------
338
-
339
- /** Three Claude tiers, ordered `haiku < sonnet < opus`. */
340
- const TIER = { haiku: 0, sonnet: 1, opus: 2 } as const;
341
-
342
- /** A model's tier index, or `null` for an unrecognized (untierable) model. */
343
- export type Tier = (typeof TIER)[keyof typeof TIER];
344
-
345
- /** A single hardcoded representative model id per tier, used as the fallback
346
- * step-up target when the user has no known model at the next tier. */
347
- const TIER_REPRESENTATIVE: Record<Tier, string> = {
348
- [TIER.haiku]: "claude-haiku-4-5",
349
- [TIER.sonnet]: "claude-sonnet-4-6",
350
- [TIER.opus]: "claude-opus-4-7",
351
- };
352
-
353
- /**
354
- * Classify a model id into a tier by substring match — deliberate so future
355
- * point releases (`claude-sonnet-4-7`, etc.) classify with no code change.
356
- * Returns `null` for any id that is not recognizably haiku/sonnet/opus.
357
- */
358
- export function tierOf(modelId: string): Tier | null {
359
- if (typeof modelId !== "string") return null;
360
- const id = modelId.toLowerCase();
361
- if (id.includes("haiku")) return TIER.haiku;
362
- if (id.includes("sonnet")) return TIER.sonnet;
363
- if (id.includes("opus")) return TIER.opus;
364
- return null;
365
- }
366
-
367
- /**
368
- * Step a model up exactly one tier.
369
- *
370
- * Among `knownModels` (the models the user already uses anywhere in their
371
- * `models` map) it picks one whose tier is exactly `tierOf(model) + 1`,
372
- * preferring deterministically the smallest id when several qualify. If the
373
- * user has no known model at the next tier, it falls back to the single
374
- * hardcoded representative for that tier. Never returns an arbitrary id, and
375
- * never steps more than one tier.
376
- *
377
- * Returns `null` when `model` is already at `opus` (no higher tier) or is
378
- * untierable (an unrecognized model id).
379
- */
380
- export function stepUp(model: string, knownModels: readonly string[]): string | null {
381
- const tier = tierOf(model);
382
- if (tier === null) return null;
383
- if (tier === TIER.opus) return null;
384
-
385
- const nextTier = (tier + 1) as Tier;
386
-
387
- const candidates = knownModels
388
- .filter((m) => typeof m === "string" && tierOf(m) === nextTier)
389
- .sort();
390
- if (candidates.length > 0) return candidates[0];
391
-
392
- return TIER_REPRESENTATIVE[nextTier];
393
- }
394
-
395
- // ---------------------------------------------------------------------------
396
- // Adjustment rules
397
- // ---------------------------------------------------------------------------
398
-
399
- /** Below this many samples a `(phase, model)` pair is ignored — too little
400
- * evidence to act on (AC 5.1). */
401
- export const MIN_SAMPLES = 5;
402
-
403
- /** Pass-rate at or under this value marks a phase as under-performing. */
404
- export const LOW_PASS_RATE = 0.6;
405
-
406
- /** Average rounds-to-pass over this value marks a phase as struggling. */
407
- export const HIGH_AVG_ROUNDS = 2.5;
408
-
409
- /** Pass-rate at or over this value marks a phase as reliable — stay put. */
410
- export const RELIABLE_PASS_RATE = 0.85;
411
-
412
- /** Below this many `v:2` records a phase is left dormant — too little
413
- * attributed evidence to act on (Story 7). Reuses v1's `MIN_SAMPLES` value. */
414
- export const MIN_V2_SAMPLES = 5;
415
-
416
- /** Mean `corregir`/run strictly above this value blames the `build` model. */
417
- export const HIGH_AVG_CORREGIR = 1.0;
418
-
419
- /** Mean `replantear`/run strictly above this value blames the `plan` model.
420
- * Lower than `HIGH_AVG_CORREGIR` because a `replantear` is rarer and costlier. */
421
- export const HIGH_AVG_REPLANTEAR = 0.5;
422
-
423
- /**
424
- * A proposed change of model for one SDD phase. `reason` is a human-readable
425
- * string used verbatim in notifications.
426
- */
427
- export interface Adjustment {
428
- /** SDD phase the change applies to. */
429
- phase: (typeof RECORD_PHASES)[number];
430
- /** Model the phase currently runs on. */
431
- from: string;
432
- /** Model the phase is proposed to move to (always one tier above `from`). */
433
- to: string;
434
- /** Human-readable justification, e.g. `"pass-rate 0.40 over 7 runs"`. */
435
- reason: string;
436
- }
437
-
438
- /** The phases v2 autotune can attribute blame to and adjust. `explore` and
439
- * `veredicto` are structurally excluded — no round verdict ever blames them. */
440
- const ATTRIBUTABLE_PHASES = ["build", "plan"] as const;
441
-
442
- /**
443
- * Decide model adjustments from aggregated statistics — v2 phase-attributed.
444
- *
445
- * Only the `build` and `plan` phases are considered: a `corregir` verdict
446
- * re-runs build and a `replantear` verdict re-runs plan, so those are the only
447
- * phases the verdict sequence can blame. `explore` and `veredicto` are
448
- * structurally never iterated and so never adjusted (Story 6).
449
- *
450
- * For each attributable phase, looks up the `(phase, currentModel)` stat and:
451
- * - skips an absent stat;
452
- * - skips a phase with `v2Samples < MIN_V2_SAMPLES` — too little attributed
453
- * evidence (the dormancy gate, Story 7);
454
- * - reads the phase's blame measure (`build` → `avgCorregir` vs
455
- * `HIGH_AVG_CORREGIR`, `plan` → `avgReplantear` vs `HIGH_AVG_REPLANTEAR`)
456
- * and skips when it is `null` or not strictly above its threshold;
457
- * - otherwise proposes one tier up via `stepUp`.
458
- *
459
- * Safety caps are baked in: a proposal is emitted only when `stepUp` returns a
460
- * model (so a phase already at `opus` or on an untierable model is left
461
- * alone), `stepUp` never jumps more than one tier, and it never returns a
462
- * model outside `knownModels`-or-the-fixed-representative set. v1's
463
- * `LOW_PASS_RATE`/`HIGH_AVG_ROUNDS`/`RELIABLE_PASS_RATE` constants remain
464
- * exported but no longer drive this function.
465
- */
466
- export function decideAdjustments(
467
- stats: Map<string, PhaseModelStat>,
468
- currentModels: Partial<Record<(typeof RECORD_PHASES)[number], string>>,
469
- knownModels: readonly string[],
470
- ): Adjustment[] {
471
- const adjustments: Adjustment[] = [];
472
-
473
- for (const phase of ATTRIBUTABLE_PHASES) {
474
- const currentModel = currentModels[phase];
475
- if (typeof currentModel !== "string" || currentModel === "") continue;
476
-
477
- const stat = stats.get(statKey(phase, currentModel));
478
- if (stat === undefined) continue;
479
-
480
- // Dormancy gate: too few v2 records to attribute blame.
481
- if (stat.v2Samples < MIN_V2_SAMPLES) continue;
482
-
483
- const measure = phase === "build" ? stat.avgCorregir : stat.avgReplantear;
484
- const threshold = phase === "build" ? HIGH_AVG_CORREGIR : HIGH_AVG_REPLANTEAR;
485
- if (measure === null || !(measure > threshold)) continue;
486
-
487
- const to = stepUp(currentModel, knownModels);
488
- if (to === null) continue; // already at top tier, or untierable
489
-
490
- const blame = phase === "build" ? "corregir" : "replantear";
491
- const reason = `avg ${measure.toFixed(1)} ${blame}/run over ${stat.v2Samples} v2 runs`;
492
-
493
- adjustments.push({ phase, from: currentModel, to, reason });
494
- }
495
-
496
- return adjustments;
497
- }
498
-
499
- // ---------------------------------------------------------------------------
500
- // Autotune mode
501
- // ---------------------------------------------------------------------------
502
-
503
- /** The valid stored values of the `autotune` key, used for membership checks. */
504
- const AUTOTUNE_MODES = ["auto", "ask", "off"] as const;
505
-
506
- /**
507
- * Read the `autotune` mode out of a parsed `~/.pi/zero.json` object.
508
- *
509
- * Returns the stored value only when it is exactly `"auto"`, `"ask"`, or
510
- * `"off"`. A missing key, a non-string value, or any other string degrades to
511
- * the safe default `"auto"` — never throws. This is the single point of truth
512
- * for "absent ⇒ auto" (AC 3.2).
513
- */
514
- export function readAutotuneMode(data: Record<string, unknown>): AutotuneMode {
515
- const value = data.autotune;
516
- if (typeof value === "string" && (AUTOTUNE_MODES as readonly string[]).includes(value)) {
517
- return value as AutotuneMode;
518
- }
519
- return "auto";
520
- }
1
+ // zero-pi — adaptive model profiles, pure-logic module.
2
+ //
3
+ // zero learns which Claude model fits each SDD phase by accumulating a local,
4
+ // append-only outcome log (`~/.pi/zero-runs.jsonl`) and tuning `~/.pi/zero.json`
5
+ // from aggregated statistics. Every *decision* — parsing, aggregation, tier
6
+ // math, adjustment — lives here in plain, dependency-free TypeScript so it is
7
+ // testable and reproducible. The pi wiring lives in `autotune-extension.ts`.
8
+ //
9
+ // This file has no pi imports and no side-effecting top-level code; it only
10
+ // touches the filesystem through the explicit `readRunRecords` reader.
11
+
12
+ import { readFileSync } from "node:fs";
13
+
14
+ /** Schema version of one `~/.pi/zero-runs.jsonl` record. A record carrying any
15
+ * other `v` is dropped by `parseRunLine` rather than mis-aggregated. v2 adds
16
+ * the per-round `verdicts` sequence; `parseRunLine` still accepts v1 records. */
17
+ export const RUN_SCHEMA_VERSION = 2;
18
+
19
+ /** The SDD phases a run record carries a model for, in pipeline order. */
20
+ const RECORD_PHASES = ["explore", "plan", "build", "veredicto"] as const;
21
+
22
+ /** Terminal states a run can be recorded with. `pasa` = success;
23
+ * `cap-reached` = the round cap was hit without a `pasa`. */
24
+ const VERDICTS = ["pasa", "cap-reached"] as const;
25
+
26
+ /** A run's terminal verdict. */
27
+ export type RunVerdict = (typeof VERDICTS)[number];
28
+
29
+ /** Per-round verdicts the `veredicto` phase can return. `corregir` re-runs
30
+ * build, `replantear` re-runs plan, `pasa` ends the run. `cap-reached` is a
31
+ * run-level terminal state and is deliberately *not* a round verdict. */
32
+ const ROUND_VERDICTS = ["corregir", "replantear", "pasa"] as const;
33
+
34
+ /** A single round's verdict from the `veredicto` phase. */
35
+ export type RunRoundVerdict = (typeof ROUND_VERDICTS)[number];
36
+
37
+ /** The model a single phase ran on for a given run. An object (not a bare
38
+ * string) so the schema can grow additive fields without a version bump. */
39
+ export interface PhaseRun {
40
+ model: string;
41
+ }
42
+
43
+ /**
44
+ * One line of `~/.pi/zero-runs.jsonl` — a single completed SDD run. The
45
+ * orchestrator prompt emits exactly this shape at run end.
46
+ */
47
+ export interface RunRecord {
48
+ /** Schema version — always `RUN_SCHEMA_VERSION`. */
49
+ v: number;
50
+ /** ISO 8601 run-end timestamp. */
51
+ ts: string;
52
+ /** SDD feature slug. */
53
+ feature: string;
54
+ /** The model each phase ran on for this run. */
55
+ phases: Record<(typeof RECORD_PHASES)[number], PhaseRun>;
56
+ /** The run's terminal verdict. */
57
+ verdict: RunVerdict;
58
+ /** Count of build/veredicto rounds — `1` for a clean first-pass run. */
59
+ rounds: number;
60
+ /** The chronological per-round verdict sequence. Present on `v:2` records
61
+ * (`verdicts.length === rounds`); `undefined` on `v:1` records, which carry
62
+ * only the run-level verdict and so cannot be phase-attributed. Its
63
+ * presence is the single discriminator `aggregate` uses for v2 attribution. */
64
+ verdicts?: RunRoundVerdict[];
65
+ }
66
+
67
+ /** How aggressively zero applies a learned profile — stored in `zero.json`.
68
+ * `auto` applies and notifies; `ask` records a pending suggestion; `off`
69
+ * changes nothing. */
70
+ export type AutotuneMode = "auto" | "ask" | "off";
71
+
72
+ /** Whether a value is a non-null object (and not an array). */
73
+ function isObject(value: unknown): value is Record<string, unknown> {
74
+ return typeof value === "object" && value !== null && !Array.isArray(value);
75
+ }
76
+
77
+ /**
78
+ * Validate a parsed `verdicts` value against the v2 invariants.
79
+ *
80
+ * Returns the typed array when the value is a non-empty `RunRoundVerdict[]`
81
+ * with `length === rounds` and a sequence consistent with the run-level
82
+ * `verdict`: a `pasa` run ends with exactly one `pasa` (last entry), a
83
+ * `cap-reached` run contains no `pasa`. Returns `null` for any violation —
84
+ * the caller drops the whole record rather than mis-attribute.
85
+ */
86
+ function validateVerdicts(
87
+ value: unknown,
88
+ verdict: RunVerdict,
89
+ rounds: number,
90
+ ): RunRoundVerdict[] | null {
91
+ if (!Array.isArray(value)) return null;
92
+ if (value.length < 1) return null;
93
+ if (value.length !== rounds) return null;
94
+
95
+ const verdicts: RunRoundVerdict[] = [];
96
+ for (const entry of value) {
97
+ if (typeof entry !== "string") return null;
98
+ if (!(ROUND_VERDICTS as readonly string[]).includes(entry)) return null;
99
+ verdicts.push(entry as RunRoundVerdict);
100
+ }
101
+
102
+ const passCount = verdicts.filter((e) => e === "pasa").length;
103
+ if (verdict === "pasa") {
104
+ // Exactly one `pasa`, and it is the final entry.
105
+ if (passCount !== 1) return null;
106
+ if (verdicts[verdicts.length - 1] !== "pasa") return null;
107
+ } else {
108
+ // `cap-reached`: the run never passed.
109
+ if (passCount !== 0) return null;
110
+ }
111
+
112
+ return verdicts;
113
+ }
114
+
115
+ /**
116
+ * Parse one JSONL line into a `RunRecord`, validating the shape.
117
+ *
118
+ * Accepts both `v:1` records (no `verdicts`) and `v:2` records (a fully
119
+ * validated per-round `verdicts` sequence). Returns `null` — never throws —
120
+ * for anything off-shape: invalid JSON, a non-object, a `v` outside
121
+ * `{1, 2}`, a missing/non-string `feature` or `ts`, a non-integer `rounds`, a
122
+ * `verdict` outside the enum, a `phases` map missing any of the four phases
123
+ * or carrying a non-string model, or — for `v:2` — a missing/non-array/
124
+ * inconsistent `verdicts`. This is the deliberate defense against malformed
125
+ * LLM-emitted records: a bad emission degrades to "one missing sample",
126
+ * never a crash.
127
+ */
128
+ export function parseRunLine(line: string): RunRecord | null {
129
+ let parsed: unknown;
130
+ try {
131
+ parsed = JSON.parse(line);
132
+ } catch {
133
+ return null;
134
+ }
135
+
136
+ if (!isObject(parsed)) return null;
137
+
138
+ if (parsed.v !== 1 && parsed.v !== 2) return null;
139
+ if (typeof parsed.ts !== "string" || parsed.ts === "") return null;
140
+ if (typeof parsed.feature !== "string" || parsed.feature === "") return null;
141
+ if (typeof parsed.rounds !== "number" || !Number.isInteger(parsed.rounds)) return null;
142
+ if (typeof parsed.verdict !== "string") return null;
143
+ if (!(VERDICTS as readonly string[]).includes(parsed.verdict)) return null;
144
+ const verdict = parsed.verdict as RunVerdict;
145
+
146
+ if (!isObject(parsed.phases)) return null;
147
+ const phases = {} as RunRecord["phases"];
148
+ for (const phase of RECORD_PHASES) {
149
+ const phaseRun = parsed.phases[phase];
150
+ if (!isObject(phaseRun)) return null;
151
+ if (typeof phaseRun.model !== "string" || phaseRun.model === "") return null;
152
+ phases[phase] = { model: phaseRun.model };
153
+ }
154
+
155
+ // v2 records must carry a fully consistent `verdicts` sequence; v1 records
156
+ // legitimately lack the field (the discriminator is `v`, not its presence).
157
+ let verdicts: RunRoundVerdict[] | undefined;
158
+ if (parsed.v === 2) {
159
+ const validated = validateVerdicts(parsed.verdicts, verdict, parsed.rounds);
160
+ if (validated === null) return null;
161
+ verdicts = validated;
162
+ }
163
+
164
+ return {
165
+ v: parsed.v,
166
+ ts: parsed.ts,
167
+ feature: parsed.feature,
168
+ phases,
169
+ verdict,
170
+ rounds: parsed.rounds,
171
+ ...(verdicts !== undefined ? { verdicts } : {}),
172
+ };
173
+ }
174
+
175
+ /**
176
+ * Read `~/.pi/zero-runs.jsonl` (or any path) into a list of valid `RunRecord`s.
177
+ *
178
+ * A missing or unreadable file yields `[]`. The file is split on `\n`; empty
179
+ * lines are skipped, and any line `parseRunLine` rejects (a malformed or
180
+ * half-written record) is dropped. Never throws.
181
+ */
182
+ export function readRunRecords(path: string): RunRecord[] {
183
+ let contents: string;
184
+ try {
185
+ contents = readFileSync(path, "utf8");
186
+ } catch {
187
+ return [];
188
+ }
189
+
190
+ const records: RunRecord[] = [];
191
+ for (const line of contents.split("\n")) {
192
+ if (line.trim() === "") continue;
193
+ const record = parseRunLine(line);
194
+ if (record !== null) records.push(record);
195
+ }
196
+ return records;
197
+ }
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // Aggregation
201
+ // ---------------------------------------------------------------------------
202
+
203
+ /**
204
+ * Aggregated outcome statistics for one `(phase, model)` pair.
205
+ */
206
+ export interface PhaseModelStat {
207
+ /** SDD phase this stat is for. */
208
+ phase: (typeof RECORD_PHASES)[number];
209
+ /** Model id this stat is for. */
210
+ model: string;
211
+ /** Total runs recorded for the pair. */
212
+ samples: number;
213
+ /** Fraction of runs that reached `pasa` — `0` when `samples` is `0`. */
214
+ passRate: number;
215
+ /** Mean `rounds` averaged ONLY over runs that reached `pasa`; `null` when no
216
+ * `pasa` run exists for the pair. */
217
+ avgRounds: number | null;
218
+ // --- v2 phase attribution ---
219
+ /** Count of `v:2` records contributing to this pair — the dormancy-gate
220
+ * denominator. v1 records do not increment this. */
221
+ v2Samples: number;
222
+ /** Mean count of `corregir` verdicts per v2 run for this pair — only
223
+ * meaningful for `phase === "build"`. `null` when `v2Samples === 0`. */
224
+ avgCorregir: number | null;
225
+ /** Mean count of `replantear` verdicts per v2 run for this pair — only
226
+ * meaningful for `phase === "plan"`. `null` when `v2Samples === 0`. */
227
+ avgReplantear: number | null;
228
+ }
229
+
230
+ /** Map key for a `(phase, model)` bucket. */
231
+ function statKey(phase: string, model: string): string {
232
+ return `${phase} ${model}`;
233
+ }
234
+
235
+ /**
236
+ * Aggregate run records into per-`(phase, model)` statistics.
237
+ *
238
+ * For every record (v1 and v2 alike), each of the four phases contributes one
239
+ * sample to the bucket of the model that phase ran on (a phase whose model is
240
+ * missing or non-string is skipped). `avgRounds` is averaged exclusively over
241
+ * runs that reached `pasa`; non-positive `rounds` values (which `parseRunLine`
242
+ * does not reject) are ignored when computing that average so a bad
243
+ * `0`/negative entry never skews or breaks the math.
244
+ *
245
+ * Only records carrying a `verdicts` sequence (v2) contribute phase
246
+ * attribution: each such record increments `v2Samples` for every phase, and
247
+ * folds its per-run `corregir` count into the `build` model's `avgCorregir`
248
+ * accumulator and its per-run `replantear` count into the `plan` model's
249
+ * `avgReplantear` accumulator. `avgCorregir`/`avgReplantear` are `null` when a
250
+ * pair has no v2 evidence. An empty input yields an empty map.
251
+ */
252
+ export function aggregate(records: RunRecord[]): Map<string, PhaseModelStat> {
253
+ interface Acc {
254
+ samples: number;
255
+ passes: number;
256
+ passRounds: number;
257
+ passRoundCount: number;
258
+ v2Samples: number;
259
+ corregirTotal: number;
260
+ replantearTotal: number;
261
+ }
262
+ const acc = new Map<string, { phase: (typeof RECORD_PHASES)[number]; model: string; data: Acc }>();
263
+
264
+ for (const record of records) {
265
+ const isPass = record.verdict === "pasa";
266
+ const isV2 = record.verdicts !== undefined;
267
+ const corregirCount = isV2
268
+ ? (record.verdicts as RunRoundVerdict[]).filter((v) => v === "corregir").length
269
+ : 0;
270
+ const replantearCount = isV2
271
+ ? (record.verdicts as RunRoundVerdict[]).filter((v) => v === "replantear").length
272
+ : 0;
273
+
274
+ for (const phase of RECORD_PHASES) {
275
+ const phaseRun = record.phases[phase];
276
+ const model = phaseRun?.model;
277
+ if (typeof model !== "string" || model === "") continue;
278
+
279
+ const key = statKey(phase, model);
280
+ let bucket = acc.get(key);
281
+ if (bucket === undefined) {
282
+ bucket = {
283
+ phase,
284
+ model,
285
+ data: {
286
+ samples: 0,
287
+ passes: 0,
288
+ passRounds: 0,
289
+ passRoundCount: 0,
290
+ v2Samples: 0,
291
+ corregirTotal: 0,
292
+ replantearTotal: 0,
293
+ },
294
+ };
295
+ acc.set(key, bucket);
296
+ }
297
+ bucket.data.samples += 1;
298
+ if (isPass) {
299
+ bucket.data.passes += 1;
300
+ // Guard against non-positive `rounds`: a clean run is `>= 1`, so a
301
+ // `0`/negative value is malformed and must not enter the average.
302
+ if (typeof record.rounds === "number" && record.rounds > 0) {
303
+ bucket.data.passRounds += record.rounds;
304
+ bucket.data.passRoundCount += 1;
305
+ }
306
+ }
307
+ if (isV2) {
308
+ bucket.data.v2Samples += 1;
309
+ // `corregir` blames the build phase's model, `replantear` the plan
310
+ // phase's. The mapping is the pipeline contract, fixed and known.
311
+ if (phase === "build") bucket.data.corregirTotal += corregirCount;
312
+ if (phase === "plan") bucket.data.replantearTotal += replantearCount;
313
+ }
314
+ }
315
+ }
316
+
317
+ const stats = new Map<string, PhaseModelStat>();
318
+ for (const [key, bucket] of acc) {
319
+ const { samples, passes, passRounds, passRoundCount, v2Samples, corregirTotal, replantearTotal } =
320
+ bucket.data;
321
+ stats.set(key, {
322
+ phase: bucket.phase,
323
+ model: bucket.model,
324
+ samples,
325
+ passRate: samples > 0 ? passes / samples : 0,
326
+ avgRounds: passRoundCount > 0 ? passRounds / passRoundCount : null,
327
+ v2Samples,
328
+ avgCorregir: v2Samples > 0 ? corregirTotal / v2Samples : null,
329
+ avgReplantear: v2Samples > 0 ? replantearTotal / v2Samples : null,
330
+ });
331
+ }
332
+ return stats;
333
+ }
334
+
335
+ // ---------------------------------------------------------------------------
336
+ // Model tier ladder
337
+ // ---------------------------------------------------------------------------
338
+
339
+ /** Three Claude tiers, ordered `haiku < sonnet < opus`. */
340
+ const TIER = { haiku: 0, sonnet: 1, opus: 2 } as const;
341
+
342
+ /** A model's tier index, or `null` for an unrecognized (untierable) model. */
343
+ export type Tier = (typeof TIER)[keyof typeof TIER];
344
+
345
+ /** A single hardcoded representative model id per tier, used as the fallback
346
+ * step-up target when the user has no known model at the next tier. */
347
+ const TIER_REPRESENTATIVE: Record<Tier, string> = {
348
+ [TIER.haiku]: "claude-haiku-4-5",
349
+ [TIER.sonnet]: "claude-sonnet-4-6",
350
+ [TIER.opus]: "claude-opus-4-7",
351
+ };
352
+
353
+ /**
354
+ * Classify a model id into a tier by substring match — deliberate so future
355
+ * point releases (`claude-sonnet-4-7`, etc.) classify with no code change.
356
+ * Returns `null` for any id that is not recognizably haiku/sonnet/opus.
357
+ */
358
+ export function tierOf(modelId: string): Tier | null {
359
+ if (typeof modelId !== "string") return null;
360
+ const id = modelId.toLowerCase();
361
+ if (id.includes("haiku")) return TIER.haiku;
362
+ if (id.includes("sonnet")) return TIER.sonnet;
363
+ if (id.includes("opus")) return TIER.opus;
364
+ return null;
365
+ }
366
+
367
+ /**
368
+ * Step a model up exactly one tier.
369
+ *
370
+ * Among `knownModels` (the models the user already uses anywhere in their
371
+ * `models` map) it picks one whose tier is exactly `tierOf(model) + 1`,
372
+ * preferring deterministically the smallest id when several qualify. If the
373
+ * user has no known model at the next tier, it falls back to the single
374
+ * hardcoded representative for that tier. Never returns an arbitrary id, and
375
+ * never steps more than one tier.
376
+ *
377
+ * Returns `null` when `model` is already at `opus` (no higher tier) or is
378
+ * untierable (an unrecognized model id).
379
+ */
380
+ export function stepUp(model: string, knownModels: readonly string[]): string | null {
381
+ const tier = tierOf(model);
382
+ if (tier === null) return null;
383
+ if (tier === TIER.opus) return null;
384
+
385
+ const nextTier = (tier + 1) as Tier;
386
+
387
+ const candidates = knownModels
388
+ .filter((m) => typeof m === "string" && tierOf(m) === nextTier)
389
+ .sort();
390
+ if (candidates.length > 0) return candidates[0];
391
+
392
+ return TIER_REPRESENTATIVE[nextTier];
393
+ }
394
+
395
+ // ---------------------------------------------------------------------------
396
+ // Adjustment rules
397
+ // ---------------------------------------------------------------------------
398
+
399
+ /** Below this many samples a `(phase, model)` pair is ignored — too little
400
+ * evidence to act on (AC 5.1). */
401
+ export const MIN_SAMPLES = 5;
402
+
403
+ /** Pass-rate at or under this value marks a phase as under-performing. */
404
+ export const LOW_PASS_RATE = 0.6;
405
+
406
+ /** Average rounds-to-pass over this value marks a phase as struggling. */
407
+ export const HIGH_AVG_ROUNDS = 2.5;
408
+
409
+ /** Pass-rate at or over this value marks a phase as reliable — stay put. */
410
+ export const RELIABLE_PASS_RATE = 0.85;
411
+
412
+ /** Below this many `v:2` records a phase is left dormant — too little
413
+ * attributed evidence to act on (Story 7). Reuses v1's `MIN_SAMPLES` value. */
414
+ export const MIN_V2_SAMPLES = 5;
415
+
416
+ /** Mean `corregir`/run strictly above this value blames the `build` model. */
417
+ export const HIGH_AVG_CORREGIR = 1.0;
418
+
419
+ /** Mean `replantear`/run strictly above this value blames the `plan` model.
420
+ * Lower than `HIGH_AVG_CORREGIR` because a `replantear` is rarer and costlier. */
421
+ export const HIGH_AVG_REPLANTEAR = 0.5;
422
+
423
+ /**
424
+ * A proposed change of model for one SDD phase. `reason` is a human-readable
425
+ * string used verbatim in notifications.
426
+ */
427
+ export interface Adjustment {
428
+ /** SDD phase the change applies to. */
429
+ phase: (typeof RECORD_PHASES)[number];
430
+ /** Model the phase currently runs on. */
431
+ from: string;
432
+ /** Model the phase is proposed to move to (always one tier above `from`). */
433
+ to: string;
434
+ /** Human-readable justification, e.g. `"pass-rate 0.40 over 7 runs"`. */
435
+ reason: string;
436
+ }
437
+
438
+ /** The phases v2 autotune can attribute blame to and adjust. `explore` and
439
+ * `veredicto` are structurally excluded — no round verdict ever blames them. */
440
+ const ATTRIBUTABLE_PHASES = ["build", "plan"] as const;
441
+
442
+ /**
443
+ * Decide model adjustments from aggregated statistics — v2 phase-attributed.
444
+ *
445
+ * Only the `build` and `plan` phases are considered: a `corregir` verdict
446
+ * re-runs build and a `replantear` verdict re-runs plan, so those are the only
447
+ * phases the verdict sequence can blame. `explore` and `veredicto` are
448
+ * structurally never iterated and so never adjusted (Story 6).
449
+ *
450
+ * For each attributable phase, looks up the `(phase, currentModel)` stat and:
451
+ * - skips an absent stat;
452
+ * - skips a phase with `v2Samples < MIN_V2_SAMPLES` — too little attributed
453
+ * evidence (the dormancy gate, Story 7);
454
+ * - reads the phase's blame measure (`build` → `avgCorregir` vs
455
+ * `HIGH_AVG_CORREGIR`, `plan` → `avgReplantear` vs `HIGH_AVG_REPLANTEAR`)
456
+ * and skips when it is `null` or not strictly above its threshold;
457
+ * - otherwise proposes one tier up via `stepUp`.
458
+ *
459
+ * Safety caps are baked in: a proposal is emitted only when `stepUp` returns a
460
+ * model (so a phase already at `opus` or on an untierable model is left
461
+ * alone), `stepUp` never jumps more than one tier, and it never returns a
462
+ * model outside `knownModels`-or-the-fixed-representative set. v1's
463
+ * `LOW_PASS_RATE`/`HIGH_AVG_ROUNDS`/`RELIABLE_PASS_RATE` constants remain
464
+ * exported but no longer drive this function.
465
+ */
466
+ export function decideAdjustments(
467
+ stats: Map<string, PhaseModelStat>,
468
+ currentModels: Partial<Record<(typeof RECORD_PHASES)[number], string>>,
469
+ knownModels: readonly string[],
470
+ ): Adjustment[] {
471
+ const adjustments: Adjustment[] = [];
472
+
473
+ for (const phase of ATTRIBUTABLE_PHASES) {
474
+ const currentModel = currentModels[phase];
475
+ if (typeof currentModel !== "string" || currentModel === "") continue;
476
+
477
+ const stat = stats.get(statKey(phase, currentModel));
478
+ if (stat === undefined) continue;
479
+
480
+ // Dormancy gate: too few v2 records to attribute blame.
481
+ if (stat.v2Samples < MIN_V2_SAMPLES) continue;
482
+
483
+ const measure = phase === "build" ? stat.avgCorregir : stat.avgReplantear;
484
+ const threshold = phase === "build" ? HIGH_AVG_CORREGIR : HIGH_AVG_REPLANTEAR;
485
+ if (measure === null || !(measure > threshold)) continue;
486
+
487
+ const to = stepUp(currentModel, knownModels);
488
+ if (to === null) continue; // already at top tier, or untierable
489
+
490
+ const blame = phase === "build" ? "corregir" : "replantear";
491
+ const reason = `avg ${measure.toFixed(1)} ${blame}/run over ${stat.v2Samples} v2 runs`;
492
+
493
+ adjustments.push({ phase, from: currentModel, to, reason });
494
+ }
495
+
496
+ return adjustments;
497
+ }
498
+
499
+ // ---------------------------------------------------------------------------
500
+ // Autotune mode
501
+ // ---------------------------------------------------------------------------
502
+
503
+ /** The valid stored values of the `autotune` key, used for membership checks. */
504
+ const AUTOTUNE_MODES = ["auto", "ask", "off"] as const;
505
+
506
+ /**
507
+ * Read the `autotune` mode out of a parsed `~/.pi/zero.json` object.
508
+ *
509
+ * Returns the stored value only when it is exactly `"auto"`, `"ask"`, or
510
+ * `"off"`. A missing key, a non-string value, or any other string degrades to
511
+ * the safe default `"auto"` — never throws. This is the single point of truth
512
+ * for "absent ⇒ auto" (AC 3.2).
513
+ */
514
+ export function readAutotuneMode(data: Record<string, unknown>): AutotuneMode {
515
+ const value = data.autotune;
516
+ if (typeof value === "string" && (AUTOTUNE_MODES as readonly string[]).includes(value)) {
517
+ return value as AutotuneMode;
518
+ }
519
+ return "auto";
520
+ }