@gonrocca/zero-pi 0.1.9 → 0.1.11
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/README.md +97 -10
- package/extensions/autotune.ts +177 -42
- package/extensions/conversation-resume.ts +399 -0
- package/extensions/provider-guard-extension.ts +195 -0
- package/extensions/provider-guard.ts +183 -0
- package/extensions/startup-banner.ts +120 -151
- package/extensions/working-phrases.ts +295 -0
- package/package.json +13 -2
- package/prompts/orchestrator.md +9 -2
- package/themes/zero-sdd.json +76 -0
package/README.md
CHANGED
|
@@ -134,14 +134,24 @@ re-tune `~/.pi/zero.json` for you.
|
|
|
134
134
|
|
|
135
135
|
**The metrics log — `~/.pi/zero-runs.jsonl`.** Every completed SDD run appends
|
|
136
136
|
one JSON line to this file: the feature slug, the per-phase models the run used,
|
|
137
|
-
the final verdict,
|
|
138
|
-
never rewritten. This local log is the
|
|
139
|
-
abandoned before it reaches a verdict adds
|
|
137
|
+
the final verdict, the build/veredicto round count, and the ordered per-round
|
|
138
|
+
verdict sequence. It is append-only and never rewritten. This local log is the
|
|
139
|
+
only thing zero learns from — a run abandoned before it reaches a verdict adds
|
|
140
|
+
no line.
|
|
141
|
+
|
|
142
|
+
**Phase attribution (v2).** The per-round verdict sequence makes blame precise:
|
|
143
|
+
a `corregir` round re-runs — and so blames — the `build` phase, and a
|
|
144
|
+
`replantear` round blames the `plan` phase. Autotune aggregates that sequence
|
|
145
|
+
per phase and upgrades **only the phase at fault**, one tier at a time — a
|
|
146
|
+
`build` problem no longer drags `plan` up with it. The `explore` and `veredicto`
|
|
147
|
+
phases are never tuned.
|
|
140
148
|
|
|
141
149
|
**Autotune modes.** At each pi session start zero aggregates the log and, once a
|
|
142
150
|
phase has accumulated enough run data to cross a confidence threshold, decides
|
|
143
|
-
whether that phase's model should change.
|
|
144
|
-
|
|
151
|
+
whether that phase's model should change. Until a phase has accumulated enough
|
|
152
|
+
v2 runs of its own, autotune deliberately stays quiet — a one-time, silent
|
|
153
|
+
cold-start after upgrading, not a regression. The `autotune` mode in
|
|
154
|
+
`~/.pi/zero.json` controls what happens next:
|
|
145
155
|
|
|
146
156
|
| Mode | Behaviour |
|
|
147
157
|
| ---- | --------- |
|
|
@@ -171,26 +181,103 @@ the most recent recommendation.
|
|
|
171
181
|
`skill-loop.md` gives the agent a closed learning loop so solutions are reused
|
|
172
182
|
instead of re-derived.
|
|
173
183
|
|
|
184
|
+
### ZERO terminal theme (`themes/zero-sdd.json`)
|
|
185
|
+
|
|
186
|
+
`zero-pi` ships a Pi theme named `zero-sdd`: a dark, high-contrast terminal
|
|
187
|
+
palette with cyan, amber, mint, rose, and violet accents tuned for SDD work.
|
|
188
|
+
Select it from `/settings`, or set `"theme": "zero-sdd"` in Pi settings.
|
|
189
|
+
|
|
174
190
|
### Startup banner (`extensions/startup-banner.ts`)
|
|
175
191
|
|
|
176
|
-
Renders the `ZERO` wordmark
|
|
177
|
-
|
|
178
|
-
|
|
192
|
+
Renders the `ZERO` wordmark as ASCII-safe Tetris cells. In the default animated
|
|
193
|
+
mode, the cells assemble from the bottom up when a pi session starts, then
|
|
194
|
+
settle into the completed ZERO banner. Pure ANSI 24-bit colour, no runtime
|
|
195
|
+
dependencies.
|
|
179
196
|
|
|
180
197
|
The render runs synchronously before pi draws its UI, so the animation never
|
|
181
198
|
fights pi's renderer.
|
|
182
199
|
|
|
200
|
+
### Working-phrase ticker (`extensions/working-phrases.ts`)
|
|
201
|
+
|
|
202
|
+
Pi shows a single static `Working...` line while the agent is busy. This
|
|
203
|
+
extension replaces it with a context-aware, rotating phrase:
|
|
204
|
+
|
|
205
|
+
- **While a tool runs** — a tool-specific line: `Leyendo archivos…`,
|
|
206
|
+
`Ejecutando comandos…`, `Buscando en el código…`. MCP tools are named by
|
|
207
|
+
server (`Consultando un MCP…`).
|
|
208
|
+
- **While a zero sub-agent runs** — the SDD phase it owns:
|
|
209
|
+
`Explorando el código…`, `Planeando la solución…`,
|
|
210
|
+
`Construyendo la implementación…`, `Revisando el veredicto…`.
|
|
211
|
+
- **While the model thinks** — a rotation of playful verbs (`Maquinando…`,
|
|
212
|
+
`Rumiando…`, `Cocinando…`). Once a `/forge` run is detected the rotation
|
|
213
|
+
biases toward SDD vocabulary.
|
|
214
|
+
|
|
215
|
+
It also installs a theme-tinted braille spinner that gently breathes through
|
|
216
|
+
the palette's `dim`/`muted`/`accent` colours. The extension uses only pi's
|
|
217
|
+
public extension API, and every handler is defensive — the indicator can never
|
|
218
|
+
break a session.
|
|
219
|
+
|
|
183
220
|
Control it with the `ZERO_BANNER` environment variable:
|
|
184
221
|
|
|
185
222
|
| `ZERO_BANNER` | Effect |
|
|
186
223
|
| ------------- | ------ |
|
|
187
|
-
| _(unset)_ / `shimmer` | Animated
|
|
188
|
-
| `static` |
|
|
224
|
+
| _(unset)_ / `shimmer` | Animated Tetris assembly, then settle (default) |
|
|
225
|
+
| `static` | Completed Tetris banner only, no animation |
|
|
189
226
|
| `off` | Render nothing |
|
|
190
227
|
|
|
191
228
|
Colour is skipped automatically when `NO_COLOR` is set or the output is not a
|
|
192
229
|
TTY.
|
|
193
230
|
|
|
231
|
+
### Provider guard (`extensions/provider-guard.ts`)
|
|
232
|
+
|
|
233
|
+
zero-pi watches model switches and steps in when you move to a metered
|
|
234
|
+
provider. When you switch to a model on the `anthropic` provider — which bills
|
|
235
|
+
per token and draws down your metered extra-usage pool — the guard offers to
|
|
236
|
+
redirect you to the equivalent model on `pi-claude-cli`, the provider backed by
|
|
237
|
+
your subscription's limits.
|
|
238
|
+
|
|
239
|
+
The redirect is offered through a confirmation dialog, and that dialog is your
|
|
240
|
+
escape hatch: say "yes" and the guard switches you to the subscription-backed
|
|
241
|
+
equivalent; say "no" (or cancel) and you stay on the metered `anthropic`
|
|
242
|
+
provider with no further nagging. When there is no equivalent model on
|
|
243
|
+
`pi-claude-cli`, and when a model is restored on session start rather than
|
|
244
|
+
switched deliberately, the guard skips the modal and just shows a one-line
|
|
245
|
+
warning. Switching to a subscription provider is a complete no-op — no dialog,
|
|
246
|
+
no notification, no noise.
|
|
247
|
+
|
|
248
|
+
### Conversation resume (`extensions/conversation-resume.ts`)
|
|
249
|
+
|
|
250
|
+
When pi exits normally, zero-pi writes a local handoff note:
|
|
251
|
+
|
|
252
|
+
```
|
|
253
|
+
.pi/zero-resume.md
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
The note includes the exact restore command for the persisted pi session:
|
|
257
|
+
|
|
258
|
+
```bash
|
|
259
|
+
pi --session '<session-file>'
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
When a session id is available it also writes the shorter form:
|
|
263
|
+
|
|
264
|
+
```bash
|
|
265
|
+
pi --session <session-id>
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
`pi --resume` is listed too for the interactive session picker. The file also
|
|
269
|
+
keeps a concise conversation tail, enough to remember the last user intent and
|
|
270
|
+
assistant progress without replacing pi's real session history.
|
|
271
|
+
|
|
272
|
+
Generate it manually at any time:
|
|
273
|
+
|
|
274
|
+
```text
|
|
275
|
+
/zero-resume
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
The resume lives under `.pi/`, and zero-pi creates `.pi/.gitignore` so local
|
|
279
|
+
conversation context is not accidentally committed.
|
|
280
|
+
|
|
194
281
|
## Relationship to `zero`
|
|
195
282
|
|
|
196
283
|
`zero-pi` is the pi-specific layer of the **[zero](https://github.com/gonzalonicolasr/zero)**
|
package/extensions/autotune.ts
CHANGED
|
@@ -12,8 +12,9 @@
|
|
|
12
12
|
import { readFileSync } from "node:fs";
|
|
13
13
|
|
|
14
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.
|
|
16
|
-
|
|
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;
|
|
17
18
|
|
|
18
19
|
/** The SDD phases a run record carries a model for, in pipeline order. */
|
|
19
20
|
const RECORD_PHASES = ["explore", "plan", "build", "veredicto"] as const;
|
|
@@ -25,6 +26,14 @@ const VERDICTS = ["pasa", "cap-reached"] as const;
|
|
|
25
26
|
/** A run's terminal verdict. */
|
|
26
27
|
export type RunVerdict = (typeof VERDICTS)[number];
|
|
27
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
|
+
|
|
28
37
|
/** The model a single phase ran on for a given run. An object (not a bare
|
|
29
38
|
* string) so the schema can grow additive fields without a version bump. */
|
|
30
39
|
export interface PhaseRun {
|
|
@@ -48,6 +57,11 @@ export interface RunRecord {
|
|
|
48
57
|
verdict: RunVerdict;
|
|
49
58
|
/** Count of build/veredicto rounds — `1` for a clean first-pass run. */
|
|
50
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[];
|
|
51
65
|
}
|
|
52
66
|
|
|
53
67
|
/** How aggressively zero applies a learned profile — stored in `zero.json`.
|
|
@@ -60,15 +74,56 @@ function isObject(value: unknown): value is Record<string, unknown> {
|
|
|
60
74
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
61
75
|
}
|
|
62
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
|
+
|
|
63
115
|
/**
|
|
64
116
|
* Parse one JSONL line into a `RunRecord`, validating the shape.
|
|
65
117
|
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
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.
|
|
72
127
|
*/
|
|
73
128
|
export function parseRunLine(line: string): RunRecord | null {
|
|
74
129
|
let parsed: unknown;
|
|
@@ -80,12 +135,13 @@ export function parseRunLine(line: string): RunRecord | null {
|
|
|
80
135
|
|
|
81
136
|
if (!isObject(parsed)) return null;
|
|
82
137
|
|
|
83
|
-
if (parsed.v !==
|
|
138
|
+
if (parsed.v !== 1 && parsed.v !== 2) return null;
|
|
84
139
|
if (typeof parsed.ts !== "string" || parsed.ts === "") return null;
|
|
85
140
|
if (typeof parsed.feature !== "string" || parsed.feature === "") return null;
|
|
86
141
|
if (typeof parsed.rounds !== "number" || !Number.isInteger(parsed.rounds)) return null;
|
|
87
142
|
if (typeof parsed.verdict !== "string") return null;
|
|
88
143
|
if (!(VERDICTS as readonly string[]).includes(parsed.verdict)) return null;
|
|
144
|
+
const verdict = parsed.verdict as RunVerdict;
|
|
89
145
|
|
|
90
146
|
if (!isObject(parsed.phases)) return null;
|
|
91
147
|
const phases = {} as RunRecord["phases"];
|
|
@@ -96,13 +152,23 @@ export function parseRunLine(line: string): RunRecord | null {
|
|
|
96
152
|
phases[phase] = { model: phaseRun.model };
|
|
97
153
|
}
|
|
98
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
|
+
|
|
99
164
|
return {
|
|
100
|
-
v:
|
|
165
|
+
v: parsed.v,
|
|
101
166
|
ts: parsed.ts,
|
|
102
167
|
feature: parsed.feature,
|
|
103
168
|
phases,
|
|
104
|
-
verdict
|
|
169
|
+
verdict,
|
|
105
170
|
rounds: parsed.rounds,
|
|
171
|
+
...(verdicts !== undefined ? { verdicts } : {}),
|
|
106
172
|
};
|
|
107
173
|
}
|
|
108
174
|
|
|
@@ -149,6 +215,16 @@ export interface PhaseModelStat {
|
|
|
149
215
|
/** Mean `rounds` averaged ONLY over runs that reached `pasa`; `null` when no
|
|
150
216
|
* `pasa` run exists for the pair. */
|
|
151
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;
|
|
152
228
|
}
|
|
153
229
|
|
|
154
230
|
/** Map key for a `(phase, model)` bucket. */
|
|
@@ -159,12 +235,19 @@ function statKey(phase: string, model: string): string {
|
|
|
159
235
|
/**
|
|
160
236
|
* Aggregate run records into per-`(phase, model)` statistics.
|
|
161
237
|
*
|
|
162
|
-
* For every record, each of the four phases contributes one
|
|
163
|
-
* bucket of the model that phase ran on (a phase whose model is
|
|
164
|
-
* non-string is skipped). `avgRounds` is averaged exclusively over
|
|
165
|
-
* reached `pasa`; non-positive `rounds` values (which `parseRunLine`
|
|
166
|
-
* reject) are ignored when computing that average so a bad
|
|
167
|
-
* never skews or breaks the math.
|
|
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.
|
|
168
251
|
*/
|
|
169
252
|
export function aggregate(records: RunRecord[]): Map<string, PhaseModelStat> {
|
|
170
253
|
interface Acc {
|
|
@@ -172,11 +255,22 @@ export function aggregate(records: RunRecord[]): Map<string, PhaseModelStat> {
|
|
|
172
255
|
passes: number;
|
|
173
256
|
passRounds: number;
|
|
174
257
|
passRoundCount: number;
|
|
258
|
+
v2Samples: number;
|
|
259
|
+
corregirTotal: number;
|
|
260
|
+
replantearTotal: number;
|
|
175
261
|
}
|
|
176
262
|
const acc = new Map<string, { phase: (typeof RECORD_PHASES)[number]; model: string; data: Acc }>();
|
|
177
263
|
|
|
178
264
|
for (const record of records) {
|
|
179
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
|
+
|
|
180
274
|
for (const phase of RECORD_PHASES) {
|
|
181
275
|
const phaseRun = record.phases[phase];
|
|
182
276
|
const model = phaseRun?.model;
|
|
@@ -185,7 +279,19 @@ export function aggregate(records: RunRecord[]): Map<string, PhaseModelStat> {
|
|
|
185
279
|
const key = statKey(phase, model);
|
|
186
280
|
let bucket = acc.get(key);
|
|
187
281
|
if (bucket === undefined) {
|
|
188
|
-
bucket = {
|
|
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
|
+
};
|
|
189
295
|
acc.set(key, bucket);
|
|
190
296
|
}
|
|
191
297
|
bucket.data.samples += 1;
|
|
@@ -198,18 +304,29 @@ export function aggregate(records: RunRecord[]): Map<string, PhaseModelStat> {
|
|
|
198
304
|
bucket.data.passRoundCount += 1;
|
|
199
305
|
}
|
|
200
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
|
+
}
|
|
201
314
|
}
|
|
202
315
|
}
|
|
203
316
|
|
|
204
317
|
const stats = new Map<string, PhaseModelStat>();
|
|
205
318
|
for (const [key, bucket] of acc) {
|
|
206
|
-
const { samples, passes, passRounds, passRoundCount } =
|
|
319
|
+
const { samples, passes, passRounds, passRoundCount, v2Samples, corregirTotal, replantearTotal } =
|
|
320
|
+
bucket.data;
|
|
207
321
|
stats.set(key, {
|
|
208
322
|
phase: bucket.phase,
|
|
209
323
|
model: bucket.model,
|
|
210
324
|
samples,
|
|
211
325
|
passRate: samples > 0 ? passes / samples : 0,
|
|
212
326
|
avgRounds: passRoundCount > 0 ? passRounds / passRoundCount : null,
|
|
327
|
+
v2Samples,
|
|
328
|
+
avgCorregir: v2Samples > 0 ? corregirTotal / v2Samples : null,
|
|
329
|
+
avgReplantear: v2Samples > 0 ? replantearTotal / v2Samples : null,
|
|
213
330
|
});
|
|
214
331
|
}
|
|
215
332
|
return stats;
|
|
@@ -292,6 +409,17 @@ export const HIGH_AVG_ROUNDS = 2.5;
|
|
|
292
409
|
/** Pass-rate at or over this value marks a phase as reliable — stay put. */
|
|
293
410
|
export const RELIABLE_PASS_RATE = 0.85;
|
|
294
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
|
+
|
|
295
423
|
/**
|
|
296
424
|
* A proposed change of model for one SDD phase. `reason` is a human-readable
|
|
297
425
|
* string used verbatim in notifications.
|
|
@@ -307,22 +435,33 @@ export interface Adjustment {
|
|
|
307
435
|
reason: string;
|
|
308
436
|
}
|
|
309
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
|
+
|
|
310
442
|
/**
|
|
311
|
-
* Decide model adjustments from aggregated statistics.
|
|
443
|
+
* Decide model adjustments from aggregated statistics — v2 phase-attributed.
|
|
312
444
|
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
* -
|
|
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`.
|
|
321
458
|
*
|
|
322
459
|
* Safety caps are baked in: a proposal is emitted only when `stepUp` returns a
|
|
323
460
|
* model (so a phase already at `opus` or on an untierable model is left
|
|
324
461
|
* alone), `stepUp` never jumps more than one tier, and it never returns a
|
|
325
|
-
* model outside `knownModels`-or-the-fixed-representative set.
|
|
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.
|
|
326
465
|
*/
|
|
327
466
|
export function decideAdjustments(
|
|
328
467
|
stats: Map<string, PhaseModelStat>,
|
|
@@ -331,29 +470,25 @@ export function decideAdjustments(
|
|
|
331
470
|
): Adjustment[] {
|
|
332
471
|
const adjustments: Adjustment[] = [];
|
|
333
472
|
|
|
334
|
-
for (const phase of
|
|
473
|
+
for (const phase of ATTRIBUTABLE_PHASES) {
|
|
335
474
|
const currentModel = currentModels[phase];
|
|
336
475
|
if (typeof currentModel !== "string" || currentModel === "") continue;
|
|
337
476
|
|
|
338
477
|
const stat = stats.get(statKey(phase, currentModel));
|
|
339
|
-
if (stat === undefined
|
|
478
|
+
if (stat === undefined) continue;
|
|
340
479
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
const reliable =
|
|
344
|
-
stat.passRate >= RELIABLE_PASS_RATE &&
|
|
345
|
-
(stat.avgRounds === null || stat.avgRounds <= HIGH_AVG_ROUNDS);
|
|
480
|
+
// Dormancy gate: too few v2 records to attribute blame.
|
|
481
|
+
if (stat.v2Samples < MIN_V2_SAMPLES) continue;
|
|
346
482
|
|
|
347
|
-
|
|
348
|
-
|
|
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;
|
|
349
486
|
|
|
350
487
|
const to = stepUp(currentModel, knownModels);
|
|
351
488
|
if (to === null) continue; // already at top tier, or untierable
|
|
352
489
|
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
? `pass-rate ${stat.passRate.toFixed(2)} over ${stat.samples} runs`
|
|
356
|
-
: `avg ${(stat.avgRounds as number).toFixed(1)} rounds-to-pass over ${stat.samples} runs`;
|
|
490
|
+
const blame = phase === "build" ? "corregir" : "replantear";
|
|
491
|
+
const reason = `avg ${measure.toFixed(1)} ${blame}/run over ${stat.v2Samples} v2 runs`;
|
|
357
492
|
|
|
358
493
|
adjustments.push({ phase, from: currentModel, to, reason });
|
|
359
494
|
}
|