Files
imbad0202__academic-researc…/scripts/check_268_nested_commitment_ledger.py
Edward Cheng-I Wu 68c8594c26 feat(#268): Schema 11 Commitment Ledger nested-object refactor (#290)
Refactor the Kong A1 Commitment Ledger from three index-aligned parallel
lists (commitment_extracted objects + top-level fulfillment_status[] +
unfulfilled_rationale[]) to a nested-object shape: fulfillment_status and
unfulfilled_rationale now nest inside each commitment_extracted object.
Length mismatch / index desync becomes structurally impossible, closing the
fragility where a dropped Markdown <br> or numbering error silently mispaired
a status with the wrong commitment and produced a false COMMITMENT_GAP.

REPLACE, not coexist: the parallel-list shape is removed entirely. No
executable consumer, lint, or fixture carried it (the calibration harness is
unshipped; the seed is a non-runnable seed), so coexistence would only
preserve the failure mode. Lifecycle fields are absent at extraction time and
appended per-object during revision execution; the old "" placeholder for
fulfilled commitments is dropped (omitted, not empty-string). The equal-length
invariant is retired (now structurally impossible); a legacy-normalization
note instructs verifying equal length before zipping any pre-refactor
top-level arrays onto the nested objects (refusing to auto-zip a desynced
ledger).

Synced across all six ledger surfaces: handoff_schemas.md Schema 11 (incl.
the residual_action coherence prose, reworded from index notation to
object-field notation), revision_coach_agent Step 3.5, re_review_mode_protocol
Commitment Ledger Verification, revision_tracking_template.md (three fragile
<br>-separated columns collapsed into one per-commitment nested YAML ledger),
worked example, and the 12-case calibration seed. author_fulfillment_claim
deferred (not required for the structural fix).

New scripts/check_268_nested_commitment_ledger.py (N1-N5 + N3b) + 18 mutation
tests, wired into spec-consistency.yml + the pytest manifest. Advisory
semantics unchanged.

Spec: docs/design/2026-05-31-ars-268-schema11-nested-commitment-ledger-spec.md
Closes #268.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 00:27:56 +08:00

181 lines
7.9 KiB
Python

#!/usr/bin/env python3
"""#268 nested-object Commitment Ledger lint.
Enforces the structural invariants of the Schema 11 Commitment Ledger after the
parallel-list -> nested-object refactor (#268). Runs alongside the existing Kong
A1 / #269 surfaces; no JSON-schema exists for Schema 11, so this lint operates on
the calibration seed YAML plus a cascade-completeness scan of the two prose
surfaces that previously carried index notation.
Invariants (spec docs/design/2026-05-31-ars-268-schema11-nested-commitment-ledger-spec.md §6):
N1 every expected_commitments[] entry in the seed is a mapping carrying
commitment_text + commitment_type + required_evidence_type.
N2 the seed carries NO top-level expected_fulfillment_status /
expected_unfulfilled_rationale parallel lists on any case (regression guard
against the retired A1 parallel-list shape).
N3 for each expected_commitments entry: fulfillment_status (if present) is in
the enum; unfulfilled_rationale, WHEN PRESENT, must be non-empty on a
non-fulfilled status and absent on a fulfilled one (no "" placeholder). A
non-fulfilled commitment MAY omit unfulfilled_rationale entirely — that is
the valid COMMITMENT_GAP case (e.g. seed N1/N2), not a violation.
N3b the case-level expected_commitment_gap oracle agrees with the per-commitment
shape: gap fires iff some commitment is non-fulfilled with a blank/absent
rationale.
N4 the Schema 11 prose (shared/handoff_schemas.md) contains no surviving
`fulfillment_status[i]` / `unfulfilled_rationale[i]` index notation.
N5 re_review_mode_protocol Commitment Ledger Verification contains no surviving
index notation either.
Exit 0 on success, 1 on any violation (CI gate).
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
import yaml
REPO = Path(__file__).resolve().parent.parent
SEED = REPO / "evals/calibration/commitment_ledger_seed.yaml"
SCHEMA = REPO / "shared/handoff_schemas.md"
RE_REVIEW = REPO / "academic-paper-reviewer/references/re_review_mode_protocol.md"
EXTRACTION_FIELDS = ("commitment_text", "commitment_type", "required_evidence_type")
STATUS_ENUM = {"fulfilled", "partial", "not-fulfilled", "explicitly-rejected-with-rationale"}
NONFULFILLED = STATUS_ENUM - {"fulfilled"} # derived so the two stay in sync
# Index-notation regex: a retired parallel-list field name immediately followed by
# a subscript like [i] or [0]. Catches parallel-list-era prose the refactor must
# remove. Tombstone check for the two fields #268 retired — add any further field
# names here whenever a later schema refactor retires an index-notation field.
INDEX_NOTATION = re.compile(r"\b(?:fulfillment_status|unfulfilled_rationale)\s*\[\s*\w+\s*\]")
def _blank_rationale(com: dict) -> bool:
"""True when a commitment's unfulfilled_rationale is missing, null, or whitespace.
`com.get(key, "")` returns None (not "") when the key is present with a YAML-null
value, and `str(None)` is the truthy "None" — so a bare `unfulfilled_rationale:`
would falsely read as populated. Treat missing / None / blank uniformly here.
"""
val = com.get("unfulfilled_rationale")
return val is None or not str(val).strip()
def check_seed(seed: dict) -> list[str]:
"""N1 + N2 + N3 + N3b against a parsed seed mapping."""
errors: list[str] = []
cases = seed.get("cases", [])
if not cases:
errors.append("seed carries no cases")
return errors
for case in cases:
cid = case.get("case_id", "<no-id>")
# N2: no retired parallel-list keys on the case.
for legacy in ("expected_fulfillment_status", "expected_unfulfilled_rationale"):
if legacy in case:
errors.append(f"N2 {cid}: retired parallel-list key `{legacy}` present")
commitments = case.get("expected_commitments", [])
for idx, com in enumerate(commitments):
where = f"{cid}[{idx}]"
if not isinstance(com, dict):
errors.append(f"N1 {where}: commitment entry is not a mapping")
continue
# N1: extraction fields present.
for field in EXTRACTION_FIELDS:
if field not in com:
errors.append(f"N1 {where}: missing extraction field `{field}`")
# N3: lifecycle coherence.
status = com.get("fulfillment_status")
has_rationale = "unfulfilled_rationale" in com
if status is not None:
if status not in STATUS_ENUM:
errors.append(f"N3 {where}: fulfillment_status `{status}` not in enum")
if status == "fulfilled" and has_rationale:
errors.append(
f"N3 {where}: fulfilled commitment carries unfulfilled_rationale "
"(omit it — no \"\" placeholder in nested shape)"
)
if status in NONFULFILLED and has_rationale and _blank_rationale(com):
errors.append(
f"N3 {where}: non-fulfilled status `{status}` carries empty "
"unfulfilled_rationale (must be non-empty, or omit the key to trigger gap)"
)
elif has_rationale:
errors.append(
f"N3 {where}: unfulfilled_rationale present without fulfillment_status"
)
# N3b: the case-level expected_commitment_gap oracle must agree with the
# per-commitment shape. COMMITMENT_GAP fires iff some commitment is
# non-fulfilled with a missing/empty rationale (re_review_mode_protocol).
# Validate the oracle so a future seed can't carry an incoherent flag.
if "expected_commitment_gap" in case:
expected_gap = case["expected_commitment_gap"]
if not isinstance(expected_gap, bool):
# A quoted "false" / "true" would coerce truthy under bool() and
# silently pass the oracle check — require a real YAML boolean.
errors.append(
f"N3b {cid}: expected_commitment_gap must be a boolean, got "
f"{type(expected_gap).__name__} ({expected_gap!r})"
)
actual_gap = any(
isinstance(com, dict)
and com.get("fulfillment_status") in NONFULFILLED
and _blank_rationale(com)
for com in commitments
)
if isinstance(expected_gap, bool) and actual_gap != expected_gap:
errors.append(
f"N3b {cid}: expected_commitment_gap={expected_gap} "
f"contradicts per-commitment shape (actual gap={actual_gap})"
)
return errors
def check_index_notation(label: str, text: str) -> list[str]:
"""N4 / N5: no surviving index-notation in the prose surface."""
errors: list[str] = []
for m in INDEX_NOTATION.finditer(text):
line_no = text.count("\n", 0, m.start()) + 1
errors.append(f"{label}:{line_no}: surviving index notation `{m.group(0)}`")
return errors
def main() -> int:
errors: list[str] = []
if not SEED.exists():
errors.append(f"missing seed file: {SEED}")
else:
errors += check_seed(yaml.safe_load(SEED.read_text(encoding="utf-8")))
if not SCHEMA.exists():
errors.append(f"missing schema file: {SCHEMA}")
else:
errors += check_index_notation("N4 shared/handoff_schemas.md", SCHEMA.read_text(encoding="utf-8"))
if not RE_REVIEW.exists():
errors.append(f"missing re-review protocol: {RE_REVIEW}")
else:
errors += check_index_notation(
"N5 re_review_mode_protocol.md", RE_REVIEW.read_text(encoding="utf-8")
)
if errors:
print("#268 nested-commitment-ledger lint FAILED:")
for e in errors:
print(f" - {e}")
return 1
print("#268 nested-commitment-ledger lint OK (N1-N5)")
return 0
if __name__ == "__main__":
sys.exit(main())