feat(adr-0055): Phase A — teaching corpus audit, supersession, typed provenance
Lands the three load-bearing pieces of ADR-0055 Phase A so later phases (DiscoveryCandidate, TeachingChainProposal) have a safe substrate to write into. - teaching/audit.py: pure, deterministic re-parse of the reviewed corpus with same gates as the runtime loader but keeps drop reasons (invalid_json, missing_required_field:*, unsupported_intent, pack_missing_subject, pack_missing_object, superseded_by:*). - teaching/provenance.py: typed Provenance(adr_id, source, review_date, raw); legacy "reviewed" maps to "hand_authored" so current corpus reports the canonical enum without a file rewrite. - chat/teaching_grounding._corpus_index honors superseded_by — active view drops superseded entries while disk preserves history. - core teaching audit CLI subcommand (--json optional); exits 1 on any drop so CI catches silent corpus shrinkage from pack swaps. Observable behaviour unchanged: corpus is 10/10 loaded, all five core lanes green (smoke 67, cognition 121, runtime 19, teaching 17, packs 6), cognition eval metrics identical on dev / public / holdout splits. versor_condition < 1e-6 invariant untouched. Tests: tests/test_teaching_audit.py — 23 tests covering provenance parser, real-corpus determinism, every drop-reason path, supersession semantics, runtime/audit parity, read-only contract.
This commit is contained in:
parent
0f797e2940
commit
7aa77806f9
5 changed files with 681 additions and 3 deletions
|
|
@ -99,11 +99,20 @@ def _corpus_index() -> dict[tuple[str, str], TeachingChain]:
|
|||
is reviewed memory but the runtime still verifies pack consistency
|
||||
on load so a pack-corpus skew cannot leak a non-pack atom into a
|
||||
surface.
|
||||
|
||||
ADR-0055 Phase A: an entry whose ``chain_id`` appears as another
|
||||
entry's ``superseded_by`` is also dropped from the active view.
|
||||
Append-only history on disk is preserved; the loader derives the
|
||||
active set.
|
||||
"""
|
||||
if not _CORPUS_PATH.exists():
|
||||
return {}
|
||||
pack = _pack_index()
|
||||
out: dict[tuple[str, str], TeachingChain] = {}
|
||||
# First sweep: collect supersession claims. Only well-formed
|
||||
# entries (parseable JSON object) can retire other entries — a
|
||||
# malformed line cannot supersede a good one.
|
||||
superseded_ids: set[str] = set()
|
||||
parsed_lines: list[dict] = []
|
||||
for line in _CORPUS_PATH.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
|
|
@ -112,6 +121,15 @@ def _corpus_index() -> dict[tuple[str, str], TeachingChain]:
|
|||
entry = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
parsed_lines.append(entry)
|
||||
sup = entry.get("superseded_by")
|
||||
if isinstance(sup, str) and sup.strip():
|
||||
superseded_ids.add(sup.strip())
|
||||
|
||||
out: dict[tuple[str, str], TeachingChain] = {}
|
||||
for entry in parsed_lines:
|
||||
subject = (entry.get("subject") or "").strip().lower()
|
||||
intent = (entry.get("intent") or "").strip().lower()
|
||||
obj = (entry.get("object") or "").strip().lower()
|
||||
|
|
@ -124,9 +142,12 @@ def _corpus_index() -> dict[tuple[str, str], TeachingChain]:
|
|||
# surface atom is pack-sourced.
|
||||
if subject not in pack or obj not in pack:
|
||||
continue
|
||||
chain_id = str(entry.get("chain_id") or f"{subject}_{intent}")
|
||||
if chain_id in superseded_ids:
|
||||
continue
|
||||
try:
|
||||
chain = TeachingChain(
|
||||
chain_id=str(entry.get("chain_id") or f"{subject}_{intent}"),
|
||||
chain_id=chain_id,
|
||||
subject=subject,
|
||||
intent=intent,
|
||||
connective=connective,
|
||||
|
|
|
|||
45
core/cli.py
45
core/cli.py
|
|
@ -23,7 +23,7 @@ _CORE_RS_DIR = _REPO_ROOT / "core-rs"
|
|||
_CORE_RS_MANIFEST = _CORE_RS_DIR / "Cargo.toml"
|
||||
|
||||
DESCRIPTION = "CORE versor engine command suite."
|
||||
EPILOG = "Examples:\n core chat\n core pulse \"What is truth?\"\n core pulse --no-glove --json \"Compare knowledge and wisdom\"\n core bench\n core bench --suite determinism --runs 50\n core bench --suite speedup --json\n core trace \"word beginning truth\"\n core trace --output-language grc --frame-pack grc --json \"logos\"\n core rust status\n core rust build\n core oov covenant\n core pack list\n core pack verify en_minimal_v1\n core test --suite fast -q\n core test --suite pulse -q\n core test --suite proof -q\n core test --suite cognition -q\n core test -- tests/test_alignment_graph.py -q\n core demo audit-tour\n core demo pack-measurements\n core demo long-context-comparison\n core eval --list\n core eval cognition\n core eval cognition --json --save\n core eval cognition --split dev --version v1\n core eval cognition --split holdout"
|
||||
EPILOG = "Examples:\n core chat\n core pulse \"What is truth?\"\n core pulse --no-glove --json \"Compare knowledge and wisdom\"\n core bench\n core bench --suite determinism --runs 50\n core bench --suite speedup --json\n core trace \"word beginning truth\"\n core trace --output-language grc --frame-pack grc --json \"logos\"\n core rust status\n core rust build\n core oov covenant\n core pack list\n core pack verify en_minimal_v1\n core teaching audit\n core teaching audit --json\n core test --suite fast -q\n core test --suite pulse -q\n core test --suite proof -q\n core test --suite cognition -q\n core test -- tests/test_alignment_graph.py -q\n core demo audit-tour\n core demo pack-measurements\n core demo long-context-comparison\n core eval --list\n core eval cognition\n core eval cognition --json --save\n core eval cognition --split dev --version v1\n core eval cognition --split holdout"
|
||||
|
||||
_TEST_SUITES: dict[str, tuple[str, ...]] = {
|
||||
"fast": (
|
||||
|
|
@ -465,6 +465,32 @@ def _safe_pack_id(pack_id: str) -> str:
|
|||
return pack_id
|
||||
|
||||
|
||||
def cmd_teaching_audit(args: argparse.Namespace) -> int:
|
||||
"""ADR-0055 Phase A — surface load decisions on the reviewed teaching corpus.
|
||||
|
||||
Re-parses the cognition-chains JSONL with the same gates as the
|
||||
runtime loader, but keeps drop reasons so silent shrinkage (pack
|
||||
skew, supersession, schema drift) is inspectable. Pure read.
|
||||
"""
|
||||
from teaching.audit import audit_corpus
|
||||
|
||||
report = audit_corpus()
|
||||
if args.json:
|
||||
print(json.dumps(report.as_dict(), ensure_ascii=False, indent=2, sort_keys=True))
|
||||
return 0 if not report.dropped else 1
|
||||
print(f"corpus_id : {report.corpus_id}")
|
||||
print(f"corpus_path : {report.corpus_path}")
|
||||
print(f"lines_on_disk : {report.lines_on_disk}")
|
||||
print(f"lines_loaded : {report.lines_loaded}")
|
||||
if report.dropped:
|
||||
print(f"\ndropped ({len(report.dropped)}):")
|
||||
for d in report.dropped:
|
||||
cid = d.chain_id or "<unknown>"
|
||||
print(f" L{d.line_no:>4} {cid:<40} {d.reason}")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_pack_validate(args: argparse.Namespace) -> int:
|
||||
"""Run executable source-pack validation gates."""
|
||||
pack_id = _safe_pack_id(args.pack_id)
|
||||
|
|
@ -1386,6 +1412,23 @@ def build_parser() -> argparse.ArgumentParser:
|
|||
)
|
||||
pack_validate.set_defaults(func=cmd_pack_validate)
|
||||
|
||||
teaching = subparsers.add_parser(
|
||||
"teaching",
|
||||
help="inspect the reviewed teaching corpus",
|
||||
)
|
||||
teaching_sub = teaching.add_subparsers(
|
||||
dest="teaching_command", metavar="teaching-command", required=True,
|
||||
)
|
||||
teaching_audit = teaching_sub.add_parser(
|
||||
"audit",
|
||||
help="surface load decisions and drop reasons for the cognition-chains corpus",
|
||||
)
|
||||
teaching_audit.add_argument(
|
||||
"--json", action="store_true",
|
||||
help="emit machine-readable JSON",
|
||||
)
|
||||
teaching_audit.set_defaults(func=cmd_teaching_audit)
|
||||
|
||||
rust = subparsers.add_parser(
|
||||
"rust",
|
||||
help="build, test, and inspect the Rust backend",
|
||||
|
|
|
|||
249
teaching/audit.py
Normal file
249
teaching/audit.py
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
"""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),
|
||||
)
|
||||
88
teaching/provenance.py
Normal file
88
teaching/provenance.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
"""ADR-0055 Phase A — typed provenance parser for reviewed teaching entries.
|
||||
|
||||
Today's corpus provenance is a free-text string like
|
||||
``"adr-0052:reviewed:2026-05-17"``. This module parses that shape
|
||||
into a typed ``Provenance`` without rewriting the JSONL on disk.
|
||||
Existing entries keep their raw string; the parser is lenient — if
|
||||
the string does not match the expected shape, the typed fields fall
|
||||
back to ``None`` / ``"unknown"`` and the raw is preserved.
|
||||
|
||||
Future entries written by ``TeachingChainProposal`` (Phase C) should
|
||||
emit the canonical shape directly.
|
||||
|
||||
Source enum follows ADR-0055 §A3:
|
||||
|
||||
- ``"hand_authored"`` — written by an operator in an ADR PR
|
||||
- ``"discovery_promoted"`` — accepted ``TeachingChainProposal`` (Phase C+)
|
||||
- ``"imported"`` — bulk-imported from an external reviewed source
|
||||
|
||||
Anything else maps to ``"unknown"``. The legacy ``"reviewed"`` token
|
||||
seen in existing entries is treated as ``"hand_authored"`` so today's
|
||||
corpus reports the right enum without a file rewrite.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
_KNOWN_SOURCES: frozenset[str] = frozenset({
|
||||
"hand_authored",
|
||||
"discovery_promoted",
|
||||
"imported",
|
||||
})
|
||||
|
||||
# Legacy free-text tokens that have appeared in the corpus prior to
|
||||
# Phase A. Each maps to its canonical typed enum value.
|
||||
_LEGACY_SOURCE_ALIASES: dict[str, str] = {
|
||||
"reviewed": "hand_authored",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Provenance:
|
||||
adr_id: str | None
|
||||
source: str
|
||||
review_date: str | None
|
||||
raw: str
|
||||
|
||||
|
||||
def _coerce_source(token: str) -> str:
|
||||
token = token.strip().lower()
|
||||
if token in _KNOWN_SOURCES:
|
||||
return token
|
||||
if token in _LEGACY_SOURCE_ALIASES:
|
||||
return _LEGACY_SOURCE_ALIASES[token]
|
||||
return "unknown"
|
||||
|
||||
|
||||
def parse_provenance(value: Any) -> Provenance:
|
||||
"""Parse a free-text provenance string into the typed shape.
|
||||
|
||||
Recognised shape: ``"<adr_id>:<source>:<review_date>"``
|
||||
where each segment is non-empty. Extra trailing segments are
|
||||
folded into ``review_date`` so ``"adr-0052:reviewed:2026-05-17"``
|
||||
parses cleanly even if a future writer adds a fourth field by
|
||||
accident (defensive — the parser does not crash on drift).
|
||||
|
||||
Non-string or empty input produces a ``Provenance`` with all
|
||||
typed fields ``None`` / ``"unknown"`` and ``raw=""``.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
return Provenance(adr_id=None, source="unknown", review_date=None, raw="")
|
||||
raw = value.strip()
|
||||
if not raw:
|
||||
return Provenance(adr_id=None, source="unknown", review_date=None, raw="")
|
||||
parts = raw.split(":")
|
||||
if len(parts) < 3:
|
||||
return Provenance(adr_id=None, source="unknown", review_date=None, raw=raw)
|
||||
adr_id = parts[0].strip() or None
|
||||
source = _coerce_source(parts[1])
|
||||
review_date_token = ":".join(parts[2:]).strip() or None
|
||||
return Provenance(
|
||||
adr_id=adr_id,
|
||||
source=source,
|
||||
review_date=review_date_token,
|
||||
raw=raw,
|
||||
)
|
||||
277
tests/test_teaching_audit.py
Normal file
277
tests/test_teaching_audit.py
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
"""ADR-0055 Phase A — teaching corpus audit + supersession + typed provenance.
|
||||
|
||||
Pinned contracts:
|
||||
|
||||
- ``teaching.audit.audit_corpus`` is pure, deterministic, and never
|
||||
mutates the corpus or the pack.
|
||||
- The active runtime loader (``chat.teaching_grounding._corpus_index``)
|
||||
and the audit module agree on which entries are dropped and why.
|
||||
- ``superseded_by`` retires an earlier chain from the active view but
|
||||
leaves it on disk.
|
||||
- Legacy ``"reviewed"`` provenance source token maps to
|
||||
``"hand_authored"`` so the current corpus reports the typed enum
|
||||
without a file rewrite.
|
||||
- Pack-consistency drop is surfaced with the specific lemma name in
|
||||
the reason.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from teaching.audit import audit_corpus
|
||||
from teaching.provenance import Provenance, parse_provenance
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_provenance — typed shape
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_parse_legacy_reviewed_token_maps_to_hand_authored() -> None:
|
||||
p = parse_provenance("adr-0052:reviewed:2026-05-17")
|
||||
assert p.adr_id == "adr-0052"
|
||||
assert p.source == "hand_authored"
|
||||
assert p.review_date == "2026-05-17"
|
||||
assert p.raw == "adr-0052:reviewed:2026-05-17"
|
||||
|
||||
|
||||
def test_parse_canonical_hand_authored() -> None:
|
||||
p = parse_provenance("adr-9999:hand_authored:2099-12-31")
|
||||
assert p.source == "hand_authored"
|
||||
|
||||
|
||||
def test_parse_discovery_promoted() -> None:
|
||||
p = parse_provenance("adr-9999:discovery_promoted:2099-12-31")
|
||||
assert p.source == "discovery_promoted"
|
||||
|
||||
|
||||
def test_parse_imported() -> None:
|
||||
p = parse_provenance("adr-9999:imported:2099-12-31")
|
||||
assert p.source == "imported"
|
||||
|
||||
|
||||
def test_parse_unknown_source_token_falls_back() -> None:
|
||||
p = parse_provenance("adr-9999:gibberish:2099-12-31")
|
||||
assert p.source == "unknown"
|
||||
# adr_id and review_date are still captured.
|
||||
assert p.adr_id == "adr-9999"
|
||||
assert p.review_date == "2099-12-31"
|
||||
|
||||
|
||||
def test_parse_non_string_input_safe() -> None:
|
||||
assert parse_provenance(None).source == "unknown"
|
||||
assert parse_provenance(42).source == "unknown"
|
||||
assert parse_provenance({"adr": "x"}).source == "unknown"
|
||||
|
||||
|
||||
def test_parse_empty_string_safe() -> None:
|
||||
p = parse_provenance("")
|
||||
assert p == Provenance(adr_id=None, source="unknown", review_date=None, raw="")
|
||||
|
||||
|
||||
def test_parse_short_string_no_crash() -> None:
|
||||
p = parse_provenance("adr-0052")
|
||||
assert p.source == "unknown"
|
||||
assert p.raw == "adr-0052"
|
||||
|
||||
|
||||
def test_parse_extra_trailing_colons_folded_into_date() -> None:
|
||||
p = parse_provenance("adr-9999:hand_authored:2099-12-31:extra")
|
||||
# Folding into review_date keeps drift safe — no crash, no silent loss.
|
||||
assert p.review_date == "2099-12-31:extra"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# audit_corpus — real corpus, no mutations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_audit_real_corpus_runs_clean() -> None:
|
||||
report = audit_corpus()
|
||||
assert report.lines_on_disk == report.lines_loaded
|
||||
assert report.dropped == ()
|
||||
assert len(report.loaded) >= 10
|
||||
assert report.corpus_id == "cognition_chains_v1"
|
||||
|
||||
|
||||
def test_audit_loaded_entries_have_typed_provenance() -> None:
|
||||
report = audit_corpus()
|
||||
for entry in report.loaded:
|
||||
assert entry.provenance.source in {
|
||||
"hand_authored", "discovery_promoted", "imported", "unknown",
|
||||
}
|
||||
|
||||
|
||||
def test_audit_is_deterministic() -> None:
|
||||
a = audit_corpus()
|
||||
b = audit_corpus()
|
||||
assert a.as_dict() == b.as_dict()
|
||||
|
||||
|
||||
def test_audit_as_dict_is_json_serialisable() -> None:
|
||||
report = audit_corpus()
|
||||
blob = json.dumps(report.as_dict(), sort_keys=True)
|
||||
assert "cognition_chains_v1" in blob
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# audit_corpus — synthetic corpora exercising drop reasons
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _write_corpus(tmp_path: Path, lines: list[str]) -> Path:
|
||||
p = tmp_path / "cognition_chains_v1.jsonl"
|
||||
p.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
return p
|
||||
|
||||
|
||||
def test_audit_surfaces_invalid_json(tmp_path: Path) -> None:
|
||||
path = _write_corpus(tmp_path, ["not json at all"])
|
||||
report = audit_corpus(path)
|
||||
assert report.lines_on_disk == 1
|
||||
assert report.lines_loaded == 0
|
||||
assert len(report.dropped) == 1
|
||||
assert report.dropped[0].reason == "invalid_json"
|
||||
|
||||
|
||||
def test_audit_surfaces_unsupported_intent(tmp_path: Path) -> None:
|
||||
path = _write_corpus(tmp_path, [json.dumps({
|
||||
"chain_id": "x", "subject": "light", "intent": "definition",
|
||||
"connective": "is", "object": "truth",
|
||||
"provenance": "adr-test:hand_authored:2026-05-18",
|
||||
})])
|
||||
report = audit_corpus(path)
|
||||
assert len(report.dropped) == 1
|
||||
assert report.dropped[0].reason == "unsupported_intent:definition"
|
||||
|
||||
|
||||
def test_audit_surfaces_pack_missing_subject(tmp_path: Path) -> None:
|
||||
path = _write_corpus(tmp_path, [json.dumps({
|
||||
"chain_id": "x", "subject": "zzznotalemma", "intent": "cause",
|
||||
"connective": "reveals", "object": "truth",
|
||||
"provenance": "adr-test:hand_authored:2026-05-18",
|
||||
})])
|
||||
report = audit_corpus(path)
|
||||
assert len(report.dropped) == 1
|
||||
assert report.dropped[0].reason == "pack_missing_subject:zzznotalemma"
|
||||
|
||||
|
||||
def test_audit_surfaces_pack_missing_object(tmp_path: Path) -> None:
|
||||
path = _write_corpus(tmp_path, [json.dumps({
|
||||
"chain_id": "x", "subject": "light", "intent": "cause",
|
||||
"connective": "reveals", "object": "zzznotalemma",
|
||||
"provenance": "adr-test:hand_authored:2026-05-18",
|
||||
})])
|
||||
report = audit_corpus(path)
|
||||
assert len(report.dropped) == 1
|
||||
assert report.dropped[0].reason == "pack_missing_object:zzznotalemma"
|
||||
|
||||
|
||||
def test_audit_surfaces_missing_required_field(tmp_path: Path) -> None:
|
||||
path = _write_corpus(tmp_path, [json.dumps({
|
||||
"chain_id": "x", "subject": "", "intent": "cause",
|
||||
"connective": "reveals", "object": "truth",
|
||||
})])
|
||||
report = audit_corpus(path)
|
||||
assert len(report.dropped) == 1
|
||||
assert report.dropped[0].reason == "missing_required_field:subject"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Supersession — disk preserves history, active view drops superseded
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_supersession_drops_earlier_chain_from_active_view(tmp_path: Path) -> None:
|
||||
older = {
|
||||
"chain_id": "cause_light_reveals_truth",
|
||||
"subject": "light", "intent": "cause", "connective": "reveals",
|
||||
"object": "truth", "domains_subject_k": 2, "domains_object_k": 1,
|
||||
"provenance": "adr-test:hand_authored:2026-05-18",
|
||||
}
|
||||
newer = {
|
||||
"chain_id": "cause_light_grounds_truth",
|
||||
"subject": "light", "intent": "cause", "connective": "grounds",
|
||||
"object": "truth", "domains_subject_k": 2, "domains_object_k": 1,
|
||||
"provenance": "adr-test:hand_authored:2026-05-19",
|
||||
"superseded_by": "cause_light_reveals_truth",
|
||||
}
|
||||
path = _write_corpus(tmp_path, [json.dumps(older), json.dumps(newer)])
|
||||
report = audit_corpus(path)
|
||||
assert report.lines_on_disk == 2
|
||||
assert report.lines_loaded == 1
|
||||
# The newer entry retires the older one.
|
||||
assert report.dropped[0].chain_id == "cause_light_reveals_truth"
|
||||
assert report.dropped[0].reason == "superseded_by:cause_light_reveals_truth"
|
||||
# Newer entry is active.
|
||||
assert report.loaded[0].chain_id == "cause_light_grounds_truth"
|
||||
|
||||
|
||||
def test_default_superseded_by_is_null_in_loaded_entries() -> None:
|
||||
"""Existing corpus uses default null — must round-trip unchanged."""
|
||||
report = audit_corpus()
|
||||
assert all(entry.superseded_by is None for entry in report.loaded)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runtime parity — runtime loader and audit agree
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_runtime_loader_and_audit_agree_on_active_chain_ids() -> None:
|
||||
"""Whatever audit_corpus says is loaded must also be what the
|
||||
runtime loader has indexed (modulo the keying — runtime keys by
|
||||
(subject, intent); audit lists chain_ids)."""
|
||||
from chat.teaching_grounding import _corpus_index
|
||||
|
||||
_corpus_index.cache_clear()
|
||||
runtime_chains = {c.chain_id for c in _corpus_index().values()}
|
||||
audit_chains = {c.chain_id for c in audit_corpus().loaded}
|
||||
assert runtime_chains == audit_chains
|
||||
|
||||
|
||||
def test_runtime_loader_honors_superseded_by(tmp_path: Path) -> None:
|
||||
"""If a corpus on disk has supersession, the runtime loader's
|
||||
active set must match the audit report."""
|
||||
older = {
|
||||
"chain_id": "cause_light_reveals_truth",
|
||||
"subject": "light", "intent": "cause", "connective": "reveals",
|
||||
"object": "truth",
|
||||
"provenance": "adr-test:hand_authored:2026-05-18",
|
||||
}
|
||||
newer = {
|
||||
"chain_id": "cause_light_grounds_truth",
|
||||
"subject": "light", "intent": "cause", "connective": "grounds",
|
||||
"object": "truth",
|
||||
"provenance": "adr-test:hand_authored:2026-05-19",
|
||||
"superseded_by": "cause_light_reveals_truth",
|
||||
}
|
||||
path = _write_corpus(tmp_path, [json.dumps(older), json.dumps(newer)])
|
||||
|
||||
from chat import teaching_grounding as tg
|
||||
|
||||
with patch.object(tg, "_CORPUS_PATH", path):
|
||||
tg._corpus_index.cache_clear()
|
||||
try:
|
||||
index = tg._corpus_index()
|
||||
active = {c.chain_id for c in index.values()}
|
||||
assert active == {"cause_light_grounds_truth"}
|
||||
finally:
|
||||
tg._corpus_index.cache_clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Audit is read-only (trust boundary)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_audit_does_not_mutate_corpus_on_disk() -> None:
|
||||
from chat.teaching_grounding import _CORPUS_PATH
|
||||
|
||||
before = _CORPUS_PATH.read_bytes()
|
||||
audit_corpus()
|
||||
after = _CORPUS_PATH.read_bytes()
|
||||
assert before == after
|
||||
Loading…
Reference in a new issue