diff --git a/executive-summary-claim-trace-assistant/README.md b/executive-summary-claim-trace-assistant/README.md
new file mode 100644
index 00000000..fde8e425
--- /dev/null
+++ b/executive-summary-claim-trace-assistant/README.md
@@ -0,0 +1,38 @@
+# Executive Summary Claim Trace Assistant
+
+This dependency-free module reviews AI-generated executive summaries before they
+are shared with collaborators, funders, or editors. It checks whether summary
+claims are traceable to manuscript evidence and whether implications or next
+steps are framed within the available evidence.
+
+## Checks
+
+- Each executive summary bullet includes at least one source evidence ID.
+- Referenced evidence IDs exist in the manuscript evidence packet.
+- Key findings are backed by results, tables, figures, or methods evidence.
+- Implications and next steps avoid unsupported universal language.
+- Clinical, policy, or funding claims require appropriate evidence types.
+- Numeric claims must match numbers present in the traced evidence.
+- Key findings must share enough terminology with the cited evidence to avoid
+ decorative or unrelated citations.
+- Unsupported or over-broad bullets receive reviewer rewrite suggestions.
+
+## Run
+
+```bash
+python3 executive-summary-claim-trace-assistant/summary_claim_trace_assistant.py \
+ --sample \
+ --json executive-summary-claim-trace-assistant/demo/report.json \
+ --markdown executive-summary-claim-trace-assistant/demo/summary.md \
+ --svg executive-summary-claim-trace-assistant/demo/graph.svg
+```
+
+Generated demo artifacts in `demo/` show the JSON decision payload, a reviewer
+Markdown table, an SVG status chart, and a short MP4 walkthrough for bounty
+reviewers.
+
+## Test
+
+```bash
+python3 -m unittest executive-summary-claim-trace-assistant/test_summary_claim_trace_assistant.py
+```
diff --git a/executive-summary-claim-trace-assistant/demo/demo.mp4 b/executive-summary-claim-trace-assistant/demo/demo.mp4
new file mode 100644
index 00000000..3f710b1f
Binary files /dev/null and b/executive-summary-claim-trace-assistant/demo/demo.mp4 differ
diff --git a/executive-summary-claim-trace-assistant/demo/graph.svg b/executive-summary-claim-trace-assistant/demo/graph.svg
new file mode 100644
index 00000000..9102bbe4
--- /dev/null
+++ b/executive-summary-claim-trace-assistant/demo/graph.svg
@@ -0,0 +1,7 @@
+
diff --git a/executive-summary-claim-trace-assistant/demo/report.json b/executive-summary-claim-trace-assistant/demo/report.json
new file mode 100644
index 00000000..509784ee
--- /dev/null
+++ b/executive-summary-claim-trace-assistant/demo/report.json
@@ -0,0 +1,134 @@
+{
+ "assistant": "executive-summary-claim-trace-assistant",
+ "bullet_reviews": [
+ {
+ "bullet_id": "SUM-001",
+ "decision": "publish",
+ "evidence_trace": [
+ {
+ "evidence_id": "EV-RESULT-001",
+ "section": "Results",
+ "type": "result"
+ }
+ ],
+ "findings": [],
+ "mode": "key_finding",
+ "rewrite": ""
+ },
+ {
+ "bullet_id": "SUM-002",
+ "decision": "publish",
+ "evidence_trace": [
+ {
+ "evidence_id": "EV-RESULT-001",
+ "section": "Results",
+ "type": "result"
+ },
+ {
+ "evidence_id": "EV-FIG-002",
+ "section": "Figure 2",
+ "type": "figure"
+ },
+ {
+ "evidence_id": "EV-METHOD-003",
+ "section": "Methods",
+ "type": "method"
+ }
+ ],
+ "findings": [],
+ "mode": "implication",
+ "rewrite": ""
+ },
+ {
+ "bullet_id": "SUM-003",
+ "decision": "revise",
+ "evidence_trace": [
+ {
+ "evidence_id": "EV-RESULT-001",
+ "section": "Results",
+ "type": "result"
+ }
+ ],
+ "findings": [
+ {
+ "code": "OVERBROAD_LANGUAGE",
+ "message": "Summary uses universal or definitive language beyond the evidence.",
+ "severity": "revise"
+ },
+ {
+ "code": "CLINICAL_CLAIM_NEEDS_SAFETY_CONTEXT",
+ "message": "Clinical claims need methods, ethics, trial, or limitation evidence.",
+ "severity": "revise"
+ }
+ ],
+ "mode": "next_step",
+ "rewrite": "The tool could be evaluated across similar clinical publishing workflows."
+ },
+ {
+ "bullet_id": "SUM-004",
+ "decision": "block",
+ "evidence_trace": [],
+ "findings": [
+ {
+ "code": "UNKNOWN_EVIDENCE_ID",
+ "message": "Summary references evidence IDs that are absent from the packet: EV-MISSING-999",
+ "severity": "block"
+ }
+ ],
+ "mode": "key_finding",
+ "rewrite": "Hold this bullet until the source evidence IDs are added or corrected."
+ },
+ {
+ "bullet_id": "SUM-005",
+ "decision": "block",
+ "evidence_trace": [],
+ "findings": [
+ {
+ "code": "MISSING_EVIDENCE_TRACE",
+ "message": "Summary bullet has no source evidence IDs.",
+ "severity": "block"
+ },
+ {
+ "code": "OVERBROAD_LANGUAGE",
+ "message": "Summary uses universal or definitive language beyond the evidence.",
+ "severity": "revise"
+ }
+ ],
+ "mode": "implication",
+ "rewrite": "Hold this bullet until the source evidence IDs are added or corrected."
+ },
+ {
+ "bullet_id": "SUM-006",
+ "decision": "revise",
+ "evidence_trace": [
+ {
+ "evidence_id": "EV-RESULT-001",
+ "section": "Results",
+ "type": "result"
+ }
+ ],
+ "findings": [
+ {
+ "code": "UNSUPPORTED_NUMERIC_DETAIL",
+ "message": "Summary includes numeric details absent from traced evidence: 50",
+ "severity": "revise"
+ },
+ {
+ "code": "OVERBROAD_LANGUAGE",
+ "message": "Summary uses universal or definitive language beyond the evidence.",
+ "severity": "revise"
+ }
+ ],
+ "mode": "key_finding",
+ "rewrite": "Replace unsupported numbers with the exact values in the traced evidence or remove the numeric detail."
+ }
+ ],
+ "manuscript_id": "MS-EXEC-SUMMARY-2026",
+ "summary": {
+ "block": 2,
+ "publish": 2,
+ "revise": 2,
+ "total": 6,
+ "trace_count": 6
+ }
+}
diff --git a/executive-summary-claim-trace-assistant/demo/summary.md b/executive-summary-claim-trace-assistant/demo/summary.md
new file mode 100644
index 00000000..df31abff
--- /dev/null
+++ b/executive-summary-claim-trace-assistant/demo/summary.md
@@ -0,0 +1,17 @@
+# Executive Summary Claim Trace Report
+
+- Manuscript: MS-EXEC-SUMMARY-2026
+- Total bullets: 6
+- Publish: 2
+- Revise: 2
+- Block: 2
+- Evidence traces: 6
+
+| Bullet | Decision | Findings | Rewrite |
+| --- | --- | --- | --- |
+| SUM-001 | publish | none | |
+| SUM-002 | publish | none | |
+| SUM-003 | revise | OVERBROAD_LANGUAGE, CLINICAL_CLAIM_NEEDS_SAFETY_CONTEXT | The tool could be evaluated across similar clinical publishing workflows. |
+| SUM-004 | block | UNKNOWN_EVIDENCE_ID | Hold this bullet until the source evidence IDs are added or corrected. |
+| SUM-005 | block | MISSING_EVIDENCE_TRACE, OVERBROAD_LANGUAGE | Hold this bullet until the source evidence IDs are added or corrected. |
+| SUM-006 | revise | UNSUPPORTED_NUMERIC_DETAIL, OVERBROAD_LANGUAGE | Replace unsupported numbers with the exact values in the traced evidence or remove the numeric detail. |
diff --git a/executive-summary-claim-trace-assistant/sample_summary_packet.json b/executive-summary-claim-trace-assistant/sample_summary_packet.json
new file mode 100644
index 00000000..8937855e
--- /dev/null
+++ b/executive-summary-claim-trace-assistant/sample_summary_packet.json
@@ -0,0 +1,84 @@
+{
+ "manuscript_id": "MS-EXEC-SUMMARY-2026",
+ "evidence": [
+ {
+ "id": "EV-RESULT-001",
+ "type": "result",
+ "section": "Results",
+ "text": "The screened model reduced annotation time from 42 minutes to 31 minutes across 84 benchmark manuscripts.",
+ "metrics": {
+ "baseline_minutes": 42,
+ "intervention_minutes": 31,
+ "n": 84
+ }
+ },
+ {
+ "id": "EV-FIG-002",
+ "type": "figure",
+ "section": "Figure 2",
+ "text": "Figure 2 shows lower reviewer disagreement for biomedical manuscripts after the checklist prompt was enabled."
+ },
+ {
+ "id": "EV-METHOD-003",
+ "type": "method",
+ "section": "Methods",
+ "text": "Evaluation used a retrospective corpus and did not include prospective clinical deployment."
+ },
+ {
+ "id": "EV-LIMIT-004",
+ "type": "limitation",
+ "section": "Limitations",
+ "text": "The study was conducted in English-language manuscripts only."
+ }
+ ],
+ "summary_bullets": [
+ {
+ "id": "SUM-001",
+ "mode": "key_finding",
+ "text": "The assistant reduced annotation time by roughly one quarter in the benchmark set.",
+ "evidence_ids": [
+ "EV-RESULT-001"
+ ]
+ },
+ {
+ "id": "SUM-002",
+ "mode": "implication",
+ "text": "These results suggest the workflow may reduce reviewer triage friction for similar manuscript review settings.",
+ "evidence_ids": [
+ "EV-RESULT-001",
+ "EV-FIG-002",
+ "EV-METHOD-003"
+ ]
+ },
+ {
+ "id": "SUM-003",
+ "mode": "next_step",
+ "text": "The tool should now be deployed across all clinical publishing workflows.",
+ "evidence_ids": [
+ "EV-RESULT-001"
+ ]
+ },
+ {
+ "id": "SUM-004",
+ "mode": "key_finding",
+ "text": "The assistant works equally well for multilingual manuscripts.",
+ "evidence_ids": [
+ "EV-MISSING-999"
+ ]
+ },
+ {
+ "id": "SUM-005",
+ "mode": "implication",
+ "text": "This will eliminate peer-review bottlenecks.",
+ "evidence_ids": []
+ },
+ {
+ "id": "SUM-006",
+ "mode": "key_finding",
+ "text": "The assistant reduced workload by 50% across all manuscripts.",
+ "evidence_ids": [
+ "EV-RESULT-001"
+ ]
+ }
+ ]
+}
diff --git a/executive-summary-claim-trace-assistant/summary_claim_trace_assistant.py b/executive-summary-claim-trace-assistant/summary_claim_trace_assistant.py
new file mode 100644
index 00000000..4dee887a
--- /dev/null
+++ b/executive-summary-claim-trace-assistant/summary_claim_trace_assistant.py
@@ -0,0 +1,343 @@
+#!/usr/bin/env python3
+"""Trace AI executive summary claims back to manuscript evidence."""
+
+from __future__ import annotations
+
+import argparse
+import html
+import json
+import re
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any, Iterable
+
+
+OVERBROAD_RE = re.compile(r"\b(all|always|never|eliminate|guarantee|prove|definitive|across every)\b", re.I)
+CLINICAL_RE = re.compile(r"\b(clinical|patient|treatment|diagnosis|therapy|medical)\b", re.I)
+POLICY_RE = re.compile(r"\b(policy|funder|mandate|regulator|government|compliance)\b", re.I)
+NUMBER_RE = re.compile(r"\b\d+(?:\.\d+)?%?\b")
+TOKEN_RE = re.compile(r"[a-z][a-z0-9-]{2,}", re.I)
+VALID_MODES = {"key_finding", "implication", "next_step"}
+FINDING_EVIDENCE = {"result", "figure", "table", "method"}
+SENSITIVE_EVIDENCE = {"clinical_trial", "ethics", "policy", "limitation", "method"}
+STOPWORDS = {
+ "about",
+ "after",
+ "also",
+ "and",
+ "are",
+ "because",
+ "before",
+ "can",
+ "could",
+ "for",
+ "from",
+ "has",
+ "have",
+ "into",
+ "may",
+ "now",
+ "our",
+ "should",
+ "shows",
+ "that",
+ "the",
+ "these",
+ "this",
+ "was",
+ "were",
+ "will",
+ "with",
+}
+
+
+@dataclass(frozen=True)
+class Finding:
+ code: str
+ severity: str
+ message: str
+
+ def to_dict(self) -> dict[str, str]:
+ return {"code": self.code, "severity": self.severity, "message": self.message}
+
+
+@dataclass
+class BulletReview:
+ bullet_id: str
+ mode: str
+ decision: str
+ findings: list[Finding] = field(default_factory=list)
+ evidence_trace: list[dict[str, str]] = field(default_factory=list)
+ rewrite: str = ""
+
+ def to_dict(self) -> dict[str, Any]:
+ return {
+ "bullet_id": self.bullet_id,
+ "mode": self.mode,
+ "decision": self.decision,
+ "findings": [finding.to_dict() for finding in self.findings],
+ "evidence_trace": self.evidence_trace,
+ "rewrite": self.rewrite,
+ }
+
+
+def sample_path() -> Path:
+ return Path(__file__).with_name("sample_summary_packet.json")
+
+
+def load_packet(path: Path) -> dict[str, Any]:
+ with path.open("r", encoding="utf-8") as handle:
+ packet = json.load(handle)
+ if not isinstance(packet, dict):
+ raise ValueError("Summary packet must be a JSON object.")
+ return packet
+
+
+def review_packet(packet: dict[str, Any]) -> dict[str, Any]:
+ evidence_index = {
+ str(item.get("id")): item
+ for item in packet.get("evidence", [])
+ if isinstance(item, dict) and item.get("id")
+ }
+ reviews = [review_bullet(bullet, evidence_index) for bullet in packet.get("summary_bullets", [])]
+ summary = {
+ "total": len(reviews),
+ "publish": sum(1 for review in reviews if review.decision == "publish"),
+ "revise": sum(1 for review in reviews if review.decision == "revise"),
+ "block": sum(1 for review in reviews if review.decision == "block"),
+ "trace_count": sum(len(review.evidence_trace) for review in reviews),
+ }
+ return {
+ "assistant": "executive-summary-claim-trace-assistant",
+ "manuscript_id": packet.get("manuscript_id", "unknown"),
+ "summary": summary,
+ "bullet_reviews": [review.to_dict() for review in reviews],
+ }
+
+
+def review_bullet(bullet: dict[str, Any], evidence_index: dict[str, dict[str, Any]]) -> BulletReview:
+ bullet_id = str(bullet.get("id") or "UNKNOWN")
+ mode = str(bullet.get("mode") or "")
+ text = str(bullet.get("text") or "")
+ evidence_ids = [str(item) for item in bullet.get("evidence_ids") or []]
+ findings: list[Finding] = []
+ trace: list[dict[str, str]] = []
+ evidence_texts: list[str] = []
+ evidence_numbers: set[str] = set()
+
+ if mode not in VALID_MODES:
+ findings.append(Finding("UNKNOWN_SUMMARY_MODE", "revise", "Summary bullet mode is not recognized."))
+
+ if not text.strip():
+ findings.append(Finding("EMPTY_SUMMARY_BULLET", "block", "Summary bullet has no text."))
+
+ if not evidence_ids:
+ findings.append(Finding("MISSING_EVIDENCE_TRACE", "block", "Summary bullet has no source evidence IDs."))
+
+ missing = []
+ evidence_types = set()
+ for evidence_id in evidence_ids:
+ evidence = evidence_index.get(evidence_id)
+ if not evidence:
+ missing.append(evidence_id)
+ continue
+ evidence_type = str(evidence.get("type") or "unknown")
+ evidence_types.add(evidence_type)
+ evidence_text = str(evidence.get("text") or "")
+ evidence_texts.append(evidence_text)
+ evidence_numbers.update(extract_numbers(evidence_text))
+ metrics = evidence.get("metrics")
+ if isinstance(metrics, dict):
+ evidence_numbers.update(normalize_number(value) for value in metrics.values())
+ trace.append(
+ {
+ "evidence_id": evidence_id,
+ "type": evidence_type,
+ "section": str(evidence.get("section") or ""),
+ }
+ )
+
+ if missing:
+ findings.append(
+ Finding(
+ "UNKNOWN_EVIDENCE_ID",
+ "block",
+ "Summary references evidence IDs that are absent from the packet: " + ", ".join(missing),
+ )
+ )
+
+ if mode == "key_finding" and trace and not evidence_types.intersection(FINDING_EVIDENCE):
+ findings.append(
+ Finding("WEAK_KEY_FINDING_SUPPORT", "revise", "Key finding lacks result, table, figure, or method support.")
+ )
+
+ if trace and mode == "key_finding" and weak_text_overlap(text, evidence_texts):
+ findings.append(
+ Finding(
+ "LOW_EVIDENCE_TEXT_OVERLAP",
+ "revise",
+ "Key finding shares too little terminology with the traced evidence.",
+ )
+ )
+
+ unsupported_numbers = sorted(extract_numbers(text) - evidence_numbers)
+ if trace and unsupported_numbers:
+ findings.append(
+ Finding(
+ "UNSUPPORTED_NUMERIC_DETAIL",
+ "revise",
+ "Summary includes numeric details absent from traced evidence: " + ", ".join(unsupported_numbers),
+ )
+ )
+
+ if OVERBROAD_RE.search(text):
+ findings.append(
+ Finding("OVERBROAD_LANGUAGE", "revise", "Summary uses universal or definitive language beyond the evidence.")
+ )
+
+ if CLINICAL_RE.search(text) and not evidence_types.intersection(SENSITIVE_EVIDENCE):
+ findings.append(
+ Finding("CLINICAL_CLAIM_NEEDS_SAFETY_CONTEXT", "revise", "Clinical claims need methods, ethics, trial, or limitation evidence.")
+ )
+
+ if POLICY_RE.search(text) and "policy" not in evidence_types:
+ findings.append(
+ Finding("POLICY_CLAIM_NEEDS_POLICY_EVIDENCE", "revise", "Policy or funder claims need policy evidence.")
+ )
+
+ decision = decision_from_findings(findings)
+ return BulletReview(
+ bullet_id=bullet_id,
+ mode=mode,
+ decision=decision,
+ findings=findings,
+ evidence_trace=trace,
+ rewrite=suggest_rewrite(text, findings),
+ )
+
+
+def normalize_number(value: Any) -> str:
+ return str(value).strip().lower().rstrip("%.,")
+
+
+def extract_numbers(text: str) -> set[str]:
+ return {normalize_number(match.group(0)) for match in NUMBER_RE.finditer(text)}
+
+
+def normalized_tokens(text: str) -> set[str]:
+ return {token.lower() for token in TOKEN_RE.findall(text) if token.lower() not in STOPWORDS}
+
+
+def weak_text_overlap(summary_text: str, evidence_texts: list[str]) -> bool:
+ claim_tokens = normalized_tokens(summary_text)
+ if len(claim_tokens) < 5:
+ return False
+ evidence_tokens = normalized_tokens(" ".join(evidence_texts))
+ return len(claim_tokens.intersection(evidence_tokens)) < 2
+
+
+def decision_from_findings(findings: Iterable[Finding]) -> str:
+ severities = {finding.severity for finding in findings}
+ if "block" in severities:
+ return "block"
+ if "revise" in severities:
+ return "revise"
+ return "publish"
+
+
+def suggest_rewrite(text: str, findings: list[Finding]) -> str:
+ codes = {finding.code for finding in findings}
+ if not findings:
+ return ""
+ if "MISSING_EVIDENCE_TRACE" in codes or "UNKNOWN_EVIDENCE_ID" in codes:
+ return "Hold this bullet until the source evidence IDs are added or corrected."
+ if "UNSUPPORTED_NUMERIC_DETAIL" in codes:
+ return "Replace unsupported numbers with the exact values in the traced evidence or remove the numeric detail."
+ revised = text
+ revised = re.sub(r"\bwill eliminate\b", "may reduce", revised, flags=re.I)
+ revised = re.sub(r"\ball\b", "similar", revised, flags=re.I)
+ revised = re.sub(r"\bshould now be deployed\b", "could be evaluated", revised, flags=re.I)
+ if revised == text:
+ revised = "Revise to state the claim with narrower scope and cite the supporting evidence."
+ return revised
+
+
+def write_json(report: dict[str, Any], path: Path) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
+
+
+def write_markdown(report: dict[str, Any], path: Path) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ summary = report["summary"]
+ lines = [
+ "# Executive Summary Claim Trace Report",
+ "",
+ f"- Manuscript: {report['manuscript_id']}",
+ f"- Total bullets: {summary['total']}",
+ f"- Publish: {summary['publish']}",
+ f"- Revise: {summary['revise']}",
+ f"- Block: {summary['block']}",
+ f"- Evidence traces: {summary['trace_count']}",
+ "",
+ "| Bullet | Decision | Findings | Rewrite |",
+ "| --- | --- | --- | --- |",
+ ]
+ for review in report["bullet_reviews"]:
+ findings = ", ".join(finding["code"] for finding in review["findings"]) or "none"
+ rewrite = review["rewrite"] or ""
+ lines.append(f"| {review['bullet_id']} | {review['decision']} | {findings} | {rewrite} |")
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+
+def write_svg(report: dict[str, Any], path: Path) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ summary = report["summary"]
+ bars = [("publish", "#2f855a", summary["publish"]), ("revise", "#b7791f", summary["revise"]), ("block", "#c53030", summary["block"])]
+ max_count = max([count for _, _, count in bars] + [1])
+ rows = []
+ for index, (label, color, count) in enumerate(bars):
+ y = 82 + index * 58
+ width = int(300 * count / max_count)
+ rows.append(f'{html.escape(label)}')
+ rows.append(f'')
+ rows.append(f'{count}')
+ svg = f"""
+"""
+ path.write_text(svg, encoding="utf-8")
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(description=__doc__)
+ group = parser.add_mutually_exclusive_group(required=True)
+ group.add_argument("--input", type=Path)
+ group.add_argument("--sample", action="store_true")
+ parser.add_argument("--json", type=Path)
+ parser.add_argument("--markdown", type=Path)
+ parser.add_argument("--svg", type=Path)
+ return parser
+
+
+def main(argv: list[str] | None = None) -> int:
+ args = build_parser().parse_args(argv)
+ packet = load_packet(sample_path() if args.sample else args.input)
+ report = review_packet(packet)
+ if args.json:
+ write_json(report, args.json)
+ if args.markdown:
+ write_markdown(report, args.markdown)
+ if args.svg:
+ write_svg(report, args.svg)
+ summary = report["summary"]
+ print(f"Executive summary trace: {summary['publish']} publish, {summary['revise']} revise, {summary['block']} block.")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/executive-summary-claim-trace-assistant/test_summary_claim_trace_assistant.py b/executive-summary-claim-trace-assistant/test_summary_claim_trace_assistant.py
new file mode 100644
index 00000000..7c44febe
--- /dev/null
+++ b/executive-summary-claim-trace-assistant/test_summary_claim_trace_assistant.py
@@ -0,0 +1,107 @@
+import importlib.util
+import json
+import subprocess
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+
+MODULE_PATH = Path(__file__).with_name("summary_claim_trace_assistant.py")
+SPEC = importlib.util.spec_from_file_location("summary_claim_trace_assistant", MODULE_PATH)
+assistant = importlib.util.module_from_spec(SPEC)
+sys.modules[SPEC.name] = assistant
+SPEC.loader.exec_module(assistant)
+
+
+class ExecutiveSummaryClaimTraceAssistantTest(unittest.TestCase):
+ def test_sample_summary_counts(self):
+ report = assistant.review_packet(assistant.load_packet(assistant.sample_path()))
+
+ self.assertEqual(report["summary"]["total"], 6)
+ self.assertEqual(report["summary"]["publish"], 2)
+ self.assertEqual(report["summary"]["revise"], 2)
+ self.assertEqual(report["summary"]["block"], 2)
+ self.assertGreaterEqual(report["summary"]["trace_count"], 5)
+
+ def test_traced_key_finding_can_publish(self):
+ report = assistant.review_packet(assistant.load_packet(assistant.sample_path()))
+ review = next(item for item in report["bullet_reviews"] if item["bullet_id"] == "SUM-001")
+
+ self.assertEqual(review["decision"], "publish")
+ self.assertEqual(review["findings"], [])
+ self.assertEqual(review["evidence_trace"][0]["evidence_id"], "EV-RESULT-001")
+
+ def test_overbroad_next_step_requires_revision(self):
+ report = assistant.review_packet(assistant.load_packet(assistant.sample_path()))
+ review = next(item for item in report["bullet_reviews"] if item["bullet_id"] == "SUM-003")
+ codes = {finding["code"] for finding in review["findings"]}
+
+ self.assertEqual(review["decision"], "revise")
+ self.assertIn("OVERBROAD_LANGUAGE", codes)
+ self.assertIn("could be evaluated", review["rewrite"])
+
+ def test_missing_or_unknown_evidence_blocks_bullet(self):
+ report = assistant.review_packet(assistant.load_packet(assistant.sample_path()))
+ missing = next(item for item in report["bullet_reviews"] if item["bullet_id"] == "SUM-004")
+ untraced = next(item for item in report["bullet_reviews"] if item["bullet_id"] == "SUM-005")
+
+ self.assertEqual(missing["decision"], "block")
+ self.assertEqual(untraced["decision"], "block")
+ self.assertIn("UNKNOWN_EVIDENCE_ID", {finding["code"] for finding in missing["findings"]})
+ self.assertIn("MISSING_EVIDENCE_TRACE", {finding["code"] for finding in untraced["findings"]})
+
+ def test_unsupported_numeric_detail_requires_revision(self):
+ report = assistant.review_packet(assistant.load_packet(assistant.sample_path()))
+ review = next(item for item in report["bullet_reviews"] if item["bullet_id"] == "SUM-006")
+ codes = {finding["code"] for finding in review["findings"]}
+
+ self.assertEqual(review["decision"], "revise")
+ self.assertIn("UNSUPPORTED_NUMERIC_DETAIL", codes)
+ self.assertIn("OVERBROAD_LANGUAGE", codes)
+ self.assertIn("exact values", review["rewrite"])
+
+ def test_artifact_writers(self):
+ report = assistant.review_packet(assistant.load_packet(assistant.sample_path()))
+ with tempfile.TemporaryDirectory() as tmpdir:
+ root = Path(tmpdir)
+ json_path = root / "report.json"
+ markdown_path = root / "summary.md"
+ svg_path = root / "graph.svg"
+
+ assistant.write_json(report, json_path)
+ assistant.write_markdown(report, markdown_path)
+ assistant.write_svg(report, svg_path)
+
+ self.assertEqual(json.loads(json_path.read_text())["summary"]["total"], 6)
+ self.assertIn("SUM-004", markdown_path.read_text())
+ self.assertIn("