claims-cli 0.6.0__py3-none-any.whl
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.
- claims/__init__.py +132 -0
- claims/__main__.py +5 -0
- claims/adapters/__init__.py +6 -0
- claims/adapters/array.py +212 -0
- claims/adapters/base.py +75 -0
- claims/adapters/cells.py +94 -0
- claims/adapters/columnar.py +188 -0
- claims/adapters/prose.py +261 -0
- claims/adapters/reads.py +47 -0
- claims/adapters/rows.py +116 -0
- claims/adapters/sheet.py +149 -0
- claims/adapters/sqlite.py +73 -0
- claims/adapters/table.py +135 -0
- claims/adapters/tree.py +163 -0
- claims/checks.py +511 -0
- claims/cli.py +1103 -0
- claims/core/__init__.py +9 -0
- claims/core/artifacts.py +121 -0
- claims/core/checks.py +132 -0
- claims/core/claims.py +100 -0
- claims/core/evidence.py +708 -0
- claims/core/manifest.py +349 -0
- claims/core/numbers.py +146 -0
- claims/core/outcomes.py +410 -0
- claims/core/report.py +503 -0
- claims/corpus.py +344 -0
- claims/exceptions.py +106 -0
- claims/export/__init__.py +23 -0
- claims/export/rocrate.py +787 -0
- claims/export/sarif.py +401 -0
- claims/manifest.py +59 -0
- claims/models.py +222 -0
- claims/numbers.py +453 -0
- claims/origins.py +303 -0
- claims/policy.py +369 -0
- claims/provenance.py +42 -0
- claims/record.py +1099 -0
- claims/regression.py +171 -0
- claims/renderers/__init__.py +10 -0
- claims/renderers/sarif.py +294 -0
- claims/reruns.py +111 -0
- claims/resolve.py +116 -0
- claims/stale.py +333 -0
- claims/starter.py +432 -0
- claims/toolchain.py +120 -0
- claims/verify.py +1371 -0
- claims/why.py +398 -0
- claims_cli-0.6.0.dist-info/METADATA +139 -0
- claims_cli-0.6.0.dist-info/RECORD +51 -0
- claims_cli-0.6.0.dist-info/WHEEL +4 -0
- claims_cli-0.6.0.dist-info/entry_points.txt +2 -0
claims/core/evidence.py
ADDED
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
"""What a claim offers, and how a value is addressed inside an artifact.
|
|
2
|
+
|
|
3
|
+
One contract over several formats: a locator names a value, an evidence assertion names the
|
|
4
|
+
value a manuscript reports for it."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import decimal
|
|
9
|
+
import enum
|
|
10
|
+
import json
|
|
11
|
+
from typing import Annotated, Literal
|
|
12
|
+
|
|
13
|
+
from pydantic import (
|
|
14
|
+
BaseModel,
|
|
15
|
+
ConfigDict,
|
|
16
|
+
Field,
|
|
17
|
+
StringConstraints,
|
|
18
|
+
model_serializer,
|
|
19
|
+
model_validator,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
from claims.core.artifacts import Digest
|
|
23
|
+
|
|
24
|
+
SCHEMA_VERSION = "repro/1"
|
|
25
|
+
|
|
26
|
+
_SHA256_HEX = r"^[a-f0-9]{64}$"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------------- evidence
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _EvidenceBase(BaseModel):
|
|
33
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
34
|
+
|
|
35
|
+
artifact: str
|
|
36
|
+
"""Id of the artifact this value is asserted to come from. The artifact carries the pin;
|
|
37
|
+
evidence never repeats a digest, so there is one source of truth per file."""
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def artifacts(self) -> tuple[str, ...]:
|
|
41
|
+
"""Every artifact this assertion reads.
|
|
42
|
+
|
|
43
|
+
One, for every kind but `correspondence` and `aggregate`. The engine resolves pins
|
|
44
|
+
over this rather than over `artifact`, so an assertion reading two files has both of
|
|
45
|
+
them checked.
|
|
46
|
+
"""
|
|
47
|
+
return (self.artifact,)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# --------------------------------------------------------------------------------- locators
|
|
51
|
+
|
|
52
|
+
#: Scalar types a row predicate may hold. Deliberately not a string expression language: a
|
|
53
|
+
#: string predicate needs a parser, coercion rules, and an escaping grammar, and every
|
|
54
|
+
#: implementation would disagree about the edges.
|
|
55
|
+
PredicateValue = str | int | float | bool
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Locator(BaseModel):
|
|
59
|
+
"""Where a value sits in an artifact.
|
|
60
|
+
|
|
61
|
+
One contract over several file formats, rather than one pointer syntax pretending every
|
|
62
|
+
file is a tree. JSON Pointer works because JSON has a single tree data model and a
|
|
63
|
+
pointer resolves to at most one value; it says nothing about table keys, multidimensional
|
|
64
|
+
indices, or database rows. Each variant below addresses its format the way that format
|
|
65
|
+
already addresses itself.
|
|
66
|
+
|
|
67
|
+
The invariant every variant shares: a locator resolves to **exactly one scalar**. Zero is
|
|
68
|
+
absent, two or more is ambiguous, and a container is not a value. No backend may quietly
|
|
69
|
+
take the first match.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
73
|
+
|
|
74
|
+
contract: Literal[1] = 1
|
|
75
|
+
"""Version of the locator contract, carried so a stored decision says which rules it was
|
|
76
|
+
resolved under."""
|
|
77
|
+
|
|
78
|
+
def canonical(self) -> str:
|
|
79
|
+
"""Canonical JSON: sorted keys, no whitespace, no coercion.
|
|
80
|
+
|
|
81
|
+
Hashed separately from the artifact, so a decision binds both what was read and how
|
|
82
|
+
it was addressed. Changing a selector changes the digest even where the file does
|
|
83
|
+
not.
|
|
84
|
+
"""
|
|
85
|
+
return json.dumps(
|
|
86
|
+
self.model_dump(mode="json"), sort_keys=True, separators=(",", ":"), ensure_ascii=False
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def digest(self) -> Digest:
|
|
91
|
+
return Digest.of_text(self.canonical())
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class TreeLocator(Locator):
|
|
95
|
+
"""RFC 6901 JSON Pointer into JSON, or into YAML restricted to a JSON-compatible tree."""
|
|
96
|
+
|
|
97
|
+
kind: Literal["tree"] = "tree"
|
|
98
|
+
pointer: str
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class TableLocator(Locator):
|
|
102
|
+
"""A column plus a predicate that must match exactly one row.
|
|
103
|
+
|
|
104
|
+
Addresses delimited text, and the binary formats that hold one table and nothing else:
|
|
105
|
+
Parquet, Feather and Arrow IPC, Stata `.dta`, SPSS `.sav`, and an R data frame saved as
|
|
106
|
+
`.rds`. `delimiter` means something only to delimited text."""
|
|
107
|
+
|
|
108
|
+
kind: Literal["table"] = "table"
|
|
109
|
+
column: str
|
|
110
|
+
where: dict[str, PredicateValue] = Field(default_factory=dict)
|
|
111
|
+
delimiter: str = ""
|
|
112
|
+
|
|
113
|
+
@model_validator(mode="after")
|
|
114
|
+
def _predicate_must_select(self):
|
|
115
|
+
if not self.where:
|
|
116
|
+
raise ValueError(
|
|
117
|
+
"a table locator needs a `where` predicate; to address by "
|
|
118
|
+
"position use kind: table_position"
|
|
119
|
+
)
|
|
120
|
+
return self
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class TablePositionLocator(Locator):
|
|
124
|
+
"""A column plus a row index. Weaker on purpose, and reported as such.
|
|
125
|
+
|
|
126
|
+
Sorting, filtering or inserting a row changes what row 37 means, so a positional address
|
|
127
|
+
has no semantic identity. The artifact pin still makes it reproducible, which is why this
|
|
128
|
+
is a distinct locator carrying a warning rather than a refusal.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
kind: Literal["table_position"] = "table_position"
|
|
132
|
+
column: str
|
|
133
|
+
row: int = Field(ge=0)
|
|
134
|
+
delimiter: str = ""
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class ArrayLocator(Locator):
|
|
138
|
+
"""A named array plus a multidimensional index, for `.npy`, `.npz`, HDF5 and NetCDF."""
|
|
139
|
+
|
|
140
|
+
kind: Literal["array"] = "array"
|
|
141
|
+
array: str | None = None
|
|
142
|
+
"""Required for `.npz`, which holds many arrays; omitted for `.npy`, which holds one. In an
|
|
143
|
+
HDF5 or NetCDF file it is the dataset's or variable's path, `/results/accuracy`, and is
|
|
144
|
+
required for the same reason."""
|
|
145
|
+
index: tuple[int, ...]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class SheetLocator(Locator):
|
|
149
|
+
"""A sheet of a workbook, a column, and a predicate that must match exactly one row.
|
|
150
|
+
|
|
151
|
+
A table locator with one more coordinate, because a workbook holds several tables and a
|
|
152
|
+
column name is unique, at best, within one of them. The first row of the sheet is the
|
|
153
|
+
header, as it is in delimited text.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
kind: Literal["sheet"] = "sheet"
|
|
157
|
+
sheet: str | None = None
|
|
158
|
+
"""Required where the workbook has more than one sheet. Omitted for a workbook with one,
|
|
159
|
+
which is what most exported results are; a second sheet added later turns the omission
|
|
160
|
+
into a broken selector rather than a silent read of whichever sheet comes first."""
|
|
161
|
+
column: str
|
|
162
|
+
where: dict[str, PredicateValue] = Field(default_factory=dict)
|
|
163
|
+
|
|
164
|
+
@model_validator(mode="after")
|
|
165
|
+
def _predicate_must_select(self):
|
|
166
|
+
if not self.where:
|
|
167
|
+
raise ValueError("a sheet locator needs a `where` predicate")
|
|
168
|
+
return self
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class SqliteLocator(Locator):
|
|
172
|
+
"""A table, a column, and a predicate that must match exactly one row."""
|
|
173
|
+
|
|
174
|
+
kind: Literal["sqlite"] = "sqlite"
|
|
175
|
+
table: str
|
|
176
|
+
column: str
|
|
177
|
+
where: dict[str, PredicateValue] = Field(default_factory=dict)
|
|
178
|
+
|
|
179
|
+
@model_validator(mode="after")
|
|
180
|
+
def _predicate_must_select(self):
|
|
181
|
+
if not self.where:
|
|
182
|
+
raise ValueError("a sqlite locator needs a `where` predicate")
|
|
183
|
+
return self
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class NumberForm(enum.StrEnum):
|
|
187
|
+
"""How the text a prose locator selects is read as a number."""
|
|
188
|
+
|
|
189
|
+
DECIMAL = "decimal"
|
|
190
|
+
"""Digits, parsed with `decimal.Decimal`. The default: no conversion happens."""
|
|
191
|
+
|
|
192
|
+
CARDINAL_WORD = "cardinal_word"
|
|
193
|
+
"""An English cardinal below one hundred, written out. `eighteen` is read as 18.
|
|
194
|
+
|
|
195
|
+
Declared per assertion and never inferred. Reading a word as a number is a semantic
|
|
196
|
+
decision, and the engine makes none on its own: under `decimal` a recognized cardinal is
|
|
197
|
+
refused with its own reason rather than converted."""
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class ProseLocator(Locator):
|
|
201
|
+
"""The value between two literal anchors in the extracted text of a document.
|
|
202
|
+
|
|
203
|
+
Every other locator addresses a data model the format already has. Prose has none, so the
|
|
204
|
+
address is the text on either side of the value: `before` is the literal that precedes it
|
|
205
|
+
and `after` the literal that follows. That is a declared address rather than an inferred
|
|
206
|
+
one -- the author says where the value sits, and no matcher searches the document for a
|
|
207
|
+
number that looks like the right one.
|
|
208
|
+
|
|
209
|
+
A capture-group pattern was the alternative and carries what §3.6 rejects elsewhere: a
|
|
210
|
+
string expression language needs a dialect, an escaping grammar, and a backtracking bound,
|
|
211
|
+
and every implementation would disagree about the edges. A braced template (`holds {n}
|
|
212
|
+
fixtures`) needs an escaping grammar for the brace, which LaTeX sources are full of. Two
|
|
213
|
+
literal anchors need neither.
|
|
214
|
+
|
|
215
|
+
The value is a run of non-whitespace characters following `before`: the whole run up to
|
|
216
|
+
the next whitespace where `after` begins past it or is empty, and otherwise the run cut at
|
|
217
|
+
the first place inside it where `after` begins. A cell printed against its divider,
|
|
218
|
+
`0.318|`, is addressed with `after: "|"`. A value containing a space is not addressable
|
|
219
|
+
this way, which bounds `cardinal_word` to numbers written as one word.
|
|
220
|
+
|
|
221
|
+
An anchor never splits a number: `after` may not begin, and `before` may not end, inside
|
|
222
|
+
a run of digits joined by a decimal point, a grouping comma or underscore, or an exponent.
|
|
223
|
+
`after: "8"` over `0.318` reads nothing, never `0.31`, and `before: "0.3"` never `18`.
|
|
224
|
+
Digits attached to a preceding letter, `v0.5` or `H10`, are a name and not a number. A
|
|
225
|
+
sign belongs to its number unless a digit or a letter precedes it, so `before: "x=-"`
|
|
226
|
+
reads nothing from `x=-0.42` and `before: "10-"` reads `12` from `10-12`.
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
kind: Literal["prose"] = "prose"
|
|
230
|
+
|
|
231
|
+
before: str = Field(min_length=1)
|
|
232
|
+
"""Literal text immediately preceding the value. Matched against the same normalized text
|
|
233
|
+
a quotation resolves against, so an anchor and a quote cannot disagree about the
|
|
234
|
+
document."""
|
|
235
|
+
|
|
236
|
+
after: str = ""
|
|
237
|
+
"""Literal text immediately following the value, with or without whitespace between.
|
|
238
|
+
Empty means the value runs to the next whitespace, so a character printed against the
|
|
239
|
+
number -- a table divider, a full stop, a percent sign -- is part of what is read unless
|
|
240
|
+
`after` names it."""
|
|
241
|
+
|
|
242
|
+
form: NumberForm = NumberForm.DECIMAL
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
ValueLocator = Annotated[
|
|
246
|
+
TreeLocator
|
|
247
|
+
| TableLocator
|
|
248
|
+
| TablePositionLocator
|
|
249
|
+
| ArrayLocator
|
|
250
|
+
| SheetLocator
|
|
251
|
+
| SqliteLocator
|
|
252
|
+
| ProseLocator,
|
|
253
|
+
Field(discriminator="kind"),
|
|
254
|
+
]
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
Verbatim = Annotated[str, StringConstraints(strip_whitespace=False)]
|
|
258
|
+
"""A string kept exactly as written: `citations.models.Verbatim`, defined here to the same
|
|
259
|
+
effect. A quotation's `prefix` is the text immediately before the passage, so the space it
|
|
260
|
+
ends on is what separates its last word from the passage's first. `citations` is imported
|
|
261
|
+
only where a quotation is checked, so that an installation without it reports a backend that
|
|
262
|
+
could not run, and importing the type from it here would make every manifest need it to
|
|
263
|
+
load."""
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class QuoteEvidence(_EvidenceBase):
|
|
267
|
+
"""Assertion: this passage occurs in this artifact."""
|
|
268
|
+
|
|
269
|
+
kind: Literal["quote"] = "quote"
|
|
270
|
+
|
|
271
|
+
text: str
|
|
272
|
+
|
|
273
|
+
prefix: Verbatim = ""
|
|
274
|
+
"""Text immediately preceding the passage in the artifact, used only to say which
|
|
275
|
+
occurrence is quoted where the passage occurs more than once.
|
|
276
|
+
|
|
277
|
+
With `suffix`, the W3C Web Annotation `TextQuoteSelector`, as `citations` records it. A
|
|
278
|
+
passage that occurs once needs neither and is checked without them. Handed to `citations`
|
|
279
|
+
unchanged, so the two are matched as a `citations` record's are: joined to the passage
|
|
280
|
+
and normalized as it is."""
|
|
281
|
+
|
|
282
|
+
suffix: Verbatim = ""
|
|
283
|
+
"""Text immediately following the passage in the artifact. See `prefix`."""
|
|
284
|
+
|
|
285
|
+
section: str | None = None
|
|
286
|
+
"""Where the source puts it, as the source names it. Recorded, never verified."""
|
|
287
|
+
|
|
288
|
+
page: int | None = None
|
|
289
|
+
"""Verified when present, for paginated sources."""
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class ComparisonMode(enum.StrEnum):
|
|
293
|
+
"""How a reported number is compared with a stored one."""
|
|
294
|
+
|
|
295
|
+
PRINTED_PRECISION = "printed_precision"
|
|
296
|
+
"""Compare at the precision the manuscript printed. `3.20` requires two decimals to
|
|
297
|
+
agree; `3.2` requires one. This is why `reported` is a string."""
|
|
298
|
+
|
|
299
|
+
ABSOLUTE = "absolute"
|
|
300
|
+
"""Agree when |stored - reported| <= tolerance."""
|
|
301
|
+
|
|
302
|
+
RELATIVE = "relative"
|
|
303
|
+
"""Agree when |stored - reported| <= tolerance * |reported|."""
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
class Conversion(enum.StrEnum):
|
|
307
|
+
"""A change of unit between the value a file stores and the value the manuscript prints.
|
|
308
|
+
|
|
309
|
+
A closed set of names, each a power of ten. There is no multiplier and no expression: an
|
|
310
|
+
assertion that could state its own arithmetic could make any stored value agree with any
|
|
311
|
+
printed one, and nothing would check the arithmetic."""
|
|
312
|
+
|
|
313
|
+
PERCENT = "percent"
|
|
314
|
+
"""The file stores a fraction and the manuscript prints a percentage: stored x 100."""
|
|
315
|
+
|
|
316
|
+
FRACTION = "fraction"
|
|
317
|
+
"""The file stores a percentage and the manuscript prints a fraction: stored / 100."""
|
|
318
|
+
|
|
319
|
+
def apply(self, stored: decimal.Decimal) -> decimal.Decimal:
|
|
320
|
+
"""The stored value in the unit the manuscript prints, exactly.
|
|
321
|
+
|
|
322
|
+
The decimal point moves and no digit changes, so nothing is rounded and no context
|
|
323
|
+
precision applies: 0.0424 as a percentage is 4.24, at any number of digits."""
|
|
324
|
+
sign, digits, exponent = stored.as_tuple()
|
|
325
|
+
if not isinstance(exponent, int):
|
|
326
|
+
raise ValueError(f"{stored} is not finite, so it has no unit to convert")
|
|
327
|
+
return decimal.Decimal((sign, digits, exponent + _PLACES[self]))
|
|
328
|
+
|
|
329
|
+
@property
|
|
330
|
+
def said(self) -> str:
|
|
331
|
+
return _SAID[self]
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
#: How many places each conversion moves the decimal point to the right.
|
|
335
|
+
_PLACES = {Conversion.PERCENT: 2, Conversion.FRACTION: -2}
|
|
336
|
+
|
|
337
|
+
_SAID = {Conversion.PERCENT: "as a percentage", Conversion.FRACTION: "as a fraction"}
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
class _Convertible(BaseModel):
|
|
341
|
+
"""A value that may be declared to sit in another unit than the one it is compared in."""
|
|
342
|
+
|
|
343
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
344
|
+
|
|
345
|
+
conversion: Conversion | None = None
|
|
346
|
+
"""Applied to the stored value before the comparison. None, the default, compares the
|
|
347
|
+
value as stored."""
|
|
348
|
+
|
|
349
|
+
@model_serializer(mode="wrap")
|
|
350
|
+
def _omit_no_conversion(self, handler):
|
|
351
|
+
"""Leave the field out where none is declared, so a manifest that declares no
|
|
352
|
+
conversion serializes, and hashes, as it did before the field existed."""
|
|
353
|
+
dumped = handler(self)
|
|
354
|
+
if self.conversion is None:
|
|
355
|
+
dumped.pop("conversion", None)
|
|
356
|
+
return dumped
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
class _Reported(_Convertible):
|
|
360
|
+
"""A value compared with one the manuscript prints, which may declare how many decimal
|
|
361
|
+
places the manuscript reports it to."""
|
|
362
|
+
|
|
363
|
+
places: int | None = Field(default=None, ge=0)
|
|
364
|
+
"""How many decimal places the manuscript reports this value to, as the author states it.
|
|
365
|
+
|
|
366
|
+
None, the default, takes the precision from `reported` as written. Declared, it is a
|
|
367
|
+
second statement that must agree with the first: `reported` must be written to exactly
|
|
368
|
+
this many places, so `"4.20"` under `places: 1` does not verify, whatever the file holds.
|
|
369
|
+
A table whose caption says one decimal and whose cell prints two is a finding about the
|
|
370
|
+
manuscript, and it is reported as one. Only under `printed_precision`: a tolerance rounds
|
|
371
|
+
nothing, so there are no places for it to be at."""
|
|
372
|
+
|
|
373
|
+
@model_serializer(mode="wrap")
|
|
374
|
+
def _omit_what_is_not_declared(self, handler):
|
|
375
|
+
"""Leave `conversion` and `places` out where neither is declared, so a manifest that
|
|
376
|
+
declares none serializes, and hashes, as it did before the fields existed."""
|
|
377
|
+
dumped = handler(self)
|
|
378
|
+
for field in ("conversion", "places"):
|
|
379
|
+
if getattr(self, field) is None:
|
|
380
|
+
dumped.pop(field, None)
|
|
381
|
+
return dumped
|
|
382
|
+
|
|
383
|
+
@model_validator(mode="after")
|
|
384
|
+
def _places_are_of_a_printed_precision(self):
|
|
385
|
+
mode = getattr(self, "mode", ComparisonMode.PRINTED_PRECISION)
|
|
386
|
+
if self.places is not None and mode is not ComparisonMode.PRINTED_PRECISION:
|
|
387
|
+
raise ValueError(
|
|
388
|
+
f"`places` is declared with mode {mode.value!r}: a tolerance rounds nothing, "
|
|
389
|
+
f"so `places` goes with `printed_precision` only"
|
|
390
|
+
)
|
|
391
|
+
return self
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
class MetricEvidence(_EvidenceBase, _Reported):
|
|
395
|
+
"""Assertion: this artifact holds this value at this location."""
|
|
396
|
+
|
|
397
|
+
kind: Literal["metric"] = "metric"
|
|
398
|
+
|
|
399
|
+
name: str
|
|
400
|
+
reported: str
|
|
401
|
+
"""The value exactly as the manuscript prints it, as a string.
|
|
402
|
+
|
|
403
|
+
A string because YAML parses `3.20` to the float `3.2`, discarding the precision the
|
|
404
|
+
manuscript chose, and because binary floats do not represent decimal fractions exactly.
|
|
405
|
+
Parsed with `decimal.Decimal`."""
|
|
406
|
+
|
|
407
|
+
pointer: str
|
|
408
|
+
"""RFC 6901 JSON Pointer: `/comparisons/primary/delta`.
|
|
409
|
+
|
|
410
|
+
JSON Pointer rather than a dotted path because a dotted path cannot distinguish a mapping
|
|
411
|
+
key containing a period from a nesting level, nor list index `0` from mapping key `"0"`,
|
|
412
|
+
and specifying an escape grammar for a new selector language is work already done."""
|
|
413
|
+
|
|
414
|
+
mode: ComparisonMode = ComparisonMode.PRINTED_PRECISION
|
|
415
|
+
tolerance: str = "0"
|
|
416
|
+
"""As a decimal string, for the same reason as `reported`. Ignored for
|
|
417
|
+
`printed_precision`."""
|
|
418
|
+
|
|
419
|
+
@property
|
|
420
|
+
def value(self) -> decimal.Decimal:
|
|
421
|
+
return decimal.Decimal(self.reported)
|
|
422
|
+
|
|
423
|
+
@property
|
|
424
|
+
def tolerance_value(self) -> decimal.Decimal:
|
|
425
|
+
return decimal.Decimal(self.tolerance)
|
|
426
|
+
|
|
427
|
+
@property
|
|
428
|
+
def locator(self) -> TreeLocator:
|
|
429
|
+
"""`metric` is sugar for a tree locator, and resolves through the same adapter."""
|
|
430
|
+
return TreeLocator(pointer=self.pointer)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
class TableCellEvidence(_EvidenceBase, _Reported):
|
|
434
|
+
"""Assertion: this table holds this value in this cell.
|
|
435
|
+
|
|
436
|
+
Most published result artifacts are delimited tables rather than JSON, so addressing a
|
|
437
|
+
cell is what a manuscript's own tables usually need. A cell is named by its column and by
|
|
438
|
+
one of two ways of picking the row.
|
|
439
|
+
"""
|
|
440
|
+
|
|
441
|
+
kind: Literal["table"] = "table"
|
|
442
|
+
|
|
443
|
+
name: str
|
|
444
|
+
reported: str
|
|
445
|
+
"""The value exactly as the manuscript prints it, as a string. See `MetricEvidence`."""
|
|
446
|
+
|
|
447
|
+
column: str
|
|
448
|
+
"""Header name of the column holding the value."""
|
|
449
|
+
|
|
450
|
+
row: int | None = None
|
|
451
|
+
"""Zero-based index among data rows, excluding the header."""
|
|
452
|
+
|
|
453
|
+
where: dict[str, str] = Field(default_factory=dict)
|
|
454
|
+
"""Select the row whose named columns hold these values: `{"model": "LASSO-Cox"}`.
|
|
455
|
+
|
|
456
|
+
Preferred over `row`, because a row index silently addresses a different cell when a table
|
|
457
|
+
is reordered or a row is inserted, and a table that has been reordered is exactly the case
|
|
458
|
+
a checker exists to notice. A selector matching no rows, or more than one, is reported
|
|
459
|
+
rather than resolved."""
|
|
460
|
+
|
|
461
|
+
delimiter: str = ""
|
|
462
|
+
"""Override the delimiter. Empty means infer it from the file's suffix and contents."""
|
|
463
|
+
|
|
464
|
+
mode: ComparisonMode = ComparisonMode.PRINTED_PRECISION
|
|
465
|
+
tolerance: str = "0"
|
|
466
|
+
|
|
467
|
+
@property
|
|
468
|
+
def value(self) -> decimal.Decimal:
|
|
469
|
+
return decimal.Decimal(self.reported)
|
|
470
|
+
|
|
471
|
+
@property
|
|
472
|
+
def tolerance_value(self) -> decimal.Decimal:
|
|
473
|
+
return decimal.Decimal(self.tolerance)
|
|
474
|
+
|
|
475
|
+
@property
|
|
476
|
+
def addresses_one_row(self) -> bool:
|
|
477
|
+
"""Exactly one row selector must be given."""
|
|
478
|
+
return (self.row is None) != (not self.where)
|
|
479
|
+
|
|
480
|
+
@property
|
|
481
|
+
def locator(self) -> TableLocator | TablePositionLocator:
|
|
482
|
+
"""`table` is sugar for one of the two table locators."""
|
|
483
|
+
if self.row is not None:
|
|
484
|
+
return TablePositionLocator(column=self.column, row=self.row, delimiter=self.delimiter)
|
|
485
|
+
return TableLocator(column=self.column, where=dict(self.where), delimiter=self.delimiter)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
class ValueEvidence(_EvidenceBase, _Reported):
|
|
489
|
+
"""Assertion: this artifact holds this value at this locator.
|
|
490
|
+
|
|
491
|
+
The general form. `metric` and `table` remain as shorthand for the two commonest cases,
|
|
492
|
+
and every kind resolves through the same adapters, so a manifest never has to choose
|
|
493
|
+
between a convenient spelling and a supported one.
|
|
494
|
+
"""
|
|
495
|
+
|
|
496
|
+
kind: Literal["value"] = "value"
|
|
497
|
+
|
|
498
|
+
name: str
|
|
499
|
+
reported: str
|
|
500
|
+
"""The value exactly as the manuscript prints it, as a string. See `MetricEvidence`."""
|
|
501
|
+
|
|
502
|
+
locator: ValueLocator
|
|
503
|
+
"""Which value, in the addressing its format already has. Kept separate from `reported`,
|
|
504
|
+
`mode` and `tolerance`: a locator identifies a stored value and says nothing about what
|
|
505
|
+
it should be."""
|
|
506
|
+
|
|
507
|
+
mode: ComparisonMode = ComparisonMode.PRINTED_PRECISION
|
|
508
|
+
tolerance: str = "0"
|
|
509
|
+
|
|
510
|
+
@property
|
|
511
|
+
def value(self) -> decimal.Decimal:
|
|
512
|
+
return decimal.Decimal(self.reported)
|
|
513
|
+
|
|
514
|
+
@property
|
|
515
|
+
def tolerance_value(self) -> decimal.Decimal:
|
|
516
|
+
return decimal.Decimal(self.tolerance)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
class CorrespondenceSide(_Convertible):
|
|
520
|
+
"""One of the two values a correspondence compares.
|
|
521
|
+
|
|
522
|
+
A side may declare a `conversion`, for a document that prints a percentage of a fraction
|
|
523
|
+
a file stores. The side is then compared as converted."""
|
|
524
|
+
|
|
525
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
526
|
+
|
|
527
|
+
name: str = Field(min_length=1)
|
|
528
|
+
"""What this side is, in the author's words -- `stated`, `measured`, `table`, `figure`.
|
|
529
|
+
Carried into the decision so a report can say which side held what. The engine attaches
|
|
530
|
+
no meaning to it and ranks neither side."""
|
|
531
|
+
|
|
532
|
+
artifact: str
|
|
533
|
+
locator: ValueLocator
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
class CorrespondenceEvidence(BaseModel):
|
|
537
|
+
"""Assertion: these two artifacts hold the same value.
|
|
538
|
+
|
|
539
|
+
Every other kind compares an artifact against a literal written in the manifest. A
|
|
540
|
+
documentation claim compares an artifact against an artifact -- a sentence saying the
|
|
541
|
+
suite holds eighteen fixtures, against a count of the fixtures -- and expressing that with
|
|
542
|
+
a literal requires transcribing one side into the manifest, where nothing checks the
|
|
543
|
+
transcription.
|
|
544
|
+
|
|
545
|
+
Neither side is privileged. When the two disagree the decision reports both values and
|
|
546
|
+
does not say which is wrong, because nothing in a byte comparison establishes that. The
|
|
547
|
+
same discipline governs the outcome vocabulary in §0.
|
|
548
|
+
"""
|
|
549
|
+
|
|
550
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
551
|
+
|
|
552
|
+
kind: Literal["correspondence"] = "correspondence"
|
|
553
|
+
|
|
554
|
+
name: str
|
|
555
|
+
sides: tuple[CorrespondenceSide, CorrespondenceSide]
|
|
556
|
+
|
|
557
|
+
mode: ComparisonMode = ComparisonMode.PRINTED_PRECISION
|
|
558
|
+
"""`relative` is rejected: its tolerance is a fraction of the reported value, and with no
|
|
559
|
+
privileged side there is no value to take the fraction of."""
|
|
560
|
+
|
|
561
|
+
tolerance: str = "0"
|
|
562
|
+
|
|
563
|
+
@property
|
|
564
|
+
def artifacts(self) -> tuple[str, ...]:
|
|
565
|
+
return tuple(side.artifact for side in self.sides)
|
|
566
|
+
|
|
567
|
+
@property
|
|
568
|
+
def tolerance_value(self) -> decimal.Decimal:
|
|
569
|
+
return decimal.Decimal(self.tolerance)
|
|
570
|
+
|
|
571
|
+
@model_validator(mode="after")
|
|
572
|
+
def _sides_are_distinct(self):
|
|
573
|
+
left, right = self.sides
|
|
574
|
+
if left.name == right.name:
|
|
575
|
+
raise ValueError(
|
|
576
|
+
f"correspondence {self.name!r}: both sides are named {left.name!r}, so a "
|
|
577
|
+
f"report cannot say which held what"
|
|
578
|
+
)
|
|
579
|
+
if (left.artifact, left.locator.canonical()) == (right.artifact, right.locator.canonical()):
|
|
580
|
+
raise ValueError(
|
|
581
|
+
f"correspondence {self.name!r}: both sides address the same value in "
|
|
582
|
+
f"{left.artifact!r}, which agrees with itself whatever it holds"
|
|
583
|
+
)
|
|
584
|
+
if self.mode is ComparisonMode.RELATIVE:
|
|
585
|
+
raise ValueError(
|
|
586
|
+
f"correspondence {self.name!r}: `relative` needs a value to take a fraction "
|
|
587
|
+
f"of, and neither side is the reference; use `absolute` or `printed_precision`"
|
|
588
|
+
)
|
|
589
|
+
return self
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
class Statistic(enum.StrEnum):
|
|
593
|
+
"""What an aggregate computes over its members. A closed set, each exact over decimals.
|
|
594
|
+
|
|
595
|
+
A standard deviation is not here. It is a square root, which no exact decimal holds, and
|
|
596
|
+
it is two quantities under one name until `sample` or `population` is said."""
|
|
597
|
+
|
|
598
|
+
MEAN = "mean"
|
|
599
|
+
MEDIAN = "median"
|
|
600
|
+
"""The middle value, or the mean of the two middle values of an even number."""
|
|
601
|
+
MIN = "min"
|
|
602
|
+
MAX = "max"
|
|
603
|
+
SUM = "sum"
|
|
604
|
+
COUNT = "count"
|
|
605
|
+
"""How many members hold a number. Every member must, so this is `count` where the
|
|
606
|
+
assertion is checked at all."""
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
class AggregateMember(BaseModel):
|
|
610
|
+
"""One of the values an aggregate is computed over."""
|
|
611
|
+
|
|
612
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
613
|
+
|
|
614
|
+
artifact: str
|
|
615
|
+
locator: ValueLocator
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
class AggregateEvidence(_Reported):
|
|
619
|
+
"""Assertion: the printed value is this statistic of these stored values.
|
|
620
|
+
|
|
621
|
+
Every other kind rests a number on one stored value. A sentence reporting a mean over ten
|
|
622
|
+
seeds bound to one run's file rests on that run, whichever it is, and the manifest reads
|
|
623
|
+
the same whether the run was the typical one or the best. Here the assertion reads every
|
|
624
|
+
member it declares, and is checked only where all of them hold a number.
|
|
625
|
+
|
|
626
|
+
A `conversion` is applied to each member's value before the statistic, and `places` is
|
|
627
|
+
what it is for a `metric`.
|
|
628
|
+
"""
|
|
629
|
+
|
|
630
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
631
|
+
|
|
632
|
+
kind: Literal["aggregate"] = "aggregate"
|
|
633
|
+
|
|
634
|
+
name: str
|
|
635
|
+
reported: str
|
|
636
|
+
"""The value exactly as the manuscript prints it, as a string. See `MetricEvidence`."""
|
|
637
|
+
|
|
638
|
+
statistic: Statistic
|
|
639
|
+
|
|
640
|
+
count: int = Field(ge=1)
|
|
641
|
+
"""How many members the manuscript says the statistic is over. Stated apart from the list
|
|
642
|
+
so that "ten seeds" is itself declared, and a member deleted from the list is a manifest
|
|
643
|
+
that no longer says ten rather than a mean over nine."""
|
|
644
|
+
|
|
645
|
+
members: tuple[AggregateMember, ...] = Field(min_length=1)
|
|
646
|
+
|
|
647
|
+
mode: ComparisonMode = ComparisonMode.PRINTED_PRECISION
|
|
648
|
+
tolerance: str = "0"
|
|
649
|
+
|
|
650
|
+
@property
|
|
651
|
+
def artifacts(self) -> tuple[str, ...]:
|
|
652
|
+
"""Each artifact a member names, once, in the order the members first name it."""
|
|
653
|
+
return tuple(dict.fromkeys(member.artifact for member in self.members))
|
|
654
|
+
|
|
655
|
+
@property
|
|
656
|
+
def value(self) -> decimal.Decimal:
|
|
657
|
+
return decimal.Decimal(self.reported)
|
|
658
|
+
|
|
659
|
+
@property
|
|
660
|
+
def tolerance_value(self) -> decimal.Decimal:
|
|
661
|
+
return decimal.Decimal(self.tolerance)
|
|
662
|
+
|
|
663
|
+
@model_validator(mode="after")
|
|
664
|
+
def _a_count_has_no_unit(self):
|
|
665
|
+
if self.statistic is Statistic.COUNT and self.conversion is not None:
|
|
666
|
+
raise ValueError(
|
|
667
|
+
f"aggregate {self.name!r}: `conversion` is declared with statistic 'count', "
|
|
668
|
+
f"and how many members there are is in no unit"
|
|
669
|
+
)
|
|
670
|
+
return self
|
|
671
|
+
|
|
672
|
+
@model_validator(mode="after")
|
|
673
|
+
def _members_are_the_declared_count_and_distinct(self):
|
|
674
|
+
if self.count != len(self.members):
|
|
675
|
+
raise ValueError(
|
|
676
|
+
f"aggregate {self.name!r}: `count` is {self.count} and {len(self.members)} "
|
|
677
|
+
f"members are listed"
|
|
678
|
+
)
|
|
679
|
+
seen: set[tuple[str, str]] = set()
|
|
680
|
+
for position, member in enumerate(self.members, start=1):
|
|
681
|
+
address = (member.artifact, member.locator.canonical())
|
|
682
|
+
if address in seen:
|
|
683
|
+
raise ValueError(
|
|
684
|
+
f"aggregate {self.name!r}: member {position} addresses a value in "
|
|
685
|
+
f"{member.artifact!r} that an earlier member addresses, so one value "
|
|
686
|
+
f"would be counted twice"
|
|
687
|
+
)
|
|
688
|
+
seen.add(address)
|
|
689
|
+
return self
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
Evidence = Annotated[
|
|
693
|
+
QuoteEvidence
|
|
694
|
+
| MetricEvidence
|
|
695
|
+
| TableCellEvidence
|
|
696
|
+
| ValueEvidence
|
|
697
|
+
| CorrespondenceEvidence
|
|
698
|
+
| AggregateEvidence,
|
|
699
|
+
Field(discriminator="kind"),
|
|
700
|
+
]
|
|
701
|
+
"""Every kind of evidence, discriminated on `kind`.
|
|
702
|
+
|
|
703
|
+
`protocol` was a third variant and is not one. Its integrity check is the artifact pin, which
|
|
704
|
+
every artifact already carries, and its content check -- that a registered document states a
|
|
705
|
+
given hypothesis -- is a quotation against the plan. What remains distinct about a protocol is
|
|
706
|
+
temporal: whether a confirmatory run postdates its registration. That needs a run record, is
|
|
707
|
+
unimplemented, and is not smuggled in as a third evidence kind in the meantime.
|
|
708
|
+
"""
|