Skip to content

Commit 79d1fa7

Browse files
Phase 3: native run engine + binary resolver/installer (#175)
* Add AMICANative run engine + binary resolver/installer * Harden binary resolver: verify-before-cache, resolve paths
1 parent 85892a2 commit 79d1fa7

9 files changed

Lines changed: 674 additions & 1 deletion

File tree

.github/workflows/ci.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,16 @@ jobs:
7777
python-version: "3.12"
7878
enable-cache: true
7979
- run: uv sync
80+
# Build the dependency-free native binary (epic #165) so the AMICANative
81+
# engine's end-to-end tests run for real on Linux (they skip without
82+
# PAMICA_NATIVE_BINARY). Static reference LAPACK/BLAS, same as the release.
83+
- name: Build native AMICA binary (for engine E2E tests)
84+
run: |
85+
sudo apt-get update
86+
sudo apt-get install -y gfortran liblapack-dev libblas-dev
87+
LAPACK_LIBS="-l:liblapack.a -l:libblas.a" EXTRA_LDFLAGS="-static-libquadmath" \
88+
bash native/build.sh
89+
echo "PAMICA_NATIVE_BINARY=$PWD/native/amica15_shim" >> "$GITHUB_ENV"
8090
# Exclude the "slow" parity tests: they invoke the macOS-only Fortran
8191
# reference binary (pyAMICA/sample_data/amica15mac), which cannot run on
8292
# the Linux runner. MPS-specific tests self-skip when MPS is absent.

docs/changelog.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,17 @@ Release notes are also published on the
55

66
## Unreleased
77

8+
- Native Fortran run engine (`AMICANative`), the fourth backend alongside NumPy,
9+
PyTorch and MLX. It runs the AMICA Fortran reference itself and returns an
10+
`AmicaOutput` with the usual accessors, so it is the parity oracle the Python
11+
backends are checked against. The reference is now built dependency-free (a
12+
single-rank MPI shim removes the Open MPI runtime, on top of sccn/amica PR
13+
\#53's no-MKL recipe; proven identical to real Open MPI at machine epsilon) and
14+
released as a self-contained binary for macOS arm64, Linux x64/arm64 and Windows
15+
x64 (Windows arm64 runs the x64 binary via emulation until a native toolchain
16+
exists, issue #173). The binary is resolved for the host and downloaded from the
17+
release on first use (SHA-256 verified); `python -m pyAMICA.native` installs it
18+
explicitly, or set `PAMICA_NATIVE_BINARY` to a local build (epic #165).
819
- Fixed `loadmodout` reading `W`, `sbeta` and `rho` in the wrong byte order:
920
it used C order where the writer, genuine Fortran output and EEGLAB's
1021
`loadmodout15.m` all use column-major (F order). The consequence was that

pyAMICA/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,14 @@
2525
# issue #34); AMICA_NumPy is its scikit-learn-style interface.
2626
from .numpy_impl import AMICA as AMICA_NumPy
2727

28+
# Native Fortran run engine (epic #165): runs the dependency-free reference binary.
29+
from .native import AMICANative
30+
2831
__all__ = [
2932
"AMICA",
3033
"AMICATorchNG",
3134
"AMICA_NumPy",
35+
"AMICANative",
3236
"metrics",
3337
"numpy_impl",
3438
"torch_impl",

pyAMICA/native/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""Native Fortran run engine (epic #165): the ``AMICANative`` backend and the
2+
binary resolver that fetches the right release binary for the host."""
3+
4+
from .engine import AMICANative
5+
from .resolver import asset_name, platform_tag, resolve
6+
7+
__all__ = ["AMICANative", "asset_name", "platform_tag", "resolve"]

pyAMICA/native/__main__.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""Install the native AMICA binary for this system (epic #165).
2+
3+
python -m pyAMICA.native # download+cache the latest release binary
4+
python -m pyAMICA.native --version v0.2.0 # a specific release
5+
python -m pyAMICA.native --print # just print where it would resolve to
6+
7+
Downloads the release asset matching the host platform, verifies its SHA-256, and
8+
caches it; prints the resolved path. Idempotent (a cached binary is reused).
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import argparse
14+
import sys
15+
16+
from . import resolver
17+
18+
19+
def main(argv: list[str] | None = None) -> int:
20+
parser = argparse.ArgumentParser(prog="python -m pyAMICA.native")
21+
parser.add_argument(
22+
"--version", default="latest", help="release tag (default: latest)"
23+
)
24+
parser.add_argument(
25+
"--print",
26+
action="store_true",
27+
dest="print_only",
28+
help="resolve from override/cache only; do not download",
29+
)
30+
args = parser.parse_args(argv)
31+
try:
32+
path = resolver.resolve(args.version, download=not args.print_only)
33+
except Exception as exc: # surface a clear message, not a traceback
34+
print(f"error: {exc}", file=sys.stderr)
35+
return 1
36+
print(path)
37+
return 0
38+
39+
40+
if __name__ == "__main__":
41+
raise SystemExit(main())

pyAMICA/native/engine.py

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
"""``AMICANative``: run the native AMICA Fortran binary as a pyAMICA backend.
2+
3+
The fourth run engine, alongside the NumPy, PyTorch (``AMICATorchNG``) and MLX
4+
backends. It writes the data and an ``input.param``, runs the dependency-free
5+
binary resolved by :mod:`pyAMICA.native.resolver`, and reads the result back
6+
through :func:`pyAMICA.numpy_impl.load.loadmodout` -- so its output is an
7+
``AmicaOutput`` with the same accessors (``.sources``, ``.W``, ``.A``, ...) as a
8+
loaded fit. This is the Fortran reference itself, so it is the parity oracle the
9+
Python backends are validated against.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import subprocess
15+
import tempfile
16+
from pathlib import Path
17+
from typing import Optional, Union
18+
19+
import numpy as np
20+
21+
from ..numpy_impl.load import AmicaOutput, loadmodout
22+
from . import resolver
23+
24+
# Full default parameter set (mirrors sample_data/input.param) so the engine does
25+
# not depend on the sample data being installed. ``files``/``outdir``/``data_dim``/
26+
# ``field_dim`` are set per fit; everything else is a tunable default. Keys are the
27+
# Fortran param names; friendly aliases are mapped in ``fit``.
28+
_DEFAULT_PARAMS: dict[str, object] = {
29+
"block_size": 512,
30+
"do_opt_block": 0,
31+
"blk_min": 256,
32+
"blk_step": 256,
33+
"blk_max": 1024,
34+
"num_models": 1,
35+
"max_threads": 10,
36+
"use_min_dll": 1,
37+
"min_dll": 1e-09,
38+
"use_grad_norm": 1,
39+
"min_grad_norm": 1e-07,
40+
"num_mix_comps": 3,
41+
"pdftype": 0,
42+
"max_iter": 2000,
43+
"num_samples": 1,
44+
"field_blocksize": 1,
45+
"do_history": 0,
46+
"histstep": 10,
47+
"share_comps": 0,
48+
"share_start": 100,
49+
"comp_thresh": 0.99,
50+
"share_iter": 100,
51+
"lrate": 0.05,
52+
"minlrate": 1e-08,
53+
"mineig": 1e-12,
54+
"lratefact": 0.5,
55+
"rholrate": 0.05,
56+
"rho0": 1.5,
57+
"minrho": 1.0,
58+
"maxrho": 2.0,
59+
"rholratefact": 0.5,
60+
"kurt_start": 3,
61+
"num_kurt": 5,
62+
"kurt_int": 1,
63+
"do_newton": 1,
64+
"newt_start": 50,
65+
"newt_ramp": 10,
66+
"newtrate": 1.0,
67+
"do_reject": 0,
68+
"numrej": 3,
69+
"rejsig": 3.0,
70+
"rejstart": 2,
71+
"rejint": 3,
72+
"writestep": 20,
73+
"write_nd": 0,
74+
"write_LLt": 1,
75+
"decwindow": 1,
76+
"max_decs": 3,
77+
"fix_init": 0,
78+
"update_A": 1,
79+
"update_c": 1,
80+
"update_gm": 1,
81+
"update_alpha": 1,
82+
"update_mu": 1,
83+
"update_beta": 1,
84+
"invsigmax": 100.0,
85+
"invsigmin": 0.0,
86+
"do_rho": 1,
87+
"load_rej": 0,
88+
"load_W": 0,
89+
"load_c": 0,
90+
"load_gm": 0,
91+
"load_alpha": 0,
92+
"load_mu": 0,
93+
"load_beta": 0,
94+
"load_rho": 0,
95+
"load_comp_list": 0,
96+
"do_mean": 1,
97+
"do_sphere": 1,
98+
"doPCA": 1,
99+
"pcakeep": 0,
100+
"pcadb": 30.0,
101+
"byte_size": 4,
102+
"doscaling": 1,
103+
"scalestep": 1,
104+
}
105+
106+
# Friendly kwarg -> Fortran param-name aliases (match the Python backends' names).
107+
_ALIASES = {"n_models": "num_models", "n_mix": "num_mix_comps"}
108+
109+
110+
def _render_param(params: dict[str, object]) -> str:
111+
return "".join(f"{k} {_fmt(v)}\n" for k, v in params.items())
112+
113+
114+
def _fmt(v: object) -> str:
115+
if isinstance(v, bool):
116+
return str(int(v))
117+
if isinstance(v, float):
118+
return f"{v:.6e}" if (v != 0 and abs(v) < 1e-3) else f"{v:.6f}"
119+
return str(v)
120+
121+
122+
class AMICANative:
123+
"""Run the native AMICA binary on data and expose the result as an
124+
``AmicaOutput``.
125+
126+
Parameters
127+
----------
128+
binary : path-like, optional
129+
Explicit binary path; otherwise resolved for the host (downloaded from the
130+
release on first use). Equivalent to setting ``PAMICA_NATIVE_BINARY``.
131+
version : str, default "latest"
132+
Release tag to resolve the binary from when not given explicitly.
133+
threads : int, optional
134+
``OMP_NUM_THREADS`` for the run (default: the binary's own default).
135+
timeout : float, optional
136+
Seconds before the subprocess is killed (default: no timeout).
137+
**params
138+
Any Fortran ``input.param`` field (or a friendly alias: ``n_models``,
139+
``n_mix``), overriding the defaults; e.g. ``max_iter``, ``lrate``,
140+
``pdftype``, ``do_newton``.
141+
"""
142+
143+
def __init__(
144+
self,
145+
binary: Optional[Union[str, Path]] = None,
146+
*,
147+
version: str = "latest",
148+
threads: Optional[int] = None,
149+
timeout: Optional[float] = None,
150+
**params: object,
151+
) -> None:
152+
# resolve() now: the subprocess runs with cwd set to a tempdir, so a
153+
# relative binary path would pass the existence check but fail to launch.
154+
self.binary = Path(binary).resolve() if binary is not None else None
155+
self.version = version
156+
self.threads = threads
157+
self.timeout = timeout
158+
self.params = params
159+
self.output_: Optional[AmicaOutput] = None
160+
161+
def _resolve_binary(self) -> Path:
162+
if self.binary is not None:
163+
if not self.binary.exists():
164+
raise FileNotFoundError(f"binary not found: {self.binary}")
165+
return self.binary
166+
return resolver.resolve(self.version)
167+
168+
def fit(self, X: np.ndarray, **params: object) -> "AMICANative":
169+
"""Run AMICA on ``X`` (shape ``(n_channels, n_samples)``) and store the
170+
result as ``self.output_`` (an ``AmicaOutput``)."""
171+
X = np.asarray(X)
172+
if X.ndim != 2:
173+
raise ValueError(f"X must be 2-D (n_channels, n_samples); got {X.shape}")
174+
n_channels, n_samples = X.shape
175+
176+
merged = dict(_DEFAULT_PARAMS)
177+
for src in (self.params, params):
178+
for key, value in src.items():
179+
merged[_ALIASES.get(key, key)] = value
180+
merged["data_dim"] = n_channels
181+
merged["field_dim"] = n_samples
182+
if not merged.get("pcakeep"):
183+
merged["pcakeep"] = n_channels # default: keep all components
184+
185+
binary = self._resolve_binary()
186+
187+
with tempfile.TemporaryDirectory(prefix="amica_native_") as td:
188+
work = Path(td)
189+
# AMICA reads the data as raw byte_size floats in column-major order
190+
# (numpy_impl/data.py: reshape order="F"); write it that way.
191+
byte_size = merged["byte_size"]
192+
dtype = (
193+
np.float32
194+
if isinstance(byte_size, int) and byte_size == 4
195+
else np.float64
196+
)
197+
X.astype(dtype).ravel(order="F").tofile(work / "data.fdt")
198+
199+
outdir = work / "amicaout"
200+
outdir.mkdir()
201+
# `files` must come first: amica15.f90 hard-stops if it parses other
202+
# keys before the data file. Dict insertion order preserves that.
203+
param = {"files": "./data.fdt", "outdir": "./amicaout/", **merged}
204+
(work / "input.param").write_text(_render_param(param))
205+
206+
env = None
207+
if self.threads is not None:
208+
import os
209+
210+
env = {**os.environ, "OMP_NUM_THREADS": str(self.threads)}
211+
212+
proc = subprocess.run(
213+
[str(binary), "input.param"],
214+
cwd=work,
215+
capture_output=True,
216+
text=True,
217+
timeout=self.timeout,
218+
env=env,
219+
)
220+
if proc.returncode != 0:
221+
raise RuntimeError(
222+
f"native AMICA failed (exit {proc.returncode}).\n"
223+
f"stdout tail:\n{proc.stdout[-2000:]}\n"
224+
f"stderr tail:\n{proc.stderr[-2000:]}"
225+
)
226+
if not (outdir / "W").exists():
227+
raise RuntimeError(
228+
"native AMICA produced no output (no 'W' file); stdout tail:\n"
229+
f"{proc.stdout[-2000:]}"
230+
)
231+
# A collapsed fit writes NaN weights (and zero model probabilities);
232+
# loadmodout's pinv(W@S) would then fail with an opaque SVD error, so
233+
# detect it here and report it as the degenerate fit it is (cf. the
234+
# #50 degenerate-fit contract for the Python backends). An empty W
235+
# (truncated write) is caught too -- an all-NaN check vacuously passes
236+
# on a zero-length array.
237+
w_raw = np.fromfile(outdir / "W")
238+
if w_raw.size == 0 or not np.all(np.isfinite(w_raw)):
239+
raise RuntimeError(
240+
"native AMICA produced a degenerate fit (non-finite weights); "
241+
"the run did not converge. Try more iterations, more data, or "
242+
"fewer models."
243+
)
244+
self.output_ = loadmodout(outdir)
245+
return self
246+
247+
def transform(self, X: np.ndarray, model_idx: int = 0) -> np.ndarray:
248+
"""Source activations for ``X`` from the fitted model (delegates to
249+
``AmicaOutput.sources``)."""
250+
if self.output_ is None:
251+
raise RuntimeError("call fit() before transform().")
252+
return self.output_.sources(X, model_idx)

0 commit comments

Comments
 (0)