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.** -![SFTGuard confirmatory dashboard](docs/assets/sftguard-confirmatory-dashboard.png) +![SFTGuard confirmatory evidence matrix showing every preregistered injected fault detected and no false positives across clean controls](docs/assets/sftguard-confirmatory-evidence.svg) + +*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. +![SFTGuard browser-local report viewer displaying the confirmatory matrix](docs/assets/sftguard-confirmatory-dashboard.png) + ```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 @@ + + +SFTGuard preregistered confirmatory fault matrix +A matrix of nine synthetic fault classes across 30 frozen seeds. 270 of 270 injected faults were detected, with 0 false positives across 30 clean controls. + + + +SFTGUARD / FROZEN EVIDENCE +The release gate was exercised, not assumed. +Synthetic fault templates × seeds 4100–4129; every dot below is one preregistered injected case. + +270/270 +required signals detected + +0/30 +clean-control false positives + +6/6 +frozen carry-forward gates passed +FAULT CLASS +DETECTED + +schema.invalid_role + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +30/30 + +schema.role_order + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +30/30 + +template.missing_eos + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +30/30 + +mask.prompt_supervised + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +30/30 + +truncation.answer_removed + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +30/30 + +data.exact_duplicate + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +30/30 + +data.prompt_conflict + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +30/30 + +data.train_eval_leakage + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +30/30 + +privacy.secret_pattern + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +30/30 + + +clean controls + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +0/30 FP +Internal detector regression • deterministic synthetic templates • not an external fine-tuning benchmark or safety certification + + 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'', + 'SFTGuard preregistered confirmatory fault matrix', + ( + 'A matrix of nine synthetic fault classes across ' + f"{escape(str(seeds['count']))} frozen seeds. {detected_total} of " + f"{fault_total} injected faults were detected, with " + f"{clean_false_positives} false positives across {clean_total} clean controls." + ), + '', + '', + '', + 'SFTGUARD / FROZEN EVIDENCE', + 'The release gate was exercised, not assumed.', + ( + f'Synthetic fault templates × seeds ' + f"{escape(str(seeds['first']))}–{escape(str(seeds['last']))}; " + "every dot below is one preregistered injected case." + ), + '', + f'{detected_total}/{fault_total}', + 'required signals detected', + '', + f'{clean_false_positives}/{clean_total}', + 'clean-control false positives', + '', + f'{passed_gates}/{len(gates)}', + 'frozen carry-forward gates passed', + 'FAULT CLASS', + 'DETECTED', + ] + + severity_colours = { + "critical": "#b7473f", + "error": "#bd7623", + "warning": "#8a6b2f", + } + for index, row in enumerate(faults): + y = row_start + index * row_step + total = int(row["case_count"]) + outcomes = [fault_outcomes.get((str(row["fault_id"]), seed)) for seed in seed_values] + if any(outcome is None for outcome in outcomes) or len(outcomes) != total: + raise ValueError(f"fault case rows do not match {row['fault_id']}") + detected = sum(outcome is True for outcome in outcomes) + if detected != int(row["detected_count"]): + raise ValueError(f"per-fault summary does not match {row['fault_id']} cases") + severity = str(row.get("severity", "unknown")) + colour = severity_colours.get(severity, "#66736e") + label = escape(str(row["fault_id"])) + lines.extend( + [ + f'', + f'{label}', + ] + ) + if total == 1: + positions = [dot_start] + else: + positions = [dot_start + i * dot_span / (total - 1) for i in range(total)] + for position, outcome in zip(positions, outcomes, strict=True): + fill = "#2c8b70" if outcome else "#d9d8d0" + lines.append(f'') + lines.append( + f'{detected}/{total}' + ) + + clean_y = row_start + len(faults) * row_step + 12 + lines.extend( + [ + f'', + f'', + f'clean controls', + ] + ) + if clean_total == 1: + clean_positions = [dot_start] + else: + clean_positions = [dot_start + i * dot_span / (clean_total - 1) for i in range(clean_total)] + if set(clean_outcomes) != set(seed_values): + raise ValueError("clean-control rows do not match the frozen seed range") + if sum(clean_outcomes.values()) != clean_false_positives: + raise ValueError("clean-control summary does not match its case rows") + for position, seed in zip(clean_positions, seed_values, strict=True): + if clean_outcomes[seed]: + lines.append(f'') + else: + lines.append( + f'' + ) + lines.extend( + [ + f'{clean_false_positives}/{clean_total} FP', + 'Internal detector regression • deterministic synthetic templates • not an external fine-tuning benchmark or safety certification', + "", + "", + "", + ] + ) + 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())