Claim checker for public texts (TypeSafe), measured manifest load times, corrected README timings
ci / test (3.13) (push) Successful in 3m59s
ci / test (3.12) (push) Successful in 4m1s

This commit is contained in:
kemal committed 2026-10-01 13:05:37 +01:00
1 parent cab702aa24
commit 783e2a0039
9 files changed
+477 -4

No files matched your search

+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
python-version: ${{ matrix.python }} python-version: ${{ matrix.python }}
- name: Secret scan (no cloud keys or private keys in the repository) - name: Secret scan (no cloud keys or private keys in the repository)
run: | run: |
if git grep -nIE '(AKIA|ASIA)[A-Z0-9]{16}|aws_secret_access_key *=|BEGIN [A-Z ]*PRIVATE KEY'; then if git grep -nIE '(AKIA|ASIA)[A-Z0-9]{16}|aws_secret_access_key *=|BEGIN [A-Z ]*PRIVATE KEY|apikey_[0-9a-f]{20,}'; then
echo "::error::credential-like content found"; exit 1 echo "::error::credential-like content found"; exit 1
fi fi
- name: Install - name: Install
+2
View File
@@ -30,3 +30,5 @@ results/
credentials credentials
.git-credentials .git-credentials
.netrc .netrc
# local caches (claim checker and others)
.cache/
+5
View File
@@ -2,6 +2,11 @@
## Unreleased ## Unreleased
**Documentation checks.** `scripts/docs/check_claims.py` checks the numeric claims of public texts against the
validation reports (code retrieves evidence, a TypeSafe System One model judges support). Its first run corrected the
manifest loading times (Omni2.5 about 18 s on a Pi, now measured in `docs/validation/P9.md`) and a demo timing
sentence in the README.
**Packaging.** The distributions are now `simurgarray` and `simurgarray-core` (the name `simurg` is taken on **Packaging.** The distributions are now `simurgarray` and `simurgarray-core` (the name `simurg` is taken on
PyPI); the `simurg` command and the `simurg` / `simurg_core` import names are unchanged. The conda recipe no longer pins PyPI); the `simurg` command and the `simurg` / `simurg_core` import names are unchanged. The conda recipe no longer pins
the kernel package to the Python package's version (it required `simurg-core == 2.0.0` while the kernels are 1.1.0). the kernel package to the Python package's version (it required `simurg-core == 2.0.0` while the kernels are 1.1.0).
+17
View File
@@ -50,6 +50,23 @@ and 3.13. If your change touches file formats, calling, QC or outputs, also run
8. **Plain writing.** Documentation follows the voice in `branding/README.md`: short sentences, numbers instead of 8. **Plain writing.** Documentation follows the voice in `branding/README.md`: short sentences, numbers instead of
adjectives, and every claim next to its limitation. adjectives, and every claim next to its limitation.
## Checking numbers before publishing
Text that quotes results (README, user guide, blog posts, announcements) is checked against the validation reports
before it goes out:
```bash
python scripts/docs/check_claims.py README.md blog/*.md docs/user_guide.md my_draft.txt
```
Code finds every sentence or table row with a number and the passages in `docs/validation/`,
`docs/known_unknowns.md` and the spec that share it; a TypeSafe System One model only judges whether each passage
supports, contradicts or says nothing about the claim. `claims_report.md` lists contradicted claims, claims without
a source, low confidence cases for a person to read, and verified claims. Only this repository's documentation text
is sent to the API, never data. The key comes from `TYPESAFE_API_KEY` or `~/.config/typesafe/credentials` and never
enters the repository; a full check costs well under one cent. `--dry-run` shows the claims and evidence without
calling the API.
## Pull requests ## Pull requests
* One topic per pull request, with tests. * One topic per pull request, with tests.
+5 -2
View File
@@ -169,7 +169,7 @@ bash examples/gsa_demo.sh results/gsa_demo 6 # end to end on 6 samples; drop th
``` ```
The demo writes GTCs, a sample summary, an HTML QC report, per sample VCFs and a cohort BCF, PLINK files, bedGraph The demo writes GTCs, a sample summary, an HTML QC report, per sample VCFs and a cohort BCF, PLINK files, bedGraph
tracks and a final report to `results/gsa_demo/`. On a Raspberry Pi 5 all 36 samples take about ten minutes. tracks and a final report to `results/gsa_demo/`. On a Raspberry Pi 5 the whole demo for all 36 samples (genotyping, QC report, VCFs and exports) takes about ten minutes with two workers.
## Usage ## Usage
@@ -303,7 +303,7 @@ Every HTML report carries the software version and a checksum of the data it sho
* With the kernels a GSA sample takes about 1.1 s on one Raspberry Pi 5 core (3.4 s pure Python) and a server core * With the kernels a GSA sample takes about 1.1 s on one Raspberry Pi 5 core (3.4 s pure Python) and a server core
handles about 80 samples a minute (`docs/validation/P9.md`). handles about 80 samples a minute (`docs/validation/P9.md`).
* Loading a manifest is a fixed cost per command: about 5 s for GSA and 15 s for Omni2.5 on a Pi. * Loading a manifest is a fixed cost per command: about 4 s for GSA and 18 s for Omni2.5 on a Pi with the kernels.
* Cohort QC steps grow with samples × loci. On small machines use `--threads 2`, and `--no-cohort-qc` on very * Cohort QC steps grow with samples × loci. On small machines use `--threads 2`, and `--no-cohort-qc` on very
large `genotype` runs, then run the `qc` commands separately. large `genotype` runs, then run the `qc` commands separately.
* `simurg train` needs about 2 GB for 120 GSA samples and takes about 70 minutes on a Pi. * `simurg train` needs about 2 GB for 120 GSA samples and takes about 70 minutes on a Pi.
@@ -328,6 +328,9 @@ bash scripts/fetch_data.sh p6 && bash scripts/make_oracle_vcf.sh
uv run pytest tests/oracle # add -m "not slow" to skip the long ones uv run pytest tests/oracle # add -m "not slow" to skip the long ones
``` ```
Before publishing text with numbers, `python scripts/docs/check_claims.py FILES` checks every claim against the
validation reports (see `CONTRIBUTING.md`).
CI runs lint, type checks, a secret scan and the unit tests with and without the Rust kernels on Python 3.12 and CI runs lint, type checks, a secret scan and the unit tests with and without the Rust kernels on Python 3.12 and
3.13 for every push. `scripts/nightly.py` reruns the differential tests against the oracles. 3.13 for every push. `scripts/nightly.py` reruns the differential tests against the oracles.
+1 -1
View File
@@ -304,7 +304,7 @@ A nightly differential run (`scripts/nightly.py`) re-checks these against the or
QC steps; on an 8 GB machine keep `--threads` low (≈ 2–3 GB per worker for GSA/Omni-size arrays). QC steps; on an 8 GB machine keep `--threads` low (≈ 2–3 GB per worker for GSA/Omni-size arrays).
* Speed: with the compiled kernels a GSA sample takes ~1.1 s on a Raspberry Pi 5 core (pure Python ~3.4 s); see * Speed: with the compiled kernels a GSA sample takes ~1.1 s on a Raspberry Pi 5 core (pure Python ~3.4 s); see
`docs/validation/P9.md` for server numbers and thread scaling. Loading a manifest/cluster file is a fixed cost `docs/validation/P9.md` for server numbers and thread scaling. Loading a manifest/cluster file is a fixed cost
per run (GSA ~5 s, Omni2.5 ~15 s on the Pi). per run (GSA ~4 s, Omni2.5 ~18 s on the Pi with the kernels).
* Methylation arrays: control-probe QC, detection and β values only (no differential analysis). * Methylation arrays: control-probe QC, detection and β values only (no differential analysis).
## Troubleshooting ## Troubleshooting
+3
View File
@@ -80,3 +80,6 @@ Manifest loading: GSA 13 s → 3 s, Omni2.5 53 s → 13 s, GDA 40 s → 10 s (Pi
calling 0.24 s, normalize/polar 0.08 s, raw X/Y 0.06 s. A full Rust normalization fit is the next step if needed. calling 0.24 s, normalize/polar 0.08 s, raw X/Y 0.06 s. A full Rust normalization fit is the next step if needed.
* Pi thread scaling is poor (36 samples, 3 workers ≈ 1.7× one core): the Pi 5's memory bandwidth, not the code. * Pi thread scaling is poor (36 samples, 3 workers ≈ 1.7× one core): the Pi 5's memory bandwidth, not the code.
* CI (`.gitea/workflows/ci.yml`) builds the extension and runs the unit tests with and without it. * CI (`.gitea/workflows/ci.yml`) builds the extension and runs the unit tests with and without it.
* Manifest loading on the Raspberry Pi 5 with the kernels (2026-10-01, two runs each, Python imports included):
GSA v1 C2 BPM (618 540 loci) 4.1 to 4.4 s; Omni2.5 BPM (2 450 000 loci) 17.4 to 19.4 s, so about 4 s and 18 s.
Before the kernels the same step took 13 s and 53 s.
+368
View File
@@ -0,0 +1,368 @@
"""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())
+75
View File
@@ -0,0 +1,75 @@
"""The documentation claim checker (scripts/docs/check_claims.py): everything except the API call."""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
_spec = importlib.util.spec_from_file_location("check_claims", ROOT / "scripts" / "docs" / "check_claims.py")
assert _spec is not None and _spec.loader is not None
cc = importlib.util.module_from_spec(_spec)
sys.modules["check_claims"] = cc # dataclasses look their module up
_spec.loader.exec_module(cc)
def test_numbers_have_percent_and_fraction_twins() -> None:
assert {"99.34", "0.9934"} <= cc.numbers("concordance 99.34 %")
assert {"0.9934", "99.34"} <= cc.numbers("concordance 0.9934")
assert "2141" in cc.numbers("2,141 arrays") and "2917" in cc.numbers("2 917 sites")
def test_dates_versions_and_small_integers_are_not_claims() -> None:
assert cc.claim_numbers("Kemal Yaylali, September 2026") == set()
assert cc.claim_numbers("Week of January 11, 2027: version 2.1") == set()
assert cc.claim_numbers("3 of 5 posts") == set()
assert cc.claim_numbers("about 4 s and 18 s") == {"18"}
def test_wrapped_sentences_stay_whole_and_code_is_skipped() -> None:
text = (
"# Accuracy\n\nScores from a trained file are not\ncomparable with 99.46 % from vendor files.\n\n"
"```bash\nsimurg genotype --threads 4096\n```\n\n| Check | Result |\n|---|---|\n| GIAB | 0.9985 |\n"
)
claims = cc.extract_claims(Path("x.md"), text)
assert [c.line for c in claims] == [3, 12]
assert "not comparable with 99.46 %" in claims[0].text and claims[0].context == "Accuracy"
assert claims[1].context == "Accuracy / | Check | Result |" and "0.9985" in claims[1].nums
def test_candidates_match_rounded_figures(tmp_path: Path) -> None:
src = tmp_path / "r.md"
src.write_text(
"# Docker\n\n| Artifact | Size |\n|---|---|\n| image | 545 MB (x86_64), 551 MB (aarch64) |\n\nUnrelated 12345.\n"
)
passages = cc.load_passages([src])
claim = cc.Claim("README.md", 1, "The image is about 550 MB", "", cc.claim_numbers("The image is about 550 MB"))
found = cc.candidates(claim, passages)
assert len(found) == 1 and "545 MB" in found[0].text
def _ans(choice: str, conf: float, supports: float = 0.0) -> dict[str, object]:
probs = {"supports": supports, "contradicts": 0.0, "says_nothing": 0.0}
probs[choice] = max(probs[choice], conf)
return {"choice": choice, "confidence": conf, "probabilities": probs}
def test_verdicts() -> None:
assert cc.verdict({"s0": _ans("says_nothing", 0.9), "s1": _ans("supports", 0.95)})[0] == "verified"
assert cc.verdict({"s0": _ans("contradicts", 0.95)})[0] == "contradicted"
# a confident contradiction while another section leans towards support goes to a person
assert cc.verdict({"s0": _ans("contradicts", 0.95), "s1": _ans("says_nothing", 0.5, supports=0.4)})[0] == "review"
assert cc.verdict({"s0": _ans("says_nothing", 0.9), "s1": _ans("says_nothing", 0.85)})[0] == "unsupported"
assert cc.verdict({"s0": _ans("supports", 0.6)})[0] == "review"
def test_request_shape() -> None:
claim = cc.Claim("README.md", 1, "99.44 % concordance", "Validation", {"99.44"})
p = cc.Passage("docs/validation/V2_arraytrain.md", 7, "v2 gives 99.44 %", {"99.44"})
req = cc.build_request(claim, [p, p])
assert req["model"] == "jev-latest" and set(req["questions"]) == {"s0", "s1"} # type: ignore[arg-type]
q = req["questions"]["s1"] # type: ignore[index]
assert q["type"] == "choice" and "`sections[1].text`" in q["instructions"]
assert set(q["criteria"]) == {"supports", "contradicts", "says_nothing"}
assert req["state"]["sections"][0]["source"] == "docs/validation/V2_arraytrain.md:7" # type: ignore[index]