"""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 / "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 {} # --------------------------------------------------------------------------- # 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:`` 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", ]