@gonrocca/zero-pi 0.1.12 → 0.1.13
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.
- package/LICENSE +21 -21
- package/README.md +296 -258
- package/extensions/autotune-extension.ts +250 -250
- package/extensions/autotune.ts +558 -520
- package/extensions/conversation-resume.ts +400 -399
- package/extensions/provider-guard-extension.ts +195 -195
- package/extensions/provider-guard.ts +183 -183
- package/extensions/spec-merge-extension.ts +286 -286
- package/extensions/spec-merge.ts +373 -373
- package/extensions/startup-banner.ts +237 -237
- package/extensions/working-phrases.ts +295 -295
- package/extensions/zero-models.ts +463 -333
- package/package.json +73 -73
- package/prompts/forge.md +34 -34
- package/prompts/orchestrator.md +292 -246
- package/prompts/phases/build.md +20 -20
- package/prompts/phases/explore.md +22 -22
- package/prompts/phases/plan.md +82 -82
- package/prompts/phases/veredicto.md +30 -30
- package/skills/{sdd-routing.md → sdd-routing/SKILL.md} +51 -51
- package/skills/{skill-loop.md → skill-loop/SKILL.md} +29 -29
- package/themes/zero-sdd.json +76 -76
package/extensions/autotune.ts
CHANGED
|
@@ -1,520 +1,558 @@
|
|
|
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
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
*
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
*/
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
for
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
*
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
*
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
*
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
* model
|
|
463
|
-
*
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
)
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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
|
+
* Drop duplicate run records by their `(feature, ts)` identity, keeping the
|
|
177
|
+
* FIRST occurrence in input order.
|
|
178
|
+
*
|
|
179
|
+
* The `~/.pi/zero-runs.jsonl` log is a local cache of a shared run log: a
|
|
180
|
+
* careless Cortex PULL can re-append a record already present (even a machine
|
|
181
|
+
* re-pulling its own push), so the same `(feature, ts)` may appear more than
|
|
182
|
+
* once. This pass collapses each identity to a single sample so `aggregate`
|
|
183
|
+
* never counts a run twice.
|
|
184
|
+
*
|
|
185
|
+
* The identity key is `` `${feature}\0${ts}` `` — a NUL separator, which can
|
|
186
|
+
* appear neither in a feature slug nor an ISO 8601 timestamp, so the two
|
|
187
|
+
* fields can never collide ambiguously (`feature:"a-b", ts:"c"` vs
|
|
188
|
+
* `feature:"a", ts:"b-c"` stay distinct). Identity is `(feature, ts)` alone,
|
|
189
|
+
* independent of `v`/`verdicts` — v1 and v2 records dedupe identically.
|
|
190
|
+
*
|
|
191
|
+
* Keeping the FIRST occurrence is deterministic and stable under append: the
|
|
192
|
+
* local PUSH writes a run's line before any later PULL can re-fetch it, so the
|
|
193
|
+
* first copy is the one closest to the origin; and appending more duplicates
|
|
194
|
+
* never changes which record survives. The kept set depends only on the
|
|
195
|
+
* identities present, not on the count or order of duplicates. Pure and never
|
|
196
|
+
* throws.
|
|
197
|
+
*/
|
|
198
|
+
export function dedupeRunRecords(records: RunRecord[]): RunRecord[] {
|
|
199
|
+
const seen = new Set<string>();
|
|
200
|
+
const out: RunRecord[] = [];
|
|
201
|
+
for (const record of records) {
|
|
202
|
+
const key = `${record.feature}\0${record.ts}`;
|
|
203
|
+
if (seen.has(key)) continue;
|
|
204
|
+
seen.add(key);
|
|
205
|
+
out.push(record);
|
|
206
|
+
}
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Read `~/.pi/zero-runs.jsonl` (or any path) into a list of valid `RunRecord`s.
|
|
212
|
+
*
|
|
213
|
+
* A missing or unreadable file yields `[]`. The file is split on `\n`; empty
|
|
214
|
+
* lines are skipped, and any line `parseRunLine` rejects (a malformed or
|
|
215
|
+
* half-written record) is dropped. The parsed records are then passed through
|
|
216
|
+
* `dedupeRunRecords`, so the returned array is already de-duplicated by
|
|
217
|
+
* `(feature, ts)` — the aggregation call site never sees a duplicate sample.
|
|
218
|
+
* Never throws.
|
|
219
|
+
*/
|
|
220
|
+
export function readRunRecords(path: string): RunRecord[] {
|
|
221
|
+
let contents: string;
|
|
222
|
+
try {
|
|
223
|
+
contents = readFileSync(path, "utf8");
|
|
224
|
+
} catch {
|
|
225
|
+
return [];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const records: RunRecord[] = [];
|
|
229
|
+
for (const line of contents.split("\n")) {
|
|
230
|
+
if (line.trim() === "") continue;
|
|
231
|
+
const record = parseRunLine(line);
|
|
232
|
+
if (record !== null) records.push(record);
|
|
233
|
+
}
|
|
234
|
+
return dedupeRunRecords(records);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
// Aggregation
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Aggregated outcome statistics for one `(phase, model)` pair.
|
|
243
|
+
*/
|
|
244
|
+
export interface PhaseModelStat {
|
|
245
|
+
/** SDD phase this stat is for. */
|
|
246
|
+
phase: (typeof RECORD_PHASES)[number];
|
|
247
|
+
/** Model id this stat is for. */
|
|
248
|
+
model: string;
|
|
249
|
+
/** Total runs recorded for the pair. */
|
|
250
|
+
samples: number;
|
|
251
|
+
/** Fraction of runs that reached `pasa` — `0` when `samples` is `0`. */
|
|
252
|
+
passRate: number;
|
|
253
|
+
/** Mean `rounds` averaged ONLY over runs that reached `pasa`; `null` when no
|
|
254
|
+
* `pasa` run exists for the pair. */
|
|
255
|
+
avgRounds: number | null;
|
|
256
|
+
// --- v2 phase attribution ---
|
|
257
|
+
/** Count of `v:2` records contributing to this pair — the dormancy-gate
|
|
258
|
+
* denominator. v1 records do not increment this. */
|
|
259
|
+
v2Samples: number;
|
|
260
|
+
/** Mean count of `corregir` verdicts per v2 run for this pair — only
|
|
261
|
+
* meaningful for `phase === "build"`. `null` when `v2Samples === 0`. */
|
|
262
|
+
avgCorregir: number | null;
|
|
263
|
+
/** Mean count of `replantear` verdicts per v2 run for this pair — only
|
|
264
|
+
* meaningful for `phase === "plan"`. `null` when `v2Samples === 0`. */
|
|
265
|
+
avgReplantear: number | null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Map key for a `(phase, model)` bucket. */
|
|
269
|
+
function statKey(phase: string, model: string): string {
|
|
270
|
+
return `${phase} ${model}`;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Aggregate run records into per-`(phase, model)` statistics.
|
|
275
|
+
*
|
|
276
|
+
* For every record (v1 and v2 alike), each of the four phases contributes one
|
|
277
|
+
* sample to the bucket of the model that phase ran on (a phase whose model is
|
|
278
|
+
* missing or non-string is skipped). `avgRounds` is averaged exclusively over
|
|
279
|
+
* runs that reached `pasa`; non-positive `rounds` values (which `parseRunLine`
|
|
280
|
+
* does not reject) are ignored when computing that average so a bad
|
|
281
|
+
* `0`/negative entry never skews or breaks the math.
|
|
282
|
+
*
|
|
283
|
+
* Only records carrying a `verdicts` sequence (v2) contribute phase
|
|
284
|
+
* attribution: each such record increments `v2Samples` for every phase, and
|
|
285
|
+
* folds its per-run `corregir` count into the `build` model's `avgCorregir`
|
|
286
|
+
* accumulator and its per-run `replantear` count into the `plan` model's
|
|
287
|
+
* `avgReplantear` accumulator. `avgCorregir`/`avgReplantear` are `null` when a
|
|
288
|
+
* pair has no v2 evidence. An empty input yields an empty map.
|
|
289
|
+
*/
|
|
290
|
+
export function aggregate(records: RunRecord[]): Map<string, PhaseModelStat> {
|
|
291
|
+
interface Acc {
|
|
292
|
+
samples: number;
|
|
293
|
+
passes: number;
|
|
294
|
+
passRounds: number;
|
|
295
|
+
passRoundCount: number;
|
|
296
|
+
v2Samples: number;
|
|
297
|
+
corregirTotal: number;
|
|
298
|
+
replantearTotal: number;
|
|
299
|
+
}
|
|
300
|
+
const acc = new Map<string, { phase: (typeof RECORD_PHASES)[number]; model: string; data: Acc }>();
|
|
301
|
+
|
|
302
|
+
for (const record of records) {
|
|
303
|
+
const isPass = record.verdict === "pasa";
|
|
304
|
+
const isV2 = record.verdicts !== undefined;
|
|
305
|
+
const corregirCount = isV2
|
|
306
|
+
? (record.verdicts as RunRoundVerdict[]).filter((v) => v === "corregir").length
|
|
307
|
+
: 0;
|
|
308
|
+
const replantearCount = isV2
|
|
309
|
+
? (record.verdicts as RunRoundVerdict[]).filter((v) => v === "replantear").length
|
|
310
|
+
: 0;
|
|
311
|
+
|
|
312
|
+
for (const phase of RECORD_PHASES) {
|
|
313
|
+
const phaseRun = record.phases[phase];
|
|
314
|
+
const model = phaseRun?.model;
|
|
315
|
+
if (typeof model !== "string" || model === "") continue;
|
|
316
|
+
|
|
317
|
+
const key = statKey(phase, model);
|
|
318
|
+
let bucket = acc.get(key);
|
|
319
|
+
if (bucket === undefined) {
|
|
320
|
+
bucket = {
|
|
321
|
+
phase,
|
|
322
|
+
model,
|
|
323
|
+
data: {
|
|
324
|
+
samples: 0,
|
|
325
|
+
passes: 0,
|
|
326
|
+
passRounds: 0,
|
|
327
|
+
passRoundCount: 0,
|
|
328
|
+
v2Samples: 0,
|
|
329
|
+
corregirTotal: 0,
|
|
330
|
+
replantearTotal: 0,
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
acc.set(key, bucket);
|
|
334
|
+
}
|
|
335
|
+
bucket.data.samples += 1;
|
|
336
|
+
if (isPass) {
|
|
337
|
+
bucket.data.passes += 1;
|
|
338
|
+
// Guard against non-positive `rounds`: a clean run is `>= 1`, so a
|
|
339
|
+
// `0`/negative value is malformed and must not enter the average.
|
|
340
|
+
if (typeof record.rounds === "number" && record.rounds > 0) {
|
|
341
|
+
bucket.data.passRounds += record.rounds;
|
|
342
|
+
bucket.data.passRoundCount += 1;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
if (isV2) {
|
|
346
|
+
bucket.data.v2Samples += 1;
|
|
347
|
+
// `corregir` blames the build phase's model, `replantear` the plan
|
|
348
|
+
// phase's. The mapping is the pipeline contract, fixed and known.
|
|
349
|
+
if (phase === "build") bucket.data.corregirTotal += corregirCount;
|
|
350
|
+
if (phase === "plan") bucket.data.replantearTotal += replantearCount;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const stats = new Map<string, PhaseModelStat>();
|
|
356
|
+
for (const [key, bucket] of acc) {
|
|
357
|
+
const { samples, passes, passRounds, passRoundCount, v2Samples, corregirTotal, replantearTotal } =
|
|
358
|
+
bucket.data;
|
|
359
|
+
stats.set(key, {
|
|
360
|
+
phase: bucket.phase,
|
|
361
|
+
model: bucket.model,
|
|
362
|
+
samples,
|
|
363
|
+
passRate: samples > 0 ? passes / samples : 0,
|
|
364
|
+
avgRounds: passRoundCount > 0 ? passRounds / passRoundCount : null,
|
|
365
|
+
v2Samples,
|
|
366
|
+
avgCorregir: v2Samples > 0 ? corregirTotal / v2Samples : null,
|
|
367
|
+
avgReplantear: v2Samples > 0 ? replantearTotal / v2Samples : null,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
return stats;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ---------------------------------------------------------------------------
|
|
374
|
+
// Model tier ladder
|
|
375
|
+
// ---------------------------------------------------------------------------
|
|
376
|
+
|
|
377
|
+
/** Three Claude tiers, ordered `haiku < sonnet < opus`. */
|
|
378
|
+
const TIER = { haiku: 0, sonnet: 1, opus: 2 } as const;
|
|
379
|
+
|
|
380
|
+
/** A model's tier index, or `null` for an unrecognized (untierable) model. */
|
|
381
|
+
export type Tier = (typeof TIER)[keyof typeof TIER];
|
|
382
|
+
|
|
383
|
+
/** A single hardcoded representative model id per tier, used as the fallback
|
|
384
|
+
* step-up target when the user has no known model at the next tier. */
|
|
385
|
+
const TIER_REPRESENTATIVE: Record<Tier, string> = {
|
|
386
|
+
[TIER.haiku]: "claude-haiku-4-5",
|
|
387
|
+
[TIER.sonnet]: "claude-sonnet-4-6",
|
|
388
|
+
[TIER.opus]: "claude-opus-4-7",
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Classify a model id into a tier by substring match — deliberate so future
|
|
393
|
+
* point releases (`claude-sonnet-4-7`, etc.) classify with no code change.
|
|
394
|
+
* Returns `null` for any id that is not recognizably haiku/sonnet/opus.
|
|
395
|
+
*/
|
|
396
|
+
export function tierOf(modelId: string): Tier | null {
|
|
397
|
+
if (typeof modelId !== "string") return null;
|
|
398
|
+
const id = modelId.toLowerCase();
|
|
399
|
+
if (id.includes("haiku")) return TIER.haiku;
|
|
400
|
+
if (id.includes("sonnet")) return TIER.sonnet;
|
|
401
|
+
if (id.includes("opus")) return TIER.opus;
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Step a model up exactly one tier.
|
|
407
|
+
*
|
|
408
|
+
* Among `knownModels` (the models the user already uses anywhere in their
|
|
409
|
+
* `models` map) it picks one whose tier is exactly `tierOf(model) + 1`,
|
|
410
|
+
* preferring deterministically the smallest id when several qualify. If the
|
|
411
|
+
* user has no known model at the next tier, it falls back to the single
|
|
412
|
+
* hardcoded representative for that tier. Never returns an arbitrary id, and
|
|
413
|
+
* never steps more than one tier.
|
|
414
|
+
*
|
|
415
|
+
* Returns `null` when `model` is already at `opus` (no higher tier) or is
|
|
416
|
+
* untierable (an unrecognized model id).
|
|
417
|
+
*/
|
|
418
|
+
export function stepUp(model: string, knownModels: readonly string[]): string | null {
|
|
419
|
+
const tier = tierOf(model);
|
|
420
|
+
if (tier === null) return null;
|
|
421
|
+
if (tier === TIER.opus) return null;
|
|
422
|
+
|
|
423
|
+
const nextTier = (tier + 1) as Tier;
|
|
424
|
+
|
|
425
|
+
const candidates = knownModels
|
|
426
|
+
.filter((m) => typeof m === "string" && tierOf(m) === nextTier)
|
|
427
|
+
.sort();
|
|
428
|
+
if (candidates.length > 0) return candidates[0];
|
|
429
|
+
|
|
430
|
+
return TIER_REPRESENTATIVE[nextTier];
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ---------------------------------------------------------------------------
|
|
434
|
+
// Adjustment rules
|
|
435
|
+
// ---------------------------------------------------------------------------
|
|
436
|
+
|
|
437
|
+
/** Below this many samples a `(phase, model)` pair is ignored — too little
|
|
438
|
+
* evidence to act on (AC 5.1). */
|
|
439
|
+
export const MIN_SAMPLES = 5;
|
|
440
|
+
|
|
441
|
+
/** Pass-rate at or under this value marks a phase as under-performing. */
|
|
442
|
+
export const LOW_PASS_RATE = 0.6;
|
|
443
|
+
|
|
444
|
+
/** Average rounds-to-pass over this value marks a phase as struggling. */
|
|
445
|
+
export const HIGH_AVG_ROUNDS = 2.5;
|
|
446
|
+
|
|
447
|
+
/** Pass-rate at or over this value marks a phase as reliable — stay put. */
|
|
448
|
+
export const RELIABLE_PASS_RATE = 0.85;
|
|
449
|
+
|
|
450
|
+
/** Below this many `v:2` records a phase is left dormant — too little
|
|
451
|
+
* attributed evidence to act on (Story 7). Reuses v1's `MIN_SAMPLES` value. */
|
|
452
|
+
export const MIN_V2_SAMPLES = 5;
|
|
453
|
+
|
|
454
|
+
/** Mean `corregir`/run strictly above this value blames the `build` model. */
|
|
455
|
+
export const HIGH_AVG_CORREGIR = 1.0;
|
|
456
|
+
|
|
457
|
+
/** Mean `replantear`/run strictly above this value blames the `plan` model.
|
|
458
|
+
* Lower than `HIGH_AVG_CORREGIR` because a `replantear` is rarer and costlier. */
|
|
459
|
+
export const HIGH_AVG_REPLANTEAR = 0.5;
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* A proposed change of model for one SDD phase. `reason` is a human-readable
|
|
463
|
+
* string used verbatim in notifications.
|
|
464
|
+
*/
|
|
465
|
+
export interface Adjustment {
|
|
466
|
+
/** SDD phase the change applies to. */
|
|
467
|
+
phase: (typeof RECORD_PHASES)[number];
|
|
468
|
+
/** Model the phase currently runs on. */
|
|
469
|
+
from: string;
|
|
470
|
+
/** Model the phase is proposed to move to (always one tier above `from`). */
|
|
471
|
+
to: string;
|
|
472
|
+
/** Human-readable justification, e.g. `"pass-rate 0.40 over 7 runs"`. */
|
|
473
|
+
reason: string;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** The phases v2 autotune can attribute blame to and adjust. `explore` and
|
|
477
|
+
* `veredicto` are structurally excluded — no round verdict ever blames them. */
|
|
478
|
+
const ATTRIBUTABLE_PHASES = ["build", "plan"] as const;
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Decide model adjustments from aggregated statistics — v2 phase-attributed.
|
|
482
|
+
*
|
|
483
|
+
* Only the `build` and `plan` phases are considered: a `corregir` verdict
|
|
484
|
+
* re-runs build and a `replantear` verdict re-runs plan, so those are the only
|
|
485
|
+
* phases the verdict sequence can blame. `explore` and `veredicto` are
|
|
486
|
+
* structurally never iterated and so never adjusted (Story 6).
|
|
487
|
+
*
|
|
488
|
+
* For each attributable phase, looks up the `(phase, currentModel)` stat and:
|
|
489
|
+
* - skips an absent stat;
|
|
490
|
+
* - skips a phase with `v2Samples < MIN_V2_SAMPLES` — too little attributed
|
|
491
|
+
* evidence (the dormancy gate, Story 7);
|
|
492
|
+
* - reads the phase's blame measure (`build` → `avgCorregir` vs
|
|
493
|
+
* `HIGH_AVG_CORREGIR`, `plan` → `avgReplantear` vs `HIGH_AVG_REPLANTEAR`)
|
|
494
|
+
* and skips when it is `null` or not strictly above its threshold;
|
|
495
|
+
* - otherwise proposes one tier up via `stepUp`.
|
|
496
|
+
*
|
|
497
|
+
* Safety caps are baked in: a proposal is emitted only when `stepUp` returns a
|
|
498
|
+
* model (so a phase already at `opus` or on an untierable model is left
|
|
499
|
+
* alone), `stepUp` never jumps more than one tier, and it never returns a
|
|
500
|
+
* model outside `knownModels`-or-the-fixed-representative set. v1's
|
|
501
|
+
* `LOW_PASS_RATE`/`HIGH_AVG_ROUNDS`/`RELIABLE_PASS_RATE` constants remain
|
|
502
|
+
* exported but no longer drive this function.
|
|
503
|
+
*/
|
|
504
|
+
export function decideAdjustments(
|
|
505
|
+
stats: Map<string, PhaseModelStat>,
|
|
506
|
+
currentModels: Partial<Record<(typeof RECORD_PHASES)[number], string>>,
|
|
507
|
+
knownModels: readonly string[],
|
|
508
|
+
): Adjustment[] {
|
|
509
|
+
const adjustments: Adjustment[] = [];
|
|
510
|
+
|
|
511
|
+
for (const phase of ATTRIBUTABLE_PHASES) {
|
|
512
|
+
const currentModel = currentModels[phase];
|
|
513
|
+
if (typeof currentModel !== "string" || currentModel === "") continue;
|
|
514
|
+
|
|
515
|
+
const stat = stats.get(statKey(phase, currentModel));
|
|
516
|
+
if (stat === undefined) continue;
|
|
517
|
+
|
|
518
|
+
// Dormancy gate: too few v2 records to attribute blame.
|
|
519
|
+
if (stat.v2Samples < MIN_V2_SAMPLES) continue;
|
|
520
|
+
|
|
521
|
+
const measure = phase === "build" ? stat.avgCorregir : stat.avgReplantear;
|
|
522
|
+
const threshold = phase === "build" ? HIGH_AVG_CORREGIR : HIGH_AVG_REPLANTEAR;
|
|
523
|
+
if (measure === null || !(measure > threshold)) continue;
|
|
524
|
+
|
|
525
|
+
const to = stepUp(currentModel, knownModels);
|
|
526
|
+
if (to === null) continue; // already at top tier, or untierable
|
|
527
|
+
|
|
528
|
+
const blame = phase === "build" ? "corregir" : "replantear";
|
|
529
|
+
const reason = `avg ${measure.toFixed(1)} ${blame}/run over ${stat.v2Samples} v2 runs`;
|
|
530
|
+
|
|
531
|
+
adjustments.push({ phase, from: currentModel, to, reason });
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
return adjustments;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// ---------------------------------------------------------------------------
|
|
538
|
+
// Autotune mode
|
|
539
|
+
// ---------------------------------------------------------------------------
|
|
540
|
+
|
|
541
|
+
/** The valid stored values of the `autotune` key, used for membership checks. */
|
|
542
|
+
const AUTOTUNE_MODES = ["auto", "ask", "off"] as const;
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Read the `autotune` mode out of a parsed `~/.pi/zero.json` object.
|
|
546
|
+
*
|
|
547
|
+
* Returns the stored value only when it is exactly `"auto"`, `"ask"`, or
|
|
548
|
+
* `"off"`. A missing key, a non-string value, or any other string degrades to
|
|
549
|
+
* the safe default `"auto"` — never throws. This is the single point of truth
|
|
550
|
+
* for "absent ⇒ auto" (AC 3.2).
|
|
551
|
+
*/
|
|
552
|
+
export function readAutotuneMode(data: Record<string, unknown>): AutotuneMode {
|
|
553
|
+
const value = data.autotune;
|
|
554
|
+
if (typeof value === "string" && (AUTOTUNE_MODES as readonly string[]).includes(value)) {
|
|
555
|
+
return value as AutotuneMode;
|
|
556
|
+
}
|
|
557
|
+
return "auto";
|
|
558
|
+
}
|