diff --git a/.agents/skills/run-behavior-diff-human-evaluation/references/workflow.md b/.agents/skills/run-behavior-diff-human-evaluation/references/workflow.md
index f145864..b2fab46 100644
--- a/.agents/skills/run-behavior-diff-human-evaluation/references/workflow.md
+++ b/.agents/skills/run-behavior-diff-human-evaluation/references/workflow.md
@@ -187,18 +187,18 @@ Do not represent an answer describing an action as evidence of execution.
## Quiz and scoring
`build` creates blinded excerpts from the original saved report HTML. It retains
-the generated headline, case-evidence context, Before/After cards and supported
-starts, practical meaning, attention findings, target gaps, and visible limits.
-It removes instruction intent/diff, full scenario/expected behavior, commit
-metadata, source-intent relationship labels, and links to unblinded evidence.
-Case context is preserved separately from the removed purpose; extraction must
-not use it to restate the instruction. An exact repetition of the Summary intent,
-any full recorded goal, or the full supplied expectation or inferred aim from
-Instruction changes is rejected. This guard cannot detect semantic paraphrases.
-The projection never rewrites prose or synthesizes missing summaries. The v3
-projection accepts only the current Summary structure and fails closed on older
-layouts. Already-built sessions keep their frozen bytes; do not rebuild an
-active evaluation to adopt a new presentation.
+the bounded observation section: case-evidence context, Before/After cards and
+supported starts, practical meaning, attention findings, target gaps, and limits.
+The assessment-derived headline is replaced by a neutral observation heading,
+not another finding. Expected criteria, method/rationale, next actions, instruction
+intent/diff, commit metadata, source-intent relationship labels and links to
+unblinded evidence are excluded because they can reveal the keyed edit.
+Case context must not restate the instruction. Exact repetition of the Summary
+intent, any full goal, supplied expectation or inferred aim is rejected; this
+guard cannot detect semantic paraphrases. Observation prose is never rewritten.
+The v4 projection accepts only the current five-step Summary structure and fails
+closed on older layouts. Already-built sessions keep their frozen bytes; do not
+rebuild an active evaluation to adopt a new presentation.
`serve SESSION --port 0` prints an available loopback URL. Only public quiz assets
are served before submission. The answer key stays on the server. Check desktop
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 d0d8bea..1e4162f 100644
--- a/.agents/skills/run-behavior-diff-human-evaluation/scripts/quiz.py
+++ b/.agents/skills/run-behavior-diff-human-evaluation/scripts/quiz.py
@@ -464,25 +464,11 @@ def _claim_shape(claim):
_shape(claim, [("strong", None), ("p", None)])
-def _intent_case_context(body, source_intents):
- """Reject case context that repeats displayed or full-detail source intent."""
- children = _children(body)
- if (
- len(children) not in (2, 3)
- or children[0].tag != "div"
- or not children[0].has_class("intent-heading")
- or children[1].tag != "p"
- or not children[1].has_class("story-intent")
- ):
- raise ValueError("Unknown intended-change step.")
- if len(children) == 2:
- return None
- context = children[2]
- if context.tag != "div" or not context.has_class("summary-case-context"):
- raise ValueError("Unknown intended-change step.")
+def _case_context(context, source_intents):
+ """Reject observed context that repeats a displayed source goal."""
_claim_shape(context)
shown = " ".join(_plain(context).split())
- for intent in (children[1], *source_intents):
+ for intent in source_intents:
text = " ".join(_plain(intent).split())
if intent.has_class("edit-aim"):
for prefix in ("Supplied expectation: ", "Likely aim: "):
@@ -491,7 +477,6 @@ def _intent_case_context(body, source_intents):
break
if text and text in shown:
raise ValueError("Case context repeats the intended change.")
- return context
def _blinded_report(source):
@@ -532,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"
@@ -541,44 +531,47 @@ def _blinded_report(source):
):
raise ValueError("Unknown summary story structure.")
steps = _children(story_children[1])
- if (
- len(steps) != 4
- or any(node.tag != "li" or not node.has_class("story-step") for node in steps)
- or not steps[2].has_class("story-attention")
- or not steps[3].has_class("story-limits")
+ names = ("finding", "expectation", "method", "evidence", "next")
+ if len(steps) != len(names) or any(
+ step.tag != "li"
+ or not step.has_class("story-step")
+ or not step.has_class(f"story-{name}")
+ for step, name in zip(steps, names)
):
raise ValueError("Unknown story steps.")
bodies = [
_shape(step, [("span", "story-number"), ("div", "story-body")])[1]
for step in steps
]
- source_intents = [
- node
- for node in nodes
- if node.has_class("purpose-goal-text") or node.has_class("edit-aim")
- ]
- case_context = _intent_case_context(bodies[0], source_intents)
+ # Check digest and qualification summarize the assessment; drop them but
+ # accept only these exact optional siblings before the observations.
evidence_shape = [("h3", None)]
- if any(node.has_class("summary-context") for node in _children(bodies[1])):
- evidence_shape.append(("p", "summary-context"))
- evidence_shape.extend([("p", "summary-provenance"), ("div", "summary-pair")])
- if any(node.has_class("primary-result-context") for node in _children(bodies[1])):
- evidence_shape.append(("section", "primary-result-context"))
- if any(
- node.has_class("summary-interpretation-unavailable")
- for node in _children(bodies[1])
+ for tag, optional in (
+ ("div", "summary-check-digest"),
+ ("p", "summary-qualification"),
):
- evidence_shape.append(("p", "summary-interpretation-unavailable"))
- else:
- evidence_shape.append(("div", "summary-why"))
- evidence_shape.append(("nav", "evidence-nav"))
- evidence_body = _shape(bodies[1], evidence_shape)
- if _plain(evidence_body[0]) != "What the evidence shows":
- raise ValueError("Unknown evidence heading.")
- pair = _single(
- [node for node in evidence_body if node.has_class("summary-pair")],
- "summary pair",
+ 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):
+ shape.append(("section", "primary-result-context"))
+ shape.append(
+ ("p", "summary-interpretation-unavailable")
+ if any(
+ node.has_class("summary-interpretation-unavailable") for node in children
+ )
+ else ("div", "summary-why")
)
+ for optional in ("summary-case-context", "summary-caution"):
+ if any(node.has_class(optional) for node in children):
+ shape.append(("div", optional))
+ shape.append(("div", "summary-attention"))
+ _shape(observations, shape)
+ pair = children[1]
cards = _shape(
pair,
[
@@ -588,9 +581,9 @@ def _blinded_report(source):
],
)
for card in (cards[0], cards[2]):
- children = _children(card)
- start = bool(len(children) > 1 and children[1].has_class("summary-start"))
- empty = bool(children and children[-1].has_class("summary-empty"))
+ parts = _children(card)
+ start = bool(len(parts) > 1 and parts[1].has_class("summary-start"))
+ empty = bool(parts and parts[-1].has_class("summary-empty"))
card_shape = [("h4", "summary-side-label")]
if start:
card_shape.append(("div", "summary-start"))
@@ -603,28 +596,52 @@ def _blinded_report(source):
_shape(card, card_shape)
if start:
start_shape = [("p", "summary-start-text"), ("p", "summary-start-source")]
- if len(_children(children[1])) == 3:
+ if len(_children(parts[1])) == 3:
start_shape.append(("span", "summary-start-arrow"))
- _shape(children[1], start_shape)
- if evidence_body[-2].tag == "div":
- _claim_shape(evidence_body[-2])
- _attention_shape(bodies[2])
- limits_shape = [("h3", None)]
- if any(node.has_class("summary-caution") for node in _children(bodies[3])):
- limits_shape.append(("div", "summary-caution"))
- limits_shape.append(("ul", "summary-notices"))
- limits = _shape(bodies[3], limits_shape)
- if len(limits) == 3:
- _claim_shape(limits[1])
- # Remove the intended change, its source label and every navigation link.
- # Case context is observation context; the reader sees it beside the evidence.
- if case_context is not None:
- evidence = bodies[1].children
- evidence.insert(evidence.index(evidence_body[0]) + 1, case_context)
+ _shape(parts[1], start_shape)
+ source_intents = [
+ node
+ for node in nodes
+ if any(
+ node.has_class(name)
+ for name in ("purpose-goal-text", "edit-aim", "story-intent")
+ )
+ ]
+ for node in children:
+ if node.has_class("summary-case-context"):
+ _case_context(node, source_intents)
+ elif node.has_class("summary-why") or node.has_class("summary-caution"):
+ _claim_shape(node)
+ _attention_shape(children[-1])
+ # Keep only the bounded observation section. Assessment verdicts, expected
+ # criteria, preparation rationale and next actions can reveal the keyed edit.
trimmed_story = _Node(
story.tag,
story.attrs.items(),
- [story_children[0], _Node("ol", story_children[1].attrs.items(), steps[1:])],
+ [
+ _Node(
+ "h2",
+ [("class", "summary-headline")],
+ ["Observed Before / After behavior"],
+ ),
+ _Node(
+ "ol",
+ story_children[1].attrs.items(),
+ [
+ _Node(
+ "li",
+ steps[3].attrs.items(),
+ [
+ _Node(
+ "div",
+ [("class", "story-body")],
+ [evidence_heading, observations],
+ )
+ ],
+ )
+ ],
+ ),
+ ],
)
fragment = "".join(
_safe_fragment(node) for node in (trimmed_story, direct[1], details[1])
@@ -774,7 +791,7 @@ def build_quiz(session: Path, repo_root: Path) -> None:
"reports": reports,
"answer_key_sha256": _hash((session / "answer-key.json").read_bytes()),
"session_sha256": _hash((session / "session.json").read_bytes()),
- "summary_method": "saved-html-summary-panel-v3",
+ "summary_method": "saved-html-summary-panel-v4",
}
_atomic_file(session / "quiz-build.json", _bytes(receipt))
diff --git a/README.md b/README.md
index 259abec..7836508 100644
--- a/README.md
+++ b/README.md
@@ -210,6 +210,35 @@ 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 "Local comparison files
'
f"
{escaped(trial.outcome)}
' answer = ( f"{escaped(trial.final)}"
if trial.final.strip()
@@ -859,8 +870,24 @@ def _target_summary(report, target):
def _target_explanation(report):
target = report.decisions.target_assessment
parts = ['{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'{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} @@ -871,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( 'Goal {criterion.goal} · {html.escape(criterion.mode)}
" f"{version_name_html(acceptance(criterion))}
" @@ -922,6 +959,21 @@ def _target_explanation(report): return "".join(parts) +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 "" + ) + ) + + def _change_explanation(report: ReportData) -> str: explanation = report.decisions.explanation navigation = ( @@ -940,15 +992,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(expected.label)}
' + f'{version_name_html(expected.note)}
' + '| Expected | Before | ' + 'After | What this means | ' + f'{html.escape(content.SUMMARY_SCOPE_HEADING)} |
|---|
{html.escape(report.summary.evidence_label)}
' + sides = "".join( + 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'{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 +1205,141 @@ 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_parts = [ + f'{version_name_html(diagnostic)}
' + f"{version_name_html(result_text)}
" + if result_text + else "" + ) + groups = content.summary_check_groups(report) + digest = ( + '{html.escape(qualification_text)}
' + if qualification_text + else "" + ) + steps = ( + _summary_step( + 1, + "story-finding", + content.SUMMARY_FINDING_HEADING, + f"{result_html}" + '{html.escape(content.SUMMARY_AIM_LABEL)}
' + '{html.escape(intent.note)}
')}" + '{intent_text}
{html.escape(content.SUMMARY_CHANGE_LABEL)}
' + f"{_summary_change(report)}{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'{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)}
' - '' + f"{html.escape(content.summary_status(report))}
" + f'" + 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..6c3eca2 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,69 @@ 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 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; }
+.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-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,
+.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; }
+.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; }
+.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 +553,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: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; }
@@ -512,7 +578,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/scenario.py b/plugin/skills/behavior-diff/scripts/reporting/scenario.py
new file mode 100644
index 0000000..310e559
--- /dev/null
+++ b/plugin/skills/behavior-diff/scripts/reporting/scenario.py
@@ -0,0 +1,99 @@
+"""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. 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
+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, ...]
+ source_requirements: Tuple[SourceRequirementData, ...]
+
+
+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")
+ 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 344d76f..bf1665c 100644
--- a/plugin/skills/behavior-diff/scripts/reporting/schema.py
+++ b/plugin/skills/behavior-diff/scripts/reporting/schema.py
@@ -12,7 +12,10 @@
parse_explanation,
)
from reporting.instruction import parse_diff_hunks
+from reporting.scenario import ScenarioData, parse_scenario
from reporting.summary import (
+ CHECK_STATES,
+ FINDING_STATES,
ICONS,
SUMMARY_STATUSES,
EditIntentData,
@@ -46,7 +49,7 @@
report_source,
)
-SCHEMA_VERSION = 16
+SCHEMA_VERSION = 19
RESULT_KINDS = ("good", "bad", "neutral")
@@ -150,6 +153,11 @@ 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
+ # Scenario and expected checks frozen before trials; None means unrecorded.
+ scenario_record: Optional[ScenarioData] = None
@dataclass(frozen=True)
@@ -168,6 +176,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 +202,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 +297,8 @@ def from_dict(cls, data: Dict[str, object]) -> "ReportData":
"target_assessment",
"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")
@@ -567,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,
@@ -574,6 +606,8 @@ def _decisions(value, path, hunk_count, groups=None, final_answers=None):
final_answers,
evidence,
parsed_rows,
+ scenario=scenario,
+ persisted=True,
)
if raw_target is not None and (
target is None or _json_value(asdict(target)) != raw_target
@@ -611,6 +645,10 @@ 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
+ ),
+ scenario_record=scenario,
)
from reporting.content import valid_decision_choices
@@ -619,6 +657,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 +780,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 +817,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/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 de6bd07..442bcdf 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,99 @@
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",
+)
+# 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,
+ "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",
+ "unavailable": "not assessed",
+}
+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.",
+}
+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 "
+ "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 +442,133 @@ 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):
+ if criterion.rejection is not None:
+ return "unavailable"
+ 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 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
+
+ checks = []
+ for number, criterion in enumerate(target.criteria if target else (), 1):
+ state = check_state(criterion, variants)
+ scope = (
+ SELF_REPORTED_SCOPE
+ if criterion.evidence_kind == "recorded_action"
+ 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,
+ criterion.text,
+ criterion.mode,
+ state,
+ question_distribution(criterion, "before", variants.before.total),
+ question_distribution(criterion, "after", variants.after.total),
+ REJECTED_CHECK.format(criterion.rejection)
+ if state == "unavailable"
+ 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 +578,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 +599,9 @@ 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)
+ unchecked = unchecked_goals(decisions)
+ state = overall_state(checks, unchecked)
if not complete:
notices.append(
"Trial evidence is incomplete, blocked, invalid, or dropped; inspect the trial records."
@@ -429,18 +641,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 +701,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..94b4a79 100644
--- a/plugin/skills/behavior-diff/scripts/reporting/target.py
+++ b/plugin/skills/behavior-diff/scripts/reporting/target.py
@@ -3,7 +3,7 @@
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,
@@ -11,11 +11,15 @@
RUN_LIMIT,
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")
+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."
@@ -99,6 +103,7 @@ class TargetCriterionData:
decisions: Tuple[int, ...]
before: Tuple[TrialAssessmentData, ...]
after: Tuple[TrialAssessmentData, ...]
+ rejection: Optional[str] = None
@dataclass(frozen=True)
@@ -263,10 +268,216 @@ def persisted_evidence(raw):
return evidence_records({"trials": trials})
-def parse_target(raw, purpose, final_answers, evidence, rows=(), positions=None):
- """Invalid assessments stay unavailable, never become successful or assessed-empty."""
+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,
+ *,
+ scenario=None,
+ persisted=False,
+):
+ """Retain rejected checks without treating them as assessed outcomes."""
+ return check_target(
+ 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,
+ persisted=False,
+):
+ """Validate identities globally, then isolate each check's evidence failures.
+
+ 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
+ return None, None
try:
if (
type(raw) is not dict
@@ -276,6 +487,21 @@ def parse_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
+ )
+ 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
@@ -283,170 +509,41 @@ def parse_target(raw, purpose, final_answers, evidence, rows=(), positions=None)
)
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 (
- "output",
- "source_consistency",
- "recorded_action",
- ):
- raise ValueError("invalid criterion evidence requirement")
- 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"
- )
- usable = [
- ref
- for ref in refs
- if ref == name + ":answer"
- or records[ref].status in ("available", "truncated")
- and records[ref].text
- ]
- if outcome in ("met", "not_met") and (
- not refs or len(usable) != len(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)
- if (
- item["evidence_kind"] == "source_consistency"
- and not (has_output and has_source)
- or item["evidence_kind"] == "output"
- and not has_output
- or item["evidence_kind"] == "recorded_action"
- and not has_source
- ):
- 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(
- identifier,
- goal,
- _text(item["text"], 600),
- item["mode"],
- _text(item["required_evidence"], 600),
- item["evidence_kind"],
- 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")
- return TargetAssessmentData(tuple(criteria))
- except (KeyError, TypeError, ValueError, AttributeError):
- return None
+ 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
+ except ValueError as error:
+ return None, str(error)
+ except (KeyError, TypeError, AttributeError):
+ return None, "malformed target assessment"
def distribution(criterion, side):
@@ -459,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(
@@ -487,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/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/plugin/skills/behavior-diff/scripts/scenario.py b/plugin/skills/behavior-diff/scripts/scenario.py
new file mode 100755
index 0000000..c5acc33
--- /dev/null
+++ b/plugin/skills/behavior-diff/scripts/scenario.py
@@ -0,0 +1,68 @@
+#!/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,"
+ "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. 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
+ 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..9f1f535 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":[],"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")
+ 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"}],"source_requirements":[]}
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"}],"source_requirements":[]}
+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/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..827d5ed 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,21 @@ def test_actual_synthetic_report_blinding_preserves_wording(self):
"intent-heading",
"scenario-context",
"scenario-prompt",
+ "summary-checks",
+ "summary-status",
+ "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",
+ "summary-finding-meaning",
+ "summary-next-action",
):
for node in nodes:
if node.has_class(name) and quiz._plain(node).strip():
@@ -470,87 +481,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 +538,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")]),
@@ -603,6 +579,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):
@@ -722,13 +751,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 +900,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(
'',
@@ -899,12 +926,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"'),
diff --git a/tests/presentation_contract.py b/tests/presentation_contract.py
index 8801ba2..56c102e 100644
--- a/tests/presentation_contract.py
+++ b/tests/presentation_contract.py
@@ -10,12 +10,39 @@
LABEL = '{0}'
WRAPPED = re.compile(r'(Before|After)')
-SUMMARY_LINKS = [
- ["#instruction-diff"],
- ["#panel-explanation"],
- ["#attention-explanation"],
- [],
-]
+
+
+def _summary_links(report):
+ """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"]
+ + (["#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}"
+ for _, _, numbers in content.summary_check_groups(report)
+ for number in numbers
+ ]
+ + [
+ "#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 +312,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 +320,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..781be3c 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,144 @@ 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_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.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):
+ """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..a946c54 100755
--- a/tests/recorded-evidence-test.py
+++ b/tests/recorded-evidence-test.py
@@ -18,6 +18,8 @@
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
def call(identity, path="facts.txt", text="Recorded cap: 500.", **result_fields):
@@ -51,6 +53,70 @@ 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_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."},
+}
+
+
+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()
@@ -77,6 +143,71 @@ 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",
+ "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
+ 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 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)",
+ ):
+ 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")
@@ -290,5 +421,360 @@ 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."""
+
+ 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, scenario=None):
+ evidence = evidence_records(collect_run_evidence(self.root))
+ 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(
+ 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())
+ 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")[:1])
+ raw = criterion(
+ "source_consistency",
+ excerpt="citing pull request 7",
+ before_outcome="unassessable",
+ )
+ self.assertEqual(
+ self.rejected(raw), "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.rejected(criterion(ref=ref)),
+ "determinate target outcome needs available evidence",
+ )
+ uncertain = criterion(
+ after_outcome="uncertain", ref=ref, before_outcome="uncertain"
+ )
+ 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.rejected(raw), "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)
+
+ 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 e9aa7b3..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"] == 16
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..2b7a75a 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": 19,
"metadata": {
"model": "synthetic/model",
"mode": "review",
@@ -251,10 +251,17 @@ def synthetic_raw():
raw["decisions"].update(
purpose=[],
purpose_summary=None,
+ target_rejection=None,
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
@@ -298,11 +305,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")
@@ -368,6 +375,364 @@ 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",
+ }
+ ],
+ "source_requirements": [],
+ }
+
+
+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),
+ "rejection": None,
+ }
+ 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 {
@@ -507,7 +872,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 +932,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 +940,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 +1018,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 +1081,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 +1148,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 +1165,303 @@ 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),
+ )
+
+ 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", 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"),
+ (
+ 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
+ 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
+ )
+ 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"]
+
+ 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
- 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("\\", "")
+
+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():
@@ -1592,7 +2204,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 +2229,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 +2239,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 +2246,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 +2267,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 +2280,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 +2344,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,
@@ -2157,6 +2765,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)
@@ -2391,19 +3014,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():
@@ -2601,6 +3219,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)
@@ -2630,97 +3249,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, 17, 18):
+ 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/report_fixtures.py b/tests/report_fixtures.py
index 48009ab..aa21b48 100644
--- a/tests/report_fixtures.py
+++ b/tests/report_fixtures.py
@@ -1943,6 +1943,31 @@ 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],
+ "source_requirements": [
+ {"check": frozen_check["id"], "any_of": ["*implementation.txt*"]}
+ ],
+ }
_write_json(run / "config.json", config)
if missing:
for side in trials:
@@ -1971,12 +1996,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..b01f339
--- /dev/null
+++ b/tests/scenario-contract-test.py
@@ -0,0 +1,314 @@
+#!/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
+from reporting.sources import (
+ DESCRIPTOR_TRUNCATED,
+ SENSITIVE_SOURCE,
+ SourceRequirementData,
+ source_matches,
+ unmet_source_requirements,
+)
+
+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],
+ "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"
+
+
+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)
+
+ 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 60aabd3..400e95a 100755
--- a/tests/shared-data-test.py
+++ b/tests/shared-data-test.py
@@ -38,6 +38,23 @@
}
]
}
+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_requirements": [],
+}
SOURCE = {
"forge": "github",
"repository_url": "https://github.com/example-org/synthetic-skills",
@@ -59,6 +76,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 +89,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 +1051,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 +1088,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 ce6cca4..8cb7cf8 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,
@@ -53,7 +58,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 +69,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"):
@@ -214,9 +225,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."
@@ -362,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):
@@ -402,7 +420,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)
)
@@ -466,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]:
@@ -663,10 +684,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, 17, 18):
raw = report.to_dict()
raw["schema_version"] = version
try:
@@ -746,8 +770,316 @@ 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."
)
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",
+ },
+ ],
+ "source_requirements": [
+ {"check": accuracy.id, "any_of": ["*implementation.txt*"]}
+ ],
+ }
+
+
+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]
+ )
+
+ # 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)
+ 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.")