crewhaus 0.1.7 → 0.2.0
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 +10 -3
- package/dist/advice-apply.d.ts +182 -0
- package/dist/advice-apply.js +286 -0
- package/dist/advise-rules.d.ts +348 -0
- package/dist/advise-rules.js +905 -0
- package/dist/alert-sink.d.ts +48 -0
- package/dist/alert-sink.js +86 -0
- package/dist/approval-gate.d.ts +127 -0
- package/dist/approval-gate.js +254 -0
- package/dist/audit-verify.d.ts +69 -0
- package/dist/audit-verify.js +97 -0
- package/dist/autodistill.d.ts +113 -0
- package/dist/autodistill.js +256 -0
- package/dist/channel-provision.d.ts +360 -0
- package/dist/channel-provision.js +881 -0
- package/dist/ci-scaffold.d.ts +31 -0
- package/dist/ci-scaffold.js +343 -0
- package/dist/compile-check.d.ts +90 -0
- package/dist/compile-check.js +285 -0
- package/dist/compliance-schedule.d.ts +35 -0
- package/dist/compliance-schedule.js +36 -0
- package/dist/context-pressure.d.ts +80 -0
- package/dist/context-pressure.js +166 -0
- package/dist/dataset-mine.d.ts +172 -0
- package/dist/dataset-mine.js +403 -0
- package/dist/datasets.d.ts +124 -0
- package/dist/datasets.js +260 -0
- package/dist/deploy-canary.d.ts +83 -0
- package/dist/deploy-canary.js +87 -0
- package/dist/doctor-checks.d.ts +33 -0
- package/dist/doctor-checks.js +92 -0
- package/dist/doctor-detect.d.ts +108 -0
- package/dist/doctor-detect.js +214 -0
- package/dist/doctor-fix.d.ts +81 -0
- package/dist/doctor-fix.js +164 -0
- package/dist/egress-triage.d.ts +121 -0
- package/dist/egress-triage.js +261 -0
- package/dist/eval-bridge.d.ts +114 -0
- package/dist/eval-bridge.js +158 -0
- package/dist/eval-coverage.d.ts +140 -0
- package/dist/eval-coverage.js +428 -0
- package/dist/eval-history.d.ts +48 -0
- package/dist/eval-history.js +157 -0
- package/dist/eval-matrix.d.ts +80 -0
- package/dist/eval-matrix.js +182 -0
- package/dist/eval-sentinel.d.ts +65 -0
- package/dist/eval-sentinel.js +132 -0
- package/dist/faq.d.ts +68 -0
- package/dist/faq.js +168 -0
- package/dist/feedback.d.ts +246 -0
- package/dist/feedback.js +612 -0
- package/dist/fewshot.d.ts +83 -0
- package/dist/fewshot.js +158 -0
- package/dist/fleet.d.ts +207 -0
- package/dist/fleet.js +488 -0
- package/dist/flywheel.d.ts +193 -0
- package/dist/flywheel.js +519 -0
- package/dist/graders-suggest.d.ts +186 -0
- package/dist/graders-suggest.js +658 -0
- package/dist/incident.d.ts +99 -0
- package/dist/incident.js +217 -0
- package/dist/index.d.ts +9 -1
- package/dist/index.js +11727 -740
- package/dist/init-interactive.d.ts +105 -0
- package/dist/init-interactive.js +208 -0
- package/dist/intents.d.ts +105 -0
- package/dist/intents.js +292 -0
- package/dist/judge-calibrate.d.ts +137 -0
- package/dist/judge-calibrate.js +247 -0
- package/dist/justification-calibrate.d.ts +150 -0
- package/dist/justification-calibrate.js +262 -0
- package/dist/justification-gate.d.ts +27 -6
- package/dist/justification-gate.js +30 -6
- package/dist/knowledge-sync.d.ts +179 -0
- package/dist/knowledge-sync.js +551 -0
- package/dist/lessons.d.ts +87 -0
- package/dist/lessons.js +207 -0
- package/dist/lint.d.ts +127 -0
- package/dist/lint.js +226 -0
- package/dist/loadtest.d.ts +114 -0
- package/dist/loadtest.js +196 -0
- package/dist/marketplace-cli.d.ts +110 -0
- package/dist/marketplace-cli.js +250 -0
- package/dist/mcp-doctor.d.ts +121 -0
- package/dist/mcp-doctor.js +249 -0
- package/dist/model-scan.d.ts +116 -0
- package/dist/model-scan.js +226 -0
- package/dist/onchain-tune.d.ts +164 -0
- package/dist/onchain-tune.js +346 -0
- package/dist/permissions-suggest.d.ts +126 -0
- package/dist/permissions-suggest.js +333 -0
- package/dist/pii-tune.d.ts +107 -0
- package/dist/pii-tune.js +122 -0
- package/dist/propose.d.ts +117 -0
- package/dist/propose.js +184 -0
- package/dist/refresh-goldens.d.ts +82 -0
- package/dist/refresh-goldens.js +221 -0
- package/dist/regression-pin.d.ts +160 -0
- package/dist/regression-pin.js +281 -0
- package/dist/retention.d.ts +193 -0
- package/dist/retention.js +607 -0
- package/dist/retire.d.ts +118 -0
- package/dist/retire.js +291 -0
- package/dist/right-size.d.ts +100 -0
- package/dist/right-size.js +123 -0
- package/dist/scaffold-evals.d.ts +138 -0
- package/dist/scaffold-evals.js +410 -0
- package/dist/scope-audit-drift.d.ts +139 -0
- package/dist/scope-audit-drift.js +260 -0
- package/dist/security-corpus.d.ts +237 -0
- package/dist/security-corpus.js +516 -0
- package/dist/security-digest.d.ts +173 -0
- package/dist/security-digest.js +650 -0
- package/dist/sessions-index.d.ts +27 -0
- package/dist/sessions-index.js +51 -0
- package/dist/slo-doctor.d.ts +67 -0
- package/dist/slo-doctor.js +119 -0
- package/dist/slo-sink.d.ts +96 -0
- package/dist/slo-sink.js +107 -0
- package/dist/spec-changelog.d.ts +102 -0
- package/dist/spec-changelog.js +237 -0
- package/dist/state-backup.d.ts +223 -0
- package/dist/state-backup.js +648 -0
- package/dist/tools-cli.d.ts +170 -0
- package/dist/tools-cli.js +298 -0
- package/dist/triage.d.ts +202 -0
- package/dist/triage.js +403 -0
- package/dist/upgrade.d.ts +57 -0
- package/dist/upgrade.js +113 -0
- package/dist/version.d.ts +6 -0
- package/dist/version.js +27 -0
- package/dist/voice-eval.d.ts +138 -0
- package/dist/voice-eval.js +309 -0
- package/dist/watch.d.ts +58 -0
- package/dist/watch.js +97 -0
- package/package.json +89 -64
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post-eval run-history orchestration for `crewhaus eval` — append the run
|
|
3
|
+
* to the index, diff against the pinned (spec, dataset) baseline, gate, and
|
|
4
|
+
* promote. Kept in a side-effect-free module so it is unit-testable (this
|
|
5
|
+
* package's entry file runs an argv switch on import).
|
|
6
|
+
*
|
|
7
|
+
* Policy (AUTOMATION-OPPORTUNITIES.md item 3):
|
|
8
|
+
* - Every run is appended to `.crewhaus/evals/index.jsonl`.
|
|
9
|
+
* - First run for a (spec, dataset) key pins the baseline.
|
|
10
|
+
* - A keyset mismatch from `diffReports` (sample ids changed) means the
|
|
11
|
+
* dataset itself changed — start a new baseline lineage instead of
|
|
12
|
+
* failing the run.
|
|
13
|
+
* - The regression gate is strict by default: ANY pass-rate drop fails, and
|
|
14
|
+
* any sample-level pass→fail flip fails even when a recovery elsewhere
|
|
15
|
+
* cancels it out in the aggregates.
|
|
16
|
+
* - Gate pass → auto-promote the new run to baseline (opt out with
|
|
17
|
+
* `--no-promote`); gate fail → never promote. `--gate` only controls
|
|
18
|
+
* whether a failing verdict maps to a non-zero exit.
|
|
19
|
+
*/
|
|
20
|
+
import { resolve } from "node:path";
|
|
21
|
+
import { ReportError, appendRunIndex, diffReports, getBaseline, loadRun, setBaseline, } from "@crewhaus/eval-report";
|
|
22
|
+
import { gate } from "@crewhaus/regression-runner";
|
|
23
|
+
/**
|
|
24
|
+
* `eval-report history|baseline show --dataset <filter>` matching. Unioned
|
|
25
|
+
* runs are recorded under `<primary>+regressions@vX`, so the filter matches
|
|
26
|
+
* a stored datasetName that equals it exactly OR continues it with a `+`
|
|
27
|
+
* suffix segment — `--dataset smoke` finds `smoke` and
|
|
28
|
+
* `smoke+regressions@v1`, but not `smoke2`.
|
|
29
|
+
*/
|
|
30
|
+
export function datasetFilterMatches(filter, datasetName) {
|
|
31
|
+
return datasetName === filter || datasetName.startsWith(`${filter}+`);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Strict gate between a baseline run and a new run. Wraps
|
|
35
|
+
* `regression-runner`'s `gate()` with `regressionThreshold: 0` (any
|
|
36
|
+
* pass-rate drop fails) and additionally fails on sample-level regressions
|
|
37
|
+
* that net out to a flat pass rate. The latency criterion is DISABLED by
|
|
38
|
+
* default (threshold Infinity): the documented gate is pass-rate/flip-only,
|
|
39
|
+
* and regression-runner's +5000ms p95 default would fail runs the docs say
|
|
40
|
+
* pass. Latency gating arrives with explicit CLI flags later — callers can
|
|
41
|
+
* already opt in via `thresholds.latencyThreshold`.
|
|
42
|
+
*/
|
|
43
|
+
export function gateRuns(prev, next, thresholds = {}) {
|
|
44
|
+
const verdict = gate(prev, next, {
|
|
45
|
+
regressionThreshold: 0,
|
|
46
|
+
latencyThreshold: Number.POSITIVE_INFINITY,
|
|
47
|
+
...thresholds,
|
|
48
|
+
});
|
|
49
|
+
if (verdict.verdict === "pass" && verdict.report.regressions.length > 0) {
|
|
50
|
+
const ids = verdict.report.regressions.map((r) => r.sampleId).join(", ");
|
|
51
|
+
return {
|
|
52
|
+
verdict: "fail",
|
|
53
|
+
reason: `sample regressions despite a flat pass rate: [${ids}]`,
|
|
54
|
+
report: verdict.report,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return verdict;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Record a completed eval run in the history index, then resolve/diff/gate
|
|
61
|
+
* against the pinned baseline for its (spec, dataset) key. Returns whether
|
|
62
|
+
* the caller should exit non-zero (gate requested + failed).
|
|
63
|
+
*/
|
|
64
|
+
export async function finishEvalRun(opts) {
|
|
65
|
+
const write = opts.write ?? ((line) => process.stdout.write(`${line}\n`));
|
|
66
|
+
const { summary, specName } = opts;
|
|
67
|
+
const datasetName = summary.config.datasetName;
|
|
68
|
+
const absOut = resolve(opts.outDir);
|
|
69
|
+
// Belt (the runner already refuses to run zero samples): a 0-sample run
|
|
70
|
+
// carries no signal and a passRate-0 "clean" entry would poison the index
|
|
71
|
+
// and could pin an empty baseline — refuse loudly instead of recording it.
|
|
72
|
+
if (summary.samples.length === 0) {
|
|
73
|
+
throw new Error(`refusing to record 0-sample eval run ${summary.runId} — dataset "${datasetName}" produced no samples`);
|
|
74
|
+
}
|
|
75
|
+
const entry = {
|
|
76
|
+
runId: summary.runId,
|
|
77
|
+
specName,
|
|
78
|
+
specHash: summary.config.specHash,
|
|
79
|
+
datasetName,
|
|
80
|
+
datasetHash: opts.datasetHash,
|
|
81
|
+
passRate: summary.aggregates.passRate,
|
|
82
|
+
meanScore: summary.aggregates.meanScore,
|
|
83
|
+
sampleCount: summary.samples.length,
|
|
84
|
+
retriedCount: summary.samples.filter((s) => s.retried === true).length,
|
|
85
|
+
ts: summary.endedAt,
|
|
86
|
+
outDir: absOut,
|
|
87
|
+
};
|
|
88
|
+
appendRunIndex(entry, opts.evalsDir);
|
|
89
|
+
const pinCurrentRun = (label) => {
|
|
90
|
+
if (!opts.promote) {
|
|
91
|
+
write(`[eval] baseline not set (${label}; --no-promote)`);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const pin = {
|
|
95
|
+
specName,
|
|
96
|
+
datasetName,
|
|
97
|
+
runId: summary.runId,
|
|
98
|
+
outDir: absOut,
|
|
99
|
+
datasetHash: opts.datasetHash,
|
|
100
|
+
ts: entry.ts,
|
|
101
|
+
};
|
|
102
|
+
setBaseline(pin, opts.evalsDir);
|
|
103
|
+
write(`[eval] baseline set: ${summary.runId} (${label})`);
|
|
104
|
+
};
|
|
105
|
+
const baseline = getBaseline(specName, datasetName, opts.evalsDir);
|
|
106
|
+
if (baseline === undefined) {
|
|
107
|
+
pinCurrentRun(`first run for ${specName}/${datasetName}`);
|
|
108
|
+
return { gateFailed: false };
|
|
109
|
+
}
|
|
110
|
+
let prevLoaded;
|
|
111
|
+
try {
|
|
112
|
+
prevLoaded = await loadRun(baseline.outDir);
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
116
|
+
write(`[eval] baseline run ${baseline.runId} unreadable (${msg}) — starting new baseline lineage`);
|
|
117
|
+
pinCurrentRun("new lineage");
|
|
118
|
+
return { gateFailed: false };
|
|
119
|
+
}
|
|
120
|
+
const nextLoaded = await loadRun(absOut);
|
|
121
|
+
let diff;
|
|
122
|
+
try {
|
|
123
|
+
diff = diffReports(prevLoaded, nextLoaded).diff;
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
if (err instanceof ReportError) {
|
|
127
|
+
write("[eval] dataset changed — starting new baseline lineage");
|
|
128
|
+
pinCurrentRun("new lineage");
|
|
129
|
+
return { gateFailed: false };
|
|
130
|
+
}
|
|
131
|
+
throw err;
|
|
132
|
+
}
|
|
133
|
+
write(`[eval] vs baseline ${baseline.runId}: regressions=${diff.regressions.length} ` +
|
|
134
|
+
`recoveries=${diff.recoveries.length} score_shifts=${diff.scoreShifts.length} ` +
|
|
135
|
+
`unchanged=${diff.unchanged}`);
|
|
136
|
+
for (const r of diff.regressions) {
|
|
137
|
+
write(`[eval] regression: ${r.sampleId} (score ${r.prev.score.toFixed(2)} → ${r.next.score.toFixed(2)})`);
|
|
138
|
+
}
|
|
139
|
+
for (const r of diff.recoveries) {
|
|
140
|
+
write(`[eval] recovery: ${r.sampleId} (score ${r.prev.score.toFixed(2)} → ${r.next.score.toFixed(2)})`);
|
|
141
|
+
}
|
|
142
|
+
const verdict = gateRuns(prevLoaded.summary, nextLoaded.summary);
|
|
143
|
+
if (verdict.verdict === "fail") {
|
|
144
|
+
const reason = verdict.reason ?? "regression gate failed";
|
|
145
|
+
write(`[eval] gate: FAIL — ${reason}`);
|
|
146
|
+
write(`[eval] baseline kept: ${baseline.runId}`);
|
|
147
|
+
return opts.gateRequested ? { gateFailed: true, gateReason: reason } : { gateFailed: false };
|
|
148
|
+
}
|
|
149
|
+
write("[eval] gate: PASS");
|
|
150
|
+
if (opts.promote) {
|
|
151
|
+
pinCurrentRun("promoted");
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
write(`[eval] baseline kept: ${baseline.runId} (--no-promote)`);
|
|
155
|
+
}
|
|
156
|
+
return { gateFailed: false };
|
|
157
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { MatrixCell, MatrixPricingFn } from "@crewhaus/eval-report";
|
|
2
|
+
import type { EvalRunSummary } from "@crewhaus/eval-runner";
|
|
3
|
+
/** Thrown on a malformed `--models` value or an incompatible flag combo.
|
|
4
|
+
* The CLI entry file catches it and routes the message through `die()`;
|
|
5
|
+
* tests assert on `.message` without the process exiting. */
|
|
6
|
+
export declare class MatrixArgError extends Error {
|
|
7
|
+
readonly name = "MatrixArgError";
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Parse `--models m1,m2,...`: split on commas, trim whitespace, drop empty
|
|
11
|
+
* segments (trailing-comma tolerance), reject duplicates, and validate
|
|
12
|
+
* every entry against the model-router grammar UP FRONT — a typo must fail
|
|
13
|
+
* before cell 1 burns a single token.
|
|
14
|
+
*/
|
|
15
|
+
export declare function parseModelsFlag(value: string): string[];
|
|
16
|
+
/**
|
|
17
|
+
* `--gate` / `--no-promote` steer the item-3 baseline lineage, which matrix
|
|
18
|
+
* cells skip entirely — reject the combination instead of silently ignoring
|
|
19
|
+
* the flags.
|
|
20
|
+
*/
|
|
21
|
+
export declare function assertMatrixFlagsCompatible(flags: {
|
|
22
|
+
readonly gate: boolean;
|
|
23
|
+
readonly noPromote: boolean;
|
|
24
|
+
}): void;
|
|
25
|
+
/**
|
|
26
|
+
* Filesystem-safe cell directory name for a model string: every character
|
|
27
|
+
* outside `[A-Za-z0-9._-]` collapses to a single `_` (so the slug stays
|
|
28
|
+
* readable/reversible-enough: `openai/gpt-4o` → `openai_gpt-4o`,
|
|
29
|
+
* `local/llama3.2@http://localhost:11434/v1` →
|
|
30
|
+
* `local_llama3.2_http_localhost_11434_v1`). A slug with no alphanumeric
|
|
31
|
+
* left (nothing a path could safely be named after — e.g. all dots) falls
|
|
32
|
+
* back to a stable content-hash name.
|
|
33
|
+
*/
|
|
34
|
+
export declare function modelSlug(model: string): string;
|
|
35
|
+
/**
|
|
36
|
+
* model → cell-directory slug for the whole matrix. Distinct models whose
|
|
37
|
+
* slugs collide (e.g. `openai/gpt.4o` vs `openai/gpt_4o`) get a `_2`, `_3`,
|
|
38
|
+
* … suffix in list order, so every cell keeps its own directory.
|
|
39
|
+
*/
|
|
40
|
+
export declare function assignCellSlugs(models: ReadonlyArray<string>): Map<string, string>;
|
|
41
|
+
/**
|
|
42
|
+
* The real `MatrixPricingFn` for eval-report's matrix renderer: model
|
|
43
|
+
* string → provider via the model-router grammar, then cost-tracker's
|
|
44
|
+
* versioned pricing table over the cell's token totals (cached-read tokens
|
|
45
|
+
* are 0 — eval aggregates don't track them). Any miss — unparseable model,
|
|
46
|
+
* unknown provider row (e.g. a groq/ or local/ model billed elsewhere) —
|
|
47
|
+
* returns `undefined`, which renders as "n/a" rather than crashing.
|
|
48
|
+
*/
|
|
49
|
+
export declare function defaultMatrixPricing(): MatrixPricingFn;
|
|
50
|
+
export type RunMatrixCellsOptions = {
|
|
51
|
+
readonly models: ReadonlyArray<string>;
|
|
52
|
+
/** model → cell directory name (see {@link assignCellSlugs}). */
|
|
53
|
+
readonly slugs: ReadonlyMap<string, string>;
|
|
54
|
+
/** Matrix root; each cell runs in `<rootDir>/<slug>`. */
|
|
55
|
+
readonly rootDir: string;
|
|
56
|
+
/** Execute one cell's eval and return its summary. Injected so tests can
|
|
57
|
+
* stub the runner; the CLI passes a `runEval` wrapper that patches the
|
|
58
|
+
* lowered ir's `agent.model` in-memory (mirroring `run --model`). */
|
|
59
|
+
readonly runCell: (model: string, cellOutDir: string) => Promise<EvalRunSummary>;
|
|
60
|
+
/** Line sink; defaults to stdout. */
|
|
61
|
+
readonly write?: (line: string) => void;
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* "The cell never really ran": eval-runner isolates per-sample invoker
|
|
65
|
+
* errors (a summary always comes back), so a bad credential / 404 model
|
|
66
|
+
* surfaces as EVERY sample erroring rather than as a throw. Map that to a
|
|
67
|
+
* cell failure — its 0% pass rate and 0ms latencies are artifacts of never
|
|
68
|
+
* producing output, not a comparison result. Partial sample errors (a 529
|
|
69
|
+
* blip on 1 of 20) leave the cell a normal "ran with failing samples" row.
|
|
70
|
+
*/
|
|
71
|
+
export declare function cellCrashReason(summary: EvalRunSummary): string | undefined;
|
|
72
|
+
/**
|
|
73
|
+
* Run every cell sequentially, isolating failures: one model erroring (bad
|
|
74
|
+
* credentials, 404 model, …) — whether thrown or absorbed by the runner as
|
|
75
|
+
* all-samples-errored (see {@link cellCrashReason}) — records an error cell
|
|
76
|
+
* and the loop continues. The caller maps "any error cell" to a non-zero
|
|
77
|
+
* exit AFTER rendering the matrix — a crashed cell is distinct from a cell
|
|
78
|
+
* that ran with failing samples, which is a normal result.
|
|
79
|
+
*/
|
|
80
|
+
export declare function runMatrixCells(opts: RunMatrixCellsOptions): Promise<MatrixCell[]>;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Item 11 — `crewhaus eval --models <m1,m2,...>`: one-command model
|
|
3
|
+
* benchmark matrix. The CLI resolves the dataset (registry ref or file,
|
|
4
|
+
* item-9 regression union included) ONCE, then runs the same samples +
|
|
5
|
+
* graders once per model, each cell writing a full run directory to
|
|
6
|
+
* `<out>/<model-slug>/` so `eval-report diff` works on any pair of cells.
|
|
7
|
+
*
|
|
8
|
+
* Deliberate non-interactions with the item-3 run-history features:
|
|
9
|
+
* matrix cells are model comparisons, not lineage runs — they never touch
|
|
10
|
+
* `finishEvalRun` (index append / baseline pin / gate / promote), because a
|
|
11
|
+
* shared (spec, dataset) baseline key across N models would corrupt the
|
|
12
|
+
* lineage. `--gate` / `--no-promote` are rejected up front for the same
|
|
13
|
+
* reason (see {@link assertMatrixFlagsCompatible}).
|
|
14
|
+
*
|
|
15
|
+
* Kept in a side-effect-free module (the CLI entry file runs an argv
|
|
16
|
+
* switch on import) mirroring `eval-history.ts` / `datasets.ts`: the
|
|
17
|
+
* per-cell eval execution is injected as `runCell`, so failure isolation
|
|
18
|
+
* is unit-testable without an LLM.
|
|
19
|
+
*/
|
|
20
|
+
import { createHash } from "node:crypto";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import { DEFAULT_PRICING, computeCostMicros, resolvePricing } from "@crewhaus/cost-tracker";
|
|
23
|
+
import { parseModelString } from "@crewhaus/model-router";
|
|
24
|
+
/** Thrown on a malformed `--models` value or an incompatible flag combo.
|
|
25
|
+
* The CLI entry file catches it and routes the message through `die()`;
|
|
26
|
+
* tests assert on `.message` without the process exiting. */
|
|
27
|
+
export class MatrixArgError extends Error {
|
|
28
|
+
name = "MatrixArgError";
|
|
29
|
+
}
|
|
30
|
+
// -------- flag parsing / validation --------
|
|
31
|
+
/**
|
|
32
|
+
* Parse `--models m1,m2,...`: split on commas, trim whitespace, drop empty
|
|
33
|
+
* segments (trailing-comma tolerance), reject duplicates, and validate
|
|
34
|
+
* every entry against the model-router grammar UP FRONT — a typo must fail
|
|
35
|
+
* before cell 1 burns a single token.
|
|
36
|
+
*/
|
|
37
|
+
export function parseModelsFlag(value) {
|
|
38
|
+
const models = value
|
|
39
|
+
.split(",")
|
|
40
|
+
.map((m) => m.trim())
|
|
41
|
+
.filter((m) => m.length > 0);
|
|
42
|
+
if (models.length === 0) {
|
|
43
|
+
throw new MatrixArgError("--models: expected a comma-separated list of model strings (e.g. claude-sonnet-4-5,openai/gpt-4o)");
|
|
44
|
+
}
|
|
45
|
+
const seen = new Set();
|
|
46
|
+
for (const model of models) {
|
|
47
|
+
if (seen.has(model)) {
|
|
48
|
+
throw new MatrixArgError(`--models: duplicate model "${model}"`);
|
|
49
|
+
}
|
|
50
|
+
seen.add(model);
|
|
51
|
+
try {
|
|
52
|
+
parseModelString(model);
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
56
|
+
throw new MatrixArgError(`--models: ${msg}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return models;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* `--gate` / `--no-promote` steer the item-3 baseline lineage, which matrix
|
|
63
|
+
* cells skip entirely — reject the combination instead of silently ignoring
|
|
64
|
+
* the flags.
|
|
65
|
+
*/
|
|
66
|
+
export function assertMatrixFlagsCompatible(flags) {
|
|
67
|
+
if (flags.gate) {
|
|
68
|
+
throw new MatrixArgError("--models is incompatible with --gate — matrix cells are model comparisons and skip the (spec, dataset) baseline lineage; gate a single-model eval instead");
|
|
69
|
+
}
|
|
70
|
+
if (flags.noPromote) {
|
|
71
|
+
throw new MatrixArgError("--models is incompatible with --no-promote — matrix cells never touch the run index or baselines, so there is nothing to promote");
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// -------- slugs --------
|
|
75
|
+
/**
|
|
76
|
+
* Filesystem-safe cell directory name for a model string: every character
|
|
77
|
+
* outside `[A-Za-z0-9._-]` collapses to a single `_` (so the slug stays
|
|
78
|
+
* readable/reversible-enough: `openai/gpt-4o` → `openai_gpt-4o`,
|
|
79
|
+
* `local/llama3.2@http://localhost:11434/v1` →
|
|
80
|
+
* `local_llama3.2_http_localhost_11434_v1`). A slug with no alphanumeric
|
|
81
|
+
* left (nothing a path could safely be named after — e.g. all dots) falls
|
|
82
|
+
* back to a stable content-hash name.
|
|
83
|
+
*/
|
|
84
|
+
export function modelSlug(model) {
|
|
85
|
+
const slug = model.replace(/[^A-Za-z0-9._-]+/g, "_").replace(/^_+|_+$/g, "");
|
|
86
|
+
if (!/[A-Za-z0-9]/.test(slug)) {
|
|
87
|
+
return `model_${createHash("sha256").update(model).digest("hex").slice(0, 8)}`;
|
|
88
|
+
}
|
|
89
|
+
return slug;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* model → cell-directory slug for the whole matrix. Distinct models whose
|
|
93
|
+
* slugs collide (e.g. `openai/gpt.4o` vs `openai/gpt_4o`) get a `_2`, `_3`,
|
|
94
|
+
* … suffix in list order, so every cell keeps its own directory.
|
|
95
|
+
*/
|
|
96
|
+
export function assignCellSlugs(models) {
|
|
97
|
+
const taken = new Set();
|
|
98
|
+
const out = new Map();
|
|
99
|
+
for (const model of models) {
|
|
100
|
+
const base = modelSlug(model);
|
|
101
|
+
let candidate = base;
|
|
102
|
+
for (let i = 2; taken.has(candidate); i += 1)
|
|
103
|
+
candidate = `${base}_${i}`;
|
|
104
|
+
taken.add(candidate);
|
|
105
|
+
out.set(model, candidate);
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
// -------- pricing seam --------
|
|
110
|
+
/**
|
|
111
|
+
* The real `MatrixPricingFn` for eval-report's matrix renderer: model
|
|
112
|
+
* string → provider via the model-router grammar, then cost-tracker's
|
|
113
|
+
* versioned pricing table over the cell's token totals (cached-read tokens
|
|
114
|
+
* are 0 — eval aggregates don't track them). Any miss — unparseable model,
|
|
115
|
+
* unknown provider row (e.g. a groq/ or local/ model billed elsewhere) —
|
|
116
|
+
* returns `undefined`, which renders as "n/a" rather than crashing.
|
|
117
|
+
*/
|
|
118
|
+
export function defaultMatrixPricing() {
|
|
119
|
+
return (model, tokens) => {
|
|
120
|
+
try {
|
|
121
|
+
const parsed = parseModelString(model);
|
|
122
|
+
const row = resolvePricing(DEFAULT_PRICING, parsed.providerId, parsed.modelId);
|
|
123
|
+
if (row === undefined)
|
|
124
|
+
return undefined;
|
|
125
|
+
return computeCostMicros(row, tokens.input, tokens.output, 0);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* "The cell never really ran": eval-runner isolates per-sample invoker
|
|
134
|
+
* errors (a summary always comes back), so a bad credential / 404 model
|
|
135
|
+
* surfaces as EVERY sample erroring rather than as a throw. Map that to a
|
|
136
|
+
* cell failure — its 0% pass rate and 0ms latencies are artifacts of never
|
|
137
|
+
* producing output, not a comparison result. Partial sample errors (a 529
|
|
138
|
+
* blip on 1 of 20) leave the cell a normal "ran with failing samples" row.
|
|
139
|
+
*/
|
|
140
|
+
export function cellCrashReason(summary) {
|
|
141
|
+
const n = summary.samples.length;
|
|
142
|
+
if (n === 0 || summary.aggregates.errorCount < n)
|
|
143
|
+
return undefined;
|
|
144
|
+
const first = summary.samples.find((s) => s.error !== undefined)?.error;
|
|
145
|
+
return `all ${n} sample(s) errored${first !== undefined ? ` (first: ${first})` : ""}`;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Run every cell sequentially, isolating failures: one model erroring (bad
|
|
149
|
+
* credentials, 404 model, …) — whether thrown or absorbed by the runner as
|
|
150
|
+
* all-samples-errored (see {@link cellCrashReason}) — records an error cell
|
|
151
|
+
* and the loop continues. The caller maps "any error cell" to a non-zero
|
|
152
|
+
* exit AFTER rendering the matrix — a crashed cell is distinct from a cell
|
|
153
|
+
* that ran with failing samples, which is a normal result.
|
|
154
|
+
*/
|
|
155
|
+
export async function runMatrixCells(opts) {
|
|
156
|
+
const write = opts.write ?? ((line) => process.stdout.write(`${line}\n`));
|
|
157
|
+
const cells = [];
|
|
158
|
+
for (const model of opts.models) {
|
|
159
|
+
const slug = opts.slugs.get(model) ?? modelSlug(model);
|
|
160
|
+
const outDir = join(opts.rootDir, slug);
|
|
161
|
+
write(`[eval] cell ${model} → ${outDir}`);
|
|
162
|
+
try {
|
|
163
|
+
const summary = await opts.runCell(model, outDir);
|
|
164
|
+
const crashed = cellCrashReason(summary);
|
|
165
|
+
if (crashed !== undefined) {
|
|
166
|
+
cells.push({ model, slug, outDir, error: crashed });
|
|
167
|
+
write(`[eval] cell FAILED (${crashed}) — continuing with remaining models`);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
cells.push({ model, slug, outDir, summary });
|
|
171
|
+
write(`[eval] pass_rate=${(summary.aggregates.passRate * 100).toFixed(1)}% ` +
|
|
172
|
+
`mean_score=${summary.aggregates.meanScore.toFixed(3)} ` +
|
|
173
|
+
`errors=${summary.aggregates.errorCount}`);
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
177
|
+
cells.push({ model, slug, outDir, error: msg });
|
|
178
|
+
write(`[eval] cell FAILED (${msg}) — continuing with remaining models`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return cells;
|
|
182
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Item 30 — nightly model-drift sentinel evals against a FROZEN baseline.
|
|
3
|
+
*
|
|
4
|
+
* `crewhaus eval --sentinel --baseline <run-dir>` re-runs a seed-pinned
|
|
5
|
+
* sentinel dataset against the UNCHANGED spec, then compares the fresh run to
|
|
6
|
+
* a stored baseline run. The premise: when the spec (specHash), the dataset
|
|
7
|
+
* (content hash), the judge model, AND the graders config (content hash) are
|
|
8
|
+
* ALL byte-identical to the baseline, ANY pass/fail flip or score shift can
|
|
9
|
+
* only be attributed to the PROVIDER silently changing model behaviour — the
|
|
10
|
+
* one variable the harness did not hold fixed. So the sentinel:
|
|
11
|
+
*
|
|
12
|
+
* 1. asserts `specHash` equality (baseline vs fresh run) — a mismatch means
|
|
13
|
+
* the spec changed, so this is NOT a clean drift probe: report and exit
|
|
14
|
+
* non-zero so a mis-pointed sentinel is loud rather than silently green.
|
|
15
|
+
* 2. asserts DATASET-HASH equality. run.json only records `datasetName`, so
|
|
16
|
+
* the caller sha256's the dataset content itself and passes both hashes;
|
|
17
|
+
* a mismatch (different sentinel data) is likewise NOT a clean probe.
|
|
18
|
+
* 3. asserts `config.judgeModel` equality AND `config.gradersHash` equality
|
|
19
|
+
* (sha256 of the parsed GradersConfig) — a different `--judge-model` or
|
|
20
|
+
* an edited graders.yaml changes what "pass" MEANS independent of the
|
|
21
|
+
* provider under test, so either mismatch is likewise NOT a clean probe
|
|
22
|
+
* (F2: without this, a judge/grader change silently reads as "provider
|
|
23
|
+
* drift" because neither ever touched specHash or the dataset).
|
|
24
|
+
* 4. with every hash equal, diffs the two runs (`diffReports`) and gates on
|
|
25
|
+
* the strict regression-runner defaults. Any regression or score shift is
|
|
26
|
+
* flagged as provider drift; the command exits non-zero so a CI cron /
|
|
27
|
+
* scheduler alerts.
|
|
28
|
+
*
|
|
29
|
+
* Side-effect-free (this package's entry file runs an argv switch on import):
|
|
30
|
+
* the fresh run + baseline load are performed by the caller and passed in as
|
|
31
|
+
* `LoadedRun`s, so the drift logic is unit-testable with seeded run dirs.
|
|
32
|
+
*/
|
|
33
|
+
import { type LoadedRun, type ReportDiff } from "@crewhaus/eval-report";
|
|
34
|
+
export type SentinelVerdict = "clean" | "drift" | "not-comparable";
|
|
35
|
+
export type SentinelResult = {
|
|
36
|
+
readonly verdict: SentinelVerdict;
|
|
37
|
+
/** True when the caller should exit non-zero (drift OR not-comparable). */
|
|
38
|
+
readonly alert: boolean;
|
|
39
|
+
readonly reason: string;
|
|
40
|
+
/** The score/flip diff, present only when the runs were comparable. */
|
|
41
|
+
readonly diff?: ReportDiff;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Compare a fresh run against a frozen baseline and decide whether the
|
|
45
|
+
* provider drifted. `baselineDatasetHash` / `currentDatasetHash` are the
|
|
46
|
+
* sha256 of the dataset CONTENT (run.json records only `datasetName`), so the
|
|
47
|
+
* caller hashes the dataset file/registry record and passes both in.
|
|
48
|
+
*
|
|
49
|
+
* Verdicts:
|
|
50
|
+
* - `not-comparable` (alert): specHash, dataset-hash, judgeModel, or
|
|
51
|
+
* gradersHash differs — the spec, the sentinel data, or what "pass" means
|
|
52
|
+
* changed, so a diff cannot be attributed to the provider. Loud, not
|
|
53
|
+
* silent: a mis-pointed sentinel must not read green.
|
|
54
|
+
* - `drift` (alert): every hash/model equal AND the diff shows a regression
|
|
55
|
+
* or a score shift ⇒ the provider silently changed model behaviour.
|
|
56
|
+
* - `clean` (no alert): every hash/model equal AND no regression / no score
|
|
57
|
+
* shift (recoveries alone are not drift-alerting — a provider getting
|
|
58
|
+
* BETTER on frozen inputs is still worth noting but is not a failure).
|
|
59
|
+
*/
|
|
60
|
+
export declare function evaluateSentinel(opts: {
|
|
61
|
+
readonly baseline: LoadedRun;
|
|
62
|
+
readonly current: LoadedRun;
|
|
63
|
+
readonly baselineDatasetHash: string;
|
|
64
|
+
readonly currentDatasetHash: string;
|
|
65
|
+
}): SentinelResult;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Item 30 — nightly model-drift sentinel evals against a FROZEN baseline.
|
|
3
|
+
*
|
|
4
|
+
* `crewhaus eval --sentinel --baseline <run-dir>` re-runs a seed-pinned
|
|
5
|
+
* sentinel dataset against the UNCHANGED spec, then compares the fresh run to
|
|
6
|
+
* a stored baseline run. The premise: when the spec (specHash), the dataset
|
|
7
|
+
* (content hash), the judge model, AND the graders config (content hash) are
|
|
8
|
+
* ALL byte-identical to the baseline, ANY pass/fail flip or score shift can
|
|
9
|
+
* only be attributed to the PROVIDER silently changing model behaviour — the
|
|
10
|
+
* one variable the harness did not hold fixed. So the sentinel:
|
|
11
|
+
*
|
|
12
|
+
* 1. asserts `specHash` equality (baseline vs fresh run) — a mismatch means
|
|
13
|
+
* the spec changed, so this is NOT a clean drift probe: report and exit
|
|
14
|
+
* non-zero so a mis-pointed sentinel is loud rather than silently green.
|
|
15
|
+
* 2. asserts DATASET-HASH equality. run.json only records `datasetName`, so
|
|
16
|
+
* the caller sha256's the dataset content itself and passes both hashes;
|
|
17
|
+
* a mismatch (different sentinel data) is likewise NOT a clean probe.
|
|
18
|
+
* 3. asserts `config.judgeModel` equality AND `config.gradersHash` equality
|
|
19
|
+
* (sha256 of the parsed GradersConfig) — a different `--judge-model` or
|
|
20
|
+
* an edited graders.yaml changes what "pass" MEANS independent of the
|
|
21
|
+
* provider under test, so either mismatch is likewise NOT a clean probe
|
|
22
|
+
* (F2: without this, a judge/grader change silently reads as "provider
|
|
23
|
+
* drift" because neither ever touched specHash or the dataset).
|
|
24
|
+
* 4. with every hash equal, diffs the two runs (`diffReports`) and gates on
|
|
25
|
+
* the strict regression-runner defaults. Any regression or score shift is
|
|
26
|
+
* flagged as provider drift; the command exits non-zero so a CI cron /
|
|
27
|
+
* scheduler alerts.
|
|
28
|
+
*
|
|
29
|
+
* Side-effect-free (this package's entry file runs an argv switch on import):
|
|
30
|
+
* the fresh run + baseline load are performed by the caller and passed in as
|
|
31
|
+
* `LoadedRun`s, so the drift logic is unit-testable with seeded run dirs.
|
|
32
|
+
*/
|
|
33
|
+
import { ReportError, diffReports } from "@crewhaus/eval-report";
|
|
34
|
+
/**
|
|
35
|
+
* Compare a fresh run against a frozen baseline and decide whether the
|
|
36
|
+
* provider drifted. `baselineDatasetHash` / `currentDatasetHash` are the
|
|
37
|
+
* sha256 of the dataset CONTENT (run.json records only `datasetName`), so the
|
|
38
|
+
* caller hashes the dataset file/registry record and passes both in.
|
|
39
|
+
*
|
|
40
|
+
* Verdicts:
|
|
41
|
+
* - `not-comparable` (alert): specHash, dataset-hash, judgeModel, or
|
|
42
|
+
* gradersHash differs — the spec, the sentinel data, or what "pass" means
|
|
43
|
+
* changed, so a diff cannot be attributed to the provider. Loud, not
|
|
44
|
+
* silent: a mis-pointed sentinel must not read green.
|
|
45
|
+
* - `drift` (alert): every hash/model equal AND the diff shows a regression
|
|
46
|
+
* or a score shift ⇒ the provider silently changed model behaviour.
|
|
47
|
+
* - `clean` (no alert): every hash/model equal AND no regression / no score
|
|
48
|
+
* shift (recoveries alone are not drift-alerting — a provider getting
|
|
49
|
+
* BETTER on frozen inputs is still worth noting but is not a failure).
|
|
50
|
+
*/
|
|
51
|
+
export function evaluateSentinel(opts) {
|
|
52
|
+
const baseSpecHash = opts.baseline.summary.config.specHash;
|
|
53
|
+
const curSpecHash = opts.current.summary.config.specHash;
|
|
54
|
+
if (baseSpecHash !== curSpecHash) {
|
|
55
|
+
return {
|
|
56
|
+
verdict: "not-comparable",
|
|
57
|
+
alert: true,
|
|
58
|
+
reason: `spec changed since the baseline (specHash ${short(baseSpecHash)} → ${short(curSpecHash)}) — a sentinel probe requires an UNCHANGED spec; re-pin the baseline against the current spec`,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
if (opts.baselineDatasetHash !== opts.currentDatasetHash) {
|
|
62
|
+
return {
|
|
63
|
+
verdict: "not-comparable",
|
|
64
|
+
alert: true,
|
|
65
|
+
reason: `sentinel dataset changed since the baseline (datasetHash ${short(opts.baselineDatasetHash)} → ${short(opts.currentDatasetHash)}) — a sentinel probe requires byte-identical data; re-pin the baseline against the current dataset`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
// F2 — a different judge model or an edited graders.yaml changes what
|
|
69
|
+
// "pass" MEANS independent of the provider under test. Neither touches
|
|
70
|
+
// specHash or the dataset hash, so without this check the sentinel would
|
|
71
|
+
// misattribute the resulting score shift to "provider drift".
|
|
72
|
+
const baseJudgeModel = opts.baseline.summary.config.judgeModel;
|
|
73
|
+
const curJudgeModel = opts.current.summary.config.judgeModel;
|
|
74
|
+
if (baseJudgeModel !== curJudgeModel) {
|
|
75
|
+
return {
|
|
76
|
+
verdict: "not-comparable",
|
|
77
|
+
alert: true,
|
|
78
|
+
reason: `judge model changed since the baseline (judgeModel ${judgeModelLabel(baseJudgeModel)} → ${judgeModelLabel(curJudgeModel)}) — a sentinel probe requires an UNCHANGED judge model; re-pin the baseline against the current --judge-model`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
const baseGradersHash = opts.baseline.summary.config.gradersHash;
|
|
82
|
+
const curGradersHash = opts.current.summary.config.gradersHash;
|
|
83
|
+
if (baseGradersHash !== curGradersHash) {
|
|
84
|
+
return {
|
|
85
|
+
verdict: "not-comparable",
|
|
86
|
+
alert: true,
|
|
87
|
+
reason: `graders config changed since the baseline (gradersHash ${short(baseGradersHash)} → ${short(curGradersHash)}) — a sentinel probe requires byte-identical graders; re-pin the baseline against the current graders.yaml`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
let diff;
|
|
91
|
+
try {
|
|
92
|
+
diff = diffReports(opts.baseline, opts.current).diff;
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
if (err instanceof ReportError) {
|
|
96
|
+
// Same specHash + dataset-hash but a keyset mismatch is a contradiction
|
|
97
|
+
// (identical data can't change sample ids) — surface it as not-comparable
|
|
98
|
+
// rather than crash the nightly job.
|
|
99
|
+
return {
|
|
100
|
+
verdict: "not-comparable",
|
|
101
|
+
alert: true,
|
|
102
|
+
reason: `sentinel diff failed despite equal hashes: ${err.message}`,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
throw err;
|
|
106
|
+
}
|
|
107
|
+
const drifted = diff.regressions.length > 0 || diff.scoreShifts.length > 0;
|
|
108
|
+
if (drifted) {
|
|
109
|
+
return {
|
|
110
|
+
verdict: "drift",
|
|
111
|
+
alert: true,
|
|
112
|
+
reason: `provider drift on a frozen spec+dataset+judge+graders: ${diff.regressions.length} regression(s), ` +
|
|
113
|
+
`${diff.scoreShifts.length} score-shift(s), ${diff.recoveries.length} recovery(-ies) ` +
|
|
114
|
+
`(specHash ${short(baseSpecHash)}, datasetHash ${short(opts.baselineDatasetHash)} unchanged)`,
|
|
115
|
+
diff,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
verdict: "clean",
|
|
120
|
+
alert: false,
|
|
121
|
+
reason: `no drift: spec+dataset+judge+graders frozen and every sample held its verdict (${diff.recoveries.length} recovery(-ies), ${diff.unchanged} unchanged)`,
|
|
122
|
+
diff,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
function judgeModelLabel(model) {
|
|
126
|
+
return model === undefined ? "(default)" : model;
|
|
127
|
+
}
|
|
128
|
+
function short(hash) {
|
|
129
|
+
if (hash === undefined)
|
|
130
|
+
return "(none)";
|
|
131
|
+
return hash.length > 12 ? `${hash.slice(0, 12)}…` : hash;
|
|
132
|
+
}
|