ayna 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ayna/__init__.py ADDED
@@ -0,0 +1,28 @@
1
+ """ayna -- know whether your inferred dynamics are real, before you interpret them.
2
+
3
+ ayna checks the outputs of a fitted data-driven dynamics model (LFADS, a
4
+ sequential autoencoder, a neural ODE, ...) for the three failure modes defined
5
+ in the Computation-through-Dynamics Toolkit: underfitting, feature invention
6
+ and dynamical misattribution. It runs on numpy, never trains anything, and
7
+ says what it could not check.
8
+ """
9
+
10
+ __version__ = "0.1.0"
11
+
12
+ from .bundle import Bundle, BundleError, load_bundle # noqa: E402
13
+ from .report import Check, Finding, Report # noqa: E402
14
+ from .scan import Thresholds, scan # noqa: E402
15
+ from .select import select # noqa: E402
16
+
17
+ __all__ = [
18
+ "Bundle",
19
+ "BundleError",
20
+ "Check",
21
+ "Finding",
22
+ "Report",
23
+ "Thresholds",
24
+ "load_bundle",
25
+ "scan",
26
+ "select",
27
+ "__version__",
28
+ ]
@@ -0,0 +1 @@
1
+ """Exporters from training frameworks to ayna bundles. Imported lazily."""
ayna/adapters/ctd.py ADDED
@@ -0,0 +1,138 @@
1
+ """Export a Computation-through-Dynamics Toolkit run to an ayna bundle.
2
+
3
+ Run this inside the CtD environment (torch, Lightning, ...). ayna itself never
4
+ imports torch; this module only touches CtD objects through the getters the
5
+ toolkit's own ``Comparison.compute_metrics`` uses, so it duck-types and can
6
+ be tested without CtD installed.
7
+
8
+ Assumptions, all taken from CtD's own comparison code:
9
+
10
+ * ``get_model_outputs(phase)`` returns ``(rates, latents)``, rates in spikes/bin;
11
+ * ``get_spiking(phase)`` returns all neurons, held-in first, held-out last;
12
+ * the held-in count is the width of the spiking the model receives,
13
+ ``get_model_inputs(phase)[0]``;
14
+ * the task-trained analysis returns trials in the same order as the
15
+ data-trained one for each phase.
16
+
17
+ Status: written against CtD 1.0.2 source; not yet run on a live CtD install
18
+ (the pretrained models are Git-LFS objects). Please report mismatches.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from typing import Optional
24
+
25
+ import numpy as np
26
+
27
+ from ..bundle import Bundle
28
+
29
+ PHASES = ("train", "val")
30
+
31
+
32
+ def _np(x):
33
+ if x is None:
34
+ return None
35
+ if hasattr(x, "detach"):
36
+ x = x.detach().cpu().numpy()
37
+ return np.asarray(x)
38
+
39
+
40
+ def _get(obj, method, phase):
41
+ fn = getattr(obj, method, None)
42
+ if fn is None:
43
+ return None
44
+ try:
45
+ return _np(fn(phase=phase))
46
+ except (NotImplementedError, AttributeError, TypeError):
47
+ return None
48
+
49
+
50
+ def from_ctd(dd, tt=None, name: Optional[str] = None, inputs_kind: Optional[str] = None,
51
+ model_type: Optional[str] = None) -> Bundle:
52
+ """Build a :class:`~ayna.bundle.Bundle` from CtD analysis objects.
53
+
54
+ ``dd`` is an ``Analysis_DD`` (SAE or LFADS); ``tt`` an optional
55
+ ``Analysis_TT`` supplying ground-truth latents and inputs.
56
+ """
57
+ model_type = model_type or getattr(dd, "model_type", "")
58
+ parts = {k: [] for k in ("spikes", "rates", "latents", "inputs", "true_rates",
59
+ "true_latents", "true_inputs", "trial_lens")}
60
+ n_heldin = None
61
+ n_val = 0
62
+ if inputs_kind is None:
63
+ inputs_kind = "inferred" if model_type == "LFADS" and hasattr(dd, "get_inferred_inputs") else "supplied"
64
+
65
+ for phase in PHASES:
66
+ rates, latents = dd.get_model_outputs(phase=phase)
67
+ rates, latents = _np(rates), _np(latents)
68
+ spikes = _get(dd, "get_spiking", phase)
69
+ model_in = dd.get_model_inputs(phase=phase)
70
+ heldin_spikes, supplied = _np(model_in[0]), _np(model_in[1])
71
+ n_heldin = heldin_spikes.shape[-1]
72
+ if inputs_kind == "inferred":
73
+ inputs = _get(dd, "get_inferred_inputs", phase)
74
+ else:
75
+ inputs = supplied
76
+ parts["spikes"].append(spikes)
77
+ parts["rates"].append(rates)
78
+ parts["latents"].append(latents)
79
+ parts["inputs"].append(inputs)
80
+ parts["true_rates"].append(_get(dd, "get_true_rates", phase))
81
+ lens = _get(dd, "get_trial_lens", phase)
82
+ parts["trial_lens"].append(None if lens is None else lens.reshape(-1).astype(int))
83
+ if tt is not None:
84
+ parts["true_latents"].append(_get(tt, "get_latents", phase))
85
+ parts["true_inputs"].append(_get(tt, "get_inputs", phase))
86
+ if phase == "val":
87
+ n_val = rates.shape[0]
88
+
89
+ def cat(key):
90
+ items = parts[key]
91
+ if not items or any(i is None for i in items):
92
+ return None
93
+ return np.concatenate(items, axis=0)
94
+
95
+ spikes = cat("spikes")
96
+ N = spikes.shape[-1]
97
+ heldout = np.arange(N) >= n_heldin if n_heldin is not None and n_heldin < N else None
98
+ R = spikes.shape[0]
99
+ val = np.zeros(R, bool)
100
+ val[R - n_val:] = True
101
+ trial_lens = cat("trial_lens")
102
+ if trial_lens is not None and np.all(trial_lens >= spikes.shape[1]):
103
+ trial_lens = None
104
+ inputs = cat("inputs")
105
+ meta = {
106
+ "name": name or getattr(dd, "run_name", "ctd_run"),
107
+ "model": model_type or "unknown",
108
+ "source": "ctd",
109
+ "exporter": "ayna.adapters.ctd",
110
+ }
111
+ if inputs is not None:
112
+ meta["inputs_kind"] = inputs_kind
113
+ if tt is not None:
114
+ meta["tt_run"] = getattr(tt, "run_name", "")
115
+ return Bundle(spikes=spikes, rates=cat("rates"), latents=cat("latents"), heldout=heldout, val=val,
116
+ trial_lens=trial_lens, inputs=inputs, true_rates=cat("true_rates"),
117
+ true_latents=cat("true_latents"), true_inputs=cat("true_inputs"), meta=meta)
118
+
119
+
120
+ def export_run(dd_dir: str, out_path: str, model_type: str, tt_dir: Optional[str] = None,
121
+ inputs_kind: Optional[str] = None) -> str:
122
+ """Load a saved CtD run from disk (needs CtD importable) and write a bundle."""
123
+ try:
124
+ from ctd.comparison.analysis.dd.dd import Analysis_DD
125
+ from ctd.comparison.analysis.tt.tt import Analysis_TT
126
+ except ImportError as exc: # pragma: no cover - depends on the user's env
127
+ raise ImportError(
128
+ "export-ctd must run in an environment where the Computation-through-Dynamics "
129
+ "Toolkit is installed (`pip install -e .` in its repository)."
130
+ ) from exc
131
+ dd_dir = dd_dir.rstrip("/") + "/"
132
+ dd = Analysis_DD.create(run_name=dd_dir.rstrip("/").split("/")[-1], filepath=dd_dir, model_type=model_type)
133
+ tt = None
134
+ if tt_dir:
135
+ tt_dir = tt_dir.rstrip("/") + "/"
136
+ tt = Analysis_TT(run_name=tt_dir.rstrip("/").split("/")[-1], filepath=tt_dir)
137
+ bundle = from_ctd(dd, tt, inputs_kind=inputs_kind, model_type=model_type)
138
+ return str(bundle.save(out_path))
ayna/bundle.py ADDED
@@ -0,0 +1,249 @@
1
+ """The ayna bundle: everything a fitted dynamics model produced, as plain arrays.
2
+
3
+ A bundle is one ``.npz`` file. ayna never imports the framework that trained
4
+ the model; whoever trained it exports these arrays once, and every check runs
5
+ on numpy from there.
6
+
7
+ Shapes use R trials, T time bins, N neurons, D inferred latent dimensions and
8
+ U input dimensions. Rates are expected counts **per bin**, the same unit as
9
+ the spikes.
10
+
11
+ Required
12
+ spikes (R, T, N) observed spike counts
13
+ rates (R, T, N) model-predicted rates (held-in and held-out neurons)
14
+ latents (R, T, D) model-inferred latent states
15
+
16
+ Optional
17
+ heldout (N,) bool neurons the model never saw as input (enables co-BPS)
18
+ val (R,) bool validation trials (default: the last 20%)
19
+ trial_lens (R,) int valid bins per trial, for padded data
20
+ inputs (R, T, U) external inputs; ``meta["inputs_kind"]`` says
21
+ whether they were ``supplied`` to the model or
22
+ ``inferred`` by it
23
+ true_latents (R, T, Dt) ground truth (synthetic data only) -- tier 2
24
+ true_rates (R, T, N)
25
+ true_inputs (R, T, Ut)
26
+ meta JSON string: name, model, bin_ms, inputs_kind, labels, ...
27
+
28
+ A ``dynamics`` callable ``f(z, u) -> z_next`` (arrays ``(M, D)``, ``(M, U)``)
29
+ can be attached in Python; it cannot be stored in an ``.npz`` and unlocks the
30
+ tier-1 probes.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import json
36
+ from dataclasses import dataclass, field
37
+ from pathlib import Path
38
+ from typing import Callable, Optional
39
+
40
+ import numpy as np
41
+
42
+ ARRAY_KEYS = (
43
+ "spikes",
44
+ "rates",
45
+ "latents",
46
+ "heldout",
47
+ "val",
48
+ "trial_lens",
49
+ "inputs",
50
+ "true_latents",
51
+ "true_rates",
52
+ "true_inputs",
53
+ )
54
+
55
+ RATE_FLOOR = 1e-9
56
+
57
+
58
+ class BundleError(ValueError):
59
+ """The bundle cannot be checked as given."""
60
+
61
+
62
+ @dataclass
63
+ class Bundle:
64
+ spikes: np.ndarray
65
+ rates: np.ndarray
66
+ latents: np.ndarray
67
+ heldout: Optional[np.ndarray] = None
68
+ val: Optional[np.ndarray] = None
69
+ trial_lens: Optional[np.ndarray] = None
70
+ inputs: Optional[np.ndarray] = None
71
+ true_latents: Optional[np.ndarray] = None
72
+ true_rates: Optional[np.ndarray] = None
73
+ true_inputs: Optional[np.ndarray] = None
74
+ meta: dict = field(default_factory=dict)
75
+ dynamics: Optional[Callable[[np.ndarray, np.ndarray], np.ndarray]] = None
76
+ source: str = ""
77
+ notes: list = field(default_factory=list)
78
+
79
+ def __post_init__(self):
80
+ for key in ARRAY_KEYS:
81
+ value = getattr(self, key)
82
+ if value is not None:
83
+ setattr(self, key, np.asarray(value))
84
+ self._validate()
85
+
86
+ # ------------------------------------------------------------------ shape
87
+ @property
88
+ def n_trials(self) -> int:
89
+ return self.spikes.shape[0]
90
+
91
+ @property
92
+ def n_bins(self) -> int:
93
+ return self.spikes.shape[1]
94
+
95
+ @property
96
+ def n_neurons(self) -> int:
97
+ return self.spikes.shape[2]
98
+
99
+ @property
100
+ def n_latents(self) -> int:
101
+ return self.latents.shape[2]
102
+
103
+ @property
104
+ def inputs_kind(self) -> Optional[str]:
105
+ if self.inputs is None:
106
+ return None
107
+ return self.meta.get("inputs_kind", "inferred")
108
+
109
+ @property
110
+ def has_ground_truth(self) -> bool:
111
+ return self.true_latents is not None or self.true_rates is not None
112
+
113
+ @property
114
+ def name(self) -> str:
115
+ return self.meta.get("name") or Path(self.source).stem or "bundle"
116
+
117
+ # ------------------------------------------------------------- validation
118
+ def _validate(self):
119
+ for key in ("spikes", "rates", "latents"):
120
+ arr = getattr(self, key)
121
+ if arr is None:
122
+ raise BundleError(f"'{key}' is required")
123
+ if arr.ndim != 3:
124
+ raise BundleError(
125
+ f"'{key}' must be (trials, bins, features); got shape {arr.shape}"
126
+ )
127
+ R, T, N = self.spikes.shape
128
+ if self.rates.shape != (R, T, N):
129
+ raise BundleError(
130
+ f"'rates' must match 'spikes' {self.spikes.shape}; got {self.rates.shape}"
131
+ )
132
+ for key in ("latents", "inputs", "true_latents", "true_inputs"):
133
+ arr = getattr(self, key)
134
+ if arr is None:
135
+ continue
136
+ if arr.ndim != 3 or arr.shape[:2] != (R, T):
137
+ raise BundleError(
138
+ f"'{key}' must be ({R}, {T}, features); got {arr.shape}"
139
+ )
140
+ if self.true_rates is not None and self.true_rates.shape != (R, T, N):
141
+ raise BundleError(f"'true_rates' must be {(R, T, N)}; got {self.true_rates.shape}")
142
+
143
+ if np.any(self.spikes < 0):
144
+ raise BundleError("'spikes' contains negative counts")
145
+ if not np.all(np.isfinite(self.rates)):
146
+ raise BundleError("'rates' contains NaN or inf")
147
+ if np.any(self.rates <= 0):
148
+ n_bad = int(np.sum(self.rates <= 0))
149
+ self.rates = np.maximum(self.rates, RATE_FLOOR)
150
+ self.notes.append(
151
+ f"{n_bad} non-positive predicted rates were floored at {RATE_FLOOR:g}"
152
+ )
153
+ if not np.all(np.isfinite(self.latents)):
154
+ raise BundleError("'latents' contains NaN or inf")
155
+
156
+ if self.heldout is not None:
157
+ self.heldout = self.heldout.astype(bool).reshape(-1)
158
+ if self.heldout.shape != (N,):
159
+ raise BundleError(f"'heldout' must have {N} entries")
160
+ if self.heldout.all():
161
+ raise BundleError("every neuron is marked held-out")
162
+ if self.val is None:
163
+ n_val = max(1, int(round(0.2 * R)))
164
+ self.val = np.zeros(R, bool)
165
+ self.val[R - n_val:] = True
166
+ self.notes.append(f"no 'val' split given; used the last {n_val} of {R} trials")
167
+ else:
168
+ self.val = self.val.astype(bool).reshape(-1)
169
+ if self.val.shape != (R,):
170
+ raise BundleError(f"'val' must have {R} entries")
171
+ if self.val.all() or not self.val.any():
172
+ raise BundleError("'val' must mark some, but not all, trials")
173
+ if self.trial_lens is not None:
174
+ self.trial_lens = self.trial_lens.astype(int).reshape(-1)
175
+ if self.trial_lens.shape != (R,) or self.trial_lens.max() > T or self.trial_lens.min() < 1:
176
+ raise BundleError("'trial_lens' must be R integers in [1, T]")
177
+ kind = self.meta.get("inputs_kind")
178
+ if kind is not None and kind not in ("supplied", "inferred"):
179
+ raise BundleError("meta['inputs_kind'] must be 'supplied' or 'inferred'")
180
+
181
+ # ------------------------------------------------------------- selection
182
+ def trials(self, split: str) -> np.ndarray:
183
+ if split == "train":
184
+ return ~self.val
185
+ if split == "val":
186
+ return self.val.copy()
187
+ if split == "all":
188
+ return np.ones(self.n_trials, bool)
189
+ raise ValueError(split)
190
+
191
+ def bin_mask(self, split: str = "all") -> np.ndarray:
192
+ """(R, T) boolean mask of valid bins in the chosen trials."""
193
+ mask = np.zeros((self.n_trials, self.n_bins), bool)
194
+ mask[self.trials(split)] = True
195
+ if self.trial_lens is not None:
196
+ mask &= np.arange(self.n_bins)[None, :] < self.trial_lens[:, None]
197
+ return mask
198
+
199
+ def flat(self, key_or_array, split: str = "all", neurons=None) -> np.ndarray:
200
+ """Flatten (R, T, F) to (samples, F) over valid bins of a split."""
201
+ arr = getattr(self, key_or_array) if isinstance(key_or_array, str) else key_or_array
202
+ if arr is None:
203
+ return None
204
+ arr = arr[self.bin_mask(split)]
205
+ if neurons is not None:
206
+ arr = arr[:, neurons]
207
+ return arr
208
+
209
+ @property
210
+ def heldin(self) -> Optional[np.ndarray]:
211
+ return None if self.heldout is None else ~self.heldout
212
+
213
+ # ------------------------------------------------------------------- I/O
214
+ def save(self, path) -> Path:
215
+ path = Path(path)
216
+ payload = {k: getattr(self, k) for k in ARRAY_KEYS if getattr(self, k) is not None}
217
+ payload["meta"] = np.array(json.dumps(self.meta, sort_keys=True))
218
+ path.parent.mkdir(parents=True, exist_ok=True)
219
+ np.savez_compressed(path, **payload)
220
+ return path if path.suffix == ".npz" else path.with_suffix(path.suffix + ".npz")
221
+
222
+ def summary(self) -> str:
223
+ parts = [
224
+ f"{self.n_trials} trials x {self.n_bins} bins",
225
+ f"{self.n_neurons} neurons"
226
+ + (f" ({int(self.heldout.sum())} held-out)" if self.heldout is not None else ""),
227
+ f"{self.n_latents} latents",
228
+ ]
229
+ if self.inputs is not None:
230
+ parts.append(f"{self.inputs.shape[2]} {self.inputs_kind} inputs")
231
+ if self.has_ground_truth:
232
+ parts.append("ground truth")
233
+ if self.dynamics is not None:
234
+ parts.append("dynamics callable")
235
+ return ", ".join(parts)
236
+
237
+
238
+ def load_bundle(path, dynamics=None) -> Bundle:
239
+ """Read a bundle written by :meth:`Bundle.save` or by any exporter."""
240
+ path = Path(path)
241
+ if not path.exists():
242
+ raise BundleError(f"no such file: {path}")
243
+ with np.load(path, allow_pickle=False) as data:
244
+ arrays = {k: data[k] for k in ARRAY_KEYS if k in data.files}
245
+ meta = json.loads(str(data["meta"])) if "meta" in data.files else {}
246
+ missing = [k for k in ("spikes", "rates", "latents") if k not in arrays]
247
+ if missing:
248
+ raise BundleError(f"{path.name} lacks required array(s): {', '.join(missing)}")
249
+ return Bundle(**arrays, meta=meta, dynamics=dynamics, source=str(path))
ayna/cli.py ADDED
@@ -0,0 +1,251 @@
1
+ """ayna command line.
2
+
3
+ ayna synth NAME [--faults underfit=0.5 feature_invention ...] [--sweep]
4
+ ayna scan BUNDLE [--out DIR] [--json] [--set name=value ...] [--daftar]
5
+ ayna select BUNDLE... | DIR [--out DIR] [--plot]
6
+ ayna validate [DIR] [--n 6] [--out DIR]
7
+ ayna info BUNDLE
8
+ ayna export-ctd DD_RUN_DIR OUT.npz --model-type SAE|LFADS [--tt TT_RUN_DIR]
9
+ ayna doctor
10
+
11
+ ``scan`` exits 1 when an error-level finding is raised from the model's
12
+ outputs, so it can gate a pipeline or CI job.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ from . import __version__
23
+
24
+
25
+ def _bundles_from(paths):
26
+ from .bundle import load_bundle
27
+
28
+ files = []
29
+ for p in paths:
30
+ p = Path(p)
31
+ files += sorted(p.glob("*.npz")) if p.is_dir() else [p]
32
+ if not files:
33
+ raise SystemExit("no .npz bundles found")
34
+ return [load_bundle(f) for f in files]
35
+
36
+
37
+ def _overrides(items):
38
+ out = {}
39
+ for item in items or []:
40
+ key, sep, value = item.partition("=")
41
+ if not sep:
42
+ raise SystemExit(f"--set expects name=value, got '{item}'")
43
+ out[key.strip()] = value
44
+ return out
45
+
46
+
47
+ def cmd_synth(args):
48
+ from .synth import synth, synth_sweep
49
+
50
+ out = Path(args.out)
51
+ if args.sweep:
52
+ target = out / args.name
53
+ bundles = synth_sweep(n_trials=args.trials, n_neurons=args.neurons, truth_seed=args.seed)
54
+ for b in bundles:
55
+ b.save(target / f"{b.name}.npz")
56
+ print(f"wrote {len(bundles)} bundles (latent sizes 2-64, simulated) to {target}/")
57
+ return 0
58
+ b = synth(args.name, args.faults, latent_dim=args.latent_dim,
59
+ inputs_kind=None if args.inputs == "auto" else args.inputs,
60
+ n_trials=args.trials, n_neurons=args.neurons, seed=args.seed)
61
+ path = b.save(out / f"{args.name}.npz")
62
+ faults = b.meta["synth"]["faults"] or "none"
63
+ print(f"wrote {path} ({b.summary()}; injected faults: {faults})")
64
+ print("note: a .npz cannot hold the dynamics callable, so tier-1 probes need the Python API")
65
+ return 0
66
+
67
+
68
+ def cmd_scan(args):
69
+ from .bundle import load_bundle
70
+ from .scan import Thresholds, scan
71
+ from . import provenance
72
+
73
+ b = load_bundle(args.bundle)
74
+ th = Thresholds().update(_overrides(args.set))
75
+ if args.daftar and not provenance.available():
76
+ print("daftar is not installed; run `pip install \"ayna[provenance]\"`", file=sys.stderr)
77
+ with provenance.tracked("ayna scan", params={"bundle": str(args.bundle), **th.__dict__},
78
+ inputs=[args.bundle] if args.daftar else []) if args.daftar else _null() as run:
79
+ report = scan(b, th, nonlinear_fallback=not args.no_nonlinear)
80
+ if args.out:
81
+ report.save(args.out)
82
+ if run is not None:
83
+ provenance.log_report(run, report, args.out)
84
+ if args.json:
85
+ from .report import _jsonable
86
+
87
+ print(json.dumps(report.to_dict(), indent=2, default=_jsonable))
88
+ else:
89
+ print(report.render_text(), end="")
90
+ if args.out:
91
+ print(f"\nreport written to {args.out}/report.{{txt,md,json}}")
92
+ return 1 if report.has_errors else 0
93
+
94
+
95
+ class _null:
96
+ def __enter__(self):
97
+ return None
98
+
99
+ def __exit__(self, *exc):
100
+ return False
101
+
102
+
103
+ def cmd_select(args):
104
+ from .select import select
105
+
106
+ rep = select(_bundles_from(args.bundles))
107
+ print(rep.render_text(), end="")
108
+ if args.out:
109
+ rep.save(args.out)
110
+ if args.plot:
111
+ try:
112
+ rep.plot(Path(args.out) / "select.png")
113
+ except ImportError:
114
+ print("plot needs matplotlib: pip install \"ayna[plot]\"", file=sys.stderr)
115
+ print(f"\nwritten to {args.out}/")
116
+ return 0
117
+
118
+
119
+ def cmd_validate(args):
120
+ from .scan import Thresholds
121
+ from .validate import validate_bundles, validate_synthetic
122
+
123
+ th = Thresholds().update(_overrides(args.set))
124
+
125
+ def progress(i, n, name):
126
+ print(f"\r [{i}/{n}] {name:<40}", end="", file=sys.stderr, flush=True)
127
+
128
+ if args.bundles:
129
+ val = validate_bundles(_bundles_from(args.bundles), th, progress)
130
+ if not val.cases:
131
+ raise SystemExit("none of these bundles carries ground truth; nothing to validate against")
132
+ else:
133
+ val = validate_synthetic(args.n, args.seed, th, progress)
134
+ print("\r" + " " * 60 + "\r", end="", file=sys.stderr)
135
+ print(val.render_text(), end="")
136
+ if not args.bundles:
137
+ print("\ncaveat: these faults were injected by ayna's own simulator, so this measures whether the "
138
+ "detectors work as designed -- not whether they transfer to trained models or recordings.")
139
+ if args.out:
140
+ val.save(args.out)
141
+ return 0
142
+
143
+
144
+ def cmd_info(args):
145
+ from .bundle import load_bundle
146
+
147
+ b = load_bundle(args.bundle)
148
+ print(f"{args.bundle}: {b.summary()}")
149
+ for k, v in sorted(b.meta.items()):
150
+ print(f" {k}: {v}")
151
+ for n in b.notes:
152
+ print(f" note: {n}")
153
+ return 0
154
+
155
+
156
+ def cmd_export_ctd(args):
157
+ from .adapters.ctd import export_run
158
+
159
+ path = export_run(args.dd_dir, args.out, args.model_type, args.tt, args.inputs)
160
+ print(f"wrote {path}")
161
+ return 0
162
+
163
+
164
+ def cmd_doctor(args):
165
+ import platform
166
+
167
+ print(f"ayna {__version__} on Python {platform.python_version()}")
168
+ for mod, why in (("numpy", "core"), ("scipy", "core"), ("sklearn", "core"),
169
+ ("matplotlib", "extra [plot]: select --plot"),
170
+ ("daftar", "extra [provenance]: scan --daftar"),
171
+ ("ctd", "export-ctd (install the CtD repository itself)"),
172
+ ("torch", "needed by CtD, never by ayna")):
173
+ try:
174
+ m = __import__(mod)
175
+ print(f" ok {mod:<11} {getattr(m, '__version__', '')} ({why})")
176
+ except ImportError:
177
+ print(f" missing {mod:<11} ({why})")
178
+ return 0
179
+
180
+
181
+ def build_parser():
182
+ p = argparse.ArgumentParser(prog="ayna", description="Know whether your inferred dynamics are real, before you interpret them.")
183
+ p.add_argument("--version", action="version", version=f"ayna {__version__}")
184
+ sub = p.add_subparsers(dest="cmd", required=True)
185
+
186
+ s = sub.add_parser("synth", help="write a synthetic bundle with injected faults")
187
+ s.add_argument("name")
188
+ s.add_argument("--faults", nargs="*", default=[], metavar="FAULT[=SEVERITY]",
189
+ help="underfit, feature_invention, misattribution, input_driven, nonlinear_readout")
190
+ s.add_argument("--sweep", action="store_true", help="write a simulated latent-size sweep instead")
191
+ s.add_argument("--latent-dim", type=int, default=8)
192
+ s.add_argument("--inputs", choices=["auto", "inferred", "supplied", "none"], default="auto")
193
+ s.add_argument("--trials", type=int, default=200)
194
+ s.add_argument("--neurons", type=int, default=60)
195
+ s.add_argument("--seed", type=int, default=0)
196
+ s.add_argument("--out", default="datasets")
197
+ s.set_defaults(fn=cmd_synth)
198
+
199
+ s = sub.add_parser("scan", help="diagnose one fitted model")
200
+ s.add_argument("bundle")
201
+ s.add_argument("--out", help="write report.{txt,md,json} here")
202
+ s.add_argument("--json", action="store_true")
203
+ s.add_argument("--set", nargs="*", metavar="NAME=VALUE", help="override thresholds")
204
+ s.add_argument("--no-nonlinear", action="store_true", help="skip the nonlinear cycle-consistency fallback")
205
+ s.add_argument("--daftar", action="store_true", help="record the scan with daftar")
206
+ s.set_defaults(fn=cmd_scan)
207
+
208
+ s = sub.add_parser("select", help="compare a sweep of models fitted to the same data")
209
+ s.add_argument("bundles", nargs="+")
210
+ s.add_argument("--out")
211
+ s.add_argument("--plot", action="store_true")
212
+ s.set_defaults(fn=cmd_select)
213
+
214
+ s = sub.add_parser("validate", help="check detectors against ground truth")
215
+ s.add_argument("bundles", nargs="*", help="bundles or directories with ground truth (default: synthetic corpus)")
216
+ s.add_argument("--n", type=int, default=6, help="synthetic cases per fault type")
217
+ s.add_argument("--seed", type=int, default=0)
218
+ s.add_argument("--set", nargs="*", metavar="NAME=VALUE")
219
+ s.add_argument("--out")
220
+ s.set_defaults(fn=cmd_validate)
221
+
222
+ s = sub.add_parser("info", help="describe a bundle")
223
+ s.add_argument("bundle")
224
+ s.set_defaults(fn=cmd_info)
225
+
226
+ s = sub.add_parser("export-ctd", help="export a CtD run to a bundle (run inside the CtD environment)")
227
+ s.add_argument("dd_dir")
228
+ s.add_argument("out")
229
+ s.add_argument("--model-type", required=True, choices=["SAE", "LFADS"])
230
+ s.add_argument("--tt", help="task-trained run directory, for ground truth")
231
+ s.add_argument("--inputs", choices=["supplied", "inferred"])
232
+ s.set_defaults(fn=cmd_export_ctd)
233
+
234
+ s = sub.add_parser("doctor", help="report versions and optional extras")
235
+ s.set_defaults(fn=cmd_doctor)
236
+ return p
237
+
238
+
239
+ def main(argv=None):
240
+ args = build_parser().parse_args(argv)
241
+ from .bundle import BundleError
242
+
243
+ try:
244
+ return args.fn(args)
245
+ except BundleError as exc:
246
+ print(f"ayna: {exc}", file=sys.stderr)
247
+ return 2
248
+
249
+
250
+ if __name__ == "__main__": # pragma: no cover
251
+ sys.exit(main())