"""ADR-0055 Phase B — DiscoveryCandidate emission from the turn loop. A ``DiscoveryCandidate`` is **structured evidence** — never a mutation. When a turn's audit trail satisfies a deterministic predicate, an entry is emitted to the discovery candidate stream. Candidates **never** load into the active teaching corpus; the only path to corpus extension is the review-gated ``TeachingChainProposal`` (Phase C, not yet built). Trigger set (Phase B lands the first; the others are stubbed in the ``Literal`` so the structure is stable when later phases add them): - ``would_have_grounded`` — the turn fell through to the universal "insufficient grounding" disclosure, the classified intent was ``CAUSE`` or ``VERIFICATION``, the subject lemma is in the ratified cognition pack, and no active chain matched ``(subject, intent)``. A reviewed chain of that subject/intent would have grounded the turn. - ``successful_comparison`` — open question §5 in ADR-0055; not fired in Phase B. - ``hedge_acknowledged`` — open question §5 in ADR-0055; not fired in Phase B. - ``oov_resolved_via_decomp`` — not fired in Phase B. Determinism contract: - ``extract_discovery_candidates`` is a pure function of its inputs. - ``candidate_id`` is a SHA-256 hash of a canonical JSON encoding of the candidate's load-bearing fields; identical inputs always produce the identical id. - No LLM, no stochastic sampling, no clock-time read. Trust boundary: - This module reads pack + corpus indices and a ``TurnEvent``. It never writes to the corpus, the pack, or runtime state. - The ``source_turn_trace`` is the upstream ``TurnEvent.trace_hash`` when present; absent that, the empty string. Tying every candidate to a replayable turn is the load-bearing audit property. """ from __future__ import annotations import hashlib import json import math from dataclasses import dataclass from typing import Any, Literal from generate.intent import IntentTag # ``chat.pack_grounding`` and ``chat.teaching_grounding`` are # imported lazily inside ``extract_discovery_candidates`` to break a # circular import chain when an entry-point (e.g. the CLI) imports # ``teaching.proposals`` → ``teaching.discovery`` before ``chat`` # has been fully initialized. DiscoveryTrigger = Literal[ "would_have_grounded", "successful_comparison", "hedge_acknowledged", "oov_resolved_via_decomp", # Third-Door Super §3.2 / fidelity #20: high geometric surprise on the # dual Procrustes–Surprise operator (epistemic frontier signal). "high_surprise", ] # ADR-0056 Phase C1: typed claim domain for the contemplation loop. ClaimDomain = Literal["factual", "relational", "evaluative"] @dataclass(frozen=True, slots=True) class EvidencePointer: """One unit of admissible evidence used by the contemplation loop. Only three source families admit a pointer: reviewed teaching corpus chains, ratified pack atoms, and vault entries stamped ``EpistemicStatus.COHERENT``. SPECULATIVE / CONTESTED / FALSIFIED vault entries contest but do not contribute as evidence. """ source: Literal["corpus", "pack", "vault_coherent"] ref: str polarity: Literal["affirms", "falsifies"] epistemic_status: str def as_dict(self) -> dict[str, Any]: return { "source": self.source, "ref": self.ref, "polarity": self.polarity, "epistemic_status": self.epistemic_status, } @classmethod def from_dict(cls, payload: dict[str, Any]) -> "EvidencePointer": return cls( source=payload["source"], ref=payload["ref"], polarity=payload["polarity"], epistemic_status=payload["epistemic_status"], ) @dataclass(frozen=True, slots=True) class SubQuestion: """One decomposed sub-question + its outcome (ADR-0056 §SubQuestion). ``outcome="gap_recorded"`` is the load-bearing case from Call 1 in ADR-0056: the sub-question could not be decomposed further so the system records the gap and stops. """ sub_id: str proposed_subject: str proposed_intent: str outcome: Literal["grounded", "gap_recorded", "depth_failsafe"] evidence: tuple[EvidencePointer, ...] = () def as_dict(self) -> dict[str, Any]: return { "sub_id": self.sub_id, "proposed_subject": self.proposed_subject, "proposed_intent": self.proposed_intent, "outcome": self.outcome, "evidence": [e.as_dict() for e in self.evidence], } @classmethod def from_dict(cls, payload: dict[str, Any]) -> "SubQuestion": return cls( sub_id=payload["sub_id"], proposed_subject=payload["proposed_subject"], proposed_intent=payload["proposed_intent"], outcome=payload["outcome"], evidence=tuple( EvidencePointer.from_dict(e) for e in payload.get("evidence", []) ), ) @dataclass(frozen=True, slots=True) class DiscoveryCandidate: """Structured evidence that a reviewed chain would have helped. Phase B emits the Phase-B fields only. ADR-0056 Phase C1 adds typed contemplation fields (``polarity``, ``claim_domain``, ``evidence``, ``sub_questions``, ``contemplation_depth``, ``recursion_overflow``). Defaults make a freshly-emitted Phase B candidate a trivially-valid un-contemplated C1 candidate. """ candidate_id: str proposed_chain: dict[str, Any] trigger: DiscoveryTrigger source_turn_trace: str pack_consistent: bool boundary_clean: bool review_state: Literal["unreviewed"] = "unreviewed" domain: Literal["cognition", "math"] = "cognition" # Phase C1 fields. Defaults preserve byte-equality with Phase B # ``as_dict`` output when the candidate has not been contemplated. polarity: Literal["affirms", "falsifies", "undetermined"] = "undetermined" claim_domain: ClaimDomain = "factual" evidence: tuple[EvidencePointer, ...] = () sub_questions: tuple[SubQuestion, ...] = () contemplation_depth: int = 0 recursion_overflow: bool = False def as_dict(self) -> dict[str, Any]: out: dict[str, Any] = { "candidate_id": self.candidate_id, "proposed_chain": self.proposed_chain, "trigger": self.trigger, "source_turn_trace": self.source_turn_trace, "pack_consistent": self.pack_consistent, "boundary_clean": self.boundary_clean, "review_state": self.review_state, } if self.domain != "cognition": out["domain"] = self.domain # Phase C1 fields are emitted only when contemplation has # produced non-default content. This keeps a freshly-emitted # Phase B candidate's JSONL line byte-identical to the pre-C1 # encoding. if ( self.polarity != "undetermined" or self.claim_domain != "factual" or self.evidence or self.sub_questions or self.contemplation_depth != 0 or self.recursion_overflow ): out["polarity"] = self.polarity out["claim_domain"] = self.claim_domain out["evidence"] = [e.as_dict() for e in self.evidence] out["sub_questions"] = [s.as_dict() for s in self.sub_questions] out["contemplation_depth"] = self.contemplation_depth out["recursion_overflow"] = self.recursion_overflow return out @classmethod def from_dict(cls, payload: dict[str, Any]) -> "DiscoveryCandidate": return cls( candidate_id=payload["candidate_id"], proposed_chain=payload["proposed_chain"], trigger=payload["trigger"], source_turn_trace=payload["source_turn_trace"], pack_consistent=payload["pack_consistent"], boundary_clean=payload["boundary_clean"], review_state=payload.get("review_state", "unreviewed"), domain=payload.get("domain", "cognition"), polarity=payload.get("polarity", "undetermined"), claim_domain=payload.get("claim_domain", "factual"), evidence=tuple( EvidencePointer.from_dict(e) for e in payload.get("evidence", []) ), sub_questions=tuple( SubQuestion.from_dict(s) for s in payload.get("sub_questions", []) ), contemplation_depth=payload.get("contemplation_depth", 0), recursion_overflow=payload.get("recursion_overflow", False), ) _TEACHING_INTENT_NAME: dict[IntentTag, str] = { IntentTag.CAUSE: "cause", IntentTag.VERIFICATION: "verification", } def _hash_candidate_id(payload: dict[str, Any]) -> str: """Deterministic SHA-256 over a canonical JSON encoding. Sorted keys + tight separators keep the hash stable across Python runtimes and dict-insertion order. This is the ``candidate_id`` — used both as the on-disk JSONL line key and by Phase C to look up the originating candidate. """ blob = json.dumps(payload, sort_keys=True, separators=(",", ":")) return hashlib.sha256(blob.encode("utf-8")).hexdigest() def _boundary_clean(turn_event: Any) -> bool: """Return True iff the source turn produced no safety/ethics refusal and no hedge injection. Tolerates events that pre-date the bundled-verdicts era (ADR-0039 onward) by reading the canonical fields directly. """ refusal_emitted = bool(getattr(turn_event, "refusal_emitted", False) or False) hedge_injected = bool(getattr(turn_event, "hedge_injected", False) or False) if refusal_emitted or hedge_injected: return False verdicts = getattr(turn_event, "verdicts", None) if verdicts is not None: if bool(getattr(verdicts, "refusal_emitted", False) or False): return False if bool(getattr(verdicts, "hedge_injected", False) or False): return False return True def _trace_hash(turn_event: Any) -> str: value = getattr(turn_event, "trace_hash", "") or "" return str(value) def extract_discovery_candidates( turn_event: Any, intent_tag: IntentTag | None, intent_subject_lemma: str | None, *, grounding_source: str | None = None, ) -> tuple[DiscoveryCandidate, ...]: """Return zero or more DiscoveryCandidates for a single turn. Phase B only fires the ``would_have_grounded`` trigger. All other triggers in the ``DiscoveryTrigger`` Literal are reserved for later phases. Fires when **every** condition holds (deterministic predicate): 1. ``grounding_source`` is ``"none"`` or absent — the turn fell through to the universal disclosure. 2. ``intent_tag`` is ``CAUSE`` or ``VERIFICATION`` — the intent set the teaching-grounded surface answers. 3. ``intent_subject_lemma`` is a non-empty pack lemma in the ratified cognition pack. 4. ``(subject_lemma, intent_name)`` is **not** in the active corpus — a chain of that shape would have grounded the turn but does not exist. Order of conditions matters for tests: short-circuit on the cheapest predicate first. """ source = (grounding_source or getattr(turn_event, "grounding_source", "none") or "none").lower() if source != "none": return () if intent_tag is None or intent_tag not in _TEACHING_INTENT_NAME: return () if not intent_subject_lemma or not isinstance(intent_subject_lemma, str): return () lemma = intent_subject_lemma.strip().lower() if not lemma: return () from chat.pack_resolver import is_resolvable from chat.teaching_grounding import _all_chains_index # ADR-0064 — discovery gate uses cross-pack residency (any mounted # lexicon pack) AND cross-corpus chain lookup (any registered # teaching corpus). A kinship CAUSE prompt whose subject is in # the relations pack but has no relations-chain in the active # corpus is now also a discovery signal. if not is_resolvable(lemma): return () intent_name = _TEACHING_INTENT_NAME[intent_tag] if (lemma, intent_name) in _all_chains_index(): return () # The candidate's proposed_chain is intentionally partial: Phase B # can only assert that a chain of this (subject, intent) would # have helped. Connective and object remain null; Phase C is # where a complete proposed entry is constructed and review-gated. proposed_chain = { "subject": lemma, "intent": intent_name, "connective": None, "object": None, } trace_hash = _trace_hash(turn_event) boundary_clean = _boundary_clean(turn_event) trigger: DiscoveryTrigger = "would_have_grounded" hash_payload = { "proposed_chain": proposed_chain, "trigger": trigger, "source_turn_trace": trace_hash, } candidate_id = _hash_candidate_id(hash_payload) candidate = DiscoveryCandidate( candidate_id=candidate_id, proposed_chain=proposed_chain, trigger=trigger, source_turn_trace=trace_hash, pack_consistent=True, # subject is in pack; object is null pending Phase C boundary_clean=boundary_clean, review_state="unreviewed", ) return (candidate,) def format_candidate_jsonl(candidate: DiscoveryCandidate) -> str: """Serialise to one JSONL line (sorted keys, no trailing newline).""" return json.dumps(candidate.as_dict(), sort_keys=True, separators=(",", ":")) def candidate_from_surprise_dual( dual: dict[str, Any], *, source_turn_trace: str = "", discovery_gamma: float | None = None, ) -> DiscoveryCandidate | None: """Build a proposal-only ``DiscoveryCandidate`` from a dual-operator audit dict. Pure function of its inputs (plus the dual dict). Fires only when the dual result is discovery-eligible: measured surprise above γ, not a productive transfer, and no metric refusal. Never writes vault / packs / corpus. ``dual`` is the audit dict from :func:`core.physics.surprise.dual_operator` or :func:`core.physics.surprise.dual_procrustes_surprise` (must carry ``surprise_norm``; preferably also ``discovery_eligible``). Returns ``None`` when the signal is not a high-surprise discovery. """ from core.physics.surprise import ( DEFAULT_DISCOVERY_GAMMA, is_discovery_eligible, ) if not isinstance(dual, dict): return None gamma = float( discovery_gamma if discovery_gamma is not None else dual.get("discovery_gamma", DEFAULT_DISCOVERY_GAMMA) ) sur_norm = float(dual.get("surprise_norm", float("nan"))) refused = dual.get("surprise_refused") productive = bool( dual.get("productive", False) or dual.get("transfer_accepted", False) ) # Prefer the physics flag when present; recompute otherwise so older dual # dicts without the field still route correctly. if "discovery_eligible" in dual: eligible = bool(dual["discovery_eligible"]) else: eligible = is_discovery_eligible( surprise_norm=sur_norm, productive_or_transfer=productive, surprise_refused=refused if isinstance(refused, str) else None, discovery_gamma=gamma, ) if not eligible: return None proc_r = dual.get("procrustes_residual") try: proc_f = float(proc_r) if proc_r is not None and math.isfinite(float(proc_r)) else None except (TypeError, ValueError): proc_f = None analog_id = dual.get("selected_analog_id") # Partial proposed chain — geometric frontier evidence, not a ready teaching # chain. Review / Phase C remains the only path to corpus extension. proposed_chain: dict[str, Any] = { "subject": "geometric_frontier", "intent": "discovery", "connective": None, "object": None, "kind": "high_surprise", "surprise_norm": sur_norm, "discovery_gamma": gamma, } if proc_f is not None: proposed_chain["procrustes_residual"] = proc_f if analog_id is not None: proposed_chain["selected_analog_id"] = str(analog_id) trace = str(source_turn_trace or "") trigger: DiscoveryTrigger = "high_surprise" hash_payload = { "proposed_chain": proposed_chain, "trigger": trigger, "source_turn_trace": trace, } candidate_id = _hash_candidate_id(hash_payload) return DiscoveryCandidate( candidate_id=candidate_id, proposed_chain=proposed_chain, trigger=trigger, source_turn_trace=trace, pack_consistent=True, # geometric signal; pack residency N/A boundary_clean=True, review_state="unreviewed", domain="math", ) def emit_surprise_discovery( dual: dict[str, Any], sink: Any | None = None, *, source_turn_trace: str = "", discovery_gamma: float | None = None, ) -> DiscoveryCandidate | None: """Opt-in sink emission of a high-surprise ``DiscoveryCandidate``. Pure construction via :func:`candidate_from_surprise_dual`. When ``sink`` is provided and a candidate is produced, emits one JSONL line through the existing :class:`~teaching.discovery_sink.DiscoveryCandidateSink` protocol (same stream contemplation already consumes). Without a sink this is a pure factory. Never mutates vault, packs, or the teaching corpus. """ candidate = candidate_from_surprise_dual( dual, source_turn_trace=source_turn_trace, discovery_gamma=discovery_gamma, ) if candidate is None: return None if sink is not None: emit = getattr(sink, "emit", None) if emit is None or not callable(emit): raise TypeError( "emit_surprise_discovery: sink must provide emit(line: str) " "(DiscoveryCandidateSink protocol)" ) emit(format_candidate_jsonl(candidate)) return candidate __all__ = [ "ClaimDomain", "DiscoveryCandidate", "DiscoveryTrigger", "EvidencePointer", "SubQuestion", "candidate_from_surprise_dual", "emit_surprise_discovery", "extract_discovery_candidates", "format_candidate_jsonl", ]