From 4ff0321c9efc9ce78f4d98ebef713a941845b3bf Mon Sep 17 00:00:00 2001
From: Kent Huang Local comparison files
'
f"
{escaped(trial.outcome)}
' answer = ( f"{escaped(trial.final)}"
if trial.final.strip()
@@ -922,6 +933,15 @@ def _target_explanation(report):
return "".join(parts)
+def _explanation_conclusion(report) -> str:
+ return (
+ '' + f"{html.escape(content.EXPLANATION_CONCLUSION_LABEL)} " + f"{html.escape(report.summary.headline)} " + 'Summary
' + ) + + def _change_explanation(report: ReportData) -> str: explanation = report.decisions.explanation navigation = ( @@ -940,15 +960,20 @@ def _change_explanation(report: ReportData) -> str: ) return ( "{html.escape(notice)}
Understand the change
', - f'{version_name_html(explanation.overview)}
', f'{html.escape(content.INTERPRETATION_NOTE)}
', '{html.escape(content.SUMMARY_NO_CHECKS)}
' + rows = "".join( + f'| Expected | Before | ' + 'After | What this means | ' + f'{html.escape(content.SUMMARY_SCOPE_HEADING)} |
|---|
{version_name_html(summary.scenario)}
' - if summary.scenario - else "" - ) # Label only a model summary; recorded goals and supplied text stay exact. intent_text = ( version_name_html(intent.text) @@ -1067,40 +1109,84 @@ def _short_story_summary(report: ReportData) -> str: and report.decisions.purpose_summary is not None else html.escape(intent.text) ) + task = report.content.task + task_html = ( + f'{html.escape(content.SUMMARY_TASK_LABEL)}:' + f" {html.escape(content.task_preview(task))}
" + '{html.escape(task)}{html.escape(content.SCENARIO_PROMPT_UNAVAILABLE)}
' + ) + method = "".join( + f"' + f"{version_name_html(summary.meaning)}
", + ), + _summary_step( + 2, + "story-expectation", + content.SUMMARY_EXPECTATION_HEADING, + '{html.escape(intent.note)}
')}" + '{intent_text}
{_summary_checks(report)}', + ), + _summary_step( + 3, + "story-method", + content.SUMMARY_METHOD_HEADING, + f'{task_html}{html.escape(summary.evidence_label)}
' + '{html.escape(GATE_NOTE)}
', + ), + ) return ( '{html.escape(intent.note)}
')}" - f'' - "{intent_text}
' - f"{case_context}" - "{html.escape(summary.evidence_label)}
' - '" + html.escape(report.content.task) + "\n",
+ "{html.escape(trial.outcome)}
" answers.append( ""
+ html.escape(
diff --git a/plugin/skills/behavior-diff/scripts/reporting/report.css b/plugin/skills/behavior-diff/scripts/reporting/report.css
index 2d02626..891aad9 100644
--- a/plugin/skills/behavior-diff/scripts/reporting/report.css
+++ b/plugin/skills/behavior-diff/scripts/reporting/report.css
@@ -43,6 +43,17 @@ h2 { font-size:1.02rem; font-weight:600; margin:0; }
.local-paths summary { cursor:pointer; width:fit-content; }
.local-paths dl { font-family:"IBM Plex Mono", ui-monospace, monospace; overflow-wrap:anywhere; }
.local-paths dd { margin:0 0 .5rem; }
+/* Provenance stays visible as one wrapping strip (1–2 lines on desktop). */
+.report-meta-strip { display:flex; flex-wrap:wrap; align-items:baseline;
+ gap:.25rem 1.2rem; margin:.45rem 0 0; }
+.report-meta-strip > .source-line, .report-meta-strip > .revision-pair,
+.report-meta-strip > .meta { margin:0; line-height:1.6; }
+.report-meta-strip > *, .report-meta-strip .meta > span {
+ min-width:0; overflow-wrap:anywhere;
+}
+.summary-task-preview { font-size:15px; line-height:1.6; margin:0 0 6px; }
+.summary-check-scope { color:var(--muted); font-size:13px; }
+.summary-reading-help { font-size:13px; color:#53646d; margin-top:12px; }
@media (max-width:650px) {
.source-row { display:block; }
.source-row + .source-row { margin-left:0; }
@@ -456,13 +467,20 @@ details { margin-bottom:.25rem; }
border-radius:0 6px 6px 0; padding:12px 18px; margin:16px 0 0; max-width:780px; }
.summary-case-context > strong { font-size:13px; }
.summary-case-context p { font-size:16px; line-height:1.6; margin:6px 0 0; }
-.target-checks { margin:18px 0; overflow-x:auto; }
-.target-checks table { width:100%; border-collapse:collapse; font-size:14px; }
-.target-checks th, .target-checks td { text-align:left; vertical-align:top;
- padding:10px 12px; border-bottom:1px solid var(--border); overflow-wrap:anywhere; }
-.target-checks th { color:var(--muted); font-size:12px; font-weight:600; }
-.target-checks td { font-weight:400; }
-.target-checks th:first-child { width:50%; }
+.target-checks, .summary-checks-wrap { margin:18px 0; overflow-x:auto; }
+.target-checks table, .summary-checks { width:100%; border-collapse:collapse; font-size:14px; }
+.target-checks th, .target-checks td, .summary-checks th, .summary-checks td {
+ text-align:left; vertical-align:top; padding:10px 12px;
+ border-bottom:1px solid var(--border); overflow-wrap:anywhere; }
+.target-checks th, .summary-checks th { color:var(--muted); font-size:12px; font-weight:600; }
+.target-checks td, .summary-checks td { font-weight:400; }
+.target-checks th:first-child, .summary-checks th:first-child { width:40%; }
+.summary-state { font-size:18px; line-height:1.5; margin:0; }
+.summary-method { font-size:14px; line-height:1.7; margin:10px 0 0; padding-left:18px; }
+.summary-task pre { white-space:pre-wrap; max-height:320px; overflow:auto; }
+.summary-attention { margin-top:18px; }
+.summary-next-action { font-size:18px; line-height:1.5; margin:0 0 8px; }
+.explanation-conclusion { font-size:1.05rem; max-width:80ch; }
.target-result-changed { font-weight:750; color:var(--ink); }
/* Version names in prose keep the surrounding size; only the weight changes. */
.version-label { font-size:inherit; font-weight:700; }
@@ -486,8 +504,7 @@ details { margin-bottom:.25rem; }
.summary-notices { font-size:13px; color:#53646d; margin:16px 0 0; padding-left:18px;
line-height:1.7; }
.summary-notices:empty { display:none; }
-.story-limits .story-body > h3 { margin-bottom:8px; }
-.story-limits .summary-notices { margin-top:0; }
+.story-method .summary-notices { margin-top:8px; }
.summary-evidence-button { display:inline-flex; align-items:center; gap:13px;
font-size:13px; font-weight:700; background:#173e4a; color:#fff; border-radius:9px;
padding:11px 15px !important; text-decoration:none; }
diff --git a/plugin/skills/behavior-diff/scripts/reporting/schema.py b/plugin/skills/behavior-diff/scripts/reporting/schema.py
index 344d76f..a3410f9 100644
--- a/plugin/skills/behavior-diff/scripts/reporting/schema.py
+++ b/plugin/skills/behavior-diff/scripts/reporting/schema.py
@@ -13,6 +13,8 @@
)
from reporting.instruction import parse_diff_hunks
from reporting.summary import (
+ CHECK_STATES,
+ FINDING_STATES,
ICONS,
SUMMARY_STATUSES,
EditIntentData,
@@ -46,7 +48,7 @@
report_source,
)
-SCHEMA_VERSION = 16
+SCHEMA_VERSION = 17
RESULT_KINDS = ("good", "bad", "neutral")
@@ -150,6 +152,9 @@ class DecisionData:
target_assessment: Optional[TargetAssessmentData] = None
evidence_limits: Optional[EvidenceLimitsData] = None
purpose_summary: Optional[str] = None
+ # Validation reason for a returned but rejected target assessment; never set
+ # alongside a valid assessment.
+ target_rejection: Optional[str] = None
@dataclass(frozen=True)
@@ -168,6 +173,20 @@ class SummarySideData:
start: Optional[EvidenceClaimData] = None
+@dataclass(frozen=True)
+class SummaryCheckData:
+ """One recorded expectation compared with its validated Before/After outcomes."""
+
+ criterion: int
+ text: str
+ mode: str
+ state: str
+ before: str
+ after: str
+ meaning: str
+ scope: str
+
+
@dataclass(frozen=True)
class VisualSummaryData:
headline: str
@@ -180,7 +199,11 @@ class VisualSummaryData:
why: Optional[EvidenceClaimData]
caution: Optional[EvidenceClaimData]
notices: Tuple[str, ...]
- context: Optional[EvidenceClaimData] = None
+ context: Optional[EvidenceClaimData]
+ state: str
+ meaning: str
+ checks: Tuple[SummaryCheckData, ...]
+ next_action: str
@dataclass(frozen=True)
@@ -271,6 +294,7 @@ def from_dict(cls, data: Dict[str, object]) -> "ReportData":
"target_assessment",
"evidence_limits",
"purpose_summary",
+ "target_rejection",
):
_field(raw_decisions, name, "decisions")
rule_diff = _expect_str(_field(data, "rule_diff", "report-data"), "rule_diff")
@@ -611,6 +635,9 @@ def _decisions(value, path, hunk_count, groups=None, final_answers=None):
target_assessment=target,
evidence_limits=parse_evidence_limits(value.get("evidence_limits")),
purpose_summary=purpose_summary,
+ target_rejection=_target_rejection(
+ value.get("target_rejection"), path + ".target_rejection", target
+ ),
)
from reporting.content import valid_decision_choices
@@ -619,6 +646,20 @@ def _decisions(value, path, hunk_count, groups=None, final_answers=None):
return decisions
+def _target_rejection(value, path, target):
+ if value is None:
+ return None
+ value = _expect_str(value, path)
+ if (
+ target is not None
+ or not value.strip()
+ or value != value.strip()
+ or len(value) > 240
+ ):
+ _invalid(path, "plain rejection reason only when the target is unavailable")
+ return value
+
+
def validate_branch_memberships(rows, final_answers):
"""Reject partial or invented attribution; aggregate-only evidence stays unavailable."""
for row in rows:
@@ -728,11 +769,28 @@ def _intent(value, path, hunk_count, expected, decisions):
return intent
+def _summary_check(value, path):
+ value = _expect_dict(value, path)
+ check = SummaryCheckData(
+ _expect_int(_field(value, "criterion", path), path + ".criterion"),
+ *(
+ _expect_str(_field(value, name, path), path + "." + name)
+ for name in ("text", "mode", "state", "before", "after", "meaning", "scope")
+ ),
+ )
+ if check.state not in CHECK_STATES:
+ _invalid(path + ".state", "supported check state")
+ return check
+
+
def _summary(value, path, decisions, metadata, variants):
value = _expect_dict(value, path)
status = _expect_str(_field(value, "status", path), path + ".status")
if status not in SUMMARY_STATUSES:
_invalid(path + ".status", "supported comparison status")
+ state = _expect_str(_field(value, "state", path), path + ".state")
+ if state not in FINDING_STATES:
+ _invalid(path + ".state", "supported finding state")
row_count = len(decisions.rows)
summary = VisualSummaryData(
_expect_str(_field(value, "headline", path), path + ".headline"),
@@ -748,9 +806,18 @@ def _summary(value, path, decisions, metadata, variants):
_optional_claim(_field(value, "caution", path), path + ".caution", row_count),
_string_tuple(_field(value, "notices", path), path + ".notices"),
_optional_claim(_field(value, "context", path), path + ".context", row_count),
+ state,
+ _expect_str(_field(value, "meaning", path), path + ".meaning"),
+ tuple(
+ _summary_check(item, "{0}.checks[{1}]".format(path, index))
+ for index, item in enumerate(
+ _expect_list(_field(value, "checks", path), path + ".checks")
+ )
+ ),
+ _expect_str(_field(value, "next_action", path), path + ".next_action"),
)
if summary != build_summary(metadata, variants, decisions):
- _invalid(path, "summary derived from the validated decision and trial evidence")
+ _invalid(path, "summary derived from the validated target and trial evidence")
return summary
diff --git a/plugin/skills/behavior-diff/scripts/reporting/summary.py b/plugin/skills/behavior-diff/scripts/reporting/summary.py
index de6bd07..42c0c78 100644
--- a/plugin/skills/behavior-diff/scripts/reporting/summary.py
+++ b/plugin/skills/behavior-diff/scripts/reporting/summary.py
@@ -1,4 +1,8 @@
-"""Validate optional extractor narrative and derive an evidence-safe visual summary."""
+"""Derive the assessment-first Summary from validated target, purpose and trial health.
+
+Optional extractor narrative is validated here too, but it only labels observed
+branches; it never decides the Summary's conclusion.
+"""
from dataclasses import dataclass
from typing import Optional, Tuple
@@ -9,9 +13,94 @@
ICONS = ("neutral", "continue", "stop", "report", "edit", "inspect", "test", "delegate")
EVIDENCE_KINDS = ("plans", "answers", "actions")
SUMMARY_STATUSES = ("changed", "unchanged", "mixed", "unavailable")
-TARGET_UNCERTAIN = (
- "Some target checks remain uncertain or lack enough evidence; "
- "inspect the detailed assessment before drawing a conclusion."
+# Overall Summary finding, decided only from validated target checks and trial health.
+FINDING_STATES = (
+ "not_met",
+ "inconclusive",
+ "baseline_incomplete",
+ "improved",
+ "already_met",
+ "preserved",
+ "unavailable",
+)
+CHECK_STATES = FINDING_STATES[:-1]
+UNMET = "The expected improvement did not happen in these tests."
+FINDING_HEADLINES = {
+ "not_met": UNMET,
+ "inconclusive": "These tests did not settle whether the expected behavior happened.",
+ "baseline_incomplete": (
+ "After met the expected behavior, but incomplete Before results do not show an improvement."
+ ),
+ "improved": "After met the expected behavior in these tests; Before did not.",
+ "already_met": (
+ "Before already met the expected behavior; no added benefit was shown in these tests."
+ ),
+ "preserved": "The behavior expected to stay the same held in these tests.",
+ "unavailable": "The expected behavior could not be checked in these tests.",
+}
+PRESERVATION_UNMET = "A behavior expected to stay the same did not hold in these tests."
+CHECK_LABELS = {
+ "not_met": "not met",
+ "inconclusive": "not settled",
+ "baseline_incomplete": "met After; Before incomplete",
+ "improved": "met After; not met Before",
+ "already_met": "already met Before",
+ "preserved": "preserved",
+}
+CHECK_MEANINGS = {
+ "not_met": "At least one After trial did not meet this.",
+ "inconclusive": (
+ "Some After trials were unclear, lacked evidence, or were not assessed, "
+ "so this is not settled."
+ ),
+ "baseline_incomplete": (
+ "Every After trial met this, but Before trials were incomplete, unclear, or "
+ "not assessed, so no improvement is shown."
+ ),
+ "improved": (
+ "Every After trial met this and at least one Before trial did not. "
+ "This does not prove the edit caused the difference."
+ ),
+ "already_met": "Before already met this, so the edit showed no added benefit here.",
+ "preserved": "Every After trial still met this.",
+}
+PRESERVATION_NOT_MET = "At least one After trial no longer met this."
+NEXT_ACTIONS = {
+ "not_met": (
+ "Revise the edit or its expectation, then retest; inspect the After trials "
+ "that did not meet the expected behavior."
+ ),
+ "inconclusive": (
+ "Inspect or rerun the unclear, missing, or interrupted trials before drawing a conclusion."
+ ),
+ "baseline_incomplete": (
+ "Inspect or rerun the incomplete Before trials before claiming an improvement."
+ ),
+ "improved": (
+ "Review the evidence and decide whether to accept the edit; these tests do not "
+ "show reliability in other situations."
+ ),
+ "already_met": (
+ "Decide whether the edit is still needed; Before already met the expected "
+ "behavior in these tests."
+ ),
+ "preserved": "Review the evidence and decide whether to accept the edit.",
+ "unavailable": (
+ "Inspect the trial evidence directly; no validated check of the expected "
+ "behavior is available."
+ ),
+}
+GATE_NOTE = "This report does not approve or block the change; the decision is yours."
+CHECK_SCOPES = {
+ "output": (
+ "Checked against final answers. An answer describing a plan or action does "
+ "not show that it was carried out."
+ ),
+ "source_consistency": "Checked final answers against recorded sources.",
+ "recorded_action": "Checked against recorded tool activity.",
+}
+SELF_REPORTED_SCOPE = (
+ "Checked against self-reported actions, not independently captured tool activity."
)
PURPOSE_SUMMARY_LIMIT = 320
@@ -348,6 +437,118 @@ def question_distribution_changed(criterion, before_total, after_total):
)
+def _side_health(criterion, side, variant):
+ """A side is usable only when every recorded trial completed and was assessed."""
+ trials = getattr(criterion, side)
+ return (
+ variant.total > 0
+ and len(trials) == variant.total
+ and variant.valid == variant.total
+ and not variant.blocked
+ and all(trial.outcome in ("met", "not_met") for trial in trials)
+ )
+
+
+def check_state(criterion, variants):
+ after = criterion.after
+ if any(trial.outcome == "not_met" for trial in after):
+ return "not_met"
+ if not _side_health(criterion, "after", variants.after):
+ return "inconclusive"
+ if not _side_health(criterion, "before", variants.before):
+ return "baseline_incomplete"
+ if all(trial.outcome == "met" for trial in criterion.before):
+ return "preserved" if criterion.mode == "preservation" else "already_met"
+ return "improved"
+
+
+def finding_state(states):
+ if not states:
+ return "unavailable"
+ return next(state for state in CHECK_STATES if state in states)
+
+
+def build_checks(metadata, variants, target):
+ from reporting.schema import SummaryCheckData
+
+ checks = []
+ for number, criterion in enumerate(target.criteria if target else (), 1):
+ state = check_state(criterion, variants)
+ preservation = criterion.mode == "preservation"
+ scope = (
+ SELF_REPORTED_SCOPE
+ if criterion.evidence_kind == "recorded_action"
+ and metadata.trace_source == "self-reported"
+ else CHECK_SCOPES[criterion.evidence_kind]
+ )
+ checks.append(
+ SummaryCheckData(
+ number,
+ criterion.text,
+ criterion.mode,
+ state,
+ question_distribution(criterion, "before", variants.before.total),
+ question_distribution(criterion, "after", variants.after.total),
+ PRESERVATION_NOT_MET
+ if preservation and state == "not_met"
+ else CHECK_MEANINGS[state],
+ scope,
+ )
+ )
+ return tuple(checks)
+
+
+ANSWER_SCOPED = "Judged from final answers, not observed actions."
+MET_STATES = ("baseline_incomplete", "improved", "already_met", "preserved")
+
+
+def finding_text(state, checks):
+ if state == "not_met" and all(
+ check.mode == "preservation" for check in checks if check.state == "not_met"
+ ):
+ return PRESERVATION_UNMET
+ # A met answer-only check cannot read as proof that actions happened.
+ if state in MET_STATES and any(
+ check.scope == CHECK_SCOPES["output"] for check in checks
+ ):
+ return f"{FINDING_HEADLINES[state]} {ANSWER_SCOPED}"
+ return FINDING_HEADLINES[state]
+
+
+def unchecked_goals(decisions):
+ """Frozen goal numbers that no validated criterion checks."""
+ from reporting.target import effective_purpose
+
+ target = decisions.target_assessment
+ if target is None:
+ return ()
+ goals = effective_purpose(decisions.purpose, decisions.intent)
+ covered = {criterion.goal for criterion in target.criteria}
+ return tuple(n for n in range(1, len(goals) + 1) if n not in covered)
+
+
+def finding_meaning(checks, rejection=None, unchecked=()):
+ if not checks:
+ from reporting.target import UNAVAILABLE
+
+ if rejection is None:
+ return UNAVAILABLE
+ return (
+ f"{UNAVAILABLE} The returned target check was rejected by validation: "
+ f"{rejection}."
+ )
+ counts = [
+ f"{sum(check.state == state for check in checks)} {CHECK_LABELS[state]}"
+ for state in CHECK_STATES
+ if any(check.state == state for check in checks)
+ ]
+ noun = "check" if len(checks) == 1 else "checks"
+ text = f"Of {len(checks)} recorded {noun}: {', '.join(counts)}."
+ if unchecked:
+ text += " No check covers recorded goal " + ", ".join(map(str, unchecked)) + "."
+ return text
+
+
def build_summary(metadata, variants, decisions):
from reporting import content
from reporting.schema import (
@@ -357,8 +558,6 @@ def build_summary(metadata, variants, decisions):
VisualSummaryData,
)
- from reporting.target import UNAVAILABLE
-
def claim(value):
return EvidenceClaimData(value.text, value.decisions) if value else None
@@ -380,16 +579,13 @@ def claim(value):
notices = [
"One scenario only; counts and narrative are model interpretations, not causal proof."
]
- target = decisions.target_assessment
- if target is None:
- notices.append(UNAVAILABLE)
- elif any(
- trial.outcome in ("uncertain", "unassessable")
- for criterion in target.criteria
- for trial in criterion.before + criterion.after
- ):
- # The target table lives in details; keep unknown outcomes visible here.
- notices.append(TARGET_UNCERTAIN)
+ checks = build_checks(metadata, variants, decisions.target_assessment)
+ states = {check.state for check in checks}
+ unchecked = unchecked_goals(decisions)
+ if checks and unchecked:
+ # A conclusion needs every frozen goal checked; a gap leaves it unsettled.
+ states.add("inconclusive")
+ state = finding_state(states)
if not complete:
notices.append(
"Trial evidence is incomplete, blocked, invalid, or dropped; inspect the trial records."
@@ -429,18 +625,7 @@ def claim(value):
notices.append(
"Plain-language narrative unavailable; original extracted comparisons are shown without added explanation."
)
- if status == "unavailable":
- headline = "Before and After behavior in this scenario."
- elif narrative:
- headline = narrative.headline
- elif different_process:
- headline = "Same result; different actions."
- elif status == "mixed":
- headline = "Answers or actions vary across trials in this scenario."
- elif status == "changed":
- headline = "Different answers or actions were observed in this scenario."
- else:
- headline = "No difference was observed in the selected comparison."
+ headline = finding_text(state, checks)
if status == "unchanged":
notices.append(
"Matching observations in this scenario do not prove that the instruction edit has no effect."
@@ -500,4 +685,8 @@ def claim(value):
claim(narrative.caution) if narrative else None,
tuple(notices),
claim(narrative.context) if narrative else None,
+ state,
+ finding_meaning(checks, decisions.target_rejection, unchecked),
+ checks,
+ NEXT_ACTIONS[state],
)
diff --git a/plugin/skills/behavior-diff/scripts/reporting/target.py b/plugin/skills/behavior-diff/scripts/reporting/target.py
index c88201c..4f2b9e8 100644
--- a/plugin/skills/behavior-diff/scripts/reporting/target.py
+++ b/plugin/skills/behavior-diff/scripts/reporting/target.py
@@ -9,8 +9,10 @@
EXCERPT_LIMIT,
RECORD_LIMIT,
RUN_LIMIT,
+ SENSITIVE_SOURCE,
SOURCE_LIMIT,
TRIAL_LIMIT,
+ UNPROVEN_CALL_REASONS,
)
from reporting.explanation import _text
@@ -263,10 +265,28 @@ def persisted_evidence(raw):
return evidence_records({"trials": trials})
+def _returned_content(record):
+ return record.status in ("available", "truncated") and bool(record.text)
+
+
+def _captured_call(record):
+ """A captured call record proves that its listed call ran, not what it returned."""
+ return (
+ re.fullmatch(r".+:tool-[1-9][0-9]*", record.id) is not None
+ and record.source != SENSITIVE_SOURCE
+ and record.reason not in UNPROVEN_CALL_REASONS
+ )
+
+
def parse_target(raw, purpose, final_answers, evidence, rows=(), positions=None):
"""Invalid assessments stay unavailable, never become successful or assessed-empty."""
+ return check_target(raw, purpose, final_answers, evidence, rows, positions)[0]
+
+
+def check_target(raw, purpose, final_answers, evidence, rows=(), positions=None):
+ """Return (assessment, None), or (None, why a returned assessment was rejected)."""
if raw is None:
- return None
+ return None, None
try:
if (
type(raw) is not dict
@@ -383,29 +403,36 @@ def parse_target(raw, purpose, final_answers, evidence, rows=(), positions=None)
raise ValueError(
"target reference must resolve in its own trial"
)
- usable = [
+ answer = name + ":answer"
+ content = [
ref
for ref in refs
- if ref == name + ":answer"
- or records[ref].status in ("available", "truncated")
- and records[ref].text
+ if ref != answer and _returned_content(records[ref])
+ ]
+ calls = [
+ ref
+ for ref in refs
+ if ref != answer and _captured_call(records[ref])
]
if outcome in ("met", "not_met") and (
- not refs or len(usable) != len(refs)
+ not refs
+ or any(
+ ref != answer and ref not in content and ref not in calls
+ for ref in refs
+ )
):
raise ValueError(
"determinate target outcome needs available evidence"
)
if outcome in ("met", "not_met"):
- has_output = name + ":answer" in refs
- has_source = any(ref != name + ":answer" for ref in usable)
+ has_output = answer in refs
if (
item["evidence_kind"] == "source_consistency"
- and not (has_output and has_source)
+ and not (has_output and content)
or item["evidence_kind"] == "output"
and not has_output
or item["evidence_kind"] == "recorded_action"
- and not has_source
+ and not calls
):
raise ValueError(
"criterion lacks required source or output evidence"
@@ -444,9 +471,11 @@ def parse_target(raw, purpose, final_answers, evidence, rows=(), positions=None)
)
if covered_goals != set(range(1, len(purpose) + 1)):
raise ValueError("target criteria must cover every purpose goal")
- return TargetAssessmentData(tuple(criteria))
- except (KeyError, TypeError, ValueError, AttributeError):
- return None
+ return TargetAssessmentData(tuple(criteria)), None
+ except ValueError as error:
+ return None, str(error)
+ except (KeyError, TypeError, AttributeError):
+ return None, "malformed target assessment"
def distribution(criterion, side):
diff --git a/plugin/skills/behavior-diff/scripts/run-trial.sh b/plugin/skills/behavior-diff/scripts/run-trial.sh
index 1a85e28..c0fa064 100755
--- a/plugin/skills/behavior-diff/scripts/run-trial.sh
+++ b/plugin/skills/behavior-diff/scripts/run-trial.sh
@@ -113,13 +113,14 @@ normalize_pi_omp_json() { # $1 = raw Pi or OMP JSONL
record_status() { # $1 JSON fields; only with --status-file
[ -n "$status_file" ] || return 0
- # Status is observability only. progress.py diagnoses its own failures on
- # stderr; none may change the trial or its exit code.
+ # A status write never changes the trial or its exit code. progress.py
+ # diagnoses its own failures on stderr.
python3 "$scripts/progress.py" "$status_file" trial "$1" || :
}
-# A failing host CLI still leaves its output for normalization and grading, as
-# before. Record its exit code rather than propagating it.
+# A failing host CLI still leaves its output for normalization as evidence.
+# Record its exit code rather than propagating it; report loading and
+# assessment treat a nonzero exit as an incomplete trial.
cli_exit=0
record_status '{"phase":"running"}'
if [ "$agent" = claude ]; then
diff --git a/tests/human-evaluation-hosted-test.py b/tests/human-evaluation-hosted-test.py
index 5586a61..e4105bd 100755
--- a/tests/human-evaluation-hosted-test.py
+++ b/tests/human-evaluation-hosted-test.py
@@ -357,7 +357,7 @@ def test_forged_attention_receipts_rejected(self):
original_summary = summary.read_bytes()
original_receipt = receipt_path.read_bytes()
prefix, attention = original_summary.split(
- b'class="story-step story-attention"', 1
+ b'class="summary-attention story-attention"', 1
)
alterations = (
attention.replace(
@@ -374,7 +374,9 @@ def test_forged_attention_receipts_rejected(self):
for altered_attention in alterations:
with self.subTest(alteration=altered_attention[:100]):
altered = (
- prefix + b'class="story-step story-attention"' + altered_attention
+ prefix
+ + b'class="summary-attention story-attention"'
+ + altered_attention
)
self.assertNotEqual(original_summary, altered)
summary.write_bytes(altered)
diff --git a/tests/human-evaluation-quiz-test.py b/tests/human-evaluation-quiz-test.py
index 4c70af2..f44a1f9 100755
--- a/tests/human-evaluation-quiz-test.py
+++ b/tests/human-evaluation-quiz-test.py
@@ -323,14 +323,10 @@ def test_actual_synthetic_report_blinding_preserves_wording(self):
blind_root = quiz._ReportParser().finish(blinded)
text = quiz._plain(blind_root)
for name in (
- "summary-headline",
- "summary-context",
- "summary-provenance",
"summary-before",
"summary-after",
"summary-why",
"summary-caution",
- "summary-notices",
"evidence-limits",
):
for node in quiz._walk(panel):
@@ -344,6 +340,10 @@ def test_actual_synthetic_report_blinding_preserves_wording(self):
"intent-heading",
"scenario-context",
"scenario-prompt",
+ "summary-checks",
+ "summary-method",
+ "summary-finding-meaning",
+ "summary-next-action",
):
for node in nodes:
if node.has_class(name) and quiz._plain(node).strip():
@@ -470,87 +470,45 @@ def test_attention_findings_states_and_steps_survive_blinding(self):
)
)
- def headings(root):
- return [
- quiz._plain(
- next(node for node in quiz._walk(step) if node.tag == "h3")
- )
- for step in quiz._walk(root)
- if step.has_class("story-step")
- ]
-
- # Evidence, attention and limits keep their order; the intent step is gone.
- original_headings = headings(original)
- self.assertEqual(headings(blinded), original_headings[1:])
- self.assertNotIn(original_headings[0], quiz._plain(blinded))
-
- def test_version_labels_survive_blinding_with_unchanged_text(self):
- def labelled(roots):
- """Each element that emphasizes a version name, with its full text."""
- return sorted(
- (node.tag, node.attrs.get("class", ""), quiz._plain(node), names)
- for root in roots
- for node in quiz._walk(root)
- for names in [
- tuple(
- quiz._plain(child)
- for child in quiz._children(node)
- if child.tag == "strong" and child.has_class("version-label")
- )
- ]
- if names
- )
-
- exported = 0
- for report in sorted(self.gallery.glob("*/report.html")):
- with self.subTest(scenario=report.parent.name):
- source = report.read_text()
- nodes = list(quiz._walk(quiz._ReportParser().finish(source)))
- panel = quiz._single(
- [node for node in nodes if node.attrs.get("id") == "panel-summary"],
- "summary panel",
- )
- summary = list(quiz._walk(panel))
- steps = [node for node in summary if node.has_class("story-step")]
- # Blinding drops the intended-change step but keeps its case context.
- kept = steps[1:] + [
- node
- for node in summary
- if any(
- node.has_class(name)
- for name in (
- "summary-case-context",
- "summary-boundary",
- "evidence-limits",
- )
- )
- ]
- blinded = quiz._ReportParser().finish(quiz._blinded_report(source))
- expected = labelled(kept)
- self.assertEqual(labelled([blinded]), expected)
- for *_, names in expected:
- self.assertTrue(set(names) <= {"Before", "After"})
- exported += len(names)
- self.assertTrue(exported, "No saved Summary exported a version label.")
- label = 'The After draft'
- source = (self.gallery / "target-additional/report.html").read_text()
- self.assertIn(label, quiz._blinded_report(source))
- hostile = source.replace(
- label,
- 'The After draft',
- 1,
+ def test_assessment_setup_and_next_actions_do_not_reveal_quiz_key(self):
+ source = (self.gallery / "target-mixed/report.html").read_text()
+ root = quiz._ReportParser().finish(source)
+ hidden = (
+ "summary-headline",
+ "story-finding",
+ "story-expectation",
+ "story-method",
+ "story-next",
)
- self.assertNotEqual(source, hostile)
- blinded = quiz._blinded_report(hostile)
- self.assertIn(label, blinded)
- self.assertNotIn("leaked", blinded)
- self.assertNotIn("font-size:40px", blinded)
+ secrets = []
+ for index, name in enumerate(hidden):
+ node = quiz._single(
+ [node for node in quiz._walk(root) if node.has_class(name)],
+ name,
+ )
+ secret = f"PRIVATE-ANSWER-KEY-{index}"
+ secrets.append(secret)
+ if name == "summary-headline":
+ node.children.append(secret)
+ else:
+ body = quiz._children(node)[1]
+ body.children.append(quiz._Node("p", children=[secret]))
+ with patch.object(quiz._ReportParser, "finish", return_value=root):
+ blinded = quiz._blinded_report(source)
+ for secret in secrets:
+ self.assertNotIn(secret, blinded)
+ self.assertIn("summary-before", blinded)
+ self.assertIn("summary-after", blinded)
def test_old_summary_shapes_and_wrapper_recovery_are_rejected(self):
source = (self.gallery / "changed-result/report.html").read_text()
- # v1 trees: no limits step, a target table in evidence, or extra disclosures.
- for legacy in ("three-steps", "meaning-step", "target-table", "other-findings"):
+ # Unknown layouts must fail closed rather than exposing unreviewed sections.
+ for legacy in (
+ "missing-step",
+ "unknown-step",
+ "target-table",
+ "other-findings",
+ ):
with self.subTest(legacy=legacy):
root = quiz._ReportParser().finish(source)
panel = quiz._single(
@@ -569,17 +527,24 @@ def test_old_summary_shapes_and_wrapper_recovery_are_rejected(self):
],
"story steps",
)
- if legacy == "three-steps":
+ if legacy == "missing-step":
steps.children = [
child
for child in steps.children
if not isinstance(child, quiz._Node)
- or not child.has_class("story-attention")
+ or not child.has_class("story-next")
]
- elif legacy == "meaning-step":
+ elif legacy == "unknown-step":
quiz._children(steps)[3].attrs["class"] = "story-step"
elif legacy == "target-table":
- evidence = quiz._children(quiz._children(steps)[1])[1]
+ evidence = quiz._single(
+ [
+ node
+ for node in quiz._walk(panel)
+ if node.has_class("summary-observations")
+ ],
+ "observations",
+ )
evidence.children.insert(
len(evidence.children) - 1,
quiz._Node("div", [("class", "target-checks")]),
@@ -722,13 +687,11 @@ def test_case_context_start_result_and_limits_survive_without_intent(self):
],
)
for name in (
- "summary-headline",
"summary-before",
"summary-after",
"summary-why",
"summary-interpretation-unavailable",
"summary-caution",
- "summary-notices",
"evidence-limits",
"attention-target-result",
):
@@ -873,7 +836,7 @@ def test_case_context_repeating_intent_and_target_links_fail_closed(self):
1,
)
self.assertNotEqual(source, provenance)
- with self.assertRaisesRegex(ValueError, "Unknown intended-change step"):
+ with self.assertRaises(ValueError):
quiz._blinded_report(provenance)
linked = source.replace(
'',
diff --git a/tests/presentation_contract.py b/tests/presentation_contract.py
index 8801ba2..c677528 100644
--- a/tests/presentation_contract.py
+++ b/tests/presentation_contract.py
@@ -10,12 +10,25 @@
LABEL = '{0}'
WRAPPED = re.compile(r'(Before|After)')
-SUMMARY_LINKS = [
- ["#instruction-diff"],
- ["#panel-explanation"],
- ["#attention-explanation"],
- [],
-]
+
+
+def _summary_links(report):
+ """Expected per-step links: checks link to criteria; evidence links to detail tabs."""
+ return [
+ [],
+ ["#instruction-diff"]
+ + [f"#target-criterion-{check.criterion}" for check in report.summary.checks],
+ [],
+ [
+ "#attention-explanation",
+ "#panel-explanation",
+ "#panel-decision",
+ "#panel-trials",
+ ],
+ [],
+ ]
+
+
# {B} and {A} mark the only words that name a compared version.
VERSION_NAME_CASES = (
("Both Before and After trials stop.", "Both {B} and {A} trials stop."),
@@ -285,7 +298,7 @@ def assert_version_labels(reports):
def assert_summary_detail_links(reports):
- """The Summary keeps one detail link per section; detailed views keep evidence."""
+ """Each Summary step links only to its detail; detailed views keep evidence."""
from reporting import content
from reporting.render_html import render_artifact
@@ -293,13 +306,17 @@ def assert_summary_detail_links(reports):
markup = _ReportMarkup(render_artifact(report, ""))
summary = markup.select(anchor="panel-summary")[0]
steps = markup.select(summary, class_name="story-step")
+ expected = _summary_links(report)
assert [
[link["attrs"].get("href") for link in markup.select(step, tag="a")]
for step in steps
- ] == SUMMARY_LINKS, name
+ ] == expected, name
assert {
link["attrs"].get("href") for link in markup.select(summary, tag="a")
- } == {href for links in SUMMARY_LINKS for href in links}, name
+ } == {href for links in expected for href in links}, name
+ for links in expected:
+ for href in links:
+ assert markup.select(anchor=href[1:]), (name, href)
for removed in ("evidence-links", "attention-finding-nav"):
assert not markup.select(summary, class_name=removed), (name, removed)
for item in content.attention_items(report):
diff --git a/tests/progress-assessment-test.py b/tests/progress-assessment-test.py
index 078b258..ebed836 100755
--- a/tests/progress-assessment-test.py
+++ b/tests/progress-assessment-test.py
@@ -1,7 +1,10 @@
#!/usr/bin/env python3
"""Synthetic assessment outcomes stay distinct from process success."""
+import contextlib
+import io
import json
+import os
from pathlib import Path
import sys
import tempfile
@@ -11,6 +14,48 @@
SCRIPTS = Path(__file__).resolve().parents[1] / "plugin/skills/behavior-diff/scripts"
sys.path.insert(0, str(SCRIPTS))
import decisions # noqa: E402
+import progress # noqa: E402
+from reporting.load import load_report # noqa: E402
+from reporting.render_html import render_artifact # noqa: E402
+from reporting.render_markdown import render_markdown # noqa: E402
+
+PURPOSE = {
+ "goals": [
+ {
+ "text": "State the synthetic export limit in the final answer.",
+ "source": "session",
+ "basis": "explicit",
+ "reference": "Synthetic request",
+ }
+ ]
+}
+
+
+def synthetic_run(run, names=("before-1", "before-2", "after-1", "after-2")):
+ """Write a synthetic review run whose every trial has a final answer."""
+ (run / "config.json").write_text(
+ json.dumps({"mode": "review", "target_file": "AGENTS.md", "purpose": PURPOSE})
+ )
+ (run / "task.md").write_text("Synthetic task.\n")
+ (run / "grades.tsv").write_text("".join(f"{name}\tREVIEW\t-\n" for name in names))
+ for name in names:
+ (run / name).mkdir()
+ (run / name / "trace.jsonl").write_text(
+ json.dumps({"type": "result", "result": f"Synthetic answer {name}."}) + "\n"
+ )
+
+
+def trial_record(trial_dir, *updates):
+ """Write the trial record through the shipped producer, in runner order."""
+ path = trial_dir / progress.TRIAL_RECORD
+ progress.update(
+ path, "trial", {"phase": "preparing", "state": "running"}, owner_pid=os.getpid()
+ )
+ for fields in updates:
+ progress.update(path, "trial", fields)
+
+
+FINISHED = {"phase": "finished", "state": "finished", "exit_code": 0}
class AssessmentProgressTests(unittest.TestCase):
@@ -61,6 +106,138 @@ def test_unexpected_failure_preserves_exception_and_safe_reason(self):
"private text", (self.run / "assessment-status.json").read_text()
)
+ def test_rejected_target_stays_available_with_its_reason(self):
+ synthetic_run(self.run)
+ trials = {"before": ["before-1", "before-2"], "after": ["after-1", "after-2"]}
+ reply = {
+ "chain": [
+ {
+ "decision": "Which answer shape was used?",
+ "anchor": "answer",
+ "before": [{"choice": "short", "trials": trials["before"]}],
+ "after": [{"choice": "short", "trials": trials["after"]}],
+ }
+ ],
+ "target_assessment": {
+ "criteria": [
+ {
+ "id": "limit",
+ "goal": 1,
+ "text": "States the export limit.",
+ "mode": "new",
+ "required_evidence": "The final answer wording.",
+ "evidence_kind": "output",
+ "decisions": [],
+ **{
+ side: [
+ {
+ "trial": name,
+ "outcome": "met",
+ "refs": [name + ":answer"],
+ "explanation": "The answer is readable.",
+ "output_excerpt": "",
+ }
+ for name in names
+ ]
+ for side, names in trials.items()
+ },
+ }
+ ]
+ },
+ }
+ extraction = ("synthetic", json.dumps(reply))
+ with (
+ patch.object(decisions, "run_extractor", return_value=extraction),
+ contextlib.redirect_stdout(io.StringIO()),
+ ):
+ decisions.main(self.run)
+ self.assertEqual(self.outcome()["state"], "available")
+ self.assertEqual(self.outcome()["reason"], "target_rejected")
+ saved = json.loads((self.run / "decisions.json").read_text())
+ self.assertIsNone(saved["target_assessment"])
+ reason = "output assessment needs its relevant exact excerpt"
+ self.assertEqual(saved["target_rejection"], reason)
+ self.assertTrue((self.run / "decisions.raw.txt").exists())
+ report = load_report(self.run, self.run, "synthetic", self.run / "config.json")
+ self.assertIsNone(report.decisions.target_assessment)
+ self.assertEqual(report.decisions.target_rejection, reason)
+
+
+class NativeTrialFailureTests(unittest.TestCase):
+ """Nonempty progress or error text from a failed host CLI is not an answer."""
+
+ def setUp(self):
+ self.temp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.temp.cleanup)
+ self.run = Path(self.temp.name)
+ synthetic_run(self.run)
+
+ def load(self):
+ return load_report(self.run, self.run, "synthetic", self.run / "config.json")
+
+ def assert_blocked(self, reason):
+ report = self.load()
+ variant = report.variants.after
+ trial = variant.trials[0]
+ self.assertEqual(trial.name, "after-1")
+ self.assertEqual(trial.verdict, "BLOCKED")
+ self.assertEqual(trial.final, "")
+ self.assertEqual(trial.outcome, "Blocked — " + reason)
+ self.assertEqual((variant.blocked, variant.valid, variant.total), (1, 1, 2))
+ self.assertIn(reason, render_artifact(report, ""))
+ self.assertIn(reason, render_markdown(report))
+ prompt, completed, _ = decisions.build_prompt(self.run, include_style=False)
+ self.assertEqual(completed["after"], ["after-2"])
+ self.assertIn(f"(not completed: {reason})", prompt)
+ self.assertNotIn("Synthetic answer after-1.", prompt)
+
+ def test_nonzero_host_exit_blocks_each_host_record_shape(self):
+ shapes = {
+ # run-trial.sh records Claude's exit while the phase is still running.
+ "claude": ({"phase": "running"}, {"cli_exit_code": 1}),
+ # Codex, Pi and OMP record the exit as normalization starts.
+ "normalized": (
+ {"phase": "running"},
+ {"phase": "normalizing", "cli_exit_code": 1},
+ ),
+ }
+ for host, updates in shapes.items():
+ with self.subTest(host):
+ trial_record(
+ self.run / "after-1", *updates, {**FINISHED, "final_result": True}
+ )
+ self.assert_blocked("agent CLI exited 1")
+ (self.run / "after-1" / progress.TRIAL_RECORD).unlink()
+
+ def test_interrupted_unfinished_and_unreadable_records_block(self):
+ cases = (
+ ({"state": "interrupted", "exit_code": 143}, "interrupted"),
+ ({"phase": "running"}, "worker ended without a final record"),
+ )
+ for fields, reason in cases:
+ with self.subTest(reason):
+ trial_record(self.run / "after-1", fields)
+ self.assert_blocked(reason)
+ (self.run / "after-1" / progress.TRIAL_RECORD).unlink()
+ (self.run / "after-1" / progress.TRIAL_RECORD).write_text("{")
+ self.assert_blocked("trial record unreadable")
+
+ def test_completed_and_unrecorded_trials_keep_their_answers(self):
+ trial_record(
+ self.run / "after-1",
+ {"phase": "running"},
+ {"cli_exit_code": 0},
+ {**FINISHED, "final_result": True},
+ )
+ report = self.load()
+ self.assertEqual(report.variants.after.blocked, 0)
+ self.assertEqual(
+ [trial.final for trial in report.variants.after.trials],
+ ["Synthetic answer after-1.", "Synthetic answer after-2."],
+ )
+ _, completed, _ = decisions.build_prompt(self.run, include_style=False)
+ self.assertEqual(completed["after"], ["after-1", "after-2"])
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/recorded-evidence-test.py b/tests/recorded-evidence-test.py
index 60e3054..98d472e 100755
--- a/tests/recorded-evidence-test.py
+++ b/tests/recorded-evidence-test.py
@@ -18,6 +18,7 @@
TRIAL_LIMIT,
collect_run_evidence,
)
+from reporting.target import check_target, evidence_records, parse_purpose # noqa: E402
def call(identity, path="facts.txt", text="Recorded cap: 500.", **result_fields):
@@ -51,6 +52,71 @@ def call(identity, path="facts.txt", text="Recorded cap: 500.", **result_fields)
]
+def command(identity, text, output="Synthetic pull request body."):
+ events = call(identity, text=output)
+ events[0]["message"]["content"][0].update(name="Bash", input={"command": text})
+ return events
+
+
+PURPOSE = parse_purpose(
+ {
+ "goals": [
+ {
+ "text": "Read the synthetic source pull request before drafting.",
+ "source": "session",
+ "basis": "explicit",
+ "reference": "Synthetic request",
+ }
+ ]
+ }
+)
+ANSWERS = {
+ "before": {"before-1": "Synthetic draft without a source."},
+ "after": {"after-1": "Synthetic draft citing pull request 7."},
+}
+
+
+def criterion(
+ kind="recorded_action",
+ after_outcome="met",
+ excerpt="",
+ ref="after-1:tool-1",
+ before_outcome="not_met",
+):
+ refs = [ref] if kind == "recorded_action" else [ref, "after-1:answer"]
+ return {
+ "criteria": [
+ {
+ "id": "source-read",
+ "goal": 1,
+ "text": "Reads the synthetic source pull request.",
+ "mode": "new",
+ "required_evidence": "A recorded pull request view.",
+ "evidence_kind": kind,
+ "decisions": [],
+ "before": [
+ {
+ "trial": "before-1",
+ "outcome": before_outcome,
+ "refs": ["before-1:tool-1"],
+ "explanation": "Only a local file read is recorded.",
+ "output_excerpt": "",
+ }
+ ],
+ "after": [
+ {
+ "trial": "after-1",
+ "outcome": after_outcome,
+ "refs": refs,
+ "explanation": "The recorded call views the pull request.",
+ "output_excerpt": excerpt,
+ }
+ ],
+ }
+ ]
+ }
+
+
class RecordedEvidenceTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
@@ -290,5 +356,93 @@ def test_codex_runner_wrappers_retain_only_simple_recorded_reads(self):
self.assertIn(json.dumps(command), record["source"])
+class TargetEvidenceTests(unittest.TestCase):
+ """A captured call proves that the call ran, never what it returned."""
+
+ def setUp(self):
+ self.temp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.temp.cleanup)
+ self.root = Path(self.temp.name)
+
+ def recorded(self, after_events):
+ for trial, events in (("before-1", call("read-1")), ("after-1", after_events)):
+ directory = self.root / trial
+ directory.mkdir(exist_ok=True)
+ trace = directory / "trace.jsonl"
+ trace.unlink(missing_ok=True)
+ if events is not None:
+ trace.write_text("".join(json.dumps(x) + "\n" for x in events))
+
+ def check(self, raw):
+ evidence = evidence_records(collect_run_evidence(self.root))
+ return check_target(raw, PURPOSE, ANSWERS, evidence)
+
+ def test_recorded_call_proves_action_despite_omitted_return(self):
+ self.recorded(command("view-1", "gh pr view 7"))
+ record = collect_run_evidence(self.root)["trials"]["after-1"][0]
+ self.assertEqual((record["status"], record["text"]), ("omitted", ""))
+ target, rejection = self.check(criterion())
+ self.assertIsNone(rejection)
+ self.assertEqual(target.criteria[0].after[0].outcome, "met")
+ self.assertEqual(target.criteria[0].before[0].outcome, "not_met")
+
+ def test_call_without_returned_content_cannot_prove_source_agreement(self):
+ self.recorded(command("view-1", "gh pr view 7"))
+ raw = criterion(
+ "source_consistency",
+ excerpt="citing pull request 7",
+ before_outcome="unassessable",
+ )
+ self.assertEqual(
+ self.check(raw),
+ (None, "criterion lacks required source or output evidence"),
+ )
+ raw["criteria"][0]["after"][0]["outcome"] = "unassessable"
+ target, rejection = self.check(raw)
+ self.assertIsNone(rejection)
+ self.assertEqual(target.criteria[0].after[0].outcome, "unassessable")
+
+ def test_unproven_calls_cannot_support_determinate_outcomes(self):
+ cases = (
+ ("sensitive source", command("view-1", "cat .env"), "after-1:tool-1"),
+ (
+ "duplicate call identity",
+ command("dup", "gh pr view 7") + command("dup", "gh pr view 7"),
+ "after-1:tool-1",
+ ),
+ ("missing recording", None, "after-1:trace"),
+ ("self-reported action", command("view-1", "gh pr view 7"), None),
+ )
+ for label, events, ref in cases:
+ with self.subTest(label):
+ self.recorded(events)
+ if ref is None:
+ ref = "after-1:tool-1"
+ (self.root / "config.json").write_text(
+ '{"trace_source":"self-reported"}'
+ )
+ self.assertEqual(
+ self.check(criterion(ref=ref)),
+ (None, "determinate target outcome needs available evidence"),
+ )
+ uncertain = criterion(
+ after_outcome="uncertain", ref=ref, before_outcome="uncertain"
+ )
+ self.assertIsNone(self.check(uncertain)[1])
+
+ def test_output_outcome_needs_its_excerpt(self):
+ self.recorded(command("view-1", "gh pr view 7"))
+ raw = criterion("output", before_outcome="unassessable")
+ self.assertEqual(
+ self.check(raw),
+ (None, "output assessment needs its relevant exact excerpt"),
+ )
+ excerpt = "citing pull request 7"
+ raw["criteria"][0]["after"][0]["output_excerpt"] = excerpt
+ target, rejection = self.check(raw)
+ self.assertIsNone(rejection)
+ self.assertEqual(target.criteria[0].after[0].output_excerpt, excerpt)
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/report-ci-context-test.py b/tests/report-ci-context-test.py
index e9aa7b3..31a0d94 100755
--- a/tests/report-ci-context-test.py
+++ b/tests/report-ci-context-test.py
@@ -81,7 +81,7 @@ def _header(page):
def assert_no_context_unchanged():
report = _load(_config())
raw = _roundtrip(report)
- assert raw["schema_version"] == 16
+ assert raw["schema_version"] == 17
assert raw["metadata"]["ci_context"] is None
page, markdown = _render_offline(report)
for absent in ("source-line", "revision-pair", "local-paths", "Workflow"):
diff --git a/tests/report-schema-test.py b/tests/report-schema-test.py
index 52519ff..814fff7 100755
--- a/tests/report-schema-test.py
+++ b/tests/report-schema-test.py
@@ -43,7 +43,7 @@
def synthetic_raw():
raw = {
- "schema_version": 16,
+ "schema_version": 17,
"metadata": {
"model": "synthetic/model",
"mode": "review",
@@ -251,6 +251,7 @@ def synthetic_raw():
raw["decisions"].update(
purpose=[],
purpose_summary=None,
+ target_rejection=None,
recorded_evidence=[],
target_assessment=None,
evidence_limits=None,
@@ -298,11 +299,11 @@ def assert_round_trip(raw):
return report
-def assert_rejected(raw, message):
+def assert_rejected(raw):
try:
ReportData.from_dict(raw)
- except ValueError as error:
- assert str(error) == message
+ except ValueError:
+ pass
else:
raise AssertionError("invalid report data was accepted")
@@ -507,7 +508,6 @@ def narrated(value, **changes):
full_raw = full.to_dict()
assert_round_trip(full_raw)
absent = narrated(plain)
- assert absent.summary.headline == narrative.headline
absent_raw = absent.to_dict()
assert_round_trip(absent_raw)
# Missing claims serialize as explicit nulls rather than disappearing.
@@ -568,7 +568,7 @@ def narrated(value, **changes):
del target[name]
else:
target[name] = value
- assert_rejected(changed, message)
+ assert_rejected(changed)
# A Summary claim cannot appear without a validated narrative claim.
for parents in ((), ("after",)):
invented = copy.deepcopy(raw)
@@ -576,7 +576,7 @@ def narrated(value, **changes):
for key in parents:
target = target[key]
target["start" if parents else "context"] = dict(shared)
- assert_rejected(invented, derived)
+ assert_rejected(invented)
# Extraction remaps sorted references and keeps the same canonical narrative.
completed = {"before": ["before-1", "before-2"], "after": ["after-1", "after-2"]}
@@ -654,7 +654,6 @@ def narrated(value, **changes):
(run / "decisions.json").write_text(json.dumps(saved_decisions))
loaded = load_report(run, run, "synthetic/model", run / "config.json")
assert loaded.decisions.narrative == expected
- assert loaded.summary.headline == expected.headline
for value, source in (
(loaded.summary.context, expected.context),
(loaded.summary.before.start, expected.before.start),
@@ -718,17 +717,14 @@ def assert_purpose_summary_contract(raw):
):
changed = copy.deepcopy(purposed)
changed["decisions"]["purpose_summary"] = value
- assert_rejected(changed, invalid)
+ assert_rejected(changed)
# Without frozen goals a summary cannot appear, even as a paraphrase.
unfrozen = copy.deepcopy(raw)
unfrozen["decisions"]["purpose_summary"] = paraphrase
- assert_rejected(unfrozen, invalid)
+ assert_rejected(unfrozen)
missing = copy.deepcopy(purposed)
del missing["decisions"]["purpose_summary"]
- assert_rejected(
- missing,
- "invalid report-data field decisions.purpose_summary: expected present field",
- )
+ assert_rejected(missing)
# Saved decisions keep observations whether the summary is valid, absent, or not.
completed = {"before": ["before-1", "before-2"], "after": ["after-1", "after-2"]}
@@ -788,10 +784,11 @@ def assert_purpose_summary_contract(raw):
def assert_derived_summary_claims(reports):
- """Starts cite one full lead branch; unknown target outcomes stay visible."""
+ """Starts cite one full lead branch; the conclusion follows validated target checks."""
from reporting.render_html import render_artifact
from reporting.render_markdown import render_markdown
- from reporting.summary import TARGET_UNCERTAIN, build_summary
+ from reporting import summary as finding
+ from reporting.summary import build_summary
for name, report in reports.items():
summary = report.summary
@@ -804,52 +801,144 @@ def assert_derived_summary_claims(reports):
members = {trial for branch in lead for trial in branch.trials}
assert len(start.decisions) == len(cited) == 1, name
assert members and set(cited[0].trials) == members, name
- target = report.decisions.target_assessment
- unknown = target is not None and any(
- trial.outcome in ("uncertain", "unassessable")
- for criterion in target.criteria
- for trial in criterion.before + criterion.after
- )
- assert (TARGET_UNCERTAIN in summary.notices) == unknown, name
report = reports["target-baseline"]
target = report.decisions.target_assessment
- narrative = report.decisions.narrative
- quiet = narrative and replace(narrative, caution=None)
- def assessed(unknown=None):
- """Mark every outcome met, except one optional (side, outcome) override."""
+ def assessed(before="met", after="met", mode=None, variants=None, drop=False):
+ """Set every outcome per side; optionally change mode, health or coverage."""
criteria = []
- for number, criterion in enumerate(target.criteria):
- sides = {}
- for side in ("before", "after"):
- outcomes = ["met"] * len(getattr(criterion, side))
- if unknown and number == 0 and side == unknown[0]:
- outcomes[0] = unknown[1]
- sides[side] = tuple(
+ for criterion in target.criteria:
+ sides = {
+ side: tuple(
replace(trial, outcome=outcome)
- for trial, outcome in zip(getattr(criterion, side), outcomes)
+ for trial in getattr(criterion, side)
)
- criteria.append(replace(criterion, **sides))
+ for side, outcome in (("before", before), ("after", after))
+ }
+ if drop:
+ sides["after"] = sides["after"][1:]
+ criteria.append(replace(criterion, mode=mode or criterion.mode, **sides))
decisions = replace(
report.decisions,
- narrative=quiet,
target_assessment=replace(target, criteria=tuple(criteria)),
)
- summary = build_summary(report.metadata, report.variants, decisions)
- return replace(report, decisions=decisions, summary=summary)
+ changed = replace(
+ report, decisions=decisions, variants=variants or report.variants
+ )
+ return replace(
+ changed,
+ summary=build_summary(changed.metadata, changed.variants, decisions),
+ )
- met = assessed().summary
- assert TARGET_UNCERTAIN not in met.notices
- assert TARGET_UNCERTAIN not in assessed(("after", "not_met")).summary.notices
- for unknown in (("after", "unassessable"), ("before", "uncertain")):
- visible = assessed(unknown)
- assert visible.summary.caution is None
- assert TARGET_UNCERTAIN in visible.summary.notices
- # The notice is additive: headline, cards, and claims stay behavioral.
- assert replace(visible.summary, notices=met.notices) == met
- for rendered in (render_artifact(visible, ""), render_markdown(visible)):
- assert TARGET_UNCERTAIN in html.unescape(rendered).replace("\\", "")
+ from presentation_contract import visible_text as visible
+
+ assert all(c.mode != "preservation" for c in target.criteria)
+ interrupted = replace(
+ report.variants,
+ before=replace(
+ report.variants.before,
+ blocked=1,
+ valid=report.variants.before.total - 1,
+ ),
+ )
+ cases = (
+ (assessed(), "already_met"),
+ (assessed(before="not_met"), "improved"),
+ (assessed(after="not_met"), "not_met"),
+ (assessed(before="not_met", after="not_met"), "not_met"),
+ (assessed(after="unassessable"), "inconclusive"),
+ (assessed(after="uncertain"), "inconclusive"),
+ (assessed(drop=True), "inconclusive"),
+ (assessed(before="uncertain"), "baseline_incomplete"),
+ # An interrupted Before never turns a met After into an improvement.
+ (assessed(before="not_met", variants=interrupted), "baseline_incomplete"),
+ (assessed(mode="preservation"), "preserved"),
+ (assessed(after="not_met", mode="preservation"), "not_met"),
+ (assessed(before="not_met", mode="preservation"), "improved"),
+ (assessed(before="uncertain", mode="preservation"), "baseline_incomplete"),
+ (assessed(before="unassessable", mode="preservation"), "baseline_incomplete"),
+ (
+ assessed(mode="preservation", variants=interrupted),
+ "baseline_incomplete",
+ ),
+ )
+ for value, state in cases:
+ summary = value.summary
+ preservation = (
+ value.decisions.target_assessment.criteria[0].mode == "preservation"
+ )
+ assert summary.state == state, (state, summary.state)
+ assert {check.state for check in summary.checks} == {state}, state
+ assert summary.next_action == finding.NEXT_ACTIONS[state], state
+ answers_only = any(
+ check.scope == finding.CHECK_SCOPES["output"] for check in summary.checks
+ )
+ expected = (
+ finding.PRESERVATION_UNMET
+ if state == "not_met" and preservation
+ else finding.FINDING_HEADLINES[state]
+ )
+ if state in finding.MET_STATES and answers_only:
+ # Met answer checks never read as proof that actions happened.
+ expected += " " + finding.ANSWER_SCOPED
+ assert summary.headline == expected, state
+ criteria = value.decisions.target_assessment.criteria
+ # A dropped assessment is invalid saved data; it is exercised in memory only.
+ if all(len(c.after) == value.variants.after.total for c in criteria):
+ assert_round_trip(value.to_dict())
+ # Every export carries the same derived conclusion and per-check results.
+ pages = (render_artifact(value, ""), render_markdown(value))
+ for page in map(visible, pages):
+ for text in (
+ summary.headline,
+ summary.meaning,
+ summary.next_action,
+ finding.GATE_NOTE,
+ *(
+ part
+ for check in summary.checks
+ for part in (
+ check.text,
+ check.before,
+ check.after,
+ check.meaning,
+ check.scope,
+ )
+ ),
+ ):
+ assert text in page, (state, text)
+ assert value.to_dict()["summary"]["state"] == state
+
+ # Every frozen goal needs a check; an uncovered goal blocks a met conclusion.
+ extra_goal = replace(report.decisions.purpose[0], text="Synthetic extra goal.")
+ uncovered = replace(
+ report.decisions, purpose=report.decisions.purpose + (extra_goal,)
+ )
+ gap = build_summary(report.metadata, report.variants, uncovered)
+ assert gap.state == "inconclusive"
+ assert f"recorded goal {len(uncovered.purpose)}." in gap.meaning
+
+ # A miss stays a miss when the extracted behavior does not diverge at all.
+ same = assessed(after="not_met")
+ rows = tuple(replace(row, after=row.before) for row in same.decisions.rows)
+ assert report.decisions.before_count == report.decisions.after_count
+ flat = replace(same.decisions, rows=rows, narrative=None)
+ flat_summary = build_summary(same.metadata, same.variants, flat)
+ assert flat_summary.state == "not_met" and flat_summary.headline == finding.UNMET
+
+ # No validated target: the conclusion is unavailable, never inferred from rows.
+ missing = replace(report.decisions, target_assessment=None)
+ unavailable = build_summary(report.metadata, report.variants, missing)
+ assert unavailable.state == "unavailable" and not unavailable.checks
+ rejected = build_summary(
+ report.metadata,
+ report.variants,
+ replace(missing, target_rejection="synthetic validation reason"),
+ )
+ assert rejected.meaning.endswith(": synthetic validation reason.")
+ # Model narrative never sets the conclusion.
+ assert unavailable.headline == finding.FINDING_HEADLINES["unavailable"]
def assert_render_import_safe():
@@ -1592,7 +1681,6 @@ def assert_visual_summaries(reports):
assert non_outcome.summary.decision == 5, (
"A changed outcome must not displace a validated changed non-outcome narrative."
)
- assert non_outcome.summary.headline == non_outcome.decisions.narrative.headline
assert non_outcome.summary.status == "changed"
selected = non_outcome.decisions.rows[4]
narrative = non_outcome.decisions.narrative
@@ -1618,7 +1706,6 @@ def assert_visual_summaries(reports):
non_outcome.metadata, non_outcome.variants, mixed_decisions
)
assert mixed_lead.decision == 5
- assert mixed_lead.headline == narrative.headline
assert mixed_lead.status == "mixed"
assert [choice.count for choice in mixed_lead.before.choices] == [2, 1]
assert [choice.count for choice in mixed_lead.after.choices] == [3]
@@ -1629,7 +1716,6 @@ def assert_visual_summaries(reports):
fallback = build_summary(non_outcome.metadata, non_outcome.variants, decisions)
assert fallback.decision == non_outcome.decisions.outcome
assert fallback.status == status
- assert fallback.headline != narrative.headline
assert fallback.why is None
mixed_primary = reports["mixed-primary"]
@@ -1637,7 +1723,6 @@ def assert_visual_summaries(reports):
assert mixed_primary.summary.decision == mixed_primary.decisions.outcome, (
"A unanimous secondary action must not displace a mixed primary-result narrative."
)
- assert mixed_primary.summary.headline == mixed_primary.decisions.narrative.headline
assert mixed_primary.summary.status == "mixed"
assert [
(choice.choice, choice.count) for choice in mixed_primary.summary.before.choices
@@ -1659,14 +1744,12 @@ def assert_visual_summaries(reports):
assert fallback.decision == 2, (
"Without a supported changed primary narrative, retain the action fallback."
)
- assert fallback.headline != primary_decisions.narrative.headline
incomplete = build_summary(
mixed_primary.metadata,
mixed_primary.variants,
replace(primary_decisions, dropped=1),
)
assert incomplete.status == "unavailable"
- assert incomplete.headline != primary_decisions.narrative.headline
assert incomplete.why is None
missing_primary = build_summary(
mixed_primary.metadata,
@@ -1674,7 +1757,6 @@ def assert_visual_summaries(reports):
replace(primary_decisions, outcome=None),
)
assert missing_primary.decision == primary_decisions.narrative.decision
- assert missing_primary.headline == primary_decisions.narrative.headline
assert missing_primary.status == "mixed"
def summarize(variants=changed.variants, decisions=changed.decisions):
@@ -1739,7 +1821,10 @@ def summarize(variants=changed.variants, decisions=changed.decisions):
payload = '|[link](javascript:alert(1))'
unsafe_decisions = replace(
changed.decisions,
- narrative=replace(changed.decisions.narrative, headline=payload),
+ narrative=replace(
+ changed.decisions.narrative,
+ why=replace(changed.decisions.narrative.why, text=payload),
+ ),
)
unsafe = replace(
changed,
@@ -2391,19 +2476,14 @@ def assert_trial_summary_validation(raw):
):
specimen = copy.deepcopy(raw)
specimen["decisions"]["trial_summaries"] = invalid
- assert_rejected(specimen, canonical_error)
+ assert_rejected(specimen)
empty = copy.deepcopy(raw)
empty["decisions"]["trial_summaries"] = []
assert assert_round_trip(empty).decisions.trial_summaries == ()
missing = copy.deepcopy(raw)
del missing["decisions"]["trial_summaries"]
- assert_rejected(
- missing,
- "invalid report-data field decisions.trial_summaries: expected present field",
- )
- assert_rejected(
- dict(raw, schema_version=7), "unsupported report-data schema version: 7"
- )
+ assert_rejected(missing)
+ assert_rejected(dict(raw, schema_version=7))
def assert_trial_summary_pipeline():
@@ -2630,97 +2710,45 @@ def main():
"search",
]
- assert_rejected(
- dict(raw, schema_version=1),
- "unsupported report-data schema version: 1",
- )
- assert_rejected(
- dict(raw, schema_version=2),
- "unsupported report-data schema version: 2",
- )
- assert_rejected(
- dict(raw, schema_version=3),
- "unsupported report-data schema version: 3",
- )
- assert_rejected(
- dict(raw, schema_version=9),
- "unsupported report-data schema version: 9",
- )
- assert_rejected(
- dict(raw, schema_version=10),
- "unsupported report-data schema version: 10",
- )
+ assert_rejected(dict(raw, schema_version=1))
+ assert_rejected(dict(raw, schema_version=2))
+ assert_rejected(dict(raw, schema_version=3))
+ assert_rejected(dict(raw, schema_version=9))
+ assert_rejected(dict(raw, schema_version=10))
# Earlier report-data shapes are rejected; saved runs regenerate from decisions.
- for version in (11, 12, 13, 14, 15):
- assert_rejected(
- dict(raw, schema_version=version),
- f"unsupported report-data schema version: {version}",
- )
- assert_rejected(
- dict(raw, schema_version=True),
- "invalid report-data field schema_version: expected integer",
- )
- assert_rejected(
- dict(raw, schema_version=1.0),
- "invalid report-data field schema_version: expected integer",
- )
+ for version in (11, 12, 13, 14, 15, 16):
+ assert_rejected(dict(raw, schema_version=version))
+ assert_rejected(dict(raw, schema_version=True))
+ assert_rejected(dict(raw, schema_version=1.0))
invalid_commands = copy.deepcopy(raw)
invalid_commands["variants"]["before"]["trials"][0]["commands"] = "read AGENTS.md"
- assert_rejected(
- invalid_commands,
- "invalid report-data field variants.before.trials[0].commands: expected list",
- )
+ assert_rejected(invalid_commands)
invalid_model = copy.deepcopy(raw)
invalid_model["metadata"]["model"] = ["synthetic/model"]
- assert_rejected(
- invalid_model,
- "invalid report-data field metadata.model: expected string",
- )
+ assert_rejected(invalid_model)
invalid_count = copy.deepcopy(raw)
invalid_count["command_flow"]["before"]["paths"][0]["count"] = True
- assert_rejected(
- invalid_count,
- "invalid report-data field command_flow.before.paths[0].count: expected integer",
- )
+ assert_rejected(invalid_count)
invalid_suffix = copy.deepcopy(raw)
invalid_suffix["variants"]["after"]["count_suffix"] = True
- assert_rejected(
- invalid_suffix,
- "invalid report-data field variants.after.count_suffix: expected string",
- )
+ assert_rejected(invalid_suffix)
invalid_result_kind = copy.deepcopy(raw)
invalid_result_kind["result"]["kind"] = "future"
- assert_rejected(
- invalid_result_kind,
- "invalid report-data field result.kind: expected one of good, bad, neutral",
- )
+ assert_rejected(invalid_result_kind)
invalid_result_kind_type = copy.deepcopy(raw)
invalid_result_kind_type["result"]["kind"] = None
- assert_rejected(
- invalid_result_kind_type,
- "invalid report-data field result.kind: expected string",
- )
+ assert_rejected(invalid_result_kind_type)
for heading in ("outcome_heading", "behavior_heading"):
missing_heading = copy.deepcopy(raw)
del missing_heading["result"][heading]
- assert_rejected(
- missing_heading,
- "invalid report-data field result." + heading + ": expected present field",
- )
+ assert_rejected(missing_heading)
invalid_heading = copy.deepcopy(raw)
invalid_heading["result"][heading] = None
- assert_rejected(
- invalid_heading,
- "invalid report-data field result." + heading + ": expected string",
- )
+ assert_rejected(invalid_heading)
invalid_evidence = copy.deepcopy(raw)
invalid_evidence["result"]["implications"][0]["decisions"] = [99]
- assert_rejected(
- invalid_evidence,
- "invalid report-data field result.implications[0].decisions[0]: "
- "expected 1-based decision row index",
- )
+ assert_rejected(invalid_evidence)
from reporting.render_html import render_artifact, render_document
diff --git a/tests/target_contract.py b/tests/target_contract.py
index ce6cca4..a5f2c3a 100644
--- a/tests/target_contract.py
+++ b/tests/target_contract.py
@@ -53,7 +53,10 @@ def _assert_presentation(reports):
report = reports[name]
for rendered in (render_artifact(report, ""), render_markdown(report)):
plain = visible_text(rendered)
- assert report.decisions.narrative.headline in plain
+ assert report.summary.headline in plain
+ assert report.summary.meaning in plain
+ for check in report.summary.checks:
+ assert check.before in plain and check.after in plain
assert report.summary.why.text in plain
for side in (report.summary.before, report.summary.after):
assert all(choice.label in plain for choice in side.choices)
@@ -61,7 +64,10 @@ def _assert_presentation(reports):
markup = _ReportMarkup(render_artifact(report, ""))
summary = markup.select(anchor="panel-summary")[0]
detail = markup.select(anchor="panel-explanation")[0]
- assert not markup.select(summary, tag="table")
+ checks = markup.select(summary, class_name="summary-check")
+ assert len(checks) == len(report.decisions.target_assessment.criteria)
+ for row, check in zip(checks, report.summary.checks):
+ assert check.text in _plain(row) and check.after in _plain(row)
assert report.summary.context.text in _plain(summary)
assert report.summary.why.text in _plain(summary)
for side in ("before", "after"):
@@ -402,7 +408,7 @@ def _assert_purpose_conclusion(report, assert_round_trip):
assert text in plain, (
"An unchanged observation must retain its supported conclusion."
)
- assert raw["headline"] in plain
+ assert specimen.summary.headline in plain
incomplete = build_summary(
report.metadata, report.variants, replace(decisions, dropped=1)
)
@@ -663,10 +669,13 @@ def _assert_missing_records(report):
assert "No (1/4)" not in plain
assert summary.why is None
assert summary.status == "unavailable"
+ # An interrupted After trial leaves the check unsettled, not met.
+ assert summary.state == "inconclusive"
+ assert {check.state for check in summary.checks} == {"inconclusive"}
def _assert_old_versions_rejected(report):
- for version in (11, 12, 13, 14, 15):
+ for version in (11, 12, 13, 14, 15, 16):
raw = report.to_dict()
raw["schema_version"] = version
try:
From 5ebc9823fea8e49b86617da93d9241434562dd48 Mon Sep 17 00:00:00 2001
From: Kent Huang
Date: Tue, 20 Oct 2026 11:24:01 +0800
Subject: [PATCH 2/6] test: retain sanitizer coverage in blinded observations
Signed-off-by: Kent Huang
---
...ention-driven-preparation-implementation.md | 12 ++++++------
tests/human-evaluation-quiz-test.py | 18 ++++++++++++++----
2 files changed, 20 insertions(+), 10 deletions(-)
diff --git a/plans/2026-10-19-intention-driven-preparation-implementation.md b/plans/2026-10-19-intention-driven-preparation-implementation.md
index 0138253..4939f18 100644
--- a/plans/2026-10-19-intention-driven-preparation-implementation.md
+++ b/plans/2026-10-19-intention-driven-preparation-implementation.md
@@ -387,17 +387,17 @@ full multi-file replay, or a new automated verdict system was delivered.
### Delivery boundaries and status
-The user deferred report UI design on 2026-10-19. The earlier layout preview is
-exploratory, not an accepted design or an implementation dependency. Revisit it
-only after observing results from the completed preparation workflow.
+The user initially deferred report UI design until preparation evidence was
+available. The later five-step, assumption-first design is now approved; PR 3
+implements that design without importing private audit conclusions.
| Stage | Deliverable | Dependency | Status |
|---|---|---|---|
| PR 1 | Deliver prepared shared data to real Before/After snapshots safely | None | Merged as #49; deterministic checks, independent review and corrected dogfood CI passed |
-| PR 2 | Generate and preflight scenario/data from intent, then execute and explain evidence | PR 1 | Published as #50; independently approved; corrected dogfood CI remains the merge gate |
+| PR 2 | Generate and preflight scenario/data from intent, then execute and explain evidence | PR 1 | Merged as #50 |
| Preparation acceptance gate | Verify the integrated workflow against AC1–AC10 | PR 2 | Passed at bounded two-host evidence level; limitations retained below |
-| Report design checkpoint | Decide what actual results need the report to communicate | Preparation acceptance gate | Deferred; requires user design review |
-| Later report PR(s) | Implement only the agreed assessment/presentation changes | Accepted report design | Not scoped or authorized by this plan |
+| Report design checkpoint | Decide what actual results need the report to communicate | Preparation acceptance gate | Approved: five-step Summary, compact metadata and retained six-tab shell |
+| PR 3 | Implement the agreed assessment/presentation changes | Accepted report design | Published as #51; deterministic and browser checks passed; independent reviewers approved |
PR numbers here are sequence labels, not GitHub PR identifiers. Do not split out
tests, docs, or smoke proof into a later PR to make an incomplete feature mergeable.
diff --git a/tests/human-evaluation-quiz-test.py b/tests/human-evaluation-quiz-test.py
index f44a1f9..ce9664b 100755
--- a/tests/human-evaluation-quiz-test.py
+++ b/tests/human-evaluation-quiz-test.py
@@ -862,12 +862,22 @@ def test_case_context_repeating_intent_and_target_links_fail_closed(self):
def test_scripts_links_attributes_and_unknown_shapes(self):
source = (self.gallery / "changed-result/report.html").read_text()
- hostile = source.replace(
- '',
- 'external secret',
+ start = source.index("
", source.index('
')) + 3
+ hostile = (
+ source[:start] + 'After'
+ 'external secret' + source[start:]
)
+ self.assertNotEqual(source, hostile)
blinded = quiz._blinded_report(hostile)
- for secret in ("leaked", "onclick", "external secret", "hostile.invalid"):
+ self.assertIn('After', blinded)
+ for secret in (
+ "leaked",
+ "onclick",
+ "color:red",
+ "external secret",
+ "hostile.invalid",
+ ):
self.assertNotIn(secret, blinded)
for altered in (
source.replace('id="panel-summary"', 'id="unknown-panel"'),
From 810367396254ed543ba3b682b6a1b47fed76d368 Mon Sep 17 00:00:00 2001
From: Kent Huang
Date: Tue, 20 Oct 2026 14:14:26 +0800
Subject: [PATCH 3/6] feat(report): retain detailed evidence in the refined
summary
Signed-off-by: Kent Huang
---
.../scripts/quiz.py | 21 +-
docs/architecture.md | 14 +-
...ntion-driven-preparation-implementation.md | 17 ++
.../scripts/reporting/content.py | 235 +++++++++++++++++-
.../scripts/reporting/render_html.py | 74 +++++-
.../scripts/reporting/render_markdown.py | 46 +++-
.../scripts/reporting/report.css | 44 +++-
.../scripts/reporting/summary.py | 6 +-
tests/human-evaluation-quiz-test.py | 61 ++++-
tests/presentation_contract.py | 7 +
tests/report-schema-test.py | 18 ++
11 files changed, 505 insertions(+), 38 deletions(-)
diff --git a/.agents/skills/run-behavior-diff-human-evaluation/scripts/quiz.py b/.agents/skills/run-behavior-diff-human-evaluation/scripts/quiz.py
index 170bfe9..1e4162f 100644
--- a/.agents/skills/run-behavior-diff-human-evaluation/scripts/quiz.py
+++ b/.agents/skills/run-behavior-diff-human-evaluation/scripts/quiz.py
@@ -517,6 +517,11 @@ def _blinded_report(source):
raise ValueError("Unknown Summary disclosure order.")
_shape(details[1], [("summary", None), ("ul", "evidence-limits")])
story_children = _children(story)
+ # A leading status badge states the verdict; it is validated, never projected.
+ if story_children and story_children[0].has_class("summary-status"):
+ if story_children[0].tag != "p":
+ raise ValueError("Unknown summary status.")
+ story_children = story_children[1:]
if (
len(story_children) != 2
or story_children[0].tag != "h2"
@@ -538,10 +543,18 @@ def _blinded_report(source):
_shape(step, [("span", "story-number"), ("div", "story-body")])[1]
for step in steps
]
- evidence_heading, observations, navigation = _shape(
- bodies[3],
- [("h3", None), ("div", "summary-observations"), ("nav", "evidence-nav")],
- )
+ # Check digest and qualification summarize the assessment; drop them but
+ # accept only these exact optional siblings before the observations.
+ evidence_shape = [("h3", None)]
+ for tag, optional in (
+ ("div", "summary-check-digest"),
+ ("p", "summary-qualification"),
+ ):
+ if any(node.has_class(optional) for node in _children(bodies[3])):
+ evidence_shape.append((tag, optional))
+ evidence_shape.extend([("div", "summary-observations"), ("nav", "evidence-nav")])
+ evidence_parts = _shape(bodies[3], evidence_shape)
+ evidence_heading, observations = evidence_parts[0], evidence_parts[-2]
children = _children(observations)
shape = [("p", "summary-provenance"), ("div", "summary-pair")]
if any(node.has_class("primary-result-context") for node in children):
diff --git a/docs/architecture.md b/docs/architecture.md
index 83b8308..14d8384 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -536,9 +536,11 @@ semantic truth, causal attribution, or live extractor adherence.
The default Summary has five numbered steps in one main reading column:
finding; expected versus observed behavior; how tested; supporting evidence;
-and next action. A compact header keeps source/PR links, compared revisions and
-the model visible without a hover. Reading help and the complete task expand
-inline. The other five tabs retain their evidence-navigation responsibilities.
+and next action. A separate status badge precedes the finding. Expected/Observed
+panels explain the comparison without replacing the full criteria table.
+A compact header keeps source/PR links, compared revisions and the model visible
+without a hover. Reading help, complete task, raw setup limits and rejected-check
+diagnostics expand inline. The other five tabs retain their navigation roles.
The finding is derived from validated target checks, not the narrative headline.
An unmet After requirement remains unmet when Before made the same mistake.
@@ -566,6 +568,12 @@ markup. Context must describe evidence, not invent a source conflict. Starts
require complete trial membership; a recorded action before a final answer does
not establish other causal ordering. Missing source evidence does not erase
supported observations, but observations alone do not prove a requirement was met.
+The presentation retains the complete observation subtree rather than replacing
+it with an abstract: Before/After cards, counts, primary outcome, model explanation,
+cautions and attention findings. A separate qualification warns that retained
+model interpretations do not override missing, rejected or partial assessments.
+Deterministic check groups make supported results and unresolved coverage visible
+together; no new model call or authored case-specific finding is used to render.
The blind human-evaluation projection retains only this observation section and
evidence limits. It excludes the assessment-derived headline, expected criteria,
diff --git a/plans/2026-10-19-intention-driven-preparation-implementation.md b/plans/2026-10-19-intention-driven-preparation-implementation.md
index 4939f18..d49c29d 100644
--- a/plans/2026-10-19-intention-driven-preparation-implementation.md
+++ b/plans/2026-10-19-intention-driven-preparation-implementation.md
@@ -643,6 +643,23 @@ not proof of extractor adherence; do not copy their conclusions into report data
invalid extractions remain unavailable; showing audited historical findings
automatically requires a separately approved fresh extraction, not UI editing.
+**Presentation-only acceptance boundary (approved after verification)**
+
+The revised design keeps a concise finding and Expected/Observed framing above
+the full original observations, counts, primary result, explanations, cautions,
+attention findings and assessment checks. Implement it in the production renderer,
+not a permanent authored wrapper. Preserve the raw evidence and qualify conflicting
+model interpretations. Regenerate the saved verification reports without model
+calls, complete deterministic checks and independent review, then obtain the user's
+visual acceptance before recommending merge.
+
+The two approved re-extractions did not establish an issue-found/no-gap pair.
+One assessment was rejected for noncontiguous output quotes and missed a required
+code-check criterion; the other had supported positive results but unresolved
+coverage. Missing source returns limited factual validation. These assessment and
+evidence problems are separate follow-up work, not claims this presentation PR
+resolves. Do not weaken validators or insert manual audit conclusions to pass.
+
Tracking: DRC-4835. The approved prototype is temporary and is not a product route.
### Step-by-step implementation protocol
diff --git a/plugin/skills/behavior-diff/scripts/reporting/content.py b/plugin/skills/behavior-diff/scripts/reporting/content.py
index 18097be..8299a3c 100644
--- a/plugin/skills/behavior-diff/scripts/reporting/content.py
+++ b/plugin/skills/behavior-diff/scripts/reporting/content.py
@@ -7,7 +7,14 @@
from reporting.attention import AttentionFindingData
from reporting.instruction import parse_diff_hunks
from reporting.schema import ContentData, ResultData
-from reporting.summary import PURPOSE_SUMMARY_LIMIT, question_distribution
+from reporting.summary import (
+ CHECK_LABELS,
+ CHECK_STATES,
+ MET_STATES,
+ PURPOSE_SUMMARY_LIMIT,
+ question_distribution,
+ unchecked_goals,
+)
from reporting.target import acceptance, conclusion, evidence_limit_text
TRIAL_EVIDENCE_HEADING = "Trial evidence"
@@ -395,29 +402,233 @@ def task_preview(task):
return text[:SUMMARY_TASK_PREVIEW_LIMIT].rstrip() + "…"
-def summary_method(report):
- """Describe only the recorded setup; absent rationale stays explicitly unrecorded."""
+SUMMARY_TASK_HEADING = "Task"
+SUMMARY_SETUP_HEADING = "Test setup"
+SUMMARY_DATA_HEADING = "Test data and its limits"
+SUMMARY_INSPECTED_HEADING = "What we inspected"
+SUMMARY_CAVEATS_HEADING = "What this does not prove"
+SUMMARY_EXPECTED_LABEL = "Expected"
+SUMMARY_OBSERVED_LABEL = "Observed"
+SUMMARY_IMPLICATION_LABEL = "What this means:"
+SUMMARY_DIAGNOSTICS_LABEL = "Assessment diagnostic"
+SUMMARY_DIGEST_HEADING = "Results by check"
+
+
+def _summary_method_parts(report):
metadata = report.metadata
before, after = report.variants.before, report.variants.after
- lines = [
+ setup = (
"Both versions received the same task and inputs: "
f"{before.total} Before {trial_noun(before.total)} and "
f"{after.total} After {trial_noun(after.total)}, model {metadata.model}.",
f"Instruction file: {metadata.target_file}.",
- DATA_RATIONALE_UNRECORDED,
- ]
+ )
scopes = tuple(dict.fromkeys(check.scope for check in report.summary.checks))
- lines += [f"Evidence inspected: {scope}" for scope in scopes] or [
- "Evidence inspected: " + SUMMARY_NO_CHECKS
- ]
- if report.decisions.recorded_evidence:
- lines.append(
+ inspected = tuple(f"Evidence inspected: {scope}" for scope in scopes) or (
+ "Evidence inspected: " + SUMMARY_NO_CHECKS,
+ )
+ limits = (
+ (
"Recorded evidence limits: "
- + evidence_limit_text(report.decisions.evidence_limits)
+ + evidence_limit_text(report.decisions.evidence_limits),
+ )
+ if report.decisions.recorded_evidence
+ else ()
+ )
+ return setup, inspected, limits
+
+
+def summary_method_sections(report):
+ """The same recorded setup lines, grouped for reading; nothing is added."""
+ setup, inspected, limits = _summary_method_parts(report)
+ return (
+ (SUMMARY_SETUP_HEADING, setup),
+ (SUMMARY_DATA_HEADING, (DATA_RATIONALE_UNRECORDED, *limits)),
+ (SUMMARY_INSPECTED_HEADING, inspected),
+ )
+
+
+def _check_label(state):
+ label = CHECK_LABELS[state]
+ return label[0].upper() + label[1:]
+
+
+def _target_rejected(report):
+ return (
+ report.summary.state == "unavailable"
+ and report.decisions.target_rejection is not None
+ )
+
+
+def _settled(checks):
+ return sum(check.state in MET_STATES for check in checks)
+
+
+def summary_status(report):
+ """A generic status label from the validated finding state, never from prose."""
+ summary = report.summary
+ if summary.state == "unavailable":
+ return (
+ "Not checked: assessment rejected"
+ if _target_rejected(report)
+ else "Not checked"
+ )
+ if summary.state == "inconclusive" and _settled(summary.checks):
+ return "Partly settled"
+ return _check_label(summary.state)
+
+
+def summary_state_text(report):
+ """Return (plain finding text, diagnostic or None); a rejection stays in full."""
+ if _target_rejected(report):
+ return (
+ "The automatic check of the expected behavior did not pass validation, "
+ "so no requirement was assessed. The observations below have not been "
+ "checked against the expected behavior.",
+ report.summary.meaning,
+ )
+ return report.summary.meaning, None
+
+
+def summary_check_groups(report):
+ """Group validated checks by state, in finding order; numbers stay exact."""
+ checks = report.summary.checks
+ return tuple(
+ (
+ state,
+ _check_label(state),
+ tuple(check.criterion for check in checks if check.state == state),
+ )
+ for state in CHECK_STATES
+ if any(check.state == state for check in checks)
+ )
+
+
+def _goal_gap(report):
+ unchecked = unchecked_goals(report.decisions)
+ if not unchecked:
+ return ""
+ noun = "goal" if len(unchecked) == 1 else "goals"
+ return f"No check covers recorded {noun} " + ", ".join(map(str, unchecked)) + "."
+
+
+def summary_observed(report):
+ """Observed facts: validated check counts and the lead extracted comparison."""
+ summary = report.summary
+ lines = []
+ total = len(summary.checks)
+ noun = "check" if total == 1 else "checks"
+ for state, _, numbers in summary_check_groups(report):
+ lines.append(f"{len(numbers)} of {total} {noun}: {CHECK_LABELS[state]}.")
+ if not total:
+ lines.append(
+ "No requirement was assessed: the automatic check was rejected."
+ if _target_rejected(report)
+ else SUMMARY_NO_CHECKS
+ )
+ gap = _goal_gap(report)
+ if gap:
+ lines.append(gap)
+ if summary.decision is not None and (
+ summary.before.choices or summary.after.choices
+ ):
+ row = report.decisions.rows[summary.decision - 1]
+ sides = "; ".join(
+ f"{label}: "
+ + (
+ ", ".join(
+ f"{choice.choice} ({trial_count(choice.count, data.total)})"
+ for choice in data.choices
+ )
+ or NO_EXTRACTED_CHOICE
+ )
+ for label, data in (("Before", summary.before), ("After", summary.after))
+ )
+ incomplete = (
+ " Trial evidence is incomplete." if summary.status == "unavailable" else ""
+ )
+ lines.append(
+ f"Extracted comparison, {source_label(row.anchor, report.metadata.trace_source)}"
+ f" ({row.topic}): {sides}.{incomplete}"
)
return tuple(lines)
+def summary_implication(report):
+ """What the validated finding allows a reader to conclude, decided from states."""
+ summary = report.summary
+ checks = summary.checks
+ state = summary.state
+ if state == "unavailable":
+ return (
+ "No conclusion about the expected behavior is available. A difference "
+ "in the observations is not evidence that the expected behavior was achieved."
+ )
+ if state == "not_met":
+ return (
+ "At least one expected behavior was not met in these tests; other "
+ "supported results do not offset it."
+ )
+ if state == "inconclusive":
+ settled = _settled(checks)
+ unsettled = sum(check.state == "inconclusive" for check in checks)
+ gap = _goal_gap(report)
+ parts = []
+ if unsettled:
+ parts.append(
+ f"{unsettled} {'check is' if unsettled == 1 else 'checks are'} not settled."
+ )
+ if gap:
+ parts.append(gap)
+ lead = (
+ f"{settled} of {len(checks)} checks have a supported result, but this "
+ "is not a pass of every expected behavior."
+ if settled
+ else "No check reached a supported result."
+ )
+ return " ".join((lead, *parts))
+ if state == "baseline_incomplete":
+ return (
+ "After met every check, but incomplete Before results mean no "
+ "improvement is shown."
+ )
+ return (
+ "Every check has a supported result in this scenario. This does not show "
+ "reliability in other situations or that the edit caused the difference."
+ )
+
+
+def summary_qualification(report):
+ """Qualify retained model interpretations whenever validation does not cover them."""
+ summary = report.summary
+ lead = (
+ "Model labels and explanations in this report interpret the extracted "
+ "comparisons; they are retained as written, not endorsed."
+ )
+ if summary.state == "unavailable":
+ reason = (
+ "the automatic check of the expected behavior was rejected"
+ if _target_rejected(report)
+ else "no validated check of the expected behavior is available"
+ )
+ return (
+ f"{lead} Because {reason}, any claim of success or verification in "
+ "them is unconfirmed."
+ )
+ if summary.state in ("not_met", "inconclusive", "baseline_incomplete"):
+ open_checks = [
+ f"{len(numbers)} {CHECK_LABELS[state]}"
+ for state, _, numbers in summary_check_groups(report)
+ if state not in MET_STATES
+ ]
+ detail = f" ({', '.join(open_checks)})" if open_checks else ""
+ return (
+ f"{lead} Only the validated checks are conclusions, and they are "
+ f"incomplete{detail}; claims beyond them are unconfirmed."
+ )
+ return None
+
+
ATTENTION_UNAVAILABLE = "Attention assessment unavailable."
ATTENTION_UNAVAILABLE_NOTE = (
"No valid attention assessment is saved for these trials. "
diff --git a/plugin/skills/behavior-diff/scripts/reporting/render_html.py b/plugin/skills/behavior-diff/scripts/reporting/render_html.py
index 6e2e422..a1ae1ad 100644
--- a/plugin/skills/behavior-diff/scripts/reporting/render_html.py
+++ b/plugin/skills/behavior-diff/scripts/reporting/render_html.py
@@ -934,11 +934,17 @@ def _target_explanation(report):
def _explanation_conclusion(report) -> str:
+ qualification = content.summary_qualification(report)
return (
''
f"{html.escape(content.EXPLANATION_CONCLUSION_LABEL)} "
f"{html.escape(report.summary.headline)} "
'Summary
'
+ + (
+ f'{html.escape(qualification)}
'
+ if qualification
+ else ""
+ )
)
@@ -1118,9 +1124,16 @@ def _short_story_summary(report: ReportData) -> str:
if task
else f'{html.escape(content.SCENARIO_PROMPT_UNAVAILABLE)}
'
)
- method = "".join(
- f"{version_name_html(line)} " for line in content.summary_method(report)
- )
+ method_parts = [
+ f'{html.escape(content.SUMMARY_TASK_HEADING)}
'
+ f"{task_html} "
+ ]
+ for heading, lines in content.summary_method_sections(report):
+ items = "".join(f"{version_name_html(line)} " for line in lines)
+ method_parts.append(
+ f'{html.escape(heading)}
'
+ f'{items}
'
+ )
help_items = "".join(
f"{html.escape(line)} " for line in content.SUMMARY_HELP
)
@@ -1128,37 +1141,82 @@ def _short_story_summary(report: ReportData) -> str:
f'{html.escape(content.SUMMARY_HELP_LABEL)}'
f"
{help_items}
"
)
+ state_text, diagnostic = content.summary_state_text(report)
+ diagnostic_html = (
+ ''
+ f"{html.escape(content.SUMMARY_DIAGNOSTICS_LABEL)}
"
+ f"{version_name_html(diagnostic)}
"
+ if diagnostic
+ else ""
+ )
+ observed = "".join(
+ f"{html.escape(line)} " for line in content.summary_observed(report)
+ )
+ groups = content.summary_check_groups(report)
+ digest = (
+ ''
+ f"{html.escape(content.SUMMARY_DIGEST_HEADING)}
"
+ + "".join(
+ f'- {html.escape(label)} '
+ + ", ".join(
+ f'Check {number}'
+ for number in numbers
+ )
+ + "
"
+ for state, label, numbers in groups
+ )
+ + "
"
+ if groups
+ else ""
+ )
+ qualification_text = content.summary_qualification(report)
+ qualification = (
+ f'{html.escape(qualification_text)}
'
+ if qualification_text
+ else ""
+ )
steps = (
_summary_step(
1,
"story-finding",
content.SUMMARY_FINDING_HEADING,
f''
- f"{version_name_html(summary.meaning)}
",
+ f"{version_name_html(state_text)}{diagnostic_html}",
),
_summary_step(
2,
"story-expectation",
content.SUMMARY_EXPECTATION_HEADING,
+ ''
+ f'{html.escape(content.SUMMARY_EXPECTED_LABEL)}
'
''
f'{html.escape(intent.badge)}'
f"{_info('intent-info', intent.label, f'{html.escape(intent.note)}
')}"
''
- f'{intent_text}
{_summary_checks(report)}',
+ f'{intent_text}
'
+ ''
+ f'{html.escape(content.SUMMARY_OBSERVED_LABEL)}
'
+ f'{observed}
'
+ f'{html.escape(content.SUMMARY_IMPLICATION_LABEL)}'
+ f" {html.escape(content.summary_implication(report))}
"
+ f"{_summary_checks(report)}",
),
_summary_step(
3,
"story-method",
content.SUMMARY_METHOD_HEADING,
- f'{task_html}{method}
'
- f'{notices}
{reading_help}',
+ f'{"".join(method_parts)}'
+ ''
+ f"{html.escape(content.SUMMARY_CAVEATS_HEADING)}
"
+ f'{notices}
{reading_help}',
),
_summary_step(
4,
"story-evidence",
content.SUMMARY_EVIDENCE_HEADING,
+ f"{digest}{qualification}"
''
f'{html.escape(summary.evidence_label)}
'
''
@@ -1185,6 +1243,8 @@ def _short_story_summary(report: ReportData) -> str:
)
return (
''
+ f''
+ f"{html.escape(content.summary_status(report))}
"
f'{html.escape(summary.headline)}
'
f'{"".join(steps)}
'
)
diff --git a/plugin/skills/behavior-diff/scripts/reporting/render_markdown.py b/plugin/skills/behavior-diff/scripts/reporting/render_markdown.py
index 7129f07..849728e 100644
--- a/plugin/skills/behavior-diff/scripts/reporting/render_markdown.py
+++ b/plugin/skills/behavior-diff/scripts/reporting/render_markdown.py
@@ -523,19 +523,38 @@ def _summary_checks_markdown(report):
def _summary_markdown(report):
summary = report.summary
intent = content.summary_intent(report)
+ state_text, diagnostic = content.summary_state_text(report)
markdown = [
"## Summary\n",
+ f"`{_text(content.summary_status(report))}`\n",
f"**{_text(summary.headline)}**\n",
f"### 1. {content.SUMMARY_FINDING_HEADING}\n",
- _text(summary.meaning) + "\n",
+ _text(state_text) + "\n",
+ ]
+ if diagnostic:
+ markdown += [
+ f"{content.SUMMARY_DIAGNOSTICS_LABEL}
\n",
+ _text(diagnostic) + "\n",
+ "\n",
+ ]
+ markdown += [
f"### 2. {content.SUMMARY_EXPECTATION_HEADING}\n",
+ f"#### {content.SUMMARY_EXPECTED_LABEL}\n",
f"**{_text(intent.label)}**\n",
_text(intent.text) + "\n",
_text(intent.note) + "\n",
"[Instruction changes →](#instruction-diff)\n",
+ f"#### {content.SUMMARY_OBSERVED_LABEL}\n",
+ ]
+ markdown += [f"- {_text(line)}" for line in content.summary_observed(report)]
+ markdown += [
+ "",
+ f"**{content.SUMMARY_IMPLICATION_LABEL}** "
+ f"{_text(content.summary_implication(report))}\n",
]
markdown += _summary_checks_markdown(report)
markdown.append(f"### 3. {content.SUMMARY_METHOD_HEADING}\n")
+ markdown.append(f"#### {content.SUMMARY_TASK_HEADING}\n")
if report.content.task:
markdown += [
f"**{content.SUMMARY_TASK_LABEL}:** "
@@ -546,13 +565,31 @@ def _summary_markdown(report):
]
else:
markdown.append(_text(content.SCENARIO_PROMPT_UNAVAILABLE) + "\n")
- markdown += [f"- {_text(line)}" for line in content.summary_method(report)]
- markdown.append("")
+ for heading, lines in content.summary_method_sections(report):
+ markdown.append(f"#### {heading}\n")
+ markdown += [f"- {_text(line)}" for line in lines]
+ markdown.append("")
+ markdown.append(f"#### {content.SUMMARY_CAVEATS_HEADING}\n")
markdown += [f"- {_text(notice)}" for notice in summary.notices]
markdown += ["", f"{content.SUMMARY_HELP_LABEL}
\n"]
markdown += [f"- {_text(line)}" for line in content.SUMMARY_HELP]
markdown += ["", "\n"]
markdown += ["", f"### 4. {content.SUMMARY_EVIDENCE_HEADING}\n"]
+ groups = content.summary_check_groups(report)
+ if groups:
+ markdown.append(f"#### {content.SUMMARY_DIGEST_HEADING}\n")
+ markdown += [
+ f"- **{_text(label)}** "
+ + ", ".join(
+ f"[Check {number}](#{content.target_criterion_anchor(number)})"
+ for number in numbers
+ )
+ for _, label, numbers in groups
+ ]
+ markdown.append("")
+ qualification = content.summary_qualification(report)
+ if qualification:
+ markdown.append(f"> {_text(qualification)}\n")
markdown.append(_text(summary.evidence_label) + "\n")
for side_name in ("before", "after"):
side = getattr(summary, side_name)
@@ -616,6 +653,9 @@ def _explanation_markdown(report):
f"**{content.EXPLANATION_CONCLUSION_LABEL}** {_text(report.summary.headline)} "
"[Summary](#panel-summary)\n",
]
+ qualification = content.summary_qualification(report)
+ if qualification:
+ markdown.append(f"> {_text(qualification)}\n")
complete = content.complete_trial_evidence(report)
if explanation is None or not complete:
notice = (
diff --git a/plugin/skills/behavior-diff/scripts/reporting/report.css b/plugin/skills/behavior-diff/scripts/reporting/report.css
index 891aad9..eea2abe 100644
--- a/plugin/skills/behavior-diff/scripts/reporting/report.css
+++ b/plugin/skills/behavior-diff/scripts/reporting/report.css
@@ -476,7 +476,40 @@ details { margin-bottom:.25rem; }
.target-checks td, .summary-checks td { font-weight:400; }
.target-checks th:first-child, .summary-checks th:first-child { width:40%; }
.summary-state { font-size:18px; line-height:1.5; margin:0; }
-.summary-method { font-size:14px; line-height:1.7; margin:10px 0 0; padding-left:18px; }
+.summary-status { display:inline-block; margin:0 0 12px; padding:3px 9px;
+ border:1px solid currentColor; border-radius:5px; font-size:12px; font-weight:700;
+ color:#8a5b12; }
+.summary-status[data-state="not_met"] { color:#a8312c; }
+.summary-status[data-state="improved"], .summary-status[data-state="preserved"],
+.summary-status[data-state="already_met"] { color:#22654b; }
+.summary-diagnostics { margin-top:10px; font-size:13px; color:#53646d; }
+.summary-diagnostics > summary { font-weight:650; }
+.summary-diagnostics p { margin:6px 0 0; line-height:1.6; }
+.summary-expect-observe { display:grid; grid-template-columns:minmax(0, 1fr) minmax(0, 1fr);
+ border:1px solid #dbe5e8; border-radius:10px; overflow:hidden; }
+.summary-expected, .summary-observed { padding:16px 18px; min-width:0; }
+.summary-expected { background:#f6f9f8; }
+.summary-observed { background:#fffaf0; border-left:1px solid #dbe5e8; }
+.summary-panel-label { margin:0 0 8px; color:#53646d; letter-spacing:.11em;
+ font-size:11px; font-weight:750; text-transform:uppercase; }
+.summary-expected .story-intent { font-size:18px; }
+.summary-observed-list { margin:0; padding-left:18px; font-size:15px; line-height:1.6; }
+.summary-observed-list li + li { margin-top:6px; }
+.summary-implication { font-size:15px; line-height:1.6; margin:16px 0 0;
+ padding-top:14px; border-top:1px solid #dbe5e8; max-width:780px; }
+.summary-method-grid { display:grid; grid-template-columns:minmax(0, 1fr) minmax(0, 1fr);
+ gap:16px 24px; }
+.summary-method-part, .summary-method-caveats { min-width:0; }
+.summary-method-part h4, .summary-method-caveats h4, .summary-check-digest h4 {
+ font-size:14px; margin:0 0 6px; }
+.summary-method-list { font-size:14px; line-height:1.7; margin:0; padding-left:18px; }
+.summary-method-caveats { margin-top:16px; padding-top:12px; border-top:1px solid #dbe5e8; }
+.summary-check-digest ul { list-style:none; margin:0 0 14px; padding:0; font-size:14px;
+ line-height:1.7; }
+.summary-check-digest a { margin-left:4px; }
+.summary-qualification, .explanation-qualification { border-left:3px solid #8a5b12;
+ background:#fff8e9; padding:10px 14px; margin:0 0 14px; font-size:14px;
+ line-height:1.6; max-width:780px; }
.summary-task pre { white-space:pre-wrap; max-height:320px; overflow:auto; }
.summary-attention { margin-top:18px; }
.summary-next-action { font-size:18px; line-height:1.5; margin:0 0 8px; }
@@ -504,7 +537,7 @@ details { margin-bottom:.25rem; }
.summary-notices { font-size:13px; color:#53646d; margin:16px 0 0; padding-left:18px;
line-height:1.7; }
.summary-notices:empty { display:none; }
-.story-method .summary-notices { margin-top:8px; }
+.story-method .summary-notices { margin-top:0; }
.summary-evidence-button { display:inline-flex; align-items:center; gap:13px;
font-size:13px; font-weight:700; background:#173e4a; color:#fff; border-radius:9px;
padding:11px 15px !important; text-decoration:none; }
@@ -529,7 +562,12 @@ details { margin-bottom:.25rem; }
.story-step:not(:last-child)::before { left:15px; top:38px; bottom:6px; }
.story-body > h3, .intent-heading h3 { font-size:15px; }
.story-body > h3, .intent-heading { margin-top:4px; }
- .story-intent { font-size:19px; }
+ .story-intent, .summary-expected .story-intent { font-size:17px; }
+ .summary-expect-observe, .summary-method-grid { grid-template-columns:minmax(0, 1fr); }
+ .summary-observed { border-left:0; border-top:1px solid #dbe5e8; }
+ .summary-expected, .summary-observed { padding:14px 12px; }
+ .summary-checks { min-width:760px; }
+ .summary-checks th, .summary-checks td { overflow-wrap:normal; }
.summary-context { font-size:16px; }
.summary-pair { grid-template-columns:minmax(0, 1fr); gap:8px; }
.summary-arrow { transform:rotate(90deg); line-height:1; font-size:24px; }
diff --git a/plugin/skills/behavior-diff/scripts/reporting/summary.py b/plugin/skills/behavior-diff/scripts/reporting/summary.py
index 42c0c78..0749a00 100644
--- a/plugin/skills/behavior-diff/scripts/reporting/summary.py
+++ b/plugin/skills/behavior-diff/scripts/reporting/summary.py
@@ -64,7 +64,6 @@
"already_met": "Before already met this, so the edit showed no added benefit here.",
"preserved": "Every After trial still met this.",
}
-PRESERVATION_NOT_MET = "At least one After trial no longer met this."
NEXT_ACTIONS = {
"not_met": (
"Revise the edit or its expectation, then retest; inspect the After trials "
@@ -474,7 +473,6 @@ def build_checks(metadata, variants, target):
checks = []
for number, criterion in enumerate(target.criteria if target else (), 1):
state = check_state(criterion, variants)
- preservation = criterion.mode == "preservation"
scope = (
SELF_REPORTED_SCOPE
if criterion.evidence_kind == "recorded_action"
@@ -489,9 +487,7 @@ def build_checks(metadata, variants, target):
state,
question_distribution(criterion, "before", variants.before.total),
question_distribution(criterion, "after", variants.after.total),
- PRESERVATION_NOT_MET
- if preservation and state == "not_met"
- else CHECK_MEANINGS[state],
+ CHECK_MEANINGS[state],
scope,
)
)
diff --git a/tests/human-evaluation-quiz-test.py b/tests/human-evaluation-quiz-test.py
index ce9664b..ea44a28 100755
--- a/tests/human-evaluation-quiz-test.py
+++ b/tests/human-evaluation-quiz-test.py
@@ -341,7 +341,13 @@ def test_actual_synthetic_report_blinding_preserves_wording(self):
"scenario-context",
"scenario-prompt",
"summary-checks",
- "summary-method",
+ "summary-status",
+ "summary-state",
+ "summary-diagnostics",
+ "summary-expect-observe",
+ "summary-method-grid",
+ "summary-method-caveats",
+ "summary-check-digest",
"summary-finding-meaning",
"summary-next-action",
):
@@ -568,6 +574,59 @@ def test_old_summary_shapes_and_wrapper_recovery_are_rejected(self):
"Boundary"
)
+ def test_whitelisted_presentation_nodes_are_validated_but_not_projected(self):
+ source = (self.gallery / "changed-result/report.html").read_text()
+
+ def decorated(extra=None):
+ root = quiz._ReportParser().finish(source)
+ nodes = list(quiz._walk(root))
+ story = quiz._single(
+ [n for n in nodes if n.has_class("short-story-summary")], "story"
+ )
+ body = quiz._children(
+ quiz._single(
+ [n for n in nodes if n.has_class("story-evidence")], "evidence"
+ )
+ )[1]
+ status = quiz._children(story)[0]
+ self.assertTrue(status.has_class("summary-status"))
+ status.children = ["LEAKSTATUS verdict"]
+ parts = [
+ node
+ for node in quiz._children(body)
+ if not node.has_class("summary-check-digest")
+ and not node.has_class("summary-qualification")
+ ]
+ added = [
+ quiz._Node(
+ "div",
+ [("class", "summary-check-digest")],
+ [quiz._Node("h4", children=["LEAKDIGEST checks"])],
+ ),
+ quiz._Node(
+ "p",
+ [("class", "summary-qualification")],
+ ["LEAKQUALIFICATION note"],
+ ),
+ ]
+ if extra is not None:
+ added.insert(0, extra)
+ body.children = [parts[0], *added, *parts[1:]]
+ return root
+
+ with patch.object(quiz._ReportParser, "finish", return_value=decorated()):
+ blinded = quiz._blinded_report(source)
+ for leak in ("LEAKSTATUS", "LEAKDIGEST", "LEAKQUALIFICATION"):
+ self.assertNotIn(leak, blinded)
+ self.assertIn("summary-observations", blinded)
+ self.assertIn("evidence-limits", blinded)
+ unknown = quiz._Node("div", [("class", "summary-other-findings")])
+ with (
+ patch.object(quiz._ReportParser, "finish", return_value=decorated(unknown)),
+ self.assertRaises(ValueError),
+ ):
+ quiz._blinded_report(source)
+
def test_blinding_retains_primary_result_beside_answer_detail_contrast(self):
for scenario in ("answer-details", "non-outcome-narrative", "missing-primary"):
with self.subTest(scenario=scenario):
diff --git a/tests/presentation_contract.py b/tests/presentation_contract.py
index c677528..72f5fec 100644
--- a/tests/presentation_contract.py
+++ b/tests/presentation_contract.py
@@ -14,12 +14,19 @@
def _summary_links(report):
"""Expected per-step links: checks link to criteria; evidence links to detail tabs."""
+ from reporting import content
+
return [
[],
["#instruction-diff"]
+ [f"#target-criterion-{check.criterion}" for check in report.summary.checks],
[],
[
+ f"#target-criterion-{number}"
+ for _, _, numbers in content.summary_check_groups(report)
+ for number in numbers
+ ]
+ + [
"#attention-explanation",
"#panel-explanation",
"#panel-decision",
diff --git a/tests/report-schema-test.py b/tests/report-schema-test.py
index 814fff7..3fb27bc 100755
--- a/tests/report-schema-test.py
+++ b/tests/report-schema-test.py
@@ -855,6 +855,18 @@ def assessed(before="met", after="met", mode=None, variants=None, drop=False):
(assessed(before="not_met", variants=interrupted), "baseline_incomplete"),
(assessed(mode="preservation"), "preserved"),
(assessed(after="not_met", mode="preservation"), "not_met"),
+ (
+ assessed(before="not_met", after="not_met", mode="preservation"),
+ "not_met",
+ ),
+ (
+ assessed(before="uncertain", after="not_met", mode="preservation"),
+ "not_met",
+ ),
+ (
+ assessed(before="unassessable", after="not_met", mode="preservation"),
+ "not_met",
+ ),
(assessed(before="not_met", mode="preservation"), "improved"),
(assessed(before="uncertain", mode="preservation"), "baseline_incomplete"),
(assessed(before="unassessable", mode="preservation"), "baseline_incomplete"),
@@ -871,6 +883,12 @@ def assessed(before="met", after="met", mode=None, variants=None, drop=False):
assert summary.state == state, (state, summary.state)
assert {check.state for check in summary.checks} == {state}, state
assert summary.next_action == finding.NEXT_ACTIONS[state], state
+ if preservation and state == "not_met":
+ # A requested preservation does not establish a previously met baseline.
+ ordinary_failure = assessed(after="not_met").summary
+ assert tuple(check.meaning for check in summary.checks) == tuple(
+ check.meaning for check in ordinary_failure.checks
+ )
answers_only = any(
check.scope == finding.CHECK_SCOPES["output"] for check in summary.checks
)
From cac7425399436a82d3399c4cf97b83cd2fcb5233 Mon Sep 17 00:00:00 2001
From: Kent Huang
Date: Tue, 20 Oct 2026 14:52:28 +0800
Subject: [PATCH 4/6] feat(report): focus summaries on observed changes and
open goals
Signed-off-by: Kent Huang
---
docs/architecture.md | 12 +-
.../scripts/reporting/content.py | 245 ++++++++++++------
.../scripts/reporting/render_html.py | 82 ++++--
.../scripts/reporting/render_markdown.py | 54 +++-
.../scripts/reporting/report.css | 22 +-
tests/human-evaluation-quiz-test.py | 5 +
tests/presentation_contract.py | 13 +-
tests/report-schema-test.py | 141 ++++++++++
tests/target_contract.py | 12 +-
9 files changed, 467 insertions(+), 119 deletions(-)
diff --git a/docs/architecture.md b/docs/architecture.md
index 14d8384..b728461 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -536,13 +536,19 @@ semantic truth, causal attribution, or live extractor adherence.
The default Summary has five numbered steps in one main reading column:
finding; expected versus observed behavior; how tested; supporting evidence;
-and next action. A separate status badge precedes the finding. Expected/Observed
-panels explain the comparison without replacing the full criteria table.
+and next action. A separate validated-result badge precedes a concrete heading
+from the lead extracted comparison's topic and status. An unmet requirement keeps
+the canonical finding prominent; a missing comparison uses that finding as its
+fallback. The comparison heading is not a claim that the change's goal was met.
+The first step pairs the recorded aim with observed choices and counts, then
+identifies unmet, unresolved and uncovered requirements without inventing findings.
+Full checks and original observation detail remain below; technical rejection
+reasons stay in diagnostics rather than dominating the first screen.
A compact header keeps source/PR links, compared revisions and the model visible
without a hover. Reading help, complete task, raw setup limits and rejected-check
diagnostics expand inline. The other five tabs retain their navigation roles.
-The finding is derived from validated target checks, not the narrative headline.
+The assessment conclusion is derived from validated target checks, not narrative prose.
An unmet After requirement remains unmet when Before made the same mistake.
All-met checks distinguish an already-correct baseline from an observed improvement;
an interrupted or uncertain Before cannot prove improvement. Preservation and
diff --git a/plugin/skills/behavior-diff/scripts/reporting/content.py b/plugin/skills/behavior-diff/scripts/reporting/content.py
index 8299a3c..e2ea9e0 100644
--- a/plugin/skills/behavior-diff/scripts/reporting/content.py
+++ b/plugin/skills/behavior-diff/scripts/reporting/content.py
@@ -15,7 +15,12 @@
question_distribution,
unchecked_goals,
)
-from reporting.target import acceptance, conclusion, evidence_limit_text
+from reporting.target import (
+ acceptance,
+ conclusion,
+ effective_purpose,
+ evidence_limit_text,
+)
TRIAL_EVIDENCE_HEADING = "Trial evidence"
FINAL_ANSWER_HEADING = "Final answer"
@@ -383,8 +388,13 @@ def primary_result_context(report):
SUMMARY_TASK_PREVIEW_LIMIT = 280
SUMMARY_HELP_LABEL = "How to read this summary"
SUMMARY_HELP = (
- "Step 1 gives the conclusion, decided only from the validated checks of the "
- "goals recorded before trials and from trial completeness.",
+ "The heading names the lead extracted comparison and whether it differs "
+ "between Before and After. A difference is not evidence that the edit's aim "
+ "was achieved.",
+ "The badge and Step 1 give the result, decided only from the validated checks "
+ "of the goals recorded before trials and from trial completeness. Step 1 also "
+ "shows the edit's aim, the lead observed change, and what remains unknown or "
+ "unmet.",
"Step 2 lists each recorded expectation with its Before and After results. "
"Unclear, unassessable, and not-assessed trials stay separate from Yes and No.",
"Step 3 shows the actual task and setup. Anything not recorded with the run "
@@ -407,8 +417,11 @@ def task_preview(task):
SUMMARY_DATA_HEADING = "Test data and its limits"
SUMMARY_INSPECTED_HEADING = "What we inspected"
SUMMARY_CAVEATS_HEADING = "What this does not prove"
-SUMMARY_EXPECTED_LABEL = "Expected"
-SUMMARY_OBSERVED_LABEL = "Observed"
+SUMMARY_AIM_LABEL = "What the edit aimed at"
+SUMMARY_CHANGE_LABEL = "Observed change"
+SUMMARY_OTHER_CHANGES_LABEL = "Other comparisons that differ"
+SUMMARY_OPEN_LABEL = "Still unknown or unmet"
+SUMMARY_AIM_UNCHECKED = "Whether the edit achieved the aim above"
SUMMARY_IMPLICATION_LABEL = "What this means:"
SUMMARY_DIAGNOSTICS_LABEL = "Assessment diagnostic"
SUMMARY_DIGEST_HEADING = "Results by check"
@@ -453,41 +466,167 @@ def _check_label(state):
return label[0].upper() + label[1:]
-def _target_rejected(report):
- return (
- report.summary.state == "unavailable"
- and report.decisions.target_rejection is not None
- )
-
-
def _settled(checks):
return sum(check.state in MET_STATES for check in checks)
def summary_status(report):
- """A generic status label from the validated finding state, never from prose."""
+ """A generic status label from the validated finding state, never from prose.
+
+ A rejected assessment is a technical failure; its reason stays in diagnostics.
+ """
summary = report.summary
if summary.state == "unavailable":
- return (
- "Not checked: assessment rejected"
- if _target_rejected(report)
- else "Not checked"
- )
+ return "Not checked"
if summary.state == "inconclusive" and _settled(summary.checks):
return "Partly settled"
return _check_label(summary.state)
-def summary_state_text(report):
- """Return (plain finding text, diagnostic or None); a rejection stays in full."""
- if _target_rejected(report):
- return (
- "The automatic check of the expected behavior did not pass validation, "
- "so no requirement was assessed. The observations below have not been "
- "checked against the expected behavior.",
- report.summary.meaning,
+HEADING_PHRASES = {
+ "changed": "changed between versions",
+ "unchanged": "unchanged between versions",
+ "mixed": "mixed across trials",
+ "unavailable": "comparison incomplete",
+}
+
+
+def _lead_row(report):
+ summary = report.summary
+ if summary.decision is None or not (
+ summary.before.choices or summary.after.choices
+ ):
+ return None
+ return report.decisions.rows[summary.decision - 1]
+
+
+def _row_name(row):
+ """An optional extracted topic, else the required extracted decision label."""
+ return row.topic or row.decision
+
+
+def summary_heading(report):
+ """Name the lead extracted comparison and its status; never narrative prose.
+
+ An unmet expectation keeps the canonical finding as the heading, so a changed
+ process cannot outweigh it. Without a lead comparison the finding is used too.
+ """
+ summary = report.summary
+ row = _lead_row(report)
+ if summary.state == "not_met" or row is None:
+ return summary.headline
+ return f"{_row_name(row)}: {HEADING_PHRASES[summary.status]}"
+
+
+def summary_result(report):
+ """Return (result text, diagnostic or None) for Step 1.
+
+ The canonical finding is repeated only when the heading does not already show
+ it. Without validated checks, the assessment's technical reason is diagnostic.
+ """
+ summary = report.summary
+ parts = [] if summary_heading(report) == summary.headline else [summary.headline]
+ if summary.checks:
+ return " ".join((*parts, summary.meaning)), None
+ return " ".join(parts), summary.meaning
+
+
+@dataclass(frozen=True)
+class SummaryChange:
+ scope: str
+ sides: Tuple[Tuple[str, str], ...]
+ note: str
+ others: Tuple[str, ...]
+
+
+def summary_change(report):
+ """The lead extracted comparison, stated once, from extracted choices only."""
+ summary = report.summary
+ row = _lead_row(report)
+ if row is None:
+ return None
+ sides = tuple(
+ (
+ label,
+ "; ".join(
+ f"{choice.choice} ({trial_count(choice.count, data.total)})"
+ for choice in data.choices
+ )
+ or NO_EXTRACTED_CHOICE,
+ )
+ for label, data in (("Before", summary.before), ("After", summary.after))
+ )
+ others = (
+ tuple(
+ _row_name(other)
+ for index, other in enumerate(report.decisions.rows, 1)
+ if index != summary.decision and decision_status(other) == "Changed"
+ )
+ if complete_trial_evidence(report)
+ else ()
+ )
+ return SummaryChange(
+ f"Lead comparison, {source_label(row.anchor, report.metadata.trace_source)}:"
+ f" {_row_name(row)}",
+ sides,
+ "Trial evidence is incomplete." if summary.status == "unavailable" else "",
+ others,
+ )
+
+
+@dataclass(frozen=True)
+class SummaryOpenItem:
+ kind: str
+ label: str
+ text: str
+ criterion: Optional[int]
+
+
+OPEN_LABELS = {
+ "not_met": "Not met",
+ "inconclusive": "Not settled",
+ "baseline_incomplete": "Before incomplete",
+ "unchecked": "No check",
+}
+
+
+def summary_open_items(report):
+ """What the validated checks leave unmet or unknown, unmet first.
+
+ Without validated checks every recorded goal is unknown; goal wording is
+ reused exactly and never selected, shortened, or authored.
+ """
+ summary = report.summary
+ checks = summary.checks
+ items = [
+ SummaryOpenItem(state, OPEN_LABELS[state], check.text, check.criterion)
+ for state in ("not_met", "inconclusive", "baseline_incomplete")
+ for check in checks
+ if check.state == state
+ ]
+ decisions = report.decisions
+ if checks:
+ goals = effective_purpose(decisions.purpose, decisions.intent)
+ numbers = unchecked_goals(decisions)
+ else:
+ goals = decisions.purpose
+ numbers = range(1, len(goals) + 1)
+ if not goals or (
+ len(goals) == 1 and goals[0].text == summary_intent(report).text
+ ):
+ numbers = ()
+ items.append(
+ SummaryOpenItem(
+ "unchecked", OPEN_LABELS["unchecked"], SUMMARY_AIM_UNCHECKED, None
+ )
+ )
+ items += [
+ SummaryOpenItem(
+ "unchecked", OPEN_LABELS["unchecked"], goals[number - 1].text, None
)
- return report.summary.meaning, None
+ for number in numbers
+ ]
+ return tuple(items)
def summary_check_groups(report):
@@ -512,48 +651,6 @@ def _goal_gap(report):
return f"No check covers recorded {noun} " + ", ".join(map(str, unchecked)) + "."
-def summary_observed(report):
- """Observed facts: validated check counts and the lead extracted comparison."""
- summary = report.summary
- lines = []
- total = len(summary.checks)
- noun = "check" if total == 1 else "checks"
- for state, _, numbers in summary_check_groups(report):
- lines.append(f"{len(numbers)} of {total} {noun}: {CHECK_LABELS[state]}.")
- if not total:
- lines.append(
- "No requirement was assessed: the automatic check was rejected."
- if _target_rejected(report)
- else SUMMARY_NO_CHECKS
- )
- gap = _goal_gap(report)
- if gap:
- lines.append(gap)
- if summary.decision is not None and (
- summary.before.choices or summary.after.choices
- ):
- row = report.decisions.rows[summary.decision - 1]
- sides = "; ".join(
- f"{label}: "
- + (
- ", ".join(
- f"{choice.choice} ({trial_count(choice.count, data.total)})"
- for choice in data.choices
- )
- or NO_EXTRACTED_CHOICE
- )
- for label, data in (("Before", summary.before), ("After", summary.after))
- )
- incomplete = (
- " Trial evidence is incomplete." if summary.status == "unavailable" else ""
- )
- lines.append(
- f"Extracted comparison, {source_label(row.anchor, report.metadata.trace_source)}"
- f" ({row.topic}): {sides}.{incomplete}"
- )
- return tuple(lines)
-
-
def summary_implication(report):
"""What the validated finding allows a reader to conclude, decided from states."""
summary = report.summary
@@ -606,14 +703,10 @@ def summary_qualification(report):
"comparisons; they are retained as written, not endorsed."
)
if summary.state == "unavailable":
- reason = (
- "the automatic check of the expected behavior was rejected"
- if _target_rejected(report)
- else "no validated check of the expected behavior is available"
- )
+ # A rejection's technical reason stays in the Step 1 diagnostic.
return (
- f"{lead} Because {reason}, any claim of success or verification in "
- "them is unconfirmed."
+ f"{lead} Because no validated check of the expected behavior is "
+ "available, any claim of success or verification in them is unconfirmed."
)
if summary.state in ("not_met", "inconclusive", "baseline_incomplete"):
open_checks = [
diff --git a/plugin/skills/behavior-diff/scripts/reporting/render_html.py b/plugin/skills/behavior-diff/scripts/reporting/render_html.py
index a1ae1ad..f998cc7 100644
--- a/plugin/skills/behavior-diff/scripts/reporting/render_html.py
+++ b/plugin/skills/behavior-diff/scripts/reporting/render_html.py
@@ -1059,7 +1059,7 @@ def _summary_claim(label: str, claim, css_class: str) -> str:
def _summary_checks(report) -> str:
checks = report.summary.checks
if not checks:
- return f'{html.escape(content.SUMMARY_NO_CHECKS)}
'
+ return ""
rows = "".join(
f''
f''
@@ -1086,6 +1086,55 @@ def _summary_step(number: int, css_class: str, heading: str, body: str) -> str:
)
+def _summary_change(report) -> str:
+ change = content.summary_change(report)
+ if change is None:
+ return f'{html.escape(report.summary.evidence_label)}
'
+ sides = "".join(
+ f"{label}: {html.escape(text)} "
+ for label, text in change.sides
+ )
+ note = (
+ f'{html.escape(change.note)}
'
+ if change.note
+ else ""
+ )
+ others = (
+ ''
+ f"{html.escape(content.SUMMARY_OTHER_CHANGES_LABEL)}: "
+ f"{html.escape(', '.join(change.others))} "
+ 'Behavior diff
'
+ if change.others
+ else ""
+ )
+ return (
+ f'{html.escape(change.scope)}
'
+ f'{sides}
{note}{others}'
+ )
+
+
+def _summary_open(report) -> str:
+ items = content.summary_open_items(report)
+ if not items:
+ return ""
+ rows = "".join(
+ f''
+ f'{html.escape(item.label)} '
+ + (
+ f''
+ f"{html.escape(item.text)}"
+ if item.criterion is not None
+ else html.escape(item.text)
+ )
+ + " "
+ for item in items
+ )
+ return (
+ f'{html.escape(content.SUMMARY_OPEN_LABEL)}
'
+ f'{rows}
'
+ )
+
+
def _short_story_summary(report: ReportData) -> str:
summary = report.summary
intent = content.summary_intent(report)
@@ -1141,7 +1190,7 @@ def _short_story_summary(report: ReportData) -> str:
f'{html.escape(content.SUMMARY_HELP_LABEL)}'
f"
{help_items}
"
)
- state_text, diagnostic = content.summary_state_text(report)
+ result_text, diagnostic = content.summary_result(report)
diagnostic_html = (
''
f"{html.escape(content.SUMMARY_DIAGNOSTICS_LABEL)}
"
@@ -1149,8 +1198,11 @@ def _short_story_summary(report: ReportData) -> str:
if diagnostic
else ""
)
- observed = "".join(
- f"{html.escape(line)} " for line in content.summary_observed(report)
+ result_html = (
+ f''
+ f"{version_name_html(result_text)}
"
+ if result_text
+ else ""
)
groups = content.summary_check_groups(report)
digest = (
@@ -1180,15 +1232,9 @@ def _short_story_summary(report: ReportData) -> str:
1,
"story-finding",
content.SUMMARY_FINDING_HEADING,
- f''
- f"{version_name_html(state_text)}
{diagnostic_html}",
- ),
- _summary_step(
- 2,
- "story-expectation",
- content.SUMMARY_EXPECTATION_HEADING,
+ f"{result_html}"
''
- f'{html.escape(content.SUMMARY_EXPECTED_LABEL)}
'
+ f'{html.escape(content.SUMMARY_AIM_LABEL)}
'
''
f'{html.escape(intent.badge)}'
f"{_info('intent-info', intent.label, f'{html.escape(intent.note)}
')}"
@@ -1197,8 +1243,14 @@ def _short_story_summary(report: ReportData) -> str:
''
f'{intent_text}
'
''
- f'{html.escape(content.SUMMARY_OBSERVED_LABEL)}
'
- f'{observed}
'
+ f'{html.escape(content.SUMMARY_CHANGE_LABEL)}
'
+ f"{_summary_change(report)}"
+ f"{_summary_open(report)}{diagnostic_html}",
+ ),
+ _summary_step(
+ 2,
+ "story-expectation",
+ content.SUMMARY_EXPECTATION_HEADING,
f'{html.escape(content.SUMMARY_IMPLICATION_LABEL)}'
f" {html.escape(content.summary_implication(report))}
"
f"{_summary_checks(report)}",
@@ -1245,7 +1297,7 @@ def _short_story_summary(report: ReportData) -> str:
''
f''
f"{html.escape(content.summary_status(report))}
"
- f'{html.escape(summary.headline)}
'
+ f'{html.escape(content.summary_heading(report))}
'
f'{"".join(steps)}
'
)
diff --git a/plugin/skills/behavior-diff/scripts/reporting/render_markdown.py b/plugin/skills/behavior-diff/scripts/reporting/render_markdown.py
index 849728e..449b233 100644
--- a/plugin/skills/behavior-diff/scripts/reporting/render_markdown.py
+++ b/plugin/skills/behavior-diff/scripts/reporting/render_markdown.py
@@ -505,7 +505,7 @@ def _target_explanation_markdown(report):
def _summary_checks_markdown(report):
checks = report.summary.checks
if not checks:
- return [_text(content.SUMMARY_NO_CHECKS) + "\n"]
+ return []
lines = [
f"| Expected | Before | After | What this means | {content.SUMMARY_SCOPE_HEADING} |",
"| --- | --- | --- | --- | --- |",
@@ -523,14 +523,50 @@ def _summary_checks_markdown(report):
def _summary_markdown(report):
summary = report.summary
intent = content.summary_intent(report)
- state_text, diagnostic = content.summary_state_text(report)
+ result_text, diagnostic = content.summary_result(report)
markdown = [
"## Summary\n",
f"`{_text(content.summary_status(report))}`\n",
- f"**{_text(summary.headline)}**\n",
+ f"**{_text(content.summary_heading(report))}**\n",
f"### 1. {content.SUMMARY_FINDING_HEADING}\n",
- _text(state_text) + "\n",
]
+ if result_text:
+ markdown.append(_text(result_text) + "\n")
+ markdown += [
+ f"#### {content.SUMMARY_AIM_LABEL}\n",
+ f"**{_text(intent.label)}**\n",
+ _text(intent.text) + "\n",
+ _text(intent.note) + "\n",
+ "[Instruction changes →](#instruction-diff)\n",
+ f"#### {content.SUMMARY_CHANGE_LABEL}\n",
+ ]
+ change = content.summary_change(report)
+ if change is None:
+ markdown.append(_text(summary.evidence_label) + "\n")
+ else:
+ markdown.append(_text(change.scope) + "\n")
+ markdown += [f"- **{label}:** {_text(text)}" for label, text in change.sides]
+ markdown.append("")
+ if change.note:
+ markdown.append(_text(change.note) + "\n")
+ if change.others:
+ markdown.append(
+ f"**{content.SUMMARY_OTHER_CHANGES_LABEL}:** "
+ f"{_text(', '.join(change.others))} [Behavior diff →](#panel-decision)\n"
+ )
+ open_items = content.summary_open_items(report)
+ if open_items:
+ markdown.append(f"#### {content.SUMMARY_OPEN_LABEL}\n")
+ markdown += [
+ f"- **{_text(item.label)}:** "
+ + (
+ f"[{_text(item.text)}](#{content.target_criterion_anchor(item.criterion)})"
+ if item.criterion is not None
+ else _text(item.text)
+ )
+ for item in open_items
+ ]
+ markdown.append("")
if diagnostic:
markdown += [
f"{content.SUMMARY_DIAGNOSTICS_LABEL}
\n",
@@ -539,16 +575,6 @@ def _summary_markdown(report):
]
markdown += [
f"### 2. {content.SUMMARY_EXPECTATION_HEADING}\n",
- f"#### {content.SUMMARY_EXPECTED_LABEL}\n",
- f"**{_text(intent.label)}**\n",
- _text(intent.text) + "\n",
- _text(intent.note) + "\n",
- "[Instruction changes →](#instruction-diff)\n",
- f"#### {content.SUMMARY_OBSERVED_LABEL}\n",
- ]
- markdown += [f"- {_text(line)}" for line in content.summary_observed(report)]
- markdown += [
- "",
f"**{content.SUMMARY_IMPLICATION_LABEL}** "
f"{_text(content.summary_implication(report))}\n",
]
diff --git a/plugin/skills/behavior-diff/scripts/reporting/report.css b/plugin/skills/behavior-diff/scripts/reporting/report.css
index eea2abe..cda4d10 100644
--- a/plugin/skills/behavior-diff/scripts/reporting/report.css
+++ b/plugin/skills/behavior-diff/scripts/reporting/report.css
@@ -475,7 +475,7 @@ details { margin-bottom:.25rem; }
.target-checks th, .summary-checks th { color:var(--muted); font-size:12px; font-weight:600; }
.target-checks td, .summary-checks td { font-weight:400; }
.target-checks th:first-child, .summary-checks th:first-child { width:40%; }
-.summary-state { font-size:18px; line-height:1.5; margin:0; }
+.summary-state { font-size:18px; line-height:1.5; margin:0 0 16px; max-width:780px; }
.summary-status { display:inline-block; margin:0 0 12px; padding:3px 9px;
border:1px solid currentColor; border-radius:5px; font-size:12px; font-weight:700;
color:#8a5b12; }
@@ -493,10 +493,22 @@ details { margin-bottom:.25rem; }
.summary-panel-label { margin:0 0 8px; color:#53646d; letter-spacing:.11em;
font-size:11px; font-weight:750; text-transform:uppercase; }
.summary-expected .story-intent { font-size:18px; }
-.summary-observed-list { margin:0; padding-left:18px; font-size:15px; line-height:1.6; }
-.summary-observed-list li + li { margin-top:6px; }
-.summary-implication { font-size:15px; line-height:1.6; margin:16px 0 0;
- padding-top:14px; border-top:1px solid #dbe5e8; max-width:780px; }
+.summary-change-scope { margin:0 0 6px; font-size:13px; color:#53646d; font-weight:650; }
+.summary-change-sides { margin:0; padding-left:18px; font-size:16px; line-height:1.55; }
+.summary-change-sides li + li { margin-top:4px; }
+.summary-change-note, .summary-other-changes { margin:10px 0 0; font-size:14px;
+ line-height:1.55; }
+.summary-open { margin:16px 0 0; max-width:900px; }
+.summary-open h4 { font-size:14px; margin:0 0 8px; }
+.summary-open-list { list-style:none; margin:0; padding:0; font-size:15px; line-height:1.55; }
+.summary-open-list li { padding:8px 0; border-top:1px solid #dbe5e8; }
+.summary-open-tag { display:inline-block; margin-right:6px; padding:1px 7px;
+ border:1px solid currentColor; border-radius:5px; font-size:11px; font-weight:750;
+ color:#8a5b12; }
+.summary-open-list li[data-kind="not_met"] .summary-open-tag { color:#a8312c; }
+.summary-open-list li[data-kind="not_met"] { font-weight:650; }
+.summary-open-list li[data-kind="unchecked"] .summary-open-tag { color:#53646d; }
+.summary-implication { font-size:15px; line-height:1.6; margin:0; max-width:780px; }
.summary-method-grid { display:grid; grid-template-columns:minmax(0, 1fr) minmax(0, 1fr);
gap:16px 24px; }
.summary-method-part, .summary-method-caveats { min-width:0; }
diff --git a/tests/human-evaluation-quiz-test.py b/tests/human-evaluation-quiz-test.py
index ea44a28..827d5ed 100755
--- a/tests/human-evaluation-quiz-test.py
+++ b/tests/human-evaluation-quiz-test.py
@@ -345,6 +345,11 @@ def test_actual_synthetic_report_blinding_preserves_wording(self):
"summary-state",
"summary-diagnostics",
"summary-expect-observe",
+ "summary-change-scope",
+ "summary-change-sides",
+ "summary-change-note",
+ "summary-other-changes",
+ "summary-open",
"summary-method-grid",
"summary-method-caveats",
"summary-check-digest",
diff --git a/tests/presentation_contract.py b/tests/presentation_contract.py
index 72f5fec..56c102e 100644
--- a/tests/presentation_contract.py
+++ b/tests/presentation_contract.py
@@ -13,13 +13,20 @@
def _summary_links(report):
- """Expected per-step links: checks link to criteria; evidence links to detail tabs."""
+ """Expected per-step links: aim to the diff, open goals and checks to criteria,
+ other differing comparisons and evidence to detail tabs."""
from reporting import content
+ change = content.summary_change(report)
return [
- [],
["#instruction-diff"]
- + [f"#target-criterion-{check.criterion}" for check in report.summary.checks],
+ + (["#panel-decision"] if change is not None and change.others else [])
+ + [
+ f"#target-criterion-{item.criterion}"
+ for item in content.summary_open_items(report)
+ if item.criterion is not None
+ ],
+ [f"#target-criterion-{check.criterion}" for check in report.summary.checks],
[],
[
f"#target-criterion-{number}"
diff --git a/tests/report-schema-test.py b/tests/report-schema-test.py
index 3fb27bc..daa1c94 100755
--- a/tests/report-schema-test.py
+++ b/tests/report-schema-test.py
@@ -958,6 +958,147 @@ def assessed(before="met", after="met", mode=None, variants=None, drop=False):
# Model narrative never sets the conclusion.
assert unavailable.headline == finding.FINDING_HEADLINES["unavailable"]
+ assert_goal_focus(report, assessed, uncovered, missing)
+
+
+def _focus_markup(page):
+ """Return (status, heading, finding-step plain text, open kinds) from HTML."""
+ from attention_contract import _ReportMarkup, _plain
+
+ markup = _ReportMarkup(page)
+ summary = markup.select(anchor="panel-summary")[0]
+ status = markup.select(summary, class_name="summary-status")[0]
+ heading = markup.select(summary, class_name="summary-headline")[0]
+ step = markup.select(summary, class_name="story-finding")[0]
+ kinds = [
+ entry["attrs"].get("data-kind")
+ for listing in markup.select(step, class_name="summary-open-list")
+ for entry in markup.select(listing, tag="li")
+ ]
+ return status, _plain(heading).strip(), _plain(step), kinds
+
+
+def assert_goal_focus(report, assessed, uncovered, missing):
+ """The first screen names the change, the result and the open requirement.
+
+ The presentation heading may differ from the canonical finding headline, but
+ the badge state, open items and diagnostics must follow validated state only.
+ """
+ from reporting.render_html import render_artifact
+ from reporting.render_markdown import render_markdown
+ from reporting.summary import build_summary
+ from presentation_contract import visible_text as visible
+
+ def rebuilt(decisions):
+ return replace(
+ report,
+ decisions=decisions,
+ summary=build_summary(report.metadata, report.variants, decisions),
+ )
+
+ def check(value):
+ summary = value.summary
+ heading = content.summary_heading(value)
+ text, diagnostic = content.summary_result(value)
+ items = content.summary_open_items(value)
+ html_page = render_artifact(value, "")
+ status, shown, step, kinds = _focus_markup(html_page)
+ assert status["attrs"].get("data-state") == summary.state
+ assert content.summary_status(value) in visible(html_page)
+ assert shown == heading
+ # The canonical finding stays visible even when a presentation heading leads.
+ for page in (visible(html_page), visible(render_markdown(value))):
+ assert heading in page and summary.headline in page
+ if diagnostic:
+ assert diagnostic in page
+ assert kinds == [item.kind for item in items]
+ for item in items:
+ assert item.text in step
+ return summary, heading, text, diagnostic, items
+
+ # Lead comparison changed, but no validated target: the change is shown,
+ # the result stays "not checked" and never reads as a pass.
+ # The fixture's lead is unchanged; rename only its After choices so the same
+ # trial memberships now record a different answer.
+ lead = rebuilt(replace(missing, narrative=None)).summary.decision
+ assert lead is not None
+ rows = list(missing.rows)
+ rows[lead - 1] = replace(
+ rows[lead - 1],
+ after=tuple(
+ replace(choice, choice="Synthetic changed answer " + str(number))
+ for number, choice in enumerate(rows[lead - 1].after, 1)
+ ),
+ )
+ # Narrative labels describe the old choices; the conclusion never uses them.
+ missing = replace(missing, rows=tuple(rows), narrative=None)
+ unavailable = rebuilt(missing)
+ assert unavailable.summary.decision == lead
+ summary, heading, text, diagnostic, items = check(unavailable)
+ row = unavailable.decisions.rows[summary.decision - 1]
+ assert summary.state == "unavailable" and not summary.checks
+ assert content.decision_status(row) == "Changed"
+ assert row.topic in heading and heading != summary.headline
+ change = content.summary_change(unavailable)
+ assert change is not None and row.topic in change.scope
+ for (_, side_text), side in zip(change.sides, (row.before, row.after)):
+ assert all(choice.choice in side_text for choice in side)
+ assert diagnostic == summary.meaning and summary.meaning not in text
+ assert items and {item.kind for item in items} == {"unchecked"}
+ assert all(item.criterion is None for item in items)
+ rejected = rebuilt(replace(missing, target_rejection="synthetic reason"))
+ assert content.summary_status(rejected) == content.summary_status(unavailable)
+ assert "synthetic reason" in check(rejected)[3]
+ # An optional topic may be blank; the heading then names the required
+ # extracted decision label, never an empty or generic subject.
+ untitled_rows = list(missing.rows)
+ untitled_rows[lead - 1] = replace(untitled_rows[lead - 1], topic="")
+ untitled = rebuilt(replace(missing, rows=tuple(untitled_rows)))
+ untitled_heading = check(untitled)[1]
+ untitled_row = untitled.decisions.rows[lead - 1]
+ assert untitled_row.decision.strip()
+ assert untitled_heading.startswith(untitled_row.decision)
+ assert untitled_heading != untitled.summary.headline
+
+ # A process change never hides a missed goal: the finding leads verbatim and
+ # every missed criterion is named and linked to its check.
+ failed = assessed(after="not_met")
+ summary, heading, text, diagnostic, items = check(failed)
+ assert summary.state == "not_met" and heading == summary.headline
+ assert summary.meaning in text and diagnostic is None
+ missed = [item for item in items if item.kind == "not_met"]
+ assert [item.criterion for item in missed] == [
+ number
+ for number, check_ in enumerate(summary.checks, 1)
+ if check_.state == "not_met"
+ ]
+ page = render_artifact(failed, "")
+ for item in missed:
+ assert f'href="#{content.target_criterion_anchor(item.criterion)}"' in page
+
+ # Met checks plus an unchecked goal: only the unchecked goal stays open.
+ met = assessed().decisions
+ partial = rebuilt(replace(met, purpose=uncovered.purpose))
+ summary, heading, text, diagnostic, items = check(partial)
+ assert summary.state == "inconclusive"
+ assert any(c.state in content.MET_STATES for c in summary.checks)
+ assert [item.text for item in items if item.kind == "unchecked"] == [
+ uncovered.purpose[-1].text
+ ]
+ assert all(
+ item.criterion is None
+ or summary.checks[item.criterion - 1].state not in content.MET_STATES
+ for item in items
+ )
+
+ # No lead comparison and no recorded purpose: an honest fallback, not a pass.
+ bare = rebuilt(replace(missing, purpose=(), rows=(), narrative=None, outcome=None))
+ summary, heading, text, diagnostic, items = check(bare)
+ assert summary.state == "unavailable"
+ assert heading == summary.headline
+ assert content.summary_change(bare) is None
+ assert [item.text for item in items] == [content.SUMMARY_AIM_UNCHECKED]
+
def assert_render_import_safe():
render_path = scripts / "render.py"
diff --git a/tests/target_contract.py b/tests/target_contract.py
index a5f2c3a..d199223 100644
--- a/tests/target_contract.py
+++ b/tests/target_contract.py
@@ -220,9 +220,15 @@ def variant(report, purpose, purpose_summary):
assert item.reference not in summary_text, (
f"{name}: Summary repeats detailed goal provenance."
)
- if shown != [item.text]:
- # No truncation, selection, or full repetition of a goal.
- assert item.text[:60] not in summary_text, name
+ # Open questions may name a whole uncovered goal, but a goal
+ # is never shown truncated.
+ if item.text[:60] in summary_text:
+ assert item.text in summary_text, name
+ if shown != [item.text]:
+ # The short aim never truncates, selects, or repeats a goal.
+ assert all(
+ item.text[:60] not in aim for aim in surfaces["intent"]
+ ), name
for instruction_text in surfaces["instruction"]:
assert item.text in instruction_text, (
f"{name}: Instruction changes lost a full recorded goal."
From ed9aaef00435f498fc2943971e3c442dc3751108 Mon Sep 17 00:00:00 2001
From: Kent Huang
Date: Tue, 20 Oct 2026 15:42:54 +0800
Subject: [PATCH 5/6] fix: carry frozen scenario checks into assessment reports
Signed-off-by: Kent Huang
---
README.md | 29 +-
bin/behavior-diff | 12 +-
docs/architecture.md | 62 ++-
...ntion-driven-preparation-implementation.md | 36 +-
plugin/skills/behavior-diff/SKILL.md | 60 +--
.../references/scenario-and-test-data.md | 108 +++--
.../behavior-diff/scripts/behavior-diff.sh | 40 +-
.../skills/behavior-diff/scripts/decisions.py | 77 +++-
.../scripts/reporting/content.py | 134 +++++-
.../scripts/reporting/evidence.py | 5 +-
.../behavior-diff/scripts/reporting/load.py | 55 ++-
.../scripts/reporting/render_html.py | 39 +-
.../scripts/reporting/render_markdown.py | 28 +-
.../scripts/reporting/report.css | 6 +-
.../scripts/reporting/scenario.py | 89 ++++
.../behavior-diff/scripts/reporting/schema.py | 12 +-
.../behavior-diff/scripts/reporting/target.py | 61 ++-
.../skills/behavior-diff/scripts/scenario.py | 61 +++
tests/hooks-test.sh | 79 +++-
tests/recorded-evidence-test.py | 47 ++-
tests/report-ci-context-test.py | 2 +-
tests/report-schema-test.py | 382 +++++++++++++++++-
tests/report_fixtures.py | 29 +-
tests/scenario-contract-test.py | 195 +++++++++
tests/shared-data-test.py | 22 +-
tests/target_contract.py | 213 ++++++++++
26 files changed, 1707 insertions(+), 176 deletions(-)
create mode 100644 plugin/skills/behavior-diff/scripts/reporting/scenario.py
create mode 100755 plugin/skills/behavior-diff/scripts/scenario.py
create mode 100755 tests/scenario-contract-test.py
diff --git a/README.md b/README.md
index 78e03a2..2a92cfe 100644
--- a/README.md
+++ b/README.md
@@ -210,6 +210,30 @@ without an assessment. No output does not mean stuck. When local process identit
cannot be checked, status stays explicitly uncertain. Older runs without progress
metadata still have their reports.
+### Explain the scenario and freeze its checks
+
+The skill prepares a reviewed scenario record alongside purpose before launching
+trials. It describes the task, why the selected data tests the change assumption,
+the necessary conditions, the limits, and separate observable checks for each goal.
+
+```bash
+behavior-diff.sh --agent claude --file SKILL.md --task "" \
+ --purpose-file --scenario-file
+```
+
+`--scenario-file` requires purpose. The runner validates both before any model call
+and excludes them from both trial copies. Only the sanitized scenario record goes
+to assessment and the report; the private preparation notes do not. See the
+[record contract](plugin/skills/behavior-diff/references/scenario-and-test-data.md)
+for its fields and privacy boundary.
+
+The assessor must evaluate every frozen check without changing its definition.
+Reading a PR description and checking the implementation are separate checks;
+one cannot stand in for the other. A missing or rejected assessment leaves those
+checks visible as **Not assessed**, not passed. Runs without a scenario record
+show missing setup information explicitly and identify any criteria written by
+the assessor after the trials.
+
### Report wording
Behavior Diff applies installed Humanizer prose guidance while generating
@@ -431,7 +455,7 @@ Expected behavior is labeled as supplied, or explicitly unavailable. The evidenc
limits state trial counts, provenance, and grading limits. Self-reported actions
remain distinct from captured tool calls.
-The structured `report-data.json` uses schema version 17, including per-group
+The structured `report-data.json` uses schema version 18, including per-group
`decisions.trial_summaries`, assessment-derived `summary.state`, `summary.meaning`,
`summary.checks` and `summary.next_action`, sourced instruction `intent`,
saved `content.task`, optional narrative, nullable `metadata.source` and
@@ -439,6 +463,9 @@ saved `content.task`, optional narrative, nullable `metadata.source` and
a rejected target assessment without dropping the underlying evidence. The optional
`decisions.purpose_summary` gives the Summary a short, labeled paraphrase of
the frozen goals; Instruction changes retains their complete text and provenance.
+`decisions.scenario_record` carries the validated setup and frozen checks from run
+configuration. The Summary explains the test rationale and conditions even when
+the automatic assessment is unavailable. These expectations are not trial evidence.
The task comes from the run's `task.md`, or the capsule's copy
when absent from the run. A scenario description never replaces missing task text.
Regenerate reports from their original run artifacts;
diff --git a/bin/behavior-diff b/bin/behavior-diff
index 0dba1a4..9dad1f2 100755
--- a/bin/behavior-diff
+++ b/bin/behavior-diff
@@ -3,7 +3,7 @@
#
# behavior-diff RULE.md --task "..." [--into FILE] [--fast | --trials N]
# [--agent claude|codex|pi|omp] [--model NAME]
-# [--purpose-file JSON] [--dry-run]
+# [--purpose-file JSON] [--scenario-file JSON] [--dry-run]
#
# The underlying runner compares the repo at HEAD against the repo plus one
# uncommitted instruction-file edit. This front door makes that edit for you:
@@ -52,16 +52,16 @@ while [[ $# -gt 0 ]]; do
passthru+=("$1")
shift
;;
- --purpose-file)
+ --purpose-file | --scenario-file)
[[ $# -ge 2 ]] || die "$1 needs a value"
# Anchor to the caller before changing directories; keep the leaf intact
# so the runner can still reject a supplied symlink.
- purpose_path=$2
- case $purpose_path in
+ context_path=$2
+ case $context_path in
/*) ;;
- *) purpose_path=$PWD/$purpose_path ;;
+ *) context_path=$PWD/$context_path ;;
esac
- passthru+=("$1" "$purpose_path")
+ passthru+=("$1" "$context_path")
shift 2
;;
--trials | --agent | --model | --extract-agent | --extract-model)
diff --git a/docs/architecture.md b/docs/architecture.md
index b728461..c84e9f1 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -22,8 +22,8 @@ scripts handle execution, evidence, and reporting.
| Change intent + preservation -> capabilities/premises |
| -> neutral task + ordinary/bounded edge data + private |
| case criteria; reuse sufficient project data |
-| Preflight host environment; freeze task, data, purpose, |
-| source revisions, criteria, and execution settings |
+| Review scenario record + checks from private notes; |
+| freeze task, data, purpose, record, sources + settings |
| Shared files or exclusions: prepare_shared.py stages |
| and verifies; otherwise runner uses user's project |
+----------------------------+----------------------------+
@@ -41,7 +41,7 @@ scripts handle execution, evidence, and reporting.
v
+---------------------------------------------------------+
| Evidence analysis |
-| decisions.py: purpose + calls/returns + trial answers |
+| decisions.py: frozen checks + calls/returns + answers |
| Validate source/output claims and native completion |
| reporting/: derive scoped assessment + linked evidence |
+----------------------------+----------------------------+
@@ -74,9 +74,10 @@ Assessment producer ----> assessment-status.json
Trial execution and decision extraction call models through agent CLIs.
Report assembly and rendering run locally without model calls.
-Purpose travels in run configuration directly to analysis, never in the trial
-task or project copies. Recorded tool calls and returns travel from trials to
-analysis; the collector does not retrieve current source files.
+Purpose and the reviewed scenario record travel in run configuration directly to
+analysis, never in the trial task or project copies. Raw preparation notes remain
+private. Recorded tool calls and returns travel from trials to analysis; the
+collector does not retrieve current source files.
Historical source provenance takes a separate path: the skill verifies the
original repository, revision pair, and any PR/MR associations, then passes
@@ -297,9 +298,13 @@ For example, a tracked instruction edit produces this initial state:
The run keeps `task.md` and `config.json` at its root. Each `before-N/` and
`after-N/` directory contains `project/`, `trace.jsonl`, and `stderr.log`.
-The configuration records the target path, scenario, Before/After labels, and
-validated `purpose`. `--purpose-file` is frozen before trials and excluded from
-trial copies, including when supplied from within the source project.
+The configuration records the target path, task description, Before/After labels,
+validated `purpose`, and nullable `scenario_record`. `--scenario-file` requires
+purpose and is validated by `scenario.py` / `reporting/scenario.py` before model
+discovery. Both context files are frozen before trials and excluded from trial
+copies, including when supplied from within the source project. The scenario
+record holds a summary, rationale, conditions, limitations and goal-linked checks;
+it cannot contain trial outcomes. Every goal must have a check.
Uncommitted setup files do not enter Git-based trials. Required task state must
already exist in the base project, in a deliberately prepared fixture, or in a
shared-data stage.
@@ -311,8 +316,8 @@ The private helper
delivers a reviewed data directory to every trial without changing the user's
checkout or adding runner options. It only delivers and verifies: the skill
designs the data, and the helper does not generate data, launch models, or infer
-provenance. Its freeze covers the stage, not the private task, purpose, source
-metadata, or tooling; the skill checks those separately before launch.
+provenance. Its freeze covers the stage, not the private task, purpose, scenario
+record, source metadata, or tooling; the skill checks those separately before launch.
```text
Project base (Git HEAD or folder copy) --+
@@ -342,7 +347,7 @@ existing runner then builds every trial from the scratch commit, unchanged.
The helper rejects links, special files, Git internals, submodules, unsafe or
colliding paths, data that replaces the instruction or an excluded path, and
inputs over `BEHAVIOR_DIFF_COPY_CAP_KB`. The caller excludes in-project purpose,
-source, and evaluator files with `--exclude`. `prepare` also requires
+scenario, source, and evaluator files with `--exclude`. `prepare` also requires
`git archive` of the scratch commit to match the expected files. Therefore,
`.gitattributes` rules such as `export-ignore`, `export-subst`, or filters that
change archived files fail before launch.
@@ -414,11 +419,13 @@ The modules below live in the skill's [`scripts/`](../plugin/skills/behavior-dif
saved output or post-edit HTML.
Before returning the extraction JSON, the extractor refines authored questions,
labels, explanations, and findings into concrete, case-specific language.
- Questions name the actual action or claim and its relevant condition or limit;
- conclusions state what each side did and what cannot be confirmed. This pass
- is required with or without Humanizer, within the existing extraction call.
- It preserves criterion scope and polarity, canonical label matches, evidence
- qualifiers, and exact excerpts; it does not rewrite rendered artifacts.
+ Frozen scenario checks are excluded from this prose refinement: their IDs,
+ goals, wording, modes and evidence requirements must remain exact. For those
+ checks the model supplies only row links and per-trial outcomes. Validation
+ rejects missing, extra or rewritten checks even when every goal still has
+ another criterion. Without a record, criteria are assessor-written after the
+ trials and labeled accordingly. Refinement preserves evidence qualifiers and
+ exact excerpts; it does not rewrite rendered artifacts.
Discovery reads only `humanizer/SKILL.md` in the invoking repository's skill
roots, then the user's. Claude checks `.claude/skills` before `.agents/skills`
at each scope; other extraction hosts use `.agents/skills`. Emitted prompts
@@ -445,6 +452,9 @@ The modules below live in the skill's [`scripts/`](../plugin/skills/behavior-dif
duplicate, self-reported and overflow records do not supply determinate proof.
`decisions.target_rejection` retains a rejected criterion's validation reason;
extraction preserves the raw response and reports `available/target_rejected`.
+ With a scenario record, the complete frozen check set is mandatory. The report
+ loader binds it to configuration, not model output; a conflicting persisted
+ record or purpose invalidates the assessment without dropping observations.
`reporting/summary.py` derives scoped findings from validated checks and native
trial health. It distinguishes unmet requirements, no added benefit, preservation,
incomplete baselines and inconclusive outcomes. Incomplete records remain in
@@ -469,13 +479,14 @@ The modules below live in the skill's [`scripts/`](../plugin/skills/behavior-dif
unmet outcome on both sides, labeled as a pre-existing target problem.
Counts come from canonical rows. Malformed bundles become unavailable, never
an assessed-empty result. No finding quota applies.
- These modules build schema-v17 `ReportData`, including nullable
+ These modules build schema-v18 `ReportData`, including nullable
`metadata.source` and `metadata.ci_context`. Older versions are
rejected; regenerate reports from their saved run evidence with `render.py`.
Saved decisions without optional context, flow starts, or a purpose summary
still render, without inventing those explanations.
- Purpose, bounded evidence, limits, and assessments persist with decisions and
- report data; rendering does not reread current source files or infer new claims.
+ Purpose, scenario records, bounded evidence, limits, and assessments persist
+ with decisions and report data; rendering does not reread current source files
+ or infer new claims.
Decision branches also retain validated trial memberships: exact coverage of
completed trials on each side, matching counts, and no duplicate or foreign
identities. Legacy aggregate-only branches show unavailable attribution;
@@ -498,6 +509,10 @@ Codex keeps the raw shell wrapper alongside its command-flow display text.
The collector structurally decodes recognized shell command arguments without
execution; wrapped simple reads remain eligible, while compound commands,
redirections, substitutions, and non-read commands remain excluded.
+Recorded `gh pr view` and `gh pr diff` returns are eligible through supported
+shell/execution adapters, including namespaced `exec` tools. Mutation commands,
+compound commands, errors and sensitive content remain excluded. This only selects
+already-recorded text; it never invokes `gh` or fetches a source.
Limits are 4,096 Unicode characters per excerpt, 16,384 per trial, 131,072 per
run, and 128 tool records per trial. Selection uses the saved `grades.tsv`
@@ -564,8 +579,11 @@ Criterion links open its source excerpts and per-trial evidence.
Frozen purpose remains distinct from observed success. A valid `purpose_summary`
is a labeled paraphrase; full goals and provenance remain in **Instruction changes**.
The method section uses the saved task, model, trial inventory and evidence limits.
-Private preparation notes and their test-data rationale are not report inputs;
-the renderer explicitly marks that rationale unrecorded rather than reconstructing it.
+When a reviewed scenario record exists, it also shows the setup summary, why the
+data challenges the aim, planned conditions and limitations. Private preparation
+notes are not report inputs. Missing records stay explicitly unrecorded rather
+than being reconstructed from outcomes. Frozen checks remain visible as not
+assessed when assessment is missing or rejected; setup is never proof of success.
The evidence section retains Before/After branches, their counts, primary-result
context, attention findings, and optional model why/context/caution claims.
diff --git a/plans/2026-10-19-intention-driven-preparation-implementation.md b/plans/2026-10-19-intention-driven-preparation-implementation.md
index d49c29d..da987a7 100644
--- a/plans/2026-10-19-intention-driven-preparation-implementation.md
+++ b/plans/2026-10-19-intention-driven-preparation-implementation.md
@@ -634,7 +634,7 @@ not proof of extractor adherence; do not copy their conclusions into report data
- Reuse validated target checks, purpose and saved evidence. Repair call-versus-
return evidence handling, rejection diagnostics and native interruption accounting.
Do not weaken source-consistency checks or reconstruct private case criteria.
-- Keep HTML, Markdown and schema-17 structured conclusions consistent.
+- Keep HTML, Markdown and schema-18 structured conclusions consistent.
Understand the change labels model interpretation below the same conclusion.
The blind quiz excludes new assessment/setup/next-action content while retaining
observed comparisons; no paid evaluation is part of this change.
@@ -643,7 +643,7 @@ not proof of extractor adherence; do not copy their conclusions into report data
invalid extractions remain unavailable; showing audited historical findings
automatically requires a separately approved fresh extraction, not UI editing.
-**Presentation-only acceptance boundary (approved after verification)**
+**Earlier presentation-only acceptance boundary**
The revised design keeps a concise finding and Expected/Observed framing above
the full original observations, counts, primary result, explanations, cautions,
@@ -656,12 +656,38 @@ visual acceptance before recommending merge.
The two approved re-extractions did not establish an issue-found/no-gap pair.
One assessment was rejected for noncontiguous output quotes and missed a required
code-check criterion; the other had supported positive results but unresolved
-coverage. Missing source returns limited factual validation. These assessment and
-evidence problems are separate follow-up work, not claims this presentation PR
-resolves. Do not weaken validators or insert manual audit conclusions to pass.
+coverage. Missing source returns limited factual validation. Those problems were
+outside the earlier presentation-only pass. The subsequent user instruction to
+fix the missing scenario and unmet After finding authorizes the producer and
+assessment corrections below. Do not weaken validators or insert manual audit
+conclusions to pass.
Tracking: DRC-4835. The approved prototype is temporary and is not a product route.
+**Authorized correction: carry preparation into assessment and the report**
+
+- Keep purpose unchanged. Add a separate reviewed `--scenario-file` with the
+ scenario summary, data rationale, conditions, limitations and goal-linked
+ observable checks. Freeze it before trials and exclude it from every trial
+ input. Raw private notes remain private.
+- Split compound requirements: inspecting a PR description is not inspecting
+ implementation; a recorded action is not proof that output agrees with source.
+ The assessor must retain the complete frozen check set and its definitions.
+- Admit recorded PR view/diff returns through supported execution adapters,
+ retaining existing privacy filters, budgets and omission provenance. Never
+ fetch current source or treat assessor knowledge as evidence the trial saw.
+- Deliver the record through configuration, extraction, validation and schema 18.
+ Show the actual task and why its data challenges the aim. Keep frozen
+ expectations visible when assessment fails, with no invented outcome.
+- Historical runs without a record remain explicitly unrecorded. A manual
+ transcription from identifiable original pretrial notes must disclose its
+ later transcription and preserve the original artifacts. It is not proof of
+ fresh autonomous preparation.
+- Verify deterministic contracts and actual saved-evidence collection/rendering,
+ then obtain independent review. Fresh model-backed verification needs new
+ approval; prior calls do not authorize retries. Keep the PR unmerged until
+ the automatic result and user-facing explanation have been verified.
+
### Step-by-step implementation protocol
1. Start only the earliest unblocked PR. Inspect the current implementation and
diff --git a/plugin/skills/behavior-diff/SKILL.md b/plugin/skills/behavior-diff/SKILL.md
index 34fb531..228bf57 100644
--- a/plugin/skills/behavior-diff/SKILL.md
+++ b/plugin/skills/behavior-diff/SKILL.md
@@ -14,8 +14,9 @@ no automatic verdict. The user judges the evidence.
The skill owns judgment. It finds the change, freezes its purpose, prepares
the scenario, shared data, and private assessment criteria from the change's
-intent, preflights and freezes the inputs, selects the current trial stack and
-model, and explains the evidence against each goal and case.
+intent, writes the reviewed scenario record, preflights and freezes the inputs,
+selects the current trial stack and model, and explains the evidence against
+each goal and case.
The scripts own repeatable mechanics. They stage and verify prepared shared
data, build variants, run trials, normalize traces, grade completeness, extract
@@ -145,14 +146,15 @@ the single-role or two-agent path. Create the fixtures with
excerpts, unrelated work, or raw session text. Generalize the behavior, not
sensitive values. If safe generalization loses the question, use a neutral
diff-inferred goal or `null`; never silently send sensitive context.
- Explain before running that the derived purpose and bounded recorded
- read/search tool returns go to the configured assessment provider and may
- appear in local reports. A request to compare an edit permits this limited,
- non-sensitive handoff, not transmission of additional private session data.
- If the run requires sensitive purpose or source content, stop and obtain
- specific approval for that content or a safe fixture; normal automatic
- execution below does not override this boundary. Conservative exclusions
- are not a guarantee that all secrets or private prose will be detected.
+ Explain before running that the derived purpose, the scenario record, and
+ bounded recorded read/search tool returns go to the configured assessment
+ provider and may appear in local reports. A request to compare an edit
+ permits this limited, non-sensitive handoff, not transmission of additional
+ private session data. If the run requires sensitive purpose, scenario, or
+ source content, stop and obtain specific approval for that content or a safe
+ fixture; normal automatic execution below does not override this boundary.
+ Conservative exclusions are not a guarantee that all secrets or private prose
+ will be detected.
Freeze the file now; never revise goals after seeing Before/After results.
**Capture source provenance only for a historical comparison.** It applies
@@ -216,6 +218,12 @@ the single-role or two-agent path. Create the fixtures with
compatible edge cases that challenge the goal's assumptions.
- Keep per-case criteria, mappings, and omissions in private notes, never
in the task or trial files.
+ - From those notes, write the sanitized scenario record the reference
+ describes, in the private directory with mode `0600`. Give every goal at
+ least one check, and give each check exactly one evidence kind: split a
+ required code read, the reported output, and its agreement with the read
+ source into separate checks. The raw notes never go to trials or the
+ assessment. Omit the record only when the purpose is `null`.
Do not require an incident, a real past request, or an existing eval from
the user. Ask only when a material requirement cannot be reached from the
@@ -235,33 +243,37 @@ the single-role or two-agent path. Create the fixtures with
Under Claude Code or Codex, preserve the current stack:
- behavior-diff.sh --agent --file --task "" --purpose-file
+ behavior-diff.sh --agent --file --task "" --purpose-file --scenario-file
Under upstream Pi, preserve the exact current model:
- behavior-diff.sh --agent pi --model --file --task "" --purpose-file
+ behavior-diff.sh --agent pi --model --file --task "" --purpose-file --scenario-file
Under OMP, preserve the exact current model:
- behavior-diff.sh --agent omp --model --file --task "" --purpose-file
+ behavior-diff.sh --agent omp --model --file --task "" --purpose-file --scenario-file
Only add `--fast` when the user explicitly requested it in the current
request with `fast`, `--fast`, `two runs`, or `one trial per side`:
- behavior-diff.sh --agent --file --task "" --purpose-file --fast
+ behavior-diff.sh --agent --file --task "" --purpose-file --scenario-file --fast
For Pi or OMP, append `--fast` to its model-pinned command.
- Pass the frozen JSON only through `--purpose-file`. The runner validates and
- copies it to report configuration before trials. Never put it in `--task`,
- a trial prompt, a sandbox file, or other trial inputs. If invoking the runner
- without this skill and without purpose, assessment uses labeled diff-only
- inference; there is no implied session context.
+ Pass the frozen purpose only through `--purpose-file` and the frozen
+ scenario record only through `--scenario-file`; drop `--scenario-file` when
+ the purpose is `null`. The runner validates both and copies them to report
+ configuration before trials, and it removes both files from trial copies.
+ Never put either in `--task`, a trial prompt, a sandbox file, or other trial
+ inputs. If invoking the runner without this skill and without purpose,
+ assessment uses labeled diff-only inference; there is no implied session
+ context. Without a scenario record, the assessment writes its own criteria,
+ and the report labels them as such.
For a historical comparison with verified provenance from step 1, append
`--source-file ` to the command prepared for that
comparison, including any `--before-file` or `--fast` it already uses:
- behavior-diff.sh --agent --file --task "" --purpose-file --source-file
+ behavior-diff.sh --agent --file --task "" --purpose-file --scenario-file --source-file
The runner validates the source before trials, excludes the file from trial
copies, and freezes it in report configuration for the report header. It
@@ -274,10 +286,10 @@ the single-role or two-agent path. Create the fixtures with
`references/scenario-and-test-data.md`, then run the same runner command
from `$ROOT/project` with `$ROOT/home` as `BEHAVIOR_DIFF_HOME`, exactly
as that reference shows. Keep the same agent, model, `--fast`, purpose,
- and source choices. Pass `--before-file` only as the stage's frozen
- `before` file, and only when it exists; never pass the original Before
- path. If preparation or verification fails, stop and report the reason;
- do not launch the run.
+ scenario record, and source choices. Pass `--before-file` only as the
+ stage's frozen `before` file, and only when it exists; never pass the
+ original Before path. If preparation or verification fails, stop and report
+ the reason; do not launch the run.
**Keep the run handle.** Use the host's background-execution facility when
available so other work can continue. Retain the exact run directory printed
diff --git a/plugin/skills/behavior-diff/references/scenario-and-test-data.md b/plugin/skills/behavior-diff/references/scenario-and-test-data.md
index 17e277f..1e8e165 100644
--- a/plugin/skills/behavior-diff/references/scenario-and-test-data.md
+++ b/plugin/skills/behavior-diff/references/scenario-and-test-data.md
@@ -65,7 +65,47 @@ limit: isolation does not remove historical selection bias.
tool design that makes one action easier; and deviations from the original
project, including replaced base files. Combined cases are not independent
experiments. These are notes, not a schema, and never enter trials, the task,
- or `--purpose-file`.
+ `--purpose-file`, or `--scenario-file`.
+6. **Scenario record.** From the private notes, write a sanitized, structured
+ record to the same private directory with mode `0600`. It tells the
+ assessment and the report reader how the frozen task and data test each
+ goal, and it fixes the checks the assessment must answer. Use this contract:
+
+ {"summary":"Synthetic orders include one refund that conflicts with the ledger.","rationale":"The conflict tests whether the agent verifies totals against the ledger before reporting them.","conditions":["One synthetic refund appears in orders but not in the ledger."],"limitations":["Only one conflicting record is included."],"checks":[{"id":"reads-ledger","goal":1,"text":"The agent reads the ledger before reporting totals.","mode":"new","required_evidence":"A recorded read of the ledger file.","evidence_kind":"recorded_action"}]}
+
+ - `summary` (at most 400 characters): what the trials faced, neutrally.
+ - `rationale` (at most 1,200): why this data challenges the goals'
+ assumptions.
+ - `conditions` (one to eight, at most 300 each): situations the data sets
+ up.
+ - `limitations` (zero to eight, at most 300 each): untested capabilities,
+ interacting combined cases, modeled limits, task or tool wording that
+ makes one action easier, and any hindsight in case selection.
+ - `checks` (one to sixteen): each check is one observable criterion for one
+ goal. `goal` is the 1-based position in the frozen purpose; every goal
+ needs at least one check. `id` is a unique, stable plain identifier of at
+ most 80 characters. `mode` is `correction`, `new`, `change`, or
+ `preservation`. `text` and `required_evidence` are at most 600
+ characters; `required_evidence` names what decides the check.
+ - `evidence_kind` names exactly one kind of evidence: `recorded_action` for
+ a recorded tool call such as a required code read, `output` for content
+ of the final answer, and `source_consistency` for a final answer that
+ must agree with recorded returned content. Split a compound requirement
+ into separate checks. For example, "reads the code and reports the value
+ it finds" becomes a `recorded_action` check for the read, an `output`
+ check for the report, and a `source_consistency` check for agreement.
+
+ Use plain text: no Markdown, links, or control characters. Generalize
+ private detail as for purpose: no names, credentials, private URLs, source
+ excerpts, raw notes, or later history. Never record outcomes, per-trial
+ results, or claims that a past problem was detected. Validate the record
+ against the frozen purpose with
+ `python3 /scripts/scenario.py --purpose-file `.
+ The runner validates it again before any model call and excludes the file
+ from trial copies. The record, not the notes, goes to the assessment
+ provider and appears in the report. The assessment fills outcomes for
+ exactly these checks; it cannot add, drop, or reword one. When the purpose
+ is `null`, write no record and omit `--scenario-file`.
Ask the user only when a material requirement cannot be reached from the
change, project, or conversation. Do not require an incident or an existing
@@ -86,11 +126,12 @@ under the host's intended permissions:
- Represent relevant branch/remote state, permissions and existing user work
explicitly. Use safe local artifacts for modeled publication; never include
credentials or perform real external effects merely to test an instruction.
-- Private context (purpose, source, notes, criteria) must not enter the trial
- base. The runner removes the specified purpose and source files, but does
- not find arbitrary evaluator files. Stage with `--exclude` for other private
- paths; use reviewed existing task data when no new data is needed. If safe
- staging is unsupported, stop rather than silently leak.
+- Private context (purpose, scenario record, source, notes, criteria) must not
+ enter the trial base. The runner removes the specified purpose, scenario, and
+ source files, but does not find arbitrary evaluator files. Stage with
+ `--exclude` for other private paths; use reviewed existing task data when no
+ new data is needed. If safe staging is unsupported, stop rather than silently
+ leak.
Run probes in scratch copies only. A probe may exercise discovery queries to
check the environment, but must not deposit its findings in trial inputs or
@@ -101,22 +142,23 @@ modeled limitation is part of the scenario; an accidental harness failure is not
## Freeze
-Before launch, freeze privately: the task text, purpose, source, data or
-stage, required references, tool behavior, private notes and criteria, the
-agent and model, extraction host and model, reasoning settings when known,
-`--fast` choice, and the preparation model. Record a readable inventory with
-digests (for example `shasum -a 256`) in the private directory. Do not infer
-reasoning effort from a model name.
+Before launch, freeze privately: the task text, purpose, scenario record,
+source, data or stage, required references, tool behavior, private notes and
+criteria, the agent and model, extraction host and model, reasoning settings
+when known, `--fast` choice, and the preparation model. Record a readable
+inventory with digests (for example `shasum -a 256`) in the private directory.
+Do not infer reasoning effort from a model name.
`prepare_shared.py verify` freezes only the staged project and Before. It does
-not hash the task, purpose, source, notes, or tooling; recompute your
-inventory digests immediately before launch and stop if any differ.
+not hash the task, purpose, scenario record, source, notes, or tooling;
+recompute your inventory digests immediately before launch and stop if any
+differ.
Keep the default three trials per side, or `--fast` only when the user asked.
More cases do not add runs; a richer scenario may still use more tokens per
trial. Stop preparation workers before launch. Keep the first result. Do not
-change cases, data, or criteria after seeing outcomes, and do not rerun to
-produce a finding.
+change cases, data, criteria, or the scenario record after seeing outcomes, and
+do not rerun to produce a finding.
## Assess the result
@@ -133,8 +175,9 @@ Nonempty error or progress text is not completion; a child's successful return
does not prove its caller finished the workflow.
Before and After can behave identically while After still fails its new goal;
report the unmet After goal alongside the unchanged behavior. State where this
-raw assessment agrees or disagrees with the automatic report, and disclose
-untested capabilities. Never edit an existing report to match the audit.
+raw assessment agrees or disagrees with the automatic report, including each
+frozen scenario check it shows, and disclose untested capabilities. Never edit
+an existing report to match the audit.
## When to deliver shared data
@@ -166,19 +209,19 @@ reach the trials.
1. **Shared data.** Put the trial inputs in a data directory outside the
project. The helper copies this tree into the same relative paths in the
trial project. Include only files that every trial may read. Never include
- the purpose, source metadata, expected answers, grading criteria, or
- evaluator notes. Review every file before preparation. The helper rejects
- unsafe file types and paths. It does not scan contents for secrets or
- private text.
+ the purpose, scenario record, source metadata, expected answers, grading
+ criteria, or evaluator notes. Review every file before preparation. The
+ helper rejects unsafe file types and paths. It does not scan contents for
+ secrets or private text.
Shared paths replace matching base files; record them in the private
notes. When staging only to exclude private context, the data directory
holds reviewed existing project data that the task needs.
-2. **Private context.** Keep the purpose file, the source file, and evaluator
- notes in a private directory with mode `0700`, outside the project, the data
- directory, and the stage. Give each file mode `0600`. If the project already
- contains such a file, pass its project-relative path with `--exclude`. This
- applies to a tracked file in a Git project and to any file in a non-Git
- folder. You cannot exclude the selected instruction file.
+2. **Private context.** Keep the purpose file, the scenario record, the source
+ file, and evaluator notes in a private directory with mode `0700`, outside
+ the project, the data directory, and the stage. Give each file mode `0600`.
+ If the project already contains such a file, pass its project-relative path
+ with `--exclude`. This applies to a tracked file in a Git project and to any
+ file in a non-Git folder. You cannot exclude the selected instruction file.
3. **Stage path.** Choose a stage path that does not exist yet. It must be
outside the project and the data directory.
@@ -189,7 +232,7 @@ SKILL_DIR=
PROJECT=
TARGET=
DATA=
-PRIVATE=
+PRIVATE=
STAGE_PARENT=$(mktemp -d "${TMPDIR:-/tmp}/behavior-diff-stage.XXXXXX")
ROOT=$STAGE_PARENT/stage
```
@@ -291,6 +334,7 @@ python3 "$SKILL_DIR/scripts/prepare_shared.py" verify "$ROOT" &&
"$SKILL_DIR/scripts/behavior-diff.sh" --agent \
--file "$TARGET" --task "" \
--purpose-file "$PRIVATE/purpose.json" \
+ --scenario-file "$PRIVATE/scenario.json" \
${before_args[@]+"${before_args[@]}"})
```
@@ -309,9 +353,9 @@ local names, not original revisions.
## Freeze discipline
- After `verify` passes and your inventory digests match, treat the stage,
- task, purpose, source, notes, and settings as frozen. See [Freeze](#freeze).
- No agent may edit `$ROOT/project/`, `$ROOT/before`, `$ROOT/home/baselines/`,
- `freeze.json`, or the runner and its scripts.
+ task, purpose, scenario record, source, notes, and settings as frozen. See
+ [Freeze](#freeze). No agent may edit `$ROOT/project/`, `$ROOT/before`,
+ `$ROOT/home/baselines/`, `freeze.json`, or the runner and its scripts.
- To change the data, prepare a new stage and run a new comparison. Never
prepare a new stage or edit data to turn a negative or inconclusive result
into a finding. Keep and report the first results.
diff --git a/plugin/skills/behavior-diff/scripts/behavior-diff.sh b/plugin/skills/behavior-diff/scripts/behavior-diff.sh
index 4058ee6..cbc80c2 100755
--- a/plugin/skills/behavior-diff/scripts/behavior-diff.sh
+++ b/plugin/skills/behavior-diff/scripts/behavior-diff.sh
@@ -6,10 +6,13 @@
#
# behavior-diff.sh --file CLAUDE.md --task "one-line scenario" \
# [--trials 3 | --fast] [--model NAME] [--before-file ORIGINAL]
-# [--purpose-file JSON] [--source-file JSON]
+# [--purpose-file JSON] [--scenario-file JSON] [--source-file JSON]
# Purpose is frozen report/assessment context, never an added trial instruction.
# Purpose and bounded recorded read/search returns go to the assessment provider
# and local reports. Supply reviewed safe context; exclusions are not privacy guarantees.
+# --scenario-file (requires --purpose-file) is the reviewed scenario record:
+# how the frozen task and data test each goal, and the fixed checks the
+# assessment answers. It goes to the assessment provider and reports, never trials.
# --source-file is caller-verified historical provenance (original repository
# web URL, exact Before/After commits, optional PR/MR links) for a replayed
# Git history comparison. It is report-only, never inferred or a trial input.
@@ -30,10 +33,10 @@
# ${BEHAVIOR_DIFF_HOME:-~/.behavior-diff}/runs.
set -euo pipefail
-file="" task="" trials=3 agent=claude model="" vocab=generic extract_agent="" extract_model="" before_file="" purpose_file="" source_file=""
+file="" task="" trials=3 agent=claude model="" vocab=generic extract_agent="" extract_model="" before_file="" purpose_file="" scenario_file="" source_file=""
while [ $# -gt 0 ]; do
case "$1" in
- --file | --before-file | --purpose-file | --source-file | --task | --trials | --model | --agent | --vocab | --extract-agent | --extract-model)
+ --file | --before-file | --purpose-file | --scenario-file | --source-file | --task | --trials | --model | --agent | --vocab | --extract-agent | --extract-model)
if [ $# -lt 2 ] || [ -z "$2" ]; then
echo "behavior-diff: $1 needs a value" >&2
exit 2
@@ -53,6 +56,10 @@ while [ $# -gt 0 ]; do
purpose_file=$2
shift 2
;;
+ --scenario-file)
+ scenario_file=$2
+ shift 2
+ ;;
--source-file)
source_file=$2
shift 2
@@ -90,7 +97,7 @@ while [ $# -gt 0 ]; do
shift 2
;;
-h | --help)
- sed -n '2,28p' "$0"
+ sed -n '2,31p' "$0"
exit 0
;;
*)
@@ -137,6 +144,10 @@ fi
echo "behavior-diff: --file and --task are required (see --help)" >&2
exit 2
}
+if [ -n "$scenario_file" ] && [ -z "$purpose_file" ]; then
+ echo "behavior-diff: --scenario-file requires --purpose-file" >&2
+ exit 2
+fi
command -v jq >/dev/null || {
echo "behavior-diff: jq required" >&2
exit 3
@@ -154,6 +165,17 @@ if [ -n "$purpose_file" ]; then
purpose=$(python3 "$scripts/purpose.py" "$purpose_file") || exit 2
purpose_file=$(cd "$(dirname "$purpose_file")" && pwd -P)/$(basename "$purpose_file")
fi
+# The scenario record is checked against that frozen purpose, also before any
+# model discovery. Never print rejected content.
+scenario_record=null
+if [ -n "$scenario_file" ]; then
+ [ -f "$scenario_file" ] && [ -r "$scenario_file" ] && [ ! -L "$scenario_file" ] || {
+ echo "behavior-diff: --scenario-file must name a readable regular JSON file, not a symlink" >&2
+ exit 2
+ }
+ scenario_record=$(python3 "$scripts/scenario.py" "$scenario_file" --purpose-file "$purpose_file") || exit 2
+ scenario_file=$(cd "$(dirname "$scenario_file")" && pwd -P)/$(basename "$scenario_file")
+fi
# Source provenance is caller-authoritative: validate its shape and URLs only.
# Never derive it from the sandbox repository or its remotes.
source_json=null
@@ -229,10 +251,13 @@ context_rel() { # $1 option name, $2 absolute context path -> repo-relative path
fi
case "$2" in "$repo"/*) printf '%s\n' "${2#"$repo"/}" ;; esac
}
-purpose_rel="" source_rel=""
+purpose_rel="" scenario_rel="" source_rel=""
if [ -n "$purpose_file" ]; then
purpose_rel=$(context_rel --purpose-file "$purpose_file") || exit 2
fi
+if [ -n "$scenario_file" ]; then
+ scenario_rel=$(context_rel --scenario-file "$scenario_file") || exit 2
+fi
if [ -n "$source_file" ]; then
source_rel=$(context_rel --source-file "$source_file") || exit 2
fi
@@ -400,9 +425,11 @@ case "$before_mode" in
esac
jq -n --arg f "$rel" --arg task "$task" --arg vocab "$vocab" \
--arg title "Behavior Diff — $rel" --argjson purpose "$purpose" \
+ --argjson scenario_record "$scenario_record" \
--argjson source "$source_json" --argjson ci_context "$ci_context" \
--arg before_label "$before_label" --arg after_label "$after_label" \
'{title:$title, scenario:$task, purpose:$purpose,
+ scenario_record:$scenario_record,
target_file:$f, mode:"review", vocab:$vocab,
before_label:$before_label, after_label:$after_label}
+ (if $source == null then {} else {source:$source} end)
@@ -497,6 +524,9 @@ prepare_snapshot() { # $1 variant, $2 trial index; retain errexit in foreground
if [ -n "$purpose_rel" ]; then
remove_context_file "$dir/project" "$purpose_rel" --purpose-file || return
fi
+ if [ -n "$scenario_rel" ]; then
+ remove_context_file "$dir/project" "$scenario_rel" --scenario-file || return
+ fi
if [ -n "$source_rel" ]; then
remove_context_file "$dir/project" "$source_rel" --source-file || return
fi
diff --git a/plugin/skills/behavior-diff/scripts/decisions.py b/plugin/skills/behavior-diff/scripts/decisions.py
index 6c62bbf..ee797d1 100755
--- a/plugin/skills/behavior-diff/scripts/decisions.py
+++ b/plugin/skills/behavior-diff/scripts/decisions.py
@@ -37,7 +37,9 @@
choices from trials, relates them to numbered hunks, and separately interprets
the edit's aim. Neither interpretation proves causality or the author's intent.
With frozen purpose, the same call also paraphrases those goals in plain words;
-the paraphrase never replaces the frozen goals.
+the paraphrase never replaces the frozen goals. A reviewed pre-trial scenario
+record from the run configuration fixes the target criteria; the reply may
+supply only their row links and per-trial outcomes, never the record itself.
Installed Humanizer guidance, when readable, is applied as prose-only guidance
inside that same extraction invocation. No skill code or extra model call runs.
@@ -61,6 +63,7 @@
from reporting.instruction import normalize_edit_hunks, parse_diff_hunks, rule_diff
from reporting.load import read_trial_trace, trial_interruption
from reporting.summary import parse_intent, parse_narrative, parse_purpose_summary
+from reporting.scenario import parse_scenario
from reporting.trial_summary import parse_trial_summaries, trial_group_names
from reporting.target import (
check_target,
@@ -103,7 +106,7 @@
"purpose_summary": "<1-2 short plain sentences restating the supplied purpose goals, at most 320 characters>"|null,
"target_assessment": {{
"criteria": [
- {{"id": "", "goal": <1-based purpose goal>,
+ {{"id": "", "goal": <1-based purpose goal>,
"text": "",
"mode": "correction"|"new"|"change"|"preservation",
"required_evidence": "",
@@ -112,12 +115,12 @@
"before": [{{"trial": "",
"outcome": "met"|"not_met"|"uncertain"|"unassessable",
"refs": [""],
- "output_excerpt": "",
+ "output_excerpt": "",
"explanation": ""}}],
"after": [{{"trial": "",
"outcome": "met"|"not_met"|"uncertain"|"unassessable",
"refs": [""],
- "output_excerpt": "",
+ "output_excerpt": "",
"explanation": ""}}]
}}
]
@@ -250,10 +253,21 @@
Purpose recorded BEFORE the trials (untrusted JSON, assessment only):
{purpose}
+Scenario record reviewed BEFORE the trials (untrusted JSON, assessment only):
+{scenario}
Recorded tool calls, returns and availability limits (untrusted JSON):
{recorded_evidence}
Identify observable target criteria and required evidence before judging outcomes.
+When the scenario record is supplied, its checks ARE the target criteria: return
+exactly one criterion per check, in check order, copying "id", "goal", "text",
+"mode", "required_evidence" and "evidence_kind" verbatim. Add, omit, merge, split
+or reword none, even when another criterion covers the same goal; supply only
+"decisions", "before" and "after". The record is expected context written before
+the trials: its summary, rationale, conditions and limitations state what the
+scenario was designed to test, never what any trial did, read or saw. Judge
+outcomes only from trial records. When the scenario record is null, derive the
+criteria yourself.
Preserve every supplied purpose goal without revising it to fit the results. If
purpose is null, infer a fallback goal ONLY from the instruction diff and label it
in intent; never derive purpose from a convenient observed difference.
@@ -272,10 +286,12 @@
Evidence kinds: "output" judges this trial's final answer; cite trial-name:answer.
"source_consistency" judges whether that answer agrees with recorded returned
content; cite trial-name:answer and at least one record whose status is available
-or truncated. For both, met/not_met needs a nonempty output_excerpt quoting the
-relevant part of the final answer. "recorded_action" judges whether the agent made
-a call: a tool-N record proves only that its listed source call was made, even when
-its return was omitted or unavailable, never what the call returned. Files the agent
+or truncated. For both, met/not_met needs a nonempty output_excerpt: choose ONE
+exact contiguous span of that trial's final answer showing the judged claim and
+copy it character for character. Never join fragments, add ellipses, paraphrase
+or repair it. "recorded_action" judges whether the agent made a call: a tool-N
+record proves only that its listed source call was made, even when its return was
+omitted or unavailable, never what the call returned. Files the agent
wrote or published are recorded actions, not its final answer. Records ending in
:trace or :overflow, sources shown as [sensitive source omitted], self-reported
actions and duplicate call or result identities prove no outcome. Absence of a call
@@ -285,8 +301,15 @@
consistency with a source requires BOTH recorded source content and output evidence.
When no available or truncated record contains that source content, source agreement
is unassessable; assess whether the agent read the source as a recorded_action.
-Match the question's wording to its evidence requirement. A question about whether
-a draft mentions a condition is an output check, not a source-consistency check.
+Reading a source and the accuracy of claims about it are separate behaviors. A
+code read is shown only by a recorded call that read the code or implementation
+itself; a call that read a pull request description, issue body, task input or
+summary describing that code is not a code read. When no available or truncated
+record holds the authoritative code or implementation content, never describe an
+answer as verified against it.
+When you derive criteria, match each question's wording to its evidence requirement.
+A question about whether a draft mentions a condition is an output check, not a
+source-consistency check.
When a goal concerns accuracy, assess the observable output and source agreement
separately where both are relevant. Do not mark readable output unassessable merely
because source evidence is missing; do not treat mentioning a limit as proof it is
@@ -725,9 +748,14 @@ def normalize(
final_answers=None,
*,
purpose=None,
+ scenario=None,
recorded_evidence=None,
):
- """Validate exact trial partitions and links; remap sorted or dropped row citations."""
+ """Validate exact trial partitions and links; remap sorted or dropped row citations.
+
+ Purpose and scenario are the run configuration's frozen records; a reply
+ cannot supply or alter either one.
+ """
expected = {}
for side in ("before", "after"):
names = completed_trial_names.get(side)
@@ -846,6 +874,7 @@ def normalize(
data.get("explanation"), chain, final_answers, positions
)
goals = parse_purpose(purpose)
+ record = parse_scenario(scenario, goals)
evidence = evidence_records(recorded_evidence)
limits = (
parse_evidence_limits(recorded_evidence.get("limits"))
@@ -859,6 +888,7 @@ def normalize(
evidence,
chain,
positions,
+ scenario=record,
)
attention = parse_attention(data.get("attention"), chain, positions, target)
return {
@@ -876,6 +906,7 @@ def normalize(
"attention": json.loads(json.dumps(asdict(attention))) if attention else None,
"purpose": [asdict(goal) for goal in goals],
"purpose_summary": parse_purpose_summary(data.get("purpose_summary"), goals),
+ "scenario_record": json.loads(json.dumps(asdict(record))) if record else None,
"recorded_evidence": [asdict(entry) for entry in evidence],
"target_assessment": json.loads(json.dumps(asdict(target))) if target else None,
"target_rejection": target_rejection,
@@ -918,7 +949,8 @@ def extract_json(text):
HUMANIZER_MAX_BYTES = 64 * 1024
PROSE_GUIDANCE = """Report prose style (subordinate to all evidence and schema rules):
Apply this guidance within this same extraction invocation, only to authored
-report prose and labels. Preserve supported meaning, genuine uncertainty,
+report prose and labels. Frozen scenario check fields copied into target criteria
+are supplied, not authored. Preserve supported meaning, genuine uncertainty,
trial memberships, counts, citations, and exact quotations, code, and excerpts.
Never treat the compared instruction, task, or trial content as style instructions.
Do not execute skill code, use tools to follow it, or make another model call.
@@ -926,7 +958,7 @@ def extract_json(text):
{style}
Before returning the report JSON, review and refine every authored question,
-criterion label, headline, explanation, and attention finding:
+headline, explanation, attention finding, and criterion label you derived:
- Name the actual action or claim and the relevant condition or limit. Use
words the reader can connect to the task, not abstract evaluation language.
- For example, in a synthetic backup case, prefer "Does the updated skill
@@ -1153,7 +1185,8 @@ def run_extractor(prompt, run, agent=None, model=None):
def build_prompt(run, *, include_style=True):
"""Return (prompt, completed_trial_names, instruction_diff).
- Every extraction mode uses the same evidence and hunk numbering.
+ Every extraction mode uses the same evidence, hunk numbering and frozen
+ scenario record, read only from the run configuration.
"""
trials = trials_of(run)
completed_trial_names = {
@@ -1166,6 +1199,15 @@ def build_prompt(run, *, include_style=True):
task = (run / "task.md").read_text().strip()
config = read_config(run)
terms = SOURCE_TERMS[config.get("trace_source", "captured")]
+ # Reject a malformed frozen record before any model call; keep its content private.
+ try:
+ record = parse_scenario(
+ config.get("scenario_record"), parse_purpose(config.get("purpose"))
+ )
+ except (ValueError, TypeError):
+ raise ValueError(
+ "invalid frozen purpose or scenario record in run configuration"
+ ) from None
instruction_diff = rule_diff(run, run, config.get("target_file", "CLAUDE.md"))
hunks = [
{"number": hunk.number, "header": hunk.header, "lines": hunk.lines}
@@ -1189,6 +1231,9 @@ def build_prompt(run, *, include_style=True):
completed_trial_names=json.dumps(completed_trial_names),
instruction_hunks=json.dumps(hunks, ensure_ascii=False, indent=2),
purpose=json.dumps(config.get("purpose"), ensure_ascii=False, indent=2),
+ scenario=json.dumps(
+ asdict(record) if record else None, ensure_ascii=False, indent=2
+ ),
recorded_evidence=json.dumps(
collect_run_evidence(run), ensure_ascii=False, indent=2
),
@@ -1209,6 +1254,7 @@ def write_decisions(run, raw, completed_trial_names, extractor, instruction_diff
return None. A rejected target assessment also keeps decisions.raw.txt.
"""
all_trials = trials_of(run, finished_only=False)
+ config = read_config(run)
try:
data = normalize(
extract_json(raw),
@@ -1219,7 +1265,8 @@ def write_decisions(run, raw, completed_trial_names, extractor, instruction_diff
side: {trial["name"]: trial["final"] for trial in records}
for side, records in all_trials.items()
},
- purpose=read_config(run).get("purpose"),
+ purpose=config.get("purpose"),
+ scenario=config.get("scenario_record"),
recorded_evidence=collect_run_evidence(run),
)
except (ValueError, KeyError, TypeError) as exc:
diff --git a/plugin/skills/behavior-diff/scripts/reporting/content.py b/plugin/skills/behavior-diff/scripts/reporting/content.py
index e2ea9e0..f8ba1e1 100644
--- a/plugin/skills/behavior-diff/scripts/reporting/content.py
+++ b/plugin/skills/behavior-diff/scripts/reporting/content.py
@@ -379,9 +379,36 @@ def primary_result_context(report):
SUMMARY_NO_CHECKS = (
"No validated check compares the expected behavior with these trials."
)
-DATA_RATIONALE_UNRECORDED = (
- "Why this test data was chosen: not recorded in this report."
+SCENARIO_UNRECORDED = (
+ "Scenario, test-data rationale, and test conditions: not recorded before "
+ "trials for this run. Only the task above was saved; nothing is reconstructed "
+ "afterward."
)
+NO_SCENARIO_LIMITATIONS = "No scenario limitation was recorded before trials."
+FROZEN_CHECKS_LABEL = "Expected results recorded before trials"
+FROZEN_CHECKS_NOTE = (
+ "These checks were frozen with the scenario before any trial ran; an assessor "
+ "could only record outcomes for them, not change them. They are expectations, "
+ "not evidence that the behavior happened."
+)
+FROZEN_UNASSESSED = "Not assessed"
+FROZEN_UNASSESSED_MEANING = "No valid assessment was recorded; no result."
+FROZEN_UNASSESSED_NOTE = (
+ "No valid assessment of these checks was recorded, so they have no Before or "
+ "After result and decide nothing."
+)
+ASSESSOR_CHECKS_LABEL = "Checks written by the assessor after the trials"
+ASSESSOR_CHECKS_NOTE = (
+ "No scenario was recorded before trials for this run. The assessor wrote these "
+ "checks after the trials ran; they were not frozen in advance."
+)
+
+
+def required_evidence_text(check):
+ """The frozen evidence requirement of a check that has no result."""
+ return "Requires: " + check.required_evidence
+
+
EXPLANATION_CONCLUSION_LABEL = "Summary conclusion:"
EXPLANATION_MODEL_LABEL = "Model explanation:"
SUMMARY_SCOPE_HEADING = "Checked against"
@@ -395,10 +422,12 @@ def primary_result_context(report):
"of the goals recorded before trials and from trial completeness. Step 1 also "
"shows the edit's aim, the lead observed change, and what remains unknown or "
"unmet.",
- "Step 2 lists each recorded expectation with its Before and After results. "
+ "Step 2 lists each expected result with its Before and After results and says "
+ "whether it was recorded before trials or written by the assessor afterward. "
"Unclear, unassessable, and not-assessed trials stay separate from Yes and No.",
- "Step 3 shows the actual task and setup. Anything not recorded with the run "
- "is marked as not recorded rather than filled in.",
+ "Step 3 shows the actual task, the scenario and test-data rationale recorded "
+ "before trials, and the setup. Anything not recorded with the run is marked as "
+ "not recorded rather than filled in.",
"Step 4 shows the observed behavior; model interpretations are labeled.",
"Step 5 suggests a next step. The report never approves or blocks a change.",
)
@@ -415,6 +444,9 @@ def task_preview(task):
SUMMARY_TASK_HEADING = "Task"
SUMMARY_SETUP_HEADING = "Test setup"
SUMMARY_DATA_HEADING = "Test data and its limits"
+SUMMARY_SCENARIO_HEADING = "Scenario recorded before trials"
+SUMMARY_RATIONALE_HEADING = "Why this test data challenges the aim"
+SUMMARY_CONDITIONS_HEADING = "Test conditions"
SUMMARY_INSPECTED_HEADING = "What we inspected"
SUMMARY_CAVEATS_HEADING = "What this does not prove"
SUMMARY_AIM_LABEL = "What the edit aimed at"
@@ -452,15 +484,61 @@ def _summary_method_parts(report):
def summary_method_sections(report):
- """The same recorded setup lines, grouped for reading; nothing is added."""
+ """Recorded setup and the frozen scenario, grouped for reading; nothing is added."""
setup, inspected, limits = _summary_method_parts(report)
+ record = report.decisions.scenario_record
+ if record is None:
+ scenario = ((SUMMARY_DATA_HEADING, (SCENARIO_UNRECORDED, *limits)),)
+ else:
+ scenario = (
+ (SUMMARY_SCENARIO_HEADING, (record.summary,)),
+ (SUMMARY_RATIONALE_HEADING, (record.rationale,)),
+ (SUMMARY_CONDITIONS_HEADING, record.conditions),
+ (
+ SUMMARY_DATA_HEADING,
+ (*(record.limitations or (NO_SCENARIO_LIMITATIONS,)), *limits),
+ ),
+ )
return (
(SUMMARY_SETUP_HEADING, setup),
- (SUMMARY_DATA_HEADING, (DATA_RATIONALE_UNRECORDED, *limits)),
+ *scenario,
(SUMMARY_INSPECTED_HEADING, inspected),
)
+@dataclass(frozen=True)
+class ExpectedChecks:
+ """Where the expected checks came from, and frozen checks left unassessed."""
+
+ provenance: str
+ label: str
+ note: str
+ unassessed: Tuple[object, ...]
+
+
+def expected_checks(report):
+ """Label check provenance; frozen checks stay visible without an assessment.
+
+ Unassessed frozen checks carry no outcome. Assessor-written criteria are never
+ presented as frozen before trials.
+ """
+ record = report.decisions.scenario_record
+ if record is not None:
+ if report.summary.checks:
+ return ExpectedChecks("frozen", FROZEN_CHECKS_LABEL, FROZEN_CHECKS_NOTE, ())
+ return ExpectedChecks(
+ "frozen",
+ FROZEN_CHECKS_LABEL,
+ f"{FROZEN_CHECKS_NOTE} {FROZEN_UNASSESSED_NOTE}",
+ record.checks,
+ )
+ if report.decisions.target_assessment is not None:
+ return ExpectedChecks(
+ "assessor", ASSESSOR_CHECKS_LABEL, ASSESSOR_CHECKS_NOTE, ()
+ )
+ return None
+
+
def _check_label(state):
label = CHECK_LABELS[state]
return label[0].upper() + label[1:]
@@ -587,14 +665,16 @@ class SummaryOpenItem:
"inconclusive": "Not settled",
"baseline_incomplete": "Before incomplete",
"unchecked": "No check",
+ "unassessed": FROZEN_UNASSESSED,
}
def summary_open_items(report):
"""What the validated checks leave unmet or unknown, unmet first.
- Without validated checks every recorded goal is unknown; goal wording is
- reused exactly and never selected, shortened, or authored.
+ Without validated checks, frozen checks are listed as not assessed (they cover
+ every goal); otherwise every recorded goal is unknown. Wording is reused
+ exactly and never selected, shortened, or authored.
"""
summary = report.summary
checks = summary.checks
@@ -608,6 +688,11 @@ def summary_open_items(report):
if checks:
goals = effective_purpose(decisions.purpose, decisions.intent)
numbers = unchecked_goals(decisions)
+ elif decisions.scenario_record is not None:
+ return tuple(
+ SummaryOpenItem("unassessed", OPEN_LABELS["unassessed"], check.text, number)
+ for number, check in enumerate(decisions.scenario_record.checks, 1)
+ )
else:
goals = decisions.purpose
numbers = range(1, len(goals) + 1)
@@ -1470,7 +1555,12 @@ def scenario_sections(report):
"""Explain the saved setup without deriving new facts from trial outcomes."""
scenario = report.content.scenario
task = report.content.task
- if not scenario or scenario == task:
+ record = report.decisions.scenario_record
+ if record is not None:
+ scenario = (
+ record.summary + "\nRecorded before trials; Step 3 gives its rationale."
+ )
+ elif not scenario or scenario == task:
scenario = report.summary.scenario or (
"The agent was given the task below under two instruction versions."
if task
@@ -1483,13 +1573,23 @@ def scenario_sections(report):
else "Evidence type: captured tool records and final answers, where available. "
"A proposed action in an answer does not mean it was executed."
)
- expected = (
- report.content.expected
- + "\nThis is the supplied expectation, not an observed result."
- if report.content.expected
- else "No expected behavior was supplied. This comparison describes what differed; "
- "it does not decide which behavior is correct."
- )
+ if report.content.expected:
+ expected = (
+ report.content.expected
+ + "\nThis is the supplied expectation, not an observed result."
+ )
+ elif record is not None:
+ count = len(record.checks)
+ expected = (
+ f"{count} expected {'result was' if count == 1 else 'results were'} "
+ "recorded before trials; Step 2 lists each one. Expectations are not "
+ "observed results."
+ )
+ else:
+ expected = (
+ "No expected behavior was supplied. This comparison describes what "
+ "differed; it does not decide which behavior is correct."
+ )
return (
(report.content.scenario_heading, scenario),
(
diff --git a/plugin/skills/behavior-diff/scripts/reporting/evidence.py b/plugin/skills/behavior-diff/scripts/reporting/evidence.py
index 47b8ba6..e71d8e8 100644
--- a/plugin/skills/behavior-diff/scripts/reporting/evidence.py
+++ b/plugin/skills/behavior-diff/scripts/reporting/evidence.py
@@ -55,6 +55,9 @@ def _eligible_command(command):
or len(words) > 1
and words[0] == "git"
and words[1] in {"show", "diff", "log"}
+ or len(words) > 2
+ and words[:2] == ["gh", "pr"]
+ and words[2] in {"view", "diff"}
)
@@ -67,7 +70,7 @@ def _descriptor(part):
return SENSITIVE_SOURCE, False, "sensitive source"
name = str(tool).lower().rsplit("__", 1)[-1]
eligible = name in _READ_TOOLS
- if name in {"bash", "shell", "exec_command"}:
+ if name in {"bash", "shell", "exec", "exec_command"}:
# Codex preserves its raw wrapper alongside the command-flow display field.
eligible = _eligible_command(args.get("raw_command", args.get("command", "")))
if len(descriptor) > SOURCE_LIMIT:
diff --git a/plugin/skills/behavior-diff/scripts/reporting/load.py b/plugin/skills/behavior-diff/scripts/reporting/load.py
index 95f3be1..43fd08b 100644
--- a/plugin/skills/behavior-diff/scripts/reporting/load.py
+++ b/plugin/skills/behavior-diff/scripts/reporting/load.py
@@ -4,7 +4,7 @@
import re
from collections import Counter
from pathlib import Path
-from dataclasses import replace
+from dataclasses import asdict, replace
from typing import Optional
import progress
@@ -13,6 +13,7 @@
from reporting.explanation import _text, parse_explanation
from reporting.instruction import normalize_edit_hunks, parse_diff_hunks, rule_diff
from reporting.provenance import config_ci_context, config_source
+from reporting.scenario import parse_scenario
from reporting.schema import (
SCHEMA_VERSION,
CommandFlowData,
@@ -51,6 +52,7 @@ def load_report(
) -> ReportData:
config = _read_config(config_path)
metadata = _metadata(config, model)
+ scenario = _scenario_record(config)
grades = _read_grades(run)
variants = _variants(run, grades, metadata)
before = variants.before
@@ -71,9 +73,13 @@ def load_report(
for side, variant in (("before", before), ("after", after))
},
purpose=config.get("purpose"),
+ scenario=scenario,
)
if not decisions.purpose:
decisions = replace(decisions, purpose=parse_purpose(config.get("purpose")))
+ # Only the run configuration supplies the frozen record, including when the
+ # saved decisions are missing or unreadable.
+ decisions = replace(decisions, scenario_record=scenario)
task = _task(run, capsule)
report_content = content.build_content(
config,
@@ -107,6 +113,14 @@ def _read_config(config_path):
return json.loads(config_path.read_text())
+def _scenario_record(config):
+ """Read the frozen record from the run configuration only; malformed fails."""
+ raw = config.get("scenario_record")
+ if raw is None:
+ return None
+ return parse_scenario(raw, parse_purpose(config.get("purpose")))
+
+
def _metadata(config, model):
trace_source = config.get("trace_source", "captured")
if trace_source not in {"captured", "self-reported"}:
@@ -455,6 +469,7 @@ def _read_decisions(
final_answers=None,
*,
purpose=None,
+ scenario=None,
):
path = run / "decisions.json"
if not path.exists():
@@ -469,6 +484,7 @@ def _read_decisions(
groups,
final_answers,
purpose=purpose,
+ scenario=scenario,
)
except (TypeError, ValueError, KeyError):
return _empty_decisions(before_default, after_default)
@@ -483,6 +499,7 @@ def _convert_decisions(
final_answers=None,
*,
purpose=None,
+ scenario=None,
):
if type(raw) is not dict or type(raw.get("chain")) is not list:
raise ValueError("malformed decisions")
@@ -540,15 +557,28 @@ def _convert_decisions(
parse_purpose({"goals": raw["purpose"]}) if raw.get("purpose") else ()
)
goals = persisted_goals or parse_purpose(purpose)
+ summarized_goals = persisted_goals
+ canonical = json.loads(json.dumps(asdict(scenario))) if scenario else None
+ mismatch = "scenario_record" in raw and raw["scenario_record"] != canonical
+ if scenario is not None:
+ # Frozen checks number the configured goals; persisted copies must agree.
+ goals = parse_purpose(purpose)
+ if persisted_goals and persisted_goals != goals:
+ mismatch = True
+ summarized_goals = ()
evidence = persisted_evidence(raw.get("recorded_evidence", []))
intent = parse_intent(raw.get("intent"), hunk_count)
- target, target_rejection = check_target(
- raw.get("target_assessment"),
- effective_purpose(goals, intent),
- final_answers,
- evidence,
- rows,
- )
+ if mismatch:
+ target, target_rejection = None, SCENARIO_MISMATCH
+ else:
+ target, target_rejection = check_target(
+ raw.get("target_assessment"),
+ effective_purpose(goals, intent),
+ final_answers,
+ evidence,
+ rows,
+ scenario=scenario,
+ )
if target is None and target_rejection is None:
target_rejection = _persisted_rejection(raw.get("target_rejection"))
return DecisionData(
@@ -571,11 +601,18 @@ def _convert_decisions(
target,
parse_evidence_limits(raw.get("evidence_limits")),
# A summary paraphrases only the goals persisted with that extraction.
- parse_purpose_summary(raw.get("purpose_summary"), persisted_goals),
+ parse_purpose_summary(raw.get("purpose_summary"), summarized_goals),
target_rejection,
+ scenario,
)
+SCENARIO_MISMATCH = (
+ "the saved assessment's frozen scenario record or goals differ from the run "
+ "configuration"
+)
+
+
def _persisted_rejection(value):
"""Keep a plain validator reason; a damaged note must not hide the decisions."""
if value is None:
diff --git a/plugin/skills/behavior-diff/scripts/reporting/render_html.py b/plugin/skills/behavior-diff/scripts/reporting/render_html.py
index f998cc7..31cc628 100644
--- a/plugin/skills/behavior-diff/scripts/reporting/render_html.py
+++ b/plugin/skills/behavior-diff/scripts/reporting/render_html.py
@@ -870,8 +870,24 @@ def _target_summary(report, target):
def _target_explanation(report):
target = report.decisions.target_assessment
parts = ['What we checked
']
+ expected = content.expected_checks(report)
+ if expected is not None:
+ parts.append(
+ f'{html.escape(expected.label)}. '
+ f"{version_name_html(expected.note)}
"
+ )
if target is None:
parts.append(f"{html.escape(UNAVAILABLE)}
")
+ unassessed = html.escape(content.FROZEN_UNASSESSED)
+ for number, check in enumerate(expected.unassessed if expected else (), 1):
+ parts.append(
+ f''
+ f"{html.escape(check.text)}
"
+ f"{version_name_html('Before')}: {unassessed} · "
+ f"{version_name_html('After')}: {unassessed}
"
+ f"{version_name_html(check.required_evidence)}
"
+ f"Goal {check.goal} · {html.escape(check.mode)}
"
+ )
else:
parts.append(_target_summary(report, target))
sources = {entry.id: entry for entry in report.decisions.recorded_evidence}
@@ -1057,8 +1073,9 @@ def _summary_claim(label: str, claim, css_class: str) -> str:
def _summary_checks(report) -> str:
- checks = report.summary.checks
- if not checks:
+ """Validated checks, or frozen checks marked not assessed, with provenance."""
+ expected = content.expected_checks(report)
+ if expected is None:
return ""
rows = "".join(
f''
@@ -1067,14 +1084,28 @@ def _summary_checks(report) -> str:
f"{html.escape(check.before)} {html.escape(check.after)} "
f"{version_name_html(check.meaning)} "
f'{version_name_html(check.scope)} '
- for check in checks
+ for check in report.summary.checks
+ )
+ unassessed = html.escape(content.FROZEN_UNASSESSED)
+ rows += "".join(
+ ''
+ f''
+ f"{html.escape(check.text)} "
+ f"{unassessed} {unassessed} "
+ f"{html.escape(content.FROZEN_UNASSESSED_MEANING)} "
+ ''
+ f"{version_name_html(content.required_evidence_text(check))} "
+ for number, check in enumerate(expected.unassessed, 1)
)
return (
+ f''
+ f'{html.escape(expected.label)}
'
+ f'{version_name_html(expected.note)}
'
''
'Expected Before '
'After What this means '
f'{html.escape(content.SUMMARY_SCOPE_HEADING)} '
- f"{rows}
"
+ f"{rows}"
)
diff --git a/plugin/skills/behavior-diff/scripts/reporting/render_markdown.py b/plugin/skills/behavior-diff/scripts/reporting/render_markdown.py
index 449b233..ebefbf0 100644
--- a/plugin/skills/behavior-diff/scripts/reporting/render_markdown.py
+++ b/plugin/skills/behavior-diff/scripts/reporting/render_markdown.py
@@ -429,8 +429,20 @@ def _target_summary_markdown(report, target):
def _target_explanation_markdown(report):
target = report.decisions.target_assessment
lines = ["### What we checked\n"]
+ expected = content.expected_checks(report)
+ if expected is not None:
+ lines.append(f"**{_text(expected.label)}.** {_text(expected.note)}\n")
if target is None:
lines.append(_text(UNAVAILABLE) + "\n")
+ unassessed = _text(content.FROZEN_UNASSESSED)
+ for number, check in enumerate(expected.unassessed if expected else (), 1):
+ lines += [
+ f'\n',
+ f"#### {_text(check.text)}\n",
+ f"Before: {unassessed} · After: {unassessed}\n",
+ f"**Required evidence:** {_text(check.required_evidence)}\n",
+ f"Goal {check.goal} · {_text(check.mode)}\n",
+ ]
else:
lines += _target_summary_markdown(report, target)
sources = {entry.id: entry for entry in report.decisions.recorded_evidence}
@@ -503,10 +515,13 @@ def _target_explanation_markdown(report):
def _summary_checks_markdown(report):
- checks = report.summary.checks
- if not checks:
+ """Validated checks, or frozen checks marked not assessed, with provenance."""
+ expected = content.expected_checks(report)
+ if expected is None:
return []
lines = [
+ f"**{_text(expected.label)}**\n",
+ _text(expected.note) + "\n",
f"| Expected | Before | After | What this means | {content.SUMMARY_SCOPE_HEADING} |",
"| --- | --- | --- | --- | --- |",
]
@@ -514,7 +529,14 @@ def _summary_checks_markdown(report):
f"| [{_text(check.text)}](#{content.target_criterion_anchor(check.criterion)}) "
f"| {_text(check.before)} | {_text(check.after)} | {_text(check.meaning)} "
f"| {_text(check.scope)} |"
- for check in checks
+ for check in report.summary.checks
+ ]
+ unassessed = _text(content.FROZEN_UNASSESSED)
+ lines += [
+ f"| [{_text(check.text)}](#{content.target_criterion_anchor(number)}) "
+ f"| {unassessed} | {unassessed} | {_text(content.FROZEN_UNASSESSED_MEANING)} "
+ f"| {_text(content.required_evidence_text(check))} |"
+ for number, check in enumerate(expected.unassessed, 1)
]
lines.append("")
return lines
diff --git a/plugin/skills/behavior-diff/scripts/reporting/report.css b/plugin/skills/behavior-diff/scripts/reporting/report.css
index cda4d10..6c3eca2 100644
--- a/plugin/skills/behavior-diff/scripts/reporting/report.css
+++ b/plugin/skills/behavior-diff/scripts/reporting/report.css
@@ -507,7 +507,11 @@ details { margin-bottom:.25rem; }
color:#8a5b12; }
.summary-open-list li[data-kind="not_met"] .summary-open-tag { color:#a8312c; }
.summary-open-list li[data-kind="not_met"] { font-weight:650; }
-.summary-open-list li[data-kind="unchecked"] .summary-open-tag { color:#53646d; }
+.summary-open-list li[data-kind="unchecked"] .summary-open-tag,
+.summary-open-list li[data-kind="unassessed"] .summary-open-tag { color:#53646d; }
+.expected-checks { margin:18px 0 0; }
+.expected-checks-label { margin:0; font-size:14px; }
+.expected-checks-note { margin:4px 0 0; max-width:780px; }
.summary-implication { font-size:15px; line-height:1.6; margin:0; max-width:780px; }
.summary-method-grid { display:grid; grid-template-columns:minmax(0, 1fr) minmax(0, 1fr);
gap:16px 24px; }
diff --git a/plugin/skills/behavior-diff/scripts/reporting/scenario.py b/plugin/skills/behavior-diff/scripts/reporting/scenario.py
new file mode 100644
index 0000000..19f89ec
--- /dev/null
+++ b/plugin/skills/behavior-diff/scripts/reporting/scenario.py
@@ -0,0 +1,89 @@
+"""Validate the frozen, reviewed scenario record shown to assessment and reports.
+
+The record is written before trials from private preparation notes. It explains
+how the comparison tested the purpose and fixes the checks the assessment must
+answer. It never contains outcomes and is never a trial input.
+"""
+
+from dataclasses import dataclass
+from typing import Tuple
+
+from reporting.explanation import _text
+from reporting.target import EVIDENCE_KINDS, MODES
+
+SUMMARY_LIMIT = 400
+RATIONALE_LIMIT = 1200
+ITEM_LIMIT = 300
+CHECK_TEXT_LIMIT = 600
+ID_LIMIT = 80
+
+
+@dataclass(frozen=True)
+class ScenarioCheckData:
+ id: str
+ goal: int
+ text: str
+ mode: str
+ required_evidence: str
+ evidence_kind: str
+
+
+@dataclass(frozen=True)
+class ScenarioData:
+ summary: str
+ rationale: str
+ conditions: Tuple[str, ...]
+ limitations: Tuple[str, ...]
+ checks: Tuple[ScenarioCheckData, ...]
+
+
+def _texts(values, minimum, maximum):
+ if type(values) is not list or not minimum <= len(values) <= maximum:
+ raise ValueError("invalid scenario list")
+ return tuple(_text(value, ITEM_LIMIT) for value in values)
+
+
+def _check(raw, purpose):
+ if type(raw) is not dict or set(raw) != set(ScenarioCheckData.__dataclass_fields__):
+ raise ValueError("invalid scenario check fields")
+ identifier = raw["id"]
+ if type(identifier) is not str or _text(identifier, ID_LIMIT) != identifier:
+ raise ValueError("invalid scenario check id")
+ goal = raw["goal"]
+ if type(goal) is not int or not 1 <= goal <= len(purpose):
+ raise ValueError("scenario check names an unknown purpose goal")
+ if raw["mode"] not in MODES or raw["evidence_kind"] not in EVIDENCE_KINDS:
+ raise ValueError("invalid scenario check mode or evidence kind")
+ return ScenarioCheckData(
+ identifier,
+ goal,
+ _text(raw["text"], CHECK_TEXT_LIMIT),
+ raw["mode"],
+ _text(raw["required_evidence"], CHECK_TEXT_LIMIT),
+ raw["evidence_kind"],
+ )
+
+
+def parse_scenario(raw, purpose):
+ """Return None for an unrecorded scenario, else a validated ScenarioData."""
+ if raw is None:
+ return None
+ if not purpose:
+ raise ValueError("a scenario record needs frozen purpose goals")
+ if type(raw) is not dict or set(raw) != set(ScenarioData.__dataclass_fields__):
+ raise ValueError("invalid scenario fields")
+ checks = raw["checks"]
+ if type(checks) is not list or not 1 <= len(checks) <= 16:
+ raise ValueError("a scenario needs one to sixteen checks")
+ parsed = tuple(_check(check, purpose) for check in checks)
+ if len({check.id for check in parsed}) != len(parsed):
+ raise ValueError("duplicate scenario check id")
+ if {check.goal for check in parsed} != set(range(1, len(purpose) + 1)):
+ raise ValueError("scenario checks must cover every purpose goal")
+ return ScenarioData(
+ _text(raw["summary"], SUMMARY_LIMIT),
+ _text(raw["rationale"], RATIONALE_LIMIT),
+ _texts(raw["conditions"], 1, 8),
+ _texts(raw["limitations"], 0, 8),
+ parsed,
+ )
diff --git a/plugin/skills/behavior-diff/scripts/reporting/schema.py b/plugin/skills/behavior-diff/scripts/reporting/schema.py
index a3410f9..2467149 100644
--- a/plugin/skills/behavior-diff/scripts/reporting/schema.py
+++ b/plugin/skills/behavior-diff/scripts/reporting/schema.py
@@ -12,6 +12,7 @@
parse_explanation,
)
from reporting.instruction import parse_diff_hunks
+from reporting.scenario import ScenarioData, parse_scenario
from reporting.summary import (
CHECK_STATES,
FINDING_STATES,
@@ -48,7 +49,7 @@
report_source,
)
-SCHEMA_VERSION = 17
+SCHEMA_VERSION = 18
RESULT_KINDS = ("good", "bad", "neutral")
@@ -155,6 +156,8 @@ class DecisionData:
# Validation reason for a returned but rejected target assessment; never set
# alongside a valid assessment.
target_rejection: Optional[str] = None
+ # Scenario and expected checks frozen before trials; None means unrecorded.
+ scenario_record: Optional[ScenarioData] = None
@dataclass(frozen=True)
@@ -295,6 +298,7 @@ def from_dict(cls, data: Dict[str, object]) -> "ReportData":
"evidence_limits",
"purpose_summary",
"target_rejection",
+ "scenario_record",
):
_field(raw_decisions, name, "decisions")
rule_diff = _expect_str(_field(data, "rule_diff", "report-data"), "rule_diff")
@@ -591,6 +595,10 @@ def _decisions(value, path, hunk_count, groups=None, final_answers=None):
if purpose_summary != raw_purpose_summary:
_invalid(path + ".purpose_summary", "plain summary of recorded purpose goals")
evidence = persisted_evidence(value.get("recorded_evidence", []))
+ raw_scenario = value.get("scenario_record")
+ scenario = parse_scenario(raw_scenario, purpose)
+ if raw_scenario is not None and _json_value(asdict(scenario)) != raw_scenario:
+ _invalid(path + ".scenario_record", "canonical scenario recorded before trials")
raw_target = value.get("target_assessment")
target = parse_target(
raw_target,
@@ -598,6 +606,7 @@ def _decisions(value, path, hunk_count, groups=None, final_answers=None):
final_answers,
evidence,
parsed_rows,
+ scenario=scenario,
)
if raw_target is not None and (
target is None or _json_value(asdict(target)) != raw_target
@@ -638,6 +647,7 @@ def _decisions(value, path, hunk_count, groups=None, final_answers=None):
target_rejection=_target_rejection(
value.get("target_rejection"), path + ".target_rejection", target
),
+ scenario_record=scenario,
)
from reporting.content import valid_decision_choices
diff --git a/plugin/skills/behavior-diff/scripts/reporting/target.py b/plugin/skills/behavior-diff/scripts/reporting/target.py
index 4f2b9e8..7d35d10 100644
--- a/plugin/skills/behavior-diff/scripts/reporting/target.py
+++ b/plugin/skills/behavior-diff/scripts/reporting/target.py
@@ -18,6 +18,8 @@
OUTCOMES = ("met", "not_met", "uncertain", "unassessable")
MODES = ("correction", "new", "change", "preservation")
+EVIDENCE_KINDS = ("output", "source_consistency", "recorded_action")
+_DEFINITION = ("id", "goal", "text", "mode", "required_evidence", "evidence_kind")
UNAVAILABLE = (
"Target assessment unavailable: no valid criterion assessment was recorded. "
"Underlying trial answers and recorded evidence remain available; this is not an assessed-empty result."
@@ -278,13 +280,23 @@ def _captured_call(record):
)
-def parse_target(raw, purpose, final_answers, evidence, rows=(), positions=None):
+def parse_target(
+ raw, purpose, final_answers, evidence, rows=(), positions=None, *, scenario=None
+):
"""Invalid assessments stay unavailable, never become successful or assessed-empty."""
- return check_target(raw, purpose, final_answers, evidence, rows, positions)[0]
+ return check_target(
+ raw, purpose, final_answers, evidence, rows, positions, scenario=scenario
+ )[0]
-def check_target(raw, purpose, final_answers, evidence, rows=(), positions=None):
- """Return (assessment, None), or (None, why a returned assessment was rejected)."""
+def check_target(
+ raw, purpose, final_answers, evidence, rows=(), positions=None, *, scenario=None
+):
+ """Return (assessment, None), or (None, why a returned assessment was rejected).
+
+ A frozen scenario fixes the criterion set: the assessment must restate every
+ check exactly and may supply only row links and per-trial outcomes.
+ """
if raw is None:
return None, None
try:
@@ -296,6 +308,11 @@ def check_target(raw, purpose, final_answers, evidence, rows=(), positions=None)
or not purpose
):
raise ValueError("invalid target criteria")
+ frozen = (
+ {check.id: check for check in scenario.checks}
+ if scenario is not None
+ else None
+ )
positions = (
positions
if positions is not None
@@ -328,12 +345,24 @@ def check_target(raw, purpose, final_answers, evidence, rows=(), positions=None)
or item["mode"] not in MODES
):
raise ValueError("invalid criterion identity")
- if item["evidence_kind"] not in (
- "output",
- "source_consistency",
- "recorded_action",
- ):
+ if item["evidence_kind"] not in EVIDENCE_KINDS:
raise ValueError("invalid criterion evidence requirement")
+ definition = (
+ identifier,
+ goal,
+ _text(item["text"], 600),
+ item["mode"],
+ _text(item["required_evidence"], 600),
+ item["evidence_kind"],
+ )
+ if frozen is not None and (
+ identifier not in frozen
+ or definition
+ != tuple(getattr(frozen[identifier], name) for name in _DEFINITION)
+ ):
+ raise ValueError(
+ "target criterion must restate its frozen scenario check"
+ )
references = item["decisions"]
if (
type(references) is not list
@@ -459,18 +488,20 @@ def check_target(raw, purpose, final_answers, evidence, rows=(), positions=None)
covered_goals.add(goal)
criteria.append(
TargetCriterionData(
- identifier,
- goal,
- _text(item["text"], 600),
- item["mode"],
- _text(item["required_evidence"], 600),
- item["evidence_kind"],
+ *definition,
tuple(positions[ref] for ref in references),
*sides,
)
)
if covered_goals != set(range(1, len(purpose) + 1)):
raise ValueError("target criteria must cover every purpose goal")
+ if frozen is not None:
+ if seen != set(frozen):
+ raise ValueError(
+ "target criteria must assess every frozen scenario check"
+ )
+ order = {check.id: index for index, check in enumerate(scenario.checks)}
+ criteria.sort(key=lambda criterion: order[criterion.id])
return TargetAssessmentData(tuple(criteria)), None
except ValueError as error:
return None, str(error)
diff --git a/plugin/skills/behavior-diff/scripts/scenario.py b/plugin/skills/behavior-diff/scripts/scenario.py
new file mode 100755
index 0000000..71dcabc
--- /dev/null
+++ b/plugin/skills/behavior-diff/scripts/scenario.py
@@ -0,0 +1,61 @@
+#!/usr/bin/env python3
+"""Read the reviewed, frozen scenario record against its frozen purpose."""
+
+import argparse
+from dataclasses import asdict
+import json
+from pathlib import Path
+import sys
+
+from purpose import read_purpose
+from reporting.scenario import parse_scenario
+from reporting.target import parse_purpose
+
+
+def unique_object(pairs):
+ result = {}
+ for key, value in pairs:
+ if key in result:
+ raise ValueError("Duplicate scenario JSON field")
+ result[key] = value
+ return result
+
+
+def read_scenario(path, purpose_path):
+ path = Path(path)
+ if path.is_symlink() or not path.is_file():
+ raise ValueError("Scenario must be a regular JSON file")
+ with path.open(encoding="utf-8") as stream:
+ raw = json.load(stream, object_pairs_hook=unique_object)
+ scenario = parse_scenario(raw, parse_purpose(read_purpose(purpose_path)))
+ return asdict(scenario) if scenario else None
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("file", type=Path)
+ parser.add_argument("--purpose-file", type=Path, required=True)
+ args = parser.parse_args()
+ try:
+ scenario = read_scenario(args.file, args.purpose_file)
+ except (OSError, ValueError, TypeError):
+ # Do not expose rejected data or private paths in error messages.
+ print(
+ "behavior-diff: invalid --scenario-file JSON; use null or "
+ "{summary,rationale,conditions,limitations,checks} with summary "
+ "<=400 and rationale <=1200 characters, 1..8 conditions and 0..8 "
+ "limitations (<=300 each), and 1..16 checks "
+ "{id,goal,text,mode,required_evidence,evidence_kind} with unique "
+ "ids (<=80), 1-based goal numbers covering every purpose goal, mode "
+ "correction|new|change|preservation, evidence_kind "
+ "output|source_consistency|recorded_action, text and "
+ "required_evidence <=600; plain text only",
+ file=sys.stderr,
+ )
+ return 2
+ print(json.dumps(scenario, ensure_ascii=False, separators=(",", ":")))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/hooks-test.sh b/tests/hooks-test.sh
index fbf7073..a8deadc 100755
--- a/tests/hooks-test.sh
+++ b/tests/hooks-test.sh
@@ -235,6 +235,56 @@ set -e
printf '%s' "$out" | grep -qF -- '--purpose-file needs a value' ||
fail "purpose missing value: missing diagnostic"
+progress 'Scenario-file validation before host discovery'
+scenario_bin=$tmp/scenario-bin && mkdir -p "$scenario_bin"
+cat >"$scenario_bin/codex" <"$scenario_purpose" <<'JSON'
+{"goals":[{"text":"Preserve the synthetic source check.","source":"session","basis":"explicit","reference":"Current conversation: selected edit"},{"text":"Report the synthetic conflicting record.","source":"session","basis":"inferred","reference":"Current conversation: selected edit"}]}
+JSON
+scenario_check='{"id":"source-check","goal":1,"text":"The answer reads the synthetic rules.","mode":"preservation","required_evidence":"A recorded read of the synthetic rules.","evidence_kind":"recorded_action"}'
+scenario_head='"summary":"Synthetic records include one conflicting row.","rationale":"The row makes the checks observable.","conditions":["One synthetic record conflicts with the rules."],"limitations":[]'
+for scenario_case in no-purpose missing malformed unknown-field dropped-goal; do
+ scenario_path=$tmp/$scenario_case-scenario.json
+ purpose_args=(--purpose-file "$scenario_purpose")
+ case "$scenario_case" in
+ no-purpose)
+ printf '{%s,"checks":[%s]}\n' "$scenario_head" "$scenario_check" >"$scenario_path"
+ purpose_args=()
+ ;;
+ malformed) printf '%s\n' 'synthetic-private-sentinel not JSON' >"$scenario_path" ;;
+ unknown-field)
+ printf '{%s,"checks":[%s],"notes":"synthetic-private-sentinel"}\n' \
+ "$scenario_head" "$scenario_check" >"$scenario_path"
+ ;;
+ dropped-goal)
+ printf '{%s,"checks":[%s]}\n' "$scenario_head" "$scenario_check" >"$scenario_path"
+ ;;
+ esac
+ set +e
+ out=$(cd "$plainrun" && PATH="$scenario_bin:$PATH" \
+ BEHAVIOR_DIFF_HOME="$tmp/scenario-invalid-home" "$runner" --agent codex \
+ --file CLAUDE.md --task t "${purpose_args[@]+"${purpose_args[@]}"}" \
+ --scenario-file "$scenario_path" 2>&1)
+ code=$?
+ set -e
+ [ "$code" -eq 2 ] || fail "$scenario_case scenario: exit $code, want 2"
+ printf '%s' "$out" | grep -qF -- '--scenario-file' ||
+ fail "$scenario_case scenario: missing option diagnostic"
+ if printf '%s' "$out" | grep -qF 'synthetic-private-sentinel'; then
+ fail "$scenario_case scenario: diagnostic echoed rejected content"
+ fi
+done
+[ ! -e "$tmp/scenario-discovery-called" ] ||
+ fail "invalid scenario reached host model discovery"
+[ ! -e "$tmp/scenario-invalid-home/runs" ] ||
+ fail "invalid scenario created a run directory"
+
progress 'Runner baseline resolution and early-stop errors'
# 21. --before-file that does not exist is a plain exit 2
@@ -486,7 +536,7 @@ mkdir -p "$capture_bin"
cat >"$capture_bin/python3" <<'SH'
#!/bin/sh
case "$1" in
- */purpose.py | */ci_context.py) exec "$REAL_PYTHON" "$@" ;;
+ */purpose.py | */scenario.py | */ci_context.py) exec "$REAL_PYTHON" "$@" ;;
*/progress.py) exit 0 ;;
esac
{
@@ -528,11 +578,14 @@ for world in git copy; do
printf '%s\n' 'before' >"$purpose_project/AGENTS.md"
cat >"$purpose_project/purpose.json" <<'JSON'
{"goals":[{"text":"Preserve the synthetic source check.","source":"session","basis":"explicit","reference":"Current conversation: selected edit"}]}
+JSON
+ cat >"$purpose_project/scenario.json" <<'JSON'
+{"summary":"Synthetic scenario summary sentinel.","rationale":"Synthetic rationale.","conditions":["Synthetic condition."],"limitations":[],"checks":[{"id":"source-check","goal":1,"text":"The answer reads the synthetic rules.","mode":"preservation","required_evidence":"A recorded read of the synthetic rules.","evidence_kind":"recorded_action"}]}
JSON
if [ "$world" = git ]; then
git -C "$purpose_project" init -q
git -C "$purpose_project" -c user.email=synthetic@example.invalid \
- -c user.name=synthetic add AGENTS.md purpose.json
+ -c user.name=synthetic add AGENTS.md purpose.json scenario.json
git -C "$purpose_project" -c user.email=synthetic@example.invalid \
-c user.name=synthetic commit -qm baseline
before_args=()
@@ -549,20 +602,30 @@ JSON
PI_ARGS_FILE="$tmp/purpose-$world-args" PI_ENV_FILE="$tmp/purpose-$world-env" \
"$runner" --agent pi --model test/pi-model --file AGENTS.md \
--task 'Inspect the synthetic project.' --purpose-file purpose.json \
+ --scenario-file scenario.json \
--fast "${before_args[@]+"${before_args[@]}"}" >/dev/null
)
run=$(find "$home/runs" -mindepth 1 -maxdepth 1 -type d)
jq -e '.purpose.goals[0].text == "Preserve the synthetic source check."' \
"$run/config.json" >/dev/null || fail "$world purpose missing from config"
+ jq -e '.scenario == "Inspect the synthetic project."
+ and .scenario_record.summary == "Synthetic scenario summary sentinel."
+ and .scenario_record.checks[0].id == "source-check"' \
+ "$run/config.json" >/dev/null || fail "$world scenario record missing from config"
grep -qxF 'Inspect the synthetic project.' "$run/task.md" ||
fail "$world purpose contaminated task"
for side in before after; do
[ ! -e "$run/$side-1/project/purpose.json" ] ||
fail "$world purpose copied into $side project"
+ [ ! -e "$run/$side-1/project/scenario.json" ] ||
+ fail "$world scenario record copied into $side project"
done
if grep -qF 'Preserve the synthetic source check' "$tmp/purpose-$world-args"; then
fail "$world purpose leaked into trial arguments"
fi
+ if grep -qF 'Synthetic scenario summary sentinel' "$tmp/purpose-$world-args" "$run/task.md"; then
+ fail "$world scenario record leaked into trial inputs"
+ fi
done
progress 'Front door preserves nested purpose paths and restores instructions'
@@ -580,6 +643,9 @@ JSON
cat >"$nested_repo/nested/purpose.json" <<'JSON'
{"goals":[{"text":"Nested goal must be frozen.","source":"session","basis":"explicit","reference":"Synthetic nested context"}]}
JSON
+cat >"$nested_repo/nested/scenario.json" <<'JSON'
+{"summary":"Nested scenario must be frozen.","rationale":"Synthetic rationale.","conditions":["Synthetic condition."],"limitations":[],"checks":[{"id":"nested-check","goal":1,"text":"The answer reads the synthetic rule.","mode":"new","required_evidence":"The final answer names the synthetic rule.","evidence_kind":"output"}]}
+JSON
git -C "$nested_repo" add AGENTS.md purpose.json nested
git -C "$nested_repo" commit -qm baseline
cp "$nested_repo/AGENTS.md" "$tmp/nested-original.md"
@@ -591,16 +657,20 @@ home=$tmp/nested-purpose-home
PI_ARGS_FILE="$tmp/nested-purpose-args" PI_ENV_FILE="$tmp/nested-purpose-env" \
"$front_door" rule.md --into AGENTS.md --agent pi --model test/pi-model \
--task 'Inspect the synthetic project.' --purpose-file ./purpose.json \
- --fast >"$tmp/nested-purpose-output"
+ --scenario-file ./scenario.json --fast >"$tmp/nested-purpose-output"
)
run=$(find "$home/runs" -mindepth 1 -maxdepth 1 -type d)
jq -e '.purpose.goals[0].text == "Nested goal must be frozen."' \
"$run/config.json" >/dev/null || fail "front door froze root purpose instead of nested purpose"
+jq -e '.scenario_record.summary == "Nested scenario must be frozen."' \
+ "$run/config.json" >/dev/null || fail "front door lost the nested scenario record"
cmp -s "$tmp/nested-original.md" "$nested_repo/AGENTS.md" ||
fail "front door did not restore instructions"
for side in before after; do
[ ! -e "$run/$side-1/project/nested/purpose.json" ] ||
fail "front door exposed supplied nested purpose in $side project"
+ [ ! -e "$run/$side-1/project/nested/scenario.json" ] ||
+ fail "front door exposed supplied nested scenario in $side project"
[ -f "$run/$side-1/project/purpose.json" ] ||
fail "front door removed the unrelated root purpose"
done
@@ -664,6 +734,9 @@ if find "$home/runs" -name trace.jsonl | grep -q .; then
fail "unsafe purpose project produced a launched trial"
fi
+progress 'Scenario record contract'
+python3 "$here/scenario-contract-test.py"
+
progress 'Bounded recorded assessment evidence'
python3 "$here/recorded-evidence-test.py"
diff --git a/tests/recorded-evidence-test.py b/tests/recorded-evidence-test.py
index 98d472e..fd1d23a 100755
--- a/tests/recorded-evidence-test.py
+++ b/tests/recorded-evidence-test.py
@@ -143,6 +143,47 @@ def test_recorded_content_not_current_file_with_returned_range(self):
self.assertIn('"offset": 20', record["source"])
self.assertEqual(record["status"], "available")
+ def test_pull_request_reads_retain_returns_through_execution_adapters(self):
+ for tool in ("Bash", "exec_command", "fixture__exec"):
+ for text in ("gh pr view 7", "gh pr diff 7"):
+ with self.subTest(tool=tool, command=text):
+ events = command("read-pr", text, "Synthetic source: cloud only.")
+ events[0]["message"]["content"][0]["name"] = tool
+ self.trace(events)
+ record = self.records()[0]
+ self.assertEqual(record["status"], "available")
+ self.assertEqual(record["text"], "Synthetic source: cloud only.")
+ self.assertEqual(record["range"]["end"], len(record["text"]))
+
+ def test_execution_adapters_do_not_admit_mutations_or_compound_commands(self):
+ for text in (
+ "gh pr edit 7 --body altered",
+ "gh pr merge 7",
+ "gh api -X POST repos/example/project",
+ "gh pr view 7; gh pr merge 7",
+ "gh pr diff 7 | cat",
+ "gh pr view $(cat .env)",
+ ):
+ with self.subTest(command=text):
+ events = command("not-read", text)
+ events[0]["message"]["content"][0]["name"] = "fixture__exec"
+ self.trace(events)
+ record = self.records()[0]
+ self.assertEqual((record["status"], record["text"]), ("omitted", ""))
+
+ def test_pull_request_returns_keep_privacy_and_error_boundaries(self):
+ for output, result_fields, status in (
+ ("fictional.person@example.invalid", {}, "omitted"),
+ ("permission denied", {"is_error": True}, "unavailable"),
+ ):
+ with self.subTest(status=status):
+ events = command("read-pr", "gh pr view 7", output)
+ events[0]["message"]["content"][0]["name"] = "fixture__exec"
+ events[1]["message"]["content"][0].update(result_fields)
+ self.trace(events)
+ record = self.records()[0]
+ self.assertEqual((record["status"], record["text"]), (status, ""))
+
def test_missing_return_and_self_report_are_not_proof(self):
self.trace(call("read-1")[:1])
self.assertEqual(self.records()[0]["status"], "unavailable")
@@ -378,7 +419,9 @@ def check(self, raw):
return check_target(raw, PURPOSE, ANSWERS, evidence)
def test_recorded_call_proves_action_despite_omitted_return(self):
- self.recorded(command("view-1", "gh pr view 7"))
+ self.recorded(
+ command("view-1", "gh pr view 7", "fictional.person@example.invalid")
+ )
record = collect_run_evidence(self.root)["trials"]["after-1"][0]
self.assertEqual((record["status"], record["text"]), ("omitted", ""))
target, rejection = self.check(criterion())
@@ -387,7 +430,7 @@ def test_recorded_call_proves_action_despite_omitted_return(self):
self.assertEqual(target.criteria[0].before[0].outcome, "not_met")
def test_call_without_returned_content_cannot_prove_source_agreement(self):
- self.recorded(command("view-1", "gh pr view 7"))
+ self.recorded(command("view-1", "gh pr view 7")[:1])
raw = criterion(
"source_consistency",
excerpt="citing pull request 7",
diff --git a/tests/report-ci-context-test.py b/tests/report-ci-context-test.py
index 31a0d94..4e8baee 100755
--- a/tests/report-ci-context-test.py
+++ b/tests/report-ci-context-test.py
@@ -81,7 +81,7 @@ def _header(page):
def assert_no_context_unchanged():
report = _load(_config())
raw = _roundtrip(report)
- assert raw["schema_version"] == 17
+ assert raw["schema_version"] == 18
assert raw["metadata"]["ci_context"] is None
page, markdown = _render_offline(report)
for absent in ("source-line", "revision-pair", "local-paths", "Workflow"):
diff --git a/tests/report-schema-test.py b/tests/report-schema-test.py
index daa1c94..b26db03 100755
--- a/tests/report-schema-test.py
+++ b/tests/report-schema-test.py
@@ -43,7 +43,7 @@
def synthetic_raw():
raw = {
- "schema_version": 17,
+ "schema_version": 18,
"metadata": {
"model": "synthetic/model",
"mode": "review",
@@ -255,7 +255,13 @@ def synthetic_raw():
recorded_evidence=[],
target_assessment=None,
evidence_limits=None,
+ scenario_record=None,
)
+ return derive_summary(raw)
+
+
+def derive_summary(raw):
+ """Recompute the derived Summary and intent after changing saved decisions."""
from reporting.schema import _decisions, _metadata, _variants
from reporting.summary import build_intent, build_summary
@@ -369,6 +375,362 @@ def assert_branch_membership_validation(raw):
)
+SCENARIO_PURPOSE = [
+ {
+ "text": "Answers name the evidence they reviewed.",
+ "source": "session",
+ "basis": "explicit",
+ "reference": "Synthetic owner request before trials",
+ }
+]
+
+
+def scenario_record(payload=""):
+ """A synthetic record frozen before trials; payload exercises escaping."""
+ return {
+ "summary": "Two synthetic file reviews must say what they read." + payload,
+ "rationale": "The task hides the reviewed file behind a search step." + payload,
+ "conditions": [
+ "Both versions receive the same synthetic file." + payload,
+ "No network access is available.",
+ ],
+ "limitations": ["One synthetic task only." + payload],
+ "checks": [
+ {
+ "id": "names-evidence",
+ "goal": 1,
+ "text": "The final answer names the reviewed file." + payload,
+ "mode": "new",
+ "required_evidence": "The exact final answer." + payload,
+ "evidence_kind": "output",
+ }
+ ],
+ }
+
+
+def scenario_target(record, after_outcome="met"):
+ """Outcomes for exactly the frozen checks, cited from exact final answers."""
+ finals = {
+ "before": ("Before first answer", "Before second answer"),
+ "after": ("After first answer", "After second answer"),
+ }
+
+ def side(name, outcome):
+ return [
+ {
+ "trial": f"{name}-{number}",
+ "outcome": outcome,
+ "refs": [f"{name}-{number}:answer"],
+ "explanation": "Synthetic assessment of the exact answer.",
+ "output_excerpt": answer,
+ }
+ for number, answer in enumerate(finals[name], 1)
+ ]
+
+ return {
+ "criteria": [
+ {
+ **check,
+ "decisions": [2],
+ "before": side("before", "not_met"),
+ "after": side("after", after_outcome),
+ }
+ for check in record["checks"]
+ ]
+ }
+
+
+def scenario_raw(raw, record, target, rejection=None):
+ changed = copy.deepcopy(raw)
+ changed["decisions"].update(
+ purpose=copy.deepcopy(SCENARIO_PURPOSE),
+ scenario_record=record,
+ target_assessment=target,
+ target_rejection=rejection,
+ )
+ return derive_summary(changed)
+
+
+def assert_rejected_at(raw, field):
+ try:
+ ReportData.from_dict(raw)
+ except ValueError as error:
+ assert field in str(error), (field, str(error))
+ else:
+ raise AssertionError(f"invalid {field} was accepted")
+
+
+def _between(page, start, end):
+ return page[page.index(start) : page.index(end, page.index(start))]
+
+
+def _plain_text(markup):
+ return html.unescape(re.sub(r"<[^>]+>", "", markup))
+
+
+def assert_scenario_record_contract(raw):
+ """Frozen records round-trip strictly, bind the target, and stay visible."""
+ from reporting import load
+ from reporting.render_html import render_artifact
+ from reporting.render_markdown import render_markdown
+ from reporting.scenario import ScenarioData
+ from reporting.summary import FINDING_HEADLINES, UNMET
+
+ record = scenario_record()
+ frozen = assert_round_trip(scenario_raw(raw, record, scenario_target(record)))
+ assert isinstance(frozen.decisions.scenario_record, ScenarioData)
+ assert frozen.decisions.scenario_record.conditions == tuple(record["conditions"])
+ assert [check.text for check in frozen.summary.checks] == [
+ record["checks"][0]["text"]
+ ]
+ assert frozen.summary.state == "improved"
+ page = render_artifact(frozen, "")
+ markdown = render_markdown(frozen)
+ expectation = _between(page, "story-expectation", "story-method")
+ method = _between(page, "story-method", "story-evidence")
+ evidence = _between(page, "story-evidence", "story-next")
+ assert content.FROZEN_CHECKS_LABEL in expectation
+ assert content.ASSESSOR_CHECKS_LABEL not in page
+ assert content.FROZEN_UNASSESSED_MEANING not in page
+ for heading, text in (
+ (content.SUMMARY_SCENARIO_HEADING, record["summary"]),
+ (content.SUMMARY_RATIONALE_HEADING, record["rationale"]),
+ (content.SUMMARY_CONDITIONS_HEADING, record["conditions"][0]),
+ (content.SUMMARY_DATA_HEADING, record["limitations"][0]),
+ (content.SUMMARY_TASK_LABEL, raw["content"]["task"]),
+ ):
+ assert heading in method and html.escape(text) in method, heading
+ assert heading in markdown, heading
+ # The blinded quiz keeps Step 4; preparation text must stay outside it.
+ assert html.escape(text) not in evidence, heading
+ assert content.SCENARIO_UNRECORDED not in page
+ assert content.FROZEN_CHECKS_LABEL in markdown
+
+ missing = copy.deepcopy(frozen.to_dict())
+ del missing["decisions"]["scenario_record"]
+ assert_rejected_at(missing, "scenario_record")
+ malformed = []
+ for key, value in (
+ ("extra", "field"),
+ ("summary", "x" * 401),
+ ("conditions", []),
+ ("limitations", "One synthetic task only."),
+ ("checks", []),
+ ):
+ broken = copy.deepcopy(record)
+ broken[key] = value
+ malformed.append(broken)
+ for key, value in (
+ ("goal", 2),
+ ("goal", True),
+ ("mode", "future"),
+ ("evidence_kind", "anything"),
+ ("id", " names-evidence"),
+ ("text", "markup"),
+ ):
+ broken = copy.deepcopy(record)
+ broken["checks"][0][key] = value
+ malformed.append(broken)
+ duplicate = copy.deepcopy(record)
+ duplicate["checks"].append(copy.deepcopy(duplicate["checks"][0]))
+ malformed.append(duplicate)
+ padded = copy.deepcopy(record)
+ padded["summary"] = " " + padded["summary"]
+ # A saved report must hold the canonical record, not merely a parseable one.
+ for broken in (*malformed, padded):
+ tampered = copy.deepcopy(frozen.to_dict())
+ tampered["decisions"]["scenario_record"] = broken
+ assert_rejected(tampered)
+ unpurposed = copy.deepcopy(frozen.to_dict())
+ unpurposed["decisions"]["purpose"] = []
+ assert_rejected(unpurposed)
+
+ # A target must restate every frozen check exactly; nothing is substituted.
+ for field, value in (
+ ("text", "A looser restatement of the check."),
+ ("required_evidence", "Any evidence."),
+ ("mode", "change"),
+ ("evidence_kind", "recorded_action"),
+ ("id", "other-check"),
+ ):
+ tampered = copy.deepcopy(frozen.to_dict())
+ tampered["decisions"]["target_assessment"]["criteria"][0][field] = value
+ assert_rejected_at(tampered, "target_assessment")
+ extra = copy.deepcopy(frozen.to_dict())
+ added = copy.deepcopy(extra["decisions"]["target_assessment"]["criteria"][0])
+ added["id"] = "assessor-added"
+ extra["decisions"]["target_assessment"]["criteria"].append(added)
+ assert_rejected_at(extra, "target_assessment")
+
+ # Without a valid assessment, frozen checks stay visible and decide nothing.
+ for rejection in (None, "target criterion must restate its frozen scenario check"):
+ unassessed = assert_round_trip(scenario_raw(raw, record, None, rejection))
+ assert unassessed.summary.checks == ()
+ assert unassessed.summary.state == "unavailable"
+ heading = content.summary_heading(unassessed)
+ assert heading != UNMET
+ assert unassessed.summary.headline == FINDING_HEADLINES["unavailable"]
+ assert [item.kind for item in content.summary_open_items(unassessed)] == [
+ "unassessed"
+ ]
+ page = render_artifact(unassessed, "")
+ markdown = render_markdown(unassessed)
+ expectation = _between(page, "story-expectation", "story-method")
+ for text in (
+ content.FROZEN_CHECKS_LABEL,
+ content.FROZEN_UNASSESSED_MEANING,
+ html.escape(record["checks"][0]["text"]),
+ html.escape(
+ content.required_evidence_text(
+ unassessed.decisions.scenario_record.checks[0]
+ )
+ ),
+ ):
+ assert text in expectation, text
+ assert content.FROZEN_UNASSESSED_NOTE in _plain_text(expectation)
+ assert 'data-state="unassessed"' in expectation
+ assert expectation.count(content.FROZEN_UNASSESSED) >= 2
+ assert UNMET not in page
+ assert f'id="{content.target_criterion_anchor(1)}"' in page
+ assert f'href="#{content.target_criterion_anchor(1)}"' in page
+ assert f'' in markdown
+
+ # Assessor-written criteria without a frozen record are labeled as such.
+ legacy_raw = scenario_raw(raw, None, scenario_target(record))
+ legacy = assert_round_trip(legacy_raw)
+ assert legacy.decisions.scenario_record is None
+ page = render_artifact(legacy, "")
+ assert content.ASSESSOR_CHECKS_LABEL in _between(
+ page, "story-expectation", "story-method"
+ )
+ assert content.FROZEN_CHECKS_LABEL not in page
+ assert html.escape(content.SCENARIO_UNRECORDED) in _between(
+ page, "story-method", "story-evidence"
+ )
+ assert content.ASSESSOR_CHECKS_LABEL in render_markdown(legacy)
+ unrecorded = ReportData.from_dict(raw)
+ page = render_artifact(unrecorded, "")
+ assert html.escape(content.SCENARIO_UNRECORDED) in page
+ assert "expected-checks" not in page
+
+ # Every new record field is escaped in both formats.
+ payload = ' Compare A & B when x < 3 > 1 "quoted" | piped.'
+ unsafe_record = scenario_record(payload)
+ for target in (scenario_target(unsafe_record), None):
+ unsafe = assert_round_trip(scenario_raw(raw, unsafe_record, target))
+ page = render_artifact(unsafe, "")
+ markdown = render_markdown(unsafe)
+ assert "x < 3" not in page and "A & B" not in page
+ assert page.count("x < 3 > 1 "quoted" | piped") >= 6
+ assert "x < 3" not in markdown and "A & B" not in markdown
+ assert markdown.count('x < 3 > 1 "quoted" | piped') >= 6
+ assert_unchanged_scripts(render_artifact(frozen, ""), page)
+
+ # Loading binds the configuration's record; persisted tampering rejects the target.
+ config = {"purpose": {"goals": SCENARIO_PURPOSE}, "scenario_record": record}
+ canonical = load._scenario_record(config)
+ assert canonical == frozen.decisions.scenario_record
+ assert load._scenario_record({"scenario_record": None}) is None
+ for broken in (*malformed, None):
+ try:
+ load._scenario_record(
+ {"purpose": config["purpose"], "scenario_record": broken}
+ if broken is not None
+ else {"scenario_record": record}
+ )
+ except ValueError:
+ pass
+ else:
+ raise AssertionError("a malformed configured scenario was accepted")
+ finals = {
+ side: {trial["name"]: trial["final"] for trial in variant["trials"]}
+ for side, variant in raw["variants"].items()
+ }
+ persisted_target = scenario_target(record)
+ for criterion in persisted_target["criteria"]:
+ criterion["decisions"] = []
+
+ def convert(**changes):
+ saved = {
+ "chain": [],
+ "purpose": copy.deepcopy(SCENARIO_PURPOSE),
+ "target_assessment": copy.deepcopy(persisted_target),
+ "scenario_record": copy.deepcopy(record),
+ **changes,
+ }
+ return load._convert_decisions(
+ saved,
+ 2,
+ 2,
+ "",
+ final_answers=finals,
+ purpose=config["purpose"],
+ scenario=canonical,
+ )
+
+ bound = convert()
+ assert bound.target_assessment is not None and bound.target_rejection is None
+ assert bound.scenario_record == canonical
+ without_key = {
+ "chain": [],
+ "purpose": copy.deepcopy(SCENARIO_PURPOSE),
+ "target_assessment": copy.deepcopy(persisted_target),
+ }
+ assert (
+ load._convert_decisions(
+ without_key,
+ 2,
+ 2,
+ "",
+ final_answers=finals,
+ purpose=config["purpose"],
+ scenario=canonical,
+ ).target_assessment
+ is not None
+ )
+ changed_record = copy.deepcopy(record)
+ changed_record["checks"][0]["text"] = "A check rewritten after the trials."
+ changed_goals = copy.deepcopy(SCENARIO_PURPOSE)
+ changed_goals[0]["text"] = "A goal rewritten after the trials."
+ for changes in (
+ {"scenario_record": changed_record},
+ {"scenario_record": None},
+ {"purpose": changed_goals},
+ ):
+ rejected = convert(**changes)
+ assert rejected.target_assessment is None, changes
+ assert rejected.target_rejection == load.SCENARIO_MISMATCH, changes
+ assert rejected.purpose == canonical_goals(config), changes
+ assert rejected.scenario_record == canonical
+ restated = copy.deepcopy(persisted_target)
+ restated["criteria"][0]["text"] = "A looser restatement of the check."
+ rejected = convert(target_assessment=restated)
+ assert rejected.target_assessment is None and rejected.target_rejection
+ unconfigured = load._convert_decisions(
+ {
+ "chain": [],
+ "purpose": copy.deepcopy(SCENARIO_PURPOSE),
+ "target_assessment": copy.deepcopy(persisted_target),
+ "scenario_record": copy.deepcopy(record),
+ },
+ 2,
+ 2,
+ "",
+ final_answers=finals,
+ purpose=config["purpose"],
+ scenario=None,
+ )
+ assert unconfigured.target_assessment is None
+ assert unconfigured.target_rejection == load.SCENARIO_MISMATCH
+ assert unconfigured.scenario_record is None
+
+
+def canonical_goals(config):
+ from reporting.target import parse_purpose
+
+ return parse_purpose(config["purpose"])
+
+
def summary_narrative(context=None, before_start=None, after_start=None):
"""Synthetic Summary copy for the action row of synthetic_raw()."""
return {
@@ -2401,6 +2763,21 @@ def assert_gallery_reports():
self_reported.command_flow.after,
):
assert branch.prefix == branch.paths == ()
+ # Only the configured record is shown; no other case gains one afterward.
+ for name, report in reports.items():
+ record = report.decisions.scenario_record
+ assert (record is not None) == (name == "target-lost-exception"), name
+ frozen = reports["target-lost-exception"].decisions
+ assert [criterion.id for criterion in frozen.target_assessment.criteria] == [
+ check.id for check in frozen.scenario_record.checks
+ ]
+ frozen_page = (root / "target-lost-exception" / "report.html").read_text()
+ assert content.FROZEN_CHECKS_LABEL in frozen_page
+ assert content.ASSESSOR_CHECKS_LABEL not in frozen_page
+ assert (
+ content.ASSESSOR_CHECKS_LABEL
+ in (root / "target-mixed" / "report.html").read_text()
+ )
assert_visual_summaries(reports)
assert_derived_summary_claims(reports)
assert_instruction_intent(reports, root)
@@ -2840,6 +3217,7 @@ def main():
assert_branch_membership_validation(raw)
assert_summary_context_and_starts(raw)
assert_purpose_summary_contract(raw)
+ assert_scenario_record_contract(raw)
assert_trial_summary_pipeline()
assert_trial_summary_rendering(report)
assert_summary_evidence(report)
@@ -2875,7 +3253,7 @@ def main():
assert_rejected(dict(raw, schema_version=9))
assert_rejected(dict(raw, schema_version=10))
# Earlier report-data shapes are rejected; saved runs regenerate from decisions.
- for version in (11, 12, 13, 14, 15, 16):
+ for version in (11, 12, 13, 14, 15, 16, 17):
assert_rejected(dict(raw, schema_version=version))
assert_rejected(dict(raw, schema_version=True))
assert_rejected(dict(raw, schema_version=1.0))
diff --git a/tests/report_fixtures.py b/tests/report_fixtures.py
index 48009ab..da2203b 100644
--- a/tests/report_fixtures.py
+++ b/tests/report_fixtures.py
@@ -1943,6 +1943,28 @@ def _target_report(run, scenario):
}
]
}
+ frozen_check = {
+ "id": "export-qualification",
+ "goal": 1,
+ "text": "Customer claims reflect the recorded format limits and supported query exception.",
+ "mode": "preservation" if preserve else "correction",
+ "required_evidence": "The summary claim, implementation limits, and actual customer-facing draft.",
+ "evidence_kind": "source_consistency",
+ }
+ if scenario.name == "target-lost-exception":
+ # Authored before the synthetic trials; the assessment may only fill outcomes.
+ config["scenario_record"] = {
+ "summary": "An agent drafts customer release notes from a promotional summary and the implementation it contradicts.",
+ "rationale": "The summary overstates export limits, and the implementation also supports one exception, so a draft can fix the limits yet still misstate the exception.",
+ "conditions": [
+ "Both versions receive the same summary and implementation files.",
+ "The draft is prepared, not published.",
+ ],
+ "limitations": [
+ "One fictional export change; other release notes are not tested."
+ ],
+ "checks": [frozen_check],
+ }
_write_json(run / "config.json", config)
if missing:
for side in trials:
@@ -1971,12 +1993,7 @@ def _target_report(run, scenario):
_row("Editorial note", "Which editorial detail is named?", "answer", trials),
]
criterion = {
- "id": "export-qualification",
- "goal": 1,
- "text": "Customer claims reflect the recorded format limits and supported query exception.",
- "mode": "preservation" if preserve else "correction",
- "required_evidence": "The summary claim, implementation limits, and actual customer-facing draft.",
- "evidence_kind": "source_consistency",
+ **frozen_check,
"decisions": [2],
**assessments,
}
diff --git a/tests/scenario-contract-test.py b/tests/scenario-contract-test.py
new file mode 100755
index 0000000..40ee9ee
--- /dev/null
+++ b/tests/scenario-contract-test.py
@@ -0,0 +1,195 @@
+#!/usr/bin/env python3
+"""Synthetic scenario-record validation; no models, transcripts, or network calls."""
+
+import copy
+import json
+import os
+from pathlib import Path
+import subprocess
+import sys
+import tempfile
+import unittest
+
+SCRIPTS = Path(__file__).resolve().parents[1] / "plugin/skills/behavior-diff/scripts"
+sys.path.insert(0, str(SCRIPTS))
+from scenario import read_scenario
+
+GOAL = {
+ "text": "Preserve the synthetic source check.",
+ "source": "session",
+ "basis": "explicit",
+ "reference": "Current conversation: selected edit",
+}
+SECOND_GOAL = dict(GOAL, text="Report the synthetic conflicting record.")
+CHECK = {
+ "id": "source-check",
+ "goal": 1,
+ "text": "The answer reads the synthetic rules before summarizing.",
+ "mode": "preservation",
+ "required_evidence": "A recorded read of the synthetic rules file.",
+ "evidence_kind": "recorded_action",
+}
+SCENARIO = {
+ "summary": "Synthetic records include one row that conflicts with the rules.",
+ "rationale": "The conflicting row makes the source check observable.",
+ "conditions": ["One synthetic record conflicts with the project rules."],
+ "limitations": ["Only one conflicting row is included."],
+ "checks": [CHECK],
+}
+SENTINEL = "synthetic-private-sentinel-7f3a"
+
+
+class ScenarioTests(unittest.TestCase):
+ def setUp(self):
+ self.temp = tempfile.TemporaryDirectory(prefix="scenario-contract-")
+ self.addCleanup(self.temp.cleanup)
+ self.root = Path(self.temp.name)
+ self.path = self.root / "scenario.json"
+ self.purpose = self.root / "purpose.json"
+ self.write_purpose([GOAL])
+
+ def write_purpose(self, goals):
+ value = {"goals": goals} if goals else None
+ self.purpose.write_text(json.dumps(value), encoding="utf-8")
+
+ def read(self, value):
+ self.path.write_text(json.dumps(value), encoding="utf-8")
+ # Compare the serialized record the runner freezes, not Python tuples.
+ return json.loads(json.dumps(read_scenario(self.path, self.purpose)))
+
+ def rejects(self, value):
+ with self.assertRaises((ValueError, TypeError)):
+ self.read(value)
+
+ def test_null_and_canonical_record(self):
+ self.assertIsNone(self.read(None))
+ self.assertEqual(self.read(SCENARIO), SCENARIO)
+ padded = copy.deepcopy(SCENARIO)
+ padded["summary"] = " " + SCENARIO["summary"] + " "
+ self.assertEqual(self.read(padded)["summary"], SCENARIO["summary"])
+ no_limits = dict(SCENARIO, limitations=[])
+ self.assertEqual(self.read(no_limits), no_limits)
+
+ def test_unknown_and_missing_fields(self):
+ self.rejects({})
+ self.rejects([])
+ self.rejects(dict(SCENARIO, outcome="met"))
+ self.rejects(dict(SCENARIO, notes="raw private notes"))
+ partial = dict(SCENARIO)
+ del partial["limitations"]
+ self.rejects(partial)
+ self.rejects(dict(SCENARIO, checks=[dict(CHECK, before=[])]))
+ partial_check = dict(CHECK)
+ del partial_check["required_evidence"]
+ self.rejects(dict(SCENARIO, checks=[partial_check]))
+
+ def test_check_identity_and_goal_references(self):
+ self.rejects(dict(SCENARIO, checks=[CHECK, dict(CHECK, text="Other check.")]))
+ for identifier in ("", " source-check", "x" * 81, 7, "a\nb", "**id**"):
+ with self.subTest(identifier=identifier):
+ self.rejects(dict(SCENARIO, checks=[dict(CHECK, id=identifier)]))
+ for goal in (0, 2, -1, True, "1", 1.0, None):
+ with self.subTest(goal=goal):
+ self.rejects(dict(SCENARIO, checks=[dict(CHECK, goal=goal)]))
+ self.assertEqual(
+ self.read(dict(SCENARIO, checks=[dict(CHECK, id="x" * 80)]))["checks"][0][
+ "id"
+ ],
+ "x" * 80,
+ )
+
+ def test_every_purpose_goal_needs_a_check(self):
+ self.write_purpose([GOAL, SECOND_GOAL])
+ self.rejects(SCENARIO)
+ both = dict(SCENARIO, checks=[CHECK, dict(CHECK, id="conflict", goal=2)])
+ self.assertEqual(self.read(both), both)
+ self.write_purpose(None)
+ self.rejects(SCENARIO)
+ self.assertIsNone(self.read(None))
+
+ def test_enums_bounds_and_plain_text(self):
+ for field, value in (
+ ("mode", "regression"),
+ ("mode", ["new"]),
+ ("evidence_kind", "transcript"),
+ ("text", ""),
+ ("text", "x" * 601),
+ ("text", "Private\x00value"),
+ ("text", "See [link](https://example.invalid)"),
+ ("required_evidence", "x" * 601),
+ ):
+ with self.subTest(field=field, value=value):
+ self.rejects(dict(SCENARIO, checks=[dict(CHECK, **{field: value})]))
+ for field, value in (
+ ("summary", "x" * 401),
+ ("summary", ""),
+ ("rationale", "x" * 1201),
+ ("conditions", []),
+ ("conditions", ["x"] * 9),
+ ("conditions", ["x" * 301]),
+ ("conditions", "One condition."),
+ ("limitations", ["x"] * 9),
+ ("limitations", [""]),
+ ("checks", []),
+ ("checks", [dict(CHECK, id=f"c{n}") for n in range(17)]),
+ ):
+ with self.subTest(field=field):
+ self.rejects(dict(SCENARIO, **{field: value}))
+ maximum = dict(
+ SCENARIO,
+ summary="s" * 400,
+ rationale="r" * 1200,
+ conditions=["c" * 300] * 8,
+ limitations=["l" * 300] * 8,
+ checks=[
+ dict(CHECK, id=f"c{n}", text="t" * 600, required_evidence="e" * 600)
+ for n in range(16)
+ ],
+ )
+ self.assertEqual(self.read(maximum), maximum)
+
+ def test_files_duplicate_keys_and_invalid_purpose(self):
+ for text in ("not json", "null null", '{"summary":"a","summary":"b"}'):
+ self.path.write_text(text, encoding="utf-8")
+ with self.subTest(text=text), self.assertRaises(ValueError):
+ read_scenario(self.path, self.purpose)
+ with self.assertRaises(ValueError):
+ read_scenario(self.root / "missing.json", self.purpose)
+ self.path.write_text(json.dumps(SCENARIO), encoding="utf-8")
+ linked = self.root / "linked.json"
+ linked.symlink_to(self.path)
+ with self.assertRaises(ValueError):
+ read_scenario(linked, self.purpose)
+ self.purpose.write_text('{"goals":[]}', encoding="utf-8")
+ with self.assertRaises(ValueError):
+ read_scenario(self.path, self.purpose)
+
+ def test_cli_requires_purpose_and_never_echoes_rejected_input(self):
+ def run(*arguments):
+ return subprocess.run(
+ [sys.executable, str(SCRIPTS / "scenario.py"), *arguments],
+ capture_output=True,
+ text=True,
+ env=dict(os.environ, PYTHONDONTWRITEBYTECODE="1"),
+ )
+
+ self.path.write_text(json.dumps(SCENARIO), encoding="utf-8")
+ accepted = run(str(self.path), "--purpose-file", str(self.purpose))
+ self.assertEqual(accepted.returncode, 0, accepted.stderr)
+ self.assertEqual(json.loads(accepted.stdout), SCENARIO)
+ self.assertEqual(run(str(self.path)).returncode, 2)
+ private = self.root / SENTINEL
+ private.mkdir()
+ leaked = private / "scenario.json"
+ leaked.write_text(
+ json.dumps(dict(SCENARIO, private_note=SENTINEL)), encoding="utf-8"
+ )
+ rejected = run(str(leaked), "--purpose-file", str(self.purpose))
+ self.assertEqual(rejected.returncode, 2)
+ self.assertEqual(rejected.stdout, "")
+ self.assertIn("--scenario-file", rejected.stderr)
+ self.assertNotIn(SENTINEL, rejected.stderr)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/shared-data-test.py b/tests/shared-data-test.py
index 60aabd3..65292dd 100755
--- a/tests/shared-data-test.py
+++ b/tests/shared-data-test.py
@@ -38,6 +38,22 @@
}
]
}
+SCENARIO = {
+ "summary": "Synthetic records include one negative amount.",
+ "rationale": "A negative row tests whether every row is still cited.",
+ "conditions": ["One synthetic record has a negative amount."],
+ "limitations": ["Only two synthetic records are included."],
+ "checks": [
+ {
+ "id": "cite-rows",
+ "goal": 1,
+ "text": "The answer cites each synthetic record row.",
+ "mode": "new",
+ "required_evidence": "The final answer names both synthetic rows.",
+ "evidence_kind": "output",
+ }
+ ],
+}
SOURCE = {
"forge": "github",
"repository_url": "https://github.com/example-org/synthetic-skills",
@@ -59,6 +75,7 @@
"docs/status.md": b"Committed synthetic status.\n",
"run.sh": (b"#!/bin/sh\necho synthetic\n", True),
"notes/purpose.json": json.dumps(PURPOSE).encode(),
+ "notes/scenario.json": json.dumps(SCENARIO).encode(),
}
WORKING = {
TARGET: AFTER,
@@ -71,7 +88,7 @@
"data/tool.sh": (b"#!/bin/sh\ncat data/records.csv\n", True),
"docs/status.md": b"Shared synthetic status.\n",
}
-PRIVATE = ["notes/purpose.json"]
+PRIVATE = ["notes/purpose.json", "notes/scenario.json"]
TRIAL_MODEL = "synthetic-trial-model"
EXTRACT_MODEL = "synthetic-extract-model"
@@ -1033,6 +1050,8 @@ def run_stage(self, stage, agent, purpose, fast):
EXTRACT_MODEL,
"--purpose-file",
str(purpose),
+ "--scenario-file",
+ str(purpose.parent / "scenario.json"),
"--source-file",
str(self.source),
]
@@ -1068,6 +1087,7 @@ def check_run(self, stage, agent, trials, before, purpose):
text=True,
).stdout
self.assertEqual(config["purpose"], json.loads(expected_purpose))
+ self.assertEqual(config["scenario_record"], SCENARIO)
scratch = self.git(stage / "project", "rev-parse", "HEAD").decode().strip()
for text in (config_text, report):
self.assertNotIn(scratch, text)
diff --git a/tests/target_contract.py b/tests/target_contract.py
index d199223..d5852e2 100644
--- a/tests/target_contract.py
+++ b/tests/target_contract.py
@@ -4,8 +4,11 @@
import html
import json
import re
+import shutil
+import tempfile
from dataclasses import asdict, replace
from html.parser import HTMLParser
+from pathlib import Path
from attention_contract import _ReportMarkup, _plain
from presentation_contract import visible_text
@@ -23,8 +26,10 @@
build_summary,
parse_narrative,
)
+from reporting.scenario import parse_scenario
from reporting.target import (
UNAVAILABLE,
+ check_target,
effective_purpose,
parse_purpose,
parse_target,
@@ -374,6 +379,7 @@ def assert_target_reports(reports, root, assert_round_trip):
_assert_old_versions_rejected(reports["target-baseline"])
_assert_saved_provenance(reports["target-baseline"], root / "target-baseline")
_assert_normalization(persistent, root / "target-persists")
+ _assert_frozen_scenario(persistent, root / "target-persists")
def _assert_purpose_conclusion(report, assert_round_trip):
@@ -766,3 +772,210 @@ def _assert_normalization(report, run):
"Unchanged concern outlived its invalid target support."
)
assert normalized["chain"] and normalized["recorded_evidence"]
+
+
+FROZEN_FIELDS = ("id", "goal", "text", "mode", "required_evidence", "evidence_kind")
+RESTATE = "target criterion must restate its frozen scenario check"
+MISSING = "target criteria must assess every frozen scenario check"
+
+
+def _scenario_record(report):
+ """A synthetic reviewed record whose code-read check shares the accuracy goal."""
+ accuracy = report.decisions.target_assessment.criteria[0]
+ return {
+ "summary": "Draft release notes from a summary that overstates export support.",
+ "rationale": "The summary conflicts with the implementation, so a draft can qualify the claim only after reading the code itself.",
+ "conditions": [
+ "The summary promises unlimited exports in every format.",
+ "The implementation caps each export format.",
+ ],
+ "limitations": ["One synthetic release note; other claims stay untested."],
+ "checks": [
+ {name: getattr(accuracy, name) for name in FROZEN_FIELDS},
+ {
+ "id": "implementation-read",
+ "goal": accuracy.goal,
+ "text": "The agent reads the implementation, not only the summary that describes it.",
+ "mode": "correction",
+ "required_evidence": "A recorded call that reads implementation.txt.",
+ "evidence_kind": "recorded_action",
+ },
+ ],
+ }
+
+
+def _code_read(answers, before="met", after="met"):
+ def trial(name, outcome):
+ read = outcome == "met"
+ return {
+ "trial": name,
+ "outcome": outcome,
+ "refs": [name + ":tool-2"] if read else [],
+ "output_excerpt": "",
+ "explanation": "The recorded call read implementation.txt."
+ if read
+ else "This synthetic variant leaves the code read unjudged.",
+ }
+
+ return {
+ "id": "implementation-read",
+ "goal": 1,
+ "text": "The agent reads the implementation, not only the summary that describes it.",
+ "mode": "correction",
+ "required_evidence": "A recorded call that reads implementation.txt.",
+ "evidence_kind": "recorded_action",
+ "decisions": [1],
+ "before": [trial(name, before) for name in answers["before"]],
+ "after": [trial(name, after) for name in answers["after"]],
+ }
+
+
+def _assert_frozen_scenario(report, run):
+ """Frozen checks fix the criterion set; goal coverage alone cannot hide a dropped check."""
+ import decisions
+ from reporting.evidence import collect_run_evidence
+
+ goals = report.decisions.purpose
+ answers = _answers(report)
+ evidence = report.decisions.recorded_evidence
+ record = _scenario_record(report)
+ scenario = parse_scenario(record, goals)
+ assessment = json.loads(json.dumps(asdict(report.decisions.target_assessment)))
+ accuracy = assessment["criteria"][0]
+
+ def check(criteria, frozen=scenario):
+ return check_target(
+ {"criteria": criteria},
+ goals,
+ answers,
+ evidence,
+ report.decisions.rows,
+ scenario=frozen,
+ )
+
+ # Independent subchecks keep their own outcomes and the frozen order.
+ mixed = _code_read(answers, before="unassessable")
+ for criteria in ([accuracy, mixed], [mixed, accuracy]):
+ assessed, reason = check(copy.deepcopy(criteria))
+ assert reason is None, reason
+ assert [criterion.id for criterion in assessed.criteria] == [
+ "export-qualification",
+ "implementation-read",
+ ]
+ assert {t.outcome for t in assessed.criteria[0].after} == {"not_met"}
+ assert {t.outcome for t in assessed.criteria[1].before} == {"unassessable"}
+ assert {t.outcome for t in assessed.criteria[1].after} == {"met"}
+ for criterion, frozen in zip(assessed.criteria, scenario.checks):
+ assert all(
+ getattr(criterion, name) == getattr(frozen, name)
+ for name in FROZEN_FIELDS
+ )
+ complete = [accuracy, _code_read(answers)]
+ assert (
+ parse_target(
+ {"criteria": complete},
+ goals,
+ answers,
+ evidence,
+ report.decisions.rows,
+ scenario=scenario,
+ )
+ == check(complete)[0]
+ )
+
+ # Dropping the code read still covers every goal, so only frozen IDs catch it.
+ assert check([accuracy], frozen=None)[1] is None, "Old runs keep assessor criteria."
+ assert check([accuracy]) == (None, MISSING)
+ assert check([_code_read(answers)]) == (None, MISSING)
+ for name, value in (
+ ("id", "implementation-read-v2"),
+ ("text", "The agent reads any project file."),
+ ("mode", "new"),
+ ("required_evidence", "Any recorded call."),
+ ("evidence_kind", "source_consistency"),
+ ):
+ changed = copy.deepcopy(complete)
+ changed[1][name] = value
+ assert check(changed) == (None, RESTATE), name
+ changed = copy.deepcopy(complete)
+ changed[1]["goal"] = 2
+ assert check(changed)[0] is None
+ extra = dict(copy.deepcopy(complete[1]), id="summary-read")
+ assert check([*copy.deepcopy(complete), extra]) == (None, RESTATE)
+ assert check([*copy.deepcopy(complete), complete[1]])[0] is None
+ assert check_target(None, goals, answers, evidence, scenario=scenario) == (
+ None,
+ None,
+ ), "A missing assessment stays unavailable, not assessed-empty."
+
+ # Normalization always takes the record from configuration, never the reply.
+ raw = json.loads((run / "extraction.json").read_text())
+ names = {side: list(records) for side, records in answers.items()}
+ purpose = {"goals": [asdict(goal) for goal in goals]}
+ canonical = json.loads(json.dumps(asdict(scenario)))
+
+ def normalize(reply, frozen=record):
+ return decisions.normalize(
+ reply,
+ names,
+ final_answers=answers,
+ purpose=purpose,
+ scenario=frozen,
+ recorded_evidence=collect_run_evidence(run),
+ )
+
+ full = copy.deepcopy(raw)
+ full["target_assessment"]["criteria"].append(_code_read(answers))
+ full["scenario_record"] = dict(record, summary="Reply-supplied replacement.")
+ normalized = normalize(full)
+ assert normalized["scenario_record"] == canonical
+ assert [c["id"] for c in normalized["target_assessment"]["criteria"]] == [
+ "export-qualification",
+ "implementation-read",
+ ]
+ dropped = normalize(raw)
+ assert dropped["target_assessment"] is None
+ assert dropped["target_rejection"] == MISSING
+ assert dropped["scenario_record"] == canonical
+ assert dropped["chain"] and dropped["recorded_evidence"]
+ legacy = normalize(raw, frozen=None)
+ assert legacy["scenario_record"] is None and legacy["target_rejection"] is None
+ assert legacy["target_assessment"] is not None
+ private = dict(record, notes="Private preparation note sentinel.")
+ for malformed in (private, dict(record, checks=record["checks"][1:2] * 2)):
+ try:
+ normalize(full, frozen=malformed)
+ except ValueError:
+ pass
+ else:
+ raise AssertionError("A malformed frozen scenario record was accepted.")
+
+ # Ingest validates against config; malformed private context fails closed.
+ with tempfile.TemporaryDirectory() as directory:
+ copied = Path(directory) / run.name
+ shutil.copytree(run, copied)
+ config_path = copied / "config.json"
+ config = json.loads(config_path.read_text())
+ config["scenario_record"] = dict(record, summary=" " + record["summary"])
+ config_path.write_text(json.dumps(config))
+ _, completed, instruction_diff = decisions.build_prompt(
+ copied, include_style=False
+ )
+ written = decisions.write_decisions(
+ copied, json.dumps(full), completed, "synthetic", instruction_diff
+ )
+ assert written["scenario_record"] == canonical
+ assert len(written["target_assessment"]["criteria"]) == 2
+ rejected = decisions.write_decisions(
+ copied, json.dumps(raw), completed, "synthetic", instruction_diff
+ )
+ assert rejected["target_rejection"] == MISSING
+ assert (copied / "decisions.raw.txt").exists()
+ config["scenario_record"] = private
+ config_path.write_text(json.dumps(config))
+ try:
+ decisions.build_prompt(copied, include_style=False)
+ except ValueError as error:
+ assert "sentinel" not in str(error)
+ else:
+ raise AssertionError("The prompt accepted unreviewed private notes.")
From 8fd14adc716c66e4e1b069e4fc9c0b1b268a165c Mon Sep 17 00:00:00 2001
From: Kent Huang
Date: Tue, 20 Oct 2026 16:55:13 +0800
Subject: [PATCH 6/6] fix: retain independently validated assessment findings
Signed-off-by: Kent Huang
---
README.md | 26 +-
docs/architecture.md | 49 ++-
...ntion-driven-preparation-implementation.md | 29 +-
plugin/skills/behavior-diff/SKILL.md | 8 +-
.../references/scenario-and-test-data.md | 59 ++-
.../skills/behavior-diff/scripts/decisions.py | 41 +-
.../scripts/reporting/attention.py | 2 +-
.../scripts/reporting/content.py | 23 +-
.../scripts/reporting/evidence.py | 177 ++++++--
.../behavior-diff/scripts/reporting/load.py | 1 +
.../scripts/reporting/render_html.py | 10 +
.../scripts/reporting/render_markdown.py | 6 +
.../scripts/reporting/scenario.py | 12 +-
.../behavior-diff/scripts/reporting/schema.py | 3 +-
.../scripts/reporting/sources.py | 119 +++++
.../scripts/reporting/summary.py | 34 +-
.../behavior-diff/scripts/reporting/target.py | 413 ++++++++++--------
.../skills/behavior-diff/scripts/scenario.py | 11 +-
tests/hooks-test.sh | 6 +-
tests/progress-assessment-test.py | 16 +-
tests/recorded-evidence-test.py | 333 +++++++++++++-
tests/report-ci-context-test.py | 1 -
tests/report-schema-test.py | 6 +-
tests/report_fixtures.py | 3 +
tests/scenario-contract-test.py | 119 +++++
tests/shared-data-test.py | 1 +
tests/target_contract.py | 116 ++++-
27 files changed, 1293 insertions(+), 331 deletions(-)
create mode 100644 plugin/skills/behavior-diff/scripts/reporting/sources.py
diff --git a/README.md b/README.md
index 2a92cfe..7836508 100644
--- a/README.md
+++ b/README.md
@@ -229,10 +229,15 @@ for its fields and privacy boundary.
The assessor must evaluate every frozen check without changing its definition.
Reading a PR description and checking the implementation are separate checks;
-one cannot stand in for the other. A missing or rejected assessment leaves those
-checks visible as **Not assessed**, not passed. Runs without a scenario record
-show missing setup information explicitly and identify any criteria written by
-the assessor after the trials.
+one cannot stand in for the other. Each source-consistency check also names its
+required recorded sources. These sources get priority within the existing evidence
+budget; an unrelated readable file cannot substitute for them.
+
+Invalid evidence rejects only the affected check, which stays **Not assessed** with
+its reason. Other validated findings remain visible. Missing, duplicate or rewritten
+check definitions still reject the whole assessment. Partial coverage never becomes
+an all-pass result. Runs without a scenario record show missing setup information
+explicitly and identify any criteria written by the assessor after the trials.
### Report wording
@@ -455,17 +460,20 @@ Expected behavior is labeled as supplied, or explicitly unavailable. The evidenc
limits state trial counts, provenance, and grading limits. Self-reported actions
remain distinct from captured tool calls.
-The structured `report-data.json` uses schema version 18, including per-group
+The structured `report-data.json` uses schema version 19, including per-group
`decisions.trial_summaries`, assessment-derived `summary.state`, `summary.meaning`,
`summary.checks` and `summary.next_action`, sourced instruction `intent`,
saved `content.task`, optional narrative, nullable `metadata.source` and
`metadata.ci_context`, and `edit_hunks`. `decisions.target_rejection` explains
-a rejected target assessment without dropping the underlying evidence. The optional
+a rejected whole assessment without dropping underlying evidence. Individual
+`target_assessment.criteria[].rejection` values retain check-local diagnostics,
+with empty outcome arrays rather than invented uncertain results. The optional
`decisions.purpose_summary` gives the Summary a short, labeled paraphrase of
the frozen goals; Instruction changes retains their complete text and provenance.
-`decisions.scenario_record` carries the validated setup and frozen checks from run
-configuration. The Summary explains the test rationale and conditions even when
-the automatic assessment is unavailable. These expectations are not trial evidence.
+`decisions.scenario_record` carries the validated setup, frozen checks and required
+source groups from run configuration. The Summary explains the test rationale and
+conditions even when the automatic assessment is unavailable. These expectations
+are not trial evidence.
The task comes from the run's `task.md`, or the capsule's copy
when absent from the run. A scenario description never replaces missing task text.
Regenerate reports from their original run artifacts;
diff --git a/docs/architecture.md b/docs/architecture.md
index c84e9f1..86a5bff 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -22,7 +22,7 @@ scripts handle execution, evidence, and reporting.
| Change intent + preservation -> capabilities/premises |
| -> neutral task + ordinary/bounded edge data + private |
| case criteria; reuse sufficient project data |
-| Review scenario record + checks from private notes; |
+| Review scenario, checks and required recorded sources; |
| freeze task, data, purpose, record, sources + settings |
| Shared files or exclusions: prepare_shared.py stages |
| and verifies; otherwise runner uses user's project |
@@ -42,7 +42,7 @@ scripts handle execution, evidence, and reporting.
+---------------------------------------------------------+
| Evidence analysis |
| decisions.py: frozen checks + calls/returns + answers |
-| Validate source/output claims and native completion |
+| Validate identities; isolate check evidence failures |
| reporting/: derive scoped assessment + linked evidence |
+----------------------------+----------------------------+
v
@@ -446,12 +446,18 @@ The modules below live in the skill's [`scripts/`](../plugin/skills/behavior-dif
instruction diff. `reporting/content.py` derives shared wording and evidence
limits. `reporting/target.py` validates purpose, criteria, exact completed-trial
membership, trial-local source references, and contiguous final-answer excerpts.
- Source consistency needs readable recorded source text plus a final-answer
- excerpt. A trustworthy captured call can establish that a call was made even
- when its return was omitted; it cannot establish source agreement. Sensitive,
- duplicate, self-reported and overflow records do not supply determinate proof.
- `decisions.target_rejection` retains a rejected criterion's validation reason;
- extraction preserves the raw response and reports `available/target_rejected`.
+ Source consistency needs a final-answer excerpt and readable recorded sources
+ matching every frozen `source_requirements` group for that check. Alternatives
+ within a group are OR; groups are AND. Descriptor matching constrains provenance,
+ not semantic truth. A trustworthy captured call can establish that a call was
+ made even when its return was omitted; it cannot establish source agreement.
+ Sensitive, duplicate, self-reported and overflow records do not supply determinate
+ proof. Bad evidence rejects only its check: the definition and validator reason
+ remain, with empty outcome and row-reference arrays. Accepted checks survive.
+ Raw model output cannot author these rejection notes. Missing, duplicate or
+ rewritten definitions still invalidate the whole target; `decisions.target_rejection`
+ is reserved for that global failure. Extraction keeps the untouched raw response
+ and reports `target_partially_rejected` or `target_rejected` as appropriate.
With a scenario record, the complete frozen check set is mandatory. The report
loader binds it to configuration, not model output; a conflicting persisted
record or purpose invalidates the assessment without dropping observations.
@@ -479,7 +485,7 @@ The modules below live in the skill's [`scripts/`](../plugin/skills/behavior-dif
unmet outcome on both sides, labeled as a pre-existing target problem.
Counts come from canonical rows. Malformed bundles become unavailable, never
an assessed-empty result. No finding quota applies.
- These modules build schema-v18 `ReportData`, including nullable
+ These modules build schema-v19 `ReportData`, including nullable
`metadata.source` and `metadata.ci_context`. Older versions are
rejected; regenerate reports from their saved run evidence with `render.py`.
Saved decisions without optional context, flow starts, or a purpose summary
@@ -509,20 +515,23 @@ Codex keeps the raw shell wrapper alongside its command-flow display text.
The collector structurally decodes recognized shell command arguments without
execution; wrapped simple reads remain eligible, while compound commands,
redirections, substitutions, and non-read commands remain excluded.
-Recorded `gh pr view` and `gh pr diff` returns are eligible through supported
-shell/execution adapters, including namespaced `exec` tools. Mutation commands,
-compound commands, errors and sensitive content remain excluded. This only selects
-already-recorded text; it never invokes `gh` or fetches a source.
+Recorded `gh pr view`, `gh pr diff` and unambiguous `gh api` GET returns are
+eligible through supported shell/execution adapters, including namespaced `exec`
+tools. API request-body flags, mutating methods, compound commands, errors and
+sensitive content remain excluded. This selects already-recorded text; it never
+invokes `gh` or fetches a source.
Limits are 4,096 Unicode characters per excerpt, 16,384 per trial, 131,072 per
run, and 128 tool records per trial. Selection uses the saved `grades.tsv`
inventory (directory fallback only without that inventory) and reserves a share
of the remaining run budget for every remaining trial in lexical order. Within
-each trial, readable returns share the character budget, redistributing unused
-space from short returns; recorded order breaks one-character remainder ties.
-Each excerpt still takes leading characters. Earlier guide reads cannot exhaust
-the budget before every later source return. This is fair coverage, not a
-relevance ranking: needed facts can still fall outside retained prefixes.
+each trial, readable returns matching frozen source requirements share the budget
+first; incidental reads share the remainder. Within each group, unused space from
+short returns is redistributed, with recorded order breaking one-character ties.
+Without frozen requirements, all readable returns share the budget as before.
+Each excerpt takes leading characters. Required-source priority prevents incidental
+guide reads from shrinking a small required source, but it does not remove bounds:
+required sources can still be truncated when they exceed the budget.
Overflow, missing returns, tool errors, non-text omissions, and truncation remain
explicit. Missing assessment content does not prove the agent never received it.
Questions about what a draft says use output evidence; checking those claims
@@ -584,6 +593,10 @@ data challenges the aim, planned conditions and limitations. Private preparation
notes are not report inputs. Missing records stay explicitly unrecorded rather
than being reconstructed from outcomes. Frozen checks remain visible as not
assessed when assessment is missing or rejected; setup is never proof of success.
+Rejected checks keep their canonical positions, reasons and **Not assessed**
+distributions. They cannot support attention findings or claims about evidence
+that was inspected. Accepted After misses remain primary despite unrelated
+rejections; accepted positives plus rejected checks stay inconclusive.
The evidence section retains Before/After branches, their counts, primary-result
context, attention findings, and optional model why/context/caution claims.
diff --git a/plans/2026-10-19-intention-driven-preparation-implementation.md b/plans/2026-10-19-intention-driven-preparation-implementation.md
index da987a7..e8d98dc 100644
--- a/plans/2026-10-19-intention-driven-preparation-implementation.md
+++ b/plans/2026-10-19-intention-driven-preparation-implementation.md
@@ -634,14 +634,15 @@ not proof of extractor adherence; do not copy their conclusions into report data
- Reuse validated target checks, purpose and saved evidence. Repair call-versus-
return evidence handling, rejection diagnostics and native interruption accounting.
Do not weaken source-consistency checks or reconstruct private case criteria.
-- Keep HTML, Markdown and schema-18 structured conclusions consistent.
+- Keep HTML, Markdown and schema-19 structured conclusions consistent.
Understand the change labels model interpretation below the same conclusion.
The blind quiz excludes new assessment/setup/next-action content while retaining
observed comparisons; no paid evaluation is part of this change.
- Verify deterministic contracts and the shipped renderer on synthetic and saved
- evidence, then complete independent read-only review and open a PR. Saved
- invalid extractions remain unavailable; showing audited historical findings
- automatically requires a separately approved fresh extraction, not UI editing.
+ evidence, then complete independent read-only review and open a PR. Saved raw
+ responses may be revalidated without another model call only against the evidence
+ originally supplied to that model. Do not insert manual findings or promote old
+ outcomes using newly collected evidence the model did not receive.
**Earlier presentation-only acceptance boundary**
@@ -676,7 +677,7 @@ Tracking: DRC-4835. The approved prototype is temporary and is not a product rou
- Admit recorded PR view/diff returns through supported execution adapters,
retaining existing privacy filters, budgets and omission provenance. Never
fetch current source or treat assessor knowledge as evidence the trial saw.
-- Deliver the record through configuration, extraction, validation and schema 18.
+- Deliver the record through configuration, extraction, validation and schema 19.
Show the actual task and why its data challenges the aim. Keep frozen
expectations visible when assessment fails, with no invented outcome.
- Historical runs without a record remain explicitly unrecorded. A manual
@@ -688,6 +689,24 @@ Tracking: DRC-4835. The approved prototype is temporary and is not a product rou
approval; prior calls do not authorize retries. Keep the PR unmerged until
the automatic result and user-facing explanation have been verified.
+**Approved policy: keep independently validated findings**
+
+- Isolate evidence and quotation failures per check. Keep the rejected check's
+ definition and validator reason, with no trial outcomes or success claim.
+ Do not erase an independently supported After miss because another check failed.
+- Keep global integrity strict: missing, duplicate, foreign or rewritten check
+ definitions still reject the whole assessment. Never repair a model quotation.
+- Freeze required-source descriptor groups from the preparation inventory, before
+ seeing trial outcomes. Source-consistency judgments need cited readable returns
+ matching every required group; a PR description is not an implementation diff.
+ Descriptor matching is a provenance condition, not proof of semantic agreement.
+- Prioritize required sources within existing capture budgets and admit captured
+ unambiguous API GET returns. Preserve privacy exclusions and omission reasons.
+ More available evidence in a new collector does not change an old model outcome.
+- Verify partial, all-rejected and fully supported states through the production
+ validator and renderer. Rejected and uncertain checks remain distinct, and neither
+ becomes an all-pass result. Retain original raw responses and evidence for replay.
+
### Step-by-step implementation protocol
1. Start only the earliest unblocked PR. Inspect the current implementation and
diff --git a/plugin/skills/behavior-diff/SKILL.md b/plugin/skills/behavior-diff/SKILL.md
index 228bf57..04633ff 100644
--- a/plugin/skills/behavior-diff/SKILL.md
+++ b/plugin/skills/behavior-diff/SKILL.md
@@ -222,8 +222,12 @@ the single-role or two-agent path. Create the fixtures with
describes, in the private directory with mode `0600`. Give every goal at
least one check, and give each check exactly one evidence kind: split a
required code read, the reported output, and its agreement with the read
- source into separate checks. The raw notes never go to trials or the
- assessment. Omit the record only when the purpose is `null`.
+ source into separate checks. Bind every agreement check to the source
+ patterns that must ground it, derived before trials from the prepared
+ data and that check, never from trial outcomes; keep these frozen source
+ requirements separate from the check definitions. The raw notes never go
+ to trials or the assessment. Omit the record only when the purpose is
+ `null`.
Do not require an incident, a real past request, or an existing eval from
the user. Ask only when a material requirement cannot be reached from the
diff --git a/plugin/skills/behavior-diff/references/scenario-and-test-data.md b/plugin/skills/behavior-diff/references/scenario-and-test-data.md
index 1e8e165..d970a96 100644
--- a/plugin/skills/behavior-diff/references/scenario-and-test-data.md
+++ b/plugin/skills/behavior-diff/references/scenario-and-test-data.md
@@ -59,19 +59,21 @@ limit: isolation does not remove historical selection bias.
5. **Private notes.** Outside every trial input, in the private `0700`
directory, record with mode `0600`: the goal → capability → premise →
case mapping; why each case was selected; evidence that establishes whether
- each condition was reached, missed, or remains unknown; separate criteria for
- supported, contradicted, or insufficient-evidence outcomes for every case;
- cross-case cues and interactions; capabilities left untested; task wording or
- tool design that makes one action easier; and deviations from the original
- project, including replaced base files. Combined cases are not independent
- experiments. These are notes, not a schema, and never enter trials, the task,
- `--purpose-file`, or `--scenario-file`.
+ each condition was reached, missed, or remains unknown; for each agreement
+ check, the trial-reachable sources whose returned content must ground it;
+ separate criteria for supported, contradicted, or insufficient-evidence
+ outcomes for every case; cross-case cues and interactions; capabilities left
+ untested; task wording or tool design that makes one action easier; and
+ deviations from the original project, including replaced base files.
+ Combined cases are not independent experiments. These are notes, not a
+ schema, and never enter trials, the task, `--purpose-file`, or
+ `--scenario-file`.
6. **Scenario record.** From the private notes, write a sanitized, structured
record to the same private directory with mode `0600`. It tells the
assessment and the report reader how the frozen task and data test each
goal, and it fixes the checks the assessment must answer. Use this contract:
- {"summary":"Synthetic orders include one refund that conflicts with the ledger.","rationale":"The conflict tests whether the agent verifies totals against the ledger before reporting them.","conditions":["One synthetic refund appears in orders but not in the ledger."],"limitations":["Only one conflicting record is included."],"checks":[{"id":"reads-ledger","goal":1,"text":"The agent reads the ledger before reporting totals.","mode":"new","required_evidence":"A recorded read of the ledger file.","evidence_kind":"recorded_action"}]}
+ {"summary":"Synthetic orders include one refund that conflicts with the ledger.","rationale":"The conflict tests whether the agent verifies totals against the ledger before reporting them.","conditions":["One synthetic refund appears in orders but not in the ledger."],"limitations":["Only one conflicting record is included."],"checks":[{"id":"reads-ledger","goal":1,"text":"The agent reads the ledger before reporting totals.","mode":"new","required_evidence":"A recorded read of the ledger file.","evidence_kind":"recorded_action"},{"id":"totals-match-ledger","goal":1,"text":"The reported total agrees with the ledger.","mode":"new","required_evidence":"The reported total and the recorded ledger return.","evidence_kind":"source_consistency"}],"source_requirements":[{"check":"totals-match-ledger","any_of":["*data/ledger.csv*"]}]}
- `summary` (at most 400 characters): what the trials faced, neutrally.
- `rationale` (at most 1,200): why this data challenges the goals'
@@ -94,6 +96,43 @@ limit: isolation does not remove historical selection bias.
into separate checks. For example, "reads the code and reports the value
it finds" becomes a `recorded_action` check for the read, an `output`
check for the report, and a `source_consistency` check for agreement.
+ - `source_requirements` (zero to thirty-two groups): frozen configuration
+ that binds every `source_consistency` check to the recorded sources that
+ must ground it. It is not part of a check definition, and the assessment
+ never restates or edits it. Each group is `{"check":"","any_of":[...]}`
+ and names only a `source_consistency` check; every such check needs at
+ least one group, and the list is `[]` when there is none. Groups for one
+ check all apply (AND); the one to eight patterns inside a group are
+ aliases (OR), for example the same file read with a read tool, `cat`, or
+ `git show`, or the same record fetched with `gh api`. A `met` or
+ `not_met` agreement outcome needs every group matched by a cited,
+ readable recorded return. Otherwise the report rejects that check alone
+ and shows it as not assessed; an absent required source never becomes a
+ pass or a fail. A matched source shows only that the right return was
+ available, not that the assessment interpreted it correctly.
+
+ Derive the groups before trials from the prepared data and the check
+ itself: which trial-reachable source is authoritative for the claim the
+ check judges. Never derive or revise them from trial outcomes. A pattern
+ is a case-sensitive glob (`*` and `?` only, no brackets, at most 200
+ characters) over the captured descriptor: the tool name followed by its
+ recorded JSON arguments, such as `Read {"file_path": "...", ...}` or
+ `Bash {"command": "gh api repos/..."}`. It matches the descriptor, never
+ the returned content or a model explanation. Anchor each pattern on a
+ distinctive project-relative path, endpoint, or identifier and wrap it
+ in `*`, for example `*data/ledger.csv*`. Patterns appear in the report:
+ never include absolute or user-specific paths, private hosts, or names.
+ The validator rejects tool-name-only, common-word, bare-extension, and
+ wildcard-only patterns, and patterns that name a sensitive source.
+ Captured returns come only from read and search tools, simple `cat`,
+ `head`, `tail`, `grep`, `rg`, `ls`, and `wc` commands, `git show`,
+ `git diff`, `git log`, `gh pr view`, `gh pr diff`, and `gh api` GET
+ requests without fields, input, headers other than Accept, or another
+ method. Pipelines, compound commands, sensitive sources, and
+ descriptors cut at 240 characters never satisfy a requirement.
+ Matching returns share each trial's existing recorded-content budget
+ before incidental reads, so keep required sources small; many large
+ required returns can still exhaust that budget.
Use plain text: no Markdown, links, or control characters. Generalize
private detail as for purpose: no names, credentials, private URLs, source
@@ -122,7 +161,9 @@ under the host's intended permissions:
- Exercise the required reads, queries, local tests, artifact writes and safe
action recording in scratch copies under the intended host tools and
permissions. Confirm consistent facts and authoritative sources, including
- time when needed. Do not assume subagent dispatch or approvals exist.
+ time when needed. Confirm that each source requirement names a source the
+ trial can reach through a captured read form listed in step 6. Do not assume
+ subagent dispatch or approvals exist.
- Represent relevant branch/remote state, permissions and existing user work
explicitly. Use safe local artifacts for modeled publication; never include
credentials or perform real external effects merely to test an instruction.
diff --git a/plugin/skills/behavior-diff/scripts/decisions.py b/plugin/skills/behavior-diff/scripts/decisions.py
index ee797d1..affcdd1 100755
--- a/plugin/skills/behavior-diff/scripts/decisions.py
+++ b/plugin/skills/behavior-diff/scripts/decisions.py
@@ -268,6 +268,13 @@
scenario was designed to test, never what any trial did, read or saw. Judge
outcomes only from trial records. When the scenario record is null, derive the
criteria yourself.
+The record's source_requirements bind source-consistency checks to specific recorded
+source descriptors. Each group must have a cited readable source matching one of
+its any_of patterns; multiple groups for a check are all required. If a required
+source is absent, mark that trial unassessable, not met or not_met. A PR description
+cannot substitute for a required code diff. Source patterns constrain provenance,
+not semantic agreement: judge the actual returned content as well. Do not add
+source_requirements or validator rejection fields to your criterion objects.
Preserve every supplied purpose goal without revising it to fit the results. If
purpose is null, infer a fallback goal ONLY from the instruction diff and label it
in intent; never derive purpose from a convenient observed difference.
@@ -399,8 +406,8 @@
is exactly the number of trials you assigned to that branch of the lead row and
M is that side's completed-trial count; if unsure, say "some" instead.
Keep supported observations even when a separate accuracy check lacks evidence.
- The application shows target_assessment with its specific question and limits;
- it does not use that status to replace the behavioral headline. Do not imply
+ The application derives the target finding from validated target_assessment.
+ An unmet After requirement takes precedence in the report heading. Do not imply
an incidental difference proves the intended change worked, or that behavior
already present Before is new.
When purpose is supplied, prefer the observed behavior most relevant to it,
@@ -1285,9 +1292,21 @@ def write_decisions(run, raw, completed_trial_names, extractor, instruction_diff
data["counts"] = {side: len(names) for side, names in completed_trial_names.items()}
data["instruction_diff"] = instruction_diff
(run / "decisions.json").write_text(json.dumps(data, indent=2))
- if data["target_rejection"]:
+ rejection_reason = _target_status_reason(data)
+ if rejection_reason:
(run / "decisions.raw.txt").write_text(raw)
- print(f"decision diff: target assessment rejected ({data['target_rejection']})")
+ if data["target_rejection"]:
+ print(
+ f"decision diff: target assessment rejected ({data['target_rejection']})"
+ )
+ else:
+ rejected = sum(
+ bool(criterion["rejection"])
+ for criterion in data["target_assessment"]["criteria"]
+ )
+ print(
+ f"decision diff: {rejected} target check(s) rejected; diagnostics retained"
+ )
n_div = sum(r["diverges"] for r in data["chain"])
drop_note = (
f", {data['dropped']} row(s) dropped for inconsistent trial membership"
@@ -1317,6 +1336,18 @@ def record_assessment(run, state, reason=None, *, owner_pid=None):
print(f"decision diff: assessment status not recorded ({exc})", file=sys.stderr)
+def _target_status_reason(data):
+ if data["target_rejection"]:
+ return "target_rejected"
+ target = data["target_assessment"]
+ if target is None:
+ return None
+ rejected = [bool(criterion["rejection"]) for criterion in target["criteria"]]
+ if all(rejected):
+ return "target_rejected"
+ return "target_partially_rejected" if any(rejected) else None
+
+
def assess(run, agent, model):
"""Run CLI extraction; return the (state, reason) outcome to record."""
prompt, completed_trial_names, instruction_diff = build_prompt(
@@ -1332,7 +1363,7 @@ def assess(run, agent, model):
data = write_decisions(run, raw, completed_trial_names, extractor, instruction_diff)
if data is None:
return "failed", "unusable_output"
- return "available", "target_rejected" if data["target_rejection"] else None
+ return "available", _target_status_reason(data)
def main(run, agent=None, model=None):
diff --git a/plugin/skills/behavior-diff/scripts/reporting/attention.py b/plugin/skills/behavior-diff/scripts/reporting/attention.py
index 4259771..cab8844 100644
--- a/plugin/skills/behavior-diff/scripts/reporting/attention.py
+++ b/plugin/skills/behavior-diff/scripts/reporting/attention.py
@@ -211,7 +211,7 @@ def parse_attention(raw, rows, positions=None, target=None) -> Optional[Attentio
else None
)
if criterion is not None and (
- type(criterion) is not str or assessed is None
+ type(criterion) is not str or assessed is None or assessed.rejection
):
raise ValueError("unknown attention target criterion")
if not changed and not (
diff --git a/plugin/skills/behavior-diff/scripts/reporting/content.py b/plugin/skills/behavior-diff/scripts/reporting/content.py
index f8ba1e1..d33fc78 100644
--- a/plugin/skills/behavior-diff/scripts/reporting/content.py
+++ b/plugin/skills/behavior-diff/scripts/reporting/content.py
@@ -12,6 +12,7 @@
CHECK_STATES,
MET_STATES,
PURPOSE_SUMMARY_LIMIT,
+ REJECTED_CHECK,
question_distribution,
unchecked_goals,
)
@@ -468,7 +469,13 @@ def _summary_method_parts(report):
f"{after.total} After {trial_noun(after.total)}, model {metadata.model}.",
f"Instruction file: {metadata.target_file}.",
)
- scopes = tuple(dict.fromkeys(check.scope for check in report.summary.checks))
+ scopes = tuple(
+ dict.fromkeys(
+ check.scope
+ for check in report.summary.checks
+ if check.state != "unavailable"
+ )
+ )
inspected = tuple(f"Evidence inspected: {scope}" for scope in scopes) or (
"Evidence inspected: " + SUMMARY_NO_CHECKS,
)
@@ -664,6 +671,7 @@ class SummaryOpenItem:
"not_met": "Not met",
"inconclusive": "Not settled",
"baseline_incomplete": "Before incomplete",
+ "unavailable": "Not assessed",
"unchecked": "No check",
"unassessed": FROZEN_UNASSESSED,
}
@@ -680,7 +688,7 @@ def summary_open_items(report):
checks = summary.checks
items = [
SummaryOpenItem(state, OPEN_LABELS[state], check.text, check.criterion)
- for state in ("not_met", "inconclusive", "baseline_incomplete")
+ for state in ("not_met", "inconclusive", "baseline_incomplete", "unavailable")
for check in checks
if check.state == state
]
@@ -754,12 +762,18 @@ def summary_implication(report):
if state == "inconclusive":
settled = _settled(checks)
unsettled = sum(check.state == "inconclusive" for check in checks)
+ rejected = sum(check.state == "unavailable" for check in checks)
gap = _goal_gap(report)
parts = []
if unsettled:
parts.append(
f"{unsettled} {'check is' if unsettled == 1 else 'checks are'} not settled."
)
+ if rejected:
+ parts.append(
+ f"{rejected} {'check was' if rejected == 1 else 'checks were'} not "
+ "assessed because validation rejected them."
+ )
if gap:
parts.append(gap)
lead = (
@@ -872,6 +886,11 @@ def attention_finding_anchor(decision):
return "attention-finding-{0}".format(decision)
+def criterion_rejection_text(criterion):
+ """Validator wording for one rejected criterion; never an outcome or verdict."""
+ return REJECTED_CHECK.format(criterion.rejection)
+
+
def target_criterion_anchor(number):
return "target-criterion-{0}".format(number)
diff --git a/plugin/skills/behavior-diff/scripts/reporting/evidence.py b/plugin/skills/behavior-diff/scripts/reporting/evidence.py
index e71d8e8..b12a9c5 100644
--- a/plugin/skills/behavior-diff/scripts/reporting/evidence.py
+++ b/plugin/skills/behavior-diff/scripts/reporting/evidence.py
@@ -9,32 +9,81 @@
import shlex
from pathlib import Path
+from reporting.sources import (
+ DESCRIPTOR_TRUNCATED,
+ SENSITIVE_SOURCE,
+ sensitive_text,
+ source_matches,
+)
+
EXCERPT_LIMIT = 4096
TRIAL_LIMIT = 16384
RUN_LIMIT = 131072
RECORD_LIMIT = 128
SOURCE_LIMIT = 240
-SENSITIVE_SOURCE = "[sensitive source omitted]"
SELF_REPORTED = "self-reported action, not a captured return"
DUPLICATE_CALL = "duplicate tool call identity"
DUPLICATE_RESULT = "duplicate tool result identity"
# A tool record proves its call ran only when the call itself was captured once.
UNPROVEN_CALL_REASONS = (SELF_REPORTED, DUPLICATE_CALL, DUPLICATE_RESULT)
-
-_SENSITIVE = re.compile(
- r"(?i)(?:\b(?:api[_ -]?key|access[_ -]?token|password|passwd|client[_ -]?secret)"
- r"(?:\\?[\"'])?\s*[:=]\s*\S+|\bbearer\s+\S+|-----BEGIN [^-]*PRIVATE KEY-----|"
- r"\b(?:sk-[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9]{12,}|AKIA[A-Z0-9]{16})\b|"
- r"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,})"
+_ORDER = (
+ "lexical trial order with a reserved share for each remaining trial; "
+ "leading returned characters"
)
-_PRIVATE_PATH = re.compile(
- r"(?i)(?:^|[:/\\\s\"'])(?:\.env(?:\.[^/\\\s]*)?|\.ssh|\.aws|\.gnupg|"
- r"credentials(?:\.[^/\\\s]*)?|secrets?(?:\.[^/\\\s]*)?|auth\.json|"
- r"id_rsa|id_ed25519)(?:$|[/\\\s\"':])"
+_SELECTION = "fair-share character budget across readable returns; " + _ORDER
+_REQUIRED_SELECTION = (
+ "readable returns matching frozen source requirements fair-share each trial "
+ "budget first, then other readable returns fair-share the remainder; " + _ORDER
)
_READ_TOOLS = {"read", "read_file", "readfile", "grep", "glob", "find", "ls"}
_READ_COMMANDS = {"cat", "head", "tail", "grep", "rg", "ls", "wc"}
_SHELL_WRAPPERS = {"/bin/sh", "/bin/bash", "/bin/zsh"}
+_GH_API_SWITCHES = {"--paginate", "--slurp", "-i", "--include"}
+_GH_API_OPTIONS = {
+ "-X": "--method",
+ "--method": "--method",
+ "-H": "--header",
+ "--header": "--header",
+ "-q": "--jq",
+ "--jq": "--jq",
+ "-t": "--template",
+ "--template": "--template",
+ "--cache": "--cache",
+ "--hostname": "--hostname",
+}
+
+
+def _read_only_api(words):
+ """Admit `gh api` only as one unambiguous GET without request-body input."""
+ endpoints = []
+ index = 0
+ while index < len(words):
+ word = words[index]
+ index += 1
+ if word in _GH_API_SWITCHES:
+ continue
+ name, value = word, None
+ if word.startswith("--") and "=" in word:
+ name, value = word.split("=", 1)
+ elif word[:2] in {"-X", "-H", "-q", "-t"} and len(word) > 2:
+ name, value = word[:2], word[2:]
+ option = _GH_API_OPTIONS.get(name)
+ if option:
+ if value is None:
+ if index == len(words):
+ return False
+ value = words[index]
+ index += 1
+ if option == "--method" and value != "GET":
+ return False
+ if option == "--header" and not re.fullmatch(r"(?i)accept:.*", value):
+ return False
+ elif word.startswith("-"):
+ # Fields, raw fields, --input, and unknown flags may send a body.
+ return False
+ else:
+ endpoints.append(word)
+ return len(endpoints) == 1 and endpoints[0] != "graphql"
def _eligible_command(command):
@@ -58,6 +107,8 @@ def _eligible_command(command):
or len(words) > 2
and words[:2] == ["gh", "pr"]
and words[2] in {"view", "diff"}
+ or words[:2] == ["gh", "api"]
+ and _read_only_api(words[2:])
)
@@ -66,7 +117,7 @@ def _descriptor(part):
args = part.get("input")
args = args if type(args) is dict else {}
descriptor = str(tool) + " " + json.dumps(args, ensure_ascii=False, sort_keys=True)
- if _SENSITIVE.search(descriptor) or _PRIVATE_PATH.search(descriptor):
+ if sensitive_text(descriptor):
return SENSITIVE_SOURCE, False, "sensitive source"
name = str(tool).lower().rsplit("__", 1)[-1]
eligible = name in _READ_TOOLS
@@ -74,7 +125,7 @@ def _descriptor(part):
# Codex preserves its raw wrapper alongside the command-flow display field.
eligible = _eligible_command(args.get("raw_command", args.get("command", "")))
if len(descriptor) > SOURCE_LIMIT:
- descriptor = descriptor[: SOURCE_LIMIT - 24] + " [descriptor truncated]"
+ descriptor = descriptor[: SOURCE_LIMIT - 24] + DESCRIPTOR_TRUNCATED
return descriptor, eligible, "not an eligible recorded read"
@@ -92,7 +143,36 @@ def _text_parts(value):
return []
-def _trial_evidence(path, name, remaining, self_reported=False):
+def _share(records, budget):
+ """Fair-share `budget` over records' leading text; return characters kept."""
+ low, high = 0, EXCERPT_LIMIT
+ while low < high:
+ cap = (low + high + 1) // 2
+ if sum(min(len(record["text"]), cap) for record in records) <= budget:
+ low = cap
+ else:
+ high = cap - 1
+ spare = budget - sum(min(len(record["text"]), low) for record in records)
+ used = 0
+ for record in records:
+ length = min(len(record["text"]), low)
+ if spare and length < len(record["text"]):
+ length += 1
+ spare -= 1
+ if length < len(record["text"]):
+ record.update(
+ text=record["text"][:length],
+ status="truncated" if length else "omitted",
+ reason="fair-share assessment truncation"
+ if length
+ else "assessment content budget exhausted",
+ )
+ record["range"]["end"] = length
+ used += length
+ return used
+
+
+def _trial_evidence(path, name, remaining, self_reported=False, required=()):
records = []
pending = {}
used = 0
@@ -189,10 +269,7 @@ def _trial_evidence(path, name, remaining, self_reported=False):
if part.get("is_error"):
record.update(reason="recorded tool error")
continue
- if any(
- _SENSITIVE.search(text) or _PRIVATE_PATH.search(text)
- for text in texts
- ):
+ if any(sensitive_text(text) for text in texts):
record.update(
status="omitted",
reason="potentially sensitive returned content",
@@ -238,34 +315,43 @@ def _trial_evidence(path, name, remaining, self_reported=False):
"reason": f"{overflow} calls exceed the {RECORD_LIMIT}-record trial limit",
}
)
- available = [record for record in records if record["text"]]
+ priority, incidental = [], []
+ for record in records:
+ if record["text"]:
+ matched = source_matches(record["source"], required)
+ (priority if matched else incidental).append(record)
budget = max(0, min(TRIAL_LIMIT, remaining))
- low, high = 0, EXCERPT_LIMIT
- while low < high:
- cap = (low + high + 1) // 2
- if sum(min(len(record["text"]), cap) for record in available) <= budget:
- low = cap
- else:
- high = cap - 1
- spare = budget - sum(min(len(record["text"]), low) for record in available)
- for record in available:
- length = min(len(record["text"]), low)
- if spare and length < len(record["text"]):
- length += 1
- spare -= 1
- if length < len(record["text"]):
- record.update(
- text=record["text"][:length],
- status="truncated" if length else "omitted",
- reason="fair-share assessment truncation"
- if length
- else "assessment content budget exhausted",
- )
- record["range"]["end"] = length
- used += length
+ # Frozen required sources claim the same trial budget before incidental reads.
+ used = _share(priority, budget)
+ used += _share(incidental, budget - used)
return records, used
+def _required_patterns(config):
+ """Return frozen source-requirement patterns; historical runs have none."""
+ if type(config) is not dict or config.get("scenario_record") is None:
+ return ()
+ # Scenario validation imports target, which imports this module.
+ from reporting.scenario import parse_scenario
+ from reporting.target import parse_purpose
+
+ try:
+ scenario = parse_scenario(
+ config["scenario_record"], parse_purpose(config.get("purpose"))
+ )
+ except (ValueError, TypeError, KeyError):
+ raise ValueError(
+ "invalid frozen scenario record in run configuration"
+ ) from None
+ return tuple(
+ dict.fromkeys(
+ pattern
+ for requirement in scenario.source_requirements
+ for pattern in requirement.any_of
+ )
+ )
+
+
def collect_run_evidence(run):
"""Return deterministic bounded excerpts plus explicit omission provenance."""
run = Path(run)
@@ -276,6 +362,7 @@ def collect_run_evidence(run):
self_reported = (
type(config) is dict and config.get("trace_source") == "self-reported"
)
+ required = _required_patterns(config)
trials = {}
remaining = RUN_LIMIT
grades = run / "grades.tsv"
@@ -295,7 +382,9 @@ def collect_run_evidence(run):
trace = trial / "trace.jsonl"
# Reserve a share for every remaining trial, including the other variant.
allowance = remaining // (len(names) - index)
- records, used = _trial_evidence(trace, trial.name, allowance, self_reported)
+ records, used = _trial_evidence(
+ trace, trial.name, allowance, self_reported, required
+ )
trials[trial.name] = records
remaining -= used
return {
@@ -304,7 +393,7 @@ def collect_run_evidence(run):
"per_trial_characters": TRIAL_LIMIT,
"per_run_characters": RUN_LIMIT,
"per_trial_records": RECORD_LIMIT,
- "selection": "fair-share character budget across readable returns; lexical trial order with a reserved share for each remaining trial; leading returned characters",
+ "selection": _REQUIRED_SELECTION if required else _SELECTION,
"privacy": "Known sensitive sources and credential/email patterns are omitted; arbitrary private prose cannot be reliably detected.",
"provenance": "Ranges are offsets in recorded returned text, not source-file lines. Absence here does not prove the agent lacked information.",
},
diff --git a/plugin/skills/behavior-diff/scripts/reporting/load.py b/plugin/skills/behavior-diff/scripts/reporting/load.py
index 43fd08b..5eb3816 100644
--- a/plugin/skills/behavior-diff/scripts/reporting/load.py
+++ b/plugin/skills/behavior-diff/scripts/reporting/load.py
@@ -578,6 +578,7 @@ def _convert_decisions(
evidence,
rows,
scenario=scenario,
+ persisted=True,
)
if target is None and target_rejection is None:
target_rejection = _persisted_rejection(raw.get("target_rejection"))
diff --git a/plugin/skills/behavior-diff/scripts/reporting/render_html.py b/plugin/skills/behavior-diff/scripts/reporting/render_html.py
index 31cc628..fda4ed7 100644
--- a/plugin/skills/behavior-diff/scripts/reporting/render_html.py
+++ b/plugin/skills/behavior-diff/scripts/reporting/render_html.py
@@ -898,6 +898,16 @@ def _target_explanation(report):
f"{version_name_html('Before')}: {_target_side(report, criterion, 'before')} · "
f"{version_name_html('After')}: {_target_side(report, criterion, 'after')}
"
f"{version_name_html(criterion.required_evidence)}
"
+ )
+ if criterion.rejection is not None:
+ parts.append(
+ f"Goal {criterion.goal} · {html.escape(criterion.mode)}
"
+ ''
+ f"{version_name_html(content.criterion_rejection_text(criterion))}"
+ "
"
+ )
+ continue
+ parts.append(
'Trial-by-trial reasoning and sources
'
f"Goal {criterion.goal} · {html.escape(criterion.mode)}
"
f"{version_name_html(acceptance(criterion))}
"
diff --git a/plugin/skills/behavior-diff/scripts/reporting/render_markdown.py b/plugin/skills/behavior-diff/scripts/reporting/render_markdown.py
index ebefbf0..ce11d8e 100644
--- a/plugin/skills/behavior-diff/scripts/reporting/render_markdown.py
+++ b/plugin/skills/behavior-diff/scripts/reporting/render_markdown.py
@@ -465,6 +465,12 @@ def _target_explanation_markdown(report):
+ "\n",
f"**Required evidence:** {_text(criterion.required_evidence)}\n",
]
+ if criterion.rejection is not None:
+ lines += [
+ f"Goal {criterion.goal} · {_text(criterion.mode)}\n",
+ f"**{_text(content.criterion_rejection_text(criterion))}**\n",
+ ]
+ continue
lines += [
"Trial-by-trial reasoning and sources
\n",
f"Goal {criterion.goal} · {_text(criterion.mode)}\n",
diff --git a/plugin/skills/behavior-diff/scripts/reporting/scenario.py b/plugin/skills/behavior-diff/scripts/reporting/scenario.py
index 19f89ec..310e559 100644
--- a/plugin/skills/behavior-diff/scripts/reporting/scenario.py
+++ b/plugin/skills/behavior-diff/scripts/reporting/scenario.py
@@ -2,13 +2,17 @@
The record is written before trials from private preparation notes. It explains
how the comparison tested the purpose and fixes the checks the assessment must
-answer. It never contains outcomes and is never a trial input.
+answer. Its source requirements bind each source-consistency check to the
+recorded sources that must ground it; they are frozen configuration, not part
+of a check definition the assessment restates. It never contains outcomes and
+is never a trial input.
"""
from dataclasses import dataclass
from typing import Tuple
from reporting.explanation import _text
+from reporting.sources import SourceRequirementData, parse_source_requirements
from reporting.target import EVIDENCE_KINDS, MODES
SUMMARY_LIMIT = 400
@@ -35,6 +39,7 @@ class ScenarioData:
conditions: Tuple[str, ...]
limitations: Tuple[str, ...]
checks: Tuple[ScenarioCheckData, ...]
+ source_requirements: Tuple[SourceRequirementData, ...]
def _texts(values, minimum, maximum):
@@ -80,10 +85,15 @@ def parse_scenario(raw, purpose):
raise ValueError("duplicate scenario check id")
if {check.goal for check in parsed} != set(range(1, len(purpose) + 1)):
raise ValueError("scenario checks must cover every purpose goal")
+ requirements = parse_source_requirements(
+ raw["source_requirements"],
+ (check.id for check in parsed if check.evidence_kind == "source_consistency"),
+ )
return ScenarioData(
_text(raw["summary"], SUMMARY_LIMIT),
_text(raw["rationale"], RATIONALE_LIMIT),
_texts(raw["conditions"], 1, 8),
_texts(raw["limitations"], 0, 8),
parsed,
+ requirements,
)
diff --git a/plugin/skills/behavior-diff/scripts/reporting/schema.py b/plugin/skills/behavior-diff/scripts/reporting/schema.py
index 2467149..bf1665c 100644
--- a/plugin/skills/behavior-diff/scripts/reporting/schema.py
+++ b/plugin/skills/behavior-diff/scripts/reporting/schema.py
@@ -49,7 +49,7 @@
report_source,
)
-SCHEMA_VERSION = 18
+SCHEMA_VERSION = 19
RESULT_KINDS = ("good", "bad", "neutral")
@@ -607,6 +607,7 @@ def _decisions(value, path, hunk_count, groups=None, final_answers=None):
evidence,
parsed_rows,
scenario=scenario,
+ persisted=True,
)
if raw_target is not None and (
target is None or _json_value(asdict(target)) != raw_target
diff --git a/plugin/skills/behavior-diff/scripts/reporting/sources.py b/plugin/skills/behavior-diff/scripts/reporting/sources.py
new file mode 100644
index 0000000..4bc7a5a
--- /dev/null
+++ b/plugin/skills/behavior-diff/scripts/reporting/sources.py
@@ -0,0 +1,119 @@
+"""Identify recorded source descriptors and the frozen sources a check requires.
+
+Patterns are bounded `fnmatchcase` globs over a captured record's `source`
+descriptor (tool name plus recorded arguments). They never inspect returned
+content or model explanations, and nothing here runs or fetches a source.
+"""
+
+from dataclasses import dataclass
+from fnmatch import fnmatchcase
+import re
+from typing import Tuple
+
+SENSITIVE_SOURCE = "[sensitive source omitted]"
+DESCRIPTOR_TRUNCATED = " [descriptor truncated]"
+PATTERN_LIMIT = 200
+PATTERNS_PER_GROUP = 8
+GROUP_LIMIT = 32
+
+_SENSITIVE = re.compile(
+ r"(?i)(?:\b(?:api[_ -]?key|access[_ -]?token|password|passwd|client[_ -]?secret)"
+ r"(?:\\?[\"'])?\s*[:=]\s*\S+|\bbearer\s+\S+|-----BEGIN [^-]*PRIVATE KEY-----|"
+ r"\b(?:sk-[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9]{12,}|AKIA[A-Z0-9]{16})\b|"
+ r"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,})"
+)
+_PRIVATE_PATH = re.compile(
+ r"(?i)(?:^|[:/\\\s\"'])(?:\.env(?:\.[^/\\\s]*)?|\.ssh|\.aws|\.gnupg|"
+ r"credentials(?:\.[^/\\\s]*)?|secrets?(?:\.[^/\\\s]*)?|auth\.json|"
+ r"id_rsa|id_ed25519)(?:$|[/\\\s\"':])"
+)
+# A selector needs one literal run that names a path, endpoint, or identifier
+# (a delimiter inside a name, or letters with a number), not merely a tool name,
+# a common word, or a bare extension.
+_IDENTITY = re.compile(
+ r"(?=.{4})(?:.*[A-Za-z0-9][./:#_\-][A-Za-z0-9]|(?=.*[A-Za-z]).*[0-9]).*"
+)
+
+
+@dataclass(frozen=True)
+class SourceRequirementData:
+ check: str
+ any_of: Tuple[str, ...]
+
+
+def sensitive_text(value):
+ """Return whether text matches known credential, email, or private-path forms."""
+ return bool(_SENSITIVE.search(value) or _PRIVATE_PATH.search(value))
+
+
+def _pattern(value):
+ if (
+ type(value) is not str
+ or not 0 < len(value) <= PATTERN_LIMIT
+ or value != value.strip()
+ or any(ord(char) < 32 or 127 <= ord(char) <= 159 for char in value)
+ or "[" in value
+ or "]" in value
+ ):
+ # Brackets would be character classes; omitted descriptors carry them too.
+ raise ValueError("invalid source requirement pattern")
+ if not any(_IDENTITY.fullmatch(run) for run in re.split(r"[*?]+", value)):
+ raise ValueError("source requirement pattern is too broad")
+ # Wildcards stand in for path delimiters when checking private selectors.
+ if sensitive_text(value) or sensitive_text(re.sub(r"[*?]", " ", value)):
+ raise ValueError("source requirement pattern names a sensitive source")
+ return value
+
+
+def parse_source_requirements(raw, source_checks):
+ """Validate frozen groups; every `source_checks` id needs at least one group."""
+ source_checks = tuple(source_checks)
+ if type(raw) is not list or len(raw) > GROUP_LIMIT:
+ raise ValueError("invalid source requirements")
+ parsed = []
+ seen = set()
+ for item in raw:
+ if type(item) is not dict or set(item) != {"check", "any_of"}:
+ raise ValueError("invalid source requirement fields")
+ check = item["check"]
+ if type(check) is not str or check not in source_checks:
+ raise ValueError("source requirement names a non-source-consistency check")
+ patterns = item["any_of"]
+ if type(patterns) is not list or not 1 <= len(patterns) <= PATTERNS_PER_GROUP:
+ raise ValueError("a source requirement needs one to eight patterns")
+ patterns = tuple(_pattern(pattern) for pattern in patterns)
+ if len(set(patterns)) != len(patterns):
+ raise ValueError("duplicate source requirement pattern")
+ identity = (check, frozenset(patterns))
+ if identity in seen:
+ raise ValueError("duplicate source requirement group")
+ seen.add(identity)
+ parsed.append(SourceRequirementData(check, patterns))
+ if {requirement.check for requirement in parsed} != set(source_checks):
+ raise ValueError("every source consistency check needs a source requirement")
+ return tuple(parsed)
+
+
+def source_matches(source, patterns):
+ """Match a trustworthy recorded descriptor against validated patterns."""
+ if (
+ type(source) is not str
+ or source == SENSITIVE_SOURCE
+ or source.endswith(DESCRIPTOR_TRUNCATED)
+ ):
+ return False
+ return any(fnmatchcase(source, pattern) for pattern in patterns)
+
+
+def unmet_source_requirements(requirements, check, sources):
+ """Return `check`'s groups that none of the given descriptors satisfies.
+
+ Pass only descriptors of referenced, readable, proven recorded calls.
+ """
+ sources = tuple(sources)
+ return tuple(
+ requirement
+ for requirement in requirements
+ if requirement.check == check
+ and not any(source_matches(source, requirement.any_of) for source in sources)
+ )
diff --git a/plugin/skills/behavior-diff/scripts/reporting/summary.py b/plugin/skills/behavior-diff/scripts/reporting/summary.py
index 0749a00..442bcdf 100644
--- a/plugin/skills/behavior-diff/scripts/reporting/summary.py
+++ b/plugin/skills/behavior-diff/scripts/reporting/summary.py
@@ -23,7 +23,8 @@
"preserved",
"unavailable",
)
-CHECK_STATES = FINDING_STATES[:-1]
+# A check is "unavailable" only when validation rejected that one criterion.
+CHECK_STATES = FINDING_STATES
UNMET = "The expected improvement did not happen in these tests."
FINDING_HEADLINES = {
"not_met": UNMET,
@@ -46,6 +47,7 @@
"improved": "met After; not met Before",
"already_met": "already met Before",
"preserved": "preserved",
+ "unavailable": "not assessed",
}
CHECK_MEANINGS = {
"not_met": "At least one After trial did not meet this.",
@@ -64,6 +66,10 @@
"already_met": "Before already met this, so the edit showed no added benefit here.",
"preserved": "Every After trial still met this.",
}
+REJECTED_CHECK = (
+ "Not assessed: validation rejected this check ({0}). No Before or After "
+ "outcome is reported for it."
+)
NEXT_ACTIONS = {
"not_met": (
"Revise the edit or its expectation, then retest; inspect the After trials "
@@ -449,6 +455,8 @@ def _side_health(criterion, side, variant):
def check_state(criterion, variants):
+ if criterion.rejection is not None:
+ return "unavailable"
after = criterion.after
if any(trial.outcome == "not_met" for trial in after):
return "not_met"
@@ -467,6 +475,18 @@ def finding_state(states):
return next(state for state in CHECK_STATES if state in states)
+def overall_state(checks, unchecked=()):
+ """Overall finding: an accepted After miss wins; rejected checks never pass."""
+ states = {check.state for check in checks}
+ if checks and unchecked:
+ # A conclusion needs every frozen goal checked; a gap leaves it unsettled.
+ states.add("inconclusive")
+ if "unavailable" in states and len(states) > 1:
+ # A rejected check leaves the rest unsettled; an accepted miss still wins.
+ states.add("inconclusive")
+ return finding_state(states)
+
+
def build_checks(metadata, variants, target):
from reporting.schema import SummaryCheckData
@@ -479,6 +499,8 @@ def build_checks(metadata, variants, target):
and metadata.trace_source == "self-reported"
else CHECK_SCOPES[criterion.evidence_kind]
)
+ if criterion.rejection:
+ scope = "Required evidence, not assessed: " + criterion.required_evidence
checks.append(
SummaryCheckData(
number,
@@ -487,7 +509,9 @@ def build_checks(metadata, variants, target):
state,
question_distribution(criterion, "before", variants.before.total),
question_distribution(criterion, "after", variants.after.total),
- CHECK_MEANINGS[state],
+ REJECTED_CHECK.format(criterion.rejection)
+ if state == "unavailable"
+ else CHECK_MEANINGS[state],
scope,
)
)
@@ -576,12 +600,8 @@ def claim(value):
"One scenario only; counts and narrative are model interpretations, not causal proof."
]
checks = build_checks(metadata, variants, decisions.target_assessment)
- states = {check.state for check in checks}
unchecked = unchecked_goals(decisions)
- if checks and unchecked:
- # A conclusion needs every frozen goal checked; a gap leaves it unsettled.
- states.add("inconclusive")
- state = finding_state(states)
+ state = overall_state(checks, unchecked)
if not complete:
notices.append(
"Trial evidence is incomplete, blocked, invalid, or dropped; inspect the trial records."
diff --git a/plugin/skills/behavior-diff/scripts/reporting/target.py b/plugin/skills/behavior-diff/scripts/reporting/target.py
index 7d35d10..94b4a79 100644
--- a/plugin/skills/behavior-diff/scripts/reporting/target.py
+++ b/plugin/skills/behavior-diff/scripts/reporting/target.py
@@ -3,18 +3,18 @@
from collections import Counter
import re
from dataclasses import dataclass
-from typing import Tuple
+from typing import Optional, Tuple
from reporting.evidence import (
EXCERPT_LIMIT,
RECORD_LIMIT,
RUN_LIMIT,
- SENSITIVE_SOURCE,
SOURCE_LIMIT,
TRIAL_LIMIT,
UNPROVEN_CALL_REASONS,
)
from reporting.explanation import _text
+from reporting.sources import SENSITIVE_SOURCE, unmet_source_requirements
OUTCOMES = ("met", "not_met", "uncertain", "unassessable")
MODES = ("correction", "new", "change", "preservation")
@@ -103,6 +103,7 @@ class TargetCriterionData:
decisions: Tuple[int, ...]
before: Tuple[TrialAssessmentData, ...]
after: Tuple[TrialAssessmentData, ...]
+ rejection: Optional[str] = None
@dataclass(frozen=True)
@@ -281,21 +282,199 @@ def _captured_call(record):
def parse_target(
- raw, purpose, final_answers, evidence, rows=(), positions=None, *, scenario=None
+ raw,
+ purpose,
+ final_answers,
+ evidence,
+ rows=(),
+ positions=None,
+ *,
+ scenario=None,
+ persisted=False,
):
- """Invalid assessments stay unavailable, never become successful or assessed-empty."""
+ """Retain rejected checks without treating them as assessed outcomes."""
return check_target(
- raw, purpose, final_answers, evidence, rows, positions, scenario=scenario
+ raw,
+ purpose,
+ final_answers,
+ evidence,
+ rows,
+ positions,
+ scenario=scenario,
+ persisted=persisted,
)[0]
+def _criterion_definition(item, purpose, seen, frozen):
+ if type(item) is not dict:
+ raise ValueError("invalid criterion fields")
+ identifier = _text(item["id"], 80)
+ goal = item["goal"]
+ if (
+ identifier != item["id"]
+ or identifier in seen
+ or type(goal) is not int
+ or not 1 <= goal <= len(purpose)
+ or item["mode"] not in MODES
+ ):
+ raise ValueError("invalid criterion identity")
+ if item["evidence_kind"] not in EVIDENCE_KINDS:
+ raise ValueError("invalid criterion evidence requirement")
+ definition = (
+ identifier,
+ goal,
+ _text(item["text"], 600),
+ item["mode"],
+ _text(item["required_evidence"], 600),
+ item["evidence_kind"],
+ )
+ if frozen is not None and (
+ identifier not in frozen
+ or definition
+ != tuple(getattr(frozen[identifier], name) for name in _DEFINITION)
+ ):
+ raise ValueError("target criterion must restate its frozen scenario check")
+ return definition
+
+
+def _criterion_assessment(
+ item, definition, final_answers, records, positions, requirements, persisted
+):
+ fields = set(_DEFINITION) | {"decisions", "before", "after"}
+ if set(item) not in (fields, fields | {"rejection"}):
+ raise ValueError("invalid criterion fields")
+ rejection = item.get("rejection")
+ if rejection is not None:
+ if (
+ not persisted
+ or _text(rejection, 240) != rejection
+ or any(item[field] != [] for field in ("decisions", "before", "after"))
+ ):
+ raise ValueError("invalid persisted criterion rejection")
+ return TargetCriterionData(*definition, (), (), (), rejection)
+ references = item["decisions"]
+ if (
+ type(references) is not list
+ or any(type(ref) is not int or ref not in positions for ref in references)
+ or len(set(references)) != len(references)
+ ):
+ raise ValueError("invalid criterion row references")
+ sides = []
+ for side in ("before", "after"):
+ expected = {
+ name
+ for name, answer in (final_answers or {}).get(side, {}).items()
+ if answer.strip()
+ }
+ trials = item[side]
+ if not expected or type(trials) is not list or len(trials) != len(expected):
+ raise ValueError("target must assess every completed trial")
+ outcomes = []
+ assigned = set()
+ for trial in trials:
+ if type(trial) is not dict or set(trial) != {
+ "trial",
+ "outcome",
+ "refs",
+ "explanation",
+ "output_excerpt",
+ }:
+ raise ValueError("invalid target trial")
+ name, outcome, refs = trial["trial"], trial["outcome"], trial["refs"]
+ if (
+ type(name) is not str
+ or name not in expected
+ or name in assigned
+ or outcome not in OUTCOMES
+ or type(refs) is not list
+ or any(type(ref) is not str for ref in refs)
+ or len(set(refs)) != len(refs)
+ ):
+ raise ValueError("invalid target trial membership")
+ excerpt = trial["output_excerpt"]
+ if type(excerpt) is not str or len(excerpt) > 1200:
+ raise ValueError("invalid target output excerpt")
+ if excerpt and name + ":answer" not in refs:
+ raise ValueError("target output excerpt must cite its trial answer")
+ if excerpt and excerpt not in final_answers[side][name]:
+ raise ValueError("target output excerpt must be exact and trial-local")
+ for ref in refs:
+ if ref != name + ":answer" and (
+ ref not in records or records[ref].trial != name
+ ):
+ raise ValueError("target reference must resolve in its own trial")
+ answer = name + ":answer"
+ content = [
+ ref for ref in refs if ref != answer and _returned_content(records[ref])
+ ]
+ calls = [
+ ref for ref in refs if ref != answer and _captured_call(records[ref])
+ ]
+ if outcome in ("met", "not_met"):
+ if not refs or any(
+ ref != answer and ref not in content and ref not in calls
+ for ref in refs
+ ):
+ raise ValueError(
+ "determinate target outcome needs available evidence"
+ )
+ has_output = answer in refs
+ if (
+ item["evidence_kind"] == "source_consistency"
+ and not (has_output and content)
+ or item["evidence_kind"] == "output"
+ and not has_output
+ or item["evidence_kind"] == "recorded_action"
+ and not calls
+ ):
+ raise ValueError(
+ "criterion lacks required source or output evidence"
+ )
+ if (
+ item["evidence_kind"] in ("output", "source_consistency")
+ and not excerpt.strip()
+ ):
+ raise ValueError(
+ "output assessment needs its relevant exact excerpt"
+ )
+ if unmet_source_requirements(
+ requirements,
+ definition[0],
+ (records[ref].source for ref in content if ref in calls),
+ ):
+ raise ValueError("criterion lacks a required frozen source")
+ assigned.add(name)
+ outcomes.append(
+ TrialAssessmentData(
+ name,
+ outcome,
+ tuple(refs),
+ _text(trial["explanation"], 600),
+ excerpt,
+ )
+ )
+ sides.append(tuple(outcomes))
+ return TargetCriterionData(
+ *definition, tuple(positions[ref] for ref in references), *sides
+ )
+
+
def check_target(
- raw, purpose, final_answers, evidence, rows=(), positions=None, *, scenario=None
+ raw,
+ purpose,
+ final_answers,
+ evidence,
+ rows=(),
+ positions=None,
+ *,
+ scenario=None,
+ persisted=False,
):
- """Return (assessment, None), or (None, why a returned assessment was rejected).
+ """Validate identities globally, then isolate each check's evidence failures.
- A frozen scenario fixes the criterion set: the assessment must restate every
- check exactly and may supply only row links and per-trial outcomes.
+ Missing, duplicate or rewritten definitions invalidate the whole assessment.
+ A rejected check retains its definition and diagnostic, never invented outcomes.
+ Only persisted producer output may carry an existing rejection diagnostic.
"""
if raw is None:
return None, None
@@ -313,6 +492,16 @@ def check_target(
if scenario is not None
else None
)
+ definitions, seen, covered_goals = [], set(), set()
+ for item in raw["criteria"]:
+ definition = _criterion_definition(item, purpose, seen, frozen)
+ definitions.append(definition)
+ seen.add(definition[0])
+ covered_goals.add(definition[1])
+ if covered_goals != set(range(1, len(purpose) + 1)):
+ raise ValueError("target criteria must cover every purpose goal")
+ if frozen is not None and seen != set(frozen):
+ raise ValueError("target criteria must assess every frozen scenario check")
positions = (
positions
if positions is not None
@@ -320,186 +509,34 @@ def check_target(
)
records = {entry.id: entry for entry in evidence}
criteria = []
- seen = set()
- covered_goals = set()
- for item in raw["criteria"]:
- if type(item) is not dict or set(item) != {
- "id",
- "goal",
- "text",
- "mode",
- "required_evidence",
- "evidence_kind",
- "decisions",
- "before",
- "after",
- }:
- raise ValueError("invalid criterion fields")
- identifier = _text(item["id"], 80)
- goal = item["goal"]
- if (
- identifier != item["id"]
- or identifier in seen
- or type(goal) is not int
- or not 1 <= goal <= len(purpose)
- or item["mode"] not in MODES
- ):
- raise ValueError("invalid criterion identity")
- if item["evidence_kind"] not in EVIDENCE_KINDS:
- raise ValueError("invalid criterion evidence requirement")
- definition = (
- identifier,
- goal,
- _text(item["text"], 600),
- item["mode"],
- _text(item["required_evidence"], 600),
- item["evidence_kind"],
- )
- if frozen is not None and (
- identifier not in frozen
- or definition
- != tuple(getattr(frozen[identifier], name) for name in _DEFINITION)
- ):
- raise ValueError(
- "target criterion must restate its frozen scenario check"
- )
- references = item["decisions"]
- if (
- type(references) is not list
- or any(
- type(ref) is not int or ref not in positions for ref in references
- )
- or len(set(references)) != len(references)
- ):
- raise ValueError("invalid criterion row references")
- sides = []
- for side in ("before", "after"):
- expected = {
- name
- for name, answer in (final_answers or {}).get(side, {}).items()
- if answer.strip()
- }
- trials = item[side]
- if (
- not expected
- or type(trials) is not list
- or len(trials) != len(expected)
- ):
- raise ValueError("target must assess every completed trial")
- outcomes = []
- assigned = set()
- for trial in trials:
- if type(trial) is not dict or set(trial) != {
- "trial",
- "outcome",
- "refs",
- "explanation",
- "output_excerpt",
- }:
- raise ValueError("invalid target trial")
- name = trial["trial"]
- outcome = trial["outcome"]
- refs = trial["refs"]
- if (
- type(name) is not str
- or name not in expected
- or name in assigned
- or outcome not in OUTCOMES
- or type(refs) is not list
- or any(type(ref) is not str for ref in refs)
- or len(set(refs)) != len(refs)
- ):
- raise ValueError("invalid target trial membership")
- excerpt = trial["output_excerpt"]
- if (
- type(excerpt) is not str
- or len(excerpt) > 1200
- or (
- excerpt
- and (
- name + ":answer" not in refs
- or excerpt not in final_answers[side][name]
- )
- )
- ):
- raise ValueError(
- "target output excerpt must be exact and trial-local"
- )
- for ref in refs:
- if ref == name + ":answer":
- continue
- if ref not in records or records[ref].trial != name:
- raise ValueError(
- "target reference must resolve in its own trial"
- )
- answer = name + ":answer"
- content = [
- ref
- for ref in refs
- if ref != answer and _returned_content(records[ref])
- ]
- calls = [
- ref
- for ref in refs
- if ref != answer and _captured_call(records[ref])
- ]
- if outcome in ("met", "not_met") and (
- not refs
- or any(
- ref != answer and ref not in content and ref not in calls
- for ref in refs
- )
- ):
- raise ValueError(
- "determinate target outcome needs available evidence"
- )
- if outcome in ("met", "not_met"):
- has_output = answer in refs
- if (
- item["evidence_kind"] == "source_consistency"
- and not (has_output and content)
- or item["evidence_kind"] == "output"
- and not has_output
- or item["evidence_kind"] == "recorded_action"
- and not calls
- ):
- raise ValueError(
- "criterion lacks required source or output evidence"
- )
- if (
- item["evidence_kind"] in ("output", "source_consistency")
- and not excerpt.strip()
- ):
- raise ValueError(
- "output assessment needs its relevant exact excerpt"
- )
- assigned.add(name)
- outcomes.append(
- TrialAssessmentData(
- name,
- outcome,
- tuple(refs),
- _text(trial["explanation"], 600),
- excerpt,
- )
- )
- sides.append(tuple(outcomes))
- seen.add(identifier)
- covered_goals.add(goal)
- criteria.append(
- TargetCriterionData(
- *definition,
- tuple(positions[ref] for ref in references),
- *sides,
+ for item, definition in zip(raw["criteria"], definitions):
+ requirements = (
+ tuple(
+ requirement
+ for requirement in scenario.source_requirements
+ if requirement.check == definition[0]
)
+ if scenario is not None
+ else ()
)
- if covered_goals != set(range(1, len(purpose) + 1)):
- raise ValueError("target criteria must cover every purpose goal")
- if frozen is not None:
- if seen != set(frozen):
- raise ValueError(
- "target criteria must assess every frozen scenario check"
+ try:
+ criterion = _criterion_assessment(
+ item,
+ definition,
+ final_answers,
+ records,
+ positions,
+ requirements,
+ persisted,
+ )
+ except ValueError as error:
+ criterion = TargetCriterionData(*definition, (), (), (), str(error))
+ except (KeyError, TypeError, AttributeError):
+ criterion = TargetCriterionData(
+ *definition, (), (), (), "malformed criterion assessment"
)
+ criteria.append(criterion)
+ if scenario is not None:
order = {check.id: index for index, check in enumerate(scenario.checks)}
criteria.sort(key=lambda criterion: order[criterion.id])
return TargetAssessmentData(tuple(criteria)), None
@@ -519,6 +556,8 @@ def distribution(criterion, side):
def conclusion(criterion):
+ if criterion.rejection:
+ return "Not assessed: " + criterion.rejection + "."
before = Counter(trial.outcome for trial in criterion.before)
after = Counter(trial.outcome for trial in criterion.after)
if before.get("unassessable", 0) == len(criterion.before) and after.get(
@@ -547,6 +586,8 @@ def conclusion(criterion):
def acceptance(criterion):
+ if criterion.rejection:
+ return "Resolve the rejected evidence before drawing a conclusion about this check."
if any(trial.outcome == "not_met" for trial in criterion.after):
return "Decide whether to accept the edit with the observed target problem still present; inspect the affected After trials."
if any(
diff --git a/plugin/skills/behavior-diff/scripts/scenario.py b/plugin/skills/behavior-diff/scripts/scenario.py
index 71dcabc..c5acc33 100755
--- a/plugin/skills/behavior-diff/scripts/scenario.py
+++ b/plugin/skills/behavior-diff/scripts/scenario.py
@@ -42,14 +42,21 @@ def main():
# Do not expose rejected data or private paths in error messages.
print(
"behavior-diff: invalid --scenario-file JSON; use null or "
- "{summary,rationale,conditions,limitations,checks} with summary "
+ "{summary,rationale,conditions,limitations,checks,"
+ "source_requirements} with summary "
"<=400 and rationale <=1200 characters, 1..8 conditions and 0..8 "
"limitations (<=300 each), and 1..16 checks "
"{id,goal,text,mode,required_evidence,evidence_kind} with unique "
"ids (<=80), 1-based goal numbers covering every purpose goal, mode "
"correction|new|change|preservation, evidence_kind "
"output|source_consistency|recorded_action, text and "
- "required_evidence <=600; plain text only",
+ "required_evidence <=600; plain text only. source_requirements is "
+ "0..32 distinct {check,any_of} groups naming only "
+ "source_consistency check ids, covering every such check, each "
+ "with 1..8 distinct fnmatch patterns (<=200, no brackets, no edge "
+ "spaces) over recorded source descriptors; each pattern needs a "
+ "literal path, endpoint, or identifier and must not name a "
+ "sensitive source",
file=sys.stderr,
)
return 2
diff --git a/tests/hooks-test.sh b/tests/hooks-test.sh
index a8deadc..9f1f535 100755
--- a/tests/hooks-test.sh
+++ b/tests/hooks-test.sh
@@ -248,7 +248,7 @@ cat >"$scenario_purpose" <<'JSON'
{"goals":[{"text":"Preserve the synthetic source check.","source":"session","basis":"explicit","reference":"Current conversation: selected edit"},{"text":"Report the synthetic conflicting record.","source":"session","basis":"inferred","reference":"Current conversation: selected edit"}]}
JSON
scenario_check='{"id":"source-check","goal":1,"text":"The answer reads the synthetic rules.","mode":"preservation","required_evidence":"A recorded read of the synthetic rules.","evidence_kind":"recorded_action"}'
-scenario_head='"summary":"Synthetic records include one conflicting row.","rationale":"The row makes the checks observable.","conditions":["One synthetic record conflicts with the rules."],"limitations":[]'
+scenario_head='"summary":"Synthetic records include one conflicting row.","rationale":"The row makes the checks observable.","conditions":["One synthetic record conflicts with the rules."],"limitations":[],"source_requirements":[]'
for scenario_case in no-purpose missing malformed unknown-field dropped-goal; do
scenario_path=$tmp/$scenario_case-scenario.json
purpose_args=(--purpose-file "$scenario_purpose")
@@ -580,7 +580,7 @@ for world in git copy; do
{"goals":[{"text":"Preserve the synthetic source check.","source":"session","basis":"explicit","reference":"Current conversation: selected edit"}]}
JSON
cat >"$purpose_project/scenario.json" <<'JSON'
-{"summary":"Synthetic scenario summary sentinel.","rationale":"Synthetic rationale.","conditions":["Synthetic condition."],"limitations":[],"checks":[{"id":"source-check","goal":1,"text":"The answer reads the synthetic rules.","mode":"preservation","required_evidence":"A recorded read of the synthetic rules.","evidence_kind":"recorded_action"}]}
+{"summary":"Synthetic scenario summary sentinel.","rationale":"Synthetic rationale.","conditions":["Synthetic condition."],"limitations":[],"checks":[{"id":"source-check","goal":1,"text":"The answer reads the synthetic rules.","mode":"preservation","required_evidence":"A recorded read of the synthetic rules.","evidence_kind":"recorded_action"}],"source_requirements":[]}
JSON
if [ "$world" = git ]; then
git -C "$purpose_project" init -q
@@ -644,7 +644,7 @@ cat >"$nested_repo/nested/purpose.json" <<'JSON'
{"goals":[{"text":"Nested goal must be frozen.","source":"session","basis":"explicit","reference":"Synthetic nested context"}]}
JSON
cat >"$nested_repo/nested/scenario.json" <<'JSON'
-{"summary":"Nested scenario must be frozen.","rationale":"Synthetic rationale.","conditions":["Synthetic condition."],"limitations":[],"checks":[{"id":"nested-check","goal":1,"text":"The answer reads the synthetic rule.","mode":"new","required_evidence":"The final answer names the synthetic rule.","evidence_kind":"output"}]}
+{"summary":"Nested scenario must be frozen.","rationale":"Synthetic rationale.","conditions":["Synthetic condition."],"limitations":[],"checks":[{"id":"nested-check","goal":1,"text":"The answer reads the synthetic rule.","mode":"new","required_evidence":"The final answer names the synthetic rule.","evidence_kind":"output"}],"source_requirements":[]}
JSON
git -C "$nested_repo" add AGENTS.md purpose.json nested
git -C "$nested_repo" commit -qm baseline
diff --git a/tests/progress-assessment-test.py b/tests/progress-assessment-test.py
index ebed836..781be3c 100755
--- a/tests/progress-assessment-test.py
+++ b/tests/progress-assessment-test.py
@@ -154,13 +154,19 @@ def test_rejected_target_stays_available_with_its_reason(self):
self.assertEqual(self.outcome()["state"], "available")
self.assertEqual(self.outcome()["reason"], "target_rejected")
saved = json.loads((self.run / "decisions.json").read_text())
- self.assertIsNone(saved["target_assessment"])
- reason = "output assessment needs its relevant exact excerpt"
- self.assertEqual(saved["target_rejection"], reason)
+ self.assertIsNone(saved["target_rejection"])
+ rejected = saved["target_assessment"]["criteria"][0]
+ self.assertTrue(rejected["rejection"])
+ self.assertEqual(rejected["before"], [])
+ self.assertEqual(rejected["after"], [])
self.assertTrue((self.run / "decisions.raw.txt").exists())
report = load_report(self.run, self.run, "synthetic", self.run / "config.json")
- self.assertIsNone(report.decisions.target_assessment)
- self.assertEqual(report.decisions.target_rejection, reason)
+ self.assertEqual(
+ report.decisions.target_assessment.criteria[0].rejection,
+ rejected["rejection"],
+ )
+ self.assertEqual(report.summary.state, "unavailable")
+ self.assertIsNone(report.decisions.target_rejection)
class NativeTrialFailureTests(unittest.TestCase):
diff --git a/tests/recorded-evidence-test.py b/tests/recorded-evidence-test.py
index fd1d23a..a946c54 100755
--- a/tests/recorded-evidence-test.py
+++ b/tests/recorded-evidence-test.py
@@ -18,6 +18,7 @@
TRIAL_LIMIT,
collect_run_evidence,
)
+from reporting.scenario import parse_scenario # noqa: E402
from reporting.target import check_target, evidence_records, parse_purpose # noqa: E402
@@ -58,18 +59,17 @@ def command(identity, text, output="Synthetic pull request body."):
return events
-PURPOSE = parse_purpose(
- {
- "goals": [
- {
- "text": "Read the synthetic source pull request before drafting.",
- "source": "session",
- "basis": "explicit",
- "reference": "Synthetic request",
- }
- ]
- }
-)
+PURPOSE_RECORD = {
+ "goals": [
+ {
+ "text": "Read the synthetic source pull request before drafting.",
+ "source": "session",
+ "basis": "explicit",
+ "reference": "Synthetic request",
+ }
+ ]
+}
+PURPOSE = parse_purpose(PURPOSE_RECORD)
ANSWERS = {
"before": {"before-1": "Synthetic draft without a source."},
"after": {"after-1": "Synthetic draft citing pull request 7."},
@@ -145,7 +145,17 @@ def test_recorded_content_not_current_file_with_returned_range(self):
def test_pull_request_reads_retain_returns_through_execution_adapters(self):
for tool in ("Bash", "exec_command", "fixture__exec"):
- for text in ("gh pr view 7", "gh pr diff 7"):
+ for text in (
+ "gh pr view 7",
+ "gh pr diff 7",
+ "gh api repos/example/project/pulls/7",
+ "gh api -X GET repos/example/project/pulls/7/files",
+ "gh api -XGET --paginate repos/example/project/pulls/7/commits",
+ "gh api --method=GET --jq .body repos/example/project/pulls/7",
+ "gh api -H 'Accept: application/vnd.github.diff' "
+ "repos/example/project/pulls/7",
+ "/bin/zsh -lc 'gh api repos/example/project/pulls/7'",
+ ):
with self.subTest(tool=tool, command=text):
events = command("read-pr", text, "Synthetic source: cloud only.")
events[0]["message"]["content"][0]["name"] = tool
@@ -160,6 +170,20 @@ def test_execution_adapters_do_not_admit_mutations_or_compound_commands(self):
"gh pr edit 7 --body altered",
"gh pr merge 7",
"gh api -X POST repos/example/project",
+ "gh api -XPATCH repos/example/project/pulls/7",
+ "gh api --method DELETE repos/example/project/labels/7",
+ "gh api --method=get repos/example/project/pulls/7",
+ "gh api repos/example/project/issues -f title=altered",
+ "gh api -X GET search/issues -f q=altered",
+ "gh api -F body=@notes.txt repos/example/project/issues/7/comments",
+ "gh api --raw-field body=altered repos/example/project/issues",
+ "gh api --input body.json repos/example/project/issues",
+ "gh api -H 'X-HTTP-Method-Override: DELETE' repos/example/project",
+ "gh api --verbose repos/example/project/pulls/7",
+ "gh api graphql",
+ "gh api",
+ "gh api repos/example/project repos/example/other",
+ "gh api repos/example/project -X",
"gh pr view 7; gh pr merge 7",
"gh pr diff 7 | cat",
"gh pr view $(cat .env)",
@@ -397,6 +421,207 @@ def test_codex_runner_wrappers_retain_only_simple_recorded_reads(self):
self.assertIn(json.dumps(command), record["source"])
+def scenario_config(*groups):
+ """Synthetic frozen run configuration binding one source-consistency check."""
+ goal = {
+ "text": "Report the synthetic export cap from its source.",
+ "source": "session",
+ "basis": "explicit",
+ "reference": "Synthetic request",
+ }
+ check = {
+ "id": "cap-agrees",
+ "goal": 1,
+ "text": "The reported cap agrees with the recorded source.",
+ "mode": "new",
+ "required_evidence": "The final answer and the recorded source return.",
+ "evidence_kind": "source_consistency",
+ }
+ return {
+ "purpose": {"goals": [goal]},
+ "scenario_record": {
+ "summary": "Synthetic guides surround one small source of the cap.",
+ "rationale": "Long guides compete with the source for budget.",
+ "conditions": ["The cap appears only in the synthetic source."],
+ "limitations": [],
+ "checks": [check],
+ "source_requirements": [
+ {"check": "cap-agrees", "any_of": list(patterns)} for patterns in groups
+ ],
+ },
+ }
+
+
+class SourceRequirementTests(unittest.TestCase):
+ """Frozen source requirements reorder an unchanged budget, never widen it."""
+
+ def setUp(self):
+ self.temp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.temp.cleanup)
+ self.root = Path(self.temp.name)
+
+ def trace(self, events, trial="before-1"):
+ directory = self.root / trial
+ directory.mkdir(exist_ok=True)
+ (directory / "trace.jsonl").write_text(
+ "".join(json.dumps(x) + "\n" for x in events)
+ )
+
+ def configure(self, *groups):
+ (self.root / "config.json").write_text(json.dumps(scenario_config(*groups)))
+
+ def records(self, trial="before-1"):
+ return collect_run_evidence(self.root)["trials"][trial]
+
+ def guides(self, count):
+ return sum(
+ (
+ call(f"g{i}", path=f"guide-{i}.md", text="g" * 9000)
+ for i in range(count)
+ ),
+ [],
+ )
+
+ def test_more_incidental_reads_do_not_shrink_a_required_source(self):
+ facts = "Export cap is 700 rows. " * 120
+ source = call("source", path="src/export_limits.py", text=facts)
+ for count in (4, 8, 16):
+ with self.subTest(guides=count):
+ self.trace(self.guides(count) + source)
+ self.configure(["*src/export_limits.py*"])
+ result = collect_run_evidence(self.root)
+ records = result["trials"]["before-1"]
+ self.assertEqual(records[-1]["text"], facts)
+ self.assertEqual(records[-1]["status"], "available")
+ self.assertEqual(records[-1]["id"], f"before-1:tool-{count + 1}")
+ self.assertEqual(len(records), count + 1)
+ self.assertLessEqual(
+ sum(len(record["text"]) for record in records), TRIAL_LIMIT
+ )
+ self.assertTrue(
+ all(record["text"] for record in records[:-1]),
+ "incidental reads still share the remainder",
+ )
+ selection = result["limits"]["selection"]
+ self.assertIn("frozen source requirements", selection)
+ (self.root / "config.json").unlink()
+ unbound = collect_run_evidence(self.root)
+ self.assertEqual(
+ [record["id"] for record in unbound["trials"]["before-1"]],
+ [record["id"] for record in records],
+ )
+ self.assertNotIn(
+ "frozen source requirements", unbound["limits"]["selection"]
+ )
+ if count == 16:
+ # Historical selection still fair-shares the same source down.
+ last = unbound["trials"]["before-1"][-1]
+ self.assertEqual(last["status"], "truncated")
+ self.assertLess(len(last["text"]), len(facts))
+
+ def test_alias_and_groups_prioritize_every_matching_return(self):
+ cap = "Export cap is 700 rows. " * 60
+ joins = "Joined comparisons use preview rows. " * 40
+ events = (
+ self.guides(6)
+ + command("api", "gh api repos/example/project/contents/limits-7", cap)
+ + call("joins", path="docs/joins.md", text=joins)
+ )
+ self.trace(events)
+ self.configure(
+ ["*src/export_limits.py*", "*gh api repos/*/contents/limits-7*"],
+ ["*docs/joins.md*"],
+ )
+ records = self.records()
+ self.assertEqual((records[-2]["text"], records[-1]["text"]), (cap, joins))
+ self.assertTrue(all(r["status"] == "truncated" for r in records[:-2]))
+ self.assertEqual(sum(len(record["text"]) for record in records), TRIAL_LIMIT)
+
+ def test_required_budget_exhaustion_keeps_omission_records(self):
+ required = sum(
+ (
+ call(f"r{i}", path=f"src/limits-{i}.py", text="r" * EXCERPT_LIMIT)
+ for i in range(5)
+ ),
+ [],
+ )
+ events = self.guides(2) + required
+ for number in range(10):
+ side = "before" if number < 5 else "after"
+ self.trace(events, f"{side}-{number % 5}")
+ self.configure(["*src/limits-?.py*"])
+ result = collect_run_evidence(self.root)
+ total = 0
+ for name, records in result["trials"].items():
+ with self.subTest(trial=name):
+ size = sum(len(record["text"]) for record in records)
+ self.assertLessEqual(size, TRIAL_LIMIT)
+ total += size
+ for record in records[:2]:
+ self.assertEqual(record["text"], "")
+ self.assertEqual(record["status"], "omitted")
+ self.assertEqual(record["range"]["end"], 0)
+ self.assertEqual(
+ record["reason"], "assessment content budget exhausted"
+ )
+ for record in records[2:]:
+ self.assertEqual(record["status"], "truncated")
+ self.assertEqual(record["range"]["end"], len(record["text"]))
+ self.assertTrue(record["text"])
+ self.assertLessEqual(total, RUN_LIMIT)
+ self.assertEqual(result, collect_run_evidence(self.root))
+
+ def test_required_matches_keep_privacy_error_and_descriptor_rules(self):
+ facts = "Export cap is 700 rows. " * 120
+ long = call("long", path="src/export_limits.py", text=facts)
+ # The matching path stays visible; only a later argument is cut off.
+ long[0]["message"]["content"][0]["input"]["window"] = "w" * 250
+ events = (
+ self.guides(8)
+ + call("email", path="src/export_limits.py", text="a@example.invalid")
+ + call("error", path="src/export_limits.py", is_error=True)
+ + call("secret", path="secrets/export_limits.py")
+ + long
+ )
+ self.trace(events)
+ self.configure(["*export_limits.py*"])
+ email, error, secret, long = self.records()[-4:]
+ self.assertEqual((email["status"], email["text"]), ("omitted", ""))
+ self.assertEqual((error["status"], error["text"]), ("unavailable", ""))
+ self.assertEqual(secret["source"], "[sensitive source omitted]")
+ self.assertEqual((secret["status"], secret["text"]), ("omitted", ""))
+ # A truncated descriptor is not a trustworthy identity, so no priority.
+ self.assertIn("src/export_limits.py", long["source"])
+ self.assertTrue(long["source"].endswith("[descriptor truncated]"))
+ self.assertEqual(long["status"], "truncated")
+ self.assertEqual(long["reason"], "fair-share assessment truncation")
+ self.assertLess(len(long["text"]), len(facts))
+
+ def test_required_patterns_never_admit_mutating_or_ineligible_returns(self):
+ self.trace(
+ command("post", "gh api -X POST repos/example/project/limits-7")
+ + command("edit", "gh pr edit 7 --body limits-7")
+ )
+ self.configure(["*limits-7*"])
+ for record in self.records():
+ self.assertEqual((record["status"], record["text"]), ("omitted", ""))
+ self.assertEqual(record["reason"], "not an eligible recorded read")
+
+ def test_invalid_frozen_scenario_fails_closed(self):
+ self.trace(call("read-1"))
+ config = scenario_config(["*"])
+ (self.root / "config.json").write_text(json.dumps(config))
+ with self.assertRaises(ValueError):
+ collect_run_evidence(self.root)
+ del config["scenario_record"]["source_requirements"]
+ (self.root / "config.json").write_text(json.dumps(config))
+ with self.assertRaises(ValueError):
+ collect_run_evidence(self.root)
+ config["scenario_record"] = None
+ (self.root / "config.json").write_text(json.dumps(config))
+ self.assertEqual(self.records()[0]["status"], "available")
+
+
class TargetEvidenceTests(unittest.TestCase):
"""A captured call proves that the call ran, never what it returned."""
@@ -414,9 +639,18 @@ def recorded(self, after_events):
if events is not None:
trace.write_text("".join(json.dumps(x) + "\n" for x in events))
- def check(self, raw):
+ def check(self, raw, scenario=None):
evidence = evidence_records(collect_run_evidence(self.root))
- return check_target(raw, PURPOSE, ANSWERS, evidence)
+ return check_target(raw, PURPOSE, ANSWERS, evidence, scenario=scenario)
+
+ def rejected(self, raw, scenario=None):
+ """Return one criterion's own rejection; the target itself must survive."""
+ target, rejection = self.check(raw, scenario)
+ self.assertIsNone(rejection)
+ (assessed,) = target.criteria
+ self.assertEqual(assessed.decisions, ())
+ self.assertEqual((assessed.before, assessed.after), ((), ()))
+ return assessed.rejection
def test_recorded_call_proves_action_despite_omitted_return(self):
self.recorded(
@@ -437,8 +671,7 @@ def test_call_without_returned_content_cannot_prove_source_agreement(self):
before_outcome="unassessable",
)
self.assertEqual(
- self.check(raw),
- (None, "criterion lacks required source or output evidence"),
+ self.rejected(raw), "criterion lacks required source or output evidence"
)
raw["criteria"][0]["after"][0]["outcome"] = "unassessable"
target, rejection = self.check(raw)
@@ -465,20 +698,21 @@ def test_unproven_calls_cannot_support_determinate_outcomes(self):
'{"trace_source":"self-reported"}'
)
self.assertEqual(
- self.check(criterion(ref=ref)),
- (None, "determinate target outcome needs available evidence"),
+ self.rejected(criterion(ref=ref)),
+ "determinate target outcome needs available evidence",
)
uncertain = criterion(
after_outcome="uncertain", ref=ref, before_outcome="uncertain"
)
- self.assertIsNone(self.check(uncertain)[1])
+ target, rejection = self.check(uncertain)
+ self.assertIsNone(rejection)
+ self.assertIsNone(target.criteria[0].rejection)
def test_output_outcome_needs_its_excerpt(self):
self.recorded(command("view-1", "gh pr view 7"))
raw = criterion("output", before_outcome="unassessable")
self.assertEqual(
- self.check(raw),
- (None, "output assessment needs its relevant exact excerpt"),
+ self.rejected(raw), "output assessment needs its relevant exact excerpt"
)
excerpt = "citing pull request 7"
raw["criteria"][0]["after"][0]["output_excerpt"] = excerpt
@@ -486,6 +720,61 @@ def test_output_outcome_needs_its_excerpt(self):
self.assertIsNone(rejection)
self.assertEqual(target.criteria[0].after[0].output_excerpt, excerpt)
+ def test_source_consistency_needs_every_frozen_source_group(self):
+ check = {
+ key: value
+ for key, value in criterion("source_consistency")["criteria"][0].items()
+ if key not in ("decisions", "before", "after")
+ }
+ record = {
+ "summary": "One synthetic pull request and one facts file.",
+ "rationale": "Agreement needs both the pull request and the facts file.",
+ "conditions": ["The draft must agree with both synthetic sources."],
+ "limitations": [],
+ "checks": [check],
+ "source_requirements": [
+ {
+ "check": "source-read",
+ "any_of": ["*gh pr view 7*", "*pulls/7/files*"],
+ },
+ {"check": "source-read", "any_of": ["*facts.txt*"]},
+ ],
+ }
+ (self.root / "config.json").write_text(
+ json.dumps({"purpose": PURPOSE_RECORD, "scenario_record": record})
+ )
+ scenario = parse_scenario(record, PURPOSE)
+ self.recorded(
+ command("view-1", "gh pr view 7")
+ + call("facts", text="Recorded cap: 500.")
+ + command("other", "gh pr view 8")
+ )
+ excerpt = "citing pull request 7"
+ cases = (
+ (["after-1:tool-1", "after-1:tool-2"], None),
+ (["after-1:tool-1"], "criterion lacks a required frozen source"),
+ (["after-1:tool-2"], "criterion lacks a required frozen source"),
+ (
+ ["after-1:tool-3", "after-1:tool-2"],
+ "criterion lacks a required frozen source",
+ ),
+ )
+ for refs, expected in cases:
+ with self.subTest(refs=refs):
+ raw = criterion(
+ "source_consistency",
+ excerpt=excerpt,
+ before_outcome="unassessable",
+ )
+ raw["criteria"][0]["after"][0]["refs"] = refs + ["after-1:answer"]
+ if expected:
+ self.assertEqual(self.rejected(raw, scenario), expected)
+ continue
+ target, rejection = self.check(raw, scenario)
+ self.assertIsNone(rejection)
+ self.assertIsNone(target.criteria[0].rejection)
+ self.assertEqual(target.criteria[0].after[0].outcome, "met")
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/report-ci-context-test.py b/tests/report-ci-context-test.py
index 4e8baee..b44d502 100755
--- a/tests/report-ci-context-test.py
+++ b/tests/report-ci-context-test.py
@@ -81,7 +81,6 @@ def _header(page):
def assert_no_context_unchanged():
report = _load(_config())
raw = _roundtrip(report)
- assert raw["schema_version"] == 18
assert raw["metadata"]["ci_context"] is None
page, markdown = _render_offline(report)
for absent in ("source-line", "revision-pair", "local-paths", "Workflow"):
diff --git a/tests/report-schema-test.py b/tests/report-schema-test.py
index b26db03..2b7a75a 100755
--- a/tests/report-schema-test.py
+++ b/tests/report-schema-test.py
@@ -43,7 +43,7 @@
def synthetic_raw():
raw = {
- "schema_version": 18,
+ "schema_version": 19,
"metadata": {
"model": "synthetic/model",
"mode": "review",
@@ -405,6 +405,7 @@ def scenario_record(payload=""):
"evidence_kind": "output",
}
],
+ "source_requirements": [],
}
@@ -434,6 +435,7 @@ def side(name, outcome):
"decisions": [2],
"before": side("before", "not_met"),
"after": side("after", after_outcome),
+ "rejection": None,
}
for check in record["checks"]
]
@@ -3253,7 +3255,7 @@ def main():
assert_rejected(dict(raw, schema_version=9))
assert_rejected(dict(raw, schema_version=10))
# Earlier report-data shapes are rejected; saved runs regenerate from decisions.
- for version in (11, 12, 13, 14, 15, 16, 17):
+ for version in (11, 12, 13, 14, 15, 16, 17, 18):
assert_rejected(dict(raw, schema_version=version))
assert_rejected(dict(raw, schema_version=True))
assert_rejected(dict(raw, schema_version=1.0))
diff --git a/tests/report_fixtures.py b/tests/report_fixtures.py
index da2203b..aa21b48 100644
--- a/tests/report_fixtures.py
+++ b/tests/report_fixtures.py
@@ -1964,6 +1964,9 @@ def _target_report(run, scenario):
"One fictional export change; other release notes are not tested."
],
"checks": [frozen_check],
+ "source_requirements": [
+ {"check": frozen_check["id"], "any_of": ["*implementation.txt*"]}
+ ],
}
_write_json(run / "config.json", config)
if missing:
diff --git a/tests/scenario-contract-test.py b/tests/scenario-contract-test.py
index 40ee9ee..b01f339 100755
--- a/tests/scenario-contract-test.py
+++ b/tests/scenario-contract-test.py
@@ -13,6 +13,13 @@
SCRIPTS = Path(__file__).resolve().parents[1] / "plugin/skills/behavior-diff/scripts"
sys.path.insert(0, str(SCRIPTS))
from scenario import read_scenario
+from reporting.sources import (
+ DESCRIPTOR_TRUNCATED,
+ SENSITIVE_SOURCE,
+ SourceRequirementData,
+ source_matches,
+ unmet_source_requirements,
+)
GOAL = {
"text": "Preserve the synthetic source check.",
@@ -35,7 +42,18 @@
"conditions": ["One synthetic record conflicts with the project rules."],
"limitations": ["Only one conflicting row is included."],
"checks": [CHECK],
+ "source_requirements": [],
}
+SOURCE_CHECK = dict(
+ CHECK,
+ id="agrees-with-rules",
+ text="The reported limit agrees with the recorded rules.",
+ mode="new",
+ required_evidence="The final answer and the recorded rules return.",
+ evidence_kind="source_consistency",
+)
+RULES = {"check": "agrees-with-rules", "any_of": ["*docs/rules.md*"]}
+SOURCED = dict(SCENARIO, checks=[CHECK, SOURCE_CHECK], source_requirements=[RULES])
SENTINEL = "synthetic-private-sentinel-7f3a"
@@ -190,6 +208,107 @@ def run(*arguments):
self.assertIn("--scenario-file", rejected.stderr)
self.assertNotIn(SENTINEL, rejected.stderr)
+ def test_source_consistency_checks_bind_frozen_sources(self):
+ self.assertEqual(self.read(SOURCED), SOURCED)
+ partial = dict(SCENARIO)
+ del partial["source_requirements"]
+ self.rejects(partial)
+ self.rejects(dict(SCENARIO, source_requirements=None))
+ self.rejects(dict(SOURCED, source_requirements=[]))
+ for group in (
+ dict(RULES, check="source-check"),
+ dict(RULES, check="unknown-check"),
+ dict(RULES, any_of=[]),
+ dict(RULES, any_of="*docs/rules.md*"),
+ dict(RULES, any_of=["*docs/rules.md*"] * 2),
+ dict(RULES, any_of=[f"*docs/rules-{n}.md*" for n in range(9)]),
+ dict(RULES, note="raw private note"),
+ {"check": "agrees-with-rules"},
+ ):
+ with self.subTest(group=group):
+ self.rejects(dict(SOURCED, source_requirements=[group]))
+ self.rejects(dict(SCENARIO, source_requirements=[RULES]))
+ alias = dict(RULES, any_of=["*docs/rules.md*", "*gh api repos/*/rules-7*"])
+ second = {"check": "agrees-with-rules", "any_of": ["*data/orders.csv*"]}
+ both = dict(SOURCED, source_requirements=[alias, second])
+ self.assertEqual(self.read(both), both)
+ reordered = dict(alias, any_of=list(reversed(alias["any_of"])))
+ for duplicate in ([RULES, RULES], [alias, reordered]):
+ with self.subTest(duplicate=duplicate):
+ self.rejects(dict(SOURCED, source_requirements=duplicate))
+ many = [dict(RULES, any_of=[f"*docs/rules-{n}.md*"]) for n in range(33)]
+ self.rejects(dict(SOURCED, source_requirements=many))
+ limit = dict(SOURCED, source_requirements=many[:32])
+ self.assertEqual(self.read(limit), limit)
+
+ def sourced(self, pattern):
+ return dict(SOURCED, source_requirements=[dict(RULES, any_of=[pattern])])
+
+ def test_source_patterns_are_bounded_identities(self):
+ accepted = (
+ "*docs/rules.md*",
+ '*"file_path": "*/limits.md"*',
+ "*gh pr view 7*",
+ "*gh api repos/*/pulls/7*",
+ "*export_cap*",
+ "*rules-v?.md*",
+ "*" + "r" * 195 + ".md*",
+ )
+ for pattern in accepted:
+ with self.subTest(pattern=pattern):
+ value = self.sourced(pattern)
+ self.assertEqual(self.read(value), value)
+ for pattern in (
+ "",
+ "*",
+ "*rules?md*",
+ "**",
+ "?*?",
+ "Read *",
+ "*rules*",
+ "*.md*",
+ "*docs/*",
+ " *docs/rules.md*",
+ "*docs/rules.md* ",
+ "*docs/rules.md*\n",
+ "*docs/[rs]ules.md*",
+ "*" + "r" * 196 + ".md*",
+ "*/.env.local*",
+ "*secrets.yaml*",
+ "*config/credentials*",
+ "*password=rules.md*",
+ "*fictional.person@example.invalid*",
+ "*[sensitive source omitted]*",
+ 7,
+ None,
+ ):
+ with self.subTest(pattern=pattern):
+ self.rejects(self.sourced(pattern))
+
+ def test_source_matching_rejects_untrustworthy_descriptors(self):
+ read = 'Read {"file_path": "/stage/after-1/project/docs/rules.md"}'
+ api = 'Bash {"command": "gh api repos/example/project/rules-7"}'
+ alias = ("*docs/rules.md*", "*gh api repos/*/rules-7*")
+ self.assertTrue(source_matches(read, alias))
+ self.assertTrue(source_matches(api, alias))
+ self.assertFalse(source_matches(read.replace("rules", "Rules"), alias))
+ self.assertFalse(source_matches(read, ()))
+ self.assertFalse(source_matches(SENSITIVE_SOURCE, ("*sensitive source*",)))
+ self.assertFalse(source_matches(read + DESCRIPTOR_TRUNCATED, ("*rules.md*",)))
+ self.assertFalse(source_matches(None, alias))
+ first = SourceRequirementData("agrees-with-rules", alias)
+ second = SourceRequirementData("agrees-with-rules", ("*data/orders.csv*",))
+ other = SourceRequirementData("other-check", ("*data/other.csv*",))
+ groups = (first, second, other)
+ self.assertEqual(
+ unmet_source_requirements(groups, "agrees-with-rules", [api]), (second,)
+ )
+ orders = 'Read {"file_path": "data/orders.csv"}'
+ self.assertEqual(
+ unmet_source_requirements(groups, "agrees-with-rules", [read, orders]), ()
+ )
+ self.assertEqual(unmet_source_requirements(groups, "source-check", []), ())
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/shared-data-test.py b/tests/shared-data-test.py
index 65292dd..400e95a 100755
--- a/tests/shared-data-test.py
+++ b/tests/shared-data-test.py
@@ -53,6 +53,7 @@
"evidence_kind": "output",
}
],
+ "source_requirements": [],
}
SOURCE = {
"forge": "github",
diff --git a/tests/target_contract.py b/tests/target_contract.py
index d5852e2..8cb7cf8 100644
--- a/tests/target_contract.py
+++ b/tests/target_contract.py
@@ -484,14 +484,17 @@ def parse(value, records=evidence):
changed["criteria"].append(changed["criteria"][0])
invalid.append(changed)
for changed in invalid:
- assert parse(changed) is None, (
- "Invalid target membership or refs became assessed evidence."
- )
+ rejected = parse(changed)
+ assert rejected is None or all(
+ criterion.rejection and not criterion.before and not criterion.after
+ for criterion in rejected.criteria
+ ), "Invalid target membership or refs became assessed evidence."
omitted = tuple(
replace(entry, status="omitted", text="", reason="Synthetic content omitted")
for entry in evidence
)
- assert parse(raw, omitted) is None, "Unavailable source cannot prove consistency."
+ rejected = parse(raw, omitted).criteria[0]
+ assert rejected.rejection and not rejected.before and not rejected.after
unavailable = copy.deepcopy(raw)
for side in ("before", "after"):
for trial in unavailable["criteria"][0][side]:
@@ -687,7 +690,7 @@ def _assert_missing_records(report):
def _assert_old_versions_rejected(report):
- for version in (11, 12, 13, 14, 15, 16):
+ for version in (11, 12, 13, 14, 15, 16, 17, 18):
raw = report.to_dict()
raw["schema_version"] = version
try:
@@ -767,7 +770,8 @@ def _assert_normalization(report, run):
purpose=purpose,
recorded_evidence=evidence,
)
- assert normalized["target_assessment"] is None
+ criterion = normalized["target_assessment"]["criteria"][0]
+ assert criterion["rejection"] and not criterion["before"] and not criterion["after"]
assert normalized["attention"] is None, (
"Unchanged concern outlived its invalid target support."
)
@@ -801,6 +805,9 @@ def _scenario_record(report):
"evidence_kind": "recorded_action",
},
],
+ "source_requirements": [
+ {"check": accuracy.id, "any_of": ["*implementation.txt*"]}
+ ],
}
@@ -883,6 +890,103 @@ def check(criteria, frozen=scenario):
== check(complete)[0]
)
+ # A bad quotation cannot erase an independent, unchanged After miss.
+ broken = copy.deepcopy(complete)
+ broken[1]["after"][0]["output_excerpt"] = "A quotation not present in this trial."
+ broken[1]["after"][0]["refs"].append("after-1:answer")
+ partial, reason = check(broken)
+ assert reason is None
+ assert partial.criteria[0] == check(complete)[0].criteria[0]
+ rejected = partial.criteria[1]
+ assert rejected.rejection and not rejected.decisions
+ assert rejected.before == rejected.after == ()
+ canonical_partial = json.loads(json.dumps(asdict(partial)))
+ assert check_target(
+ canonical_partial,
+ goals,
+ answers,
+ evidence,
+ report.decisions.rows,
+ scenario=scenario,
+ persisted=True,
+ ) == (partial, None)
+ spoofed, reason = check(canonical_partial["criteria"])
+ assert reason is None and spoofed.criteria[1].rejection != rejected.rejection
+
+ # The production report keeps coverage, links and diagnostics in both formats.
+ accepted = partial.criteria[0]
+ met = replace(
+ accepted,
+ before=tuple(replace(trial, outcome="met") for trial in accepted.before),
+ after=tuple(replace(trial, outcome="met") for trial in accepted.after),
+ )
+ rejected_first = replace(
+ accepted, decisions=(), before=(), after=(), rejection=rejected.rejection
+ )
+ for criteria, expected_state in (
+ (partial.criteria, "not_met"),
+ ((met, rejected), "inconclusive"),
+ ((rejected_first, rejected), "unavailable"),
+ ):
+ data = replace(
+ report.decisions,
+ target_assessment=replace(partial, criteria=criteria),
+ scenario_record=scenario,
+ attention=None,
+ )
+ rendered_report = replace(
+ report,
+ decisions=data,
+ summary=build_summary(report.metadata, report.variants, data),
+ )
+ round_trip = ReportData.from_dict(rendered_report.to_dict())
+ assert round_trip.summary.state == expected_state
+ assert len(round_trip.summary.checks) == len(scenario.checks)
+ page = render_artifact(round_trip, "")
+ markdown = render_markdown(round_trip)
+ markup = _ReportMarkup(page)
+ summary = markup.select(anchor="panel-summary")[0]
+ detail = markup.select(anchor="panel-explanation")[0]
+ checks = markup.select(summary, class_name="summary-check")
+ assert checks[1]["attrs"]["data-state"] == "unavailable"
+ assert rejected.rejection in _plain(checks[1])
+ assert any(
+ rejected.rejection in _plain(node)
+ for node in markup.select(detail, class_name="criterion-rejection")
+ )
+ assert rejected.rejection in visible_text(markdown)
+ for number, criterion in enumerate(criteria, 1):
+ anchor = content.target_criterion_anchor(number)
+ assert markup.select(anchor=anchor)
+ assert any(
+ link["attrs"].get("href") == "#" + anchor
+ for link in markup.select(summary, tag="a")
+ )
+ assert criterion.text in _plain(detail)
+ assert content.summary_heading(round_trip) in _plain(summary)
+ method = markup.select(summary, class_name="story-method")[0]
+ assert round_trip.summary.checks[1].scope not in _plain(method)
+
+ # An unrelated readable source cannot satisfy a frozen implementation source.
+ wrong_sources = tuple(
+ replace(
+ entry, source=entry.source.replace("implementation.txt", "proposal.txt")
+ )
+ for entry in evidence
+ )
+ guarded, reason = check_target(
+ {"criteria": complete},
+ goals,
+ answers,
+ wrong_sources,
+ report.decisions.rows,
+ scenario=scenario,
+ )
+ assert reason is None
+ assert guarded.criteria[0].rejection == "criterion lacks a required frozen source"
+ assert not guarded.criteria[0].after
+ assert guarded.criteria[1].rejection is None
+
# Dropping the code read still covers every goal, so only frozen IDs catch it.
assert check([accuracy], frozen=None)[1] is None, "Old runs keep assessor criteria."
assert check([accuracy]) == (None, MISSING)