Skip to content

Commit dc64b06

Browse files
author
TNFR AI Agent
committed
Fix empty spectral sectors and portable temporal and phase checks
1 parent 01e4afb commit dc64b06

8 files changed

Lines changed: 177 additions & 24 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ All notable changes to this project will be documented in this file.
6565
interfaces from loading.
6666
- Keep spectral-cache writes from signature readouts off the caller's graph,
6767
including snapshot-only calls and graphs with an existing cache.
68+
- Handle the empty disagreement sector explicitly in matrix-exponential and
69+
transient-gain calculations. Accept UTC `Z` timestamps consistently on Python
70+
3.10 while preserving missing, nonmonotone and mixed-timezone evidence. Compare
71+
FFT phase preservation by circular separation at the existing tolerance.
6872
- Make CLI byte-lineage tests independent of ignored historical artifacts,
6973
remove a pytest-benchmark fixture-name collision and compare spectral
7074
estimates against an analytic reference. CI retains per-Python JUnit reports.

‎benchmarks/temporal_interface_benchmark.py‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,10 +217,21 @@ def _parse_frequency_rows(text: str, *, max_rows: int):
217217

218218

219219
def _relative_times(timestamps: tuple[str, ...]):
220+
"""Read relative ISO times, normalizing only the terminal UTC designator.
221+
222+
Python 3.10 accepts an explicit ``+00:00`` offset but not a terminal ``Z``.
223+
This conversion changes no stored timestamp, offset, missing-row or ordering
224+
policy. A date alone followed by ``Z`` is not a UTC timestamp.
225+
"""
220226
parsed = []
221227
for value in timestamps:
222228
try:
223-
parsed.append(datetime.fromisoformat(value))
229+
terminal_utc = value.endswith("Z")
230+
iso_value = value[:-1] + "+00:00" if terminal_utc else value
231+
instant = datetime.fromisoformat(iso_value)
232+
if terminal_utc and instant.utcoffset() is None:
233+
raise ValueError("UTC designator requires a timezone-aware time")
234+
parsed.append(instant)
224235
except ValueError:
225236
parsed.append(None)
226237
if not parsed:

‎src/tnfr/physics/spectral_projectors.py‎

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@
1010
1111
This module represents degenerate spectral information as **projectors**
1212
``Π_λ = Q_λ Q_λ^H``. A projector depends only on the subspace, so it is
13-
invariant under ``Q_λ → Q_λ · U``. Every downstream spectral observable in TNFR
14-
is built from these projectors (see :mod:`tnfr.physics.spectral_certificates`),
15-
which makes basis independence structural rather than incidental.
13+
invariant under ``Q_λ → Q_λ · U``. Consumers of this projector API, including
14+
:mod:`tnfr.physics.spectral_certificates`, obtain that basis independence by
15+
construction. Other spectral APIs can retain basis-dependent coordinates.
1616
1717
Scope
1818
=====
@@ -33,7 +33,7 @@
3333

3434
import numpy as np
3535

36-
try: # scipy is an OPTIONAL dependency; the Schur certificate is gated on it
36+
try: # Retained availability guard; SciPy is a declared core package dependency.
3737
from scipy.linalg import schur as _scipy_schur
3838

3939
_HAS_SCIPY = True
@@ -224,16 +224,23 @@ def matrix_exponential(matrix, *, terms: int = 18) -> np.ndarray:
224224
225225
Self-contained so the non-normal certificates need no SciPy: ``A`` is scaled
226226
by ``2^{-s}`` until its ∞-norm is ``≤ 1``, a truncated Taylor series is
227-
evaluated, and the result is squared ``s`` times.
227+
evaluated, and the result is squared ``s`` times. Input must be a finite
228+
square matrix. On the zero-dimensional space, its exponential is the
229+
empty identity, represented with shape ``(0, 0)``.
228230
"""
229231
a = np.asarray(matrix, dtype=float)
232+
if a.ndim != 2 or a.shape[0] != a.shape[1] or not np.all(np.isfinite(a)):
233+
raise ValueError("matrix must be a finite square array")
234+
orders = range(1, terms)
230235
n = a.shape[0]
236+
if n == 0:
237+
return np.eye(0)
231238
norm = float(np.linalg.norm(a, np.inf))
232239
s = max(0, int(np.ceil(np.log2(norm + 1.0))))
233240
b = a / (2**s)
234241
result = np.eye(n)
235242
term = np.eye(n)
236-
for k in range(1, terms):
243+
for k in orders:
237244
term = term @ b / k
238245
result = result + term
239246
for _ in range(s):
@@ -258,8 +265,11 @@ def transient_gain(matrix, *, t_max: float = 10.0, samples: int = 200) -> float:
258265
stable spectrum alone does not reveal.
259266
"""
260267
a = np.asarray(matrix, dtype=float)
268+
times = np.linspace(0.0, t_max, samples)
269+
if a.shape == (0, 0):
270+
return 0.0 # The only operator on the zero-dimensional space has norm zero.
261271
gain = 0.0
262-
for t in np.linspace(0.0, t_max, samples):
272+
for t in times:
263273
gain = max(gain, float(np.linalg.norm(matrix_exponential(a * t), 2)))
264274
return gain
265275

‎src/tnfr/physics/transient_u2.py‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@
66
certificate on the non-consensus subspace ``{y : πᵀ y = 0}`` (the ``L``-invariant
77
range of the consensus projection ``Q = I − 1 πᵀ``):
88
9-
* **U2 (bounded reorganization).** The peak amplification ``sup_s ‖e^{−sL}‖`` on
10-
the non-consensus subspace is certified both directly (``peak_gain``, an upper
11-
scan) and from the resolvent (``kreiss_lower_bound``, the Kreiss lower bound);
12-
``kreiss ≤ peak`` by the Kreiss matrix theorem. The integrated reorganization
13-
``J`` and its closed bound ``M‖LQ‖‖x₀‖/ω`` come from the structural-time
14-
layer.
9+
* **U2 diagnostics.** ``peak_gain`` is the largest sampled amplification on a
10+
finite structural-time window of the non-consensus subspace. It is not an
11+
upper certificate for unsampled times or the infinite horizon. The resolvent
12+
routine supplies a finite sampled comparison. The exact Kreiss inequality
13+
relates global resolvent and semigroup suprema; it does not order these two
14+
independently sampled maxima. The structural-time layer also supplies
15+
integrated-reorganization estimates ``J`` and the reported comparison
16+
``M‖LQ‖‖x₀‖/ω``; a global bound requires an independently justified ``M``.
1517
* **Structural-potential magnitude.** The structural potential along the
1618
sampled relaxation window is ``Φ_s(s) = −B L e^{−sL} Q x₀`` with the
1719
inverse-square aggregation ``B[i][j] = d(i,j)^{−2}``. Its sampled absolute
@@ -151,6 +153,8 @@ def peak_transient_gain(
151153
"""
152154
t_max, samples = _scan_parameters(t_max, samples)
153155
lsub = restricted_generator(adjacency)
156+
if lsub.shape == (0, 0):
157+
return 0.0, 0.0 # No disagreement coordinates or amplification to scan.
154158
best, s_star = 0.0, 0.0
155159
for s in np.linspace(0.0, t_max, samples):
156160
g = float(np.linalg.norm(matrix_exponential(-lsub * s), 2))

‎tests/physics/test_directed_diffusion.py‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,44 @@ def test_matrix_exponential_of_zero_is_identity():
150150
assert np.allclose(matrix_exponential(np.zeros((3, 3))), np.eye(3))
151151

152152

153+
def test_empty_matrix_exponential_is_identity_without_a_norm_reduction(monkeypatch):
154+
def forbidden_norm(*args, **kwargs):
155+
raise AssertionError("An empty identity needs no numerical norm reduction")
156+
157+
monkeypatch.setattr(np.linalg, "norm", forbidden_norm)
158+
matrix = np.empty((0, 0))
159+
result = matrix_exponential(matrix)
160+
161+
np.testing.assert_array_equal(result, np.eye(0))
162+
assert result.shape == (0, 0)
163+
assert result is not matrix
164+
assert (result @ np.empty(0)).shape == (0,)
165+
166+
167+
@pytest.mark.parametrize(
168+
"matrix",
169+
[
170+
0.0,
171+
[],
172+
[1.0, 2.0],
173+
np.empty((0, 2)),
174+
np.empty((2, 0)),
175+
np.empty((0, 0, 0)),
176+
[[0.0, float("nan")], [0.0, 0.0]],
177+
[[float("inf")]],
178+
[[-float("inf")]],
179+
],
180+
)
181+
def test_matrix_exponential_rejects_malformed_or_nonfinite_matrices(matrix):
182+
with pytest.raises(ValueError, match="finite square"):
183+
matrix_exponential(matrix)
184+
185+
186+
def test_empty_matrix_exponential_does_not_bypass_series_index_validation():
187+
with pytest.raises(TypeError):
188+
matrix_exponential(np.empty((0, 0)), terms=1.5)
189+
190+
153191
def test_matrix_exponential_matches_diagonal():
154192
d = np.diag([-1.0, -2.0, 0.5])
155193
expected = np.diag([np.exp(-1.0), np.exp(-2.0), np.exp(0.5)])
@@ -160,6 +198,14 @@ def test_transient_gain_of_zero_generator_is_one():
160198
assert transient_gain(np.zeros((3, 3))) == pytest.approx(1.0, abs=1e-9)
161199

162200

201+
def test_empty_transient_sector_has_zero_gain_without_a_norm_reduction(monkeypatch):
202+
def forbidden_norm(*args, **kwargs):
203+
raise AssertionError("The zero-dimensional sector has operator norm zero")
204+
205+
monkeypatch.setattr(np.linalg, "norm", forbidden_norm)
206+
assert transient_gain(np.empty((0, 0))) == 0.0
207+
208+
163209
# --------------------------------------------------------------------------- #
164210
# Certificate and exports
165211
# --------------------------------------------------------------------------- #

‎tests/physics/test_transient_u2.py‎

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -171,12 +171,15 @@ def test_small_perturbation_has_small_potential_magnitude_without_u6_verdict():
171171

172172

173173
@pytest.mark.parametrize("kwargs", [{"t_max": -1.0}, {"samples": 1}])
174-
def test_finite_transient_scans_reject_degenerate_windows(kwargs):
175-
x = _unit([1.0, -1.0, 0.5, -0.5, 0.25, -0.25, 0.125])
174+
@pytest.mark.parametrize(
175+
"adjacency", [NORMAL, np.zeros((1, 1))], ids=["nontrivial", "trivial"]
176+
)
177+
def test_finite_transient_scans_reject_degenerate_windows(kwargs, adjacency):
178+
x = _unit([(-1) ** i / (i + 1) for i in range(len(adjacency))])
176179
with pytest.raises(ValueError):
177-
peak_transient_gain(NORMAL, **kwargs)
180+
peak_transient_gain(adjacency, **kwargs)
178181
with pytest.raises(ValueError):
179-
structural_potential_peak(NORMAL, x, **kwargs)
182+
structural_potential_peak(adjacency, x, **kwargs)
180183

181184

182185
def test_transient_certificate_reports_unassessed_tail():
@@ -195,7 +198,17 @@ def test_transient_certificate_rejects_unimplemented_metric_label():
195198
certify_transient_u2(NON_NORMAL, X4, norm_kind="weighted_l2")
196199

197200

198-
def test_single_node_certificate_has_trivial_nonconsensus_sector():
201+
def test_single_node_certificate_has_trivial_nonconsensus_sector(monkeypatch):
202+
original_norm = np.linalg.norm
203+
204+
def reject_empty_reduction(value, *args, **kwargs):
205+
# NumPy 2.2 rejects both empty infinity and spectral norm reductions.
206+
# Keep this boundary covered when newer NumPy supplies zero itself.
207+
if np.asarray(value).size == 0:
208+
raise ValueError("Empty norm reduction has no maximum")
209+
return original_norm(value, *args, **kwargs)
210+
211+
monkeypatch.setattr(np.linalg, "norm", reject_empty_reduction)
199212
certificate = certify_transient_u2(np.zeros((1, 1)), np.array([0.25]))
200213

201214
assert certificate.peak_gain == 0.0

‎tests/test_fft_nodal_product.py‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -308,17 +308,24 @@ def test_fft_step_rejects_tampered_diffusion_operator() -> None:
308308
engine.fft_accelerated_step(graph, state, 0.01)
309309

310310

311-
def test_fft_phase_step_respects_live_u3_gate() -> None:
311+
@pytest.mark.parametrize("phase_origin", (0.0, math.tau - 0.1))
312+
def test_fft_phase_step_respects_live_u3_gate(phase_origin: float) -> None:
312313
graph = nx.path_graph(2)
313314
graph.graph["DELTA_PHI_MAX"] = 0.1
314-
graph.nodes[0].update(EPI=0.2, nu_f=0.0, theta=0.0)
315-
graph.nodes[1].update(EPI=0.2, nu_f=0.0, theta=0.5)
315+
phases = (phase_origin, (phase_origin + 0.5) % math.tau)
316+
for node, phase in enumerate(phases):
317+
graph.nodes[node].update(EPI=0.2, nu_f=0.0, theta=phase)
316318
engine = FFTDynamicsEngine(enable_caching=False)
317319
state = engine.fft_accelerated_step(graph, engine.create_fft_state(graph), 0.1)
318320
engine.reconstruct_graph_from_fft(graph, state)
319321

320-
assert get_attr(graph.nodes[0], ALIAS_THETA) == pytest.approx(0.0, abs=1e-14)
321-
assert get_attr(graph.nodes[1], ALIAS_THETA) == pytest.approx(0.5, abs=1e-14)
322+
# A round-trip error below zero wraps near 2*pi. U3 and phase preservation
323+
# concern circular separation, including preparations across that branch.
324+
for node, expected in enumerate(phases):
325+
error = math.remainder(
326+
get_attr(graph.nodes[node], ALIAS_THETA) - expected, math.tau
327+
)
328+
assert error == pytest.approx(0.0, abs=1e-14)
322329

323330

324331
@pytest.mark.parametrize("channel", ("phase", "capacity"))

‎tests/test_temporal_ingestion.py‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import importlib.util
55
import sys
66
import zipfile
7+
from datetime import datetime
78
from pathlib import Path
89

910
import pytest
@@ -63,6 +64,63 @@ def test_time_unavailability_is_explicit_without_dropping_samples(
6364
assert record.time_status == status
6465

6566

67+
@pytest.mark.parametrize(
68+
"times,elapsed,status",
69+
[
70+
(
71+
("2020-01-01T00:00:00Z", "2020-01-01T00:00:01.25Z"),
72+
(0.0, 1.25),
73+
"relative_seconds",
74+
),
75+
(
76+
("2020-01-01T00:00:00Z", "2020-01-01T01:00:01+01:00"),
77+
(0.0, 1.0),
78+
"relative_seconds",
79+
),
80+
(
81+
("2020-01-01T00:00:01Z", "2020-01-01T00:00:00Z"),
82+
(0.0, -1.0),
83+
"nonmonotone_timestamp",
84+
),
85+
(
86+
("2020-01-01T00:00:00", "2020-01-01T00:00:01Z"),
87+
(None, None),
88+
"mixed_timezone_unavailable",
89+
),
90+
(
91+
("2020-01-01Z", "2020-01-01T00:00:01Z"),
92+
(None, None),
93+
"unavailable_timestamp",
94+
),
95+
],
96+
)
97+
def test_utc_designator_with_legacy_iso_parser_preserves_time_admission(
98+
tmp_path, monkeypatch, times, elapsed, status
99+
):
100+
received = []
101+
102+
class LegacyDatetime:
103+
@staticmethod
104+
def fromisoformat(value):
105+
received.append(value)
106+
# Reproduce Python 3.10's missing terminal-Z support without
107+
# requiring that interpreter on every development machine.
108+
if value.endswith("Z"):
109+
raise ValueError("Invalid isoformat string")
110+
return datetime.fromisoformat(value)
111+
112+
monkeypatch.setattr(BENCH, "datetime", LegacyDatetime)
113+
path = archive(tmp_path, f"timestamp,frequency\n{times[0]},50\n{times[1]},\n")
114+
record = BENCH.load_grid_frequency_record(path)
115+
assert record.timestamps == times
116+
assert record.values_hz == (50.0, None)
117+
assert record.missing == (False, True)
118+
assert record.source_line_numbers == (2, 3)
119+
assert record.elapsed_seconds == elapsed
120+
assert record.time_status == status
121+
assert len(received) == 2 and all(not value.endswith("Z") for value in received)
122+
123+
66124
@pytest.mark.parametrize(
67125
"limits,match",
68126
[

0 commit comments

Comments
 (0)