feat(third-door): wire high surprise to DiscoveryCandidate (#20 follow-up)
Some checks failed
lane-shas / verify pinned lane SHAs (pull_request) Failing after 15m37s
smoke / smoke (-m "not quarantine") (pull_request) Failing after 22m54s

- 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.
This commit is contained in:
Shay 2026-07-13 17:25:17 -07:00
parent 25f8c14e27
commit 995f4fc3b8
6 changed files with 438 additions and 12 deletions

View file

@ -44,7 +44,13 @@ from core.physics.dynamic_manifold import (
signature_aware_pca, signature_aware_pca,
signature_aware_pca_report, 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 ( from core.physics.biography import (
BiographyHolonomyBlade, BiographyHolonomyBlade,
biography_telemetry, biography_telemetry,
@ -79,7 +85,9 @@ __all__ = [
"cartan_iwasawa_extract", "cartan_iwasawa_factorize", "cartan_iwasawa_extract", "cartan_iwasawa_factorize",
"conformal_procrustes", "dual_correction_slerp", "procrustes_residual", "conformal_procrustes", "dual_correction_slerp", "procrustes_residual",
"signature_aware_pca", "signature_aware_pca_report", "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", "BiographyHolonomyBlade", "biography_telemetry",
"integrate_biography", "reconstruct_biography", "integrate_biography", "reconstruct_biography",
"TemporalAdmissibilityGate", "TemporalContext", "TemporalAdmissibilityGate", "TemporalContext",

View file

@ -49,6 +49,14 @@ _NEAR_ZERO = 1e-12
_CLOSURE_TOL = 1e-6 _CLOSURE_TOL = 1e-6
_GRADE_TOL = 1e-9 _GRADE_TOL = 1e-9
_MAX_GRADE = 5 _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): class SurpriseResidualError(ValueError):
@ -196,18 +204,45 @@ def surprise_residual(
raise ValueError("surprise_residual expects 5-vector or 32-vector x") 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( def dual_procrustes_surprise(
P: np.ndarray, P: np.ndarray,
Q: np.ndarray, Q: np.ndarray,
current_basis: np.ndarray, current_basis: np.ndarray,
*,
discovery_gamma: float = DEFAULT_DISCOVERY_GAMMA,
) -> dict: ) -> dict:
"""The dual operator: run Procrustes and Surprise together. """The dual operator: run Procrustes and Surprise together.
``transfer_accepted`` is a PRODUCTIVE-TRANSFER gate: a structural match was ``transfer_accepted`` is a PRODUCTIVE-TRANSFER gate: a structural match was
found (low Procrustes residual) AND the probe is inside the admissible span 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 (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 surprise is NOT a transfer; it is a *discovery* signal
fail-closed surprise refusal (degenerate basis) is recorded, not raised. (``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) V, proc_residual = conformal_procrustes(P, Q)
Q_arr = np.asarray(Q, dtype=np.float64) Q_arr = np.asarray(Q, dtype=np.float64)
@ -232,16 +267,27 @@ def dual_procrustes_surprise(
if np.asarray(V).shape == (N_COMPONENTS,): if np.asarray(V).shape == (N_COMPONENTS,):
closed = versor_condition(V) < _CLOSURE_TOL 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 { return {
"versor": V, "versor": V,
"procrustes_residual": float(proc_residual), "procrustes_residual": float(proc_residual),
"surprise_vector": sur_vec, "surprise_vector": sur_vec,
"surprise_norm": float(sur_norm), "surprise_norm": float(sur_norm),
"transfer_accepted": bool( "transfer_accepted": transfer_accepted,
refused is None and proc_residual < 1e-5 and sur_norm < 1e-4 and closed
),
"versor_closed": bool(closed), "versor_closed": bool(closed),
"surprise_refused": refused, "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, kappa: float = 1.0,
productive_threshold: float = 0.35, productive_threshold: float = 0.35,
surprise_threshold: float = 0.35, surprise_threshold: float = 0.35,
discovery_gamma: float | None = None,
) -> dict: ) -> dict:
"""Extended dual for multivector analogy seeds (ADR-0240 harness). """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 a structural match (``proc_r <= thr``) AND the query being inside the
admissible span (``sur_norm <= surprise_threshold``). The previous admissible span (``sur_norm <= surprise_threshold``). The previous
``sur_norm >= 0.0`` conjunct was always true, so surprise never gated. A HIGH ``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): if isinstance(basis, np.ndarray):
B = basis B = basis
@ -276,7 +325,10 @@ def dual_operator(
except SurpriseResidualError as exc: except SurpriseResidualError as exc:
sur_vec, sur_norm, surprise_refused = None, float("inf"), exc.reason 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: if not analogs:
productive = False
return { return {
"surprise_norm": float(sur_norm), "surprise_norm": float(sur_norm),
"procrustes_residual": float("inf"), "procrustes_residual": float("inf"),
@ -285,6 +337,13 @@ def dual_operator(
"selected_analog_id": None, "selected_analog_id": None,
"versor": None, "versor": None,
"surprise_refused": surprise_refused, "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] aid, src, tgt = analogs[0]
V, proc_r = conformal_procrustes(src, tgt) V, proc_r = conformal_procrustes(src, tgt)
@ -303,4 +362,11 @@ def dual_operator(
"versor": V, "versor": V,
"surprise_vector": sur_vec, "surprise_vector": sur_vec,
"surprise_refused": surprise_refused, "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,
} }

View file

@ -31,7 +31,7 @@
| 3 | Conformal Procrustes | Super §3.1 | 🟢 faithful (Kabsch + field conjugacy) | #17 | | 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 | | 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) | | 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 | | 7 | Trajectory invariants + zero-fabrication | R&D §2.2 | ⚫ absent | #21 |
| 8 | ADR-DAG conformal embedding | R&D §2.4 | ⚫ absent | #21 | | 8 | ADR-DAG conformal embedding | R&D §2.4 | ⚫ absent | #21 |
| — | Biography holonomy | (ADR-0240; not in blueprints) | 🟢 sound | — | | — | 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. - **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. - 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) ### Remaining → wiring landed (issue #30 / this surface)
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. 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 | | Kabsch-conformal Procrustes on point sets | #17 |
| GoldTether gold-set + harmonized residual + α=Φ(R) | #18 | | GoldTether gold-set + harmonized residual + α=Φ(R) | #18 |
| Grade-5 pseudoscalar preservation gate — ⚪ RETIRED (vacuous; see §5) | #19 (closed) | | 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 | | 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. 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.

View file

@ -48,6 +48,7 @@ from __future__ import annotations
import hashlib import hashlib
import json import json
import math
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Literal from typing import Any, Literal
@ -65,6 +66,9 @@ DiscoveryTrigger = Literal[
"successful_comparison", "successful_comparison",
"hedge_acknowledged", "hedge_acknowledged",
"oov_resolved_via_decomp", "oov_resolved_via_decomp",
# Third-Door Super §3.2 / fidelity #20: high geometric surprise on the
# dual ProcrustesSurprise 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=(",", ":")) 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__ = [ __all__ = [
"ClaimDomain", "ClaimDomain",
"DiscoveryCandidate", "DiscoveryCandidate",
"DiscoveryTrigger", "DiscoveryTrigger",
"EvidencePointer", "EvidencePointer",
"SubQuestion", "SubQuestion",
"candidate_from_surprise_dual",
"emit_surprise_discovery",
"extract_discovery_candidates", "extract_discovery_candidates",
"format_candidate_jsonl", "format_candidate_jsonl",
] ]

View file

@ -54,7 +54,9 @@ def test_dual_procrustes_surprise_audit_dict():
assert "procrustes_residual" in out assert "procrustes_residual" in out
assert "surprise_norm" in out assert "surprise_norm" in out
assert "transfer_accepted" in out assert "transfer_accepted" in out
assert "discovery_eligible" in out
assert isinstance(out["transfer_accepted"], bool) assert isinstance(out["transfer_accepted"], bool)
assert isinstance(out["discovery_eligible"], bool)
if np.asarray(out["versor"]).shape == (32,): if np.asarray(out["versor"]).shape == (32,):
assert versor_condition(out["versor"]) < 1e-6 assert versor_condition(out["versor"]) < 1e-6

View file

@ -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