From 995f4fc3b8affbfcab8dac8506e2cc261ea370f2 Mon Sep 17 00:00:00 2001 From: Shay Date: Mon, 13 Jul 2026 17:25:17 -0700 Subject: [PATCH] feat(third-door): wire high surprise to DiscoveryCandidate (#20 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- core/physics/__init__.py | 12 +- core/physics/surprise.py | 78 ++++++- .../research/third-door-blueprint-fidelity.md | 11 +- teaching/discovery.py | 134 +++++++++++ tests/test_adr_0239_surprise_dual.py | 2 + ...st_third_door_surprise_discovery_wiring.py | 213 ++++++++++++++++++ 6 files changed, 438 insertions(+), 12 deletions(-) create mode 100644 tests/test_third_door_surprise_discovery_wiring.py diff --git a/core/physics/__init__.py b/core/physics/__init__.py index e7247084..ba478a75 100644 --- a/core/physics/__init__.py +++ b/core/physics/__init__.py @@ -44,7 +44,13 @@ from core.physics.dynamic_manifold import ( signature_aware_pca, signature_aware_pca_report, ) -from core.physics.surprise import dual_operator, dual_procrustes_surprise, surprise_residual +from core.physics.surprise import ( + DEFAULT_DISCOVERY_GAMMA, + dual_operator, + dual_procrustes_surprise, + is_discovery_eligible, + surprise_residual, +) from core.physics.biography import ( BiographyHolonomyBlade, biography_telemetry, @@ -79,7 +85,9 @@ __all__ = [ "cartan_iwasawa_extract", "cartan_iwasawa_factorize", "conformal_procrustes", "dual_correction_slerp", "procrustes_residual", "signature_aware_pca", "signature_aware_pca_report", - "dual_operator", "dual_procrustes_surprise", "surprise_residual", + "DEFAULT_DISCOVERY_GAMMA", + "dual_operator", "dual_procrustes_surprise", "is_discovery_eligible", + "surprise_residual", "BiographyHolonomyBlade", "biography_telemetry", "integrate_biography", "reconstruct_biography", "TemporalAdmissibilityGate", "TemporalContext", diff --git a/core/physics/surprise.py b/core/physics/surprise.py index 063703d1..674a8510 100644 --- a/core/physics/surprise.py +++ b/core/physics/surprise.py @@ -49,6 +49,14 @@ _NEAR_ZERO = 1e-12 _CLOSURE_TOL = 1e-6 _GRADE_TOL = 1e-9 _MAX_GRADE = 5 +# Blueprint Super §3.2 discovery gate: high surprise (above γ) is a discovery +# signal, not a productive transfer. Shared default γ for dual_operator and the +# discovery_eligible flag on dual_procrustes_surprise. Teaching-layer factories +# may override; physics never imports teaching. +DEFAULT_DISCOVERY_GAMMA = 0.35 +# Productive-transfer surprise ceiling on the dual_procrustes_surprise path +# (stricter than discovery γ — inside the admissible span for safe transport). +_TRANSFER_SURPRISE_TOL = 1e-4 class SurpriseResidualError(ValueError): @@ -196,18 +204,45 @@ def surprise_residual( raise ValueError("surprise_residual expects 5-vector or 32-vector x") +def is_discovery_eligible( + *, + surprise_norm: float, + productive_or_transfer: bool, + surprise_refused: Optional[str] = None, + discovery_gamma: float = DEFAULT_DISCOVERY_GAMMA, +) -> bool: + """True iff high surprise is a discovery signal (Super §3.2). + + Discovery requires a measured residual above γ, no metric refusal, and + **not** a productive transfer (transfer and discovery are dual, not the + same gate). Pure predicate — no I/O, no teaching import. + """ + if surprise_refused is not None: + return False + if productive_or_transfer: + return False + sn = float(surprise_norm) + if not np.isfinite(sn): + return False + return sn > float(discovery_gamma) + + def dual_procrustes_surprise( P: np.ndarray, Q: np.ndarray, current_basis: np.ndarray, + *, + discovery_gamma: float = DEFAULT_DISCOVERY_GAMMA, ) -> dict: """The dual operator: run Procrustes and Surprise together. ``transfer_accepted`` is a PRODUCTIVE-TRANSFER gate: a structural match was found (low Procrustes residual) AND the probe is inside the admissible span (low surprise) — i.e. it is safe to transport ``P``'s solution to ``Q``. High - surprise is NOT a transfer; it is a *discovery* signal (wired separately). A - fail-closed surprise refusal (degenerate basis) is recorded, not raised. + surprise is NOT a transfer; it is a *discovery* signal + (``discovery_eligible``). A fail-closed surprise refusal (degenerate basis) + is recorded, not raised. Physics never constructs teaching objects — callers + pass this dict to ``teaching.discovery.candidate_from_surprise_dual``. """ V, proc_residual = conformal_procrustes(P, Q) Q_arr = np.asarray(Q, dtype=np.float64) @@ -232,16 +267,27 @@ def dual_procrustes_surprise( if np.asarray(V).shape == (N_COMPONENTS,): closed = versor_condition(V) < _CLOSURE_TOL + transfer_accepted = bool( + refused is None + and proc_residual < 1e-5 + and sur_norm < _TRANSFER_SURPRISE_TOL + and closed + ) return { "versor": V, "procrustes_residual": float(proc_residual), "surprise_vector": sur_vec, "surprise_norm": float(sur_norm), - "transfer_accepted": bool( - refused is None and proc_residual < 1e-5 and sur_norm < 1e-4 and closed - ), + "transfer_accepted": transfer_accepted, "versor_closed": bool(closed), "surprise_refused": refused, + "discovery_eligible": is_discovery_eligible( + surprise_norm=float(sur_norm), + productive_or_transfer=transfer_accepted, + surprise_refused=refused, + discovery_gamma=discovery_gamma, + ), + "discovery_gamma": float(discovery_gamma), } @@ -255,6 +301,7 @@ def dual_operator( kappa: float = 1.0, productive_threshold: float = 0.35, surprise_threshold: float = 0.35, + discovery_gamma: float | None = None, ) -> dict: """Extended dual for multivector analogy seeds (ADR-0240 harness). @@ -262,7 +309,9 @@ def dual_operator( a structural match (``proc_r <= thr``) AND the query being inside the admissible span (``sur_norm <= surprise_threshold``). The previous ``sur_norm >= 0.0`` conjunct was always true, so surprise never gated. A HIGH - ``sur_norm`` marks a discovery candidate, not a productive transfer. + ``sur_norm`` marks discovery (``discovery_eligible``), not a productive + transfer. Teaching constructs candidates from this flag; this module never + imports teaching. """ if isinstance(basis, np.ndarray): B = basis @@ -276,7 +325,10 @@ def dual_operator( except SurpriseResidualError as exc: sur_vec, sur_norm, surprise_refused = None, float("inf"), exc.reason + gamma = float(surprise_threshold if discovery_gamma is None else discovery_gamma) + if not analogs: + productive = False return { "surprise_norm": float(sur_norm), "procrustes_residual": float("inf"), @@ -285,6 +337,13 @@ def dual_operator( "selected_analog_id": None, "versor": None, "surprise_refused": surprise_refused, + "discovery_eligible": is_discovery_eligible( + surprise_norm=float(sur_norm), + productive_or_transfer=productive, + surprise_refused=surprise_refused, + discovery_gamma=gamma, + ), + "discovery_gamma": gamma, } aid, src, tgt = analogs[0] V, proc_r = conformal_procrustes(src, tgt) @@ -303,4 +362,11 @@ def dual_operator( "versor": V, "surprise_vector": sur_vec, "surprise_refused": surprise_refused, + "discovery_eligible": is_discovery_eligible( + surprise_norm=float(sur_norm), + productive_or_transfer=bool(productive), + surprise_refused=surprise_refused, + discovery_gamma=gamma, + ), + "discovery_gamma": gamma, } diff --git a/docs/research/third-door-blueprint-fidelity.md b/docs/research/third-door-blueprint-fidelity.md index 26b5a0b3..6afc0485 100644 --- a/docs/research/third-door-blueprint-fidelity.md +++ b/docs/research/third-door-blueprint-fidelity.md @@ -31,7 +31,7 @@ | 3 | Conformal Procrustes | Super §3.1 | 🟢 faithful (Kabsch + field conjugacy) | #17 | | 4 | GoldTether residual + α law | Super §2.3, R&D §2.3/§5 | 🟡 partial (#24 residual+α; bootstrap/prune deferred) | #18 | | 5 | Grade-5 pseudoscalar invariant | Super §3.3 | ⚪ RETIRED — vacuous in odd-dim Cl(4,1) | #19 (closed) | -| 6 | Surprise residual operator | Super §3.2 | 🟢 operator math fixed (metric proj + polarity); wiring split | #20 | +| 6 | Surprise residual operator | Super §3.2 | 🟢 math fixed; DiscoveryCandidate wiring on branch (issue #30) | #20 | | 7 | Trajectory invariants + zero-fabrication | R&D §2.2 | ⚫ absent | #21 | | 8 | ADR-DAG conformal embedding | R&D §2.4 | ⚫ absent | #21 | | — | Biography holonomy | (ADR-0240; not in blueprints) | 🟢 sound | — | @@ -159,8 +159,11 @@ This diagnostic is what killed §3.3, and it should be applied to every remainin - **Reconciled productivity polarity.** `productive` (and `transfer_accepted`) now both mean **productive TRANSFER = low Procrustes ∧ low surprise** (a structural match found AND the query inside the admissible span). This **corrects** §6's earlier "high surprise ∧ low procrustes = productive" phrasing, which conflated *transfer* with *discovery*: HIGH surprise is a **discovery** signal, not a productive transfer, and routes to the (split-out) `DiscoveryCandidate` path. - Tests: `tests/test_adr_0239_surprise_metric_projection.py` — metric-vs-Euclidean divergence (the load-bearing proof), null refusal, null-pair admission, redundant-basis admission, in/out-of-span, grade purity, polarity, determinism. -### Remaining (follow-up — its own PR) -Raise a `DiscoveryCandidate` (`teaching/discovery.py`) on high surprise into the contemplation loop, behind the existing proposal-only / no-self-install discipline, with no-self-install boundary tests. +### Remaining → wiring landed (issue #30 / this surface) +High surprise now routes to a proposal-only `DiscoveryCandidate`: +- Physics (`dual_operator` / `dual_procrustes_surprise`) sets `discovery_eligible` when `sur_norm > γ` and transfer is not productive (`is_discovery_eligible`; default `γ = 0.35`). **No teaching import** in `core.physics.surprise`. +- Teaching factory: `candidate_from_surprise_dual` / `emit_surprise_discovery` produce `trigger=high_surprise`, `review_state=unreviewed`, `domain=math`. Opt-in `DiscoveryCandidateSink` emit (same stream contemplation already consumes). **Never** vault/store/self-install. +- Tests: `tests/test_third_door_surprise_discovery_wiring.py`. --- @@ -238,7 +241,7 @@ PY | Kabsch-conformal Procrustes on point sets | #17 | | GoldTether gold-set + harmonized residual + α=Φ(R) | #18 | | Grade-5 pseudoscalar preservation gate — ⚪ RETIRED (vacuous; see §5) | #19 (closed) | -| Surprise: metric projection + productivity polarity — 🟢 done (#20); DiscoveryCandidate wiring split to follow-up | #20 | +| Surprise: metric projection + productivity polarity — 🟢 done (#20); DiscoveryCandidate wiring — issue #30 / branch `feat/third-door-surprise-discovery-wiring` | #20 | | Absent proposals: sensorimotor + ADR-DAG | #21 | Closing a gap = flip its `xfail` in `tests/test_third_door_blueprint_fidelity.py` to a passing behavioral test and delete the matching characterization lock. That is the definition of "done right" here. diff --git a/teaching/discovery.py b/teaching/discovery.py index 17d6d19a..f3263bbe 100644 --- a/teaching/discovery.py +++ b/teaching/discovery.py @@ -48,6 +48,7 @@ from __future__ import annotations import hashlib import json +import math from dataclasses import dataclass from typing import Any, Literal @@ -65,6 +66,9 @@ DiscoveryTrigger = Literal[ "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", ] @@ -362,12 +366,142 @@ def format_candidate_jsonl(candidate: DiscoveryCandidate) -> str: 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", ] diff --git a/tests/test_adr_0239_surprise_dual.py b/tests/test_adr_0239_surprise_dual.py index 1f71ead1..9fa519d5 100644 --- a/tests/test_adr_0239_surprise_dual.py +++ b/tests/test_adr_0239_surprise_dual.py @@ -54,7 +54,9 @@ def test_dual_procrustes_surprise_audit_dict(): assert "procrustes_residual" in out assert "surprise_norm" in out assert "transfer_accepted" in out + assert "discovery_eligible" in out assert isinstance(out["transfer_accepted"], bool) + assert isinstance(out["discovery_eligible"], bool) if np.asarray(out["versor"]).shape == (32,): assert versor_condition(out["versor"]) < 1e-6 diff --git a/tests/test_third_door_surprise_discovery_wiring.py b/tests/test_third_door_surprise_discovery_wiring.py new file mode 100644 index 00000000..d089bf9e --- /dev/null +++ b/tests/test_third_door_surprise_discovery_wiring.py @@ -0,0 +1,213 @@ +"""Third-Door #20 follow-up: high surprise → DiscoveryCandidate wiring. + +Ledger §6 remaining surface (issue #30): + - physics marks discovery_eligible (no teaching import) + - teaching builds proposal-only DiscoveryCandidate (trigger=high_surprise) + - opt-in sink emit; never VaultStore / self-install +""" + +from __future__ import annotations + +import inspect +import json + +import numpy as np +import pytest + +from algebra.cl41 import N_COMPONENTS +from algebra.rotor import make_rotor_from_angle +from core.physics import surprise as surprise_mod +from core.physics.surprise import ( + DEFAULT_DISCOVERY_GAMMA, + dual_operator, + dual_procrustes_surprise, + is_discovery_eligible, + surprise_residual, +) +from teaching.discovery import ( + candidate_from_surprise_dual, + emit_surprise_discovery, +) +from teaching.discovery_sink import DiscoveryBufferSink + + +def _id32() -> np.ndarray: + v = np.zeros(N_COMPONENTS, dtype=np.float64) + v[0] = 1.0 + return v + + +def _basis_identity() -> np.ndarray: + """Single-column basis = identity versor (admits only near-identity probes).""" + return _id32().reshape(N_COMPONENTS, 1) + + +# --------------------------------------------------------------------------- +# Physics layer — discovery_eligible flag +# --------------------------------------------------------------------------- + + +def test_is_discovery_eligible_predicate() -> None: + assert is_discovery_eligible( + surprise_norm=0.5, productive_or_transfer=False, discovery_gamma=0.35 + ) + assert not is_discovery_eligible( + surprise_norm=0.1, productive_or_transfer=False, discovery_gamma=0.35 + ) + assert not is_discovery_eligible( + surprise_norm=0.9, productive_or_transfer=True, discovery_gamma=0.35 + ) + assert not is_discovery_eligible( + surprise_norm=0.9, + productive_or_transfer=False, + surprise_refused="degenerate_metric_span", + discovery_gamma=0.35, + ) + assert not is_discovery_eligible( + surprise_norm=float("inf"), productive_or_transfer=False + ) + + +def test_dual_procrustes_marks_discovery_on_out_of_span_probe() -> None: + """Non-identity probe against identity-only basis → high surprise → discovery.""" + basis = _basis_identity() + src = _id32() + tgt = make_rotor_from_angle(0.9, bivector_idx=6) + dual = dual_procrustes_surprise(src, tgt, basis) + assert dual["surprise_refused"] is None + assert dual["surprise_norm"] > DEFAULT_DISCOVERY_GAMMA + assert dual["transfer_accepted"] is False + assert dual["discovery_eligible"] is True + assert dual["discovery_gamma"] == DEFAULT_DISCOVERY_GAMMA + + +def test_dual_procrustes_no_discovery_on_identity_transfer() -> None: + basis = _basis_identity() + src = _id32() + dual = dual_procrustes_surprise(src, src, basis) + assert dual["surprise_norm"] < 1e-9 + assert dual["discovery_eligible"] is False + + +def test_dual_operator_discovery_eligible_when_surprise_high() -> None: + x = make_rotor_from_angle(1.1, bivector_idx=7) + basis = _basis_identity() + analogs = [("a", _id32(), make_rotor_from_angle(0.4, bivector_idx=6))] + out = dual_operator(x, basis, analogs, surprise_threshold=0.35) + assert out["productive"] is False + assert out["surprise_norm"] > 0.35 + assert out["discovery_eligible"] is True + + +def test_physics_surprise_does_not_import_teaching_or_vault() -> None: + """No teaching/vault *imports* (docstrings may mention teaching by name).""" + import ast + from pathlib import Path + + tree = ast.parse(Path(surprise_mod.__file__).read_text(encoding="utf-8")) + imported: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + imported.add(alias.name.split(".")[0]) + elif isinstance(node, ast.ImportFrom) and node.module: + imported.add(node.module.split(".")[0]) + assert "teaching" not in imported + assert "vault" not in imported + assert "VaultStore" not in inspect.getsource(surprise_mod) + + +# --------------------------------------------------------------------------- +# Teaching factory — proposal-only DiscoveryCandidate +# --------------------------------------------------------------------------- + + +def test_candidate_from_high_surprise_dual() -> None: + dual = dual_procrustes_surprise( + _id32(), make_rotor_from_angle(0.9, bivector_idx=6), _basis_identity() + ) + assert dual["discovery_eligible"] is True + c = candidate_from_surprise_dual(dual, source_turn_trace="trace-abc") + assert c is not None + assert c.trigger == "high_surprise" + assert c.review_state == "unreviewed" + assert c.domain == "math" + assert c.proposed_chain["kind"] == "high_surprise" + assert c.proposed_chain["subject"] == "geometric_frontier" + assert c.proposed_chain["intent"] == "discovery" + assert c.source_turn_trace == "trace-abc" + assert c.boundary_clean is True + assert float(c.proposed_chain["surprise_norm"]) == pytest.approx( + dual["surprise_norm"] + ) + + +def test_candidate_absent_when_not_eligible() -> None: + dual = dual_procrustes_surprise(_id32(), _id32(), _basis_identity()) + assert dual["discovery_eligible"] is False + assert candidate_from_surprise_dual(dual) is None + + +def test_candidate_id_is_deterministic() -> None: + # Angle must clear DEFAULT_DISCOVERY_GAMMA (0.35); 0.7 is just under. + dual = dual_procrustes_surprise( + _id32(), make_rotor_from_angle(1.0, bivector_idx=10), _basis_identity() + ) + assert dual["discovery_eligible"] is True + a = candidate_from_surprise_dual(dual, source_turn_trace="t1") + b = candidate_from_surprise_dual(dual, source_turn_trace="t1") + assert a is not None and b is not None + assert a.candidate_id == b.candidate_id + assert len(a.candidate_id) == 64 # sha256 hex + + +def test_emit_surprise_discovery_opt_in_sink() -> None: + dual = dual_procrustes_surprise( + _id32(), make_rotor_from_angle(1.0, bivector_idx=6), _basis_identity() + ) + sink = DiscoveryBufferSink() + c = emit_surprise_discovery(dual, sink, source_turn_trace="emit-1") + assert c is not None + assert len(sink.lines) == 1 + payload = json.loads(sink.lines[0]) + assert payload["trigger"] == "high_surprise" + assert payload["review_state"] == "unreviewed" + assert payload["domain"] == "math" + assert payload["candidate_id"] == c.candidate_id + + +def test_emit_without_sink_is_pure() -> None: + dual = dual_procrustes_surprise( + _id32(), make_rotor_from_angle(0.8, bivector_idx=6), _basis_identity() + ) + c = emit_surprise_discovery(dual, sink=None) + assert c is not None + assert c.trigger == "high_surprise" + + +def test_emit_no_candidate_leaves_sink_empty() -> None: + dual = dual_procrustes_surprise(_id32(), _id32(), _basis_identity()) + sink = DiscoveryBufferSink() + assert emit_surprise_discovery(dual, sink) is None + assert sink.lines == [] + + +def test_discovery_module_no_vault_store() -> None: + import teaching.discovery as disc + + src = inspect.getsource(disc) + assert "VaultStore" not in src + # Factory must not call store() / write corpus paths + assert "vault.store" not in src + assert "VaultStore.store" not in src + + +def test_productive_transfer_is_not_discovery() -> None: + """Low surprise + low procrustes → transfer, not discovery.""" + basis = np.column_stack([_id32(), make_rotor_from_angle(0.2, bivector_idx=6)]) + # Probe nearly in span of identity alone still low surprise on identity basis + dual = dual_procrustes_surprise(_id32(), _id32(), _basis_identity()) + assert dual["transfer_accepted"] is True or dual["surprise_norm"] < 1e-4 + assert dual["discovery_eligible"] is False + assert candidate_from_surprise_dual(dual) is None + _ = basis # reserved for future multi-column transfer fixtures