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/manifest.py
ADDED
|
@@ -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]})
|