mirror of
https://github.com/Imbad0202/academic-research-skills.git
synced 2026-09-14 13:51:17 +08:00
2181 lines
95 KiB
Python
2181 lines
95 KiB
Python
#!/usr/bin/env python3
|
||
"""Fail-closed Phase 1 -> Phase 2 conformance checker for reviewer Schema 13.2.
|
||
|
||
Usage:
|
||
python scripts/check_phase_conformance.py --contract C.json --role eic \
|
||
--phase1 eic.phase1.md --phase2 eic.phase2.md \
|
||
--manuscript manuscript.md --metadata metadata.json
|
||
|
||
Exit 0 pass, 2 contract/infra failure, 3 reviewer conformance failure.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import html
|
||
import json
|
||
import re
|
||
import sys
|
||
import unicodedata
|
||
from collections import Counter
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
|
||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||
import check_panel_synthesis as panel # noqa: E402
|
||
import recompute_receipts as recompute # noqa: E402
|
||
|
||
EXIT_PASS = 0
|
||
EXIT_CONTRACT = 2
|
||
EXIT_CONFORMANCE = 3
|
||
_METADATA_KEYS = frozenset({"title", "field", "word_count"})
|
||
_FIELD_PATTERNS = {
|
||
"dimension_id": re.compile(r"^dimension_id: (?P<value>D\d+)$"),
|
||
"what_to_look_for": re.compile(r"^what_to_look_for: (?P<value>\S.*)$"),
|
||
"what_triggers_block": re.compile(
|
||
r"^what_triggers_block: (?P<value>\S.*)$"
|
||
),
|
||
"what_triggers_warn": re.compile(
|
||
r"^what_triggers_warn: (?P<value>\S.*)$"
|
||
),
|
||
"what_triggers_fatal": re.compile(
|
||
r"^what_triggers_fatal: (?P<value>\S.*)$"
|
||
),
|
||
}
|
||
_DISSENT_DIM_RE = re.compile(r"^dimension_id: (?P<dim>D\d+)\s*$")
|
||
_DISSENT_RATIONALE_RE = re.compile(r"^rationale: (?P<text>\S.*)\s*$")
|
||
_SEVERITY_RE = re.compile(
|
||
r"(?:^|\|\s*)\s*(?:[-*]\s*)?\*\*Severity\*\*:\s*"
|
||
r"(?P<severity>Critical|Major|Minor)\b"
|
||
)
|
||
_ANCHOR_RE = re.compile(
|
||
r"(?:^|\|\s*)\s*(?:[-*]\s*)?\*\*Evidence Anchor\*\*:\s*"
|
||
r"(?P<value>[^|]+)"
|
||
)
|
||
_SEVERITY_DECL_RE = re.compile(
|
||
r"\*\*Severity(?:\*\*)?\s*:",
|
||
re.IGNORECASE,
|
||
)
|
||
_ANCHOR_DECL_RE = re.compile(
|
||
r"\*\*Evidence Anchor(?:\*\*)?\s*:",
|
||
re.IGNORECASE,
|
||
)
|
||
_FINDING_H3_RE = re.compile(r"^W[1-9]\d*: \S.*$")
|
||
# --- #610 methodology arithmetic-receipt grammar -------------------------
|
||
# Receipt machine lines are lowercase snake_case on purpose: they can never
|
||
# collide with the finding grammar's bold `**Severity**:` / `**Evidence
|
||
# Anchor**:` declarations, so the receipt section cannot trip the
|
||
# outside-Review-Body severity gate and a finding cannot satisfy a receipt
|
||
# field. Decoration tolerance is bounded to the two shapes the finding
|
||
# grammar already accepts (one leading list marker, bold around the key).
|
||
# The section is read fence-transparently (#637/#638 display-form
|
||
# discipline): a fenced receipt line is still content, so a stylistic fence
|
||
# neither hides a receipt nor turns one into a false abort, and any other
|
||
# decoration of a field-shaped line is detected and aborts loudly instead of
|
||
# silently passing a forbidden-field guard or failing a required count.
|
||
_RECEIPT_SECTION = "Arithmetic Receipts"
|
||
_RECEIPT_H3_RE = re.compile(r"^AR[1-9]\d*$")
|
||
_RECEIPT_PROCEDURES = frozenset(
|
||
{"p_from_test_statistic", "grim", "grimmer", "n_from_df"}
|
||
)
|
||
_RECEIPT_STATUSES = frozenset(
|
||
{"consistent", "mismatch", "not_computable", "not_applicable"}
|
||
)
|
||
_NOT_COMPUTABLE_REASONS = frozenset({
|
||
"missing_reported_value",
|
||
"test_family_ambiguous",
|
||
"tail_ambiguous",
|
||
"nonstandard_p_procedure",
|
||
"inequality_unresolvable",
|
||
"rounding_rule_ambiguous",
|
||
"rounding_boundary_ambiguous",
|
||
"scale_granularity_unknown",
|
||
"scale_support_unknown",
|
||
"analytic_n_ambiguous",
|
||
"aggregation_or_weighting_unknown",
|
||
"sd_convention_unknown",
|
||
"mean_grim_inconsistent",
|
||
"df_identity_ambiguous",
|
||
"model_correction_or_pooling",
|
||
"reachability_not_completed",
|
||
})
|
||
_TAIL_CONVENTIONS = frozenset(
|
||
{"two-tailed", "one-tailed", "upper-tail", "unstated"}
|
||
)
|
||
|
||
|
||
def _receipt_field_re(key: str) -> re.Pattern[str]:
|
||
# Bold is balanced-or-absent: a half-bold `**key:` is a decorated line
|
||
# for the shape guard to abort, never a canonical field (#610 round-2).
|
||
return re.compile(
|
||
rf"^(?:[-*] )?(?:{key}|\*\*{key}\*\*): (?P<value>\S.*?)\s*$"
|
||
)
|
||
|
||
|
||
_RECEIPT_FIELD_RES = {
|
||
key: _receipt_field_re(key)
|
||
for key in (
|
||
"procedure_id",
|
||
"evidence_anchor",
|
||
"reported_inputs",
|
||
"assumptions",
|
||
"derivation",
|
||
"derived_value_or_range",
|
||
"comparison_rule",
|
||
"status",
|
||
"not_computable_reason",
|
||
"finding_ref",
|
||
"tail_convention",
|
||
"rounding_interval",
|
||
"nearest_achievable",
|
||
"df_identity",
|
||
)
|
||
}
|
||
_RECEIPT_ATTESTATION_RE = _receipt_field_re("no_recomputable_statistics")
|
||
_RECEIPT_ATTESTATION_ADVISORY = (
|
||
"[RECEIPT-ATTESTATION: declaration-only — applicability not "
|
||
"machine-verified; adjudication judges the attestation]"
|
||
)
|
||
# Folded field-name alphabets for the decoration-agnostic shape test, the
|
||
# same construction as `_is_dissent_field_shaped`: a line is receipt-field-
|
||
# shaped when the letters before its first colon spell a field name after
|
||
# markup-span strip + NFKC fold. A shaped line that the canonical grammar
|
||
# does not parse is a loud abort, never a silently ignored decoration.
|
||
_RECEIPT_FIELD_SHAPE_NAMES = frozenset(
|
||
"".join(char for char in key if char.isalpha())
|
||
for key in (*_RECEIPT_FIELD_RES, "no_recomputable_statistics")
|
||
)
|
||
_FINDING_REF_VALUE_RE = re.compile(r"^W[1-9]\d*$")
|
||
# The back-reference value is exact: `AR<n>` up to end-of-line or the next
|
||
# table pipe. `AR1, AR2` and trailing prose fail this parse and then abort
|
||
# via the lenient declaration count below rather than half-reading `AR1`.
|
||
_RECEIPT_BACKREF_RE = re.compile(
|
||
r"(?:^|\|\s*)\s*(?:[-*]\s*)?\*\*Arithmetic Receipt\*\*:\s*"
|
||
r"(?P<value>AR[1-9]\d*)\s*(?=\||$)"
|
||
)
|
||
# Lenient declaration shape for the back-reference, checked against the
|
||
# canonical parse per line (`_is_dissent_field_shaped` construction): a line
|
||
# whose letters before the first colon spell exactly `arithmeticreceipt` is
|
||
# a declaration the seat made, so a decorated, fenced, or prose-trailed
|
||
# back-reference the canonical regex refuses aborts instead of vanishing.
|
||
# Prose that merely mentions the section (`the Arithmetic Receipts
|
||
# section: ...`) carries extra letters in its head and stays prose.
|
||
_RECEIPT_BACKREF_SHAPE_NAME = "arithmeticreceipt"
|
||
# Both-tails value rule (#610 rounds 1-2): under an unstated tail, each tail
|
||
# label must share its own `;`-delimited segment of `derived_value_or_range`
|
||
# with a digit — either side of the label, so `p = .192 (two-tailed)` is a
|
||
# shown value while a bare label in a digitless segment is not. Hyphen
|
||
# variants (space, ASCII hyphen, U+2010-U+2015) are folded into the label
|
||
# match, the value text is NFKC-folded first, and the label needs letter
|
||
# boundaries so `notwo-tailed` prose cannot satisfy the rule; a fused
|
||
# `twotailed` typo still counts as the label — it carries its value, and
|
||
# rejecting it would be a false abort on an unretryable phase.
|
||
_TAIL_LABEL_RES = {
|
||
label: re.compile(rf"(?<![a-z]){label}[\s‐-―-]?tailed(?![a-z])")
|
||
for label in ("two", "one")
|
||
}
|
||
# Conditional-field matrix, spec §4/§5 (2026-08-02 #610 spec): which
|
||
# procedure-specific lines each procedure may carry. `tail_convention` is
|
||
# required on every p_from_test_statistic receipt (what the paper states is
|
||
# always statable — `unstated` exists for exactly that case); the completed-
|
||
# procedure fields (`rounding_interval`, `nearest_achievable`, `df_identity`)
|
||
# are required only under a verdict status, because `not_computable` may
|
||
# legitimately stop before the field can exist, and remain permitted there
|
||
# for a partial attempt that stopped late.
|
||
_RECEIPT_VERDICT_STATUSES = frozenset({"consistent", "mismatch"})
|
||
_PROCEDURE_FIELDS = {
|
||
"p_from_test_statistic": ("tail_convention",),
|
||
"grim": ("rounding_interval", "nearest_achievable"),
|
||
"grimmer": ("rounding_interval", "nearest_achievable"),
|
||
"n_from_df": ("df_identity",),
|
||
}
|
||
# Inverted view driving the conditional-field loop, so the matrix above is
|
||
# the single authority: field -> the procedures that carry it.
|
||
_PROCEDURE_FIELD_OWNERS = {
|
||
field: tuple(
|
||
procedure
|
||
for procedure, fields in _PROCEDURE_FIELDS.items()
|
||
if field in fields
|
||
)
|
||
for fields in _PROCEDURE_FIELDS.values()
|
||
for field in fields
|
||
}
|
||
_ALWAYS_REQUIRED_PROCEDURE_FIELDS = frozenset({"tail_convention"})
|
||
# --- end #610 receipt grammar --------------------------------------------
|
||
_DISSENT_FIELD_NAMES = frozenset({"dimensionid", "rationale"})
|
||
_MARKUP_SPAN_RE = re.compile(
|
||
r"<[^>]*>|\]\((?:[^()]|\([^()]*\))*\)|\]\[[^\]]*\]|\[[ xX]?\]"
|
||
)
|
||
# Non-comment raw HTML is forbidden only inside the dissent span (#682).
|
||
# Match the opening delimiter rather than requiring a complete tag: CommonMark
|
||
# raw blocks such as a line ending in ``<script`` can still hide the canonical
|
||
# fields below it, and a malformed claimed dissent must abort rather than fall
|
||
# through to the empty-section advisory. Autolinks such as
|
||
# ``<https://example.test>`` do not match because ``:`` is not a tag-name
|
||
# boundary. Comment delimiters remain owned by the #613 state machine so its
|
||
# more specific ``[DISSENT-HIDDEN]`` diagnostic keeps precedence.
|
||
_DISSENT_RAW_HTML_RE = re.compile(
|
||
r"<(?:(?:/?[A-Za-z][A-Za-z0-9-]*)(?=[\s/>]|$)|![A-Za-z]|!\[CDATA\[|\?)",
|
||
re.IGNORECASE,
|
||
)
|
||
# A block opener, including one behind list or blockquote markers: rendered
|
||
# through CommonMark, `- <!--` opens raw HTML just as a bare `<!--` does, and
|
||
# needs no closer to swallow the rest of the item. The indentation allowances
|
||
# must not add up to four, which would make the whole line indented code: a
|
||
# list marker takes one to four following spaces before its content, a block
|
||
# quote at most one, and a block start may still be indented up to three
|
||
# inside the quote it opens. Erring narrow costs a miss; erring wide aborts a
|
||
# valid card. Only an ordered list beginning at 1 may interrupt an open
|
||
# paragraph, so `2. <!--` under a paragraph line is text, not a list opening
|
||
# a raw-HTML block, and the fields below it stay on the page.
|
||
def _container_prefix(ordered: str) -> str:
|
||
"""Matched against a tab-expanded line, so spaces are the only gap.
|
||
|
||
Every gap is one unambiguous run: letting a single space be claimed by
|
||
either of two adjacent optional groups gave two ways to match each marker
|
||
and doubled the work per nesting level, so a short nested quote stalled
|
||
the checker rather than returning a verdict.
|
||
"""
|
||
return rf"(?:(?:[-*+]|{ordered}) {{1,4}}|> {{0,4}})*"
|
||
|
||
|
||
# Lines that end the paragraph above them, so the NEXT line sits at a real
|
||
# block start where any ordered marker may open a list. An ATX heading of any
|
||
# level, a thematic break, and a lone `-` do that from either state, the last
|
||
# because it reads as a setext underline under a paragraph and as an empty
|
||
# list item without one. A bare `>` starts a container rather than closing a
|
||
# paragraph and is left to #613 with the rest of the container family.
|
||
_CLOSES_PARAGRAPH_RE = re.compile(
|
||
r"^ {0,3}(?:"
|
||
r"#{1,6}(?:[ \t].*)?$"
|
||
r"|(?:\*[ \t]*){3,}$|(?:_[ \t]*){3,}$|(?:-[ \t]*){3,}$"
|
||
r"|-[ \t]*$"
|
||
r")"
|
||
)
|
||
# A `=` run or two-or-more hyphens underlines a paragraph, so it closes one
|
||
# only when there is a paragraph to underline; with none, it is ordinary
|
||
# paragraph text and OPENS one.
|
||
_SETEXT_UNDERLINE_RE = re.compile(r"^ {0,3}(?:=+|-{2,})[ \t]*$")
|
||
# An empty list item has a blank first line, so it cannot interrupt an open
|
||
# paragraph. It only holds the state down where none is open. Reading every
|
||
# lone marker as a closer looked symmetrical and aborted valid cards.
|
||
_EMPTY_LIST_ITEM_RE = re.compile(r"^ {0,3}(?:[*+]|\d{1,9}[.)])[ \t]*$")
|
||
_ANY_ORDERED_MARKER = r"\d{1,9}[.)]"
|
||
_PARAGRAPH_INTERRUPTING_MARKER = r"1[.)]"
|
||
_COMMENT_OPENER_RE = re.compile(
|
||
rf"^ {{0,3}}{_container_prefix(_ANY_ORDERED_MARKER)}<!--"
|
||
)
|
||
_PARAGRAPH_OPENER_RE = re.compile(
|
||
rf"^ {{0,3}}{_container_prefix(_PARAGRAPH_INTERRUPTING_MARKER)}<!--"
|
||
)
|
||
|
||
|
||
class ConformanceError(Exception):
|
||
"""Reviewer conformance failure -> exit 3."""
|
||
|
||
|
||
@dataclass
|
||
class PhaseOnePlan:
|
||
commitments: dict[str, dict[str, str]]
|
||
warnings: list[str]
|
||
|
||
|
||
@dataclass
|
||
class DissentSpan:
|
||
"""The dissent section as written: every line, plus the commented ones.
|
||
|
||
`strip_fences` leaves HTML comments in place, so a canonical field inside
|
||
`<!-- ... -->` otherwise parses as a real dissent and collects the
|
||
trigger-binding exemption while the visible card claims nothing.
|
||
"""
|
||
|
||
lines: list[str]
|
||
hidden_by_comment: list[str]
|
||
raw_html: list[str]
|
||
|
||
|
||
@dataclass
|
||
class DissentParse:
|
||
dimensions: set[str]
|
||
diagnostics: list[str]
|
||
|
||
|
||
def _normalise(text: str) -> str:
|
||
return " ".join(text.casefold().split())
|
||
|
||
|
||
def _is_dissent_field_shaped(line: str) -> bool:
|
||
"""True when a line spells a dissent field, canonically or decorated.
|
||
|
||
Decoration-agnostic by construction: a line is field-shaped when the
|
||
letters preceding its first colon spell exactly a field name, whatever
|
||
non-letters surround them. Enumerating Markdown wrappers instead would
|
||
leave the next unenumerated wrapper (task item, table cell, link label)
|
||
reading as prose. Folded first so a fullwidth re-spelling cannot pass.
|
||
|
||
Markup spans carrying letters of their own (HTML tags, reference targets,
|
||
task-list markers, and link destinations up to one nesting level deep) are
|
||
dropped BEFORE the colon is located, not after: an absolute link target
|
||
carries its own `https:` and would otherwise win the partition.
|
||
|
||
Decoration beyond those — a deeper nested destination, a quoted HTML
|
||
attribute containing `>`, exotic custom syntax — is deliberately out of
|
||
scope, because absorbing it costs one advisory-flagged record while a
|
||
broader rule costs false aborts, which is the failure this tolerance
|
||
exists to remove. `test_a_nested_paren_link_destination_is_a_declared_
|
||
limit` pins that boundary so it cannot move by accident.
|
||
|
||
`str.isalpha` keeps every script's letters, so CJK prose that happens to
|
||
name a field stays prose rather than collapsing onto the field name and
|
||
aborting a panel it should tolerate.
|
||
"""
|
||
stripped = _MARKUP_SPAN_RE.sub(
|
||
"", unicodedata.normalize("NFKC", line).casefold()
|
||
)
|
||
head, separator, _ = stripped.partition(":")
|
||
label = "".join(char for char in head if char.isalpha())
|
||
return bool(separator) and label in _DISSENT_FIELD_NAMES
|
||
|
||
|
||
def _display_fold(line: str) -> str:
|
||
"""Unescape HTML entities FIRST, then NFKC-fold and casefold.
|
||
|
||
Order matters (#610 round-3 P1): a fullwidth-colon entity `:`
|
||
decodes to a fullwidth colon that only the SUBSEQUENT NFKC fold maps to
|
||
`:`, so folding before unescaping left the decoded colon unfolded and
|
||
the declaration unseen. A doubly-encoded or fullwidth-ampersand entity
|
||
stays literal, which matches what the renderer shows.
|
||
"""
|
||
return unicodedata.normalize("NFKC", html.unescape(line)).casefold()
|
||
|
||
|
||
_ESCAPED_PIPE_RE = re.compile(r"\\\|")
|
||
# CommonMark §2.4: a backslash escapes the next punctuation character, so
|
||
# `\\` is a literal backslash and ``\` `` a literal backtick that can
|
||
# neither open nor close a code span. One left-to-right pass sequences the
|
||
# two correctly (`\\` consumes its backslash before a following backtick is
|
||
# considered). Ignoring this let an escaped-backtick "span" blank a live
|
||
# `<!--` and credit a hidden dissent field (#613 security round 1, P1).
|
||
_ESCAPED_BACKTICK_RE = re.compile(r"\\[\\`]")
|
||
|
||
|
||
def _blank_code_spans(line: str) -> str:
|
||
"""Blank CommonMark inline code spans, matching runs of EQUAL length.
|
||
|
||
A regex accepting unequal delimiter runs (#610 round-4 P1) blanked
|
||
from a single-backtick opener to a double-backtick closer — a stretch
|
||
the renderer does NOT treat as code, re-opening the later-cell attack
|
||
— and conversely swallowed legitimate prose. This scanner pairs an
|
||
opening run only with the next run of exactly its length, as the
|
||
renderer does; an unmatched run stays literal. Backslash-escaped
|
||
backticks are blanked FIRST: they are literal to the renderer and must
|
||
not participate in pairing.
|
||
"""
|
||
line = _ESCAPED_BACKTICK_RE.sub(" ", line)
|
||
out: list[str] = []
|
||
index, length = 0, len(line)
|
||
while index < length:
|
||
if line[index] != "`":
|
||
out.append(line[index])
|
||
index += 1
|
||
continue
|
||
run_end = index
|
||
while run_end < length and line[run_end] == "`":
|
||
run_end += 1
|
||
run = run_end - index
|
||
scan, close = run_end, -1
|
||
while scan < length:
|
||
if line[scan] != "`":
|
||
scan += 1
|
||
continue
|
||
candidate_end = scan
|
||
while candidate_end < length and line[candidate_end] == "`":
|
||
candidate_end += 1
|
||
if candidate_end - scan == run:
|
||
close = scan
|
||
break
|
||
scan = candidate_end
|
||
if close >= 0:
|
||
out.append(" " * (close + run - index))
|
||
index = close + run
|
||
else:
|
||
out.append(line[index:run_end])
|
||
index = run_end
|
||
return "".join(out)
|
||
|
||
|
||
def _cell_split_form(line: str) -> str:
|
||
"""The line with pipe-bearing non-cell constructs blanked for splitting.
|
||
|
||
A naive `split("|")` read the pipe inside a Markdown link destination,
|
||
an escaped `\\|`, or an inline code span as a GFM cell boundary and
|
||
minted a phantom field-shaped cell out of legitimate prose — a false
|
||
abort on an unretryable phase (#610 round-3). Those spans are literal
|
||
or non-tabular to the renderer, so they are blanked before the split;
|
||
the whole-line candidate is always tested unmodified.
|
||
"""
|
||
blanked = _MARKUP_SPAN_RE.sub(" ", line)
|
||
blanked = _blank_code_spans(blanked)
|
||
return _ESCAPED_PIPE_RE.sub(" ", blanked)
|
||
|
||
|
||
def _is_receipt_field_shaped(line: str) -> bool:
|
||
"""True when a line spells a receipt machine field, however decorated.
|
||
|
||
Same construction as `_is_dissent_field_shaped` (#610 round-1 fix 4):
|
||
markup spans are stripped, the line is NFKC-folded and HTML-unescaped
|
||
(an entity colon `:` renders as a colon and must read as one), and
|
||
the letters before the first colon must spell exactly a receipt field
|
||
name. An unenumerated decoration — `**status:** mismatch`, a table
|
||
cell, an indented, ordered, or blockquoted marker — is therefore a
|
||
declaration the seat made, and a shaped line the canonical grammar
|
||
refuses aborts loudly instead of silently passing a forbidden-field
|
||
guard or starving a required-field count.
|
||
"""
|
||
stripped = _MARKUP_SPAN_RE.sub("", _display_fold(line))
|
||
head, separator, _ = stripped.partition(":")
|
||
label = "".join(char for char in head if char.isalpha())
|
||
return bool(separator) and label in _RECEIPT_FIELD_SHAPE_NAMES
|
||
|
||
|
||
def _receipt_shape_candidates(line: str) -> list[str]:
|
||
"""The line plus, when it carries pipes, each `|`-delimited cell.
|
||
|
||
The head-of-line shape test alone lets a machine declaration hide in a
|
||
LATER table cell (`| note | **tail_convention:** two-tailed |`), where
|
||
the head letters of the whole line spell nothing (#610 round-2). Cells
|
||
are scanned only when the line does not canonically parse, so a
|
||
canonical field whose free-text value happens to contain a pipe is
|
||
never re-partitioned against itself.
|
||
"""
|
||
if "|" not in line:
|
||
return [line]
|
||
return [line, *_cell_split_form(line).split("|")]
|
||
|
||
|
||
def _tail_value_shown(derived_fold: str, label_re: re.Pattern[str]) -> bool:
|
||
"""Whether a tail label shares a `;`-delimited segment with a digit.
|
||
|
||
Segments are the `;`-delimited pieces of the (NFKC-folded, casefolded)
|
||
`derived_value_or_range` value: a bare label whose number sits in some
|
||
other segment — or nowhere — is a label, not a shown value, and cannot
|
||
satisfy the both-tails display rule. Digit-adjacency is direction-free
|
||
within the segment (`two-tailed p ≈ .192` and `p = .192 (two-tailed)`
|
||
both show the value); the one-pass any() also keeps the scan linear.
|
||
"""
|
||
return any(
|
||
label_re.search(segment)
|
||
and any(char.isdigit() for char in segment)
|
||
for segment in derived_fold.split(";")
|
||
)
|
||
|
||
|
||
def _is_backref_shaped(line: str) -> bool:
|
||
"""True when a line spells the `**Arithmetic Receipt**:` field, however
|
||
decorated — the same head shape test as the receipt fields."""
|
||
stripped = _MARKUP_SPAN_RE.sub("", _display_fold(line))
|
||
head, separator, _ = stripped.partition(":")
|
||
label = "".join(char for char in head if char.isalpha())
|
||
return bool(separator) and label == _RECEIPT_BACKREF_SHAPE_NAME
|
||
|
||
|
||
def _strip_inline_comment_spans(
|
||
line: str, open_: bool
|
||
) -> tuple[str, list[str], bool]:
|
||
"""(rendered remainder, hidden segments, state) for inline spans.
|
||
|
||
The block visibility model deliberately refuses to read a mid-paragraph
|
||
`prose <!--` as an opener (see `_raw_dissent_span`), but CommonMark
|
||
treats it as raw inline HTML whose comment hides everything until
|
||
`-->`. The Review Body backref walk therefore strips those spans and
|
||
parses only what the renderer shows (#610 rounds 3-5): content after
|
||
the opener on the SAME line is stripped too, and content after a
|
||
closing `-->` on the same line is kept and parsed — so a hidden
|
||
back-reference is never credited and a rendered one is never dropped.
|
||
The hidden segments are returned as well, because a declaration inside
|
||
a span must abort even when the line also carries visible content —
|
||
silently dropping it re-opened the hidden-declaration channel one
|
||
partial-visibility case at a time (#610 round-5). They stay one entry
|
||
PER SPAN, never joined (#610 round-6): joining let a prose span prefix
|
||
shield a declaration in the next span, and conversely synthesized a
|
||
phantom declaration out of harmless fragments. Callers pass a
|
||
code-span-blanked line, matching the renderer's precedence of code
|
||
spans over raw HTML.
|
||
"""
|
||
visible_parts: list[str] = []
|
||
hidden_segments: list[str] = []
|
||
index, state = 0, open_
|
||
while True:
|
||
token = "-->" if state else "<!--"
|
||
position = line.find(token, index)
|
||
if position < 0:
|
||
if state:
|
||
hidden_segments.append(line[index:])
|
||
else:
|
||
visible_parts.append(line[index:])
|
||
return "".join(visible_parts), hidden_segments, state
|
||
if state:
|
||
hidden_segments.append(line[index:position])
|
||
else:
|
||
visible_parts.append(line[index:position])
|
||
state, index = not state, position + len(token)
|
||
|
||
|
||
def _backref_declared_count(line: str) -> int:
|
||
"""Back-reference declarations on the line, counted per table cell.
|
||
|
||
Counting the whole line once let a malformed second declaration ride
|
||
beside a canonical one (`… AR1 | **Arithmetic Receipt:** see AR2`) —
|
||
the canonical parse found a match, the shape test never ran, and the
|
||
visible second declaration vanished (#610 round-2). Cells are the
|
||
declaration slots the canonical regex itself recognises (its
|
||
start-or-pipe prefix), so declared-vs-parsed equality is exact.
|
||
"""
|
||
if "|" not in line:
|
||
return 1 if _is_backref_shaped(line) else 0
|
||
return sum(
|
||
1 for cell in _cell_split_form(line).split("|")
|
||
if _is_backref_shaped(cell)
|
||
)
|
||
|
||
|
||
def _lines_with_fence_state(text: str):
|
||
"""Yield every line with whether it sits inside a fenced block.
|
||
|
||
Mirrors the fence bookkeeping of `panel.strip_fences`, which drops those
|
||
lines rather than reporting them. The dissent scan needs both facts: the
|
||
content, so a fenced field cannot hide, and the state, so a fenced heading
|
||
is not mistaken for a section boundary.
|
||
|
||
Fenced lines are yielded in DISPLAY form: CommonMark strips up to the
|
||
opener's indentation from every content line of an indented fence, so a
|
||
consumer reading fenced content (the receipt gate) sees what the page
|
||
shows rather than false-aborting on the indent (#610 round-2).
|
||
"""
|
||
fence_char, fence_len, fence_indent = None, 0, 0
|
||
for line in panel._COMMONMARK_LINE_END_RE.split(text):
|
||
if fence_char is not None:
|
||
if match := panel._FENCE_CLOSE_RE.fullmatch(line):
|
||
token = match.group("fence")
|
||
if token[0] == fence_char and len(token) >= fence_len:
|
||
fence_char, fence_len, fence_indent = None, 0, 0
|
||
continue
|
||
leading = len(line) - len(line.lstrip(" "))
|
||
yield line[min(fence_indent, leading):], True
|
||
continue
|
||
if match := panel._FENCE_OPEN_RE.fullmatch(line):
|
||
token, info = match.group("fence"), match.group("info")
|
||
if token[0] != "`" or "`" not in info:
|
||
fence_char, fence_len = token[0], len(token)
|
||
fence_indent = len(line) - len(line.lstrip(" "))
|
||
continue
|
||
yield line, False
|
||
|
||
|
||
def _opens_comment(line: str, *, paragraph_open: bool) -> bool:
|
||
"""Whether the line starts an HTML comment at a block position.
|
||
|
||
Tabs are measured to the next four-column stop rather than counted as one
|
||
character, the way CommonMark reads them wherever indentation defines
|
||
block structure. Counting characters put `> \\t<!--` (column four, a live
|
||
comment) and ` - \\t<!--` (column eight, indented code) on the wrong
|
||
sides of the boundary in opposite directions.
|
||
|
||
With a paragraph open, an ordered marker other than 1 cannot start a list,
|
||
so it cannot open a comment either.
|
||
"""
|
||
pattern = _PARAGRAPH_OPENER_RE if paragraph_open else _COMMENT_OPENER_RE
|
||
return bool(pattern.match(line.expandtabs(4)))
|
||
|
||
|
||
def _comment_state_after(
|
||
line: str, *, commented: bool, paragraph_open: bool = False
|
||
) -> bool:
|
||
"""Whether the line ends inside an HTML comment.
|
||
|
||
Resolved by delimiter ORDER, not by presence: `<!-- a --> <!--` closes
|
||
and reopens on one line, and reading that as closed credited a comment
|
||
carrying canonical fields as a real dissent. Only a block opener starts
|
||
one, list and blockquote markers included, so a marker discussed mid-line
|
||
is still not a comment; once a line has opened one, its later delimiters
|
||
are that same block's raw HTML.
|
||
|
||
The closer may reuse the opener's own last two dashes, which is how
|
||
CommonMark closes `<!-->` and `<!--->`. Ordering the scan without that
|
||
overlap would read them as unterminated and abort a card that presence-
|
||
testing for a closer had passed.
|
||
"""
|
||
index = 0
|
||
if not commented:
|
||
if not _opens_comment(line, paragraph_open=paragraph_open):
|
||
return False
|
||
# Located in the ORIGINAL line: the prefix carries no `<!--`, so the
|
||
# first occurrence is the opener wherever tab expansion moved it.
|
||
commented, index = True, line.find("<!--") + 2
|
||
while True:
|
||
token = "-->" if commented else "<!--"
|
||
position = line.find(token, index)
|
||
if position < 0:
|
||
return commented
|
||
commented, index = not commented, position + len(token)
|
||
|
||
|
||
def _inline_comment_state_after(line: str, *, commented: bool) -> bool:
|
||
"""Delimiter-order comment state with NO block-position requirement.
|
||
|
||
Span-scoped (#613): inside the dissent span the output grammar makes a
|
||
bare ``<!--`` out-of-grammar prose — the delivered Phase 2 prompts and
|
||
the protocol now require inline code for any mention — so every
|
||
occurrence is an opener, including the two CommonMark shapes the block
|
||
visibility model deliberately does not read (a marker following text on
|
||
its own line; a marker indented as a lazy paragraph continuation).
|
||
Callers blank code spans first, so the sanctioned inline-code mention
|
||
never opens.
|
||
"""
|
||
index = 0
|
||
while True:
|
||
token = "-->" if commented else "<!--"
|
||
position = line.find(token, index)
|
||
if position < 0:
|
||
return commented
|
||
commented = not commented
|
||
# After an opener, resume at +2 rather than +4: the closer may
|
||
# reuse the opener's own last two dashes (`<!-->`, `<!--->`) —
|
||
# the same overlap rule the block scanner applies (codex #650
|
||
# round 1, P2: skipping it left the empty comment "open" and
|
||
# false-aborted the rendered fields below it).
|
||
index = position + (2 if commented else len(token))
|
||
|
||
|
||
def _lines_with_hidden_state(text: str):
|
||
"""Yield (line, fenced, hidden): fence plus HTML-comment visibility.
|
||
|
||
The comment and paragraph bookkeeping mirror `_raw_dissent_span` line
|
||
for line, so the receipt gate and the dissent gate share one visibility
|
||
model (#610 round-2, both tracks): `hidden` is True when the line sits
|
||
inside — or itself opens — an HTML comment at a block position, i.e.
|
||
when a CommonMark renderer would not display it. `code_indented` is
|
||
True when the line starts an indented code block (four columns at a
|
||
block position, no paragraph open), i.e. when the renderer shows it as
|
||
literal text rather than a field line (#610 round-3). Fence state is
|
||
layered first, exactly as in `_raw_dissent_span`: a comment marker
|
||
inside a fence is literal text and advances no comment state.
|
||
"""
|
||
commented = False
|
||
paragraph_open = False
|
||
for line, fenced in _lines_with_fence_state(text):
|
||
entered_commented = commented
|
||
opens_comment = not fenced and _opens_comment(
|
||
line, paragraph_open=paragraph_open
|
||
)
|
||
if not fenced:
|
||
commented = _comment_state_after(
|
||
line, commented=commented, paragraph_open=paragraph_open
|
||
)
|
||
expanded = line.expandtabs(4)
|
||
code_indented = (
|
||
not fenced
|
||
and not paragraph_open
|
||
and bool(line.strip(" \t"))
|
||
and expanded.startswith(" ")
|
||
)
|
||
state_dependent = (
|
||
_SETEXT_UNDERLINE_RE if paragraph_open else _EMPTY_LIST_ITEM_RE
|
||
)
|
||
closes_paragraph = bool(
|
||
_CLOSES_PARAGRAPH_RE.match(expanded)
|
||
or state_dependent.match(expanded)
|
||
)
|
||
paragraph_open = (
|
||
not fenced
|
||
and bool(line.strip(" \t"))
|
||
and not closes_paragraph
|
||
and not (entered_commented or opens_comment)
|
||
# An indented-code line is literal, never a paragraph: letting
|
||
# it open one made the NEXT code line read as a live paragraph
|
||
# continuation and earn credit the renderer does not show
|
||
# (#610 round-4).
|
||
and not code_indented
|
||
)
|
||
yield line, fenced, entered_commented or opens_comment, code_indented
|
||
|
||
|
||
def _raw_dissent_span(text: str) -> DissentSpan:
|
||
"""Dissent-section lines as written, before any sanitizer runs.
|
||
|
||
Comment delimiters are opened up rather than dropped, so a field inside an
|
||
HTML comment is scanned instead of vanishing with the comment. A
|
||
field-shaped H2 immediately inside the span rides along, because a field
|
||
spelled as its own `## dimension_id: D1` heading leaves the section body
|
||
empty and would otherwise be invisible; a field-shaped heading anywhere
|
||
else is an ordinary extra section, which the report grammar permits.
|
||
|
||
Only an unfenced heading delimits the span, so a heading written inside a
|
||
fenced block cannot end it early and hide the fields that follow — not
|
||
even one repeating a title that exists structurally elsewhere. A COMMENTED
|
||
heading still delimits, agreeing with `split_sections` rather than second-
|
||
guessing it: disagreeing cost four false aborts across review rounds and
|
||
bought only a miss that credits the seat nothing, while agreeing keeps a
|
||
comment opened above the heading from laundering the fields below it.
|
||
"""
|
||
span, hidden_by_comment, raw_html = [], [], []
|
||
inside, commented = False, False
|
||
paragraph_open = False
|
||
# #613: span-scoped inline comment state. Outside the span, only a
|
||
# block-position opener counts (the #612 model, unchanged, because
|
||
# prose there may legitimately mention a bare marker). INSIDE the span
|
||
# the delivered output grammar requires inline code for any mention, so
|
||
# a bare `<!--` is an opener wherever it appears — closing the two
|
||
# residual shapes (#613): a marker following text on its own line, and
|
||
# a marker indented as a lazy paragraph continuation.
|
||
span_inline = False
|
||
# Code spans pair by equal-length runs ACROSS soft line breaks within a
|
||
# paragraph, which a per-line blanker cannot see: a trailing unpaired
|
||
# run on one line can pair into the next line and pull a `<!--` out of
|
||
# (or into) code (#613 security round 1, P1). Once a span line leaves
|
||
# an odd number of backtick runs, local blanking is untrustworthy for
|
||
# the REST of that paragraph: stop blanking and read every `<!--` as an
|
||
# opener — abort-direction, since the sanctioned mention is a
|
||
# same-line inline-code span in a paragraph with balanced runs.
|
||
code_parity_suspect = False
|
||
for line, fenced in _lines_with_fence_state(text):
|
||
entered_commented = commented
|
||
entered_inline = span_inline
|
||
opens_comment = not fenced and _opens_comment(
|
||
line, paragraph_open=paragraph_open
|
||
)
|
||
if not fenced:
|
||
# A block opener only, list and blockquote markers included:
|
||
# four columns STARTING a block makes indented code, and a fence
|
||
# makes every marker inert. Not read as an opener HERE: a marker
|
||
# mid-line, one in an inline-code span, or one indented as a lazy
|
||
# paragraph continuation — inside the dissent span those are the
|
||
# #613 inline state's job, now that the output grammar makes a
|
||
# bare marker out-of-grammar prose. #613 tracks, and leaves
|
||
# OPEN, the wider hiding channel this visibility model does not
|
||
# cover at all: raw HTML that is not a comment, such as a
|
||
# `<script>` or `<template>` block — the shipped closure is the
|
||
# comment channel only.
|
||
commented = _comment_state_after(
|
||
line, commented=commented, paragraph_open=paragraph_open
|
||
)
|
||
if not fenced and (match := panel._H2_RE.fullmatch(line)):
|
||
title = match.group(1)
|
||
if inside and _is_dissent_field_shaped(title):
|
||
span.append(title)
|
||
else:
|
||
inside = title == "Scoring Plan Dissent"
|
||
paragraph_open = False
|
||
span_inline = False
|
||
code_parity_suspect = False
|
||
continue
|
||
# CommonMark counts only spaces and tabs as blank, so a line holding
|
||
# an ideographic space is a paragraph. Calling it blank put the next
|
||
# line at a block start and aborted a valid zh-TW card.
|
||
expanded = line.expandtabs(4)
|
||
state_dependent = (
|
||
_SETEXT_UNDERLINE_RE if paragraph_open else _EMPTY_LIST_ITEM_RE
|
||
)
|
||
closes_paragraph = bool(
|
||
_CLOSES_PARAGRAPH_RE.match(expanded)
|
||
or state_dependent.match(expanded)
|
||
)
|
||
paragraph_open = (
|
||
not fenced
|
||
and bool(line.strip(" \t"))
|
||
and not closes_paragraph
|
||
# A recognized comment block is raw HTML, never a paragraph, so
|
||
# the line after one is at a block start again. Recording its own
|
||
# lines as an open paragraph read the next `2. <!--` with the
|
||
# restricted pattern and credited the fields it hides.
|
||
and not (entered_commented or opens_comment)
|
||
)
|
||
if inside:
|
||
if not line.strip(" \t"):
|
||
# A blank line closes the paragraph, and with it any
|
||
# cross-line code-span ambiguity.
|
||
code_parity_suspect = False
|
||
opens_inline = False
|
||
blanked = None
|
||
if (not fenced and not entered_commented and not entered_inline
|
||
and not opens_comment):
|
||
# #613: only lines the block model does NOT already own can
|
||
# open the inline state; code spans are blanked first so the
|
||
# grammar's sanctioned `` `<!--` `` mention stays prose —
|
||
# unless this paragraph's runs stopped pairing locally, in
|
||
# which case blanking is off and every marker opens.
|
||
escaped = _ESCAPED_BACKTICK_RE.sub(" ", line)
|
||
blanked = (
|
||
escaped if code_parity_suspect
|
||
else _blank_code_spans(line)
|
||
)
|
||
opens_inline = "<!--" in blanked
|
||
if len(re.findall(r"`+", escaped)) % 2:
|
||
code_parity_suspect = True
|
||
# #682: any non-comment raw-HTML tag/delimiter in the dissent
|
||
# span is out of grammar even when it hides no field. Scan the
|
||
# display form after inline-code blanking; fenced examples retain
|
||
# their previous semantics, and the exact H2 boundaries above
|
||
# keep the rule span-scoped. Under cross-line code ambiguity we
|
||
# take the same abort-direction as the #613 comment guard rather
|
||
# than letting a malformed tag earn a dissent exemption.
|
||
if not fenced:
|
||
html_scan = (
|
||
_ESCAPED_BACKTICK_RE.sub(" ", line)
|
||
if code_parity_suspect
|
||
else _blank_code_spans(line)
|
||
)
|
||
if _DISSENT_RAW_HTML_RE.search(html_scan):
|
||
raw_html.append(line)
|
||
# Opened up only where a comment actually is. Rewriting every line
|
||
# carrying the tokens would break a canonical `rationale:` that
|
||
# merely mentions them in inline code from matching the
|
||
# canonical parse, aborting an unretryable Phase 2 on a valid
|
||
# card.
|
||
if (entered_commented or opens_comment or entered_inline
|
||
or opens_inline):
|
||
span.append(line.replace("<!--", " ").replace("-->", " "))
|
||
else:
|
||
span.append(line)
|
||
if entered_commented or entered_inline:
|
||
hidden_by_comment.append(line)
|
||
if not fenced and (entered_inline or opens_inline):
|
||
span_inline = _inline_comment_state_after(
|
||
blanked if blanked is not None
|
||
else _blank_code_spans(line),
|
||
commented=entered_inline,
|
||
)
|
||
return DissentSpan(span, hidden_by_comment, raw_html)
|
||
|
||
|
||
def _empty_dissent_section_diagnostic(raw_span: list[str]) -> str:
|
||
"""Counted on the raw span: fenced prose is archived content too."""
|
||
non_blank = sum(1 for line in raw_span if line.strip())
|
||
return (
|
||
"[DISSENT-EMPTY-SECTION: ## Scoring Plan Dissent spells no dissent "
|
||
"field; read as no dissent, with full Phase 1 trigger binding "
|
||
f"enforced on every dimension; {non_blank} non-blank line(s) present "
|
||
"— read the archived response if any narrate a deviation]"
|
||
)
|
||
|
||
|
||
def _one_field(
|
||
lines: list[str],
|
||
field: str,
|
||
path: str,
|
||
*,
|
||
required: bool,
|
||
dimension_id: str,
|
||
) -> str | None:
|
||
hits = [match.group("value") for line in lines
|
||
if (match := _FIELD_PATTERNS[field].fullmatch(line))]
|
||
expected = "exactly one" if required else "at most one"
|
||
if (required and len(hits) != 1) or (not required and len(hits) > 1):
|
||
raise ConformanceError(
|
||
f"[PHASE1-GRAMMAR: {path}: expected {expected} canonical "
|
||
f"{field}: line for dimension {dimension_id}, found {len(hits)}]"
|
||
)
|
||
return hits[0] if hits else None
|
||
|
||
|
||
def parse_phase1(
|
||
path: str, text: str, contract: dict, role: str
|
||
) -> PhaseOnePlan:
|
||
lines = panel.strip_fences(text)
|
||
sections, dupes = panel.split_sections(lines)
|
||
if "Scoring Plan" in dupes or "Scoring Plan" not in sections:
|
||
raise ConformanceError(
|
||
f"[PHASE1-GRAMMAR: {path}: exactly one ## Scoring Plan required]"
|
||
)
|
||
# §4 names three requirements, not one: the paraphrase section and the
|
||
# terminal acknowledgement are as mandatory as the plan, and a plan
|
||
# alone passing would let the dispatcher retry -- or accept -- a
|
||
# protocol-invalid Phase 1.
|
||
if "Contract Paraphrase" in dupes or "Contract Paraphrase" \
|
||
not in sections:
|
||
raise ConformanceError(
|
||
f"[PHASE1-GRAMMAR: {path}: exactly one ## Contract Paraphrase "
|
||
"required]"
|
||
)
|
||
# BOTH tails must be the marker. The raw tail catches output after
|
||
# the acknowledgement (a trailing fenced block would vanish from the
|
||
# structural view before the tail is computed); the fence-aware tail
|
||
# catches an acknowledgement that exists only as fenced code.
|
||
# Located by nonblankness but compared UNSTRIPPED (bar the line
|
||
# ending): an indented ` [CONTRACT-ACKNOWLEDGED]` renders as a
|
||
# code block, and stripping before comparison let it pass the exact
|
||
# terminal-line requirement.
|
||
raw_tail = next(
|
||
(line for line in reversed(text.splitlines())
|
||
if line.strip()), "")
|
||
fenced_tail = next(
|
||
(line for line in reversed(lines) if line.strip()), "")
|
||
if raw_tail.rstrip() != "[CONTRACT-ACKNOWLEDGED]" or \
|
||
fenced_tail.rstrip() != "[CONTRACT-ACKNOWLEDGED]":
|
||
raise ConformanceError(
|
||
f"[PHASE1-GRAMMAR: {path}: final nonblank line must be "
|
||
"[CONTRACT-ACKNOWLEDGED]]"
|
||
)
|
||
# And in §4's exact order, with nothing else at H2: presence alone
|
||
# would let a reordered or extra-sectioned precommitment pass. All
|
||
# four real dispatch outputs carry exactly this sequence.
|
||
h2_titles = [line[3:].strip() for line in lines
|
||
if line.startswith("## ")]
|
||
if h2_titles != ["Contract Paraphrase", "Scoring Plan"]:
|
||
raise ConformanceError(
|
||
f"[PHASE1-GRAMMAR: {path}: H2 sections must be exactly "
|
||
"## Contract Paraphrase then ## Scoring Plan, found "
|
||
f"{h2_titles!r}]"
|
||
)
|
||
dimensions = {d["id"]: d for d in contract["acceptance_dimensions"]}
|
||
# §4's paragraph floor: a bare heading over an empty or one-line body
|
||
# is not the paraphrase the contract's `paraphrase_minimum_dimensions`
|
||
# names ("all" = one paragraph per dimension). The count is the
|
||
# machine-checkable lower bound; whether each paragraph is TIED to a
|
||
# distinct dimension stays with the seat's own §4 preflight.
|
||
# A CLOSED list of zero-content lines that separate and never count:
|
||
# ATX headings, thematic breaks, single-line HTML comments -- six
|
||
# bare `### Dn` headings (or six `---` rules) used to count as six
|
||
# paragraphs, satisfying the floor with no paraphrase prose at all.
|
||
# Deliberately NOT a full CommonMark block classifier: a list item
|
||
# still counts, because a bulleted six-point paraphrase is real
|
||
# content and refusing it would abort a panel over formatting (the
|
||
# false-abort channel #609 exists to remove). Blocks are still
|
||
# blank-line separated -- a TIGHT six-item list is one block and
|
||
# does not meet a six-paragraph floor, same as before this list.
|
||
# TERMINATION BOUND: zero-content variants beyond this list
|
||
# (container-prefixed comments like `- <!-- -->`, malformed comments
|
||
# like `<!-->`, entity/whitespace tricks) are out of scope by
|
||
# declared design -- the variant space is unbounded, the observed
|
||
# base rate in committed panels is zero, and §4's substantive
|
||
# judgment sits with the seat's preflight, not this floor.
|
||
separator = re.compile(
|
||
r"#{1,6}(\s|$)" # ATX heading
|
||
r"|([-*_])(\s*\2){2,}$" # thematic break
|
||
r"|<!--.*-->$" # single-line HTML comment
|
||
r"|[-*+]$" # lone list marker, no item text
|
||
)
|
||
paragraphs, in_paragraph, in_comment = 0, False, False
|
||
for line in sections["Contract Paraphrase"]:
|
||
stripped = line.strip()
|
||
if in_comment:
|
||
# Hidden until the closing `-->`, closer line included: six
|
||
# multi-line comment blocks are as unrendered as six
|
||
# single-line ones.
|
||
if "-->" in stripped:
|
||
in_comment = False
|
||
in_paragraph = False
|
||
continue
|
||
if stripped.startswith("<!--") and "-->" not in stripped:
|
||
# Conservative entry -- a line-LEADING opener with no closer
|
||
# on the same line. Prose that merely mentions `<!--`
|
||
# mid-line stays countable content.
|
||
in_comment = True
|
||
in_paragraph = False
|
||
continue
|
||
if stripped and not separator.match(stripped):
|
||
if not in_paragraph:
|
||
paragraphs += 1
|
||
in_paragraph = True
|
||
else:
|
||
in_paragraph = False
|
||
minimum = contract.get("measurement_procedure", {}).get(
|
||
"paraphrase_minimum_dimensions")
|
||
required = len(dimensions) if minimum == "all" else (
|
||
minimum if isinstance(minimum, int) else 0)
|
||
if paragraphs < required:
|
||
raise ConformanceError(
|
||
f"[PHASE1-GRAMMAR: {path}: Contract Paraphrase has "
|
||
f"{paragraphs} paragraph(s), fewer than the {required} "
|
||
"required]"
|
||
)
|
||
eligible = {
|
||
did for did, dim in dimensions.items()
|
||
if role in dim["eligible_roles"]
|
||
}
|
||
subsections, subsection_dupes = panel.split_subsections(
|
||
sections["Scoring Plan"]
|
||
)
|
||
if subsection_dupes:
|
||
raise ConformanceError(
|
||
f"[PHASE1-GRAMMAR: {path}: duplicate scoring-plan subsection: "
|
||
f"{', '.join(sorted(subsection_dupes))}]"
|
||
)
|
||
commitments: dict[str, dict[str, str]] = {}
|
||
warnings: list[str] = []
|
||
for title, sublines in subsections.items():
|
||
match = panel._DIM_H3_RE.fullmatch(title)
|
||
if not match or match.group("dim") not in dimensions:
|
||
raise ConformanceError(
|
||
f"[PHASE1-GRAMMAR: {path}: invalid subsection '### {title}']"
|
||
)
|
||
did = match.group("dim")
|
||
if did not in eligible:
|
||
raise ConformanceError(
|
||
f"[PHASE1-OUT-OF-ROLE: {path}: role {role} planned {did}]"
|
||
)
|
||
if match.group("name") != dimensions[did]["name"]:
|
||
raise ConformanceError(
|
||
f"[PHASE1-GRAMMAR: {path}: {did} name mismatch]"
|
||
)
|
||
fields = {
|
||
field: _one_field(
|
||
sublines,
|
||
field,
|
||
path,
|
||
required=True,
|
||
dimension_id=did,
|
||
)
|
||
for field in (
|
||
"dimension_id", "what_to_look_for",
|
||
"what_triggers_block", "what_triggers_warn",
|
||
)
|
||
}
|
||
mandatory = dimensions[did]["priority"] == "mandatory"
|
||
fields["what_triggers_fatal"] = _one_field(
|
||
sublines,
|
||
"what_triggers_fatal",
|
||
path,
|
||
required=mandatory,
|
||
dimension_id=did,
|
||
)
|
||
if not mandatory and fields["what_triggers_fatal"] is not None:
|
||
raise ConformanceError(
|
||
f"[PHASE1-GRAMMAR: {path}: what_triggers_fatal is forbidden "
|
||
f"on non-mandatory dimension {did}]"
|
||
)
|
||
if fields["dimension_id"] != did:
|
||
raise ConformanceError(
|
||
f"[PHASE1-GRAMMAR: {path}: heading {did} disagrees with "
|
||
f"dimension_id={fields['dimension_id']}]"
|
||
)
|
||
triggers = [
|
||
fields["what_triggers_block"],
|
||
fields["what_triggers_warn"],
|
||
]
|
||
if mandatory:
|
||
triggers.append(fields["what_triggers_fatal"])
|
||
if len({_normalise(value) for value in triggers}) != len(triggers):
|
||
raise ConformanceError(
|
||
f"[PHASE1-TRIGGER-COLLISION: {path}: {did} trigger "
|
||
"commitments must be pairwise distinct]"
|
||
)
|
||
for field in (
|
||
"what_triggers_block", "what_triggers_warn",
|
||
"what_triggers_fatal",
|
||
):
|
||
value = fields[field]
|
||
if value is not None and len(value.split()) < 8:
|
||
warnings.append(
|
||
f"[PHASE1-TRIGGER-SHORT: {path}: {did} {field} "
|
||
"has fewer than 8 words]"
|
||
)
|
||
commitments[did] = fields
|
||
if set(commitments) != eligible:
|
||
raise ConformanceError(
|
||
f"[PHASE1-SCOPE: {path}: planned={sorted(commitments)}, "
|
||
f"eligible={sorted(eligible)}]"
|
||
)
|
||
return PhaseOnePlan(commitments, warnings)
|
||
|
||
|
||
def _flatten_metadata_values(value) -> list[str]:
|
||
if isinstance(value, dict):
|
||
return [
|
||
item
|
||
for nested in value.values()
|
||
for item in _flatten_metadata_values(nested)
|
||
]
|
||
if isinstance(value, list):
|
||
return [
|
||
item
|
||
for nested in value
|
||
for item in _flatten_metadata_values(nested)
|
||
]
|
||
if isinstance(value, (str, int, float, bool)):
|
||
return [str(value)]
|
||
return []
|
||
|
||
|
||
def validate_metadata_envelope(metadata) -> None:
|
||
if not isinstance(metadata, dict) or set(metadata) != _METADATA_KEYS:
|
||
actual = (
|
||
sorted(metadata)
|
||
if isinstance(metadata, dict)
|
||
else type(metadata).__name__
|
||
)
|
||
raise panel.ContractError(
|
||
"[METADATA-INVALID: expected exact title/field/word_count "
|
||
f"envelope, got {actual}]"
|
||
)
|
||
if (
|
||
not isinstance(metadata["title"], str)
|
||
or not metadata["title"].strip()
|
||
or not isinstance(metadata["field"], str)
|
||
or not metadata["field"].strip()
|
||
or isinstance(metadata["word_count"], bool)
|
||
or not isinstance(metadata["word_count"], int)
|
||
or metadata["word_count"] < 0
|
||
):
|
||
raise panel.ContractError(
|
||
"[METADATA-INVALID: title and field must be non-empty strings; "
|
||
"word_count must be a non-negative integer]"
|
||
)
|
||
|
||
|
||
def check_manuscript_leakage(
|
||
phase1_text: str, manuscript_text: str, metadata: dict, contract: dict
|
||
) -> None:
|
||
validate_metadata_envelope(metadata)
|
||
phase1_norm = _normalise(phase1_text)
|
||
words = _normalise(manuscript_text).split()
|
||
exemption_haystacks = [
|
||
_normalise(value) for value in _flatten_metadata_values(metadata)
|
||
]
|
||
exemption_haystacks.append(_normalise(json.dumps(
|
||
contract, ensure_ascii=False, sort_keys=True
|
||
)))
|
||
for index in range(max(0, len(words) - 11)):
|
||
shingle = " ".join(words[index:index + 12])
|
||
if shingle not in phase1_norm:
|
||
continue
|
||
if any(shingle in haystack for haystack in exemption_haystacks):
|
||
continue
|
||
raise ConformanceError(
|
||
"[PHASE1-MANUSCRIPT-LEAK: 12-word manuscript shingle appears "
|
||
"in Phase 1 outside metadata/contract exemptions]"
|
||
)
|
||
|
||
|
||
def parse_dissent_dimensions(text: str) -> DissentParse:
|
||
lines = panel.strip_fences(text)
|
||
sections, dupes = panel.split_sections(lines)
|
||
if "Scoring Plan Dissent" in dupes:
|
||
raise ConformanceError(
|
||
"[DISSENT-GRAMMAR: duplicate ## Scoring Plan Dissent]"
|
||
)
|
||
if "Scoring Plan Dissent" not in sections:
|
||
return DissentParse(set(), [])
|
||
body = sections["Scoring Plan Dissent"]
|
||
span = _raw_dissent_span(text)
|
||
raw_span = span.lines
|
||
# A commented-out field is not a claim the seat made, so it is struck from
|
||
# the canonical parse and left to fail below as an unparsed occurrence.
|
||
outstanding = Counter(span.hidden_by_comment)
|
||
visible = []
|
||
for line in body:
|
||
if outstanding.get(line):
|
||
outstanding[line] -= 1
|
||
continue
|
||
visible.append(line)
|
||
parsed = Counter(
|
||
line for line in visible
|
||
if _DISSENT_DIM_RE.fullmatch(line)
|
||
or _DISSENT_RATIONALE_RE.fullmatch(line)
|
||
)
|
||
dims = [match.group("dim") for line in visible
|
||
if (match := _DISSENT_DIM_RE.fullmatch(line))]
|
||
rationales = [
|
||
match.group("text") for line in visible
|
||
if (match := _DISSENT_RATIONALE_RE.fullmatch(line))
|
||
]
|
||
# Scanned on the RAW span, not the sanitized body: a field the sanitizers
|
||
# delete (fence, comment) or relocate (its own H2) would otherwise reach
|
||
# the tolerance branch as an empty section. A field line the canonical
|
||
# parse never saw is a dissent this seat cannot be credited with, so it
|
||
# fails whether or not it is canonically spelled. Counted rather than
|
||
# matched by value, so a hidden copy of a canonical field is still one
|
||
# unparsed occurrence and cannot ride in on its twin's identity.
|
||
hidden = Counter(
|
||
candidate for candidate in raw_span
|
||
if _is_dissent_field_shaped(candidate)
|
||
)
|
||
# Raw HTML owns its own diagnostic even when stripping the tag leaves a
|
||
# field-shaped line. Checking it first prevents a ``<span>dimension_id``
|
||
# wrapper from being mislabeled as comment hiding by the older generic
|
||
# raw-span occurrence guard. Comment-only cards never populate this list
|
||
# and retain the #613 ``[DISSENT-HIDDEN]`` path below.
|
||
if span.raw_html:
|
||
raise ConformanceError(
|
||
"[DISSENT-RAW-HTML: raw HTML tags or delimiters are forbidden "
|
||
"inside ## Scoring Plan Dissent; put markup mentions in inline "
|
||
"code]"
|
||
)
|
||
if any(count > parsed[value] for value, count in hidden.items()):
|
||
# Distinct marker (#613 security round 1, P3): these fields ARE
|
||
# canonical — the failure is that comment markup hides them from
|
||
# the rendered card, and pointing the operator at line grammar
|
||
# misattributes an unretryable abort.
|
||
raise ConformanceError(
|
||
"[DISSENT-HIDDEN: a canonical dissent field is hidden from the "
|
||
"rendered card by comment markup; write dissent fields in the "
|
||
"clear and mention comment syntax only in inline code]"
|
||
)
|
||
if any(
|
||
_is_dissent_field_shaped(candidate) and candidate not in parsed
|
||
for candidate in visible
|
||
):
|
||
raise ConformanceError(
|
||
"[DISSENT-GRAMMAR: dissent fields must be canonical unbulleted "
|
||
"dimension_id: and rationale: lines]"
|
||
)
|
||
if not dims and not rationales:
|
||
# A section that spells no dissent field carries the same information
|
||
# as an absent section, so it is read as no dissent instead of
|
||
# aborting an unretryable Phase 2. The occurrence stays auditable in
|
||
# the run record, and full Phase 1 trigger binding still applies to
|
||
# every dimension.
|
||
return DissentParse(
|
||
set(), [_empty_dissent_section_diagnostic(raw_span)]
|
||
)
|
||
h2_positions = {
|
||
match.group(1): index
|
||
for index, line in enumerate(lines)
|
||
if (match := panel._H2_RE.fullmatch(line))
|
||
}
|
||
if h2_positions["Scoring Plan Dissent"] > h2_positions.get(
|
||
"Dimension Scores", -1
|
||
):
|
||
raise ConformanceError(
|
||
"[DISSENT-GRAMMAR: ## Scoring Plan Dissent must precede "
|
||
"## Dimension Scores]"
|
||
)
|
||
if not dims:
|
||
raise ConformanceError(
|
||
"[DISSENT-GRAMMAR: dissent section must name dimension_id]"
|
||
)
|
||
if len(dims) != len(set(dims)):
|
||
raise ConformanceError("[DISSENT-GRAMMAR: duplicate dimension_id]")
|
||
if len(rationales) != len(dims):
|
||
raise ConformanceError(
|
||
"[DISSENT-GRAMMAR: each dissent requires one rationale: line]"
|
||
)
|
||
return DissentParse(set(dims), [])
|
||
|
||
|
||
def check_trigger_binding(
|
||
report: panel.ReviewerReport,
|
||
plan: PhaseOnePlan,
|
||
dimensions: dict[str, dict],
|
||
dissent: set[str],
|
||
) -> None:
|
||
if len(dissent) >= 2:
|
||
raise ConformanceError(
|
||
f"[PROTOCOL-VIOLATION: multi_dissent=true, "
|
||
f"dimensions={sorted(dissent)}]"
|
||
)
|
||
unknown = dissent - set(dimensions)
|
||
if unknown:
|
||
raise ConformanceError(
|
||
f"[DISSENT-GRAMMAR: unknown dimensions {sorted(unknown)}]"
|
||
)
|
||
uncommitted = dissent - set(plan.commitments)
|
||
if uncommitted:
|
||
raise ConformanceError(
|
||
f"[DISSENT-GRAMMAR: dissent dimensions were not committed by "
|
||
f"this seat {sorted(uncommitted)}]"
|
||
)
|
||
for did, value in report.scores.items():
|
||
eligible = report.role in dimensions[did]["eligible_roles"]
|
||
needs_trigger = eligible and value.score in {"block", "warn"}
|
||
if needs_trigger != bool(value.trigger):
|
||
raise ConformanceError(
|
||
f"[TRIGGER-GRAMMAR: {report.path}: {did} trigger is required "
|
||
"iff an eligible dimension scores block or warn]"
|
||
)
|
||
if did in dissent:
|
||
if value.block_class == "fatal":
|
||
raise ConformanceError(
|
||
f"[DISSENT-FATALITY: {did} dissent may not mint fatality]"
|
||
)
|
||
continue
|
||
if not value.trigger:
|
||
continue
|
||
if value.score == "warn":
|
||
field = "what_triggers_warn"
|
||
elif value.block_class == "fatal":
|
||
field = "what_triggers_fatal"
|
||
else:
|
||
field = "what_triggers_block"
|
||
committed = plan.commitments.get(did, {}).get(field)
|
||
if not committed or _normalise(value.trigger) not in _normalise(committed):
|
||
raise ConformanceError(
|
||
f"[TRIGGER-DRIFT: {did} {field} does not contain emitted "
|
||
"trigger text]"
|
||
)
|
||
matching_fields = {
|
||
candidate
|
||
for candidate in (
|
||
"what_triggers_block", "what_triggers_warn",
|
||
"what_triggers_fatal",
|
||
)
|
||
if plan.commitments.get(did, {}).get(candidate)
|
||
and _normalise(value.trigger) in _normalise(
|
||
plan.commitments[did][candidate]
|
||
)
|
||
}
|
||
if matching_fields != {field}:
|
||
raise ConformanceError(
|
||
f"[TRIGGER-AMBIGUOUS: {did} emitted trigger matches "
|
||
f"{sorted(matching_fields)}, expected only {field}]"
|
||
)
|
||
|
||
|
||
def _validate_anchor(anchor: str, context: str) -> None:
|
||
try:
|
||
panel.validate_evidence_anchor(anchor, context)
|
||
except panel.ReportError as exc:
|
||
raise ConformanceError(str(exc)) from exc
|
||
|
||
|
||
def check_scoring_seat_anchors(report: panel.ReviewerReport) -> None:
|
||
lines = panel.strip_fences(report.text)
|
||
sections, dupes = panel.split_sections(lines)
|
||
if "Review Body" in dupes or "Review Body" not in sections:
|
||
raise ConformanceError(
|
||
f"[REVIEW-BODY-MISSING: {report.path}]"
|
||
)
|
||
current_h2 = None
|
||
for line in lines:
|
||
if match := panel._H2_RE.fullmatch(line):
|
||
current_h2 = match.group(1)
|
||
elif _SEVERITY_DECL_RE.search(line) and current_h2 != "Review Body":
|
||
raise ConformanceError(
|
||
f"[FINDING-GRAMMAR: {report.path}: Severity outside "
|
||
"## Review Body]"
|
||
)
|
||
review_lines = sections["Review Body"]
|
||
blocks, subsection_dupes = panel.split_subsections(review_lines)
|
||
if subsection_dupes:
|
||
raise ConformanceError(
|
||
f"[FINDING-GRAMMAR: {report.path}: duplicate finding heading]"
|
||
)
|
||
preamble = []
|
||
for line in review_lines:
|
||
if panel._H3_RE.fullmatch(line):
|
||
break
|
||
preamble.append(line)
|
||
if any(_SEVERITY_DECL_RE.search(line) for line in preamble):
|
||
raise ConformanceError(
|
||
f"[FINDING-GRAMMAR: {report.path}: every finding with "
|
||
"Severity must have its own ### finding heading]"
|
||
)
|
||
for title, block in blocks.items():
|
||
severity_declarations = sum(
|
||
len(_SEVERITY_DECL_RE.findall(line)) for line in block
|
||
)
|
||
severities = [
|
||
match.group("severity") for line in block
|
||
for match in _SEVERITY_RE.finditer(line)
|
||
]
|
||
is_finding = _FINDING_H3_RE.fullmatch(title) is not None
|
||
if severity_declarations and not is_finding:
|
||
raise ConformanceError(
|
||
f"[FINDING-GRAMMAR: {report.path}: every finding with "
|
||
"Severity must have its own ### W<n>: <title> heading]"
|
||
)
|
||
if is_finding and any(panel._H4_RE.fullmatch(line) for line in block):
|
||
raise ConformanceError(
|
||
f"[FINDING-GRAMMAR: {report.path}: {title} may not nest "
|
||
"a Severity finding under H4]"
|
||
)
|
||
if not is_finding:
|
||
continue
|
||
# A finding needs at least one parseable Severity declaration, and
|
||
# every declaration must parse. When a card declares more than one
|
||
# ACROSS lines and the chain strictly ESCALATES (Minor < Major <
|
||
# Critical), the LAST in reading order is operative — the current
|
||
# model generation self-corrects mid-card with explicit supersession
|
||
# prose ("See the Severity line below, which supersedes the line
|
||
# above", #637 ms01_quant r1: Major -> Critical), and Phase 2 permits
|
||
# no retry for this class, so a strict exactly-one rule turns a
|
||
# visible, reader-unambiguous correction into a whole-panel abort.
|
||
# Every other multi-declaration shape keeps the loud abort:
|
||
# de-escalation could waive the Critical/Major Evidence-Anchor
|
||
# requirement by appending one weaker line; a non-monotone or
|
||
# repeated-value chain signals several findings bundled under one W
|
||
# heading (the one-finding-per-heading accounting feeds the severity
|
||
# ladder); and two parseable declarations on ONE line are not a
|
||
# reading-order correction at all. The advisory line below keeps the
|
||
# full declaration trail in the gate log for adjudication.
|
||
parseable_per_line = [
|
||
sum(1 for _ in _SEVERITY_RE.finditer(line)) for line in block
|
||
]
|
||
if (not severities or len(severities) != severity_declarations
|
||
or any(count > 1 for count in parseable_per_line)):
|
||
raise ConformanceError(
|
||
f"[FINDING-GRAMMAR: {report.path}: {title} must contain "
|
||
"exactly one parseable Severity declaration]"
|
||
)
|
||
severity_rank = {"Minor": 0, "Major": 1, "Critical": 2}
|
||
if any(severity_rank[later] <= severity_rank[earlier]
|
||
for earlier, later in zip(severities, severities[1:])):
|
||
raise ConformanceError(
|
||
f"[FINDING-GRAMMAR: {report.path}: {title}: multiple "
|
||
"Severity declarations must form a strictly escalating "
|
||
"self-correction chain]"
|
||
)
|
||
if len(severities) > 1:
|
||
print(
|
||
f"[SEVERITY-SUPERSEDED: {report.path}: {title}: "
|
||
+ " -> ".join(severities) + "]"
|
||
)
|
||
operative_severity = severities[-1]
|
||
anchor_declarations = sum(
|
||
len(_ANCHOR_DECL_RE.findall(line)) for line in block
|
||
)
|
||
anchors = [
|
||
match.group("value") for line in block
|
||
for match in _ANCHOR_RE.finditer(line)
|
||
]
|
||
if operative_severity not in {"Critical", "Major"}:
|
||
if anchor_declarations > 1 or len(anchors) != anchor_declarations:
|
||
raise ConformanceError(
|
||
f"[FINDING-GRAMMAR: {report.path}: {title} may contain "
|
||
"at most one parseable Evidence Anchor declaration]"
|
||
)
|
||
if anchors:
|
||
_validate_anchor(anchors[0], f"{report.path}:{title}")
|
||
continue
|
||
if len(anchors) != 1 or anchor_declarations != 1:
|
||
raise ConformanceError(
|
||
f"[ANCHOR-MISSING: {report.path}: {title} "
|
||
f"{operative_severity} finding needs exactly one "
|
||
"Evidence Anchor]"
|
||
)
|
||
_validate_anchor(anchors[0], f"{report.path}:{title}")
|
||
|
||
|
||
def check_da_anchors(report: panel.ReviewerReport) -> None:
|
||
try:
|
||
rows, major_anchors = panel.parse_da_tables(report.text, report.path)
|
||
except panel.ReportError as exc:
|
||
raise ConformanceError(str(exc)) from exc
|
||
expected = [f"C{index}" for index in range(1, len(rows) + 1)]
|
||
if list(rows) != expected:
|
||
raise ConformanceError(
|
||
f"[DA-CRITICAL-ID: {report.path}: IDs must be dense C1..Cn; "
|
||
f"got={list(rows)}]"
|
||
)
|
||
for finding_id, anchor in rows.items():
|
||
if not anchor:
|
||
raise ConformanceError(
|
||
f"[ANCHOR-MISSING: {report.path}: {finding_id}]"
|
||
)
|
||
_validate_anchor(anchor, f"{report.path}:{finding_id}")
|
||
|
||
for anchor in major_anchors:
|
||
if not anchor:
|
||
raise ConformanceError(
|
||
f"[ANCHOR-MISSING: {report.path}: DA MAJOR row]"
|
||
)
|
||
_validate_anchor(anchor, f"{report.path}:DA MAJOR")
|
||
|
||
|
||
def _one_receipt_field(
|
||
lines: list[str], key: str, path: str, receipt_id: str, *, required: bool
|
||
) -> str | None:
|
||
hits = [
|
||
match.group("value")
|
||
for line in lines
|
||
if (match := _RECEIPT_FIELD_RES[key].fullmatch(line))
|
||
]
|
||
expected = "exactly one" if required else "at most one"
|
||
if (required and len(hits) != 1) or (not required and len(hits) > 1):
|
||
raise ConformanceError(
|
||
f"[RECEIPT-GRAMMAR: {path}: {receipt_id} expected {expected} "
|
||
f"canonical {key}: line, found {len(hits)}]"
|
||
)
|
||
return hits[0] if hits else None
|
||
|
||
|
||
def _review_body_receipt_backrefs(
|
||
report: panel.ReviewerReport,
|
||
) -> tuple[set[str], dict[str, list[str]]]:
|
||
"""W-finding ids and per-block `**Arithmetic Receipt**:` back-references.
|
||
|
||
Back-references are collected across the WHOLE Review Body — preamble and
|
||
non-W subsections included — so a back-reference stranded outside its
|
||
weakness block is a linkage failure, not invisible. The walk is
|
||
fence-aware: only unfenced headings delimit (agreeing with
|
||
`split_sections`), a fenced or decorated back-reference declaration is
|
||
counted by the lenient detector, and a declaration count exceeding the
|
||
canonical parse aborts — a back-reference the seat spelled but the
|
||
grammar refused is a linkage failure, never a silent drop.
|
||
"""
|
||
lines = panel.strip_fences(report.text)
|
||
sections, _ = panel.split_sections(lines)
|
||
review_lines = sections.get("Review Body", [])
|
||
blocks, _ = panel.split_subsections(review_lines)
|
||
finding_ids = {
|
||
title.split(":", 1)[0]
|
||
for title in blocks
|
||
if _FINDING_H3_RE.fullmatch(title)
|
||
}
|
||
backrefs: dict[str, list[str]] = {}
|
||
current = "(preamble)"
|
||
inside = False
|
||
inline_commented = False
|
||
for line, fenced, hidden, code_indented in _lines_with_hidden_state(
|
||
report.text
|
||
):
|
||
if not fenced and (match := panel._H2_RE.fullmatch(line)):
|
||
inside = match.group(1) == "Review Body"
|
||
current = "(preamble)"
|
||
inline_commented = False
|
||
continue
|
||
if not inside:
|
||
continue
|
||
parse_line = line
|
||
if not fenced and not hidden and not code_indented:
|
||
# A paragraph-inline comment span cannot cross a paragraph
|
||
# boundary: a blank line or an interrupting ATX heading (any
|
||
# level) ends the paragraph and with it the raw-HTML span, so
|
||
# the state resets there instead of leaking a false abort into
|
||
# later prose. Code spans are blanked first, matching the
|
||
# renderer's precedence: a literal `<!--` inside inline code
|
||
# opens nothing (#610 round-4).
|
||
if (
|
||
not line.strip(" \t")
|
||
or _ATX_HEADING_RE.match(line)
|
||
):
|
||
inline_commented = False
|
||
else:
|
||
code_blanked = _blank_code_spans(line)
|
||
visible, hidden_segments, inline_commented = (
|
||
_strip_inline_comment_spans(
|
||
code_blanked, inline_commented
|
||
)
|
||
)
|
||
if visible != code_blanked:
|
||
# A declaration inside a hidden span aborts whether or
|
||
# not the line also shows visible content (#610 round
|
||
# 5): hidden prose is skippable, a hidden machine
|
||
# declaration never is. Each span is checked on its
|
||
# own (#610 round-6).
|
||
if any(
|
||
_backref_declared_count(segment)
|
||
for segment in hidden_segments
|
||
):
|
||
raise ConformanceError(
|
||
f"[RECEIPT-LINKAGE: {report.path}: an "
|
||
"Arithmetic Receipt back-reference "
|
||
"declaration sits inside a paragraph-"
|
||
"inline HTML comment span and does not "
|
||
"render; it is non-conforming]"
|
||
)
|
||
if not visible.strip(" \t"):
|
||
continue
|
||
# Only the rendered remainder is parsed: content after
|
||
# a closing `-->` stays live, content behind an opener
|
||
# is gone.
|
||
parse_line = visible
|
||
if fenced or hidden or code_indented:
|
||
# Never credited: a machine declaration the page does not show
|
||
# as a field line — fenced, commented, or rendered as indented
|
||
# code — is a loud failure, not a silent drop or a silent
|
||
# credit (#610 rounds 2-3).
|
||
if _backref_declared_count(line):
|
||
kind = (
|
||
"fenced" if fenced
|
||
else "commented-out" if hidden
|
||
else "indented-code"
|
||
)
|
||
raise ConformanceError(
|
||
f"[RECEIPT-LINKAGE: {report.path}: a {kind} Arithmetic "
|
||
"Receipt back-reference declaration does not render as "
|
||
"a field line and is non-conforming]"
|
||
)
|
||
continue
|
||
if match := panel._H3_RE.fullmatch(line):
|
||
title = match.group(1)
|
||
current = (
|
||
title.split(":", 1)[0]
|
||
if _FINDING_H3_RE.fullmatch(title)
|
||
else f"(non-finding: {title})"
|
||
)
|
||
continue
|
||
# Canonical parsing and the declaration count run on the SAME
|
||
# display form (#610 round-4 P1): parsing the raw line while
|
||
# counting on a blanked one let a code-span declaration be
|
||
# credited over the rendered field beside it.
|
||
display = _cell_split_form(parse_line)
|
||
matches = list(_RECEIPT_BACKREF_RE.finditer(display))
|
||
if _backref_declared_count(display) != len(matches):
|
||
raise ConformanceError(
|
||
f"[RECEIPT-LINKAGE: {report.path}: back-reference "
|
||
f"declaration(s) on {line.strip()!r} do not all parse "
|
||
"canonically — each value must be exactly "
|
||
"**Arithmetic Receipt**: AR<n> with no trailing text]"
|
||
)
|
||
for match in matches:
|
||
backrefs.setdefault(current, []).append(match.group("value"))
|
||
return finding_ids, backrefs
|
||
|
||
|
||
_INDENTED_H2_RE = re.compile(r"^ {1,3}##\s+\S")
|
||
# Any ATX heading interrupts a paragraph (CommonMark), so any level ends a
|
||
# paragraph-inline comment span — resetting only on H3 false-aborted a
|
||
# rendered backref after an interrupting H4 (#610 round-4).
|
||
_ATX_HEADING_RE = re.compile(r"^ {0,3}#{1,6}(?:[ \t]|$)")
|
||
|
||
|
||
def _receipt_section_view(
|
||
text: str,
|
||
) -> tuple[list[tuple[str, bool]], list[str], bool]:
|
||
"""Receipt-section lines with visibility, the unfenced H2 order, and
|
||
whether an indented H2 renders after the section opened.
|
||
|
||
The section body is read through `_lines_with_hidden_state` rather than
|
||
`strip_fences` (#610 rounds 1-2, the #637/#609 convergence): a model
|
||
that fences its receipt block still WROTE the receipts, so the fenced
|
||
lines are read as content — no false abort — while a fenced `### AR<n>`
|
||
block sitting beside an unfenced attestation is SEEN and aborts as
|
||
receipts-plus-attestation instead of hiding. Each content line carries
|
||
its hidden flag so a machine line inside an HTML comment — which the
|
||
rendered card does not show — can abort rather than be credited. Only
|
||
an unfenced H2 delimits, agreeing with `split_sections` (a COMMENTED
|
||
heading therefore still delimits, as in `_raw_dissent_span`), so a
|
||
fenced heading can neither open nor close the section. The H2 title is
|
||
matched exactly, case-sensitively, consistent with every other section
|
||
grammar. A 1-3-space-indented `##` line is not a section to this
|
||
grammar but still renders as a heading, so one appearing after the
|
||
receipt section opens is reported for the terminal-section rule.
|
||
"""
|
||
h2_titles: list[str] = []
|
||
section: list[tuple[str, bool, bool]] = []
|
||
inside = False
|
||
indented_h2_after = False
|
||
for line, fenced, hidden, _ in _lines_with_hidden_state(text):
|
||
if not fenced and (match := panel._H2_RE.fullmatch(line)):
|
||
h2_titles.append(match.group(1))
|
||
inside = match.group(1) == _RECEIPT_SECTION
|
||
continue
|
||
if inside:
|
||
if not fenced and not hidden and _INDENTED_H2_RE.match(line):
|
||
indented_h2_after = True
|
||
section.append((line, hidden, fenced))
|
||
return section, h2_titles, indented_h2_after
|
||
|
||
|
||
def check_methodology_receipts(report: panel.ReviewerReport) -> None:
|
||
"""#610 arithmetic-receipt gate, methodology seat only.
|
||
|
||
Auditability, not truth: this gate proves the receipt fields exist, use
|
||
the closed enums, and link mismatches to weaknesses bidirectionally. It
|
||
never attests that the arithmetic is correct — that judgment stays with
|
||
human adjudication (`VERIFIED` / `MISCOMPUTED`), per the spec's invariant
|
||
6 and the receipt block's leading epistemic-status note.
|
||
"""
|
||
view, h2_titles, indented_h2_after = _receipt_section_view(report.text)
|
||
occurrences = h2_titles.count(_RECEIPT_SECTION)
|
||
if occurrences > 1:
|
||
raise ConformanceError(
|
||
f"[RECEIPT-GRAMMAR: {report.path}: duplicate "
|
||
f"## {_RECEIPT_SECTION}]"
|
||
)
|
||
if occurrences == 0:
|
||
raise ConformanceError(
|
||
f"[RECEIPT-MISSING: {report.path}: methodology card requires "
|
||
f"exactly one ## {_RECEIPT_SECTION} section]"
|
||
)
|
||
if h2_titles[-1] != _RECEIPT_SECTION or indented_h2_after:
|
||
raise ConformanceError(
|
||
f"[RECEIPT-GRAMMAR: {report.path}: ## {_RECEIPT_SECTION} must "
|
||
"be the final section of the card]"
|
||
)
|
||
in_subsection = False
|
||
for line, hidden, fenced in view:
|
||
if not fenced and ("<!--" in line or "-->" in line):
|
||
# Comment-free zone (#610 round-3 P1): the receipt section is
|
||
# machine lines, so a paragraph-inline `prose <!--` opener —
|
||
# which the block-position visibility model deliberately does
|
||
# not read — could otherwise launder the receipts below it out
|
||
# of the rendered card. Banning the markup outright is
|
||
# deterministic and costs no legitimate content; fenced lines
|
||
# render the markup literally and stay exempt.
|
||
raise ConformanceError(
|
||
f"[RECEIPT-GRAMMAR: {report.path}: HTML comment markup "
|
||
f"{line.strip()!r} is not allowed in the receipt section]"
|
||
)
|
||
if panel._H3_RE.fullmatch(line):
|
||
if hidden:
|
||
raise ConformanceError(
|
||
f"[RECEIPT-GRAMMAR: {report.path}: receipt heading "
|
||
f"{line.strip()!r} is inside an HTML comment; the "
|
||
"rendered card does not show it]"
|
||
)
|
||
in_subsection = True
|
||
continue
|
||
canonical = bool(
|
||
_RECEIPT_ATTESTATION_RE.fullmatch(line)
|
||
or any(
|
||
pattern.fullmatch(line)
|
||
for pattern in _RECEIPT_FIELD_RES.values()
|
||
)
|
||
)
|
||
if canonical:
|
||
if hidden:
|
||
raise ConformanceError(
|
||
f"[RECEIPT-GRAMMAR: {report.path}: receipt machine "
|
||
f"line {line.strip()!r} is inside an HTML comment; the "
|
||
"rendered card does not show it]"
|
||
)
|
||
if (
|
||
not in_subsection
|
||
and not _RECEIPT_ATTESTATION_RE.fullmatch(line)
|
||
):
|
||
raise ConformanceError(
|
||
f"[RECEIPT-GRAMMAR: {report.path}: receipt machine "
|
||
f"line {line.strip()!r} sits outside every ### AR<n> "
|
||
"subsection; the enum and linkage gates never inspect "
|
||
"it there]"
|
||
)
|
||
continue
|
||
if any(
|
||
_is_receipt_field_shaped(candidate)
|
||
for candidate in _receipt_shape_candidates(line)
|
||
):
|
||
# One neutral message for both visibility states: an opener
|
||
# line like `<!-- c --> key: value` is hidden to the model but
|
||
# partially renders, so claiming "commented-out" would misread
|
||
# the page to the seat (#610 round-3 P3).
|
||
raise ConformanceError(
|
||
f"[RECEIPT-GRAMMAR: {report.path}: receipt machine line "
|
||
f"{line.strip()!r} is decorated or non-canonical; write "
|
||
"plain unbulleted key: value text (tolerated: one "
|
||
"leading list marker, balanced bold around the key)]"
|
||
)
|
||
body = [line for line, _, _ in view]
|
||
attestations = [
|
||
line for line in body if _RECEIPT_ATTESTATION_RE.fullmatch(line)
|
||
]
|
||
subsections, sub_dupes = panel.split_subsections(body)
|
||
if sub_dupes:
|
||
raise ConformanceError(
|
||
f"[RECEIPT-GRAMMAR: {report.path}: duplicate receipt heading(s) "
|
||
f"{sorted(sub_dupes)}]"
|
||
)
|
||
for title in subsections:
|
||
if not _RECEIPT_H3_RE.fullmatch(title):
|
||
raise ConformanceError(
|
||
f"[RECEIPT-GRAMMAR: {report.path}: invalid receipt "
|
||
f"subsection '### {title}'; expected ### AR<n>]"
|
||
)
|
||
if not subsections:
|
||
if len(attestations) != 1:
|
||
raise ConformanceError(
|
||
f"[RECEIPT-GRAMMAR: {report.path}: a receipt section with "
|
||
"no ### AR<n> subsection requires exactly one "
|
||
f"no_recomputable_statistics: line, found {len(attestations)}]"
|
||
)
|
||
# Honest-claim boundary (#610 round-1 fix 2): the gate proved only
|
||
# that the declaration EXISTS. Whether the manuscript really has
|
||
# nothing recomputable is judged at adjudication, so the pass is
|
||
# annotated rather than silent.
|
||
print(_RECEIPT_ATTESTATION_ADVISORY)
|
||
return
|
||
if attestations:
|
||
raise ConformanceError(
|
||
f"[RECEIPT-GRAMMAR: {report.path}: no_recomputable_statistics: "
|
||
"is forbidden when ### AR<n> receipts exist]"
|
||
)
|
||
expected_ids = [f"AR{index}" for index in range(1, len(subsections) + 1)]
|
||
if list(subsections) != expected_ids:
|
||
raise ConformanceError(
|
||
f"[RECEIPT-GRAMMAR: {report.path}: receipt IDs must be dense "
|
||
f"AR1..ARn in order; got={list(subsections)}]"
|
||
)
|
||
finding_ids, backrefs = _review_body_receipt_backrefs(report)
|
||
mismatch_refs: dict[str, str] = {}
|
||
for receipt_id, sublines in subsections.items():
|
||
procedure = _one_receipt_field(
|
||
sublines, "procedure_id", report.path, receipt_id, required=True
|
||
)
|
||
if procedure not in _RECEIPT_PROCEDURES:
|
||
raise ConformanceError(
|
||
f"[RECEIPT-GRAMMAR: {report.path}: {receipt_id} "
|
||
f"procedure_id '{procedure}' is not a bounded procedure]"
|
||
)
|
||
anchor = _one_receipt_field(
|
||
sublines, "evidence_anchor", report.path, receipt_id,
|
||
required=True,
|
||
)
|
||
_validate_anchor(anchor, f"{report.path}:{receipt_id}")
|
||
for key in (
|
||
"reported_inputs", "assumptions", "derivation",
|
||
"comparison_rule",
|
||
):
|
||
_one_receipt_field(
|
||
sublines, key, report.path, receipt_id, required=True
|
||
)
|
||
derived = _one_receipt_field(
|
||
sublines, "derived_value_or_range", report.path, receipt_id,
|
||
required=True,
|
||
)
|
||
status = _one_receipt_field(
|
||
sublines, "status", report.path, receipt_id, required=True
|
||
)
|
||
if status not in _RECEIPT_STATUSES:
|
||
raise ConformanceError(
|
||
f"[RECEIPT-GRAMMAR: {report.path}: {receipt_id} status "
|
||
f"'{status}' is not in the closed status enum]"
|
||
)
|
||
reason = _one_receipt_field(
|
||
sublines, "not_computable_reason", report.path, receipt_id,
|
||
required=(status == "not_computable"),
|
||
)
|
||
if status != "not_computable" and reason is not None:
|
||
raise ConformanceError(
|
||
f"[RECEIPT-GRAMMAR: {report.path}: {receipt_id} "
|
||
"not_computable_reason is forbidden unless status is "
|
||
"not_computable]"
|
||
)
|
||
if reason is not None and reason not in _NOT_COMPUTABLE_REASONS:
|
||
raise ConformanceError(
|
||
f"[RECEIPT-GRAMMAR: {report.path}: {receipt_id} "
|
||
f"not_computable_reason '{reason}' is not in the closed v1 "
|
||
"enum]"
|
||
)
|
||
tail = None
|
||
for key, procedures in _PROCEDURE_FIELD_OWNERS.items():
|
||
if procedure in procedures:
|
||
required = (
|
||
key in _ALWAYS_REQUIRED_PROCEDURE_FIELDS
|
||
or status in _RECEIPT_VERDICT_STATUSES
|
||
)
|
||
value = _one_receipt_field(
|
||
sublines, key, report.path, receipt_id, required=required
|
||
)
|
||
if key == "tail_convention":
|
||
tail = value
|
||
else:
|
||
if _one_receipt_field(
|
||
sublines, key, report.path, receipt_id, required=False
|
||
) is not None:
|
||
raise ConformanceError(
|
||
f"[RECEIPT-GRAMMAR: {report.path}: {receipt_id} "
|
||
f"{key} is forbidden for procedure {procedure}]"
|
||
)
|
||
if procedure == "p_from_test_statistic":
|
||
if tail is not None and tail not in _TAIL_CONVENTIONS:
|
||
raise ConformanceError(
|
||
f"[RECEIPT-GRAMMAR: {report.path}: {receipt_id} "
|
||
f"tail_convention '{tail}' is not in the closed enum]"
|
||
)
|
||
if tail == "unstated" and status in _RECEIPT_VERDICT_STATUSES:
|
||
derived_fold = unicodedata.normalize(
|
||
"NFKC", derived
|
||
).casefold()
|
||
if not all(
|
||
_tail_value_shown(derived_fold, label_re)
|
||
for label_re in _TAIL_LABEL_RES.values()
|
||
):
|
||
raise ConformanceError(
|
||
f"[RECEIPT-TAILS: {report.path}: {receipt_id} "
|
||
"unstated tail requires derived_value_or_range to "
|
||
"show BOTH labeled VALUES — each of the two-tailed "
|
||
"and one-tailed labels sharing its own ;-segment "
|
||
"with its derived number, either order]"
|
||
)
|
||
finding_ref = _one_receipt_field(
|
||
sublines, "finding_ref", report.path, receipt_id,
|
||
required=(status == "mismatch"),
|
||
)
|
||
if status != "mismatch":
|
||
if finding_ref is not None:
|
||
raise ConformanceError(
|
||
f"[RECEIPT-LINKAGE: {report.path}: {receipt_id} "
|
||
"finding_ref is forbidden unless status is mismatch]"
|
||
)
|
||
continue
|
||
if not _FINDING_REF_VALUE_RE.fullmatch(finding_ref):
|
||
raise ConformanceError(
|
||
f"[RECEIPT-LINKAGE: {report.path}: {receipt_id} finding_ref "
|
||
f"'{finding_ref}' must name one W<n> weakness]"
|
||
)
|
||
if finding_ref not in finding_ids:
|
||
raise ConformanceError(
|
||
f"[RECEIPT-LINKAGE: {report.path}: {receipt_id} finding_ref "
|
||
f"{finding_ref} has no matching ### {finding_ref} weakness "
|
||
"in ## Review Body]"
|
||
)
|
||
if finding_ref in mismatch_refs:
|
||
raise ConformanceError(
|
||
f"[RECEIPT-LINKAGE: {report.path}: {finding_ref} is claimed "
|
||
f"by both {mismatch_refs[finding_ref]} and {receipt_id}; "
|
||
"no two receipts share a finding_ref]"
|
||
)
|
||
mismatch_refs[finding_ref] = receipt_id
|
||
for finding_id, receipt_id in mismatch_refs.items():
|
||
if backrefs.get(finding_id) != [receipt_id]:
|
||
raise ConformanceError(
|
||
f"[RECEIPT-LINKAGE: {report.path}: weakness {finding_id} "
|
||
"must carry exactly one **Arithmetic Receipt**: "
|
||
f"{receipt_id} back-reference, found "
|
||
f"{backrefs.get(finding_id, [])}]"
|
||
)
|
||
for location, values in backrefs.items():
|
||
for value in values:
|
||
if mismatch_refs.get(location) != value:
|
||
raise ConformanceError(
|
||
f"[RECEIPT-LINKAGE: {report.path}: back-reference "
|
||
f"{value} in {location} does not correspond to a "
|
||
"mismatch receipt naming that weakness]"
|
||
)
|
||
|
||
|
||
def check_recompute_extraction(path: str, text: str) -> None:
|
||
"""#610 step-5 extraction gate, methodology seat only.
|
||
|
||
The isolated numeric input surface: the response must be exactly one
|
||
``## Recompute Extraction`` section of machine lines. The grammar
|
||
authority is the calculator's own parser (`recompute_receipts`), so the
|
||
gate and the consumer can never disagree about what an extraction says;
|
||
this gate adds the response-level structure rules and the anchor-grammar
|
||
check the calculator deliberately does not own.
|
||
"""
|
||
lines = panel.strip_fences(text)
|
||
sections, dupes = panel.split_sections(lines)
|
||
if recompute.EXTRACTION_SECTION in dupes:
|
||
raise ConformanceError(
|
||
f"[EXTRACTION-GRAMMAR: {path}: duplicate "
|
||
f"## {recompute.EXTRACTION_SECTION}]"
|
||
)
|
||
if recompute.EXTRACTION_SECTION not in sections:
|
||
raise ConformanceError(
|
||
f"[EXTRACTION-GRAMMAR: {path}: exactly one "
|
||
f"## {recompute.EXTRACTION_SECTION} section is required]"
|
||
)
|
||
extra = [
|
||
title for title in list(sections) + list(dupes)
|
||
if title != recompute.EXTRACTION_SECTION
|
||
]
|
||
if extra:
|
||
raise ConformanceError(
|
||
f"[EXTRACTION-GRAMMAR: {path}: the response may carry no "
|
||
f"section other than ## {recompute.EXTRACTION_SECTION}; "
|
||
f"found {extra}]"
|
||
)
|
||
preamble = []
|
||
for line in lines:
|
||
if panel._H2_RE.fullmatch(line):
|
||
break
|
||
preamble.append(line)
|
||
if any(line.strip() for line in preamble):
|
||
raise ConformanceError(
|
||
f"[EXTRACTION-GRAMMAR: {path}: no content is allowed before "
|
||
f"## {recompute.EXTRACTION_SECTION}]"
|
||
)
|
||
try:
|
||
extraction = recompute.parse_extraction(text)
|
||
except recompute.ExtractionError as exc:
|
||
raise ConformanceError(
|
||
f"[EXTRACTION-GRAMMAR: {path}: {exc}]"
|
||
) from exc
|
||
for request in extraction.requests:
|
||
_validate_anchor(
|
||
request["evidence_anchor"], f"{path}:{request.rr_id}"
|
||
)
|
||
if extraction.attestation is not None:
|
||
# Same declaration-only honesty boundary as the receipt attestation.
|
||
print(_RECEIPT_ATTESTATION_ADVISORY)
|
||
|
||
|
||
def check_injected_receipts(
|
||
report: panel.ReviewerReport, injected_text: str
|
||
) -> None:
|
||
"""#610 step-5 identity gate: the card's receipt section must be the
|
||
dispatcher-injected receipts verbatim, plus only the canonical
|
||
``finding_ref:`` lines the mismatch receipts require.
|
||
|
||
Runs AFTER `check_methodology_receipts`, so grammar, enum, linkage, and
|
||
per-subsection `finding_ref` placement are already proven; this gate
|
||
proves nothing else changed. The comparison ignores blank lines and
|
||
reads the card fence-transparently — the same view the receipt gate
|
||
uses — so a decorated or re-spelled injected line fails identity loudly
|
||
rather than being silently re-read.
|
||
"""
|
||
injected_lines = [
|
||
line.rstrip("\r") for line in injected_text.split("\n")
|
||
]
|
||
heading = f"## {recompute.RECEIPT_SECTION}"
|
||
if not injected_lines or injected_lines[0] != heading:
|
||
raise panel.ContractError(
|
||
f"[INJECTED-RECEIPTS-INVALID: the injected file must begin "
|
||
f"with {heading!r}]"
|
||
)
|
||
expected = [line for line in injected_lines[1:] if line.strip()]
|
||
view, _, _ = _receipt_section_view(report.text)
|
||
# Only the PLAIN spelling is the permitted addition (codex round 1,
|
||
# P2-4): the receipt grammar tolerates a decorated finding_ref, but
|
||
# under injection "add exactly one finding_ref: line" means the
|
||
# undecorated canonical form — a bolded or bulleted spelling stays in
|
||
# the comparison and fails identity loudly.
|
||
plain_finding_ref = re.compile(r"^finding_ref: W[1-9]\d*$")
|
||
actual = [
|
||
line for line, _, _ in view
|
||
if line.strip() and not plain_finding_ref.fullmatch(line)
|
||
]
|
||
if actual != expected:
|
||
divergence = next(
|
||
(
|
||
f"card={card!r} vs injected={wanted!r}"
|
||
for card, wanted in zip(actual, expected)
|
||
if card != wanted
|
||
),
|
||
f"card has {len(actual)} content lines, injected has "
|
||
f"{len(expected)}",
|
||
)
|
||
raise ConformanceError(
|
||
f"[RECEIPT-IDENTITY: {report.path}: the ## "
|
||
f"{recompute.RECEIPT_SECTION} section must reproduce the "
|
||
"dispatcher-computed receipts verbatim, adding only "
|
||
f"finding_ref: lines on mismatch receipts; first divergence: "
|
||
f"{divergence}]"
|
||
)
|
||
|
||
|
||
def check_receipt_section_forbidden(report: panel.ReviewerReport) -> None:
|
||
# Declared boundary: this guard sees what `split_sections` sees, so a
|
||
# fenced or indented `## Arithmetic Receipts` on a non-methodology seat
|
||
# is an inert displayed block, not a section — it earns the seat
|
||
# nothing (no consumer reads it) and is deliberately not chased here.
|
||
lines = panel.strip_fences(report.text)
|
||
sections, dupes = panel.split_sections(lines)
|
||
if _RECEIPT_SECTION in sections or _RECEIPT_SECTION in dupes:
|
||
raise ConformanceError(
|
||
f"[RECEIPT-SECTION-FORBIDDEN: {report.path}: role "
|
||
f"{report.role} may not emit ## {_RECEIPT_SECTION}; the #610 "
|
||
"receipt gate is methodology-only]"
|
||
)
|
||
|
||
|
||
def _parse_args(argv):
|
||
parser = argparse.ArgumentParser(description=__doc__)
|
||
parser.add_argument("--contract", required=True, type=Path)
|
||
parser.add_argument("--role", required=True)
|
||
parser.add_argument("--phase1", required=True, type=Path)
|
||
# The retry decision is taken while Phase 2 has not been requested yet, so
|
||
# the gate has to be answerable on Phase 1 alone. Mirrors the
|
||
# --synthesis / --layer1-only split in check_panel_synthesis.py.
|
||
stage = parser.add_mutually_exclusive_group(required=True)
|
||
stage.add_argument("--phase2", type=Path)
|
||
stage.add_argument("--phase1-only", action="store_true")
|
||
# #610 step 5: the methodology extraction call is its own gated stage,
|
||
# answerable between Phase 1 and Phase 2 like --phase1-only is before
|
||
# Phase 2.
|
||
stage.add_argument("--extraction", type=Path)
|
||
parser.add_argument("--injected-receipts", type=Path)
|
||
parser.add_argument("--manuscript", required=True, type=Path)
|
||
parser.add_argument("--metadata", required=True, type=Path)
|
||
args = parser.parse_args(argv)
|
||
if args.injected_receipts is not None and args.phase2 is None:
|
||
parser.error("--injected-receipts requires --phase2")
|
||
return args
|
||
|
||
|
||
def main(argv=None) -> int:
|
||
args = _parse_args(argv)
|
||
try:
|
||
contract, _ = panel.load_contract(args.contract)
|
||
if args.role not in panel.ROLE_SETS[contract["mode"]]:
|
||
raise panel.ContractError(
|
||
f"[ROLE-BINDING: --role {args.role} is invalid for "
|
||
f"{contract['mode']}]"
|
||
)
|
||
phase1_text = panel._read_text(args.phase1)
|
||
phase2_text = (
|
||
panel._read_text(args.phase2) if args.phase2 is not None
|
||
else None
|
||
)
|
||
manuscript_text = panel._read_text(args.manuscript)
|
||
try:
|
||
metadata = json.loads(panel._read_text(args.metadata))
|
||
except json.JSONDecodeError as exc:
|
||
raise panel.ContractError(
|
||
f"[METADATA-INVALID: {args.metadata}: {exc}]"
|
||
) from exc
|
||
if args.phase1_only:
|
||
# Blindness FIRST, before structural parsing: a response both
|
||
# malformed and leaking would otherwise report only the grammar
|
||
# failure, and the dispatcher would grant the retry a proven
|
||
# leak must never receive. It is the half a retry must not be
|
||
# granted in spite of.
|
||
check_manuscript_leakage(
|
||
phase1_text, manuscript_text, metadata, contract
|
||
)
|
||
plan = parse_phase1(str(args.phase1), phase1_text, contract, args.role)
|
||
for warning in plan.warnings:
|
||
print(warning)
|
||
if args.phase1_only:
|
||
print("PHASE1-CONFORMANCE: PASS")
|
||
return EXIT_PASS
|
||
if args.extraction is not None:
|
||
# #610 step 5. The extraction call is methodology-only by
|
||
# design: no other seat has an extraction stage to gate.
|
||
if args.role != "methodology":
|
||
raise panel.ContractError(
|
||
f"[ROLE-BINDING: --extraction is methodology-only, "
|
||
f"dispatched as {args.role}]"
|
||
)
|
||
check_manuscript_leakage(
|
||
phase1_text, manuscript_text, metadata, contract
|
||
)
|
||
check_recompute_extraction(
|
||
str(args.extraction), panel._read_text(args.extraction)
|
||
)
|
||
print("EXTRACTION-CONFORMANCE: PASS")
|
||
return EXIT_PASS
|
||
report = panel.parse_report(
|
||
str(args.phase2), phase2_text, contract
|
||
)
|
||
if report.role != args.role:
|
||
raise ConformanceError(
|
||
f"[ROLE-BINDING: report declares {report.role}, dispatched "
|
||
f"as {args.role}]"
|
||
)
|
||
check_manuscript_leakage(
|
||
phase1_text, manuscript_text, metadata, contract
|
||
)
|
||
dissent = parse_dissent_dimensions(phase2_text)
|
||
for diagnostic in dissent.diagnostics:
|
||
print(diagnostic)
|
||
dimensions = {
|
||
dim["id"]: dim for dim in contract["acceptance_dimensions"]
|
||
}
|
||
check_trigger_binding(report, plan, dimensions, dissent.dimensions)
|
||
if report.role == "da":
|
||
check_da_anchors(report)
|
||
else:
|
||
check_scoring_seat_anchors(report)
|
||
if report.role == "methodology":
|
||
check_methodology_receipts(report)
|
||
if args.injected_receipts is not None:
|
||
check_injected_receipts(
|
||
report, panel._read_text(args.injected_receipts)
|
||
)
|
||
# A distinct witness line (security round 1, P2-5): the
|
||
# evidence contract names this gate as contract content, so
|
||
# a Phase 2 gated WITHOUT it must be distinguishable in the
|
||
# preserved gate log.
|
||
print("RECEIPT-IDENTITY: PASS")
|
||
else:
|
||
if args.injected_receipts is not None:
|
||
raise panel.ContractError(
|
||
f"[ROLE-BINDING: --injected-receipts is "
|
||
f"methodology-only, dispatched as {args.role}]"
|
||
)
|
||
check_receipt_section_forbidden(report)
|
||
except panel.ContractError as exc:
|
||
print(exc)
|
||
return EXIT_CONTRACT
|
||
except (panel.ReportError, ConformanceError) as exc:
|
||
print(exc)
|
||
return EXIT_CONFORMANCE
|
||
print("PHASE-CONFORMANCE: PASS")
|
||
return EXIT_PASS
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|