core/teaching/contemplation.py

666 lines
24 KiB
Python

"""ADR-0056 Phase C1 — Contemplation loop.
``contemplate(candidate)`` takes a Phase B ``DiscoveryCandidate``
(a *posed question*: "would a chain of shape (subject, intent) have
grounded this turn?") and returns an *enriched* candidate with:
- ``polarity ∈ {affirms, falsifies, undetermined}`` — what
composed reviewed evidence says about the proposed relation.
- ``claim_domain ∈ {factual, relational, evaluative}`` — the
epistemic register the claim sits in. Determines the evidence
threshold the future C2 review gate will demand.
- ``evidence`` — tuple of ``EvidencePointer`` from the canonical
probe order (vault → pack → corpus).
- ``sub_questions`` — decomposed sub-questions and their outcomes
(``grounded``, ``gap_recorded``, ``depth_failsafe``).
- ``contemplation_depth`` — recursion depth reached.
- ``recursion_overflow`` — True iff the bounded-depth failsafe
fired. Hitting the ceiling is itself an audit event;
contemplation never silently truncates.
The loop is a pure function of the candidate, the reviewed teaching
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 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
import hashlib
import json
from dataclasses import replace
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
from teaching.discovery import (
ClaimDomain,
DiscoveryCandidate,
EvidencePointer,
SubQuestion,
)
# Frame-dependent connectives (open question §1 in ADR-0056). v1
# list lives here as a small reviewed constant; the long-term home
# is versioned pack data so that refining the taxonomy doesn't
# require a code change. Adding/removing entries here is a reviewed
# code change, same as any other reviewed surface.
_FRAME_DEPENDENT_CONNECTIVES: frozenset[str] = frozenset({
"orders",
"grounds",
"informs",
"constrains",
})
_VaultProbe = Callable[[str, str], tuple[EvidencePointer, ...]]
"""Optional injectable vault probe.
Signature: ``probe(subject_lemma, object_lemma) -> tuple[EvidencePointer, ...]``.
Implementations MUST return only ``vault_coherent`` pointers
(``EpistemicStatus.COHERENT``); SPECULATIVE / CONTESTED / FALSIFIED
vault entries are filtered out by the implementation, not by the
loop. ``None`` means "no vault probe in this contemplation pass."
"""
_DEFAULT_MAX_DEPTH: int = 8
_MATH_PACK_PATH = (
Path(__file__).resolve().parent.parent
/ "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 {}
# ---------------------------------------------------------------------------
# Sub-question id derivation
# ---------------------------------------------------------------------------
def _sub_id(parent_candidate_id: str, index: int, payload: dict[str, Any]) -> str:
"""Deterministic sub-question id.
SHA-256 over ``(parent_id, index, sorted_payload_json)`` keeps the
id stable across runs and ties the sub-question's identity to
both its parent and its content.
"""
import json as _json
blob = _json.dumps(
{"parent": parent_candidate_id, "index": index, "payload": payload},
sort_keys=True,
separators=(",", ":"),
)
return hashlib.sha256(blob.encode("utf-8")).hexdigest()[:32]
# ---------------------------------------------------------------------------
# Probing — vault → pack → corpus
# ---------------------------------------------------------------------------
def _probe_corpus_direct(
subject: str,
intent: str,
connective: str | None,
obj: str | None,
*,
domain: str = "cognition",
) -> tuple[EvidencePointer, ...]:
"""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).
- Same ``(subject, intent, object)`` but different connective is
a same-pack contradiction → falsifying evidence.
- ``(subject, intent)`` match with no object filter and any
connective is weak affirming evidence (the *shape* exists in
reviewed memory).
"""
out: list[EvidencePointer] = []
corpus = _corpus_index_for_domain(domain)
chain = corpus.get((subject, intent))
if chain is None:
return ()
if obj is None and connective is None:
# Phase B shape: shape evidence only. The exact (subject,
# intent) cell is in the corpus — affirming.
out.append(EvidencePointer(
source="corpus",
ref=chain.chain_id,
polarity="affirms",
epistemic_status="coherent",
))
return tuple(out)
if obj is not None and chain.object == obj:
if connective is None or chain.connective == connective:
out.append(EvidencePointer(
source="corpus",
ref=chain.chain_id,
polarity="affirms",
epistemic_status="coherent",
))
else:
# Same subject + intent + object, different connective.
# Direct same-pack contradiction.
out.append(EvidencePointer(
source="corpus",
ref=chain.chain_id,
polarity="falsifies",
epistemic_status="coherent",
))
return tuple(out)
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
subject and object are pack-resident, the relation has both
endpoints anchored in ratified memory. Pack residency cannot
falsify (pack ``semantic_domains`` don't express negation —
Call 2 of ADR-0056).
"""
pack = _pack_index_for_domain(domain)
out: list[EvidencePointer] = []
if subject in pack:
out.append(EvidencePointer(
source="pack",
ref=subject,
polarity="affirms",
epistemic_status="coherent",
))
if obj is not None and obj in pack:
out.append(EvidencePointer(
source="pack",
ref=obj,
polarity="affirms",
epistemic_status="coherent",
))
return tuple(out)
def _probe_vault(
subject: str, obj: str | None, vault_probe: _VaultProbe | None
) -> tuple[EvidencePointer, ...]:
if vault_probe is None or obj is None:
return ()
try:
return tuple(vault_probe(subject, obj))
except Exception: # pragma: no cover — defensive: vault probe must not poison loop
return ()
# ---------------------------------------------------------------------------
# Decomposition
# ---------------------------------------------------------------------------
def _decompose(
candidate: DiscoveryCandidate,
) -> tuple[dict[str, Any], ...]:
"""Return decomposed sub-question payloads.
For a Phase B partial chain ``(subject, intent, None, None)``,
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.
"""
intent = str(candidate.proposed_chain.get("intent") or "")
if not intent:
return ()
obj = candidate.proposed_chain.get("object")
if obj is not None:
# Already has a concrete object — no further decomposition.
return ()
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():
if key[1] != intent:
continue
seen_objects.append((chain.object, chain.connective))
if not seen_objects:
return ()
seen_objects.sort()
subject = str(candidate.proposed_chain.get("subject") or "")
out: list[dict[str, Any]] = []
for cand_obj, cand_conn in seen_objects:
out.append({
"subject": subject,
"intent": intent,
"connective": cand_conn,
"object": cand_obj,
})
return tuple(out)
# ---------------------------------------------------------------------------
# Classification + composition
# ---------------------------------------------------------------------------
def _classify_claim_domain(chain: dict[str, Any]) -> ClaimDomain:
"""Deterministic claim-domain classification.
- ``relational`` if the connective is in the reviewed
frame-dependent set (e.g. ``orders``, ``grounds``).
- ``factual`` otherwise (the default for pack-resident
cognition lemmas).
- ``evaluative`` is NOT auto-assigned in C1 — open question §2
in ADR-0056. Operator-assignable only.
"""
connective = str(chain.get("connective") or "").strip().lower()
if connective and connective in _FRAME_DEPENDENT_CONNECTIVES:
return "relational"
return "factual"
_DOMAIN_TIER: dict[ClaimDomain, int] = {
"factual": 0,
"relational": 1,
"evaluative": 2,
}
_DOMAIN_BY_TIER: dict[int, ClaimDomain] = {
0: "factual",
1: "relational",
2: "evaluative",
}
def _upgrade_domain(domain: ClaimDomain) -> ClaimDomain:
tier = _DOMAIN_TIER[domain]
return _DOMAIN_BY_TIER[min(tier + 1, 2)]
def _compose_polarity(
direct_evidence: tuple[EvidencePointer, ...],
sub_questions: tuple[SubQuestion, ...],
) -> Literal["affirms", "falsifies", "undetermined"]:
"""Reduce evidence + sub-question outcomes to one polarity verdict.
Rules (Call 1 + Call 2 of ADR-0056):
- Any direct ``falsifies`` evidence on the parent → ``falsifies``.
A same-pack contradiction overrides supporting sub-evidence
because reviewed contradiction is the strongest signal.
- All admissible evidence ``affirms`` and at least one direct
reviewed pointer (corpus or vault_coherent) → ``affirms``.
- Mixed (some affirm, some falsify, but no direct parent-level
falsification) → ``undetermined``.
- No admissible evidence at all → ``undetermined``.
"""
# Direct same-pack contradiction is dispositive — but ONLY when
# the falsifying pointer comes from the reviewed teaching corpus
# (Call 2 of ADR-0056: reviewed evidence in the same pack family).
# Vault and pack pointers cannot dispositively falsify; they
# contest but compose into the mixed-evidence path below.
if any(
e.polarity == "falsifies" and e.source == "corpus"
for e in direct_evidence
):
return "falsifies"
# Gather all evidence pointers (direct + sub-question contributions).
all_evidence: list[EvidencePointer] = list(direct_evidence)
for sq in sub_questions:
all_evidence.extend(sq.evidence)
if not all_evidence:
return "undetermined"
has_falsifies = any(e.polarity == "falsifies" for e in all_evidence)
has_affirms = any(e.polarity == "affirms" for e in all_evidence)
if has_falsifies and has_affirms:
return "undetermined"
if has_falsifies:
return "falsifies"
# Require at least one *reviewed* affirming pointer (corpus or
# vault_coherent) before promoting to ``affirms`` — pack
# residency alone is shape evidence, not relation evidence.
has_reviewed_affirm = any(
e.polarity == "affirms" and e.source in ("corpus", "vault_coherent")
for e in all_evidence
)
if has_reviewed_affirm:
return "affirms"
return "undetermined"
# ---------------------------------------------------------------------------
# The loop itself
# ---------------------------------------------------------------------------
def _materialise_sub_candidate(
parent: DiscoveryCandidate, sub_payload: dict[str, Any], index: int
) -> DiscoveryCandidate:
"""Build a sub-candidate from a decomposed payload.
Sub-candidates inherit ``trigger`` and ``source_turn_trace`` from
the parent. The ``candidate_id`` is derived deterministically
from parent + index + payload — same as ``_sub_id``.
"""
sub_id = _sub_id(parent.candidate_id, index, sub_payload)
return replace(
parent,
candidate_id=sub_id,
proposed_chain=dict(sub_payload),
contemplation_depth=parent.contemplation_depth + 1,
evidence=(),
sub_questions=(),
polarity="undetermined",
claim_domain="factual",
recursion_overflow=False,
)
def _probe(
chain: dict[str, Any], vault_probe: _VaultProbe | None, *, domain: str = "cognition"
) -> tuple[EvidencePointer, ...]:
"""Canonical probe order: vault → pack → corpus.
The first source that grounds wins for *that* axis, but all
admissible pointers contribute — composition reduces them.
"""
subject = str(chain.get("subject") or "").strip().lower()
intent = str(chain.get("intent") or "").strip().lower()
connective_raw = chain.get("connective")
connective = str(connective_raw).strip().lower() if connective_raw else None
obj_raw = chain.get("object")
obj = str(obj_raw).strip().lower() if obj_raw else None
out: list[EvidencePointer] = []
out.extend(_probe_vault(subject, obj, vault_probe))
out.extend(_probe_pack(subject, obj, domain=domain))
out.extend(_probe_corpus_direct(subject, intent, connective, obj, domain=domain))
return tuple(out)
def _gap_subquestion(parent: DiscoveryCandidate) -> SubQuestion:
subject = str(parent.proposed_chain.get("subject") or "")
intent = str(parent.proposed_chain.get("intent") or "")
payload = {"subject": subject, "intent": intent, "outcome": "gap_recorded"}
return SubQuestion(
sub_id=_sub_id(parent.candidate_id, -1, payload),
proposed_subject=subject,
proposed_intent=intent,
outcome="gap_recorded",
evidence=(),
)
def _depth_failsafe_subquestion(parent: DiscoveryCandidate) -> SubQuestion:
subject = str(parent.proposed_chain.get("subject") or "")
intent = str(parent.proposed_chain.get("intent") or "")
payload = {"subject": subject, "intent": intent, "outcome": "depth_failsafe"}
return SubQuestion(
sub_id=_sub_id(parent.candidate_id, -2, payload),
proposed_subject=subject,
proposed_intent=intent,
outcome="depth_failsafe",
evidence=(),
)
def contemplate(
candidate: DiscoveryCandidate,
*,
max_depth: int = _DEFAULT_MAX_DEPTH,
vault_probe: _VaultProbe | None = None,
) -> DiscoveryCandidate:
"""Run the contemplation loop on a single candidate.
Returns an *enriched* candidate (same id, populated C1 fields).
Never mutates the corpus, the pack, or the input candidate
(``DiscoveryCandidate`` is frozen).
"""
# Failsafe (Call 1 of ADR-0056): bounded depth ceiling whose hit
# is itself an audit event, not a silent truncation.
if candidate.contemplation_depth >= max_depth:
return replace(
candidate,
recursion_overflow=True,
sub_questions=(_depth_failsafe_subquestion(candidate),),
)
# Direct probe on the parent chain.
direct_evidence = _probe(candidate.proposed_chain, vault_probe, domain=candidate.domain)
# Decompose into sub-questions.
sub_payloads = _decompose(candidate)
if not sub_payloads:
# Terminal: cannot decompose further. Record the gap.
# Direct evidence (if any) still composes — a parent may be
# directly groundable without sub-decomposition.
if direct_evidence:
polarity = _compose_polarity(direct_evidence, ())
domain = _classify_claim_domain(candidate.proposed_chain)
if polarity == "undetermined":
has_aff = any(p.polarity == "affirms" for p in direct_evidence)
has_fal = any(p.polarity == "falsifies" for p in direct_evidence)
if has_aff and has_fal:
domain = _upgrade_domain(domain)
return replace(
candidate,
polarity=polarity,
claim_domain=domain,
evidence=direct_evidence,
sub_questions=(),
)
# No evidence and no decomposition → gap recorded.
return replace(
candidate,
polarity="undetermined",
claim_domain=_classify_claim_domain(candidate.proposed_chain),
evidence=(),
sub_questions=(_gap_subquestion(candidate),),
)
sub_results: list[SubQuestion] = []
for index, payload in enumerate(sub_payloads):
sub_candidate = _materialise_sub_candidate(candidate, payload, index)
recursed = contemplate(
sub_candidate, max_depth=max_depth, vault_probe=vault_probe
)
outcome: Literal["grounded", "gap_recorded", "depth_failsafe"]
if recursed.recursion_overflow:
outcome = "depth_failsafe"
elif recursed.evidence and recursed.polarity != "undetermined":
outcome = "grounded"
elif recursed.evidence:
# Has evidence but composed to undetermined: treat as
# grounded (evidence exists) — the parent's compose step
# will see the pointers and may still go undetermined.
outcome = "grounded"
else:
outcome = "gap_recorded"
sub_results.append(SubQuestion(
sub_id=_sub_id(candidate.candidate_id, index, payload),
proposed_subject=str(payload.get("subject") or ""),
proposed_intent=str(payload.get("intent") or ""),
outcome=outcome,
evidence=recursed.evidence,
))
sub_tuple = tuple(sub_results)
polarity = _compose_polarity(direct_evidence, sub_tuple)
domain = _classify_claim_domain(candidate.proposed_chain)
# Composition rule from ADR-0056: mixed evidence ⇒
# ``undetermined`` AND claim_domain upgrades one tier.
if polarity == "undetermined":
all_ptrs = list(direct_evidence) + [p for sq in sub_tuple for p in sq.evidence]
has_aff = any(p.polarity == "affirms" for p in all_ptrs)
has_fal = any(p.polarity == "falsifies" for p in all_ptrs)
if has_aff and has_fal:
domain = _upgrade_domain(domain)
return replace(
candidate,
polarity=polarity,
claim_domain=domain,
evidence=direct_evidence,
sub_questions=sub_tuple,
)
# ---------------------------------------------------------------------------
# ADR-0163 Phase C — exemplar-corpus contemplation
# ---------------------------------------------------------------------------
def _exemplar_candidate_id(corpus_digest: str, spec_digest: str) -> str:
"""Deterministic candidate id for an exemplar-derived contemplation.
Hash over the corpus digest + the spec digest: identical corpora
yield identical specs yield identical candidate ids. Re-running the
contemplation pipeline against an unchanged corpus is a no-op for
the proposal log (idempotency via ProposalLog.find).
"""
blob = json.dumps(
{"corpus_digest": corpus_digest, "spec_digest": spec_digest},
sort_keys=True,
separators=(",", ":"),
)
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
def contemplate_exemplar_corpus(corpus: Any) -> DiscoveryCandidate:
"""Return a :class:`DiscoveryCandidate` distilled from *corpus*.
Ingests a single :class:`~teaching.exemplar_ingest.ExemplarCorpus`,
synthesizes its :class:`~teaching.recognizer_synthesis.RecognizerSpec`,
and serializes both into a complete-shape ``DiscoveryCandidate`` that
the existing proposal pipeline can consume.
Trust boundary
- Pure: no filesystem writes, no global state, no LLM, no
stochastic sampling.
- The returned candidate carries ``polarity="affirms"`` — exemplars
are reviewed-evidence-floor material under ADR-0163 §Phase B —
and one ``EvidencePointer`` per ingested exemplar, sourced from
the exemplar corpus itself. ``ref`` strings carry the verbatim
``case_id`` (when present) or ``exemplar:<exemplar_id>`` so the
proposal log records every seed cited.
- Encodes the recognizer-shaped chain as a synthetic
``(shape_category, "admissibility", "recognizes", spec_digest)``
tuple so ``proposed_chain`` satisfies the four-field completeness
gate enforced by ``check_eligibility``. The full
:class:`RecognizerSpec` rides along as a ``recognizer_spec``
sub-mapping on ``proposed_chain``.
"""
# Deferred imports keep this module's import cost cheap for
# callers that never trigger Phase C ingest.
from teaching.exemplar_ingest import ExemplarCorpus
from teaching.recognizer_synthesis import (
RecognizerSpec,
synthesize_recognizer,
)
if not isinstance(corpus, ExemplarCorpus):
raise TypeError(
f"contemplate_exemplar_corpus expects ExemplarCorpus; got "
f"{type(corpus).__name__}"
)
spec: RecognizerSpec = synthesize_recognizer(corpus)
spec_digest = spec.spec_digest()
proposed_chain: dict[str, Any] = {
"subject": spec.shape_category.value,
"intent": "admissibility",
"connective": "recognizes",
"object": spec_digest,
"recognizer_spec": spec.as_dict(),
}
evidence: tuple[EvidencePointer, ...] = tuple(
EvidencePointer(
source="corpus",
ref=(
f"exemplar:{ex.case_id}"
if ex.case_id
else f"exemplar:{ex.exemplar_id}"
),
polarity="affirms",
epistemic_status="coherent",
)
for ex in corpus.exemplars
)
candidate_id = _exemplar_candidate_id(corpus.corpus_digest, spec_digest)
return DiscoveryCandidate(
candidate_id=candidate_id,
proposed_chain=proposed_chain,
trigger="would_have_grounded",
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",
evidence=evidence,
sub_questions=(),
contemplation_depth=0,
recursion_overflow=False,
)
__all__ = [
"contemplate",
"contemplate_exemplar_corpus",
]