fix(ADR-0167): route contemplation and proposal replay by candidate domain (#363)

* fix(teaching): select proposal replay gate from candidate domain

* test(teaching): pin domain-selected proposal replay gates

* fix(teaching): make contemplation probes domain-aware

* test(teaching): pin domain-aware contemplation partition
This commit is contained in:
Shay 2026-05-27 09:43:16 -07:00 committed by GitHub
parent dbeb1b2f00
commit 00c3968937
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 230 additions and 51 deletions

View file

@ -19,14 +19,14 @@ grounded this turn?") and returns an *enriched* candidate with:
contemplation never silently truncates.
The loop is a pure function of the candidate, the reviewed teaching
corpus, the ratified cognition pack, and an optional vault probe
hook. No clock-time, no LLM, no stochastic sampling, no concurrency
ADR-0056 Call 4 (sync, not async).
corpus, the ratified domain pack, and an optional vault probe hook. No
clock-time, no LLM, no stochastic sampling, no concurrency ADR-0056
Call 4 (sync, not async).
Trust boundary: this module reads ``_pack_index()`` and
``_corpus_index()`` only. It NEVER writes to the corpus, the pack,
or runtime state. Output enriched candidates flow back through the
same Phase B sink as JSONL lines.
Trust boundary: this module reads domain-selected pack/corpus indices
only. It NEVER writes to the corpus, the pack, or runtime state. Output
enriched candidates flow back through the same Phase B sink as JSONL
lines.
"""
from __future__ import annotations
@ -34,7 +34,8 @@ from __future__ import annotations
import hashlib
import json
from dataclasses import replace
from typing import Any, Callable, Literal
from pathlib import Path
from typing import Any, Callable, Literal, Mapping
from chat.pack_grounding import _pack_index
from chat.teaching_grounding import _corpus_index
@ -68,6 +69,55 @@ loop. ``None`` means "no vault probe in this contemplation pass."
"""
_DEFAULT_MAX_DEPTH: int = 8
_MATH_PACK_PATH = (
Path(__file__).resolve().parent.parent
/ "language_packs"
/ "data"
/ "en_core_math_v1"
)
# ---------------------------------------------------------------------------
# Domain index resolution
# ---------------------------------------------------------------------------
def _pack_index_for_domain(domain: str) -> dict[str, tuple[str, ...]]:
"""Return the read-only pack index for *domain*.
Cognition preserves the legacy ``chat.pack_grounding._pack_index``
semantics. Math reads ``en_core_math_v1`` through the operational
lexicon loader and exposes category membership as shape-level pack
evidence. Unknown domains fail closed.
"""
if domain == "cognition":
return _pack_index()
if domain == "math":
from generate.comprehension.lexicon import load_lexicon
lexicon = load_lexicon(_MATH_PACK_PATH)
return {
surface: (entry.category,)
for surface, entry in lexicon.by_surface.items()
}
return {}
def _corpus_index_for_domain(domain: str) -> Mapping[Any, Any]:
"""Return the reviewed corpus index for *domain*.
The cognition domain keeps the ADR-0056 reviewed teaching corpus.
Math currently has no reviewed TeachingChain-style corpus for
contemplation; returning an empty mapping is deliberate fail-closed
behavior that prevents math candidates from borrowing cognition
evidence. ADR-0167 FOLLOWUPS §5a can tighten this once a math corpus
exists.
"""
if domain == "cognition":
return _corpus_index()
if domain == "math":
return {}
return {}
# ---------------------------------------------------------------------------
@ -97,9 +147,14 @@ def _sub_id(parent_candidate_id: str, index: int, payload: dict[str, Any]) -> st
def _probe_corpus_direct(
subject: str, intent: str, connective: str | None, obj: str | None
subject: str,
intent: str,
connective: str | None,
obj: str | None,
*,
domain: str = "cognition",
) -> tuple[EvidencePointer, ...]:
"""Look in the active reviewed corpus for affirming/falsifying chains.
"""Look in the domain-selected reviewed corpus for direct evidence.
- Exact match on ``(subject, intent, connective, object)`` is
affirming evidence (the proposed chain already exists).
@ -110,7 +165,7 @@ def _probe_corpus_direct(
reviewed memory).
"""
out: list[EvidencePointer] = []
corpus = _corpus_index()
corpus = _corpus_index_for_domain(domain)
chain = corpus.get((subject, intent))
if chain is None:
return ()
@ -144,7 +199,9 @@ def _probe_corpus_direct(
return tuple(out)
def _probe_pack(subject: str, obj: str | None) -> tuple[EvidencePointer, ...]:
def _probe_pack(
subject: str, obj: str | None, *, domain: str = "cognition"
) -> tuple[EvidencePointer, ...]:
"""Pack lemma residency is shape-level affirming evidence.
A pack-resident subject means the subject is grounded; if both
@ -153,7 +210,7 @@ def _probe_pack(subject: str, obj: str | None) -> tuple[EvidencePointer, ...]:
falsify (pack ``semantic_domains`` don't express negation —
Call 2 of ADR-0056).
"""
pack = _pack_index()
pack = _pack_index_for_domain(domain)
out: list[EvidencePointer] = []
if subject in pack:
out.append(EvidencePointer(
@ -194,10 +251,8 @@ def _decompose(
"""Return decomposed sub-question payloads.
For a Phase B partial chain ``(subject, intent, None, None)``,
enumerate every reviewed object the corpus has used with the
same ``intent`` and treat each as a candidate match for
``subject``. This is the deterministic, pack-grounded analogue
of "what could this relation be about?"
enumerate every reviewed object the domain corpus has used with the
same ``intent`` and treat each as a candidate match for ``subject``.
Returns an empty tuple when no decomposition is possible the
parent records the gap (Call 1 of ADR-0056) and stops.
@ -209,7 +264,7 @@ def _decompose(
if obj is not None:
# Already has a concrete object — no further decomposition.
return ()
corpus = _corpus_index()
corpus = _corpus_index_for_domain(candidate.domain)
# Deterministic order: sort by object lemma.
seen_objects: list[tuple[str, str]] = []
for key, chain in corpus.items():
@ -352,7 +407,7 @@ def _materialise_sub_candidate(
def _probe(
chain: dict[str, Any], vault_probe: _VaultProbe | None
chain: dict[str, Any], vault_probe: _VaultProbe | None, *, domain: str = "cognition"
) -> tuple[EvidencePointer, ...]:
"""Canonical probe order: vault → pack → corpus.
@ -368,8 +423,8 @@ def _probe(
out: list[EvidencePointer] = []
out.extend(_probe_vault(subject, obj, vault_probe))
out.extend(_probe_pack(subject, obj))
out.extend(_probe_corpus_direct(subject, intent, connective, obj))
out.extend(_probe_pack(subject, obj, domain=domain))
out.extend(_probe_corpus_direct(subject, intent, connective, obj, domain=domain))
return tuple(out)
@ -421,7 +476,7 @@ def contemplate(
)
# Direct probe on the parent chain.
direct_evidence = _probe(candidate.proposed_chain, vault_probe)
direct_evidence = _probe(candidate.proposed_chain, vault_probe, domain=candidate.domain)
# Decompose into sub-questions.
sub_payloads = _decompose(candidate)
@ -594,6 +649,7 @@ def contemplate_exemplar_corpus(corpus: Any) -> DiscoveryCandidate:
source_turn_trace=f"exemplar_corpus:{corpus.corpus_digest}",
pack_consistent=True,
boundary_clean=True,
domain="math",
review_state="unreviewed",
polarity="affirms",
claim_domain="factual",

View file

@ -23,7 +23,7 @@ import hashlib
import json
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal
from typing import TYPE_CHECKING, Any, Callable, Literal
from teaching.provenance import Provenance
from teaching.source import ProposalSource
@ -52,6 +52,7 @@ DEFAULT_CONTEMPLATION_RUNS_DIR: Path = (
ReviewState = Literal["pending", "accepted", "rejected", "withdrawn"]
ReplayGate = Callable[[dict[str, Any]], Any]
@dataclass(frozen=True, slots=True)
@ -310,7 +311,7 @@ class ProposalLog:
def record_created(self, proposal: TeachingChainProposal) -> None:
self._append({"event": "created", "proposal": proposal.as_dict()})
def record_replay(self, proposal_id: str, evidence: ReplayEvidence) -> None:
def record_replay(self, proposal_id: str, evidence: Any) -> None:
self._append({
"event": "replay",
"proposal_id": proposal_id,
@ -474,6 +475,23 @@ def append_chain_to_corpus(
# ---------------------------------------------------------------------------
def _replay_gate_for_domain(domain: str) -> ReplayGate:
"""Return the replay gate for a candidate domain.
Cognition candidates keep the ADR-0057 cognition replay-equivalence
gate. Math candidates use the ADR-0163 admissibility gate so wrong=0
capability axes and GSM8K train-sample evidence are checked by
default instead of depending on each caller to pass an override.
"""
if domain == "cognition":
from teaching.replay import run_replay_equivalence
return run_replay_equivalence
if domain == "math":
from teaching.replay import run_admissibility_replay_gate
return run_admissibility_replay_gate
raise ProposalError(f"unsupported proposal domain: {domain!r}")
def propose_from_candidate(
candidate: DiscoveryCandidate,
*,
@ -486,10 +504,10 @@ def propose_from_candidate(
"""End-to-end: build proposal, run replay-equivalence gate,
auto-reject on regression, otherwise leave pending.
``run_replay`` is the replay function (``teaching.replay.
run_replay_equivalence`` by default); accepting it as a kwarg
keeps tests fast they can pass a fake that returns a stub
``ReplayEvidence`` without booting the cognition lane.
``run_replay`` overrides the domain-selected replay function for
tests or specialised callers. When omitted, the gate is selected
from ``candidate.domain``: cognition ``run_replay_equivalence``;
math ``run_admissibility_replay_gate``.
Submission-time checks fire in this order (ADR-0161 §3):
1. Capacity (Step 2) queue_full if pending_count >= cap
@ -604,9 +622,8 @@ def propose_from_candidate(
log.record_created(proposal)
if run_replay is None:
from teaching.replay import run_replay_equivalence as run_replay
evidence = run_replay(proposal.proposed_chain)
replay = run_replay if run_replay is not None else _replay_gate_for_domain(candidate.domain)
evidence = replay(proposal.proposed_chain)
log.record_replay(proposal.proposal_id, evidence)
if not evidence.replay_equivalent:
@ -662,12 +679,12 @@ def accept_proposal(
def reject_proposal(
proposal_id: str, *, log: ProposalLog, operator_note: str = ""
) -> None:
record = log.find(proposal_id)
if record is None:
rec = log.find(proposal_id)
if rec is None:
raise ProposalError(f"proposal not found: {proposal_id}")
if record["state"] != "pending":
if rec["state"] != "pending":
raise ProposalError(
f"proposal {proposal_id} is {record['state']!r}, not pending"
f"proposal {proposal_id} is {rec['state']!r}, not pending"
)
log.record_transition(proposal_id, "rejected", operator_note)
@ -675,26 +692,23 @@ def reject_proposal(
def withdraw_proposal(
proposal_id: str, *, log: ProposalLog, operator_note: str = ""
) -> None:
record = log.find(proposal_id)
if record is None:
rec = log.find(proposal_id)
if rec is None:
raise ProposalError(f"proposal not found: {proposal_id}")
if record["state"] != "pending":
if rec["state"] != "pending":
raise ProposalError(
f"proposal {proposal_id} is {record['state']!r}, not pending"
f"proposal {proposal_id} is {rec['state']!r}, not pending"
)
log.record_transition(proposal_id, "withdrawn", operator_note)
__all__ = [
"DEFAULT_PENDING_CAP",
"DEFAULT_PROPOSAL_LOG_PATH",
"ProposalError",
"ProposalLog",
"RefusedAsDependent",
"RefusedAsCapacity",
"RefusedAsDuplicate",
"RefusedAtCapacity",
"RefusedAsDependent",
"ReplayEvidence",
"ReviewState",
"TeachingChainProposal",
"accept_proposal",
"append_chain_to_corpus",

View file

@ -35,6 +35,7 @@ CORPUS_BYTES_BEFORE = _CORPUS_PATH.read_bytes() if _CORPUS_PATH.exists() else b"
def _phase_b_candidate(
*, subject: str = "wisdom", intent: str = "cause",
candidate_id: str = "cand_abc", trace: str = "trace_xyz",
domain: str = "cognition",
) -> DiscoveryCandidate:
return DiscoveryCandidate(
candidate_id=candidate_id,
@ -48,6 +49,7 @@ def _phase_b_candidate(
source_turn_trace=trace,
pack_consistent=True,
boundary_clean=True,
domain=domain,
)
@ -93,8 +95,8 @@ def test_empty_pack_and_corpus_terminates_with_gap(monkeypatch):
"""No pack, no corpus ⇒ every probe fails, parent gap-records."""
from teaching import contemplation as contemp_mod
monkeypatch.setattr(contemp_mod, "_pack_index", lambda: {})
monkeypatch.setattr(contemp_mod, "_corpus_index", lambda: {})
monkeypatch.setattr(contemp_mod, "_pack_index_for_domain", lambda _domain: {})
monkeypatch.setattr(contemp_mod, "_corpus_index_for_domain", lambda _domain: {})
cand = _phase_b_candidate()
out = contemplate(cand)
@ -106,6 +108,55 @@ def test_empty_pack_and_corpus_terminates_with_gap(monkeypatch):
assert out.recursion_overflow is False
# ---------------------------------------------------------------------------
# Domain-aware partition
# ---------------------------------------------------------------------------
def test_math_contemplation_does_not_borrow_cognition_corpus():
"""Math candidates fail closed instead of using cognition corpus evidence."""
cand = DiscoveryCandidate(
candidate_id="cand_math_no_cognition_leak",
proposed_chain={
"subject": "light",
"intent": "cause",
"connective": "reveals",
"object": "truth",
},
trigger="would_have_grounded",
source_turn_trace="t_math",
pack_consistent=True,
boundary_clean=True,
domain="math",
)
out = contemplate(cand)
assert out.domain == "math"
assert out.polarity == "undetermined"
assert not any(e.source == "corpus" for e in out.evidence)
def test_math_contemplation_uses_math_pack_residency():
"""Math candidates can receive math-pack evidence without corpus leakage."""
cand = DiscoveryCandidate(
candidate_id="cand_math_pack",
proposed_chain={
"subject": "does",
"intent": "admissibility",
"connective": "recognizes",
"object": "does",
},
trigger="would_have_grounded",
source_turn_trace="t_math_pack",
pack_consistent=True,
boundary_clean=True,
domain="math",
)
out = contemplate(cand)
assert out.domain == "math"
assert any(e.source == "pack" and e.ref == "does" for e in out.evidence)
assert not any(e.source == "corpus" for e in out.evidence)
# ---------------------------------------------------------------------------
# Factual affirming evidence
# ---------------------------------------------------------------------------
@ -172,7 +223,7 @@ def test_mixed_evidence_upgrades_claim_domain(monkeypatch):
"""Mixed affirm + falsify ⇒ undetermined AND domain upgrades one tier."""
from teaching import contemplation as contemp_mod
def fake_corpus_probe(subject, intent, connective, obj):
def fake_corpus_probe(subject, intent, connective, obj, *, domain="cognition"):
return (
EvidencePointer(
source="corpus", ref="chain_aff", polarity="affirms",

View file

@ -44,7 +44,7 @@ CORPUS_BYTES_BEFORE = _CORPUS_PATH.read_bytes() if _CORPUS_PATH.exists() else b"
def _enriched(*, polarity="affirms", claim_domain="factual",
connective="reveals", obj="truth", subject="light",
evidence=None, boundary_clean=True):
evidence=None, boundary_clean=True, domain="cognition"):
if evidence is None:
evidence = (
EvidencePointer(
@ -62,6 +62,7 @@ def _enriched(*, polarity="affirms", claim_domain="factual",
source_turn_trace="trace_1",
pack_consistent=True,
boundary_clean=boundary_clean,
domain=domain,
polarity=polarity,
claim_domain=claim_domain,
evidence=evidence,
@ -190,6 +191,63 @@ def test_propose_from_candidate_auto_rejects_on_regression(tmp_path: Path):
assert "surface_groundedness" in rec["operator_note"]
def test_propose_selects_replay_gate_by_candidate_domain(monkeypatch, tmp_path: Path):
calls: list[str] = []
def fake_cognition_gate(chain):
calls.append("cognition")
return _fake_replay_equivalent(chain)
def fake_math_gate(chain):
calls.append("math")
return _fake_replay_equivalent(chain)
monkeypatch.setattr(
"teaching.replay.run_replay_equivalence",
fake_cognition_gate,
)
monkeypatch.setattr(
"teaching.replay.run_admissibility_replay_gate",
fake_math_gate,
)
log_cognition = ProposalLog(tmp_path / "cognition" / "proposals.jsonl")
propose_from_candidate(_enriched(domain="cognition"), log=log_cognition)
log_math = ProposalLog(tmp_path / "math" / "proposals.jsonl")
propose_from_candidate(
_enriched(domain="math", subject="sees", connective="recognizes", obj="drain"),
log=log_math,
)
assert calls == ["cognition", "math"]
def test_explicit_replay_override_wins_over_domain(monkeypatch, tmp_path: Path):
calls: list[str] = []
def forbidden_math_gate(chain):
raise AssertionError("domain-selected math gate should not run")
def override_gate(chain):
calls.append("override")
return _fake_replay_equivalent(chain)
monkeypatch.setattr(
"teaching.replay.run_admissibility_replay_gate",
forbidden_math_gate,
)
log = ProposalLog(tmp_path / "proposals.jsonl")
propose_from_candidate(
_enriched(domain="math", subject="sees", connective="recognizes", obj="drain"),
log=log,
run_replay=override_gate,
)
assert calls == ["override"]
def test_propose_is_idempotent(tmp_path: Path):
log = ProposalLog(tmp_path / "proposals.jsonl")
c = _enriched()