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.
Files changed (51) hide show
  1. claims/__init__.py +132 -0
  2. claims/__main__.py +5 -0
  3. claims/adapters/__init__.py +6 -0
  4. claims/adapters/array.py +212 -0
  5. claims/adapters/base.py +75 -0
  6. claims/adapters/cells.py +94 -0
  7. claims/adapters/columnar.py +188 -0
  8. claims/adapters/prose.py +261 -0
  9. claims/adapters/reads.py +47 -0
  10. claims/adapters/rows.py +116 -0
  11. claims/adapters/sheet.py +149 -0
  12. claims/adapters/sqlite.py +73 -0
  13. claims/adapters/table.py +135 -0
  14. claims/adapters/tree.py +163 -0
  15. claims/checks.py +511 -0
  16. claims/cli.py +1103 -0
  17. claims/core/__init__.py +9 -0
  18. claims/core/artifacts.py +121 -0
  19. claims/core/checks.py +132 -0
  20. claims/core/claims.py +100 -0
  21. claims/core/evidence.py +708 -0
  22. claims/core/manifest.py +349 -0
  23. claims/core/numbers.py +146 -0
  24. claims/core/outcomes.py +410 -0
  25. claims/core/report.py +503 -0
  26. claims/corpus.py +344 -0
  27. claims/exceptions.py +106 -0
  28. claims/export/__init__.py +23 -0
  29. claims/export/rocrate.py +787 -0
  30. claims/export/sarif.py +401 -0
  31. claims/manifest.py +59 -0
  32. claims/models.py +222 -0
  33. claims/numbers.py +453 -0
  34. claims/origins.py +303 -0
  35. claims/policy.py +369 -0
  36. claims/provenance.py +42 -0
  37. claims/record.py +1099 -0
  38. claims/regression.py +171 -0
  39. claims/renderers/__init__.py +10 -0
  40. claims/renderers/sarif.py +294 -0
  41. claims/reruns.py +111 -0
  42. claims/resolve.py +116 -0
  43. claims/stale.py +333 -0
  44. claims/starter.py +432 -0
  45. claims/toolchain.py +120 -0
  46. claims/verify.py +1371 -0
  47. claims/why.py +398 -0
  48. claims_cli-0.6.0.dist-info/METADATA +139 -0
  49. claims_cli-0.6.0.dist-info/RECORD +51 -0
  50. claims_cli-0.6.0.dist-info/WHEEL +4 -0
  51. claims_cli-0.6.0.dist-info/entry_points.txt +2 -0
@@ -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
+ """