ADR-0006: wire energy recomputation into propagate_step, add test_energy.py, mark ADR Implemented
This commit is contained in:
parent
6bad4189d2
commit
47975dbcc7
3 changed files with 486 additions and 4 deletions
|
|
@ -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**
|
||||||
|
|
|
||||||
|
|
@ -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
366
tests/test_energy.py
Normal 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 (E0–E4)
|
||||||
|
- 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
|
||||||
Loading…
Reference in a new issue