@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
package/lib/report.mjs
ADDED
|
@@ -0,0 +1,1124 @@
|
|
|
1
|
+
// The reader the corpora never had.
|
|
2
|
+
//
|
|
3
|
+
// Interlock writes three corpora and, until this module, read none of them.
|
|
4
|
+
// `lib/outcomes.mjs` says so in its own header — per §4.15(a) it "records and
|
|
5
|
+
// nothing else" — `lib/run-log.mjs` accumulates a trajectory per ship run, and
|
|
6
|
+
// `lib/metrics.mjs` writes a review's counts. All three were write-only, which
|
|
7
|
+
// left the §4.16 / F6.6 question ("should we have skipped the human that time?")
|
|
8
|
+
// unanswerable not because the data was absent but because nothing computed
|
|
9
|
+
// over it.
|
|
10
|
+
//
|
|
11
|
+
// WHY THIS NEVER BLOCKS, AND NEVER WILL. Every rate here is one comparison away
|
|
12
|
+
// from a threshold, and §4.15(a) forbids exactly that comparison. So this module
|
|
13
|
+
// is an instrument: it exits nowhere, it labels nothing pass or fail, and no
|
|
14
|
+
// gate, readiness check, risk classification or workflow step may consume it.
|
|
15
|
+
// `interlock drift` and `interlock conformance` set the precedent for a
|
|
16
|
+
// subcommand that reports and does not block; this one carries the stronger
|
|
17
|
+
// version of that promise because the temptation is stronger.
|
|
18
|
+
//
|
|
19
|
+
// Four properties are load-bearing:
|
|
20
|
+
//
|
|
21
|
+
// 1. NEVER THROWS. A missing corpus, an unreadable file, a torn line and an
|
|
22
|
+
// unreadable directory each become a reported condition carrying its
|
|
23
|
+
// reason. A reader whose own crash is the output is worse than no reader.
|
|
24
|
+
//
|
|
25
|
+
// 2. WRITES NOTHING. Not even a cache. `lib/metrics.mjs` isolates the write
|
|
26
|
+
// paths in `lib/` deliberately, and a reader that writes would join that
|
|
27
|
+
// exception for no policy reason. The mitigation for a slow scan is
|
|
28
|
+
// `--since`, not an index.
|
|
29
|
+
//
|
|
30
|
+
// 3. A DENOMINATOR TRAVELS WITH EVERY VALUE. `lib/doctor.mjs` holds that an
|
|
31
|
+
// unknown is not a pass; `lib/outcomes.mjs` holds that reading absence as
|
|
32
|
+
// cleanliness would flatter exactly the runs a corpus exists to explain. A
|
|
33
|
+
// rate over an unstated denominator commits both errors at once, so a zero
|
|
34
|
+
// denominator yields `null` and a reason — never `0`.
|
|
35
|
+
//
|
|
36
|
+
// 4. PROVENANCE SURVIVES THE ARITHMETIC. `lib/outcomes.mjs` partitions what a
|
|
37
|
+
// run observed from what an agent reported. Averaging across that boundary
|
|
38
|
+
// would launder a report into a measurement, so every indicator declares
|
|
39
|
+
// the corpus it came from and the two are never summed.
|
|
40
|
+
//
|
|
41
|
+
// A FOURTH CORPUS JOINED LATER AND IS NOT ONE OF THE THREE. The outcome-eval
|
|
42
|
+
// history (`evals/history/ship-outcomes.jsonl`) records what a fixture run
|
|
43
|
+
// produced, not what real work produced. It is read under the same four
|
|
44
|
+
// properties above, and under one more: its indicators live in their own group,
|
|
45
|
+
// name their own source, carry their own denominators, and are never summed with
|
|
46
|
+
// a figure derived from a real run. A fixture result folded into a real-run
|
|
47
|
+
// figure would be a number about neither.
|
|
48
|
+
//
|
|
49
|
+
// THE SHARPEST INDICATOR IS COMPUTED FROM TWO RECORDED PATH SETS. "Does the
|
|
50
|
+
// merged diff still match the plan" was published as a declared gap for as long
|
|
51
|
+
// as nothing recorded the comparison. The receipt now records both halves — the
|
|
52
|
+
// paths the run's commit touched, read from version control, and the paths its
|
|
53
|
+
// executed plan predicted — so `planFidelity.diffMatchesPlan` is a share with a
|
|
54
|
+
// denominator, like every other value here.
|
|
55
|
+
//
|
|
56
|
+
// Its two near misses stay separate and are never merged into it: `planStatus`
|
|
57
|
+
// is about whether a *stored plan* was reused given its planning inputs, and the
|
|
58
|
+
// replan rate is about the plan changing mid-run. Both are about the plan;
|
|
59
|
+
// neither observes the diff, and publishing either under this name would be the
|
|
60
|
+
// restatement defect the old gap text existed to prevent.
|
|
61
|
+
//
|
|
62
|
+
// A run contributes only when both sets were observed, the prediction was
|
|
63
|
+
// complete, and neither set was truncated. Every other run is EXCLUDED AND
|
|
64
|
+
// COUNTED BY REASON rather than absorbed. A plan that declined to predict for
|
|
65
|
+
// some task is excluded rather than imputed as predicting nothing: imputing
|
|
66
|
+
// would depress the share for a planner's silence, and read as an implementer
|
|
67
|
+
// going off-plan.
|
|
68
|
+
//
|
|
69
|
+
// Exposed to skills as `interlock report [--json] [--since <iso>] [--change <name>]`.
|
|
70
|
+
|
|
71
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
|
72
|
+
import { join } from 'node:path'
|
|
73
|
+
import { LEARNING_DIR, OUTCOMES_FILE, readOutcomes } from './outcomes.mjs'
|
|
74
|
+
import { METRICS_DIR, REVIEW_METRICS_SCHEMA } from './metrics.mjs'
|
|
75
|
+
import { RUN_LOG_DIR, readRunLog, runLogDir } from './run-log.mjs'
|
|
76
|
+
import { EVAL_HISTORY_DIR, SHIP_OUTCOMES_FILE, readShipOutcomes } from './eval-history.mjs'
|
|
77
|
+
import { REPORT_CAPS } from './limits.mjs'
|
|
78
|
+
|
|
79
|
+
/** Bump when an indicator's definition changes, so two reports stay comparable. */
|
|
80
|
+
export const REPORT_SCHEMA = 'interlock.report/1'
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Where each indicator's numbers came from. Kept as an explicit vocabulary
|
|
84
|
+
* rather than a free string so a reader can tell a measurement from a report
|
|
85
|
+
* without reading this file.
|
|
86
|
+
*/
|
|
87
|
+
export const SOURCES = Object.freeze({
|
|
88
|
+
RECEIPT: 'receipt',
|
|
89
|
+
TRAJECTORY: 'trajectory',
|
|
90
|
+
OUTCOME_OBSERVED: 'outcome.observed',
|
|
91
|
+
OUTCOME_REPORTED: 'outcome.reported',
|
|
92
|
+
REVIEW_METRICS: 'review-metrics',
|
|
93
|
+
/**
|
|
94
|
+
* The outcome-eval history — results from fixture runs, not from real work.
|
|
95
|
+
* Its own source name is what keeps it out of every other indicator: a figure
|
|
96
|
+
* that summed a fixture result with a real run would be reporting a number
|
|
97
|
+
* about neither.
|
|
98
|
+
*/
|
|
99
|
+
EVAL_HISTORY: 'eval-history'
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The change name a trajectory carries when nothing told it which change it was
|
|
104
|
+
* shipping. Runs carrying it are counted and reported as unattributed rather
|
|
105
|
+
* than folded into a per-change tally — `a8e0db5` fixed the cause, so the share
|
|
106
|
+
* is a fact about when a run happened, not a defect in this reader.
|
|
107
|
+
*/
|
|
108
|
+
export const UNATTRIBUTED = 'unnamed'
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* What every surface says when the outcome-eval history holds nothing.
|
|
112
|
+
*
|
|
113
|
+
* Exported so the text and HTML renderings read it from one place. Two surfaces
|
|
114
|
+
* wording the same absence differently is how a reader comes to believe they are
|
|
115
|
+
* looking at two different facts.
|
|
116
|
+
*/
|
|
117
|
+
export const NO_EVAL_RESULT = 'no outcome-eval result has been recorded'
|
|
118
|
+
|
|
119
|
+
function messageOf(err) {
|
|
120
|
+
return (err && err.message) || String(err)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function isObject(value) {
|
|
124
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* One indicator. `value` is `null` whenever the denominator is zero, and
|
|
129
|
+
* `reason` then says why — the whole point of property 3 in the header.
|
|
130
|
+
*/
|
|
131
|
+
function indicator(source, { value = null, observedOf = 0, notObserved = 0, reason = null } = {}) {
|
|
132
|
+
return {
|
|
133
|
+
source,
|
|
134
|
+
value: observedOf > 0 ? value : null,
|
|
135
|
+
observedOf,
|
|
136
|
+
notObserved,
|
|
137
|
+
reason: observedOf > 0 ? null : reason || 'nothing observed this value'
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A share, rounded to four places so a rate reads as a rate rather than a float artefact. */
|
|
142
|
+
function share(numerator, denominator) {
|
|
143
|
+
if (!denominator) return null
|
|
144
|
+
return Math.round((numerator / denominator) * 10000) / 10000
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// A tri-state count, matching `lib/run-log.mjs`'s receipt fields: `null` means
|
|
148
|
+
// the run never found out, which is not zero.
|
|
149
|
+
function observedCount(value) {
|
|
150
|
+
if (value === null || value === undefined) return null
|
|
151
|
+
const n = Number(value)
|
|
152
|
+
return Number.isFinite(n) && n >= 0 ? Math.trunc(n) : null
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// Corpus reading
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Enumerate trajectory files newest-first and bound how many are opened.
|
|
161
|
+
*
|
|
162
|
+
* Ordered by mtime rather than by the timestamps inside, because the bound has
|
|
163
|
+
* to be applied *before* reading — sorting by recorded start time would require
|
|
164
|
+
* opening every file to decide which files not to open. mtime is the best
|
|
165
|
+
* available proxy for "most recent" without a read, and the truncation is
|
|
166
|
+
* reported rather than sampled silently.
|
|
167
|
+
*/
|
|
168
|
+
function enumerateTrajectories(root, maxRuns) {
|
|
169
|
+
const dir = runLogDir(root)
|
|
170
|
+
let names
|
|
171
|
+
try {
|
|
172
|
+
names = existsSync(dir) ? readdirSync(dir) : []
|
|
173
|
+
} catch (err) {
|
|
174
|
+
return { total: 0, runIds: [], truncated: false, notScanned: 0, reason: messageOf(err) }
|
|
175
|
+
}
|
|
176
|
+
const files = names.filter(name => name.endsWith('.jsonl'))
|
|
177
|
+
const stamped = files.map(name => {
|
|
178
|
+
let mtime = 0
|
|
179
|
+
try {
|
|
180
|
+
mtime = statSync(join(dir, name)).mtimeMs
|
|
181
|
+
} catch {
|
|
182
|
+
// An unstattable file sorts last and is still eligible to be read; the
|
|
183
|
+
// read reports its own failure.
|
|
184
|
+
mtime = 0
|
|
185
|
+
}
|
|
186
|
+
return { runId: name.slice(0, -'.jsonl'.length), mtime }
|
|
187
|
+
})
|
|
188
|
+
stamped.sort((a, b) => b.mtime - a.mtime)
|
|
189
|
+
const kept = stamped.slice(0, maxRuns)
|
|
190
|
+
return {
|
|
191
|
+
total: files.length,
|
|
192
|
+
runIds: kept.map(s => s.runId),
|
|
193
|
+
truncated: files.length > kept.length,
|
|
194
|
+
notScanned: files.length - kept.length,
|
|
195
|
+
reason: null
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Read the trajectories, keeping one digest per run rather than every event —
|
|
201
|
+
* the indicators need tallies, and holding 4 000 event objects to compute six
|
|
202
|
+
* numbers would be the only unbounded allocation in this module.
|
|
203
|
+
*
|
|
204
|
+
* A run is in the window if its earliest record is at or after `since`. One rule
|
|
205
|
+
* for run-level and event-level indicators alike, so they share a denominator
|
|
206
|
+
* basis; two rules would make the two halves of the report quietly
|
|
207
|
+
* incomparable.
|
|
208
|
+
*/
|
|
209
|
+
function digestTrajectories(root, { since, change, maxRuns }) {
|
|
210
|
+
const listing = enumerateTrajectories(root, maxRuns)
|
|
211
|
+
const runs = []
|
|
212
|
+
const unreadable = []
|
|
213
|
+
let skippedLines = 0
|
|
214
|
+
let filteredOut = 0
|
|
215
|
+
|
|
216
|
+
for (const runId of listing.runIds) {
|
|
217
|
+
const read = readRunLog(root, runId)
|
|
218
|
+
if (read.reason) {
|
|
219
|
+
unreadable.push({ runId, reason: read.reason })
|
|
220
|
+
continue
|
|
221
|
+
}
|
|
222
|
+
skippedLines += read.skipped.length
|
|
223
|
+
const records = read.records
|
|
224
|
+
if (!records.length) {
|
|
225
|
+
// A file that parsed to nothing is not an unreadable file and not a run.
|
|
226
|
+
filteredOut++
|
|
227
|
+
continue
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const firstTs = records.map(r => (typeof r.ts === 'string' ? r.ts : '')).filter(Boolean).sort()[0] || ''
|
|
231
|
+
if (since && (!firstTs || firstTs < since)) {
|
|
232
|
+
filteredOut++
|
|
233
|
+
continue
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const start = records.find(r => r.type === 'run-start')
|
|
237
|
+
const receipt = records.find(r => r.type === 'run-receipt')
|
|
238
|
+
const runChange =
|
|
239
|
+
(start && typeof start.change === 'string' && start.change) ||
|
|
240
|
+
(typeof records[0].change === 'string' && records[0].change) ||
|
|
241
|
+
UNATTRIBUTED
|
|
242
|
+
if (change && runChange !== change) {
|
|
243
|
+
filteredOut++
|
|
244
|
+
continue
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const waveActions = records.filter(r => r.type === 'wave-action')
|
|
248
|
+
const cliExits = records.filter(r => r.type === 'cli-exit')
|
|
249
|
+
|
|
250
|
+
runs.push({
|
|
251
|
+
runId,
|
|
252
|
+
change: runChange,
|
|
253
|
+
startedAt: firstTs || null,
|
|
254
|
+
hasRunStart: Boolean(start),
|
|
255
|
+
hasTerminal: records.some(r => r.type === 'run-complete' || r.type === 'run-halt'),
|
|
256
|
+
hasReceipt: Boolean(receipt),
|
|
257
|
+
// Receipt-derived, tri-state throughout: a receipt written by a run that
|
|
258
|
+
// halted before its review step reports the review counts as unobserved.
|
|
259
|
+
halted: receipt ? receipt.halted === true : null,
|
|
260
|
+
remediationRounds: receipt ? observedCount(receipt.remediationRounds) : null,
|
|
261
|
+
planStatus: receipt && typeof receipt.planStatus === 'string' ? receipt.planStatus : null,
|
|
262
|
+
reviewRaised: receipt ? observedCount(receipt.reviewRaised) : null,
|
|
263
|
+
reviewSurviving: receipt ? observedCount(receipt.reviewSurviving) : null,
|
|
264
|
+
reviewBlockers: receipt ? observedCount(receipt.reviewBlockers) : null,
|
|
265
|
+
reviewWarnings: receipt ? observedCount(receipt.reviewWarnings) : null,
|
|
266
|
+
// The two path sets, carried as `null` unless the receipt recorded an
|
|
267
|
+
// actual array. `committed` comes with them because it is what separates
|
|
268
|
+
// the two ways a touched set can be missing: a run that made no commit
|
|
269
|
+
// from a commit whose paths could not be read.
|
|
270
|
+
committed: receipt ? (receipt.committed === true ? true : receipt.committed === false ? false : null) : null,
|
|
271
|
+
touchedPaths: receipt && Array.isArray(receipt.touchedPaths) ? receipt.touchedPaths : null,
|
|
272
|
+
predictedPaths: receipt && Array.isArray(receipt.predictedPaths) ? receipt.predictedPaths : null,
|
|
273
|
+
predictedPathsComplete: receipt && receipt.predictedPathsComplete === true,
|
|
274
|
+
pathsTruncated:
|
|
275
|
+
Boolean(receipt) &&
|
|
276
|
+
(receipt.touchedPathsTruncated === true || receipt.predictedPathsTruncated === true),
|
|
277
|
+
// Trajectory-derived, and available on runs with no receipt at all — which
|
|
278
|
+
// is why these two indicators carry the report before the receipt path has
|
|
279
|
+
// ever fired.
|
|
280
|
+
replanned: waveActions.some(r => r.source === 'replan'),
|
|
281
|
+
hasWaveActions: waveActions.length > 0,
|
|
282
|
+
exits: cliExits.map(r => ({
|
|
283
|
+
command: typeof r.command === 'string' && r.command ? r.command : '(unnamed)',
|
|
284
|
+
exitCode: Number(r.exitCode) || 0
|
|
285
|
+
}))
|
|
286
|
+
})
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return { listing, runs, unreadable, skippedLines, filteredOut }
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Read `.claude/metrics/`, classifying by the declared `schema` and never by
|
|
294
|
+
* filename.
|
|
295
|
+
*
|
|
296
|
+
* `lib/metrics.mjs` writes `review-<change>-<stamp>.json`; `skills/review-
|
|
297
|
+
* artifacts/SKILL.md` writes `review-artifacts-<change>-<stamp>.json`, which the
|
|
298
|
+
* glob `review-*.json` also matches while carrying full finding bodies instead
|
|
299
|
+
* of counts. A filename reader would mix a skill's prose into a counts
|
|
300
|
+
* indicator and nothing would notice, so an unrecognized shape is named and
|
|
301
|
+
* excluded rather than parsed hopefully.
|
|
302
|
+
*/
|
|
303
|
+
function digestMetrics(root, { since, change }) {
|
|
304
|
+
const dir = join(root, METRICS_DIR)
|
|
305
|
+
const empty = { exists: false, total: 0, recognized: [], unrecognized: [], unreadable: [], filteredOut: 0 }
|
|
306
|
+
let names
|
|
307
|
+
try {
|
|
308
|
+
if (!existsSync(dir)) return empty
|
|
309
|
+
names = readdirSync(dir).filter(n => n.endsWith('.json'))
|
|
310
|
+
} catch (err) {
|
|
311
|
+
return { ...empty, exists: true, unreadable: [{ file: METRICS_DIR, reason: messageOf(err) }] }
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const recognized = []
|
|
315
|
+
const unrecognized = []
|
|
316
|
+
const unreadable = []
|
|
317
|
+
let filteredOut = 0
|
|
318
|
+
|
|
319
|
+
for (const name of names.sort()) {
|
|
320
|
+
let parsed
|
|
321
|
+
try {
|
|
322
|
+
parsed = JSON.parse(readFileSync(join(dir, name), 'utf8'))
|
|
323
|
+
} catch (err) {
|
|
324
|
+
unreadable.push({ file: name, reason: messageOf(err) })
|
|
325
|
+
continue
|
|
326
|
+
}
|
|
327
|
+
if (!isObject(parsed) || parsed.schema !== REVIEW_METRICS_SCHEMA) {
|
|
328
|
+
unrecognized.push({ file: name, schema: isObject(parsed) && typeof parsed.schema === 'string' ? parsed.schema : null })
|
|
329
|
+
continue
|
|
330
|
+
}
|
|
331
|
+
const ts = typeof parsed.timestamp === 'string' ? parsed.timestamp : ''
|
|
332
|
+
if (since && (!ts || ts < since)) {
|
|
333
|
+
filteredOut++
|
|
334
|
+
continue
|
|
335
|
+
}
|
|
336
|
+
if (change && parsed.change !== change) {
|
|
337
|
+
filteredOut++
|
|
338
|
+
continue
|
|
339
|
+
}
|
|
340
|
+
const counts = isObject(parsed.counts) ? parsed.counts : {}
|
|
341
|
+
recognized.push({
|
|
342
|
+
file: name,
|
|
343
|
+
change: typeof parsed.change === 'string' ? parsed.change : null,
|
|
344
|
+
raised: observedCount(counts.raised) || 0,
|
|
345
|
+
dismissed: observedCount(counts.dismissed) || 0,
|
|
346
|
+
droppedByQuality: observedCount(counts.droppedByQuality) || 0,
|
|
347
|
+
surviving: observedCount(counts.surviving) || 0
|
|
348
|
+
})
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return { exists: true, total: names.length, recognized, unrecognized, unreadable, filteredOut }
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Read `evals/history/ship-outcomes.jsonl` — the fourth corpus.
|
|
356
|
+
*
|
|
357
|
+
* Under exactly the rules the other three are read by: never throws, a missing
|
|
358
|
+
* or unreadable file is a reported condition carrying its reason, a torn line
|
|
359
|
+
* costs that record and not the file, and a line is classified by its declared
|
|
360
|
+
* schema rather than by where it was found.
|
|
361
|
+
*
|
|
362
|
+
* THE CHANGE FILTER CANNOT APPLY HERE, and that is stated rather than worked
|
|
363
|
+
* around. Every other corpus keys on the change a run was shipping; an
|
|
364
|
+
* outcome-eval result keys on a FIXTURE, which is not a change in this
|
|
365
|
+
* repository and never will be. Returning these rows unfiltered under a
|
|
366
|
+
* `--change` view would put fixture results into a page about one change, and
|
|
367
|
+
* matching them against the change name would silently return nothing. So the
|
|
368
|
+
* corpus is excluded and says so — the quiet degradation this project forbids,
|
|
369
|
+
* refused in the smallest place it could have happened.
|
|
370
|
+
*/
|
|
371
|
+
function digestEvalHistory(root, { since, change }) {
|
|
372
|
+
const path = join(root, EVAL_HISTORY_DIR, SHIP_OUTCOMES_FILE)
|
|
373
|
+
if (change) {
|
|
374
|
+
return {
|
|
375
|
+
path,
|
|
376
|
+
exists: null,
|
|
377
|
+
excluded: true,
|
|
378
|
+
reason:
|
|
379
|
+
`excluded from a change-filtered view: an outcome-eval result is keyed by fixture and ` +
|
|
380
|
+
`version, not by a change name, so the filter "${change}" cannot be applied to it`,
|
|
381
|
+
records: [],
|
|
382
|
+
unrecognized: [],
|
|
383
|
+
skipped: [],
|
|
384
|
+
filteredOut: 0
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const read = readShipOutcomes(root)
|
|
389
|
+
const kept = []
|
|
390
|
+
let filteredOut = 0
|
|
391
|
+
for (const record of read.records) {
|
|
392
|
+
const ts = typeof record.ts === 'string' ? record.ts : ''
|
|
393
|
+
// A window filter narrows this corpus like any other: an excluded row
|
|
394
|
+
// contributes to no value AND to no denominator.
|
|
395
|
+
if (since && (!ts || ts < since)) {
|
|
396
|
+
filteredOut++
|
|
397
|
+
continue
|
|
398
|
+
}
|
|
399
|
+
kept.push(record)
|
|
400
|
+
}
|
|
401
|
+
return {
|
|
402
|
+
path: read.path || path,
|
|
403
|
+
exists: read.exists,
|
|
404
|
+
excluded: false,
|
|
405
|
+
reason: read.reason,
|
|
406
|
+
records: kept,
|
|
407
|
+
unrecognized: read.unrecognized,
|
|
408
|
+
skipped: read.skipped,
|
|
409
|
+
filteredOut
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function digestOutcomes(root, { since, change }) {
|
|
414
|
+
const read = readOutcomes(root)
|
|
415
|
+
const kept = []
|
|
416
|
+
let filteredOut = 0
|
|
417
|
+
for (const record of read.records) {
|
|
418
|
+
const ts = typeof record.ts === 'string' ? record.ts : ''
|
|
419
|
+
if (since && (!ts || ts < since)) {
|
|
420
|
+
filteredOut++
|
|
421
|
+
continue
|
|
422
|
+
}
|
|
423
|
+
if (change && record.change !== change) {
|
|
424
|
+
filteredOut++
|
|
425
|
+
continue
|
|
426
|
+
}
|
|
427
|
+
kept.push(record)
|
|
428
|
+
}
|
|
429
|
+
return { ...read, records: kept, filteredOut }
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// ---------------------------------------------------------------------------
|
|
433
|
+
// Indicators
|
|
434
|
+
// ---------------------------------------------------------------------------
|
|
435
|
+
|
|
436
|
+
function coverageOf(trajectories, outcomes, metrics, evalHistory) {
|
|
437
|
+
const byMode = {}
|
|
438
|
+
for (const record of outcomes.records) {
|
|
439
|
+
const mode = typeof record.mode === 'string' ? record.mode : 'unknown'
|
|
440
|
+
byMode[mode] = (byMode[mode] || 0) + 1
|
|
441
|
+
}
|
|
442
|
+
return {
|
|
443
|
+
trajectories: {
|
|
444
|
+
total: trajectories.listing.total,
|
|
445
|
+
scanned: trajectories.runs.length,
|
|
446
|
+
truncated: trajectories.listing.truncated,
|
|
447
|
+
notScanned: trajectories.listing.notScanned,
|
|
448
|
+
filteredOut: trajectories.filteredOut,
|
|
449
|
+
withRunStart: trajectories.runs.filter(r => r.hasRunStart).length,
|
|
450
|
+
withTerminal: trajectories.runs.filter(r => r.hasTerminal).length,
|
|
451
|
+
withReceipt: trajectories.runs.filter(r => r.hasReceipt).length,
|
|
452
|
+
unreadable: trajectories.unreadable,
|
|
453
|
+
skippedLines: trajectories.skippedLines,
|
|
454
|
+
reason: trajectories.listing.reason
|
|
455
|
+
},
|
|
456
|
+
outcomes: {
|
|
457
|
+
path: outcomes.path,
|
|
458
|
+
exists: outcomes.exists,
|
|
459
|
+
records: outcomes.records.length,
|
|
460
|
+
byMode,
|
|
461
|
+
filteredOut: outcomes.filteredOut,
|
|
462
|
+
skippedLines: outcomes.skipped.length,
|
|
463
|
+
reason: outcomes.reason
|
|
464
|
+
},
|
|
465
|
+
metrics: {
|
|
466
|
+
exists: metrics.exists,
|
|
467
|
+
total: metrics.total,
|
|
468
|
+
recognized: metrics.recognized.length,
|
|
469
|
+
unrecognized: metrics.unrecognized,
|
|
470
|
+
unreadable: metrics.unreadable,
|
|
471
|
+
filteredOut: metrics.filteredOut
|
|
472
|
+
},
|
|
473
|
+
// The fourth corpus. Fixture runs, not real work — kept in its own block for
|
|
474
|
+
// the same reason its indicators are kept in their own group.
|
|
475
|
+
evalHistory: {
|
|
476
|
+
path: evalHistory.path,
|
|
477
|
+
exists: evalHistory.exists,
|
|
478
|
+
excluded: evalHistory.excluded,
|
|
479
|
+
records: evalHistory.records.length,
|
|
480
|
+
unrecognized: evalHistory.unrecognized,
|
|
481
|
+
skippedLines: evalHistory.skipped.length,
|
|
482
|
+
filteredOut: evalHistory.filteredOut,
|
|
483
|
+
reason: evalHistory.reason
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* The share of runs that reached their close without a remediation round.
|
|
490
|
+
*
|
|
491
|
+
* Deliberately NOT called a CI success rate: no CI result is recorded anywhere
|
|
492
|
+
* in these corpora, and borrowing the name would claim an observation nothing
|
|
493
|
+
* made. A receipt whose remediation count is unobserved is counted as not
|
|
494
|
+
* observed and leaves the denominator short — the alternative, reading absence
|
|
495
|
+
* as zero rounds, would score exactly the runs that died early as the cleanest.
|
|
496
|
+
*/
|
|
497
|
+
function firstPassShipOf(runs) {
|
|
498
|
+
const receipts = runs.filter(r => r.hasReceipt)
|
|
499
|
+
const observed = receipts.filter(r => r.halted !== null && r.remediationRounds !== null)
|
|
500
|
+
const firstPass = observed.filter(r => r.halted === false && r.remediationRounds === 0)
|
|
501
|
+
return {
|
|
502
|
+
rate: indicator(SOURCES.RECEIPT, {
|
|
503
|
+
value: share(firstPass.length, observed.length),
|
|
504
|
+
observedOf: observed.length,
|
|
505
|
+
notObserved: receipts.length - observed.length,
|
|
506
|
+
reason: receipts.length
|
|
507
|
+
? 'no receipt observed both the halt state and the remediation count'
|
|
508
|
+
: 'no run has written a receipt'
|
|
509
|
+
}),
|
|
510
|
+
firstPassRuns: firstPass.length,
|
|
511
|
+
note: 'not a CI success rate — no CI result is recorded in any corpus'
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function reworkOf(runs) {
|
|
516
|
+
const receipts = runs.filter(r => r.hasReceipt && r.remediationRounds !== null)
|
|
517
|
+
const distribution = {}
|
|
518
|
+
let total = 0
|
|
519
|
+
for (const run of receipts) {
|
|
520
|
+
const key = String(run.remediationRounds)
|
|
521
|
+
distribution[key] = (distribution[key] || 0) + 1
|
|
522
|
+
total += run.remediationRounds
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const attributed = runs.filter(r => r.change !== UNATTRIBUTED)
|
|
526
|
+
const perChange = {}
|
|
527
|
+
for (const run of attributed) perChange[run.change] = (perChange[run.change] || 0) + 1
|
|
528
|
+
const changes = Object.keys(perChange)
|
|
529
|
+
.sort()
|
|
530
|
+
.map(change => ({ change, runs: perChange[change] }))
|
|
531
|
+
|
|
532
|
+
return {
|
|
533
|
+
// Kept apart from attempts-per-change on purpose: one counts repair inside a
|
|
534
|
+
// run, the other counts runs against one change. Summing them would invent a
|
|
535
|
+
// "rework" number neither corpus supports.
|
|
536
|
+
remediationRounds: {
|
|
537
|
+
...indicator(SOURCES.RECEIPT, {
|
|
538
|
+
value: receipts.length ? Math.round((total / receipts.length) * 100) / 100 : null,
|
|
539
|
+
observedOf: receipts.length,
|
|
540
|
+
notObserved: runs.filter(r => r.hasReceipt).length - receipts.length,
|
|
541
|
+
reason: 'no receipt observed a remediation count'
|
|
542
|
+
}),
|
|
543
|
+
distribution
|
|
544
|
+
},
|
|
545
|
+
attemptsPerChange: {
|
|
546
|
+
...indicator(SOURCES.TRAJECTORY, {
|
|
547
|
+
value: changes.length ? Math.round((attributed.length / changes.length) * 100) / 100 : null,
|
|
548
|
+
observedOf: attributed.length,
|
|
549
|
+
notObserved: runs.length - attributed.length,
|
|
550
|
+
reason: `every scanned run is attributed to "${UNATTRIBUTED}"`
|
|
551
|
+
}),
|
|
552
|
+
changes,
|
|
553
|
+
unattributed: runs.length - attributed.length
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Why a run kept out of the diff-vs-plan share was kept out. Each is counted and
|
|
560
|
+
* printed beside the figure, so a reader can see which recording gap to close
|
|
561
|
+
* first rather than inferring one from a thin denominator.
|
|
562
|
+
*/
|
|
563
|
+
export const PLAN_FIDELITY_EXCLUSIONS = Object.freeze({
|
|
564
|
+
noCommit: 'the run made no commit, so no touched set was observed',
|
|
565
|
+
unreadableSet: 'a path set was recorded unobserved — the read failed',
|
|
566
|
+
incompletePrediction: 'the executed plan did not predict paths for every task',
|
|
567
|
+
truncatedSet: 'a path set exceeded its recorded bound, so it is a prefix rather than the set'
|
|
568
|
+
})
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* The share of touched paths the executed plan predicted, pooled over paths.
|
|
572
|
+
*
|
|
573
|
+
* POOLED, NOT AVERAGED PER RUN: a run touching one file would otherwise weigh
|
|
574
|
+
* the same as a run touching forty, and the question ("how much of what we
|
|
575
|
+
* changed was foreseen") is about paths, not about runs.
|
|
576
|
+
*
|
|
577
|
+
* THE DIRECTION IS FIXED. Numerator: touched paths that were predicted.
|
|
578
|
+
* Denominator: touched paths. The converse — predicted paths that were touched
|
|
579
|
+
* — answers a different question (a plan that predicted work the run never did)
|
|
580
|
+
* and is never published under this name.
|
|
581
|
+
*/
|
|
582
|
+
function diffMatchesPlanOf(runs) {
|
|
583
|
+
const receipts = runs.filter(r => r.hasReceipt)
|
|
584
|
+
const excluded = { noCommit: 0, unreadableSet: 0, incompletePrediction: 0, truncatedSet: 0 }
|
|
585
|
+
const qualifying = []
|
|
586
|
+
|
|
587
|
+
for (const run of receipts) {
|
|
588
|
+
if (!Array.isArray(run.touchedPaths)) {
|
|
589
|
+
// Which of the two absences this is, decided from the recorded commit
|
|
590
|
+
// state rather than by parsing the recorded reason string.
|
|
591
|
+
if (run.committed !== true) excluded.noCommit++
|
|
592
|
+
else excluded.unreadableSet++
|
|
593
|
+
continue
|
|
594
|
+
}
|
|
595
|
+
if (!Array.isArray(run.predictedPaths)) {
|
|
596
|
+
excluded.unreadableSet++
|
|
597
|
+
continue
|
|
598
|
+
}
|
|
599
|
+
if (run.pathsTruncated) {
|
|
600
|
+
excluded.truncatedSet++
|
|
601
|
+
continue
|
|
602
|
+
}
|
|
603
|
+
if (!run.predictedPathsComplete) {
|
|
604
|
+
excluded.incompletePrediction++
|
|
605
|
+
continue
|
|
606
|
+
}
|
|
607
|
+
qualifying.push(run)
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
let matched = 0
|
|
611
|
+
let touched = 0
|
|
612
|
+
for (const run of qualifying) {
|
|
613
|
+
const predicted = new Set(run.predictedPaths)
|
|
614
|
+
touched += run.touchedPaths.length
|
|
615
|
+
matched += run.touchedPaths.filter(p => predicted.has(p)).length
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const excludedRuns = Object.values(excluded).reduce((a, b) => a + b, 0)
|
|
619
|
+
|
|
620
|
+
return {
|
|
621
|
+
// A zero denominator yields `null` with its reason, never `0` — a `0` here
|
|
622
|
+
// would assert that nothing the runs touched was predicted.
|
|
623
|
+
...indicator(SOURCES.RECEIPT, {
|
|
624
|
+
value: share(matched, touched),
|
|
625
|
+
observedOf: touched,
|
|
626
|
+
notObserved: excludedRuns,
|
|
627
|
+
reason: qualifying.length
|
|
628
|
+
? 'the qualifying runs committed no paths, so there is nothing to have predicted'
|
|
629
|
+
: receipts.length
|
|
630
|
+
? 'no scanned run recorded both path sets complete and untruncated'
|
|
631
|
+
: 'no run has written a receipt'
|
|
632
|
+
}),
|
|
633
|
+
direction:
|
|
634
|
+
'the share of paths the run TOUCHED that the executed plan predicted — the touched set is ' +
|
|
635
|
+
'the denominator; the converse figure is a different question and is not published here',
|
|
636
|
+
runs: qualifying.length,
|
|
637
|
+
paths: { matched, touched },
|
|
638
|
+
excluded,
|
|
639
|
+
excludedRuns,
|
|
640
|
+
exclusionReasons: PLAN_FIDELITY_EXCLUSIONS
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function planFidelityOf(runs) {
|
|
645
|
+
const withStatus = runs.filter(r => r.planStatus)
|
|
646
|
+
const statusCounts = {}
|
|
647
|
+
for (const run of withStatus) statusCounts[run.planStatus] = (statusCounts[run.planStatus] || 0) + 1
|
|
648
|
+
|
|
649
|
+
const withWaves = runs.filter(r => r.hasWaveActions)
|
|
650
|
+
const revised = withWaves.filter(r => r.replanned)
|
|
651
|
+
|
|
652
|
+
return {
|
|
653
|
+
planStatus: {
|
|
654
|
+
...indicator(SOURCES.RECEIPT, {
|
|
655
|
+
value: null,
|
|
656
|
+
observedOf: withStatus.length,
|
|
657
|
+
notObserved: runs.filter(r => r.hasReceipt).length - withStatus.length,
|
|
658
|
+
reason: 'no receipt recorded a plan-reuse status'
|
|
659
|
+
}),
|
|
660
|
+
// A distribution, not a rate: the statuses are the `REUSE_*` vocabulary in
|
|
661
|
+
// lib/plan-fingerprint.mjs and are not ordered, so there is no numerator.
|
|
662
|
+
counts: statusCounts
|
|
663
|
+
},
|
|
664
|
+
midRunRevision: indicator(SOURCES.TRAJECTORY, {
|
|
665
|
+
value: share(revised.length, withWaves.length),
|
|
666
|
+
observedOf: withWaves.length,
|
|
667
|
+
notObserved: runs.length - withWaves.length,
|
|
668
|
+
reason: 'no scanned run recorded a wave action'
|
|
669
|
+
}),
|
|
670
|
+
// Computed from the receipt's two path sets. See the module header: the two
|
|
671
|
+
// figures above are about the plan, this one is about the diff, and none of
|
|
672
|
+
// the three may be published under another's name.
|
|
673
|
+
diffMatchesPlan: diffMatchesPlanOf(runs)
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function reviewFindingsOf(runs, metrics) {
|
|
678
|
+
const files = metrics.recognized
|
|
679
|
+
const fromMetrics = files.reduce(
|
|
680
|
+
(acc, f) => ({
|
|
681
|
+
raised: acc.raised + f.raised,
|
|
682
|
+
dismissed: acc.dismissed + f.dismissed,
|
|
683
|
+
droppedByQuality: acc.droppedByQuality + f.droppedByQuality,
|
|
684
|
+
surviving: acc.surviving + f.surviving
|
|
685
|
+
}),
|
|
686
|
+
{ raised: 0, dismissed: 0, droppedByQuality: 0, surviving: 0 }
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
const receipts = runs.filter(r => r.hasReceipt && r.reviewRaised !== null)
|
|
690
|
+
const fromReceipts = receipts.reduce(
|
|
691
|
+
(acc, r) => ({
|
|
692
|
+
raised: acc.raised + r.reviewRaised,
|
|
693
|
+
surviving: acc.surviving + (r.reviewSurviving || 0),
|
|
694
|
+
blockers: acc.blockers + (r.reviewBlockers || 0),
|
|
695
|
+
warnings: acc.warnings + (r.reviewWarnings || 0)
|
|
696
|
+
}),
|
|
697
|
+
{ raised: 0, surviving: 0, blockers: 0, warnings: 0 }
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
return {
|
|
701
|
+
// Two series, never summed: different writers, different runs, and one of
|
|
702
|
+
// them counts a dismissal the other does not record at all.
|
|
703
|
+
fromMetrics: {
|
|
704
|
+
files: files.length,
|
|
705
|
+
counts: fromMetrics,
|
|
706
|
+
dismissalShare: indicator(SOURCES.REVIEW_METRICS, {
|
|
707
|
+
value: share(fromMetrics.dismissed, fromMetrics.raised),
|
|
708
|
+
observedOf: fromMetrics.raised,
|
|
709
|
+
notObserved: files.length,
|
|
710
|
+
reason: files.length
|
|
711
|
+
? 'no finding was raised in the recognized review-metrics files'
|
|
712
|
+
: 'no review-metrics file was recognized'
|
|
713
|
+
})
|
|
714
|
+
},
|
|
715
|
+
fromReceipts: {
|
|
716
|
+
runs: receipts.length,
|
|
717
|
+
counts: fromReceipts,
|
|
718
|
+
// The receipt records what survived, not what was dismissed, so the share
|
|
719
|
+
// it supports is survival — naming it a dismissal share would assert a
|
|
720
|
+
// count nothing wrote.
|
|
721
|
+
survivalShare: indicator(SOURCES.RECEIPT, {
|
|
722
|
+
value: share(fromReceipts.surviving, fromReceipts.raised),
|
|
723
|
+
observedOf: fromReceipts.raised,
|
|
724
|
+
notObserved: runs.filter(r => r.hasReceipt).length - receipts.length,
|
|
725
|
+
reason: 'no receipt observed a raised-finding count'
|
|
726
|
+
})
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Which deterministic gate actually blocks, and how often. Derived from
|
|
733
|
+
* `cli-exit` events, which exist on runs that never wrote a receipt — this and
|
|
734
|
+
* `midRunRevision` are why the report says something useful before the receipt
|
|
735
|
+
* path has ever fired.
|
|
736
|
+
*
|
|
737
|
+
* A command with no recorded exits is absent rather than zero: an unexercised
|
|
738
|
+
* command and a command that never failed are different facts.
|
|
739
|
+
*/
|
|
740
|
+
function gateExitHealthOf(runs) {
|
|
741
|
+
const byCommand = new Map()
|
|
742
|
+
let totalExits = 0
|
|
743
|
+
let totalNonZero = 0
|
|
744
|
+
for (const run of runs) {
|
|
745
|
+
for (const exit of run.exits) {
|
|
746
|
+
const entry = byCommand.get(exit.command) || { command: exit.command, exits: 0, nonZero: 0 }
|
|
747
|
+
entry.exits++
|
|
748
|
+
totalExits++
|
|
749
|
+
if (exit.exitCode !== 0) {
|
|
750
|
+
entry.nonZero++
|
|
751
|
+
totalNonZero++
|
|
752
|
+
}
|
|
753
|
+
byCommand.set(exit.command, entry)
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
const commands = [...byCommand.values()]
|
|
757
|
+
.map(e => ({ ...e, nonZeroShare: share(e.nonZero, e.exits) }))
|
|
758
|
+
.sort((a, b) => b.nonZero - a.nonZero || a.command.localeCompare(b.command))
|
|
759
|
+
return {
|
|
760
|
+
nonZeroShare: indicator(SOURCES.TRAJECTORY, {
|
|
761
|
+
value: share(totalNonZero, totalExits),
|
|
762
|
+
observedOf: totalExits,
|
|
763
|
+
notObserved: 0,
|
|
764
|
+
reason: 'no scanned run recorded a command exit'
|
|
765
|
+
}),
|
|
766
|
+
totalExits,
|
|
767
|
+
totalNonZero,
|
|
768
|
+
commands
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* What the outcome-eval history records, per arm.
|
|
774
|
+
*
|
|
775
|
+
* THREE RULES, and they are the whole reason this is a separate function rather
|
|
776
|
+
* than more entries in the groups above:
|
|
777
|
+
*
|
|
778
|
+
* 1. Its indicators are sourced from `eval-history` and from nothing else. No
|
|
779
|
+
* figure here counts a real run, and no figure above counts a fixture
|
|
780
|
+
* result. The two are never summed.
|
|
781
|
+
* 2. Every figure carries its own denominator, and a zero denominator yields
|
|
782
|
+
* `null` with a reason rather than a `0`.
|
|
783
|
+
* 3. There is no verdict. A pass share here is a count over a stated whole; it
|
|
784
|
+
* is not a grade, and the thinness of the record is stated beside it
|
|
785
|
+
* because one run per fixture per arm licenses nothing on its own.
|
|
786
|
+
*
|
|
787
|
+
* A row missing its model or its eval-agent identity is counted `incomparable`
|
|
788
|
+
* and excluded: without the instrument, a row cannot be paired with another for
|
|
789
|
+
* a version comparison, and pairing it anyway is how an apparatus change gets
|
|
790
|
+
* read as a product change.
|
|
791
|
+
*/
|
|
792
|
+
function shipOutcomeEvalOf(history) {
|
|
793
|
+
const base = {
|
|
794
|
+
excluded: Boolean(history.excluded),
|
|
795
|
+
reason: history.excluded ? history.reason : null,
|
|
796
|
+
records: history.records.length,
|
|
797
|
+
byArm: {},
|
|
798
|
+
fixtures: [],
|
|
799
|
+
versions: {},
|
|
800
|
+
incomparable: 0,
|
|
801
|
+
note:
|
|
802
|
+
'fixture results, never real runs — no figure here is summed with another corpus, and one ' +
|
|
803
|
+
'run per fixture per arm licenses no conclusion on its own'
|
|
804
|
+
}
|
|
805
|
+
if (history.excluded) return base
|
|
806
|
+
|
|
807
|
+
const fixtures = new Set()
|
|
808
|
+
const byArm = {}
|
|
809
|
+
for (const record of history.records) {
|
|
810
|
+
const arm = typeof record.arm === 'string' ? record.arm : 'unknown'
|
|
811
|
+
if (typeof record.fixture === 'string') fixtures.add(record.fixture)
|
|
812
|
+
if (typeof record.version === 'string') {
|
|
813
|
+
base.versions[record.version] = (base.versions[record.version] || 0) + 1
|
|
814
|
+
}
|
|
815
|
+
// The instrument, or its absence. A row that cannot say which model and
|
|
816
|
+
// which agent produced it is not comparable across versions, and says so.
|
|
817
|
+
const comparable =
|
|
818
|
+
typeof record.model === 'string' &&
|
|
819
|
+
record.model &&
|
|
820
|
+
record.model !== 'unknown' &&
|
|
821
|
+
typeof record.evalAgent === 'string' &&
|
|
822
|
+
record.evalAgent &&
|
|
823
|
+
record.evalAgent !== 'unknown'
|
|
824
|
+
if (!comparable) base.incomparable++
|
|
825
|
+
|
|
826
|
+
const entry = (byArm[arm] = byArm[arm] || { rows: 0, met: 0, applicable: 0, notApplicable: 0, unobserved: 0 })
|
|
827
|
+
entry.rows++
|
|
828
|
+
const tally = record.tally && typeof record.tally === 'object' ? record.tally : {}
|
|
829
|
+
entry.met += observedCount(tally.met) || 0
|
|
830
|
+
entry.applicable += observedCount(tally.applicable) || 0
|
|
831
|
+
entry.notApplicable += observedCount(tally.notApplicable) || 0
|
|
832
|
+
entry.unobserved += observedCount(tally.unobserved) || 0
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
for (const [arm, entry] of Object.entries(byArm)) {
|
|
836
|
+
base.byArm[arm] = {
|
|
837
|
+
rows: entry.rows,
|
|
838
|
+
criteriaMet: indicator(SOURCES.EVAL_HISTORY, {
|
|
839
|
+
value: share(entry.met, entry.applicable),
|
|
840
|
+
observedOf: entry.applicable,
|
|
841
|
+
notObserved: entry.notApplicable,
|
|
842
|
+
reason: entry.rows
|
|
843
|
+
? 'no recorded result carried an applicable criterion'
|
|
844
|
+
: 'no result has been recorded for this arm'
|
|
845
|
+
}),
|
|
846
|
+
// Kept beside the share rather than folded into it: a criterion that could
|
|
847
|
+
// not be decided is not a criterion that failed, and a criterion the arm
|
|
848
|
+
// cannot have is neither.
|
|
849
|
+
notApplicable: entry.notApplicable,
|
|
850
|
+
unobserved: entry.unobserved
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
base.fixtures = [...fixtures].sort()
|
|
854
|
+
return base
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// ---------------------------------------------------------------------------
|
|
858
|
+
// Report
|
|
859
|
+
// ---------------------------------------------------------------------------
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* Compute every indicator the corpora support. Never throws, writes nothing,
|
|
863
|
+
* and produces no verdict.
|
|
864
|
+
*
|
|
865
|
+
* @param {string} [root] repository root
|
|
866
|
+
* @param {{since?: string, change?: string, maxRuns?: number}} [opts]
|
|
867
|
+
* `since` is an ISO 8601 instant; a run is in the window when its earliest
|
|
868
|
+
* record is at or after it. `maxRuns` overrides the published scan cap.
|
|
869
|
+
* @returns {object} report
|
|
870
|
+
*/
|
|
871
|
+
export function buildReport(root = '.', opts = {}) {
|
|
872
|
+
const since = typeof opts.since === 'string' && opts.since.trim() ? opts.since.trim() : null
|
|
873
|
+
const change = typeof opts.change === 'string' && opts.change.trim() ? opts.change.trim() : null
|
|
874
|
+
const requested = Number(opts.maxRuns)
|
|
875
|
+
const maxRuns =
|
|
876
|
+
Number.isFinite(requested) && requested > 0
|
|
877
|
+
? Math.min(Math.trunc(requested), REPORT_CAPS.maxRunsScanned)
|
|
878
|
+
: REPORT_CAPS.maxRunsScanned
|
|
879
|
+
|
|
880
|
+
const filter = { since, change, maxRuns }
|
|
881
|
+
|
|
882
|
+
try {
|
|
883
|
+
const trajectories = digestTrajectories(root, filter)
|
|
884
|
+
const outcomes = digestOutcomes(root, filter)
|
|
885
|
+
const metrics = digestMetrics(root, filter)
|
|
886
|
+
const evalHistory = digestEvalHistory(root, filter)
|
|
887
|
+
const runs = trajectories.runs
|
|
888
|
+
|
|
889
|
+
return {
|
|
890
|
+
schema: REPORT_SCHEMA,
|
|
891
|
+
root,
|
|
892
|
+
filter,
|
|
893
|
+
coverage: coverageOf(trajectories, outcomes, metrics, evalHistory),
|
|
894
|
+
indicators: {
|
|
895
|
+
firstPassShip: firstPassShipOf(runs),
|
|
896
|
+
rework: reworkOf(runs),
|
|
897
|
+
planFidelity: planFidelityOf(runs),
|
|
898
|
+
reviewFindings: reviewFindingsOf(runs, metrics),
|
|
899
|
+
gateExitHealth: gateExitHealthOf(runs),
|
|
900
|
+
// Last, and in its own group: these are fixture results, and everything
|
|
901
|
+
// above is real work. Nothing crosses between the two.
|
|
902
|
+
shipOutcomeEval: shipOutcomeEvalOf(evalHistory)
|
|
903
|
+
},
|
|
904
|
+
// Restated on the payload, not only in the header, because the payload is
|
|
905
|
+
// what a reader who never opens this file will see.
|
|
906
|
+
gates: false,
|
|
907
|
+
note: 'reports only — nothing in the loop reads this output, and no value is compared against a threshold (§4.15a)'
|
|
908
|
+
}
|
|
909
|
+
} catch (err) {
|
|
910
|
+
// A reader that cannot read is still a reader that reports. Property 1.
|
|
911
|
+
return {
|
|
912
|
+
schema: REPORT_SCHEMA,
|
|
913
|
+
root,
|
|
914
|
+
filter,
|
|
915
|
+
coverage: null,
|
|
916
|
+
indicators: null,
|
|
917
|
+
gates: false,
|
|
918
|
+
reason: messageOf(err),
|
|
919
|
+
note: 'the report could not be computed; nothing is asserted about the corpora'
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// ---------------------------------------------------------------------------
|
|
925
|
+
// Formatting
|
|
926
|
+
// ---------------------------------------------------------------------------
|
|
927
|
+
|
|
928
|
+
function pct(ind) {
|
|
929
|
+
if (!ind || ind.value === null) return `unobserved (${ind ? ind.reason : 'no indicator'})`
|
|
930
|
+
return `${(ind.value * 100).toFixed(1)}% of ${ind.observedOf} observed` +
|
|
931
|
+
(ind.notObserved ? `, ${ind.notObserved} not observed` : '')
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function plain(ind, unit = '') {
|
|
935
|
+
if (!ind || ind.value === null) return `unobserved (${ind ? ind.reason : 'no indicator'})`
|
|
936
|
+
return `${ind.value}${unit} of ${ind.observedOf} observed` +
|
|
937
|
+
(ind.notObserved ? `, ${ind.notObserved} not observed` : '')
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/** Human-readable report. Coverage first, per design.md D3. */
|
|
941
|
+
export function formatReport(report) {
|
|
942
|
+
const lines = []
|
|
943
|
+
lines.push('INTERLOCK REPORT — indicators over the recorded corpora. Gates nothing (§4.15a).')
|
|
944
|
+
|
|
945
|
+
if (report.filter && (report.filter.since || report.filter.change)) {
|
|
946
|
+
const bits = []
|
|
947
|
+
if (report.filter.since) bits.push(`since ${report.filter.since}`)
|
|
948
|
+
if (report.filter.change) bits.push(`change ${report.filter.change}`)
|
|
949
|
+
lines.push(` filter: ${bits.join(', ')}`)
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
if (!report.coverage) {
|
|
953
|
+
lines.push(` the report could not be computed: ${report.reason || 'unknown'}`)
|
|
954
|
+
return lines.join('\n') + '\n'
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
const { trajectories: t, outcomes: o, metrics: m, evalHistory: e } = report.coverage
|
|
958
|
+
|
|
959
|
+
lines.push('')
|
|
960
|
+
lines.push('COVERAGE — what the corpora can and cannot answer')
|
|
961
|
+
lines.push(
|
|
962
|
+
` trajectories ${t.scanned} scanned of ${t.total} on disk` +
|
|
963
|
+
(t.truncated ? ` (scan capped; ${t.notScanned} not read)` : '') +
|
|
964
|
+
(t.filteredOut ? ` (${t.filteredOut} outside the filter)` : '')
|
|
965
|
+
)
|
|
966
|
+
lines.push(` with run-start ${t.withRunStart}`)
|
|
967
|
+
lines.push(` with terminal ${t.withTerminal} (run-complete or run-halt)`)
|
|
968
|
+
lines.push(` with receipt ${t.withReceipt} — every receipt-derived indicator below rests on this`)
|
|
969
|
+
if (t.skippedLines) lines.push(` torn lines ${t.skippedLines} skipped`)
|
|
970
|
+
for (const u of t.unreadable) lines.push(` unreadable ${u.runId}: ${u.reason}`)
|
|
971
|
+
if (t.reason) lines.push(` directory ${t.reason}`)
|
|
972
|
+
|
|
973
|
+
lines.push(
|
|
974
|
+
` outcome corpus ${o.exists ? `${o.records} record(s)` : 'absent'}` +
|
|
975
|
+
(o.exists && Object.keys(o.byMode).length
|
|
976
|
+
? `: ${Object.keys(o.byMode).sort().map(k => `${o.byMode[k]} ${k}`).join(', ')}`
|
|
977
|
+
: '') +
|
|
978
|
+
(o.filteredOut ? ` (${o.filteredOut} outside the filter)` : '')
|
|
979
|
+
)
|
|
980
|
+
if (!o.exists) lines.push(` ${o.path} does not exist — no ship run has recorded an outcome`)
|
|
981
|
+
if (o.skippedLines) lines.push(` ${o.skippedLines} unreadable line(s) skipped`)
|
|
982
|
+
|
|
983
|
+
lines.push(
|
|
984
|
+
` review metrics ${m.recognized} recognized of ${m.total} file(s)` +
|
|
985
|
+
(m.filteredOut ? ` (${m.filteredOut} outside the filter)` : '')
|
|
986
|
+
)
|
|
987
|
+
for (const u of m.unrecognized) {
|
|
988
|
+
lines.push(` not counted ${u.file}${u.schema ? ` (schema ${u.schema})` : ' (no schema key)'}`)
|
|
989
|
+
}
|
|
990
|
+
for (const u of m.unreadable) lines.push(` unreadable ${u.file}: ${u.reason}`)
|
|
991
|
+
|
|
992
|
+
// The fourth corpus, and it says out loud when a filter has removed it rather
|
|
993
|
+
// than reporting an empty one.
|
|
994
|
+
if (e) {
|
|
995
|
+
lines.push(
|
|
996
|
+
` outcome evals ` +
|
|
997
|
+
(e.excluded
|
|
998
|
+
? 'excluded from this view'
|
|
999
|
+
: e.exists
|
|
1000
|
+
? `${e.records} result(s)`
|
|
1001
|
+
: 'absent') +
|
|
1002
|
+
(!e.excluded && e.filteredOut ? ` (${e.filteredOut} outside the filter)` : '')
|
|
1003
|
+
)
|
|
1004
|
+
if (e.excluded) lines.push(` ${e.reason}`)
|
|
1005
|
+
else if (!e.exists) lines.push(` ${e.path} does not exist — no outcome eval has recorded a result`)
|
|
1006
|
+
if (e.reason && !e.excluded) lines.push(` unreadable ${e.reason}`)
|
|
1007
|
+
if (e.skippedLines) lines.push(` ${e.skippedLines} unreadable line(s) skipped`)
|
|
1008
|
+
for (const u of e.unrecognized || []) {
|
|
1009
|
+
lines.push(` not counted line ${u.line}${u.schema ? ` (schema ${u.schema})` : ' (no schema key)'}`)
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
const i = report.indicators
|
|
1014
|
+
lines.push('')
|
|
1015
|
+
lines.push('INDICATORS')
|
|
1016
|
+
|
|
1017
|
+
lines.push(` first-pass ship ${pct(i.firstPassShip.rate)}`)
|
|
1018
|
+
lines.push(` ${i.firstPassShip.note}`)
|
|
1019
|
+
|
|
1020
|
+
lines.push(` remediation rounds/run ${plain(i.rework.remediationRounds)}`)
|
|
1021
|
+
const dist = Object.keys(i.rework.remediationRounds.distribution).sort()
|
|
1022
|
+
if (dist.length) {
|
|
1023
|
+
lines.push(` distribution: ${dist.map(k => `${k}→${i.rework.remediationRounds.distribution[k]}`).join(' ')}`)
|
|
1024
|
+
}
|
|
1025
|
+
lines.push(` runs per change ${plain(i.rework.attemptsPerChange)}`)
|
|
1026
|
+
if (i.rework.attemptsPerChange.unattributed) {
|
|
1027
|
+
lines.push(` ${i.rework.attemptsPerChange.unattributed} run(s) attributed to "${UNATTRIBUTED}" and excluded`)
|
|
1028
|
+
}
|
|
1029
|
+
for (const c of i.rework.attemptsPerChange.changes) {
|
|
1030
|
+
lines.push(` ${c.change}: ${c.runs} run(s)`)
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
const statuses = Object.keys(i.planFidelity.planStatus.counts).sort()
|
|
1034
|
+
lines.push(
|
|
1035
|
+
` plan-reuse status ${statuses.length ? statuses.map(s => `${s}→${i.planFidelity.planStatus.counts[s]}`).join(' ') : `unobserved (${i.planFidelity.planStatus.reason})`}`
|
|
1036
|
+
)
|
|
1037
|
+
lines.push(` plan revised mid-run ${pct(i.planFidelity.midRunRevision)}`)
|
|
1038
|
+
const dmp = i.planFidelity.diffMatchesPlan
|
|
1039
|
+
lines.push(` diff matches plan ${pct(dmp)}`)
|
|
1040
|
+
lines.push(` ${dmp.direction}`)
|
|
1041
|
+
if (dmp.value !== null) {
|
|
1042
|
+
lines.push(
|
|
1043
|
+
` ${dmp.paths.matched} of ${dmp.paths.touched} touched path(s) ` +
|
|
1044
|
+
`across ${dmp.runs} qualifying run(s)`
|
|
1045
|
+
)
|
|
1046
|
+
}
|
|
1047
|
+
// Named and counted, never absorbed: the exclusions are how a reader tells a
|
|
1048
|
+
// thin denominator from a fair one, and which recording gap to close first.
|
|
1049
|
+
for (const key of Object.keys(dmp.excluded)) {
|
|
1050
|
+
if (!dmp.excluded[key]) continue
|
|
1051
|
+
lines.push(` excluded ${dmp.excluded[key]} run(s): ${dmp.exclusionReasons[key]}`)
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
const fm = i.reviewFindings.fromMetrics
|
|
1055
|
+
lines.push(
|
|
1056
|
+
` review findings from ${fm.files} metrics file(s): ` +
|
|
1057
|
+
`${fm.counts.raised} raised, ${fm.counts.dismissed} dismissed, ` +
|
|
1058
|
+
`${fm.counts.droppedByQuality} dropped by quality, ${fm.counts.surviving} surviving`
|
|
1059
|
+
)
|
|
1060
|
+
lines.push(` dismissal share: ${pct(fm.dismissalShare)}`)
|
|
1061
|
+
const fr = i.reviewFindings.fromReceipts
|
|
1062
|
+
lines.push(
|
|
1063
|
+
` from ${fr.runs} receipt(s): ` +
|
|
1064
|
+
`${fr.counts.raised} raised, ${fr.counts.surviving} surviving, ` +
|
|
1065
|
+
`${fr.counts.blockers} blocker(s), ${fr.counts.warnings} warning(s)`
|
|
1066
|
+
)
|
|
1067
|
+
lines.push(` survival share: ${pct(fr.survivalShare)}`)
|
|
1068
|
+
lines.push(' the two series are never summed — different writers, different runs')
|
|
1069
|
+
|
|
1070
|
+
const g = i.gateExitHealth
|
|
1071
|
+
lines.push(` gate exits non-zero ${pct(g.nonZeroShare)}`)
|
|
1072
|
+
for (const c of g.commands) {
|
|
1073
|
+
lines.push(` ${String(c.command).padEnd(26)} ${c.nonZero} of ${c.exits} non-zero`)
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
// Its own block, after a rule, because everything above is real work and this
|
|
1077
|
+
// is fixtures. A reader who skims must not carry a fixture number away as a
|
|
1078
|
+
// fact about the loop's real runs.
|
|
1079
|
+
const s = i.shipOutcomeEval
|
|
1080
|
+
if (s) {
|
|
1081
|
+
lines.push('')
|
|
1082
|
+
lines.push(' OUTCOME EVALS (fixtures, never real runs — never summed with anything above)')
|
|
1083
|
+
if (s.excluded) {
|
|
1084
|
+
lines.push(` excluded: ${s.reason}`)
|
|
1085
|
+
} else if (!s.records) {
|
|
1086
|
+
// Worded exactly as the HTML surface words it, from one constant: the two
|
|
1087
|
+
// must not describe the same absence differently.
|
|
1088
|
+
lines.push(` unobserved (${NO_EVAL_RESULT})`)
|
|
1089
|
+
} else {
|
|
1090
|
+
for (const arm of Object.keys(s.byArm).sort()) {
|
|
1091
|
+
const a = s.byArm[arm]
|
|
1092
|
+
lines.push(` ${arm.padEnd(10)} ${a.rows} result(s), criteria met ${pct(a.criteriaMet)}`)
|
|
1093
|
+
if (a.notApplicable) {
|
|
1094
|
+
lines.push(` ${a.notApplicable} criterion/criteria not applicable to this arm, excluded`)
|
|
1095
|
+
}
|
|
1096
|
+
if (a.unobserved) lines.push(` ${a.unobserved} unobserved, counted as not met`)
|
|
1097
|
+
}
|
|
1098
|
+
const versions = Object.keys(s.versions).sort()
|
|
1099
|
+
if (versions.length) {
|
|
1100
|
+
lines.push(` versions ${versions.map(v => `${v}→${s.versions[v]}`).join(' ')}`)
|
|
1101
|
+
}
|
|
1102
|
+
if (s.fixtures.length) lines.push(` fixtures ${s.fixtures.join(', ')}`)
|
|
1103
|
+
if (s.incomparable) {
|
|
1104
|
+
lines.push(
|
|
1105
|
+
` ${s.incomparable} row(s) carry no model or eval-agent identity and are not paired ` +
|
|
1106
|
+
`across versions`
|
|
1107
|
+
)
|
|
1108
|
+
}
|
|
1109
|
+
lines.push(` ${s.note}`)
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
lines.push('')
|
|
1114
|
+
lines.push(' Nothing above is a verdict, and nothing in the loop reads it. Exit status is always 0.')
|
|
1115
|
+
return lines.join('\n') + '\n'
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
/** Re-exported so a caller need not import three modules to know where to look. */
|
|
1119
|
+
export const CORPORA = Object.freeze({
|
|
1120
|
+
outcomes: join(LEARNING_DIR, OUTCOMES_FILE),
|
|
1121
|
+
trajectories: RUN_LOG_DIR,
|
|
1122
|
+
metrics: METRICS_DIR,
|
|
1123
|
+
evalHistory: join(EVAL_HISTORY_DIR, SHIP_OUTCOMES_FILE)
|
|
1124
|
+
})
|