testguard-cli 0.6.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 +489 -0
- package/README.md +287 -34
- package/package.json +2 -2
- package/spec/GATE-SEMANTICS.md +210 -8
- package/spec/README.md +32 -11
- package/spec/lib/validate.mjs +125 -14
- package/spec/lib/wilson.mjs +112 -0
- package/spec/schemas/baseline.schema.json +7 -7
- package/spec/schemas/brief.schema.json +12 -12
- package/spec/schemas/calibration.schema.json +96 -26
- package/spec/schemas/claims.schema.json +129 -25
- package/spec/schemas/common.schema.json +127 -29
- package/spec/schemas/evidence.schema.json +175 -73
- package/spec/schemas/gate.schema.json +12 -12
- package/spec/schemas/ignore.schema.json +4 -4
- package/spec/schemas/replay.schema.json +10 -10
- package/spec/schemas/status.schema.json +25 -25
- package/src/cli.mjs +20 -5
- package/src/commands/claims.mjs +22 -2
- package/src/commands/probe.mjs +24 -4
- package/src/commands/replay.mjs +1 -1
- package/src/gate/changed.mjs +9 -2
- package/src/probe/attribution.mjs +123 -0
- package/src/probe/classify.mjs +18 -2
- package/src/probe/cost.mjs +205 -0
- package/src/probe/discover.mjs +14 -6
- package/src/probe/inject.mjs +41 -9
- package/src/probe/mocks.mjs +2 -0
- package/src/probe/probe.mjs +235 -52
- package/src/probe/progress.mjs +87 -0
- package/src/probe/pyimports.mjs +287 -0
- package/src/probe/rank.mjs +2 -0
- package/src/probe/runners/index.mjs +38 -11
- package/src/probe/runners/jest.mjs +13 -1
- package/src/probe/runners/playwright.mjs +9 -0
- package/src/probe/runners/python/_testguard_pytest_plugin.py +132 -0
- package/src/probe/runners/python/_testguard_report.py +141 -0
- package/src/probe/runners/python/_testguard_unittest_main.py +168 -0
- package/src/probe/runners/python.mjs +316 -0
- package/src/probe/runners/shared.mjs +10 -1
- package/src/probe/runners/vitest.mjs +13 -1
- package/src/replay/replay.mjs +50 -22
- package/src/scaffold/producers.python.mjs +209 -0
- package/src/scaffold/scaffold.mjs +86 -32
- package/src/util/glob.mjs +7 -1
package/spec/GATE-SEMANTICS.md
CHANGED
|
@@ -12,7 +12,7 @@ The verdict set is closed. Only one value is a pass.
|
|
|
12
12
|
| `killed` | Fault applied; every one of N probe runs failed with a genuine assertion failure, on a defender set that was green N/N unmodified. | no |
|
|
13
13
|
| `survived` | Fault applied; every one of N probe runs passed. The claim is **unproven**. | **yes** |
|
|
14
14
|
| `nocover` | No defending test exists: the declared globs resolve to nothing, or no test imports the subject. Worse than `survived` — nothing was even tried. | **yes** |
|
|
15
|
-
| `unverifiable` | The claim could not be probed: the fault's anchor is missing or ambiguous,
|
|
15
|
+
| `unverifiable` | The claim could not be probed: the fault's anchor is missing or ambiguous, its defenders failed to load (`defenders-failed-to-load`), probing it threw (`probe-error`), or the defenders do not execute the subject at all (`subject-not-executed`). Carries a `reason`. A loud, gating verdict — a claim that cannot be probed is not "skipped", it is undefended until someone fixes the fault or the defenders. Never confused with `flaky-defender`, which requires tests that *ran*. | **yes** |
|
|
16
16
|
| `timeout` | Probe run exceeded its budget. Not counted as a kill; the pessimistic reading is the safe one because flakiness biases the metric optimistically. | **yes** |
|
|
17
17
|
| `fault-invalid` | The replacement does not load or compile. A bad fault, not a detection. | **yes** |
|
|
18
18
|
| `flaky-defender` | Defenders were not green N/N on unmodified source (`defenders-not-green`), or the N probe runs disagreed with each other (`inconsistent-probe`). Either way no verdict about the fault can be trusted; fix the defenders first. | **yes** |
|
|
@@ -36,10 +36,16 @@ Rules that follow from the table:
|
|
|
36
36
|
that failed and then passed on retry (Playwright's `flaky`) is a
|
|
37
37
|
**non-green run** even when the runner exits 0, and a test the runner
|
|
38
38
|
reports as timed out (`timedOut`) is a timeout, never an assertion
|
|
39
|
-
failure.
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
failure. Python is read the same way: an `AssertionError` and any other
|
|
40
|
+
exception raised by the test body both count, because both are the suite
|
|
41
|
+
rejecting the behaviour; a collection error — a module that will not
|
|
42
|
+
import, which is what a replacement that does not parse looks like from
|
|
43
|
+
Python — is a load failure and never a kill; and a test the suite expected
|
|
44
|
+
to fail that passed (`xpass`) makes the run non-green without ever being an
|
|
45
|
+
assertion failure, exactly as Playwright's `flaky` does. When one claim's
|
|
46
|
+
defenders run under several runners, their runs merge pessimistically —
|
|
47
|
+
any load error is an error, any timeout is a timeout, any failure is a
|
|
48
|
+
failure — and the evidence names which file ran under which runner.
|
|
43
49
|
4. **A mixed result is not a kill.** If some of the N probe runs fail and
|
|
44
50
|
others pass, the defender's response to the fault is nondeterministic.
|
|
45
51
|
Reporting `killed` would be the optimistic bias flakiness introduces;
|
|
@@ -68,6 +74,115 @@ Rules that follow from the table:
|
|
|
68
74
|
content hash; the status document lists every fault whose content
|
|
69
75
|
changed since it was probed, with its previous verdict. The change is
|
|
70
76
|
allowed; it is never invisible.
|
|
77
|
+
The same hash governs **reuse**: a tool that carries a prior verdict
|
|
78
|
+
forward to save a run may only do so for the fault that produced it. A
|
|
79
|
+
verdict is an answer about one exact `find`/`replace`, so once those
|
|
80
|
+
change the old answer is not an answer to the new question — a weakened
|
|
81
|
+
fault would keep the verdict it earned before, and a repaired anchor would
|
|
82
|
+
keep `unverifiable`. Where the prior record has no content hash to compare,
|
|
83
|
+
the fault is probed again: the cost of re-measuring is a run, the cost of
|
|
84
|
+
the other direction is a verdict nobody measured.
|
|
85
|
+
|
|
86
|
+
9. **The fault must be the code that ran.** A green baseline proves the
|
|
87
|
+
harness is not reporting everything as broken. It proves nothing about the
|
|
88
|
+
opposite direction: a fault the interpreter never executes leaves the
|
|
89
|
+
baseline green, every verdict `survived`, and a report that reads as a
|
|
90
|
+
devastating finding while being entirely false. A tool that can tell which
|
|
91
|
+
file was actually loaded must check it while the fault is applied, and must
|
|
92
|
+
**refuse the run** when the subject was loaded from outside the tree being
|
|
93
|
+
probed — in Python an editable install (PEP 660) registers an import hook
|
|
94
|
+
consulted ahead of every path entry, and a plain install leaves a copy in
|
|
95
|
+
`site-packages`, so this is the normal case, not an exotic one. When no
|
|
96
|
+
defender imported the subject at all, the run continues: an import inside a
|
|
97
|
+
branch the fault does not reach is legitimate. It is recorded as
|
|
98
|
+
`detail.targetNotImported`, because a `survived` there is a statement about
|
|
99
|
+
the defenders' reach and not about their assertions, and the two must not
|
|
100
|
+
be read as the same finding.
|
|
101
|
+
10. **A signal describes a file, and says which kind of blindness it is.**
|
|
102
|
+
Replacing a whole module (`vi.mock`, `jest.mock`, a Python `patch` of the
|
|
103
|
+
module itself) removes a file from the defenders: it cannot detect anything
|
|
104
|
+
in what it replaced. Replacing **one attribute** of a module —
|
|
105
|
+
`patch("pkg.mod.fn")`, which is what Python code almost always does — does
|
|
106
|
+
not: a fault anywhere else in that module is still fully detectable, and
|
|
107
|
+
dropping the file would report `nocover` for a claim that is well defended.
|
|
108
|
+
The file stays a defender and carries `target-attribute-patched`, naming
|
|
109
|
+
the attributes. Signals never change a verdict.
|
|
110
|
+
|
|
111
|
+
11. **One fault's failure costs that fault, not the run.** A fault whose probe
|
|
112
|
+
throws is recorded as `unverifiable` with reason `probe-error` and a
|
|
113
|
+
`detail.message` naming what threw; the run continues and still writes its
|
|
114
|
+
evidence. Losing the document would throw away every verdict already
|
|
115
|
+
decided and leave a caller with an exit code this document does not define,
|
|
116
|
+
which is strictly worse than one loud unverifiable claim. A `probe-error`
|
|
117
|
+
record is never reused by a later run: it says something about the run, not
|
|
118
|
+
about the code. The one exception is a **precondition** failure — the
|
|
119
|
+
interpreter loading the source from outside the probed tree, a runner that
|
|
120
|
+
does not resolve — which is a statement about every verdict in the run and
|
|
121
|
+
still refuses it outright.
|
|
122
|
+
Restoring the source is held to the same reading. The restore is
|
|
123
|
+
belt-and-braces in worktree mode, where the mutation only ever existed
|
|
124
|
+
inside a scratch worktree that is discarded anyway, so a target that has
|
|
125
|
+
vanished is already restored and is recorded as
|
|
126
|
+
`detail.restoreSkipped: "target-missing"` rather than raised — a reader can
|
|
127
|
+
still see that the tree moved underneath the run. Under `--in-place` the
|
|
128
|
+
same condition is the user's own file gone and must be raised. Any other
|
|
129
|
+
failure to write the original back is raised in both modes: it can mean a
|
|
130
|
+
mutated file left on disk.
|
|
131
|
+
|
|
132
|
+
12. **A survivor must prove the defenders execute the subject.** A green
|
|
133
|
+
baseline proves the defenders can *pass*. It does not prove they can fail
|
|
134
|
+
*because of this file* — and if the fault is applied to code the test
|
|
135
|
+
process never executes, the baseline is green, every probe run is green,
|
|
136
|
+
and every claim is reported `survived`. That output reads as a devastating
|
|
137
|
+
audit finding and is entirely false, and no individual check in the run
|
|
138
|
+
contradicts it.
|
|
139
|
+
So a would-be `survived` is charged one more run: the subject is replaced
|
|
140
|
+
with content its loader cannot parse, and the defenders run once. Going red
|
|
141
|
+
proves they execute it (`detail.negativeControl: "reached"`), and the
|
|
142
|
+
`survived` stands. Staying green proves they do not, and the verdict is
|
|
143
|
+
`unverifiable` with reason `subject-not-executed` — the pessimistic reading,
|
|
144
|
+
and the only honest one, because nothing was measured about their
|
|
145
|
+
assertions.
|
|
146
|
+
Charged **only** on a would-be survivor, and cached per subject and defender
|
|
147
|
+
set. A kill already proves the defenders reached the code, so paying there
|
|
148
|
+
would be waste. A runner that cannot say what "cannot compile" means for its
|
|
149
|
+
language does not guess: no control is run, no signal is recorded, and the
|
|
150
|
+
verdict is unchanged. `detail.targetNotImported` (reported by runners that
|
|
151
|
+
can name the file the interpreter loaded) answers the same question more
|
|
152
|
+
precisely for one language; where both are present they must agree.
|
|
153
|
+
|
|
154
|
+
13. **Fault injection is one method, not the definition.** A claim declares
|
|
155
|
+
what must be true; a probe declares how a tool would try to make it false,
|
|
156
|
+
and `method` says which way. `fault-injection` — mutate the source, run the
|
|
157
|
+
claim's defenders, confirm over N runs — is the only method specified in
|
|
158
|
+
v1, and it is the default when the field is absent, so every document
|
|
159
|
+
written before the field existed is held to exactly the rules it was
|
|
160
|
+
written against.
|
|
161
|
+
Everything in this document above this rule is `fault-injection`'s
|
|
162
|
+
semantics. `confirmRuns`, `mode`, `defenders`, `inputs` and the baseline
|
|
163
|
+
and probe runs are what *it* means by showing its work; a tool that reads
|
|
164
|
+
code or scans a surface has none of them, and requiring them would force it
|
|
165
|
+
to emit empty arrays to conform — which is lying, and the one thing this
|
|
166
|
+
format exists to make impossible. The schema therefore requires them only
|
|
167
|
+
under `fault-injection`, and the validator enforces them there in full: the
|
|
168
|
+
relaxation is for a tool that injects nothing, never a licence for an
|
|
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.
|
|
178
|
+
`assertion` and `scan` are **reserved**. Their required fields are
|
|
179
|
+
deliberately unspecified and will be defined by their first real consumer,
|
|
180
|
+
with its own conformance examples written from the shape that tool actually
|
|
181
|
+
has. Designing them for an absent tool is precisely the mistake that made
|
|
182
|
+
this rule necessary: the spec was drafted against a single consumer and
|
|
183
|
+
hard-coded its assumptions as requirements.
|
|
184
|
+
The verdict set stays closed and shared. What each method may legitimately
|
|
185
|
+
report is part of defining it.
|
|
71
186
|
|
|
72
187
|
## Replay reports; it never gates
|
|
73
188
|
|
|
@@ -94,9 +209,96 @@ Two rules that follow:
|
|
|
94
209
|
2. **The fix's own test is removed before the run.** It proves nothing about
|
|
95
210
|
what the suite knew before the fix existed.
|
|
96
211
|
|
|
97
|
-
|
|
98
|
-
`
|
|
99
|
-
|
|
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.
|
|
100
302
|
|
|
101
303
|
## Baseline and delta
|
|
102
304
|
|
package/spec/README.md
CHANGED
|
@@ -1,16 +1,25 @@
|
|
|
1
|
-
#
|
|
1
|
+
# claimspec — the contract spine
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
is Node ESM, [`websec-validator`](https://pypi.org/project/websec-validator/)
|
|
6
|
-
is Python, and porting one runtime into the other is not worth it. A tool
|
|
7
|
-
conforms by emitting these shapes; it keeps its own language, CLI and UX.
|
|
3
|
+
Nine formats for declaring what must be true about a codebase, recording the
|
|
4
|
+
evidence that tried to falsify it, and gating only what is new.
|
|
8
5
|
|
|
9
|
-
|
|
6
|
+
They are shared as *formats*, not as code. The first tools to adopt them are
|
|
7
|
+
[`docguard-cli`](https://www.npmjs.com/package/docguard-cli) (Node ESM) and
|
|
8
|
+
[`websec-validator`](https://pypi.org/project/websec-validator/) (Python) —
|
|
9
|
+
porting one runtime into the other was never worth it, and never the point. A
|
|
10
|
+
tool conforms by emitting these shapes; it keeps its own language, CLI and UX.
|
|
11
|
+
|
|
12
|
+
The method they have in common:
|
|
10
13
|
|
|
11
14
|
> declare what must be true → try mechanically to falsify it → freeze a
|
|
12
15
|
> baseline → gate only the delta → brief the agent before it writes code.
|
|
13
16
|
|
|
17
|
+
**Falsifying by fault injection is one method, not the definition.** A tool
|
|
18
|
+
that verifies a claim by reading code, by scanning a surface, or by replaying
|
|
19
|
+
history is as much a consumer as one that mutates source. The formats that
|
|
20
|
+
carry *how* a claim was tested say which method produced them; the formats
|
|
21
|
+
that carry claims, baselines, scoping and briefs do not care.
|
|
22
|
+
|
|
14
23
|
## Formats
|
|
15
24
|
|
|
16
25
|
| Kind | Schema | Purpose |
|
|
@@ -19,7 +28,7 @@ The pattern all such tools run:
|
|
|
19
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. |
|
|
20
29
|
| `baseline` | [`schemas/baseline.schema.json`](schemas/baseline.schema.json) | Frozen fingerprints of existing debt. Gate only what is new. |
|
|
21
30
|
| `ignore` | [`schemas/ignore.schema.json`](schemas/ignore.schema.json) | Reviewable scoping. Every entry has a reason. |
|
|
22
|
-
| `calibration` | [`schemas/calibration.schema.json`](schemas/calibration.schema.json) |
|
|
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. |
|
|
23
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. |
|
|
24
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. |
|
|
25
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. |
|
|
@@ -30,7 +39,7 @@ Shared definitions (verdicts, fault classes, provenance, annotations) live in
|
|
|
30
39
|
which verdicts turn CI red, baseline and delta rules, exit codes — is in
|
|
31
40
|
[`GATE-SEMANTICS.md`](GATE-SEMANTICS.md).
|
|
32
41
|
|
|
33
|
-
Schemas are JSON Schema 2020-12 and identified as `urn:
|
|
42
|
+
Schemas are JSON Schema 2020-12 and identified as `urn:claimspec:v1:<kind>`.
|
|
34
43
|
|
|
35
44
|
## Conformance
|
|
36
45
|
|
|
@@ -53,5 +62,17 @@ npm run test:spec
|
|
|
53
62
|
|
|
54
63
|
The spec is drafted here and validated against `testguard` as its first
|
|
55
64
|
consumer. It moves to its own repository only when a second tool adopts it.
|
|
56
|
-
|
|
57
|
-
|
|
65
|
+
|
|
66
|
+
Adoption is additive and optional: an existing tool gains an output writer, and
|
|
67
|
+
nothing it already detects or already writes changes. That holds today 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
|
|
72
|
+
`claims` and `evidence`, whose required fields assume fault injection
|
|
73
|
+
(`find`/`replace` anchors, `confirmRuns`, `baselineRuns`/`probeRuns`,
|
|
74
|
+
`defenders`) — a tool that scans or reads would have to emit empty arrays to
|
|
75
|
+
satisfy the schema, which is conforming by lying. Making those two kinds
|
|
76
|
+
method-agnostic is tracked as
|
|
77
|
+
[#81](https://github.com/raccioly/testguard/issues/81) and blocks adoption of
|
|
78
|
+
either.
|
package/spec/lib/validate.mjs
CHANGED
|
@@ -3,10 +3,18 @@ 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
|
|
|
9
10
|
export const KINDS = Object.freeze(['claims', 'evidence', 'baseline', 'ignore', 'calibration', 'brief', 'status', 'gate', 'replay']);
|
|
11
|
+
/**
|
|
12
|
+
* How a document says it tried to falsify its claims. Absent means
|
|
13
|
+
* `fault-injection`, so every document written before the field existed is
|
|
14
|
+
* held to exactly the rules it was written against.
|
|
15
|
+
*/
|
|
16
|
+
export const methodOf = (x) => x?.method ?? 'fault-injection';
|
|
17
|
+
const isInjection = (x) => methodOf(x) === 'fault-injection';
|
|
10
18
|
export const PASSING_VERDICTS = Object.freeze(new Set(['killed']));
|
|
11
19
|
|
|
12
20
|
const ajv = new Ajv2020({ strict: true, allErrors: true });
|
|
@@ -15,7 +23,7 @@ for (const f of readdirSync(schemaDir).filter((n) => n.endsWith('.schema.json'))
|
|
|
15
23
|
}
|
|
16
24
|
|
|
17
25
|
const schemaFor = (kind) => {
|
|
18
|
-
const v = ajv.getSchema(`urn:
|
|
26
|
+
const v = ajv.getSchema(`urn:claimspec:v1:${kind}`);
|
|
19
27
|
if (!v) throw new RangeError(`unknown Guard-spec kind: ${kind}`);
|
|
20
28
|
return v;
|
|
21
29
|
};
|
|
@@ -33,10 +41,15 @@ const semantic = {
|
|
|
33
41
|
const p = `/claims/${ci}/faults/${fi}`;
|
|
34
42
|
if (seenFault.has(f.id)) errors.push({ path: `${p}/id`, message: `duplicate fault id "${f.id}" in claim "${c.id}"` });
|
|
35
43
|
seenFault.add(f.id);
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if (f
|
|
44
|
+
// Anchor arithmetic is a property of fault injection. A probe that does
|
|
45
|
+
// not substitute text has no `find` to compare, and `undefined ===
|
|
46
|
+
// undefined` would report every one of them as a no-op.
|
|
47
|
+
if (isInjection(f)) {
|
|
48
|
+
const occ = f.occurrence ?? 1;
|
|
49
|
+
const hits = f.expectHits ?? 1;
|
|
50
|
+
if (occ > hits) errors.push({ path: `${p}/occurrence`, message: `occurrence ${occ} exceeds expectHits ${hits}` });
|
|
51
|
+
if (f.find === f.replace) errors.push({ path: `${p}/replace`, message: 'replace is identical to find; the fault is a no-op' });
|
|
52
|
+
}
|
|
40
53
|
});
|
|
41
54
|
});
|
|
42
55
|
return errors;
|
|
@@ -44,9 +57,15 @@ const semantic = {
|
|
|
44
57
|
|
|
45
58
|
evidence(doc) {
|
|
46
59
|
const errors = [];
|
|
60
|
+
const injection = isInjection(doc.run);
|
|
47
61
|
const n = doc.run.confirmRuns;
|
|
48
|
-
|
|
49
|
-
|
|
62
|
+
// N-run agreement is what fault injection means by "confirmed". A method
|
|
63
|
+
// that does not re-run anything has no N, and a rule about one would be a
|
|
64
|
+
// rule about a number that is not there.
|
|
65
|
+
if (injection) {
|
|
66
|
+
if (n < 3 && doc.run.provisional !== true) errors.push({ path: '/run/provisional', message: `confirmRuns ${n} is below 3; the run must declare provisional: true` });
|
|
67
|
+
if (n >= 3 && doc.run.provisional === true) errors.push({ path: '/run/provisional', message: `confirmRuns ${n} is confirmed; provisional must be absent or false` });
|
|
68
|
+
}
|
|
50
69
|
if (doc.run.runners && doc.run.runner && !doc.run.runners.some((x) => x.name === doc.run.runner.name)) errors.push({ path: '/run/runners', message: 'runners must include the project runner named in run.runner' });
|
|
51
70
|
if (doc.run.repo.ignoredDirty && doc.run.repo.snapshot) errors.push({ path: '/run/repo/ignoredDirty', message: 'a working-tree snapshot has no ignored dirty files: the tree was probed as it is' });
|
|
52
71
|
doc.records.forEach((r, i) => {
|
|
@@ -54,6 +73,26 @@ const semantic = {
|
|
|
54
73
|
const expected = fingerprint({ claimId: r.claim.id, subjectId: r.subject.id, file: r.subject.file ?? '', verdict: r.verdict });
|
|
55
74
|
if (r.fingerprint !== expected) errors.push({ path: `${p}/fingerprint`, message: `fingerprint does not match spec derivation (expected ${expected})` });
|
|
56
75
|
|
|
76
|
+
// What the schema no longer requires, the validator still does — for the
|
|
77
|
+
// one method that means it. Fault injection without defenders, inputs or
|
|
78
|
+
// runs is not a leaner document, it is a verdict with nothing behind it;
|
|
79
|
+
// relaxing the schema was to let a scanner conform, never to let an
|
|
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
|
+
}
|
|
84
|
+
if (injection) {
|
|
85
|
+
for (const k of ['defenders', 'inputs']) {
|
|
86
|
+
if (!r[k]) errors.push({ path: `${p}/${k}`, message: `fault-injection requires ${k} on every record` });
|
|
87
|
+
}
|
|
88
|
+
for (const k of ['baselineRuns', 'probeRuns']) {
|
|
89
|
+
if (!Array.isArray(r.detail[k])) errors.push({ path: `${p}/detail/${k}`, message: `fault-injection requires detail.${k} on every record` });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// Every rule below reads those fields, so a record that is already
|
|
93
|
+
// malformed must not also throw on the way to being reported.
|
|
94
|
+
if (!injection || !r.defenders || !r.inputs || !Array.isArray(r.detail.baselineRuns) || !Array.isArray(r.detail.probeRuns)) return;
|
|
95
|
+
|
|
57
96
|
const { baselineRuns, probeRuns } = r.detail;
|
|
58
97
|
if (r.verdict === 'killed' || r.verdict === 'survived') {
|
|
59
98
|
if (baselineRuns.length !== n) errors.push({ path: `${p}/detail/baselineRuns`, message: `${r.verdict} requires exactly confirmRuns (${n}) baseline runs, got ${baselineRuns.length}` });
|
|
@@ -99,23 +138,95 @@ const semantic = {
|
|
|
99
138
|
}
|
|
100
139
|
for (const s of r.defenders.signals ?? []) {
|
|
101
140
|
if (s.signal === 'unasserted-annotated' && !s.reason) errors.push({ path: `${p}/defenders/signals`, message: `unasserted-annotated on ${s.file} requires the annotation's reason` });
|
|
102
|
-
|
|
141
|
+
// An attribute patch (Python) leaves the file a defender, so it belongs
|
|
142
|
+
// to `resolved` and not to `mocking`; every other signal is about a file
|
|
143
|
+
// that replaced the module and must be listed as mocking it.
|
|
144
|
+
if (s.signal === 'target-attribute-patched') {
|
|
145
|
+
if (!s.reason) errors.push({ path: `${p}/defenders/signals`, message: `target-attribute-patched on ${s.file} requires the patched attributes as its reason` });
|
|
146
|
+
if (!r.defenders.resolved.includes(s.file)) errors.push({ path: `${p}/defenders/signals`, message: `${s.file} patches attributes of the subject but is not a resolved defender` });
|
|
147
|
+
} else if (!(r.defenders.mocking ?? []).includes(s.file)) {
|
|
148
|
+
errors.push({ path: `${p}/defenders/signals`, message: `${s.file} carries a mock signal but is not listed in defenders.mocking` });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// A module the defenders never imported cannot have been killed by them.
|
|
152
|
+
if (r.detail.targetNotImported && r.verdict === 'killed') {
|
|
153
|
+
errors.push({ path: `${p}/detail/targetNotImported`, message: 'the subject was never imported, so the defenders cannot have killed the fault' });
|
|
154
|
+
}
|
|
155
|
+
// The control is only ever charged on a would-be survivor, so a `reached`
|
|
156
|
+
// record must be one, and `not-reached` must have become unverifiable.
|
|
157
|
+
if (r.detail.negativeControl === 'reached' && r.verdict !== 'survived') {
|
|
158
|
+
errors.push({ path: `${p}/detail/negativeControl`, message: `negativeControl "reached" belongs to a survivor, not to ${r.verdict}` });
|
|
159
|
+
}
|
|
160
|
+
if (r.detail.negativeControl === 'not-reached' && !(r.verdict === 'unverifiable' && r.detail.reason === 'subject-not-executed')) {
|
|
161
|
+
errors.push({ path: `${p}/detail/negativeControl`, message: 'negativeControl "not-reached" requires verdict unverifiable with reason subject-not-executed' });
|
|
162
|
+
}
|
|
163
|
+
if (r.detail.reason === 'subject-not-executed' && r.detail.negativeControl !== 'not-reached') {
|
|
164
|
+
errors.push({ path: `${p}/detail/reason`, message: 'subject-not-executed requires the negativeControl that established it' });
|
|
165
|
+
}
|
|
166
|
+
// Python's import provenance and the general control answer the same
|
|
167
|
+
// question. A document where they disagree describes a run that cannot
|
|
168
|
+
// have happened.
|
|
169
|
+
if (r.detail.targetNotImported && r.detail.negativeControl === 'reached') {
|
|
170
|
+
errors.push({ path: `${p}/detail/negativeControl`, message: 'the subject was never imported, so the negative control cannot have reached it' });
|
|
103
171
|
}
|
|
104
172
|
if (r.verdict === 'unverifiable' && !r.detail.reason) {
|
|
105
173
|
errors.push({ path: `${p}/detail/reason`, message: 'unverifiable requires a reason (e.g. anchor-missing, anchor-ambiguous)' });
|
|
106
174
|
}
|
|
175
|
+
// `probe-error` is the one reason that names nothing on its own: it says
|
|
176
|
+
// the run threw, not what threw. Without the message the record is a
|
|
177
|
+
// dead end for anyone reading the document instead of rerunning it.
|
|
178
|
+
if (r.detail.reason === 'probe-error' && !r.detail.message) {
|
|
179
|
+
errors.push({ path: `${p}/detail/message`, message: 'probe-error requires a message naming what threw' });
|
|
180
|
+
}
|
|
181
|
+
// In-place is the mode where a vanished target is the user's own file
|
|
182
|
+
// gone; it raises rather than records. A document claiming otherwise
|
|
183
|
+
// describes a run that cannot have happened.
|
|
184
|
+
if (r.detail.restoreSkipped && doc.run.mode === 'in-place') {
|
|
185
|
+
errors.push({ path: `${p}/detail/restoreSkipped`, message: 'restoreSkipped cannot occur in in-place mode, where a missing target is raised' });
|
|
186
|
+
}
|
|
107
187
|
});
|
|
108
188
|
return errors;
|
|
109
189
|
},
|
|
110
190
|
|
|
111
191
|
calibration(doc) {
|
|
112
192
|
const errors = [];
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
+
}
|
|
119
230
|
}
|
|
120
231
|
return errors;
|
|
121
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
|
+
}
|