From 508687d536921444d00c91b6a69c89c00a5fe352 Mon Sep 17 00:00:00 2001 From: Gilbra Date: Tue, 18 Aug 2026 13:42:03 +0200 Subject: [PATCH 1/3] fix: clear mypy debt and repair Vopson helper --- .github/workflows/ci.yml | 2 + CHANGELOG.md | 4 + .../analysis/information/agencity_info.py | 16 ++-- agencitylab/analysis/information/vopson.py | 37 +++++---- agencitylab/analysis/reports.py | 14 ++-- agencitylab/api/analyze.py | 7 +- agencitylab/api/export.py | 4 +- agencitylab/backends/numba_backend.py | 5 +- agencitylab/backends/numpy_backend.py | 10 +-- .../data/feature_extract/embeddings.py | 12 +-- agencitylab/data/pipeline.py | 7 +- agencitylab/dynamics/delays.py | 9 ++- agencitylab/fields/dynamics/simulation.py | 4 +- agencitylab/fields/numerics/operators.py | 69 +++++++++------- agencitylab/fields/physics/bridge.py | 2 + agencitylab/io/csv.py | 4 +- agencitylab/io/json.py | 4 +- agencitylab/io/result_serialization.py | 4 +- agencitylab/models/config_model.py | 17 ++-- agencitylab/models/context.py | 14 +++- agencitylab/models/dataset.py | 8 ++ agencitylab/models/field_extensions.py | 12 ++- agencitylab/models/result.py | 10 ++- agencitylab/models/streaming.py | 18 +++-- agencitylab/reference/scenarios.py | 19 +++-- agencitylab/thermodynamics/laws.py | 18 +++-- agencitylab/utils/normalization.py | 6 +- agencitylab/visualization/heatmaps.py | 2 +- tests/test_mypy_debt_runtime.py | 80 +++++++++++++++++++ 29 files changed, 293 insertions(+), 125 deletions(-) create mode 100644 tests/test_mypy_debt_runtime.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 810a751..dc4dd13 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,6 +89,8 @@ jobs: agencitylab/reference/_download.py \ agencitylab/exceptions.py \ agencitylab/scientific_status.py + - name: Type-check complete package + run: mypy agencitylab tests: name: Tests / Python ${{ matrix.python-version }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d21a9a..06afdb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,10 @@ identifiers are not part of the stable public compatibility contract. - Added explicit result-summary names for magnitude means and circular phase statistics while retaining compatibility keys. - Reconciled the documented and actual ``agencitylab.fields`` dynamics exports. +- Fixed the optional Vopson information-mass helper to use numeric physical-constant + values while keeping the relation explicitly speculative. +- Resolved the historical package-wide mypy debt and added a full-package typing + gate so internal typing regressions are no longer hidden behind the public-surface check. ### Scientific integrity diff --git a/agencitylab/analysis/information/agencity_info.py b/agencitylab/analysis/information/agencity_info.py index fc8e53a..bd88bb6 100644 --- a/agencitylab/analysis/information/agencity_info.py +++ b/agencitylab/analysis/information/agencity_info.py @@ -81,19 +81,17 @@ def agencity_phase_information(theta, *, verbose=False): return H -def full_information_summary(b, J=None, theta=None): - """ - Complete information description. - """ +def full_information_summary(b, J=None, theta=None, *, verbose=False): + """Return the descriptive information summary without altering canonical data.""" out = { - "entropy_b": agencity_information_index(b), - "density_b": agencity_information_density(b), + "entropy_b": agencity_information_index(b, verbose=verbose), + "density_b": agencity_information_density(b, verbose=verbose), } if J is not None: - out["structure_J"] = agencity_structural_information(J) + out["structure_J"] = agencity_structural_information(J, verbose=verbose) if theta is not None: - out["phase_entropy"] = agencity_phase_information(theta) + out["phase_entropy"] = agencity_phase_information(theta, verbose=verbose) - return out \ No newline at end of file + return out diff --git a/agencitylab/analysis/information/vopson.py b/agencitylab/analysis/information/vopson.py index 15c5ebb..dc4537c 100644 --- a/agencitylab/analysis/information/vopson.py +++ b/agencitylab/analysis/information/vopson.py @@ -1,27 +1,34 @@ -""" -Vopson information mass equivalence (advanced). +"""Vopson information-mass equivalence hypothesis. + +This module implements the proposed relation as a speculative information-physics +extension. It is not part of the canonical Theory of Agencity and is not treated +as an experimentally established mass law by AgencityLab. """ from __future__ import annotations -import numpy as np -C = 299792458 # speed of light +from agencitylab.constants.physics import SPEED_OF_LIGHT +from agencitylab.scientific_status import ScientificStatus + +from .landauer import landauer_from_entropy, landauer_lower_bound + +SCIENTIFIC_STATUS = ScientificStatus.SPECULATIVE +C = SPEED_OF_LIGHT.value def information_mass(bits: float, temperature: float) -> float: - """ - Information → mass equivalence (Vopson hypothesis) + """Return the Vopson-hypothesis mass equivalent for ``bits`` at ``temperature``. - m = (k_B * T * ln2 * bits) / c^2 + The implemented relation is ``m = k_B*T*ln(2)*bits/c**2``. The Landauer + helper supplies the numerical Boltzmann constant and validates finite, + non-negative inputs; division by ``c**2`` is the speculative mass-equivalence + step. """ - from agencitylab.constants.physics import BOLTZMANN_CONSTANT - return (BOLTZMANN_CONSTANT * temperature * np.log(2) * bits) / (C**2) + return float(landauer_lower_bound(bits, temperature) / (C**2)) -def vopson_mass_equivalent(entropy_nats: float, temperature: float): - """ - Convert entropy (nats) → mass - """ - bits = entropy_nats / np.log(2) - return information_mass(bits, temperature) \ No newline at end of file +def vopson_mass_equivalent(entropy_nats: float, temperature: float) -> float: + """Return the speculative mass equivalent for entropy expressed in nats.""" + + return float(landauer_from_entropy(entropy_nats, temperature) / (C**2)) diff --git a/agencitylab/analysis/reports.py b/agencitylab/analysis/reports.py index 4a162b5..6a46fc9 100644 --- a/agencitylab/analysis/reports.py +++ b/agencitylab/analysis/reports.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import asdict, is_dataclass -from typing import Any, Dict, Mapping, Optional +from typing import Any, Dict, Mapping, Optional, cast import numpy as np @@ -46,7 +46,7 @@ def _portable(value): """Convert analysis output to JSON-friendly values without losing metadata.""" if is_dataclass(value): - return _portable(asdict(value)) + return _portable(asdict(cast(Any, value))) if isinstance(value, np.ndarray): return value.tolist() if isinstance(value, (np.floating, np.integer, np.bool_)): @@ -178,6 +178,7 @@ def build_report_dict( crossing_indices = crossings + start crossing_times = xi_valid[crossings] if crossings.size else np.asarray([], dtype=float) + theta_jumps: dict[str, Any] if theta_jump_threshold is None: theta_jumps = { "status": "not configured", @@ -198,6 +199,7 @@ def build_report_dict( "times": xi_valid[jump_indices] if jump_indices.size else np.asarray([], dtype=float), } + plateaus: dict[str, Any] if plateau_slope_threshold is None and plateau_min_duration is None: plateaus = { "status": "not configured", @@ -221,9 +223,11 @@ def build_report_dict( ), } - orientation = orientation_statistics( - np.asarray(result.M)[start:stop], - np.asarray(result.O)[start:stop], + orientation: dict[str, Any] = dict( + orientation_statistics( + np.asarray(result.M)[start:stop], + np.asarray(result.O)[start:stop], + ) ) orientation["sigma_theta_mean"] = ( float(np.mean(sigma[finite_sigma])) diff --git a/agencitylab/api/analyze.py b/agencitylab/api/analyze.py index 784eda3..da32da0 100644 --- a/agencitylab/api/analyze.py +++ b/agencitylab/api/analyze.py @@ -136,7 +136,7 @@ def analyze_coherence( b = np.asarray(_get_attr(result, "b"), dtype=complex) valid = S > 0.0 sigma = sigma_theta(theta, xi, tau, valid_mask=valid) - orientation = orientation_statistics(M, O) + orientation: dict[str, Any] = dict(orientation_statistics(M, O)) orientation["structural_phase_coherence"] = phase_coherence( theta, valid_mask=valid, @@ -184,8 +184,8 @@ def analyze_stability(result, *, verbose: bool = False) -> Dict[str, Any]: def analyze_information(result, *, verbose: bool = False) -> Dict[str, Any]: b = _get_attr(result, "b") info = full_information_summary(b, verbose=verbose) - info["agencity_information_index"] = agencity_information_index(b, verbose=verbose) - info["agencity_information_density"] = agencity_information_density(b, verbose=verbose) + info["agencity_information_index"] = info["entropy_b"] + info["agencity_information_density"] = info["density_b"] return info @@ -236,6 +236,7 @@ def analyze_transitions( crossings = critical_surface_crossings(D[start:], S[start:]) crossing_indices = crossings + start + jumps: dict[str, Any] if theta_jump_threshold is None: jumps = { "status": "not configured", diff --git a/agencitylab/api/export.py b/agencitylab/api/export.py index 940c64f..9902618 100644 --- a/agencitylab/api/export.py +++ b/agencitylab/api/export.py @@ -9,7 +9,7 @@ from dataclasses import asdict, is_dataclass from pathlib import Path -from typing import Any +from typing import Any, cast import numpy as np @@ -30,7 +30,7 @@ def _flatten_for_table(data: Any, parent_key: str = "", sep: str = ".") -> dict[ if hasattr(data, "to_dict") and callable(data.to_dict): data = data.to_dict() elif is_dataclass(data): - data = asdict(data) + data = asdict(cast(Any, data)) items: dict[str, Any] = {} if isinstance(data, dict): diff --git a/agencitylab/backends/numba_backend.py b/agencitylab/backends/numba_backend.py index 4fe7f2c..79336d2 100644 --- a/agencitylab/backends/numba_backend.py +++ b/agencitylab/backends/numba_backend.py @@ -8,11 +8,12 @@ from __future__ import annotations -from typing import Callable +from typing import Callable, cast import numpy as np from .numpy_backend import ( + WindowKind, apply_window_numpy, causal_moving_correlation_numpy, normalize_numpy, @@ -84,7 +85,7 @@ def central_difference_numba(values, step: float): def apply_window_numba(values, kind: str = "hann"): """Use NumPy for already-vectorised tapering.""" - return apply_window_numpy(values, kind=kind, axis=-1) + return apply_window_numpy(values, kind=cast(WindowKind, kind), axis=-1) def causal_moving_correlation_numba(values, window: int = 1, epsilon: float = 1e-12): diff --git a/agencitylab/backends/numpy_backend.py b/agencitylab/backends/numpy_backend.py index bba6729..bf5a3b2 100644 --- a/agencitylab/backends/numpy_backend.py +++ b/agencitylab/backends/numpy_backend.py @@ -69,16 +69,16 @@ def apply_window_numpy(values, kind: WindowKind = "hann", axis: int = -1): if values.shape[axis] < 1: raise ValueError("values must contain at least one sample.") - kind = str(kind).lower().strip() + key = str(kind).lower().strip() n = values.shape[axis] - if kind == "hann": + if key == "hann": window = np.hanning(n) - elif kind == "hamming": + elif key == "hamming": window = np.hamming(n) - elif kind == "blackman": + elif key == "blackman": window = np.blackman(n) - elif kind == "rectangular": + elif key == "rectangular": window = np.ones(n, dtype=float) else: raise ValueError("Unknown window kind.") diff --git a/agencitylab/data/feature_extract/embeddings.py b/agencitylab/data/feature_extract/embeddings.py index 7bac3d5..9c6e284 100644 --- a/agencitylab/data/feature_extract/embeddings.py +++ b/agencitylab/data/feature_extract/embeddings.py @@ -30,30 +30,30 @@ def build_embedding_signal( if arr.ndim != 2: raise ValueError("vectors must be 2D (n_samples, n_features)") - mode = mode.lower().strip() + normalized_mode = str(mode).lower().strip() # ========================= # 🔥 1. FULL INFORMATION (RECOMMENDED) # ========================= - if mode == "raw": + if normalized_mode == "raw": return arr # shape (n, d) # ========================= # ⚠️ LOSSY MODES # ========================= - if mode == "norm": + if normalized_mode == "norm": return np.linalg.norm(arr, axis=1) - if mode == "mean": + if normalized_mode == "mean": return np.mean(arr, axis=1) - if mode == "sum": + if normalized_mode == "sum": return np.sum(arr, axis=1) # ========================= # 🔬 PCA REDUCTION (SMART) # ========================= - if mode == "pca": + if normalized_mode == "pca": # centrage X = arr - np.mean(arr, axis=0) diff --git a/agencitylab/data/pipeline.py b/agencitylab/data/pipeline.py index bed1d81..24b1696 100644 --- a/agencitylab/data/pipeline.py +++ b/agencitylab/data/pipeline.py @@ -97,8 +97,13 @@ def window(self, kind: str = "hann") -> "DataPipeline": def build(self) -> SignalData: """Return a canonical SignalData object.""" self._require_signal() + assert self.xi is not None and self.u is not None self.steps.append("build") - return SignalData(xi=self.xi.copy(), u=self.u.copy(), metadata=dict(self.metadata)) + return SignalData( + xi=self.xi.copy(), + u=self.u.copy(), + metadata=dict(self.metadata), + ) def _require_signal(self) -> None: """Ensure that a signal has already been loaded.""" diff --git a/agencitylab/dynamics/delays.py b/agencitylab/dynamics/delays.py index 0b0c6c1..4f28588 100644 --- a/agencitylab/dynamics/delays.py +++ b/agencitylab/dynamics/delays.py @@ -35,7 +35,7 @@ def interpolate_history(history_xi, history_y, xi_query): def solve_delay_euler( - rhs: Callable[[float, np.ndarray, Callable[[float], np.ndarray]], np.ndarray], + rhs: Callable[[float, np.ndarray, np.ndarray], np.ndarray], history_function: Callable[[float], np.ndarray], xi_grid, delay: float, @@ -71,14 +71,17 @@ def solve_delay_euler( def state_at(query_xi: float) -> np.ndarray: if query_xi <= xi_grid[0]: return np.asarray(history_function(query_xi), dtype=float) - idx = np.searchsorted(xi_grid[: len(trajectory)], query_xi, side="right") - 1 + idx = int(np.searchsorted(xi_grid, query_xi, side="right")) - 1 idx = max(0, min(idx, len(trajectory) - 1)) return trajectory[idx] for i in range(1, xi_grid.size): h = float(xi_grid[i] - xi_grid[i - 1]) delayed_state = state_at(float(xi_grid[i - 1] - delay)) - derivative = np.asarray(rhs(xi_grid[i - 1], trajectory[i - 1], delayed_state), dtype=float) + derivative = np.asarray( + rhs(float(xi_grid[i - 1]), trajectory[i - 1], delayed_state), + dtype=float, + ) trajectory[i] = trajectory[i - 1] + h * derivative return trajectory diff --git a/agencitylab/fields/dynamics/simulation.py b/agencitylab/fields/dynamics/simulation.py index 17eed62..dc75ae4 100644 --- a/agencitylab/fields/dynamics/simulation.py +++ b/agencitylab/fields/dynamics/simulation.py @@ -87,8 +87,8 @@ def _set_boundary_faces(field: np.ndarray, value: complex | float) -> np.ndarray dtype = np.result_type(field.dtype, np.asarray(value).dtype) projected = np.array(field, dtype=dtype, copy=True) for axis in range(projected.ndim): - lower = [slice(None)] * projected.ndim - upper = [slice(None)] * projected.ndim + lower: list[slice | int] = [slice(None)] * projected.ndim + upper: list[slice | int] = [slice(None)] * projected.ndim lower[axis] = 0 upper[axis] = -1 projected[tuple(lower)] = value diff --git a/agencitylab/fields/numerics/operators.py b/agencitylab/fields/numerics/operators.py index ecb072d..e79006e 100644 --- a/agencitylab/fields/numerics/operators.py +++ b/agencitylab/fields/numerics/operators.py @@ -7,6 +7,8 @@ from __future__ import annotations +from typing import TypeAlias + import numpy as np from .boundaries import ( @@ -19,6 +21,13 @@ from .grid import UniformRectilinearGrid +IndexComponent: TypeAlias = slice | int + + +def _full_index(ndim: int) -> list[IndexComponent]: + return [slice(None)] * ndim + + def _validate_field(field: np.ndarray, grid: UniformRectilinearGrid) -> np.ndarray: array = np.asarray(field) if array.size == 0: @@ -46,8 +55,8 @@ def _dirichlet_project(field: np.ndarray, value: complex | float) -> np.ndarray: dtype = np.result_type(field.dtype, np.asarray(value).dtype) projected = np.array(field, dtype=dtype, copy=True) for axis in range(projected.ndim): - lower = [slice(None)] * projected.ndim - upper = [slice(None)] * projected.ndim + lower = _full_index(projected.ndim) + upper = _full_index(projected.ndim) lower[axis] = 0 upper[axis] = -1 projected[tuple(lower)] = value @@ -90,9 +99,9 @@ def gradient( dtype = np.result_type(dtype, np.asarray(resolved.gradient).dtype) derivative = np.empty(grid.shape, dtype=dtype) - center = [slice(None)] * grid.ndim - plus = [slice(None)] * grid.ndim - minus = [slice(None)] * grid.ndim + center = _full_index(grid.ndim) + plus = _full_index(grid.ndim) + minus = _full_index(grid.ndim) center[axis] = slice(1, -1) plus[axis] = slice(2, None) minus[axis] = slice(None, -2) @@ -100,20 +109,20 @@ def gradient( work[tuple(plus)] - work[tuple(minus)] ) / (2.0 * spacing) - lower = [slice(None)] * grid.ndim - upper = [slice(None)] * grid.ndim + lower = _full_index(grid.ndim) + upper = _full_index(grid.ndim) lower[axis] = 0 upper[axis] = -1 if isinstance(resolved, NeumannBoundary): derivative[tuple(lower)] = resolved.gradient derivative[tuple(upper)] = resolved.gradient else: - i0 = [slice(None)] * grid.ndim - i1 = [slice(None)] * grid.ndim - i2 = [slice(None)] * grid.ndim - im0 = [slice(None)] * grid.ndim - im1 = [slice(None)] * grid.ndim - im2 = [slice(None)] * grid.ndim + i0 = _full_index(grid.ndim) + i1 = _full_index(grid.ndim) + i2 = _full_index(grid.ndim) + im0 = _full_index(grid.ndim) + im1 = _full_index(grid.ndim) + im2 = _full_index(grid.ndim) i0[axis], i1[axis], i2[axis] = 0, 1, 2 im0[axis], im1[axis], im2[axis] = -1, -2, -3 derivative[tuple(lower)] = ( @@ -167,9 +176,9 @@ def laplacian( continue contribution = np.empty(grid.shape, dtype=dtype) - center = [slice(None)] * grid.ndim - plus = [slice(None)] * grid.ndim - minus = [slice(None)] * grid.ndim + center = _full_index(grid.ndim) + plus = _full_index(grid.ndim) + minus = _full_index(grid.ndim) center[axis] = slice(1, -1) plus[axis] = slice(2, None) minus[axis] = slice(None, -2) @@ -177,15 +186,15 @@ def laplacian( work[tuple(plus)] - 2.0 * work[tuple(center)] + work[tuple(minus)] ) / h2 - lower = [slice(None)] * grid.ndim - upper = [slice(None)] * grid.ndim + lower = _full_index(grid.ndim) + upper = _full_index(grid.ndim) lower[axis] = 0 upper[axis] = -1 if isinstance(resolved, NeumannBoundary): - i0 = [slice(None)] * grid.ndim - i1 = [slice(None)] * grid.ndim - im0 = [slice(None)] * grid.ndim - im1 = [slice(None)] * grid.ndim + i0 = _full_index(grid.ndim) + i1 = _full_index(grid.ndim) + im0 = _full_index(grid.ndim) + im1 = _full_index(grid.ndim) i0[axis], i1[axis] = 0, 1 im0[axis], im1[axis] = -1, -2 contribution[tuple(lower)] = ( @@ -197,14 +206,14 @@ def laplacian( + 2.0 * resolved.gradient / spacing ) else: - i0 = [slice(None)] * grid.ndim - i1 = [slice(None)] * grid.ndim - i2 = [slice(None)] * grid.ndim - i3 = [slice(None)] * grid.ndim - im0 = [slice(None)] * grid.ndim - im1 = [slice(None)] * grid.ndim - im2 = [slice(None)] * grid.ndim - im3 = [slice(None)] * grid.ndim + i0 = _full_index(grid.ndim) + i1 = _full_index(grid.ndim) + i2 = _full_index(grid.ndim) + i3 = _full_index(grid.ndim) + im0 = _full_index(grid.ndim) + im1 = _full_index(grid.ndim) + im2 = _full_index(grid.ndim) + im3 = _full_index(grid.ndim) i0[axis], i1[axis], i2[axis], i3[axis] = 0, 1, 2, 3 im0[axis], im1[axis], im2[axis], im3[axis] = -1, -2, -3, -4 contribution[tuple(lower)] = ( diff --git a/agencitylab/fields/physics/bridge.py b/agencitylab/fields/physics/bridge.py index 127eeee..ae7fbe1 100644 --- a/agencitylab/fields/physics/bridge.py +++ b/agencitylab/fields/physics/bridge.py @@ -90,6 +90,7 @@ def beta_to_phi(beta, P_c, tau, *, time_axis: int = 0) -> np.ndarray: spatial_shape = raw_beta.shape[:axis] + raw_beta.shape[axis + 1 :] tau_arr = _finite_real_array(tau, name="tau") + tau_resolved: float | np.ndarray if tau_arr.ndim == 0: tau_resolved = float(tau_arr) if tau_resolved <= 0.0: @@ -105,6 +106,7 @@ def beta_to_phi(beta, P_c, tau, *, time_axis: int = 0) -> np.ndarray: ) power_arr = _finite_real_array(P_c, name="P_c") + power_resolved: float | np.ndarray if power_arr.ndim == 0: power_resolved = float(power_arr) if power_resolved < 0.0: diff --git a/agencitylab/io/csv.py b/agencitylab/io/csv.py index ad64296..186d40f 100644 --- a/agencitylab/io/csv.py +++ b/agencitylab/io/csv.py @@ -13,7 +13,7 @@ import csv from dataclasses import asdict, is_dataclass from pathlib import Path -from typing import Any, Dict, List, Mapping, Sequence, Union +from typing import Any, Dict, List, Mapping, Sequence, Union, cast import numpy as np @@ -23,7 +23,7 @@ def _to_plain(value: Any) -> Any: return _to_plain(value.to_dict()) if is_dataclass(value): - return _to_plain(asdict(value)) + return _to_plain(asdict(cast(Any, value))) if isinstance(value, np.ndarray): return value.tolist() diff --git a/agencitylab/io/json.py b/agencitylab/io/json.py index 4dda63e..7ca28e2 100644 --- a/agencitylab/io/json.py +++ b/agencitylab/io/json.py @@ -10,7 +10,7 @@ import json from dataclasses import asdict, is_dataclass from pathlib import Path -from typing import Any, Mapping, Union +from typing import Any, Mapping, Union, cast import numpy as np @@ -21,7 +21,7 @@ def _to_jsonable(value: Any) -> Any: return _to_jsonable(value.to_dict()) if is_dataclass(value): - return _to_jsonable(asdict(value)) + return _to_jsonable(asdict(cast(Any, value))) if isinstance(value, np.ndarray): return _to_jsonable(value.tolist()) diff --git a/agencitylab/io/result_serialization.py b/agencitylab/io/result_serialization.py index 76232c8..843df26 100644 --- a/agencitylab/io/result_serialization.py +++ b/agencitylab/io/result_serialization.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import asdict, is_dataclass -from typing import Any +from typing import Any, cast import numpy as np @@ -15,7 +15,7 @@ def serialize_value(value: Any) -> Any: if hasattr(value, "to_dict") and callable(value.to_dict): return serialize_value(value.to_dict()) if is_dataclass(value): - return serialize_value(asdict(value)) + return serialize_value(asdict(cast(Any, value))) if isinstance(value, np.ndarray): if np.iscomplexobj(value): return { diff --git a/agencitylab/models/config_model.py b/agencitylab/models/config_model.py index efef4db..0e92aa1 100644 --- a/agencitylab/models/config_model.py +++ b/agencitylab/models/config_model.py @@ -47,13 +47,18 @@ def from_dict(cls, data: Dict[str, Any]) -> "AnalysisConfig": extra = dict(known.get("extra", {})) extra.update(data) + defaults = cls() return cls( - regime_window=known.get("regime_window", cls.regime_window), - spectrum_nfft=known.get("spectrum_nfft", cls.spectrum_nfft), - diagnostics_threshold=known.get("diagnostics_threshold", cls.diagnostics_threshold), - report_language=known.get("report_language", cls.report_language), - compute_signature=known.get("compute_signature", cls.compute_signature), - compute_multiscale=known.get("compute_multiscale", cls.compute_multiscale), + regime_window=known.get("regime_window", defaults.regime_window), + spectrum_nfft=known.get("spectrum_nfft", defaults.spectrum_nfft), + diagnostics_threshold=known.get( + "diagnostics_threshold", defaults.diagnostics_threshold + ), + report_language=known.get("report_language", defaults.report_language), + compute_signature=known.get("compute_signature", defaults.compute_signature), + compute_multiscale=known.get( + "compute_multiscale", defaults.compute_multiscale + ), extra=extra, ) diff --git a/agencitylab/models/context.py b/agencitylab/models/context.py index 9c69ce8..363e783 100644 --- a/agencitylab/models/context.py +++ b/agencitylab/models/context.py @@ -44,12 +44,22 @@ def add_artifact(self, name: str, value: Any) -> None: def to_dict(self) -> Dict[str, Any]: """Serialize the context to a dictionary.""" + signal = self.signal + result = self.result return { "name": self.name, "metadata": dict(self.metadata), "config": dict(self.config), - "signal": self.signal.to_dict() if hasattr(self.signal, "to_dict") else self.signal, - "result": self.result.to_dict() if hasattr(self.result, "to_dict") else self.result, + "signal": ( + signal.to_dict() + if signal is not None and hasattr(signal, "to_dict") + else signal + ), + "result": ( + result.to_dict() + if result is not None and hasattr(result, "to_dict") + else result + ), "analysis": dict(self.analysis), "report": self.report, "artifacts": dict(self.artifacts), diff --git a/agencitylab/models/dataset.py b/agencitylab/models/dataset.py index b436b86..30e2652 100644 --- a/agencitylab/models/dataset.py +++ b/agencitylab/models/dataset.py @@ -33,6 +33,14 @@ def __len__(self) -> int: def __iter__(self): return iter(self.items) + def summary(self) -> Dict[str, Any]: + """Return compact dataset metadata without duplicating signal payloads.""" + return { + "n_signals": len(self.items), + "n_samples_total": sum(item.n_samples for item in self.items), + "metadata": self.metadata.to_dict(), + } + def to_dict(self) -> Dict[str, Any]: """Serialize the dataset to a dictionary.""" return { diff --git a/agencitylab/models/field_extensions.py b/agencitylab/models/field_extensions.py index 99d46bb..1e2b47d 100644 --- a/agencitylab/models/field_extensions.py +++ b/agencitylab/models/field_extensions.py @@ -30,6 +30,10 @@ def __str__(self) -> str: return self.value +def _coerce_parameter_source(value: ParameterSource | str) -> ParameterSource: + return value if isinstance(value, ParameterSource) else ParameterSource(value) + + @dataclass(frozen=True, slots=True) class ParameterProvenance: """Minimal provenance record for one parameter value or convention.""" @@ -56,7 +60,7 @@ def __post_init__(self) -> None: def to_dict(self) -> dict[str, str]: return { - "source": self.source.value, + "source": _coerce_parameter_source(self.source).value, "note": self.note, "reference": self.reference, } @@ -184,7 +188,7 @@ def __post_init__(self) -> None: def to_dict(self) -> dict[str, Any]: return { "model_name": self.model_name, - "scientific_status": self.scientific_status.value, + "scientific_status": _coerce_status(self.scientific_status).value, "theory_source": self.theory_source, "assumptions": list(self.assumptions), "units_convention": self.units_convention, @@ -268,7 +272,7 @@ def to_dict(self) -> dict[str, Any]: else tuple(axis.copy() for axis in self.spatial_axes) ), "metadata": dict(self.metadata), - "scientific_status": self.scientific_status.value, + "scientific_status": _validate_research_status(self.scientific_status).value, "model_name": self.model_name, "units_convention": self.units_convention, } @@ -363,7 +367,7 @@ def to_dict(self) -> dict[str, Any]: }, "dynamics_name": self.dynamics_name, "boundary_name": self.boundary_name, - "scientific_status": self.scientific_status.value, + "scientific_status": _validate_research_status(self.scientific_status).value, "solver_metadata": dict(self.solver_metadata), "units_convention": self.units_convention, } diff --git a/agencitylab/models/result.py b/agencitylab/models/result.py index 3ffbf87..ff5dac4 100644 --- a/agencitylab/models/result.py +++ b/agencitylab/models/result.py @@ -242,11 +242,17 @@ def complex_mean_beta(self) -> complex: @property def theta_mean(self) -> float: - return float(np.mean(self.theta)) + theta = self.theta + if theta is None: + return float("nan") + return float(np.mean(theta)) @property def theta_std(self) -> float: - return float(np.std(self.theta)) + theta = self.theta + if theta is None: + return float("nan") + return float(np.std(theta)) @property def theta_circular_mean(self) -> float: diff --git a/agencitylab/models/streaming.py b/agencitylab/models/streaming.py index 3d3cb5e..7b790d0 100644 --- a/agencitylab/models/streaming.py +++ b/agencitylab/models/streaming.py @@ -129,10 +129,11 @@ def update( should_analyze = self.analyze if run_analysis is None else bool(run_analysis) if should_analyze: - self.last_analysis = analyze_agencity(self.last_result, verbose=verbose) - self.last_result.attach_analysis(self.last_analysis) - self.last_result.signature = self.last_analysis.get("signature") - self.last_result.multiscale = self.last_analysis.get("multiscale") + analysis = analyze_agencity(self.last_result, verbose=verbose) + self.last_analysis = analysis + self.last_result.attach_analysis(analysis) + self.last_result.signature = analysis.get("signature") + self.last_result.multiscale = analysis.get("multiscale") return self.last_result @@ -157,10 +158,11 @@ def flush(self, *, verbose: bool = False, **kwargs): ) if self.analyze: - self.last_analysis = analyze_agencity(self.last_result, verbose=verbose) - self.last_result.attach_analysis(self.last_analysis) - self.last_result.signature = self.last_analysis.get("signature") - self.last_result.multiscale = self.last_analysis.get("multiscale") + analysis = analyze_agencity(self.last_result, verbose=verbose) + self.last_analysis = analysis + self.last_result.attach_analysis(analysis) + self.last_result.signature = analysis.get("signature") + self.last_result.multiscale = analysis.get("multiscale") return self.last_result diff --git a/agencitylab/reference/scenarios.py b/agencitylab/reference/scenarios.py index 04fd898..ee7771f 100644 --- a/agencitylab/reference/scenarios.py +++ b/agencitylab/reference/scenarios.py @@ -24,6 +24,13 @@ ) +def _required_characteristic_time(signal: AgencitySignal) -> float: + value = signal.metadata.characteristic_time + if value is None: + raise ValueError("reference signal metadata must define characteristic_time") + return float(value) + + @dataclass(frozen=True, slots=True) class ReferenceScenario: """Observable plus explicit physical context for canonical analysis. @@ -140,7 +147,7 @@ def sinusoidal( """Return the smooth unit-sinusoid scenario.""" signal = signals.sinusoid(samples_per_tau=samples_per_tau, cycles=cycles) - tau = float(signal.metadata.characteristic_time) + tau = _required_characteristic_time(signal) return ReferenceScenario( name="sinusoidal", signal=signal, @@ -159,7 +166,7 @@ def damped( """Return the passive underdamped-oscillator scenario.""" signal = signals.damped_oscillator(samples_per_tau=samples_per_tau, cycles=cycles) - tau = float(signal.metadata.characteristic_time) + tau = _required_characteristic_time(signal) return ReferenceScenario( name="damped", signal=signal, @@ -176,7 +183,7 @@ def van_der_pol(*, samples_per_tau: int = 64, P_c: float = 1.0) -> ReferenceScen """Return the active self-sustained Van der Pol scenario.""" signal = signals.van_der_pol(samples_per_tau=samples_per_tau) - tau = float(signal.metadata.characteristic_time) + tau = _required_characteristic_time(signal) return ReferenceScenario( name="van_der_pol", signal=signal, @@ -195,7 +202,7 @@ def unstable( """Return the exponentially growing oscillator scenario.""" signal = signals.unstable_oscillator(samples_per_tau=samples_per_tau, cycles=cycles) - tau = float(signal.metadata.characteristic_time) + tau = _required_characteristic_time(signal) return ReferenceScenario( name="unstable", signal=signal, @@ -217,7 +224,7 @@ def stochastic( seed=seed, samples_per_tau=samples_per_tau, ) - tau = float(signal.metadata.characteristic_time) + tau = _required_characteristic_time(signal) return ReferenceScenario( name="stochastic", signal=signal, @@ -234,7 +241,7 @@ def lorenz(*, samples_per_tau: int = 50, P_c: float = 1.0) -> ReferenceScenario: """Return the classical chaotic Lorenz-x scenario.""" signal = signals.lorenz(samples_per_tau=samples_per_tau) - tau = float(signal.metadata.characteristic_time) + tau = _required_characteristic_time(signal) return ReferenceScenario( name="lorenz", signal=signal, diff --git a/agencitylab/thermodynamics/laws.py b/agencitylab/thermodynamics/laws.py index fe93c87..d466549 100644 --- a/agencitylab/thermodynamics/laws.py +++ b/agencitylab/thermodynamics/laws.py @@ -8,7 +8,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, Mapping +from typing import Any, Mapping, cast import numpy as np @@ -57,7 +57,9 @@ class PhaseLawFit: r_squared: float | None = None reference_kind: str = "user_supplied_fit" scientific_status: ScientificStatus | str = ScientificStatus.RESEARCH - provenance: Mapping[str, ParameterProvenance] = field(default_factory=dict) + provenance: Mapping[str, ParameterProvenance | Mapping[str, Any]] = field( + default_factory=dict + ) def __post_init__(self) -> None: alpha = _finite_real_scalar(self.alpha, name="alpha") @@ -90,15 +92,19 @@ def __post_init__(self) -> None: def to_dict(self) -> dict[str, Any]: """Return a lightweight serializable metadata representation.""" + status = ( + self.scientific_status + if isinstance(self.scientific_status, ScientificStatus) + else ScientificStatus(self.scientific_status) + ) + provenance = cast(Mapping[str, ParameterProvenance], self.provenance) return { "alpha": self.alpha, "beta_fit": self.beta_fit, "r_squared": self.r_squared, "reference_kind": self.reference_kind, - "scientific_status": self.scientific_status.value, - "provenance": { - key: item.to_dict() for key, item in self.provenance.items() - }, + "scientific_status": status.value, + "provenance": {key: item.to_dict() for key, item in provenance.items()}, } diff --git a/agencitylab/utils/normalization.py b/agencitylab/utils/normalization.py index f158adf..f32bf39 100644 --- a/agencitylab/utils/normalization.py +++ b/agencitylab/utils/normalization.py @@ -1 +1,5 @@ -from ..core.normalization import normalize +"""Compatibility exports for observable normalization helpers.""" + +from ..core.normalization import normalize_signal as normalize + +__all__ = ["normalize"] diff --git a/agencitylab/visualization/heatmaps.py b/agencitylab/visualization/heatmaps.py index 76da725..7edcb34 100644 --- a/agencitylab/visualization/heatmaps.py +++ b/agencitylab/visualization/heatmaps.py @@ -15,7 +15,7 @@ def plot_heatmap(result, show: bool = True): matrix, aspect="auto", origin="lower", - extent=[xi[0], xi[-1], -0.5, 4.5], + extent=(float(xi[0]), float(xi[-1]), -0.5, 4.5), ) ax.set_yticks(range(5), labels=["M", "O", "D", "S", "J"]) ax.set_xlabel("Coordinate") diff --git a/tests/test_mypy_debt_runtime.py b/tests/test_mypy_debt_runtime.py new file mode 100644 index 0000000..84c8418 --- /dev/null +++ b/tests/test_mypy_debt_runtime.py @@ -0,0 +1,80 @@ +"""Runtime regressions uncovered while eliminating repository-wide mypy debt.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import numpy as np +import pytest + +from agencitylab.analysis.information.vopson import ( + C, + SCIENTIFIC_STATUS, + information_mass, + vopson_mass_equivalent, +) +from agencitylab.api.analyze import analyze_information +from agencitylab.constants.physics import BOLTZMANN_CONSTANT, SPEED_OF_LIGHT +from agencitylab.models.config_model import AnalysisConfig +from agencitylab.models.dataset import AgencityDataset +from agencitylab.models.experiment import AgencityExperiment +from agencitylab.models.context import Context +from agencitylab.scientific_status import ScientificStatus +from agencitylab.utils.normalization import normalize + + +def test_vopson_helper_uses_numeric_physical_constants_without_changing_hypothesis(): + expected = BOLTZMANN_CONSTANT.value * 300.0 * np.log(2.0) / SPEED_OF_LIGHT.value**2 + + assert SCIENTIFIC_STATUS is ScientificStatus.SPECULATIVE + assert C == SPEED_OF_LIGHT.value + assert information_mass(1.0, 300.0) == pytest.approx(expected) + assert vopson_mass_equivalent(float(np.log(2.0)), 300.0) == pytest.approx(expected) + assert information_mass(0.0, 300.0) == 0.0 + + +@pytest.mark.parametrize( + ("bits", "temperature"), + [(-1.0, 300.0), (1.0, -1.0), (np.nan, 300.0), (1.0, np.inf)], +) +def test_vopson_helper_rejects_invalid_landauer_inputs(bits, temperature): + with pytest.raises(ValueError): + information_mass(bits, temperature) + + +def test_information_summary_verbose_api_is_runtime_valid(capsys): + result = SimpleNamespace(b=np.array([0.5 + 0.0j, 1.0 + 0.5j, 0.75 - 0.25j])) + summary = analyze_information(result, verbose=True) + + assert "entropy_b" in summary + assert "density_b" in summary + assert "[info]" in capsys.readouterr().out + + +def test_analysis_config_from_dict_uses_instance_defaults_with_slots(): + config = AnalysisConfig.from_dict({}) + defaults = AnalysisConfig() + + assert config.to_dict() == defaults.to_dict() + + +def test_experiment_summary_uses_dataset_summary_contract(): + dataset = AgencityDataset() + summary = AgencityExperiment(dataset=dataset).summary() + + assert summary["dataset"]["n_signals"] == 0 + assert summary["dataset"]["n_samples_total"] == 0 + + +def test_context_serialization_accepts_absent_signal_and_result(): + payload = Context().to_dict() + + assert payload["signal"] is None + assert payload["result"] is None + + +def test_legacy_utils_normalize_alias_targets_current_normalization_api(): + normalized, reference = normalize(np.array([2.0, 4.0, 6.0]), A_ref=2.0) + + np.testing.assert_allclose(normalized, [1.0, 2.0, 3.0]) + assert float(reference) == 2.0 From 51a7ca99f949a7dc8d1a4fd062897639322e44be Mon Sep 17 00:00:00 2001 From: Gilbert243 Date: Tue, 18 Aug 2026 14:02:32 +0200 Subject: [PATCH 2/3] fix(mypy): ensure atol/atol args are float by wrapping computed tolerances with float() --- agencitylab/core/crm.py | 4 ++-- agencitylab/core/multiscale.py | 4 ++-- agencitylab/fields/numerics/grid.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/agencitylab/core/crm.py b/agencitylab/core/crm.py index da5d865..a7a35eb 100644 --- a/agencitylab/core/crm.py +++ b/agencitylab/core/crm.py @@ -24,7 +24,7 @@ def _uniform_step(axis): axis = validate_axis(axis) diffs = np.diff(axis) step = float(diffs[0]) - tolerance = np.finfo(float).eps * max(1.0, abs(step)) * 64.0 + tolerance = float(np.finfo(float).eps * max(1.0, abs(step)) * 64.0) if not np.allclose(diffs, step, rtol=1e-10, atol=tolerance): raise ValueError("discrete CRM requires uniformly sampled coordinates") return step @@ -38,7 +38,7 @@ def _window_to_samples(window, axis): raise ValueError("CRM window is smaller than one sampling interval") represented = n * step - tolerance = max(np.finfo(float).eps * max(1.0, abs(window)) * 128.0, abs(step) * 1e-9) + tolerance = float(max(np.finfo(float).eps * max(1.0, abs(window)) * 128.0, abs(step) * 1e-9)) if not np.isclose(represented, window, rtol=1e-9, atol=tolerance): raise ValueError("CRM window must be an integer multiple of the sampling interval") return n diff --git a/agencitylab/core/multiscale.py b/agencitylab/core/multiscale.py index 4b7c470..ada72e6 100644 --- a/agencitylab/core/multiscale.py +++ b/agencitylab/core/multiscale.py @@ -32,7 +32,7 @@ def _sample_step(axis: np.ndarray) -> float: axis = validate_axis(axis) diffs = np.diff(axis) step = float(diffs[0]) - tolerance = np.finfo(float).eps * max(1.0, abs(step)) * 64.0 + tolerance = float(np.finfo(float).eps * max(1.0, abs(step)) * 64.0) if not np.allclose(diffs, step, rtol=1e-10, atol=tolerance): raise ValueError("discrete multiscale extensions require uniformly sampled coordinates") return step @@ -45,7 +45,7 @@ def _window_samples(window: float, axis: np.ndarray) -> int: if samples < 1: raise ValueError("w is smaller than one sampling interval") represented = samples * step - tolerance = max(np.finfo(float).eps * max(1.0, abs(window)) * 128.0, step * 1e-9) + tolerance = float(max(np.finfo(float).eps * max(1.0, abs(window)) * 128.0, step * 1e-9)) if not np.isclose(represented, window, rtol=1e-9, atol=tolerance): raise ValueError("w must be an integer multiple of the sampling interval") return samples diff --git a/agencitylab/fields/numerics/grid.py b/agencitylab/fields/numerics/grid.py index 9c5338a..bfa9d37 100644 --- a/agencitylab/fields/numerics/grid.py +++ b/agencitylab/fields/numerics/grid.py @@ -100,7 +100,7 @@ def _validate_axes(axes: Sequence[Iterable[float]]) -> tuple[np.ndarray, ...]: if np.any(differences <= 0.0): raise ValueError(f"axis {index} must be strictly increasing") spacing = float(differences[0]) - tolerance = max(abs(spacing) * 1e-12, np.finfo(float).eps * 32.0) + tolerance = float(max(abs(spacing) * 1e-12, np.finfo(float).eps * 32.0)) if not np.allclose(differences, spacing, rtol=1e-10, atol=tolerance): raise ValueError(f"axis {index} must be uniformly spaced") copy = np.array(axis, dtype=float, copy=True) From 4bcc424b1829e6b75ef5aeb37dff96c0e0d5f989 Mon Sep 17 00:00:00 2001 From: Gilbra Date: Tue, 18 Aug 2026 14:23:12 +0200 Subject: [PATCH 3/3] fix(mypy): type NumPy tolerance operands as float --- agencitylab/core/multiscale.py | 4 ++-- agencitylab/fields/numerics/grid.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/agencitylab/core/multiscale.py b/agencitylab/core/multiscale.py index ada72e6..966c56b 100644 --- a/agencitylab/core/multiscale.py +++ b/agencitylab/core/multiscale.py @@ -39,13 +39,13 @@ def _sample_step(axis: np.ndarray) -> float: def _window_samples(window: float, axis: np.ndarray) -> int: - window = validate_positive_scalar(window, name="w") + window = float(validate_positive_scalar(window, name="w")) step = _sample_step(axis) samples = int(round(window / step)) if samples < 1: raise ValueError("w is smaller than one sampling interval") represented = samples * step - tolerance = float(max(np.finfo(float).eps * max(1.0, abs(window)) * 128.0, step * 1e-9)) + tolerance = max(float(np.finfo(float).eps) * max(1.0, abs(window)) * 128.0, step * 1e-9) if not np.isclose(represented, window, rtol=1e-9, atol=tolerance): raise ValueError("w must be an integer multiple of the sampling interval") return samples diff --git a/agencitylab/fields/numerics/grid.py b/agencitylab/fields/numerics/grid.py index bfa9d37..9bc373b 100644 --- a/agencitylab/fields/numerics/grid.py +++ b/agencitylab/fields/numerics/grid.py @@ -100,7 +100,7 @@ def _validate_axes(axes: Sequence[Iterable[float]]) -> tuple[np.ndarray, ...]: if np.any(differences <= 0.0): raise ValueError(f"axis {index} must be strictly increasing") spacing = float(differences[0]) - tolerance = float(max(abs(spacing) * 1e-12, np.finfo(float).eps * 32.0)) + tolerance = max(abs(spacing) * 1e-12, float(np.finfo(float).eps) * 32.0) if not np.allclose(differences, spacing, rtol=1e-10, atol=tolerance): raise ValueError(f"axis {index} must be uniformly spaced") copy = np.array(axis, dtype=float, copy=True)