From b0f168ab4d6a959d4e28611e345a250c14ca2ea9 Mon Sep 17 00:00:00 2001
From: Muhammad Labeeb Aryan <93521146+Labeeb2339@users.noreply.github.com>
Date: Sun, 19 Jul 2026 20:32:45 +0800
Subject: [PATCH 1/4] Implement SFTGuard release candidate
---
.github/ISSUE_TEMPLATE/bug_report.yml | 67 +
.github/ISSUE_TEMPLATE/config.yml | 8 +
.github/ISSUE_TEMPLATE/feature_request.yml | 46 +
.github/dependabot.yml | 37 +
.github/pull_request_template.md | 24 +
.github/release.yml | 24 +
.github/workflows/ci.yml | 79 +
.github/workflows/codeql.yml | 34 +
.github/workflows/dependency-audit.yml | 58 +
.github/workflows/pages.yml | 55 +
.gitignore | 2 +
CHANGELOG.md | 18 +
CITATION.cff | 24 +
CODE_OF_CONDUCT.md | 50 +
CONTRIBUTING.md | 66 +
README.md | 210 ++-
SECURITY.md | 55 +
docs/ARCHITECTURE.md | 105 ++
docs/EVIDENCE.md | 65 +
docs/RELATED_WORK.md | 81 +
docs/ROADMAP.md | 33 +
docs/SHARING.md | 71 +
examples/adapter-eval.jsonl | 6 +
examples/base-eval.jsonl | 6 +
examples/clean-eval.jsonl | 1 +
examples/clean-train.jsonl | 2 +
examples/regression-contract.json | 17 +
pyproject.toml | 58 +
schemas/audit-report.schema.json | 48 +
schemas/confirmatory-fault-matrix.schema.json | 32 +
schemas/confirmatory-manifest.schema.json | 33 +
schemas/regression-card.schema.json | 47 +
schemas/regression-contract.schema.json | 32 +
.../regression-evaluation-record.schema.json | 14 +
schemas/sft-record.schema.json | 42 +
scripts/confirmatory_evidence.py | 296 ++++
src/sftguard/__init__.py | 8 +
src/sftguard/__main__.py | 3 +
src/sftguard/artifacts.py | 88 +
src/sftguard/audit.py | 396 +++++
src/sftguard/benchmark.py | 124 ++
src/sftguard/cli.py | 146 ++
src/sftguard/confirmatory.py | 231 +++
src/sftguard/demo.py | 77 +
src/sftguard/io.py | 153 ++
src/sftguard/models.py | 59 +
src/sftguard/privacy.py | 63 +
src/sftguard/regression.py | 387 +++++
src/sftguard/synthetic.py | 156 ++
src/sftguard/tokenizer.py | 94 ++
tests/python/test_artifacts_io.py | 70 +
tests/python/test_audit.py | 121 ++
tests/python/test_benchmark.py | 53 +
tests/python/test_cli.py | 119 ++
tests/python/test_confirmatory_contract.py | 57 +
tests/python/test_regression.py | 153 ++
web/README.md | 41 +
web/index.html | 17 +
web/package-lock.json | 1373 ++++++++++++++++
web/package.json | 26 +
web/src/App.test.tsx | 31 +
web/src/App.tsx | 715 +++++++++
web/src/data/sample-audit.json | 54 +
web/src/data/sample-fault.json | 65 +
web/src/data/sample-regression.json | 64 +
web/src/main.tsx | 13 +
web/src/reportSchema.test.ts | 330 ++++
web/src/reportSchema.ts | 1325 ++++++++++++++++
web/src/sampleReports.ts | 28 +
web/src/styles.css | 1411 +++++++++++++++++
web/src/vite-env.d.ts | 1 +
web/tsconfig.json | 21 +
web/vite.config.ts | 13 +
73 files changed, 9912 insertions(+), 20 deletions(-)
create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml
create mode 100644 .github/ISSUE_TEMPLATE/config.yml
create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml
create mode 100644 .github/dependabot.yml
create mode 100644 .github/pull_request_template.md
create mode 100644 .github/release.yml
create mode 100644 .github/workflows/ci.yml
create mode 100644 .github/workflows/codeql.yml
create mode 100644 .github/workflows/dependency-audit.yml
create mode 100644 .github/workflows/pages.yml
create mode 100644 CHANGELOG.md
create mode 100644 CITATION.cff
create mode 100644 CODE_OF_CONDUCT.md
create mode 100644 CONTRIBUTING.md
create mode 100644 SECURITY.md
create mode 100644 docs/ARCHITECTURE.md
create mode 100644 docs/EVIDENCE.md
create mode 100644 docs/RELATED_WORK.md
create mode 100644 docs/ROADMAP.md
create mode 100644 docs/SHARING.md
create mode 100644 examples/adapter-eval.jsonl
create mode 100644 examples/base-eval.jsonl
create mode 100644 examples/clean-eval.jsonl
create mode 100644 examples/clean-train.jsonl
create mode 100644 examples/regression-contract.json
create mode 100644 pyproject.toml
create mode 100644 schemas/audit-report.schema.json
create mode 100644 schemas/confirmatory-fault-matrix.schema.json
create mode 100644 schemas/confirmatory-manifest.schema.json
create mode 100644 schemas/regression-card.schema.json
create mode 100644 schemas/regression-contract.schema.json
create mode 100644 schemas/regression-evaluation-record.schema.json
create mode 100644 schemas/sft-record.schema.json
create mode 100644 scripts/confirmatory_evidence.py
create mode 100644 src/sftguard/__init__.py
create mode 100644 src/sftguard/__main__.py
create mode 100644 src/sftguard/artifacts.py
create mode 100644 src/sftguard/audit.py
create mode 100644 src/sftguard/benchmark.py
create mode 100644 src/sftguard/cli.py
create mode 100644 src/sftguard/confirmatory.py
create mode 100644 src/sftguard/demo.py
create mode 100644 src/sftguard/io.py
create mode 100644 src/sftguard/models.py
create mode 100644 src/sftguard/privacy.py
create mode 100644 src/sftguard/regression.py
create mode 100644 src/sftguard/synthetic.py
create mode 100644 src/sftguard/tokenizer.py
create mode 100644 tests/python/test_artifacts_io.py
create mode 100644 tests/python/test_audit.py
create mode 100644 tests/python/test_benchmark.py
create mode 100644 tests/python/test_cli.py
create mode 100644 tests/python/test_confirmatory_contract.py
create mode 100644 tests/python/test_regression.py
create mode 100644 web/README.md
create mode 100644 web/index.html
create mode 100644 web/package-lock.json
create mode 100644 web/package.json
create mode 100644 web/src/App.test.tsx
create mode 100644 web/src/App.tsx
create mode 100644 web/src/data/sample-audit.json
create mode 100644 web/src/data/sample-fault.json
create mode 100644 web/src/data/sample-regression.json
create mode 100644 web/src/main.tsx
create mode 100644 web/src/reportSchema.test.ts
create mode 100644 web/src/reportSchema.ts
create mode 100644 web/src/sampleReports.ts
create mode 100644 web/src/styles.css
create mode 100644 web/src/vite-env.d.ts
create mode 100644 web/tsconfig.json
create mode 100644 web/vite.config.ts
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
new file mode 100644
index 0000000..6156725
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -0,0 +1,67 @@
+name: Bug report
+description: Report a reproducible problem using synthetic or sanitized data.
+title: "[Bug]: "
+labels: [bug]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Do not attach private datasets, prompts, responses, credentials, model weights, or unsanitized Regression Cards. Use [private vulnerability reporting](https://github.com/Labeeb2339/sftguard/security/advisories/new) for a security or privacy failure.
+ - type: input
+ id: version
+ attributes:
+ label: SFTGuard version or commit
+ placeholder: 0.1.0 or a full Git commit
+ validations:
+ required: true
+ - type: dropdown
+ id: component
+ attributes:
+ label: Component
+ options:
+ - Python CLI or library
+ - Dataset audit
+ - Tokenizer or loss mask
+ - Regression Card or gate
+ - Static report viewer
+ - CI or packaging
+ - Documentation
+ validations:
+ required: true
+ - type: input
+ id: environment
+ attributes:
+ label: Sanitized environment
+ description: Operating system, Python or Node version, and relevant package versions. Remove usernames and private paths.
+ placeholder: Windows 11, Python 3.12.4
+ validations:
+ required: true
+ - type: textarea
+ id: steps
+ attributes:
+ label: Reproduction steps
+ description: Use the smallest synthetic fixture that demonstrates the problem.
+ validations:
+ required: true
+ - type: textarea
+ id: expected
+ attributes:
+ label: Expected behavior
+ validations:
+ required: true
+ - type: textarea
+ id: actual
+ attributes:
+ label: Actual behavior
+ description: Quote only sanitized output; do not paste a real record or secret candidate.
+ validations:
+ required: true
+ - type: checkboxes
+ id: privacy
+ attributes:
+ label: Privacy check
+ options:
+ - label: I used synthetic or sanitized data and removed credentials, raw records, local usernames, and private paths.
+ required: true
+ - label: This report is not a vulnerability or private-data exposure that should be submitted privately.
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..4ec9197
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,8 @@
+blank_issues_enabled: false
+contact_links:
+ - name: Private security report
+ url: https://github.com/Labeeb2339/sftguard/security/advisories/new
+ about: Report vulnerabilities, secret exposure, or private-data leakage without opening a public issue.
+ - name: Safe sharing guidance
+ url: https://github.com/Labeeb2339/sftguard/blob/main/docs/SHARING.md
+ about: Check what can be included safely in a public issue or reproduction.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
new file mode 100644
index 0000000..142f76e
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -0,0 +1,46 @@
+name: Feature request
+description: Propose a bounded addition to SFTGuard's correctness or regression contract.
+title: "[Feature]: "
+labels: [enhancement]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ SFTGuard does not train models, predict final fine-tuning performance, certify safety, or automatically publish reports. Explain how the proposal fits the local-first, fail-closed boundary.
+ - type: textarea
+ id: problem
+ attributes:
+ label: Problem and user evidence
+ description: Describe the observable pipeline failure or missing release evidence.
+ validations:
+ required: true
+ - type: textarea
+ id: proposal
+ attributes:
+ label: Proposed behavior
+ description: Include the expected PASS, FAIL, and ABSTAIN behavior.
+ validations:
+ required: true
+ - type: textarea
+ id: validation
+ attributes:
+ label: Validation plan
+ description: State the synthetic fixture, negative control, and measurable gate.
+ validations:
+ required: true
+ - type: textarea
+ id: privacy
+ attributes:
+ label: Privacy and sharing impact
+ description: Identify any new input, artifact field, network access, or disclosure risk.
+ validations:
+ required: true
+ - type: checkboxes
+ id: boundaries
+ attributes:
+ label: Boundary check
+ options:
+ - label: The proposal does not require posting private data or reports to an external service.
+ required: true
+ - label: Missing or untrustworthy evidence will not be converted into PASS.
+ required: true
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..8cb70df
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,37 @@
+version: 2
+updates:
+ - package-ecosystem: pip
+ directory: /
+ schedule:
+ interval: weekly
+ day: monday
+ time: "03:00"
+ timezone: Asia/Kuala_Lumpur
+ open-pull-requests-limit: 5
+ labels: [dependencies, python]
+
+ - package-ecosystem: npm
+ directory: /web
+ schedule:
+ interval: weekly
+ day: monday
+ time: "03:15"
+ timezone: Asia/Kuala_Lumpur
+ open-pull-requests-limit: 5
+ labels: [dependencies, web]
+ groups:
+ npm-minor-and-patch:
+ update-types: [minor, patch]
+
+ - package-ecosystem: github-actions
+ directory: /
+ schedule:
+ interval: weekly
+ day: monday
+ time: "03:30"
+ timezone: Asia/Kuala_Lumpur
+ open-pull-requests-limit: 5
+ labels: [dependencies, github-actions]
+ groups:
+ actions-minor-and-patch:
+ update-types: [minor, patch]
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..a4dc474
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,24 @@
+## Change
+
+Describe the user-visible behavior and why it belongs inside SFTGuard's bounded
+correctness or regression contract.
+
+## Evidence
+
+List the deterministic tests, synthetic fixtures, commands, and observed
+results. Do not attach private datasets or an unsanitized report.
+
+## Privacy and security
+
+Describe new inputs, outputs, paths, dependencies, or network behavior. State
+whether any artifact schema or safe-sharing guidance changed.
+
+## Checklist
+
+- [ ] Python tests pass on the supported versions, when applicable.
+- [ ] The Node 22 viewer checks and production build pass, when applicable.
+- [ ] New behavior has a deterministic synthetic test and negative control.
+- [ ] `PASS`, `FAIL`, and `ABSTAIN` remain distinct and fail closed.
+- [ ] Artifacts and fixtures contain no raw private records, credentials, or model weights.
+- [ ] No telemetry, automatic upload, or automatic posting was introduced.
+- [ ] Documentation and the frozen evidence boundary remain accurate.
diff --git a/.github/release.yml b/.github/release.yml
new file mode 100644
index 0000000..7f88699
--- /dev/null
+++ b/.github/release.yml
@@ -0,0 +1,24 @@
+changelog:
+ exclude:
+ labels:
+ - skip-changelog
+ categories:
+ - title: Security and privacy
+ labels:
+ - security
+ - privacy
+ - title: Detectors and regression gates
+ labels:
+ - detector
+ - regression
+ - title: Viewer and reports
+ labels:
+ - web
+ - reporting
+ - title: Documentation and maintenance
+ labels:
+ - documentation
+ - dependencies
+ - title: Other changes
+ labels:
+ - "*"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..a14f0ea
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,79 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ci-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ python:
+ name: Python ${{ matrix.python-version }} / ${{ matrix.os }}
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, windows-latest]
+ python-version: ["3.11", "3.12"]
+ steps:
+ - uses: actions/checkout@v7
+ with:
+ fetch-depth: 0
+ - uses: actions/setup-python@v6
+ with:
+ python-version: ${{ matrix.python-version }}
+ cache: pip
+ cache-dependency-path: pyproject.toml
+ - name: Install package and test tools
+ run: >-
+ python -m pip install --upgrade pip
+ && python -m pip install -e ".[dev]" build
+ - name: Check installed dependencies
+ run: python -m pip check
+ - name: Compile Python sources
+ run: python -m compileall -q src tests scripts
+ - name: Lint and format-check Python
+ run: >-
+ python -m ruff check src tests/python scripts
+ && python -m ruff format --check src tests/python scripts
+ - name: Run Python tests
+ run: python -m pytest
+ - name: Verify checked-in evidence integrity
+ if: ${{ hashFiles('evidence/v0.1.0-confirmatory.json') != '' }}
+ run: python scripts/confirmatory_evidence.py verify --integrity-only
+ - 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
+ - name: Smoke-test the CLI
+ run: sftguard --help
+ - name: Build source and wheel distributions
+ run: python -m build
+
+ web:
+ name: Node 22 / ${{ matrix.os }}
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, windows-latest]
+ defaults:
+ run:
+ working-directory: web
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-node@v7
+ with:
+ node-version: "22"
+ cache: npm
+ cache-dependency-path: web/package-lock.json
+ - name: Install locked dependencies
+ run: npm ci
+ - name: Type-check, test, and build the viewer
+ run: npm run check
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
new file mode 100644
index 0000000..59d2f37
--- /dev/null
+++ b/.github/workflows/codeql.yml
@@ -0,0 +1,34 @@
+name: CodeQL
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+ schedule:
+ - cron: "37 4 * * 4"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ security-events: write
+
+jobs:
+ analyze:
+ name: Analyze ${{ matrix.language }}
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ language: [python, javascript-typescript]
+ steps:
+ - uses: actions/checkout@v7
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v4
+ with:
+ languages: ${{ matrix.language }}
+ build-mode: none
+ - name: Analyze
+ uses: github/codeql-action/analyze@v4
+ with:
+ category: /language:${{ matrix.language }}
diff --git a/.github/workflows/dependency-audit.yml b/.github/workflows/dependency-audit.yml
new file mode 100644
index 0000000..5d23b43
--- /dev/null
+++ b/.github/workflows/dependency-audit.yml
@@ -0,0 +1,58 @@
+name: Dependency audit
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - pyproject.toml
+ - web/package.json
+ - web/package-lock.json
+ - .github/workflows/dependency-audit.yml
+ pull_request:
+ paths:
+ - pyproject.toml
+ - web/package.json
+ - web/package-lock.json
+ - .github/workflows/dependency-audit.yml
+ schedule:
+ - cron: "19 3 * * 1"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ python:
+ name: Python dependency audit
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-python@v6
+ with:
+ python-version: "3.12"
+ cache: pip
+ cache-dependency-path: pyproject.toml
+ - name: Install the project auditor
+ run: >-
+ python -m pip install --upgrade pip
+ && python -m pip install pip-audit
+ - name: Audit declared project dependencies
+ run: python -m pip_audit --strict .
+
+ web:
+ name: npm dependency audit
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: web
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-node@v7
+ with:
+ node-version: "22"
+ cache: npm
+ cache-dependency-path: web/package-lock.json
+ - name: Install locked dependencies
+ run: npm ci
+ - name: Audit production and development dependencies
+ run: npm audit --audit-level=high
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
new file mode 100644
index 0000000..12e72ec
--- /dev/null
+++ b/.github/workflows/pages.yml
@@ -0,0 +1,55 @@
+name: Deploy static report viewer
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - web/**
+ - .github/workflows/pages.yml
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: pages
+ cancel-in-progress: false
+
+jobs:
+ build:
+ name: Build Pages artifact
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - name: Read Pages configuration
+ id: pages
+ uses: actions/configure-pages@v6
+ - uses: actions/setup-node@v7
+ with:
+ node-version: "22"
+ cache: npm
+ cache-dependency-path: web/package-lock.json
+ - name: Install locked dependencies
+ working-directory: web
+ run: npm ci
+ - name: Build for the repository Pages base path
+ working-directory: web
+ run: npm run build -- --base "${{ steps.pages.outputs.base_path }}/"
+ - name: Upload static site only
+ uses: actions/upload-pages-artifact@v5
+ with:
+ path: web/dist
+
+ deploy:
+ name: Deploy to GitHub Pages
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ runs-on: ubuntu-latest
+ needs: build
+ steps:
+ - name: Deploy Pages artifact
+ id: deployment
+ uses: actions/deploy-pages@v5
diff --git a/.gitignore b/.gitignore
index 975f2d6..e2949c9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,7 @@ __pycache__/
*.py[cod]
*.egg-info/
.pytest_cache/
+.pytest-tmp-*/
.ruff_cache/
.coverage
htmlcov/
@@ -12,6 +13,7 @@ build/
web/node_modules/
web/dist/
web/.vite/
+*.tsbuildinfo
.env
.env.*
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..8e8fa2e
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,18 @@
+# Changelog
+
+All notable changes to SFTGuard are documented here. The project follows
+[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [Unreleased]
+
+### Added
+
+- Preregistered v0.1 product and fault-injection protocols.
+- Dependency-light `audit`, `gate`, `benchmark`, and `demo` CLI workflows.
+- Bounded JSON/JSONL parsing, redacted data findings, and exported mask checks.
+- Paired target/retention Regression Cards with deterministic seed-cluster
+ bootstrap intervals and a fixed work budget.
+- One-shot, fresh-process confirmatory evidence generation and replay tooling.
+- Static browser-local report viewer for native SFTGuard artifacts.
+- Cross-platform CI, CodeQL, dependency audits, Pages deployment, community
+ templates, citation metadata, and security documentation.
diff --git a/CITATION.cff b/CITATION.cff
new file mode 100644
index 0000000..4006410
--- /dev/null
+++ b/CITATION.cff
@@ -0,0 +1,24 @@
+cff-version: 1.2.0
+message: >-
+ If SFTGuard supports your work, cite the software and the exact released
+ version or commit used.
+title: SFTGuard
+type: software
+authors:
+ - given-names: Muhammad Labeeb Aryan
+ name-particle: bin
+ family-names: Mohd Lokman
+repository-code: https://github.com/Labeeb2339/sftguard
+url: https://github.com/Labeeb2339/sftguard
+license: Apache-2.0
+version: 0.1.0
+abstract: >-
+ SFTGuard is a local-first correctness and regression gate for supervised
+ fine-tuning pipelines. It audits bounded pipeline evidence and emits
+ reproducible PASS, FAIL, or ABSTAIN artifacts without claiming to train,
+ predict, or certify a model.
+keywords:
+ - supervised fine-tuning
+ - regression testing
+ - dataset auditing
+ - reproducibility
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000..23ef9ea
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,50 @@
+# Code of conduct
+
+## Our pledge
+
+We pledge to make participation in SFTGuard a harassment-free experience for
+everyone, regardless of age, body size, visible or invisible disability,
+ethnicity, sex characteristics, gender identity and expression, level of
+experience, education, socioeconomic status, nationality, personal appearance,
+race, caste, color, religion, or sexual identity and orientation.
+
+We will act and interact in ways that contribute to an open, welcoming,
+diverse, inclusive, and healthy community.
+
+## Expected behavior
+
+Examples of behavior that supports the community include:
+
+- demonstrating empathy and kindness;
+- respecting differing opinions and experiences;
+- giving and accepting constructive feedback;
+- taking responsibility, apologizing, and learning from mistakes;
+- focusing on what is best for the project and community.
+
+Unacceptable behavior includes harassment, sexualized language or attention,
+trolling or insulting comments, threats, sustained disruption, publishing
+someone else's private information, and other conduct that would reasonably be
+considered inappropriate in a professional setting.
+
+## Enforcement
+
+Maintainers may remove, edit, or reject contributions and communication that do
+not follow this code, and may temporarily or permanently restrict participation
+for harmful behavior. Enforcement decisions should be proportionate, private
+where practical, and focused on community safety.
+
+Do not place personal details in a public issue. Use private contact information
+published by the repository owner when available. If no private project contact
+is available, use [GitHub Support's abuse-reporting channel](https://support.github.com/contact/report-abuse)
+rather than publishing sensitive allegations.
+
+## Scope
+
+This code applies in project spaces and when an individual is officially
+representing the project in public spaces.
+
+## Attribution
+
+This policy is adapted from the
+[Contributor Covenant, version 2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct.html),
+available under the [CC BY 4.0 license](https://creativecommons.org/licenses/by/4.0/).
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..f6416b2
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,66 @@
+# Contributing to SFTGuard
+
+SFTGuard welcomes small, reviewable contributions that preserve its
+fail-closed and local-first boundaries. Before beginning a large change, open a
+feature request describing the intended evidence and failure behavior.
+
+## Privacy first
+
+Use synthetic fixtures in issues, pull requests, tests, and screenshots. Never
+submit private training data, prompts, responses, access tokens, model weights,
+or reports containing them. Redact local usernames and absolute paths. Security
+and privacy vulnerabilities belong in a private report under
+[SECURITY.md](SECURITY.md), not a public issue.
+
+SFTGuard does not automatically publish reports or post results to external
+services. Contributions must not introduce telemetry, remote report processing,
+or automatic uploads without an explicit design review and an opt-in boundary.
+
+## Development setup
+
+Python 3.11 or 3.12 and Node.js 22 are supported for development.
+
+```bash
+python -m venv .venv
+python -m pip install --upgrade pip
+python -m pip install -e ".[dev]"
+python -m pytest
+```
+
+Build and test the static viewer separately:
+
+```bash
+cd web
+npm ci
+npm run check
+```
+
+The repository CI is the authority for the complete supported-platform matrix.
+If a command changes, update the workflow and this file in the same pull
+request.
+
+## Change requirements
+
+- Keep input parsing bounded and avoid reading entire untrusted files when a
+ streaming implementation is possible.
+- Never construct shell commands from dataset values.
+- Preserve `PASS`, `FAIL`, and `ABSTAIN` as distinct outcomes. Missing evidence
+ must not become a pass.
+- Keep public artifacts free of raw prompt, response, and injected-secret text.
+- Add deterministic tests for every detector, gate, parser, and schema change.
+- Document new report fields and state whether they are safe to share.
+- Do not describe synthetic validation as a real-world safety guarantee.
+
+The confirmatory seed range in
+[docs/FAULT_INJECTION_PROTOCOL.md](docs/FAULT_INJECTION_PROTOCOL.md) is frozen.
+Do not tune rules or thresholds after inspecting confirmatory results. A failed
+gate should remain visible with `carryForward: false`.
+
+## Pull requests
+
+Create a focused branch, run the relevant Python and web checks, and complete
+the pull-request checklist. Explain the user-visible behavior, evidence, and
+privacy impact. Generated build directories, local artifacts, datasets, and
+model files must remain untracked.
+
+By participating, you agree to follow [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md).
diff --git a/README.md b/README.md
index 83e4b99..8bfecf2 100644
--- a/README.md
+++ b/README.md
@@ -1,30 +1,200 @@
# SFTGuard
-SFTGuard is a local-first correctness and regression gate for supervised
-fine-tuning pipelines.
+[](https://github.com/Labeeb2339/sftguard/actions/workflows/ci.yml)
+[](https://github.com/Labeeb2339/sftguard/actions/workflows/codeql.yml)
+[](LICENSE)
-The project is currently under preregistered development. The v0.1 protocol is
-public before the confirmatory fault matrix is executed. Implementation,
-checked-in evidence, and a browser report will follow on a review branch.
+**Fail-closed preflight and regression CI for supervised fine-tuning.**
-## Intended v0.1 boundary
+SFTGuard catches a narrow set of expensive-to-discover pipeline mistakes before
+an adapter is released: malformed chat records, conflicting or leaked examples,
+credential-shaped text, broken EOS handling, prompt tokens included in loss, and
+answer-destroying truncation. It then gates paired base-versus-adapter scores
+against explicit target and retention budgets.
-SFTGuard will:
+It runs locally, emits content-addressed JSON, and returns `PASS`, `FAIL`, or
+`ABSTAIN`. Missing evidence never becomes a silent pass.
-- inspect chat records, template output, and supervised-token masks;
-- detect deterministic data and pipeline faults such as leakage, duplicated or
- conflicting examples, missing EOS handling, and answer-destroying truncation;
-- compare base and adapter evaluation records against explicit target and
- retention budgets;
-- emit reproducible `PASS`, `FAIL`, or `ABSTAIN` artifacts without embedding
- dataset text.
+## Why this exists
-SFTGuard will **not** train a model, prove that a dataset is high quality,
-predict final fine-tuning performance, or guarantee that an adapter is safe.
+A training job can complete while optimizing the wrong tokens, leaking an
+evaluation prompt into training, or degrading a capability that was not the
+fine-tuning target. Training loss alone does not answer those questions.
+SFTGuard turns the checks that *can* be established deterministically into a
+small release contract:
-Read the frozen [product specification](docs/PRODUCT_SPEC.md) and
-[fault-injection protocol](docs/FAULT_INJECTION_PROTOCOL.md).
+```text
+local JSONL + exported token/mask evidence ──> audit ──> PASS / FAIL / ABSTAIN
+paired base + adapter evaluation records ───> gate ──> Regression Card
+ └──> CI exit code + viewer
+```
-## License
+## Try it in two minutes
-Apache-2.0.
+SFTGuard requires Python 3.11 or newer and has no runtime dependencies.
+
+```bash
+git clone https://github.com/Labeeb2339/sftguard.git
+cd sftguard
+python -m venv .venv
+
+# Windows PowerShell
+.venv\Scripts\Activate.ps1
+
+# macOS / Linux
+# source .venv/bin/activate
+
+python -m pip install -e .
+sftguard demo --output artifacts/local/demo.json
+```
+
+The demo is deterministic and uses synthetic records only. Exit codes are
+`0 = PASS`, `1 = FAIL`, and `2 = ABSTAIN`.
+
+Audit the checked-in safe fixtures:
+
+```bash
+sftguard audit \
+ --train examples/clean-train.jsonl \
+ --eval examples/clean-eval.jsonl \
+ --output artifacts/local/audit.json
+```
+
+Gate paired base and adapter evaluation evidence:
+
+```bash
+sftguard gate \
+ --base examples/base-eval.jsonl \
+ --adapter examples/adapter-eval.jsonl \
+ --contract examples/regression-contract.json \
+ --output artifacts/local/regression-card.json
+```
+
+## Exact mask evidence, not a heuristic
+
+The data checks accept ordinary `messages` records. Template, EOS, truncation,
+and loss-mask claims require an `_sftguard` annotation exported from the exact
+tokenization/collation path used for training:
+
+```json
+{
+ "messages": [
+ {"role": "user", "content": "Classify this."},
+ {"role": "assistant", "content": "class_a"}
+ ],
+ "_sftguard": {
+ "token_ids": [10, 20, 30, 2],
+ "token_roles": ["user", "user", "assistant", "assistant"],
+ "loss_mask": [false, false, true, true],
+ "eos_token_id": 2,
+ "original_assistant_tokens": 2,
+ "truncated": false
+ }
+}
+```
+
+v0.1 intentionally does **not** guess an assistant mask or claim direct
+integration with Transformers, TRL, Axolotl, or LLaMA-Factory. If trustworthy
+mask evidence is missing or malformed, the full audit returns `ABSTAIN`. The
+`MaskInspector` protocol in `src/sftguard/tokenizer.py` is the extension point
+for trainer-specific exporters.
+
+For compatible Hugging Face templates, a future adapter can use
+`apply_chat_template(..., tokenize=True, return_dict=True,
+return_assistant_tokens_mask=True)` only when the template marks assistant spans
+with `{% generation %}`. Until that adapter is implemented and tested, it is not
+part of SFTGuard's claims.
+
+## Regression Cards
+
+The gate pairs base and adapter measurements by seed, sample ID, group, and
+metric. A contract declares:
+
+- a minimum confidence-bound improvement for each `target` metric;
+- a maximum confidence-bound regression for each `retention` metric; and
+- the bootstrap draw count, confidence level, and deterministic seed.
+
+SFTGuard normalizes lower-is-better metrics, resamples seed clusters with a
+frozen xorshift32 stream, and applies the threshold to the lower confidence
+bound. Unpaired, duplicated, undeclared, non-finite, or incomplete evidence
+returns `ABSTAIN`.
+
+## Static report viewer
+
+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
+npm run dev
+```
+
+It accepts the native audit, Regression Card, development-benchmark, and frozen
+confirmatory schemas. The viewer treats a supplied digest as **declared but
+unverified** because it cannot promise byte-for-byte parity with Python's
+canonical JSON serializer. Use the Python verifier for an integrity attestation.
+
+## Preregistered evidence
+
+The v0.1 fault protocol was committed before confirmatory seeds were generated
+or inspected. It freezes nine single-fault templates, seeds `4100–4129`, six
+carry-forward gates, and the allowed claim. The implementation and one-shot
+evidence runner are committed and pushed before the confirmatory run.
+
+- [Frozen protocol](docs/FAULT_INJECTION_PROTOCOL.md)
+- [Product specification](docs/PRODUCT_SPEC.md)
+- [Evidence runner](scripts/confirmatory_evidence.py)
+- [Evidence reproduction guide](docs/EVIDENCE.md)
+- [Related work and boundaries](docs/RELATED_WORK.md)
+
+The matrix is an **internal detector regression**: each seed changes synthetic
+markers, while each fault class keeps the same structural injection template.
+It is not 30 independent real-world datasets, an external benchmark, a safety
+certification, or proof that SFTGuard catches unknown failures.
+
+Checked-in confirmatory evidence is added only after the runner verifies a clean
+and pushed implementation commit and reproduces the artifact in two fresh
+processes. The result is preserved even if a frozen gate fails.
+
+## Privacy and security boundary
+
+- Files are bounded and parsed as untrusted UTF-8 JSON/JSONL.
+- Findings contain signal categories and bounded locations, not dataset text.
+- Reports contain aggregate values and hashes; hashes are identifiers, **not
+ anonymization**.
+- The repository contains no model weights, adapters, real datasets, prompts,
+ responses, or credentials.
+- Do not publish a real report until you have followed the
+ [safe-sharing checklist](docs/SHARING.md).
+
+See [SECURITY.md](SECURITY.md) for private vulnerability reports.
+
+## What SFTGuard does not prove
+
+SFTGuard does not train a model, score dataset quality, predict fine-tuning
+success, detect every secret, replace a real evaluation harness, or establish
+that an adapter is generally safe. A Regression Card is valid only for the
+records and thresholds supplied to it.
+
+## Develop
+
+```bash
+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
+
+cd web
+npm ci
+npm run check
+```
+
+Contributions that add a detector should include a minimal fault fixture, a
+clean control, redaction tests, and a precise claim boundary. Start with
+[CONTRIBUTING.md](CONTRIBUTING.md) and the [roadmap](docs/ROADMAP.md).
+
+## License and citation
+
+Apache-2.0. Citation metadata is available in [CITATION.cff](CITATION.cff).
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..2b6b663
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,55 @@
+# Security policy
+
+## Supported versions
+
+Security fixes are applied to the latest released version and the `main` branch.
+Pre-release branches may change without a compatibility guarantee.
+
+## Report a vulnerability privately
+
+Do not open a public issue for a vulnerability, exposed credential, private
+dataset, or personally identifying record. Use GitHub's
+[private vulnerability reporting](https://github.com/Labeeb2339/sftguard/security/advisories/new)
+for this repository. Include only the minimum synthetic information needed to
+reproduce the problem.
+
+If a credential has already been exposed, revoke or rotate it with its provider
+before changing local files. Do not paste the credential into a report, commit,
+chat, screenshot, or Regression Card. Rewriting Git history is a separate,
+potentially disruptive action and should be planned only after rotation and
+current-file redaction.
+
+Please allow the maintainer a reasonable period to reproduce and address a
+report before public disclosure. There is no guaranteed response or remediation
+time. Good-faith reports that avoid privacy violations and service disruption
+are welcome.
+
+## Local-data privacy boundary
+
+SFTGuard is designed for local analysis. The command-line tool must not upload
+datasets, prompts, responses, model weights, or generated reports. Its public
+static viewer has no report-upload endpoint, analytics, or server-side report
+processing; a report selected in the viewer remains in that browser context.
+
+Generated artifacts are intended to contain aggregates, configuration,
+content-addressing information, and bounded findings rather than raw dataset
+text. This is a design boundary, not a guarantee that every user-supplied file
+is safe to publish. Before sharing any artifact, follow
+[docs/SHARING.md](docs/SHARING.md). Treat hashes as stable identifiers that can
+still reveal equality or permit guessing when the underlying data is simple.
+
+When reporting a suspected privacy failure:
+
+- name the affected command, version, and output field;
+- describe the data category, not the sensitive value;
+- use synthetic reproduction data whenever possible;
+- remove local usernames, absolute paths, access tokens, and model credentials;
+- do not attach the original dataset or an unsanitized report.
+
+## Security scope
+
+Reports about unsafe parsing, path traversal, command construction, secret or
+raw-text leakage, unbounded resource use, cross-site scripting in the viewer,
+and dependency compromise are in scope. A `PASS` Regression Card is not a
+safety certification and does not show that a model, adapter, or dataset is
+secure.
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
new file mode 100644
index 0000000..710e72d
--- /dev/null
+++ b/docs/ARCHITECTURE.md
@@ -0,0 +1,105 @@
+# Architecture
+
+This document describes the intended v0.1 boundaries. The frozen behavior is
+defined by [PRODUCT_SPEC.md](PRODUCT_SPEC.md); implementation details may evolve
+only within that contract.
+
+## System boundary
+
+SFTGuard is an evidence gate around a supervised fine-tuning workflow. It does
+not train an adapter. The Python package audits local records and paired
+evaluation evidence, writes a content-addressed Regression Card, and exits with
+a machine-readable status. The static web application renders an explicitly
+selected card without sending it to a report-processing service.
+
+```mermaid
+flowchart LR
+ A["Local JSONL records"] --> B["Bounded parser and schema audit"]
+ T["Optional compatible tokenizer"] --> C["Template and loss-mask audit"]
+ B --> C
+ C --> D["Deterministic findings"]
+ E["Paired base and adapter evaluations"] --> F["Target and retention gates"]
+ D --> G["Status reducer"]
+ F --> G
+ G --> H["Content-addressed Regression Card"]
+ H --> I["Static browser viewer"]
+ H --> J["CI exit code and artifact validation"]
+```
+
+## Components
+
+### Bounded input layer
+
+The CLI accepts local JSONL and evaluation files subject to explicit file,
+line, record, and field-size limits. Parsing is line-oriented. Dataset values
+are treated as untrusted data and are never interpolated into shell commands.
+Malformed, oversized, or incomplete inputs produce a finding or `ABSTAIN`
+rather than an implicit pass.
+
+### Dataset, template, and mask audits
+
+Deterministic checks cover the preregistered schema, ordering, duplication,
+conflict, leakage, EOS, truncation, supervision-mask, and credential-shaped
+fault classes. When a tokenizer cannot expose a trustworthy assistant-token
+mask, the template-dependent checks abstain. A heuristic mask must not be
+reported as verified evidence.
+
+### Regression gate
+
+The regression layer consumes supplied, paired base-versus-adapter evaluation
+records. It evaluates target improvement and retention budgets defined by the
+user's evaluation contract and reports deterministic bootstrap intervals where
+configured. It does not infer model quality from training loss and does not
+predict the outcome of a future run.
+
+### Status reducer
+
+The reducer preserves three terminal states:
+
+- `PASS`: every required check ran and met its frozen gate;
+- `FAIL`: at least one completed required check violated a gate;
+- `ABSTAIN`: required evidence was unavailable or untrustworthy.
+
+`ABSTAIN` has release-blocking semantics. Mixed results retain the individual
+finding states so that missing evidence is not hidden behind an aggregate.
+
+### Artifact layer
+
+Canonical JSON serialization supports byte-stable hashing and independent
+verification. Public artifacts contain configuration, provenance, aggregates,
+bounded findings, and content identifiers—not raw prompts, responses, injected
+secrets, model weights, or access credentials. Exact protocol and
+implementation commits are part of confirmatory evidence.
+
+### Static report viewer
+
+The viewer is a build-time static React application. It has no dataset or report
+upload endpoint, telemetry, external fonts, or server-side rendering of an
+imported Regression Card. File selection is an explicit user action; imported
+content remains in the browser. The viewer treats all report strings as data
+and must not render supplied HTML.
+
+## Trust boundaries
+
+| Boundary | Trusted assumption | Required response when it fails |
+| --- | --- | --- |
+| Dataset and evaluation files | Files are untrusted and may be malformed or large | Bound work; emit a finding or abstain |
+| Exported mask evidence | The producer exported the exact training token and loss mask | Validate structure; abstain when evidence is absent or malformed |
+| Regression contract | Metrics and pair identifiers are supplied honestly | Validate shape, pairing, and work budget; do not invent missing pairs |
+| Browser artifact reader | A report may be modified or hostile | Allow-list aggregate fields, escape values, and label the declared digest unverified |
+| Python evidence verifier | Checked-in evidence and manifest may be modified | Recompute canonical and raw hashes; fail on any mismatch |
+| GitHub Pages | Application assets are public | Never bundle private reports or datasets |
+
+## Determinism and evidence
+
+The synthetic fault matrix validates SFTGuard's own bounded detectors. Its
+confirmatory result is written only after the protocol and implementation are
+committed, and a fresh process must reproduce the canonical payload. This
+evidence does not establish coverage of unknown real-world faults.
+
+## Deployment
+
+Python and web checks run on Linux and Windows. Dependency audits and static
+analysis run separately from behavior tests. GitHub Pages deploys only the
+contents of `web/dist`; local Regression Cards are not part of that directory
+and are not deployed automatically.
diff --git a/docs/EVIDENCE.md b/docs/EVIDENCE.md
new file mode 100644
index 0000000..7a704e3
--- /dev/null
+++ b/docs/EVIDENCE.md
@@ -0,0 +1,65 @@
+# Reproducing the v0.1 evidence
+
+The confirmatory bundle is generated once from the frozen implementation and
+then retained whether its gates pass or fail. The runner does not overwrite an
+existing result.
+
+## Phase separation
+
+1. Commit and publish the [fault protocol](FAULT_INJECTION_PROTOCOL.md).
+2. Implement and test only with development seeds `0–9`.
+3. Commit and push the complete detector, runner, tests, and frozen thresholds.
+4. From that clean pushed commit, execute the confirmatory seeds `4100–4129`.
+5. Commit the resulting artifact and manifest without changing a detector or
+ threshold in response to the result.
+
+The runner enforces steps 3 and 4 by refusing a dirty tree, an implementation
+commit not represented by a local `origin/*` ref, or existing evidence paths.
+
+## Generate the bundle once
+
+```bash
+python scripts/confirmatory_evidence.py generate
+```
+
+Two fresh Python processes independently produce the complete JSON bytes. The
+runner writes files only if those bytes match and both artifacts pass their
+canonical integrity check.
+
+The bundle contains:
+
+- `evidence/v0.1.0-confirmatory.json` — aggregate gates, per-fault recall,
+ per-case identifiers/statuses, provenance, and no synthetic record text;
+- `evidence/v0.1.0-confirmatory.manifest.json` — raw file SHA-256, canonical
+ payload SHA-256, protocol commit, and implementation commit.
+
+The raw file digest lives in the manifest because a file cannot contain its own
+SHA-256 without changing that digest.
+
+## Verify integrity
+
+This check needs only the two evidence files:
+
+```bash
+python scripts/confirmatory_evidence.py verify --integrity-only
+```
+
+## Replay the exact implementation
+
+With full Git history available, this creates a temporary detached worktree at
+the recorded implementation commit and regenerates the bytes:
+
+```bash
+python scripts/confirmatory_evidence.py verify
+```
+
+Replay requires the same Python major/minor version recorded by the artifact.
+CI performs the exact replay on Python 3.11 and integrity checks across the
+supported matrix.
+
+## Interpretation
+
+A passing artifact supports only the claim frozen in the protocol. The nine
+injections are structural templates repeated across deterministic synthetic
+markers. The result is internal detector regression evidence, not an external
+dataset benchmark or a claim about unknown fine-tuning failures.
diff --git a/docs/RELATED_WORK.md b/docs/RELATED_WORK.md
new file mode 100644
index 0000000..1dcf302
--- /dev/null
+++ b/docs/RELATED_WORK.md
@@ -0,0 +1,81 @@
+# Related work and project boundary
+
+This comparison defines SFTGuard's scope; it is not an exhaustive novelty
+search. SFTGuard is a local correctness and regression gate around a fine-tuning
+pipeline. It does not replace trainers, parameter-efficient tuning methods,
+data-selection research, or general model benchmarks.
+
+## Training frameworks
+
+[TRL](https://github.com/huggingface/trl) provides post-training algorithms and
+trainers, including supervised fine-tuning. Its official
+[SFTTrainer documentation](https://huggingface.co/docs/trl/main/en/sft_trainer)
+defines dataset formats, chat-template application, token-level loss, and
+assistant/completion masking. SFTGuard inspects evidence about those semantics;
+it does not implement or accelerate the training loop.
+
+[LLaMA-Factory](https://github.com/hiyouga/LLaMA-Factory) and
+[Axolotl](https://github.com/axolotl-ai-cloud/axolotl) are configurable
+fine-tuning systems spanning model loading, dataset preparation, distributed
+training, parameter-efficient methods, and evaluation integrations. SFTGuard is
+designed to sit beside such a system: it can audit exported local records and
+gate supplied base-versus-adapter results, but it does not launch their jobs or
+claim configuration parity with them.
+
+## Parameter-efficient fine-tuning
+
+[PEFT](https://github.com/huggingface/peft) implements LoRA and other
+parameter-efficient methods. Its
+[LoRA API](https://huggingface.co/docs/peft/main/en/package_reference/lora)
+supports target modules, per-module rank and alpha patterns, multiple
+initialization methods, quantization-aware workflows, and automatic target
+selection. SFTGuard does not propose a new adapter, choose an optimal rank, or
+allocate modules. It checks pipeline correctness and evaluates an explicit
+release contract for an adapter that another tool produced.
+
+## Prediction and experiment screening
+
+[TuneAhead](https://arxiv.org/abs/2606.17660) predicts full fine-tuning
+performance using static dataset descriptors and features from a short probe
+run. SFTGuard deliberately makes a different claim: it does not estimate a
+future score. It verifies bounded, observable properties and returns `ABSTAIN`
+when the evidence required for a gate is missing.
+
+## Data selection and processing
+
+[LESS](https://github.com/princeton-nlp/LESS) selects influential examples for
+targeted instruction tuning using optimizer-aware low-rank gradient features.
+[DataInf](https://github.com/ykwon0407/DataInf) estimates data influence for
+LoRA-tuned language and diffusion models. These methods rank training examples
+by estimated utility. SFTGuard does not rank examples or assert that a dataset
+is high quality; its data checks target deterministic conditions such as schema
+errors, exact duplication, conflicting answers, split leakage, and bounded
+privacy signals.
+
+[Data-Juicer](https://github.com/datajuicer/data-juicer) is a broad data
+processing system with filtering, analysis, transformation, and distributed
+execution operators for foundation-model data. SFTGuard is narrower: it uses a
+small frozen audit contract and emits a release decision artifact. It is not a
+general cleaning or data-transformation pipeline.
+
+## Evaluation and forgetting
+
+[lm-evaluation-harness](https://github.com/EleutherAI/lm-evaluation-harness)
+runs a large catalog of language-model evaluations.
+[TRACE](https://github.com/BeyonderXX/TRACE), introduced in the
+[TRACE paper](https://arxiv.org/abs/2310.06762), benchmarks continual learning
+across multiple tasks and measures degradation in general and
+instruction-following abilities. SFTGuard does not reproduce those benchmark
+suites. It consumes paired evaluation records chosen by the user and applies
+explicit target and retention budgets; the resulting Regression Card is valid
+only for that supplied contract.
+
+## Practical distinction
+
+SFTGuard's intended contribution is the integration of four conservative
+software checks: exact training-data semantics where they can be verified,
+deterministic fault findings, paired regression budgets, and shareable
+content-addressed provenance. Existing projects above can provide the trainer,
+adapter, data pipeline, selector, or benchmark. SFTGuard records whether a
+specific proposed release met its declared evidence gates, without converting
+missing evidence into success or claiming that the release is safe in general.
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
new file mode 100644
index 0000000..b98490e
--- /dev/null
+++ b/docs/ROADMAP.md
@@ -0,0 +1,33 @@
+# Roadmap
+
+SFTGuard grows through evidence, not feature count.
+
+## v0.1 — correctness and regression gate
+
+- Local JSONL audit with bounded parsing and redacted findings.
+- Exact inspection of trusted, exported token-role and loss-mask evidence.
+- Base-versus-adapter Regression Cards.
+- Deterministic synthetic fault matrix.
+- Static report viewer and GitHub Action integration.
+
+## v0.2 — real training integration
+
+- TRL callback for checkpoint-level target and retention evaluation.
+- Tested Transformers chat-template mask exporter for compatible templates.
+- LLaMA-Factory and Axolotl result importers.
+- One small, redistributable real-model benchmark with pinned revisions.
+
+## Research track
+
+- Fault-injection benchmark across multiple trainers and model families.
+- False-alarm and fault-detection calibration on independently authored cases.
+- Retention-contract design for structured output, multilingual behavior, and
+ safety probes.
+- Post-merge and post-quantization Regression Cards.
+
+## Deliberately out of scope
+
+- Hosted dataset upload or model training.
+- A new LoRA rank allocator or optimizer.
+- Predicted final performance without full evaluation.
+- Automatic claims that an adapter is safe or production-ready.
diff --git a/docs/SHARING.md b/docs/SHARING.md
new file mode 100644
index 0000000..9c9aaa3
--- /dev/null
+++ b/docs/SHARING.md
@@ -0,0 +1,71 @@
+# Sharing SFTGuard results safely
+
+SFTGuard is local-first. It does not automatically post, upload, or publish a
+dataset, Regression Card, screenshot, or result. Sharing is a separate decision
+made by the person who controls the underlying data.
+
+## Usually appropriate to share
+
+After inspection, these fields are generally intended for reproducibility:
+
+- SFTGuard version and implementation commit;
+- protocol version, configuration, and frozen thresholds;
+- aggregate finding counts and `PASS`, `FAIL`, or `ABSTAIN` status;
+- synthetic benchmark seeds and synthetic evidence hashes;
+- Python, Node.js, operating-system, and dependency versions;
+- evaluation metric names, pair counts, and aggregate intervals when the
+ evaluation contract permits disclosure.
+
+Hashes are identifiers, not anonymization. A dataset or row hash can reveal
+that two parties used the same content and may be guessable for short or common
+records. Do not publish a hash when even dataset identity or equality is
+sensitive.
+
+## Do not share
+
+- raw prompts, responses, system messages, labels, or private evaluation data;
+- credentials, tokens, cookies, private keys, connection strings, or `.env`
+ files;
+- names, contact details, health, school, financial, or other identifying data;
+- absolute local paths that reveal usernames or private project names;
+- proprietary model weights, adapter files, or licensed data without
+ permission;
+- an unsanitized report, terminal log, screenshot, issue attachment, or browser
+ export.
+
+Finding locations should use bounded record indexes and field categories, not
+the sensitive value itself. A secret-pattern finding should say where and what
+category was detected without copying the candidate secret.
+
+## Pre-publication checklist
+
+1. Work from a copy of the intended artifact; keep the original private.
+2. Validate the card schema and canonical hash.
+3. Search the copy for raw prompt/response fields, credentials, local paths,
+ emails, phone numbers, and other identifiers.
+4. Confirm that no data sample was embedded in metadata, error text, or a
+ screenshot.
+5. Check the dataset and model licenses and any competition or institutional
+ disclosure rules.
+6. State the exact evidence boundary. A synthetic pass is not a safety
+ certification or a claim about unknown faults.
+7. When uncertain, share only the aggregate conclusion and reproduction code,
+ or do not publish the artifact.
+
+## Browser viewer and GitHub Pages
+
+The public Pages site contains only static application assets from `web/dist`.
+Selecting a local report reads it into the current browser; the project has no
+report upload endpoint or analytics. Browser extensions, modified builds, and
+the surrounding device are outside SFTGuard's control, so sensitive reports
+should still be opened only in a trusted local environment.
+
+Never commit a real Regression Card merely to view it on Pages. Use synthetic
+fixtures for documentation, tests, issues, and demonstrations.
+
+## Security reports and support requests
+
+Use synthetic reproduction data in public issues. For suspected leakage or
+another vulnerability, follow [SECURITY.md](../SECURITY.md) and report it
+privately. Rotate an exposed credential before discussing code remediation, and
+never include the credential value in the report.
diff --git a/examples/adapter-eval.jsonl b/examples/adapter-eval.jsonl
new file mode 100644
index 0000000..52c7752
--- /dev/null
+++ b/examples/adapter-eval.jsonl
@@ -0,0 +1,6 @@
+{"seed":0,"sample_id":"target-0","group":"target","metric":"task_score","value":0.62}
+{"seed":0,"sample_id":"retention-0","group":"retention","metric":"general_score","value":0.795}
+{"seed":1,"sample_id":"target-1","group":"target","metric":"task_score","value":0.63}
+{"seed":1,"sample_id":"retention-1","group":"retention","metric":"general_score","value":0.785}
+{"seed":2,"sample_id":"target-2","group":"target","metric":"task_score","value":0.63}
+{"seed":2,"sample_id":"retention-2","group":"retention","metric":"general_score","value":0.805}
diff --git a/examples/base-eval.jsonl b/examples/base-eval.jsonl
new file mode 100644
index 0000000..2aebafb
--- /dev/null
+++ b/examples/base-eval.jsonl
@@ -0,0 +1,6 @@
+{"seed":0,"sample_id":"target-0","group":"target","metric":"task_score","value":0.50}
+{"seed":0,"sample_id":"retention-0","group":"retention","metric":"general_score","value":0.80}
+{"seed":1,"sample_id":"target-1","group":"target","metric":"task_score","value":0.52}
+{"seed":1,"sample_id":"retention-1","group":"retention","metric":"general_score","value":0.79}
+{"seed":2,"sample_id":"target-2","group":"target","metric":"task_score","value":0.51}
+{"seed":2,"sample_id":"retention-2","group":"retention","metric":"general_score","value":0.81}
diff --git a/examples/clean-eval.jsonl b/examples/clean-eval.jsonl
new file mode 100644
index 0000000..8f281c6
--- /dev/null
+++ b/examples/clean-eval.jsonl
@@ -0,0 +1 @@
+{"messages":[{"role":"system","content":"Return one class label."},{"role":"user","content":"Classify the held-out development fixture."},{"role":"assistant","content":"class_a"}],"_sftguard":{"token_ids":[12,22,32,2],"token_roles":["system","user","assistant","assistant"],"loss_mask":[false,false,true,true],"eos_token_id":2,"original_assistant_tokens":2,"truncated":false}}
diff --git a/examples/clean-train.jsonl b/examples/clean-train.jsonl
new file mode 100644
index 0000000..fa7fdd1
--- /dev/null
+++ b/examples/clean-train.jsonl
@@ -0,0 +1,2 @@
+{"messages":[{"role":"system","content":"Return one class label."},{"role":"user","content":"Classify the first development fixture."},{"role":"assistant","content":"class_a"}],"_sftguard":{"token_ids":[10,20,30,2],"token_roles":["system","user","assistant","assistant"],"loss_mask":[false,false,true,true],"eos_token_id":2,"original_assistant_tokens":2,"truncated":false}}
+{"messages":[{"role":"system","content":"Return one class label."},{"role":"user","content":"Classify the second development fixture."},{"role":"assistant","content":"class_b"}],"_sftguard":{"token_ids":[11,21,31,2],"token_roles":["system","user","assistant","assistant"],"loss_mask":[false,false,true,true],"eos_token_id":2,"original_assistant_tokens":2,"truncated":false}}
diff --git a/examples/regression-contract.json b/examples/regression-contract.json
new file mode 100644
index 0000000..7c85d0c
--- /dev/null
+++ b/examples/regression-contract.json
@@ -0,0 +1,17 @@
+{
+ "bootstrap": {"draws": 10000, "confidence": 0.95, "seed": 2339},
+ "metrics": [
+ {
+ "group": "target",
+ "metric": "task_score",
+ "higher_is_better": true,
+ "min_improvement": 0.05
+ },
+ {
+ "group": "retention",
+ "metric": "general_score",
+ "higher_is_better": true,
+ "max_regression": 0.02
+ }
+ ]
+}
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..8462fd3
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,58 @@
+[build-system]
+requires = ["setuptools>=77"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "sftguard"
+version = "0.1.0"
+description = "Local-first correctness and regression gates for supervised fine-tuning pipelines"
+readme = "README.md"
+requires-python = ">=3.11"
+license = "Apache-2.0"
+license-files = ["LICENSE"]
+authors = [{ name = "Muhammad Labeeb Aryan" }]
+keywords = ["fine-tuning", "llm", "regression-testing", "sft"]
+classifiers = [
+ "Development Status :: 3 - Alpha",
+ "Environment :: Console",
+ "License :: OSI Approved :: Apache Software License",
+ "Operating System :: OS Independent",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
+]
+dependencies = []
+
+[project.urls]
+Homepage = "https://github.com/Labeeb2339/sftguard"
+Documentation = "https://github.com/Labeeb2339/sftguard#readme"
+Issues = "https://github.com/Labeeb2339/sftguard/issues"
+Source = "https://github.com/Labeeb2339/sftguard"
+
+[project.optional-dependencies]
+dev = [
+ "build>=1.2,<2",
+ "pytest>=8,<9",
+ "ruff>=0.9,<1",
+]
+
+[project.scripts]
+sftguard = "sftguard.cli:main"
+
+[tool.setuptools]
+package-dir = { "" = "src" }
+
+[tool.setuptools.packages.find]
+where = ["src"]
+
+[tool.pytest.ini_options]
+testpaths = ["tests/python"]
+addopts = "-q --basetemp=.pytest_cache/basetemp"
+
+[tool.ruff]
+target-version = "py311"
+line-length = 100
+
+[tool.ruff.lint]
+select = ["E", "F", "I", "UP", "B"]
diff --git a/schemas/audit-report.schema.json b/schemas/audit-report.schema.json
new file mode 100644
index 0000000..f25a22c
--- /dev/null
+++ b/schemas/audit-report.schema.json
@@ -0,0 +1,48 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "urn:sftguard:schema:audit-report:v1",
+ "title": "SFTGuard audit report",
+ "type": "object",
+ "required": [
+ "schema_version",
+ "tool",
+ "status",
+ "inputs",
+ "checks",
+ "summary",
+ "findings",
+ "integrity"
+ ],
+ "properties": {
+ "schema_version": {"const": "sftguard.audit.v1"},
+ "status": {"enum": ["PASS", "FAIL", "ABSTAIN"]},
+ "inputs": {"type": "array"},
+ "checks": {"type": "object"},
+ "summary": {"type": "object"},
+ "findings": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": ["signal", "severity", "location"],
+ "properties": {
+ "signal": {"type": "string"},
+ "severity": {"enum": ["info", "warning", "error", "critical"]},
+ "location": {
+ "type": "object",
+ "required": ["split", "line"],
+ "properties": {
+ "split": {"enum": ["train", "evaluation"]},
+ "line": {"type": "integer", "minimum": 1},
+ "field": {"type": "string"}
+ }
+ },
+ "redacted": {"type": "boolean"}
+ }
+ }
+ },
+ "integrity": {
+ "type": "object",
+ "required": ["algorithm", "canonical_payload_sha256"]
+ }
+ }
+}
diff --git a/schemas/confirmatory-fault-matrix.schema.json b/schemas/confirmatory-fault-matrix.schema.json
new file mode 100644
index 0000000..9facef1
--- /dev/null
+++ b/schemas/confirmatory-fault-matrix.schema.json
@@ -0,0 +1,32 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "urn:sftguard:schema:confirmatory-fault-matrix:v1",
+ "title": "SFTGuard confirmatory fault-matrix evidence",
+ "type": "object",
+ "required": [
+ "schema_version",
+ "tool",
+ "provenance",
+ "protocol",
+ "summary",
+ "per_fault",
+ "cases",
+ "gates",
+ "carry_forward",
+ "status",
+ "integrity"
+ ],
+ "properties": {
+ "schema_version": {"const": "sftguard.confirmatory-fault-matrix.v1"},
+ "status": {"enum": ["PASS", "FAIL"]},
+ "carry_forward": {"type": "boolean"},
+ "tool": {"type": "object"},
+ "provenance": {"type": "object"},
+ "protocol": {"type": "object"},
+ "summary": {"type": "object"},
+ "per_fault": {"type": "array", "minItems": 9, "maxItems": 9},
+ "cases": {"type": "array", "minItems": 300, "maxItems": 300},
+ "gates": {"type": "object"},
+ "integrity": {"type": "object"}
+ }
+}
diff --git a/schemas/confirmatory-manifest.schema.json b/schemas/confirmatory-manifest.schema.json
new file mode 100644
index 0000000..6f04eaf
--- /dev/null
+++ b/schemas/confirmatory-manifest.schema.json
@@ -0,0 +1,33 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "urn:sftguard:schema:confirmatory-manifest:v1",
+ "title": "SFTGuard confirmatory evidence manifest",
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "schema_version",
+ "artifact_file_sha256",
+ "artifact_integrity_sha256",
+ "artifact_path",
+ "implementation_git_commit",
+ "protocol_git_commit",
+ "integrity"
+ ],
+ "properties": {
+ "schema_version": {"const": "sftguard.confirmatory-manifest.v1"},
+ "artifact_file_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"},
+ "artifact_integrity_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"},
+ "artifact_path": {"const": "evidence/v0.1.0-confirmatory.json"},
+ "implementation_git_commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"},
+ "protocol_git_commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"},
+ "integrity": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["algorithm", "canonical_payload_sha256"],
+ "properties": {
+ "algorithm": {"const": "sha256"},
+ "canonical_payload_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}
+ }
+ }
+ }
+}
diff --git a/schemas/regression-card.schema.json b/schemas/regression-card.schema.json
new file mode 100644
index 0000000..87239d9
--- /dev/null
+++ b/schemas/regression-card.schema.json
@@ -0,0 +1,47 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "urn:sftguard:schema:regression-card:v1",
+ "title": "SFTGuard Regression Card",
+ "type": "object",
+ "required": [
+ "schema_version",
+ "tool",
+ "status",
+ "inputs",
+ "metrics",
+ "summary",
+ "integrity"
+ ],
+ "properties": {
+ "schema_version": {"const": "sftguard.regression-card.v1"},
+ "status": {"enum": ["PASS", "FAIL", "ABSTAIN"]},
+ "inputs": {"type": "array"},
+ "bootstrap": {"type": "object"},
+ "metrics": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": [
+ "group",
+ "metric",
+ "status",
+ "pair_count",
+ "mean_normalized_improvement",
+ "confidence_interval",
+ "threshold",
+ "decision_rule"
+ ],
+ "properties": {
+ "group": {"enum": ["target", "retention"]},
+ "metric": {"type": "string"},
+ "status": {"enum": ["PASS", "FAIL"]}
+ }
+ }
+ },
+ "summary": {"type": "object"},
+ "integrity": {
+ "type": "object",
+ "required": ["algorithm", "canonical_payload_sha256"]
+ }
+ }
+}
diff --git a/schemas/regression-contract.schema.json b/schemas/regression-contract.schema.json
new file mode 100644
index 0000000..1fb19c3
--- /dev/null
+++ b/schemas/regression-contract.schema.json
@@ -0,0 +1,32 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "urn:sftguard:schema:regression-contract:v1",
+ "title": "SFTGuard Regression Card contract",
+ "type": "object",
+ "required": ["metrics"],
+ "properties": {
+ "bootstrap": {
+ "type": "object",
+ "properties": {
+ "draws": {"type": "integer", "minimum": 1, "maximum": 100000},
+ "confidence": {"type": "number", "exclusiveMinimum": 0, "exclusiveMaximum": 1},
+ "seed": {"type": "integer"}
+ }
+ },
+ "metrics": {
+ "type": "array",
+ "minItems": 2,
+ "items": {
+ "type": "object",
+ "required": ["group", "metric", "higher_is_better"],
+ "properties": {
+ "group": {"enum": ["target", "retention"]},
+ "metric": {"type": "string", "pattern": "^[A-Za-z0-9_.-]{1,64}$"},
+ "higher_is_better": {"type": "boolean"},
+ "min_improvement": {"type": "number"},
+ "max_regression": {"type": "number", "minimum": 0}
+ }
+ }
+ }
+ }
+}
diff --git a/schemas/regression-evaluation-record.schema.json b/schemas/regression-evaluation-record.schema.json
new file mode 100644
index 0000000..f466a9c
--- /dev/null
+++ b/schemas/regression-evaluation-record.schema.json
@@ -0,0 +1,14 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "urn:sftguard:schema:regression-evaluation-record:v1",
+ "title": "SFTGuard paired evaluation record",
+ "type": "object",
+ "required": ["seed", "sample_id", "group", "metric", "value"],
+ "properties": {
+ "seed": {"type": "integer"},
+ "sample_id": {"type": "string", "minLength": 1, "maxLength": 512},
+ "group": {"type": "string", "pattern": "^[A-Za-z0-9_.-]{1,64}$"},
+ "metric": {"type": "string", "pattern": "^[A-Za-z0-9_.-]{1,64}$"},
+ "value": {"type": "number"}
+ }
+}
diff --git a/schemas/sft-record.schema.json b/schemas/sft-record.schema.json
new file mode 100644
index 0000000..a0eb932
--- /dev/null
+++ b/schemas/sft-record.schema.json
@@ -0,0 +1,42 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "urn:sftguard:schema:sft-record:v1",
+ "title": "SFTGuard chat record",
+ "type": "object",
+ "required": ["messages"],
+ "properties": {
+ "messages": {
+ "type": "array",
+ "minItems": 1,
+ "items": {
+ "type": "object",
+ "required": ["role", "content"],
+ "properties": {
+ "role": {"enum": ["system", "user", "assistant", "tool"]},
+ "content": {"type": "string"}
+ }
+ }
+ },
+ "_sftguard": {
+ "type": "object",
+ "required": [
+ "token_ids",
+ "token_roles",
+ "loss_mask",
+ "eos_token_id",
+ "original_assistant_tokens"
+ ],
+ "properties": {
+ "token_ids": {"type": "array", "items": {"type": "integer"}},
+ "token_roles": {
+ "type": "array",
+ "items": {"enum": ["system", "user", "assistant", "tool"]}
+ },
+ "loss_mask": {"type": "array", "items": {"type": "boolean"}},
+ "eos_token_id": {"type": "integer"},
+ "original_assistant_tokens": {"type": "integer", "minimum": 0},
+ "truncated": {"type": "boolean", "default": false}
+ }
+ }
+ }
+}
diff --git a/scripts/confirmatory_evidence.py b/scripts/confirmatory_evidence.py
new file mode 100644
index 0000000..daf5242
--- /dev/null
+++ b/scripts/confirmatory_evidence.py
@@ -0,0 +1,296 @@
+"""Generate once or independently verify the frozen SFTGuard v0.1 evidence."""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import os
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+from typing import Any
+
+PROTOCOL_COMMIT = "d4c74cefca6729124142635dc8893b5e3ddec2d3"
+REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
+DEFAULT_ARTIFACT = Path("evidence/v0.1.0-confirmatory.json")
+DEFAULT_MANIFEST = Path("evidence/v0.1.0-confirmatory.manifest.json")
+MAX_EVIDENCE_BYTES = 16 * 1024 * 1024
+
+
+class EvidenceError(RuntimeError):
+ """Raised when an evidence safety or verification condition fails."""
+
+
+def _git(*arguments: str, cwd: Path = REPOSITORY_ROOT) -> str:
+ completed = subprocess.run(
+ ["git", *arguments],
+ cwd=cwd,
+ check=False,
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ )
+ if completed.returncode != 0:
+ detail = completed.stderr.strip() or completed.stdout.strip() or "git command failed"
+ raise EvidenceError(detail)
+ return completed.stdout.strip()
+
+
+def _sha256(value: bytes) -> str:
+ return hashlib.sha256(value).hexdigest()
+
+
+def _canonical_json_bytes(value: Any) -> bytes:
+ return json.dumps(
+ value,
+ ensure_ascii=False,
+ allow_nan=False,
+ separators=(",", ":"),
+ sort_keys=True,
+ ).encode("utf-8")
+
+
+def _seal(payload: dict[str, Any]) -> dict[str, Any]:
+ sealed = dict(payload)
+ sealed["integrity"] = {
+ "algorithm": "sha256",
+ "canonical_payload_sha256": _sha256(_canonical_json_bytes(sealed)),
+ }
+ return sealed
+
+
+def _pretty_json_bytes(value: Any) -> bytes:
+ return (
+ json.dumps(
+ value,
+ ensure_ascii=False,
+ allow_nan=False,
+ indent=2,
+ sort_keys=True,
+ )
+ + "\n"
+ ).encode("utf-8")
+
+
+def _verify_integrity(value: dict[str, Any]) -> bool:
+ integrity = value.get("integrity")
+ if not isinstance(integrity, dict):
+ return False
+ expected = integrity.get("canonical_payload_sha256")
+ payload = dict(value)
+ payload.pop("integrity", None)
+ return isinstance(expected, str) and expected == _sha256(_canonical_json_bytes(payload))
+
+
+def _reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
+ result: dict[str, Any] = {}
+ for key, value in pairs:
+ if key in result:
+ raise EvidenceError("JSON object contains a duplicate key")
+ result[key] = value
+ return result
+
+
+def _parse_object(raw: bytes, label: str) -> dict[str, Any]:
+ if len(raw) > MAX_EVIDENCE_BYTES:
+ raise EvidenceError(f"{label} exceeds the evidence byte limit")
+ try:
+ value = json.loads(raw.decode("utf-8"), object_pairs_hook=_reject_duplicate_keys)
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
+ raise EvidenceError(f"{label} is not valid UTF-8 JSON") from exc
+ if not isinstance(value, dict):
+ raise EvidenceError(f"{label} must contain a JSON object")
+ return value
+
+
+def _worker_bytes(source_root: Path, implementation_commit: str) -> bytes:
+ environment = os.environ.copy()
+ existing_path = environment.get("PYTHONPATH")
+ source_path = str(source_root / "src")
+ environment["PYTHONPATH"] = (
+ source_path if not existing_path else os.pathsep.join((source_path, existing_path))
+ )
+ environment["PYTHONHASHSEED"] = "0"
+ completed = subprocess.run(
+ [
+ sys.executable,
+ "-m",
+ "sftguard.confirmatory",
+ "--protocol-commit",
+ PROTOCOL_COMMIT,
+ "--implementation-commit",
+ implementation_commit,
+ ],
+ cwd=source_root,
+ env=environment,
+ check=False,
+ capture_output=True,
+ )
+ if completed.returncode not in {0, 1}:
+ detail = completed.stderr.decode("utf-8", errors="replace").strip()
+ raise EvidenceError(detail or "confirmatory worker failed")
+ artifact = _parse_object(completed.stdout, "worker output")
+ if not _verify_integrity(artifact):
+ raise EvidenceError("worker produced an invalid artifact integrity hash")
+ return completed.stdout
+
+
+def _resolve_output(path: Path) -> Path:
+ resolved = (REPOSITORY_ROOT / path).resolve() if not path.is_absolute() else path.resolve()
+ try:
+ resolved.relative_to(REPOSITORY_ROOT)
+ except ValueError as exc:
+ raise EvidenceError("evidence outputs must remain inside the repository") from exc
+ return resolved
+
+
+def _write_exclusive(path: Path, raw: bytes) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ try:
+ with path.open("xb") as stream:
+ stream.write(raw)
+ stream.flush()
+ os.fsync(stream.fileno())
+ except FileExistsError as exc:
+ raise EvidenceError(f"refusing to overwrite existing evidence: {path}") from exc
+
+
+def generate(artifact_path: Path, manifest_path: Path) -> None:
+ artifact = _resolve_output(artifact_path)
+ manifest = _resolve_output(manifest_path)
+ if artifact.exists() or manifest.exists():
+ raise EvidenceError("refusing to overwrite an existing evidence bundle")
+ if _git("status", "--porcelain", "--untracked-files=all"):
+ raise EvidenceError("generation requires a completely clean Git worktree")
+
+ implementation_commit = _git("rev-parse", "HEAD")
+ _git("merge-base", "--is-ancestor", PROTOCOL_COMMIT, implementation_commit)
+ remote_branches = _git("branch", "-r", "--contains", implementation_commit)
+ if not any(line.strip().startswith("origin/") for line in remote_branches.splitlines()):
+ raise EvidenceError("implementation commit must be pushed to origin before generation")
+
+ first = _worker_bytes(REPOSITORY_ROOT, implementation_commit)
+ second = _worker_bytes(REPOSITORY_ROOT, implementation_commit)
+ if first != second:
+ raise EvidenceError("fresh worker processes did not reproduce byte-identical evidence")
+
+ evidence = _parse_object(first, "confirmatory artifact")
+ manifest_value = _seal(
+ {
+ "schema_version": "sftguard.confirmatory-manifest.v1",
+ "artifact_file_sha256": _sha256(first),
+ "artifact_integrity_sha256": evidence["integrity"]["canonical_payload_sha256"],
+ "artifact_path": artifact.relative_to(REPOSITORY_ROOT).as_posix(),
+ "implementation_git_commit": implementation_commit,
+ "protocol_git_commit": PROTOCOL_COMMIT,
+ }
+ )
+ _write_exclusive(artifact, first)
+ try:
+ _write_exclusive(manifest, _pretty_json_bytes(manifest_value))
+ except BaseException:
+ artifact.unlink(missing_ok=True)
+ raise
+ print(f"wrote {artifact.relative_to(REPOSITORY_ROOT)}")
+ print(f"artifact sha256: {_sha256(first)}")
+ print(f"carry forward: {str(evidence.get('carry_forward')).lower()}")
+
+
+def _verify_bundle(artifact_path: Path, manifest_path: Path) -> tuple[bytes, dict[str, Any]]:
+ artifact = _resolve_output(artifact_path)
+ manifest = _resolve_output(manifest_path)
+ try:
+ if artifact.stat().st_size > MAX_EVIDENCE_BYTES:
+ raise EvidenceError("artifact exceeds the evidence byte limit")
+ if manifest.stat().st_size > MAX_EVIDENCE_BYTES:
+ raise EvidenceError("manifest exceeds the evidence byte limit")
+ artifact_raw = artifact.read_bytes()
+ manifest_raw = manifest.read_bytes()
+ except OSError as exc:
+ raise EvidenceError("evidence bundle cannot be read") from exc
+ artifact_value = _parse_object(artifact_raw, "artifact")
+ manifest_value = _parse_object(manifest_raw, "manifest")
+ if not _verify_integrity(artifact_value) or not _verify_integrity(manifest_value):
+ raise EvidenceError("artifact or manifest integrity verification failed")
+ if manifest_value.get("artifact_file_sha256") != _sha256(artifact_raw):
+ raise EvidenceError("raw artifact SHA-256 does not match the manifest")
+ if (
+ manifest_value.get("artifact_integrity_sha256")
+ != artifact_value["integrity"]["canonical_payload_sha256"]
+ ):
+ raise EvidenceError("canonical artifact SHA-256 does not match the manifest")
+ if manifest_value.get("protocol_git_commit") != PROTOCOL_COMMIT:
+ raise EvidenceError("manifest protocol commit does not match the frozen runner")
+ if manifest_value.get("artifact_path") != artifact.relative_to(REPOSITORY_ROOT).as_posix():
+ raise EvidenceError("manifest artifact path does not match the selected artifact")
+ if artifact_value.get("schema_version") != "sftguard.confirmatory-fault-matrix.v1":
+ raise EvidenceError("artifact schema version is not the frozen v0.1 schema")
+ provenance = artifact_value.get("provenance")
+ if not isinstance(provenance, dict):
+ raise EvidenceError("artifact provenance is missing")
+ if provenance.get("protocol_git_commit") != PROTOCOL_COMMIT:
+ raise EvidenceError("artifact protocol commit does not match the frozen runner")
+ implementation_commit = provenance.get("implementation_git_commit")
+ if (
+ not isinstance(implementation_commit, str)
+ or len(implementation_commit) != 40
+ or any(character not in "0123456789abcdef" for character in implementation_commit)
+ ):
+ raise EvidenceError("artifact implementation commit is invalid")
+ if implementation_commit != manifest_value.get("implementation_git_commit"):
+ raise EvidenceError("implementation commit differs between artifact and manifest")
+ return artifact_raw, manifest_value
+
+
+def verify(artifact_path: Path, manifest_path: Path, *, replay: bool) -> None:
+ artifact_raw, manifest = _verify_bundle(artifact_path, manifest_path)
+ implementation_commit = manifest["implementation_git_commit"]
+ if replay:
+ _git("cat-file", "-e", f"{implementation_commit}^{{commit}}")
+ with tempfile.TemporaryDirectory(prefix="sftguard-evidence-") as temporary:
+ worktree = Path(temporary) / "source"
+ _git("worktree", "add", "--detach", str(worktree), implementation_commit)
+ try:
+ replayed = _worker_bytes(worktree, implementation_commit)
+ finally:
+ _git("worktree", "remove", "--force", str(worktree))
+ if replayed != artifact_raw:
+ raise EvidenceError("exact implementation replay did not match checked-in evidence")
+ print("evidence integrity verified")
+ if replay:
+ print("exact implementation replay matched")
+
+
+def _parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(description=__doc__)
+ commands = parser.add_subparsers(dest="command", required=True)
+ generate_command = commands.add_parser("generate")
+ verify_command = commands.add_parser("verify")
+ for command in (generate_command, verify_command):
+ command.add_argument("--artifact", type=Path, default=DEFAULT_ARTIFACT)
+ command.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST)
+ verify_command.add_argument(
+ "--integrity-only",
+ action="store_true",
+ help="verify hashes without recreating the implementation worktree",
+ )
+ return parser
+
+
+def main() -> int:
+ args = _parser().parse_args()
+ try:
+ if args.command == "generate":
+ generate(args.artifact, args.manifest)
+ else:
+ verify(args.artifact, args.manifest, replay=not args.integrity_only)
+ except EvidenceError as exc:
+ print(f"error: {exc}", file=sys.stderr)
+ return 2
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/src/sftguard/__init__.py b/src/sftguard/__init__.py
new file mode 100644
index 0000000..a547413
--- /dev/null
+++ b/src/sftguard/__init__.py
@@ -0,0 +1,8 @@
+"""SFTGuard public package surface."""
+
+__version__ = "0.1.0"
+
+from .audit import AuditConfig, audit_files, audit_records
+from .regression import build_regression_card
+
+__all__ = ["AuditConfig", "audit_files", "audit_records", "build_regression_card"]
diff --git a/src/sftguard/__main__.py b/src/sftguard/__main__.py
new file mode 100644
index 0000000..eb53e2f
--- /dev/null
+++ b/src/sftguard/__main__.py
@@ -0,0 +1,3 @@
+from .cli import main
+
+raise SystemExit(main())
diff --git a/src/sftguard/artifacts.py b/src/sftguard/artifacts.py
new file mode 100644
index 0000000..caa2b72
--- /dev/null
+++ b/src/sftguard/artifacts.py
@@ -0,0 +1,88 @@
+"""Deterministic, content-addressed JSON artifact helpers."""
+
+from __future__ import annotations
+
+import copy
+import hashlib
+import json
+import os
+import tempfile
+from collections.abc import Mapping
+from pathlib import Path
+from typing import Any
+
+
+def canonical_json_bytes(value: Any) -> bytes:
+ """Return the repository's canonical UTF-8 JSON representation."""
+
+ return json.dumps(
+ value,
+ ensure_ascii=False,
+ allow_nan=False,
+ separators=(",", ":"),
+ sort_keys=True,
+ ).encode("utf-8")
+
+
+def sha256_bytes(value: bytes) -> str:
+ return hashlib.sha256(value).hexdigest()
+
+
+def seal_artifact(payload: Mapping[str, Any]) -> dict[str, Any]:
+ """Attach a hash of the payload excluding the integrity field itself."""
+
+ sealed = copy.deepcopy(dict(payload))
+ sealed.pop("integrity", None)
+ sealed["integrity"] = {
+ "algorithm": "sha256",
+ "canonical_payload_sha256": sha256_bytes(canonical_json_bytes(sealed)),
+ }
+ return sealed
+
+
+def verify_artifact(artifact: Mapping[str, Any]) -> bool:
+ integrity = artifact.get("integrity")
+ if not isinstance(integrity, Mapping):
+ return False
+ expected = integrity.get("canonical_payload_sha256")
+ if not isinstance(expected, str):
+ return False
+ payload = copy.deepcopy(dict(artifact))
+ payload.pop("integrity", None)
+ return expected == sha256_bytes(canonical_json_bytes(payload))
+
+
+def write_artifact(path: str | Path | None, artifact: Mapping[str, Any]) -> None:
+ """Write an artifact atomically, or print it when path is ``None``."""
+
+ rendered = (
+ json.dumps(
+ artifact,
+ ensure_ascii=False,
+ allow_nan=False,
+ indent=2,
+ sort_keys=True,
+ )
+ + "\n"
+ )
+ if path is None:
+ print(rendered, end="")
+ return
+
+ destination = Path(path)
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ handle, temporary_name = tempfile.mkstemp(
+ prefix=f".{destination.name}.", suffix=".tmp", dir=destination.parent
+ )
+ try:
+ with os.fdopen(handle, "w", encoding="utf-8", newline="\n") as stream:
+ stream.write(rendered)
+ stream.flush()
+ os.fsync(stream.fileno())
+ os.replace(temporary_name, destination)
+ except BaseException:
+ try:
+ os.unlink(temporary_name)
+ except FileNotFoundError:
+ pass
+ raise
diff --git a/src/sftguard/audit.py b/src/sftguard/audit.py
new file mode 100644
index 0000000..cfee05f
--- /dev/null
+++ b/src/sftguard/audit.py
@@ -0,0 +1,396 @@
+"""Deterministic SFT dataset and rendered-mask audits."""
+
+from __future__ import annotations
+
+from collections import Counter
+from collections.abc import Iterable
+from dataclasses import dataclass
+from typing import Any
+
+from . import __version__
+from .artifacts import canonical_json_bytes, seal_artifact, sha256_bytes
+from .io import (
+ DEFAULT_MAX_FILE_BYTES,
+ DEFAULT_MAX_LINE_BYTES,
+ DEFAULT_MAX_RECORDS,
+ InputError,
+ InputSummary,
+ read_jsonl_bounded,
+ summarize_records,
+)
+from .models import SEVERITY_ORDER, Finding, Status, combine_status
+from .privacy import scan_record
+from .tokenizer import FixtureMaskInspector, MaskInspector
+
+ALLOWED_MESSAGE_ROLES = {"system", "user", "assistant", "tool"}
+
+
+@dataclass(frozen=True)
+class AuditConfig:
+ max_file_bytes: int = DEFAULT_MAX_FILE_BYTES
+ max_line_bytes: int = DEFAULT_MAX_LINE_BYTES
+ max_records: int = DEFAULT_MAX_RECORDS
+ require_assistant_mask: bool = True
+ scan_privacy: bool = True
+
+ def as_dict(self) -> dict[str, Any]:
+ return {
+ "max_file_bytes": self.max_file_bytes,
+ "max_line_bytes": self.max_line_bytes,
+ "max_records": self.max_records,
+ "require_assistant_mask": self.require_assistant_mask,
+ "scan_privacy": self.scan_privacy,
+ }
+
+
+@dataclass(frozen=True)
+class _RecordIdentity:
+ split: str
+ line: int
+ record_sha256: str
+ prompt_sha256: str | None
+ answer_sha256: str | None
+
+
+def _record_hash(record: dict[str, Any], *, split: str, line: int) -> tuple[str, bool]:
+ try:
+ return sha256_bytes(canonical_json_bytes(record)), True
+ except (TypeError, ValueError, RecursionError):
+ marker = canonical_json_bytes({"invalid_record": True, "split": split, "line": line})
+ return sha256_bytes(marker), False
+
+
+def _message_identity(
+ record: dict[str, Any],
+ *,
+ split: str,
+ line: int,
+ record_sha256: str,
+) -> tuple[str | None, str | None, list[Finding]]:
+ findings: list[Finding] = []
+ messages = record.get("messages")
+ if not isinstance(messages, list) or not messages:
+ findings.append(
+ Finding("invalid_messages", "error", split, line, record_sha256=record_sha256)
+ )
+ return None, None, findings
+
+ valid_messages: list[dict[str, str]] = []
+ awaiting_assistant = False
+ for message in messages:
+ if not isinstance(message, dict):
+ findings.append(
+ Finding("invalid_messages", "error", split, line, record_sha256=record_sha256)
+ )
+ continue
+ role = message.get("role")
+ content = message.get("content")
+ if not isinstance(role, str) or role not in ALLOWED_MESSAGE_ROLES:
+ findings.append(
+ Finding(
+ "invalid_role",
+ "error",
+ split,
+ line,
+ field="role",
+ record_sha256=record_sha256,
+ )
+ )
+ continue
+ if not isinstance(content, str):
+ findings.append(
+ Finding(
+ "invalid_messages",
+ "error",
+ split,
+ line,
+ field="content",
+ record_sha256=record_sha256,
+ )
+ )
+ continue
+ valid_messages.append({"role": role, "content": content})
+ if role == "user":
+ awaiting_assistant = True
+ elif role == "assistant":
+ if not awaiting_assistant:
+ findings.append(
+ Finding("role_order", "error", split, line, record_sha256=record_sha256)
+ )
+ awaiting_assistant = False
+
+ if len(valid_messages) != len(messages):
+ return None, None, findings
+
+ assistant_indexes = [
+ index for index, message in enumerate(valid_messages) if message["role"] == "assistant"
+ ]
+ if not assistant_indexes:
+ findings.append(
+ Finding("missing_assistant", "error", split, line, record_sha256=record_sha256)
+ )
+ return None, None, findings
+ final_index = assistant_indexes[-1]
+ prompt = valid_messages[:final_index]
+ answer = valid_messages[final_index]["content"]
+ prompt_hash = sha256_bytes(canonical_json_bytes(prompt))
+ answer_hash = sha256_bytes(canonical_json_bytes(answer))
+ return prompt_hash, answer_hash, findings
+
+
+def _check_status(findings: Iterable[Finding], signals: set[str]) -> Status:
+ relevant = [finding for finding in findings if finding.signal in signals]
+ if any(SEVERITY_ORDER[finding.severity] >= SEVERITY_ORDER["error"] for finding in relevant):
+ return Status.FAIL
+ return Status.PASS
+
+
+def _abstain_report(config: AuditConfig, reason: str) -> dict[str, Any]:
+ return seal_artifact(
+ {
+ "schema_version": "sftguard.audit.v1",
+ "tool": {"name": "sftguard", "version": __version__},
+ "status": Status.ABSTAIN,
+ "config": config.as_dict(),
+ "inputs": [],
+ "checks": {"input": {"status": Status.ABSTAIN, "reason": reason}},
+ "summary": {"finding_count": 0, "signal_counts": {}},
+ "findings": [],
+ }
+ )
+
+
+def audit_files(
+ train_path: str,
+ evaluation_path: str,
+ *,
+ config: AuditConfig | None = None,
+ mask_inspector: MaskInspector | None = None,
+) -> dict[str, Any]:
+ selected = config or AuditConfig()
+ try:
+ train = read_jsonl_bounded(
+ train_path,
+ max_file_bytes=selected.max_file_bytes,
+ max_line_bytes=selected.max_line_bytes,
+ max_records=selected.max_records,
+ )
+ evaluation = read_jsonl_bounded(
+ evaluation_path,
+ max_file_bytes=selected.max_file_bytes,
+ max_line_bytes=selected.max_line_bytes,
+ max_records=selected.max_records,
+ )
+ except InputError:
+ return _abstain_report(selected, "invalid_unreadable_or_oversized_input")
+ return audit_records(
+ train.records,
+ evaluation.records,
+ train_summary=train.summary,
+ evaluation_summary=evaluation.summary,
+ config=selected,
+ mask_inspector=mask_inspector,
+ )
+
+
+def audit_records(
+ train_records: Iterable[tuple[int, dict[str, Any]]],
+ evaluation_records: Iterable[tuple[int, dict[str, Any]]],
+ *,
+ train_summary: InputSummary | None = None,
+ evaluation_summary: InputSummary | None = None,
+ config: AuditConfig | None = None,
+ mask_inspector: MaskInspector | None = None,
+) -> dict[str, Any]:
+ selected = config or AuditConfig()
+ inspector = mask_inspector or FixtureMaskInspector()
+ train = tuple(train_records)
+ evaluation = tuple(evaluation_records)
+ train_summary = train_summary or summarize_records(train)
+ evaluation_summary = evaluation_summary or summarize_records(evaluation)
+
+ if not train or not evaluation:
+ return _abstain_report(selected, "train_and_evaluation_records_required")
+
+ findings: list[Finding] = []
+ identities: list[_RecordIdentity] = []
+ mask_abstentions = 0
+ seen_records: dict[tuple[str, str], int] = {}
+
+ for split, records in (("train", train), ("evaluation", evaluation)):
+ for line, record in records:
+ record_sha, record_serializable = _record_hash(record, split=split, line=line)
+ if not record_serializable:
+ findings.append(
+ Finding(
+ "invalid_record",
+ "error",
+ split,
+ line,
+ record_sha256=record_sha,
+ )
+ )
+ prompt_sha, answer_sha, schema_findings = _message_identity(
+ record,
+ split=split,
+ line=line,
+ record_sha256=record_sha,
+ )
+ findings.extend(schema_findings)
+ identities.append(_RecordIdentity(split, line, record_sha, prompt_sha, answer_sha))
+
+ duplicate_key = (split, record_sha)
+ if duplicate_key in seen_records:
+ findings.append(
+ Finding(
+ "exact_duplicate",
+ "warning",
+ split,
+ line,
+ related_line=seen_records[duplicate_key],
+ record_sha256=record_sha,
+ )
+ )
+ else:
+ seen_records[duplicate_key] = line
+
+ if selected.scan_privacy:
+ for signal, category, field in scan_record(record):
+ findings.append(
+ Finding(
+ signal,
+ "critical" if signal == "secret_pattern" else "warning",
+ split,
+ line,
+ field=field,
+ category=category,
+ record_sha256=record_sha,
+ redacted=True,
+ )
+ )
+
+ inspection = inspector.inspect(record)
+ if not inspection.available:
+ if selected.require_assistant_mask:
+ mask_abstentions += 1
+ findings.append(
+ Finding(
+ inspection.reason or "assistant_mask_unavailable",
+ "info",
+ split,
+ line,
+ record_sha256=record_sha,
+ redacted=True,
+ )
+ )
+ else:
+ for signal in inspection.signals:
+ findings.append(
+ Finding(
+ signal.signal,
+ signal.severity,
+ split,
+ line,
+ record_sha256=record_sha,
+ )
+ )
+
+ prompt_answers: dict[str, dict[str, int]] = {}
+ for identity in identities:
+ if (
+ identity.split != "train"
+ or identity.prompt_sha256 is None
+ or identity.answer_sha256 is None
+ ):
+ continue
+ prior = prompt_answers.setdefault(identity.prompt_sha256, {})
+ if prior and identity.answer_sha256 not in prior:
+ findings.append(
+ Finding(
+ "prompt_conflict",
+ "error",
+ identity.split,
+ identity.line,
+ related_line=min(prior.values()),
+ record_sha256=identity.record_sha256,
+ )
+ )
+ prior.setdefault(identity.answer_sha256, identity.line)
+
+ prompts_by_split: dict[str, dict[str, int]] = {"train": {}, "evaluation": {}}
+ for identity in identities:
+ if identity.prompt_sha256 is not None:
+ prompts_by_split[identity.split].setdefault(identity.prompt_sha256, identity.line)
+ for prompt_sha in sorted(set(prompts_by_split["train"]) & set(prompts_by_split["evaluation"])):
+ findings.append(
+ Finding(
+ "train_eval_leakage",
+ "critical",
+ "train",
+ prompts_by_split["train"][prompt_sha],
+ related_line=prompts_by_split["evaluation"][prompt_sha],
+ record_sha256=prompt_sha,
+ )
+ )
+
+ findings.sort(
+ key=lambda finding: (
+ finding.split,
+ finding.line,
+ finding.signal,
+ finding.related_line or 0,
+ finding.category or "",
+ )
+ )
+ signal_counts = Counter(finding.signal for finding in findings)
+
+ schema_signals = {
+ "invalid_record",
+ "invalid_messages",
+ "invalid_role",
+ "role_order",
+ "missing_assistant",
+ }
+ exact_signals = {"exact_duplicate", "prompt_conflict", "train_eval_leakage"}
+ privacy_signals = {"secret_pattern", "pii_like"}
+ mask_signals = {
+ "prompt_token_supervised",
+ "assistant_fully_truncated",
+ "missing_eos",
+ }
+ checks = {
+ "schema": {"status": _check_status(findings, schema_signals)},
+ "duplicates_conflicts_and_leakage": {"status": _check_status(findings, exact_signals)},
+ "privacy_patterns": {"status": _check_status(findings, privacy_signals)},
+ "assistant_mask": {
+ "status": (
+ Status.ABSTAIN if mask_abstentions else _check_status(findings, mask_signals)
+ ),
+ "abstained_record_count": mask_abstentions,
+ },
+ }
+ overall = combine_status(*(entry["status"] for entry in checks.values()))
+
+ return seal_artifact(
+ {
+ "schema_version": "sftguard.audit.v1",
+ "tool": {"name": "sftguard", "version": __version__},
+ "status": overall,
+ "config": selected.as_dict(),
+ "inputs": [
+ train_summary.as_dict("train"),
+ evaluation_summary.as_dict("evaluation"),
+ ],
+ "checks": checks,
+ "summary": {
+ "finding_count": len(findings),
+ "signal_counts": dict(sorted(signal_counts.items())),
+ "error_or_critical_count": sum(
+ SEVERITY_ORDER[finding.severity] >= SEVERITY_ORDER["error"]
+ for finding in findings
+ ),
+ "redacted_finding_count": sum(finding.redacted for finding in findings),
+ },
+ "findings": [finding.as_dict() for finding in findings],
+ }
+ )
diff --git a/src/sftguard/benchmark.py b/src/sftguard/benchmark.py
new file mode 100644
index 0000000..a506879
--- /dev/null
+++ b/src/sftguard/benchmark.py
@@ -0,0 +1,124 @@
+"""Development-only execution of the preregistered synthetic fault matrix."""
+
+from __future__ import annotations
+
+import statistics
+from collections import defaultdict
+from collections.abc import Iterable
+from typing import Any
+
+from . import __version__
+from .artifacts import canonical_json_bytes, seal_artifact
+from .audit import audit_records
+from .models import Status
+from .synthetic import DEVELOPMENT_SEEDS, FAULT_SPECS, generate_case
+
+
+def validate_development_seeds(seeds: Iterable[int]) -> tuple[int, ...]:
+ selected = tuple(sorted(set(seeds)))
+ if not selected:
+ raise ValueError("at least one development seed is required")
+ if any(seed not in DEVELOPMENT_SEEDS for seed in selected):
+ raise ValueError("benchmark execution is restricted to development seeds 0 through 9")
+ return selected
+
+
+def _run_once(seeds: tuple[int, ...]) -> tuple[dict[str, Any], tuple[str, ...]]:
+ cases: list[dict[str, Any]] = []
+ raw_markers: list[str] = []
+ recall_by_fault: dict[str, list[bool]] = defaultdict(list)
+ clean_false_positives = 0
+ clean_count = 0
+
+ for seed in seeds:
+ for fault_id in (None, *(spec.fault_id for spec in FAULT_SPECS)):
+ case = generate_case(seed, fault_id)
+ raw_markers.extend(case.private_markers)
+ report = audit_records(case.train, case.evaluation)
+ observed_signals = sorted(report["summary"]["signal_counts"])
+ expected = case.fault.expected_signal if case.fault else None
+ observed_primary = expected in observed_signals if expected else not observed_signals
+ if case.fault is None:
+ clean_count += 1
+ if observed_signals:
+ clean_false_positives += 1
+ else:
+ recall_by_fault[case.fault.fault_id].append(observed_primary)
+ cases.append(
+ {
+ "seed": seed,
+ "kind": "fault" if case.fault else "clean_control",
+ "fault_id": case.fault.fault_id if case.fault else None,
+ "expected_primary_signal": expected,
+ "observed_primary_signal": observed_primary,
+ "observed_signals": observed_signals,
+ "audit_status": report["status"],
+ "case_sha256": case.case_sha256,
+ }
+ )
+
+ per_fault: list[dict[str, Any]] = []
+ fault_recalls: list[float] = []
+ for spec in FAULT_SPECS:
+ observations = recall_by_fault[spec.fault_id]
+ detected = sum(observations)
+ recall = detected / len(observations)
+ fault_recalls.append(recall)
+ per_fault.append(
+ {
+ "fault_id": spec.fault_id,
+ "expected_signal": spec.expected_signal,
+ "severity": spec.severity,
+ "case_count": len(observations),
+ "detected_count": detected,
+ "recall": round(recall, 12),
+ }
+ )
+
+ critical = [item for item in per_fault if item["severity"] == "critical"]
+ macro_recall = statistics.fmean(fault_recalls)
+ clean_false_positive_rate = clean_false_positives / clean_count
+ base_gates = {
+ "critical_fault_recall_100_percent": all(item["recall"] == 1.0 for item in critical),
+ "macro_recall_at_least_90_percent": macro_recall >= 0.9,
+ "clean_false_positive_rate_at_most_10_percent": clean_false_positive_rate <= 0.1,
+ "expected_and_observed_primary_recorded": all(
+ case["expected_primary_signal"] is None
+ or isinstance(case["observed_primary_signal"], bool)
+ for case in cases
+ ),
+ }
+ payload = {
+ "schema_version": "sftguard.development-benchmark.v1",
+ "tool": {"name": "sftguard", "version": __version__},
+ "development_only": True,
+ "seed_range": {"first": seeds[0], "last": seeds[-1], "count": len(seeds)},
+ "fault_count": len(FAULT_SPECS),
+ "summary": {
+ "case_count": len(cases),
+ "clean_control_count": clean_count,
+ "clean_false_positive_count": clean_false_positives,
+ "clean_false_positive_rate": round(clean_false_positive_rate, 12),
+ "macro_recall": round(macro_recall, 12),
+ },
+ "per_fault": per_fault,
+ "cases": cases,
+ "gates": base_gates,
+ }
+ return payload, tuple(raw_markers)
+
+
+def run_development_benchmark(
+ seeds: Iterable[int] = DEVELOPMENT_SEEDS,
+) -> dict[str, Any]:
+ selected = validate_development_seeds(seeds)
+ first, markers = _run_once(selected)
+ second, _ = _run_once(selected)
+ reproducible = canonical_json_bytes(first) == canonical_json_bytes(second)
+ rendered = canonical_json_bytes(first).decode("utf-8")
+ redacted = all(marker not in rendered for marker in markers)
+ first["gates"]["in_process_byte_reproducible"] = reproducible
+ first["gates"]["raw_synthetic_text_absent"] = redacted
+ first["all_development_gates_passed"] = all(first["gates"].values())
+ first["status"] = Status.PASS if first["all_development_gates_passed"] else Status.FAIL
+ return seal_artifact(first)
diff --git a/src/sftguard/cli.py b/src/sftguard/cli.py
new file mode 100644
index 0000000..c488e3a
--- /dev/null
+++ b/src/sftguard/cli.py
@@ -0,0 +1,146 @@
+"""Command-line interface for audit, gate, benchmark, and demo workflows."""
+
+from __future__ import annotations
+
+import argparse
+import re
+from collections.abc import Sequence
+from typing import Any
+
+from . import __version__
+from .artifacts import seal_artifact, write_artifact
+from .audit import AuditConfig, audit_files
+from .benchmark import run_development_benchmark, validate_development_seeds
+from .demo import run_demo
+from .io import InputError, read_json_bounded, read_jsonl_bounded
+from .models import Status
+from .regression import build_regression_card
+
+EXIT_CODES = {Status.PASS: 0, Status.FAIL: 1, Status.ABSTAIN: 2}
+
+
+def _bounded_positive_int(value: str) -> int:
+ parsed = int(value)
+ if parsed <= 0:
+ raise argparse.ArgumentTypeError("value must be positive")
+ return parsed
+
+
+def _parse_seeds(value: str) -> tuple[int, ...]:
+ try:
+ selected: list[int] = []
+ for part in value.split(","):
+ part = part.strip()
+ range_match = re.fullmatch(r"([0-9])-([0-9])", part)
+ if range_match:
+ start, end = (int(component) for component in range_match.groups())
+ if end < start:
+ raise ValueError
+ selected.extend(range(start, end + 1))
+ elif re.fullmatch(r"[0-9]", part):
+ selected.append(int(part))
+ else:
+ raise ValueError
+ return validate_development_seeds(selected)
+ except ValueError as exc:
+ raise argparse.ArgumentTypeError(
+ "seeds must be a subset of development seeds 0 through 9"
+ ) from exc
+
+
+def _input_abstention(schema_version: str) -> dict[str, Any]:
+ return seal_artifact(
+ {
+ "schema_version": schema_version,
+ "tool": {"name": "sftguard", "version": __version__},
+ "status": Status.ABSTAIN,
+ "reason_codes": ["invalid_unreadable_or_oversized_input"],
+ }
+ )
+
+
+def _emit(artifact: dict[str, Any], output: str | None) -> int:
+ write_artifact(output, artifact)
+ status = Status(artifact["status"])
+ return EXIT_CODES[status]
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ prog="sftguard",
+ description="Local-first correctness and regression gates for SFT pipelines.",
+ )
+ parser.add_argument("--version", action="version", version=__version__)
+ commands = parser.add_subparsers(dest="command", required=True)
+
+ audit = commands.add_parser("audit", help="audit training and evaluation JSONL files")
+ audit.add_argument("--train", required=True)
+ audit.add_argument("--eval", required=True, dest="evaluation")
+ audit.add_argument("--output")
+ audit.add_argument("--max-file-bytes", type=_bounded_positive_int, default=16 * 1024 * 1024)
+ audit.add_argument("--max-line-bytes", type=_bounded_positive_int, default=1024 * 1024)
+ audit.add_argument("--max-records", type=_bounded_positive_int, default=100_000)
+
+ gate = commands.add_parser("gate", help="build a paired target/retention Regression Card")
+ gate.add_argument("--base", required=True)
+ gate.add_argument("--adapter", required=True)
+ gate.add_argument("--contract", required=True)
+ gate.add_argument("--output")
+ gate.add_argument("--max-file-bytes", type=_bounded_positive_int, default=16 * 1024 * 1024)
+ gate.add_argument("--max-line-bytes", type=_bounded_positive_int, default=1024 * 1024)
+ gate.add_argument("--max-records", type=_bounded_positive_int, default=100_000)
+
+ benchmark = commands.add_parser(
+ "benchmark", help="run the deterministic development-only synthetic matrix"
+ )
+ benchmark.add_argument("--seeds", type=_parse_seeds, default=tuple(range(10)))
+ benchmark.add_argument("--output")
+
+ demo = commands.add_parser("demo", help="run a redacted deterministic end-to-end demo")
+ demo.add_argument("--output")
+ return parser
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ args = build_parser().parse_args(argv)
+ if args.command == "audit":
+ config = AuditConfig(
+ max_file_bytes=args.max_file_bytes,
+ max_line_bytes=args.max_line_bytes,
+ max_records=args.max_records,
+ )
+ return _emit(audit_files(args.train, args.evaluation, config=config), args.output)
+
+ if args.command == "gate":
+ try:
+ base = read_jsonl_bounded(
+ args.base,
+ max_file_bytes=args.max_file_bytes,
+ max_line_bytes=args.max_line_bytes,
+ max_records=args.max_records,
+ )
+ adapter = read_jsonl_bounded(
+ args.adapter,
+ max_file_bytes=args.max_file_bytes,
+ max_line_bytes=args.max_line_bytes,
+ max_records=args.max_records,
+ )
+ contract, _, _ = read_json_bounded(args.contract, max_file_bytes=args.max_file_bytes)
+ artifact = build_regression_card(
+ base.records,
+ adapter.records,
+ contract,
+ base_summary=base.summary,
+ adapter_summary=adapter.summary,
+ )
+ except InputError:
+ artifact = _input_abstention("sftguard.regression-card.v1")
+ return _emit(artifact, args.output)
+
+ if args.command == "benchmark":
+ return _emit(run_development_benchmark(args.seeds), args.output)
+
+ if args.command == "demo":
+ return _emit(run_demo(), args.output)
+
+ raise AssertionError("argparse accepted an unknown command")
diff --git a/src/sftguard/confirmatory.py b/src/sftguard/confirmatory.py
new file mode 100644
index 0000000..8bda5fc
--- /dev/null
+++ b/src/sftguard/confirmatory.py
@@ -0,0 +1,231 @@
+"""Frozen v0.1 confirmatory fault-matrix evidence construction.
+
+The public orchestration script invokes this module only from a clean, pushed
+implementation commit. Keeping construction separate from orchestration makes
+the payload testable on development seeds without touching the confirmatory
+range before the implementation is frozen.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import platform
+import re
+import statistics
+import sys
+from collections import defaultdict
+from collections.abc import Callable, Iterable, Sequence
+from typing import Any
+
+from . import __version__
+from .artifacts import canonical_json_bytes, seal_artifact
+from .audit import AuditConfig, audit_records
+from .models import Status
+from .synthetic import (
+ CONFIRMATORY_SEEDS,
+ FAULT_SPECS,
+ SyntheticCase,
+ generate_confirmatory_case,
+)
+
+_FULL_COMMIT = re.compile(r"^[0-9a-f]{40}$")
+
+
+def pretty_json_bytes(value: Any) -> bytes:
+ """Render stable human-readable JSON for the checked-in evidence file."""
+
+ return (
+ json.dumps(
+ value,
+ ensure_ascii=False,
+ allow_nan=False,
+ indent=2,
+ sort_keys=True,
+ )
+ + "\n"
+ ).encode("utf-8")
+
+
+def _validate_commit(value: str, label: str) -> None:
+ if _FULL_COMMIT.fullmatch(value) is None:
+ raise ValueError(f"{label} must be a full lowercase Git commit")
+
+
+def _build_artifact(
+ seeds: Iterable[int],
+ case_factory: Callable[[int, str | None], SyntheticCase],
+ *,
+ protocol_commit: str,
+ implementation_commit: str,
+ fresh_process_byte_reproducible: bool,
+) -> dict[str, Any]:
+ """Build a sealed matrix; tests exercise this with development seeds."""
+
+ _validate_commit(protocol_commit, "protocol_commit")
+ _validate_commit(implementation_commit, "implementation_commit")
+ selected = tuple(seeds)
+ if not selected or tuple(sorted(set(selected))) != selected:
+ raise ValueError("seeds must be a non-empty sorted unique sequence")
+
+ cases: list[dict[str, Any]] = []
+ recall_by_fault: dict[str, list[bool]] = defaultdict(list)
+ private_text: set[str] = set()
+ clean_false_positives = 0
+
+ for seed in selected:
+ for fault_id in (None, *(spec.fault_id for spec in FAULT_SPECS)):
+ case = case_factory(seed, fault_id)
+ private_text.update(case.private_markers)
+ for _, record in (*case.train, *case.evaluation):
+ messages = record.get("messages")
+ if isinstance(messages, list):
+ private_text.update(
+ message["content"]
+ for message in messages
+ if isinstance(message, dict) and isinstance(message.get("content"), str)
+ )
+
+ report = audit_records(case.train, case.evaluation)
+ observed_signals = tuple(sorted(report["summary"]["signal_counts"]))
+ expected = case.fault.expected_signal if case.fault else None
+ expectation_met = expected in observed_signals if expected else not observed_signals
+ observed_primary = expected if expected in observed_signals else None
+
+ if case.fault is None:
+ if observed_signals:
+ clean_false_positives += 1
+ else:
+ recall_by_fault[case.fault.fault_id].append(expectation_met)
+
+ cases.append(
+ {
+ "audit_status": report["status"],
+ "case_sha256": case.case_sha256,
+ "expected_primary_signal": expected,
+ "fault_id": case.fault.fault_id if case.fault else None,
+ "kind": "fault" if case.fault else "clean_control",
+ "observed_primary_signal": observed_primary,
+ "observed_signal_ids": list(observed_signals),
+ "primary_expectation_met": expectation_met,
+ "seed": seed,
+ }
+ )
+
+ per_fault: list[dict[str, Any]] = []
+ recalls: list[float] = []
+ for spec in FAULT_SPECS:
+ observations = recall_by_fault[spec.fault_id]
+ detected_count = sum(observations)
+ recall = detected_count / len(observations)
+ recalls.append(recall)
+ per_fault.append(
+ {
+ "case_count": len(observations),
+ "detected_count": detected_count,
+ "expected_signal": spec.expected_signal,
+ "fault_id": spec.fault_id,
+ "recall": round(recall, 12),
+ "severity": spec.severity,
+ }
+ )
+
+ clean_count = len(selected)
+ clean_false_positive_rate = clean_false_positives / clean_count
+ macro_recall = statistics.fmean(recalls)
+ critical_faults = [item for item in per_fault if item["severity"] == "critical"]
+ expected_observed_recorded = all(
+ "expected_primary_signal" in case and "observed_primary_signal" in case for case in cases
+ )
+
+ payload_without_redaction_gate: dict[str, Any] = {
+ "schema_version": "sftguard.confirmatory-fault-matrix.v1",
+ "tool": {"name": "sftguard", "version": __version__},
+ "provenance": {
+ "implementation_git_commit": implementation_commit,
+ "protocol_git_commit": protocol_commit,
+ "python_implementation": platform.python_implementation(),
+ "python_version": f"{sys.version_info.major}.{sys.version_info.minor}",
+ },
+ "protocol": {
+ "audit_config": AuditConfig().as_dict(),
+ "faults": [
+ {
+ "expected_signal": spec.expected_signal,
+ "fault_id": spec.fault_id,
+ "severity": spec.severity,
+ }
+ for spec in FAULT_SPECS
+ ],
+ "seed_range": {
+ "count": len(selected),
+ "first": selected[0],
+ "last": selected[-1],
+ },
+ },
+ "summary": {
+ "case_count": len(cases),
+ "clean_control_count": clean_count,
+ "clean_false_positive_count": clean_false_positives,
+ "clean_false_positive_rate": round(clean_false_positive_rate, 12),
+ "fault_case_count": len(cases) - clean_count,
+ "macro_recall": round(macro_recall, 12),
+ },
+ "per_fault": per_fault,
+ "cases": cases,
+ "gates": {
+ "clean_false_positive_rate_at_most_10_percent": (clean_false_positive_rate <= 0.10),
+ "critical_fault_recall_100_percent": all(
+ item["recall"] == 1.0 for item in critical_faults
+ ),
+ "expected_and_observed_primary_recorded": expected_observed_recorded,
+ "fresh_process_byte_reproducible": fresh_process_byte_reproducible,
+ "macro_recall_at_least_90_percent": macro_recall >= 0.90,
+ },
+ }
+ rendered = canonical_json_bytes(payload_without_redaction_gate).decode("utf-8")
+ payload_without_redaction_gate["gates"]["raw_synthetic_text_absent"] = all(
+ value not in rendered for value in private_text
+ )
+ carry_forward = all(payload_without_redaction_gate["gates"].values())
+ payload_without_redaction_gate["carry_forward"] = carry_forward
+ payload_without_redaction_gate["status"] = Status.PASS if carry_forward else Status.FAIL
+ return seal_artifact(payload_without_redaction_gate)
+
+
+def build_confirmatory_artifact(
+ *,
+ protocol_commit: str,
+ implementation_commit: str,
+ fresh_process_byte_reproducible: bool = True,
+) -> dict[str, Any]:
+ """Execute the exact preregistered seed range and fault matrix."""
+
+ return _build_artifact(
+ CONFIRMATORY_SEEDS,
+ generate_confirmatory_case,
+ protocol_commit=protocol_commit,
+ implementation_commit=implementation_commit,
+ fresh_process_byte_reproducible=fresh_process_byte_reproducible,
+ )
+
+
+def _parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(description="Build frozen SFTGuard evidence bytes.")
+ parser.add_argument("--protocol-commit", required=True)
+ parser.add_argument("--implementation-commit", required=True)
+ return parser
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ args = _parser().parse_args(argv)
+ artifact = build_confirmatory_artifact(
+ protocol_commit=args.protocol_commit,
+ implementation_commit=args.implementation_commit,
+ )
+ sys.stdout.buffer.write(pretty_json_bytes(artifact))
+ return 0 if artifact["status"] == Status.PASS else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/src/sftguard/demo.py b/src/sftguard/demo.py
new file mode 100644
index 0000000..fdf6e6e
--- /dev/null
+++ b/src/sftguard/demo.py
@@ -0,0 +1,77 @@
+"""Small deterministic, redacted end-to-end demonstration."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from . import __version__
+from .artifacts import seal_artifact
+from .audit import audit_records
+from .models import Status
+from .regression import build_regression_card
+from .synthetic import generate_case
+
+
+def _evaluation_records() -> tuple[
+ tuple[tuple[int, dict[str, Any]], ...], tuple[tuple[int, dict[str, Any]], ...]
+]:
+ base: list[tuple[int, dict[str, Any]]] = []
+ adapter: list[tuple[int, dict[str, Any]]] = []
+ line = 1
+ for seed in range(4):
+ for group, metric, base_value, adapter_value in (
+ ("target", "task_score", 0.50 + seed * 0.01, 0.65 + seed * 0.01),
+ ("retention", "general_score", 0.80 - seed * 0.01, 0.795 - seed * 0.01),
+ ):
+ common = {
+ "seed": seed,
+ "sample_id": f"development-{group}-{seed}",
+ "group": group,
+ "metric": metric,
+ }
+ base.append((line, {**common, "value": base_value}))
+ adapter.append((line, {**common, "value": adapter_value}))
+ line += 1
+ return tuple(base), tuple(adapter)
+
+
+def run_demo() -> dict[str, Any]:
+ case = generate_case(0)
+ audit = audit_records(case.train, case.evaluation)
+ base, adapter = _evaluation_records()
+ card = build_regression_card(
+ base,
+ adapter,
+ {
+ "bootstrap": {"draws": 10_000, "confidence": 0.95, "seed": 2339},
+ "metrics": [
+ {
+ "group": "target",
+ "metric": "task_score",
+ "higher_is_better": True,
+ "min_improvement": 0.10,
+ },
+ {
+ "group": "retention",
+ "metric": "general_score",
+ "higher_is_better": True,
+ "max_regression": 0.02,
+ },
+ ],
+ },
+ )
+ status = (
+ Status.PASS
+ if audit["status"] == Status.PASS and card["status"] == Status.PASS
+ else Status.FAIL
+ )
+ return seal_artifact(
+ {
+ "schema_version": "sftguard.demo.v1",
+ "tool": {"name": "sftguard", "version": __version__},
+ "status": status,
+ "note": "Deterministic synthetic development demonstration; not external evidence.",
+ "audit": audit,
+ "regression_card": card,
+ }
+ )
diff --git a/src/sftguard/io.py b/src/sftguard/io.py
new file mode 100644
index 0000000..cb1a21f
--- /dev/null
+++ b/src/sftguard/io.py
@@ -0,0 +1,153 @@
+"""Bounded JSON and JSONL readers used by every untrusted input path."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+from collections.abc import Iterable
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+DEFAULT_MAX_FILE_BYTES = 16 * 1024 * 1024
+DEFAULT_MAX_LINE_BYTES = 1024 * 1024
+DEFAULT_MAX_RECORDS = 100_000
+
+
+class InputError(ValueError):
+ """Raised when an input cannot be safely or unambiguously parsed."""
+
+
+@dataclass(frozen=True)
+class InputSummary:
+ sha256: str
+ byte_count: int
+ record_count: int
+
+ def as_dict(self, split: str) -> dict[str, Any]:
+ return {
+ "split": split,
+ "sha256": self.sha256,
+ "byte_count": self.byte_count,
+ "record_count": self.record_count,
+ }
+
+
+@dataclass(frozen=True)
+class ParsedJSONL:
+ records: tuple[tuple[int, dict[str, Any]], ...]
+ summary: InputSummary
+
+
+def _reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
+ result: dict[str, Any] = {}
+ for key, value in pairs:
+ if key in result:
+ raise InputError("JSON object contains a duplicate key")
+ result[key] = value
+ return result
+
+
+def _decode_json(raw: bytes, *, context: str) -> Any:
+ try:
+ text = raw.decode("utf-8")
+ except UnicodeDecodeError as exc:
+ raise InputError(f"{context} is not valid UTF-8") from exc
+ try:
+ return json.loads(
+ text,
+ object_pairs_hook=_reject_duplicate_keys,
+ parse_constant=lambda _value: (_ for _ in ()).throw(
+ InputError(f"{context} contains a non-finite number")
+ ),
+ )
+ except InputError:
+ raise
+ except (json.JSONDecodeError, RecursionError) as exc:
+ raise InputError(f"{context} is not valid JSON") from exc
+
+
+def read_jsonl_bounded(
+ path: str | Path,
+ *,
+ max_file_bytes: int = DEFAULT_MAX_FILE_BYTES,
+ max_line_bytes: int = DEFAULT_MAX_LINE_BYTES,
+ max_records: int = DEFAULT_MAX_RECORDS,
+) -> ParsedJSONL:
+ source = Path(path)
+ try:
+ size = source.stat().st_size
+ except OSError as exc:
+ raise InputError("input file cannot be read") from exc
+ if size > max_file_bytes:
+ raise InputError("input file exceeds the configured byte limit")
+
+ digest = hashlib.sha256()
+ records: list[tuple[int, dict[str, Any]]] = []
+ bytes_seen = 0
+ try:
+ with source.open("rb") as stream:
+ for line_number, raw_line in enumerate(stream, start=1):
+ bytes_seen += len(raw_line)
+ digest.update(raw_line)
+ if bytes_seen > max_file_bytes:
+ raise InputError("input file grew beyond the configured byte limit")
+ if len(raw_line) > max_line_bytes:
+ raise InputError(f"JSONL line {line_number} exceeds the byte limit")
+ stripped = raw_line.strip()
+ if not stripped:
+ continue
+ if len(records) >= max_records:
+ raise InputError("input file exceeds the configured record limit")
+ decoded = _decode_json(stripped, context=f"JSONL line {line_number}")
+ if not isinstance(decoded, dict):
+ raise InputError(f"JSONL line {line_number} must be an object")
+ records.append((line_number, decoded))
+ except InputError:
+ raise
+ except OSError as exc:
+ raise InputError("input file cannot be read") from exc
+
+ return ParsedJSONL(
+ records=tuple(records),
+ summary=InputSummary(
+ sha256=digest.hexdigest(),
+ byte_count=bytes_seen,
+ record_count=len(records),
+ ),
+ )
+
+
+def read_json_bounded(
+ path: str | Path, *, max_file_bytes: int = DEFAULT_MAX_FILE_BYTES
+) -> tuple[Any, str, int]:
+ source = Path(path)
+ try:
+ size = source.stat().st_size
+ if size > max_file_bytes:
+ raise InputError("JSON file exceeds the configured byte limit")
+ raw = source.read_bytes()
+ except InputError:
+ raise
+ except OSError as exc:
+ raise InputError("JSON file cannot be read") from exc
+ if len(raw) > max_file_bytes:
+ raise InputError("JSON file grew beyond the configured byte limit")
+ return _decode_json(raw, context="JSON file"), hashlib.sha256(raw).hexdigest(), len(raw)
+
+
+def summarize_records(records: Iterable[tuple[int, dict[str, Any]]]) -> InputSummary:
+ """Create a deterministic input summary for generated in-memory records."""
+
+ from .artifacts import canonical_json_bytes
+
+ materialized = [record for _, record in records]
+ try:
+ raw = canonical_json_bytes(materialized)
+ except (TypeError, ValueError, RecursionError):
+ raw = canonical_json_bytes({"invalid_input": True, "record_count": len(materialized)})
+ return InputSummary(
+ sha256=hashlib.sha256(raw).hexdigest(),
+ byte_count=len(raw),
+ record_count=len(materialized),
+ )
diff --git a/src/sftguard/models.py b/src/sftguard/models.py
new file mode 100644
index 0000000..f31699b
--- /dev/null
+++ b/src/sftguard/models.py
@@ -0,0 +1,59 @@
+"""Shared status and finding types."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from dataclasses import field as dataclass_field
+from enum import StrEnum
+from typing import Any
+
+
+class Status(StrEnum):
+ PASS = "PASS"
+ FAIL = "FAIL"
+ ABSTAIN = "ABSTAIN"
+
+
+SEVERITY_ORDER = {"info": 0, "warning": 1, "error": 2, "critical": 3}
+
+
+@dataclass(frozen=True)
+class Finding:
+ signal: str
+ severity: str
+ split: str
+ line: int
+ field: str | None = None
+ category: str | None = None
+ related_line: int | None = None
+ record_sha256: str | None = None
+ redacted: bool = False
+ details: dict[str, Any] = dataclass_field(default_factory=dict)
+
+ def as_dict(self) -> dict[str, Any]:
+ result: dict[str, Any] = {
+ "signal": self.signal,
+ "severity": self.severity,
+ "location": {"split": self.split, "line": self.line},
+ }
+ if self.field is not None:
+ result["location"]["field"] = self.field
+ if self.category is not None:
+ result["category"] = self.category
+ if self.related_line is not None:
+ result["related_line"] = self.related_line
+ if self.record_sha256 is not None:
+ result["record_sha256"] = self.record_sha256
+ if self.redacted:
+ result["redacted"] = True
+ if self.details:
+ result["details"] = dict(sorted(self.details.items()))
+ return result
+
+
+def combine_status(*statuses: Status) -> Status:
+ if Status.FAIL in statuses:
+ return Status.FAIL
+ if Status.ABSTAIN in statuses:
+ return Status.ABSTAIN
+ return Status.PASS
diff --git a/src/sftguard/privacy.py b/src/sftguard/privacy.py
new file mode 100644
index 0000000..a8bb9c7
--- /dev/null
+++ b/src/sftguard/privacy.py
@@ -0,0 +1,63 @@
+"""Conservative secret and PII-like pattern location without value retention."""
+
+from __future__ import annotations
+
+import re
+from collections.abc import Iterator
+from typing import Any
+
+_SECRET_PATTERNS = (
+ ("openai_style_key", re.compile(r"\bsk-(?:test-)?[A-Za-z0-9_-]{12,}\b")),
+ ("aws_access_key", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
+ (
+ "assigned_credential",
+ re.compile(
+ r"(?i)\b(?:api[_-]?key|access[_-]?token|auth[_-]?token|password|secret)"
+ r"\s*[:=]\s*[\"']?[A-Za-z0-9_./+=-]{12,}"
+ ),
+ ),
+ ("bearer_token", re.compile(r"(?i)\bbearer\s+[A-Za-z0-9._~+/=-]{12,}")),
+)
+
+_PII_PATTERNS = (
+ (
+ "email_address",
+ re.compile(r"(?i)(? str:
+ for component in reversed(path):
+ if component in {"content", "arguments", "name", "role"}:
+ return str(component)
+ return "other"
+
+
+def iter_strings(value: Any, path: tuple[str | int, ...] = ()) -> Iterator[tuple[str, str]]:
+ if isinstance(value, str):
+ yield _field_label(path), value
+ elif isinstance(value, list):
+ for index, item in enumerate(value):
+ yield from iter_strings(item, path + (index,))
+ elif isinstance(value, dict):
+ for key in sorted(value):
+ yield from iter_strings(value[key], path + (key,))
+
+
+def scan_record(value: dict[str, Any]) -> tuple[tuple[str, str, str], ...]:
+ """Return unique ``(signal, category, safe-field)`` tuples, never matches."""
+
+ findings: set[tuple[str, str, str]] = set()
+ for field, text in iter_strings(value):
+ for category, pattern in _SECRET_PATTERNS:
+ if pattern.search(text):
+ findings.add(("secret_pattern", category, field))
+ for category, pattern in _PII_PATTERNS:
+ if pattern.search(text):
+ findings.add(("pii_like", category, field))
+ return tuple(sorted(findings))
diff --git a/src/sftguard/regression.py b/src/sftguard/regression.py
new file mode 100644
index 0000000..032f419
--- /dev/null
+++ b/src/sftguard/regression.py
@@ -0,0 +1,387 @@
+"""Paired target/retention Regression Cards with deterministic bootstrap CIs."""
+
+from __future__ import annotations
+
+import math
+import re
+import statistics
+from collections.abc import Iterable, Mapping
+from dataclasses import dataclass
+from typing import Any
+
+from . import __version__
+from .artifacts import canonical_json_bytes, seal_artifact, sha256_bytes
+from .io import InputSummary, summarize_records
+from .models import Status
+
+_IDENTIFIER = re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
+MAX_BOOTSTRAP_WORK = 10_000_000
+
+
+@dataclass(frozen=True)
+class BootstrapConfig:
+ draws: int = 10_000
+ confidence: float = 0.95
+ seed: int = 2339
+
+
+class _XorShift32:
+ def __init__(self, seed: int):
+ self.state = seed & 0xFFFFFFFF or 0x6D2B79F5
+
+ def next_u32(self) -> int:
+ value = self.state
+ value ^= (value << 13) & 0xFFFFFFFF
+ value ^= value >> 17
+ value ^= (value << 5) & 0xFFFFFFFF
+ self.state = value & 0xFFFFFFFF
+ return self.state
+
+ def randbelow(self, stop: int) -> int:
+ if stop <= 0:
+ raise ValueError("stop must be positive")
+ return self.next_u32() % stop
+
+
+def _round(value: float) -> float:
+ return round(value, 12)
+
+
+def _percentile(sorted_values: list[float], probability: float) -> float:
+ if not sorted_values:
+ raise ValueError("cannot calculate a percentile of no values")
+ position = (len(sorted_values) - 1) * probability
+ lower = math.floor(position)
+ upper = math.ceil(position)
+ if lower == upper:
+ return sorted_values[lower]
+ fraction = position - lower
+ return sorted_values[lower] * (1.0 - fraction) + sorted_values[upper] * fraction
+
+
+def seed_cluster_bootstrap(
+ values_by_seed: Mapping[int, tuple[float, ...]], config: BootstrapConfig
+) -> tuple[float, float]:
+ """Cluster-resample seeds, retaining all pairs inside each selected seed."""
+
+ seeds = sorted(values_by_seed)
+ if not seeds:
+ raise ValueError("at least one seed cluster is required")
+ if config.draws <= 0 or config.draws > 100_000:
+ raise ValueError("bootstrap draws must be between 1 and 100000")
+ if not 0.0 < config.confidence < 1.0:
+ raise ValueError("bootstrap confidence must be between 0 and 1")
+
+ generator = _XorShift32(config.seed)
+ estimates: list[float] = []
+ for _ in range(config.draws):
+ sampled_values: list[float] = []
+ for _ in seeds:
+ selected_seed = seeds[generator.randbelow(len(seeds))]
+ sampled_values.extend(values_by_seed[selected_seed])
+ estimates.append(statistics.fmean(sampled_values))
+ estimates.sort()
+ tail = (1.0 - config.confidence) / 2.0
+ return _round(_percentile(estimates, tail)), _round(_percentile(estimates, 1.0 - tail))
+
+
+@dataclass(frozen=True)
+class _Evaluation:
+ seed: int
+ sample_id: str
+ group: str
+ metric: str
+ value: float
+
+ @property
+ def key(self) -> tuple[int, str, str, str]:
+ return self.seed, self.sample_id, self.group, self.metric
+
+
+def _parse_evaluations(
+ records: Iterable[tuple[int, dict[str, Any]]],
+) -> tuple[dict[tuple[int, str, str, str], _Evaluation], set[str]]:
+ parsed: dict[tuple[int, str, str, str], _Evaluation] = {}
+ errors: set[str] = set()
+ for _, record in records:
+ seed = record.get("seed")
+ sample_id = record.get("sample_id")
+ group = record.get("group")
+ metric = record.get("metric")
+ value = record.get("value")
+ valid = (
+ isinstance(seed, int)
+ and not isinstance(seed, bool)
+ and isinstance(sample_id, str)
+ and 0 < len(sample_id) <= 512
+ and isinstance(group, str)
+ and _IDENTIFIER.fullmatch(group) is not None
+ and isinstance(metric, str)
+ and _IDENTIFIER.fullmatch(metric) is not None
+ and isinstance(value, (int, float))
+ and not isinstance(value, bool)
+ and math.isfinite(float(value))
+ )
+ if not valid:
+ errors.add("invalid_evaluation_record")
+ continue
+ evaluation = _Evaluation(seed, sample_id, group, metric, float(value))
+ if evaluation.key in parsed:
+ errors.add("duplicate_pair_key")
+ else:
+ parsed[evaluation.key] = evaluation
+ return parsed, errors
+
+
+def _parse_contract(contract: Any) -> tuple[list[dict[str, Any]], BootstrapConfig, set[str]]:
+ errors: set[str] = set()
+ if not isinstance(contract, dict):
+ return [], BootstrapConfig(), {"invalid_contract"}
+ raw_metrics = contract.get("metrics")
+ raw_bootstrap = contract.get("bootstrap", {})
+ if not isinstance(raw_metrics, list) or not raw_metrics:
+ errors.add("invalid_contract_metrics")
+ raw_metrics = []
+ if not isinstance(raw_bootstrap, dict):
+ errors.add("invalid_bootstrap_config")
+ raw_bootstrap = {}
+
+ draws = raw_bootstrap.get("draws", 10_000)
+ confidence = raw_bootstrap.get("confidence", 0.95)
+ seed = raw_bootstrap.get("seed", 2339)
+ if (
+ not isinstance(draws, int)
+ or isinstance(draws, bool)
+ or not 1 <= draws <= 100_000
+ or not isinstance(confidence, (int, float))
+ or isinstance(confidence, bool)
+ or not 0.0 < float(confidence) < 1.0
+ or not isinstance(seed, int)
+ or isinstance(seed, bool)
+ ):
+ errors.add("invalid_bootstrap_config")
+ bootstrap = BootstrapConfig()
+ else:
+ bootstrap = BootstrapConfig(draws, float(confidence), seed)
+
+ parsed: list[dict[str, Any]] = []
+ seen: set[tuple[str, str]] = set()
+ groups: set[str] = set()
+ for raw in raw_metrics:
+ if not isinstance(raw, dict):
+ errors.add("invalid_metric_contract")
+ continue
+ group = raw.get("group")
+ metric = raw.get("metric")
+ higher = raw.get("higher_is_better")
+ if (
+ group not in {"target", "retention"}
+ or not isinstance(metric, str)
+ or _IDENTIFIER.fullmatch(metric) is None
+ or not isinstance(higher, bool)
+ ):
+ errors.add("invalid_metric_contract")
+ continue
+ key = (group, metric)
+ if key in seen:
+ errors.add("duplicate_metric_contract")
+ continue
+ seen.add(key)
+ groups.add(group)
+
+ if group == "target":
+ threshold = raw.get("min_improvement", 0.0)
+ threshold_name = "min_improvement"
+ if not isinstance(threshold, (int, float)) or isinstance(threshold, bool):
+ errors.add("invalid_metric_threshold")
+ continue
+ else:
+ threshold = raw.get("max_regression")
+ threshold_name = "max_regression"
+ if (
+ not isinstance(threshold, (int, float))
+ or isinstance(threshold, bool)
+ or float(threshold) < 0.0
+ ):
+ errors.add("invalid_metric_threshold")
+ continue
+ if not math.isfinite(float(threshold)):
+ errors.add("invalid_metric_threshold")
+ continue
+ parsed.append(
+ {
+ "group": group,
+ "metric": metric,
+ "higher_is_better": higher,
+ threshold_name: float(threshold),
+ }
+ )
+
+ if groups != {"target", "retention"}:
+ errors.add("target_and_retention_contracts_required")
+ return parsed, bootstrap, errors
+
+
+def _abstain_card(
+ reasons: set[str],
+ *,
+ base_summary: InputSummary,
+ adapter_summary: InputSummary,
+ contract_hash: str,
+) -> dict[str, Any]:
+ return seal_artifact(
+ {
+ "schema_version": "sftguard.regression-card.v1",
+ "tool": {"name": "sftguard", "version": __version__},
+ "status": Status.ABSTAIN,
+ "inputs": [
+ base_summary.as_dict("base"),
+ adapter_summary.as_dict("adapter"),
+ {"split": "contract", "sha256": contract_hash},
+ ],
+ "reason_codes": sorted(reasons),
+ "metrics": [],
+ "summary": {"metric_count": 0, "pair_count": 0, "seed_cluster_count": 0},
+ }
+ )
+
+
+def _safe_summary(records: tuple[tuple[int, dict[str, Any]], ...]) -> InputSummary:
+ try:
+ return summarize_records(records)
+ except (TypeError, ValueError, RecursionError):
+ marker = canonical_json_bytes({"invalid_input": True, "record_count": len(records)})
+ return InputSummary(
+ sha256=sha256_bytes(marker),
+ byte_count=len(marker),
+ record_count=len(records),
+ )
+
+
+def _safe_contract_hash(contract: Any) -> str:
+ try:
+ material = canonical_json_bytes(contract)
+ except (TypeError, ValueError, RecursionError):
+ material = canonical_json_bytes({"invalid_contract": True})
+ return sha256_bytes(material)
+
+
+def build_regression_card(
+ base_records: Iterable[tuple[int, dict[str, Any]]],
+ adapter_records: Iterable[tuple[int, dict[str, Any]]],
+ contract: Any,
+ *,
+ base_summary: InputSummary | None = None,
+ adapter_summary: InputSummary | None = None,
+) -> dict[str, Any]:
+ base_materialized = tuple(base_records)
+ adapter_materialized = tuple(adapter_records)
+ base_summary = base_summary or _safe_summary(base_materialized)
+ adapter_summary = adapter_summary or _safe_summary(adapter_materialized)
+ contract_hash = _safe_contract_hash(contract)
+
+ base, base_errors = _parse_evaluations(base_materialized)
+ adapter, adapter_errors = _parse_evaluations(adapter_materialized)
+ metric_contracts, bootstrap, contract_errors = _parse_contract(contract)
+ errors = base_errors | adapter_errors | contract_errors
+ if set(base) != set(adapter):
+ errors.add("unpaired_evaluation_records")
+ if not base:
+ errors.add("paired_evaluation_records_required")
+
+ declared = {(item["group"], item["metric"]) for item in metric_contracts}
+ observed = {(item.group, item.metric) for item in base.values()}
+ if observed - declared:
+ errors.add("undeclared_metric_records")
+ if declared - observed:
+ errors.add("contract_metric_without_pairs")
+ if bootstrap.draws * len(base) > MAX_BOOTSTRAP_WORK:
+ errors.add("bootstrap_work_limit_exceeded")
+ if errors:
+ return _abstain_card(
+ errors,
+ base_summary=base_summary,
+ adapter_summary=adapter_summary,
+ contract_hash=contract_hash,
+ )
+
+ metric_results: list[dict[str, Any]] = []
+ all_seeds: set[int] = set()
+ for metric_contract in sorted(
+ metric_contracts, key=lambda item: (item["group"], item["metric"])
+ ):
+ group = metric_contract["group"]
+ metric = metric_contract["metric"]
+ sign = 1.0 if metric_contract["higher_is_better"] else -1.0
+ values_by_seed: dict[int, list[float]] = {}
+ for key, base_value in base.items():
+ if base_value.group != group or base_value.metric != metric:
+ continue
+ delta = sign * (adapter[key].value - base_value.value)
+ values_by_seed.setdefault(base_value.seed, []).append(delta)
+ all_seeds.add(base_value.seed)
+ frozen_clusters = {seed: tuple(values) for seed, values in sorted(values_by_seed.items())}
+ all_values = [value for values in frozen_clusters.values() for value in values]
+ estimate = _round(statistics.fmean(all_values))
+ ci_low, ci_high = seed_cluster_bootstrap(frozen_clusters, bootstrap)
+
+ if group == "target":
+ threshold_name = "min_improvement"
+ threshold = metric_contract[threshold_name]
+ passed = ci_low >= threshold
+ decision = "lower_confidence_bound_at_or_above_minimum"
+ else:
+ threshold_name = "max_regression"
+ threshold = metric_contract[threshold_name]
+ passed = ci_low >= -threshold
+ decision = "lower_confidence_bound_within_regression_budget"
+ metric_results.append(
+ {
+ "group": group,
+ "metric": metric,
+ "higher_is_better": metric_contract["higher_is_better"],
+ "status": Status.PASS if passed else Status.FAIL,
+ "pair_count": len(all_values),
+ "seed_cluster_count": len(frozen_clusters),
+ "mean_normalized_improvement": estimate,
+ "confidence_interval": {"low": ci_low, "high": ci_high},
+ "threshold": {threshold_name: _round(threshold)},
+ "decision_rule": decision,
+ }
+ )
+
+ overall = (
+ Status.PASS
+ if all(result["status"] == Status.PASS for result in metric_results)
+ else Status.FAIL
+ )
+ return seal_artifact(
+ {
+ "schema_version": "sftguard.regression-card.v1",
+ "tool": {"name": "sftguard", "version": __version__},
+ "status": overall,
+ "inputs": [
+ base_summary.as_dict("base"),
+ adapter_summary.as_dict("adapter"),
+ {"split": "contract", "sha256": contract_hash},
+ ],
+ "bootstrap": {
+ "method": "paired_seed_cluster_xorshift32",
+ "draws": bootstrap.draws,
+ "confidence": bootstrap.confidence,
+ "seed": bootstrap.seed,
+ },
+ "metrics": metric_results,
+ "summary": {
+ "metric_count": len(metric_results),
+ "pair_count": len(base),
+ "seed_cluster_count": len(all_seeds),
+ "passed_metric_count": sum(
+ result["status"] == Status.PASS for result in metric_results
+ ),
+ "failed_metric_count": sum(
+ result["status"] == Status.FAIL for result in metric_results
+ ),
+ },
+ }
+ )
diff --git a/src/sftguard/synthetic.py b/src/sftguard/synthetic.py
new file mode 100644
index 0000000..8f31207
--- /dev/null
+++ b/src/sftguard/synthetic.py
@@ -0,0 +1,156 @@
+"""Deterministic synthetic fault generation with phase-separated seeds."""
+
+from __future__ import annotations
+
+import copy
+import hashlib
+from dataclasses import dataclass
+from typing import Any
+
+from .artifacts import canonical_json_bytes, sha256_bytes
+
+DEVELOPMENT_SEEDS = tuple(range(10))
+CONFIRMATORY_SEEDS = tuple(range(4100, 4130))
+
+
+@dataclass(frozen=True)
+class FaultSpec:
+ fault_id: str
+ expected_signal: str
+ severity: str
+
+
+FAULT_SPECS = (
+ FaultSpec("schema.invalid_role", "invalid_role", "error"),
+ FaultSpec("schema.role_order", "role_order", "error"),
+ FaultSpec("template.missing_eos", "missing_eos", "error"),
+ FaultSpec("mask.prompt_supervised", "prompt_token_supervised", "critical"),
+ FaultSpec("truncation.answer_removed", "assistant_fully_truncated", "critical"),
+ FaultSpec("data.exact_duplicate", "exact_duplicate", "warning"),
+ FaultSpec("data.prompt_conflict", "prompt_conflict", "error"),
+ FaultSpec("data.train_eval_leakage", "train_eval_leakage", "critical"),
+ FaultSpec("privacy.secret_pattern", "secret_pattern", "critical"),
+)
+
+
+@dataclass(frozen=True)
+class SyntheticCase:
+ seed: int
+ fault: FaultSpec | None
+ train: tuple[tuple[int, dict[str, Any]], ...]
+ evaluation: tuple[tuple[int, dict[str, Any]], ...]
+ case_sha256: str
+ private_markers: tuple[str, ...]
+
+
+def _marker(seed: int, split: str, index: int) -> str:
+ hexadecimal = hashlib.sha256(f"sftguard:{seed}:{split}:{index}".encode()).hexdigest()[:16]
+ return hexadecimal.translate(str.maketrans("0123456789abcdef", "abcdefghijklmnop"))
+
+
+def _record(marker: str, answer: str, offset: int) -> dict[str, Any]:
+ return {
+ "messages": [
+ {"role": "system", "content": "Return the requested class label."},
+ {"role": "user", "content": f"Classify marker_{marker}."},
+ {"role": "assistant", "content": answer},
+ ],
+ "_sftguard": {
+ "token_ids": [10 + offset, 20 + offset, 30 + offset, 2],
+ "token_roles": ["system", "user", "assistant", "assistant"],
+ "loss_mask": [False, False, True, True],
+ "eos_token_id": 2,
+ "original_assistant_tokens": 2,
+ "truncated": False,
+ },
+ }
+
+
+def _clean(seed: int) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[str]]:
+ train_markers = [_marker(seed, "train", index) for index in range(3)]
+ evaluation_markers = [_marker(seed, "evaluation", index) for index in range(2)]
+ train = [
+ _record(marker, "class_a" if index % 2 == 0 else "class_b", index)
+ for index, marker in enumerate(train_markers)
+ ]
+ evaluation = [
+ _record(marker, "class_b" if index % 2 == 0 else "class_a", index + 10)
+ for index, marker in enumerate(evaluation_markers)
+ ]
+ return train, evaluation, train_markers + evaluation_markers
+
+
+def _generate_case(seed: int, fault_id: str | None = None) -> SyntheticCase:
+ spec = next((item for item in FAULT_SPECS if item.fault_id == fault_id), None)
+ if fault_id is not None and spec is None:
+ raise ValueError("unknown synthetic fault identifier")
+
+ train, evaluation, markers = _clean(seed)
+ private_markers = list(markers)
+ if spec is not None:
+ if spec.fault_id == "schema.invalid_role":
+ train[0]["messages"][1]["role"] = "invalid_development_role"
+ elif spec.fault_id == "schema.role_order":
+ train[0]["messages"].insert(0, {"role": "assistant", "content": "class_a"})
+ elif spec.fault_id == "template.missing_eos":
+ train[0]["_sftguard"]["token_ids"][-1] = 99
+ elif spec.fault_id == "mask.prompt_supervised":
+ train[0]["_sftguard"]["loss_mask"][1] = True
+ elif spec.fault_id == "truncation.answer_removed":
+ train[0]["_sftguard"] = {
+ "token_ids": [10, 20],
+ "token_roles": ["system", "user"],
+ "loss_mask": [False, False],
+ "eos_token_id": 2,
+ "original_assistant_tokens": 2,
+ "truncated": True,
+ }
+ elif spec.fault_id == "data.exact_duplicate":
+ train.append(copy.deepcopy(train[0]))
+ elif spec.fault_id == "data.prompt_conflict":
+ conflicting = copy.deepcopy(train[0])
+ conflicting["messages"][-1]["content"] = "class_conflict"
+ train.append(conflicting)
+ elif spec.fault_id == "data.train_eval_leakage":
+ evaluation.append(copy.deepcopy(train[0]))
+ elif spec.fault_id == "privacy.secret_pattern":
+ secret = f"sk-test-{hashlib.sha256(f'secret:{seed}'.encode()).hexdigest()[:20]}"
+ train[0]["messages"][1]["content"] += f" Credential {secret}."
+ private_markers.append(secret)
+
+ numbered_train = tuple((index + 1, record) for index, record in enumerate(train))
+ numbered_evaluation = tuple((index + 1, record) for index, record in enumerate(evaluation))
+ case_hash = sha256_bytes(
+ canonical_json_bytes(
+ {
+ "seed": seed,
+ "fault_id": fault_id,
+ "train": train,
+ "evaluation": evaluation,
+ }
+ )
+ )
+ return SyntheticCase(
+ seed=seed,
+ fault=spec,
+ train=numbered_train,
+ evaluation=numbered_evaluation,
+ case_sha256=case_hash,
+ private_markers=tuple(private_markers),
+ )
+
+
+def generate_case(seed: int, fault_id: str | None = None) -> SyntheticCase:
+ """Generate one development case without exposing confirmatory seeds."""
+
+ if seed not in DEVELOPMENT_SEEDS:
+ raise ValueError("synthetic generation is restricted to development seeds 0 through 9")
+ return _generate_case(seed, fault_id)
+
+
+def generate_confirmatory_case(seed: int, fault_id: str | None = None) -> SyntheticCase:
+ """Generate a preregistered confirmatory case for the evidence runner only."""
+
+ if seed not in CONFIRMATORY_SEEDS:
+ raise ValueError("seed is outside the frozen confirmatory range")
+ return _generate_case(seed, fault_id)
diff --git a/src/sftguard/tokenizer.py b/src/sftguard/tokenizer.py
new file mode 100644
index 0000000..c1a2399
--- /dev/null
+++ b/src/sftguard/tokenizer.py
@@ -0,0 +1,94 @@
+"""Tokenizer-independent assistant-mask inspection contracts and fixtures."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any, Protocol
+
+FIXTURE_FIELD = "_sftguard"
+
+
+@dataclass(frozen=True)
+class InspectionSignal:
+ signal: str
+ severity: str
+
+
+@dataclass(frozen=True)
+class MaskInspection:
+ available: bool
+ signals: tuple[InspectionSignal, ...] = ()
+ reason: str | None = None
+
+
+class MaskInspector(Protocol):
+ def inspect(self, record: dict[str, Any]) -> MaskInspection:
+ """Inspect rendered tokens without returning their text."""
+
+
+class FixtureMaskInspector:
+ """Inspect a deterministic token/mask fixture embedded in a JSONL record.
+
+ Real tokenizer integrations can implement :class:`MaskInspector` and supply
+ the same semantic output. The fixture deliberately contains token IDs and
+ role labels only; audit artifacts never copy either.
+ """
+
+ _ROLES = {"system", "user", "assistant", "tool"}
+
+ def inspect(self, record: dict[str, Any]) -> MaskInspection:
+ raw = record.get(FIXTURE_FIELD)
+ if raw is None:
+ return MaskInspection(available=False, reason="assistant_mask_unavailable")
+ if not isinstance(raw, dict):
+ return MaskInspection(available=False, reason="mask_fixture_invalid")
+
+ token_ids = raw.get("token_ids")
+ token_roles = raw.get("token_roles")
+ loss_mask = raw.get("loss_mask")
+ eos_token_id = raw.get("eos_token_id")
+ original_assistant_tokens = raw.get("original_assistant_tokens")
+ truncated = raw.get("truncated", False)
+
+ valid = (
+ isinstance(token_ids, list)
+ and isinstance(token_roles, list)
+ and isinstance(loss_mask, list)
+ and len(token_ids) == len(token_roles) == len(loss_mask)
+ and all(isinstance(token, int) and not isinstance(token, bool) for token in token_ids)
+ and all(isinstance(role, str) and role in self._ROLES for role in token_roles)
+ and all(isinstance(masked, bool) for masked in loss_mask)
+ and isinstance(eos_token_id, int)
+ and not isinstance(eos_token_id, bool)
+ and isinstance(original_assistant_tokens, int)
+ and not isinstance(original_assistant_tokens, bool)
+ and original_assistant_tokens >= 0
+ and isinstance(truncated, bool)
+ )
+ if not valid:
+ return MaskInspection(available=False, reason="mask_fixture_invalid")
+
+ signals: set[InspectionSignal] = set()
+ if any(
+ masked and role != "assistant"
+ for masked, role in zip(loss_mask, token_roles, strict=True)
+ ):
+ signals.add(InspectionSignal("prompt_token_supervised", "critical"))
+
+ supervised_assistant = [
+ index
+ for index, (masked, role) in enumerate(zip(loss_mask, token_roles, strict=True))
+ if masked and role == "assistant"
+ ]
+ assistant_positions = [
+ index for index, role in enumerate(token_roles) if role == "assistant"
+ ]
+ if truncated and original_assistant_tokens > 0 and not supervised_assistant:
+ signals.add(InspectionSignal("assistant_fully_truncated", "critical"))
+ elif assistant_positions and token_ids[assistant_positions[-1]] != eos_token_id:
+ signals.add(InspectionSignal("missing_eos", "error"))
+
+ return MaskInspection(
+ available=True,
+ signals=tuple(sorted(signals, key=lambda item: (item.signal, item.severity))),
+ )
diff --git a/tests/python/test_artifacts_io.py b/tests/python/test_artifacts_io.py
new file mode 100644
index 0000000..b8dbb03
--- /dev/null
+++ b/tests/python/test_artifacts_io.py
@@ -0,0 +1,70 @@
+from __future__ import annotations
+
+import json
+
+import pytest
+
+from sftguard.artifacts import (
+ canonical_json_bytes,
+ seal_artifact,
+ verify_artifact,
+ write_artifact,
+)
+from sftguard.io import InputError, read_json_bounded, read_jsonl_bounded
+
+
+def test_canonical_json_is_sorted_compact_and_utf8() -> None:
+ assert canonical_json_bytes({"z": 1, "a": "Sarawak"}) == b'{"a":"Sarawak","z":1}'
+
+
+def test_sealed_artifact_verifies_and_tampering_fails() -> None:
+ artifact = seal_artifact({"status": "PASS", "count": 2})
+ assert verify_artifact(artifact)
+ artifact["count"] = 3
+ assert not verify_artifact(artifact)
+
+
+def test_write_artifact_round_trips(tmp_path) -> None:
+ artifact = seal_artifact({"status": "PASS"})
+ destination = tmp_path / "nested" / "report.json"
+ write_artifact(destination, artifact)
+ assert json.loads(destination.read_text(encoding="utf-8")) == artifact
+
+
+def test_jsonl_reader_streams_objects_and_hashes_bytes(tmp_path) -> None:
+ source = tmp_path / "records.jsonl"
+ source.write_bytes(b'{"a":1}\n\n{"b":2}\n')
+ parsed = read_jsonl_bounded(source)
+ assert parsed.records == ((1, {"a": 1}), (3, {"b": 2}))
+ assert parsed.summary.byte_count == source.stat().st_size
+ assert parsed.summary.record_count == 2
+ assert len(parsed.summary.sha256) == 64
+
+
+@pytest.mark.parametrize(
+ ("contents", "kwargs"),
+ [
+ (b'{"a":1}\n', {"max_file_bytes": 2}),
+ (b'{"long":"value"}\n', {"max_line_bytes": 4}),
+ (b'{"a":1}\n{"b":2}\n', {"max_records": 1}),
+ (b'{"a":1,"a":2}\n', {}),
+ (b"[1,2,3]\n", {}),
+ (b'{"score":NaN}\n', {}),
+ (b"\xff\xfe\n", {}),
+ ],
+)
+def test_jsonl_reader_rejects_malformed_or_bounded_input(tmp_path, contents, kwargs) -> None:
+ source = tmp_path / "bad.jsonl"
+ source.write_bytes(contents)
+ with pytest.raises(InputError):
+ read_jsonl_bounded(source, **kwargs)
+
+
+def test_bounded_json_rejects_duplicate_keys_and_large_file(tmp_path) -> None:
+ source = tmp_path / "contract.json"
+ source.write_text('{"x":1,"x":2}', encoding="utf-8")
+ with pytest.raises(InputError):
+ read_json_bounded(source)
+ source.write_text('{"x":1}', encoding="utf-8")
+ with pytest.raises(InputError):
+ read_json_bounded(source, max_file_bytes=2)
diff --git a/tests/python/test_audit.py b/tests/python/test_audit.py
new file mode 100644
index 0000000..3742798
--- /dev/null
+++ b/tests/python/test_audit.py
@@ -0,0 +1,121 @@
+from __future__ import annotations
+
+import copy
+import json
+
+import pytest
+
+from sftguard.audit import audit_files, audit_records
+from sftguard.models import Status
+from sftguard.synthetic import FAULT_SPECS, generate_case
+from sftguard.tokenizer import FixtureMaskInspector
+
+
+def test_clean_control_passes_without_findings() -> None:
+ case = generate_case(0)
+ report = audit_records(case.train, case.evaluation)
+ assert report["status"] == Status.PASS
+ assert report["findings"] == []
+ assert report["summary"]["signal_counts"] == {}
+
+
+@pytest.mark.parametrize("fault", FAULT_SPECS, ids=lambda fault: fault.fault_id)
+def test_each_registered_fault_emits_its_primary_signal(fault) -> None:
+ case = generate_case(1, fault.fault_id)
+ report = audit_records(case.train, case.evaluation)
+ assert fault.expected_signal in report["summary"]["signal_counts"]
+
+
+def test_warning_duplicate_is_detected_without_forcing_failure() -> None:
+ case = generate_case(2, "data.exact_duplicate")
+ report = audit_records(case.train, case.evaluation)
+ assert report["status"] == Status.PASS
+ assert report["summary"]["signal_counts"] == {"exact_duplicate": 1}
+
+
+def test_missing_mask_abstains_and_is_not_silently_passed() -> None:
+ case = generate_case(2)
+ train = copy.deepcopy(case.train)
+ train[0][1].pop("_sftguard")
+ report = audit_records(train, case.evaluation)
+ assert report["status"] == Status.ABSTAIN
+ assert report["checks"]["assistant_mask"]["status"] == Status.ABSTAIN
+ assert report["summary"]["signal_counts"]["assistant_mask_unavailable"] == 1
+
+
+def test_known_failure_dominates_mask_abstention() -> None:
+ case = generate_case(3, "schema.invalid_role")
+ train = copy.deepcopy(case.train)
+ train[0][1].pop("_sftguard")
+ report = audit_records(train, case.evaluation)
+ assert report["status"] == Status.FAIL
+
+
+def test_invalid_mask_fixture_abstains() -> None:
+ case = generate_case(4)
+ train = copy.deepcopy(case.train)
+ train[0][1]["_sftguard"]["loss_mask"] = [True]
+ report = audit_records(train, case.evaluation)
+ assert report["status"] == Status.ABSTAIN
+ assert "mask_fixture_invalid" in report["summary"]["signal_counts"]
+
+
+def test_direct_nonfinite_record_fails_closed() -> None:
+ case = generate_case(4)
+ train = copy.deepcopy(case.train)
+ train[0][1]["score"] = float("nan")
+ report = audit_records(train, case.evaluation)
+ assert report["status"] == Status.FAIL
+ assert report["summary"]["signal_counts"]["invalid_record"] == 1
+
+
+def test_secret_and_pii_findings_are_redacted_and_values_absent() -> None:
+ case = generate_case(5)
+ secret = "sk-test-abcdefghijklmnopqrst"
+ email = "person@example.invalid"
+ train = copy.deepcopy(case.train)
+ train[0][1]["messages"][1]["content"] += f" {secret} {email}"
+ report = audit_records(train, case.evaluation)
+ rendered = json.dumps(report, sort_keys=True)
+ assert secret not in rendered
+ assert email not in rendered
+ findings = [
+ finding
+ for finding in report["findings"]
+ if finding["signal"] in {"secret_pattern", "pii_like"}
+ ]
+ assert {finding["signal"] for finding in findings} == {"secret_pattern", "pii_like"}
+ assert all(finding["redacted"] is True for finding in findings)
+ assert all("category" in finding and "location" in finding for finding in findings)
+
+
+def test_artifact_never_contains_prompt_or_response_text() -> None:
+ case = generate_case(6)
+ report = audit_records(case.train, case.evaluation)
+ rendered = json.dumps(report, sort_keys=True)
+ for _, record in (*case.train, *case.evaluation):
+ for message in record["messages"]:
+ assert message["content"] not in rendered
+
+
+def test_mask_inspector_flags_each_semantic_fault() -> None:
+ inspector = FixtureMaskInspector()
+ for fault_id, signal in (
+ ("template.missing_eos", "missing_eos"),
+ ("mask.prompt_supervised", "prompt_token_supervised"),
+ ("truncation.answer_removed", "assistant_fully_truncated"),
+ ):
+ case = generate_case(7, fault_id)
+ inspection = inspector.inspect(case.train[0][1])
+ assert inspection.available
+ assert signal in {item.signal for item in inspection.signals}
+
+
+def test_audit_files_returns_abstention_for_bad_input(tmp_path) -> None:
+ train = tmp_path / "train.jsonl"
+ evaluation = tmp_path / "eval.jsonl"
+ train.write_text("not-json\n", encoding="utf-8")
+ evaluation.write_text("{}\n", encoding="utf-8")
+ report = audit_files(str(train), str(evaluation))
+ assert report["status"] == Status.ABSTAIN
+ assert report["inputs"] == []
diff --git a/tests/python/test_benchmark.py b/tests/python/test_benchmark.py
new file mode 100644
index 0000000..755522a
--- /dev/null
+++ b/tests/python/test_benchmark.py
@@ -0,0 +1,53 @@
+from __future__ import annotations
+
+import json
+
+import pytest
+
+from sftguard.artifacts import canonical_json_bytes, verify_artifact
+from sftguard.benchmark import run_development_benchmark, validate_development_seeds
+from sftguard.models import Status
+from sftguard.synthetic import DEVELOPMENT_SEEDS, FAULT_SPECS, generate_case
+
+
+def test_generator_accepts_every_development_seed() -> None:
+ for seed in DEVELOPMENT_SEEDS:
+ assert generate_case(seed).seed == seed
+
+
+@pytest.mark.parametrize("seed", [-1, 10, 99])
+def test_generator_rejects_non_development_seeds(seed) -> None:
+ with pytest.raises(ValueError, match="development seeds"):
+ generate_case(seed)
+
+
+def test_seed_validator_deduplicates_and_sorts() -> None:
+ assert validate_development_seeds([3, 1, 3, 2]) == (1, 2, 3)
+
+
+def test_benchmark_passes_all_registered_faults_on_development_matrix() -> None:
+ result = run_development_benchmark()
+ assert result["status"] == Status.PASS
+ assert result["development_only"] is True
+ assert result["summary"]["case_count"] == len(DEVELOPMENT_SEEDS) * (len(FAULT_SPECS) + 1)
+ assert result["summary"]["macro_recall"] == 1.0
+ assert result["summary"]["clean_false_positive_rate"] == 0.0
+ assert all(result["gates"].values())
+ assert verify_artifact(result)
+
+
+def test_benchmark_is_byte_deterministic() -> None:
+ first = run_development_benchmark([0, 1, 2])
+ second = run_development_benchmark([0, 1, 2])
+ assert canonical_json_bytes(first) == canonical_json_bytes(second)
+
+
+def test_benchmark_artifact_excludes_synthetic_record_text_and_secret() -> None:
+ case = generate_case(0, "privacy.secret_pattern")
+ result = run_development_benchmark([0])
+ rendered = json.dumps(result, sort_keys=True)
+ for marker in case.private_markers:
+ assert marker not in rendered
+ for _, record in (*case.train, *case.evaluation):
+ for message in record["messages"]:
+ assert message["content"] not in rendered
diff --git a/tests/python/test_cli.py b/tests/python/test_cli.py
new file mode 100644
index 0000000..f55fb74
--- /dev/null
+++ b/tests/python/test_cli.py
@@ -0,0 +1,119 @@
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import pytest
+
+from sftguard.cli import build_parser, main
+
+REPOSITORY = Path(__file__).resolve().parents[2]
+
+
+def _read(path: Path):
+ return json.loads(path.read_text(encoding="utf-8"))
+
+
+def test_audit_cli_pass_exit_and_artifact(tmp_path) -> None:
+ output = tmp_path / "audit.json"
+ code = main(
+ [
+ "audit",
+ "--train",
+ str(REPOSITORY / "examples" / "clean-train.jsonl"),
+ "--eval",
+ str(REPOSITORY / "examples" / "clean-eval.jsonl"),
+ "--output",
+ str(output),
+ ]
+ )
+ assert code == 0
+ assert _read(output)["status"] == "PASS"
+
+
+def test_gate_cli_pass_exit_and_artifact(tmp_path) -> None:
+ output = tmp_path / "card.json"
+ code = main(
+ [
+ "gate",
+ "--base",
+ str(REPOSITORY / "examples" / "base-eval.jsonl"),
+ "--adapter",
+ str(REPOSITORY / "examples" / "adapter-eval.jsonl"),
+ "--contract",
+ str(REPOSITORY / "examples" / "regression-contract.json"),
+ "--output",
+ str(output),
+ ]
+ )
+ assert code == 0
+ assert _read(output)["status"] == "PASS"
+
+
+def test_demo_and_benchmark_cli_pass(tmp_path) -> None:
+ demo = tmp_path / "demo.json"
+ benchmark = tmp_path / "benchmark.json"
+ assert main(["demo", "--output", str(demo)]) == 0
+ assert main(["benchmark", "--seeds", "0-2", "--output", str(benchmark)]) == 0
+ assert _read(demo)["status"] == "PASS"
+ assert _read(benchmark)["development_only"] is True
+
+
+def test_audit_cli_abstains_on_unreadable_input(tmp_path) -> None:
+ output = tmp_path / "audit.json"
+ code = main(
+ [
+ "audit",
+ "--train",
+ str(tmp_path / "missing.jsonl"),
+ "--eval",
+ str(tmp_path / "also-missing.jsonl"),
+ "--output",
+ str(output),
+ ]
+ )
+ assert code == 2
+ assert _read(output)["status"] == "ABSTAIN"
+
+
+def test_gate_cli_fail_exit(tmp_path) -> None:
+ base = REPOSITORY / "examples" / "base-eval.jsonl"
+ adapter = tmp_path / "adapter.jsonl"
+ rows = [json.loads(line) for line in base.read_text(encoding="utf-8").splitlines()]
+ for row in rows:
+ if row["group"] == "target":
+ row["value"] -= 0.1
+ adapter.write_text(
+ "".join(json.dumps(row, separators=(",", ":")) + "\n" for row in rows),
+ encoding="utf-8",
+ )
+ output = tmp_path / "card.json"
+ code = main(
+ [
+ "gate",
+ "--base",
+ str(base),
+ "--adapter",
+ str(adapter),
+ "--contract",
+ str(REPOSITORY / "examples" / "regression-contract.json"),
+ "--output",
+ str(output),
+ ]
+ )
+ assert code == 1
+ assert _read(output)["status"] == "FAIL"
+
+
+def test_cli_rejects_out_of_scope_benchmark_seed() -> None:
+ parser = build_parser()
+ with pytest.raises(SystemExit) as exc:
+ parser.parse_args(["benchmark", "--seeds", "10"])
+ assert exc.value.code == 2
+
+
+def test_cli_rejects_huge_benchmark_range_without_expanding_it() -> None:
+ parser = build_parser()
+ with pytest.raises(SystemExit) as exc:
+ parser.parse_args(["benchmark", "--seeds", "0-1000000000"])
+ assert exc.value.code == 2
diff --git a/tests/python/test_confirmatory_contract.py b/tests/python/test_confirmatory_contract.py
new file mode 100644
index 0000000..14d80b6
--- /dev/null
+++ b/tests/python/test_confirmatory_contract.py
@@ -0,0 +1,57 @@
+from __future__ import annotations
+
+import json
+
+import pytest
+
+from sftguard.artifacts import verify_artifact
+from sftguard.confirmatory import _build_artifact, pretty_json_bytes
+from sftguard.synthetic import (
+ CONFIRMATORY_SEEDS,
+ FAULT_SPECS,
+ generate_case,
+ generate_confirmatory_case,
+)
+
+FAKE_COMMIT = "a" * 40
+
+
+def test_frozen_confirmatory_range_is_exact_without_generating_it() -> None:
+ assert CONFIRMATORY_SEEDS == tuple(range(4100, 4130))
+ assert len(CONFIRMATORY_SEEDS) == 30
+
+
+def test_phase_separation_rejects_the_other_phase_before_generation() -> None:
+ with pytest.raises(ValueError, match="development seeds"):
+ generate_case(CONFIRMATORY_SEEDS[0])
+ with pytest.raises(ValueError, match="confirmatory range"):
+ generate_confirmatory_case(0)
+
+
+def test_confirmatory_payload_logic_is_covered_only_with_development_seed() -> None:
+ artifact = _build_artifact(
+ (0,),
+ generate_case,
+ protocol_commit=FAKE_COMMIT,
+ implementation_commit=FAKE_COMMIT,
+ fresh_process_byte_reproducible=True,
+ )
+ assert artifact["status"] == "PASS"
+ assert artifact["carry_forward"] is True
+ assert artifact["summary"]["case_count"] == len(FAULT_SPECS) + 1
+ assert artifact["gates"]["raw_synthetic_text_absent"] is True
+ assert all(artifact["gates"].values())
+ assert verify_artifact(artifact)
+ assert json.loads(pretty_json_bytes(artifact)) == artifact
+
+
+@pytest.mark.parametrize("commit", ["short", "G" * 40, "a" * 39])
+def test_evidence_payload_rejects_noncanonical_commits(commit: str) -> None:
+ with pytest.raises(ValueError, match="full lowercase Git commit"):
+ _build_artifact(
+ (0,),
+ generate_case,
+ protocol_commit=commit,
+ implementation_commit=FAKE_COMMIT,
+ fresh_process_byte_reproducible=True,
+ )
diff --git a/tests/python/test_regression.py b/tests/python/test_regression.py
new file mode 100644
index 0000000..887acb3
--- /dev/null
+++ b/tests/python/test_regression.py
@@ -0,0 +1,153 @@
+from __future__ import annotations
+
+import json
+
+import pytest
+
+from sftguard.artifacts import canonical_json_bytes, verify_artifact
+from sftguard.models import Status
+from sftguard.regression import (
+ MAX_BOOTSTRAP_WORK,
+ BootstrapConfig,
+ build_regression_card,
+ seed_cluster_bootstrap,
+)
+
+
+def _records(
+ target_delta: float = 0.12,
+ retention_delta: float = -0.005,
+ seed_count: int = 4,
+):
+ base = []
+ adapter = []
+ line = 1
+ for seed in range(seed_count):
+ for group, metric, baseline, delta in (
+ ("target", "task_score", 0.5, target_delta),
+ ("retention", "general_score", 0.8, retention_delta),
+ ):
+ common = {
+ "seed": seed,
+ "sample_id": f"private-sample-{group}-{seed}",
+ "group": group,
+ "metric": metric,
+ }
+ base.append((line, {**common, "value": baseline}))
+ adapter.append((line, {**common, "value": baseline + delta}))
+ line += 1
+ return tuple(base), tuple(adapter)
+
+
+def _contract():
+ return {
+ "bootstrap": {"draws": 1000, "confidence": 0.95, "seed": 2339},
+ "metrics": [
+ {
+ "group": "target",
+ "metric": "task_score",
+ "higher_is_better": True,
+ "min_improvement": 0.05,
+ },
+ {
+ "group": "retention",
+ "metric": "general_score",
+ "higher_is_better": True,
+ "max_regression": 0.02,
+ },
+ ],
+ }
+
+
+def test_regression_card_passes_target_and_retention_contract() -> None:
+ base, adapter = _records()
+ card = build_regression_card(base, adapter, _contract())
+ assert card["status"] == Status.PASS
+ assert all(metric["status"] == Status.PASS for metric in card["metrics"])
+ assert card["summary"]["pair_count"] == 8
+ assert verify_artifact(card)
+
+
+@pytest.mark.parametrize(
+ ("target_delta", "retention_delta", "failed_group"),
+ [(0.01, -0.005, "target"), (0.12, -0.05, "retention")],
+)
+def test_regression_card_fails_violated_gate(target_delta, retention_delta, failed_group) -> None:
+ base, adapter = _records(target_delta, retention_delta)
+ card = build_regression_card(base, adapter, _contract())
+ assert card["status"] == Status.FAIL
+ assert (
+ next(item for item in card["metrics"] if item["group"] == failed_group)["status"]
+ == Status.FAIL
+ )
+
+
+def test_unpaired_records_abstain() -> None:
+ base, adapter = _records()
+ card = build_regression_card(base, adapter[:-1], _contract())
+ assert card["status"] == Status.ABSTAIN
+ assert "unpaired_evaluation_records" in card["reason_codes"]
+
+
+def test_duplicate_pair_key_abstains() -> None:
+ base, adapter = _records()
+ card = build_regression_card((*base, base[0]), adapter, _contract())
+ assert card["status"] == Status.ABSTAIN
+ assert "duplicate_pair_key" in card["reason_codes"]
+
+
+def test_contract_requires_both_target_and_retention() -> None:
+ base, adapter = _records()
+ contract = _contract()
+ contract["metrics"] = contract["metrics"][:1]
+ card = build_regression_card(base, adapter, contract)
+ assert card["status"] == Status.ABSTAIN
+ assert "target_and_retention_contracts_required" in card["reason_codes"]
+
+
+def test_nonfinite_value_abstains_without_serializing_it() -> None:
+ base, adapter = _records()
+ broken = list(adapter)
+ broken[0] = (broken[0][0], {**broken[0][1], "value": float("nan")})
+ card = build_regression_card(base, broken, _contract())
+ assert card["status"] == Status.ABSTAIN
+ assert b"NaN" not in canonical_json_bytes(card)
+
+
+def test_nonfinite_contract_abstains_without_serializing_it() -> None:
+ base, adapter = _records()
+ contract = _contract()
+ contract["metrics"][0]["min_improvement"] = float("nan")
+ card = build_regression_card(base, adapter, contract)
+ assert card["status"] == Status.ABSTAIN
+ assert "invalid_metric_threshold" in card["reason_codes"]
+ assert b"NaN" not in canonical_json_bytes(card)
+
+
+def test_seed_cluster_bootstrap_is_deterministic() -> None:
+ clusters = {0: (0.1, 0.2), 1: (0.3,), 2: (0.4, 0.5)}
+ config = BootstrapConfig(draws=2000, confidence=0.9, seed=2339)
+ assert seed_cluster_bootstrap(clusters, config) == seed_cluster_bootstrap(clusters, config)
+
+
+def test_constant_cluster_bootstrap_has_exact_interval() -> None:
+ interval = seed_cluster_bootstrap(
+ {0: (0.25,), 1: (0.25,), 2: (0.25,)}, BootstrapConfig(draws=100)
+ )
+ assert interval == (0.25, 0.25)
+
+
+def test_regression_card_contains_no_sample_ids() -> None:
+ base, adapter = _records()
+ rendered = json.dumps(build_regression_card(base, adapter, _contract()), sort_keys=True)
+ assert "private-sample" not in rendered
+
+
+def test_bootstrap_joint_work_limit_abstains_before_resampling() -> None:
+ base, adapter = _records(seed_count=51)
+ contract = _contract()
+ contract["bootstrap"]["draws"] = 100_000
+ assert contract["bootstrap"]["draws"] * len(base) > MAX_BOOTSTRAP_WORK
+ card = build_regression_card(base, adapter, contract)
+ assert card["status"] == Status.ABSTAIN
+ assert "bootstrap_work_limit_exceeded" in card["reason_codes"]
diff --git a/web/README.md b/web/README.md
new file mode 100644
index 0000000..c6aa2a7
--- /dev/null
+++ b/web/README.md
@@ -0,0 +1,41 @@
+# SFTGuard report viewer
+
+Static, local-first browser UI for SFTGuard aggregate report artifacts.
+
+```bash
+npm ci
+npm run dev
+```
+
+`npm run check` performs the TypeScript, unit-test, and production-build checks.
+
+## Supported artifacts
+
+The viewer validates and adapts these checked-in core artifact formats:
+
+- `sftguard.audit.v1`;
+- `sftguard.regression-card.v1`;
+- `sftguard.development-benchmark.v1`; and
+- `sftguard.confirmatory-fault-matrix.v1`.
+
+The illustrative UI fixtures use the additional `sftguard.report.v1` render
+envelope documented in `src/reportSchema.ts`. The allow-listed render model
+contains:
+
+- a `PASS`, `FAIL`, or `ABSTAIN` decision;
+- one audit, regression, or fault-matrix summary;
+- aggregate issue categories and counts;
+- content-addressed provenance; and
+- claim and privacy boundaries.
+
+Unknown fields are not copied into the render model. Audit record locations,
+record hashes and details, evaluation rows, benchmark `cases`, and confirmatory
+protocol internals are intentionally discarded by the adapters. Missing
+timestamps, commits, runtime details, or replay evidence are shown as
+unavailable rather than inferred. Artifact digests are displayed as declared
+and explicitly marked unverified because this static viewer does not reproduce
+the Python canonical-JSON hash calculation.
+
+Imports are read into the current browser tab only and are neither uploaded nor
+stored. The three sample artifacts are intentionally illustrative placeholders,
+not confirmatory project evidence.
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..ed33e74
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+ SFTGuard — Local report viewer
+
+
+
+
+
+
diff --git a/web/package-lock.json b/web/package-lock.json
new file mode 100644
index 0000000..8bf6d35
--- /dev/null
+++ b/web/package-lock.json
@@ -0,0 +1,1373 @@
+{
+ "name": "sftguard-report-viewer",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "sftguard-report-viewer",
+ "version": "0.1.0",
+ "dependencies": {
+ "react": "19.2.7",
+ "react-dom": "19.2.7"
+ },
+ "devDependencies": {
+ "@types/react": "19.2.17",
+ "@types/react-dom": "19.2.3",
+ "@vitejs/plugin-react": "6.0.3",
+ "typescript": "5.9.3",
+ "vite": "8.1.5",
+ "vitest": "4.1.10"
+ }
+ },
+ "node_modules/@emnapi/core": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
+ "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.2",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
+ "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
+ "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
+ "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@tybys/wasm-util": "^0.10.3"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.139.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
+ "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
+ "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz",
+ "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz",
+ "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz",
+ "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz",
+ "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz",
+ "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz",
+ "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz",
+ "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz",
+ "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz",
+ "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz",
+ "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz",
+ "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
+ "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "1.11.1",
+ "@emnapi/runtime": "1.11.1",
+ "@napi-rs/wasm-runtime": "^1.1.6"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
+ "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz",
+ "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@tybys/wasm-util": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
+ "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "19.2.17",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
+ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.3",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
+ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz",
+ "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rolldown/pluginutils": "^1.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
+ "babel-plugin-react-compiler": "^1.0.0",
+ "vite": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@rolldown/plugin-babel": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
+ "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
+ "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.1.10",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
+ "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
+ "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.1.10",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
+ "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
+ "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
+ "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.10",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
+ "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.16",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
+ "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/obug": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz",
+ "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.20",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.20.tgz",
+ "integrity": "sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.16",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.2.7",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
+ "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.7",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
+ "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.7"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
+ "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.139.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm64": "1.1.5",
+ "@rolldown/binding-darwin-arm64": "1.1.5",
+ "@rolldown/binding-darwin-x64": "1.1.5",
+ "@rolldown/binding-freebsd-x64": "1.1.5",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.1.5",
+ "@rolldown/binding-linux-arm64-gnu": "1.1.5",
+ "@rolldown/binding-linux-arm64-musl": "1.1.5",
+ "@rolldown/binding-linux-ppc64-gnu": "1.1.5",
+ "@rolldown/binding-linux-s390x-gnu": "1.1.5",
+ "@rolldown/binding-linux-x64-gnu": "1.1.5",
+ "@rolldown/binding-linux-x64-musl": "1.1.5",
+ "@rolldown/binding-openharmony-arm64": "1.1.5",
+ "@rolldown/binding-wasm32-wasi": "1.1.5",
+ "@rolldown/binding-win32-arm64-msvc": "1.1.5",
+ "@rolldown/binding-win32-x64-msvc": "1.1.5"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
+ "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
+ "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
+ "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD",
+ "optional": true
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/vite": {
+ "version": "8.1.5",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
+ "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "lightningcss": "^1.32.0",
+ "picomatch": "^4.0.5",
+ "postcss": "^8.5.17",
+ "rolldown": "~1.1.5",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.3.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
+ "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.1.10",
+ "@vitest/mocker": "4.1.10",
+ "@vitest/pretty-format": "4.1.10",
+ "@vitest/runner": "4.1.10",
+ "@vitest/snapshot": "4.1.10",
+ "@vitest/spy": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.1.10",
+ "@vitest/browser-preview": "4.1.10",
+ "@vitest/browser-webdriverio": "4.1.10",
+ "@vitest/coverage-istanbul": "4.1.10",
+ "@vitest/coverage-v8": "4.1.10",
+ "@vitest/ui": "4.1.10",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ }
+ }
+}
diff --git a/web/package.json b/web/package.json
new file mode 100644
index 0000000..d987643
--- /dev/null
+++ b/web/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "sftguard-report-viewer",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc --noEmit && vite build",
+ "preview": "vite preview",
+ "typecheck": "tsc --noEmit --pretty false",
+ "test": "vitest run",
+ "check": "npm run typecheck && npm run test && npm run build"
+ },
+ "dependencies": {
+ "react": "19.2.7",
+ "react-dom": "19.2.7"
+ },
+ "devDependencies": {
+ "@types/react": "19.2.17",
+ "@types/react-dom": "19.2.3",
+ "@vitejs/plugin-react": "6.0.3",
+ "typescript": "5.9.3",
+ "vite": "8.1.5",
+ "vitest": "4.1.10"
+ }
+}
diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx
new file mode 100644
index 0000000..b27bfb6
--- /dev/null
+++ b/web/src/App.test.tsx
@@ -0,0 +1,31 @@
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it } from "vitest";
+import App from "./App";
+
+describe("SFTGuard report viewer", () => {
+ it("renders all decision states and the local-only import boundary", () => {
+ const html = renderToStaticMarkup();
+
+ expect(html).toContain("PASS");
+ expect(html).toContain("FAIL");
+ expect(html).toContain("ABSTAIN");
+ expect(html).toContain("Choose local file");
+ expect(html).toContain("Nothing is uploaded");
+ expect(html).toContain("Claim boundary");
+ expect(html).toContain("Privacy boundary");
+ expect(html).toContain("Unverified — digest not recomputed");
+ expect(html).not.toContain("dangerouslySetInnerHTML");
+ expect(html).not.toMatch(/Ã|Â|â€|�/u);
+ });
+
+ it("uses accessible landmarks and explicit progress semantics", () => {
+ const html = renderToStaticMarkup();
+
+ expect(html).toContain('href="#report-main"');
+ expect(html).toContain(' = {
+ PASS: {
+ eyebrow: "Release gate cleared",
+ action: "Proceed within this evidence contract",
+ explanation: "Every required check represented by this report passed.",
+ },
+ FAIL: {
+ eyebrow: "Release gate blocked",
+ action: "Resolve failures before release",
+ explanation: "At least one required check violated its declared gate.",
+ },
+ ABSTAIN: {
+ eyebrow: "Decision withheld",
+ action: "Supply the missing evidence",
+ explanation: "The harness could not make a trustworthy pass/fail decision.",
+ },
+};
+
+function reportSourceKey(source: ReportSource): string {
+ return `${source.origin}:${source.report.reportId}`;
+}
+
+function signedPoints(value: number): string {
+ const sign = value > 0 ? "+" : value < 0 ? "\u2212" : "";
+ return `${sign}${Math.abs(value).toFixed(1)} pp`;
+}
+
+function signedMetric(
+ value: number,
+ unit: ScoreContract["unit"],
+): string {
+ if (unit === "percentage-points") return signedPoints(value);
+ const sign = value > 0 ? "+" : value < 0 ? "\u2212" : "";
+ return `${sign}${Math.abs(value).toFixed(3)} normalized`;
+}
+
+function compactNumber(value: number | null): string {
+ if (value === null) return "Not verified";
+ return new Intl.NumberFormat("en", { notation: "compact" }).format(value);
+}
+
+function displayDate(value: string): string {
+ return new Intl.DateTimeFormat("en", {
+ year: "numeric",
+ month: "short",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: false,
+ timeZone: "UTC",
+ timeZoneName: "short",
+ }).format(new Date(value));
+}
+
+function shortHash(hash: string): string {
+ return `${hash.slice(0, 10)}\u2026${hash.slice(-8)}`;
+}
+
+function StatusBadge({ status, compact = false }: { status: ReportStatus; compact?: boolean }) {
+ return (
+
+
+ {status}
+
+ );
+}
+
+function SectionHeading({
+ number,
+ title,
+ detail,
+}: {
+ number: string;
+ title: string;
+ detail: string;
+}) {
+ return (
+
+ );
+}
+
+function MetricCard({
+ label,
+ value,
+ context,
+ tone = "neutral",
+ children,
+}: {
+ label: string;
+ value: string;
+ context: string;
+ tone?: "positive" | "negative" | "caution" | "neutral";
+ children?: ReactNode;
+}) {
+ return (
+
+ {label}
+ {value}
+ {context}
+ {children}
+
+ );
+}
+
+function ScoreEvidence({ score, label }: { score: ScoreContract; label: string }) {
+ if (score.baseScore !== null && score.adapterScore !== null) {
+ return (
+
+ Base {score.baseScore.toFixed(1)}
+
+ Adapter {score.adapterScore.toFixed(1)}
+
+ );
+ }
+ if (score.confidenceInterval) {
+ return (
+
+ Pairs {score.pairCount ?? "unavailable"}
+
+
+ Interval {score.confidenceInterval.low.toFixed(3)} to{" "}
+ {score.confidenceInterval.high.toFixed(3)}
+
+
+ );
+ }
+ return null;
+}
+
+function RegressionContract({ report }: { report: SftGuardReport }) {
+ const regression = report.regression;
+ if (!regression) {
+ return (
+
+
+
+
+ );
+ }
+
+ const targetPassed = regression.target.status === "PASS";
+ const retentionPassed = regression.retention.status === "PASS";
+
+ return (
+
+
+
+
+
+
+
+
+ );
+}
+
+function AuditContract({ report }: { report: SftGuardReport }) {
+ if (!report.audit) return null;
+ const audit = report.audit;
+ return (
+
+
+
+
+
+ );
+}
+
+function FaultCoveragePanel({ report }: { report: SftGuardReport }) {
+ const coverage = report.faultCoverage;
+ if (!coverage) {
+ return (
+
+
Not part of this artifact
+
No fault matrix attached
+
+ Open the checked-in fault-matrix sample or import a compatible report
+ to inspect detection coverage and clean-control behavior.
+
+
+ );
+ }
+
+ const coveragePercent =
+ coverage.expectedFaultClasses === 0
+ ? 0
+ : (coverage.detectedFaultClasses / coverage.expectedFaultClasses) * 100;
+ return (
+
+
+
+ {coverage.detectedFaultClasses}
+ of {coverage.expectedFaultClasses}
+
+
+
+
+ {coverage.carryForward ? "Carry-forward allowed" : "Carry-forward blocked"}
+
+
+ This reports deterministic synthetic coverage of SFTGuard’s own
+ checks—not coverage of unknown real-world faults.
+
+
+
+
+
+
+
+
+ );
+}
+
+function CoverageBar({
+ label,
+ value,
+ target,
+ inverse = false,
+}: {
+ label: string;
+ value: number;
+ target: string;
+ inverse?: boolean;
+}) {
+ return (
+
+
+ {label}
+ {target}
+
+
{value.toFixed(1)}%
+
+
+
+
+ );
+}
+
+function IssueSummary({ issues }: { issues: ReportIssue[] }) {
+ const categoryCounts = useMemo(() => {
+ const counts = new Map();
+ for (const issue of issues) {
+ counts.set(issue.category, (counts.get(issue.category) ?? 0) + issue.count);
+ }
+ return [...counts.entries()].sort((left, right) => right[1] - left[1]);
+ }, [issues]);
+
+ if (issues.length === 0) {
+ return (
+
+
No reported issues
+
The artifact contains no issue records.
+
+ );
+ }
+
+ return (
+ <>
+
+ {categoryCounts.map(([category, count]) => (
+
+ {category} {count}
+
+ ))}
+
+
+
+ Reported SFTGuard issues
+
+
+ | Signal |
+ Severity |
+ Outcome |
+ Count |
+
+
+
+ {issues.map((issue) => (
+
+ |
+ {issue.title}
+ {issue.id}
+ {issue.detail}
+ |
+
+
+ {issue.severity}
+
+ |
+
+
+ |
+ {issue.count} |
+
+ ))}
+
+
+
+ >
+ );
+}
+
+function ProvenancePanel({ report }: { report: SftGuardReport }) {
+ const provenance = report.provenance;
+ const values: Array<{
+ label: string;
+ value: string | null;
+ isHash?: boolean;
+ }> = [
+ { label: "Tool version", value: provenance.toolVersion },
+ { label: "Protocol commit", value: provenance.protocolCommit, isHash: true },
+ {
+ label: "Implementation",
+ value: provenance.implementationCommit,
+ isHash: true,
+ },
+ { label: "Input SHA-256", value: provenance.inputSha256, isHash: true },
+ {
+ label: "Declared artifact SHA-256",
+ value: provenance.artifactSha256,
+ isHash: true,
+ },
+ {
+ label: "Digest verification",
+ value:
+ provenance.integrityStatus === "verified"
+ ? "Verified"
+ : provenance.integrityStatus === "mismatch"
+ ? "Mismatch"
+ : "Unverified — digest not recomputed",
+ },
+ { label: "Runtime", value: provenance.runtime },
+ ];
+ const replayTone =
+ provenance.deterministicReplay === null
+ ? "replay-unknown"
+ : provenance.deterministicReplay
+ ? "replay-pass"
+ : "replay-fail";
+ const replayTitle =
+ provenance.deterministicReplay === null
+ ? "Unavailable"
+ : provenance.deterministicReplay
+ ? "Matched"
+ : "Not matched";
+ const replayDetail =
+ provenance.deterministicReplay === null
+ ? "The artifact does not declare a replay result"
+ : provenance.deterministicReplay
+ ? "Canonical payload reproduced"
+ : "Reproduction claim is blocked";
+ return (
+
+
+
Deterministic replay
+
{replayTitle}
+
{replayDetail}
+
+
+ {values.map(({ label, value, isHash = false }) => {
+ return (
+
+
- {label}
+ -
+ {value === null
+ ? "Unavailable in artifact"
+ : isHash
+ ? shortHash(value)
+ : value}
+
+
+ );
+ })}
+
+
+ );
+}
+
+function BoundaryPanel({ report }: { report: SftGuardReport }) {
+ return (
+
+
+ Claim boundary
+ Evidence, not certification
+
+ {report.boundaries.claim.map((item) => (
+ - {item}
+ ))}
+
+
+
+ Privacy boundary
+ Local viewing, narrow display
+
+ {report.boundaries.privacy.map((item) => (
+ - {item}
+ ))}
+
+
+
+ );
+}
+
+export default function App() {
+ const [selectedKey, setSelectedKey] = useState(
+ reportSourceKey(SAMPLE_REPORTS[0]),
+ );
+ const [importedReport, setImportedReport] = useState(null);
+ const [importMessage, setImportMessage] = useState(
+ "Choose a compatible JSON report. The file never leaves this browser tab.",
+ );
+ const [importError, setImportError] = useState(false);
+ const fileInputRef = useRef(null);
+
+ const reports = importedReport
+ ? [importedReport, ...SAMPLE_REPORTS]
+ : SAMPLE_REPORTS;
+ const activeSource =
+ reports.find((source) => reportSourceKey(source) === selectedKey) ?? reports[0];
+ const report = activeSource.report;
+ const statusCopy = STATUS_COPY[report.status];
+
+ async function handleImport(event: ChangeEvent) {
+ const file = event.target.files?.[0];
+ if (!file) return;
+ try {
+ if (file.size > MAX_REPORT_BYTES) {
+ throw new RangeError("Report exceeds the 2 MB local import limit.");
+ }
+ const parsed = parseReportText(await file.text());
+ const source: ReportSource = {
+ report: parsed,
+ origin: "local import",
+ sourceLabel: file.name,
+ };
+ setImportedReport(source);
+ setSelectedKey(reportSourceKey(source));
+ setImportError(false);
+ setImportMessage(`${file.name} loaded locally. Nothing was uploaded or stored.`);
+ } catch (error) {
+ setImportError(true);
+ setImportMessage(
+ error instanceof Error ? error.message : "The report could not be read.",
+ );
+ } finally {
+ event.target.value = "";
+ }
+ }
+
+ function removeImportedReport() {
+ setImportedReport(null);
+ setSelectedKey(reportSourceKey(SAMPLE_REPORTS[0]));
+ setImportError(false);
+ setImportMessage("Local import cleared from memory. Checked-in samples remain available.");
+ fileInputRef.current?.focus();
+ }
+
+ return (
+ <>
+
+ Skip to report
+
+
+
+
+
+
+
+
+
+
+
+
+ {reportTypeLabel(report.reportType)}
+ {activeSource.origin}
+ report-declared decision
+
+
{statusCopy.eyebrow}
+
{report.title}
+
{report.summary}
+
{statusCopy.action}
+
+
+
{report.status.slice(0, 1)}
+
+ {report.status}
+ {statusCopy.explanation}
+
+
+
+ Report {report.reportId}
+
+ {report.generatedAt
+ ? `Generated ${displayDate(report.generatedAt)}`
+ : "Generated time unavailable in artifact"}
+
+
+
+
+
+
+ Evaluation contract results
+
+
+
+
+
+
+ Signals and issue categories
+
+
+
+
+
+ Fault coverage results
+
+
+
+
+
+ Artifact provenance
+
+
+
+
+
+ Claim and privacy boundaries
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/web/src/data/sample-audit.json b/web/src/data/sample-audit.json
new file mode 100644
index 0000000..9b39d28
--- /dev/null
+++ b/web/src/data/sample-audit.json
@@ -0,0 +1,54 @@
+{
+ "schemaVersion": "sftguard.report.v1",
+ "reportType": "audit",
+ "reportId": "sample-audit-mask-abstention",
+ "title": "Training data and mask audit",
+ "generatedAt": "2026-07-19T09:30:00.000Z",
+ "status": "ABSTAIN",
+ "summary": "Structural checks completed, but the tokenizer could not expose a trustworthy assistant-token mask. Release approval is withheld.",
+ "audit": {
+ "recordsScanned": 1280,
+ "checksPassed": 7,
+ "checksTotal": 8,
+ "supervisedTokens": null
+ },
+ "issues": [
+ {
+ "id": "template.mask_unavailable",
+ "category": "Template & mask",
+ "severity": "error",
+ "outcome": "ABSTAIN",
+ "count": 1,
+ "title": "Assistant-token mask unavailable",
+ "detail": "The selected tokenizer template did not expose a verifiable assistant-token mask. SFTGuard did not infer one."
+ },
+ {
+ "id": "data.exact_duplicate",
+ "category": "Data integrity",
+ "severity": "warning",
+ "outcome": "FAIL",
+ "count": 3,
+ "title": "Exact duplicate records",
+ "detail": "Three content hashes occurred more than once. Report data contains counts and hashes only."
+ }
+ ],
+ "provenance": {
+ "toolVersion": "0.1.0-sample",
+ "protocolCommit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "implementationCommit": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ "inputSha256": "1111111111111111111111111111111111111111111111111111111111111111",
+ "artifactSha256": "2222222222222222222222222222222222222222222222222222222222222222",
+ "runtime": "Python 3.12 · local illustrative fixture",
+ "deterministicReplay": true
+ },
+ "boundaries": {
+ "claim": [
+ "Illustrative UI fixture, not checked confirmatory evidence.",
+ "An abstention means the evidence contract was incomplete; it is not a pass."
+ ],
+ "privacy": [
+ "No prompts, responses, model weights, or detected secret values are included.",
+ "Counts and content-addressed identifiers are illustrative placeholders."
+ ]
+ }
+}
diff --git a/web/src/data/sample-fault.json b/web/src/data/sample-fault.json
new file mode 100644
index 0000000..96df531
--- /dev/null
+++ b/web/src/data/sample-fault.json
@@ -0,0 +1,65 @@
+{
+ "schemaVersion": "sftguard.report.v1",
+ "reportType": "fault-matrix",
+ "reportId": "sample-fault-matrix-fail",
+ "title": "Synthetic fault-injection matrix",
+ "generatedAt": "2026-07-19T10:00:00.000Z",
+ "status": "FAIL",
+ "summary": "Eight of nine illustrative fault classes were detected. The macro-recall gate failed, so carry-forward remains blocked.",
+ "faultCoverage": {
+ "expectedFaultClasses": 9,
+ "detectedFaultClasses": 8,
+ "criticalRecallPercent": 100,
+ "macroRecallPercent": 88.9,
+ "cleanFalsePositiveRatePercent": 6.7,
+ "carryForward": false
+ },
+ "issues": [
+ {
+ "id": "data.exact_duplicate",
+ "category": "Data integrity",
+ "severity": "warning",
+ "outcome": "FAIL",
+ "count": 1,
+ "title": "Duplicate fault missed",
+ "detail": "The illustrative exact-duplicate case did not emit its required primary signal."
+ },
+ {
+ "id": "gate.macro_recall",
+ "category": "Fault coverage",
+ "severity": "error",
+ "outcome": "FAIL",
+ "count": 1,
+ "title": "Macro recall below gate",
+ "detail": "Observed 88.9% against the frozen 90% minimum."
+ },
+ {
+ "id": "gate.critical_recall",
+ "category": "Fault coverage",
+ "severity": "info",
+ "outcome": "PASS",
+ "count": 1,
+ "title": "Critical recall met",
+ "detail": "All illustrative critical fault classes emitted their expected primary signal."
+ }
+ ],
+ "provenance": {
+ "toolVersion": "0.1.0-sample",
+ "protocolCommit": "dddddddddddddddddddddddddddddddddddddddd",
+ "implementationCommit": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
+ "inputSha256": "5555555555555555555555555555555555555555555555555555555555555555",
+ "artifactSha256": "6666666666666666666666666666666666666666666666666666666666666666",
+ "runtime": "Python 3.12 · local illustrative fixture",
+ "deterministicReplay": false
+ },
+ "boundaries": {
+ "claim": [
+ "Illustrative UI fixture; confirmatory seeds have not been represented here.",
+ "Synthetic fault detection does not establish coverage of unknown real-world failures."
+ ],
+ "privacy": [
+ "Injected strings and raw records are intentionally omitted.",
+ "Only expected and observed aggregate signals would appear in a real artifact."
+ ]
+ }
+}
diff --git a/web/src/data/sample-regression.json b/web/src/data/sample-regression.json
new file mode 100644
index 0000000..5af9c67
--- /dev/null
+++ b/web/src/data/sample-regression.json
@@ -0,0 +1,64 @@
+{
+ "schemaVersion": "sftguard.report.v1",
+ "reportType": "regression",
+ "reportId": "sample-regression-card-pass",
+ "title": "Adapter release regression card",
+ "generatedAt": "2026-07-19T09:45:00.000Z",
+ "status": "PASS",
+ "summary": "The adapter cleared the declared target gain and stayed inside the retention regression budget for this supplied evaluation contract.",
+ "regression": {
+ "target": {
+ "label": "Domain task accuracy",
+ "baseScore": 61.2,
+ "adapterScore": 73.6,
+ "deltaPercentagePoints": 12.4,
+ "minimumGainPercentagePoints": 5
+ },
+ "retention": {
+ "label": "General capability score",
+ "baseScore": 78.8,
+ "adapterScore": 78.2,
+ "deltaPercentagePoints": -0.6,
+ "maximumRegressionPercentagePoints": 2
+ }
+ },
+ "issues": [
+ {
+ "id": "regression.target_gain",
+ "category": "Target behavior",
+ "severity": "info",
+ "outcome": "PASS",
+ "count": 1,
+ "title": "Target gain cleared",
+ "detail": "Observed +12.4 percentage points against a preregistered +5.0 point minimum."
+ },
+ {
+ "id": "regression.retention_budget",
+ "category": "Retention",
+ "severity": "info",
+ "outcome": "PASS",
+ "count": 1,
+ "title": "Retention stayed in budget",
+ "detail": "Observed −0.6 percentage points against a maximum permitted regression of −2.0 points."
+ }
+ ],
+ "provenance": {
+ "toolVersion": "0.1.0-sample",
+ "protocolCommit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "implementationCommit": "cccccccccccccccccccccccccccccccccccccccc",
+ "inputSha256": "3333333333333333333333333333333333333333333333333333333333333333",
+ "artifactSha256": "4444444444444444444444444444444444444444444444444444444444444444",
+ "runtime": "Python 3.12 · local illustrative fixture",
+ "deterministicReplay": true
+ },
+ "boundaries": {
+ "claim": [
+ "Illustrative UI fixture, not a measured adapter result.",
+ "A pass applies only to the supplied target and retention evaluation records."
+ ],
+ "privacy": [
+ "No evaluation examples or model outputs are embedded.",
+ "The report contains only aggregate scores and placeholder hashes."
+ ]
+ }
+}
diff --git a/web/src/main.tsx b/web/src/main.tsx
new file mode 100644
index 0000000..d172db6
--- /dev/null
+++ b/web/src/main.tsx
@@ -0,0 +1,13 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import App from "./App";
+import "./styles.css";
+
+const root = document.getElementById("root");
+if (!root) throw new Error("SFTGuard viewer root element is missing.");
+
+createRoot(root).render(
+
+
+ ,
+);
diff --git a/web/src/reportSchema.test.ts b/web/src/reportSchema.test.ts
new file mode 100644
index 0000000..fe14c31
--- /dev/null
+++ b/web/src/reportSchema.test.ts
@@ -0,0 +1,330 @@
+import { describe, expect, it } from "vitest";
+import auditJson from "./data/sample-audit.json";
+import faultJson from "./data/sample-fault.json";
+import regressionJson from "./data/sample-regression.json";
+import {
+ MAX_REPORT_BYTES,
+ parseReport,
+ parseReportText,
+} from "./reportSchema";
+
+const HASH_A = "a".repeat(64);
+const HASH_B = "b".repeat(64);
+const HASH_C = "c".repeat(64);
+
+const coreAuditArtifact = {
+ schema_version: "sftguard.audit.v1",
+ tool: { name: "sftguard", version: "0.1.0" },
+ status: "ABSTAIN",
+ inputs: [
+ {
+ split: "train",
+ sha256: HASH_A,
+ byte_count: 120,
+ record_count: 2,
+ },
+ {
+ split: "evaluation",
+ sha256: HASH_B,
+ byte_count: 80,
+ record_count: 1,
+ },
+ ],
+ checks: {
+ schema: { status: "PASS" },
+ assistant_mask: {
+ status: "ABSTAIN",
+ reason: "private-record-marker",
+ },
+ },
+ summary: {
+ finding_count: 1,
+ signal_counts: { pii_like: 1 },
+ error_or_critical_count: 0,
+ redacted_finding_count: 1,
+ },
+ findings: [
+ {
+ signal: "pii_like",
+ severity: "warning",
+ category: "Privacy pattern",
+ location: { split: "train", line: 2, field: "messages" },
+ record_sha256: HASH_C,
+ details: { raw_match: "private-record-marker" },
+ },
+ ],
+ integrity: { algorithm: "sha256", canonical_payload_sha256: HASH_C },
+};
+
+const coreRegressionArtifact = {
+ schema_version: "sftguard.regression-card.v1",
+ tool: { name: "sftguard", version: "0.1.0" },
+ status: "FAIL",
+ inputs: [
+ {
+ split: "base",
+ sha256: HASH_A,
+ byte_count: 400,
+ record_count: 8,
+ },
+ {
+ split: "adapter",
+ sha256: HASH_B,
+ byte_count: 400,
+ record_count: 8,
+ },
+ { split: "contract", sha256: HASH_C },
+ ],
+ bootstrap: {
+ method: "paired_seed_cluster_xorshift32",
+ draws: 1000,
+ confidence: 0.95,
+ seed: 7,
+ },
+ metrics: [
+ {
+ group: "target",
+ metric: "answer_accuracy",
+ higher_is_better: true,
+ status: "PASS",
+ pair_count: 8,
+ seed_cluster_count: 2,
+ mean_normalized_improvement: 0.12,
+ confidence_interval: { low: 0.08, high: 0.16 },
+ threshold: { min_improvement: 0.05 },
+ decision_rule: "lower bound meets minimum improvement",
+ },
+ {
+ group: "retention",
+ metric: "general_accuracy",
+ higher_is_better: true,
+ status: "FAIL",
+ pair_count: 8,
+ seed_cluster_count: 2,
+ mean_normalized_improvement: -0.04,
+ confidence_interval: { low: -0.06, high: -0.02 },
+ threshold: { max_regression: 0.03 },
+ decision_rule: "lower bound stays above retention floor",
+ },
+ ],
+ summary: { metric_count: 2, pair_count: 8, seed_cluster_count: 2 },
+ integrity: { algorithm: "sha256", canonical_payload_sha256: HASH_B },
+};
+
+const developmentBenchmarkArtifact = {
+ schema_version: "sftguard.development-benchmark.v1",
+ tool: { name: "sftguard", version: "0.1.0" },
+ development_only: true,
+ seed_range: { first: 1, last: 2, count: 2 },
+ fault_count: 2,
+ summary: {
+ case_count: 6,
+ clean_control_count: 2,
+ clean_false_positive_count: 0,
+ clean_false_positive_rate: 0,
+ macro_recall: 0.75,
+ },
+ per_fault: [
+ {
+ fault_id: "train_eval_leakage",
+ expected_signal: "train_eval_leakage",
+ severity: "critical",
+ case_count: 2,
+ detected_count: 2,
+ recall: 1,
+ },
+ {
+ fault_id: "missing_eos",
+ expected_signal: "missing_eos",
+ severity: "error",
+ case_count: 2,
+ detected_count: 1,
+ recall: 0.5,
+ },
+ ],
+ cases: [{ raw_synthetic_text: "raw-case-marker" }],
+ gates: {
+ critical_fault_recall_100_percent: true,
+ macro_recall_at_least_90_percent: false,
+ clean_false_positive_rate_at_most_10_percent: true,
+ expected_and_observed_primary_recorded: true,
+ in_process_byte_reproducible: true,
+ raw_synthetic_text_absent: true,
+ },
+ all_development_gates_passed: false,
+ status: "FAIL",
+ integrity: { algorithm: "sha256", canonical_payload_sha256: HASH_A },
+};
+
+const confirmatoryFaultMatrixArtifact = {
+ schema_version: "sftguard.confirmatory-fault-matrix.v1",
+ tool: { name: "sftguard", version: "0.1.0" },
+ provenance: {
+ implementation_git_commit: "1".repeat(40),
+ protocol_git_commit: "2".repeat(40),
+ python_implementation: "CPython",
+ python_version: "3.13",
+ },
+ protocol: { private_fixture_note: "protocol-private-marker" },
+ summary: {
+ case_count: 6,
+ clean_control_count: 2,
+ clean_false_positive_count: 0,
+ clean_false_positive_rate: 0,
+ fault_case_count: 4,
+ macro_recall: 0.75,
+ },
+ per_fault: [
+ {
+ fault_id: "train_eval_leakage",
+ expected_signal: "train_eval_leakage",
+ severity: "critical",
+ case_count: 2,
+ detected_count: 2,
+ recall: 1,
+ },
+ {
+ fault_id: "missing_eos",
+ expected_signal: "missing_eos",
+ severity: "error",
+ case_count: 2,
+ detected_count: 1,
+ recall: 0.5,
+ },
+ ],
+ cases: [{ raw_private_text: "confirmatory-case-marker" }],
+ gates: {
+ clean_false_positive_rate_at_most_10_percent: true,
+ critical_fault_recall_100_percent: true,
+ expected_and_observed_primary_recorded: true,
+ fresh_process_byte_reproducible: true,
+ macro_recall_at_least_90_percent: false,
+ raw_synthetic_text_absent: true,
+ },
+ carry_forward: false,
+ status: "FAIL",
+ integrity: { algorithm: "sha256", canonical_payload_sha256: HASH_C },
+};
+
+describe("SFTGuard report schema", () => {
+ it("loads the three checked-in status and report variants", () => {
+ const audit = parseReport(auditJson);
+ const regression = parseReport(regressionJson);
+ const fault = parseReport(faultJson);
+
+ expect(audit.status).toBe("ABSTAIN");
+ expect(audit.audit?.supervisedTokens).toBeNull();
+ expect(regression.status).toBe("PASS");
+ expect(regression.regression?.target.delta).toBe(12.4);
+ expect(fault.status).toBe("FAIL");
+ expect(fault.faultCoverage?.detectedFaultClasses).toBe(8);
+ });
+
+ it("allow-lists aggregate fields and ignores unknown raw content", () => {
+ const report = parseReport({
+ ...regressionJson,
+ rawPrompt: "this must never be rendered",
+ messages: [{ role: "user", content: "private" }],
+ });
+ expect(report).not.toHaveProperty("rawPrompt");
+ expect(report).not.toHaveProperty("messages");
+ });
+
+ it("adapts the core audit without retaining record-level context", () => {
+ const report = parseReport(coreAuditArtifact);
+ const serialized = JSON.stringify(report);
+
+ expect(report.reportType).toBe("audit");
+ expect(report.status).toBe("ABSTAIN");
+ expect(report.audit?.recordsScanned).toBe(3);
+ expect(report.audit?.checksPassed).toBe(1);
+ expect(report.generatedAt).toBeNull();
+ expect(report.provenance.protocolCommit).toBeNull();
+ expect(report.provenance.integrityStatus).toBe("unverified");
+ expect(serialized).not.toContain("private-record-marker");
+ expect(serialized).not.toContain("record_sha256");
+ expect(report.issues[0]).not.toHaveProperty("location");
+ });
+
+ it("adapts normalized regression metrics and preserves metric decisions", () => {
+ const report = parseReport(coreRegressionArtifact);
+
+ expect(report.reportType).toBe("regression");
+ expect(report.status).toBe("FAIL");
+ expect(report.regression?.target.unit).toBe("normalized-delta");
+ expect(report.regression?.target.delta).toBe(0.12);
+ expect(report.regression?.target.baseScore).toBeNull();
+ expect(report.regression?.target.status).toBe("PASS");
+ expect(report.regression?.retention.status).toBe("FAIL");
+ expect(report.provenance.runtime).toBeNull();
+ });
+
+ it("adapts development fault aggregates and drops the raw cases array", () => {
+ const report = parseReport(developmentBenchmarkArtifact);
+ const serialized = JSON.stringify(report);
+
+ expect(report.reportType).toBe("fault-matrix");
+ expect(report.faultCoverage?.detectedFaultClasses).toBe(1);
+ expect(report.faultCoverage?.criticalRecallPercent).toBe(100);
+ expect(report.faultCoverage?.macroRecallPercent).toBe(75);
+ expect(report.provenance.deterministicReplay).toBe(true);
+ expect(serialized).not.toContain("raw-case-marker");
+ expect(report).not.toHaveProperty("cases");
+ });
+
+ it("adapts confirmatory aggregates, commits, gates, and fresh-process replay", () => {
+ const report = parseReport(confirmatoryFaultMatrixArtifact);
+ const serialized = JSON.stringify(report);
+
+ expect(report.title).toBe("Confirmatory fault-injection matrix");
+ expect(report.status).toBe("FAIL");
+ expect(report.faultCoverage?.carryForward).toBe(false);
+ expect(report.faultCoverage?.macroRecallPercent).toBe(75);
+ expect(report.provenance.protocolCommit).toBe("2".repeat(40));
+ expect(report.provenance.implementationCommit).toBe("1".repeat(40));
+ expect(report.provenance.runtime).toBe("CPython 3.13");
+ expect(report.provenance.deterministicReplay).toBe(true);
+ expect(report.provenance.integrityStatus).toBe("unverified");
+ expect(serialized).not.toContain("confirmatory-case-marker");
+ expect(serialized).not.toContain("protocol-private-marker");
+ expect(report).not.toHaveProperty("cases");
+ expect(report).not.toHaveProperty("protocol");
+ });
+
+ it("fails closed on unsupported status and malformed provenance", () => {
+ expect(() =>
+ parseReport({ ...regressionJson, status: "UNKNOWN" }),
+ ).toThrow(/status must be one of/i);
+ expect(() =>
+ parseReport({
+ ...regressionJson,
+ provenance: { ...regressionJson.provenance, artifactSha256: "short" },
+ }),
+ ).toThrow(/sha-256/i);
+ expect(() =>
+ parseReport({
+ ...regressionJson,
+ regression: {
+ ...regressionJson.regression,
+ target: {
+ ...regressionJson.regression.target,
+ deltaPercentagePoints: 99,
+ },
+ },
+ }),
+ ).toThrow(/adapterScore minus baseScore/i);
+ });
+
+ it("rejects invalid JSON and files beyond the local limit", () => {
+ expect(() => parseReportText("not json")).toThrow(/not valid json/i);
+ expect(() => parseReportText(" ".repeat(MAX_REPORT_BYTES + 1))).toThrow(
+ /2 MB/i,
+ );
+ });
+
+ it("rejects unsupported artifact schemas", () => {
+ expect(() =>
+ parseReport({ schema_version: "sftguard.unknown.v9" }),
+ ).toThrow(/unsupported sftguard report schema/i);
+ });
+});
diff --git a/web/src/reportSchema.ts b/web/src/reportSchema.ts
new file mode 100644
index 0000000..d3b8862
--- /dev/null
+++ b/web/src/reportSchema.ts
@@ -0,0 +1,1325 @@
+/**
+ * SFTGuard browser render model and artifact adapters.
+ *
+ * The parser is deliberately allow-list based: it copies only documented
+ * aggregate fields into the render model. Unknown fields are ignored and are
+ * never rendered. Core artifact adapters intentionally discard record-level
+ * locations, hashes, details, and synthetic cases before React sees them.
+ */
+export const REPORT_SCHEMA_VERSION = "sftguard.report.v1" as const;
+export const AUDIT_SCHEMA_VERSION = "sftguard.audit.v1" as const;
+export const REGRESSION_SCHEMA_VERSION =
+ "sftguard.regression-card.v1" as const;
+export const DEVELOPMENT_BENCHMARK_SCHEMA_VERSION =
+ "sftguard.development-benchmark.v1" as const;
+export const CONFIRMATORY_FAULT_MATRIX_SCHEMA_VERSION =
+ "sftguard.confirmatory-fault-matrix.v1" as const;
+export const MAX_REPORT_BYTES = 2 * 1024 * 1024;
+
+export type ReportStatus = "PASS" | "FAIL" | "ABSTAIN";
+export type ReportType = "audit" | "regression" | "fault-matrix";
+export type IssueSeverity = "critical" | "error" | "warning" | "info";
+
+export interface ReportIssue {
+ id: string;
+ category: string;
+ severity: IssueSeverity;
+ outcome: ReportStatus;
+ count: number;
+ title: string;
+ detail: string;
+}
+
+export interface AuditSummary {
+ recordsScanned: number;
+ checksPassed: number;
+ checksTotal: number;
+ supervisedTokens: number | null;
+}
+
+export interface ScoreContract {
+ label: string;
+ status: "PASS" | "FAIL";
+ baseScore: number | null;
+ adapterScore: number | null;
+ delta: number;
+ unit: "percentage-points" | "normalized-delta";
+ pairCount?: number;
+ confidenceInterval?: { low: number; high: number };
+}
+
+export interface TargetScoreContract extends ScoreContract {
+ minimumGain: number;
+}
+
+export interface RetentionScoreContract extends ScoreContract {
+ maximumRegression: number;
+}
+
+export interface RegressionSummary {
+ target: TargetScoreContract;
+ retention: RetentionScoreContract;
+}
+
+export interface FaultCoverage {
+ expectedFaultClasses: number;
+ detectedFaultClasses: number;
+ criticalRecallPercent: number;
+ macroRecallPercent: number;
+ cleanFalsePositiveRatePercent: number;
+ carryForward: boolean;
+}
+
+export interface ReportProvenance {
+ toolVersion: string;
+ protocolCommit: string | null;
+ implementationCommit: string | null;
+ inputSha256: string | null;
+ artifactSha256: string;
+ integrityStatus: "verified" | "mismatch" | "unverified";
+ runtime: string | null;
+ deterministicReplay: boolean | null;
+}
+
+export interface ReportBoundaries {
+ claim: string[];
+ privacy: string[];
+}
+
+export interface SftGuardReport {
+ schemaVersion: typeof REPORT_SCHEMA_VERSION;
+ reportType: ReportType;
+ reportId: string;
+ title: string;
+ generatedAt: string | null;
+ status: ReportStatus;
+ summary: string;
+ audit?: AuditSummary;
+ regression?: RegressionSummary;
+ faultCoverage?: FaultCoverage;
+ issues: ReportIssue[];
+ provenance: ReportProvenance;
+ boundaries: ReportBoundaries;
+}
+
+function asRecord(value: unknown, label: string): Record {
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
+ throw new TypeError(`${label} must be an object.`);
+ }
+ return value as Record;
+}
+
+function asString(
+ value: unknown,
+ label: string,
+ maximumLength = 500,
+): string {
+ if (typeof value !== "string" || value.trim().length === 0) {
+ throw new TypeError(`${label} must be a non-empty string.`);
+ }
+ if (value.length > maximumLength) {
+ throw new RangeError(`${label} is longer than ${maximumLength} characters.`);
+ }
+ return value;
+}
+
+function asFiniteNumber(value: unknown, label: string): number {
+ if (typeof value !== "number" || !Number.isFinite(value)) {
+ throw new TypeError(`${label} must be a finite number.`);
+ }
+ return value;
+}
+
+function asNonNegativeInteger(value: unknown, label: string): number {
+ const number = asFiniteNumber(value, label);
+ if (!Number.isSafeInteger(number) || number < 0) {
+ throw new RangeError(`${label} must be a non-negative safe integer.`);
+ }
+ return number;
+}
+
+function asBoolean(value: unknown, label: string): boolean {
+ if (typeof value !== "boolean") {
+ throw new TypeError(`${label} must be true or false.`);
+ }
+ return value;
+}
+
+function asEnum(
+ value: unknown,
+ label: string,
+ choices: readonly T[],
+): T {
+ if (typeof value !== "string" || !choices.includes(value as T)) {
+ throw new RangeError(`${label} must be one of ${choices.join(", ")}.`);
+ }
+ return value as T;
+}
+
+function asSha256(value: unknown, label: string): string {
+ const hash = asString(value, label, 64);
+ if (!/^[0-9a-f]{64}$/.test(hash)) {
+ throw new RangeError(`${label} must be a lowercase SHA-256 digest.`);
+ }
+ return hash;
+}
+
+function asCommit(value: unknown, label: string): string {
+ const commit = asString(value, label, 40);
+ if (!/^[0-9a-f]{40}$/.test(commit)) {
+ throw new RangeError(`${label} must be a full lowercase Git commit.`);
+ }
+ return commit;
+}
+
+function asStringArray(value: unknown, label: string): string[] {
+ if (!Array.isArray(value) || value.length === 0 || value.length > 20) {
+ throw new RangeError(`${label} must contain between 1 and 20 entries.`);
+ }
+ return value.map((entry, index) =>
+ asString(entry, `${label}[${index}]`, 300),
+ );
+}
+
+function parseIssue(value: unknown, index: number): ReportIssue {
+ const issue = asRecord(value, `issues[${index}]`);
+ return {
+ id: asString(issue.id, `issues[${index}].id`, 120),
+ category: asString(issue.category, `issues[${index}].category`, 80),
+ severity: asEnum(issue.severity, `issues[${index}].severity`, [
+ "critical",
+ "error",
+ "warning",
+ "info",
+ ] as const),
+ outcome: asEnum(issue.outcome, `issues[${index}].outcome`, [
+ "PASS",
+ "FAIL",
+ "ABSTAIN",
+ ] as const),
+ count: asNonNegativeInteger(issue.count, `issues[${index}].count`),
+ title: asString(issue.title, `issues[${index}].title`, 140),
+ detail: asString(issue.detail, `issues[${index}].detail`, 500),
+ };
+}
+
+function parseAudit(value: unknown): AuditSummary {
+ const audit = asRecord(value, "audit");
+ const supervisedTokens =
+ audit.supervisedTokens === null
+ ? null
+ : asNonNegativeInteger(audit.supervisedTokens, "audit.supervisedTokens");
+ const parsed = {
+ recordsScanned: asNonNegativeInteger(
+ audit.recordsScanned,
+ "audit.recordsScanned",
+ ),
+ checksPassed: asNonNegativeInteger(
+ audit.checksPassed,
+ "audit.checksPassed",
+ ),
+ checksTotal: asNonNegativeInteger(audit.checksTotal, "audit.checksTotal"),
+ supervisedTokens,
+ };
+ if (parsed.checksPassed > parsed.checksTotal) {
+ throw new RangeError("audit.checksPassed cannot exceed checksTotal.");
+ }
+ return parsed;
+}
+
+function parseScore(
+ value: unknown,
+ label: string,
+): Omit {
+ const score = asRecord(value, label);
+ const parsed = {
+ label: asString(score.label, `${label}.label`, 100),
+ baseScore: asFiniteNumber(score.baseScore, `${label}.baseScore`),
+ adapterScore: asFiniteNumber(score.adapterScore, `${label}.adapterScore`),
+ delta: asFiniteNumber(
+ score.deltaPercentagePoints,
+ `${label}.deltaPercentagePoints`,
+ ),
+ unit: "percentage-points" as const,
+ };
+ const calculatedDelta = parsed.adapterScore - parsed.baseScore;
+ if (Math.abs(calculatedDelta - parsed.delta) > 1e-9) {
+ throw new RangeError(
+ `${label}.deltaPercentagePoints must equal adapterScore minus baseScore.`,
+ );
+ }
+ return parsed;
+}
+
+function parseRegression(value: unknown): RegressionSummary {
+ const regression = asRecord(value, "regression");
+ const target = asRecord(regression.target, "regression.target");
+ const retention = asRecord(regression.retention, "regression.retention");
+ const targetScore = parseScore(target, "regression.target");
+ const retentionScore = parseScore(retention, "regression.retention");
+ const minimumGain = asFiniteNumber(
+ target.minimumGainPercentagePoints,
+ "regression.target.minimumGainPercentagePoints",
+ );
+ const maximumRegression = asFiniteNumber(
+ retention.maximumRegressionPercentagePoints,
+ "regression.retention.maximumRegressionPercentagePoints",
+ );
+ return {
+ target: {
+ ...targetScore,
+ status: targetScore.delta >= minimumGain ? "PASS" : "FAIL",
+ minimumGain,
+ },
+ retention: {
+ ...retentionScore,
+ status:
+ retentionScore.delta >= -maximumRegression ? "PASS" : "FAIL",
+ maximumRegression,
+ },
+ };
+}
+
+function asPercent(value: unknown, label: string): number {
+ const percent = asFiniteNumber(value, label);
+ if (percent < 0 || percent > 100) {
+ throw new RangeError(`${label} must be between 0 and 100.`);
+ }
+ return percent;
+}
+
+function parseFaultCoverage(value: unknown): FaultCoverage {
+ const coverage = asRecord(value, "faultCoverage");
+ const expected = asNonNegativeInteger(
+ coverage.expectedFaultClasses,
+ "faultCoverage.expectedFaultClasses",
+ );
+ const detected = asNonNegativeInteger(
+ coverage.detectedFaultClasses,
+ "faultCoverage.detectedFaultClasses",
+ );
+ if (detected > expected) {
+ throw new RangeError(
+ "faultCoverage.detectedFaultClasses cannot exceed expectedFaultClasses.",
+ );
+ }
+ return {
+ expectedFaultClasses: expected,
+ detectedFaultClasses: detected,
+ criticalRecallPercent: asPercent(
+ coverage.criticalRecallPercent,
+ "faultCoverage.criticalRecallPercent",
+ ),
+ macroRecallPercent: asPercent(
+ coverage.macroRecallPercent,
+ "faultCoverage.macroRecallPercent",
+ ),
+ cleanFalsePositiveRatePercent: asPercent(
+ coverage.cleanFalsePositiveRatePercent,
+ "faultCoverage.cleanFalsePositiveRatePercent",
+ ),
+ carryForward: asBoolean(
+ coverage.carryForward,
+ "faultCoverage.carryForward",
+ ),
+ };
+}
+
+function parseProvenance(value: unknown): ReportProvenance {
+ const provenance = asRecord(value, "provenance");
+ return {
+ toolVersion: asString(provenance.toolVersion, "provenance.toolVersion", 80),
+ protocolCommit: asCommit(
+ provenance.protocolCommit,
+ "provenance.protocolCommit",
+ ),
+ implementationCommit: asCommit(
+ provenance.implementationCommit,
+ "provenance.implementationCommit",
+ ),
+ inputSha256: asSha256(provenance.inputSha256, "provenance.inputSha256"),
+ artifactSha256: asSha256(
+ provenance.artifactSha256,
+ "provenance.artifactSha256",
+ ),
+ integrityStatus: "unverified",
+ runtime: asString(provenance.runtime, "provenance.runtime", 160),
+ deterministicReplay: asBoolean(
+ provenance.deterministicReplay,
+ "provenance.deterministicReplay",
+ ),
+ };
+}
+
+function parseEnvelopeReport(value: unknown): SftGuardReport {
+ const report = asRecord(value, "report");
+ const schemaVersion = asEnum(report.schemaVersion, "schemaVersion", [
+ REPORT_SCHEMA_VERSION,
+ ] as const);
+ const reportType = asEnum(report.reportType, "reportType", [
+ "audit",
+ "regression",
+ "fault-matrix",
+ ] as const);
+ const generatedAt = asString(report.generatedAt, "generatedAt", 80);
+ if (!Number.isFinite(Date.parse(generatedAt))) {
+ throw new RangeError("generatedAt must be an ISO-compatible date string.");
+ }
+ if (!Array.isArray(report.issues) || report.issues.length > 100) {
+ throw new RangeError("issues must be an array with at most 100 entries.");
+ }
+ const boundaries = asRecord(report.boundaries, "boundaries");
+
+ const parsed: SftGuardReport = {
+ schemaVersion,
+ reportType,
+ reportId: asString(report.reportId, "reportId", 120),
+ title: asString(report.title, "title", 160),
+ generatedAt,
+ status: asEnum(report.status, "status", [
+ "PASS",
+ "FAIL",
+ "ABSTAIN",
+ ] as const),
+ summary: asString(report.summary, "summary", 800),
+ issues: report.issues.map(parseIssue),
+ provenance: parseProvenance(report.provenance),
+ boundaries: {
+ claim: asStringArray(boundaries.claim, "boundaries.claim"),
+ privacy: asStringArray(boundaries.privacy, "boundaries.privacy"),
+ },
+ };
+
+ if (reportType === "audit") parsed.audit = parseAudit(report.audit);
+ if (reportType === "regression") {
+ parsed.regression = parseRegression(report.regression);
+ }
+ if (reportType === "fault-matrix") {
+ parsed.faultCoverage = parseFaultCoverage(report.faultCoverage);
+ }
+ return parsed;
+}
+
+const REPORT_STATUSES = ["PASS", "FAIL", "ABSTAIN"] as const;
+const ISSUE_SEVERITIES = ["critical", "error", "warning", "info"] as const;
+
+function asBoundedArray(
+ value: unknown,
+ label: string,
+ maximumLength: number,
+): unknown[] {
+ if (!Array.isArray(value) || value.length > maximumLength) {
+ throw new RangeError(
+ `${label} must be an array with at most ${maximumLength} entries.`,
+ );
+ }
+ return value;
+}
+
+function asFraction(value: unknown, label: string): number {
+ const fraction = asFiniteNumber(value, label);
+ if (fraction < 0 || fraction > 1) {
+ throw new RangeError(`${label} must be between 0 and 1.`);
+ }
+ return fraction;
+}
+
+function asNonNegativeNumber(value: unknown, label: string): number {
+ const number = asFiniteNumber(value, label);
+ if (number < 0) {
+ throw new RangeError(`${label} must be non-negative.`);
+ }
+ return number;
+}
+
+function humanizeIdentifier(value: string): string {
+ const words = value.replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim();
+ return `${words.slice(0, 1).toUpperCase()}${words.slice(1)}`;
+}
+
+function formatDecimal(value: number): string {
+ const normalized = Object.is(value, -0) ? 0 : value;
+ return normalized.toFixed(4).replace(/\.0+$|(?<=\.[0-9]*?)0+$/g, "");
+}
+
+function parseCoreMetadata(
+ report: Record,
+ label: string,
+ deterministicReplay: boolean | null = null,
+): ReportProvenance {
+ const tool = asRecord(report.tool, `${label}.tool`);
+ if (asString(tool.name, `${label}.tool.name`, 80) !== "sftguard") {
+ throw new RangeError(`${label}.tool.name must be sftguard.`);
+ }
+ const integrity = asRecord(report.integrity, `${label}.integrity`);
+ if (
+ asString(integrity.algorithm, `${label}.integrity.algorithm`, 20) !==
+ "sha256"
+ ) {
+ throw new RangeError(`${label}.integrity.algorithm must be sha256.`);
+ }
+ return {
+ toolVersion: asString(tool.version, `${label}.tool.version`, 80),
+ protocolCommit: null,
+ implementationCommit: null,
+ inputSha256: null,
+ artifactSha256: asSha256(
+ integrity.canonical_payload_sha256,
+ `${label}.integrity.canonical_payload_sha256`,
+ ),
+ integrityStatus: "unverified",
+ runtime: null,
+ deterministicReplay,
+ };
+}
+
+function validateCoreInputs(
+ value: unknown,
+ label: string,
+ requireRecordCount = false,
+): number {
+ const inputs = asBoundedArray(value, label, 100);
+ let records = 0;
+ inputs.forEach((entry, index) => {
+ const input = asRecord(entry, `${label}[${index}]`);
+ asString(input.split, `${label}[${index}].split`, 80);
+ asSha256(input.sha256, `${label}[${index}].sha256`);
+ if (input.byte_count !== undefined) {
+ asNonNegativeInteger(input.byte_count, `${label}[${index}].byte_count`);
+ }
+ if (input.record_count === undefined) {
+ if (requireRecordCount) {
+ throw new TypeError(`${label}[${index}].record_count is required.`);
+ }
+ } else {
+ records += asNonNegativeInteger(
+ input.record_count,
+ `${label}[${index}].record_count`,
+ );
+ }
+ });
+ return records;
+}
+
+function parseAuditArtifact(report: Record): SftGuardReport {
+ const label = "audit artifact";
+ const status = asEnum(report.status, `${label}.status`, REPORT_STATUSES);
+ const provenance = parseCoreMetadata(report, label);
+ const recordsScanned = validateCoreInputs(report.inputs, `${label}.inputs`, true);
+
+ const checks = asRecord(report.checks, `${label}.checks`);
+ const checkEntries = Object.entries(checks);
+ if (checkEntries.length === 0 || checkEntries.length > 100) {
+ throw new RangeError(`${label}.checks must contain between 1 and 100 entries.`);
+ }
+ const parsedChecks = checkEntries.map(([name, value]) => {
+ asString(name, `${label}.checks key`, 100);
+ const check = asRecord(value, `${label}.checks.${name}`);
+ return {
+ name,
+ status: asEnum(
+ check.status,
+ `${label}.checks.${name}.status`,
+ REPORT_STATUSES,
+ ),
+ };
+ });
+
+ const findings = asBoundedArray(report.findings, `${label}.findings`, 100_000);
+ type AuditAggregate = {
+ signal: string;
+ category: string;
+ severity: IssueSeverity;
+ count: number;
+ };
+ const aggregates = new Map();
+ const derivedSignalCounts = new Map();
+ findings.forEach((entry, index) => {
+ const finding = asRecord(entry, `${label}.findings[${index}]`);
+ const signal = asString(
+ finding.signal,
+ `${label}.findings[${index}].signal`,
+ 120,
+ );
+ const severity = asEnum(
+ finding.severity,
+ `${label}.findings[${index}].severity`,
+ ISSUE_SEVERITIES,
+ );
+ const location = asRecord(
+ finding.location,
+ `${label}.findings[${index}].location`,
+ );
+ asEnum(
+ location.split,
+ `${label}.findings[${index}].location.split`,
+ ["train", "evaluation"] as const,
+ );
+ const line = asNonNegativeInteger(
+ location.line,
+ `${label}.findings[${index}].location.line`,
+ );
+ if (line < 1) {
+ throw new RangeError(
+ `${label}.findings[${index}].location.line must be at least 1.`,
+ );
+ }
+ if (location.field !== undefined) {
+ asString(
+ location.field,
+ `${label}.findings[${index}].location.field`,
+ 160,
+ );
+ }
+ const category =
+ finding.category === undefined
+ ? "Audit finding"
+ : asString(
+ finding.category,
+ `${label}.findings[${index}].category`,
+ 80,
+ );
+ const key = `${category}\u0000${signal}\u0000${severity}`;
+ const aggregate = aggregates.get(key);
+ if (aggregate) {
+ aggregate.count += 1;
+ } else {
+ if (aggregates.size >= 100) {
+ throw new RangeError(
+ `${label}.findings contain more than 100 aggregate categories.`,
+ );
+ }
+ aggregates.set(key, { signal, category, severity, count: 1 });
+ }
+ derivedSignalCounts.set(
+ signal,
+ (derivedSignalCounts.get(signal) ?? 0) + 1,
+ );
+ });
+
+ const summary = asRecord(report.summary, `${label}.summary`);
+ const findingCount = asNonNegativeInteger(
+ summary.finding_count,
+ `${label}.summary.finding_count`,
+ );
+ if (findingCount !== findings.length) {
+ throw new RangeError(
+ `${label}.summary.finding_count must match findings length.`,
+ );
+ }
+ const signalCounts = asRecord(
+ summary.signal_counts,
+ `${label}.summary.signal_counts`,
+ );
+ if (Object.keys(signalCounts).length > 100) {
+ throw new RangeError(`${label}.summary.signal_counts has too many entries.`);
+ }
+ for (const [signal, count] of Object.entries(signalCounts)) {
+ const parsedCount = asNonNegativeInteger(
+ count,
+ `${label}.summary.signal_counts.${signal}`,
+ );
+ if (derivedSignalCounts.get(signal) !== parsedCount) {
+ throw new RangeError(
+ `${label}.summary.signal_counts must match aggregate findings.`,
+ );
+ }
+ }
+ if (Object.keys(signalCounts).length !== derivedSignalCounts.size) {
+ throw new RangeError(
+ `${label}.summary.signal_counts must cover every finding signal.`,
+ );
+ }
+
+ const issues: ReportIssue[] = [...aggregates.values()].map(
+ (aggregate, index) => ({
+ id: `audit-signal-${index + 1}`,
+ category: aggregate.category,
+ severity: aggregate.severity,
+ outcome:
+ status === "ABSTAIN"
+ ? "ABSTAIN"
+ : aggregate.severity === "critical" || aggregate.severity === "error"
+ ? "FAIL"
+ : "PASS",
+ count: aggregate.count,
+ title: humanizeIdentifier(aggregate.signal),
+ detail: `${aggregate.count} aggregate finding${aggregate.count === 1 ? "" : "s"} reported. Record locations and record-level details are not copied into this viewer.`,
+ }),
+ );
+ for (const check of parsedChecks.filter((entry) => entry.status === "ABSTAIN")) {
+ issues.push({
+ id: `audit-check-${check.name}`,
+ category: "Audit check",
+ severity: "warning",
+ outcome: "ABSTAIN",
+ count: 1,
+ title: humanizeIdentifier(check.name),
+ detail:
+ "This check abstained. Its raw reason and record context remain outside the aggregate render model.",
+ });
+ }
+
+ return {
+ schemaVersion: REPORT_SCHEMA_VERSION,
+ reportType: "audit",
+ reportId: `core-audit-${provenance.artifactSha256.slice(0, 12)}`,
+ title: "Dataset and mask audit",
+ generatedAt: null,
+ status,
+ summary: `The audit declared ${status} across ${recordsScanned} record${recordsScanned === 1 ? "" : "s"}, ${findingCount} finding${findingCount === 1 ? "" : "s"}, and ${parsedChecks.length} check${parsedChecks.length === 1 ? "" : "s"}.`,
+ audit: {
+ recordsScanned,
+ checksPassed: parsedChecks.filter((entry) => entry.status === "PASS").length,
+ checksTotal: parsedChecks.length,
+ supervisedTokens: null,
+ },
+ issues,
+ provenance,
+ boundaries: {
+ claim: [
+ "The decision reflects the supplied local audit artifact only.",
+ "A passing audit does not prove dataset quality, model quality, or model safety.",
+ ],
+ privacy: [
+ "Only aggregate signals, severities, and counts are rendered.",
+ "Record locations, record hashes, finding details, and source records are discarded by the adapter.",
+ ],
+ },
+ };
+}
+
+interface ParsedCoreMetric {
+ group: "target" | "retention";
+ metric: string;
+ status: "PASS" | "FAIL";
+ pairCount: number;
+ mean: number;
+ interval: { low: number; high: number };
+ threshold: number;
+}
+
+function parseCoreMetric(value: unknown, index: number): ParsedCoreMetric {
+ const label = `regression artifact.metrics[${index}]`;
+ const metric = asRecord(value, label);
+ const group = asEnum(metric.group, `${label}.group`, [
+ "target",
+ "retention",
+ ] as const);
+ if (metric.higher_is_better !== undefined) {
+ asBoolean(metric.higher_is_better, `${label}.higher_is_better`);
+ }
+ if (metric.seed_cluster_count !== undefined) {
+ asNonNegativeInteger(
+ metric.seed_cluster_count,
+ `${label}.seed_cluster_count`,
+ );
+ }
+ const interval = asRecord(
+ metric.confidence_interval,
+ `${label}.confidence_interval`,
+ );
+ const low = asFiniteNumber(interval.low, `${label}.confidence_interval.low`);
+ const high = asFiniteNumber(
+ interval.high,
+ `${label}.confidence_interval.high`,
+ );
+ if (low > high) {
+ throw new RangeError(`${label}.confidence_interval.low cannot exceed high.`);
+ }
+ const threshold = asRecord(metric.threshold, `${label}.threshold`);
+ const thresholdKey =
+ group === "target" ? "min_improvement" : "max_regression";
+ asString(metric.decision_rule, `${label}.decision_rule`, 300);
+ return {
+ group,
+ metric: asString(metric.metric, `${label}.metric`, 120),
+ status: asEnum(metric.status, `${label}.status`, ["PASS", "FAIL"] as const),
+ pairCount: asNonNegativeInteger(metric.pair_count, `${label}.pair_count`),
+ mean: asFiniteNumber(
+ metric.mean_normalized_improvement,
+ `${label}.mean_normalized_improvement`,
+ ),
+ interval: { low, high },
+ threshold: asNonNegativeNumber(
+ threshold[thresholdKey],
+ `${label}.threshold.${thresholdKey}`,
+ ),
+ };
+}
+
+function parseRegressionArtifact(
+ report: Record,
+): SftGuardReport {
+ const label = "regression artifact";
+ const status = asEnum(report.status, `${label}.status`, REPORT_STATUSES);
+ const provenance = parseCoreMetadata(report, label);
+ validateCoreInputs(report.inputs, `${label}.inputs`);
+ const rawMetrics = asBoundedArray(report.metrics, `${label}.metrics`, 100);
+ const metrics = rawMetrics
+ .map(parseCoreMetric)
+ .sort((left, right) =>
+ `${left.group}:${left.metric}`.localeCompare(`${right.group}:${right.metric}`),
+ );
+ const summary = asRecord(report.summary, `${label}.summary`);
+ const metricCount = asNonNegativeInteger(
+ summary.metric_count,
+ `${label}.summary.metric_count`,
+ );
+ if (metricCount !== metrics.length) {
+ throw new RangeError(`${label}.summary.metric_count must match metrics length.`);
+ }
+ asNonNegativeInteger(summary.pair_count, `${label}.summary.pair_count`);
+ asNonNegativeInteger(
+ summary.seed_cluster_count,
+ `${label}.summary.seed_cluster_count`,
+ );
+
+ const reasonCodes =
+ report.reason_codes === undefined
+ ? []
+ : asBoundedArray(report.reason_codes, `${label}.reason_codes`, 100).map(
+ (reason, index) =>
+ asString(reason, `${label}.reason_codes[${index}]`, 160),
+ );
+ const issues: ReportIssue[] = metrics.map((metric, index) => ({
+ id: `regression-metric-${index + 1}`,
+ category:
+ metric.group === "target" ? "Target behavior" : "Retention behavior",
+ severity: metric.status === "FAIL" ? "error" : "info",
+ outcome: metric.status,
+ count: 1,
+ title: metric.metric,
+ detail: `Mean normalized improvement ${formatDecimal(metric.mean)} with reported interval [${formatDecimal(metric.interval.low)}, ${formatDecimal(metric.interval.high)}] across ${metric.pairCount} pairs; ${metric.group === "target" ? "minimum improvement" : "maximum regression"} ${formatDecimal(metric.threshold)}.`,
+ }));
+ reasonCodes.forEach((reason, index) => {
+ issues.push({
+ id: `regression-reason-${index + 1}`,
+ category: "Decision evidence",
+ severity: "warning",
+ outcome: "ABSTAIN",
+ count: 1,
+ title: humanizeIdentifier(reason),
+ detail:
+ "The regression card reported this aggregate reason code without exposing evaluation examples.",
+ });
+ });
+ if (status === "ABSTAIN" && reasonCodes.length === 0) {
+ issues.push({
+ id: "regression-reason-unavailable",
+ category: "Decision evidence",
+ severity: "warning",
+ outcome: "ABSTAIN",
+ count: 1,
+ title: "Reason unavailable",
+ detail: "The artifact abstained without a reason code in its aggregate fields.",
+ });
+ }
+
+ const target = metrics.find((metric) => metric.group === "target");
+ const retention = metrics.find((metric) => metric.group === "retention");
+ const adapted: SftGuardReport = {
+ schemaVersion: REPORT_SCHEMA_VERSION,
+ reportType: "regression",
+ reportId: `core-regression-${provenance.artifactSha256.slice(0, 12)}`,
+ title: "Target and retention regression card",
+ generatedAt: null,
+ status,
+ summary: `The regression card declared ${status} across ${metricCount} aggregate metric${metricCount === 1 ? "" : "s"}. Estimates are normalized paired improvements, not raw percentage-point scores.`,
+ issues,
+ provenance,
+ boundaries: {
+ claim: [
+ "The decision applies only to the supplied metric contract and paired evaluation artifact.",
+ "Passing declared gates does not establish broad model quality or safety.",
+ ],
+ privacy: [
+ "Only aggregate estimates, intervals, thresholds, pair counts, and reason codes are rendered.",
+ "Evaluation rows, prompts, responses, and per-example results are not copied into the viewer.",
+ ],
+ },
+ };
+ if (target && retention) {
+ adapted.regression = {
+ target: {
+ label: target.metric,
+ status: target.status,
+ baseScore: null,
+ adapterScore: null,
+ delta: target.mean,
+ unit: "normalized-delta",
+ pairCount: target.pairCount,
+ confidenceInterval: target.interval,
+ minimumGain: target.threshold,
+ },
+ retention: {
+ label: retention.metric,
+ status: retention.status,
+ baseScore: null,
+ adapterScore: null,
+ delta: retention.mean,
+ unit: "normalized-delta",
+ pairCount: retention.pairCount,
+ confidenceInterval: retention.interval,
+ maximumRegression: retention.threshold,
+ },
+ };
+ }
+ return adapted;
+}
+
+interface ParsedFaultClass {
+ faultId: string;
+ expectedSignal: string;
+ severity: IssueSeverity;
+ caseCount: number;
+ detectedCount: number;
+ recall: number;
+}
+
+function parseFaultClasses(value: unknown, label: string): ParsedFaultClass[] {
+ return asBoundedArray(value, label, 90).map((entry, index) => {
+ const faultLabel = `${label}[${index}]`;
+ const fault = asRecord(entry, faultLabel);
+ const caseCount = asNonNegativeInteger(
+ fault.case_count,
+ `${faultLabel}.case_count`,
+ );
+ if (caseCount < 1) {
+ throw new RangeError(`${faultLabel}.case_count must be at least 1.`);
+ }
+ const detectedCount = asNonNegativeInteger(
+ fault.detected_count,
+ `${faultLabel}.detected_count`,
+ );
+ if (detectedCount > caseCount) {
+ throw new RangeError(
+ `${faultLabel}.detected_count cannot exceed case_count.`,
+ );
+ }
+ const recall = asFraction(fault.recall, `${faultLabel}.recall`);
+ if (Math.abs(recall - detectedCount / caseCount) > 1e-9) {
+ throw new RangeError(`${faultLabel}.recall must match aggregate counts.`);
+ }
+ return {
+ faultId: asString(fault.fault_id, `${faultLabel}.fault_id`, 120),
+ expectedSignal: asString(
+ fault.expected_signal,
+ `${faultLabel}.expected_signal`,
+ 120,
+ ),
+ severity: asEnum(
+ fault.severity,
+ `${faultLabel}.severity`,
+ ISSUE_SEVERITIES,
+ ),
+ caseCount,
+ detectedCount,
+ recall,
+ };
+ });
+}
+
+function parseDevelopmentBenchmarkArtifact(
+ report: Record,
+): SftGuardReport {
+ const label = "development benchmark artifact";
+ if (asBoolean(report.development_only, `${label}.development_only`) !== true) {
+ throw new RangeError(`${label}.development_only must be true.`);
+ }
+ const status = asEnum(report.status, `${label}.status`, ["PASS", "FAIL"] as const);
+ const gates = asRecord(report.gates, `${label}.gates`);
+ const gateKeys = [
+ "critical_fault_recall_100_percent",
+ "macro_recall_at_least_90_percent",
+ "clean_false_positive_rate_at_most_10_percent",
+ "expected_and_observed_primary_recorded",
+ "in_process_byte_reproducible",
+ "raw_synthetic_text_absent",
+ ] as const;
+ const parsedGates = new Map(
+ gateKeys.map((key) => [
+ key,
+ asBoolean(gates[key], `${label}.gates.${key}`),
+ ]),
+ );
+ const allGatesPassed = asBoolean(
+ report.all_development_gates_passed,
+ `${label}.all_development_gates_passed`,
+ );
+ if (allGatesPassed !== [...parsedGates.values()].every(Boolean)) {
+ throw new RangeError(
+ `${label}.all_development_gates_passed must match the declared gates.`,
+ );
+ }
+ if ((status === "PASS") !== allGatesPassed) {
+ throw new RangeError(`${label}.status must match the development gates.`);
+ }
+ const provenance = parseCoreMetadata(
+ report,
+ label,
+ parsedGates.get("in_process_byte_reproducible") ?? null,
+ );
+
+ const seedRange = asRecord(report.seed_range, `${label}.seed_range`);
+ asNonNegativeInteger(seedRange.first, `${label}.seed_range.first`);
+ asNonNegativeInteger(seedRange.last, `${label}.seed_range.last`);
+ const seedCount = asNonNegativeInteger(
+ seedRange.count,
+ `${label}.seed_range.count`,
+ );
+ if (seedCount < 1) {
+ throw new RangeError(`${label}.seed_range.count must be at least 1.`);
+ }
+
+ const faults = parseFaultClasses(report.per_fault, `${label}.per_fault`);
+ const faultCount = asNonNegativeInteger(
+ report.fault_count,
+ `${label}.fault_count`,
+ );
+ if (faultCount !== faults.length) {
+ throw new RangeError(`${label}.fault_count must match per_fault length.`);
+ }
+
+ const summary = asRecord(report.summary, `${label}.summary`);
+ const caseCount = asNonNegativeInteger(
+ summary.case_count,
+ `${label}.summary.case_count`,
+ );
+ const cleanControlCount = asNonNegativeInteger(
+ summary.clean_control_count,
+ `${label}.summary.clean_control_count`,
+ );
+ const cleanFalsePositiveCount = asNonNegativeInteger(
+ summary.clean_false_positive_count,
+ `${label}.summary.clean_false_positive_count`,
+ );
+ if (cleanFalsePositiveCount > cleanControlCount) {
+ throw new RangeError(
+ `${label}.summary.clean_false_positive_count cannot exceed clean_control_count.`,
+ );
+ }
+ if (
+ caseCount !==
+ cleanControlCount + faults.reduce((total, fault) => total + fault.caseCount, 0)
+ ) {
+ throw new RangeError(
+ `${label}.summary.case_count must match clean and injected aggregate counts.`,
+ );
+ }
+ const cleanFalsePositiveRate = asFraction(
+ summary.clean_false_positive_rate,
+ `${label}.summary.clean_false_positive_rate`,
+ );
+ if (
+ cleanControlCount > 0 &&
+ Math.abs(cleanFalsePositiveRate - cleanFalsePositiveCount / cleanControlCount) >
+ 1e-9
+ ) {
+ throw new RangeError(
+ `${label}.summary.clean_false_positive_rate must match aggregate counts.`,
+ );
+ }
+ const macroRecall = asFraction(
+ summary.macro_recall,
+ `${label}.summary.macro_recall`,
+ );
+ const calculatedMacro =
+ faults.length === 0
+ ? 0
+ : faults.reduce((total, fault) => total + fault.recall, 0) / faults.length;
+ if (Math.abs(macroRecall - calculatedMacro) > 1e-9) {
+ throw new RangeError(`${label}.summary.macro_recall must match per_fault recall.`);
+ }
+
+ const criticalFaults = faults.filter((fault) => fault.severity === "critical");
+ const criticalRecall =
+ criticalFaults.length === 0
+ ? 0
+ : Math.min(...criticalFaults.map((fault) => fault.recall));
+ const issues: ReportIssue[] = faults.map((fault, index) => ({
+ id: `fault-class-${index + 1}`,
+ category: "Fault detection",
+ severity: fault.severity,
+ outcome: fault.detectedCount === fault.caseCount ? "PASS" : "FAIL",
+ count: fault.caseCount,
+ title: humanizeIdentifier(fault.faultId),
+ detail: `Expected signal ${fault.expectedSignal}; detected ${fault.detectedCount} of ${fault.caseCount} aggregate synthetic cases (${(fault.recall * 100).toFixed(1)}% recall).`,
+ }));
+ for (const [key, passed] of parsedGates) {
+ if (!passed) {
+ issues.push({
+ id: `development-gate-${key}`,
+ category: "Development gate",
+ severity: key === "critical_fault_recall_100_percent" ? "critical" : "error",
+ outcome: "FAIL",
+ count: 1,
+ title: humanizeIdentifier(key),
+ detail: "This aggregate development-only gate was reported as false.",
+ });
+ }
+ }
+
+ return {
+ schemaVersion: REPORT_SCHEMA_VERSION,
+ reportType: "fault-matrix",
+ reportId: `core-benchmark-${provenance.artifactSha256.slice(0, 12)}`,
+ title: "Development fault-injection matrix",
+ generatedAt: null,
+ status,
+ summary: `The development-only benchmark declared ${status} across ${caseCount} synthetic cases and ${faultCount} known fault classes.`,
+ faultCoverage: {
+ expectedFaultClasses: faultCount,
+ detectedFaultClasses: faults.filter(
+ (fault) => fault.detectedCount === fault.caseCount,
+ ).length,
+ criticalRecallPercent: criticalRecall * 100,
+ macroRecallPercent: macroRecall * 100,
+ cleanFalsePositiveRatePercent: cleanFalsePositiveRate * 100,
+ carryForward: allGatesPassed,
+ },
+ issues,
+ provenance,
+ boundaries: {
+ claim: [
+ "This is a development-only synthetic benchmark of known SFTGuard checks.",
+ "It is not a confirmatory benchmark and does not demonstrate coverage of unknown real-world faults.",
+ ],
+ privacy: [
+ "Only per-fault aggregate counts, recall, and gate results are rendered.",
+ "The cases array and any raw synthetic case content are discarded by the adapter.",
+ ],
+ },
+ };
+}
+
+function parseConfirmatoryFaultMatrixArtifact(
+ report: Record,
+): SftGuardReport {
+ const label = "confirmatory fault-matrix artifact";
+ const status = asEnum(report.status, `${label}.status`, ["PASS", "FAIL"] as const);
+ asRecord(report.protocol, `${label}.protocol`);
+ asBoundedArray(report.cases, `${label}.cases`, 100_000);
+
+ const gates = asRecord(report.gates, `${label}.gates`);
+ const gateKeys = [
+ "clean_false_positive_rate_at_most_10_percent",
+ "critical_fault_recall_100_percent",
+ "expected_and_observed_primary_recorded",
+ "fresh_process_byte_reproducible",
+ "macro_recall_at_least_90_percent",
+ "raw_synthetic_text_absent",
+ ] as const;
+ const parsedGates = new Map(
+ gateKeys.map((key) => [
+ key,
+ asBoolean(gates[key], `${label}.gates.${key}`),
+ ]),
+ );
+ const carryForward = asBoolean(
+ report.carry_forward,
+ `${label}.carry_forward`,
+ );
+ if (carryForward !== [...parsedGates.values()].every(Boolean)) {
+ throw new RangeError(`${label}.carry_forward must match the declared gates.`);
+ }
+ if ((status === "PASS") !== carryForward) {
+ throw new RangeError(`${label}.status must match carry_forward.`);
+ }
+
+ const artifactProvenance = asRecord(
+ report.provenance,
+ `${label}.provenance`,
+ );
+ const pythonImplementation = asString(
+ artifactProvenance.python_implementation,
+ `${label}.provenance.python_implementation`,
+ 80,
+ );
+ const pythonVersion = asString(
+ artifactProvenance.python_version,
+ `${label}.provenance.python_version`,
+ 40,
+ );
+ const baseProvenance = parseCoreMetadata(
+ report,
+ label,
+ parsedGates.get("fresh_process_byte_reproducible") ?? null,
+ );
+ const provenance: ReportProvenance = {
+ ...baseProvenance,
+ protocolCommit: asCommit(
+ artifactProvenance.protocol_git_commit,
+ `${label}.provenance.protocol_git_commit`,
+ ),
+ implementationCommit: asCommit(
+ artifactProvenance.implementation_git_commit,
+ `${label}.provenance.implementation_git_commit`,
+ ),
+ runtime: `${pythonImplementation} ${pythonVersion}`,
+ };
+
+ const faults = parseFaultClasses(report.per_fault, `${label}.per_fault`);
+ if (faults.length === 0) {
+ throw new RangeError(`${label}.per_fault must contain at least one entry.`);
+ }
+ const summary = asRecord(report.summary, `${label}.summary`);
+ const caseCount = asNonNegativeInteger(
+ summary.case_count,
+ `${label}.summary.case_count`,
+ );
+ const cleanControlCount = asNonNegativeInteger(
+ summary.clean_control_count,
+ `${label}.summary.clean_control_count`,
+ );
+ const faultCaseCount = asNonNegativeInteger(
+ summary.fault_case_count,
+ `${label}.summary.fault_case_count`,
+ );
+ const cleanFalsePositiveCount = asNonNegativeInteger(
+ summary.clean_false_positive_count,
+ `${label}.summary.clean_false_positive_count`,
+ );
+ if (cleanFalsePositiveCount > cleanControlCount) {
+ throw new RangeError(
+ `${label}.summary.clean_false_positive_count cannot exceed clean_control_count.`,
+ );
+ }
+ if (caseCount !== cleanControlCount + faultCaseCount) {
+ throw new RangeError(
+ `${label}.summary.case_count must equal clean and fault case counts.`,
+ );
+ }
+ if (
+ faultCaseCount !==
+ faults.reduce((total, fault) => total + fault.caseCount, 0)
+ ) {
+ throw new RangeError(
+ `${label}.summary.fault_case_count must match per_fault aggregate counts.`,
+ );
+ }
+ const cleanFalsePositiveRate = asFraction(
+ summary.clean_false_positive_rate,
+ `${label}.summary.clean_false_positive_rate`,
+ );
+ if (
+ cleanControlCount > 0 &&
+ Math.abs(cleanFalsePositiveRate - cleanFalsePositiveCount / cleanControlCount) >
+ 1e-9
+ ) {
+ throw new RangeError(
+ `${label}.summary.clean_false_positive_rate must match aggregate counts.`,
+ );
+ }
+ const macroRecall = asFraction(
+ summary.macro_recall,
+ `${label}.summary.macro_recall`,
+ );
+ const calculatedMacro =
+ faults.reduce((total, fault) => total + fault.recall, 0) / faults.length;
+ if (Math.abs(macroRecall - calculatedMacro) > 1e-9) {
+ throw new RangeError(`${label}.summary.macro_recall must match per_fault recall.`);
+ }
+
+ const criticalFaults = faults.filter((fault) => fault.severity === "critical");
+ const criticalRecall =
+ criticalFaults.length === 0
+ ? 0
+ : Math.min(...criticalFaults.map((fault) => fault.recall));
+ const issues: ReportIssue[] = faults.map((fault, index) => ({
+ id: `confirmatory-fault-class-${index + 1}`,
+ category: "Confirmatory fault detection",
+ severity: fault.severity,
+ outcome: fault.detectedCount === fault.caseCount ? "PASS" : "FAIL",
+ count: fault.caseCount,
+ title: humanizeIdentifier(fault.faultId),
+ detail: `Expected signal ${fault.expectedSignal}; detected ${fault.detectedCount} of ${fault.caseCount} aggregate confirmatory cases (${(fault.recall * 100).toFixed(1)}% recall).`,
+ }));
+ for (const [key, passed] of parsedGates) {
+ if (!passed) {
+ issues.push({
+ id: `confirmatory-gate-${key}`,
+ category: "Confirmatory gate",
+ severity: key === "critical_fault_recall_100_percent" ? "critical" : "error",
+ outcome: "FAIL",
+ count: 1,
+ title: humanizeIdentifier(key),
+ detail: "This aggregate confirmatory gate was reported as false.",
+ });
+ }
+ }
+
+ return {
+ schemaVersion: REPORT_SCHEMA_VERSION,
+ reportType: "fault-matrix",
+ reportId: `confirmatory-matrix-${provenance.artifactSha256.slice(0, 12)}`,
+ title: "Confirmatory fault-injection matrix",
+ generatedAt: null,
+ status,
+ summary: `The confirmatory matrix declared ${status} across ${caseCount} synthetic cases and ${faults.length} known fault classes.`,
+ faultCoverage: {
+ expectedFaultClasses: faults.length,
+ detectedFaultClasses: faults.filter(
+ (fault) => fault.detectedCount === fault.caseCount,
+ ).length,
+ criticalRecallPercent: criticalRecall * 100,
+ macroRecallPercent: macroRecall * 100,
+ cleanFalsePositiveRatePercent: cleanFalsePositiveRate * 100,
+ carryForward,
+ },
+ issues,
+ provenance,
+ boundaries: {
+ claim: [
+ "The result is scoped to the declared frozen protocol, provenance, and known fault matrix.",
+ "It does not establish coverage of unknown faults, model quality, or model safety.",
+ ],
+ privacy: [
+ "Only summary, per-fault aggregates, gates, and declared provenance are rendered.",
+ "All per-case fields and any private synthetic content are discarded by the adapter.",
+ ],
+ },
+ };
+}
+
+export function parseReport(value: unknown): SftGuardReport {
+ const report = asRecord(value, "report");
+ if ("schemaVersion" in report) {
+ return parseEnvelopeReport(value);
+ }
+ const schemaVersion = asString(report.schema_version, "schema_version", 100);
+ if (schemaVersion === AUDIT_SCHEMA_VERSION) {
+ return parseAuditArtifact(report);
+ }
+ if (schemaVersion === REGRESSION_SCHEMA_VERSION) {
+ return parseRegressionArtifact(report);
+ }
+ if (schemaVersion === DEVELOPMENT_BENCHMARK_SCHEMA_VERSION) {
+ return parseDevelopmentBenchmarkArtifact(report);
+ }
+ if (schemaVersion === CONFIRMATORY_FAULT_MATRIX_SCHEMA_VERSION) {
+ return parseConfirmatoryFaultMatrixArtifact(report);
+ }
+ throw new RangeError(
+ `Unsupported SFTGuard report schema: ${schemaVersion}.`,
+ );
+}
+
+export function parseReportText(text: string): SftGuardReport {
+ if (new TextEncoder().encode(text).byteLength > MAX_REPORT_BYTES) {
+ throw new RangeError("Report exceeds the 2 MB local import limit.");
+ }
+ let value: unknown;
+ try {
+ value = JSON.parse(text) as unknown;
+ } catch {
+ throw new SyntaxError("The selected file is not valid JSON.");
+ }
+ return parseReport(value);
+}
+
+export function reportTypeLabel(reportType: ReportType): string {
+ if (reportType === "fault-matrix") return "Fault matrix";
+ if (reportType === "regression") return "Regression card";
+ return "Dataset audit";
+}
diff --git a/web/src/sampleReports.ts b/web/src/sampleReports.ts
new file mode 100644
index 0000000..10829f2
--- /dev/null
+++ b/web/src/sampleReports.ts
@@ -0,0 +1,28 @@
+import auditJson from "./data/sample-audit.json";
+import faultJson from "./data/sample-fault.json";
+import regressionJson from "./data/sample-regression.json";
+import { parseReport, type SftGuardReport } from "./reportSchema";
+
+export interface ReportSource {
+ report: SftGuardReport;
+ origin: "checked-in sample" | "local import";
+ sourceLabel: string;
+}
+
+export const SAMPLE_REPORTS: ReportSource[] = [
+ {
+ report: parseReport(regressionJson),
+ origin: "checked-in sample",
+ sourceLabel: "Regression sample",
+ },
+ {
+ report: parseReport(faultJson),
+ origin: "checked-in sample",
+ sourceLabel: "Fault-matrix sample",
+ },
+ {
+ report: parseReport(auditJson),
+ origin: "checked-in sample",
+ sourceLabel: "Audit sample",
+ },
+];
diff --git a/web/src/styles.css b/web/src/styles.css
new file mode 100644
index 0000000..2c44f57
--- /dev/null
+++ b/web/src/styles.css
@@ -0,0 +1,1411 @@
+:root {
+ color: #13221f;
+ background: #ecece5;
+ font-family:
+ Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI",
+ sans-serif;
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ --ink: #13221f;
+ --muted: #60706c;
+ --line: #d7dad1;
+ --canvas: #f6f6f0;
+ --paper: #fdfdf9;
+ --navy: #152c2a;
+ --teal: #126d62;
+ --teal-soft: #dcece6;
+ --pass: #16745f;
+ --pass-soft: #dcefe7;
+ --fail: #b84238;
+ --fail-soft: #f5dfda;
+ --abstain: #9a6317;
+ --abstain-soft: #f5e9cf;
+ --shadow: 0 24px 60px rgba(24, 41, 37, 0.09);
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html {
+ scroll-behavior: smooth;
+}
+
+body {
+ margin: 0;
+ min-width: 320px;
+ min-height: 100vh;
+ background:
+ radial-gradient(circle at 12% 8%, rgba(25, 112, 98, 0.08), transparent 30rem),
+ linear-gradient(135deg, #efefe8 0%, #e8e9e2 100%);
+}
+
+button,
+input {
+ font: inherit;
+}
+
+button,
+a,
+label[for] {
+ -webkit-tap-highlight-color: transparent;
+}
+
+button {
+ color: inherit;
+}
+
+code {
+ font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
+}
+
+.skip-link {
+ position: fixed;
+ top: 0.75rem;
+ left: 0.75rem;
+ z-index: 100;
+ transform: translateY(-160%);
+ border-radius: 0.45rem;
+ background: var(--ink);
+ color: #fff;
+ padding: 0.7rem 1rem;
+ text-decoration: none;
+ transition: transform 160ms ease;
+}
+
+.skip-link:focus {
+ transform: translateY(0);
+}
+
+:focus-visible {
+ outline: 3px solid #1c8072;
+ outline-offset: 3px;
+}
+
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+.app-shell {
+ width: min(1540px, calc(100% - 2rem));
+ margin: 1rem auto;
+ overflow: hidden;
+ border: 1px solid rgba(75, 93, 88, 0.2);
+ border-radius: 1.4rem;
+ background: var(--canvas);
+ box-shadow: var(--shadow);
+}
+
+.topbar {
+ min-height: 76px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ padding: 0.95rem 1.3rem;
+ border-bottom: 1px solid var(--line);
+ background: rgba(253, 253, 249, 0.92);
+}
+
+.brand {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.8rem;
+ color: inherit;
+ text-decoration: none;
+}
+
+.brand-mark {
+ display: grid;
+ place-items: center;
+ width: 2.5rem;
+ height: 2.5rem;
+ border-radius: 0.75rem;
+ background: var(--navy);
+ color: #f6fffb;
+ font-family: "SFMono-Regular", Consolas, monospace;
+ font-weight: 750;
+ font-size: 0.78rem;
+ letter-spacing: 0.08em;
+ box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12);
+}
+
+.brand > span:last-child {
+ display: flex;
+ flex-direction: column;
+ gap: 0.08rem;
+}
+
+.brand strong {
+ font-size: 1rem;
+ letter-spacing: -0.02em;
+}
+
+.brand small {
+ color: var(--muted);
+ font-size: 0.7rem;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+
+.local-indicator {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.55rem;
+ color: #3e5a53;
+ font-size: 0.76rem;
+ font-weight: 650;
+}
+
+.local-indicator > span {
+ width: 0.55rem;
+ height: 0.55rem;
+ border-radius: 50%;
+ background: #2a9b82;
+ box-shadow: 0 0 0 5px rgba(42, 155, 130, 0.1);
+}
+
+.workspace {
+ display: grid;
+ grid-template-columns: 292px minmax(0, 1fr);
+ align-items: start;
+}
+
+.report-rail {
+ position: sticky;
+ top: 0;
+ min-height: calc(100vh - 108px);
+ display: flex;
+ flex-direction: column;
+ gap: 1.15rem;
+ padding: 1.3rem 1.05rem;
+ border-right: 1px solid var(--line);
+ background: #f0f1ea;
+}
+
+.rail-heading {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 0.3rem;
+}
+
+.rail-heading p {
+ margin: 0;
+ color: #60706c;
+ font-size: 0.69rem;
+ font-weight: 750;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+}
+
+.rail-heading span {
+ min-width: 1.8rem;
+ border-radius: 999px;
+ background: #dfe3da;
+ padding: 0.24rem 0.45rem;
+ color: #4b5d58;
+ text-align: center;
+ font-family: "SFMono-Regular", Consolas, monospace;
+ font-size: 0.66rem;
+}
+
+.report-list {
+ display: grid;
+ gap: 0.52rem;
+}
+
+.report-list button {
+ appearance: none;
+ width: 100%;
+ display: grid;
+ gap: 0.5rem;
+ border: 1px solid transparent;
+ border-radius: 0.85rem;
+ background: transparent;
+ padding: 0.85rem;
+ text-align: left;
+ cursor: pointer;
+ transition:
+ border-color 150ms ease,
+ background 150ms ease,
+ transform 150ms ease;
+}
+
+.report-list button:hover {
+ border-color: #d3d8ce;
+ background: rgba(255, 255, 255, 0.6);
+}
+
+.report-list button.is-active {
+ border-color: #cad4cc;
+ background: var(--paper);
+ box-shadow: 0 8px 24px rgba(31, 52, 47, 0.06);
+}
+
+.report-list button.is-active::before {
+ content: "";
+ position: absolute;
+}
+
+.report-list-topline {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.4rem;
+ color: #61716d;
+ font-size: 0.66rem;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+
+.report-list button > strong {
+ color: #1b2b28;
+ font-size: 0.86rem;
+ line-height: 1.3;
+}
+
+.report-list button > small {
+ color: #7a8884;
+ font-size: 0.68rem;
+}
+
+.status-badge {
+ width: fit-content;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ border: 1px solid currentColor;
+ border-radius: 999px;
+ padding: 0.4rem 0.68rem;
+ font-size: 0.72rem;
+ font-weight: 800;
+ letter-spacing: 0.08em;
+}
+
+.status-badge.is-compact {
+ gap: 0.3rem;
+ border-color: transparent;
+ padding: 0.12rem 0;
+ background: transparent;
+ font-size: 0.59rem;
+}
+
+.status-dot {
+ width: 0.45rem;
+ height: 0.45rem;
+ border-radius: 50%;
+ background: currentColor;
+}
+
+.status-pass {
+ color: var(--pass);
+ background: var(--pass-soft);
+}
+
+.status-fail {
+ color: var(--fail);
+ background: var(--fail-soft);
+}
+
+.status-abstain {
+ color: var(--abstain);
+ background: var(--abstain-soft);
+}
+
+.import-card {
+ margin-top: auto;
+ border: 1px solid #d5dad0;
+ border-radius: 1rem;
+ background: #e7e9e1;
+ padding: 1rem;
+}
+
+.import-eyebrow {
+ margin: 0 0 0.45rem;
+ color: var(--teal);
+ font-size: 0.65rem;
+ font-weight: 800;
+ letter-spacing: 0.11em;
+ text-transform: uppercase;
+}
+
+.import-card h2 {
+ margin: 0;
+ font-size: 1.02rem;
+ letter-spacing: -0.025em;
+}
+
+.import-card > p:not(.import-eyebrow, .import-message) {
+ margin: 0.6rem 0 0.9rem;
+ color: #61706c;
+ font-size: 0.73rem;
+ line-height: 1.55;
+}
+
+.import-button {
+ appearance: none;
+ min-height: 42px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ border-radius: 0.65rem;
+ border: 0;
+ background: var(--navy);
+ color: #f7fffb;
+ padding: 0.65rem 0.8rem;
+ font-size: 0.76rem;
+ font-weight: 750;
+ cursor: pointer;
+ transition:
+ background 150ms ease,
+ transform 150ms ease;
+}
+
+.import-button:hover {
+ background: #1c413c;
+ transform: translateY(-1px);
+}
+
+.visually-hidden-input {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ opacity: 0;
+ pointer-events: none;
+}
+
+.import-message {
+ min-height: 2.5em;
+ margin: 0.7rem 0 0;
+ color: #677570;
+ font-size: 0.65rem;
+ line-height: 1.4;
+}
+
+.import-message.is-error {
+ color: var(--fail);
+}
+
+.clear-import {
+ appearance: none;
+ border: 0;
+ background: none;
+ padding: 0.4rem 0 0;
+ color: #476760;
+ font-size: 0.68rem;
+ font-weight: 700;
+ text-decoration: underline;
+ text-underline-offset: 0.2rem;
+ cursor: pointer;
+}
+
+.report-main {
+ min-width: 0;
+ padding: 1.35rem;
+}
+
+.decision-hero {
+ position: relative;
+ overflow: hidden;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 240px;
+ gap: 2rem;
+ border-radius: 1.25rem;
+ background: var(--navy);
+ color: #f5faf7;
+ padding: clamp(1.5rem, 4vw, 3.2rem);
+ isolation: isolate;
+}
+
+.decision-hero::before,
+.decision-hero::after {
+ content: "";
+ position: absolute;
+ z-index: -1;
+ border-radius: 50%;
+ pointer-events: none;
+}
+
+.decision-hero::before {
+ width: 28rem;
+ height: 28rem;
+ right: -11rem;
+ top: -15rem;
+ background: radial-gradient(circle, rgba(103, 211, 183, 0.18), transparent 66%);
+}
+
+.decision-hero::after {
+ width: 15rem;
+ height: 15rem;
+ left: 34%;
+ bottom: -11rem;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ box-shadow:
+ 0 0 0 2.5rem rgba(255, 255, 255, 0.018),
+ 0 0 0 5rem rgba(255, 255, 255, 0.012);
+}
+
+.hero-pass {
+ --hero-accent: #73d8b9;
+}
+
+.hero-fail {
+ --hero-accent: #ff988b;
+}
+
+.hero-abstain {
+ --hero-accent: #edc173;
+}
+
+.hero-meta {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.7rem;
+ margin-bottom: 2.8rem;
+}
+
+.hero-meta > span:not(.status-badge) {
+ color: #9fb3ad;
+ font-size: 0.7rem;
+ font-weight: 650;
+ letter-spacing: 0.05em;
+ text-transform: capitalize;
+}
+
+.hero-meta > span:not(.status-badge) + span:not(.status-badge)::before {
+ content: "/";
+ margin-right: 0.7rem;
+ color: #687d77;
+}
+
+.hero-eyebrow {
+ margin: 0 0 0.7rem;
+ color: var(--hero-accent);
+ font-size: 0.74rem;
+ font-weight: 800;
+ letter-spacing: 0.11em;
+ text-transform: uppercase;
+}
+
+.decision-copy h1 {
+ max-width: 760px;
+ margin: 0;
+ font-size: clamp(2rem, 5vw, 4.2rem);
+ line-height: 0.98;
+ letter-spacing: -0.055em;
+ text-wrap: balance;
+}
+
+.hero-summary {
+ max-width: 720px;
+ margin: 1.35rem 0 0;
+ color: #c6d3cf;
+ font-size: clamp(0.92rem, 1.5vw, 1.07rem);
+ line-height: 1.68;
+}
+
+.hero-action {
+ width: fit-content;
+ margin: 1.45rem 0 0;
+ border-left: 2px solid var(--hero-accent);
+ padding: 0.2rem 0 0.2rem 0.8rem;
+ color: #f0f7f4;
+ font-size: 0.8rem;
+ font-weight: 700;
+}
+
+.decision-seal {
+ align-self: center;
+ display: grid;
+ justify-items: center;
+ gap: 1rem;
+ text-align: center;
+}
+
+.decision-seal > span {
+ display: grid;
+ place-items: center;
+ width: 8rem;
+ height: 8rem;
+ border: 1px solid color-mix(in srgb, var(--hero-accent) 70%, transparent);
+ border-radius: 50%;
+ background: color-mix(in srgb, var(--hero-accent) 8%, transparent);
+ color: var(--hero-accent);
+ font-family: "SFMono-Regular", Consolas, monospace;
+ font-size: 3.7rem;
+ font-weight: 300;
+ box-shadow:
+ inset 0 0 0 0.75rem rgba(255, 255, 255, 0.025),
+ 0 0 0 0.65rem rgba(255, 255, 255, 0.025);
+}
+
+.decision-seal div {
+ display: grid;
+ gap: 0.35rem;
+}
+
+.decision-seal strong {
+ color: var(--hero-accent);
+ font-size: 0.78rem;
+ letter-spacing: 0.14em;
+}
+
+.decision-seal small {
+ max-width: 200px;
+ color: #9fb1ac;
+ font-size: 0.66rem;
+ line-height: 1.45;
+}
+
+.hero-footer {
+ grid-column: 1 / -1;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
+ margin-top: 0.5rem;
+ padding-top: 1.1rem;
+ color: #91a69f;
+ font-size: 0.68rem;
+}
+
+.hero-footer code {
+ color: #c9d7d3;
+}
+
+.report-section {
+ scroll-margin-top: 1rem;
+ padding: clamp(2.3rem, 5vw, 4.5rem) clamp(0.1rem, 2vw, 1rem);
+ border-bottom: 1px solid var(--line);
+}
+
+.section-heading {
+ display: grid;
+ grid-template-columns: 3rem minmax(0, 1fr);
+ gap: 1rem;
+ align-items: start;
+ margin-bottom: 1.5rem;
+}
+
+.section-number {
+ padding-top: 0.34rem;
+ color: #8b9894;
+ font-family: "SFMono-Regular", Consolas, monospace;
+ font-size: 0.72rem;
+}
+
+.section-heading h2 {
+ margin: 0;
+ color: #182824;
+ font-size: clamp(1.45rem, 2.6vw, 2.2rem);
+ letter-spacing: -0.04em;
+}
+
+.section-heading p {
+ margin: 0.45rem 0 0;
+ color: #6a7975;
+ font-size: 0.83rem;
+ line-height: 1.55;
+}
+
+.metric-grid {
+ display: grid;
+ gap: 0.8rem;
+}
+
+.metric-grid.two-up {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.metric-grid.three-up {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.supplemental-metrics {
+ margin-top: 0.8rem;
+}
+
+.metric-card {
+ position: relative;
+ overflow: hidden;
+ min-height: 190px;
+ border: 1px solid var(--line);
+ border-radius: 1rem;
+ background: var(--paper);
+ padding: 1.25rem;
+}
+
+.metric-card::after {
+ content: "";
+ position: absolute;
+ right: 0;
+ top: 0;
+ width: 3rem;
+ height: 3rem;
+ border-radius: 0 0 0 3rem;
+ background: #e8ebe4;
+}
+
+.metric-positive::after {
+ background: var(--pass-soft);
+}
+
+.metric-negative::after {
+ background: var(--fail-soft);
+}
+
+.metric-caution::after {
+ background: var(--abstain-soft);
+}
+
+.metric-label {
+ margin: 0;
+ color: #687772;
+ font-size: 0.69rem;
+ font-weight: 800;
+ letter-spacing: 0.09em;
+ text-transform: uppercase;
+}
+
+.metric-value {
+ margin: 1.05rem 0 0;
+ color: #172723;
+ font-size: clamp(2rem, 4vw, 3.25rem);
+ font-weight: 600;
+ letter-spacing: -0.055em;
+}
+
+.metric-positive .metric-value {
+ color: var(--pass);
+}
+
+.metric-negative .metric-value {
+ color: var(--fail);
+}
+
+.metric-caution .metric-value {
+ color: var(--abstain);
+}
+
+.metric-context {
+ max-width: 440px;
+ margin: 0.35rem 0 0;
+ color: #71807c;
+ font-size: 0.73rem;
+ line-height: 1.45;
+}
+
+.score-pair {
+ display: grid;
+ grid-template-columns: auto minmax(2rem, 1fr) auto;
+ align-items: center;
+ gap: 0.65rem;
+ margin-top: 1.2rem;
+ color: #687772;
+ font-family: "SFMono-Regular", Consolas, monospace;
+ font-size: 0.64rem;
+}
+
+.score-line {
+ height: 1px;
+ background: linear-gradient(90deg, #d8ded7, #7eb5a6, #d8ded7);
+}
+
+.category-strip {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ margin-bottom: 0.8rem;
+}
+
+.category-strip span {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.55rem;
+ border: 1px solid #d8ddd4;
+ border-radius: 999px;
+ background: #f8f8f3;
+ padding: 0.45rem 0.7rem;
+ color: #586863;
+ font-size: 0.69rem;
+}
+
+.category-strip strong {
+ min-width: 1.3rem;
+ border-radius: 999px;
+ background: #e5e9e1;
+ padding: 0.12rem 0.32rem;
+ text-align: center;
+ color: #2b423c;
+ font-size: 0.63rem;
+}
+
+.table-shell {
+ overflow-x: auto;
+ border: 1px solid var(--line);
+ border-radius: 1rem;
+ background: var(--paper);
+}
+
+table {
+ width: 100%;
+ border-collapse: collapse;
+ text-align: left;
+}
+
+th {
+ background: #f0f2eb;
+ padding: 0.78rem 1rem;
+ color: #687772;
+ font-size: 0.64rem;
+ font-weight: 800;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+td {
+ padding: 1rem;
+ border-top: 1px solid #e1e4dd;
+ color: #3a4c47;
+ font-size: 0.76rem;
+ vertical-align: top;
+}
+
+td:first-child {
+ width: 62%;
+}
+
+.issue-title {
+ color: #1a2a26;
+ font-size: 0.85rem;
+ font-weight: 750;
+}
+
+td code {
+ display: inline-block;
+ margin-top: 0.35rem;
+ color: #697a75;
+ font-size: 0.64rem;
+}
+
+td p {
+ max-width: 680px;
+ margin: 0.55rem 0 0;
+ color: #778580;
+ font-size: 0.69rem;
+ line-height: 1.52;
+}
+
+.number-cell {
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+}
+
+.severity {
+ display: inline-block;
+ border-radius: 999px;
+ padding: 0.25rem 0.48rem;
+ font-size: 0.61rem;
+ font-weight: 750;
+ text-transform: capitalize;
+}
+
+.severity-critical,
+.severity-error {
+ background: var(--fail-soft);
+ color: var(--fail);
+}
+
+.severity-warning {
+ background: var(--abstain-soft);
+ color: var(--abstain);
+}
+
+.severity-info {
+ background: var(--teal-soft);
+ color: var(--teal);
+}
+
+.empty-panel {
+ border: 1px dashed #cbd1c8;
+ border-radius: 1rem;
+ background: rgba(255, 255, 255, 0.45);
+ padding: clamp(1.4rem, 4vw, 2.4rem);
+}
+
+.empty-panel h3 {
+ margin: 0;
+ font-size: 1.2rem;
+ letter-spacing: -0.03em;
+}
+
+.empty-panel p {
+ max-width: 650px;
+ margin: 0.55rem 0 0;
+ color: #71807c;
+ font-size: 0.76rem;
+ line-height: 1.6;
+}
+
+.empty-panel .empty-kicker {
+ margin: 0 0 0.5rem;
+ color: var(--teal);
+ font-size: 0.63rem;
+ font-weight: 800;
+ letter-spacing: 0.09em;
+ text-transform: uppercase;
+}
+
+.compact-empty {
+ padding: 1.4rem;
+}
+
+.coverage-layout {
+ display: grid;
+ grid-template-columns: 230px minmax(0, 1fr);
+ align-items: center;
+ gap: clamp(1.5rem, 5vw, 4rem);
+ border: 1px solid var(--line);
+ border-radius: 1rem;
+ background: var(--paper);
+ padding: clamp(1.5rem, 4vw, 2.5rem);
+}
+
+.coverage-ring {
+ --coverage: 0%;
+ width: min(100%, 210px);
+ aspect-ratio: 1;
+ display: grid;
+ place-items: center;
+ border-radius: 50%;
+ background: conic-gradient(var(--teal) var(--coverage), #e4e8e0 0);
+ position: relative;
+}
+
+.coverage-ring::after {
+ content: "";
+ position: absolute;
+ inset: 0.85rem;
+ border-radius: 50%;
+ background: var(--paper);
+}
+
+.coverage-ring > div {
+ position: relative;
+ z-index: 1;
+ display: grid;
+ justify-items: center;
+}
+
+.coverage-ring strong {
+ color: #1c342e;
+ font-size: 3.2rem;
+ font-weight: 550;
+ letter-spacing: -0.06em;
+}
+
+.coverage-ring span {
+ color: #74827e;
+ font-size: 0.72rem;
+}
+
+.coverage-result {
+ margin: 0;
+ color: #263d37;
+ font-size: 1.12rem;
+ font-weight: 750;
+ letter-spacing: -0.025em;
+}
+
+.coverage-copy > p:nth-child(2) {
+ max-width: 670px;
+ margin: 0.55rem 0 1.4rem;
+ color: #71807c;
+ font-size: 0.76rem;
+ line-height: 1.55;
+}
+
+.coverage-bars {
+ display: grid;
+ gap: 1rem;
+}
+
+.coverage-row {
+ display: grid;
+ grid-template-columns: minmax(150px, 1fr) auto;
+ gap: 0.4rem 1rem;
+}
+
+.coverage-row > div:first-child {
+ display: flex;
+ align-items: baseline;
+ gap: 0.5rem;
+ color: #43554f;
+ font-size: 0.72rem;
+ font-weight: 700;
+}
+
+.coverage-target {
+ color: #8a9692;
+ font-size: 0.62rem;
+ font-weight: 500;
+}
+
+.coverage-value {
+ color: #263b35;
+ font-family: "SFMono-Regular", Consolas, monospace;
+ font-size: 0.7rem;
+}
+
+.bar-track {
+ grid-column: 1 / -1;
+ height: 0.38rem;
+ overflow: hidden;
+ border-radius: 999px;
+ background: #e2e6de;
+}
+
+.bar-track span {
+ display: block;
+ height: 100%;
+ border-radius: inherit;
+ background: var(--teal);
+}
+
+.bar-track.is-inverse span {
+ background: var(--abstain);
+}
+
+.provenance-grid {
+ display: grid;
+ grid-template-columns: minmax(190px, 0.65fr) minmax(0, 1.35fr);
+ gap: 0.8rem;
+}
+
+.replay-card,
+.provenance-list {
+ border: 1px solid var(--line);
+ border-radius: 1rem;
+ background: var(--paper);
+}
+
+.replay-card {
+ min-height: 220px;
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-end;
+ padding: 1.3rem;
+}
+
+.replay-card::before {
+ content: "";
+ width: 2.5rem;
+ height: 2.5rem;
+ margin-bottom: auto;
+ border: 1px solid currentColor;
+ border-radius: 50%;
+ background: radial-gradient(circle, currentColor 0 19%, transparent 21%);
+}
+
+.replay-pass {
+ color: var(--pass);
+}
+
+.replay-fail {
+ color: var(--fail);
+}
+
+.replay-unknown {
+ color: var(--muted);
+}
+
+.replay-card p {
+ margin: 0;
+ color: #72817c;
+ font-size: 0.68rem;
+ font-weight: 750;
+ letter-spacing: 0.07em;
+ text-transform: uppercase;
+}
+
+.replay-card strong {
+ margin-top: 0.45rem;
+ font-size: 1.55rem;
+ letter-spacing: -0.04em;
+}
+
+.replay-card span {
+ margin-top: 0.25rem;
+ color: #7a8884;
+ font-size: 0.68rem;
+}
+
+.provenance-list {
+ margin: 0;
+ overflow: hidden;
+}
+
+.provenance-list > div {
+ display: grid;
+ grid-template-columns: minmax(130px, 0.8fr) minmax(0, 1.2fr);
+ gap: 1rem;
+ padding: 0.84rem 1rem;
+ border-bottom: 1px solid #e2e5de;
+}
+
+.provenance-list > div:last-child {
+ border-bottom: 0;
+}
+
+.provenance-list dt {
+ color: #74827e;
+ font-size: 0.68rem;
+}
+
+.provenance-list dd {
+ min-width: 0;
+ margin: 0;
+ color: #30443e;
+ font-size: 0.7rem;
+ font-weight: 650;
+ overflow-wrap: anywhere;
+}
+
+.provenance-list .hash-value {
+ font-family: "SFMono-Regular", Consolas, monospace;
+ font-size: 0.66rem;
+}
+
+.boundaries-section {
+ border-bottom: 0;
+}
+
+.boundary-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.8rem;
+}
+
+.boundary-card {
+ min-height: 260px;
+ border-radius: 1rem;
+ padding: clamp(1.3rem, 3vw, 2rem);
+}
+
+.claim-boundary {
+ background: #1b302d;
+ color: #ecf5f1;
+}
+
+.privacy-boundary {
+ border: 1px solid #cddbd4;
+ background: var(--teal-soft);
+ color: #1c3932;
+}
+
+.boundary-label {
+ margin: 0;
+ color: #79c9b3;
+ font-size: 0.65rem;
+ font-weight: 800;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+}
+
+.privacy-boundary .boundary-label {
+ color: var(--teal);
+}
+
+.boundary-card h3 {
+ margin: 0.75rem 0 1.2rem;
+ font-size: 1.35rem;
+ letter-spacing: -0.035em;
+}
+
+.boundary-card ul {
+ display: grid;
+ gap: 0.8rem;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.boundary-card li {
+ position: relative;
+ padding-left: 1rem;
+ color: #b9cbc5;
+ font-size: 0.73rem;
+ line-height: 1.55;
+}
+
+.privacy-boundary li {
+ color: #4e6b63;
+}
+
+.boundary-card li::before {
+ content: "";
+ position: absolute;
+ left: 0;
+ top: 0.56rem;
+ width: 0.35rem;
+ height: 0.35rem;
+ border-radius: 50%;
+ background: #73cbb3;
+}
+
+.report-footer {
+ display: grid;
+ grid-template-columns: auto minmax(0, 720px);
+ justify-content: space-between;
+ gap: 2rem;
+ border-top: 1px solid var(--line);
+ padding: 1.6rem 1rem 0.7rem;
+ color: #75837f;
+ font-size: 0.67rem;
+}
+
+.report-footer span {
+ color: #40554f;
+ font-weight: 750;
+}
+
+.report-footer p {
+ margin: 0;
+ line-height: 1.6;
+ text-align: right;
+}
+
+@media (max-width: 1080px) {
+ .workspace {
+ grid-template-columns: 240px minmax(0, 1fr);
+ }
+
+ .report-rail {
+ padding-inline: 0.75rem;
+ }
+
+ .decision-hero {
+ grid-template-columns: minmax(0, 1fr) 170px;
+ }
+
+ .decision-seal > span {
+ width: 6.5rem;
+ height: 6.5rem;
+ font-size: 3rem;
+ }
+
+ .coverage-layout {
+ grid-template-columns: 180px minmax(0, 1fr);
+ }
+}
+
+@media (max-width: 820px) {
+ .app-shell {
+ width: 100%;
+ margin: 0;
+ border: 0;
+ border-radius: 0;
+ }
+
+ .topbar {
+ position: sticky;
+ top: 0;
+ z-index: 20;
+ }
+
+ .workspace {
+ display: block;
+ }
+
+ .report-rail {
+ position: static;
+ min-height: 0;
+ border-right: 0;
+ border-bottom: 1px solid var(--line);
+ padding: 1rem;
+ }
+
+ .report-list {
+ display: grid;
+ grid-auto-flow: column;
+ grid-auto-columns: minmax(230px, 72vw);
+ overflow-x: auto;
+ padding-bottom: 0.4rem;
+ scroll-snap-type: x proximity;
+ }
+
+ .report-list button {
+ scroll-snap-align: start;
+ }
+
+ .import-card {
+ margin-top: 0;
+ }
+
+ .decision-hero {
+ grid-template-columns: 1fr;
+ }
+
+ .decision-seal {
+ grid-template-columns: auto minmax(0, 1fr);
+ justify-items: start;
+ text-align: left;
+ }
+
+ .decision-seal > span {
+ width: 4.5rem;
+ height: 4.5rem;
+ font-size: 2.1rem;
+ }
+
+ .hero-footer {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .metric-grid.three-up {
+ grid-template-columns: 1fr;
+ }
+
+ .coverage-layout {
+ grid-template-columns: 1fr;
+ }
+
+ .coverage-ring {
+ width: 170px;
+ }
+
+ .provenance-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (max-width: 600px) {
+ .topbar {
+ min-height: 68px;
+ padding-inline: 1rem;
+ }
+
+ .local-indicator {
+ max-width: 9rem;
+ font-size: 0.65rem;
+ line-height: 1.3;
+ text-align: right;
+ }
+
+ .report-main {
+ padding: 0.75rem;
+ }
+
+ .decision-hero {
+ border-radius: 1rem;
+ padding: 1.25rem;
+ }
+
+ .hero-meta {
+ margin-bottom: 2rem;
+ }
+
+ .decision-copy h1 {
+ font-size: clamp(2rem, 11vw, 3rem);
+ }
+
+ .metric-grid.two-up,
+ .boundary-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .section-heading {
+ grid-template-columns: 2rem minmax(0, 1fr);
+ gap: 0.5rem;
+ }
+
+ .table-shell {
+ border: 0;
+ background: transparent;
+ overflow: visible;
+ }
+
+ table,
+ thead,
+ tbody,
+ tr,
+ th,
+ td {
+ display: block;
+ }
+
+ thead {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ }
+
+ tbody {
+ display: grid;
+ gap: 0.7rem;
+ }
+
+ tr {
+ border: 1px solid var(--line);
+ border-radius: 0.85rem;
+ background: var(--paper);
+ padding: 0.9rem;
+ }
+
+ td,
+ td:first-child {
+ width: auto;
+ border: 0;
+ padding: 0.35rem 0;
+ }
+
+ .number-cell {
+ text-align: left;
+ }
+
+ .number-cell::before {
+ content: "Count: ";
+ color: #7b8985;
+ }
+
+ .provenance-list > div {
+ grid-template-columns: 1fr;
+ gap: 0.3rem;
+ }
+
+ .report-footer {
+ grid-template-columns: 1fr;
+ gap: 0.8rem;
+ }
+
+ .report-footer p {
+ text-align: left;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ scroll-behavior: auto !important;
+ transition-duration: 0.01ms !important;
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ }
+}
diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/web/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 0000000..220f11d
--- /dev/null
+++ b/web/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "types": ["vite/client", "vitest/globals"]
+ },
+ "include": ["src", "vite.config.ts"]
+}
diff --git a/web/vite.config.ts b/web/vite.config.ts
new file mode 100644
index 0000000..2fb6b7b
--- /dev/null
+++ b/web/vite.config.ts
@@ -0,0 +1,13 @@
+import react from "@vitejs/plugin-react";
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ plugins: [react()],
+ build: {
+ target: "es2022",
+ sourcemap: true,
+ },
+ test: {
+ environment: "node",
+ },
+});
From 97bb02364c027b787d7750931434734c07ca7644 Mon Sep 17 00:00:00 2001
From: Muhammad Labeeb Aryan <93521146+Labeeb2339@users.noreply.github.com>
Date: Sun, 19 Jul 2026 20:41:23 +0800
Subject: [PATCH 2/4] Publish preregistered v0.1 evidence
---
.github/workflows/pages.yml | 1 +
CHANGELOG.md | 3 +
README.md | 61 +
docs/ANNOUNCEMENT.md | 71 +
docs/ARCHITECTURE.md | 2 +-
docs/RESULTS.md | 50 +
.../sftguard-confirmatory-dashboard.png | Bin 0 -> 62580 bytes
evidence/v0.1.0-confirmatory.json | 4046 +++++++++++++++++
evidence/v0.1.0-confirmatory.manifest.json | 12 +
pyproject.toml | 1 -
web/README.md | 5 +-
web/src/App.tsx | 8 +-
web/src/sampleReports.ts | 8 +-
13 files changed, 4262 insertions(+), 6 deletions(-)
create mode 100644 docs/ANNOUNCEMENT.md
create mode 100644 docs/RESULTS.md
create mode 100644 docs/assets/sftguard-confirmatory-dashboard.png
create mode 100644 evidence/v0.1.0-confirmatory.json
create mode 100644 evidence/v0.1.0-confirmatory.manifest.json
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
index 12e72ec..5c91abc 100644
--- a/.github/workflows/pages.yml
+++ b/.github/workflows/pages.yml
@@ -5,6 +5,7 @@ on:
branches: [main]
paths:
- web/**
+ - evidence/**
- .github/workflows/pages.yml
workflow_dispatch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8e8fa2e..62f7619 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,9 @@ All notable changes to SFTGuard are documented here. The project follows
- Paired target/retention Regression Cards with deterministic seed-cluster
bootstrap intervals and a fixed work budget.
- One-shot, fresh-process confirmatory evidence generation and replay tooling.
+- Checked-in v0.1 confirmatory evidence: 270/270 known synthetic faults
+ detected, 0/30 clean controls flagged, and byte-identical replay from the
+ frozen implementation commit.
- Static browser-local report viewer for native SFTGuard artifacts.
- Cross-platform CI, CodeQL, dependency audits, Pages deployment, community
templates, citation metadata, and security documentation.
diff --git a/README.md b/README.md
index 8bfecf2..6d344bd 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,8 @@
**Fail-closed preflight and regression CI for supervised fine-tuning.**
+
+
SFTGuard catches a narrow set of expensive-to-discover pipeline mistakes before
an adapter is released: malformed chat records, conflicting or leaked examples,
credential-shaped text, broken EOS handling, prompt tokens included in loss, and
@@ -119,6 +121,34 @@ frozen xorshift32 stream, and applies the threshold to the lower confidence
bound. Unpaired, duplicated, undeclared, non-finite, or incomplete evidence
returns `ABSTAIN`.
+The fixed joint work budget also returns `ABSTAIN` before an extreme
+draw-count × pair-count request can consume unbounded CPU time.
+
+### Put the gate in GitHub Actions
+
+```yaml
+name: SFT preflight
+on: [push, pull_request]
+
+jobs:
+ sftguard:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-python@v6
+ with:
+ python-version: "3.11"
+ - run: python -m pip install git+https://github.com/Labeeb2339/sftguard.git
+ - run: >-
+ sftguard audit
+ --train data/train.jsonl
+ --eval data/eval.jsonl
+ --output artifacts/sftguard-audit.json
+```
+
+The command's exit code blocks `FAIL` and `ABSTAIN` by default. Keep private
+datasets out of Actions artifacts unless their disclosure is explicitly safe.
+
## Static report viewer
The viewer is a static React application with no analytics, report endpoint,
@@ -147,6 +177,7 @@ evidence runner are committed and pushed before the confirmatory run.
- [Product specification](docs/PRODUCT_SPEC.md)
- [Evidence runner](scripts/confirmatory_evidence.py)
- [Evidence reproduction guide](docs/EVIDENCE.md)
+- [Confirmatory result summary](docs/RESULTS.md)
- [Related work and boundaries](docs/RELATED_WORK.md)
The matrix is an **internal detector regression**: each seed changes synthetic
@@ -158,6 +189,33 @@ Checked-in confirmatory evidence is added only after the runner verifies a clean
and pushed implementation commit and reproduces the artifact in two fresh
processes. The result is preserved even if a frozen gate fails.
+### v0.1 confirmatory result
+
+All six frozen carry-forward gates passed without post-result changes:
+
+| Measure | Frozen gate | Observed |
+| --- | ---: | ---: |
+| Critical fault recall | 100% per class | **100%** |
+| Macro recall, nine fault classes | ≥ 90% | **100%** |
+| Clean-control false-positive rate | ≤ 10% | **0 / 30 (0%)** |
+| Fault injections detected | — | **270 / 270** |
+| Fresh-process reproduction | byte-identical | **matched** |
+| Raw synthetic text in report | none | **none detected** |
+
+`carry_forward: true` across 300 synthetic cases. Protocol commit:
+`d4c74cefca6729124142635dc8893b5e3ddec2d3`. Implementation commit:
+`b0f168ab4d6a959d4e28611e345a250c14ca2ea9`.
+
+- [Confirmatory artifact](evidence/v0.1.0-confirmatory.json)
+- [Evidence manifest](evidence/v0.1.0-confirmatory.manifest.json)
+- Raw artifact SHA-256:
+ `961b2842d7791f6f3af7bec5bce6105e433b68d41bf6f7b82a9f3a98e1f695ab`
+- Canonical payload SHA-256:
+ `eb55eaa16a492881ff1bcec8812b2ef37bb2ed6055b6b90a1da408386f632e3a`
+
+This supports the preregistered synthetic-detector statement only. It is not a
+claim that 300 varied real-world fine-tuning pipelines were evaluated.
+
## Privacy and security boundary
- Files are bounded and parsed as untrusted UTF-8 JSON/JSONL.
@@ -195,6 +253,9 @@ Contributions that add a detector should include a minimal fault fixture, a
clean control, redaction tests, and a precise claim boundary. Start with
[CONTRIBUTING.md](CONTRIBUTING.md) and the [roadmap](docs/ROADMAP.md).
+Planning to share the project? Use the truthful, limitation-preserving
+[Reddit and Discord drafts](docs/ANNOUNCEMENT.md).
+
## License and citation
Apache-2.0. Citation metadata is available in [CITATION.cff](CITATION.cff).
diff --git a/docs/ANNOUNCEMENT.md b/docs/ANNOUNCEMENT.md
new file mode 100644
index 0000000..fcf303a
--- /dev/null
+++ b/docs/ANNOUNCEMENT.md
@@ -0,0 +1,71 @@
+# Share copy
+
+These drafts describe the checked-in v0.1 result without calling it a
+breakthrough, benchmark win, or safety certification. Check each community's
+current self-promotion rules before posting and do not mass-post identical copy.
+
+## Reddit
+
+**Title**
+
+> I built SFTGuard: a fail-closed preflight and regression gate for supervised fine-tuning
+
+**Body**
+
+> I kept seeing fine-tuning checks discussed separately, so I built a small
+> local tool that turns the deterministic ones into a release gate.
+>
+> SFTGuard audits chat JSONL plus exported token/loss-mask evidence for schema,
+> role order, exact duplicates, conflicting answers, train/eval leakage,
+> credential-shaped text, EOS handling, prompt supervision, and destructive
+> truncation. It also compares paired base-vs-adapter metrics against explicit
+> target and retention budgets. Missing evidence returns ABSTAIN instead of
+> silently passing.
+>
+> I preregistered the synthetic fault protocol before running its confirmatory
+> seeds. On the frozen internal matrix it detected 270/270 injected faults, had
+> 0/30 clean-control false positives, and reproduced the artifact byte-for-byte
+> from the recorded implementation commit.
+>
+> Important limitation: this is internal regression evidence using one
+> structural template per known fault class—not an external benchmark, model
+> quality result, or proof that it catches unknown failures.
+>
+> I would especially value technical feedback on the exported mask-evidence
+> contract and the paired retention gate.
+>
+> Repo: https://github.com/Labeeb2339/sftguard
+>
+> Static viewer: https://labeeb2339.github.io/sftguard/
+
+## Discord
+
+> I built **SFTGuard**, a local fail-closed preflight + regression gate for SFT
+> pipelines. It checks chat data and exported loss-mask evidence, then gates
+> paired base-vs-adapter target/retention metrics with PASS / FAIL / ABSTAIN.
+>
+> Frozen synthetic result: 270/270 known fault injections detected, 0/30 clean
+> controls flagged, exact implementation replay matched. This is internal
+> detector regression evidence, not an external benchmark or safety claim.
+>
+> Feedback on the mask-evidence format and regression contract would help:
+> https://github.com/Labeeb2339/sftguard
+
+## Optional author line
+
+Use this only where age/context is relevant; the technical work should stand on
+its own:
+
+> I’m a Form 3 student in Sarawak, Malaysia, and I’m using this project to learn
+> how to make AI tooling claims reproducible instead of relying on demos alone.
+
+## Before posting
+
+- Open the public repo and Pages link in a signed-out window.
+- Use only [`docs/assets/sftguard-confirmatory-dashboard.png`](assets/sftguard-confirmatory-dashboard.png);
+ do not capture private tabs,
+ local file paths, Discord messages, tokens, or notifications.
+- Keep the limitation paragraph intact.
+- Ask for specific technical feedback; do not trade stars, spam, or imply that
+ stars validate the research claim.
+- If a community bans project promotion, do not post there.
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 710e72d..1925009 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -15,7 +15,7 @@ selected card without sending it to a report-processing service.
```mermaid
flowchart LR
A["Local JSONL records"] --> B["Bounded parser and schema audit"]
- T["Optional compatible tokenizer"] --> C["Template and loss-mask audit"]
+ T["Trusted exported token/mask evidence"] --> C["Template and loss-mask audit"]
B --> C
C --> D["Deterministic findings"]
E["Paired base and adapter evaluations"] --> F["Target and retention gates"]
diff --git a/docs/RESULTS.md b/docs/RESULTS.md
new file mode 100644
index 0000000..6e0bc2d
--- /dev/null
+++ b/docs/RESULTS.md
@@ -0,0 +1,50 @@
+# v0.1 confirmatory result
+
+Status: `PASS`
+
+Carry forward: `true`
+
+The result was generated after the protocol and implementation were committed
+and pushed. No detector or threshold was changed after confirmatory execution.
+Post-result changes are limited to checked-in evidence, documentation,
+packaging metadata, Pages routing, and rendering that evidence in the static
+viewer; the recorded implementation remains the replay authority.
+
+## Frozen gates
+
+| Gate | Result |
+| --- | ---: |
+| 100% recall for every critical fault class | pass — 30/30 for each class |
+| Macro recall across all nine fault classes ≥ 90% | pass — 100% |
+| Clean-control false-positive rate ≤ 10% | pass — 0/30 (0%) |
+| Expected and observed primary signal recorded | pass |
+| Fresh process reproduces result bytes | pass |
+| Raw prompts, responses, and injected secrets absent | pass |
+
+Across 30 seeds, all 270 injected cases emitted their required primary signal.
+All 30 clean controls emitted no signal. The exact implementation replay
+matched the checked-in artifact byte-for-byte.
+
+## Provenance
+
+| Item | Value |
+| --- | --- |
+| Protocol commit | `d4c74cefca6729124142635dc8893b5e3ddec2d3` |
+| Implementation commit | `b0f168ab4d6a959d4e28611e345a250c14ca2ea9` |
+| Python | CPython 3.11 |
+| Raw artifact SHA-256 | `961b2842d7791f6f3af7bec5bce6105e433b68d41bf6f7b82a9f3a98e1f695ab` |
+| Canonical payload SHA-256 | `eb55eaa16a492881ff1bcec8812b2ef37bb2ed6055b6b90a1da408386f632e3a` |
+
+See the [artifact](../evidence/v0.1.0-confirmatory.json),
+[manifest](../evidence/v0.1.0-confirmatory.manifest.json), and
+[reproduction guide](EVIDENCE.md).
+
+## Allowed claim
+
+> SFTGuard detected the preregistered synthetic fault matrix with the reported
+> false-positive rate and reproduced its content-addressed evidence.
+
+This is internal detector regression evidence. Each class uses one structural
+fault template across deterministic marker variations. The result does not show
+coverage of unknown faults, model quality, or safety, and it is not an external
+fine-tuning benchmark.
diff --git a/docs/assets/sftguard-confirmatory-dashboard.png b/docs/assets/sftguard-confirmatory-dashboard.png
new file mode 100644
index 0000000000000000000000000000000000000000..acbe4eecb829915fe0aa61e63863fd7f9190947e
GIT binary patch
literal 62580
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