Files
SimurgArray/scripts/docs/check_claims.py
T
kemal 783e2a0039
ci / test (3.13) (push) Successful in 3m59s
ci / test (3.12) (push) Successful in 4m1s
Claim checker for public texts (TypeSafe), measured manifest load times, corrected README timings
2026-10-01 13:05:37 +01:00

369 lines
15 KiB
Python

"""Check the numeric claims in public texts (README, blog posts, guide, drafts) against the validation reports.
Code finds the claims and the candidate evidence; a TypeSafe System One model (Jev) only judges whether a passage
supports, contradicts or says nothing about a claim (the "double checking citations" pattern,
https://docs.typesafe.ai/cookbooks/citation_check). Only this repository's own documentation text is sent; no data,
no sample sheets, no vendor files. Needs ``TYPESAFE_API_KEY`` in the environment, or in
``~/.config/typesafe/credentials`` (mode 600), for the judgment step.
python scripts/docs/check_claims.py README.md blog/*.md # check, write claims_report.md
python scripts/docs/check_claims.py --dry-run linkedin_draft.txt # extraction and retrieval only, no API calls
Verdicts: verified (a passage supports the claim, confidence >= threshold), contradicted, unsupported (passages found
but none addresses the claim), no_source (no passage contains any of the claim's numbers), review (low confidence).
Exit code 1 when any claim is contradicted or has no source, so the script can gate a publishing step.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import sys
import time
import urllib.error
import urllib.request
from dataclasses import dataclass, field
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
API_URL = "https://api.typesafe.ai/v1/systemone"
MODEL = "jev-latest"
AUTO_ACCEPT = 0.8 # cookbook default; evaluate on our own claims before trusting it
TOP_K = 3
DEFAULT_SOURCES = ["docs/validation", "docs/known_unknowns.md", "SIMURGARRAY_FUNCTIONAL_SPEC.md"]
CACHE = ROOT / ".cache" / "claims"
RELATION = {
"type": "choice",
"criteria": {
"supports": "The section states the claim's numbers and meaning, or directly implies that the claim is true",
"contradicts": "The section gives a different value for the same quantity, or implies the claim is false",
"says_nothing": "The section does not address what the claim asserts, either way",
},
}
# ------------------------------------------------------------------ numbers
_NUM = re.compile(r"(?<![\w.])(\d{1,3}(?:[ ,]\d{3})+|\d+(?:\.\d+)?)(\s?%)?")
def numbers(text: str) -> set[str]:
"""Normalised number tokens of a text, with percent/fraction twins: '99.34%' -> {'99.34', '0.9934'}."""
out: set[str] = set()
for m in _NUM.finditer(text):
raw, pct = m.group(1), m.group(2)
v = raw.replace(",", "").replace(" ", "")
try:
f = float(v)
except ValueError:
continue
out.add(_fmt(f))
if pct:
out.add(_fmt(f / 100))
elif 0 < f < 1:
out.add(_fmt(f * 100))
return out
def _fmt(f: float) -> str:
s = f"{f:.6f}".rstrip("0").rstrip(".")
return s or "0"
_MONTHS = "January|February|March|April|May|June|July|August|September|October|November|December"
_DATE = re.compile(
rf"\b({_MONTHS})\s+\d{{4}}\b|\b({_MONTHS})\s+\d{{1,2}}(,\s*\d{{4}})?|\b(19|20)\d{{2}}\b|\d{{4}}-\d{{2}}-\d{{2}}"
r"|\b(version|v)\s?\d+(\.\d+)+\b"
)
def significant(nums: set[str]) -> set[str]:
"""Numbers worth matching: drop tiny integers that appear everywhere (0 to 12)."""
return {n for n in nums if not (n.isdigit() and int(n) <= 12)}
def claim_numbers(text: str) -> set[str]:
"""Significant numbers of a claim; dates and years are context, not claims."""
return significant(numbers(_DATE.sub(" ", text)))
# ------------------------------------------------------------------ claims
@dataclass
class Claim:
path: str
line: int
text: str
context: str
nums: set[str]
def _strip_md(s: str) -> str:
s = re.sub(r"<[^>]+>", " ", s)
s = re.sub(r"!\[[^\]]*\]\([^)]*\)", " ", s)
s = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", s)
s = re.sub(r"https?://\S+", " ", s)
return re.sub(r"\s+", " ", s.replace("**", "").replace("`", "")).strip()
def extract_claims(path: Path, text: str) -> list[Claim]:
"""Sentences of paragraphs and bullets, and table rows, that carry significant numbers. Lines of one paragraph
or bullet are joined first, so a sentence wrapped over several lines stays whole. Code blocks and commands are
skipped."""
claims: list[Claim] = []
heading, table_head, in_code = "", "", False
buf: list[str] = []
start = 0
def add(unit: str, line: int) -> None:
nums = claim_numbers(unit)
if nums and not re.search(r"\b(simurg|uv|pip|bash|docker|conda) ", unit):
ctx = " / ".join(x for x in (heading, table_head) if x)
claims.append(Claim(str(path), line, unit, ctx, nums))
def flush() -> None:
if buf:
para = _strip_md(" ".join(buf))
for u in re.split(r"(?<=[.!?])\s+(?=[A-Z])", para):
if u:
add(u, start)
buf.clear()
for i, raw in enumerate([*text.splitlines(), ""], 1):
ln = raw.rstrip()
if ln.lstrip().startswith("```"):
flush()
in_code = not in_code
continue
if in_code:
continue
if not ln.strip():
flush()
table_head = ""
continue
if ln.startswith("#"):
flush()
heading = _strip_md(ln.lstrip("#"))
continue
if ln.lstrip().startswith("|"):
flush()
if re.fullmatch(r"\s*\|[\s:|-]+\|?\s*", ln):
continue
if not table_head:
table_head = _strip_md(ln)
continue
add(_strip_md(ln), i)
continue
bullet = re.match(r"\s*([*>-]|\d+\.)\s+", ln)
if bullet:
flush()
ln = ln[bullet.end() :]
if not buf:
start = i
buf.append(ln.strip())
return claims
# ------------------------------------------------------------------ evidence
@dataclass
class Passage:
path: str
line: int
text: str
nums: set[str] = field(default_factory=set)
def load_passages(sources: list[Path]) -> list[Passage]:
"""Paragraphs and table rows (with their header) of the source documents."""
out: list[Passage] = []
files = [p for s in sources for p in (sorted(s.rglob("*.md")) if s.is_dir() else [s])]
for f in files:
rel = str(f.relative_to(ROOT)) if f.is_relative_to(ROOT) else str(f)
heading, table_head = "", ""
buf: list[str] = []
start = 0
for i, ln in enumerate([*f.read_text(encoding="utf-8").splitlines(), ""], 1):
is_row = ln.lstrip().startswith("|")
if (ln.startswith("#") or is_row or not ln.strip()) and buf:
t = (heading + ": " if heading else "") + " ".join(buf)
out.append(Passage(rel, start, t, numbers(t)))
buf = []
if ln.startswith("#"):
heading, table_head = _strip_md(ln.lstrip("#")), ""
elif is_row:
if re.fullmatch(r"\s*\|[\s:|-]+\|?\s*", ln):
continue
if not table_head:
table_head = _strip_md(ln)
continue
t = f"{heading}: {table_head} || {_strip_md(ln)}"
out.append(Passage(rel, i, t, numbers(t)))
elif not ln.strip():
table_head = ""
else:
if not buf:
start = i
buf.append(_strip_md(ln))
return out
def _shared(a: set[str], b: set[str], tol: float = 0.03) -> int:
"""Numbers of ``a`` found in ``b`` exactly, or within 3 % for values of 10 and more (rounded figures such as
'about 550 MB' for 545 and 551 MB)."""
fb = [float(x) for x in b]
n = 0
for x in a:
if x in b:
n += 1
continue
v = float(x)
if v >= 10 and any(w >= 10 and abs(v - w) <= tol * max(v, w) for w in fb):
n += 1
return n
def candidates(claim: Claim, passages: list[Passage], k: int = TOP_K) -> list[Passage]:
"""Passages sharing the most of the claim's numbers (exactly or approximately), then the most words."""
words = set(re.findall(r"[a-z]{4,}", claim.text.lower()))
scored = []
for p in passages:
shared = _shared(claim.nums, p.nums)
if shared:
overlap = len(words & set(re.findall(r"[a-z]{4,}", p.text.lower())))
scored.append((shared, overlap, p))
scored.sort(key=lambda t: (-t[0], -t[1], t[2].path, t[2].line))
return [p for _, _, p in scored[:k]]
# ------------------------------------------------------------------ judgment
def build_request(claim: Claim, cands: list[Passage]) -> dict[str, object]:
state = {
"claim": claim.text,
"claim_context": claim.context,
"sections": [{"source": f"{p.path}:{p.line}", "text": p.text[:1500]} for p in cands],
}
ask = "How does `sections[{i}].text` relate to `claim` (read `claim` together with `claim_context`)?"
questions = {f"s{i}": {**RELATION, "instructions": ask.format(i=i)} for i in range(len(cands))}
return {"model": MODEL, "state": state, "questions": questions}
def call_api(payload: dict[str, object], key: str, retries: int = 5) -> dict[str, object]:
body = json.dumps(payload).encode()
digest = hashlib.sha256(body).hexdigest()
cached = CACHE / f"{digest}.json"
if cached.exists():
return json.loads(cached.read_text()) # type: ignore[no-any-return]
headers = {
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
"User-Agent": "simurgarray-claims/1",
}
req = urllib.request.Request(API_URL, data=body, method="POST", headers=headers)
for attempt in range(retries):
try:
with urllib.request.urlopen(req, timeout=60) as r:
res = json.loads(r.read())
CACHE.mkdir(parents=True, exist_ok=True)
cached.write_text(json.dumps(res))
return res # type: ignore[no-any-return]
except urllib.error.HTTPError as e:
if e.code in (429, 529) and attempt < retries - 1:
time.sleep(2**attempt)
continue
raise RuntimeError(f"TypeSafe API error {e.code}: {e.read()[:200]!r}") from e
raise RuntimeError("TypeSafe API: retries exhausted")
def verdict(answers: dict[str, dict[str, object]], threshold: float = AUTO_ACCEPT) -> tuple[str, str | None, float]:
"""Combine per-section answers: any confident 'supports' wins; else a confident 'contradicts'; else a confident
'says_nothing' from every section; anything else goes to a person."""
best: dict[str, tuple[float, str]] = {}
for qid, a in answers.items():
ch, conf = str(a["choice"]), float(a["confidence"]) # type: ignore[arg-type]
if conf >= threshold and conf > best.get(ch, (-1.0, ""))[0]:
best[ch] = (conf, qid)
if "supports" in best:
return "verified", best["supports"][1], best["supports"][0]
support = max((float(a["probabilities"].get("supports", 0.0)) for a in answers.values()), default=0.0) # type: ignore[attr-defined]
if "contradicts" in best:
if support >= 0.3: # another section leans towards support: a person decides
return "review", best["contradicts"][1], best["contradicts"][0]
return "contradicted", best["contradicts"][1], best["contradicts"][0]
confident = [float(a["confidence"]) >= threshold and a["choice"] == "says_nothing" for a in answers.values()] # type: ignore[arg-type]
if confident and all(confident):
return "unsupported", None, min(float(a["confidence"]) for a in answers.values()) # type: ignore[arg-type]
return "review", None, max((float(a["confidence"]) for a in answers.values()), default=0.0) # type: ignore[arg-type]
# ------------------------------------------------------------------ report
def _key_from_file(path: Path) -> str:
"""``TYPESAFE_API_KEY=...`` from a private local file (never from the repository)."""
if path.exists():
for ln in path.read_text().splitlines():
if ln.startswith("TYPESAFE_API_KEY="):
return ln.split("=", 1)[1].strip()
return ""
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
ap.add_argument("files", nargs="+", type=Path, help="texts to check (markdown or plain text)")
ap.add_argument("--sources", nargs="+", type=Path, default=[ROOT / s for s in DEFAULT_SOURCES])
ap.add_argument("--out", type=Path, default=Path("claims_report.md"))
ap.add_argument("--threshold", type=float, default=AUTO_ACCEPT)
ap.add_argument("--dry-run", action="store_true", help="extract claims and evidence, no API calls")
a = ap.parse_args(argv)
key = os.environ.get("TYPESAFE_API_KEY", "") or _key_from_file(Path.home() / ".config" / "typesafe" / "credentials")
if not a.dry_run and not key:
print("TYPESAFE_API_KEY is not set (use --dry-run to check extraction without the API).", file=sys.stderr)
return 2
passages = load_passages(a.sources)
rows: list[tuple[str, Claim, Passage | None, float, str]] = []
for f in a.files:
for c in extract_claims(f, f.read_text(encoding="utf-8")):
cands = candidates(c, passages)
if not cands:
rows.append(("no_source", c, None, 1.0, ""))
continue
if a.dry_run:
rows.append(("planned", c, cands[0], 0.0, f"{len(cands)} candidate(s)"))
continue
res = call_api(build_request(c, cands), key)
answers = res["answers"] # type: ignore[index]
v, qid, conf = verdict(answers, a.threshold) # type: ignore[arg-type]
src = cands[int(qid[1:])] if qid else cands[0]
probs = ", ".join(f"{q}: {answers[q]['choice']} {answers[q]['confidence']:.2f}" for q in answers) # type: ignore[index]
rows.append((v, c, src, conf, probs))
order = ["contradicted", "no_source", "review", "unsupported", "verified", "planned"]
counts = {v: sum(r[0] == v for r in rows) for v in order}
lines = ["# Claim check", "", " · ".join(f"{v} {n}" for v, n in counts.items() if n), ""]
for v in order:
sel = [r for r in rows if r[0] == v]
if not sel:
continue
lines += [f"## {v} ({len(sel)})", ""]
for _, c, src, conf, extra in sel:
lines.append(f"* `{c.path}:{c.line}` {c.text}")
if src:
lines.append(f" * evidence `{src.path}:{src.line}` (confidence {conf:.2f}) {src.text[:220]}")
if extra:
lines.append(f" * {extra}")
lines.append("")
a.out.write_text("\n".join(lines))
print(f"{len(rows)} claims: " + ", ".join(f"{v} {n}" for v, n in counts.items() if n) + f" → {a.out}")
return 1 if counts["contradicted"] or counts["no_source"] else 0
if __name__ == "__main__":
sys.exit(main())