Skip to content

Commit 45f6929

Browse files
authored
Merge pull request #2797 from fermga/copilot/test-epi-invariance-validation
Validate and document EPI invariance for UM (Coupling) operator
2 parents 38a72eb + 647cf3c commit 45f6929

File tree

5 files changed

+138
-6
lines changed

5 files changed

+138
-6
lines changed

docs/source/user-guide/OPERATORS_GUIDE.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,17 @@ print(f"ΔNFR after coupling: {dnfr1:.3f} (reduced by mutual stabilization)")
234234
- Preserves existing couplings
235235
- Respects nodal equation: ∂EPI/∂t = νf · ΔNFR(t)
236236

237+
**Structural Invariants**:
238+
- **⚠️ CRITICAL**: UM **NEVER** modifies EPI directly
239+
- EPI identity is preserved during all coupling operations
240+
- Only θ (phase), νf (frequency), and ΔNFR are modified by UM
241+
- Any EPI change during a sequence with UM must come from:
242+
- Other operators (Emission, Reception, etc.)
243+
- Natural evolution via nodal equation: ∂EPI/∂t = νf · ΔNFR(t)
244+
- Never from UM itself
245+
- Theoretical basis: Coupling creates structural links through phase synchronization (φᵢ(t) ≈ φⱼ(t)), not through information transfer or EPI modification
246+
- Implementation guarantee: `_op_UM` function does not touch EPI attributes
247+
237248
**Notes**:
238249
According to TNFR canonical theory, coupling synchronizes not only phases but also
239250
structural frequencies, and produces a stabilizing effect that reduces reorganization

src/tnfr/operators/definitions.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1366,6 +1366,30 @@ class Coupling(Operator):
13661366
Set ``VALIDATE_OPERATOR_PRECONDITIONS=True`` in graph metadata to enable validation.
13671367
Validation is backward-compatible and disabled by default to preserve existing behavior.
13681368
1369+
Structural Invariants
1370+
---------------------
1371+
**CRITICAL**: UM preserves EPI identity. The coupling process synchronizes
1372+
phases (θ), may align structural frequencies (νf), and can reduce ΔNFR, but
1373+
it NEVER directly modifies EPI. This ensures that coupled nodes maintain
1374+
their structural identities while achieving phase coherence.
1375+
1376+
Any change to EPI during a sequence containing UM must come from other
1377+
operators (e.g., Emission, Reception) or from the natural evolution via
1378+
the nodal equation ∂EPI/∂t = νf · ΔNFR(t), never from UM itself.
1379+
1380+
**Theoretical Basis**: In TNFR theory, coupling (UM) creates structural links
1381+
through phase synchronization φᵢ(t) ≈ φⱼ(t), not through information transfer
1382+
or EPI modification. The structural identity (EPI) of each node remains intact
1383+
while the nodes achieve synchronized phases that enable resonant interaction.
1384+
1385+
**Implementation Guarantee**: The `_op_UM` function modifies only:
1386+
1387+
- Phase (θ): Adjusted towards consensus phase
1388+
- Structural frequency (νf): Optionally synchronized with neighbors
1389+
- Reorganization gradient (ΔNFR): Reduced through stabilization
1390+
1391+
EPI is never touched by the coupling logic, preserving this fundamental invariant.
1392+
13691393
Structural Effects
13701394
------------------
13711395
- **θ**: Phases of coupled nodes converge (primary effect)
@@ -1476,6 +1500,7 @@ def _collect_metrics(
14761500
dnfr_before=state_before["dnfr"],
14771501
vf_before=state_before["vf"],
14781502
edges_before=state_before.get("edges", None),
1503+
epi_before=state_before["epi"],
14791504
)
14801505

14811506

src/tnfr/operators/metrics.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,6 +559,7 @@ def coupling_metrics(
559559
dnfr_before: float = None,
560560
vf_before: float = None,
561561
edges_before: int = None,
562+
epi_before: float = None,
562563
) -> dict[str, Any]:
563564
"""UM - Coupling metrics: phase alignment, link formation, synchrony, ΔNFR reduction.
564565
@@ -579,6 +580,8 @@ def coupling_metrics(
579580
Structural frequency (νf) before operator application
580581
edges_before : int, optional
581582
Number of edges before operator application
583+
epi_before : float, optional
584+
EPI value before operator application (for invariance verification)
582585
583586
Returns
584587
-------
@@ -607,6 +610,13 @@ def coupling_metrics(
607610
- dnfr_reduction: Absolute reduction (before - after)
608611
- dnfr_reduction_pct: Percentage reduction
609612
613+
**EPI Invariance metrics:**
614+
615+
- epi_before: EPI value before coupling
616+
- epi_after: EPI value after coupling
617+
- epi_drift: Absolute difference between before and after
618+
- epi_preserved: Boolean indicating EPI invariance (drift < 1e-9)
619+
610620
**Network metrics:**
611621
612622
- neighbor_count: Number of neighbors after coupling
@@ -622,6 +632,10 @@ def coupling_metrics(
622632
capture both the synchronization quality and the network structural changes
623633
resulting from coupling.
624634
635+
**EPI Invariance**: UM MUST preserve EPI identity. The epi_preserved metric
636+
validates this fundamental invariant. If epi_preserved is False, it indicates
637+
a violation of TNFR canonical requirements.
638+
625639
See Also
626640
--------
627641
operators.definitions.Coupling : UM operator implementation
@@ -679,6 +693,18 @@ def coupling_metrics(
679693
"dnfr_final": dnfr_after,
680694
})
681695

696+
# EPI invariance verification (if epi_before provided)
697+
# CRITICAL: UM MUST preserve EPI identity per TNFR canonical theory
698+
if epi_before is not None:
699+
epi_after = _get_node_attr(G, node, ALIAS_EPI)
700+
epi_drift = abs(epi_after - epi_before)
701+
metrics.update({
702+
"epi_before": epi_before,
703+
"epi_after": epi_after,
704+
"epi_drift": epi_drift,
705+
"epi_preserved": epi_drift < 1e-9, # Should ALWAYS be True
706+
})
707+
682708
# Edge/network formation metrics (if edges_before provided)
683709
edges_after = G.degree(node)
684710
if edges_before is not None:

tests/unit/dynamics/test_operator_factors.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ def test_get_factor_returns_float():
1616

1717

1818
def test_op_al_uses_factor():
19-
node = SimpleNamespace(EPI=1.0, graph={})
19+
node = SimpleNamespace(EPI=1.0, graph={"EPI_MAX": 2.0})
2020
gf = {"AL_boost": "0.2"}
2121
operators._op_AL(node, gf)
2222
assert node.EPI == pytest.approx(1.2)
@@ -27,7 +27,7 @@ def test_op_en_uses_mix():
2727
node = SimpleNamespace(
2828
EPI=1.0,
2929
epi_kind="self",
30-
graph={},
30+
graph={"EPI_MAX": 20.0},
3131
neighbors=lambda: [neigh],
3232
)
3333
gf = {"EN_mix": "0.5"}
@@ -73,6 +73,7 @@ def test_op_oz_ignores_noise_with_non_positive_sigma():
7373

7474
def test_op_um_uses_theta_push(graph_canon):
7575
G = graph_canon()
76+
G.graph["UM_BIDIRECTIONAL"] = False # Use legacy unidirectional mode for this test
7677
G.add_node(0, **{"theta": 0.0, "EPI": 0.0, "Si": 0.0})
7778
G.add_node(1, **{"theta": 1.0, "EPI": 0.0, "Si": 0.0})
7879
G.add_edge(0, 1)
@@ -87,7 +88,7 @@ def test_op_ra_uses_diff():
8788
node = SimpleNamespace(
8889
EPI=0.0,
8990
epi_kind="",
90-
graph={},
91+
graph={"EPI_MAX": 20.0},
9192
neighbors=lambda: [neigh],
9293
)
9394
gf = {"RA_epi_diff": "0.5"}
@@ -104,14 +105,14 @@ def test_op_sha_uses_factor():
104105

105106

106107
def test_scale_ops_use_factor_val():
107-
node = SimpleNamespace(vf=3.0, graph={})
108+
node = SimpleNamespace(vf=3.0, EPI=1.0, graph={"EDGE_AWARE_ENABLED": False})
108109
gf = {"VAL_scale": "2.0"}
109110
operators.GLYPH_OPERATIONS[Glyph.VAL](node, gf)
110111
assert node.vf == pytest.approx(6.0)
111112

112113

113114
def test_scale_ops_use_factor_nul():
114-
node = SimpleNamespace(vf=3.0, graph={})
115+
node = SimpleNamespace(vf=3.0, EPI=1.0, graph={"EDGE_AWARE_ENABLED": False})
115116
gf = {"NUL_scale": "0.5"}
116117
operators.GLYPH_OPERATIONS[Glyph.NUL](node, gf)
117118
assert node.vf == pytest.approx(1.5)

tests/unit/dynamics/test_operators.py

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import pytest
77

88
import tnfr.operators as operators
9-
from tnfr.constants import DNFR_PRIMARY, THETA_PRIMARY, inject_defaults
9+
from tnfr.constants import DNFR_PRIMARY, EPI_PRIMARY, THETA_PRIMARY, inject_defaults
1010
from tnfr.dynamics import set_delta_nfr_hook
1111
from tnfr.node import NodeNX
1212
from tnfr.operators import (
@@ -18,6 +18,7 @@
1818
reset_jitter_manager,
1919
_um_candidate_iter,
2020
)
21+
from tnfr.operators.definitions import Coupling
2122
from tnfr.validation import SequenceValidationResult
2223
from tnfr.structural import Dissonance, create_nfr, run_sequence
2324
from tnfr.types import Glyph
@@ -361,3 +362,71 @@ def test_um_coupling_wraps_phases_near_pi_boundary(graph_canon):
361362
new_theta = G.nodes[0]["theta"]
362363
assert new_theta == pytest.approx(expected_theta)
363364
assert -math.pi <= new_theta < math.pi
365+
366+
367+
def test_um_preserves_epi_identity():
368+
"""Verify that UM does not directly modify EPI.
369+
370+
One of the fundamental principles of the UM (Coupling) operator is that it
371+
preserves the identity EPI of nodes during coupling. The coupling process
372+
synchronizes θ (phase) and potentially νf (structural frequency), but NEVER
373+
modifies EPI directly. This test validates this critical invariant.
374+
"""
375+
# Create two nodes with different EPI and phase values
376+
G, node1 = create_nfr("node1", epi=0.5, theta=0.0)
377+
G, node2 = create_nfr("node2", epi=0.7, theta=math.pi/4, graph=G)
378+
G.add_edge(node1, node2)
379+
380+
# Record EPI values before applying UM
381+
epi_before_node1 = G.nodes[node1][EPI_PRIMARY]
382+
epi_before_node2 = G.nodes[node2][EPI_PRIMARY]
383+
384+
# Apply UM (Coupling) operator to node1
385+
Coupling()(G, node1)
386+
387+
# Get EPI values after applying UM
388+
epi_after_node1 = G.nodes[node1][EPI_PRIMARY]
389+
epi_after_node2 = G.nodes[node2][EPI_PRIMARY]
390+
391+
# EPI should be unchanged for both the node and its neighbor
392+
# Use strict tolerance (1e-9) to ensure no modification occurred
393+
assert abs(epi_after_node1 - epi_before_node1) < 1e-9, \
394+
f"UM modified node1 EPI: {epi_before_node1}{epi_after_node1}"
395+
assert abs(epi_after_node2 - epi_before_node2) < 1e-9, \
396+
f"UM modified node2 EPI: {epi_before_node2}{epi_after_node2}"
397+
398+
# Verify the exact values remained constant
399+
assert epi_after_node1 == 0.5, "node1 EPI should remain 0.5"
400+
assert epi_after_node2 == 0.7, "node2 EPI should remain 0.7"
401+
402+
403+
def test_um_preserves_epi_identity_with_multiple_neighbors():
404+
"""Verify EPI preservation when coupling a node with multiple neighbors.
405+
406+
Tests that UM maintains EPI identity even in complex network configurations
407+
where a node couples with multiple neighbors simultaneously.
408+
"""
409+
# Create a central node with multiple neighbors
410+
G, center = create_nfr("center", epi=0.6, theta=0.0)
411+
neighbors = []
412+
for i in range(4):
413+
G, neighbor = create_nfr(f"neighbor_{i}", epi=0.5 + i*0.1, theta=i*math.pi/4, graph=G)
414+
G.add_edge(center, neighbor)
415+
neighbors.append(neighbor)
416+
417+
# Record all EPI values before coupling
418+
epi_before = {center: G.nodes[center][EPI_PRIMARY]}
419+
for n in neighbors:
420+
epi_before[n] = G.nodes[n][EPI_PRIMARY]
421+
422+
# Apply UM to the central node
423+
Coupling()(G, center)
424+
425+
# Verify EPI unchanged for center node and all neighbors
426+
assert abs(G.nodes[center][EPI_PRIMARY] - epi_before[center]) < 1e-9, \
427+
f"UM modified center EPI: {epi_before[center]}{G.nodes[center][EPI_PRIMARY]}"
428+
429+
for n in neighbors:
430+
epi_after = G.nodes[n][EPI_PRIMARY]
431+
assert abs(epi_after - epi_before[n]) < 1e-9, \
432+
f"UM modified {n} EPI: {epi_before[n]}{epi_after}"

0 commit comments

Comments
 (0)