mirror of
https://github.com/Imbad0202/academic-research-skills.git
synced 2026-09-14 13:51:17 +08:00
68c8594c26
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>
181 lines
7.9 KiB
Python
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())
|