core/teaching/audit.py
Shay 3cad6686cc feat(adr-0057): operator supersession history view — closes the supersede loop
`core teaching supersessions` (+ `--json`) pairs each retired chain with its
active replacement.  Derived view over `audit_corpus()`; pure, read-only.

- teaching/audit.py — `SupersessionRecord` + `supersession_history(report)`
  returns retired→replacement pairs ordered by retired-line (disk order,
  oldest first).  Orphan supersessions (retired with no live entry carrying
  the matching `superseded_by` — e.g. chained retirements where the middle
  link itself was retired) surface as `replacement=None` so silent corpus
  drift is inspectable.
- core/cli.py — `core teaching supersessions [--json]`.  Exit 1 if any
  orphan is detected (catches silent drift in CI); 0 otherwise.
- tests/test_supersession_history.py — 7 tests pin empty-history,
  single-pair shape, chained-supersession surfaces both pairs, line-no
  ordering, orphan detection, JSON round-trip, no corpus mutation.

Lane state: smoke 67 / cognition 121 / supersession-history 7 new / supersede 13 /
audit 23 — green.  `core eval cognition`: unchanged (intent 100% / surface 100% /
term 91.7% / versor 100%).  Real corpus today reports `(no supersessions)`.
2026-05-18 10:40:38 -07:00

313 lines
11 KiB
Python

"""ADR-0055 Phase A — corpus audit for the reviewed teaching chains.
Re-parses ``teaching/cognition_chains/cognition_chains_v1.jsonl`` with
the same gates as ``chat.teaching_grounding._corpus_index`` (schema,
intent whitelist, pack-consistency, supersession), but **keeps the
drop reasons** so an operator can inspect silent corpus shrinkage —
e.g. a pack swap that left a chain referencing a missing lemma, or
a supersession chain that retired older entries.
Pure-function, non-mutating, deterministic. Output is order-stable
(input line order) so it is safe to wire into CI and diff against
prior runs.
This module is **read-only**. It must never write to the corpus,
to the pack, or to any runtime state. Trust-boundary: the
``raw_line`` field surfaces the verbatim JSONL line; callers that
log this should route through ``core._safe_display.safe_display``.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from chat.pack_grounding import _pack_index
from chat.teaching_grounding import (
_CORPUS_PATH,
_VALID_INTENTS,
TEACHING_CORPUS_ID,
)
from teaching.provenance import Provenance, parse_provenance
@dataclass(frozen=True, slots=True)
class DroppedChain:
"""One JSONL line that did not enter the active corpus."""
line_no: int
reason: str
raw_line: str
chain_id: str | None = None
@dataclass(frozen=True, slots=True)
class LoadedChain:
"""One JSONL line that entered the active corpus."""
line_no: int
chain_id: str
subject: str
intent: str
connective: str
object: str
provenance: Provenance
superseded_by: str | None
@dataclass(frozen=True, slots=True)
class AuditReport:
corpus_id: str
corpus_path: str
lines_on_disk: int
lines_loaded: int
loaded: tuple[LoadedChain, ...] = field(default_factory=tuple)
dropped: tuple[DroppedChain, ...] = field(default_factory=tuple)
def as_dict(self) -> dict[str, Any]:
return {
"corpus_id": self.corpus_id,
"corpus_path": self.corpus_path,
"lines_on_disk": self.lines_on_disk,
"lines_loaded": self.lines_loaded,
"loaded": [
{
"line_no": c.line_no,
"chain_id": c.chain_id,
"subject": c.subject,
"intent": c.intent,
"connective": c.connective,
"object": c.object,
"provenance": {
"adr_id": c.provenance.adr_id,
"source": c.provenance.source,
"review_date": c.provenance.review_date,
"raw": c.provenance.raw,
},
"superseded_by": c.superseded_by,
}
for c in self.loaded
],
"dropped": [
{
"line_no": d.line_no,
"reason": d.reason,
"chain_id": d.chain_id,
"raw_line": d.raw_line,
}
for d in self.dropped
],
}
def _read_entries(path: Path) -> list[tuple[int, str, dict | None]]:
"""Read JSONL lines as ``(line_no, raw, parsed_or_none)`` tuples.
Line numbers are 1-based to match editor conventions. Blank lines
are skipped silently (they were never on the disk-loaded count
either — they are not chains).
"""
out: list[tuple[int, str, dict | None]] = []
if not path.exists():
return out
for idx, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
line = raw.strip()
if not line:
continue
try:
entry = json.loads(line)
except json.JSONDecodeError:
out.append((idx, line, None))
continue
out.append((idx, line, entry))
return out
def audit_corpus(path: Path | None = None) -> AuditReport:
"""Re-parse the teaching corpus and surface load decisions.
Returns an ``AuditReport`` with one entry per non-empty line on
disk, classified as loaded or dropped with a deterministic reason
string. Pack consistency is checked against the same pack the
runtime loads.
Reasons (stable strings, safe to assert against in tests):
- ``"invalid_json"``
- ``"missing_required_field:<field>"``
- ``"unsupported_intent:<intent>"``
- ``"pack_missing_subject:<lemma>"``
- ``"pack_missing_object:<lemma>"``
- ``"superseded_by:<chain_id>"``
"""
corpus_path = path or _CORPUS_PATH
entries = _read_entries(corpus_path)
pack = _pack_index()
# First sweep — collect supersession claims from entries that
# otherwise look well-formed enough to assert one. An invalid
# entry cannot supersede anything; this is intentional so a bad
# line cannot retire a good one.
superseded_ids: set[str] = set()
for _line_no, _raw, entry in entries:
if not isinstance(entry, dict):
continue
sup = entry.get("superseded_by")
if isinstance(sup, str) and sup.strip():
# The current entry claims to supersede ``sup``.
superseded_ids.add(sup.strip())
loaded: list[LoadedChain] = []
dropped: list[DroppedChain] = []
for line_no, raw, entry in entries:
if entry is None:
dropped.append(DroppedChain(line_no=line_no, reason="invalid_json", raw_line=raw))
continue
chain_id_raw = entry.get("chain_id")
chain_id_for_audit = str(chain_id_raw) if isinstance(chain_id_raw, str) else None
subject = (entry.get("subject") or "").strip().lower() if isinstance(entry.get("subject"), str) else ""
intent = (entry.get("intent") or "").strip().lower() if isinstance(entry.get("intent"), str) else ""
obj = (entry.get("object") or "").strip().lower() if isinstance(entry.get("object"), str) else ""
connective = (entry.get("connective") or "").strip() if isinstance(entry.get("connective"), str) else ""
if not subject:
dropped.append(DroppedChain(
line_no=line_no, reason="missing_required_field:subject",
raw_line=raw, chain_id=chain_id_for_audit,
))
continue
if not intent:
dropped.append(DroppedChain(
line_no=line_no, reason="missing_required_field:intent",
raw_line=raw, chain_id=chain_id_for_audit,
))
continue
if not obj:
dropped.append(DroppedChain(
line_no=line_no, reason="missing_required_field:object",
raw_line=raw, chain_id=chain_id_for_audit,
))
continue
if not connective:
dropped.append(DroppedChain(
line_no=line_no, reason="missing_required_field:connective",
raw_line=raw, chain_id=chain_id_for_audit,
))
continue
if intent not in _VALID_INTENTS:
dropped.append(DroppedChain(
line_no=line_no, reason=f"unsupported_intent:{intent}",
raw_line=raw, chain_id=chain_id_for_audit,
))
continue
if subject not in pack:
dropped.append(DroppedChain(
line_no=line_no, reason=f"pack_missing_subject:{subject}",
raw_line=raw, chain_id=chain_id_for_audit,
))
continue
if obj not in pack:
dropped.append(DroppedChain(
line_no=line_no, reason=f"pack_missing_object:{obj}",
raw_line=raw, chain_id=chain_id_for_audit,
))
continue
chain_id = chain_id_for_audit or f"{subject}_{intent}"
if chain_id in superseded_ids:
dropped.append(DroppedChain(
line_no=line_no, reason=f"superseded_by:{chain_id}",
raw_line=raw, chain_id=chain_id,
))
continue
sup_raw = entry.get("superseded_by")
sup = sup_raw.strip() if isinstance(sup_raw, str) and sup_raw.strip() else None
loaded.append(LoadedChain(
line_no=line_no,
chain_id=chain_id,
subject=subject,
intent=intent,
connective=connective,
object=obj,
provenance=parse_provenance(entry.get("provenance")),
superseded_by=sup,
))
return AuditReport(
corpus_id=TEACHING_CORPUS_ID,
corpus_path=str(corpus_path),
lines_on_disk=len(entries),
lines_loaded=len(loaded),
loaded=tuple(loaded),
dropped=tuple(dropped),
)
@dataclass(frozen=True, slots=True)
class SupersessionRecord:
"""One retired→replacement pair derived from an ``AuditReport``.
``replacement`` is ``None`` when the corpus claims a chain was
superseded but no currently-active entry carries the matching
``superseded_by`` field — i.e. an orphan supersession. This
surfaces silent corpus drift to the operator.
"""
retired_chain_id: str
retired_line_no: int
replacement: LoadedChain | None
def as_dict(self) -> dict[str, Any]:
return {
"retired_chain_id": self.retired_chain_id,
"retired_line_no": self.retired_line_no,
"replacement": (
{
"chain_id": self.replacement.chain_id,
"line_no": self.replacement.line_no,
"subject": self.replacement.subject,
"intent": self.replacement.intent,
"connective": self.replacement.connective,
"object": self.replacement.object,
"provenance": {
"adr_id": self.replacement.provenance.adr_id,
"source": self.replacement.provenance.source,
"review_date": self.replacement.provenance.review_date,
"raw": self.replacement.provenance.raw,
},
}
if self.replacement is not None
else None
),
}
def supersession_history(report: AuditReport) -> tuple[SupersessionRecord, ...]:
"""Derive retired→replacement pairs from an audit report.
Pure function of the report; deterministic, ordered by the
retired entry's line number (disk order, oldest first).
"""
by_supersedes: dict[str, LoadedChain] = {
entry.superseded_by: entry
for entry in report.loaded
if entry.superseded_by
}
records: list[SupersessionRecord] = []
for dropped in report.dropped:
if not dropped.reason.startswith("superseded_by:"):
continue
retired_id = dropped.chain_id or dropped.reason.split(":", 1)[1]
records.append(SupersessionRecord(
retired_chain_id=retired_id,
retired_line_no=dropped.line_no,
replacement=by_supersedes.get(retired_id),
))
records.sort(key=lambda r: r.retired_line_no)
return tuple(records)