core/teaching/discovery.py
Shay 995f4fc3b8
Some checks failed
lane-shas / verify pinned lane SHAs (pull_request) Failing after 15m37s
smoke / smoke (-m "not quarantine") (pull_request) Failing after 22m54s
feat(third-door): wire high surprise to DiscoveryCandidate (#20 follow-up)
- Physics dual operators set discovery_eligible (γ=0.35); is_discovery_eligible
  pure predicate; no teaching/vault imports in core.physics.surprise.
- teaching.discovery: trigger high_surprise; candidate_from_surprise_dual +
  emit_surprise_discovery (opt-in sink, proposal-only unreviewed, domain=math).
- Boundary tests: threshold gates, determinism, no VaultStore, no teaching import
  in physics. Ledger §6 notes wiring surface (issue #30).

Does not self-install; contemplation consumes via existing DiscoveryCandidateSink.
2026-07-13 17:25:17 -07:00

507 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 ProcrustesSurprise 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",
]