@dzhechkov/harness-core 0.3.117 → 0.3.119
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/dist/bto-optimize.d.ts +97 -0
- package/dist/bto-optimize.d.ts.map +1 -0
- package/dist/bto-optimize.js +250 -0
- package/dist/bto-optimize.js.map +1 -0
- package/dist/feature-adr-routing.d.ts +8 -0
- package/dist/feature-adr-routing.d.ts.map +1 -1
- package/dist/feature-adr-routing.js +10 -0
- package/dist/feature-adr-routing.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/routing-outcomes.d.ts +88 -0
- package/dist/routing-outcomes.d.ts.map +1 -0
- package/dist/routing-outcomes.js +208 -0
- package/dist/routing-outcomes.js.map +1 -0
- package/package.json +5 -5
- package/src/bto-optimize.ts +270 -0
- package/src/feature-adr-routing.ts +11 -0
- package/src/index.ts +2 -0
- package/src/routing-outcomes.ts +238 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Learned cost-optimal model routing (feature learned-cost-routing, ADR-001) — the `auto-cost` spec.
|
|
3
|
+
*
|
|
4
|
+
* A per-stage model router that learns which model actually SUCCEEDS at a stage (fewest retries = lowest true
|
|
5
|
+
* cost). The SELECTOR is PURE over an injected store snapshot (mirrors the shipped rUv `MetaHarnessRouter`,
|
|
6
|
+
* `open-claude-code/v2/src/optimize/router.mjs`: cheapest model clearing a 0.7 success bar, else a cheapest-
|
|
7
|
+
* first chain for escalate-on-fail — the `cve-bench/.../model-chain.mjs` pattern). The store is a thin JSON
|
|
8
|
+
* layer under `.dz/` (top-level ESM fs; a lazy require() is undefined at runtime — the R1 footgun).
|
|
9
|
+
*
|
|
10
|
+
* Storage is JSON, not SQLite: `better-sqlite3` is not a harness-core dep, `.dz/` already persists JSON state,
|
|
11
|
+
* and the grounded reference is emphatically zero-dependency (ADR-073 "pure-TS path is dependency-free").
|
|
12
|
+
*
|
|
13
|
+
* SAFETY PROPERTIES (ADR-001, load-bearing, each pinned by a test):
|
|
14
|
+
* 1. §3 — `selectAutoCost('qe', …, {family})` ranks ONLY the cross-family of the coder → a model that wrote
|
|
15
|
+
* code can NEVER self-QE (the named cross-model-QE invariant).
|
|
16
|
+
* 2. §2 — a stage that PRODUCED an artifact but FAILED the downstream gate is recorded as a FAILURE at its
|
|
17
|
+
* key (`finalizeOutcome(..., false)`), down-ranking that model — success ≠ "returned something".
|
|
18
|
+
* 3. §1 — the same store snapshot yields the same pick (deterministic); no `auto-cost` spec ⇒ nothing here
|
|
19
|
+
* is touched (byte-identical, opt-in).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync } from 'node:fs';
|
|
23
|
+
import { join, dirname } from 'node:path';
|
|
24
|
+
|
|
25
|
+
export type Family = 'claude' | 'openai';
|
|
26
|
+
|
|
27
|
+
export interface ModelRung {
|
|
28
|
+
readonly id: string;
|
|
29
|
+
readonly costRank: number; // coarse relative ordering (ESTIMATE from conservative public list prices), NOT a $/tok metric
|
|
30
|
+
readonly family: Family;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* KNOWN model set, cheapest→dearest. `costRank` is a COARSE relative ordering (an estimate from conservative
|
|
35
|
+
* public list prices — the router.mjs precedent, "not fabricated metrics"); only the ORDER is load-bearing.
|
|
36
|
+
* gpt-5.6-ready: adding a rung is a data-only edit. Kept consistent with KNOWN_CODEX / CLAUDE_NAMES in
|
|
37
|
+
* feature-adr-routing.ts.
|
|
38
|
+
*/
|
|
39
|
+
export const COST_LADDER: readonly ModelRung[] = Object.freeze([
|
|
40
|
+
Object.freeze({ id: 'haiku', costRank: 1, family: 'claude' as Family }),
|
|
41
|
+
Object.freeze({ id: 'fable', costRank: 1, family: 'claude' as Family }),
|
|
42
|
+
Object.freeze({ id: 'gpt-5.5', costRank: 2, family: 'openai' as Family }),
|
|
43
|
+
Object.freeze({ id: 'sonnet', costRank: 2, family: 'claude' as Family }),
|
|
44
|
+
Object.freeze({ id: 'gpt-5.6', costRank: 3, family: 'openai' as Family }),
|
|
45
|
+
Object.freeze({ id: 'gpt-5.6-sol', costRank: 3, family: 'openai' as Family }),
|
|
46
|
+
Object.freeze({ id: 'opus', costRank: 4, family: 'claude' as Family }),
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
export interface OutcomeStats {
|
|
50
|
+
readonly attempts: number;
|
|
51
|
+
readonly successes: number;
|
|
52
|
+
readonly successRate: number; // successes/attempts, 0 when no attempts
|
|
53
|
+
}
|
|
54
|
+
export type StatsFor = (model: string) => OutcomeStats;
|
|
55
|
+
|
|
56
|
+
export interface AutoCostOpts {
|
|
57
|
+
readonly ladder?: readonly ModelRung[]; // the probe-filtered live set (FR-7); default COST_LADDER
|
|
58
|
+
readonly qualityBar?: number; // default 0.7
|
|
59
|
+
readonly minSamples?: number; // default 3 — below this, no learned trust (cold-start)
|
|
60
|
+
readonly family?: Family; // FR-6: restrict the ladder to this family (qe → cross-family of the coder)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface AutoCostPick {
|
|
64
|
+
readonly model: string; // the model to dispatch NOW
|
|
65
|
+
readonly chain: readonly string[]; // cheapest-first order for escalate-on-fail (FR-5)
|
|
66
|
+
readonly evidence: string; // for modelsUsed (FR-8)
|
|
67
|
+
readonly metBar: boolean; // true = a learned model cleared the bar; false = cold-start / none cleared
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const DEFAULT_BAR = 0.7;
|
|
71
|
+
const DEFAULT_MIN_SAMPLES = 3;
|
|
72
|
+
|
|
73
|
+
/** Cheapest-first, deterministic order: by costRank, tie-broken by id (stable). */
|
|
74
|
+
function orderedLadder(ladder: readonly ModelRung[], family?: Family): ModelRung[] {
|
|
75
|
+
return ladder
|
|
76
|
+
.filter((r) => family === undefined || r.family === family)
|
|
77
|
+
.slice()
|
|
78
|
+
.sort((a, b) => a.costRank - b.costRank || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* PURE selection (ADR §1). Strategy (b), bar 0.7: pick the CHEAPEST model that has ≥`minSamples` attempts AND
|
|
83
|
+
* a learned success-rate ≥ bar at this key; if NONE has proven itself, return the cheapest rung (cold-start)
|
|
84
|
+
* with the full cheapest-first `chain` so the caller escalates on real failure (never a pre-emptive jump to a
|
|
85
|
+
* dear model). Deterministic given the snapshot. `family` restricts the ladder (qe cross-family guard).
|
|
86
|
+
*/
|
|
87
|
+
export function selectAutoCost(stage: string, tier: string, statsFor: StatsFor, opts: AutoCostOpts = {}): AutoCostPick {
|
|
88
|
+
const bar = opts.qualityBar ?? DEFAULT_BAR;
|
|
89
|
+
const minSamples = opts.minSamples ?? DEFAULT_MIN_SAMPLES;
|
|
90
|
+
const rungs = orderedLadder(opts.ladder ?? COST_LADDER, opts.family);
|
|
91
|
+
const chain = rungs.map((r) => r.id);
|
|
92
|
+
if (rungs.length === 0) {
|
|
93
|
+
return { model: '', chain: [], evidence: `auto-cost(${stage}/${tier}): no candidate models`, metBar: false };
|
|
94
|
+
}
|
|
95
|
+
// Pass 1 — a PROVEN-GOOD model (≥minSamples, rate ≥ bar): cheapest wins.
|
|
96
|
+
for (const r of rungs) {
|
|
97
|
+
const s = statsFor(r.id);
|
|
98
|
+
if (s.attempts >= minSamples && s.successRate >= bar) {
|
|
99
|
+
const pct = (s.successRate * 100).toFixed(0);
|
|
100
|
+
return { model: r.id, chain, evidence: `${r.id} (auto-cost: ${pct}% / ${s.attempts} runs @ ${stage}/${tier})`, metBar: true };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// Pass 2 — cross-run escalate-on-fail: SKIP a KNOWN-BAD rung (enough samples, rate < bar) so a repeatedly
|
|
104
|
+
// failing cheapest model is not re-selected forever. Pick the cheapest rung that is untried-or-thin.
|
|
105
|
+
const isKnownBad = (id: string): boolean => {
|
|
106
|
+
const s = statsFor(id);
|
|
107
|
+
return s.attempts >= minSamples && s.successRate < bar;
|
|
108
|
+
};
|
|
109
|
+
for (const r of rungs) {
|
|
110
|
+
if (!isKnownBad(r.id)) {
|
|
111
|
+
const s = statsFor(r.id);
|
|
112
|
+
const note = s.attempts > 0 ? `${s.successes}/${s.attempts} so far` : 'untried';
|
|
113
|
+
return { model: r.id, chain, evidence: `${r.id} (auto-cost: cold-start [${note}], chain ${chain.join('→')} @ ${stage}/${tier})`, metBar: false };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// Pass 3 — every rung is known-bad: fall back to the LEAST-bad (highest rate, tie → cheapest via stable order).
|
|
117
|
+
const leastBad = [...rungs].sort((a, b) => statsFor(b.id).successRate - statsFor(a.id).successRate)[0]!;
|
|
118
|
+
const lb = statsFor(leastBad.id);
|
|
119
|
+
return { model: leastBad.id, chain, evidence: `${leastBad.id} (auto-cost: all rungs under bar; least-bad ${(lb.successRate * 100).toFixed(0)}% @ ${stage}/${tier})`, metBar: false };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The next rung after a failed model in the chain (FR-5 escalate-on-fail); null at the top. */
|
|
123
|
+
export function nextInChain(chain: readonly string[], failedModel: string): string | null {
|
|
124
|
+
const i = chain.indexOf(failedModel);
|
|
125
|
+
if (i === -1 || i >= chain.length - 1) return null;
|
|
126
|
+
return chain[i + 1] ?? null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── JSON store (thin I/O; never throws → the caller degrades to the default model loudly) ────────────────
|
|
130
|
+
|
|
131
|
+
export interface OutcomeRow { attempts: number; successes: number; provisional?: number }
|
|
132
|
+
export interface OutcomeStore { readonly rows: Record<string, OutcomeRow> }
|
|
133
|
+
|
|
134
|
+
export const ROUTING_OUTCOMES_PATH = '.dz/routing-outcomes.json';
|
|
135
|
+
const keyOf = (stage: string, tier: string, model: string): string => `${stage}|${tier}|${model}`;
|
|
136
|
+
|
|
137
|
+
/** Load the outcome store; absent/corrupt/unreadable → empty (never throws). */
|
|
138
|
+
export function loadOutcomes(repoRoot: string): OutcomeStore {
|
|
139
|
+
try {
|
|
140
|
+
const p = join(repoRoot, ROUTING_OUTCOMES_PATH);
|
|
141
|
+
if (!existsSync(p)) return { rows: {} };
|
|
142
|
+
const parsed = JSON.parse(readFileSync(p, 'utf8')) as unknown;
|
|
143
|
+
if (parsed === null || typeof parsed !== 'object') return { rows: {} };
|
|
144
|
+
const rows = (parsed as { rows?: unknown }).rows;
|
|
145
|
+
if (rows === null || typeof rows !== 'object') return { rows: {} };
|
|
146
|
+
// sanitize: keep only well-formed numeric rows
|
|
147
|
+
const clean: Record<string, OutcomeRow> = {};
|
|
148
|
+
for (const [k, v] of Object.entries(rows as Record<string, unknown>)) {
|
|
149
|
+
if (v && typeof v === 'object') {
|
|
150
|
+
const r = v as Record<string, unknown>;
|
|
151
|
+
// Number.isFinite rejects Infinity/NaN (1e400 JSON-parses to Infinity, which is a number ≥ 0 and would
|
|
152
|
+
// otherwise pass, then Infinity/Infinity → NaN poisons successRate). Floor to a non-negative integer.
|
|
153
|
+
const num = (x: unknown): number => (typeof x === 'number' && Number.isFinite(x) && x >= 0 ? Math.floor(x) : 0);
|
|
154
|
+
const a = num(r.attempts);
|
|
155
|
+
const s = Math.min(num(r.successes), a);
|
|
156
|
+
clean[k] = { attempts: a, successes: s, ...(typeof r.provisional === 'number' && Number.isFinite(r.provisional) && r.provisional >= 0 ? { provisional: Math.floor(r.provisional) } : {}) };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return { rows: clean };
|
|
160
|
+
} catch {
|
|
161
|
+
return { rows: {} };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Build the injected StatsFor for a (stage, tier) from a loaded snapshot. PURE. */
|
|
166
|
+
export function statsForKey(store: OutcomeStore, stage: string, tier: string): StatsFor {
|
|
167
|
+
return (model: string): OutcomeStats => {
|
|
168
|
+
const r = store.rows[keyOf(stage, tier, model)];
|
|
169
|
+
if (!r || r.attempts <= 0) return { attempts: 0, successes: 0, successRate: 0 };
|
|
170
|
+
return { attempts: r.attempts, successes: r.successes, successRate: r.successes / r.attempts };
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function writeStore(repoRoot: string, store: OutcomeStore): void {
|
|
175
|
+
try {
|
|
176
|
+
const p = join(repoRoot, ROUTING_OUTCOMES_PATH);
|
|
177
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
178
|
+
// Atomic write: a temp file + rename can't leave a half-written/corrupt store if the process dies mid-write
|
|
179
|
+
// (rename is atomic on the same filesystem). Cross-PROCESS concurrent writers can still lose an update —
|
|
180
|
+
// that is an accepted degradation (the feature-adr workflow records sequentially; see architecture/degradations.md).
|
|
181
|
+
const tmp = p + '.tmp-' + process.pid;
|
|
182
|
+
writeFileSync(tmp, JSON.stringify({ rows: store.rows }, null, 2) + '\n');
|
|
183
|
+
try {
|
|
184
|
+
renameSync(tmp, p);
|
|
185
|
+
} catch (e) {
|
|
186
|
+
// rename failed (e.g. dest is a directory / cross-device) — do NOT orphan the temp file (QE #4).
|
|
187
|
+
try { unlinkSync(tmp); } catch { /* best-effort cleanup */ }
|
|
188
|
+
throw e;
|
|
189
|
+
}
|
|
190
|
+
} catch {
|
|
191
|
+
/* non-blocking: learning is advisory */
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Record a PROVISIONAL outcome (i): the stage produced a non-empty artifact / did not die. Bumps `provisional`
|
|
197
|
+
* and, for a gate-less stage, this weak signal counts toward attempts+successes (weak-provisional decision).
|
|
198
|
+
* `weakCredit=false` for gated stages — the real (ii) credit lands in finalizeOutcome.
|
|
199
|
+
*/
|
|
200
|
+
export function recordProvisional(repoRoot: string, stage: string, tier: string, model: string, weakCredit = false): void {
|
|
201
|
+
const store = loadOutcomes(repoRoot);
|
|
202
|
+
const k = keyOf(stage, tier, model);
|
|
203
|
+
const r = store.rows[k] ?? { attempts: 0, successes: 0 };
|
|
204
|
+
const next: OutcomeRow = { attempts: r.attempts + (weakCredit ? 1 : 0), successes: r.successes + (weakCredit ? 1 : 0), provisional: (r.provisional ?? 0) + 1 };
|
|
205
|
+
writeStore(repoRoot, { rows: { ...store.rows, [k]: next } });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Finalize the AUTHORITATIVE outcome (ii) from the downstream gate, attributed back to the (stage, model)
|
|
210
|
+
* that produced the artifact. A produced-but-gate-FAILED run records a FAILURE (attempts+1, successes+0) —
|
|
211
|
+
* success is NOT "returned something" (ADR §2, load-bearing).
|
|
212
|
+
*/
|
|
213
|
+
export function finalizeOutcome(repoRoot: string, stage: string, tier: string, model: string, success: boolean): void {
|
|
214
|
+
const store = loadOutcomes(repoRoot);
|
|
215
|
+
const k = keyOf(stage, tier, model);
|
|
216
|
+
const r = store.rows[k] ?? { attempts: 0, successes: 0 };
|
|
217
|
+
const next: OutcomeRow = { attempts: r.attempts + 1, successes: r.successes + (success ? 1 : 0), ...(r.provisional !== undefined ? { provisional: r.provisional } : {}) };
|
|
218
|
+
writeStore(repoRoot, { rows: { ...store.rows, [k]: next } });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Human-readable learned table for `dz routing`. Deterministic (sorted). */
|
|
222
|
+
export function renderOutcomes(store: OutcomeStore, filterStage?: string): string {
|
|
223
|
+
const keys = Object.keys(store.rows).sort();
|
|
224
|
+
const shown = keys.filter((k) => filterStage === undefined || k.startsWith(filterStage + '|'));
|
|
225
|
+
if (shown.length === 0) return filterStage ? `No learned routing outcomes for stage "${filterStage}".` : 'No learned routing outcomes yet (auto-cost has not run, or no gate has resolved).';
|
|
226
|
+
const lines = ['Learned routing outcomes (what `auto-cost` currently believes):', ''];
|
|
227
|
+
let lastStage = '';
|
|
228
|
+
for (const k of shown) {
|
|
229
|
+
const r = store.rows[k];
|
|
230
|
+
if (!r) continue;
|
|
231
|
+
const [stage = '', tier = '', model = ''] = k.split('|');
|
|
232
|
+
if (stage !== lastStage) { lines.push(`## ${stage}`); lastStage = stage; }
|
|
233
|
+
const rate = r.attempts > 0 ? ((r.successes / r.attempts) * 100).toFixed(0) + '%' : 'n/a';
|
|
234
|
+
const prov = r.provisional ? `, ${r.provisional} provisional` : '';
|
|
235
|
+
lines.push(` ${tier.padEnd(3)} ${model.padEnd(14)} ${rate.padStart(4)} (${r.successes}/${r.attempts} gated${prov})`);
|
|
236
|
+
}
|
|
237
|
+
return lines.join('\n');
|
|
238
|
+
}
|