Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/source/user_guide/advanced_topics.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
20 changes: 20 additions & 0 deletions src/eegprep/cli/commands/software_info.py
Original file line number Diff line number Diff line change
@@ -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())
15 changes: 13 additions & 2 deletions src/eegprep/cli/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import hashlib
import json
import math
import os
import platform
import sys
from dataclasses import dataclass
Expand Down Expand Up @@ -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(),
}


Expand All @@ -238,16 +245,20 @@ 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,
"input_files": [_input_file_record(path) for path in input_files],
"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
Expand Down
14 changes: 14 additions & 0 deletions src/eegprep/cli/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -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 <validate|import|export> ... --json",
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/eegprep/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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)
Expand Down
75 changes: 75 additions & 0 deletions src/eegprep/utils/math_backend.py
Original file line number Diff line number Diff line change
@@ -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
43 changes: 43 additions & 0 deletions tests/test_cli_core.py
Original file line number Diff line number Diff line change
@@ -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
19 changes: 19 additions & 0 deletions tests/test_cli_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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"
Expand Down
72 changes: 72 additions & 0 deletions tests/test_math_backend.py
Original file line number Diff line number Diff line change
@@ -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": {},
}
Loading