@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.
Files changed (125) hide show
  1. package/.claude-plugin/marketplace.json +19 -0
  2. package/.claude-plugin/plugin.json +58 -0
  3. package/CHANGELOG.md +380 -0
  4. package/LICENSE +21 -0
  5. package/README.md +358 -0
  6. package/agents/ping.md +8 -0
  7. package/agents/worker.md +8 -0
  8. package/bin/interlock +2655 -0
  9. package/bin/interlock-graph +6 -0
  10. package/bin/interlock-run +508 -0
  11. package/bin/interlock-ship-acp +39 -0
  12. package/hooks/_shared.mjs +162 -0
  13. package/hooks/guard-commit.mjs +77 -0
  14. package/hooks/guard-tasks.mjs +107 -0
  15. package/hooks/guard-tests.mjs +63 -0
  16. package/hooks/preflight.mjs +107 -0
  17. package/lib/artifacts.mjs +316 -0
  18. package/lib/autonomy.mjs +149 -0
  19. package/lib/conformance.mjs +132 -0
  20. package/lib/doctor.mjs +1176 -0
  21. package/lib/drift.mjs +409 -0
  22. package/lib/eval-history.mjs +304 -0
  23. package/lib/evals-calibrate.mjs +317 -0
  24. package/lib/evals-capture.mjs +437 -0
  25. package/lib/evals-promote.mjs +257 -0
  26. package/lib/evals-triage.mjs +216 -0
  27. package/lib/findings.mjs +340 -0
  28. package/lib/graph/cli.mjs +266 -0
  29. package/lib/graph/extract/config-json.mjs +74 -0
  30. package/lib/graph/extract/create-extractor.mjs +35 -0
  31. package/lib/graph/extract/memory.mjs +99 -0
  32. package/lib/graph/extract/openspec.mjs +80 -0
  33. package/lib/graph/extract/python.mjs +137 -0
  34. package/lib/graph/extract/regex-fallback.mjs +187 -0
  35. package/lib/graph/extract/shell.mjs +71 -0
  36. package/lib/graph/extract/typescript.mjs +217 -0
  37. package/lib/graph/extract/walk.mjs +272 -0
  38. package/lib/graph/format.mjs +51 -0
  39. package/lib/graph/graph/build.mjs +374 -0
  40. package/lib/graph/graph/query.mjs +249 -0
  41. package/lib/graph/graph/report.mjs +122 -0
  42. package/lib/graph/graph/update.mjs +47 -0
  43. package/lib/graph/paths.mjs +66 -0
  44. package/lib/graph/retrieve/context.mjs +86 -0
  45. package/lib/graph/retrieve/docs-index.mjs +116 -0
  46. package/lib/graph/retrieve/markdown.mjs +246 -0
  47. package/lib/host/acp.mjs +551 -0
  48. package/lib/host/claude-cli.mjs +285 -0
  49. package/lib/host/codex.mjs +231 -0
  50. package/lib/host/model-map.mjs +129 -0
  51. package/lib/host/qwen.mjs +176 -0
  52. package/lib/host/registry.mjs +213 -0
  53. package/lib/host.mjs +393 -0
  54. package/lib/ledger.mjs +444 -0
  55. package/lib/limits.mjs +525 -0
  56. package/lib/locators.mjs +99 -0
  57. package/lib/merge-lanes.mjs +148 -0
  58. package/lib/metrics.mjs +99 -0
  59. package/lib/notify.mjs +229 -0
  60. package/lib/outcomes.mjs +560 -0
  61. package/lib/plan-fingerprint.mjs +570 -0
  62. package/lib/project-slug.mjs +18 -0
  63. package/lib/prompts/commit.mjs +26 -0
  64. package/lib/prompts/dimensions.mjs +70 -0
  65. package/lib/prompts/handoff.mjs +86 -0
  66. package/lib/prompts/implementer.mjs +150 -0
  67. package/lib/prompts/index.mjs +250 -0
  68. package/lib/prompts/planner.mjs +70 -0
  69. package/lib/prompts/remediate.mjs +137 -0
  70. package/lib/prompts/replan.mjs +23 -0
  71. package/lib/prompts/review.mjs +107 -0
  72. package/lib/prompts/schemas.mjs +206 -0
  73. package/lib/prompts/stage.mjs +48 -0
  74. package/lib/prompts/verify.mjs +88 -0
  75. package/lib/ready.mjs +812 -0
  76. package/lib/receipt.mjs +434 -0
  77. package/lib/remediate.mjs +275 -0
  78. package/lib/report-html.mjs +623 -0
  79. package/lib/report.mjs +1124 -0
  80. package/lib/review-core.mjs +713 -0
  81. package/lib/risk.mjs +370 -0
  82. package/lib/run-log.mjs +1004 -0
  83. package/lib/run-paths.mjs +239 -0
  84. package/lib/run.mjs +2921 -0
  85. package/lib/ship-stage.mjs +223 -0
  86. package/lib/spill.mjs +150 -0
  87. package/lib/surface.mjs +195 -0
  88. package/lib/verify.mjs +1074 -0
  89. package/lib/waves.mjs +2661 -0
  90. package/package.json +40 -0
  91. package/shared/CONTEXT-HYGIENE.md +84 -0
  92. package/shared/DECISION-LEDGER.md +141 -0
  93. package/shared/EXPLORE-BRIEF.md +96 -0
  94. package/shared/INVARIANT-SWEEP.md +57 -0
  95. package/shared/SESSION-SHAPES.md +261 -0
  96. package/shared/SKILL-CANDIDATE-BRIEF.md +106 -0
  97. package/shared/TEST-PROFILE.md +204 -0
  98. package/shared/TOOL-ECONOMY.md +155 -0
  99. package/skills/bootstrap/SKILL.md +226 -0
  100. package/skills/commit/SKILL.md +99 -0
  101. package/skills/dispatch/SKILL.md +78 -0
  102. package/skills/docs-digest/SKILL.md +109 -0
  103. package/skills/evals/SKILL.md +160 -0
  104. package/skills/explain-code/SKILL.md +42 -0
  105. package/skills/explain-code/commit-teachin.md +136 -0
  106. package/skills/explain-code/module-walkthrough.md +358 -0
  107. package/skills/explore/SKILL.md +159 -0
  108. package/skills/fix-tests/SKILL.md +120 -0
  109. package/skills/graph/SKILL.md +136 -0
  110. package/skills/manual-test-plan/SKILL.md +121 -0
  111. package/skills/mr/SKILL.md +170 -0
  112. package/skills/report/SKILL.md +121 -0
  113. package/skills/review-artifacts/SKILL.md +127 -0
  114. package/skills/review-code/REVIEW.template.md +49 -0
  115. package/skills/review-code/SKILL.md +156 -0
  116. package/skills/review-code/dimensions/architecture.md +10 -0
  117. package/skills/review-code/dimensions/devops.md +12 -0
  118. package/skills/review-code/dimensions/language.md +12 -0
  119. package/skills/review-code/dimensions/qa.md +12 -0
  120. package/skills/review-code/dimensions/security.md +12 -0
  121. package/skills/review-code/dimensions/technical-lead.md +19 -0
  122. package/skills/ship/SKILL.md +67 -0
  123. package/skills/spec/SKILL.md +264 -0
  124. package/skills/spec/continuity.md +66 -0
  125. 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
+ }