From 8ba997f8e946cad887a4c370379f7fad86cc1e3f Mon Sep 17 00:00:00 2001 From: Jules Date: Wed, 24 Jun 2026 10:17:15 +0000 Subject: [PATCH 1/3] Add math backend observability for numerical parity --- docs/source/user_guide/advanced_topics.rst | 16 ++++++++ src/eegprep/cli/commands/software_info.py | 19 ++++++++++ src/eegprep/cli/core.py | 18 ++++++++- src/eegprep/cli/main.py | 2 + src/eegprep/utils/math_backend.py | 44 ++++++++++++++++++++++ 5 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 src/eegprep/cli/commands/software_info.py create mode 100644 src/eegprep/utils/math_backend.py diff --git a/docs/source/user_guide/advanced_topics.rst b/docs/source/user_guide/advanced_topics.rst index 69244be0..be284269 100644 --- a/docs/source/user_guide/advanced_topics.rst +++ b/docs/source/user_guide/advanced_topics.rst @@ -247,3 +247,19 @@ dictionary behavior: For GUI/console synchronization changes, add coverage near ``tests/test_console_workspace.py`` so ``EEG``, ``ALLEEG``, ``CURRENTSET``, ``LASTCOM``, ``ALLCOM``, ``STUDY``, and ``CURRENTSTUDY`` stay synchronized. + +Numerical Parity and Math Backends +================================== + +EEGPrep aims for a numerical parity target of ``1e-5`` uV across iterative algorithms (e.g., ICA, ASR). The exact results you observe may vary depending on the active BLAS (Basic Linear Algebra Subprograms) and LAPACK implementation used by the underlying numerical libraries. + +Common BLAS Implementations and Precision: +* **OpenBLAS:** The default on many Linux distributions. Generally stable and meets the ``1e-5`` parity target in single-threaded environments, but multi-threading configurations may introduce non-deterministic round-off errors at the ``1e-6`` level. +* **Intel MKL:** Highly optimized for Intel architectures. Exceeds the ``1e-5`` target reliably, but aggressive vectorization can occasionally lead to micro-deviations (``1e-7``) compared to OpenBLAS. +* **Apple Accelerate:** The default on macOS. Meets the ``1e-5`` target, but differences compared to OpenBLAS/MKL may surface near ``1e-6`` due to distinct floating-point accumulation strategies. + +You can audit the active math backend using the CLI command: +``eegprep software_info`` + +This will report the threading layer and the active BLAS implementation. Manifests generated by pipeline commands automatically include a ``math_backend_info`` section to help you validate environment consistency across different computing infrastructures. + diff --git a/src/eegprep/cli/commands/software_info.py b/src/eegprep/cli/commands/software_info.py new file mode 100644 index 00000000..d646b3bd --- /dev/null +++ b/src/eegprep/cli/commands/software_info.py @@ -0,0 +1,19 @@ +"""Agent-friendly EEGPrep software_info command support.""" + +from __future__ import annotations + +import argparse +from typing import Any + +from eegprep.cli.core import software_info, command_ok + +def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> argparse.ArgumentParser: + """Register ``software_info`` with an argparse dispatcher.""" + parser = subparsers.add_parser("software_info", help="Display software, active threading backend and core limits.") + parser.add_argument("--json", action="store_true", help="Emit structured JSON") + parser.set_defaults(func=handle_registered, handler=handle_registered) + return parser + +def handle_registered(args: argparse.Namespace) -> dict[str, Any]: + info = software_info() + return command_ok("software_info", info=info) diff --git a/src/eegprep/cli/core.py b/src/eegprep/cli/core.py index e5378438..373d3019 100644 --- a/src/eegprep/cli/core.py +++ b/src/eegprep/cli/core.py @@ -218,10 +218,15 @@ def file_sha256(path: str | Path) -> str: def software_info() -> dict[str, Any]: + from eegprep.utils.math_backend import get_math_backend_info + return { "eegprep_version": eegprep.__version__, "python_version": platform.python_version(), "platform": platform.platform(), + "architecture": platform.machine(), + "processor": platform.processor(), + "math_backend_info": get_math_backend_info(), } @@ -237,7 +242,15 @@ def build_manifest( deterministic: bool | None = None, warnings: list[Any] | None = None, ) -> dict[str, Any]: + from eegprep.utils.math_backend import check_conflicting_libraries + stamp = runtime_stamp(started_at) if finished_at is None else RuntimeStamp(started_at, finished_at) + soft_info = software_info() + + all_warnings = warnings or [] + math_warnings = check_conflicting_libraries(soft_info.get("math_backend_info", [])) + all_warnings.extend(math_warnings) + manifest: dict[str, Any] = { "schema_version": "eegprep.manifest.v1", "command": command, @@ -245,9 +258,10 @@ def build_manifest( "output_files": output_files, "parameters": json_safe(parameters), "history": history, - "software": software_info(), + "software": soft_info, + "math_backend_info": soft_info.get("math_backend_info", []), "runtime": {"started_at": stamp.started_at, "finished_at": stamp.finished_at}, - "warnings": warnings or [], + "warnings": all_warnings, } if deterministic is not None: manifest["deterministic"] = deterministic diff --git a/src/eegprep/cli/main.py b/src/eegprep/cli/main.py index 71e7847a..8faedfea 100644 --- a/src/eegprep/cli/main.py +++ b/src/eegprep/cli/main.py @@ -17,6 +17,7 @@ from eegprep.cli.commands import pipeline as pipeline_commands from eegprep.cli.commands import qc as qc_commands from eegprep.cli.commands import report as report_commands +from eegprep.cli.commands import software_info as software_info_commands from eegprep.cli.commands.transforms import register_subcommands @@ -139,6 +140,7 @@ def build_parser(*, json_requested: bool = False) -> EEGPrepArgumentParser: pipeline_commands.register(subparsers) qc_commands.register(subparsers) report_commands.register(subparsers) + software_info_commands.register(subparsers) batch_commands.register(subparsers) bids_commands.register(subparsers) migrate_commands.register(subparsers) diff --git a/src/eegprep/utils/math_backend.py b/src/eegprep/utils/math_backend.py new file mode 100644 index 00000000..bc9f6952 --- /dev/null +++ b/src/eegprep/utils/math_backend.py @@ -0,0 +1,44 @@ +import threadpoolctl + +def get_math_backend_info(): + try: + import numpy # noqa: F401 + except ImportError: + pass + + info = threadpoolctl.threadpool_info() + return info + +def check_conflicting_libraries(info): + """ + Returns a list of warning dictionaries if multiple conflicting math libraries + (e.g., multiple different internal APIs for BLAS) are loaded. + """ + blas_apis = set() + lapack_apis = set() + + for item in info: + user_api = item.get("user_api") + internal_api = item.get("internal_api") + if user_api == "blas" and internal_api: + blas_apis.add(internal_api) + if user_api == "lapack" and internal_api: + lapack_apis.add(internal_api) + + warnings = [] + if len(blas_apis) > 1: + warnings.append({ + "code": "CONFLICTING_BLAS_LIBRARIES", + "message": f"Multiple conflicting BLAS libraries detected: {', '.join(sorted(blas_apis))}", + "severity": "warning", + "suggestion": "Ensure only one BLAS implementation is loaded to avoid numerical drift." + }) + if len(lapack_apis) > 1: + warnings.append({ + "code": "CONFLICTING_LAPACK_LIBRARIES", + "message": f"Multiple conflicting LAPACK libraries detected: {', '.join(sorted(lapack_apis))}", + "severity": "warning", + "suggestion": "Ensure only one LAPACK implementation is loaded to avoid numerical drift." + }) + + return warnings From 9e35aaa2a9913e0c21bd75028d4a2495594eec50 Mon Sep 17 00:00:00 2001 From: Jules Date: Thu, 16 Jul 2026 06:36:52 +0000 Subject: [PATCH 2/3] Fix list mutation, manifest structure, and remove math heuristics --- docs/source/user_guide/advanced_topics.rst | 13 +++----- src/eegprep/cli/commands/software_info.py | 2 ++ src/eegprep/cli/core.py | 13 +++----- src/eegprep/utils/math_backend.py | 35 +-------------------- tests/test_cli_core.py | 36 ++++++++++++++++++++++ 5 files changed, 47 insertions(+), 52 deletions(-) create mode 100644 tests/test_cli_core.py diff --git a/docs/source/user_guide/advanced_topics.rst b/docs/source/user_guide/advanced_topics.rst index be284269..b03a6328 100644 --- a/docs/source/user_guide/advanced_topics.rst +++ b/docs/source/user_guide/advanced_topics.rst @@ -248,18 +248,13 @@ For GUI/console synchronization changes, add coverage near ``tests/test_console_workspace.py`` so ``EEG``, ``ALLEEG``, ``CURRENTSET``, ``LASTCOM``, ``ALLCOM``, ``STUDY``, and ``CURRENTSTUDY`` stay synchronized. -Numerical Parity and Math Backends -================================== +Math Backends +============= -EEGPrep aims for a numerical parity target of ``1e-5`` uV across iterative algorithms (e.g., ICA, ASR). The exact results you observe may vary depending on the active BLAS (Basic Linear Algebra Subprograms) and LAPACK implementation used by the underlying numerical libraries. - -Common BLAS Implementations and Precision: -* **OpenBLAS:** The default on many Linux distributions. Generally stable and meets the ``1e-5`` parity target in single-threaded environments, but multi-threading configurations may introduce non-deterministic round-off errors at the ``1e-6`` level. -* **Intel MKL:** Highly optimized for Intel architectures. Exceeds the ``1e-5`` target reliably, but aggressive vectorization can occasionally lead to micro-deviations (``1e-7``) compared to OpenBLAS. -* **Apple Accelerate:** The default on macOS. Meets the ``1e-5`` target, but differences compared to OpenBLAS/MKL may surface near ``1e-6`` due to distinct floating-point accumulation strategies. +The exact numerical results you observe from iterative algorithms (e.g., ICA, ASR) may vary depending on the active BLAS (Basic Linear Algebra Subprograms) and LAPACK implementation used by the underlying numerical libraries (such as OpenBLAS, Intel MKL, or Apple Accelerate). You can audit the active math backend using the CLI command: ``eegprep software_info`` -This will report the threading layer and the active BLAS implementation. Manifests generated by pipeline commands automatically include a ``math_backend_info`` section to help you validate environment consistency across different computing infrastructures. +This will report the threading layer and the active BLAS implementation. Manifests generated by pipeline commands automatically record this environment data to help you validate environment consistency across different computing infrastructures. diff --git a/src/eegprep/cli/commands/software_info.py b/src/eegprep/cli/commands/software_info.py index d646b3bd..58166a05 100644 --- a/src/eegprep/cli/commands/software_info.py +++ b/src/eegprep/cli/commands/software_info.py @@ -7,6 +7,7 @@ from eegprep.cli.core import software_info, command_ok + def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> argparse.ArgumentParser: """Register ``software_info`` with an argparse dispatcher.""" parser = subparsers.add_parser("software_info", help="Display software, active threading backend and core limits.") @@ -14,6 +15,7 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> parser.set_defaults(func=handle_registered, handler=handle_registered) return parser + def handle_registered(args: argparse.Namespace) -> dict[str, Any]: info = software_info() return command_ok("software_info", info=info) diff --git a/src/eegprep/cli/core.py b/src/eegprep/cli/core.py index 373d3019..9ecd18bf 100644 --- a/src/eegprep/cli/core.py +++ b/src/eegprep/cli/core.py @@ -219,7 +219,7 @@ def file_sha256(path: str | Path) -> str: def software_info() -> dict[str, Any]: from eegprep.utils.math_backend import get_math_backend_info - + return { "eegprep_version": eegprep.__version__, "python_version": platform.python_version(), @@ -242,15 +242,11 @@ def build_manifest( deterministic: bool | None = None, warnings: list[Any] | None = None, ) -> dict[str, Any]: - from eegprep.utils.math_backend import check_conflicting_libraries - stamp = runtime_stamp(started_at) if finished_at is None else RuntimeStamp(started_at, finished_at) soft_info = software_info() - - all_warnings = warnings or [] - math_warnings = check_conflicting_libraries(soft_info.get("math_backend_info", [])) - all_warnings.extend(math_warnings) - + + all_warnings = list(warnings) if warnings else [] + manifest: dict[str, Any] = { "schema_version": "eegprep.manifest.v1", "command": command, @@ -259,7 +255,6 @@ def build_manifest( "parameters": json_safe(parameters), "history": history, "software": soft_info, - "math_backend_info": soft_info.get("math_backend_info", []), "runtime": {"started_at": stamp.started_at, "finished_at": stamp.finished_at}, "warnings": all_warnings, } diff --git a/src/eegprep/utils/math_backend.py b/src/eegprep/utils/math_backend.py index bc9f6952..30dd4fa4 100644 --- a/src/eegprep/utils/math_backend.py +++ b/src/eegprep/utils/math_backend.py @@ -1,5 +1,6 @@ import threadpoolctl + def get_math_backend_info(): try: import numpy # noqa: F401 @@ -8,37 +9,3 @@ def get_math_backend_info(): info = threadpoolctl.threadpool_info() return info - -def check_conflicting_libraries(info): - """ - Returns a list of warning dictionaries if multiple conflicting math libraries - (e.g., multiple different internal APIs for BLAS) are loaded. - """ - blas_apis = set() - lapack_apis = set() - - for item in info: - user_api = item.get("user_api") - internal_api = item.get("internal_api") - if user_api == "blas" and internal_api: - blas_apis.add(internal_api) - if user_api == "lapack" and internal_api: - lapack_apis.add(internal_api) - - warnings = [] - if len(blas_apis) > 1: - warnings.append({ - "code": "CONFLICTING_BLAS_LIBRARIES", - "message": f"Multiple conflicting BLAS libraries detected: {', '.join(sorted(blas_apis))}", - "severity": "warning", - "suggestion": "Ensure only one BLAS implementation is loaded to avoid numerical drift." - }) - if len(lapack_apis) > 1: - warnings.append({ - "code": "CONFLICTING_LAPACK_LIBRARIES", - "message": f"Multiple conflicting LAPACK libraries detected: {', '.join(sorted(lapack_apis))}", - "severity": "warning", - "suggestion": "Ensure only one LAPACK implementation is loaded to avoid numerical drift." - }) - - return warnings diff --git a/tests/test_cli_core.py b/tests/test_cli_core.py new file mode 100644 index 00000000..5650d563 --- /dev/null +++ b/tests/test_cli_core.py @@ -0,0 +1,36 @@ +from eegprep.cli.core import build_manifest + + +def test_build_manifest_copies_warnings(): + # Test that the warnings list is copied and not mutated in place + input_warnings = ["test_warning_1"] + manifest = build_manifest( + command="test", + input_files=[], + output_files=[], + parameters={}, + started_at="2026-07-15T00:00:00Z", + warnings=input_warnings, + ) + assert "test_warning_1" in manifest["warnings"] + assert manifest["warnings"] is not input_warnings + + # Mutating the manifest's warning list shouldn't affect the input + manifest["warnings"].append("new_warning") + assert "new_warning" not in input_warnings + + +def test_build_manifest_software_serialization(): + # Test that the software info is serialized correctly and no top-level math_backend_info exists + manifest = build_manifest( + command="test", + input_files=[], + output_files=[], + parameters={}, + started_at="2026-07-15T00:00:00Z", + ) + assert "software" in manifest + assert "eegprep_version" in manifest["software"] + assert "python_version" in manifest["software"] + assert "math_backend_info" in manifest["software"] + assert "math_backend_info" not in manifest # Should not be at the top level From 25cfe4dec5f89d6787d925e54583d840038513bf Mon Sep 17 00:00:00 2001 From: suraj-ranganath Date: Thu, 16 Jul 2026 03:12:19 -0700 Subject: [PATCH 3/3] Harden math backend observability --- docs/source/user_guide/advanced_topics.rst | 27 ++++++-- src/eegprep/cli/commands/software_info.py | 5 +- src/eegprep/cli/core.py | 2 + src/eegprep/cli/discovery.py | 14 ++++ src/eegprep/utils/math_backend.py | 78 ++++++++++++++++++++-- tests/test_cli_core.py | 53 ++++++++------- tests/test_cli_main.py | 19 ++++++ tests/test_math_backend.py | 72 ++++++++++++++++++++ 8 files changed, 230 insertions(+), 40 deletions(-) create mode 100644 tests/test_math_backend.py diff --git a/docs/source/user_guide/advanced_topics.rst b/docs/source/user_guide/advanced_topics.rst index b03a6328..b9036ca6 100644 --- a/docs/source/user_guide/advanced_topics.rst +++ b/docs/source/user_guide/advanced_topics.rst @@ -251,10 +251,23 @@ For GUI/console synchronization changes, add coverage near Math Backends ============= -The exact numerical results you observe from iterative algorithms (e.g., ICA, ASR) may vary depending on the active BLAS (Basic Linear Algebra Subprograms) and LAPACK implementation used by the underlying numerical libraries (such as OpenBLAS, Intel MKL, or Apple Accelerate). - -You can audit the active math backend using the CLI command: -``eegprep software_info`` - -This will report the threading layer and the active BLAS implementation. Manifests generated by pipeline commands automatically record this environment data to help you validate environment consistency across different computing infrastructures. - +Floating-point results from iterative algorithms such as ICA and ASR can vary +across numerical-library and threading configurations. Inspect the current +process with either output format: + +.. code-block:: console + + eegprep software_info + eegprep software_info --json + +The report distinguishes the BLAS and LAPACK libraries used to build NumPy +from libraries currently visible to ``threadpoolctl``. This distinction matters +on platforms such as macOS, where Apple Accelerate can appear in NumPy's build +configuration without appearing in the loaded-library list. Effective thread +counts and explicitly configured thread environment variables are reported when +available. + +CLI manifests store the same facts under ``software.math_backend_info``. This +metadata helps compare environments; it does not predict or guarantee a +particular numerical tolerance. Backend inspection is best-effort, and any +collection error is recorded without interrupting the processing command. diff --git a/src/eegprep/cli/commands/software_info.py b/src/eegprep/cli/commands/software_info.py index 58166a05..a7d31804 100644 --- a/src/eegprep/cli/commands/software_info.py +++ b/src/eegprep/cli/commands/software_info.py @@ -5,7 +5,7 @@ import argparse from typing import Any -from eegprep.cli.core import software_info, command_ok +from eegprep.cli.core import command_ok, software_info def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> argparse.ArgumentParser: @@ -17,5 +17,4 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> def handle_registered(args: argparse.Namespace) -> dict[str, Any]: - info = software_info() - return command_ok("software_info", info=info) + return command_ok("software_info", **software_info()) diff --git a/src/eegprep/cli/core.py b/src/eegprep/cli/core.py index 9ecd18bf..eeb4a835 100644 --- a/src/eegprep/cli/core.py +++ b/src/eegprep/cli/core.py @@ -5,6 +5,7 @@ import hashlib import json import math +import os import platform import sys from dataclasses import dataclass @@ -226,6 +227,7 @@ def software_info() -> dict[str, Any]: "platform": platform.platform(), "architecture": platform.machine(), "processor": platform.processor(), + "logical_cpu_count": os.cpu_count(), "math_backend_info": get_math_backend_info(), } diff --git a/src/eegprep/cli/discovery.py b/src/eegprep/cli/discovery.py index 174eb49e..b8de53c6 100644 --- a/src/eegprep/cli/discovery.py +++ b/src/eegprep/cli/discovery.py @@ -63,6 +63,13 @@ def capabilities() -> dict[str, Any]: "supports_json": True, "supports_dry_run": False, }, + "software_info": { + "description": "Report NumPy build backends, loaded thread-pool libraries, and CPU metadata.", + "inputs": [], + "outputs": ["text", "json"], + "supports_json": True, + "supports_dry_run": False, + }, "bids": { "description": "Validate, import, and export BIDS EEG datasets.", "inputs": ["bids_eeg", "eeglab_set"], @@ -203,6 +210,12 @@ def command_schema(command: str) -> dict[str, Any]: "overwrite": {"type": "boolean", "default": False}, }, }, + "software_info": { + "schema_version": "eegprep.schema.command.software_info.v1", + "syntax": "eegprep software_info [--json]", + "required": [], + "properties": {}, + }, "bids": { "schema_version": "eegprep.schema.command.bids.v1", "syntax": "eegprep bids ... --json", @@ -301,6 +314,7 @@ def examples(name: str) -> dict[str, Any]: "eegprep qc report sample_data/eeglab_data.set --html qc.html --json", ], "report": ["eegprep report sample_data/eeglab_data.set --output report.html --json"], + "software_info": ["eegprep software_info", "eegprep software_info --json"], "bids": [ "eegprep bids validate bids_root --json", "eegprep bids export input.set --bids-root bids_out --subject 01 --task rest --json", diff --git a/src/eegprep/utils/math_backend.py b/src/eegprep/utils/math_backend.py index 30dd4fa4..fce3dfc9 100644 --- a/src/eegprep/utils/math_backend.py +++ b/src/eegprep/utils/math_backend.py @@ -1,11 +1,75 @@ -import threadpoolctl +"""Read-only diagnostics for NumPy math backends and thread pools.""" +from __future__ import annotations -def get_math_backend_info(): +import os +from collections.abc import Mapping +from typing import Any + +import numpy as np +from threadpoolctl import threadpool_info + + +_THREAD_ENVIRONMENT_VARIABLES = ( + "OMP_NUM_THREADS", + "OMP_DYNAMIC", + "OPENBLAS_NUM_THREADS", + "MKL_NUM_THREADS", + "MKL_DYNAMIC", + "MKL_THREADING_LAYER", + "BLIS_NUM_THREADS", + "VECLIB_MAXIMUM_THREADS", + "NUMEXPR_NUM_THREADS", +) + + +def get_math_backend_info() -> dict[str, Any]: + """Return factual build and runtime math-library metadata. + + Collection errors are recorded in the result so diagnostics cannot prevent + a processing command from writing its manifest. + """ + errors: list[str] = [] try: - import numpy # noqa: F401 - except ImportError: - pass + build_dependencies = _numpy_build_dependencies() + except Exception as exc: # Diagnostic collection must not break processing. + build_dependencies = {} + errors.append(f"numpy config: {type(exc).__name__}: {exc}") + + try: + loaded_libraries = [dict(library) for library in threadpool_info()] + except Exception as exc: # Third-party runtime inspection is best-effort. + loaded_libraries = [] + errors.append(f"threadpoolctl: {type(exc).__name__}: {exc}") + + return { + "numpy_version": np.__version__, + "numpy_build_dependencies": build_dependencies, + "loaded_libraries": loaded_libraries, + "thread_environment": { + name: value for name in _THREAD_ENVIRONMENT_VARIABLES if (value := os.environ.get(name)) is not None + }, + "collection_errors": errors, + } + + +def _numpy_build_dependencies() -> dict[str, Any]: + """Read NumPy 2.x config, with a fallback for older supported NumPy.""" + config = getattr(np.__config__, "CONFIG", {}) + if isinstance(config, Mapping): + dependencies = config.get("Build Dependencies") + if isinstance(dependencies, Mapping): + result: dict[str, Any] = {} + for name in ("blas", "lapack"): + value = dependencies.get(name, {}) + result[name] = dict(value) if isinstance(value, Mapping) else {} + return result - info = threadpoolctl.threadpool_info() - return info + get_info = getattr(np.__config__, "get_info", None) + if not callable(get_info): + return {} + result = {} + for name in ("blas", "lapack"): + value = get_info(f"{name}_opt_info") + result[name] = dict(value) if isinstance(value, Mapping) else {} + return result diff --git a/tests/test_cli_core.py b/tests/test_cli_core.py index 5650d563..0e2f7e40 100644 --- a/tests/test_cli_core.py +++ b/tests/test_cli_core.py @@ -1,10 +1,19 @@ -from eegprep.cli.core import build_manifest +from __future__ import annotations +import json -def test_build_manifest_copies_warnings(): - # Test that the warnings list is copied and not mutated in place - input_warnings = ["test_warning_1"] - manifest = build_manifest( +from eegprep.cli import core + + +def test_build_manifest_copies_warnings_and_serializes_software(monkeypatch): + software = { + "eegprep_version": "test", + "math_backend_info": {"loaded_libraries": [{"internal_api": "openblas"}]}, + } + monkeypatch.setattr(core, "software_info", lambda: software) + input_warnings = ["test warning"] + + manifest = core.build_manifest( command="test", input_files=[], output_files=[], @@ -12,25 +21,23 @@ def test_build_manifest_copies_warnings(): started_at="2026-07-15T00:00:00Z", warnings=input_warnings, ) - assert "test_warning_1" in manifest["warnings"] + + assert manifest["software"] == software + assert "math_backend_info" not in manifest + assert manifest["warnings"] == input_warnings assert manifest["warnings"] is not input_warnings + json.dumps(core.json_safe(manifest)) - # Mutating the manifest's warning list shouldn't affect the input - manifest["warnings"].append("new_warning") - assert "new_warning" not in input_warnings + manifest["warnings"].append("new warning") + assert input_warnings == ["test warning"] -def test_build_manifest_software_serialization(): - # Test that the software info is serialized correctly and no top-level math_backend_info exists - manifest = build_manifest( - command="test", - input_files=[], - output_files=[], - parameters={}, - started_at="2026-07-15T00:00:00Z", - ) - assert "software" in manifest - assert "eegprep_version" in manifest["software"] - assert "python_version" in manifest["software"] - assert "math_backend_info" in manifest["software"] - assert "math_backend_info" not in manifest # Should not be at the top level +def test_software_info_reports_logical_cpu_count(monkeypatch): + backend_info = {"loaded_libraries": []} + monkeypatch.setattr("eegprep.utils.math_backend.get_math_backend_info", lambda: backend_info) + monkeypatch.setattr(core.os, "cpu_count", lambda: 12) + + info = core.software_info() + + assert info["logical_cpu_count"] == 12 + assert info["math_backend_info"] is backend_info diff --git a/tests/test_cli_main.py b/tests/test_cli_main.py index 086b0ff4..d1b94d39 100644 --- a/tests/test_cli_main.py +++ b/tests/test_cli_main.py @@ -42,6 +42,24 @@ def test_help_has_agent_start_section(): assert "eeglab Inspect EEGLAB history" not in result.stdout +def test_software_info_has_human_and_json_output(): + human = _run_cli("software_info") + structured = _run_cli("software_info", "--json") + + assert human.returncode == 0, human.stderr + assert "eegprep_version:" in human.stdout + assert "logical_cpu_count:" in human.stdout + assert "math_backend_info:" in human.stdout + assert "\ninfo:" not in human.stdout + + assert structured.returncode == 0, structured.stderr + payload = _json_stdout(structured) + assert payload["command"] == "software_info" + assert payload["logical_cpu_count"] + assert "numpy_build_dependencies" in payload["math_backend_info"] + assert "loaded_libraries" in payload["math_backend_info"] + + def test_capabilities_schema_examples_and_skill_are_json_readable(): capabilities = _run_cli("capabilities", "--json") schema = _run_cli("schema", "command", "filter", "--json") @@ -53,6 +71,7 @@ def test_capabilities_schema_examples_and_skill_are_json_readable(): assert "filter" in commands assert "batch" in commands assert "migrate" in commands + assert "software_info" in commands assert "eeglab" not in commands assert schema.returncode == 0 assert _json_stdout(schema)["schema"]["schema_version"] == "eegprep.schema.command.filter.v1" diff --git a/tests/test_math_backend.py b/tests/test_math_backend.py new file mode 100644 index 00000000..10592133 --- /dev/null +++ b/tests/test_math_backend.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from eegprep.utils import math_backend + + +def test_math_backend_info_separates_numpy_build_and_loaded_libraries(monkeypatch): + monkeypatch.setattr( + math_backend.np.__config__, + "CONFIG", + { + "Build Dependencies": { + "blas": {"name": "accelerate", "found": True}, + "lapack": {"name": "accelerate", "found": True}, + } + }, + ) + monkeypatch.setattr( + math_backend, + "threadpool_info", + lambda: [{"user_api": "openmp", "internal_api": "openmp", "num_threads": 4}], + ) + monkeypatch.setenv("OMP_NUM_THREADS", "4") + + info = math_backend.get_math_backend_info() + + assert info["numpy_build_dependencies"] == { + "blas": {"name": "accelerate", "found": True}, + "lapack": {"name": "accelerate", "found": True}, + } + assert info["loaded_libraries"] == [{"user_api": "openmp", "internal_api": "openmp", "num_threads": 4}] + assert info["thread_environment"]["OMP_NUM_THREADS"] == "4" + assert info["collection_errors"] == [] + + +def test_math_backend_info_is_non_fatal_when_threadpool_inspection_fails(monkeypatch): + def fail() -> list[dict[str, object]]: + raise RuntimeError("inspection unavailable") + + monkeypatch.setattr(math_backend, "threadpool_info", fail) + + info = math_backend.get_math_backend_info() + + assert info["loaded_libraries"] == [] + assert info["collection_errors"] == ["threadpoolctl: RuntimeError: inspection unavailable"] + + +def test_math_backend_info_is_non_fatal_when_numpy_config_inspection_fails(monkeypatch): + def fail() -> dict[str, object]: + raise ValueError("bad build metadata") + + monkeypatch.setattr(math_backend, "_numpy_build_dependencies", fail) + monkeypatch.setattr(math_backend, "threadpool_info", lambda: []) + + info = math_backend.get_math_backend_info() + + assert info["numpy_build_dependencies"] == {} + assert info["collection_errors"] == ["numpy config: ValueError: bad build metadata"] + + +def test_numpy_build_dependencies_supports_legacy_numpy_config(monkeypatch): + monkeypatch.setattr(math_backend.np.__config__, "CONFIG", {}, raising=False) + monkeypatch.setattr( + math_backend.np.__config__, + "get_info", + lambda name: {"libraries": ["openblas"]} if name == "blas_opt_info" else {}, + raising=False, + ) + + assert math_backend._numpy_build_dependencies() == { + "blas": {"libraries": ["openblas"]}, + "lapack": {}, + }