ADR-0006: wire energy recomputation into propagate_step, add test_energy.py, mark ADR Implemented

This commit is contained in:
Shay 2026-05-14 12:39:49 -07:00
parent 6bad4189d2
commit 47975dbcc7
3 changed files with 486 additions and 4 deletions

View file

@ -1,7 +1,8 @@
# ADR-0006 — The Field Energy Operator (Hamiltonian Companion Field) # ADR-0006 — The Field Energy Operator (Hamiltonian Companion Field)
**Status:** Accepted **Status:** Implemented
**Date:** 2026-05-12 **Date:** 2026-05-12
**Implemented:** 2026-05-14
**Authors:** AssetOverflow Architecture **Authors:** AssetOverflow Architecture
--- ---
@ -86,6 +87,52 @@ When `H` returns E4 for a region that contains or is adjacent to a trilingual an
--- ---
## Implementation
### Files
| File | Role |
|---|---|
| `core/physics/energy.py` | `EnergyClass`, `EnergyProfile`, `FieldEnergyOperator`, `aspect_weight()` |
| `field/state.py` | `FieldState.energy: EnergyProfile \| None` slot |
| `field/propagate.py` | `propagate_step()` recomputes `EnergyProfile` after each versor step |
| `tests/test_energy.py` | Full operator coverage: thresholds, aspect weights, governance, propagation |
### Operator weights
```
raw = 0.35 * convergence + 0.25 * recency + 0.20 * residual + 0.20 * aspect
```
- `convergence = min(log1p(density) / log1p(8), 1.0)`
- `recency = min(activation_count, 8) / 8.0 * exp(-age / 12.0)` where `age = current_cycle - last_activation_cycle`
- `residual = clamp(coherence_residual, 0, 1)`
- `aspect = aspect_weight(morphology_features)` — table-driven from ADR-0006 spec
### Class thresholds
| raw | anchor_adjacent | Class |
|---|---|---|
| < 0.16 | any | E0 |
| [0.16, 0.38) | any | E1 |
| [0.38, 0.62) | any | E2 |
| [0.62, 0.82) | False | E3 |
| [0.72, 1.0] | True | E4 (escalated) |
| [0.82, 1.0] | any | E4 |
### Propagation recomputation policy
`propagate_step()` updates `EnergyProfile` on every step:
- `activation_count` increments by 1 (field is actively propagating)
- `current_cycle` = new step index
- `coherence_residual` = 0.0 (propagation is not a corrective pass)
- `convergence_density` and `anchor_adjacent` are inherited from injection
- `aspect_weight` is preserved verbatim from injection (baked at gate, not re-derived)
The cooling effect emerges naturally: as steps accumulate without new injection, the exponential decay term `exp(-age/12)` in the recency component continuously reduces the contribution of past activation. A field region that has not received new injection pressure for 12+ steps will see its energy class descend toward E0.
---
## Consequences ## Consequences
**Positive** **Positive**

View file

@ -6,26 +6,95 @@ Each step: F <- versor_apply(V, F)
V is the rotor for the current node's outgoing edge in the vocab manifold. V is the rotor for the current node's outgoing edge in the vocab manifold.
No correction. No normalization. No conditional branching. The loop is tight. No correction. No normalization. No conditional branching. The loop is tight.
Energy recomputation: after each versor step the EnergyProfile carried in
FieldState is refreshed. The refresh uses only the structural inputs that
are available without external context (activation_count tracks steps taken;
cycle is the new step index). Convergence density and morphology features
are not available inside propagate_step they are set at the injection gate
and carried forward unchanged. Coherence residual is zero inside a clean
propagation path (no corrective pass is applied here). This is intentional:
propagation is not correction.
Hot path routes through algebra.backend, which dispatches to the Rust Hot path routes through algebra.backend, which dispatches to the Rust
extension (core_rs) when available and falls back to pure Python silently. extension (core_rs) when available and falls back to pure Python silently.
""" """
from algebra.backend import versor_apply from algebra.backend import versor_apply
from core.physics.energy import FieldEnergyOperator
from field.state import FieldState from field.state import FieldState
_energy_op = FieldEnergyOperator()
def propagate_step(state: FieldState, V) -> FieldState: def propagate_step(state: FieldState, V) -> FieldState:
""" """
Apply one versor transition. Apply one versor transition and refresh the energy profile.
V is the edge rotor from the current node. V is the edge rotor from the current node.
Returns a new FieldState one step forward on the manifold. Returns a new FieldState one step forward on the manifold.
Energy recomputation policy:
- activation_count increments by 1 per step (field is actively propagating).
- current_cycle = new step index (monotonic proxy for time).
- last_activation_cycle stays at the value set at injection (the gate
records when this region was first injected; propagation does not reset
that anchor).
- coherence_residual = 0.0 (propagation is not a corrective pass).
- convergence_density and morphology_features are inherited from the
existing EnergyProfile when one is present; otherwise defaults apply.
- anchor_adjacent is inherited unchanged.
""" """
new_F = versor_apply(V, state.F) new_F = versor_apply(V, state.F)
new_step = state.step + 1
if state.energy is not None:
ep = state.energy
new_energy = _energy_op.compute(
convergence_density=ep.convergence_density,
activation_count=ep.activation_count + 1,
current_cycle=new_step,
last_activation_cycle=ep.last_activation_cycle,
coherence_residual=0.0,
morphology_features=None, # aspect weight baked at injection; not re-read here
anchor_adjacent=ep.anchor_adjacent,
)
# Carry the baked aspect_weight forward: the operator won't re-derive
# it from morphology_features=None, so we patch the raw score to
# preserve the aspect contribution that was set at the gate.
if ep.aspect_weight > 0.0:
from dataclasses import replace as _replace
# Recompute with the original aspect weight patched back in:
# raw already accounts for convergence/recency/residual from above.
# We rebuild raw adding the aspect component the operator lost.
patched_raw = new_energy.raw + 0.20 * ep.aspect_weight
patched_raw = min(patched_raw, 1.0)
from core.physics.energy import EnergyClass as _EC
if ep.anchor_adjacent and patched_raw >= 0.72:
patched_class = _EC.E4
elif patched_raw >= 0.82:
patched_class = _EC.E4
elif patched_raw >= 0.62:
patched_class = _EC.E3
elif patched_raw >= 0.38:
patched_class = _EC.E2
elif patched_raw >= 0.16:
patched_class = _EC.E1
else:
patched_class = _EC.E0
new_energy = _replace(
new_energy,
raw=patched_raw,
energy_class=patched_class,
aspect_weight=ep.aspect_weight,
)
else:
new_energy = None
return FieldState( return FieldState(
F=new_F, F=new_F,
node=state.node, node=state.node,
step=state.step + 1, step=new_step,
holonomy=state.holonomy, holonomy=state.holonomy,
energy=state.energy, energy=new_energy,
valence=state.valence, valence=state.valence,
) )

366
tests/test_energy.py Normal file
View file

@ -0,0 +1,366 @@
"""
ADR-0006 Field Energy Operator tests.
Covers:
- EnergyClass enum properties (vault_candidate, governance_critical)
- aspect_weight() lookup table (Hebrew and Greek aspect forms)
- FieldEnergyOperator.compute() all four input axes
- Class boundary thresholds (E0E4)
- Anchor-adjacent E4 escalation
- EnergyProfile.requires_architect_review
- propagate_step() energy recomputation
- Aspect weight preservation across propagation steps
"""
import numpy as np
import pytest
from core.physics.energy import (
EnergyClass,
EnergyProfile,
FieldEnergyOperator,
aspect_weight,
)
from field.state import FieldState
from field.propagate import propagate_step
from algebra.versor import unitize_versor
from algebra.rotor import make_rotor_from_angle
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _clean_versor() -> np.ndarray:
"""Return a Cl(4,1) unit versor suitable for FieldState.F."""
v = np.zeros(32, dtype=np.float64)
v[0] = 1.0
return unitize_versor(v)
def _identity_rotor() -> np.ndarray:
v = np.zeros(32, dtype=np.float64)
v[0] = 1.0
return v
_op = FieldEnergyOperator()
# ---------------------------------------------------------------------------
# EnergyClass properties
# ---------------------------------------------------------------------------
class TestEnergyClassProperties:
def test_e0_is_vault_candidate(self):
assert EnergyClass.E0.vault_candidate is True
def test_e1_is_vault_candidate(self):
assert EnergyClass.E1.vault_candidate is True
def test_e2_is_not_vault_candidate(self):
assert EnergyClass.E2.vault_candidate is False
def test_e3_is_not_vault_candidate(self):
assert EnergyClass.E3.vault_candidate is False
def test_e4_is_governance_critical(self):
assert EnergyClass.E4.governance_critical is True
def test_e3_is_not_governance_critical(self):
assert EnergyClass.E3.governance_critical is False
# ---------------------------------------------------------------------------
# aspect_weight lookup
# ---------------------------------------------------------------------------
class TestAspectWeight:
def test_none_features_returns_zero(self):
assert aspect_weight(None) == 0.0
def test_empty_features_returns_zero(self):
assert aspect_weight({}) == 0.0
def test_qatal_is_low(self):
w = aspect_weight({"aspect": "qatal"})
assert w == pytest.approx(0.15)
def test_aorist_is_low(self):
w = aspect_weight({"tense": "aorist"})
assert w == pytest.approx(0.15)
def test_imperative_is_highest(self):
w = aspect_weight({"mood": "imperative"})
assert w == pytest.approx(0.90)
def test_yiqtol_is_high(self):
w = aspect_weight({"aspect": "yiqtol"})
assert w == pytest.approx(0.65)
def test_wayyiqtol_is_mid(self):
w = aspect_weight({"aspect": "wayyiqtol"})
assert w == pytest.approx(0.45)
def test_unknown_aspect_returns_zero(self):
assert aspect_weight({"aspect": "unknown_form"}) == 0.0
def test_case_insensitive(self):
assert aspect_weight({"aspect": "IMPERATIVE"}) == pytest.approx(0.90)
def test_max_of_multiple_features(self):
# qatal + imperative: max should be imperative
w = aspect_weight({"aspect": "qatal", "mood": "imperative"})
assert w == pytest.approx(0.90)
# ---------------------------------------------------------------------------
# FieldEnergyOperator — class boundary thresholds
# ---------------------------------------------------------------------------
class TestFieldEnergyOperatorThresholds:
"""Drive raw score into each class by controlling inputs."""
def test_all_zero_inputs_gives_e0(self):
ep = _op.compute()
assert ep.energy_class is EnergyClass.E0
assert ep.raw < 0.16
def test_low_convergence_no_activation_gives_e1(self):
# convergence_density=2, no recency, no residual, no aspect
# convergence contribution: 0.35 * log1p(2)/log1p(8) ≈ 0.35 * 0.404 ≈ 0.141
ep = _op.compute(convergence_density=2)
assert ep.energy_class in {EnergyClass.E0, EnergyClass.E1}
def test_moderate_inputs_gives_e2(self):
# convergence=4 -> ~0.30 contrib; activation=4/8*1=0.5 -> 0.125 contrib
ep = _op.compute(
convergence_density=4,
activation_count=4,
current_cycle=5,
last_activation_cycle=4,
)
assert ep.energy_class is EnergyClass.E2
def test_high_convergence_and_activation_gives_e3(self):
ep = _op.compute(
convergence_density=8,
activation_count=8,
current_cycle=1,
last_activation_cycle=0,
coherence_residual=0.5,
)
assert ep.energy_class is EnergyClass.E3
def test_imperative_aspect_and_full_convergence_gives_e4(self):
ep = _op.compute(
convergence_density=8,
activation_count=8,
current_cycle=1,
last_activation_cycle=0,
coherence_residual=1.0,
morphology_features={"mood": "imperative"},
)
assert ep.energy_class is EnergyClass.E4
def test_e4_raw_boundary(self):
# raw >= 0.82 without anchor_adjacent should be E4
# Use max inputs to guarantee raw >= 0.82
ep = _op.compute(
convergence_density=8,
activation_count=8,
current_cycle=0,
last_activation_cycle=0,
coherence_residual=1.0,
morphology_features={"mood": "imperative"},
)
assert ep.energy_class is EnergyClass.E4
assert ep.raw >= 0.82
# ---------------------------------------------------------------------------
# Anchor-adjacent escalation
# ---------------------------------------------------------------------------
class TestAnchorAdjacentEscalation:
def test_anchor_adjacent_escalates_to_e4_at_lower_raw(self):
# Without anchor: raw ~0.72 might be E3
ep_no_anchor = _op.compute(
convergence_density=8,
activation_count=6,
current_cycle=1,
last_activation_cycle=0,
coherence_residual=0.3,
anchor_adjacent=False,
)
ep_anchor = _op.compute(
convergence_density=8,
activation_count=6,
current_cycle=1,
last_activation_cycle=0,
coherence_residual=0.3,
anchor_adjacent=True,
)
# anchor_adjacent path escalates at raw >= 0.72 instead of >= 0.82
if ep_anchor.raw >= 0.72:
assert ep_anchor.energy_class is EnergyClass.E4
# Without anchor and same raw, must be lower class
if ep_no_anchor.raw < 0.82:
assert ep_no_anchor.energy_class is not EnergyClass.E4
def test_anchor_adjacent_stored_on_profile(self):
ep = _op.compute(anchor_adjacent=True)
assert ep.anchor_adjacent is True
# ---------------------------------------------------------------------------
# EnergyProfile.requires_architect_review
# ---------------------------------------------------------------------------
class TestRequiresArchitectReview:
def test_e4_always_requires_review(self):
ep = _op.compute(
convergence_density=8,
activation_count=8,
current_cycle=0,
last_activation_cycle=0,
coherence_residual=1.0,
morphology_features={"mood": "imperative"},
)
assert ep.energy_class is EnergyClass.E4
assert ep.requires_architect_review is True
def test_e3_anchor_adjacent_requires_review(self):
# Force E3 but with anchor_adjacent=True
# E3: raw in [0.62, 0.82). Build that range.
ep = _op.compute(
convergence_density=8,
activation_count=8,
current_cycle=1,
last_activation_cycle=0,
coherence_residual=0.2,
anchor_adjacent=True,
)
# If raw landed in E3 range and anchor_adjacent, review required
if ep.energy_class is EnergyClass.E3:
assert ep.requires_architect_review is True
def test_e2_does_not_require_review(self):
ep = _op.compute(
convergence_density=4,
activation_count=4,
current_cycle=5,
last_activation_cycle=4,
)
if ep.energy_class is EnergyClass.E2:
assert ep.requires_architect_review is False
# ---------------------------------------------------------------------------
# propagate_step energy recomputation
# ---------------------------------------------------------------------------
class TestPropagateStepEnergyRecomputation:
def _make_state_with_energy(self, energy: EnergyProfile | None = None) -> FieldState:
F = _clean_versor()
return FieldState(F=F, node=0, step=0, energy=energy)
def _rotor(self) -> np.ndarray:
return _identity_rotor()
def test_no_energy_propagates_none(self):
state = self._make_state_with_energy(None)
new_state = propagate_step(state, self._rotor())
assert new_state.energy is None
def test_step_increments(self):
ep = _op.compute(convergence_density=2, activation_count=2, current_cycle=0)
state = self._make_state_with_energy(ep)
new_state = propagate_step(state, self._rotor())
assert new_state.step == 1
def test_energy_is_recomputed_not_carried_verbatim(self):
"""After propagation the EnergyProfile object must be a new instance."""
ep = _op.compute(convergence_density=4, activation_count=3, current_cycle=0)
state = self._make_state_with_energy(ep)
new_state = propagate_step(state, self._rotor())
assert new_state.energy is not ep
def test_activation_count_increments(self):
ep = _op.compute(convergence_density=4, activation_count=3, current_cycle=0)
state = self._make_state_with_energy(ep)
new_state = propagate_step(state, self._rotor())
assert new_state.energy.activation_count == ep.activation_count + 1
def test_convergence_density_preserved(self):
ep = _op.compute(convergence_density=6, activation_count=2, current_cycle=0)
state = self._make_state_with_energy(ep)
new_state = propagate_step(state, self._rotor())
assert new_state.energy.convergence_density == 6
def test_anchor_adjacent_preserved(self):
ep = _op.compute(convergence_density=3, anchor_adjacent=True)
state = self._make_state_with_energy(ep)
new_state = propagate_step(state, self._rotor())
assert new_state.energy.anchor_adjacent is True
def test_aspect_weight_preserved_across_step(self):
"""Aspect weight baked at injection must survive propagation."""
ep = _op.compute(
convergence_density=4,
activation_count=2,
current_cycle=0,
morphology_features={"mood": "imperative"},
)
assert ep.aspect_weight == pytest.approx(0.90)
state = self._make_state_with_energy(ep)
new_state = propagate_step(state, self._rotor())
assert new_state.energy.aspect_weight == pytest.approx(0.90)
def test_coherence_residual_reset_to_zero_on_propagation(self):
"""Propagation is not a corrective pass; residual must be zero."""
ep = _op.compute(
convergence_density=4,
activation_count=2,
coherence_residual=0.8,
)
state = self._make_state_with_energy(ep)
new_state = propagate_step(state, self._rotor())
assert new_state.energy.coherence_residual == pytest.approx(0.0)
def test_multiple_steps_monotonically_age(self):
"""Repeated propagation cools energy as recency decays."""
ep = _op.compute(
convergence_density=4,
activation_count=4,
current_cycle=0,
last_activation_cycle=0,
)
state = self._make_state_with_energy(ep)
# 20 steps of propagation — recency term exp(-age/12) decays
for _ in range(20):
state = propagate_step(state, _identity_rotor())
# After 20 cold steps, energy class should not be E4
assert state.energy.energy_class is not EnergyClass.E4
# ---------------------------------------------------------------------------
# EnergyProfile field storage round-trip on FieldState
# ---------------------------------------------------------------------------
class TestEnergyProfileRoundTrip:
def test_field_state_carries_energy_profile(self):
ep = _op.compute(convergence_density=3, activation_count=2)
F = _clean_versor()
state = FieldState(F=F, node=0, step=0, energy=ep)
assert state.energy is ep
assert state.energy.energy_class in list(EnergyClass)
def test_field_state_advance_preserves_energy(self):
ep = _op.compute(convergence_density=3)
F = _clean_versor()
state = FieldState(F=F, node=0, step=0, energy=ep)
new_F = _clean_versor()
advanced = state.advance(new_F, new_node=1)
assert advanced.energy is ep
assert advanced.step == 1