crewhaus 0.1.8 → 0.2.1
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 +173 -0
- package/dist/dataset-mine.js +404 -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 +9 -2
- package/dist/feedback.js +17 -7
- 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 +11664 -963
- 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/route.d.ts +19 -0
- package/dist/route.js +90 -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 +174 -0
- package/dist/security-digest.js +656 -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 +300 -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 +90 -65
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type { LoggedEvent, SessionTurn } from "./feedback";
|
|
2
|
+
/** Thrown on malformed flags / unusable inputs. The CLI entry file routes it
|
|
3
|
+
* through `die()`; tests assert on `.message` without the process exiting. */
|
|
4
|
+
export declare class EvalCoverageError extends Error {
|
|
5
|
+
readonly name = "EvalCoverageError";
|
|
6
|
+
}
|
|
7
|
+
/** How many sessions feed the distribution by default (most-recent first). */
|
|
8
|
+
export declare const DEFAULT_COVERAGE_SESSIONS = 50;
|
|
9
|
+
export type CoverageFormat = "text" | "html" | "json";
|
|
10
|
+
/** Parse `--format`; defaults to text. */
|
|
11
|
+
export declare function parseCoverageFormat(value: string | undefined): CoverageFormat;
|
|
12
|
+
/** Parse `--sessions`: a positive integer or `all`. */
|
|
13
|
+
export declare function parseSessionsFlag(value: string | undefined): number | "all";
|
|
14
|
+
/** The tool names an `assistant_message` payload called, verbatim (PascalCase
|
|
15
|
+
* builtins, `mcp__`-prefixed MCP tools), in first-seen order. */
|
|
16
|
+
export declare function assistantToolNames(payload: unknown): string[];
|
|
17
|
+
/** True when a tool name is an MCP tool (verbatim `mcp__`-prefixed). */
|
|
18
|
+
export declare function isMcpTool(name: string): boolean;
|
|
19
|
+
export type ProdBehavior = {
|
|
20
|
+
/** Sessions scanned. */
|
|
21
|
+
readonly sessionCount: number;
|
|
22
|
+
/** tool name → number of SESSIONS in which it was called at least once. */
|
|
23
|
+
readonly toolSessions: ReadonlyMap<string, number>;
|
|
24
|
+
/** tool name → total call count across all sessions. */
|
|
25
|
+
readonly toolCalls: ReadonlyMap<string, number>;
|
|
26
|
+
/** ordered `A B` tool bigram → number of sessions it appeared in. */
|
|
27
|
+
readonly bigramSessions: ReadonlyMap<string, number>;
|
|
28
|
+
/** Number of sessions in which a `compaction` event fired. */
|
|
29
|
+
readonly compactionSessions: number;
|
|
30
|
+
/** Deterministic clusters of user-input themes. */
|
|
31
|
+
readonly inputThemes: ReadonlyArray<InputTheme>;
|
|
32
|
+
};
|
|
33
|
+
export type InputTheme = {
|
|
34
|
+
/** Top tokens joined — the human handle. */
|
|
35
|
+
readonly label: string;
|
|
36
|
+
readonly tokens: ReadonlyArray<string>;
|
|
37
|
+
/** How many user inputs fell in this cluster. */
|
|
38
|
+
readonly count: number;
|
|
39
|
+
/** A representative input (shortest, for a compact exemplar). */
|
|
40
|
+
readonly exemplar: string;
|
|
41
|
+
};
|
|
42
|
+
/** Split a bigram key back into its two tool names. */
|
|
43
|
+
export declare function splitBigram(key: string): [string, string];
|
|
44
|
+
/**
|
|
45
|
+
* Build the production behavior distribution from a set of sessions' parsed
|
|
46
|
+
* event arrays. Each session contributes at most once to the per-session
|
|
47
|
+
* counts (tool_sessions / bigram_sessions / compaction), so a chatty session
|
|
48
|
+
* cannot dominate the frequency ranking; raw call totals are kept separately.
|
|
49
|
+
* User inputs are clustered by the same deterministic token overlap
|
|
50
|
+
* `graders-suggest` uses.
|
|
51
|
+
*/
|
|
52
|
+
export declare function buildProdBehavior(sessions: ReadonlyArray<{
|
|
53
|
+
sessionId: string;
|
|
54
|
+
events: ReadonlyArray<LoggedEvent>;
|
|
55
|
+
}>): ProdBehavior;
|
|
56
|
+
/**
|
|
57
|
+
* Deterministic greedy clustering of user inputs by normalized token overlap
|
|
58
|
+
* — the same seed-comparison algorithm `graders-suggest.clusterFailures`
|
|
59
|
+
* uses, inlined here over plain strings. Inputs are visited in a stable order
|
|
60
|
+
* (text asc); themes come back largest-first, labelled by their most frequent
|
|
61
|
+
* tokens. Inputs with no clusterable tokens are dropped.
|
|
62
|
+
*/
|
|
63
|
+
export declare function clusterInputs(inputs: ReadonlyArray<string>, threshold?: number): InputTheme[];
|
|
64
|
+
export type EvalCoverage = {
|
|
65
|
+
/** Every tool named in a Sample's `expected_tools`, or observed in the most
|
|
66
|
+
* recent eval run's per-sample events. Compared against prod, so the set
|
|
67
|
+
* IS the coverage. */
|
|
68
|
+
readonly toolsExercised: ReadonlySet<string>;
|
|
69
|
+
/** Ordered `A B` tool bigrams present in any Sample's expected_tools
|
|
70
|
+
* sequence, or the eval run's per-sample tool stream. */
|
|
71
|
+
readonly bigramsExercised: ReadonlySet<string>;
|
|
72
|
+
/** Number of dataset samples inspected. */
|
|
73
|
+
readonly sampleCount: number;
|
|
74
|
+
/** Whether a recent eval run's per-sample events were available. */
|
|
75
|
+
readonly hasRunEvents: boolean;
|
|
76
|
+
/** Whether any sample declared expected_tools. */
|
|
77
|
+
readonly hasExpectedTools: boolean;
|
|
78
|
+
};
|
|
79
|
+
/** Minimal sample view the coverage computation needs. */
|
|
80
|
+
export type CoverageSample = {
|
|
81
|
+
readonly expected_tools?: ReadonlyArray<string>;
|
|
82
|
+
};
|
|
83
|
+
/**
|
|
84
|
+
* Build the eval-coverage distribution from the dataset's `expected_tools` and
|
|
85
|
+
* (optionally) the most recent eval run's per-sample `events.jsonl` texts.
|
|
86
|
+
* Both a sample's expected_tools sequence and a run's observed tool stream
|
|
87
|
+
* contribute their ordered bigrams, so a bigram is "exercised" if EITHER the
|
|
88
|
+
* dataset asserts it or a real eval run walked it.
|
|
89
|
+
*/
|
|
90
|
+
export declare function buildEvalCoverage(samples: ReadonlyArray<CoverageSample>, runEventTexts: ReadonlyArray<string>): EvalCoverage;
|
|
91
|
+
export type CoverageGapKind = "tool" | "mcp-tool" | "bigram" | "compaction";
|
|
92
|
+
export type CoverageGap = {
|
|
93
|
+
readonly kind: CoverageGapKind;
|
|
94
|
+
/** Human-readable subject: a tool name, `A → B` bigram, or "compaction". */
|
|
95
|
+
readonly subject: string;
|
|
96
|
+
/** Sessions the behavior appeared in. */
|
|
97
|
+
readonly sessions: number;
|
|
98
|
+
/** Fraction of scanned sessions (0..1). */
|
|
99
|
+
readonly fraction: number;
|
|
100
|
+
/** One-line rationale for the report. */
|
|
101
|
+
readonly detail: string;
|
|
102
|
+
};
|
|
103
|
+
export type CoverageReport = {
|
|
104
|
+
readonly specName?: string;
|
|
105
|
+
readonly sessionsScanned: number;
|
|
106
|
+
readonly datasetName?: string;
|
|
107
|
+
readonly sampleCount: number;
|
|
108
|
+
readonly hasRunEvents: boolean;
|
|
109
|
+
/** Ranked (by session frequency desc) production behaviors with no eval. */
|
|
110
|
+
readonly gaps: ReadonlyArray<CoverageGap>;
|
|
111
|
+
/** Input themes with no obviously-covering sample (advisory — inputs aren't
|
|
112
|
+
* directly intersected, so these are surfaced, not counted as hard gaps). */
|
|
113
|
+
readonly inputThemes: ReadonlyArray<InputTheme>;
|
|
114
|
+
};
|
|
115
|
+
/**
|
|
116
|
+
* Intersect the production distribution with the eval coverage and rank the
|
|
117
|
+
* gaps. A tool/bigram is a gap when it appears in ≥1 production session but is
|
|
118
|
+
* never exercised by the dataset or a recent eval run. Compaction is a gap
|
|
119
|
+
* when it fired in prod but no run event stream shows it was exercised (eval
|
|
120
|
+
* runs don't compact, so any prod compaction is by definition uncovered —
|
|
121
|
+
* surfaced whenever it fired at all). Gaps are ranked by session frequency
|
|
122
|
+
* desc, then subject asc for stability.
|
|
123
|
+
*/
|
|
124
|
+
export declare function computeCoverage(opts: {
|
|
125
|
+
prod: ProdBehavior;
|
|
126
|
+
evalCov: EvalCoverage;
|
|
127
|
+
specName?: string;
|
|
128
|
+
datasetName?: string;
|
|
129
|
+
}): CoverageReport;
|
|
130
|
+
/** The JSON backlog — stable, ranked, consumable by `dataset mine`. */
|
|
131
|
+
export declare function renderCoverageJson(report: CoverageReport): string;
|
|
132
|
+
/** The plain-text report — the default terminal output. */
|
|
133
|
+
export declare function renderCoverageText(report: CoverageReport): string;
|
|
134
|
+
/** Dependency-free HTML report, styled to mirror eval-report / graders-suggest. */
|
|
135
|
+
export declare function renderCoverageHtml(report: CoverageReport): string;
|
|
136
|
+
/** Dispatch the requested format. */
|
|
137
|
+
export declare function renderCoverage(report: CoverageReport, format: CoverageFormat): string;
|
|
138
|
+
/** Suggested output filename for a `-o <dir>` write (text/html/json). */
|
|
139
|
+
export declare function coverageFileName(format: CoverageFormat): string;
|
|
140
|
+
export type { SessionTurn };
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Item 6 — `crewhaus eval coverage`: detect agent behaviors that PRODUCTION
|
|
3
|
+
* exercises but the eval dataset never does. Answers "what is the agent doing
|
|
4
|
+
* in the wild that my eval is blind to?" so a user can close the gaps before
|
|
5
|
+
* they ship a regression.
|
|
6
|
+
*
|
|
7
|
+
* Two distributions are built and intersected, both DETERMINISTICALLY (no
|
|
8
|
+
* model call — the same clustering `graders-suggest` uses):
|
|
9
|
+
*
|
|
10
|
+
* - PRODUCTION behavior, from the cwd spec's session JSONLs
|
|
11
|
+
* (`.crewhaus/sessions/*.jsonl`): tool/MCP call frequencies (parsed from
|
|
12
|
+
* `assistant_message` `tool_use` blocks — MCP tools carry the `mcp__`
|
|
13
|
+
* prefix), tool sequence bigrams, compaction-fired frequency, and clustered
|
|
14
|
+
* user-input themes (via `deriveTurns` + token clustering).
|
|
15
|
+
* - EVAL coverage, from what the dataset EXERCISES: each Sample's
|
|
16
|
+
* `expected_tools` (file or `registry:` ref) plus the tools actually used
|
|
17
|
+
* in the most recent eval run's per-sample `events.jsonl` (located via the
|
|
18
|
+
* item-3 run-history index; the eval events use the `tool_call_end` trace
|
|
19
|
+
* shape, distinct from the production `assistant_message` shape).
|
|
20
|
+
*
|
|
21
|
+
* The report is a ranked backlog of gaps — tools/bigrams/behaviors present in
|
|
22
|
+
* prod but absent from eval — rendered as text, dependency-free HTML (mirrors
|
|
23
|
+
* eval-report / graders-suggest style), or JSON. The JSON is a stable,
|
|
24
|
+
* ranked backlog `dataset mine` (item 2) can consume.
|
|
25
|
+
*
|
|
26
|
+
* Kept in a side-effect-free module (the CLI entry file runs an argv switch on
|
|
27
|
+
* import) mirroring `feedback.ts` / `graders-suggest.ts`; all filesystem
|
|
28
|
+
* access and registry/run-index resolution live in `apps/cli/src/index.ts`.
|
|
29
|
+
*/
|
|
30
|
+
import { deriveTurns } from "./feedback";
|
|
31
|
+
import { normalizeEvidenceTokens, toolNamesFromEventsJsonl } from "./graders-suggest";
|
|
32
|
+
/** Thrown on malformed flags / unusable inputs. The CLI entry file routes it
|
|
33
|
+
* through `die()`; tests assert on `.message` without the process exiting. */
|
|
34
|
+
export class EvalCoverageError extends Error {
|
|
35
|
+
name = "EvalCoverageError";
|
|
36
|
+
}
|
|
37
|
+
/** How many sessions feed the distribution by default (most-recent first). */
|
|
38
|
+
export const DEFAULT_COVERAGE_SESSIONS = 50;
|
|
39
|
+
/** Parse `--format`; defaults to text. */
|
|
40
|
+
export function parseCoverageFormat(value) {
|
|
41
|
+
if (value === undefined)
|
|
42
|
+
return "text";
|
|
43
|
+
if (value === "text" || value === "html" || value === "json")
|
|
44
|
+
return value;
|
|
45
|
+
throw new EvalCoverageError(`invalid --format "${value}" — expected text, html, or json`);
|
|
46
|
+
}
|
|
47
|
+
/** Parse `--sessions`: a positive integer or `all`. */
|
|
48
|
+
export function parseSessionsFlag(value) {
|
|
49
|
+
if (value === undefined)
|
|
50
|
+
return DEFAULT_COVERAGE_SESSIONS;
|
|
51
|
+
if (value.trim().toLowerCase() === "all")
|
|
52
|
+
return "all";
|
|
53
|
+
if (!/^\d+$/.test(value.trim())) {
|
|
54
|
+
throw new EvalCoverageError(`invalid --sessions "${value}" — expected a positive integer or "all"`);
|
|
55
|
+
}
|
|
56
|
+
const n = Number.parseInt(value, 10);
|
|
57
|
+
if (n < 1)
|
|
58
|
+
throw new EvalCoverageError(`invalid --sessions "${value}" — need at least 1`);
|
|
59
|
+
return n;
|
|
60
|
+
}
|
|
61
|
+
function contentBlocks(payload) {
|
|
62
|
+
const content = payload?.content;
|
|
63
|
+
return Array.isArray(content) ? content : [];
|
|
64
|
+
}
|
|
65
|
+
/** The tool names an `assistant_message` payload called, verbatim (PascalCase
|
|
66
|
+
* builtins, `mcp__`-prefixed MCP tools), in first-seen order. */
|
|
67
|
+
export function assistantToolNames(payload) {
|
|
68
|
+
return contentBlocks(payload)
|
|
69
|
+
.filter((b) => b.type === "tool_use" && typeof b.name === "string")
|
|
70
|
+
.map((b) => b.name);
|
|
71
|
+
}
|
|
72
|
+
/** True when a tool name is an MCP tool (verbatim `mcp__`-prefixed). */
|
|
73
|
+
export function isMcpTool(name) {
|
|
74
|
+
return name.startsWith("mcp__");
|
|
75
|
+
}
|
|
76
|
+
const BIGRAM_SEP = " ";
|
|
77
|
+
/** Split a bigram key back into its two tool names. */
|
|
78
|
+
export function splitBigram(key) {
|
|
79
|
+
const idx = key.indexOf(BIGRAM_SEP);
|
|
80
|
+
return [key.slice(0, idx), key.slice(idx + 1)];
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Build the production behavior distribution from a set of sessions' parsed
|
|
84
|
+
* event arrays. Each session contributes at most once to the per-session
|
|
85
|
+
* counts (tool_sessions / bigram_sessions / compaction), so a chatty session
|
|
86
|
+
* cannot dominate the frequency ranking; raw call totals are kept separately.
|
|
87
|
+
* User inputs are clustered by the same deterministic token overlap
|
|
88
|
+
* `graders-suggest` uses.
|
|
89
|
+
*/
|
|
90
|
+
export function buildProdBehavior(sessions) {
|
|
91
|
+
const toolSessions = new Map();
|
|
92
|
+
const toolCalls = new Map();
|
|
93
|
+
const bigramSessions = new Map();
|
|
94
|
+
let compactionSessions = 0;
|
|
95
|
+
const allInputs = [];
|
|
96
|
+
for (const { events } of sessions) {
|
|
97
|
+
const seenTools = new Set();
|
|
98
|
+
const seenBigrams = new Set();
|
|
99
|
+
// Ordered tool-call stream across the whole session (for bigrams).
|
|
100
|
+
const toolStream = [];
|
|
101
|
+
let sawCompaction = false;
|
|
102
|
+
for (const ev of events) {
|
|
103
|
+
if (ev.kind === "assistant_message") {
|
|
104
|
+
for (const name of assistantToolNames(ev.payload)) {
|
|
105
|
+
toolCalls.set(name, (toolCalls.get(name) ?? 0) + 1);
|
|
106
|
+
seenTools.add(name);
|
|
107
|
+
toolStream.push(name);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
else if (ev.kind === "compaction") {
|
|
111
|
+
sawCompaction = true;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
for (const name of seenTools)
|
|
115
|
+
toolSessions.set(name, (toolSessions.get(name) ?? 0) + 1);
|
|
116
|
+
for (let i = 0; i + 1 < toolStream.length; i += 1) {
|
|
117
|
+
const key = `${toolStream[i]}${BIGRAM_SEP}${toolStream[i + 1]}`;
|
|
118
|
+
seenBigrams.add(key);
|
|
119
|
+
}
|
|
120
|
+
for (const key of seenBigrams)
|
|
121
|
+
bigramSessions.set(key, (bigramSessions.get(key) ?? 0) + 1);
|
|
122
|
+
if (sawCompaction)
|
|
123
|
+
compactionSessions += 1;
|
|
124
|
+
// Derived user-input text for clustering.
|
|
125
|
+
for (const t of deriveTurns(events)) {
|
|
126
|
+
if (t.input.trim() !== "")
|
|
127
|
+
allInputs.push(t.input);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
sessionCount: sessions.length,
|
|
132
|
+
toolSessions,
|
|
133
|
+
toolCalls,
|
|
134
|
+
bigramSessions,
|
|
135
|
+
compactionSessions,
|
|
136
|
+
inputThemes: clusterInputs(allInputs),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Deterministic greedy clustering of user inputs by normalized token overlap
|
|
141
|
+
* — the same seed-comparison algorithm `graders-suggest.clusterFailures`
|
|
142
|
+
* uses, inlined here over plain strings. Inputs are visited in a stable order
|
|
143
|
+
* (text asc); themes come back largest-first, labelled by their most frequent
|
|
144
|
+
* tokens. Inputs with no clusterable tokens are dropped.
|
|
145
|
+
*/
|
|
146
|
+
export function clusterInputs(inputs, threshold = 0.34) {
|
|
147
|
+
const sorted = [...inputs].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
148
|
+
const clusters = [];
|
|
149
|
+
for (const input of sorted) {
|
|
150
|
+
const tokens = new Set(normalizeEvidenceTokens(input));
|
|
151
|
+
if (tokens.size === 0)
|
|
152
|
+
continue;
|
|
153
|
+
let bestIdx = -1;
|
|
154
|
+
let bestOverlap = 0;
|
|
155
|
+
for (let i = 0; i < clusters.length; i += 1) {
|
|
156
|
+
const overlap = jaccard(tokens, clusters[i].seed);
|
|
157
|
+
if (overlap > bestOverlap) {
|
|
158
|
+
bestOverlap = overlap;
|
|
159
|
+
bestIdx = i;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (bestIdx >= 0 && bestOverlap >= threshold) {
|
|
163
|
+
const c = clusters[bestIdx];
|
|
164
|
+
c.texts.push(input);
|
|
165
|
+
c.tokenSets.push(tokens);
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
clusters.push({ seed: tokens, texts: [input], tokenSets: [tokens] });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const themes = clusters.map((c) => {
|
|
172
|
+
const freq = new Map();
|
|
173
|
+
for (const set of c.tokenSets)
|
|
174
|
+
for (const t of set)
|
|
175
|
+
freq.set(t, (freq.get(t) ?? 0) + 1);
|
|
176
|
+
const tokens = [...freq.entries()]
|
|
177
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
178
|
+
.slice(0, 3)
|
|
179
|
+
.map(([t]) => t);
|
|
180
|
+
const exemplar = [...c.texts].sort((a, b) => a.length - b.length || (a < b ? -1 : 1))[0] ?? "";
|
|
181
|
+
return { label: tokens.join(" "), tokens, count: c.texts.length, exemplar };
|
|
182
|
+
});
|
|
183
|
+
return themes.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
|
|
184
|
+
}
|
|
185
|
+
function jaccard(a, b) {
|
|
186
|
+
let intersection = 0;
|
|
187
|
+
for (const t of a)
|
|
188
|
+
if (b.has(t))
|
|
189
|
+
intersection += 1;
|
|
190
|
+
const union = a.size + b.size - intersection;
|
|
191
|
+
return union === 0 ? 0 : intersection / union;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Build the eval-coverage distribution from the dataset's `expected_tools` and
|
|
195
|
+
* (optionally) the most recent eval run's per-sample `events.jsonl` texts.
|
|
196
|
+
* Both a sample's expected_tools sequence and a run's observed tool stream
|
|
197
|
+
* contribute their ordered bigrams, so a bigram is "exercised" if EITHER the
|
|
198
|
+
* dataset asserts it or a real eval run walked it.
|
|
199
|
+
*/
|
|
200
|
+
export function buildEvalCoverage(samples, runEventTexts) {
|
|
201
|
+
const tools = new Set();
|
|
202
|
+
const bigrams = new Set();
|
|
203
|
+
let hasExpectedTools = false;
|
|
204
|
+
for (const s of samples) {
|
|
205
|
+
const seq = s.expected_tools ?? [];
|
|
206
|
+
if (seq.length > 0)
|
|
207
|
+
hasExpectedTools = true;
|
|
208
|
+
for (const t of seq)
|
|
209
|
+
tools.add(t);
|
|
210
|
+
for (let i = 0; i + 1 < seq.length; i += 1) {
|
|
211
|
+
bigrams.add(`${seq[i]}${BIGRAM_SEP}${seq[i + 1]}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
for (const text of runEventTexts) {
|
|
215
|
+
const seq = toolNamesFromEventsJsonl(text);
|
|
216
|
+
for (const t of seq)
|
|
217
|
+
tools.add(t);
|
|
218
|
+
for (let i = 0; i + 1 < seq.length; i += 1) {
|
|
219
|
+
bigrams.add(`${seq[i]}${BIGRAM_SEP}${seq[i + 1]}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
toolsExercised: tools,
|
|
224
|
+
bigramsExercised: bigrams,
|
|
225
|
+
sampleCount: samples.length,
|
|
226
|
+
hasRunEvents: runEventTexts.length > 0,
|
|
227
|
+
hasExpectedTools,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
const pct = (f) => `${Math.round(f * 100)}%`;
|
|
231
|
+
/**
|
|
232
|
+
* Intersect the production distribution with the eval coverage and rank the
|
|
233
|
+
* gaps. A tool/bigram is a gap when it appears in ≥1 production session but is
|
|
234
|
+
* never exercised by the dataset or a recent eval run. Compaction is a gap
|
|
235
|
+
* when it fired in prod but no run event stream shows it was exercised (eval
|
|
236
|
+
* runs don't compact, so any prod compaction is by definition uncovered —
|
|
237
|
+
* surfaced whenever it fired at all). Gaps are ranked by session frequency
|
|
238
|
+
* desc, then subject asc for stability.
|
|
239
|
+
*/
|
|
240
|
+
export function computeCoverage(opts) {
|
|
241
|
+
const { prod, evalCov } = opts;
|
|
242
|
+
const gaps = [];
|
|
243
|
+
const n = prod.sessionCount;
|
|
244
|
+
const frac = (c) => (n === 0 ? 0 : c / n);
|
|
245
|
+
for (const [tool, sessions] of prod.toolSessions) {
|
|
246
|
+
if (evalCov.toolsExercised.has(tool))
|
|
247
|
+
continue;
|
|
248
|
+
const calls = prod.toolCalls.get(tool) ?? sessions;
|
|
249
|
+
gaps.push({
|
|
250
|
+
kind: isMcpTool(tool) ? "mcp-tool" : "tool",
|
|
251
|
+
subject: tool,
|
|
252
|
+
sessions,
|
|
253
|
+
fraction: frac(sessions),
|
|
254
|
+
detail: `${tool} appears in ${pct(frac(sessions))} of sessions (${sessions}/${n}, ${calls} call(s)) but 0 dataset samples exercise it`,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
for (const [key, sessions] of prod.bigramSessions) {
|
|
258
|
+
if (evalCov.bigramsExercised.has(key))
|
|
259
|
+
continue;
|
|
260
|
+
const [a, b] = splitBigram(key);
|
|
261
|
+
gaps.push({
|
|
262
|
+
kind: "bigram",
|
|
263
|
+
subject: `${a} → ${b}`,
|
|
264
|
+
sessions,
|
|
265
|
+
fraction: frac(sessions),
|
|
266
|
+
detail: `tool sequence ${a} → ${b} occurs in ${pct(frac(sessions))} of sessions (${sessions}/${n}) but no expected_tools sequence covers it`,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
if (prod.compactionSessions > 0) {
|
|
270
|
+
const f = frac(prod.compactionSessions);
|
|
271
|
+
gaps.push({
|
|
272
|
+
kind: "compaction",
|
|
273
|
+
subject: "compaction",
|
|
274
|
+
sessions: prod.compactionSessions,
|
|
275
|
+
fraction: f,
|
|
276
|
+
detail: `compaction fired in ${prod.compactionSessions} session(s) (${pct(f)}) but is never exercised in eval — add a long-context sample`,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
gaps.sort((a, b) => b.sessions - a.sessions ||
|
|
280
|
+
kindRank(a.kind) - kindRank(b.kind) ||
|
|
281
|
+
a.subject.localeCompare(b.subject));
|
|
282
|
+
return {
|
|
283
|
+
...(opts.specName !== undefined ? { specName: opts.specName } : {}),
|
|
284
|
+
sessionsScanned: n,
|
|
285
|
+
...(opts.datasetName !== undefined ? { datasetName: opts.datasetName } : {}),
|
|
286
|
+
sampleCount: evalCov.sampleCount,
|
|
287
|
+
hasRunEvents: evalCov.hasRunEvents,
|
|
288
|
+
gaps,
|
|
289
|
+
inputThemes: prod.inputThemes,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
function kindRank(kind) {
|
|
293
|
+
return kind === "mcp-tool" ? 0 : kind === "tool" ? 1 : kind === "compaction" ? 2 : 3;
|
|
294
|
+
}
|
|
295
|
+
// -------- rendering --------
|
|
296
|
+
/** The JSON backlog — stable, ranked, consumable by `dataset mine`. */
|
|
297
|
+
export function renderCoverageJson(report) {
|
|
298
|
+
const backlog = report.gaps.map((g) => ({
|
|
299
|
+
kind: g.kind,
|
|
300
|
+
subject: g.subject,
|
|
301
|
+
sessions: g.sessions,
|
|
302
|
+
fraction: Number(g.fraction.toFixed(4)),
|
|
303
|
+
detail: g.detail,
|
|
304
|
+
}));
|
|
305
|
+
return `${JSON.stringify({
|
|
306
|
+
spec: report.specName ?? null,
|
|
307
|
+
dataset: report.datasetName ?? null,
|
|
308
|
+
sessionsScanned: report.sessionsScanned,
|
|
309
|
+
sampleCount: report.sampleCount,
|
|
310
|
+
hasRunEvents: report.hasRunEvents,
|
|
311
|
+
gapCount: report.gaps.length,
|
|
312
|
+
backlog,
|
|
313
|
+
inputThemes: report.inputThemes.map((t) => ({
|
|
314
|
+
label: t.label,
|
|
315
|
+
tokens: t.tokens,
|
|
316
|
+
count: t.count,
|
|
317
|
+
exemplar: t.exemplar,
|
|
318
|
+
})),
|
|
319
|
+
}, null, 2)}\n`;
|
|
320
|
+
}
|
|
321
|
+
/** The plain-text report — the default terminal output. */
|
|
322
|
+
export function renderCoverageText(report) {
|
|
323
|
+
const lines = [];
|
|
324
|
+
const forSpec = report.specName !== undefined ? ` for "${report.specName}"` : "";
|
|
325
|
+
lines.push(`eval coverage${forSpec}: ${report.sessionsScanned} session(s) vs ${report.sampleCount} dataset sample(s)`);
|
|
326
|
+
if (report.datasetName !== undefined)
|
|
327
|
+
lines.push(` dataset: ${report.datasetName}`);
|
|
328
|
+
if (!report.hasRunEvents) {
|
|
329
|
+
lines.push(" (no recent eval run events found — coverage is dataset-expected_tools only)");
|
|
330
|
+
}
|
|
331
|
+
lines.push("");
|
|
332
|
+
if (report.gaps.length === 0) {
|
|
333
|
+
lines.push("no coverage gaps — every production tool/sequence is exercised by the eval.");
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
lines.push(`${report.gaps.length} coverage gap(s), ranked by production frequency:`);
|
|
337
|
+
for (const g of report.gaps)
|
|
338
|
+
lines.push(` - [${g.kind}] ${g.detail}`);
|
|
339
|
+
}
|
|
340
|
+
if (report.inputThemes.length > 0) {
|
|
341
|
+
lines.push("");
|
|
342
|
+
lines.push(`production input themes (${report.inputThemes.length}) — check the dataset probes each:`);
|
|
343
|
+
for (const t of report.inputThemes.slice(0, 10)) {
|
|
344
|
+
lines.push(` - "${t.label}" (${t.count} input(s)) e.g. ${clip(t.exemplar, 80)}`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return `${lines.join("\n")}\n`;
|
|
348
|
+
}
|
|
349
|
+
const clip = (s, max) => (s.length > max ? `${s.slice(0, max)}…` : s);
|
|
350
|
+
const escapeHtml = (s) => s
|
|
351
|
+
.replace(/&/g, "&")
|
|
352
|
+
.replace(/</g, "<")
|
|
353
|
+
.replace(/>/g, ">")
|
|
354
|
+
.replace(/"/g, """)
|
|
355
|
+
.replace(/'/g, "'");
|
|
356
|
+
/** Dependency-free HTML report, styled to mirror eval-report / graders-suggest. */
|
|
357
|
+
export function renderCoverageHtml(report) {
|
|
358
|
+
const forSpec = report.specName !== undefined ? ` — ${escapeHtml(report.specName)}` : "";
|
|
359
|
+
const rows = report.gaps
|
|
360
|
+
.map((g) => ` <tr>
|
|
361
|
+
<td><span class="kind kind-${g.kind}">${escapeHtml(g.kind)}</span></td>
|
|
362
|
+
<td>${escapeHtml(g.subject)}</td>
|
|
363
|
+
<td data-sort="${g.sessions}">${g.sessions} (${pct(g.fraction)})</td>
|
|
364
|
+
<td>${escapeHtml(g.detail)}</td>
|
|
365
|
+
</tr>`)
|
|
366
|
+
.join("\n");
|
|
367
|
+
const themes = report.inputThemes
|
|
368
|
+
.slice(0, 20)
|
|
369
|
+
.map((t) => ` <li><strong>${escapeHtml(t.label)}</strong> — ${t.count} input(s): <code>${escapeHtml(clip(t.exemplar, 120))}</code></li>`)
|
|
370
|
+
.join("\n");
|
|
371
|
+
const gapSection = report.gaps.length === 0
|
|
372
|
+
? `<p class="empty">No coverage gaps — every production tool/sequence is exercised by the eval.</p>`
|
|
373
|
+
: `<table data-sortable>
|
|
374
|
+
<thead><tr><th>kind</th><th>subject</th><th>sessions</th><th>detail</th></tr></thead>
|
|
375
|
+
<tbody>
|
|
376
|
+
${rows}
|
|
377
|
+
</tbody>
|
|
378
|
+
</table>`;
|
|
379
|
+
return `<!doctype html>
|
|
380
|
+
<html lang="en">
|
|
381
|
+
<head>
|
|
382
|
+
<meta charset="utf-8">
|
|
383
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
384
|
+
<title>eval coverage${forSpec}</title>
|
|
385
|
+
<style>
|
|
386
|
+
:root { --bg:#0f1115; --fg:#e6e6e6; --muted:#999; --card:#1a1d23; --border:#333; --link:#61dafb; --warn:#ffb74d; --mcp:#ef5350; }
|
|
387
|
+
* { box-sizing:border-box; }
|
|
388
|
+
body { font-family: ui-sans-serif, system-ui, sans-serif; background:var(--bg); color:var(--fg); margin:0; padding:24px; line-height:1.5; }
|
|
389
|
+
h1 { margin:0 0 8px; } h2 { margin:24px 0 12px; }
|
|
390
|
+
.meta { color:var(--muted); font-size:13px; margin-bottom:16px; }
|
|
391
|
+
table { width:100%; border-collapse:collapse; background:var(--card); border-radius:6px; overflow:hidden; }
|
|
392
|
+
th,td { padding:10px 14px; text-align:left; border-bottom:1px solid var(--border); font-size:14px; vertical-align:top; }
|
|
393
|
+
th { background:#14171c; cursor:pointer; user-select:none; } th:hover { color:var(--link); }
|
|
394
|
+
tr:last-child td { border-bottom:none; }
|
|
395
|
+
code { background:#0a0c10; padding:2px 5px; border-radius:4px; font-size:12px; }
|
|
396
|
+
.kind { font-weight:600; font-size:12px; text-transform:uppercase; }
|
|
397
|
+
.kind-mcp-tool { color:var(--mcp); } .kind-tool { color:var(--link); } .kind-bigram { color:var(--warn); } .kind-compaction { color:var(--muted); }
|
|
398
|
+
.empty { color:var(--muted); } ul { padding-left:20px; }
|
|
399
|
+
</style>
|
|
400
|
+
</head>
|
|
401
|
+
<body>
|
|
402
|
+
<h1>eval coverage${forSpec}</h1>
|
|
403
|
+
<p class="meta">${report.sessionsScanned} session(s) scanned vs ${report.sampleCount} dataset sample(s)${report.datasetName !== undefined ? ` (${escapeHtml(report.datasetName)})` : ""}${report.hasRunEvents ? "" : " — no recent eval run events; coverage is dataset-expected_tools only"}. ${report.gaps.length} gap(s).</p>
|
|
404
|
+
<h2>Coverage gaps</h2>
|
|
405
|
+
${gapSection}
|
|
406
|
+
${report.inputThemes.length > 0 ? `<h2>Production input themes</h2>\n<ul>\n${themes}\n</ul>` : ""}
|
|
407
|
+
<script>
|
|
408
|
+
(function(){
|
|
409
|
+
function sortTable(table,col){var tb=table.querySelector('tbody');if(!tb)return;var rows=Array.from(tb.rows);var asc=table.dataset.sortCol===String(col)?table.dataset.sortDir!=='asc':true;rows.sort(function(a,b){var va=a.cells[col].dataset.sort||a.cells[col].textContent.trim();var vb=b.cells[col].dataset.sort||b.cells[col].textContent.trim();var na=parseFloat(va),nb=parseFloat(vb);if(!isNaN(na)&&!isNaN(nb))return asc?na-nb:nb-na;return asc?va.localeCompare(vb):vb.localeCompare(va);});rows.forEach(function(r){tb.appendChild(r);});table.dataset.sortCol=String(col);table.dataset.sortDir=asc?'asc':'desc';}
|
|
410
|
+
document.querySelectorAll('table[data-sortable] th').forEach(function(th,i){th.addEventListener('click',function(){sortTable(th.closest('table'),i);});});
|
|
411
|
+
})();
|
|
412
|
+
</script>
|
|
413
|
+
</body>
|
|
414
|
+
</html>
|
|
415
|
+
`;
|
|
416
|
+
}
|
|
417
|
+
/** Dispatch the requested format. */
|
|
418
|
+
export function renderCoverage(report, format) {
|
|
419
|
+
if (format === "json")
|
|
420
|
+
return renderCoverageJson(report);
|
|
421
|
+
if (format === "html")
|
|
422
|
+
return renderCoverageHtml(report);
|
|
423
|
+
return renderCoverageText(report);
|
|
424
|
+
}
|
|
425
|
+
/** Suggested output filename for a `-o <dir>` write (text/html/json). */
|
|
426
|
+
export function coverageFileName(format) {
|
|
427
|
+
return format === "html" ? "coverage.html" : format === "json" ? "coverage.json" : "coverage.txt";
|
|
428
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { EvalRunSummary } from "@crewhaus/eval-runner";
|
|
2
|
+
import { type GateThresholds, type GateVerdict } from "@crewhaus/regression-runner";
|
|
3
|
+
export type FinishEvalOptions = {
|
|
4
|
+
readonly summary: EvalRunSummary;
|
|
5
|
+
readonly specName: string;
|
|
6
|
+
/** sha256 hex of the dataset file bytes. */
|
|
7
|
+
readonly datasetHash: string;
|
|
8
|
+
/** Absolute path to the new run's output directory. */
|
|
9
|
+
readonly outDir: string;
|
|
10
|
+
/** `--gate`: map a failing gate verdict to a non-zero exit. */
|
|
11
|
+
readonly gateRequested: boolean;
|
|
12
|
+
/** `!--no-promote`: allow baseline writes (initial pin + promotion). */
|
|
13
|
+
readonly promote: boolean;
|
|
14
|
+
/** Override `.crewhaus/evals` (tests, tenant scopes). */
|
|
15
|
+
readonly evalsDir?: string;
|
|
16
|
+
/** Line sink; defaults to stdout. */
|
|
17
|
+
readonly write?: (line: string) => void;
|
|
18
|
+
};
|
|
19
|
+
export type FinishEvalResult = {
|
|
20
|
+
/** True only when `gateRequested` and the regression gate failed. */
|
|
21
|
+
readonly gateFailed: boolean;
|
|
22
|
+
readonly gateReason?: string;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* `eval-report history|baseline show --dataset <filter>` matching. Unioned
|
|
26
|
+
* runs are recorded under `<primary>+regressions@vX`, so the filter matches
|
|
27
|
+
* a stored datasetName that equals it exactly OR continues it with a `+`
|
|
28
|
+
* suffix segment — `--dataset smoke` finds `smoke` and
|
|
29
|
+
* `smoke+regressions@v1`, but not `smoke2`.
|
|
30
|
+
*/
|
|
31
|
+
export declare function datasetFilterMatches(filter: string, datasetName: string): boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Strict gate between a baseline run and a new run. Wraps
|
|
34
|
+
* `regression-runner`'s `gate()` with `regressionThreshold: 0` (any
|
|
35
|
+
* pass-rate drop fails) and additionally fails on sample-level regressions
|
|
36
|
+
* that net out to a flat pass rate. The latency criterion is DISABLED by
|
|
37
|
+
* default (threshold Infinity): the documented gate is pass-rate/flip-only,
|
|
38
|
+
* and regression-runner's +5000ms p95 default would fail runs the docs say
|
|
39
|
+
* pass. Latency gating arrives with explicit CLI flags later — callers can
|
|
40
|
+
* already opt in via `thresholds.latencyThreshold`.
|
|
41
|
+
*/
|
|
42
|
+
export declare function gateRuns(prev: EvalRunSummary, next: EvalRunSummary, thresholds?: GateThresholds): GateVerdict;
|
|
43
|
+
/**
|
|
44
|
+
* Record a completed eval run in the history index, then resolve/diff/gate
|
|
45
|
+
* against the pinned baseline for its (spec, dataset) key. Returns whether
|
|
46
|
+
* the caller should exit non-zero (gate requested + failed).
|
|
47
|
+
*/
|
|
48
|
+
export declare function finishEvalRun(opts: FinishEvalOptions): Promise<FinishEvalResult>;
|