diff --git a/agencitylab/analysis/regimes.py b/agencitylab/analysis/regimes.py index 5f94c2d..103cc41 100644 --- a/agencitylab/analysis/regimes.py +++ b/agencitylab/analysis/regimes.py @@ -18,9 +18,14 @@ class RegimeCriteria: """Contextual thresholds for automatic regime classification. The theory supplies qualitative descriptors such as low/high angular - variance and zero/non-zero curvature, but it does not prescribe universal - finite-record thresholds. Every numerical field here is therefore a - diagnostic choice that must be supplied by the caller or experiment. + variance, convergence, periodicity, weak structure, and irregular + structure, but it does not prescribe universal finite-record thresholds. + Every numerical field here is therefore a diagnostic choice supplied by + the caller or experiment. + + ``weak_flow_max`` is retained only for source compatibility with v0.5. It + no longer separates stochastic and chaotic regimes because absolute flow + magnitude is not the theoretical distinction between them. """ sigma_theta_low_max: float @@ -29,13 +34,18 @@ class RegimeCriteria: unstable_growth_ratio_min: float curvature_zero_max: float periodicity_min: float - weak_flow_max: float + weak_flow_max: float | None = None + weak_structure_max: float | None = None + weak_beta_variance_max: float | None = None + structure_variability_min: float | None = None def __post_init__(self) -> None: values = asdict(self) for name, value in values.items(): - value = float(value) - if not np.isfinite(value) or value < 0.0: + if value is None: + continue + numeric = float(value) + if not np.isfinite(numeric) or numeric < 0.0: raise ValueError(f"{name} must be finite and non-negative") if self.sigma_theta_high_min < self.sigma_theta_low_max: raise ValueError("sigma_theta_high_min must be >= sigma_theta_low_max") @@ -54,20 +64,104 @@ def _tail(values: np.ndarray, fraction: float = 0.25) -> np.ndarray: return values[max(0, start) :] -def _periodicity_score(beta: np.ndarray, xi: np.ndarray, tau: float) -> float: - if beta.size < 4 or np.all(beta == 0.0): +def _coefficient_of_variation(values: np.ndarray) -> float: + if not values.size: return float("nan") - step = float(np.median(np.diff(xi))) - shift = int(round(float(tau) / step)) - if shift < 1 or 2 * shift >= beta.size: + mean = float(np.mean(values)) + std = float(np.std(values)) + if mean > 0.0: + return std / mean + return 0.0 if std == 0.0 else float("nan") + + +def _growth_ratio(values: np.ndarray) -> float: + if not values.size: return float("nan") + quarter = max(1, values.size // 4) + early = float(np.mean(values[:quarter])) + late = float(np.mean(values[-quarter:])) + if early == 0.0: + return float("inf") if late > 0.0 else 1.0 + return late / early + + +def _complex_variance(values: np.ndarray) -> float: + if not values.size: + return float("nan") + centre = np.mean(values) + return float(np.mean(np.abs(values - centre) ** 2)) + + +def _relative_fixed_point_rms(values: np.ndarray) -> float: + """Return tail scatter relative to its complex fixed-point candidate.""" + if not values.size: + return float("nan") + centre = complex(np.mean(values)) + scatter = float(np.sqrt(np.mean(np.abs(values - centre) ** 2))) + scale = abs(centre) + if scale > 0.0: + return scatter / scale + return 0.0 if scatter == 0.0 else float("inf") + + +def _periodicity_diagnostic( + beta: np.ndarray, + xi: np.ndarray, +) -> tuple[float, float | None]: + """Estimate finite-record beta periodicity without identifying tau with T. + + This is a numerical diagnostic, not a canonical equation. The strongest + non-zero Fourier component proposes a candidate period, then two complete + terminal cycles are compared directly. A constant/fixed-point record and + records with fewer than two candidate cycles are left undefined. + """ + if beta.ndim != 1 or xi.ndim != 1 or beta.size != xi.size or beta.size < 8: + return float("nan"), None + if not np.all(np.isfinite(xi)) or np.any(np.diff(xi) <= 0.0): + return float("nan"), None + finite_beta = np.isfinite(np.real(beta)) & np.isfinite(np.imag(beta)) + if not np.all(finite_beta): + return float("nan"), None + + diffs = np.diff(xi) + step = float(np.median(diffs)) + spacing_error = float(np.max(np.abs(diffs - step))) + numerical_spacing_tol = ( + 64.0 + * np.finfo(float).eps + * max(1.0, abs(step), float(np.max(np.abs(xi)))) + ) + if spacing_error > numerical_spacing_tol: + return float("nan"), None + + centred = beta - np.mean(beta) + scale = float(np.sqrt(np.mean(np.abs(centred) ** 2))) + if scale == 0.0: + return float("nan"), None + + n = beta.size + spectrum = np.fft.fft(centred) + max_k = n // 2 + if max_k < 1: + return float("nan"), None + candidate_k = np.arange(1, max_k + 1) + paired_power = np.abs(spectrum[candidate_k]) ** 2 + negative_k = (-candidate_k) % n + paired_power += np.abs(spectrum[negative_k]) ** 2 + if not np.any(paired_power > 0.0): + return float("nan"), None + + dominant_k = int(candidate_k[int(np.argmax(paired_power))]) + candidate_period = n * step / dominant_k + shift = int(round(candidate_period / step)) + if shift < 2 or 2 * shift > n: + return float("nan"), None + left = beta[-2 * shift : -shift] right = beta[-shift:] - scale = float(np.sqrt(np.mean(np.abs(left) ** 2))) mismatch = float(np.sqrt(np.mean(np.abs(left - right) ** 2))) - if scale == 0.0: - return float("nan") - return float(1.0 / (1.0 + mismatch / scale)) + score = float(1.0 / (1.0 + mismatch / scale)) + return score, float(shift * step) def regime_signature( @@ -87,7 +181,13 @@ def regime_signature( if any(arr.ndim != 1 or arr.size != n for arr in (S, D, J, theta, beta, b)): raise ValueError("result arrays must be one-dimensional and share xi length") - exact_null = bool(np.all(b == 0.0) and np.all(S == 0.0)) + exact_null = bool( + np.all(D == 0.0) + and np.all(S == 0.0) + and np.all(J == 0.0) + and np.all(beta == 0.0) + and np.all(b == 0.0) + ) interval = resolve_analysis_interval(result, edge_samples=2) analysis_mask = interval.mask valid_count = int(np.count_nonzero(analysis_mask)) @@ -98,42 +198,36 @@ def regime_signature( kappa = curvature(beta, xi) finite_kappa = np.isfinite(kappa) & analysis_mask - mag = np.abs(b) - mag_valid = mag[analysis_mask] + mag_b = np.abs(b) + mag_beta = np.abs(beta) + mag_b_valid = mag_b[analysis_mask] + mag_beta_valid = mag_beta[analysis_mask] D_valid = D[analysis_mask] S_valid = S[analysis_mask] J_valid = J[analysis_mask] beta_valid = beta[analysis_mask] xi_valid = xi[analysis_mask] - tail_mag = _tail(mag_valid) + tail_mag_b = _tail(mag_b_valid) + tail_mag_beta = _tail(mag_beta_valid) + tail_beta = _tail(beta_valid) tail_J = _tail(J_valid) - tail_mean = float(np.mean(tail_mag)) if tail_mag.size else float("nan") - tail_std = float(np.std(tail_mag)) if tail_mag.size else float("nan") - tail_cv = ( - tail_std / tail_mean - if tail_mean > 0.0 - else (0.0 if tail_std == 0.0 and tail_mag.size else float("nan")) - ) - - if mag_valid.size: - quarter = max(1, mag_valid.size // 4) - early = float(np.mean(mag_valid[:quarter])) - late = float(np.mean(mag_valid[-quarter:])) - if early == 0.0: - growth_ratio = float("inf") if late > 0.0 else 1.0 - else: - growth_ratio = late / early - else: - growth_ratio = float("nan") + finite_kappa_values = kappa[finite_kappa] + tail_kappa = _tail(finite_kappa_values) zeros = detect_agencity_zeros(S, J) analysis_zero_count = int(np.count_nonzero(analysis_mask[zeros])) if zeros.size else 0 - periodicity = _periodicity_score(beta_valid, xi_valid, tau) if valid_count else float("nan") + periodicity, estimated_period = ( + _periodicity_diagnostic(beta_valid, xi_valid) + if valid_count + else (float("nan"), None) + ) def mean_or_nan(values): return float(np.mean(values)) if values.size else float("nan") + tail_beta_mean = complex(np.mean(tail_beta)) if tail_beta.size else complex(np.nan, np.nan) + return { "n_samples": int(n), "analysis_valid_samples": valid_count, @@ -145,21 +239,37 @@ def mean_or_nan(values): "exact_null": exact_null, "mean_b_real": mean_or_nan(np.real(b[analysis_mask])), "mean_b_imag": mean_or_nan(np.imag(b[analysis_mask])), - "mean_abs_b": mean_or_nan(mag_valid), - "var_abs_b": float(np.var(mag_valid)) if mag_valid.size else float("nan"), + "mean_abs_b": mean_or_nan(mag_b_valid), + "var_abs_b": float(np.var(mag_b_valid)) if mag_b_valid.size else float("nan"), + "tail_cv_abs_b": _coefficient_of_variation(tail_mag_b), + "growth_ratio_abs_b": _growth_ratio(mag_b_valid), + "mean_abs_beta": mean_or_nan(mag_beta_valid), + "variance_beta": _complex_variance(beta_valid), + "tail_mean_abs_beta": mean_or_nan(tail_mag_beta), + "tail_beta_mean_real": float(np.real(tail_beta_mean)), + "tail_beta_mean_imag": float(np.imag(tail_beta_mean)), + "tail_beta_mean_abs": float(abs(tail_beta_mean)), + "tail_beta_relative_rms": _relative_fixed_point_rms(tail_beta), + "tail_cv_abs_beta": _coefficient_of_variation(tail_mag_beta), + "growth_ratio_abs_beta": _growth_ratio(mag_beta_valid), "mean_D": mean_or_nan(D_valid), "mean_S": mean_or_nan(S_valid), + "std_S": float(np.std(S_valid)) if S_valid.size else float("nan"), "mean_J": mean_or_nan(J_valid), "tail_mean_J": mean_or_nan(tail_J), - "tail_cv_abs_b": float(tail_cv), - "growth_ratio_abs_b": float(growth_ratio), "sigma_theta_mean": mean_or_nan(sigma[finite_sigma]), "curvature_mean_abs": mean_or_nan(np.abs(kappa[finite_kappa])), + "tail_curvature_mean_abs": mean_or_nan(np.abs(tail_kappa)), "curvature_std": ( float(np.std(kappa[finite_kappa])) if np.any(finite_kappa) else float("nan") ), + "periodicity_score": periodicity, + "periodicity_defined": bool(np.isfinite(periodicity)), + "estimated_period": estimated_period, + # Compatibility aliases. Their names are historical; the score is no + # longer evaluated at lag tau and tau is not identified with a period. "tau_periodicity_score": periodicity, "tau_periodicity_defined": bool(np.isfinite(periodicity)), "zero_density_after_crm_warmup": ( @@ -179,15 +289,36 @@ def _criteria_from(value) -> RegimeCriteria | None: raise TypeError("criteria must be RegimeCriteria, a mapping, or None") -def classify_regime(result_or_signature, *, criteria=None, theta=None, alpha=None, epsilon=None, verbose: bool = False) -> str: +def _metric(signature: Mapping, name: str) -> float: + value = signature.get(name, float("nan")) + try: + return float(value) + except (TypeError, ValueError): + return float("nan") + + +def classify_regime( + result_or_signature, + *, + criteria=None, + theta=None, + alpha=None, + epsilon=None, + verbose: bool = False, +) -> str: """Classify a regime using explicit diagnostic criteria. With no criteria, only the exact null state is classified; every non-null - finite record is returned as ``"undetermined"``. This conservative default - prevents old hard-coded thresholds from masquerading as theory. + finite record is returned as ``"undetermined"``. A raw ``b`` array never + proves the null regime because ``b = 0`` alone does not establish + ``D = S = beta = 0``. + + Non-null classification uses intrinsic evidence from ``beta``, ``J``, + ``Theta`` and ``S``. Absolute ``b`` magnitude is retained in the signature + as a flux observation but does not define the intrinsic regime. ``theta``, ``alpha`` and ``epsilon`` are accepted for source compatibility - with the pre-v0.5 API but are not used to redefine the v0.5 classification. + with the pre-v0.5 API but are not used to redefine the classification. """ del theta, alpha, epsilon if isinstance(result_or_signature, Mapping) and "mean_abs_b" in result_or_signature: @@ -198,8 +329,6 @@ def classify_regime(result_or_signature, *, criteria=None, theta=None, alpha=Non b = np.asarray(result_or_signature, dtype=complex) if b.ndim != 1 or b.size == 0: return "unknown" - if np.all(b == 0.0): - return "null" return "undetermined" if bool(sig.get("exact_null", False)): @@ -209,28 +338,70 @@ def classify_regime(result_or_signature, *, criteria=None, theta=None, alpha=Non if cfg is None: return "undetermined" - sigma = float(sig["sigma_theta_mean"]) - kappa = float(sig["curvature_mean_abs"]) - growth = float(sig["growth_ratio_abs_b"]) - tail_cv = float(sig["tail_cv_abs_b"]) - periodicity = float(sig["tau_periodicity_score"]) - mean_abs_b = float(sig["mean_abs_b"]) - tail_J = float(sig["tail_mean_J"]) + sigma = _metric(sig, "sigma_theta_mean") + tail_kappa = _metric(sig, "tail_curvature_mean_abs") + growth = _metric(sig, "growth_ratio_abs_beta") + fixed_point_rms = _metric(sig, "tail_beta_relative_rms") + periodicity = _metric(sig, "periodicity_score") + beta_variance = _metric(sig, "variance_beta") + mean_abs_beta = _metric(sig, "mean_abs_beta") + tail_beta_abs = _metric(sig, "tail_beta_mean_abs") + mean_S = _metric(sig, "mean_S") + std_S = _metric(sig, "std_S") + tail_J = _metric(sig, "tail_mean_J") low_sigma = np.isfinite(sigma) and sigma <= cfg.sigma_theta_low_max high_sigma = np.isfinite(sigma) and sigma >= cfg.sigma_theta_high_min - flat_geometry = np.isfinite(kappa) and kappa <= cfg.curvature_zero_max + flat_geometry = np.isfinite(tail_kappa) and tail_kappa <= cfg.curvature_zero_max + fixed_point = np.isfinite(fixed_point_rms) and fixed_point_rms <= cfg.tail_cv_max periodic = np.isfinite(periodicity) and periodicity >= cfg.periodicity_min - - if low_sigma and growth >= cfg.unstable_growth_ratio_min and flat_geometry: + aperiodic = np.isfinite(periodicity) and periodicity < cfg.periodicity_min + + if ( + low_sigma + and np.isfinite(growth) + and growth >= cfg.unstable_growth_ratio_min + and tail_J > 0.0 + and flat_geometry + ): label = "unstable" - elif low_sigma and tail_cv <= cfg.tail_cv_max and tail_J < 0.0 and flat_geometry: + elif low_sigma and fixed_point and tail_J < 0.0 and tail_beta_abs > 0.0: label = "passive_damped" - elif low_sigma and periodic and np.isfinite(kappa) and kappa > cfg.curvature_zero_max: + elif ( + low_sigma + and periodic + and not fixed_point + and mean_abs_beta > 0.0 + and beta_variance > 0.0 + and (not np.isfinite(growth) or growth < cfg.unstable_growth_ratio_min) + ): + # Curvature remains reported evidence, but is not a hard gate here: + # the theory's Van der Pol discussion can degenerate to an almost-real + # segment even while beta is demonstrably periodic and closed. label = "active_oscillating" - elif high_sigma and mean_abs_b <= cfg.weak_flow_max: + elif ( + high_sigma + and cfg.weak_structure_max is not None + and cfg.weak_beta_variance_max is not None + and np.isfinite(mean_S) + and mean_S <= cfg.weak_structure_max + and np.isfinite(beta_variance) + and beta_variance <= cfg.weak_beta_variance_max + ): label = "stochastic" - elif high_sigma and mean_abs_b > cfg.weak_flow_max: + elif ( + high_sigma + and cfg.weak_structure_max is not None + and cfg.weak_beta_variance_max is not None + and cfg.structure_variability_min is not None + and np.isfinite(mean_S) + and mean_S > cfg.weak_structure_max + and np.isfinite(std_S) + and std_S >= cfg.structure_variability_min + and np.isfinite(beta_variance) + and beta_variance > cfg.weak_beta_variance_max + and aperiodic + ): label = "chaotic" else: label = "undetermined" @@ -241,7 +412,13 @@ def classify_regime(result_or_signature, *, criteria=None, theta=None, alpha=Non return label -def detect_regime_changes(b, *, window: int = 32, epsilon: float = 1e-12, component: str = "magnitude"): +def detect_regime_changes( + b, + *, + window: int = 32, + epsilon: float = 1e-12, + component: str = "magnitude", +): """Historical rolling-variance change detector retained as a heuristic. It is not used by the theory-facing v0.5 classifier. diff --git a/docs/agencity_analysis.md b/docs/agencity_analysis.md index f901f95..49e56df 100644 --- a/docs/agencity_analysis.md +++ b/docs/agencity_analysis.md @@ -102,7 +102,9 @@ For CRM-dependent finite-record geometry and transition summaries, the high-leve ## Regime signatures and classification -`regime_signature(result)` extracts threshold-free finite-record observations such as mean and variance of `|b|`, mean `D/S/J`, tail behaviour, `Sigma_Theta`, beta curvature, a tau-periodicity score, growth ratio, and zero density. +`regime_signature(result)` extracts threshold-free finite-record observations from both the observable flux and the intrinsic state. Flux statistics on `b` are retained descriptively, while regime evidence is computed from `beta`, `J`, `Theta`, and `S`: complex fixed-point convergence, intrinsic growth, beta variance, structural variability, curvature, zero density, and a periodicity score whose candidate period is estimated independently of `tau`. + +`tau` remains the structural characteristic time used by the theory. It is not silently identified with a dynamical period `T`. The historical `tau_periodicity_score` signature key is retained as a compatibility alias, but its value now comes from the independently estimated-period diagnostic. Automatic classification is deliberately separate. The qualitative theory table distinguishes: @@ -113,9 +115,13 @@ Automatic classification is deliberately separate. The qualitative theory table - `stochastic` - `chaotic` -Only the exact null state can be classified without interpretive thresholds. Non-null records default to `undetermined`. To request automatic classification, supply a `RegimeCriteria` object or equivalent mapping. Its values are contextual diagnostic criteria and are recorded alongside the result. +Only the canonical null state can be classified without interpretive thresholds. The null predicate requires the intrinsic pipeline to be null (`D = S = J = beta = b = 0`); a raw zero-valued `b` array is insufficient evidence and therefore remains `undetermined`. + +Non-null records default to `undetermined`. To request automatic classification, supply a `RegimeCriteria` object or equivalent mapping. Its values are contextual diagnostic criteria and are recorded alongside the result. Passive and unstable decisions use intrinsic beta convergence/growth. The stochastic/chaotic split uses weak structure and low beta variance versus fluctuating structured, aperiodic beta behaviour; absolute `|b|` no longer separates those regimes. `weak_flow_max` remains accepted only for source compatibility and is not used for this split. + +Curvature remains an independent geometric diagnostic. It is not forced to be non-zero to manufacture an `active_oscillating` result: the theoretical Van der Pol discussion can yield an almost-real beta segment even though periodic closure is present. This tension is exposed rather than hidden by an artificial curvature rule. -This conservative design is intentional: noise and chaos may have local non-zero `beta`, and high `D` does not imply coherent agencity. +This conservative design is intentional: noise and chaos may have local non-zero `beta`, high `D` does not imply coherent agencity, and finite records may legitimately remain `undetermined`. ## Multiscale signatures diff --git a/tests/analysis/test_regimes.py b/tests/analysis/test_regimes.py index 49a16fa..93734c7 100644 --- a/tests/analysis/test_regimes.py +++ b/tests/analysis/test_regimes.py @@ -1,6 +1,10 @@ import numpy as np -from agencitylab.analysis.regimes import RegimeCriteria, classify_regime +from agencitylab.analysis.regimes import ( + RegimeCriteria, + _periodicity_diagnostic, + classify_regime, +) CRITERIA = RegimeCriteria( @@ -11,18 +15,26 @@ curvature_zero_max=0.05, periodicity_min=0.8, weak_flow_max=0.2, + weak_structure_max=0.2, + weak_beta_variance_max=0.05, + structure_variability_min=0.05, ) def signature(**overrides): base = { "exact_null": False, - "sigma_theta_mean": 0.1, - "curvature_mean_abs": 0.0, - "growth_ratio_abs_b": 1.0, - "tail_cv_abs_b": 0.5, - "tau_periodicity_score": 0.0, "mean_abs_b": 1.0, + "sigma_theta_mean": 0.1, + "tail_curvature_mean_abs": 0.0, + "growth_ratio_abs_beta": 1.0, + "tail_beta_relative_rms": 0.5, + "periodicity_score": 0.0, + "variance_beta": 0.2, + "mean_abs_beta": 1.0, + "tail_beta_mean_abs": 1.0, + "mean_S": 0.7, + "std_S": 0.02, "tail_mean_J": 0.0, } base.update(overrides) @@ -31,31 +43,154 @@ def signature(**overrides): def test_classifier_has_no_universal_non_null_default(): assert classify_regime(np.array([1.0 + 0.0j])) == "undetermined" - assert classify_regime(np.zeros(5, dtype=complex)) == "null" + assert classify_regime(np.zeros(5, dtype=complex)) == "undetermined" + assert classify_regime(signature(exact_null=True)) == "null" def test_explicit_criteria_map_theory_regime_signatures(): assert classify_regime( - signature(growth_ratio_abs_b=3.0), + signature(growth_ratio_abs_beta=3.0, tail_mean_J=0.5), criteria=CRITERIA, ) == "unstable" assert classify_regime( - signature(tail_cv_abs_b=0.05, tail_mean_J=-0.5), + signature(tail_beta_relative_rms=0.05, tail_mean_J=-0.5), criteria=CRITERIA, ) == "passive_damped" assert classify_regime( - signature(curvature_mean_abs=0.2, tau_periodicity_score=0.95), + signature( + periodicity_score=0.95, + tail_beta_relative_rms=0.4, + tail_curvature_mean_abs=0.0, + ), criteria=CRITERIA, ) == "active_oscillating" assert classify_regime( - signature(sigma_theta_mean=2.0, mean_abs_b=0.1), + signature( + sigma_theta_mean=2.0, + mean_S=0.1, + std_S=0.02, + variance_beta=0.02, + ), criteria=CRITERIA, ) == "stochastic" assert classify_regime( - signature(sigma_theta_mean=2.0, mean_abs_b=1.0), + signature( + sigma_theta_mean=2.0, + mean_S=0.6, + std_S=0.2, + variance_beta=0.4, + periodicity_score=0.3, + ), criteria=CRITERIA, ) == "chaotic" + + +def test_absolute_flow_magnitude_no_longer_splits_noise_from_chaos(): + noisy = signature( + sigma_theta_mean=2.0, + mean_abs_b=1000.0, + mean_S=0.1, + variance_beta=0.02, + ) + chaotic = signature( + sigma_theta_mean=2.0, + mean_abs_b=0.001, + mean_S=0.6, + std_S=0.2, + variance_beta=0.4, + periodicity_score=0.3, + ) + assert classify_regime(noisy, criteria=CRITERIA) == "stochastic" + assert classify_regime(chaotic, criteria=CRITERIA) == "chaotic" + + +def test_high_sigma_is_undetermined_without_structure_variance_criteria(): + legacy_criteria = RegimeCriteria( + sigma_theta_low_max=0.2, + sigma_theta_high_min=1.0, + tail_cv_max=0.1, + unstable_growth_ratio_min=2.0, + curvature_zero_max=0.05, + periodicity_min=0.8, + weak_flow_max=0.2, + ) + assert classify_regime( + signature(sigma_theta_mean=2.0, mean_abs_b=0.01), + criteria=legacy_criteria, + ) == "undetermined" + + +def test_periodicity_is_estimated_independently_of_tau(): + xi = np.linspace(0.0, 40.0, 4001) + period = 2.5 + beta = (0.7 + 0.3 * np.cos(2.0 * np.pi * xi / period)) + 0.1j * np.sin( + 2.0 * np.pi * xi / period + ) + + score, estimated_period = _periodicity_diagnostic(beta, xi) + + assert score > 0.99 + assert estimated_period is not None + assert np.isclose(estimated_period, period, rtol=0.01) + + +def test_periodicity_is_undefined_for_a_fixed_point(): + xi = np.linspace(0.0, 20.0, 1001) + beta = np.full(xi.shape, -0.5 + 0.0j) + score, estimated_period = _periodicity_diagnostic(beta, xi) + assert np.isnan(score) + assert estimated_period is None + + +def test_exact_null_requires_absence_of_dynamics_not_only_zero_flux(monkeypatch): + import agencitylab.analysis.regimes as regimes + + class Interval: + mask = np.ones(101, dtype=bool) + valid_fraction = 1.0 + start_time = 0.0 + stop_time = 10.0 + memory_window = 1.0 + memory_window_source = "tau_fallback" + + monkeypatch.setattr(regimes, "resolve_analysis_interval", lambda *args, **kwargs: Interval()) + monkeypatch.setattr( + regimes, + "sigma_theta", + lambda theta, xi, tau, valid_mask=None: np.full(theta.shape, np.nan), + ) + monkeypatch.setattr( + regimes, + "curvature", + lambda beta, xi: np.full(beta.shape, np.nan), + ) + monkeypatch.setattr( + regimes, + "detect_agencity_zeros", + lambda S, J: np.flatnonzero((S == 0.0) | (J == 0.0)), + ) + + xi = np.linspace(0.0, 10.0, 101) + zeros = np.zeros_like(xi) + rest = { + "xi": xi, + "tau": 1.0, + "D": zeros, + "S": zeros, + "J": zeros, + "theta": zeros, + "beta": zeros.astype(complex), + "b": zeros.astype(complex), + } + dynamic_without_structure = { + **rest, + "D": np.ones_like(xi), + "J": np.full_like(xi, np.log((np.e + 1.0) / np.e)), + } + + assert regimes.regime_signature(rest)["exact_null"] is True + assert regimes.regime_signature(dynamic_without_structure)["exact_null"] is False diff --git a/tests/analysis/test_runtime_validation_regressions.py b/tests/analysis/test_runtime_validation_regressions.py index 7b318d1..04d2747 100644 --- a/tests/analysis/test_runtime_validation_regressions.py +++ b/tests/analysis/test_runtime_validation_regressions.py @@ -125,6 +125,7 @@ def test_regime_growth_distinguishes_decay_and_growth_after_warmup(): def test_exact_null_periodicity_is_undefined(): result = _result(magnitude=np.array([0.0])) + result.D = np.zeros_like(result.D) result.S = np.zeros_like(result.S) signature = regime_signature(result) assert signature["exact_null"] is True