diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a14f0ea..cb39379 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -48,6 +48,8 @@ jobs:
- name: Verify checked-in evidence integrity
if: ${{ hashFiles('evidence/v0.1.0-confirmatory.json') != '' }}
run: python scripts/confirmatory_evidence.py verify --integrity-only
+ - name: Verify evidence-derived README graphic
+ run: python scripts/generate_readme_assets.py --check
- name: Replay evidence from the implementation commit
if: ${{ matrix.python-version == '3.11' && hashFiles('evidence/v0.1.0-confirmatory.json') != '' }}
run: python scripts/confirmatory_evidence.py verify
diff --git a/README.md b/README.md
index 8c513cd..fb2e546 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,12 @@
**Fail-closed preflight and regression CI for supervised fine-tuning.**
-
+
+
+*Generated from the sealed v0.1 artifact: 270/270 required signals across
+nine synthetic fault templates and 30 frozen seeds, with 0/30 clean-control
+false positives. This is internal detector regression evidence, not an external
+fine-tuning benchmark or safety certification.*
I built SFTGuard to catch a narrow set of expensive-to-discover pipeline
mistakes before an adapter is released: malformed chat records, conflicting or
@@ -155,6 +160,8 @@ The viewer is a static React application with no analytics, report endpoint,
external font, or server-side storage. An imported report stays in the current
browser tab.
+
+
```bash
cd web
npm ci
@@ -244,6 +251,7 @@ python -m pip install -e ".[dev]"
python -m pytest
python -m ruff check src tests/python scripts
python -m ruff format --check src tests/python scripts
+python scripts/generate_readme_assets.py --check
cd web
npm ci
diff --git a/docs/assets/sftguard-confirmatory-evidence.svg b/docs/assets/sftguard-confirmatory-evidence.svg
new file mode 100644
index 0000000..97ce655
--- /dev/null
+++ b/docs/assets/sftguard-confirmatory-evidence.svg
@@ -0,0 +1,355 @@
+
+
diff --git a/scripts/generate_readme_assets.py b/scripts/generate_readme_assets.py
new file mode 100644
index 0000000..b8d66ff
--- /dev/null
+++ b/scripts/generate_readme_assets.py
@@ -0,0 +1,234 @@
+"""Render README evidence graphics from the sealed confirmatory artifact.
+
+Run without arguments to update the SVG, or use ``--check`` in CI to fail
+when the checked-in image no longer matches the checked-in evidence.
+"""
+
+# SVG elements remain one-per-line so generated diffs stay readable.
+# ruff: noqa: E501
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import sys
+import xml.etree.ElementTree as ET
+from html import escape
+from pathlib import Path
+from typing import Any
+
+ROOT = Path(__file__).resolve().parents[1]
+EVIDENCE = ROOT / "evidence" / "v0.1.0-confirmatory.json"
+OUTPUT = ROOT / "docs" / "assets" / "sftguard-confirmatory-evidence.svg"
+
+
+def _load_evidence() -> dict[str, Any]:
+ data = json.loads(EVIDENCE.read_text(encoding="utf-8"))
+ if data.get("schema_version") != "sftguard.confirmatory-fault-matrix.v1":
+ raise ValueError("unsupported confirmatory evidence schema")
+
+ summary = data.get("summary")
+ faults = data.get("per_fault")
+ cases = data.get("cases")
+ gates = data.get("gates")
+ seed_range = data.get("protocol", {}).get("seed_range")
+ if not isinstance(summary, dict) or not isinstance(faults, list):
+ raise ValueError("confirmatory evidence is missing its summary or fault rows")
+ if (
+ not faults
+ or not isinstance(cases, list)
+ or not isinstance(gates, dict)
+ or not isinstance(seed_range, dict)
+ ):
+ raise ValueError("confirmatory evidence is missing graphable protocol data")
+
+ for fault in faults:
+ if not isinstance(fault, dict):
+ raise ValueError("invalid per-fault row")
+ total = fault.get("case_count")
+ detected = fault.get("detected_count")
+ if not isinstance(total, int) or not isinstance(detected, int):
+ raise ValueError("fault counts must be integers")
+ if total <= 0 or detected < 0 or detected > total:
+ raise ValueError("fault counts are outside their valid range")
+ return data
+
+
+def _svg(data: dict[str, Any]) -> str:
+ summary = data["summary"]
+ faults = data["per_fault"]
+ cases = data["cases"]
+ gates = data["gates"]
+ seeds = data["protocol"]["seed_range"]
+ seed_values = list(range(int(seeds["first"]), int(seeds["last"]) + 1))
+ if len(seed_values) != int(seeds["count"]):
+ raise ValueError("frozen seed count does not match its endpoints")
+
+ fault_outcomes: dict[tuple[str, int], bool] = {}
+ clean_outcomes: dict[int, bool] = {}
+ for case in cases:
+ if not isinstance(case, dict) or not isinstance(case.get("seed"), int):
+ raise ValueError("invalid confirmatory case row")
+ if case.get("kind") == "fault":
+ key = (str(case.get("fault_id")), int(case["seed"]))
+ if key in fault_outcomes:
+ raise ValueError(f"duplicate confirmatory fault case: {key}")
+ fault_outcomes[key] = case.get("primary_expectation_met") is True
+ elif case.get("kind") == "clean_control":
+ seed = int(case["seed"])
+ if seed in clean_outcomes:
+ raise ValueError(f"duplicate clean control for seed {seed}")
+ clean_outcomes[seed] = bool(case.get("observed_signal_ids"))
+ else:
+ raise ValueError("unknown confirmatory case kind")
+
+ detected_total = sum(row["detected_count"] for row in faults)
+ fault_total = sum(row["case_count"] for row in faults)
+ passed_gates = sum(value is True for value in gates.values())
+ clean_total = int(summary["clean_control_count"])
+ clean_false_positives = int(summary["clean_false_positive_count"])
+
+ width = 1200
+ height = 720
+ row_start = 318
+ row_step = 34
+ dot_start = 415
+ dot_span = 665
+
+ lines = [
+ '',
+ f'",
+ "",
+ ]
+ )
+ return "\n".join(lines)
+
+
+def _validate_svg(content: str) -> None:
+ try:
+ ET.fromstring(content)
+ except ET.ParseError as exc:
+ raise ValueError(f"generated invalid SVG: {exc}") from exc
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "--check",
+ action="store_true",
+ help="fail if the checked-in SVG differs from the evidence-derived output",
+ )
+ args = parser.parse_args()
+
+ content = _svg(_load_evidence())
+ _validate_svg(content)
+ if args.check:
+ if not OUTPUT.exists() or OUTPUT.read_text(encoding="utf-8") != content:
+ print(f"stale: {OUTPUT.relative_to(ROOT)}", file=sys.stderr)
+ return 1
+ print("SFTGuard README evidence asset is current and valid XML.")
+ return 0
+
+ OUTPUT.parent.mkdir(parents=True, exist_ok=True)
+ OUTPUT.write_text(content, encoding="utf-8", newline="\n")
+ digest = hashlib.sha256(content.encode("utf-8")).hexdigest()
+ print(f"wrote {OUTPUT.relative_to(ROOT)} sha256={digest}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())