@renzrollon/interlock 1.0.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/.claude-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +58 -0
- package/CHANGELOG.md +380 -0
- package/LICENSE +21 -0
- package/README.md +358 -0
- package/agents/ping.md +8 -0
- package/agents/worker.md +8 -0
- package/bin/interlock +2655 -0
- package/bin/interlock-graph +6 -0
- package/bin/interlock-run +508 -0
- package/bin/interlock-ship-acp +39 -0
- package/hooks/_shared.mjs +162 -0
- package/hooks/guard-commit.mjs +77 -0
- package/hooks/guard-tasks.mjs +107 -0
- package/hooks/guard-tests.mjs +63 -0
- package/hooks/preflight.mjs +107 -0
- package/lib/artifacts.mjs +316 -0
- package/lib/autonomy.mjs +149 -0
- package/lib/conformance.mjs +132 -0
- package/lib/doctor.mjs +1176 -0
- package/lib/drift.mjs +409 -0
- package/lib/eval-history.mjs +304 -0
- package/lib/evals-calibrate.mjs +317 -0
- package/lib/evals-capture.mjs +437 -0
- package/lib/evals-promote.mjs +257 -0
- package/lib/evals-triage.mjs +216 -0
- package/lib/findings.mjs +340 -0
- package/lib/graph/cli.mjs +266 -0
- package/lib/graph/extract/config-json.mjs +74 -0
- package/lib/graph/extract/create-extractor.mjs +35 -0
- package/lib/graph/extract/memory.mjs +99 -0
- package/lib/graph/extract/openspec.mjs +80 -0
- package/lib/graph/extract/python.mjs +137 -0
- package/lib/graph/extract/regex-fallback.mjs +187 -0
- package/lib/graph/extract/shell.mjs +71 -0
- package/lib/graph/extract/typescript.mjs +217 -0
- package/lib/graph/extract/walk.mjs +272 -0
- package/lib/graph/format.mjs +51 -0
- package/lib/graph/graph/build.mjs +374 -0
- package/lib/graph/graph/query.mjs +249 -0
- package/lib/graph/graph/report.mjs +122 -0
- package/lib/graph/graph/update.mjs +47 -0
- package/lib/graph/paths.mjs +66 -0
- package/lib/graph/retrieve/context.mjs +86 -0
- package/lib/graph/retrieve/docs-index.mjs +116 -0
- package/lib/graph/retrieve/markdown.mjs +246 -0
- package/lib/host/acp.mjs +551 -0
- package/lib/host/claude-cli.mjs +285 -0
- package/lib/host/codex.mjs +231 -0
- package/lib/host/model-map.mjs +129 -0
- package/lib/host/qwen.mjs +176 -0
- package/lib/host/registry.mjs +213 -0
- package/lib/host.mjs +393 -0
- package/lib/ledger.mjs +444 -0
- package/lib/limits.mjs +525 -0
- package/lib/locators.mjs +99 -0
- package/lib/merge-lanes.mjs +148 -0
- package/lib/metrics.mjs +99 -0
- package/lib/notify.mjs +229 -0
- package/lib/outcomes.mjs +560 -0
- package/lib/plan-fingerprint.mjs +570 -0
- package/lib/project-slug.mjs +18 -0
- package/lib/prompts/commit.mjs +26 -0
- package/lib/prompts/dimensions.mjs +70 -0
- package/lib/prompts/handoff.mjs +86 -0
- package/lib/prompts/implementer.mjs +150 -0
- package/lib/prompts/index.mjs +250 -0
- package/lib/prompts/planner.mjs +70 -0
- package/lib/prompts/remediate.mjs +137 -0
- package/lib/prompts/replan.mjs +23 -0
- package/lib/prompts/review.mjs +107 -0
- package/lib/prompts/schemas.mjs +206 -0
- package/lib/prompts/stage.mjs +48 -0
- package/lib/prompts/verify.mjs +88 -0
- package/lib/ready.mjs +812 -0
- package/lib/receipt.mjs +434 -0
- package/lib/remediate.mjs +275 -0
- package/lib/report-html.mjs +623 -0
- package/lib/report.mjs +1124 -0
- package/lib/review-core.mjs +713 -0
- package/lib/risk.mjs +370 -0
- package/lib/run-log.mjs +1004 -0
- package/lib/run-paths.mjs +239 -0
- package/lib/run.mjs +2921 -0
- package/lib/ship-stage.mjs +223 -0
- package/lib/spill.mjs +150 -0
- package/lib/surface.mjs +195 -0
- package/lib/verify.mjs +1074 -0
- package/lib/waves.mjs +2661 -0
- package/package.json +40 -0
- package/shared/CONTEXT-HYGIENE.md +84 -0
- package/shared/DECISION-LEDGER.md +141 -0
- package/shared/EXPLORE-BRIEF.md +96 -0
- package/shared/INVARIANT-SWEEP.md +57 -0
- package/shared/SESSION-SHAPES.md +261 -0
- package/shared/SKILL-CANDIDATE-BRIEF.md +106 -0
- package/shared/TEST-PROFILE.md +204 -0
- package/shared/TOOL-ECONOMY.md +155 -0
- package/skills/bootstrap/SKILL.md +226 -0
- package/skills/commit/SKILL.md +99 -0
- package/skills/dispatch/SKILL.md +78 -0
- package/skills/docs-digest/SKILL.md +109 -0
- package/skills/evals/SKILL.md +160 -0
- package/skills/explain-code/SKILL.md +42 -0
- package/skills/explain-code/commit-teachin.md +136 -0
- package/skills/explain-code/module-walkthrough.md +358 -0
- package/skills/explore/SKILL.md +159 -0
- package/skills/fix-tests/SKILL.md +120 -0
- package/skills/graph/SKILL.md +136 -0
- package/skills/manual-test-plan/SKILL.md +121 -0
- package/skills/mr/SKILL.md +170 -0
- package/skills/report/SKILL.md +121 -0
- package/skills/review-artifacts/SKILL.md +127 -0
- package/skills/review-code/REVIEW.template.md +49 -0
- package/skills/review-code/SKILL.md +156 -0
- package/skills/review-code/dimensions/architecture.md +10 -0
- package/skills/review-code/dimensions/devops.md +12 -0
- package/skills/review-code/dimensions/language.md +12 -0
- package/skills/review-code/dimensions/qa.md +12 -0
- package/skills/review-code/dimensions/security.md +12 -0
- package/skills/review-code/dimensions/technical-lead.md +19 -0
- package/skills/ship/SKILL.md +67 -0
- package/skills/spec/SKILL.md +264 -0
- package/skills/spec/continuity.md +66 -0
- package/workflows/ship.js +565 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
// The outcome eval's committed results record.
|
|
2
|
+
//
|
|
3
|
+
// One append-only line per fixture per arm per run, at
|
|
4
|
+
// `evals/history/ship-outcomes.jsonl`. Two releases produce two comparable rows;
|
|
5
|
+
// that is the whole reason the file is committed rather than uploaded as a CI
|
|
6
|
+
// artefact, and the whole reason it holds counts and measures only.
|
|
7
|
+
//
|
|
8
|
+
// THREE PROPERTIES, EACH LOAD-BEARING.
|
|
9
|
+
//
|
|
10
|
+
// 1. FIELDS ARE COPIED BY NAME. The writer builds the payload key by key, so
|
|
11
|
+
// handing it an entire run summary — one that also carries suite output,
|
|
12
|
+
// agent messages and a diff — writes only the named fields. This is the
|
|
13
|
+
// same stance `lib/outcomes.mjs` and `lib/metrics.mjs` take, for the same
|
|
14
|
+
// reason: a corpus that grows without bound in version control stops being
|
|
15
|
+
// readable within a year, and a transcript in git is a decision nobody
|
|
16
|
+
// made.
|
|
17
|
+
//
|
|
18
|
+
// 2. THE INSTRUMENT IS RECORDED WITH THE MEASUREMENT. Version under test,
|
|
19
|
+
// fixture, arm, host, model, the eval's own agent, the price table's
|
|
20
|
+
// identifier and the Node version travel on every row. A change to the
|
|
21
|
+
// apparatus is then visible in the record rather than confounding a
|
|
22
|
+
// comparison between two releases — the same lesson the eval exists to
|
|
23
|
+
// apply to the loop, applied to the eval.
|
|
24
|
+
//
|
|
25
|
+
// 3. A FAILED APPEND IS FATAL TO THE EVAL RUN AND TO NOTHING ELSE. This
|
|
26
|
+
// module never throws: it reports `written: false` with a reason, and the
|
|
27
|
+
// eval runner exits non-zero on it. The repository's corpus-loss classes
|
|
28
|
+
// differ deliberately — the outcome corpus and review metrics report a lost
|
|
29
|
+
// line and never touch an exit code, while the run trajectory and the
|
|
30
|
+
// verify spill are fatal because a run nobody can reconstruct defeats the
|
|
31
|
+
// reason the file exists. This record is in the fatal class by that same
|
|
32
|
+
// test read literally: the row IS the product of a metered scheduled run,
|
|
33
|
+
// and a run that spends real money and then drops its only durable result
|
|
34
|
+
// is indistinguishable from a run that was never scheduled.
|
|
35
|
+
//
|
|
36
|
+
// The fatality is scoped to the eval runner's own exit code. No ship run is
|
|
37
|
+
// in the path when this appends, and `interlock report` keeps its
|
|
38
|
+
// never-throw, always-zero contract when it reads this back.
|
|
39
|
+
//
|
|
40
|
+
// Written by `evals/ship/run.mjs`. Read by `lib/report.mjs` as a fourth corpus,
|
|
41
|
+
// under exactly the rules the other three are read by.
|
|
42
|
+
|
|
43
|
+
import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, statSync } from 'node:fs'
|
|
44
|
+
import { join } from 'node:path'
|
|
45
|
+
|
|
46
|
+
/** Where the record lives, relative to the repository root. */
|
|
47
|
+
export const EVAL_HISTORY_DIR = join('evals', 'history')
|
|
48
|
+
|
|
49
|
+
/** The append-only log itself. */
|
|
50
|
+
export const SHIP_OUTCOMES_FILE = 'ship-outcomes.jsonl'
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Bump when the record shape changes, so two lines stay legible side by side.
|
|
54
|
+
*
|
|
55
|
+
* Distinct from every other corpus's schema on purpose: a stray row landing in
|
|
56
|
+
* the outcome corpus, the trajectory corpus or the metrics corpus is still not
|
|
57
|
+
* readable as a record of a real run, because none of their readers recognizes
|
|
58
|
+
* this string.
|
|
59
|
+
*/
|
|
60
|
+
export const SHIP_OUTCOME_SCHEMA = 'interlock.ship-outcome-eval/1'
|
|
61
|
+
|
|
62
|
+
/** The arms a row may name. A row naming neither is refused rather than guessed at. */
|
|
63
|
+
export const ARMS = Object.freeze(['loop', 'control'])
|
|
64
|
+
|
|
65
|
+
/** Every criterion status a row may carry, mirroring `evals/ship/graders.mjs`. */
|
|
66
|
+
export const CRITERION_STATUSES = Object.freeze(['pass', 'fail', 'unobserved', 'n/a'])
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The measures a row carries, each as a value-with-reason pair.
|
|
70
|
+
*
|
|
71
|
+
* The two token fields are separate and MUST NOT be merged. `receiptOutputTokens`
|
|
72
|
+
* is the loop's own accounting — absent on a host without token accounting, with
|
|
73
|
+
* that host's reason, which `ship-run` already requires. `agentOutputTokens` is
|
|
74
|
+
* what the eval's own agent read off the API responses it made itself. Merging
|
|
75
|
+
* them would let an apparatus measurement masquerade as a product measurement.
|
|
76
|
+
*/
|
|
77
|
+
export const MEASURE_FIELDS = Object.freeze([
|
|
78
|
+
'agentsSpawned',
|
|
79
|
+
'wallClockMs',
|
|
80
|
+
'remediationRounds',
|
|
81
|
+
'agentInputTokens',
|
|
82
|
+
'agentOutputTokens',
|
|
83
|
+
'receiptOutputTokens',
|
|
84
|
+
'spendUsd'
|
|
85
|
+
])
|
|
86
|
+
|
|
87
|
+
const MAX_ID = 80
|
|
88
|
+
const MAX_REASON = 240
|
|
89
|
+
const MAX_CRITERIA = 16
|
|
90
|
+
|
|
91
|
+
function messageOf(err) {
|
|
92
|
+
return (err && err.message) || String(err)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isObject(value) {
|
|
96
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A bounded identifier. Never a sentence, never a body of text. */
|
|
100
|
+
function id(value, max = MAX_ID) {
|
|
101
|
+
if (typeof value !== 'string') return null
|
|
102
|
+
const text = value.replace(/\s+/g, ' ').trim()
|
|
103
|
+
if (!text) return null
|
|
104
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A bounded reason. The one place free text is allowed, and it is capped. */
|
|
108
|
+
function reason(value) {
|
|
109
|
+
return id(value, MAX_REASON)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A finite number, or `null`. Never a zero standing in for an absence. */
|
|
113
|
+
function measure(value) {
|
|
114
|
+
if (value === null || value === undefined) return null
|
|
115
|
+
const n = Number(value)
|
|
116
|
+
return Number.isFinite(n) ? n : null
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* One measure, as the pair a reader has to branch on.
|
|
121
|
+
*
|
|
122
|
+
* `{value: null, reason: '…'}` is "not measured"; `{value: 0, reason: null}` is
|
|
123
|
+
* "measured, and it was zero". Those are different facts and the record keeps
|
|
124
|
+
* them apart — a measure recorded as zero when nothing measured it is the single
|
|
125
|
+
* error this eval is most able to commit.
|
|
126
|
+
*/
|
|
127
|
+
function measurePair(input) {
|
|
128
|
+
const source = isObject(input) ? input : { value: input }
|
|
129
|
+
const value = measure(source.value)
|
|
130
|
+
return {
|
|
131
|
+
value,
|
|
132
|
+
reason: value === null ? reason(source.reason) || 'not measured' : null
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Absolute path of the record, from a repository root. */
|
|
137
|
+
export function shipOutcomesPath(root) {
|
|
138
|
+
return join(root, EVAL_HISTORY_DIR, SHIP_OUTCOMES_FILE)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// A previous append torn mid-line (crash, full disk) would otherwise be spliced
|
|
142
|
+
// onto by the next one, destroying two records instead of one. Reading the final
|
|
143
|
+
// byte is cheap and makes the log self-healing at the seam — the same guard
|
|
144
|
+
// `lib/outcomes.mjs` carries.
|
|
145
|
+
function endsWithNewline(file) {
|
|
146
|
+
const size = statSync(file).size
|
|
147
|
+
if (size === 0) return true
|
|
148
|
+
const fd = openSync(file, 'r')
|
|
149
|
+
try {
|
|
150
|
+
const buf = Buffer.alloc(1)
|
|
151
|
+
readSync(fd, buf, 0, 1, size - 1)
|
|
152
|
+
return buf[0] === 0x0a
|
|
153
|
+
} finally {
|
|
154
|
+
closeSync(fd)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Append one graded result.
|
|
160
|
+
*
|
|
161
|
+
* Never throws. A failure comes back as `{written: false, reason}` and the eval
|
|
162
|
+
* runner exits non-zero on it — see property 3 in the module header.
|
|
163
|
+
*
|
|
164
|
+
* @param {string} root repository root; `evals/history/` is created beneath it
|
|
165
|
+
* @param {{
|
|
166
|
+
* version?: string, fixture?: string, arm?: 'loop'|'control', host?: string,
|
|
167
|
+
* model?: string, evalAgent?: string, priceTable?: string, node?: string,
|
|
168
|
+
* criteria?: Array<{id: string, status: string, exitCode?: number|null}>,
|
|
169
|
+
* measures?: Record<string, {value: number|null, reason: string|null}>,
|
|
170
|
+
* tally?: {met?: number, applicable?: number, notApplicable?: number, unobserved?: number},
|
|
171
|
+
* now?: Date|string
|
|
172
|
+
* }} [input] anything not named here is ignored, however fat the object is
|
|
173
|
+
* @returns {{written: boolean, path: string|null, reason: string|null}}
|
|
174
|
+
*/
|
|
175
|
+
export function appendShipOutcome(root, input = {}) {
|
|
176
|
+
try {
|
|
177
|
+
if (typeof root !== 'string' || !root.trim()) {
|
|
178
|
+
return { written: false, path: null, reason: 'no root directory given' }
|
|
179
|
+
}
|
|
180
|
+
const source = isObject(input) ? input : {}
|
|
181
|
+
|
|
182
|
+
const arm = id(source.arm)
|
|
183
|
+
if (!ARMS.includes(arm)) {
|
|
184
|
+
// Not defaulted: the record exists to compare two arms, and a row whose
|
|
185
|
+
// arm was guessed is worse than no row at all.
|
|
186
|
+
return { written: false, path: null, reason: `arm must be one of ${ARMS.join(', ')}` }
|
|
187
|
+
}
|
|
188
|
+
const fixture = id(source.fixture)
|
|
189
|
+
if (!fixture) {
|
|
190
|
+
return { written: false, path: null, reason: 'a fixture identity is required' }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const when = source.now instanceof Date ? source.now : new Date(source.now || Date.now())
|
|
194
|
+
const ts = Number.isNaN(when.getTime()) ? new Date().toISOString() : when.toISOString()
|
|
195
|
+
|
|
196
|
+
const criteria = (Array.isArray(source.criteria) ? source.criteria : [])
|
|
197
|
+
.slice(0, MAX_CRITERIA)
|
|
198
|
+
.map(entry => {
|
|
199
|
+
const c = isObject(entry) ? entry : {}
|
|
200
|
+
const status = id(c.status, 16)
|
|
201
|
+
return {
|
|
202
|
+
id: id(c.id),
|
|
203
|
+
status: CRITERION_STATUSES.includes(status) ? status : null,
|
|
204
|
+
// The deciding exit code travels with the criterion, so a row can be
|
|
205
|
+
// re-derived by hand without the run that produced it.
|
|
206
|
+
exitCode: measure(c.exitCode)
|
|
207
|
+
}
|
|
208
|
+
})
|
|
209
|
+
.filter(c => c.id && c.status)
|
|
210
|
+
|
|
211
|
+
const measures = {}
|
|
212
|
+
const given = isObject(source.measures) ? source.measures : {}
|
|
213
|
+
for (const field of MEASURE_FIELDS) measures[field] = measurePair(given[field])
|
|
214
|
+
|
|
215
|
+
const tally = isObject(source.tally) ? source.tally : {}
|
|
216
|
+
|
|
217
|
+
const payload = {
|
|
218
|
+
schema: SHIP_OUTCOME_SCHEMA,
|
|
219
|
+
ts,
|
|
220
|
+
// --- the instrument, recorded with the measurement (design D8)
|
|
221
|
+
version: id(source.version) || 'unknown',
|
|
222
|
+
fixture,
|
|
223
|
+
arm,
|
|
224
|
+
host: id(source.host) || 'unknown',
|
|
225
|
+
model: id(source.model) || 'unknown',
|
|
226
|
+
evalAgent: id(source.evalAgent) || 'unknown',
|
|
227
|
+
priceTable: id(source.priceTable) || 'unknown',
|
|
228
|
+
node: id(source.node) || 'unknown',
|
|
229
|
+
// --- the measurement
|
|
230
|
+
criteria,
|
|
231
|
+
tally: {
|
|
232
|
+
met: measure(tally.met),
|
|
233
|
+
applicable: measure(tally.applicable),
|
|
234
|
+
notApplicable: measure(tally.notApplicable),
|
|
235
|
+
unobserved: measure(tally.unobserved)
|
|
236
|
+
},
|
|
237
|
+
measures
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const dir = join(root, EVAL_HISTORY_DIR)
|
|
241
|
+
mkdirSync(dir, { recursive: true })
|
|
242
|
+
const file = join(dir, SHIP_OUTCOMES_FILE)
|
|
243
|
+
const prefix = existsSync(file) && !endsWithNewline(file) ? '\n' : ''
|
|
244
|
+
// JSON.stringify never emits a raw newline, so one record is always one line.
|
|
245
|
+
appendFileSync(file, `${prefix}${JSON.stringify(payload)}\n`)
|
|
246
|
+
return { written: true, path: file, reason: null }
|
|
247
|
+
} catch (err) {
|
|
248
|
+
return { written: false, path: null, reason: messageOf(err) }
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Read the record back.
|
|
254
|
+
*
|
|
255
|
+
* Never throws, tolerates a torn line, and classifies by the declared schema
|
|
256
|
+
* rather than by filename — the same three rules the other corpora are read
|
|
257
|
+
* under. A line declaring a schema this reader does not know is counted as
|
|
258
|
+
* unrecognized and contributes to nothing; it is never interpreted hopefully.
|
|
259
|
+
*
|
|
260
|
+
* @param {string} root repository root
|
|
261
|
+
* @returns {{path: string|null, exists: boolean, records: object[],
|
|
262
|
+
* unrecognized: Array<{line: number, schema: string|null}>,
|
|
263
|
+
* skipped: Array<{line: number, reason: string}>, reason: string|null}}
|
|
264
|
+
*/
|
|
265
|
+
export function readShipOutcomes(root) {
|
|
266
|
+
const empty = { path: null, exists: false, records: [], unrecognized: [], skipped: [], reason: null }
|
|
267
|
+
if (typeof root !== 'string' || !root.trim()) {
|
|
268
|
+
return { ...empty, reason: 'no root directory given' }
|
|
269
|
+
}
|
|
270
|
+
const path = shipOutcomesPath(root)
|
|
271
|
+
try {
|
|
272
|
+
if (!existsSync(path)) return { ...empty, path }
|
|
273
|
+
const records = []
|
|
274
|
+
const unrecognized = []
|
|
275
|
+
const skipped = []
|
|
276
|
+
readFileSync(path, 'utf8')
|
|
277
|
+
.split('\n')
|
|
278
|
+
.forEach((line, i) => {
|
|
279
|
+
if (!line.trim()) return
|
|
280
|
+
let parsed
|
|
281
|
+
try {
|
|
282
|
+
parsed = JSON.parse(line)
|
|
283
|
+
} catch (err) {
|
|
284
|
+
skipped.push({ line: i + 1, reason: messageOf(err) })
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
if (!isObject(parsed)) {
|
|
288
|
+
skipped.push({ line: i + 1, reason: 'line is not a JSON object' })
|
|
289
|
+
return
|
|
290
|
+
}
|
|
291
|
+
if (parsed.schema !== SHIP_OUTCOME_SCHEMA) {
|
|
292
|
+
unrecognized.push({
|
|
293
|
+
line: i + 1,
|
|
294
|
+
schema: typeof parsed.schema === 'string' ? parsed.schema : null
|
|
295
|
+
})
|
|
296
|
+
return
|
|
297
|
+
}
|
|
298
|
+
records.push(parsed)
|
|
299
|
+
})
|
|
300
|
+
return { path, exists: true, records, unrecognized, skipped, reason: null }
|
|
301
|
+
} catch (err) {
|
|
302
|
+
return { ...empty, path, exists: true, reason: messageOf(err) }
|
|
303
|
+
}
|
|
304
|
+
}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// Judge calibration — how often a judged grader agreed with a human on the
|
|
2
|
+
// same transcript, and out of how many.
|
|
3
|
+
//
|
|
4
|
+
// A judged grader's verdict is a model's opinion. Every other grader type in the
|
|
5
|
+
// suite is decidable from the trace; these two are not, and nothing so far has
|
|
6
|
+
// asked whether the opinion is any good. This module answers that, and answers
|
|
7
|
+
// nothing else: it reports agreement with its denominator and issues NO verdict.
|
|
8
|
+
// There is no floor here, no pass, no fail, and the command exits successfully
|
|
9
|
+
// whatever the number says. The floor lives on the limits surface and is applied
|
|
10
|
+
// by `interlock evals promote` — one module decides, one measures, and keeping
|
|
11
|
+
// them apart is what stops a later edit from turning a measurement into a gate.
|
|
12
|
+
//
|
|
13
|
+
// Why a command rather than a test, when the calibration *presence* check is a
|
|
14
|
+
// test: half the input is not in the repository. The human labels are committed
|
|
15
|
+
// (`evals/<case>/calibration/<grader>/`), but the judge's votes are in a results
|
|
16
|
+
// file a run produced. A test over repository files structurally cannot see them
|
|
17
|
+
// (design D2).
|
|
18
|
+
//
|
|
19
|
+
// No model and no network: this compares a stored label against a recorded vote.
|
|
20
|
+
// Re-judging a transcript would measure the judge against itself.
|
|
21
|
+
//
|
|
22
|
+
// Exposed as `interlock evals calibrate --results <file> [--labels <dir>]`.
|
|
23
|
+
|
|
24
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
25
|
+
import { join } from 'node:path'
|
|
26
|
+
|
|
27
|
+
/** Grader types whose verdict comes from a model judge. */
|
|
28
|
+
const JUDGED_TYPES = new Set(['llm', 'baseline', 'judge', 'judged', 'model-judge', 'semantic'])
|
|
29
|
+
|
|
30
|
+
function isJudged(grader) {
|
|
31
|
+
if (grader && grader.judged === true) return true
|
|
32
|
+
const type = grader && typeof grader.type === 'string' ? grader.type.toLowerCase() : ''
|
|
33
|
+
return JUDGED_TYPES.has(type)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function graderName(grader, i) {
|
|
37
|
+
return grader && typeof grader.name === 'string' && grader.name ? grader.name : `grader-${i + 1}`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The identity that matches a label to a vote. The harness records a
|
|
42
|
+
* `trace_path` per run; a label names the same run in its `transcript:` field.
|
|
43
|
+
* Every fallback is a field the same run object might carry instead — an
|
|
44
|
+
* unidentifiable run is left unnamed rather than given a positional index, so it
|
|
45
|
+
* cannot accidentally match a label meant for a different run.
|
|
46
|
+
*/
|
|
47
|
+
function runIdentity(run, i) {
|
|
48
|
+
for (const key of ['transcript', 'trace_path', 'trace', 'id', 'run_id']) {
|
|
49
|
+
const v = run && run[key]
|
|
50
|
+
if (typeof v === 'string' && v) return v
|
|
51
|
+
}
|
|
52
|
+
return `run-${i + 1}`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** `pass` / `fail`, normalised. Anything else is not a label. */
|
|
56
|
+
function normaliseLabel(value) {
|
|
57
|
+
const v = String(value == null ? '' : value).trim().toLowerCase()
|
|
58
|
+
if (['pass', 'passed', 'true', 'yes', 'ok'].includes(v)) return 'pass'
|
|
59
|
+
if (['fail', 'failed', 'false', 'no'].includes(v)) return 'fail'
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const key = (caseName, grader, transcript) => `${caseName}/${grader}/${transcript}`
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Compare stored human labels against recorded judge votes.
|
|
67
|
+
*
|
|
68
|
+
* Pure: no fs, no network, no model. The same inputs always yield the same
|
|
69
|
+
* report, which is what makes a disagreement worth arguing about.
|
|
70
|
+
*
|
|
71
|
+
* @param {{labels?: Array, results?: any, graders?: string[]}} input
|
|
72
|
+
* `labels` — `{ case, grader, transcript, label }` records read from
|
|
73
|
+
* `evals/<case>/calibration/<grader>/`
|
|
74
|
+
* `results` — a parsed results file
|
|
75
|
+
* `graders` — optional: judged graders known to exist in the suite. Supplying
|
|
76
|
+
* it lets a grader with no calibration set AND no vote in this
|
|
77
|
+
* results file still be reported as unmeasured rather than
|
|
78
|
+
* vanish, which is the difference between "not measured" and
|
|
79
|
+
* "not mentioned".
|
|
80
|
+
* @returns {{graders: Array, unmeasured: string[], unmatchedLabels: string[],
|
|
81
|
+
* unmatchedVotes: string[], counts: {measured: number, unmeasured: number}}}
|
|
82
|
+
*/
|
|
83
|
+
export function calibrate({ labels = [], results = null, graders = [] } = {}) {
|
|
84
|
+
// --- the judge's recorded votes -----------------------------------------
|
|
85
|
+
const votes = new Map() // key -> boolean passed
|
|
86
|
+
const votedGraders = new Set()
|
|
87
|
+
const r = results && typeof results === 'object' && !Array.isArray(results) ? results : {}
|
|
88
|
+
for (const kase of Array.isArray(r.cases) ? r.cases : []) {
|
|
89
|
+
const caseName = kase && typeof kase.id === 'string' && kase.id ? kase.id : '(unnamed)'
|
|
90
|
+
const runs = Array.isArray(kase && kase.runs) ? kase.runs : []
|
|
91
|
+
runs.forEach((run, i) => {
|
|
92
|
+
const transcript = runIdentity(run, i)
|
|
93
|
+
const graderList = Array.isArray(run && run.graders) ? run.graders : []
|
|
94
|
+
graderList.forEach((g, gi) => {
|
|
95
|
+
if (!isJudged(g)) return
|
|
96
|
+
const name = graderName(g, gi)
|
|
97
|
+
votedGraders.add(`${caseName}/${name}`)
|
|
98
|
+
votes.set(key(caseName, name, transcript), g.passed === true)
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// --- the human labels ----------------------------------------------------
|
|
104
|
+
const byGrader = new Map() // `<case>/<grader>` -> label records
|
|
105
|
+
const labelKeys = new Set()
|
|
106
|
+
for (const raw of Array.isArray(labels) ? labels : []) {
|
|
107
|
+
const caseName = String((raw && raw.case) || '').trim()
|
|
108
|
+
const grader = String((raw && raw.grader) || '').trim()
|
|
109
|
+
const transcript = String((raw && raw.transcript) || '').trim()
|
|
110
|
+
const label = normaliseLabel(raw && raw.label)
|
|
111
|
+
if (!caseName || !grader || !transcript || !label) continue
|
|
112
|
+
const id = `${caseName}/${grader}`
|
|
113
|
+
if (!byGrader.has(id)) byGrader.set(id, [])
|
|
114
|
+
byGrader.get(id).push({ caseName, grader, transcript, label })
|
|
115
|
+
labelKeys.add(key(caseName, grader, transcript))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Every judged grader worth reporting on: those the caller named, those the
|
|
119
|
+
// results file voted on, and those a label exists for. The union, so a grader
|
|
120
|
+
// is never dropped for being absent from one of the three.
|
|
121
|
+
const known = new Set([...graders, ...votedGraders, ...byGrader.keys()])
|
|
122
|
+
|
|
123
|
+
const report = []
|
|
124
|
+
const unmeasured = []
|
|
125
|
+
const unmatchedLabels = []
|
|
126
|
+
|
|
127
|
+
for (const id of [...known].sort()) {
|
|
128
|
+
const records = byGrader.get(id) || []
|
|
129
|
+
if (records.length === 0) {
|
|
130
|
+
// No calibration set at all. Named, never omitted, and never reported as
|
|
131
|
+
// agreeing (calibration spec, "A judged grader with no calibration set is
|
|
132
|
+
// stated, never assumed").
|
|
133
|
+
unmeasured.push(id)
|
|
134
|
+
report.push({
|
|
135
|
+
id,
|
|
136
|
+
status: 'no_calibration_set',
|
|
137
|
+
agreements: null,
|
|
138
|
+
comparisons: 0,
|
|
139
|
+
agreement: null,
|
|
140
|
+
oneSided: null,
|
|
141
|
+
labelled: 0
|
|
142
|
+
})
|
|
143
|
+
continue
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let agreements = 0
|
|
147
|
+
let comparisons = 0
|
|
148
|
+
for (const rec of records) {
|
|
149
|
+
const k = key(rec.caseName, rec.grader, rec.transcript)
|
|
150
|
+
if (!votes.has(k)) {
|
|
151
|
+
// Excluded from the denominator EXPLICITLY. A labelled transcript with
|
|
152
|
+
// no vote is missing evidence, not evidence of agreement.
|
|
153
|
+
unmatchedLabels.push(k)
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
156
|
+
comparisons += 1
|
|
157
|
+
if (votes.get(k) === (rec.label === 'pass')) agreements += 1
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const outcomes = new Set(records.map(rec => rec.label))
|
|
161
|
+
const oneSided = outcomes.size === 1 ? [...outcomes][0] : null
|
|
162
|
+
|
|
163
|
+
if (comparisons === 0) {
|
|
164
|
+
// Labels exist but none of them matched a vote. Distinct from having no
|
|
165
|
+
// set at all, and distinct from disagreeing on every one.
|
|
166
|
+
report.push({
|
|
167
|
+
id,
|
|
168
|
+
status: 'no_matches',
|
|
169
|
+
agreements: null,
|
|
170
|
+
comparisons: 0,
|
|
171
|
+
agreement: null,
|
|
172
|
+
oneSided,
|
|
173
|
+
labelled: records.length
|
|
174
|
+
})
|
|
175
|
+
continue
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
report.push({
|
|
179
|
+
id,
|
|
180
|
+
status: 'measured',
|
|
181
|
+
agreements,
|
|
182
|
+
comparisons,
|
|
183
|
+
agreement: agreements / comparisons,
|
|
184
|
+
oneSided,
|
|
185
|
+
labelled: records.length
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// A judge vote nobody labelled. Reported for the same reason as the mirror
|
|
190
|
+
// case: the pair of lists is what makes the denominator auditable.
|
|
191
|
+
const unmatchedVotes = [...votes.keys()].filter(k => !labelKeys.has(k)).sort()
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
graders: report,
|
|
195
|
+
unmeasured,
|
|
196
|
+
unmatchedLabels: unmatchedLabels.sort(),
|
|
197
|
+
unmatchedVotes,
|
|
198
|
+
counts: {
|
|
199
|
+
measured: report.filter(g => g.status === 'measured').length,
|
|
200
|
+
unmeasured: report.filter(g => g.status !== 'measured').length
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Read every stored label under `<evalsDir>/<case>/calibration/<grader>/`.
|
|
207
|
+
*
|
|
208
|
+
* Separate from `calibrate` on purpose: the decision stays pure and testable
|
|
209
|
+
* from literals, and the filesystem walk is the part that has to know the
|
|
210
|
+
* layout (`evals/CALIBRATION-LAYOUT.md`).
|
|
211
|
+
*
|
|
212
|
+
* A label file whose frontmatter is unreadable is returned in `problems` rather
|
|
213
|
+
* than skipped — a label nobody could parse must not read as a label that does
|
|
214
|
+
* not exist.
|
|
215
|
+
*
|
|
216
|
+
* @returns {{labels: Array, problems: string[]}}
|
|
217
|
+
*/
|
|
218
|
+
export function readLabels(evalsDir) {
|
|
219
|
+
const labels = []
|
|
220
|
+
const problems = []
|
|
221
|
+
if (!existsSync(evalsDir) || !statSync(evalsDir).isDirectory()) {
|
|
222
|
+
return { labels, problems: [`${evalsDir}: no such directory — no labels were read`] }
|
|
223
|
+
}
|
|
224
|
+
for (const caseEntry of readdirSync(evalsDir, { withFileTypes: true })) {
|
|
225
|
+
if (!caseEntry.isDirectory()) continue
|
|
226
|
+
const calDir = join(evalsDir, caseEntry.name, 'calibration')
|
|
227
|
+
if (!existsSync(calDir) || !statSync(calDir).isDirectory()) continue
|
|
228
|
+
for (const graderEntry of readdirSync(calDir, { withFileTypes: true })) {
|
|
229
|
+
if (!graderEntry.isDirectory()) continue
|
|
230
|
+
const graderDir = join(calDir, graderEntry.name)
|
|
231
|
+
for (const file of readdirSync(graderDir).filter(f => f.endsWith('.md'))) {
|
|
232
|
+
const path = join(graderDir, file)
|
|
233
|
+
let text
|
|
234
|
+
try {
|
|
235
|
+
text = readFileSync(path, 'utf8')
|
|
236
|
+
} catch (err) {
|
|
237
|
+
problems.push(`${path}: could not be read (${err && err.message})`)
|
|
238
|
+
continue
|
|
239
|
+
}
|
|
240
|
+
const fm = /^---\n([\s\S]*?)\n---/.exec(text)
|
|
241
|
+
if (!fm) {
|
|
242
|
+
problems.push(`${path}: no frontmatter block — not counted as a label`)
|
|
243
|
+
continue
|
|
244
|
+
}
|
|
245
|
+
const field = k => {
|
|
246
|
+
const m = new RegExp(`^${k}:\\s*(.+)$`, 'm').exec(fm[1])
|
|
247
|
+
return m ? m[1].trim().replace(/['"]/g, '') : ''
|
|
248
|
+
}
|
|
249
|
+
const label = normaliseLabel(field('label'))
|
|
250
|
+
const transcript = field('transcript')
|
|
251
|
+
if (!label || !transcript) {
|
|
252
|
+
problems.push(
|
|
253
|
+
`${path}: needs both \`transcript:\` and \`label: pass|fail\` — not counted as a label`
|
|
254
|
+
)
|
|
255
|
+
continue
|
|
256
|
+
}
|
|
257
|
+
labels.push({
|
|
258
|
+
case: caseEntry.name,
|
|
259
|
+
grader: field('grader') || graderEntry.name,
|
|
260
|
+
transcript,
|
|
261
|
+
label
|
|
262
|
+
})
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return { labels, problems }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Human-readable rendering. There is no verdict here to render — the command
|
|
271
|
+
* exits 0 whatever this says, and the text states that so a reader does not go
|
|
272
|
+
* looking for one.
|
|
273
|
+
*/
|
|
274
|
+
export function formatCalibration(result, problems = []) {
|
|
275
|
+
const lines = []
|
|
276
|
+
const pct = g => `${g.agreements}/${g.comparisons} (${Math.round(g.agreement * 100)}%)`
|
|
277
|
+
|
|
278
|
+
if (result.graders.length === 0) {
|
|
279
|
+
lines.push('no judged grader was found to calibrate')
|
|
280
|
+
}
|
|
281
|
+
for (const g of result.graders) {
|
|
282
|
+
if (g.status === 'measured') {
|
|
283
|
+
lines.push(` agreement ${g.id} ${pct(g)}`)
|
|
284
|
+
if (g.oneSided) {
|
|
285
|
+
// Not a complete measurement, and saying so is the whole point: a judge
|
|
286
|
+
// that answers "pass" to everything agrees perfectly with a set that
|
|
287
|
+
// holds only passes (calibration spec, "One-sided set is reported as
|
|
288
|
+
// such").
|
|
289
|
+
lines.push(
|
|
290
|
+
` one side only — every stored label is "${g.oneSided}", so this is ` +
|
|
291
|
+
`not a complete measurement`
|
|
292
|
+
)
|
|
293
|
+
}
|
|
294
|
+
} else if (g.status === 'no_matches') {
|
|
295
|
+
lines.push(
|
|
296
|
+
` unmeasured ${g.id} agreement could not be measured — ${g.labelled} stored ` +
|
|
297
|
+
`label(s), none matching a judge vote in this results file`
|
|
298
|
+
)
|
|
299
|
+
} else {
|
|
300
|
+
lines.push(` unmeasured ${g.id} no calibration set stored`)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
for (const k of result.unmatchedLabels) {
|
|
305
|
+
lines.push(` unmatched label ${k} — no judge vote in this results file; excluded from the denominator`)
|
|
306
|
+
}
|
|
307
|
+
for (const k of result.unmatchedVotes) {
|
|
308
|
+
lines.push(` unmatched vote ${k} — no stored human label; excluded from the denominator`)
|
|
309
|
+
}
|
|
310
|
+
for (const p of problems) lines.push(` problem ${p}`)
|
|
311
|
+
|
|
312
|
+
lines.push(
|
|
313
|
+
` ${result.counts.measured} grader(s) measured, ${result.counts.unmeasured} unmeasured`
|
|
314
|
+
)
|
|
315
|
+
lines.push('this report classifies nothing; the promotion decision applies the floor')
|
|
316
|
+
return lines.join('\n') + '\n'
|
|
317
|
+
}
|