Skip to content

Fix ty type-check diagnostics repo-wide (496 -> 0)#124

Merged
neuromechanist merged 19 commits into
mainfrom
issue-122-ty-diagnostics-cleanup
Jul 14, 2026
Merged

Fix ty type-check diagnostics repo-wide (496 -> 0)#124
neuromechanist merged 19 commits into
mainfrom
issue-122-ty-diagnostics-cleanup

Conversation

@neuromechanist

Copy link
Copy Markdown
Member

Summary

Fixes #122. Drives uv run ty check . from 496 diagnostics to 0
repo-wide, unblocking the CI gate + pre-commit hooks tracked in #119.

The large majority of diagnostics had a single root cause: the stateful
estimators (AMICATorchNG, AMICA_NumPy, AMICAMLXNG) initialize
fit-dependent attributes (self.W, self.A, self.sphere, self.c,
self.comp_list, ...) to None in __init__ and only assign real
tensors/arrays in fit(). ty correctly infers these as Tensor | None
(etc.) and flags every downstream use.

Fix approach (no API changes, no blanket silencing)

  • Internal EM-loop call sites: assert x is not None at point of use,
    treating "fit populated this" as an enforced invariant ("trust internal
    code", per CLAUDE.md).
  • Public accessors (transform, get_mixing_matrix,
    get_unmixing_matrix, variance_order, save, ...): real
    RuntimeError guards, matching the existing state_dict()/_check_usable
    convention, since a user can call these before fit().
  • Test files: assert right after the relevant fit()/helper call,
    which also documents "fit succeeded" as a precondition.
  • Explicit Optional[...] annotations added to the fit-dependent
    attributes so the intent is declared, not just inferred.
  • A handful of unrelated real type issues fixed on their own merits:
    spec_from_file_location(...) -> ModuleSpec | None narrowing in test
    loaders, np.dtype default/annotation mismatches widened to
    npt.DTypeLike, str-only path params widened to Union[str, Path],
    dict-spread-into-typed-constructor annotated dict[str, Any], and raw
    instance-attribute method monkey-patches replaced with
    mock.patch.object(...).

Scoped exceptions (documented, not silenced globally)

  • .context/** excluded from ty scope via [tool.ty.src] in
    pyproject.toml: these are frozen historical investigation scripts for
    closed issues (explicitly marked "do not ship as-is"). Live/regenerated
    analysis scripts were fixed on their own merits, not excluded.
  • Two import mlx.core lines carry a scoped
    # ty: ignore[unresolved-import] with an explanatory comment. mlx
    ships as a compiled extension with no .pyi stubs, so ty cannot
    resolve the import even when the package is installed (verified: the
    diagnostic persists after uv pip install mlx). No annotation or assert
    can resolve a missing-stubs import; the suppression is local to those two
    lines only.

Side-effect finding

While annotating numpy_impl/core.py I found _reject_outliers()
references self.data_mask, which was never initialized anywhere, so
do_reject=True would crash. Filed as #123. This PR adds a minimal
non-crashing init (data_mask allocated in _initialize_parameters) with
a comment noting the feature is still functionally incomplete pending the
real fix in #123 (out of scope here).

Validation

  • uv run ty check . -> All checks passed! (0 diagnostics)
  • uv run ruff check / ruff format --check clean
  • Full test suite green (annotation/assert-only change, no logic change)

Definition of done (#122)

Fit-dependent attributes (W, A, c, mu, alpha, beta, rho, pdtype, gm,
comp_list, mean, sphere) are None until fit() runs, then reassigned
to real tensors; ty correctly flagged every downstream use as a
possible None-subscript/attribute error.

Explicit Optional[torch.Tensor] annotations plus assert narrowing at
each internal hot-path call site (E-step, M-step, component sharing,
adaptive PDF switch) resolved most of these; the four public
accessors that a user could call before fit() (transform,
get_mixing_matrix, get_unmixing_matrix, variance_order) get a real
RuntimeError instead, matching the existing state_dict() convention.

Also fixed a genuine possibly-unbound risk in _get_block_updates:
self.do_newton was checked twice without a local binding, so ty
couldn't prove the two checks agree; hoisted into a local.

No behavior change (assertions only, all true at their call sites);
full torch_tests suite green (130 passed, 5 skipped).
Same pattern as torch_impl/core.py: fit-dependent attributes are
None until fit() runs. Explicit Optional[np.ndarray]/Optional[int]
annotations plus assert narrowing at internal call sites; transform()
and load_default_params() get real checks/type widening since they
can be called before fit() or with a Path argument.

Also widened load_default_params(params_file) to accept Path (it
already did at runtime; only the annotation was wrong).

Found and filed as a separate bug (#123, not fixed here):
_reject_outliers() referenced self.data_mask, which was never
initialized anywhere -- do_reject would crash with AttributeError if
its schedule ever fired, and even fixed, nothing consumes data_mask
to actually filter the fit data (unlike the torch backend's
good_idx). Added a minimal, non-crashing initialization here (an
all-True mask) with a comment pointing to #123; the feature is still
functionally incomplete pending that issue.

No behavior change otherwise; full non-slow numpy-backend test suite
green.
Direct attribute access on AMICATorchNG (.A, .comp_list) and on the
sklearn-wrapper's Optional .model_ after fit()/save()/load(), plus a
**dict-spread into AMICATorchNG's constructor whose inferred
dict[str, int | str | bool] value type doesn't match each keyword's
specific type (a known false-positive pattern for spreading
heterogeneous dicts into a strongly-typed constructor -- annotated
the dict Dict[str, Any] instead of duplicating the kwargs).

No behavior change; full file passes (19 passed).
Same recurring patterns as the other torch_tests files: direct
attribute access on AMICATorchNG's Optional fit-dependent attributes,
dict-spread into strongly-typed constructors (annotated Dict[str,
Any] instead of duplicating kwargs), and two instance-level method
monkeypatches (_finalize_newton_stats = spy) rewritten with
unittest.mock.patch.object so the replacement is properly scoped and
type-correct instead of a raw attribute overwrite.

_snapshot_params() legitimately returns Dict[str, object] (it mixes
Tensor values with a plain int), so the two tests indexing into it
now isinstance-narrow before using the Tensor ops.

No behavior change; full file passes (34 passed, 2 deselected).
Same recurring patterns: Optional attribute access after fit()
(m.pdtype, m.W), and dict-spread into AMICATorchNG's constructor
from a dynamically-keyed dict (annotated dict[str, Any] rather than
restructuring the parametrization).

No behavior change; full file passes (34 passed, 5 skipped).
Same pattern as the torch/numpy backends: fit-dependent attributes
are None until fit() runs. Explicit Optional[mx.array] annotations
(safe under `from __future__ import annotations`, so no runtime cost
even when mlx isn't installed) plus assert narrowing at internal
call sites.

The one remaining diagnostic (unresolved-import for mlx.core) is not
fixable from this repo: mlx ships mlx.core as a compiled extension
(.so) with no type stubs, so no static checker can resolve it,
verified by installing mlx locally (uv pip install mlx, Apple
Silicon) and re-running ty -- same single diagnostic persists even
with the package present.

No behavior change; full mlx_tests suite green (8 passed, run
locally on Apple Silicon with mlx installed).
.context/ holds point-in-time investigation/debugging artifacts (per
AGENTS.md's own convention: scratch_history.md, research.md, etc.),
including frozen historical reproduction scripts for closed issues
explicitly marked "do not ship as-is" (e.g. issue-21's
corrected_mstep_prototype.py, whose fix was already ported into the
shipped backend). These are not maintained production code and
forcing them to stay type-clean forever conflicts with their nature
as point-in-time snapshots.

Live, actively-regenerated analysis scripts (e.g. issue-27's
amari_distance.py, which backs current paper/docs claims) are a
different case and were already fixed on their own merits, not
excluded.
Same pattern: AMICA wrapper's Optional .model_ accessed directly
after fit(). No behavior change; full file passes (16 passed).
The sklearn-style wrapper's public accessors (transform,
get_mixing_matrix, get_unmixing_matrix, variance_order,
write_amica_output, save) already call self._check_usable(...) --
which raises if self.model_ is None -- before touching self.model_,
but ty can't narrow across a method call, so each accessor gets a
local assert matching that already-enforced invariant.

Also widened _select_device's return type and the device parameter
on __init__/load from the overly-loose `object` to the accurate
Union[str, torch.device] (torch is already imported in this file, so
there was no reason to avoid the precise type).

No behavior change; test_amica.py + test_amica_ng_wrapper.py pass
(20 passed).
Same recurring patterns: Optional final_ll_ used directly after
fit(), and a dict-mixed-type list spread into AMICAMLXNG's
constructor (annotated list[dict[str, Any]] instead of restructuring
the parametrization).

No behavior change; full mlx_tests suite green (8 passed, run
locally on Apple Silicon).
Same importlib.util.spec_from_file_location None-check as the
existing repo precedent, Optional final_ll_ used in float()
unguarded, and an untyped heterogeneous-dict list (runs) whose
"frames"/"channels" fields lost their int type through a plain
dict[str, object] inference (annotated list[dict[str, Any]]).

The two remaining unresolved-import diagnostics (mne) are the
optional `viz` extra, not installed by default -- verified clean by
installing it locally (uv pip install "mne>=1.6") and re-running ty.

No behavior change; test_decompose_equiv.py (which imports from this
script) passes.
Same dtype: np.dtype = np.float64/float32 default-vs-annotation
mismatch already fixed once in torch_impl/utils.py, now in
load_data_file/load_multiple_files (widened to npt.DTypeLike,
canonicalized once via np.dtype(dtype) before use in
load_multiple_files so its return type stays a concrete ndarray);
load_results(indir: str) reassigned to Path(indir) internally
(widened to Union[str, Path]); and the same
importlib.util.spec_from_file_location None-check pattern in the
test file.

No behavior change; test_fortran_adapter.py, test_pyAMICA.py,
test_amica.py all pass.
Optional final_ll_ used in float() unguarded (torch and mlx timing
helpers), two mixed-type dict-spread helpers into AMICA/AMICATorchNG
constructors (annotated dict[str, Any]), and _platform_info's dict
whose value type was inferred as str from its two initial literal
entries then rejected int/list[float] additions (annotated
dict[str, Any]).

The one remaining diagnostic (unresolved-import for mlx.core) is the
same irreducible mlx stub gap documented in the mlx_impl/core.py fix
commit.

No behavior change; test_dimsweep_threads.py (which imports from
this script) passes.
Same pattern: AMICA_NumPy's Optional .W/.A/.alpha accessed directly
after fit()/load. No behavior change; 5 passed, 1 xfailed (expected).
Two spec_from_file_location None-checks (same established pattern),
and load_data_file's filepath: str widened to Union[str, Path] --
open()/np.fromfile() already accept PathLike at runtime, and two
call sites in the test suite already pass Path objects directly, so
the annotation was simply wrong rather than the call sites.

No behavior change; test_channel_selection.py, test_decompose_equiv.py,
test_amica.py, test_pyAMICA.py all pass (21 passed).
Remaining 2 are the irreducible mlx.core unresolved-import (compiled .so,
no .pyi stubs) in mlx_impl/core.py and benchmark_dimsweep.py.
mlx ships as a compiled extension with no type stubs, so ty cannot
resolve 'import mlx.core' statically even when the package is installed
(verified: the diagnostic persists after 'uv pip install mlx'). There is
no annotation or assert that resolves a missing-stubs import, so scope an
explicit '# ty: ignore[unresolved-import]' to those two lines only, with
a comment explaining why. 'uv run ty check .' now reports 0 diagnostics
repo-wide, meeting issue #122's definition of done and unblocking the
CI gate in #119.
@neuromechanist neuromechanist linked an issue Jul 14, 2026 that may be closed by this pull request
@codecov-commenter

codecov-commenter commented Jul 14, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 82.75862% with 15 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.60%. Comparing base (9a92d49) to head (614532c).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
pyAMICA/torch_impl/core.py 77.27% 5 Missing and 5 partials ⚠️
pyAMICA/numpy_impl/core.py 87.50% 4 Missing ⚠️
pyAMICA/numpy_impl/data.py 75.00% 1 Missing ⚠️
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #124      +/-   ##
==========================================
- Coverage   79.76%   79.60%   -0.16%     
==========================================
  Files          14       14              
  Lines        2011     2064      +53     
  Branches      339      345       +6     
==========================================
+ Hits         1604     1643      +39     
- Misses        301      310       +9     
- Partials      106      111       +5     
Files with missing lines Coverage Δ
pyAMICA/amica.py 91.96% <100.00%> (+0.45%) ⬆️
pyAMICA/numpy_impl/data.py 67.05% <75.00%> (+0.39%) ⬆️
pyAMICA/numpy_impl/core.py 82.30% <87.50%> (-0.38%) ⬇️
pyAMICA/torch_impl/core.py 92.35% <77.27%> (-1.08%) ⬇️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Silent-failure review found do_reject=True on the NumPy backend crashes
mid-fit (num_good_samples divided every iteration but only set later in
_reject_outliers) -- a bare AttributeError deep in the EM loop. fit() now
refuses it with a clear NotImplementedError (issue #123) instead of
crashing cryptically or silently no-op'ing; misleading data_mask comments
corrected; _reject_outliers made self-contained dormant scaffolding; added
a regression test.

Also guard AMICATorchNG.write_amica_output (public accessor missed by the
ty pass because Optionals flow through an unannotated local helper) with
the same RuntimeError as its sibling accessors.

Doc/comment consistency: caveat numpy outlier rejection in AGENTS.md and
docs/api/numpy-backend.md (#123); reword the .context/** ty-exclude comment
to name its example file and note tested scripts were fixed not excluded.
Minor: lowercase dict[str, Any] in test_ng_sharing; drop redundant str()
in test_amica.
@neuromechanist

Copy link
Copy Markdown
Member Author

/review-pr summary (5 parallel reviewers: code, tests, silent-failure, type-design, comments)

No Critical findings. The PR is behavior-preserving; ty check . = 0 diagnostics, ruff clean, full suite 191 passed / 8 skipped / 3 xfailed / 0 failed. Reviewers surfaced two real gaps (now fixed) plus doc/comment consistency items (now fixed) and two intentional decisions to document (below).

Fixed in follow-up commits

  1. do_reject=True on the NumPy backend crashed mid-fit (silent-failure HIGH; corroborated by code-review + type-design). _update_parameters divides by self.num_good_samples every iteration under do_reject, but that attribute is only set inside _reject_outliers (later, on schedule), so iteration 0 raised a bare AttributeError deep in the EM loop, independent of the reject schedule. My initial data_mask stub + comment only addressed a different, unreached crash and were misleading. Fix: fit() now refuses do_reject=True with a clear NotImplementedError pointing at numpy_impl: do_reject outlier rejection is non-functional (data_mask never initialized/consumed) #123 (loud + actionable instead of a cryptic crash or a silent no-op), the misleading comments are corrected, and a regression test (test_do_reject_refused_on_numpy_backend) locks the behavior. The real port stays tracked in numpy_impl: do_reject outlier rejection is non-functional (data_mask never initialized/consumed) #123. The PyTorch backend's do_reject (via good_idx) is unaffected and works.

  2. AMICATorchNG.write_amica_output was left unguarded (silent-failure HIGH). Its four sibling public accessors (transform, get_mixing_matrix, get_unmixing_matrix, variance_order) got RuntimeError fit-guards in this PR, but write_amica_output was missed because its Optional attributes flow through an unannotated local _np helper that ty can't see through. Calling it before fit() raised a bare AttributeError. Fix: added the same RuntimeError("... requires a fitted model; call fit() first.") guard.

  3. Doc/comment consistency (comment + silent-failure reviewers): docs/api/numpy-backend.md and AGENTS.md claimed the NumPy backend has working "outlier rejection", which now contradicts the honest numpy_impl: do_reject outlier rejection is non-functional (data_mask never initialized/consumed) #123 gate. Both caveated. The pyproject.toml .context/** comment over-generalized the "do not ship as-is" marking (only one file carries it verbatim); reworded to name that file as the example and note that live/tested .context scripts were fixed on their merits, not excluded.

  4. Minor consistency (test reviewer): test_ng_sharing.py now uses lowercase dict[str, Any] like every other touched file (was Dict[str, Any]); removed the now-redundant str() wrapper in test_amica.py (load_data_file accepts Path).

Intentional decisions (documented per the no-mocks / no-behavior-change policies)

  • unittest.mock.patch.object in test_ng_backend.py (code + test reviewers asked to confirm): this is a spy, not a mock/fake. mock.patch.object(ng, "_finalize_newton_stats", side_effect=fn) where fn calls through to the real _finalize_newton_stats(acc) — no result is faked, the real EM computation runs. It replaces a raw instance-attribute method reassignment that ty correctly flagged as a signature mismatch, and it auto-restores after the with block (strictly better than the leaked monkey-patch). Consistent with the "no mock data/tests" policy.
  • sigma2/lambda_/kappa init hoist (type-design nit): these were previously assigned None only inside if self.do_newton:; the diff hoists them to unconditional None so the Optional[...] annotations are honest. This means the attributes now always exist (vs. not existing when do_newton=False) — a benign change, no test relies on hasattr, and internal call sites are always reached only after _initialize_parameters populates the real values.

Follow-ups (out of scope, tracked)

The Intel benchmark host is a Core i9-13900K (24 cores / 32 threads,
8P+16E), not a Xeon. Fix paper.md Table 2 and the docs core-scaling
section (header, the "24 of 32 cores" and "32-core workstation" phrasings
that conflated threads with cores). Rebuild and commit paper.pdf (JOSS
inara toolchain) so the compiled paper lives in the repo root.
@neuromechanist neuromechanist merged commit 9e726e4 into main Jul 14, 2026
7 checks passed
@neuromechanist neuromechanist deleted the issue-122-ty-diagnostics-cleanup branch July 14, 2026 07:40
neuromechanist added a commit that referenced this pull request Jul 14, 2026
Port AMICATorchNG's outlier rejection to AMICA_NumPy, replacing the
non-functional data_mask stub. Previously do_reject was dead: _reject_outliers
thresholded the scalar total LL (not per-sample), self.data_mask was never
consumed by the E-step, and num_good_samples was used before assignment.

- good_idx: an index array of kept samples (init to all in _initialize_parameters,
  only ever shrinks), with num_good_samples tracking its size.
- _get_updates_and_likelihood restricts the E-step to data[:, good_idx] and
  captures the pre-update per-sample LL (in good_idx order) when do_reject.
- _reject_outliers thresholds that per-sample LL (mean - rejsig*std, population
  std), shrinks good_idx, and raises a clear ValueError if all samples drop
  (mirrors the torch backend exactly).
- gm/LL normalize by num_good_samples under do_reject.
- Validate rejint/rejsig/maxrej at construction (matching AMICATorchNG).

do_reject=False is bit-identical: every addition is guarded by self.do_reject
and the LL split is np.sum of the same expression. Removes the #124
NotImplementedError gate. New test_numpy_reject.py exercises the real sample
EEG (shrinks good set, gm stays a valid distribution, degenerate LL raises,
param validation); drops the now-obsolete refusal test.
neuromechanist added a commit that referenced this pull request Jul 14, 2026
Reverse the #124 caveats in AGENTS.md and docs/api/numpy-backend.md now
that outlier rejection is implemented on the NumPy backend.
neuromechanist added a commit that referenced this pull request Jul 14, 2026
* Implement NumPy do_reject via good_idx (#123)

Port AMICATorchNG's outlier rejection to AMICA_NumPy, replacing the
non-functional data_mask stub. Previously do_reject was dead: _reject_outliers
thresholded the scalar total LL (not per-sample), self.data_mask was never
consumed by the E-step, and num_good_samples was used before assignment.

- good_idx: an index array of kept samples (init to all in _initialize_parameters,
  only ever shrinks), with num_good_samples tracking its size.
- _get_updates_and_likelihood restricts the E-step to data[:, good_idx] and
  captures the pre-update per-sample LL (in good_idx order) when do_reject.
- _reject_outliers thresholds that per-sample LL (mean - rejsig*std, population
  std), shrinks good_idx, and raises a clear ValueError if all samples drop
  (mirrors the torch backend exactly).
- gm/LL normalize by num_good_samples under do_reject.
- Validate rejint/rejsig/maxrej at construction (matching AMICATorchNG).

do_reject=False is bit-identical: every addition is guarded by self.do_reject
and the LL split is np.sum of the same expression. Removes the #124
NotImplementedError gate. New test_numpy_reject.py exercises the real sample
EEG (shrinks good set, gm stays a valid distribution, degenerate LL raises,
param validation); drops the now-obsolete refusal test.

* Docs: NumPy do_reject is now functional (#123)

Reverse the #124 caveats in AGENTS.md and docs/api/numpy-backend.md now
that outlier rejection is implemented on the NumPy backend.

* Address PR #126 review: reject schedule, restart, LL comments

Code review found the reject schedule missing Fortran/torch's max(1, ...)
clamp: Python's non-negative modulo let (iter-rejstart) % rejint hit 0 for
iter < rejstart, firing rejection before rejstart. Add the clamp (verified:
rejstart=3,rejint=2 now fires at [3,5,7], not [1,3,5]).

Silent-failure + comment review found my "num_good_samples drives the gm/LL
normalization" comments wrong: self.ll is a raw sum over the good set, never
normalized (this backend's convention for both modes). Correct the comments
to say only gm is normalized, and note that dropping the most-negative samples
raises the raw-sum LL, so the reject iteration is an increase and does not
spuriously trip the convergence checks (now covered by a test).

Also: expose self.numrej as an instance attribute (matching AMICATorchNG) so
the maxrej cap is testable; preserve good_idx/num_good_samples across a NaN
restart (Fortran startover keeps prior rejections); validate rejstart>=0; fix
the rejsig<=0 validation wording (at 0 it keeps ~half, not "every sample").

Tests: assert the numrej cap and c-finiteness under multi-model rejection
(dead-model dgm==0 guard), add an LL-sanity test across the reject event.
@neuromechanist neuromechanist mentioned this pull request Jul 14, 2026
3 tasks
neuromechanist added a commit that referenced this pull request Jul 14, 2026
Release 0.1.2: NumPy do_reject outlier rejection (#123) + reject
robustness (#127), repo-wide ty type-checking enforced in CI/pre-commit
(#124, #125), and the comprehensive validation-evidence docs (#108).
Backfill the missing 0.1.1 changelog entry and add 0.1.2.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fix ty type-check diagnostics repo-wide (blocks #119)

2 participants