testguard-cli 0.7.0 → 0.8.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/CHANGELOG.md CHANGED
@@ -7,6 +7,122 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.8.0] - 2026-09-18
11
+
12
+ A calibration you can quote.
13
+
14
+ A calibration document now says what its number means and where its labels
15
+ came from, and it reproduces: `p` and every interval are recomputed by the
16
+ validator from `n`, `positives`, `method` and `confidence`, at the document's
17
+ own precision — the spec's own example had carried a truncated bound since
18
+ the day it was written, and nothing could notice. `nocover` counts as the
19
+ miss it is, so a project with no tests at all for a subsystem no longer
20
+ outscores one with weak tests. The format also carries what
21
+ websec-validator's shipped table already said — corpus, caveat, a producer
22
+ floor, backoff tiers, a labelled prior — so a second tool's honesty survives
23
+ translation; that table is now a conformance example, and "adoptable" means
24
+ the file validates rather than a sentence saying so. Underneath: the
25
+ self-probe gate is held to a cost budget (#89), and a reserved method has
26
+ somewhere to put its data (#91).
27
+
28
+ ### Added
29
+ - **A calibration can now say what it measures and where it came from** (#82).
30
+ websec-validator's shipped `calibration.json` already carried `corpus`,
31
+ `min_n`, `caveat`, `evidence_status` and `limitation` — the fields that say
32
+ "indicative, do not quote this number" — and claimspec had nowhere to put
33
+ them, so adopting it would have stripped that honesty in translation. It
34
+ also had nowhere to say what `p` *was*: testguard's is a miss rate where
35
+ high is bad, websec's is P(real) where high is good, and both validated
36
+ identically. The schema gains `measures` (a registry: `escape-missed`,
37
+ `finding-real`), provenance in `source` (`corpus`, `caveat`, `limitation`,
38
+ `evidenceStatus`, a `tool-oracle` kind, and an unconstrained `detail` on the
39
+ `methodDetail` pattern), a producer floor `minN` that consumers may raise
40
+ and never lower, ordered `backoff` tiers so a class→label→prior cascade is
41
+ expressible, a `fallback` whose shape says it is a guess, a per-cell
42
+ `breakdown` that sums to `n`, `p: null` at `n = 0`, and `|`-compound bucket
43
+ keys whose arity the validator checks. Every field is optional and absence
44
+ means *unattributed* — readable, not quotable — so no existing document
45
+ breaks; testguard's emitter always writes them and a self-claim guards it.
46
+ The proof of adoption is a file, not a sentence: websec-validator's table,
47
+ translated field for field, is now `spec/conformance/examples/calibration-websec.json`
48
+ and validates with every number reproducing. `GATE-SEMANTICS.md` records
49
+ the merge rule (sum counts, recompute, never average; only when `measures`
50
+ and `bucketBy` agree) that websec's shipped-plus-local overlay already
51
+ depends on.
52
+ - **The gate is now gated on its own cost** (#89). `--cost` has printed the
53
+ self-probe's wall clock into every CI log since #67, and the gate still went
54
+ from 9.6 to 23.6 minutes across one merged change without anything saying a
55
+ word. A measurement nothing gates on is not a check — which is the premise
56
+ this tool rests on, applied to itself.
57
+ `testguard.cost-budget.json` carries the ceiling, its measurement and why it
58
+ is set where it is. CI fails when the probe exceeds it and opens with the
59
+ most expensive claims, so the first thing a failure shows is where to look.
60
+ Raising it is a committed diff with a reason: growth is allowed, going
61
+ unnoticed is not.
62
+ The budget is on the **total**, never a per-fault average. The regression
63
+ that prompted this took the corpus from 59 faults to 91 while per-fault cost
64
+ barely moved — an average would have reported everything fine while the gate
65
+ tripled, and a test says so. A budget that is missing or not a positive
66
+ number is refused rather than treated as unlimited, so a typo cannot quietly
67
+ disable the gate.
68
+
69
+
70
+ ### Fixed
71
+ - **The cost budget is set against what CI measures.** 950 s was 18% over
72
+ the local 804 s figure and only 4.6% over the 906 s CI already measured on
73
+ `main`, so the first pull request to add any claim tripped it — #94 passed
74
+ its own leg only because the cache reused verdicts, and `main` went red at
75
+ 981 s from scratch the moment it merged. Raised to 1100 s (~14% over the
76
+ 962 s CI measures with #82's re-pointed defenders), with the CI figures
77
+ recorded beside the local one so the next person budgets against the
78
+ number the check actually sees. The heavy claims the failure now names are
79
+ the next #73-style cut.
80
+ - **Calibration's pure tests moved off the replay fixture** (the #73 pattern).
81
+ Three calibration claims were defended by `test/replay.test.mjs`, which
82
+ builds a scripted git corpus on every run; their kills come from pure
83
+ `calibrationFrom` tests that need none of it. The cost budget fired on the
84
+ first full run — 1022 s against 950 s, the top rows being exactly those
85
+ claims — so the tests now live in `test/calibration.test.mjs`, the claims
86
+ point there, and every fault was re-probed after the move, not assumed.
87
+ - **A cost-budget failure names its claims.** `checkCostBudget` read `id`
88
+ from a report whose claims carry `claimId`, so the one line meant to say
89
+ *which* claims to look at printed `undefined` five times — and its unit test
90
+ fed the shape the code wished for, so it passed. Found the first time the
91
+ budget actually fired. The test now goes through `costReport`, and
92
+ `TG-COST-BUDGET-NAMES-THE-CLAIM` guards it.
93
+ - **A calibration counts `nocover` as a miss** (#82). `nocover` — no test
94
+ even imports the broken file — is the worst replay outcome, and the ratio
95
+ excluded it: a project with no tests at all for a subsystem scored *better*
96
+ than one with weak tests, because its worst outcomes left the denominator
97
+ before the ratio was taken. It now enters both sides, as it does in Stryker
98
+ and PIT, and the replay summary says how many misses were blind and how many
99
+ had no test at all. The schema description, the emitter's docstring and
100
+ `GATE-SEMANTICS.md` had also disagreed about what `p` was — "P(finding is
101
+ real)" in one place, the miss rate in another, and the docstring's two
102
+ sentences conditioning on different denominators — and now say one thing:
103
+ for testguard, the share of real escaped bugs the suite failed to catch.
104
+ - **A calibration's arithmetic is checked, not trusted.** The validator
105
+ recomputed nothing: it checked that `p` sat inside `ci` and would pass a
106
+ document with every number invented. It now recomputes `p` from
107
+ `positives / n` and `ci` from `n`, `positives`, `method` and `confidence`
108
+ with `spec/lib/wilson.mjs` — one implementation, shared with the emitter —
109
+ at the document's own precision, accepting either the exact quantile or the
110
+ textbook 1.96. The spec's own example had carried a truncated lower bound
111
+ (6/30 → `0.09`; Wilson gives 0.0950 → `0.10`) since the day it was written,
112
+ and websec-validator's shipped table reproduces exactly.
113
+ - **A reserved method can now say something.** #81 reserved `assertion` and
114
+ `scan` without required fields, and `fault` / `detail` are both
115
+ `additionalProperties: false` — so a probe using a reserved method could
116
+ declare that it existed and **nothing whatsoever about it**: not which rule
117
+ was checked, not where. That is not a reservation, it is a dead end, and
118
+ DocGuard's writer would have hit it on its first line.
119
+ Both now carry `methodDetail`: an object the spec deliberately does not
120
+ constrain, on the probe and on the record. It is **forbidden under
121
+ `fault-injection`**, whose shape is specified and must not acquire a junk
122
+ drawer — a self-claim fails if that ever stops being enforced — and it is a
123
+ staging area rather than a permanent home. What the first real consumer puts
124
+ there is the evidence for what the specified shape should become.
125
+
10
126
  ## [0.7.0] - 2026-09-18
11
127
 
12
128
  Python, and a fault that cannot hide.
package/README.md CHANGED
@@ -126,9 +126,9 @@ raises coverage. TestGuard admits it because it fails when the claim is false.
126
126
  | npm | `npm i -D testguard-cli` then `npx testguard probe` |
127
127
  | pip | `pip install testguard-cli` then `testguard probe` (needs Node ≥ 20) |
128
128
  | Homebrew | `brew tap raccioly/tap && brew install testguard` |
129
- | GitHub Action | `uses: raccioly/testguard@v0.7.0` — see [`action.yml`](./action.yml) |
129
+ | GitHub Action | `uses: raccioly/testguard@v0.8.0` — see [`action.yml`](./action.yml) |
130
130
  | pre-commit | `repo: https://github.com/raccioly/testguard`, hooks `testguard-claims`, `testguard-probe` |
131
- | GitLab CI | `include: - remote: https://raw.githubusercontent.com/raccioly/testguard/v0.7.0/packaging/gitlab/testguard.gitlab-ci.yml` with `inputs:` — see [`packaging/gitlab/`](./packaging/gitlab/testguard.gitlab-ci.yml) |
131
+ | GitLab CI | `include: - remote: https://raw.githubusercontent.com/raccioly/testguard/v0.8.0/packaging/gitlab/testguard.gitlab-ci.yml` with `inputs:` — see [`packaging/gitlab/`](./packaging/gitlab/testguard.gitlab-ci.yml) |
132
132
 
133
133
  Projects that set `min-release-age` in `.npmrc` cannot see a version published
134
134
  less than that many days ago (`ENOVERSIONS`); install that one with
@@ -345,15 +345,18 @@ most resembles, which is the join key that turns an uninterpretable mutation
345
345
  score into a statement with a sample size:
346
346
 
347
347
  ```
348
- guard-removed blind 7/9 p=0.78 ci [0.45, 0.94]
349
- field-dropped blind 4/4 p=1.00 ci [0.51, 1.00]
348
+ guard-removed missed 7/9 p=0.78 ci [0.45, 0.94]
349
+ field-dropped missed 4/4 p=1.00 ci [0.51, 1.00]
350
350
  ```
351
351
 
352
- Read as *"when a fault of this class survives, how often does that
353
- correspond to a bug that really escaped"*. It is written as a
354
- `calibration` document beside the replay one. Only `caught` and `blind`
355
- carry information; the other verdicts are excluded from both sides of the
356
- ratio.
352
+ Read as *"when a real bug of this class escapes, how often does the suite
353
+ miss it"* — a miss rate, so a high `p` is bad. It is written as a
354
+ `calibration` document beside the replay one. `caught`, `blind` and
355
+ `nocover` are measurements and enter the ratio, `nocover` as a miss: leaving
356
+ it out would score a project with no tests at all for a subsystem *better*
357
+ than one with weak tests. `flaky` and `unverifiable` are failed measurements
358
+ and enter neither side. Every `p` and `ci` is recomputable from `n` and the
359
+ miss count, and the validator recomputes them.
357
360
 
358
361
  **The open question this exists to answer.** Does a calibration learned on a
359
362
  repository *with* history transfer to a greenfield one that has none? AI
@@ -424,12 +427,12 @@ detected base that does not resolve is a warning for `status` and `brief`
424
427
  # GitHub Actions
425
428
  - uses: actions/checkout@v4
426
429
  with: { fetch-depth: 0 }
427
- - uses: raccioly/testguard@v0.7.0
430
+ - uses: raccioly/testguard@v0.8.0
428
431
  with: { command: gate }
429
432
 
430
433
  # GitLab CI — the component-shaped template: gate + probe, brief as an artifact and, opted in, as a merge-request note
431
434
  include:
432
- - remote: 'https://raw.githubusercontent.com/raccioly/testguard/v0.7.0/packaging/gitlab/testguard.gitlab-ci.yml'
435
+ - remote: 'https://raw.githubusercontent.com/raccioly/testguard/v0.8.0/packaging/gitlab/testguard.gitlab-ci.yml'
433
436
  inputs: { dir: backend, post_note: true } # post_note needs TESTGUARD_GITLAB_TOKEN (api scope); one note, updated in place
434
437
  ```
435
438
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testguard-cli",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Breaks your code on purpose and reports every promise your tests did not notice breaking. Mutation testing bound to the claims a project makes — vitest, jest, Playwright, pytest and unittest.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -167,6 +167,14 @@ Rules that follow from the table:
167
167
  under `fault-injection`, and the validator enforces them there in full: the
168
168
  relaxation is for a tool that injects nothing, never a licence for an
169
169
  injecting tool to stop showing its work.
170
+ A reserved method carries its own data in `methodDetail`, on the probe and
171
+ on the record: an object the spec deliberately does not constrain, so a tool
172
+ can say which rule it checked and where. Without it, "reserved" would mean a
173
+ probe that can declare it exists and nothing about it, which is not a
174
+ reservation but a dead end. It is **forbidden under `fault-injection`**,
175
+ whose shape is specified and must not acquire a junk drawer, and it is a
176
+ staging area rather than a permanent home: what the first consumer puts
177
+ there is the evidence for what the specified shape should become.
170
178
  `assertion` and `scan` are **reserved**. Their required fields are
171
179
  deliberately unspecified and will be defined by their first real consumer,
172
180
  with its own conformance examples written from the shape that tool actually
@@ -201,9 +209,96 @@ Two rules that follow:
201
209
  2. **The fix's own test is removed before the run.** It proves nothing about
202
210
  what the suite knew before the fix existed.
203
211
 
204
- Only `caught` and `blind` carry information. `nocover`, `flaky` and
205
- `unverifiable` are excluded from both sides of a calibration ratio, because a
206
- number computed over them would mean nothing.
212
+ `caught`, `blind` and `nocover` are measurements and enter the calibration
213
+ ratio: `caught` as a hit, the other two as misses. `nocover` counts as a miss
214
+ because it is one — the worst kind. Leaving it out would let a project with
215
+ **no tests at all** for a subsystem score better than one with weak tests,
216
+ since its worst outcomes would leave the denominator before the ratio is
217
+ taken; Stryker and PIT count no-coverage mutants against the headline score
218
+ for the same reason. `flaky` and `unverifiable` are failed measurements and
219
+ enter neither side.
220
+
221
+ ### Calibration arithmetic is reproducible
222
+
223
+ A calibration cell declares `n` and `positives`, and with them has declared
224
+ `p`; a document declares `method: wilson` at `confidence`, and with them has
225
+ declared every `ci`. The validator recomputes both with `lib/wilson.mjs` —
226
+ the single implementation, as `lib/fingerprint.mjs` is for fingerprints — and
227
+ a cell that does not reproduce does not conform. Before this rule the
228
+ validator checked only that `p` lay inside `ci`, which a document with every
229
+ number invented satisfies, and the spec's own example shipped a truncated
230
+ lower bound (6/30 → `0.09`, where Wilson gives 0.0950 → `0.10`) that nothing
231
+ could notice.
232
+
233
+ - **Precision is the document's own**: the most decimal places any `p` or
234
+ `ci` value shows, capped at 6. Values are compared after rounding to that
235
+ precision, so a producer at 3 dp and one at 4 dp both reproduce and a
236
+ truncated bound does not.
237
+ - **z may be the exact quantile or the textbook rounding** (1.96, 2.576,
238
+ 1.645). The two differ by up to 9.2e-6 in the interval — enough to flip a
239
+ rounding boundary at 4 dp — so either reproduces, and a document never fails
240
+ for having used the number in the textbook.
241
+ - **`p` is `positives / n`.** A smoothed or shrunk point estimate is a
242
+ different method and must say so; under `wilson` the point estimate is the
243
+ observed proportion.
244
+
245
+ ### Calibration provenance, backoff and merging
246
+
247
+ Everything beyond a document's primary buckets is optional and additive, in
248
+ the way `method` is on claims and evidence: absence has a defined meaning, and
249
+ no document written before a field existed becomes invalid. The meaning of
250
+ absence is **unattributed** — readable, auditable, and not a source of
251
+ numbers. A tool that emits calibrations always emits these fields; a
252
+ self-claim guards that it does.
253
+
254
+ - **`measures`** says what `p` is the probability of. It is the one field
255
+ that lets two calibrations be told apart: testguard's `escape-missed` is
256
+ P(a real escaped bug of this class was not caught), and high is bad;
257
+ websec-validator's `finding-real` is P(a reported finding of this class is a
258
+ real vulnerability), and high is good. A document without it cannot be
259
+ quoted and cannot be merged. Registry: `escape-missed`, `finding-real`. A
260
+ new value is added here by the tool that first emits it.
261
+ - **`source`** carries provenance. `corpus` names external ground-truth
262
+ sources (a bug-replay's is the repository's own history, which `ref` already
263
+ names); `caveat` is the one sentence a consumer shows beside any quoted
264
+ number; `limitation` is the longer story; `evidenceStatus` says whether the
265
+ labels were reviewed; `kind: tool-oracle` is a tool confirming its own
266
+ findings, as websec-validator's dynamic probes do. `detail` is the tool's
267
+ own provenance, unconstrained, on the `methodDetail` pattern. A number
268
+ quoted without its `caveat` is a misquote.
269
+ - **`minN`** is the producer's floor. A cell below it is not quoted on its
270
+ own; the consumer backs off a tier. A consumer may raise it — a gate that
271
+ blocks a merge wants more certainty than a report — and may never lower
272
+ it: lowering it resurrects a cell the producer suppressed. Absent, the
273
+ producer sets no floor and the consumer applies its own. A spec-level
274
+ constant would be wrong in both directions: websec-validator's 5 would
275
+ suppress every cell of a corpus the size of DocGuard's benchmark.
276
+ - **`backoff`** is the ordered list of coarser tiers consulted below `minN`.
277
+ Each tier is held to the same arithmetic as the primary. Keys may be
278
+ compound (`attackClass|confidence` backs off to `confidence`), and every key
279
+ in a tier has as many `|`-parts as its `bucketBy` — a key with fewer was
280
+ translated from another table and lost a dimension on the way.
281
+ - **`fallback`** is what remains when no tier has a cell above `minN`: a
282
+ labelled guess, not a measurement, and anything quoted from it carries
283
+ `n = 0`, `ci = [0, 1]` and its `basis`. It lives in the document so that
284
+ "what did it assume when it had no data?" has one auditable answer instead
285
+ of one per consumer, each unlabelled.
286
+ - **`breakdown`** splits a cell's `n` by outcome when the producer can say
287
+ (testguard: `caught` / `blind` / `nocover`), and sums to `n`. It lets a
288
+ consumer recover a narrower rate — misses among covered code, say —
289
+ without the producer publishing a second, misleading headline.
290
+
291
+ **Merging.** Two calibrations merge by summing `n` and `positives` per cell
292
+ and recomputing `p` and `ci` from the sums — never by averaging `p` or `ci`,
293
+ which have no meaning combined. They merge only when `measures`, `bucketBy`
294
+ and `confidence` agree, and the merged document's `source.kind` is `mixed`.
295
+ This is how websec-validator folds an operator's own confirmed samples over
296
+ its shipped table, and it works only because cells carry raw counts.
297
+
298
+ **The second producer is a conformance example.** websec-validator's shipped
299
+ table, translated field for field, is `conformance/examples/calibration-websec.json`
300
+ — corpus, caveat, limitation, floor, backoff tier and labelled fallback all
301
+ present. That file, not a sentence in a README, is what "adoptable" means.
207
302
 
208
303
  ## Baseline and delta
209
304
 
package/spec/README.md CHANGED
@@ -28,7 +28,7 @@ that carry claims, baselines, scoping and briefs do not care.
28
28
  | `evidence` | [`schemas/evidence.schema.json`](schemas/evidence.schema.json) | One run's findings on disk, readable without the tool. Every verdict carries every run that produced it. |
29
29
  | `baseline` | [`schemas/baseline.schema.json`](schemas/baseline.schema.json) | Frozen fingerprints of existing debt. Gate only what is new. |
30
30
  | `ignore` | [`schemas/ignore.schema.json`](schemas/ignore.schema.json) | Reviewable scoping. Every entry has a reason. |
31
- | `calibration` | [`schemas/calibration.schema.json`](schemas/calibration.schema.json) | P(finding is real) per bucket, with a Wilson interval and the sample size behind it. |
31
+ | `calibration` | [`schemas/calibration.schema.json`](schemas/calibration.schema.json) | The observed rate of a bucket's outcome — what it measures is in the document — with a Wilson interval and the sample size behind it. Every number is recomputable and the validator recomputes it; provenance, a producer floor, backoff tiers and a labelled fallback ride along so a second tool's honesty survives translation. |
32
32
  | `brief` | [`schemas/brief.schema.json`](schemas/brief.schema.json) | What to tell an agent before it writes code — ranked, capped, never a single score, and carrying the one next action. |
33
33
  | `status` | [`schemas/status.schema.json`](schemas/status.schema.json) | Where the project is and what happens next — the single machine-readable truth every human rendering derives from. Surfaces faults whose content changed since they were probed, and changed files that carry no claim. |
34
34
  | `replay` | [`schemas/replay.schema.json`](schemas/replay.schema.json) | Would this suite have caught the bugs that already escaped? Replays real fix commits: revert the source, remove the test the fix shipped, run what remains. A replayed bug is ground truth — a human already confirmed it was a defect — which is what a fault model is calibrated against. |
@@ -65,7 +65,10 @@ consumer. It moves to its own repository only when a second tool adopts it.
65
65
 
66
66
  Adoption is additive and optional: an existing tool gains an output writer, and
67
67
  nothing it already detects or already writes changes. That holds today for
68
- `baseline`, `ignore`, `brief` and `status`. It does **not** yet hold for
68
+ `baseline`, `ignore`, `brief`, `status` and `calibration` — websec-validator's
69
+ shipped calibration table is `conformance/examples/calibration-websec.json`,
70
+ translated field for field with its corpus, caveat, floor, backoff and
71
+ fallback intact. It does **not** yet hold for
69
72
  `claims` and `evidence`, whose required fields assume fault injection
70
73
  (`find`/`replace` anchors, `confirmRuns`, `baselineRuns`/`probeRuns`,
71
74
  `defenders`) — a tool that scans or reads would have to emit empty arrays to
@@ -3,6 +3,7 @@ import { readFileSync, readdirSync } from 'node:fs';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { fingerprint } from './fingerprint.mjs';
6
+ import { reproduces, decimals, MAX_PLACES } from './wilson.mjs';
6
7
 
7
8
  const schemaDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'schemas');
8
9
 
@@ -77,6 +78,9 @@ const semantic = {
77
78
  // runs is not a leaner document, it is a verdict with nothing behind it;
78
79
  // relaxing the schema was to let a scanner conform, never to let an
79
80
  // injecting tool stop showing its work.
81
+ if (injection && r.detail.methodDetail) {
82
+ errors.push({ path: `${p}/detail/methodDetail`, message: 'methodDetail is for a method the spec has not specified; fault-injection has a specified shape and must not acquire a junk drawer' });
83
+ }
80
84
  if (injection) {
81
85
  for (const k of ['defenders', 'inputs']) {
82
86
  if (!r[k]) errors.push({ path: `${p}/${k}`, message: `fault-injection requires ${k} on every record` });
@@ -186,12 +190,43 @@ const semantic = {
186
190
 
187
191
  calibration(doc) {
188
192
  const errors = [];
189
- for (const [key, b] of Object.entries(doc.buckets)) {
190
- const p = `/buckets/${key}`;
191
- if (b.positives > b.n) errors.push({ path: `${p}/positives`, message: `positives (${b.positives}) exceed n (${b.n})` });
192
- const [lo, hi] = b.ci;
193
- if (lo > hi) errors.push({ path: `${p}/ci`, message: `ci lower bound ${lo} exceeds upper bound ${hi}` });
194
- if (b.p < lo || b.p > hi) errors.push({ path: `${p}/p`, message: `p (${b.p}) lies outside ci [${lo}, ${hi}]` });
193
+ // The primary buckets and every backoff tier are held to the same rules:
194
+ // a coarser tier a consumer falls back to is exactly where a wrong number
195
+ // would go unnoticed.
196
+ const tiers = [{ bucketBy: doc.bucketBy, buckets: doc.buckets, path: '' }, ...(doc.backoff ?? []).map((t, i) => ({ ...t, path: `/backoff/${i}` }))];
197
+ // The precision the document wrote at: the most places any value shows.
198
+ // Parsing drops trailing zeros (0.10 → 0.1), so a single value can
199
+ // under-report; the maximum cannot unless every value ended in zero.
200
+ const places = tiers.flatMap((t) => Object.values(t.buckets)).reduce((m, b) => Math.max(m, decimals(b.p ?? 0), decimals(b.ci[0]), decimals(b.ci[1])), 0);
201
+ for (const t of tiers) {
202
+ // A compound bucketBy (`attackClass|confidence`) means compound keys,
203
+ // each with the same number of parts — a key with fewer was translated
204
+ // from another tool's table and lost a dimension on the way.
205
+ const arity = t.bucketBy.split('|').length;
206
+ for (const [key, b] of Object.entries(t.buckets)) {
207
+ const p = `${t.path}/buckets/${key}`;
208
+ const parts = key.split('|').length;
209
+ if (parts !== arity) errors.push({ path: p, message: `bucket key "${key}" has ${parts} part(s); bucketBy "${t.bucketBy}" has ${arity}` });
210
+ if (b.positives > b.n) errors.push({ path: `${p}/positives`, message: `positives (${b.positives}) exceed n (${b.n})` });
211
+ const [lo, hi] = b.ci;
212
+ if (lo > hi) errors.push({ path: `${p}/ci`, message: `ci lower bound ${lo} exceeds upper bound ${hi}` });
213
+ if (b.p !== null && (b.p < lo || b.p > hi)) errors.push({ path: `${p}/p`, message: `p (${b.p}) lies outside ci [${lo}, ${hi}]` });
214
+ if (b.breakdown) {
215
+ const sum = Object.values(b.breakdown).reduce((s, v) => s + v, 0);
216
+ if (sum !== b.n) errors.push({ path: `${p}/breakdown`, message: `breakdown sums to ${sum}; n is ${b.n}` });
217
+ }
218
+ // A cell that declares n and positives has declared p; a document that
219
+ // declares `wilson` at `confidence` has declared every ci. Recompute
220
+ // both with the spec's own implementation: "conforms" means
221
+ // "reproducible", not "internally plausible". Before this the validator
222
+ // would pass a document with every number invented, and the spec's own
223
+ // example carried a truncated bound nothing could notice.
224
+ // A cell with more positives than trials has no proportion to reproduce.
225
+ if (b.positives > b.n) continue;
226
+ const r = reproduces(b, { confidence: doc.confidence, places });
227
+ if (!r.p) errors.push({ path: `${p}/p`, message: `p (${b.p}) is not positives/n: ${b.positives}/${b.n} = ${r.expected.p} at ${Math.min(places, MAX_PLACES)} dp` });
228
+ if (!r.ci) errors.push({ path: `${p}/ci`, message: `ci [${lo}, ${hi}] does not reproduce: ${doc.method} at ${doc.confidence} for ${b.positives}/${b.n} is [${r.expected.ci[0]}, ${r.expected.ci[1]}]` });
229
+ }
195
230
  }
196
231
  return errors;
197
232
  },
@@ -0,0 +1,112 @@
1
+ /**
2
+ * The single Wilson interval implementation, as `fingerprint.mjs` is the
3
+ * single fingerprint: a producer emits with it, the validator recomputes with
4
+ * it, and "conforms" means the two agree. Before this file existed the emitter
5
+ * and the validator had no shared arithmetic at all — the validator checked
6
+ * that `p` sat inside `ci` and nothing else, and the spec's own example
7
+ * carried a truncated lower bound (6/30 → 0.09, where Wilson gives 0.0950 →
8
+ * 0.10) that nothing could notice.
9
+ */
10
+
11
+ // Inverse normal CDF: Acklam's rational approximation, |relative error| < 1.2e-9.
12
+ // Enough to reproduce an interval to 6 decimal places, which is the most any
13
+ // producer writes (websec-validator 3, testguard 4, docguard 6).
14
+ const A = [-3.969683028665376e+01, 2.209460984245205e+02, -2.759285104469687e+02, 1.383577518672690e+02, -3.066479806614716e+01, 2.506628277459239e+00];
15
+ const B = [-5.447609879822406e+01, 1.615858368580409e+02, -1.556989798598866e+02, 6.680131188771972e+01, -1.328068155288572e+01];
16
+ const C = [-7.784894002430293e-03, -3.223964580411365e-01, -2.400758277161838e+00, -2.549732539343734e+00, 4.374664141464968e+00, 2.938163982698783e+00];
17
+ const D = [7.784695709041462e-03, 3.224671290700398e-01, 2.445134137142996e+00, 3.754408661907416e+00];
18
+ const P_LOW = 0.02425;
19
+
20
+ /** The standard normal quantile: z such that Φ(z) = p. */
21
+ export function probit(p) {
22
+ if (!(p > 0 && p < 1)) throw new RangeError(`probit is defined on (0, 1), got ${p}`);
23
+ let q;
24
+ if (p < P_LOW) {
25
+ q = Math.sqrt(-2 * Math.log(p));
26
+ return (((((C[0] * q + C[1]) * q + C[2]) * q + C[3]) * q + C[4]) * q + C[5]) / ((((D[0] * q + D[1]) * q + D[2]) * q + D[3]) * q + 1);
27
+ }
28
+ if (p <= 1 - P_LOW) {
29
+ q = p - 0.5;
30
+ const r = q * q;
31
+ return (((((A[0] * r + A[1]) * r + A[2]) * r + A[3]) * r + A[4]) * r + A[5]) * q / (((((B[0] * r + B[1]) * r + B[2]) * r + B[3]) * r + B[4]) * r + 1);
32
+ }
33
+ q = Math.sqrt(-2 * Math.log(1 - p));
34
+ return -(((((C[0] * q + C[1]) * q + C[2]) * q + C[3]) * q + C[4]) * q + C[5]) / ((((D[0] * q + D[1]) * q + D[2]) * q + D[3]) * q + 1);
35
+ }
36
+
37
+ /**
38
+ * The textbook roundings of z that producers actually type. testguard and
39
+ * docguard use 1.96; websec-validator uses the exact quantile. Across n ≤ 200
40
+ * the two differ by at most 9.2e-6 in the interval, which can still flip a
41
+ * rounding boundary at 4 dp — so the validator accepts either, and a document
42
+ * never fails for having used the number in the textbook.
43
+ */
44
+ export const CONVENTIONAL_Z = Object.freeze({ 0.9: 1.645, 0.95: 1.96, 0.99: 2.576 });
45
+
46
+ /** Every z a producer may legitimately have used for a two-sided interval at `confidence`. */
47
+ export function zCandidates(confidence) {
48
+ const exact = probit(1 - (1 - confidence) / 2);
49
+ const conventional = CONVENTIONAL_Z[confidence];
50
+ return conventional === undefined ? [exact] : [exact, conventional];
51
+ }
52
+
53
+ /**
54
+ * Wilson score interval for `positives` in `n`, unrounded, clamped to [0, 1].
55
+ * Wilson rather than the normal approximation because it stays sane at small
56
+ * n and extreme p — exactly the regime a young corpus is in. n = 0 is maximal
57
+ * ignorance: [0, 1].
58
+ */
59
+ export function wilsonInterval(positives, n, z) {
60
+ if (n === 0) return [0, 1];
61
+ const p = positives / n;
62
+ const d = 1 + (z * z) / n;
63
+ const centre = (p + (z * z) / (2 * n)) / d;
64
+ const half = (z * Math.sqrt((p * (1 - p)) / n + (z * z) / (4 * n * n))) / d;
65
+ return [Math.max(0, centre - half), Math.min(1, centre + half)];
66
+ }
67
+
68
+ /** Round half away from zero to `places` decimals — what `toFixed` does on the value's binary form. */
69
+ export const roundTo = (x, places) => Number(x.toFixed(places));
70
+
71
+ /**
72
+ * How many decimal places a parsed JSON number still shows. Parsing drops
73
+ * trailing zeros (0.10 → 0.1), so a single value under-reports; the validator
74
+ * takes the maximum across a document, which is the precision the producer
75
+ * wrote at as long as at least one value did not end in zero.
76
+ */
77
+ export function decimals(x) {
78
+ if (!Number.isFinite(x)) return 0;
79
+ const s = String(x);
80
+ const e = s.indexOf('e-');
81
+ if (e !== -1) {
82
+ const mantissa = s.slice(0, e);
83
+ const dot = mantissa.indexOf('.');
84
+ return Number(s.slice(e + 2)) + (dot === -1 ? 0 : mantissa.length - dot - 1);
85
+ }
86
+ const dot = s.indexOf('.');
87
+ return dot === -1 ? 0 : s.length - dot - 1;
88
+ }
89
+
90
+ /** The most decimal places the validator will hold a document to; beyond it the quantile approximation is the noise. */
91
+ export const MAX_PLACES = 6;
92
+
93
+ const near = (a, b) => Math.abs(a - b) < 1e-9;
94
+
95
+ /**
96
+ * Does a cell's declared `p` and `ci` reproduce from its own `n` and
97
+ * `positives`? Everything is compared after rounding to `places` — the
98
+ * precision the document wrote at — so a producer at 3 dp and one at 4 dp
99
+ * both reproduce, and a truncated bound does not. A `p` of `null` is a cell
100
+ * that declines to give a point estimate; only its interval is checked.
101
+ */
102
+ export function reproduces(cell, { confidence, places }) {
103
+ const { n, positives, p, ci } = cell;
104
+ const pl = Math.min(places, MAX_PLACES);
105
+ const expectedP = n === 0 ? null : roundTo(positives / n, pl);
106
+ const candidates = zCandidates(confidence).map((z) => wilsonInterval(positives, n, z).map((v) => roundTo(v, pl)));
107
+ return {
108
+ p: p === null || n === 0 || near(expectedP, p),
109
+ ci: candidates.some(([lo, hi]) => near(lo, ci[0]) && near(hi, ci[1])),
110
+ expected: { p: expectedP, ci: candidates[0] },
111
+ };
112
+ }
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "urn:claimspec:v1:calibration",
4
4
  "title": "claimspec v1 — calibration",
5
- "description": "P(finding is real) per bucket, with a confidence interval and the sample size behind it. A label without n is a vibe; this is the format that stops it being one.",
5
+ "description": "The observed rate of a bucket's outcome — for testguard, the share of real escaped bugs of a fault class that the suite failed to catch, so a high p is bad — with a confidence interval and the sample size behind it. A label without n is a vibe; this is the format that stops it being one. p and ci are recomputable from n, positives, method and confidence, and the validator recomputes them. Everything beyond the primary buckets is optional and additive, as `method` is on claims and evidence: a document that omits `measures` or `source.caveat` stays valid and is UNATTRIBUTED — a consumer may read it and audit it, merge nothing into it, and quote no number from it.",
6
6
  "type": "object",
7
7
  "required": ["schemaVersion", "tool", "computedAt", "method", "confidence", "bucketBy", "source", "buckets"],
8
8
  "properties": {
@@ -11,44 +11,114 @@
11
11
  "tool": { "$ref": "urn:claimspec:v1:common#/$defs/tool" },
12
12
  "computedAt": { "$ref": "urn:claimspec:v1:common#/$defs/isoDateTime" },
13
13
  "method": {
14
- "description": "Interval method. `wilson` is the only method defined in v1.",
14
+ "description": "Interval method. `wilson` is the only method defined in v1; under it the point estimate is the observed proportion positives / n, and a smoothed or shrunk estimate is a different method.",
15
15
  "enum": ["wilson"]
16
16
  },
17
17
  "confidence": { "type": "number", "exclusiveMinimum": 0, "exclusiveMaximum": 1 },
18
+ "measures": {
19
+ "description": "What `p` is the probability of — the one field that lets two calibrations be told apart. Registry in GATE-SEMANTICS.md: `escape-missed` (testguard: a real escaped bug of this class was not caught; high is bad) and `finding-real` (websec-validator: a reported finding of this class is a real vulnerability; high is good). Two documents merge only when this, `bucketBy` and `confidence` agree. Absent = unattributed: nothing may be quoted from the document.",
20
+ "type": "string",
21
+ "pattern": "^[a-z][a-z0-9-]*$",
22
+ "maxLength": 64
23
+ },
18
24
  "bucketBy": {
19
- "description": "The field findings are bucketed on, e.g. `faultClass` or `severity`.",
20
- "$ref": "urn:claimspec:v1:common#/$defs/identifier"
25
+ "description": "The field findings are bucketed on, e.g. `faultClass` or `severity`. A compound key joins fields with `|` (`attackClass|confidence`), and every bucket key then has the same number of parts.",
26
+ "$ref": "#/$defs/bucketKey"
27
+ },
28
+ "minN": {
29
+ "description": "The producer's floor. A cell with fewer samples is not quoted on its own; the consumer backs off a tier, then to `fallback`, then to nothing. A consumer may raise this — a gate that blocks a merge wants more certainty than a report — and may never lower it: lowering it resurrects a cell the producer suppressed. Absent = the producer sets no floor and the consumer applies its own.",
30
+ "type": "integer",
31
+ "minimum": 0
21
32
  },
22
33
  "source": {
23
- "description": "Where the ground-truth labels came from.",
34
+ "description": "Where the ground-truth labels came from. `kind` is the mechanism (`tool-oracle`: a tool confirming its own findings, as websec-validator's dynamic probes do). `corpus` names external ground-truth sources — a bug-replay's ground truth is the repository's own history, already named by `ref`, so it is usually absent there. `caveat` is the one sentence a consumer must show beside any quoted number; `limitation` is the longer story; `evidenceStatus` says whether the labels were reviewed. `detail` is the tool's own provenance, deliberately unconstrained, on the `methodDetail` pattern: what the first consumers put there is the evidence for what should become specified.",
24
35
  "type": "object",
25
36
  "required": ["kind"],
26
37
  "properties": {
27
- "kind": { "enum": ["bug-replay", "human-label", "mixed"] },
28
- "ref": { "type": "string", "maxLength": 512 }
38
+ "kind": { "enum": ["bug-replay", "human-label", "tool-oracle", "mixed"] },
39
+ "ref": { "type": "string", "maxLength": 512 },
40
+ "corpus": {
41
+ "type": "array",
42
+ "minItems": 1,
43
+ "items": { "type": "string", "minLength": 1, "maxLength": 128 }
44
+ },
45
+ "caveat": { "type": "string", "minLength": 1, "maxLength": 512 },
46
+ "limitation": { "type": "string", "minLength": 1, "maxLength": 1024 },
47
+ "evidenceStatus": { "enum": ["verified", "unverified", "quarantined"] },
48
+ "detail": { "type": "object", "minProperties": 1, "additionalProperties": true }
29
49
  },
30
50
  "additionalProperties": false
31
51
  },
32
- "buckets": {
33
- "type": "object",
34
- "propertyNames": { "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$" },
35
- "additionalProperties": {
52
+ "buckets": { "$ref": "#/$defs/buckets" },
53
+ "backoff": {
54
+ "description": "Ordered coarser tiers consulted when a primary cell is below `minN`: websec-validator's `attackClass|confidence` backs off to `confidence`. Each tier is held to the same arithmetic as the primary. Absent = no backoff.",
55
+ "type": "array",
56
+ "minItems": 1,
57
+ "items": {
36
58
  "type": "object",
37
- "required": ["n", "positives", "p", "ci"],
59
+ "required": ["bucketBy", "buckets"],
38
60
  "properties": {
39
- "n": { "type": "integer", "minimum": 0 },
40
- "positives": { "type": "integer", "minimum": 0 },
41
- "p": { "type": "number", "minimum": 0, "maximum": 1 },
42
- "ci": {
43
- "type": "array",
44
- "minItems": 2,
45
- "maxItems": 2,
46
- "items": { "type": "number", "minimum": 0, "maximum": 1 }
47
- }
61
+ "bucketBy": { "$ref": "#/$defs/bucketKey" },
62
+ "buckets": { "$ref": "#/$defs/buckets" }
48
63
  },
49
64
  "additionalProperties": false
50
65
  }
66
+ },
67
+ "fallback": {
68
+ "description": "What remains when no tier has a cell above `minN`: a labelled guess, not a measurement, and the shape says so — it has a `basis` and no `n`, and anything quoted from it carries n = 0, ci = [0, 1] and the basis. It lives in the document so that \"what did it assume when it had no data?\" has one auditable answer instead of one per consumer.",
69
+ "type": "object",
70
+ "required": ["basis", "values"],
71
+ "properties": {
72
+ "basis": { "const": "uncalibrated-prior" },
73
+ "values": {
74
+ "type": "object",
75
+ "minProperties": 1,
76
+ "propertyNames": { "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*(\\|[A-Za-z0-9][A-Za-z0-9._-]*)*$" },
77
+ "additionalProperties": { "type": "number", "minimum": 0, "maximum": 1 }
78
+ }
79
+ },
80
+ "additionalProperties": false
51
81
  }
52
82
  },
53
- "additionalProperties": false
83
+ "additionalProperties": false,
84
+ "$defs": {
85
+ "bucketKey": {
86
+ "type": "string",
87
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*(\\|[A-Za-z0-9][A-Za-z0-9._-]*)*$",
88
+ "maxLength": 128
89
+ },
90
+ "cell": {
91
+ "type": "object",
92
+ "required": ["n", "positives", "p", "ci"],
93
+ "properties": {
94
+ "n": { "type": "integer", "minimum": 0 },
95
+ "positives": { "type": "integer", "minimum": 0 },
96
+ "p": {
97
+ "description": "positives / n at the document's precision, or null when n = 0: a cell with no trials has no proportion, and 0 would be a fabricated one.",
98
+ "type": ["number", "null"],
99
+ "minimum": 0,
100
+ "maximum": 1
101
+ },
102
+ "ci": {
103
+ "type": "array",
104
+ "minItems": 2,
105
+ "maxItems": 2,
106
+ "items": { "type": "number", "minimum": 0, "maximum": 1 }
107
+ },
108
+ "breakdown": {
109
+ "description": "How n splits by outcome, when the producer can say (testguard: caught / blind / nocover). Values sum to n. Lets a consumer recover a narrower rate — misses among covered code, say — without the producer publishing a second, misleading headline.",
110
+ "type": "object",
111
+ "minProperties": 1,
112
+ "propertyNames": { "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$" },
113
+ "additionalProperties": { "type": "integer", "minimum": 0 }
114
+ }
115
+ },
116
+ "additionalProperties": false
117
+ },
118
+ "buckets": {
119
+ "type": "object",
120
+ "propertyNames": { "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*(\\|[A-Za-z0-9][A-Za-z0-9._-]*)*$" },
121
+ "additionalProperties": { "$ref": "#/$defs/cell" }
122
+ }
123
+ }
54
124
  }
@@ -152,6 +152,12 @@
152
152
  },
153
153
  "method": {
154
154
  "$ref": "urn:claimspec:v1:common#/$defs/method"
155
+ },
156
+ "methodDetail": {
157
+ "description": "Data belonging to a method the spec has not specified yet. `assertion` and `scan` are reserved names with no required fields, so a tool adopting one needs somewhere to put the rule it checked and the place it checked \u2014 without which it can declare that a probe exists and nothing about it. Deliberately unconstrained, deliberately namespaced, and **forbidden under `fault-injection`**, whose shape IS specified and must not acquire a junk drawer. What the first real consumer puts here is the evidence for what the specified shape should become; it is a staging area, not a permanent home.",
158
+ "type": "object",
159
+ "minProperties": 1,
160
+ "additionalProperties": true
155
161
  }
156
162
  },
157
163
  "additionalProperties": false,
@@ -184,7 +190,8 @@
184
190
  "faultClass": true,
185
191
  "file": true,
186
192
  "find": true,
187
- "replace": true
193
+ "replace": true,
194
+ "methodDetail": false
188
195
  },
189
196
  "required": [
190
197
  "faultClass",
@@ -501,6 +501,12 @@
501
501
  }
502
502
  },
503
503
  "additionalProperties": false
504
+ },
505
+ "methodDetail": {
506
+ "description": "Data belonging to a method the spec has not specified yet. `assertion` and `scan` are reserved names with no required fields, so a tool adopting one needs somewhere to put the rule it checked and the place it checked \u2014 without which it can declare that a probe exists and nothing about it. Deliberately unconstrained, deliberately namespaced, and **forbidden under `fault-injection`**, whose shape IS specified and must not acquire a junk drawer. What the first real consumer puts here is the evidence for what the specified shape should become; it is a staging area, not a permanent home.",
507
+ "type": "object",
508
+ "minProperties": 1,
509
+ "additionalProperties": true
504
510
  }
505
511
  },
506
512
  "additionalProperties": false,
@@ -50,7 +50,7 @@ export async function replayCommand({ projectDir, values, version }, io) {
50
50
  io.out(renderReplay(doc));
51
51
  io.out('');
52
52
  for (const [cls, b] of Object.entries(calibration.buckets).sort((a, b2) => b2[1].n - a[1].n)) {
53
- io.out(` ${cls.padEnd(22)} blind ${String(b.positives).padStart(3)}/${String(b.n).padEnd(3)} p=${b.p.toFixed(2)} ci [${b.ci[0].toFixed(2)}, ${b.ci[1].toFixed(2)}]`);
53
+ io.out(` ${cls.padEnd(22)} missed ${String(b.positives).padStart(3)}/${String(b.n).padEnd(3)} p=${b.p === null ? ' n/a' : b.p.toFixed(2)} ci [${b.ci[0].toFixed(2)}, ${b.ci[1].toFixed(2)}]`);
54
54
  }
55
55
  if (Object.keys(calibration.buckets).length === 0) io.out(' no measurable bug in this range: nothing to calibrate from yet.');
56
56
  io.out('');
@@ -143,3 +143,63 @@ export function renderCost(report, { limit = 10 } = {}) {
143
143
  }
144
144
  return out.join('\n');
145
145
  }
146
+
147
+ /**
148
+ * Is this probe within its cost budget?
149
+ *
150
+ * The gate went from 9.6 minutes to 23.6 across one merged pull request, and
151
+ * nothing said a word — `--cost` had been printing the number into every CI log
152
+ * since the previous optimisation. A measurement nothing gates on is not a
153
+ * check, which is the premise this whole tool rests on, applied to itself.
154
+ *
155
+ * Budget the TOTAL, never the per-fault average. The regression that prompted
156
+ * this raised the fault count from 59 to 91 while per-fault cost barely moved,
157
+ * so an average would have reported everything fine while the gate tripled.
158
+ * Growth is legitimate; the point is that someone signs for it in a diff.
159
+ *
160
+ * `previousMs` is optional and only ever informational: "804s, budget 900s" is
161
+ * a much weaker signal than "804s, was 780s", and the prior total is already in
162
+ * the evidence CI restores for verdict reuse.
163
+ *
164
+ * Pure. Reads a report, returns a decision; the caller owns exit codes and I/O.
165
+ */
166
+ export function checkCostBudget(report, { budgetSeconds, previousMs, worst = 5 } = {}) {
167
+ if (typeof budgetSeconds !== 'number' || !Number.isFinite(budgetSeconds) || budgetSeconds <= 0) {
168
+ throw new TypeError('checkCostBudget: budgetSeconds must be a positive number');
169
+ }
170
+ const budgetMs = budgetSeconds * 1000;
171
+ const totalMs = report.totalMs ?? 0;
172
+ return {
173
+ ok: totalMs <= budgetMs,
174
+ totalMs,
175
+ budgetMs,
176
+ overByMs: Math.max(0, totalMs - budgetMs),
177
+ headroomMs: Math.max(0, budgetMs - totalMs),
178
+ ...(typeof previousMs === 'number' ? { previousMs, deltaMs: totalMs - previousMs } : {}),
179
+ // Named so a failure opens with which claims to look at, not just a number.
180
+ // claimCosts emits `claimId`; reading `id` here printed "undefined" for every
181
+ // claim the first time the budget fired, and the test had fed the wished-for shape.
182
+ worst: (report.claims ?? []).slice(0, worst).map((c) => ({ id: c.claimId, ms: c.ms, runs: c.runs })),
183
+ };
184
+ }
185
+
186
+ /** The budget decision as text, for a CI log that someone reads only when it fails. */
187
+ export function renderCostBudget(d) {
188
+ const out = [];
189
+ const delta = typeof d.deltaMs === 'number'
190
+ ? ` (${d.deltaMs >= 0 ? '+' : ''}${secs(Math.abs(d.deltaMs))} against the previous run's ${secs(d.previousMs)})`
191
+ : '';
192
+ out.push(d.ok
193
+ ? `cost ${secs(d.totalMs)} of a ${secs(d.budgetMs)} budget — ${secs(d.headroomMs)} to spare${delta}.`
194
+ : `COST BUDGET EXCEEDED: ${secs(d.totalMs)} against a ${secs(d.budgetMs)} budget, over by ${secs(d.overByMs)}${delta}.`);
195
+ if (!d.ok && d.worst.length) {
196
+ out.push('');
197
+ out.push('most expensive claims');
198
+ for (const c of d.worst) out.push(` ${secs(c.ms).padStart(7)} ${c.id} (${c.runs} runs)`);
199
+ out.push('');
200
+ out.push('Either move a claim onto a defender that does not need the expensive setup —');
201
+ out.push('re-probing afterwards, never assuming — or raise the budget in a commit that');
202
+ out.push('says why. Growth is allowed; going unnoticed is not.');
203
+ }
204
+ return out.join('\n');
205
+ }
@@ -8,6 +8,7 @@ import { parseCommandTemplate } from '../probe/runners/shared.mjs';
8
8
  import { fileImports } from '../probe/rank.mjs';
9
9
  import { IS_PY_TEST, pyFileImports } from '../probe/pyimports.mjs';
10
10
  import { labelDiff } from './label.mjs';
11
+ import { wilsonInterval, roundTo } from '../../spec/lib/wilson.mjs';
11
12
 
12
13
  const TEST_RE = /(^|\/)(__tests__|tests?)\//;
13
14
  const IS_TEST = (f) => /\.(test|spec)\.[cm]?[jt]sx?$/.test(f) || IS_PY_TEST.test(f) || TEST_RE.test(f);
@@ -247,35 +248,50 @@ export function classifyReplay(runs) {
247
248
  return { verdict: 'flaky', reason: 'runs-disagreed' };
248
249
  }
249
250
 
250
- /** Wilson score interval: the reason a label carries n rather than a vibe. */
251
+ /**
252
+ * Wilson score interval at 4 dp — the reason a label carries n rather than a
253
+ * vibe. Computed by the spec's single implementation, which the validator
254
+ * recomputes with: an emitter and a validator with separate arithmetic can
255
+ * drift, and did (see `spec/lib/wilson.mjs`).
256
+ */
251
257
  export function wilson(positives, n, z = 1.96) {
252
- if (n === 0) return { p: 0, ci: [0, 1] };
253
- const p = positives / n;
254
- const d = 1 + (z * z) / n;
255
- const centre = (p + (z * z) / (2 * n)) / d;
256
- const half = (z * Math.sqrt((p * (1 - p)) / n + (z * z) / (4 * n * n))) / d;
257
- const round = (x) => Number(Math.min(1, Math.max(0, x)).toFixed(4));
258
- return { p: Number(p.toFixed(4)), ci: [round(centre - half), round(centre + half)] };
258
+ if (n === 0) return { p: null, ci: [0, 1] };
259
+ const [lo, hi] = wilsonInterval(positives, n, z);
260
+ return { p: roundTo(positives / n, 4), ci: [roundTo(lo, 4), roundTo(hi, 4)] };
259
261
  }
260
262
 
263
+ /**
264
+ * Replay verdicts that are measurements of the suite. `caught` is a hit.
265
+ * `blind` is a miss. `nocover` is also a miss — the worst kind, no test even
266
+ * imported the broken file — and it counts as one: excluding it would score a
267
+ * project with no tests for a subsystem BETTER than one with weak tests,
268
+ * because its worst outcomes would leave the denominator before the ratio is
269
+ * taken. Stryker and PIT count no-coverage mutants against the headline score
270
+ * for the same reason. `flaky` and `unverifiable` are failed measurements
271
+ * and enter neither side.
272
+ */
273
+ export const MEASURED_VERDICTS = Object.freeze(['caught', 'blind', 'nocover']);
274
+
261
275
  /**
262
276
  * Calibration from a replay document: per fault class, the share of real
263
- * escaped bugs of that shape the suite did NOT catch.
277
+ * escaped bugs of that shape the suite failed to catch.
264
278
  *
265
- * Read it as "when a fault of this class survives, how often does that
266
- * correspond to a bug that really escaped" — the number that turns an
267
- * uninterpretable mutation score into a statement with a sample size.
268
- * `caught` and `blind` are the only outcomes that carry information;
269
- * `nocover`, `flaky` and `unverifiable` are excluded from both sides.
279
+ * `p` is P(the suite misses it | a bug of this class escapes) — a miss rate,
280
+ * so a HIGH number is bad. It is the number that turns an uninterpretable
281
+ * mutation score into a statement with a sample size. `positives` counts the
282
+ * misses; see MEASURED_VERDICTS for which verdicts are measurements at all
283
+ * and why `nocover` is a miss rather than an exclusion.
270
284
  */
271
285
  export function calibrationFrom(replayDoc, { confidence = 0.95, computedAt = new Date().toISOString(), toolVersion } = {}) {
272
286
  const buckets = {};
273
287
  for (const r of replayDoc.records) {
274
- if (!['caught', 'blind'].includes(r.verdict)) continue;
288
+ if (!MEASURED_VERDICTS.includes(r.verdict)) continue;
275
289
  const key = r.faultClass ?? 'other';
276
- buckets[key] ??= { n: 0, positives: 0 };
290
+ buckets[key] ??= { n: 0, positives: 0, breakdown: {} };
277
291
  buckets[key].n += 1;
278
- if (r.verdict === 'blind') buckets[key].positives += 1;
292
+ buckets[key].breakdown[r.verdict] = (buckets[key].breakdown[r.verdict] ?? 0) + 1;
293
+ // Anything measured that was not caught is a miss — blind and nocover alike.
294
+ if (r.verdict !== 'caught') buckets[key].positives += 1;
279
295
  }
280
296
  for (const [k, b] of Object.entries(buckets)) buckets[k] = { ...b, ...wilson(b.positives, b.n) };
281
297
  return {
@@ -284,8 +300,18 @@ export function calibrationFrom(replayDoc, { confidence = 0.95, computedAt = new
284
300
  computedAt,
285
301
  method: 'wilson',
286
302
  confidence,
303
+ // What p is the probability of. Without it the document is unattributed
304
+ // and a consumer may quote nothing from it — see GATE-SEMANTICS.
305
+ measures: 'escape-missed',
287
306
  bucketBy: 'faultClass',
288
- source: { kind: 'bug-replay', ref: replayDoc.run.range },
307
+ source: {
308
+ kind: 'bug-replay',
309
+ ref: replayDoc.run.range,
310
+ // The ground truth is this repository's own history, which `ref` names,
311
+ // so there is no external corpus to list. The caveat is the sentence a
312
+ // consumer shows beside any number it quotes from here.
313
+ caveat: 'measured on this repository\'s own fix history: every replayed bug is one that escaped, so a first run is expected to be high — the number to move is this one, over time',
314
+ },
289
315
  buckets,
290
316
  };
291
317
  }
@@ -303,8 +329,9 @@ export function renderReplay(doc) {
303
329
  lines.push(`${doc.records.length} fix commit${doc.records.length === 1 ? '' : 's'} replayed from ${doc.run.candidates} candidate${doc.run.candidates === 1 ? '' : 's'}` +
304
330
  (doc.run.duplicates ? ` (${doc.run.duplicates} dropped as the same patch)` : '') + ': ' +
305
331
  order.filter((v) => by[v]).map((v) => `${by[v]} ${v}`).join(', ') + '.');
306
- const measurable = (by.caught ?? 0) + (by.blind ?? 0);
307
- if (measurable) lines.push(`${by.blind ?? 0} of ${measurable} measurable bugs were invisible to the suite — the escaped-bug replay rate is ${(((by.blind ?? 0) / measurable) * 100).toFixed(0)}%.`);
332
+ const measurable = MEASURED_VERDICTS.reduce((s, v) => s + (by[v] ?? 0), 0);
333
+ const missed = measurable - (by.caught ?? 0);
334
+ if (measurable) lines.push(`${missed} of ${measurable} measurable bugs were invisible to the suite (${by.blind ?? 0} blind, ${by.nocover ?? 0} with no test at all) — the escaped-bug miss rate is ${((missed / measurable) * 100).toFixed(0)}%.`);
308
335
  lines.push('A bug that reached production is by construction one the suite missed, so a high rate is expected on a first run. The number to move is this one, over time.');
309
336
  return lines.join('\n');
310
337
  }