diff --git a/core/physics/atlas_packing.py b/core/physics/atlas_packing.py new file mode 100644 index 00000000..867f3f46 --- /dev/null +++ b/core/physics/atlas_packing.py @@ -0,0 +1,108 @@ +"""core.physics.atlas_packing — Golden-Angle mode packing (ADR-0242). + +Construction-boundary lift of Poincaré polar coordinates into Cl(4,1) null +points via :func:`algebra.cga.embed_point`. Runtime storage is pure 32-vectors +only (no Poincaré attribute leaks). + +Separation uses the CGA null-point distance recovered from ``cga_inner``: + + ⟨P,Q⟩ = −d²/2 ⇒ d = √(−2⟨P,Q⟩) + +which is the Euclidean distance of the embedded R³ points (see ``cga_inner`` +doc). This is the cohesion-plan ``d_min`` pin progressive form — not a full +H² geodesic solver. Fail-closed if any pair has ``d < min_d``. + +Off-serve: do not import from ``chat/runtime.py`` (A-04 quarantine). +""" + +from __future__ import annotations + +import math +from typing import Sequence + +import numpy as np + +from algebra.cga import cga_inner, embed_point +from core.physics.wave_manifold import WaveManifold + +PHI = (1.0 + math.sqrt(5.0)) / 2.0 +DEFAULT_MIN_D = 0.12 + + +class AtlasPackingError(ValueError): + """Fail-closed packing refusal (separation / bounds).""" + + +def null_point_separation(p: np.ndarray, q: np.ndarray) -> float: + """CGA null-point separation d = √(max(0, −2⟨P,Q⟩)).""" + inner = float(cga_inner(np.asarray(p, dtype=np.float64), np.asarray(q, dtype=np.float64))) + # Clamp tiny positive float dust from null-cone numerics. + return math.sqrt(max(0.0, -2.0 * min(0.0, inner))) + + +def golden_angle_pack( + n: int, + alpha: float, + *, + min_d: float = DEFAULT_MIN_D, +) -> list[np.ndarray]: + """Golden-Angle packing on the Cl(4,1) null cone (horosphere lift). + + For k = 0..n-1: + θ_k = 2π k / φ + r_k = tanh(α √k) + (x,y) = (r cos θ, r sin θ) → embed_point → null 32-vector + + Rejects with :class:`AtlasPackingError` if any pairwise separation < min_d. + """ + if n < 1: + raise AtlasPackingError("n must be >= 1") + if not math.isfinite(alpha) or alpha <= 0.0: + raise AtlasPackingError("alpha must be a positive finite float") + if not math.isfinite(min_d) or min_d < 0.0: + raise AtlasPackingError("min_d must be a non-negative finite float") + + modes: list[np.ndarray] = [] + for k in range(n): + # 2π/φ ≈ 222.5°; packing-equivalent complement is the classic ~137.5° golden angle. + theta_k = 2.0 * math.pi * k / PHI + r_k = math.tanh(alpha * math.sqrt(float(k))) + x = r_k * math.cos(theta_k) + y = r_k * math.sin(theta_k) + mode = embed_point(np.asarray([x, y, 0.0], dtype=np.float64), dtype=np.float64) + modes.append(np.asarray(mode, dtype=np.float64)) + + for i in range(len(modes)): + for j in range(i + 1, len(modes)): + d = null_point_separation(modes[i], modes[j]) + if d < min_d: + raise AtlasPackingError( + f"Packing rejected: separation {d:.4f} between {i} and {j} " + f"is less than required minimum {min_d:.4f}." + ) + return modes + + +def register_packed_modes( + modes: Sequence[np.ndarray], + manifold: WaveManifold, +) -> tuple[int, ...]: + """Register packed null modes on a session WaveManifold. Returns indices. + + Note: these are null-point modes for spectral span / packing geometry, not + unit-versor holographic seals (seal_mode would refuse non-closed versors). + """ + indices: list[int] = [] + for mode in modes: + indices.append(manifold.register_resonant_mode(mode)) + return tuple(indices) + + +__all__ = [ + "PHI", + "DEFAULT_MIN_D", + "AtlasPackingError", + "null_point_separation", + "golden_angle_pack", + "register_packed_modes", +] diff --git a/core/physics/fibonacci_search.py b/core/physics/fibonacci_search.py new file mode 100644 index 00000000..2d4b3df3 --- /dev/null +++ b/core/physics/fibonacci_search.py @@ -0,0 +1,174 @@ +"""core.physics.fibonacci_search — fixed-budget Fibonacci section search (ADR-0242). + +Deterministic 1D unimodal minimization for construction / calibration / +GoldTether κ-style scalar brackets. Not a serve-path operator (A-04 quarantine). + +Fail-closed on: + * nonfinite objective values + * invalid bounds / budget + * sampled unimodality violation (values must decrease to the observed + minimum then increase when sorted by coordinate) +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass, field +from typing import Callable + + +@dataclass(frozen=True, slots=True) +class BoundedUnimodalObjective: + lower: float + upper: float + evaluation_budget: int + objective_id: str + objective_version: str + + def __post_init__(self) -> None: + if self.evaluation_budget < 2: + raise ValueError("evaluation_budget must be >= 2") + if self.upper <= self.lower: + raise ValueError("upper bound must be strictly greater than lower bound") + if not math.isfinite(self.lower) or not math.isfinite(self.upper): + raise ValueError("bounds must be finite") + + +@dataclass(slots=True) +class SearchTrace: + best_observed_point: float + eval_sequence: list[float] = field(default_factory=list) + certificate: dict = field(default_factory=dict) + + +def _fibonacci(n: int) -> int: + """F_0=0, F_1=1, … standard Fibonacci. n may be 0.""" + if n < 0: + raise ValueError("fibonacci index must be non-negative") + a, b = 0, 1 + for _ in range(n): + a, b = b, a + b + return a + + +def _assert_sampled_unimodality(eval_values: dict[float, float]) -> None: + """Fail-closed if sorted samples are not unimodal about the observed min.""" + sorted_points = sorted(eval_values.keys()) + min_idx = 0 + min_val = float("inf") + for i, x in enumerate(sorted_points): + v = eval_values[x] + if v < min_val: + min_val = v + min_idx = i + + # Strictly non-increasing toward min (allow float ties). + for i in range(min_idx): + left = eval_values[sorted_points[i]] + right = eval_values[sorted_points[i + 1]] + if left < right - 1e-9: + raise ValueError( + "unimodality violation detected (multiple extrema): " + "values not decreasing before minimum." + ) + + # Strictly non-decreasing after min (allow float ties). + for i in range(min_idx, len(sorted_points) - 1): + left = eval_values[sorted_points[i]] + right = eval_values[sorted_points[i + 1]] + if left > right + 1e-9: + raise ValueError( + "unimodality violation detected (multiple extrema): " + "values not increasing after minimum." + ) + + +def fibonacci_section_search( + objective: BoundedUnimodalObjective, + func: Callable[[float], float], +) -> SearchTrace: + """Fibonacci section search: exactly ``evaluation_budget`` function evals. + + Returns :class:`SearchTrace` with ``best_observed_point``, ``eval_sequence``, + and a small certificate dict (budget, ids, bounds). + """ + n = int(objective.evaluation_budget) + a = float(objective.lower) + b = float(objective.upper) + + f_n_plus_1 = _fibonacci(n + 1) + f_n_minus_1 = _fibonacci(n - 1) + f_n = _fibonacci(n) + + c = a + (f_n_minus_1 / f_n_plus_1) * (b - a) + d = a + (f_n / f_n_plus_1) * (b - a) + + def _eval(x: float) -> float: + if x < objective.lower - 1e-12 or x > objective.upper + 1e-12: + raise ValueError(f"bounds violation: evaluated {x} outside [{objective.lower}, {objective.upper}]") + y = float(func(x)) + if not math.isfinite(y): + raise ValueError(f"Objective function returned nonfinite value {y} at {x}") + return y + + fc = _eval(c) + fd = _eval(d) + eval_sequence = [c, d] + eval_values: dict[float, float] = {c: fc, d: fd} + + best_x = c if fc < fd else d + best_f = min(fc, fd) + + k = 1 + while k < n - 1: + if fc < fd: + b = d + d = c + fd = fc + f_n_minus_k_minus_1 = _fibonacci(n - k - 1) + f_n_minus_k_plus_1 = _fibonacci(n - k + 1) + c = a + (f_n_minus_k_minus_1 / f_n_minus_k_plus_1) * (b - a) + fc = _eval(c) + eval_sequence.append(c) + eval_values[c] = fc + if fc < best_f: + best_f = fc + best_x = c + else: + a = c + c = d + fc = fd + f_n_minus_k = _fibonacci(n - k) + f_n_minus_k_plus_1 = _fibonacci(n - k + 1) + d = a + (f_n_minus_k / f_n_minus_k_plus_1) * (b - a) + fd = _eval(d) + eval_sequence.append(d) + eval_values[d] = fd + if fd < best_f: + best_f = fd + best_x = d + k += 1 + + _assert_sampled_unimodality(eval_values) + + certificate = { + "budget": objective.evaluation_budget, + "objective_id": objective.objective_id, + "objective_version": objective.objective_version, + "lower_bound": objective.lower, + "upper_bound": objective.upper, + "best_value": best_f, + "n_evals": len(eval_sequence), + } + return SearchTrace( + best_observed_point=float(best_x), + eval_sequence=list(eval_sequence), + certificate=certificate, + ) + + +__all__ = [ + "BoundedUnimodalObjective", + "SearchTrace", + "fibonacci_section_search", +] diff --git a/docs/adr/ADR-0242-atlas-packing-and-fibonacci.md b/docs/adr/ADR-0242-atlas-packing-and-fibonacci.md new file mode 100644 index 00000000..d75d0118 --- /dev/null +++ b/docs/adr/ADR-0242-atlas-packing-and-fibonacci.md @@ -0,0 +1,75 @@ +# ADR-0242: Hyperbolic Atlas Golden-Angle Packing and Fibonacci Search + +**Status**: Proposed — packing + Fibonacci search green on branch; acceptance path: Joshua review + merge +**Date**: 2026-07-14 +**Deciders**: Joshua Shay + multi-model R&D (Gemini implementation pass) +**Traceability**: PR #37, parent ADR-0241 / cohesion master plan +**Related**: ADR-0003, ADR-0238, ADR-0241, `docs/analysis/core_cohesion_master_plan.md`, `docs/briefs/ADR-0242-atlas-packing-and-fibonacci-brief.md` +**Canonical path**: `docs/adr/` + +--- + +## Context + +ADR-0241 established `WaveManifold` and `HolographicVaultStore`. Entity cohesion still needed: + +1. **Uniform resonant-mode packing** without resurrecting pointwise `core_ha` node IDs or Poincaré as runtime memory truth (ADR-0003). +2. **Fixed-budget unimodal scalar search** for construction/calibration (e.g. GoldTether κ brackets) without scipy-as-truth or stochastic optimizers. + +## Decision + +### 1. Golden-Angle packing (`core/physics/atlas_packing.py`) + +For \(k = 0 \ldots n-1\): + +\[ +\theta_k = 2\pi k / \varphi,\qquad r_k = \tanh(\alpha\sqrt{k}) +\] + +Lift \((r\cos\theta, r\sin\theta, 0)\) via `algebra.cga.embed_point` to Cl(4,1) **null points**. + +**Separation pin:** CGA null-point distance from `cga_inner` contract \(\langle P,Q\rangle = -d^2/2\): + +\[ +d = \sqrt{-2\langle P,Q\rangle} +\] + +Fail-closed (`AtlasPackingError`) if any pair has \(d < d_{\min}\) (default \(0.12\)). + +**Honest scope:** this \(d\) is the Euclidean distance of the embedded \(\mathbb{R}^3\) points (null-cone isometric readout), not a full hyperbolic \(H^2\) geodesic solver. Sufficient for the cohesion packing density gate. + +**No attribute leaks:** returned modes are pure `float64` 32-vectors. No stored θ/r. + +**Not holographic seals:** packed null points are session mode-registry geometry; `HolographicVaultStore.seal_mode` still requires closed unit versors. + +### 2. Fibonacci section search (`core/physics/fibonacci_search.py`) + +- `BoundedUnimodalObjective(lower, upper, evaluation_budget, objective_id, objective_version)` +- `fibonacci_section_search(objective, func) -> SearchTrace` +- Exactly `evaluation_budget` evaluations +- Fail-closed on nonfinite, bounds violation, sampled unimodality violation +- Certificate carries budget, ids, bounds, best value, n_evals + +### 3. Serve quarantine (A-04) + +Neither module may be imported from `chat/runtime.py`. Pinned in `tests/test_third_door_cohesion.py`. + +## Consequences + +### Benefits + +- Deterministic atlas packing for standing-wave mode placement +- Algebra-native fixed-budget scalar search for κ / residual brackets +- Continues `core_ha` deprecation (no node IDs / Poincaré runtime store) + +### Trade-offs + +- Separation is CGA null-point Euclidean distance, not full hyperbolic geodesic +- Unimodality check is sample-based (only evaluated points), not a global oracle +- Packing modes are null points, not unit versors — durable vault seal path remains separate + +## Validation + +- `tests/test_adr_0242_atlas_packing.py` +- `tests/test_adr_0242_fibonacci.py` +- `tests/test_third_door_cohesion.py` (serve quarantine + κ integration) diff --git a/docs/research/third-door-blueprint-fidelity.md b/docs/research/third-door-blueprint-fidelity.md index d0e027f8..261aa943 100644 --- a/docs/research/third-door-blueprint-fidelity.md +++ b/docs/research/third-door-blueprint-fidelity.md @@ -299,8 +299,8 @@ PY | Serve path not wired to wave / Fibonacci (containment) | 🟢 (AST-pinned in cohesion suite) | | Entity I-01…I-05 cohesion suite | 🟢 progressive pins in `test_third_door_cohesion.py` (I-02 float32-honest) | | Vault public `get_versor` ABI | 🟢 | -| Golden-Angle atlas packing \(d_{\min}=0.12\) | 🔴 STOP → `docs/briefs/ADR-0242-atlas-packing-and-fibonacci-brief.md` | -| Fibonacci κ search | 🔴 STOP → same brief | +| Golden-Angle atlas packing \(d_{\min}=0.12\) | 🟢 ADR-0242 (`atlas_packing`; CGA null-point \(d\)) | +| Fibonacci κ search | 🟢 ADR-0242 (`fibonacci_search`) | ### Subsumption map (Slice 2–3) | Operator | Delegation | @@ -337,6 +337,6 @@ PY | `core_ha` deprecation — 🟢 no live tree + hygiene + Phase 0 grep | ADR-0241 / deprecation plan | | Durable holographic vault spectrum — 🟢 HolographicVaultStore | ADR-0241 | | Entity cohesion I-01…I-05 + Trace A/B | cohesion master plan | -| Atlas packing + Fibonacci κ (ADR-0242) | cohesion master plan | +| Atlas packing + Fibonacci κ (ADR-0242) — 🟢 packing + search | PR #37 / ADR-0242 | Closing a gap = flip its `xfail` in `tests/test_third_door_blueprint_fidelity.py` (or the ADR-0241 / cohesion suite) to a passing behavioral test and delete the matching characterization lock. That is the definition of "done right" here. diff --git a/tests/test_adr_0242_atlas_packing.py b/tests/test_adr_0242_atlas_packing.py new file mode 100644 index 00000000..a9e7b72d --- /dev/null +++ b/tests/test_adr_0242_atlas_packing.py @@ -0,0 +1,73 @@ +"""ADR-0242 — Golden-Angle atlas packing behavioral pins.""" + +from __future__ import annotations + +import math + +import numpy as np +import pytest + +from algebra.cga import is_null +from core.physics.atlas_packing import ( + DEFAULT_MIN_D, + AtlasPackingError, + golden_angle_pack, + null_point_separation, + register_packed_modes, +) +from core.physics.wave_manifold import WaveManifold + + +def test_golden_angle_pack_n_modes_min_geodesic_ge_0_12(): + modes = golden_angle_pack(n=10, alpha=0.5) + assert len(modes) == 10 + min_d = min( + null_point_separation(modes[i], modes[j]) + for i in range(len(modes)) + for j in range(i + 1, len(modes)) + ) + assert min_d >= DEFAULT_MIN_D + + +def test_golden_angle_pack_rejects_when_alpha_too_dense(): + with pytest.raises(AtlasPackingError, match="separation"): + golden_angle_pack(n=50, alpha=0.01) + + +def test_packing_lift_produces_closed_or_null_legal_points(): + modes = golden_angle_pack(n=5, alpha=0.3) + for m in modes: + assert is_null(m), "Lifted points must be legal null points in CGA" + assert m.shape == (32,) + assert m.dtype == np.float64 + + +def test_packing_deterministic_for_fixed_alpha_n(): + modes1 = golden_angle_pack(n=20, alpha=0.4) + modes2 = golden_angle_pack(n=20, alpha=0.4) + for m1, m2 in zip(modes1, modes2): + np.testing.assert_allclose(m1, m2) + + +def test_no_poincare_runtime_storage_in_wave_or_vault_metadata_truth(): + manifold = WaveManifold() + modes = golden_angle_pack(n=5, alpha=0.3) + idxs = register_packed_modes(modes, manifold) + assert len(idxs) == 5 + for m in manifold.resonant_modes: + assert m.shape == (32,) + assert m.dtype == np.float64 + assert not hasattr(m, "theta") + assert not hasattr(m, "r") + # Modes are plain arrays — no Poincaré sidecar attributes. + assert not hasattr(modes[0], "theta") + + +def test_null_point_separation_matches_euclidean_embed(): + """cga_inner contract: ⟨P,Q⟩ = −d²/2 for embedded Euclidean points.""" + from algebra.cga import embed_point + + p = embed_point(np.asarray([0.1, 0.0, 0.0], dtype=np.float64), dtype=np.float64) + q = embed_point(np.asarray([0.4, 0.0, 0.0], dtype=np.float64), dtype=np.float64) + d = null_point_separation(p, q) + assert abs(d - 0.3) < 1e-9 diff --git a/tests/test_adr_0242_fibonacci.py b/tests/test_adr_0242_fibonacci.py new file mode 100644 index 00000000..aad2182c --- /dev/null +++ b/tests/test_adr_0242_fibonacci.py @@ -0,0 +1,77 @@ +"""ADR-0242 — Fibonacci section search behavioral pins.""" + +from __future__ import annotations + +import pytest + +from core.physics.fibonacci_search import ( + BoundedUnimodalObjective, + fibonacci_section_search, +) + + +def test_fibonacci_search_hits_known_unimodal_min_within_1e_3(): + objective = BoundedUnimodalObjective( + lower=0.1, + upper=2.0, + evaluation_budget=20, + objective_id="test_id", + objective_version="v1", + ) + + def func(x: float) -> float: + return (x - 0.789) ** 2 + + trace = fibonacci_section_search(objective, func) + assert abs(trace.best_observed_point - 0.789) < 1e-3 + assert len(trace.eval_sequence) == 20 + + +def test_fibonacci_search_eval_count_equals_budget(): + objective = BoundedUnimodalObjective( + lower=-5.0, + upper=5.0, + evaluation_budget=15, + objective_id="test_id2", + objective_version="v1", + ) + + def func(x: float) -> float: + return x**2 + + trace = fibonacci_section_search(objective, func) + assert len(trace.eval_sequence) == 15 + assert trace.certificate["n_evals"] == 15 + + +def test_fibonacci_search_rejects_nan_objective(): + objective = BoundedUnimodalObjective( + lower=-1.0, + upper=1.0, + evaluation_budget=10, + objective_id="nan", + objective_version="v1", + ) + + def func(x: float) -> float: + return float("nan") + + with pytest.raises(ValueError, match="nonfinite"): + fibonacci_section_search(objective, func) + + +def test_fibonacci_search_unimodality_violation_fail_closed(): + objective = BoundedUnimodalObjective( + lower=-2.0, + upper=2.0, + evaluation_budget=10, + objective_id="multi", + objective_version="v1", + ) + + def func(x: float) -> float: + # Multiple extrema: x^4 - x^2 + return x**4 - x**2 + + with pytest.raises(ValueError, match="unimodality"): + fibonacci_section_search(objective, func) diff --git a/tests/test_third_door_cohesion.py b/tests/test_third_door_cohesion.py index 38083773..075c7aeb 100644 --- a/tests/test_third_door_cohesion.py +++ b/tests/test_third_door_cohesion.py @@ -269,11 +269,22 @@ def test_resonant_reconstruct_empty_refused(): # --- ADR-0242 placeholder (Fibonacci not yet landed) -------------------------- -def test_fibonacci_search_module_absent_or_importable_placeholder(): - """Until P5, fibonacci_search may be absent; if present it must not hit serve.""" - spec = importlib.util.find_spec("core.physics.fibonacci_search") - if spec is None: - pytest.skip("ADR-0242 fibonacci_search not landed yet (expected until P5)") - # If present, ensure runtime still quarantined (A-04 already covers imports). - mod = importlib.import_module("core.physics.fibonacci_search") - assert hasattr(mod, "fibonacci_section_search") +def test_fibonacci_search_goldtether_integration(): + """Asserts Fibonacci search can optimize kappa and return a valid certificate.""" + from core.physics.fibonacci_search import BoundedUnimodalObjective, fibonacci_section_search + + objective = BoundedUnimodalObjective( + lower=0.1, + upper=2.0, + evaluation_budget=20, + objective_id="sha256_mock_id_for_goldtether_kappa", + objective_version="v1.0", + ) + + def synthetic_objective(kappa: float) -> float: + return (kappa - 0.789) ** 2 # unimodal minimum at 0.789 + + trace = fibonacci_section_search(objective, synthetic_objective) + assert abs(trace.best_observed_point - 0.789) < 1e-3 + assert len(trace.eval_sequence) == 20 + assert trace.certificate.get("budget") == 20