osaudit 0.1.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.
osaudit/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """osaudit: trace every claim of a scientific paper to the evidence that supports it."""
2
+
3
+ __version__ = "0.1.0"
osaudit/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Run `python -m osaudit`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ._cli import main
6
+
7
+ raise SystemExit(main())
osaudit/_cli.py ADDED
@@ -0,0 +1,114 @@
1
+ """The `osaudit` command line: silent when the paper is clean, one line per finding otherwise.
2
+
3
+ Exit status: 0 no finding, 1 findings, 2 the audit could not run (configuration, input).
4
+ `osaudit run` writes a results file with its provenance; it prints nothing on success.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import json
11
+ import sys
12
+ from collections.abc import Callable, Sequence
13
+ from pathlib import Path
14
+
15
+ from . import __version__, _numbers, _provenance, _quotes, _results_check
16
+ from ._config import Config, ConfigError, instruments, load
17
+ from ._findings import Finding
18
+ from ._git import GitError
19
+ from ._pdf import PdfError
20
+
21
+
22
+ def _results(cfg: Config, args: argparse.Namespace) -> list[Finding]:
23
+ return _results_check.check(cfg, inputs=getattr(args, "inputs", False),
24
+ rerun=getattr(args, "rerun", False),
25
+ timeout=getattr(args, "timeout", None))
26
+
27
+
28
+ CHECKS: dict[str, tuple[Callable[[Config, argparse.Namespace], list[Finding]], str]] = {
29
+ "numbers": (lambda cfg, _: _numbers.check(cfg),
30
+ "every number of the paper traced to results, derivation or allow"),
31
+ "quotes": (lambda cfg, _: _quotes.check(cfg),
32
+ "every cited verbatim quote in a verified [[quotes]] entry, with its page"),
33
+ "results": (_results,"every results file has a provenance that still holds "
34
+ "(static; --inputs, --rerun to go further)"),
35
+ }
36
+
37
+
38
+ def parser() -> argparse.ArgumentParser:
39
+ p = argparse.ArgumentParser(
40
+ prog="osaudit",
41
+ description="Audit a paper repository: report every claim not traced to its evidence. "
42
+ "Silent when clean; `file:line: rule: message` per finding. "
43
+ "Conventions: CONVENTIONS.md. Without a check name, runs every check "
44
+ "(static parts only).",
45
+ epilog="exit status: 0 clean, 1 findings, 2 the audit could not run",
46
+ )
47
+ p.add_argument("--version", action="version", version=f"osaudit {__version__}")
48
+ common = argparse.ArgumentParser(add_help=False)
49
+ common.add_argument("--root", type=Path, default=Path("."),
50
+ help="paper repository (default: current directory)")
51
+ common.add_argument("--config", type=Path, help="audit.toml (default: <root>/audit.toml)")
52
+ common.add_argument("--json", action="store_true",
53
+ help="print findings as a JSON array ([] when clean)")
54
+ sub = p.add_subparsers(dest="check", metavar="CHECK")
55
+ for name, (_, help_) in CHECKS.items():
56
+ sp = sub.add_parser(name, parents=[common], help=help_, description=help_)
57
+ if name == "results":
58
+ sp.add_argument("--inputs", action="store_true",
59
+ help="rehash every recorded input (needs the inputs)")
60
+ sp.add_argument("--rerun", action="store_true",
61
+ help="rerun every recorded command at its time anchor and compare")
62
+ sp.add_argument("--timeout", type=float, help="seconds per rerun")
63
+ sub.add_parser("all", parents=[common], help="run every check (the default)")
64
+ run = sub.add_parser(
65
+ "run", help="run an instrument, write its JSON results with their provenance",
66
+ description="Run CMD (its stdout must be a JSON object) with the time anchor in "
67
+ f"${_provenance.ENV_NOW}, and write the object to OUTPUT with a provenance "
68
+ "block: command, inputs and their sha256, git commit, time anchor. "
69
+ "Refuses to run on a tree with uncommitted changes.",
70
+ epilog="example: osaudit run --input data/answers.csv -o results/survey.json -- "
71
+ "python study/survey.py data/answers.csv")
72
+ run.add_argument("--root", type=Path, default=Path("."), help="repository of the instruments")
73
+ run.add_argument("--input", type=Path, action="append", default=[], dest="input_paths",
74
+ help="an input file or directory (repeat for each)")
75
+ run.add_argument("-o", "--output", type=Path, required=True, help="results file to write")
76
+ run.add_argument("cmd", nargs=argparse.REMAINDER, help="-- CMD [ARG ...]")
77
+ return p
78
+
79
+
80
+ def _run(args: argparse.Namespace) -> int:
81
+ cmd = args.cmd[1:] if args.cmd[:1] == ["--"] else args.cmd
82
+ if not cmd:
83
+ print("osaudit run: no command (put it after --)", file=sys.stderr)
84
+ return 2
85
+ root = args.root.resolve()
86
+ try:
87
+ output = args.output.resolve()
88
+ out_rel = str(output.relative_to(root)) if output.is_relative_to(root) else str(output)
89
+ _provenance.run(root, cmd, args.input_paths, args.output, instruments(root, out_rel))
90
+ except (_provenance.RunError, ConfigError, GitError, OSError) as e:
91
+ print(f"osaudit run: {e}", file=sys.stderr)
92
+ return 2
93
+ return 0
94
+
95
+
96
+ def main(argv: Sequence[str] | None = None) -> int:
97
+ argv = list(sys.argv[1:] if argv is None else argv)
98
+ if not (argv and argv[0] in (*CHECKS, "all", "run", "-h", "--help", "--version")):
99
+ argv = ["all", *argv]
100
+ args = parser().parse_args(argv)
101
+ if args.check == "run":
102
+ return _run(args)
103
+ names = list(CHECKS) if args.check == "all" else [args.check]
104
+ try:
105
+ cfg = load(args.root.resolve(), args.config.resolve() if args.config else None)
106
+ findings = [f for n in names for f in CHECKS[n][0](cfg, args)]
107
+ except (ConfigError, PdfError, GitError, OSError, ValueError) as e:
108
+ print(f"osaudit: {e}", file=sys.stderr)
109
+ return 2
110
+ if args.json:
111
+ print(json.dumps([f.as_json() for f in findings], indent=1))
112
+ else:
113
+ sys.stdout.write("".join(f"{f}\n" for f in findings))
114
+ return 1 if findings else 0
osaudit/_config.py ADDED
@@ -0,0 +1,283 @@
1
+ """`audit.toml`: where the paper, the results and the instruments are, and how they are audited.
2
+
3
+ [paper]
4
+ tex = ["paper/sections/*.tex"] # LaTeX sources, audited when present
5
+ pdf = "paper/main.pdf" # audited when no tex is declared
6
+
7
+ [results]
8
+ files = ["study/results/*.json"]
9
+ instruments = ["study/*.py"] # the code that produces the results (staleness, clock)
10
+
11
+ [[results.depends]] # optional: the instruments one results file depends on
12
+ file = "study/results/survey.json" # (default: every instrument)
13
+ instruments = ["study/survey.py", "study/_common.py"]
14
+
15
+ [[results.manual]] # a results file made by a person, not an instrument
16
+ file = "study/results/labels_check.json"
17
+ by = "A. Author"
18
+ date = "2026-10-17"
19
+ reason = "the author checked 20 labels of the classifier"
20
+
21
+ [[numbers.derive]]
22
+ value = "1,204"
23
+ expr = "survey.responses - survey.partial"
24
+ file = "paper/results.tex" # optional: only there
25
+
26
+ [[numbers.allow]]
27
+ value = "2,000,000"
28
+ reason = "population size published by the cited census"
29
+ cite = "census2020" # optional
30
+ file = "paper/method.tex" # optional
31
+
32
+ [[quotes]] # a quote as read in its source
33
+ key = "doe2001theory" # BibTeX key
34
+ text = "The full sentence, verbatim, of which the paper may quote a part."
35
+ version = "publisher" # publisher|author-pdf|preprint|reprint|abstract|secondary
36
+ url = "https://doi.org/10.1000/xyz"
37
+ page = "107" # omitted for an abstract
38
+ date = "2026-10-17" # read on
39
+ status = "verified" # verified|unverified
40
+ claim = "what the citation supports" # optional
41
+
42
+ `[results]` is optional: a paper without a study audits its quotes and references only.
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import re
48
+ import tomllib
49
+ from dataclasses import dataclass
50
+ from pathlib import Path
51
+ from typing import Any
52
+
53
+ from ._number import Num, parse
54
+
55
+
56
+ class ConfigError(Exception):
57
+ pass
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class Entry:
62
+ """A `[[numbers.derive]]` or `[[numbers.allow]]` entry; `line` is its header in audit.toml."""
63
+
64
+ kind: str
65
+ num: Num
66
+ line: int
67
+ file: str | None
68
+ expr: str | None = None
69
+ reason: str | None = None
70
+ cite: str | None = None
71
+
72
+ def covers(self, n: Num) -> bool:
73
+ return n.key == self.num.key and (self.file is None or self.file == n.file)
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class Manual:
78
+ """A `[[results.manual]]` entry: a results file made by a person."""
79
+
80
+ file: str
81
+ by: str
82
+ date: str
83
+ reason: str
84
+ line: int
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class Depends:
89
+ """A `[[results.depends]]` entry: the instruments one results file depends on."""
90
+
91
+ file: str
92
+ instruments: tuple[Path, ...]
93
+ line: int
94
+
95
+
96
+ VERSIONS = ("publisher", "author-pdf", "preprint", "reprint", "abstract", "secondary")
97
+ STATUSES = ("verified", "unverified")
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class Quote:
102
+ """A `[[quotes]]` entry: a quote as read in a source."""
103
+
104
+ key: str
105
+ text: str
106
+ version: str
107
+ url: str
108
+ page: str | None
109
+ date: str
110
+ status: str
111
+ claim: str | None
112
+ line: int
113
+
114
+
115
+ @dataclass(frozen=True)
116
+ class Config:
117
+ root: Path
118
+ path: Path
119
+ tex: tuple[Path, ...]
120
+ pdf: Path | None
121
+ results: tuple[Path, ...]
122
+ entries: tuple[Entry, ...]
123
+ instruments: tuple[Path, ...] = ()
124
+ manual: tuple[Manual, ...] = ()
125
+ depends: tuple[Depends, ...] = ()
126
+ quotes: tuple[Quote, ...] = ()
127
+
128
+ def rel(self, p: Path) -> str:
129
+ try:
130
+ return str(p.relative_to(self.root))
131
+ except ValueError:
132
+ return str(p)
133
+
134
+ def instruments_of(self, results_file: str) -> tuple[Path, ...]:
135
+ """The instruments `results_file` depends on: its `[[results.depends]]`, or all."""
136
+ for d in self.depends:
137
+ if d.file == results_file:
138
+ return d.instruments
139
+ return self.instruments
140
+
141
+
142
+ def _glob(root: Path, patterns: Any, what: str) -> tuple[Path, ...]:
143
+ if isinstance(patterns, str):
144
+ patterns = [patterns]
145
+ if not isinstance(patterns, list) or not all(isinstance(p, str) for p in patterns):
146
+ raise ConfigError(f"{what} must be a string or a list of strings")
147
+ out: list[Path] = []
148
+ for p in patterns:
149
+ hits = sorted(root.glob(p))
150
+ if not hits:
151
+ raise ConfigError(f"{what}: {p!r} matches no file")
152
+ out += hits
153
+ return tuple(out)
154
+
155
+
156
+ def _header_lines(text: str, table: str) -> list[int]:
157
+ pat = re.compile(rf"^[ \t]*\[\[[ \t]*{re.escape(table)}[ \t]*\]\]", re.MULTILINE)
158
+ return [text.count("\n", 0, m.start()) + 1 for m in pat.finditer(text)]
159
+
160
+
161
+ def _entries(text: str, numbers: dict[str, Any], kind: str) -> list[Entry]:
162
+ raw = numbers.get(kind, [])
163
+ if not isinstance(raw, list):
164
+ raise ConfigError(f"numbers.{kind} must be an array of tables ([[numbers.{kind}]])")
165
+ lines = _header_lines(text, f"numbers.{kind}")
166
+ out = []
167
+ for i, e in enumerate(raw):
168
+ line = lines[i] if i < len(lines) else 0
169
+ where = f"audit.toml:{line}: numbers.{kind}"
170
+ value = e.get("value")
171
+ num = parse(value) if isinstance(value, str) else None
172
+ if num is None:
173
+ raise ConfigError(f"{where}: value must be a string, the number as printed")
174
+ if kind == "derive" and not isinstance(e.get("expr"), str):
175
+ raise ConfigError(f"{where}: expr is mandatory")
176
+ if kind == "allow" and not str(e.get("reason", "")).strip():
177
+ raise ConfigError(f"{where}: reason is mandatory")
178
+ out.append(Entry(kind, num, line, e.get("file"), e.get("expr"), e.get("reason"),
179
+ e.get("cite")))
180
+ return out
181
+
182
+
183
+ def load(root: Path, path: Path | None = None) -> Config:
184
+ path = path or root / "audit.toml"
185
+ if not path.is_file():
186
+ raise ConfigError(f"{path}: no audit.toml (see CONVENTIONS.md)")
187
+ text = path.read_text()
188
+ try:
189
+ data = tomllib.loads(text)
190
+ except tomllib.TOMLDecodeError as e:
191
+ raise ConfigError(f"{path}: {e}") from e
192
+ paper = data.get("paper", {})
193
+ tex = _glob(root, paper["tex"], "paper.tex") if "tex" in paper else ()
194
+ pdf = None
195
+ if not tex:
196
+ if "pdf" not in paper:
197
+ raise ConfigError(f"{path}: [paper] declares neither tex nor pdf")
198
+ pdf = root / paper["pdf"]
199
+ if not pdf.is_file():
200
+ raise ConfigError(f"paper.pdf: {paper['pdf']!r} is not a file")
201
+ res = data.get("results", {})
202
+ results = _glob(root, res["files"], "results.files") if "files" in res else ()
203
+ numbers = data.get("numbers", {})
204
+ entries = _entries(text, numbers, "derive") + _entries(text, numbers, "allow")
205
+ instruments = res.get("instruments", [])
206
+ return Config(root, path, tex, pdf, results, tuple(entries),
207
+ _glob(root, instruments, "results.instruments") if instruments else (),
208
+ tuple(_manual(text, res.get("manual", []))),
209
+ tuple(_depends(root, text, res.get("depends", []))),
210
+ tuple(_quotes(text, data.get("quotes", []))))
211
+
212
+
213
+ def _quotes(text: str, raw: Any) -> list[Quote]:
214
+ if not isinstance(raw, list):
215
+ raise ConfigError("quotes must be an array of tables ([[quotes]])")
216
+ lines = _header_lines(text, "quotes")
217
+ out = []
218
+ for i, e in enumerate(raw):
219
+ line = lines[i] if i < len(lines) else 0
220
+ where = f"audit.toml:{line}: quotes"
221
+ missing = [k for k in ("key", "text", "version", "url", "date", "status")
222
+ if not str(e.get(k, "")).strip()]
223
+ if missing:
224
+ raise ConfigError(f"{where}: {', '.join(missing)} mandatory")
225
+ if e["version"] not in VERSIONS:
226
+ raise ConfigError(f"{where}: version must be one of {', '.join(VERSIONS)}")
227
+ if e["status"] not in STATUSES:
228
+ raise ConfigError(f"{where}: status must be one of {', '.join(STATUSES)}")
229
+ page = e.get("page")
230
+ out.append(Quote(e["key"], e["text"], e["version"], e["url"],
231
+ None if page is None else str(page), str(e["date"]), e["status"],
232
+ e.get("claim"), line))
233
+ return out
234
+
235
+
236
+ def instruments(root: Path, output: str) -> list[str] | None:
237
+ """The instruments `output` depends on, per `<root>/audit.toml`, relative to root; None if
238
+ undeclared.
239
+
240
+ For `osaudit run`, which may create the first results file, so `load` would refuse.
241
+ """
242
+ path = root / "audit.toml"
243
+ if not path.is_file():
244
+ return None
245
+ text = path.read_text()
246
+ try:
247
+ results = tomllib.loads(text).get("results", {})
248
+ except tomllib.TOMLDecodeError as e:
249
+ raise ConfigError(f"{path}: {e}") from e
250
+ for d in _depends(root, text, results.get("depends", [])):
251
+ if d.file == output:
252
+ return [str(p.relative_to(root)) for p in d.instruments]
253
+ if not results.get("instruments"):
254
+ return None
255
+ return [str(p.relative_to(root))
256
+ for p in _glob(root, results["instruments"], "results.instruments")]
257
+
258
+
259
+ def _depends(root: Path, text: str, raw: Any) -> list[Depends]:
260
+ if not isinstance(raw, list):
261
+ raise ConfigError("results.depends must be an array of tables ([[results.depends]])")
262
+ lines = _header_lines(text, "results.depends")
263
+ out = []
264
+ for i, e in enumerate(raw):
265
+ line = lines[i] if i < len(lines) else 0
266
+ if not isinstance(e.get("file"), str) or "instruments" not in e:
267
+ raise ConfigError(f"audit.toml:{line}: results.depends: file, instruments mandatory")
268
+ out.append(Depends(e["file"], _glob(root, e["instruments"], "results.depends"), line))
269
+ return out
270
+
271
+
272
+ def _manual(text: str, raw: Any) -> list[Manual]:
273
+ if not isinstance(raw, list):
274
+ raise ConfigError("results.manual must be an array of tables ([[results.manual]])")
275
+ lines = _header_lines(text, "results.manual")
276
+ out = []
277
+ for i, e in enumerate(raw):
278
+ line = lines[i] if i < len(lines) else 0
279
+ missing = [k for k in ("file", "by", "date", "reason") if not str(e.get(k, "")).strip()]
280
+ if missing:
281
+ raise ConfigError(f"audit.toml:{line}: results.manual: {', '.join(missing)} mandatory")
282
+ out.append(Manual(e["file"], e["by"], str(e["date"]), e["reason"], line))
283
+ return out
osaudit/_findings.py ADDED
@@ -0,0 +1,23 @@
1
+ """A finding: one line `file:line: rule: message`, or one JSON object."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, dataclass, field
6
+ from typing import Any
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class Finding:
11
+ file: str
12
+ line: int
13
+ rule: str
14
+ message: str
15
+ candidates: tuple[str, ...] = field(default=())
16
+
17
+ def __str__(self) -> str:
18
+ return f"{self.file}:{self.line}: {self.rule}: {self.message}"
19
+
20
+ def as_json(self) -> dict[str, Any]:
21
+ d = asdict(self)
22
+ d["candidates"] = list(self.candidates)
23
+ return d
osaudit/_git.py ADDED
@@ -0,0 +1,45 @@
1
+ """The few git queries the audit needs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+
9
+ class GitError(Exception):
10
+ pass
11
+
12
+
13
+ def _git(root: Path, *args: str) -> str:
14
+ try:
15
+ r = subprocess.run(["git", "-C", str(root), *args], capture_output=True, text=True)
16
+ except OSError as e:
17
+ raise GitError(f"git: {e}") from e
18
+ if r.returncode != 0:
19
+ raise GitError(f"git {' '.join(args)}: {r.stderr.strip()}")
20
+ return r.stdout
21
+
22
+
23
+ def head(root: Path) -> str:
24
+ return _git(root, "rev-parse", "HEAD").strip()
25
+
26
+
27
+ def dirty(root: Path) -> list[str]:
28
+ """Tracked files modified, staged or deleted, relative to `root` (untracked do not count)."""
29
+ return _git(root, "diff", "--name-only", "--relative", "HEAD").splitlines()
30
+
31
+
32
+ def has_commit(root: Path, commit: str) -> bool:
33
+ try:
34
+ _git(root, "cat-file", "-e", f"{commit}^{{commit}}")
35
+ except GitError:
36
+ return False
37
+ return True
38
+
39
+
40
+ def changed_since(root: Path, commit: str, paths: list[str]) -> list[str]:
41
+ """Of `paths`, those that differ between `commit` and the working tree."""
42
+ if not paths:
43
+ return []
44
+ out = _git(root, "diff", "--name-only", "--relative", commit, "--", *paths)
45
+ return out.splitlines()
osaudit/_number.py ADDED
@@ -0,0 +1,63 @@
1
+ """A number as printed in a paper, and when it matches a value."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from decimal import Decimal
8
+
9
+ # A number not glued to a word, a path or a command: `123d0d04`, `3.0cm`, `RQ1` and `\\2` are not
10
+ # numbers. The lookahead refuses a trailing `.x`/`,x`, so backtracking cannot cut `3.0cm` to `3`.
11
+ NUMBER = r"(?<![\w.\\/])(\d{1,3}(?:,\d{3})+|\d+)(\.\d+)?(?![.,]?\w)"
12
+ # A number that names a part of a document, not a quantity: "Section 4.3", "Table~1", "p. 107".
13
+ PART = re.compile(
14
+ r"\b(?:sections?|sec\.|tables?|tab\.|figures?|fig\.|definitions?|equations?|eq\.|appendix"
15
+ r"|chapters?|ch\.|pages?|pp?\.)(?:\s|~)*\d+(?:\.\d+)*(?:\s*[-–]\s*\d+)?",
16
+ re.IGNORECASE)
17
+ PRINTED = re.compile(r"\s*(\d{1,3}(?:,\d{3})+|\d+)(\.\d+)?\s*(\\?%)?\s*")
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Num:
22
+ """A printed number: `text` as in the paper, `value` without separators, d `decimals`."""
23
+
24
+ file: str
25
+ line: int
26
+ text: str
27
+ value: Decimal
28
+ decimals: int
29
+ percent: bool
30
+
31
+ @property
32
+ def key(self) -> tuple[Decimal, int, bool]:
33
+ """What makes two printed numbers the same number (`1,204` and `1204`)."""
34
+ return (self.value, self.decimals, self.percent)
35
+
36
+
37
+ def make(file: str, line: int, text: str, integer: str, frac: str | None, percent: bool) -> Num:
38
+ digits = integer.replace(",", "") + (frac or "")
39
+ return Num(file, line, text, Decimal(digits), len(frac) - 1 if frac else 0, percent)
40
+
41
+
42
+ def parse(text: str) -> Num | None:
43
+ """Parse a value written in `audit.toml` (`"1,204"`, `"80.3%"`, `"80.3\\%"`)."""
44
+ m = PRINTED.fullmatch(text)
45
+ if not m:
46
+ return None
47
+ return make("", 0, text.strip(), m.group(1), m.group(2), m.group(3) is not None)
48
+
49
+
50
+ def close(num: Num, v: Decimal) -> bool:
51
+ """`num` is `v` printed with its decimals: equal within half a unit of its last digit.
52
+
53
+ A plain integer matches only an integral value: `7` is not a rounding of 7.2. A percent
54
+ matches v as a share (100·v), or as a value already in percent when v is not a count (a
55
+ JSON integer): `57.0%` is not the count 57.
56
+ """
57
+ half = Decimal(5) * Decimal(10) ** -(num.decimals + 1)
58
+ if num.percent:
59
+ in_percent = v.as_tuple().exponent != 0 and abs(num.value - v) <= half
60
+ return abs(num.value - 100 * v) <= half or in_percent
61
+ if num.decimals == 0:
62
+ return v == v.to_integral_value() and v == num.value
63
+ return abs(num.value - v) <= half
osaudit/_numbers.py ADDED
@@ -0,0 +1,122 @@
1
+ """`osaudit numbers`: every number of the paper is a results value, a derivation, or allowed.
2
+
3
+ Rules:
4
+ numbers/untraced a printed number matches no results value, derivation or allow entry
5
+ numbers/derive-mismatch a derivation evaluates to another value than the one it declares
6
+ numbers/derive-unknown a derivation names a results value that does not exist
7
+ numbers/unused a derivation or allow entry matches no number of the paper
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import re
14
+ from collections.abc import Iterable, Iterator
15
+ from decimal import Decimal
16
+
17
+ from . import _pdf, _tex
18
+ from ._config import Config
19
+ from ._findings import Finding
20
+ from ._number import Num, close
21
+ from ._results import Results
22
+
23
+ MAX_CANDIDATES = 3
24
+
25
+
26
+ def paper_numbers(cfg: Config) -> Iterator[Num]:
27
+ for f in cfg.tex:
28
+ yield from _tex.numbers(cfg.rel(f), f.read_text())
29
+ if cfg.pdf is not None:
30
+ yield from _pdf.numbers(cfg.rel(cfg.pdf), _pdf.text_of(cfg.pdf))
31
+
32
+
33
+ def _printed(v: Decimal) -> str:
34
+ return format(v.normalize(), "f") if v == v.to_integral_value() else format(v, ".6g")
35
+
36
+
37
+ def _shown(n: Num, v: Decimal) -> str:
38
+ """`v` rounded as `n` is printed, for messages."""
39
+ x = 100 * v if n.percent else v
40
+ q = x.quantize(Decimal(10) ** -n.decimals)
41
+ return f"{q:,}" + ("%" if n.percent else "")
42
+
43
+
44
+ def candidates(n: Num, values: dict[str, Decimal]) -> list[str]:
45
+ """Up to MAX_CANDIDATES single-operation expressions over results values that print as `n`.
46
+
47
+ Operands are counts (JSON integers): a sum or difference of counts, or a share of two counts.
48
+ Expressions within one results file come first, then those whose operands are closest in
49
+ the results tree (`survey.by_age.young.complete / survey.total`), then the shortest.
50
+ """
51
+ named = sorted((k, v) for k, v in values.items()
52
+ if not k.endswith(" (key)") and v.as_tuple().exponent == 0)
53
+ by_value: dict[Decimal, list[str]] = {}
54
+ for k, v in named:
55
+ by_value.setdefault(v, []).append(k)
56
+ out: list[str] = []
57
+ if not n.percent and n.decimals == 0:
58
+ for a, va in named:
59
+ for b in by_value.get(va - n.value, []):
60
+ out.append(f"{a} - {b}")
61
+ for b in by_value.get(n.value - va, []):
62
+ if a < b:
63
+ out.append(f"{a} + {b}")
64
+ else:
65
+ for a, va in named:
66
+ for b, vb in named:
67
+ if a != b and vb != 0 and close(n, va / vb):
68
+ out.append(f"{a} / {b}")
69
+ def rank(e: str) -> tuple[int, int, int, str]:
70
+ a, b = (re.split(r"[.\[]", x) for x in e.split(" ", 2)[::2])
71
+ shared = len(os.path.commonprefix([a, b]))
72
+ return (a[0] != b[0], len(a) + len(b) - 2 * shared, len(e), e)
73
+
74
+ return sorted(out, key=rank)[:MAX_CANDIDATES]
75
+
76
+
77
+ def check(cfg: Config) -> list[Finding]:
78
+ results = Results.load(list(cfg.results))
79
+ values = list(results.values.values())
80
+ toml = cfg.rel(cfg.path)
81
+ findings: list[Finding] = []
82
+
83
+ broken: set[int] = set()
84
+ for i, e in enumerate(cfg.entries):
85
+ if e.kind != "derive":
86
+ continue
87
+ assert e.expr is not None
88
+ try:
89
+ v = results.evaluate(e.expr)
90
+ except ValueError as err:
91
+ findings.append(Finding(toml, e.line, "numbers/derive-unknown", str(err)))
92
+ broken.add(i)
93
+ continue
94
+ if not close(e.num, v):
95
+ findings.append(Finding(
96
+ toml, e.line, "numbers/derive-mismatch",
97
+ f"{e.num.text} declared, but {e.expr} = {_printed(v)}, printed {_shown(e.num, v)}"))
98
+ broken.add(i)
99
+
100
+ # A broken entry still covers its number: the defect is reported once, on the entry.
101
+ used: set[int] = set()
102
+ for n in paper_numbers(cfg):
103
+ hits = [i for i, e in enumerate(cfg.entries) if e.covers(n)]
104
+ used.update(hits)
105
+ if hits or any(close(n, v) for v in values):
106
+ continue
107
+ cands = candidates(n, results.values)
108
+ msg = f"{n.text} matches no results value, derivation or allow entry"
109
+ if cands:
110
+ msg += "; candidate expr: " + " | ".join(cands)
111
+ findings.append(Finding(n.file, n.line, "numbers/untraced", msg, tuple(cands)))
112
+
113
+ for i, e in enumerate(cfg.entries):
114
+ if i not in used and i not in broken:
115
+ where = f" in {e.file}" if e.file else ""
116
+ findings.append(Finding(toml, e.line, "numbers/unused",
117
+ f"{e.kind} {e.num.text} matches no number of the paper{where}"))
118
+ return sorted(findings, key=lambda f: (f.file, f.line, f.rule, f.message))
119
+
120
+
121
+ def render(findings: Iterable[Finding]) -> str:
122
+ return "".join(f"{f}\n" for f in findings)