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,349 @@
1
+ """What a project declares: its artifacts, its runs, and how they were produced.
2
+
3
+ The `Manifest` model lives here; reading one off disk is `claims.manifest`."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import enum
8
+ import pathlib
9
+
10
+ from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, model_validator
11
+
12
+ from claims.core.artifacts import ArtifactRef, Digest
13
+ from claims.core.claims import Claim
14
+ from claims.core.evidence import Conversion
15
+ from claims.core.outcomes import RegistrationAuthority
16
+
17
+ SCHEMA_VERSION = "repro/1"
18
+
19
+ _SHA256_HEX = r"^[a-f0-9]{64}$"
20
+
21
+
22
+ # ---------------------------------------------------------------------------------- manifest
23
+
24
+
25
+ class Provenance(BaseModel):
26
+ """Where a manifest's artifacts came from.
27
+
28
+ Recorded, never verified. The pin is the artifact's digest: a commit identifies a tree,
29
+ and a tree does not establish that the file on disk today is the file that was in it.
30
+ A reader who wants these exact bytes uses the commit to fetch them and the digest to
31
+ confirm they arrived.
32
+ """
33
+
34
+ model_config = ConfigDict(frozen=True, extra="forbid")
35
+
36
+ repository: str = ""
37
+ """Remote URL, or a local path where there is no remote."""
38
+
39
+ commit: str = ""
40
+ """Full SHA of the revision the artifacts were read from."""
41
+
42
+ dirty: bool = False
43
+ """Whether the working tree had uncommitted changes when the manifest was written. A
44
+ manifest built from a dirty tree names a commit that does not contain what was read."""
45
+
46
+ generated_by: str = ""
47
+ """The script that wrote this manifest, so the figures can be regenerated."""
48
+
49
+
50
+ class RunOutput(BaseModel):
51
+ """An artifact a run produced, bound by digest rather than by name.
52
+
53
+ Naming the artifact alone is not a binding: a later file written to the same path can be
54
+ presented as the output of an earlier run, which is the manoeuvre the ordering check
55
+ exists to detect. A run that records no digest is reported `run_output_unlinked`.
56
+ """
57
+
58
+ model_config = ConfigDict(frozen=True, extra="forbid")
59
+
60
+ artifact: str
61
+ digest: Digest | None = None
62
+
63
+ @model_validator(mode="before")
64
+ @classmethod
65
+ def _accept_a_bare_id(cls, data):
66
+ """`outputs: [results]` parses, and is reported as unlinked rather than rejected."""
67
+ return {"artifact": data} if isinstance(data, str) else data
68
+
69
+
70
+ class RunRecord(BaseModel):
71
+ """A computation that produced artifacts, and the plan it was registered under."""
72
+
73
+ model_config = ConfigDict(frozen=True, extra="forbid")
74
+
75
+ id: str
76
+ registered_plan: str = ""
77
+ """The plan this run was registered under. Where it names a declared artifact, that
78
+ document is pinned, so it cannot be edited after the fact without breaking the pin."""
79
+
80
+ registration_authority: RegistrationAuthority = RegistrationAuthority.SELF_RECORDED
81
+ """Who attests to `registered_at`. Defaults to the weakest, which is what an unqualified
82
+ timestamp in a file actually is."""
83
+
84
+ registered_at: AwareDatetime | None = None
85
+ started_at: AwareDatetime | None = None
86
+ """Both must carry an offset. A naive timestamp compared against an aware one is either a
87
+ crash or a silently wrong ordering, and an ordering that is wrong by a timezone is the
88
+ failure this check exists to catch."""
89
+
90
+ outputs: tuple[RunOutput, ...] = ()
91
+
92
+ def output(self, artifact_id: str) -> RunOutput | None:
93
+ return next((o for o in self.outputs if o.artifact == artifact_id), None)
94
+
95
+
96
+ class Regeneration(enum.StrEnum):
97
+ """What happened when a declared command was run again.
98
+
99
+ The verdict is about the numbers the manuscript prints from the output, never about its
100
+ bytes: a re-run that names a different file for the same value has reproduced every number
101
+ and matched no digest. Whether the bytes matched is recorded beside the verdict.
102
+ """
103
+
104
+ REPRODUCED = "reproduced"
105
+ """Re-ran, and every number the manuscript prints from the output still holds."""
106
+ CHANGED = "changed"
107
+ """Re-ran, and at least one number the manuscript prints from the output is now different."""
108
+ UNCHECKED = "unchecked"
109
+ """Re-ran and wrote the output, but a number could not be read from it."""
110
+ FAILED = "failed"
111
+ """The command did not finish, or finished and wrote nothing."""
112
+ NOT_RERUN = "not_rerun"
113
+ """Never executed. The reason says why: skipped, or the inputs are not the ones declared."""
114
+
115
+
116
+ class RegenerationReason(enum.StrEnum):
117
+ OUTPUT_MATCHES = "output_matches"
118
+ """The output has the pinned bytes, after any volatile fields are removed."""
119
+ CLAIMS_HOLD = "claims_hold"
120
+ """The bytes differ and every assertion reading the output holds against the new file."""
121
+ CLAIM_CHANGED = "claim_changed"
122
+ CLAIM_UNREADABLE = "claim_unreadable"
123
+ """The bytes differ and an assertion could not be evaluated against the new file."""
124
+ NO_CLAIM_READS_OUTPUT = "no_claim_reads_output"
125
+ """The bytes differ and no assertion reads the output, so bytes are all there is to compare."""
126
+ SKIPPED = "skipped"
127
+ INPUT_UNPINNED = "input_unpinned"
128
+ INPUT_CHANGED = "input_changed"
129
+ INPUT_MISSING = "input_missing"
130
+ COMMAND_FAILED = "command_failed"
131
+ """The command exited non-zero. Where the inputs were copied into an empty directory,
132
+ this also catches a script that needs a file the manifest never declared."""
133
+ COMMAND_TIMED_OUT = "command_timed_out"
134
+ OUTPUT_NOT_PRODUCED = "output_not_produced"
135
+ OUTPUT_UNPINNED = "output_unpinned"
136
+ """The record names no expected digest for its output."""
137
+ OUTPUT_NOT_THE_ARTIFACT = "output_not_the_artifact"
138
+ """The record's expected output is not the artifact the claims were checked against, so
139
+ reproducing it would say nothing about the manuscript's numbers."""
140
+ OUTPUT_IS_ALSO_AN_INPUT = "output_is_also_an_input"
141
+ """The output was copied into the sandbox as one of its own inputs, so a command that does
142
+ nothing reproduces it."""
143
+ OUTPUT_OUTSIDE_SANDBOX = "output_outside_sandbox"
144
+ RUNNER_UNAVAILABLE = "runner_unavailable"
145
+
146
+
147
+ class RegenerationRecord(BaseModel):
148
+ """A command that produces an artifact from declared inputs.
149
+
150
+ The honest analogue of the ordering check for a measurement no plan could have
151
+ registered: not "did this follow its plan" but "does the pinned code, over the pinned
152
+ inputs, still produce this". Opt-in, because it executes a command.
153
+ """
154
+
155
+ model_config = ConfigDict(frozen=True, extra="forbid")
156
+
157
+ id: str
158
+ command: tuple[str, ...]
159
+ """argv, never a shell string. A shell string needs quoting rules, brings a shell's
160
+ expansion with it, and turns a manifest into something that can run anything."""
161
+
162
+ inputs: tuple[RunOutput, ...] = ()
163
+ """Every artifact the command reads. Only these are placed in the working directory, so
164
+ a command needing an undeclared file fails and says so."""
165
+
166
+ output: RunOutput
167
+ """The artifact the command writes, and the digest it is expected to have."""
168
+
169
+ volatile: tuple[str, ...] = ()
170
+ """JSON Pointers to fields removed before comparing. Literal byte identity is the right
171
+ test only after timestamps, paths and environment metadata are controlled; naming them
172
+ keeps the comparison honest instead of loosening it everywhere."""
173
+
174
+ timeout_seconds: float = 300.0
175
+
176
+ @model_validator(mode="after")
177
+ def _command_is_not_empty(self):
178
+ if not self.command:
179
+ raise ValueError(f"regeneration {self.id!r}: command is empty")
180
+ return self
181
+
182
+
183
+ class Reading(enum.StrEnum):
184
+ """One assertion, evaluated against the file a re-run wrote."""
185
+
186
+ HOLDS = "holds"
187
+ CHANGED = "changed"
188
+ """It held against the pinned file and does not hold against the new one."""
189
+ ALREADY_MISMATCHED = "already_mismatched"
190
+ """The new file holds what the pinned file holds, and the manuscript prints something else.
191
+ The re-run changed nothing here; `repro verify` reports the disagreement."""
192
+ UNREADABLE = "unreadable"
193
+
194
+
195
+ class ClaimReading(BaseModel):
196
+ """What one assertion found in the pinned output and in the re-run's output."""
197
+
198
+ model_config = ConfigDict(frozen=True, extra="forbid")
199
+
200
+ claim_id: str
201
+ name: str = ""
202
+ printed: str = ""
203
+ """The value as the manuscript prints it."""
204
+ pinned: str | None = None
205
+ """The value in the pinned file, as stored."""
206
+ fresh: str | None = None
207
+ """The value in the file the re-run wrote, as stored."""
208
+ difference: str | None = None
209
+ """`fresh - pinned`, where both are numbers."""
210
+ conversion: Conversion | None = None
211
+ """The conversion the assertion declares, or None. The manuscript prints the value under
212
+ it, so `pinned` and `fresh` are in another unit than `printed` where one is declared."""
213
+ pinned_converted: str | None = None
214
+ """`pinned` under `conversion`. None where no conversion is declared, or where the pinned
215
+ file holds no number at the address."""
216
+ fresh_converted: str | None = None
217
+ """`fresh` under `conversion`, which is the value compared with `printed`."""
218
+ reading: Reading
219
+ detail: str = ""
220
+
221
+
222
+ class InputReading(BaseModel):
223
+ """One declared input: the digest the record names and the digest found on disk."""
224
+
225
+ model_config = ConfigDict(frozen=True, extra="forbid")
226
+
227
+ artifact_id: str
228
+ path: str = ""
229
+ pinned: str | None = None
230
+ observed: str | None = None
231
+
232
+
233
+ class RegenerationState(BaseModel):
234
+ """What happened when a regeneration record was run again."""
235
+
236
+ model_config = ConfigDict(frozen=True, extra="forbid")
237
+
238
+ regeneration_id: str
239
+ artifact_id: str
240
+ state: Regeneration
241
+ reason: RegenerationReason
242
+ expected: str | None = None
243
+ actual: str | None = None
244
+ detail: str = ""
245
+
246
+ executed: bool = False
247
+ """Whether the command was started. False for a record that was skipped or refused."""
248
+ bytes_identical: bool | None = None
249
+ """Whether the output had the pinned bytes. None where no output was compared."""
250
+ claims: tuple[ClaimReading, ...] = ()
251
+ """Each assertion reading the output, where the bytes differed and the claims were read."""
252
+ rests_on: tuple[str, ...] = ()
253
+ """Records producing an input of this one that did not themselves reproduce. This record
254
+ ran over their pinned outputs, so its result is conditional on them."""
255
+
256
+ command: tuple[str, ...] = ()
257
+ exit_code: int | None = None
258
+ duration_seconds: float | None = None
259
+ stdout_digest: str | None = None
260
+ stderr_digest: str | None = None
261
+ inputs: tuple[InputReading, ...] = ()
262
+ undeclared_outputs: tuple[str, ...] = ()
263
+ """Files the command wrote that the record does not declare, relative to its directory."""
264
+
265
+
266
+ class SetAside(BaseModel):
267
+ """A passage whose numbers the author declares are not claims, and why.
268
+
269
+ `repro numbers` counts every number a text prints. A number in a passage listed here is
270
+ counted as set aside with a reason and printed with that reason, never dropped.
271
+ """
272
+
273
+ model_config = ConfigDict(frozen=True, extra="forbid")
274
+
275
+ artifact: str
276
+ """Id of the text artifact the passage is in."""
277
+
278
+ text: str = Field(min_length=1)
279
+ """The passage, matched as a quotation is. Every number inside each place it occurs is
280
+ set aside, unless an assertion binds it."""
281
+
282
+ reason: str = Field(min_length=1)
283
+ """Why no assertion is offered for these numbers. Required: an entry without one would be
284
+ a way to shrink the count of unbound numbers without saying anything."""
285
+
286
+
287
+ class Manifest(BaseModel):
288
+ """One project: its artifacts and its claims."""
289
+
290
+ model_config = ConfigDict(frozen=True, extra="forbid")
291
+
292
+ schema_version: str = SCHEMA_VERSION
293
+ project: str = ""
294
+ provenance: Provenance | None = None
295
+ artifacts: tuple[ArtifactRef, ...] = ()
296
+ claims: tuple[Claim, ...] = ()
297
+ runs: tuple[RunRecord, ...] = ()
298
+ regenerations: tuple[RegenerationRecord, ...] = ()
299
+ set_aside: tuple[SetAside, ...] = ()
300
+
301
+ path: pathlib.Path | None = Field(default=None, exclude=True)
302
+
303
+ @model_validator(mode="after")
304
+ def _ids_are_unique(self):
305
+ """Two artifacts with one id is not a manifest with a duplicate; it is a manifest that
306
+ says two different things about the same name.
307
+
308
+ The engine keys its state by id, so a duplicate silently kept the last declaration and
309
+ dropped the rest -- including, in the case that matters, a broken pin, which then
310
+ disappeared from the report entirely and left `strict` passing with no violations.
311
+ Claims are checked the same way for the same reason, and so are regenerations: a
312
+ re-run is recorded under its id, and two records sharing one cannot be told apart in
313
+ the record of re-runs.
314
+ """
315
+ for label, items in (
316
+ ("artifact", self.artifacts),
317
+ ("claim", self.claims),
318
+ ("regeneration", self.regenerations),
319
+ ):
320
+ seen: set[str] = set()
321
+ for item in items:
322
+ if item.id in seen:
323
+ raise ValueError(
324
+ f"duplicate {label} id {item.id!r}: one id must name one thing"
325
+ )
326
+ seen.add(item.id)
327
+ return self
328
+
329
+ @model_validator(mode="after")
330
+ def _one_regeneration_writes_an_artifact(self):
331
+ """Which command produced a file has one answer. With two records naming one output,
332
+ the last declared was taken as the producer and the other was never consulted."""
333
+ producers: dict[str, str] = {}
334
+ for record in self.regenerations:
335
+ other = producers.setdefault(record.output.artifact, record.id)
336
+ if other != record.id:
337
+ raise ValueError(
338
+ f"regenerations {other!r} and {record.id!r} both write artifact "
339
+ f"{record.output.artifact!r}: one artifact has one producer"
340
+ )
341
+ return self
342
+
343
+ def artifact(self, artifact_id: str) -> ArtifactRef | None:
344
+ return next((a for a in self.artifacts if a.id == artifact_id), None)
345
+
346
+ def resolve(self, artifact: ArtifactRef) -> pathlib.Path:
347
+ if artifact.path.is_absolute() or self.path is None:
348
+ return artifact.path
349
+ return (self.path.parent / artifact.path).resolve()
claims/core/numbers.py ADDED
@@ -0,0 +1,146 @@
1
+ """What an inventory of the numbers a text prints found."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import collections
6
+ import enum
7
+
8
+ from pydantic import BaseModel, ConfigDict
9
+
10
+ from claims.core.evidence import NumberForm
11
+
12
+ SCHEMA_VERSION = "repro/1"
13
+
14
+
15
+ class Standing(enum.StrEnum):
16
+ """Where one printed number stands against the manifest. Each number has exactly one."""
17
+
18
+ BOUND = "bound"
19
+ """A declared assertion accounts for this occurrence: it lies in the passage of a `quote`
20
+ whose claim reports its value from a file, or it is the value a prose address selects."""
21
+ SET_ASIDE_WITH_A_REASON = "set_aside_with_a_reason"
22
+ """It lies in a passage the manifest lists under `set_aside`, which carries the reason."""
23
+ SET_ASIDE_BY_RULE = "set_aside_by_rule"
24
+ """A named rule reads it as something other than a quantity: a year, a table's number."""
25
+ UNBOUND = "unbound"
26
+ """Printed, and nothing in the manifest accounts for it."""
27
+
28
+
29
+ class NumberRule(enum.StrEnum):
30
+ """Why a run of digits is set aside without the author saying so. Applied in this order,
31
+ and the first that holds is the one named."""
32
+
33
+ LINK = "link"
34
+ """Inside a URL."""
35
+ DATE = "date"
36
+ """One of the three parts of a date written `2026-10-13`."""
37
+ DOCUMENT = "document_identifier"
38
+ """The number a document is known by: what follows `rfc`, `iso`, `iec`, `ieee`, `ansi`,
39
+ `ecma`, `fips`, `pep`, `arxiv`, `doi`, `isbn`, `issn`, `pmid` or `pmcid`, as in `rfc 6901`
40
+ and `arxiv 2609.22111`, and the digits of a DOI."""
41
+ NAME = "name"
42
+ """Part of a name: a letter stands immediately before the digits, `v15zw` and `sha256sum`,
43
+ or a hyphen or underscore joins them to a part that holds a letter, `gpt-4` and `9750` in
44
+ `i7-9750h`, or the digits open a run of letters and digits that holds more digits later,
45
+ as a hexadecimal digest does. An em or en dash is punctuation and joins nothing."""
46
+ VERSION_OR_SECTION = "version_or_section"
47
+ """Digits joined by two or more full stops, `0.5.5` and `3.2.1`."""
48
+ CITATION = "citation"
49
+ """Whole numbers from 1 up, alone in square brackets: `[12]`, `[3, 7]`, `[4-6]`."""
50
+ LABEL = "label"
51
+ """The number of a section, figure, table, equation, appendix, chapter, page, algorithm,
52
+ listing, theorem, lemma, proposition, corollary, definition, footnote, step or line: it
53
+ follows that word, and a range written `2-4` after the word is two labels."""
54
+ LIST_MARKER = "list_marker"
55
+ """A whole number of one or two digits alone in parentheses, `(1)`: an item of a list or
56
+ the number of an equation."""
57
+ ORDINAL = "ordinal"
58
+ """Digits with `st`, `nd`, `rd` or `th` attached: `3rd`, `95th`."""
59
+ YEAR = "year"
60
+ """Four digits from 1800 to 2099 with no sign, separator, decimal point or percent sign."""
61
+ THE_WORD_ONE = "the_word_one"
62
+ """The word `one`, which is a pronoun more often than a count. Every other cardinal
63
+ written as one word, `eighteen` and `thirty-five`, is counted as a number."""
64
+
65
+
66
+ class PrintedNumber(BaseModel):
67
+ """One occurrence of a number in a text."""
68
+
69
+ model_config = ConfigDict(frozen=True, extra="forbid")
70
+
71
+ text: str
72
+ """As printed, after the normalization a quotation is matched under: `2,364`, `-0.42`,
73
+ `24.11%`, `eighteen`."""
74
+ value: str | None = None
75
+ """The decimal it is read as, without grouping commas or a percent sign. None for digits
76
+ no value is read from: a name, a link, a document's identifier, a version."""
77
+ form: NumberForm = NumberForm.DECIMAL
78
+ """Whether it is printed in digits or as a word."""
79
+ offset: int
80
+ """Where it starts in the normalized text."""
81
+ line: int | None = None
82
+ """The line of the extracted text it is on, from 1. None where the lines could not be
83
+ matched to the normalized text."""
84
+ context: str = ""
85
+ """The words on either side."""
86
+ standing: Standing
87
+ rule: NumberRule | None = None
88
+ claims: tuple[str, ...] = ()
89
+ """The claims whose assertions bind it."""
90
+ reason: str = ""
91
+ """The author's reason, where the manifest sets it aside."""
92
+ reported_elsewhere: bool = False
93
+ """Of an unbound number only: some assertion in the manifest reports this value, and
94
+ none accounts for this occurrence. Marked where the value has two digits or more, or a
95
+ decimal point."""
96
+ reported_elsewhere_one_digit: bool = False
97
+ """The same, for a value of one digit and no decimal point. Most manifests report some
98
+ `4`, so the match says little: it is counted and not marked."""
99
+
100
+
101
+ class TextInventory(BaseModel):
102
+ """Every number one text artifact prints, in the order printed."""
103
+
104
+ model_config = ConfigDict(frozen=True, extra="forbid")
105
+
106
+ artifact: str
107
+ path: str = ""
108
+ numbers: tuple[PrintedNumber, ...] = ()
109
+
110
+
111
+ class SetAsideCount(BaseModel):
112
+ """One `set_aside` entry of the manifest, and how many numbers it accounts for."""
113
+
114
+ model_config = ConfigDict(frozen=True, extra="forbid")
115
+
116
+ artifact: str
117
+ text: str
118
+ reason: str
119
+ numbers: int
120
+ """0 where the passage is not in the text, or holds no number that is not bound."""
121
+
122
+
123
+ class NumbersReport(BaseModel):
124
+ """What `repro numbers` found. Frozen, and carries no verdict."""
125
+
126
+ model_config = ConfigDict(frozen=True, extra="forbid")
127
+
128
+ schema_version: str = SCHEMA_VERSION
129
+ project: str = ""
130
+ manifest_digest: str = ""
131
+ texts: tuple[TextInventory, ...] = ()
132
+ set_aside: tuple[SetAsideCount, ...] = ()
133
+
134
+ @property
135
+ def numbers(self) -> tuple[PrintedNumber, ...]:
136
+ return tuple(number for text in self.texts for number in text.numbers)
137
+
138
+ @property
139
+ def counts(self) -> collections.Counter[Standing]:
140
+ return collections.Counter(number.standing for number in self.numbers)
141
+
142
+ @property
143
+ def rules(self) -> collections.Counter[NumberRule]:
144
+ """How many numbers each rule set aside, in the order the rules are applied."""
145
+ found = collections.Counter(number.rule for number in self.numbers if number.rule)
146
+ return collections.Counter({rule: found[rule] for rule in NumberRule if found[rule]})