diff --git a/docs/source/user_guide/advanced_topics.rst b/docs/source/user_guide/advanced_topics.rst index 69244be0..b9036ca6 100644 --- a/docs/source/user_guide/advanced_topics.rst +++ b/docs/source/user_guide/advanced_topics.rst @@ -247,3 +247,27 @@ 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. + +Math Backends +============= + +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 new file mode 100644 index 00000000..a7d31804 --- /dev/null +++ b/src/eegprep/cli/commands/software_info.py @@ -0,0 +1,20 @@ +"""Agent-friendly EEGPrep software_info command support.""" + +from __future__ import annotations + +import argparse +from typing import Any + +from eegprep.cli.core import command_ok, software_info + + +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]: + return command_ok("software_info", **software_info()) diff --git a/src/eegprep/cli/core.py b/src/eegprep/cli/core.py index e5378438..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 @@ -218,10 +219,16 @@ 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(), + "logical_cpu_count": os.cpu_count(), + "math_backend_info": get_math_backend_info(), } @@ -238,6 +245,10 @@ def build_manifest( warnings: list[Any] | None = None, ) -> dict[str, Any]: stamp = runtime_stamp(started_at) if finished_at is None else RuntimeStamp(started_at, finished_at) + soft_info = software_info() + + all_warnings = list(warnings) if warnings else [] + manifest: dict[str, Any] = { "schema_version": "eegprep.manifest.v1", "command": command, @@ -245,9 +256,9 @@ def build_manifest( "output_files": output_files, "parameters": json_safe(parameters), "history": history, - "software": software_info(), + "software": soft_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/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/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..fce3dfc9 --- /dev/null +++ b/src/eegprep/utils/math_backend.py @@ -0,0 +1,75 @@ +"""Read-only diagnostics for NumPy math backends and thread pools.""" + +from __future__ import annotations + +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: + 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 + + 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 new file mode 100644 index 00000000..0e2f7e40 --- /dev/null +++ b/tests/test_cli_core.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import json + +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=[], + parameters={}, + started_at="2026-07-15T00:00:00Z", + warnings=input_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)) + + manifest["warnings"].append("new warning") + assert input_warnings == ["test warning"] + + +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": {}, + }