diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..2a80d39a --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,13 @@ +# Pull requests that touch these files need a maintainer's approval (branch +# protection: "Require review from Code Owners"). They hold recorded results, +# the tests that pin formulas to their papers, and the CI that runs them; a +# pull request must not edit them to make itself pass. +/test/regression/ @Hananel-Hazan +/test/api_surface.json @Hananel-Hazan +/test/regen_api_surface.py @Hananel-Hazan +/test/test_api_surface.py @Hananel-Hazan +/test/network/test_all_classes.py @Hananel-Hazan +/test/network/test_learning_rule_specs.py @Hananel-Hazan +/test/network/test_mstdp_florian.py @Hananel-Hazan +/test/network/test_perf_equivalence.py @Hananel-Hazan +/.github/ @Hananel-Hazan diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..ff8de0b6 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,30 @@ +## What this changes and why + + + +## Checklist + +- [ ] I ran `poetry run pytest` locally and it passed. +- [ ] If I fixed a bug, this test fails on `master` and passes with my change + (checked by the `bug-test` check): `test/...::test_...` +- [ ] I did not remove tests, remove checks, add skips or xfails, or widen + tolerances. If I did, I explain each one below (the `weakened-tests` + check lists them). +- [ ] If I changed a formula (a neuron update, a learning rule, a connection's + compute), this test pins the new formula: `test/...::test_...` +- [ ] Does this change simulated results (spikes, voltages, traces, weights)? + - [ ] No. + - [ ] Yes. I regenerated the golden files in `test/regression/` with + `--reason`, and explain below why the results changed. (Results-changing + fixes are released in 0.4.0, not 0.3.x; see `CONTRIBUTING.md`.) +- [ ] Did any of these files change? If yes, say which and why: + - golden files under `test/regression/` + - operation-count file under `test/regression/` (if present) + - `test/api_surface.json` (public API) + - `KNOWN_BUGS` in `test/network/test_all_classes.py` +- [ ] I added an entry to the `Unreleased` section of `CHANGELOG.md`. +- [ ] If I used an AI tool to write or find this change, I name it here: + +## Why the results or the API changed (if they did) + + diff --git a/.github/scripts/bench_ab.py b/.github/scripts/bench_ab.py new file mode 100644 index 00000000..4a825391 --- /dev/null +++ b/.github/scripts/bench_ab.py @@ -0,0 +1,393 @@ +#!/usr/bin/env python3 +"""Alternating timing comparison of a pull request's base and head (informational). + + python .github/scripts/bench_ab.py --base --head --out DIR + python .github/scripts/bench_ab.py --base HEAD --head-tree . --out DIR # local + +``--base`` and ``--head`` are checked out with ``git worktree add`` into +DIR/base and DIR/head (removed again at the end); ``--head-tree`` uses an +existing folder instead (for example the working tree, to test uncommitted +changes). Nothing is installed: both trees use the current Python environment. +Each measurement is a fresh process with the tree first on PYTHONPATH, and the +process checks that ``bindsnet.__file__`` lies inside that tree. + +The workloads are those of examples/benchmark/hot_path_bench.py, same sizes, +copied here so that editing the example cannot change what is measured. Each +process fixes torch to --threads threads (default 2; also OMP_NUM_THREADS and +MKL_NUM_THREADS, and pins itself to the first --threads CPUs it may use), +builds every workload, does one warm-up run, then repeats run-and-reset until +at least --min-seconds have passed and records the mean time per run. +Processes alternate base, head, head, base, ... for --rounds rounds, so slow +drift of the machine affects both sides alike. + +Report per workload: median seconds per run on each side, the median of the +per-round ratios head/base, its 95% bootstrap interval (2000 resamples of the +round ratios, fixed seed 0), and the lowest and highest round ratio. Flag +SLOWER when the median ratio is above --threshold (default 1.25) and the +interval's lower end is above 1; "possible slowdown" when the median is above +the threshold but the interval reaches 1; "faster" and "possible speed-up" the +same way below 1/threshold; otherwise "no clear change". + +The report (Markdown) is written to DIR/bench_ab.md, appended to --summary if +given (GitHub's $GITHUB_STEP_SUMMARY), and the raw numbers to DIR/bench_ab.json. +The exit status is always 0: this is a non-required check and a human decides; +the verdict, or a TOOL ERROR line, is in the report. +""" + +from __future__ import annotations + +import argparse +import json +import os +import platform +import random +import statistics +import subprocess +import sys +import time +import traceback +from pathlib import Path + +WORKLOADS = [ + "dc2015_b1", + "dc2015_b16", + "conn_postpre_b1", + "conn_postpre_b16", + "local2d_postpre", + "conn_mstdp", +] + + +def build(name, T): + """(network, inputs, run kwargs) for one workload; mirrors hot_path_bench.py.""" + import torch + + from bindsnet.encoding import poisson + from bindsnet.learning import MSTDP, PostPre + from bindsnet.models import DiehlAndCook2015 + from bindsnet.network import Network + from bindsnet.network.nodes import Input, LIFNodes + from bindsnet.network.topology import Connection, LocalConnection2D + + torch.manual_seed(0) + if name.startswith("dc2015"): + b = int(name.split("_b")[1]) + net = DiehlAndCook2015( + n_inpt=784, n_neurons=400, batch_size=b, inpt_shape=(1, 28, 28) + ) + return net, {"X": poisson(torch.rand(b, 1, 28, 28) * 128, time=T)}, {} + if name.startswith("conn_postpre"): + b = int(name.split("_b")[1]) + net = Network(dt=1.0, batch_size=b) + net.add_layer(Input(n=784, traces=True), "in") + net.add_layer(LIFNodes(n=1000, traces=True), "out") + conn = Connection( + net.layers["in"], + net.layers["out"], + nu=(1e-4, 1e-2), + update_rule=PostPre, + wmin=0, + wmax=1, + norm=78.4, + ) + net.add_connection(conn, "in", "out") + return net, {"in": poisson(torch.rand(b, 784) * 128, time=T)}, {} + if name == "local2d_postpre": + net = Network(dt=1.0) + net.add_layer(Input(shape=[1, 28, 28], traces=True), "in") + net.add_layer(LIFNodes(shape=[16, 6, 6], traces=True), "out") + conn = LocalConnection2D( + net.layers["in"], + net.layers["out"], + kernel_size=8, + stride=4, + n_filters=16, + nu=(1e-4, 1e-2), + update_rule=PostPre, + wmin=0, + wmax=1, + ) + net.add_connection(conn, "in", "out") + return net, {"in": poisson(torch.rand(1, 1, 28, 28) * 128, time=T)}, {} + if name == "conn_mstdp": + net = Network(dt=1.0) + net.add_layer(Input(n=784), "in") + net.add_layer(LIFNodes(n=500), "out") + conn = Connection( + net.layers["in"], + net.layers["out"], + nu=1e-3, + update_rule=MSTDP, + wmin=0, + wmax=1, + ) + net.add_connection(conn, "in", "out") + return net, {"in": poisson(torch.rand(1, 784) * 128, time=T)}, {"reward": 1.0} + raise ValueError(f"unknown workload {name}") + + +def worker(args) -> int: + """One measurement process: time every workload once, print JSON.""" + tree = args.tree.resolve() + import torch + + import bindsnet + + where = Path(bindsnet.__file__).resolve() + if tree not in where.parents: + print(f"TOOL ERROR: bindsnet imported from {where}, not from {tree}") + return 2 + torch.set_num_threads(args.threads) + try: # the same CPUs for every process, base and head alike + os.sched_setaffinity(0, sorted(os.sched_getaffinity(0))[: args.threads]) + except (AttributeError, OSError): + pass + result = {"bindsnet_file": str(where)} + for name in args.only.split(","): + net, inputs, kw = build(name, args.time) + net.run(inputs=inputs, time=args.time, **kw) # warm-up + net.reset_state_variables() + runs, t = 0, time.perf_counter() + while runs == 0 or time.perf_counter() - t < args.min_seconds: + net.run(inputs=inputs, time=args.time, **kw) + net.reset_state_variables() + runs += 1 + result[name] = {"wall": (time.perf_counter() - t) / runs, "runs": runs} + print(json.dumps(result)) + return 0 + + +def median_interval(ratios, n=2000, seed=0): + """95% bootstrap interval of the median of the round ratios (fixed seed).""" + rng = random.Random(seed) + meds = sorted( + statistics.median(rng.choices(ratios, k=len(ratios))) for _ in range(n) + ) + return meds[int(0.025 * n)], meds[int(0.975 * n) - 1] + + +def flag(median, lo, hi, threshold): + if median > threshold: + return "SLOWER" if lo > 1.0 else "possible slowdown" + if median < 1 / threshold: + return "faster" if hi < 1.0 else "possible speed-up" + return "no clear change" + + +def machine(): + info = {"python": platform.python_version(), "platform": platform.platform()} + try: + for line in Path("/proc/cpuinfo").read_text().splitlines(): + if line.startswith("model name"): + info["cpu"] = line.split(":", 1)[1].strip() + break + except OSError: + pass + info["cpu_count"] = os.cpu_count() + code = "import torch; print(torch.__version__)" + out = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True) + info["torch"] = out.stdout.strip() or "unknown" + return info + + +def git(*args, cwd=None): + return subprocess.run( + ["git", *args], cwd=cwd, check=True, capture_output=True, text=True + ).stdout.strip() + + +def measure(trees, args, out, log): + """Alternate base/head processes; return {side: [per-round result dicts]}.""" + times = {side: [] for side in trees} + env = dict( + os.environ, + PYTHONDONTWRITEBYTECODE="1", + OMP_NUM_THREADS=str(args.threads), + MKL_NUM_THREADS=str(args.threads), + ) + for i in range(args.rounds): + for side in ("base", "head") if i % 2 == 0 else ("head", "base"): + cmd = [ + sys.executable, + "-s", + str(Path(__file__).resolve()), + "--worker", + "--tree", + str(trees[side]), + "--time", + str(args.time), + "--threads", + str(args.threads), + "--only", + args.only, + "--min-seconds", + str(args.min_seconds), + ] + proc = subprocess.run( + cmd, + env=dict(env, PYTHONPATH=str(trees[side])), + cwd=out, + capture_output=True, + text=True, + timeout=args.timeout, + ) + lines = [x for x in proc.stdout.splitlines() if x.startswith("{")] + if proc.returncode != 0 or not lines: + raise RuntimeError( + f"{side} worker exited {proc.returncode}: " + f"{(proc.stdout + proc.stderr)[-1500:]}" + ) + times[side].append(json.loads(lines[-1])) + log(f"round {i + 1}/{args.rounds} {side} done") + return times + + +def summarise(times, args): + rows = [] + for name in args.only.split(","): + b = [r[name]["wall"] for r in times["base"]] + h = [r[name]["wall"] for r in times["head"]] + ratios = [hh / bb for hh, bb in zip(h, b)] + lo, hi = median_interval(ratios) + med = statistics.median(ratios) + rows.append( + { + "workload": name, + "base_median_s": statistics.median(b), + "head_median_s": statistics.median(h), + "ratio_median": med, + "ratio_ci95": [lo, hi], + "ratio_min": min(ratios), + "ratio_max": max(ratios), + "ratios": ratios, + "runs_per_sample": min( + r[name]["runs"] for r in times["base"] + times["head"] + ), + "flag": flag(med, lo, hi, args.threshold), + } + ) + return rows + + +def markdown(rows, args, info, refs, error=None): + lines = ["## Speed comparison, base versus head (informational)", ""] + lines.append( + f"Base `{refs['base']}`, head `{refs['head']}`. CPU, {args.threads} threads, " + f"{args.time} time steps per run, {args.rounds} alternating rounds, at least " + f"{args.min_seconds} s per measurement, SLOWER threshold {args.threshold}." + ) + lines.append( + f"Machine: {info.get('cpu', 'unknown CPU')}, {info.get('cpu_count')} CPUs, " + f"Python {info['python']}, torch {info['torch']}." + ) + lines.append("") + if error: + lines += ["**TOOL ERROR** (no timing result):", "", "```", error, "```"] + return "\n".join(lines) + "\n" + lines.append( + "| workload | base s/run | head s/run | head/base median | 95% interval " + "| lowest..highest round | verdict |" + ) + lines.append("|---|---|---|---|---|---|---|") + for r in rows: + lo, hi = r["ratio_ci95"] + lines.append( + f"| {r['workload']} | {r['base_median_s']:.4f} | {r['head_median_s']:.4f} " + f"| {r['ratio_median']:.3f} | {lo:.3f}..{hi:.3f} " + f"| {r['ratio_min']:.3f}..{r['ratio_max']:.3f} | {r['flag']} |" + ) + slower = [r["workload"] for r in rows if r["flag"] == "SLOWER"] + lines.append("") + lines.append( + f"**Verdict: SLOWER in {', '.join(slower)}.** A ratio of 1.30 means the head " + "takes 30% longer. Shared CI machines are noisy; a maintainer decides." + if slower + else "**Verdict: no workload flagged SLOWER.**" + ) + return "\n".join(lines) + "\n" + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--base", help="git ref of the base (checked out as a worktree)") + ap.add_argument("--head", help="git ref of the head (checked out as a worktree)") + ap.add_argument("--head-tree", type=Path, help="existing folder used as the head") + ap.add_argument("--out", type=Path, help="output folder") + ap.add_argument("--summary", type=Path, help="also append the report here") + ap.add_argument("--rounds", type=int, default=10) + ap.add_argument("--time", type=int, default=100, help="time steps per run") + ap.add_argument("--threads", type=int, default=2) + ap.add_argument("--threshold", type=float, default=1.25) + ap.add_argument("--min-seconds", type=float, default=1.0) + ap.add_argument("--only", default=",".join(WORKLOADS)) + ap.add_argument("--timeout", type=int, default=900, help="seconds per process") + ap.add_argument("--worker", action="store_true", help=argparse.SUPPRESS) + ap.add_argument("--tree", type=Path, help=argparse.SUPPRESS) + args = ap.parse_args() + if args.worker: + return worker(args) + if not args.base or not args.out or bool(args.head) == bool(args.head_tree): + ap.error("need --base, --out, and exactly one of --head or --head-tree") + + out = args.out.resolve() + out.mkdir(parents=True, exist_ok=True) + repo = Path(git("rev-parse", "--show-toplevel")) + refs = {"base": git("rev-parse", "--short", args.base, cwd=repo)} + refs["head"] = ( + git("rev-parse", "--short", args.head, cwd=repo) + if args.head + else f"{args.head_tree.resolve()} (working tree)" + ) + worktrees, rows, times, error = [], [], {}, None + + def log(msg): + print(msg, flush=True) + + try: + trees = {} + for side, ref in (("base", args.base), ("head", args.head)): + if ref is None: + trees[side] = args.head_tree.resolve() + continue + path = out / side + git("worktree", "add", "--detach", "--force", str(path), ref, cwd=repo) + worktrees.append(path) + trees[side] = path + times = measure(trees, args, out, log) + rows = summarise(times, args) + except Exception: # report every failure; the check is informational + error = traceback.format_exc()[-3000:] + finally: + for path in worktrees: + subprocess.run( + ["git", "worktree", "remove", "--force", str(path)], + cwd=repo, + capture_output=True, + ) + + info = machine() + report = markdown(rows, args, info, refs, error) + print(report) + (out / "bench_ab.md").write_text(report) + settings = {k: str(v) for k, v in vars(args).items()} + (out / "bench_ab.json").write_text( + json.dumps( + { + "rows": rows, + "machine": info, + "refs": refs, + "settings": settings, + "error": error, + "raw": times, + }, + indent=1, + ) + ) + if args.summary: + with open(args.summary, "a") as f: + f.write(report) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/coverage_floor.py b/.github/scripts/coverage_floor.py new file mode 100755 index 00000000..4e178f84 --- /dev/null +++ b/.github/scripts/coverage_floor.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Fail when line coverage of a package directory drops below its floor. + + python .github/scripts/coverage_floor.py coverage.xml + +Reads the Cobertura XML that ``pytest --cov=bindsnet --cov-report=xml`` writes and +checks each directory in FLOORS. The floors are one point under the value measured +when they were set (see the comment on each). They are floors, not targets: raise one +when coverage has clearly moved up, never lower one to make a pull request pass. +Prints one line per directory and exits 1 if any is below its floor. +""" + +import sys +import xml.etree.ElementTree as ET + +# Measured on CI? No: measured locally on 2026-10-24 with torch 2.14.1 CPU after +# Measured on GitHub CI (build job, Python 3.13, CPU torch), run 40798879593, +# 2026-10-28. +FLOORS = { + "network": 89.6, # 90.6% measured + "learning": 94.2, # 95.2% measured +} +# Cobertura filenames are relative to the element, which pytest-cov sets to +# the bindsnet package directory, so "network/nodes.py", not "bindsnet/network/...". + + +def main(path: str) -> int: + root = ET.parse(path).getroot() + lines = {d: [0, 0] for d in FLOORS} # dir -> [covered, valid] + for cls in root.iter("class"): + filename = cls.get("filename", "") + for d in FLOORS: + if filename.startswith(d + "/"): + for line in cls.iter("line"): + lines[d][1] += 1 + if int(line.get("hits", "0")) > 0: + lines[d][0] += 1 + bad = 0 + for d, floor in FLOORS.items(): + covered, valid = lines[d] + pct = 100.0 * covered / valid if valid else 0.0 + if floor is None: + print(f"bindsnet/{d}: {pct:.1f}% (no floor set yet)") + continue + status = "ok" if pct >= floor else "BELOW FLOOR" + print(f"bindsnet/{d}: {pct:.1f}% (floor {floor}%) {status}") + bad += pct < floor + return 1 if bad else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "coverage.xml")) diff --git a/.github/scripts/fails_before_fix.py b/.github/scripts/fails_before_fix.py new file mode 100644 index 00000000..8063b348 --- /dev/null +++ b/.github/scripts/fails_before_fix.py @@ -0,0 +1,168 @@ +"""Check that a pull request which changes library code shows a test that fails +without the change and passes with it. + +A bug fix should come with the test that exposes the bug. This script runs the +test files the pull request adds or changes twice: + +- on the base commit's ``bindsnet/`` with the pull request's ``test/`` folder + copied over it (``git worktree`` of the base, then ``git checkout -- test``), +- on the head commit as checked out. + +It passes when at least one test fails (or cannot be collected) on the base and +passes on the head. A test file that cannot be imported on the base (for +example, it imports a function the pull request adds) counts as failing there. + +It also passes, with a note, when the pull request does not touch any +``bindsnet/*.py`` file (documentation, dependencies, workflows), or when the +pull request carries the label given by ``--skip-label``. Only maintainers can +add labels, so contributors cannot skip the check themselves. Use the label for +changes that are not bug fixes, such as a refactor or a speed-up, which must +instead keep every existing test passing. + +Usage (in CI, from the head checkout): + + python .github/scripts/fails_before_fix.py --base --head \ + --labels "" --summary "$GITHUB_STEP_SUMMARY" + +Exit code 0 when the check passes, 1 when it fails, 2 on a tool error. +""" + +import argparse +import os +import shutil +import subprocess +import sys +import tempfile +import xml.etree.ElementTree as ET +from pathlib import Path + +SKIP_LABEL = "no-bug-test" + + +def git(*args, cwd=None): + return subprocess.run( + ["git", *args], cwd=cwd, check=True, capture_output=True, text=True + ).stdout + + +def changed(base, head, path, filters="ACMR"): + out = git("diff", "--name-only", f"--diff-filter={filters}", base, head, "--", path) + return [p for p in out.splitlines() if p.endswith(".py")] + + +def run_pytest(tree, files, xml): + """Run pytest on ``files`` inside ``tree`` with ``tree`` first on the path. + + Returns {test id: "pass" | "fail"} and {module: "error"} for files that + could not be collected. Skipped and xfailed tests are left out. + """ + env = dict(os.environ, PYTHONPATH=str(tree)) + where = subprocess.run( + [sys.executable, "-c", "import bindsnet; print(bindsnet.__file__)"], + cwd=tree, + env=env, + capture_output=True, + text=True, + check=True, + ).stdout.strip() + if not Path(where).resolve().is_relative_to(Path(tree).resolve()): + raise RuntimeError(f"bindsnet imported from {where}, not from {tree}") + subprocess.run( + [sys.executable, "-m", "pytest", "-q", "-p", "no:cacheprovider"] + + ["--continue-on-collection-errors", f"--junitxml={xml}", *files], + cwd=tree, + env=env, + ) + results, broken = {}, set() + for case in ET.parse(xml).iter("testcase"): + tags = {child.tag for child in case} + if not case.get("classname"): # a file that could not be collected + broken.add(case.get("name")) + continue + test_id = f"{case.get('classname')}::{case.get('name')}" + if "skipped" in tags: + continue + results[test_id] = "fail" if tags & {"failure", "error"} else "pass" + return results, broken + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument("--base", required=True) + parser.add_argument("--head", required=True) + parser.add_argument("--labels", default="") + parser.add_argument("--skip-label", default=SKIP_LABEL) + parser.add_argument("--summary", default=None) + args = parser.parse_args() + + lines = ["## Test that fails before the fix", ""] + + def finish(code, message): + lines.append(message) + text = "\n".join(lines) + "\n" + print(text) + if args.summary: + with open(args.summary, "a") as f: + f.write(text) + return code + + labels = {label.strip() for label in args.labels.split(",") if label.strip()} + if args.skip_label in labels: + return finish(0, f"Skipped: a maintainer added the label `{args.skip_label}`.") + + source = changed(args.base, args.head, "bindsnet/", filters="ACDMR") + if not source: + return finish(0, "Passed: no file under `bindsnet/` changed.") + lines += ["Library files changed: " + ", ".join(f"`{p}`" for p in source), ""] + + tests = [p for p in changed(args.base, args.head, "test/") if "/test_" in p] + if not tests: + return finish( + 1, + "**Failed:** library code changed, but no test file was added or " + "changed. Add a test that fails on `master` and passes with this " + "change. If this is not a bug fix (a refactor or a speed-up), a " + f"maintainer can add the label `{args.skip_label}`.", + ) + lines += ["Test files run: " + ", ".join(f"`{p}`" for p in tests), ""] + + work = Path(tempfile.mkdtemp(prefix="fails_before_fix_")) + base_tree = work / "base" + try: + git("worktree", "add", "--detach", str(base_tree), args.base) + git("checkout", args.head, "--", "test", cwd=base_tree) + on_base, broken = run_pytest(base_tree, tests, work / "base.xml") + on_head, _ = run_pytest(Path.cwd(), tests, work / "head.xml") + finally: + subprocess.run(["git", "worktree", "remove", "--force", str(base_tree)]) + shutil.rmtree(work, ignore_errors=True) + + def failed_on_base(test_id): + module = test_id.split("::")[0] + if any(module == m or module.startswith(m + ".") for m in broken): + return True + return on_base.get(test_id) == "fail" + + shown = [t for t, r in on_head.items() if r == "pass" and failed_on_base(t)] + if shown: + lines.append("Tests that fail on the base and pass with this change:") + lines += [f"- `{t}`" for t in sorted(shown)[:50]] + if len(shown) > 50: + lines.append(f"- ... and {len(shown) - 50} more") + return finish(0, "\n**Passed.**") + return finish( + 1, + "**Failed:** every test in the changed test files gives the same result " + "with and without the library change, so none of them shows the bug " + "this pull request fixes. Add a test that fails on `master` and passes " + "with this change. If this is not a bug fix (a refactor or a speed-up), " + f"a maintainer can add the label `{args.skip_label}`.", + ) + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception as exc: # report tool errors without hiding them + print(f"fails_before_fix.py: tool error: {exc!r}") + sys.exit(2) diff --git a/.github/scripts/weakened_tests.py b/.github/scripts/weakened_tests.py new file mode 100644 index 00000000..c2f5e1ab --- /dev/null +++ b/.github/scripts/weakened_tests.py @@ -0,0 +1,226 @@ +"""Flag pull requests that weaken the test suite. + +A change can make the tests pass by changing the tests instead of the code. +This script compares the base and head commits and lists every place where +that may have happened: + +- a test file or a test function was removed; +- a function in a test file (helpers included) has fewer checks (``assert`` + statements and calls whose name contains ``assert``, ``allclose``, + ``equal`` or ``raises``); +- a test file has more skip or xfail markers (``skip``, ``skipif``, ``xfail``, + ``importorskip``); +- a tolerance in a test file now allows a larger value than before: a keyword + argument ``atol``, ``rtol``, ``abs``, ``rel``, ``tol`` or ``delta``, or an + assigned name containing ``tol``; +- a recorded-result file changed: golden outputs, operation counts, the API + snapshot, or the coverage floor; +- a ``conftest.py`` or the pytest settings in ``pyproject.toml`` changed (they + decide which tests run). + +Each finding needs a maintainer's look, not a fix: removing a wrong test or +regenerating goldens after a real fix is fine when the pull request says why. +The check fails while there are findings, unless a maintainer adds the label +given by ``--accept-label``. Contributors cannot add labels. + +Usage (in CI): python .github/scripts/weakened_tests.py --base \ + --head --labels "" --summary "$GITHUB_STEP_SUMMARY" + +Exit code 0 when nothing is found or the label is present, 1 otherwise, 2 on a +tool error. +""" + +import argparse +import ast +import subprocess +import sys + +ACCEPT_LABEL = "tests-changed-ok" +CHECK_WORDS = ("assert", "allclose", "equal", "raises") +SKIP_WORDS = {"skip", "skipif", "xfail", "importorskip"} +TOLERANCE_KEYWORDS = {"atol", "rtol", "abs", "rel", "tol", "delta"} +RECORDED = ( + "test/regression/golden/", + "test/regression/op_counts.json", + "test/api_surface.json", + ".github/scripts/coverage_floor.py", +) + + +def git(*args): + return subprocess.run( + ["git", *args], check=True, capture_output=True, text=True + ).stdout + + +def source_at(commit, path): + try: + return git("show", f"{commit}:{path}") + except subprocess.CalledProcessError: + return None + + +def call_name(node): + func = node.func + if isinstance(func, ast.Attribute): + return func.attr + if isinstance(func, ast.Name): + return func.id + return "" + + +def number(node): + try: + value = ast.literal_eval(node) + except (ValueError, SyntaxError, TypeError): + return None + return value if isinstance(value, (int, float)) else None + + +def tolerances_in(nodes): + """Largest value of each tolerance keyword argument, and of each assigned + name containing "tol" (``ATOL = 1e-6``), among ``nodes``.""" + found = {} + for node in nodes: + pairs = [] + if isinstance(node, ast.Call): + pairs = [ + (k.arg, k.value) for k in node.keywords if k.arg in TOLERANCE_KEYWORDS + ] + elif isinstance(node, ast.Assign): + pairs = [ + (t.id, node.value) + for t in node.targets + if isinstance(t, ast.Name) and "tol" in t.id.lower() + ] + for key, value_node in pairs: + value = number(value_node) + if value is not None: + found[key] = max(found.get(key, value), value) + return found + + +def describe(source): + """Per function (helpers included, so a check moved into a helper still + counts): number of checks and the largest value of each tolerance. The + module level is the entry "". Plus the file's count of skip and + xfail markers.""" + tree = ast.parse(source) + skips = 0 + for node in ast.walk(tree): + if isinstance(node, (ast.Name, ast.Attribute)): + name = node.id if isinstance(node, ast.Name) else node.attr + skips += name in SKIP_WORDS + functions = {"": (0, tolerances_in(tree.body))} + stack = [(node, "") for node in tree.body] + while stack: + node, prefix = stack.pop() + if isinstance(node, ast.ClassDef): + stack += [(child, f"{prefix}{node.name}.") for child in node.body] + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + inner = list(ast.walk(node)) + checks = sum(isinstance(n, ast.Assert) for n in inner) + sum( + any(word in call_name(n).lower() for word in CHECK_WORDS) + for n in inner + if isinstance(n, ast.Call) + ) + functions[prefix + node.name] = (checks, tolerances_in(inner)) + return functions, skips + + +def compare_test_file(path, before, after): + if after is None: + return [f"`{path}`: test file removed"] + if before is None: + return [] + old_functions, old_skips = describe(before) + new_functions, new_skips = describe(after) + found = [] + for name, (old_checks, old_tols) in old_functions.items(): + if name not in new_functions: + if name.rsplit(".", 1)[-1].startswith("test"): + found.append(f"`{path}::{name}`: test removed") + continue + new_checks, new_tols = new_functions[name] + if new_checks < old_checks: + found.append( + f"`{path}::{name}`: checks went from {old_checks} to {new_checks}" + ) + for keyword, value in new_tols.items(): + if keyword in old_tols and value > old_tols[keyword]: + found.append( + f"`{path}::{name}`: `{keyword}` widened from " + f"{old_tols[keyword]:g} to {value:g}" + ) + if new_skips > old_skips: + found.append( + f"`{path}`: skip/xfail markers went from {old_skips} to {new_skips}" + ) + return found + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument("--base", required=True) + parser.add_argument("--head", required=True) + parser.add_argument("--labels", default="") + parser.add_argument("--accept-label", default=ACCEPT_LABEL) + parser.add_argument("--summary", default=None) + args = parser.parse_args() + + paths = git("diff", "--name-only", args.base, args.head).splitlines() + found = [] + for path in paths: + name = path.rsplit("/", 1)[-1] + if path.startswith(RECORDED): + found.append(f"`{path}`: recorded results changed") + elif name == "conftest.py": + found.append(f"`{path}`: changed (it decides how tests run)") + elif path == "pyproject.toml": + before = source_at(args.base, path) or "" + after = source_at(args.head, path) or "" + section = "[tool.pytest" + if before.partition(section)[2] != after.partition(section)[2]: + found.append("`pyproject.toml`: pytest settings changed") + elif ( + path.startswith("test/") + and name.startswith("test_") + and name.endswith(".py") + ): + found += compare_test_file( + path, source_at(args.base, path), source_at(args.head, path) + ) + + labels = {label.strip() for label in args.labels.split(",") if label.strip()} + lines = ["## Tests weakened or recorded results changed", ""] + if not found: + lines.append("**Passed:** nothing found.") + code = 0 + else: + lines += [f"- {item}" for item in found] + [""] + if args.accept_label in labels: + lines.append( + f"**Passed:** a maintainer reviewed these and added `{args.accept_label}`." + ) + code = 0 + else: + lines.append( + "**Failed:** a maintainer must review each item above. The pull " + "request should say why each one is right. A maintainer then " + f"adds the label `{args.accept_label}`." + ) + code = 1 + text = "\n".join(lines) + "\n" + print(text) + if args.summary: + with open(args.summary, "a") as f: + f.write(text) + return code + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception as exc: # report tool errors without hiding them + print(f"weakened_tests.py: tool error: {exc!r}") + sys.exit(2) diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml new file mode 100644 index 00000000..b9a25760 --- /dev/null +++ b/.github/workflows/bench.yml @@ -0,0 +1,56 @@ +# Speed comparison of a pull request against its base. Informational only: +# this is NOT a required check, and it always exits 0. The verdict (SLOWER or +# not) is in the job summary and in the uploaded "bench-ab" artifact; a +# maintainer decides. It does not post PR comments, because pull requests from +# forks get a read-only token. Script and statistics: .github/scripts/bench_ab.py. +name: Speed comparison (informational) + +on: + pull_request: + branches: [ master ] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + POETRY_VERSION: "2.4.3" + +jobs: + bench: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 # the base commit must be present for git worktree + - name: Install Poetry + run: pipx install "poetry==${POETRY_VERSION}" + - uses: actions/setup-python@v7 + with: + python-version: "3.13" + cache: poetry + - name: Install dependencies + run: | + poetry install + .github/scripts/use_cpu_torch.sh "poetry run python" + # Head is github.sha, the test merge of the pull request into master, so the + # difference from the base commit is exactly the pull request's change. + - name: Compare base and head (alternating rounds) + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.sha }} + run: > + poetry run python .github/scripts/bench_ab.py + --base "$BASE_SHA" --head "$HEAD_SHA" + --out bench-out --summary "$GITHUB_STEP_SUMMARY" + - uses: actions/upload-artifact@v7 + if: always() + with: + name: bench-ab + path: | + bench-out/bench_ab.md + bench-out/bench_ab.json diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index f89dda13..35bebc4c 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -36,8 +36,16 @@ jobs: .github/scripts/use_cpu_torch.sh "poetry run python" - name: Lint with flake8 (syntax errors and undefined names) run: pipx run flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - - name: Test with pytest - run: poetry run pytest + - name: Test with pytest (with coverage) + run: poetry run pytest --cov=bindsnet --cov-report=term --cov-report=xml + # A floor, not a target: fails only when coverage of the core packages drops + # below the value recorded in the script (see its docstring). + - name: Check coverage floor + run: python .github/scripts/coverage_floor.py coverage.xml + - uses: actions/upload-artifact@v7 + with: + name: coverage-xml + path: coverage.xml # The other supported Python versions (pyproject.toml: >=3.11,<3.14). test: diff --git a/.github/workflows/test-guard.yml b/.github/workflows/test-guard.yml new file mode 100644 index 00000000..3340ba8e --- /dev/null +++ b/.github/workflows/test-guard.yml @@ -0,0 +1,70 @@ +# Guards against fixes that pass by weakening the tests. Two checks, both +# required on master by branch protection; keep the job names. +# bug-test: a pull request that changes bindsnet/ must add or change a test +# that fails on the base and passes with the change +# (.github/scripts/fails_before_fix.py). Maintainer override +# label: no-bug-test (refactors, speed-ups). +# weakened-tests: lists removed tests, fewer checks, more skips, wider +# tolerances and changed recorded results +# (.github/scripts/weakened_tests.py). Maintainer override +# label: tests-changed-ok, after reviewing each item. +# Only maintainers can add labels; adding or removing one re-runs the checks. +name: Test guard + +on: + pull_request: + branches: [ master ] + types: [ opened, synchronize, reopened, labeled, unlabeled ] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + POETRY_VERSION: "2.4.3" + LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} + +jobs: + weakened-tests: + name: weakened-tests + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 2 # HEAD is the test merge; HEAD^1 is master it merges into + - uses: actions/setup-python@v7 + with: + python-version: "3.13" + - name: Compare tests with the base + run: > + python .github/scripts/weakened_tests.py + --base "$(git rev-parse HEAD^1)" --head "$(git rev-parse HEAD)" + --labels "$LABELS" --summary "$GITHUB_STEP_SUMMARY" + + bug-test: + name: bug-test + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 2 + - name: Install Poetry + run: pipx install "poetry==${POETRY_VERSION}" + - uses: actions/setup-python@v7 + with: + python-version: "3.13" + cache: poetry + - name: Install dependencies + run: | + poetry install + .github/scripts/use_cpu_torch.sh "poetry run python" + - name: Run changed tests on base and head + run: > + poetry run python .github/scripts/fails_before_fix.py + --base "$(git rev-parse HEAD^1)" --head "$(git rev-parse HEAD)" + --labels "$LABELS" --summary "$GITHUB_STEP_SUMMARY" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 465c62cd..c2250cab 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,6 +29,84 @@ poetry run pytest Notable changes are recorded in [`CHANGELOG.md`](CHANGELOG.md); please add an entry to the `Unreleased` section in your pull request. +## A bug fix needs a test that fails without it + +If your pull request changes any file under `bindsnet/`, it must add or change a +test that **fails on `master` and passes with your change**. The `bug-test` check +(`.github/scripts/fails_before_fix.py`) runs the test files you added or changed +twice, once on `master`'s code and once on yours, and fails if none of them goes +from failing to passing. This shows the bug is real and that your change is what +fixes it. You can run the same check locally: + +```shell +poetry run python .github/scripts/fails_before_fix.py --base master --head HEAD +``` + +A change that is not a bug fix (a refactor or a speed-up) has no such test; a +maintainer then adds the label `no-bug-test`, and every existing test must still pass. + +The `weakened-tests` check (`.github/scripts/weakened_tests.py`) lists every +place where a pull request may make tests pass by changing the tests: a removed +test, fewer checks in a test, a new skip or xfail, a wider tolerance, or changed +recorded results (golden files, operation counts, API snapshot, coverage floor). +Some of these are right, for example regenerating goldens after a real fix. Explain +each one in the pull request; a maintainer reviews them and adds the label +`tests-changed-ok`. Files listed in `.github/CODEOWNERS` also need a maintainer's +approval. + +## Tests that guard results + +Some tests do not check one behaviour; they check that nothing changed. They fail +when a change alters simulated results or the public API, even if the change looks +harmless. Please do not edit them by hand to make them pass. + +- **Golden-output tests (`test/regression/`).** Small seeded networks are run and their + spikes, voltages, traces and weights are compared with stored reference files under + `test/regression/`. Spikes must match exactly; other values must match to within + 1e-6. If your change alters these on purpose (for example, you fixed a formula), the + reference files must be regenerated with the script in `test/regression/`, which + refuses to run without `--reason ""` and writes that reason + into the files. Say in the pull request which files changed and why. +- **Public API snapshot (`test/api_surface.json`).** `test/test_api_surface.py` records + the parameters and defaults of every public class, method and function in + `bindsnet`. If you add, remove, rename or reorder a parameter, or change a default, + regenerate it in the same pull request: + + ```shell + poetry run python test/regen_api_surface.py --reason "" + ``` + + The reason is required and is stored in the file. Removing or renaming a parameter + can break other people's code, so expect to be asked why. +- **Every-class smoke tests (`test/network/test_all_classes.py`).** Every node, + connection, learning rule, feature, monitor and model class is found automatically + and run for a few steps. A new connection class needs an entry in `CONNECTION_SPECS` + (its source and target shapes), a new model needs one in `MODEL_SPECS`, and a new + learning rule needs one in `SUPPORTED`. Known bugs on `master` are listed in + `KNOWN_BUGS` with their exact error; if your change fixes one, its test fails with + "DID NOT RAISE", and you should remove the entry in the same pull request. +- **Operation counts (`test/regression/op_counts.json`).** `test/regression/test_op_counts.py` + counts the tensor operations of one learning-rule update, one simulation step and one + neuron update on small fixed networks. It fails when a count grows, because more + operations per time step usually means slower code. If the extra work is needed, + regenerate in the same pull request: + + ```shell + poetry run python test/regression/regen_op_counts.py --reason "" + ``` + +- **Speed comparison (informational).** The workflow `Speed comparison (informational)` + (`.github/workflows/bench.yml`) times the benchmark workloads on the base and the head + of your pull request, alternating ten rounds, and writes a table to the job summary + (also downloadable as the `bench-ab` artifact). A workload is flagged SLOWER when the + head takes more than 1.25 times as long (median over the rounds) and the lower end of + the 95% bootstrap interval of that ratio is above 1. It is not a required check; shared CI machines are noisy, and a maintainer decides. + +**Fixes that change results are released in 0.4.0, not in 0.3.x.** A correct fix that +changes what a simulation produces still changes published numbers for people using +0.3.x, so it waits for the next minor release. State in the pull request whether your +change alters results, and add a `CHANGELOG.md` entry that says so. + ## Releasing to PyPI 1. Set `version` in `pyproject.toml` and `CITATION.cff`, and move the `Unreleased` diff --git a/bindsnet/learning/README.md b/bindsnet/learning/README.md index 53cd6c7f..3a8189c7 100644 --- a/bindsnet/learning/README.md +++ b/bindsnet/learning/README.md @@ -39,6 +39,19 @@ step against a from-scratch transcription of the paper. weight by the same amount at any simulation step. (`MSTDPET` does carry the paper's `dt` factor, eq. 2.7.) +- **Reward-rule state survives `reset_state_variables()` and is not saved.** + `MSTDP` (`p_plus`, `p_minus`, `eligibility`), `MSTDPET` (the same plus + `eligibility_trace`) and `Rmax` (`eligibility_trace`) keep these tensors on the + rule object, outside the network's `state_dict()`. A reset does not clear them, + and `load_state_dict` does not restore them, so a second trial after a reset + does not equal a fresh network, and a reloaded network does not continue + exactly. The same holds for the CSRM spike window `Connection.s_w`. Measured + 2026-10-24 by `test/regression/test_invariants.py` (strict expected failures); + a fix changes results and is scheduled for 0.4.0. +- **`MulticompartmentConnection` weights are not in `state_dict()`.** A + `DiehlAndCook2015` network saved with `state_dict()` and loaded back loses its + trained weights. Same test file, same release plan. + ## Using the Diehl & Cook (2015) rule ```python diff --git a/poetry.lock b/poetry.lock index 1ac30bbf..812abf37 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.5.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.4.3 and should not be changed by hand. [[package]] name = "ale-py" @@ -786,6 +786,135 @@ mypy = ["bokeh", "contourpy[bokeh,docs]", "docutils-stubs", "mypy (==1.17.0)", " test = ["Pillow", "contourpy[test-no-images]", "matplotlib"] test-no-images = ["pytest", "pytest-cov", "pytest-rerunfailures", "pytest-xdist", "wurlitzer"] +[[package]] +name = "coverage" +version = "7.17.0" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.11" +groups = ["dev"] +files = [ + {file = "coverage-7.17.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:99a7b390cbccc6bf029ca4da3f0f607c218bb4085e9cd6b58a90d14ca12993c3"}, + {file = "coverage-7.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f72640e02a470b57b15cd655820133f6892760209283a363a60e2df932d5f0fe"}, + {file = "coverage-7.17.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2dc9233f39149b5a4f4aba17a4b6bc5cca22e52cfc4b06fdb07d014439b8b2ea"}, + {file = "coverage-7.17.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e8113d3852e9377421a61de1917df27b3abb25404a4ecf69f9e64612c4345b5"}, + {file = "coverage-7.17.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c86a39539d10d666bb819c16c94d7edfc1aa2e0531b937f7c549b83d15177c6e"}, + {file = "coverage-7.17.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:559f748446ee799803686b5784e3deb54c70d655ac386a860e06b11672504ba7"}, + {file = "coverage-7.17.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34d65c9e8e257f48a654908005046fbf1bc48d9540ae3776ad08c0acd010e10c"}, + {file = "coverage-7.17.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5b7fa0997b8eba7378ac66f31ec7f70164490353ab79b9667d37c0d365e50177"}, + {file = "coverage-7.17.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6ae0453a3390e67f7bfbe34417bbdf5b4ebfc21db23fb446a0a41115eeeca6ca"}, + {file = "coverage-7.17.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:96151262832f3c230ebae23b2d6911ad549e1738465946c97004913ff06112ba"}, + {file = "coverage-7.17.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:cd7397de6cc782f00fe5ac1d75e9b306bcfa49c8b6b8dd6ed1f7709393ad40b3"}, + {file = "coverage-7.17.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9d1fdf82c6fea57b5e9dada509f9ce9638c8334664e2c98c7f6f1ccf3d143ad2"}, + {file = "coverage-7.17.0-cp311-cp311-win32.whl", hash = "sha256:38c86e5d74bbfd88a393fa3f91257c1225c63aa6db6d5918de3e49ab04f91c6e"}, + {file = "coverage-7.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:2ed3097c6817bc1a7fc8ae0a6131db74aa1150d863cccc308237ff362d3226b2"}, + {file = "coverage-7.17.0-cp311-cp311-win_arm64.whl", hash = "sha256:6052e878b628abcd38fa5f2b64abebc686eed4e2267da42434017a9c6d24f241"}, + {file = "coverage-7.17.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0cfdc938ca3319d873d1f1252e60620a39aee4ebf9dbd7cc58238109ebda9c51"}, + {file = "coverage-7.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d523da3c2e4bca49417768bcdb1a443c5b359dc44b8b93a3102e118fce912c40"}, + {file = "coverage-7.17.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:adbf3450c0f3f2b2854e282a8758f2b3eddf787eeff24ec9c9bc2cbc38328cc1"}, + {file = "coverage-7.17.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:85932227e9dd38e62cd29c17fefdfecfb919eead6af1e3259522d03cf85de983"}, + {file = "coverage-7.17.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:30f5477aac9c9e4a0a641c55f8f8fddd7f3bd91176c912c50f40ffced96b4034"}, + {file = "coverage-7.17.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0b8bb651a4406fb6a983a905309fec1783443c65e1771b41e92740a0f4a1176b"}, + {file = "coverage-7.17.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0edeb6594f080c8de1263cccb239cb316ef77aeb959e2e0f38f2953fcc72a49e"}, + {file = "coverage-7.17.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:da94ec0484e84fce5c7bf8d0f54b97767a90d992653a129b35634699d516e0a5"}, + {file = "coverage-7.17.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a25cc5e8c23a590815c6a7aa43624234d25cb23b21b484adb8a0daed59860db5"}, + {file = "coverage-7.17.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:db645c81cf4d6bcb392ea1894aad65d55d1588a8472c834b25ca575da3029a56"}, + {file = "coverage-7.17.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6a80a11b90c4a03df1502304cf24ff953255b6b5d076ef79066ecf88baaff31f"}, + {file = "coverage-7.17.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e4eaf11d6b0f2147f46cfbf06fe7edcca6f095d0120da00ddc2458995ee869fc"}, + {file = "coverage-7.17.0-cp312-cp312-win32.whl", hash = "sha256:912a29f338798541d795e2e25903c6fe7a99dee70a40fe4706a9a124649b523e"}, + {file = "coverage-7.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:88a1a9fb0075b83389dd9b403bb3fce976031b3c2aa0e05e3e57143cf639de69"}, + {file = "coverage-7.17.0-cp312-cp312-win_arm64.whl", hash = "sha256:2c680e838ff9e68d491158625ca2c1ddb17d78d791bba96a31f8b839b4990438"}, + {file = "coverage-7.17.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:84538c040f4e6214f663459d89372ce4e6b2bbdd9d397ef0dca7b3ecd56b8eb2"}, + {file = "coverage-7.17.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:6abfb6a4cf1cb1b67bc8fccdde177e2d2b781d0d34449e7078156547025140e5"}, + {file = "coverage-7.17.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:ef65e96f0fc1a7c8b7644c7011d47a8228555e7888ac4d6246f715a7f3d7c398"}, + {file = "coverage-7.17.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:55939ea4c097fda2edabf80fac5e49bef15597b950a9f29fc56292a895245feb"}, + {file = "coverage-7.17.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bfb6f868af552c0ceb2d19220b8cbcc2be096ca26163ac30e682a13041433429"}, + {file = "coverage-7.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8d823d66afd59566030539c49c7a6acaf3083996682be2dda760c63555842094"}, + {file = "coverage-7.17.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:50678474e714c684b82c108e63fbb8f1bcf1266d8293a49980f1d08526f093b5"}, + {file = "coverage-7.17.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:045c1eeb19fb4eae949850a761102fffb3a5f638b97f977912f95843e54f6669"}, + {file = "coverage-7.17.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfb12891a773cbc2132349c77c690a045a882fe3f98b4bda50dfabb01893f9bd"}, + {file = "coverage-7.17.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3067608b4429629f640c1b23d2f69b05d2fd45e00ebb6ee9e8fad220a6c11722"}, + {file = "coverage-7.17.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a1762e85cbbe3b5ff9c6dcbcc1b89d5c72320094292b7b9572bf882c85e428b7"}, + {file = "coverage-7.17.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62bb77987d3f72a0aaa77c567ee7f9806bc2e748c8f46fafbe1fdb90dd148398"}, + {file = "coverage-7.17.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6cc31f27240d4e080437e3b8229d92f1d809c9a4f233607385373b73974e183e"}, + {file = "coverage-7.17.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:61cde5222f1933d476647c5a25ed1a8678b7703098cb5a15f4a0aa39967d8c28"}, + {file = "coverage-7.17.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:11f5f7fb2d97a0fe865f5e7bad4eb0619b957637f676bc088d13844adf26ba95"}, + {file = "coverage-7.17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e09612b1b82c9df1d2b6c85dee88d5aad3e73d77a8ec0b16ac6a7322cbdf5d9"}, + {file = "coverage-7.17.0-cp313-cp313-win32.whl", hash = "sha256:bd3e9eb493631ff1ca34bf5efac25b5c66724f837bf6182c4fc771c55946423d"}, + {file = "coverage-7.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:f1fc9a7cea899c51d8a53e66c2f082a3d9e461ece32418d3ef0a0b90c32bfd3d"}, + {file = "coverage-7.17.0-cp313-cp313-win_arm64.whl", hash = "sha256:e65a1e61a68493fbfbe44d8bec71b3231d81b9917f62d3dafe0e105072147e83"}, + {file = "coverage-7.17.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:dc5936491e306f2f6f095bd5fba593de11eb0375cee787ca1f245c3a333a523a"}, + {file = "coverage-7.17.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:968c5fb6e0c83cab752d79a70209648ce4d90cae815c60fa229b59e264601062"}, + {file = "coverage-7.17.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:12cb8a20ad904a5811e6660d829a586b216e5254b9b34b76dde8a4d65d827dc6"}, + {file = "coverage-7.17.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:bb5ca66937c55bc85d1a5e9c5900a3e4693a8edf2280a9b3672b7b2661a84b95"}, + {file = "coverage-7.17.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5cc15faaf318ee5040d835a62c75bc6f122d837274f3b5efd47bf9fbd4ac5b85"}, + {file = "coverage-7.17.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7d6d9d396c8b7bcaf76d774a10140b218111484e54c73a5f2a1b75655381748e"}, + {file = "coverage-7.17.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:42e6f170d1ac04b7eb3363310a6c41cdfd82c1fe6bf9ffbc4a86f380bdf091c7"}, + {file = "coverage-7.17.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:acdb5f3c96046efff9dc6c572a6acb2b8d0da09372d7b9a9ed7249cf7d17434f"}, + {file = "coverage-7.17.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3393209f6894713161ee82a9b0e673e58237e0f9733c6638e7a6c0c620ef0b33"}, + {file = "coverage-7.17.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e9494d9e99389d39e6a96227749af898d8924ff591cb63944f8a98e68582bf0e"}, + {file = "coverage-7.17.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:032d4c2db4c677eb71d43f2f8b80bf64e9346f7a3f7b6ebb13f1782f3cc6c274"}, + {file = "coverage-7.17.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4343a9378b964eb3cb80fa90406385de91c7e982a769ca9314d848f93bc0d3f2"}, + {file = "coverage-7.17.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:b609df3d6d900464207532c5170a65c6ede8d2e0b91f151f90008a866395cd76"}, + {file = "coverage-7.17.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a01ef92caeedfa4f2ad171a3a8e6fece4565de871d8bca428151bf9929f41048"}, + {file = "coverage-7.17.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:a3914e2c98c34b94c26d2add3d2a61ccfd3534fc4eaa879ee290bdbb5184ba6c"}, + {file = "coverage-7.17.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8a72b26dc5c9a9422e45472b1d67ba5caf273c524c8668cc04b23062663755e"}, + {file = "coverage-7.17.0-cp314-cp314-win32.whl", hash = "sha256:03baf0688e14a5a44ffd1eefa8928dfc92b14565e521174e072b31b971aa1657"}, + {file = "coverage-7.17.0-cp314-cp314-win_amd64.whl", hash = "sha256:3a4459b0f389dd1e51aac3dbfc4ca35b30cae7dc99caa3c0a086d1997a985709"}, + {file = "coverage-7.17.0-cp314-cp314-win_arm64.whl", hash = "sha256:a32ee5831b15ba239bef5d272ae586ca8acea4a661b5f2941f5a0d380e518b23"}, + {file = "coverage-7.17.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9c083e253e96fdeeaf48133fe836cac86ace2db215d235700e7f7532420a200c"}, + {file = "coverage-7.17.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b88fb4d4d7608cbcebdc7ff7dbffc2115e3b81d6c33a79147cd79d54fcb080f4"}, + {file = "coverage-7.17.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2bf3ee38a40c0570c316b9e7507c472469586906a20f289705898528c31aaea8"}, + {file = "coverage-7.17.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7c570293f001ea91598b90e90cb9b98fddee833e90bbede432cbd218331ccd8e"}, + {file = "coverage-7.17.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:791f9ee09814dd30d9562b4f4eb037a371485ec1c830b53999d2320d20c4e764"}, + {file = "coverage-7.17.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c7c5672ecf1b497de7a0b37743bef40e3b58e3207e9fe2ed6682cb0420836a88"}, + {file = "coverage-7.17.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:337f87e62e1c5f43c8198d879f677c342d152f84d3f157add12558853cbb8eaf"}, + {file = "coverage-7.17.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c8a58baa8eb6154c6645eadcc19b6c089deab78c24a612a83206f2a8a5992a7c"}, + {file = "coverage-7.17.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:fb5745048ee20f796b0e3ab61831049e749acff188563e2af32f58ede957ac1b"}, + {file = "coverage-7.17.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:7b5abcae74750302ac4bc77351eaee87c450e4abd3b6225eacde3858e260f8b2"}, + {file = "coverage-7.17.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4e153f0bd6509dbf837fd585f489b3328bfd98f2a718a0b15ae63032148b285c"}, + {file = "coverage-7.17.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e42a835271922fe7bf98c7a05c78d4032f4e52bf5b07fafa1eadfdfc2f0ad22f"}, + {file = "coverage-7.17.0-cp314-cp314t-win32.whl", hash = "sha256:5900055351ec89fdffb34b27898815106529ded29a9496464c1ebd6313fd40fa"}, + {file = "coverage-7.17.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5ee9c8bd13a59e301dc2aefe7aa8384633406808de051b1982775667094eb673"}, + {file = "coverage-7.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a9cb9a0fb58e6fff5442969af36094ecd6976f4907f179e33b27e15355d8f74"}, + {file = "coverage-7.17.0-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:adabc1079ba844b096799ba8638d8df434aca1613f95bb7d5791af320cf05559"}, + {file = "coverage-7.17.0-cp315-cp315-android_24_x86_64.whl", hash = "sha256:6b4593d2871c4c801656dd5861e759f8f5156febd6b6061b02f6a72d64618b7b"}, + {file = "coverage-7.17.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:c2c48a36bb970b7265b196032bfd4237c0b373f2b25ef7012d7e905b5b35c19c"}, + {file = "coverage-7.17.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f8bc2cd4c6aa58be1ca243fb8cddb6c3c42031fe4403991ad9cf2b3baed03d9f"}, + {file = "coverage-7.17.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:cbf0b83ce927de6053279ba61342d9f8892afde1385fcba9144cd05d4023d109"}, + {file = "coverage-7.17.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:66efc0fb99cee34626fc17d3adac9c3cd816bfdad563bb1e983c3ef432f566d4"}, + {file = "coverage-7.17.0-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:58a304efdf2253fb2ea03d45d0a04cc174a92a0cd5279c28c5bb893ce7410968"}, + {file = "coverage-7.17.0-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:aed06129a9f530a948928260d7d2d81c47261355b0127e3c782251b38958463c"}, + {file = "coverage-7.17.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6cdb47a9be2d3313829bed1d296086287682a3ee901ff987b3432b45705b771"}, + {file = "coverage-7.17.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:804fbaf81d09b34e2f8b1159d01e211a50713d743d32fd9b2ca6cf0ae507a66f"}, + {file = "coverage-7.17.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b2afebb96da9a1bbca142f8cc12320c14e5c215e0a49cded4f191f5a31506a28"}, + {file = "coverage-7.17.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:bd04fd23206be9ad3de13b0b39313094fafa66a1f6baf70f5471c004a9fac47d"}, + {file = "coverage-7.17.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:5ebfbc87836529a31d25c2dec1262c38eea852ec1c46c0f690a515a7339d9c5b"}, + {file = "coverage-7.17.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:a59c3b470ac3b2432af3598a996affbeb10236cd899a005c1c4e517fcff8b592"}, + {file = "coverage-7.17.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:7fef12cec4b96f59e09761cbd4fc9bdf4788062d1ae435b920241fc16c8a9549"}, + {file = "coverage-7.17.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:a14e8d69464da9cbe1dc29e7116dbd37296d1d51a695327c478e250349085089"}, + {file = "coverage-7.17.0-cp315-cp315-win32.whl", hash = "sha256:9a66074231799d8884d267234d49f60c90b43a945dfe069646b72f3058cd4e67"}, + {file = "coverage-7.17.0-cp315-cp315-win_amd64.whl", hash = "sha256:79cf71e5f9a48877ccfed5baa2c72f45af4feeaa596249e23eb15b473134830e"}, + {file = "coverage-7.17.0-cp315-cp315-win_arm64.whl", hash = "sha256:09e563b49c81cd0a33734e926d7a4a6ef1778d74304cde2dcc3aff8b30ea1036"}, + {file = "coverage-7.17.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:a5e2e0625dfc789af0b2e10acfc106b352f314fffbd19e6392a7961c8cbe5d9a"}, + {file = "coverage-7.17.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:194562ab1eff0d85aee8a17ce4c5c0bc1ebf70a629a7145257123156ad100fd6"}, + {file = "coverage-7.17.0-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:85682ca89bc4601125dcdff23fa70e6274e13c9ad502fba845e6c274ab2c6569"}, + {file = "coverage-7.17.0-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0d0fac604a962ff546beb038cba9a5a09d88e6e3f01612fb46f7bd4fe5dc8dca"}, + {file = "coverage-7.17.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72edd4f9483ae30cd1ba4f230687840ef7b58a16afb1c9e81131fd6d64655f36"}, + {file = "coverage-7.17.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:91e487ad07c511da77b574820141a7c2da09bc2a4b4090b5be2424295d100697"}, + {file = "coverage-7.17.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1cabb31531002fef5197de5df3c84730aef01e750e25154b9813a2583435aba2"}, + {file = "coverage-7.17.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:c9ac886242bbd50fb35a2242ba79555a99891e657da33f98b7ca968e3488a350"}, + {file = "coverage-7.17.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:d45cdc495671fc99268ff3653e0da4244972e9fc0e1fc3ea9de18781ad28963a"}, + {file = "coverage-7.17.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:12961f738d717a046d4bf3f8d1727eb3ebe2ed5478ff96cd6233d4dfc39323d3"}, + {file = "coverage-7.17.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:1ee0a982b0e1230b3edf768a7dd022bd2377303763895327ac62deb5efacee03"}, + {file = "coverage-7.17.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:460679f53893081edb94eeed011d26be37228ba649b853fd2aed7e4394fb1f35"}, + {file = "coverage-7.17.0-cp315-cp315t-win32.whl", hash = "sha256:600e121e412cd6bdb52ec105b95c6a04da0c9710d1534f6e64a42db667907802"}, + {file = "coverage-7.17.0-cp315-cp315t-win_amd64.whl", hash = "sha256:c3e11b81faafb1f063248735c3e2c5c4183fc1db2c7f46782170dd7760dffcbc"}, + {file = "coverage-7.17.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d472ee1c64868a917a47f02ffb4ac8c22b477f7589d57a3eb0c58b1ad4f0137c"}, + {file = "coverage-7.17.0-py3-none-any.whl", hash = "sha256:bac487dce0d2aff28ddf1a533a916f771416eee0e493960f611e49037b878e9d"}, + {file = "coverage-7.17.0.tar.gz", hash = "sha256:cc010a0a7fbdd9bb8bdfd0d3f04a5151275860213cbd7e2b876b29778489c712"}, +] + [[package]] name = "cuda-bindings" version = "13.3.1" @@ -1346,7 +1475,7 @@ comm = ">=0.1.1" debugpy = ">=1.6.5" ipython = ">=7.23.1" jupyter-client = ">=8.9.0" -jupyter-core = ">=5.1,<6.0 || >=6.1.dev0" +jupyter-core = ">=5.1,<6.0.dev0 || >=6.1.dev0" matplotlib-inline = ">=0.1" nest-asyncio2 = ">=1.7.0" packaging = ">=22" @@ -1728,7 +1857,7 @@ anyio = ">=3.1.0" argon2-cffi = ">=21.1" jinja2 = ">=3.0.3" jupyter-client = ">=7.4.4" -jupyter-core = ">=4.12,<5.0 || >=5.1.dev0" +jupyter-core = ">=4.12,<5.0.dev0 || >=5.1.dev0" jupyter-events = ">=0.11.0" jupyter-server-terminals = ">=0.4.4" nbconvert = ">=6.4.4" @@ -1736,7 +1865,7 @@ nbformat = ">=5.3.0" overrides = {version = ">=5.0", markers = "python_version < \"3.12\""} packaging = ">=22.0" prometheus-client = ">=0.9" -pywinpty = {version = ">=2.0.1,!=3.0.4", markers = "os_name == \"nt\""} +pywinpty = {version = ">=2.0.1,<3.0.4 || >3.0.4", markers = "os_name == \"nt\""} pyzmq = ">=24" send2trash = ">=1.8.2" terminado = ">=0.8.3" @@ -1783,7 +1912,7 @@ files = [ [package.dependencies] async-lru = ">=1.0.0" httpx = ">=0.25.0,<1" -ipykernel = ">=6.5.0,!=6.30.0" +ipykernel = ">=6.5.0,<6.30.0 || >6.30.0" jinja2 = ">=3.0.3" jupyter-builder = ">=1.0.2" jupyter-core = "*" @@ -2363,7 +2492,7 @@ files = [ [package.dependencies] fastjsonschema = ">=2.15" jsonschema = ">=2.6" -jupyter-core = ">=4.12,<5.0 || >=5.1.dev0" +jupyter-core = ">=4.12,<5.0.dev0 || >=5.1.dev0" traitlets = ">=5.1" [package.extras] @@ -2566,7 +2695,7 @@ description = "CUBLAS native runtime libraries" optional = false python-versions = ">=3" groups = ["main"] -markers = "platform_system == \"Linux\"" +markers = "platform_system == \"Linux\" and (sys_platform != \"linux\" or platform_machine != \"aarch64\" and platform_machine != \"x86_64\") and (sys_platform != \"linux\" and sys_platform != \"win32\" or platform_machine != \"x86_64\")" files = [ {file = "nvidia_cublas-13.6.1.10-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e05a431062a17cb9b02e2f37e67817b637051ce8fad57b388482c594396ddbb4"}, {file = "nvidia_cublas-13.6.1.10-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:feb2ed8a1e211bc5774413efc0f1a08c4d5269b56f68b4ac6fe5408e57f7dc1c"}, @@ -2610,7 +2739,7 @@ description = "NVRTC native runtime libraries" optional = false python-versions = ">=3" groups = ["main"] -markers = "platform_system == \"Linux\"" +markers = "platform_system == \"Linux\" and (sys_platform != \"linux\" or platform_machine != \"aarch64\" and platform_machine != \"x86_64\") and (sys_platform != \"linux\" and sys_platform != \"win32\" or platform_machine != \"x86_64\")" files = [ {file = "nvidia_cuda_nvrtc-13.3.33-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:82530788b8c6164a54d3fd9ae8bcca8893d397c4aeb998861982a03bbe41e204"}, {file = "nvidia_cuda_nvrtc-13.3.33-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7b05ecda494c6dabc44231a608b060a71008a730d9dfda932cc508e6d29159e0"}, @@ -3243,7 +3372,7 @@ description = "Run a subprocess in a pseudo terminal" optional = false python-versions = "*" groups = ["dev"] -markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\" or os_name != \"nt\"" +markers = "os_name != \"nt\" or sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, @@ -3341,6 +3470,26 @@ pygments = ">=2.7.2" [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] +[[package]] +name = "pytest-cov" +version = "7.1.0" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678"}, + {file = "pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2"}, +] + +[package.dependencies] +coverage = {version = ">=7.10.6", extras = ["toml"]} +pluggy = ">=1.2" +pytest = ">=7" + +[package.extras] +testing = ["process-tests", "pytest-xdist", "virtualenv"] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -3916,7 +4065,7 @@ files = [ ] [package.dependencies] -imageio = ">=2.33,!=2.35.0" +imageio = ">=2.33,<2.35.0 || >2.35.0" lazy-loader = ">=0.4" networkx = ">=3.0" numpy = ">=1.24" @@ -4420,7 +4569,7 @@ files = [ [package.dependencies] numpy = "*" -pillow = ">=5.3.0,<8.3 || >=8.4.dev0" +pillow = ">=5.3.0,<8.3.dev0 || >=8.4.dev0" torch = ">=2.14.0" [package.extras] @@ -4464,7 +4613,7 @@ files = [ [package.dependencies] numpy = "*" -pillow = ">=5.3.0,<8.3 || >=8.4.dev0" +pillow = ">=5.3.0,<8.3.dev0 || >=8.4.dev0" torch = ">=2.14.0" [package.extras] @@ -4726,4 +4875,4 @@ test = ["pytest", "websockets"] [metadata] lock-version = "2.1" python-versions = ">=3.11,<3.14" -content-hash = "6a9dbf90bac08551eeaeb90b85c3b6c3566de17b4258f035401034bfe7ab3338" +content-hash = "dfa3bf2de0a403d70430954a74c6edb179bedf049e0338218bffc19451480ad2" diff --git a/pyproject.toml b/pyproject.toml index d045bf79..b2ea9d1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,6 +65,7 @@ jupyterlab = "^4" isort = "^9" black = "^26" autoflake = "^2" +pytest-cov = "^7" [build-system] requires = ["poetry-core>=2.0"] diff --git a/test/api_surface.json b/test/api_surface.json new file mode 100644 index 00000000..1a7df2f4 --- /dev/null +++ b/test/api_surface.json @@ -0,0 +1,461 @@ +{ + "meta": { + "reason": "Initial snapshot of the public API (test hardening Phase 6)", + "date": "2026-10-24", + "commit": "db7d193", + "python": "3.11.9", + "torch": "2.14.1+cu130" + }, + "history": [ + { + "date": "2026-10-24", + "commit": "db7d193", + "reason": "Initial snapshot of the public API (test hardening Phase 6)" + } + ], + "surface": { + "bindsnet.analysis.dotTrace_plotter.main": "()", + "bindsnet.analysis.dotTrace_plotter.plotGrids": "(gridData)", + "bindsnet.analysis.dotTrace_plotter.plotPerformance": "(perfData, fname)", + "bindsnet.analysis.dotTrace_plotter.plotRewards": "(rewData, fname)", + "bindsnet.analysis.pipeline_analysis.MatplotlibAnalyzer": "(**kwargs)", + "bindsnet.analysis.pipeline_analysis.MatplotlibAnalyzer.finalize_step": "(self)", + "bindsnet.analysis.pipeline_analysis.MatplotlibAnalyzer.plot_conv2d_weights": "(self, weights, tag='conv2d', step=None)", + "bindsnet.analysis.pipeline_analysis.MatplotlibAnalyzer.plot_obs": "(self, obs, tag='obs', step=None)", + "bindsnet.analysis.pipeline_analysis.MatplotlibAnalyzer.plot_reward": "(self, reward_list, reward_window=None, tag='reward', step=None)", + "bindsnet.analysis.pipeline_analysis.MatplotlibAnalyzer.plot_spikes": "(self, spike_record, tag='spike', step=None)", + "bindsnet.analysis.pipeline_analysis.MatplotlibAnalyzer.plot_voltages": "(self, voltage_record, thresholds=None, tag='voltage', step=None)", + "bindsnet.analysis.pipeline_analysis.PipelineAnalyzer": "()", + "bindsnet.analysis.pipeline_analysis.PipelineAnalyzer.finalize_step": "(self)", + "bindsnet.analysis.pipeline_analysis.PipelineAnalyzer.plot_conv2d_weights": "(self, weights, tag='conv2d', step=None)", + "bindsnet.analysis.pipeline_analysis.PipelineAnalyzer.plot_obs": "(self, obs, tag='obs', step=None)", + "bindsnet.analysis.pipeline_analysis.PipelineAnalyzer.plot_reward": "(self, reward_list, reward_window=None, tag='reward', step=None)", + "bindsnet.analysis.pipeline_analysis.PipelineAnalyzer.plot_spikes": "(self, spike_record, tag='spike', step=None)", + "bindsnet.analysis.pipeline_analysis.PipelineAnalyzer.plot_voltages": "(self, voltage_record, thresholds=None, tag='voltage', step=None)", + "bindsnet.analysis.pipeline_analysis.TensorboardAnalyzer": "(summary_directory='./logs')", + "bindsnet.analysis.pipeline_analysis.TensorboardAnalyzer.finalize_step": "(self)", + "bindsnet.analysis.pipeline_analysis.TensorboardAnalyzer.plot_conv2d_weights": "(self, weights, tag='conv2d', step=None)", + "bindsnet.analysis.pipeline_analysis.TensorboardAnalyzer.plot_obs": "(self, obs, tag='obs', step=None)", + "bindsnet.analysis.pipeline_analysis.TensorboardAnalyzer.plot_reward": "(self, reward_list, reward_window=None, tag='reward', step=None)", + "bindsnet.analysis.pipeline_analysis.TensorboardAnalyzer.plot_spikes": "(self, spike_record, tag='spike', step=None)", + "bindsnet.analysis.pipeline_analysis.TensorboardAnalyzer.plot_voltages": "(self, voltage_record, thresholds=None, tag='voltage', step=None)", + "bindsnet.analysis.plotting.plot_assignments": "(assignments, im=None, figsize=(5, 5), classes=None, save=None)", + "bindsnet.analysis.plotting.plot_conv2d_weights": "(weights, wmin=0.0, wmax=1.0, im=None, figsize=(5, 5), cmap='hot_r', title=None)", + "bindsnet.analysis.plotting.plot_input": "(image, inpt, label=None, axes=None, ims=None, figsize=(8, 4))", + "bindsnet.analysis.plotting.plot_local_connection_2d_weights": "(lc, input_channel=0, output_channel=None, im=None, lines=True, figsize=(5, 5), cmap='hot_r', color='r', title=None)", + "bindsnet.analysis.plotting.plot_locally_connected_weights": "(weights, n_filters, kernel_size, conv_size, locations, input_sqrt, wmin=0.0, wmax=1.0, im=None, lines=True, figsize=(5, 5), cmap='hot_r', title=None)", + "bindsnet.analysis.plotting.plot_performance": "(performances, ax=None, figsize=(7, 4), x_scale=1, save=None)", + "bindsnet.analysis.plotting.plot_spikes": "(spikes, time=None, n_neurons=None, ims=None, axes=None, figsize=(8.0, 4.5))", + "bindsnet.analysis.plotting.plot_voltages": "(voltages, ims=None, axes=None, time=None, n_neurons=None, cmap='jet', plot_type='color', thresholds=None, figsize=(8.0, 4.5))", + "bindsnet.analysis.plotting.plot_weights": "(weights, wmin=0, wmax=1, im=None, figsize=(5, 5), cmap='hot_r', save=None, title=None)", + "bindsnet.analysis.visualization.plot_spike_trains_for_example": "(spikes, n_ex=None, top_k=None, indices=None)", + "bindsnet.analysis.visualization.plot_voltage": "(voltage, n_ex=0, n_neuron=0, time=None, threshold=None)", + "bindsnet.analysis.visualization.plot_weights_movie": "(ws, sample_every=1)", + "bindsnet.analysis.visualization.summary": "(net)", + "bindsnet.conversion.conversion.FeatureExtractor": "(submodule)", + "bindsnet.conversion.conversion.FeatureExtractor.forward": "(self, x)", + "bindsnet.conversion.conversion.Permute": "(dims)", + "bindsnet.conversion.conversion.Permute.forward": "(self, x)", + "bindsnet.conversion.conversion.ann_to_snn": "(ann, input_shape, data=None, percentile=99.9, node_type=bindsnet.conversion.nodes.SubtractiveResetIFNodes, **kwargs)", + "bindsnet.conversion.conversion.data_based_normalization": "(ann, data, percentile=99.9)", + "bindsnet.conversion.nodes.PassThroughNodes": "(n=None, shape=None, traces=False, traces_additive=False, tc_trace=20.0, trace_scale=1.0, sum_input=False)", + "bindsnet.conversion.nodes.PassThroughNodes.forward": "(self, x)", + "bindsnet.conversion.nodes.PassThroughNodes.reset_state_variables": "(self)", + "bindsnet.conversion.nodes.SubtractiveResetIFNodes": "(n=None, shape=None, traces=False, traces_additive=False, tc_trace=20.0, trace_scale=1.0, sum_input=False, thresh=-52.0, reset=-65.0, refrac=5, lbound=None, **kwargs)", + "bindsnet.conversion.nodes.SubtractiveResetIFNodes.forward": "(self, x)", + "bindsnet.conversion.nodes.SubtractiveResetIFNodes.reset_state_variables": "(self)", + "bindsnet.conversion.nodes.SubtractiveResetIFNodes.set_batch_size": "(self, batch_size)", + "bindsnet.conversion.topology.ConstantPad2dConnection": "(source, target, padding, nu=None, weight_decay=0.0, **kwargs)", + "bindsnet.conversion.topology.ConstantPad2dConnection.compute": "(self, s)", + "bindsnet.conversion.topology.PermuteConnection": "(source, target, dims, nu=None, weight_decay=0.0, **kwargs)", + "bindsnet.conversion.topology.PermuteConnection.compute": "(self, s)", + "bindsnet.datasets.alov300.ALOV300": "(root, transform, input_size, download=False)", + "bindsnet.datasets.alov300.ALOV300.get_bb": "(self, ann)", + "bindsnet.datasets.alov300.ALOV300.get_orig_sample": "(self, idx, i=1)", + "bindsnet.datasets.alov300.ALOV300.get_sample": "(self, idx)", + "bindsnet.datasets.alov300.ALOV300.progress": "(self, count, block_size, total_size)", + "bindsnet.datasets.alov300.ALOV300.show": "(self, idx, is_current=1)", + "bindsnet.datasets.alov300.ALOV300.show_sample": "(self, idx)", + "bindsnet.datasets.collate.safe_worker_check": "()", + "bindsnet.datasets.collate.time_aware_collate": "(batch)", + "bindsnet.datasets.dataloader.DataLoader": "(dataset, batch_size=1, shuffle=False, sampler=None, batch_sampler=None, num_workers=0, collate_fn=bindsnet.datasets.collate.time_aware_collate, pin_memory=False, drop_last=False, timeout=0, worker_init_fn=None)", + "bindsnet.datasets.davis.Davis": "(root, task='unsupervised', subset='train', sequences='all', resolution='480p', size=(600, 480), codalab=False, download=False, num_samples=-1)", + "bindsnet.datasets.davis.Davis.get_all_images": "(self, sequence)", + "bindsnet.datasets.davis.Davis.get_all_masks": "(self, sequence, separate_objects_masks=False)", + "bindsnet.datasets.davis.Davis.get_frames": "(self, sequence)", + "bindsnet.datasets.davis.Davis.get_sequences": "(self)", + "bindsnet.datasets.davis.Davis.progress": "(count, block_size, total_size)", + "bindsnet.datasets.preprocess.BoundingBox": "(x1, y1, x2, y2)", + "bindsnet.datasets.preprocess.BoundingBox.compute_output_height": "(self)", + "bindsnet.datasets.preprocess.BoundingBox.compute_output_width": "(self)", + "bindsnet.datasets.preprocess.BoundingBox.edge_spacing_x": "(self)", + "bindsnet.datasets.preprocess.BoundingBox.edge_spacing_y": "(self)", + "bindsnet.datasets.preprocess.BoundingBox.get_bb_list": "(self)", + "bindsnet.datasets.preprocess.BoundingBox.get_center_x": "(self)", + "bindsnet.datasets.preprocess.BoundingBox.get_center_y": "(self)", + "bindsnet.datasets.preprocess.BoundingBox.get_height": "(self)", + "bindsnet.datasets.preprocess.BoundingBox.get_width": "(self)", + "bindsnet.datasets.preprocess.BoundingBox.print_bb": "(self)", + "bindsnet.datasets.preprocess.BoundingBox.recenter": "(self, search_loc, edge_spacing_x, edge_spacing_y, bbox_gt_recentered)", + "bindsnet.datasets.preprocess.BoundingBox.scale": "(self, image)", + "bindsnet.datasets.preprocess.BoundingBox.shift": "(self, image, lambda_scale_frac, lambda_shift_frac, min_scale, max_scale, shift_motion_model, bbox_rand)", + "bindsnet.datasets.preprocess.BoundingBox.uncenter": "(self, raw_image, search_location, edge_spacing_x, edge_spacing_y)", + "bindsnet.datasets.preprocess.BoundingBox.unscale": "(self, image)", + "bindsnet.datasets.preprocess.NormalizeToTensor": "()", + "bindsnet.datasets.preprocess.Rescale": "(output_size)", + "bindsnet.datasets.preprocess.bgr2rgb": "(image)", + "bindsnet.datasets.preprocess.binary_image": "(image)", + "bindsnet.datasets.preprocess.computeCropPadImageLocation": "(bbox_tight, image)", + "bindsnet.datasets.preprocess.crop": "(image, x1, x2, y1, y2)", + "bindsnet.datasets.preprocess.cropPadImage": "(bbox_tight, image)", + "bindsnet.datasets.preprocess.crop_sample": "(sample)", + "bindsnet.datasets.preprocess.gray_scale": "(image)", + "bindsnet.datasets.preprocess.sample_exp_two_sides": "(lambda_)", + "bindsnet.datasets.preprocess.sample_rand_uniform": "()", + "bindsnet.datasets.preprocess.shift_crop_training_sample": "(sample, bb_params)", + "bindsnet.datasets.preprocess.subsample": "(image, x, y)", + "bindsnet.datasets.spoken_mnist.SpokenMNIST": "(path, download=False, shuffle=True, train=True, split=0.8, num_samples=-1)", + "bindsnet.datasets.spoken_mnist.SpokenMNIST.process_data": "(self, file_names)", + "bindsnet.datasets.torchvision_wrapper.create_torchvision_dataset_wrapper": "(ds_type)", + "bindsnet.encoding.encoders.BernoulliEncoder": "(time, dt=1.0, **kwargs)", + "bindsnet.encoding.encoders.Encoder": "(*args, **kwargs)", + "bindsnet.encoding.encoders.NullEncoder": "()", + "bindsnet.encoding.encoders.PoissonEncoder": "(time, dt=1.0, approx=False, **kwargs)", + "bindsnet.encoding.encoders.RankOrderEncoder": "(time, dt=1.0, **kwargs)", + "bindsnet.encoding.encoders.RepeatEncoder": "(time, dt=1.0, **kwargs)", + "bindsnet.encoding.encoders.SingleEncoder": "(time, dt=1.0, sparsity=0.5, **kwargs)", + "bindsnet.encoding.encodings.bernoulli": "(datum, time=None, dt=1.0, device='cpu', **kwargs)", + "bindsnet.encoding.encodings.poisson": "(datum, time, dt=1.0, device='cpu', approx=False, **kwargs)", + "bindsnet.encoding.encodings.rank_order": "(datum, time, dt=1.0, device='cpu', **kwargs)", + "bindsnet.encoding.encodings.repeat": "(datum, time, dt=1.0, **kwargs)", + "bindsnet.encoding.encodings.single": "(datum, time, dt=1.0, sparsity=0.5, device='cpu', **kwargs)", + "bindsnet.encoding.loaders.bernoulli_loader": "(data, time=None, dt=1.0, **kwargs)", + "bindsnet.encoding.loaders.poisson_loader": "(data, time, dt=1.0, **kwargs)", + "bindsnet.encoding.loaders.rank_order_loader": "(data, time, dt=1.0, **kwargs)", + "bindsnet.environment.cue_reward.CueRewardSimulator": "(**kwargs)", + "bindsnet.environment.cue_reward.CueRewardSimulator.make": "(self, name)", + "bindsnet.environment.cue_reward.CueRewardSimulator.render": "(self)", + "bindsnet.environment.cue_reward.CueRewardSimulator.reset": "(self)", + "bindsnet.environment.cue_reward.CueRewardSimulator.step": "(self, action)", + "bindsnet.environment.cue_reward.driver": "()", + "bindsnet.environment.dot_simulator.Dot": "(r=0, c=0, t=1)", + "bindsnet.environment.dot_simulator.Dot.move": "(self, r, c)", + "bindsnet.environment.dot_simulator.DotSimulator": "(t, **kwargs)", + "bindsnet.environment.dot_simulator.DotSimulator.addFileSuffix": "(self, suffix)", + "bindsnet.environment.dot_simulator.DotSimulator.changeFileSuffix": "(self, sFrom, sTo)", + "bindsnet.environment.dot_simulator.DotSimulator.compute_reward": "(self)", + "bindsnet.environment.dot_simulator.DotSimulator.cycleOutFiles": "(self, newInt=-1)", + "bindsnet.environment.dot_simulator.DotSimulator.movePoint": "(self, d, dotDir=-1)", + "bindsnet.environment.dot_simulator.DotSimulator.render": "(self)", + "bindsnet.environment.dot_simulator.DotSimulator.reset": "(self)", + "bindsnet.environment.dot_simulator.DotSimulator.step": "(self, action)", + "bindsnet.environment.dot_simulator.driver": "()", + "bindsnet.environment.environment.Environment": "()", + "bindsnet.environment.environment.Environment.close": "(self)", + "bindsnet.environment.environment.Environment.preprocess": "(self)", + "bindsnet.environment.environment.Environment.render": "(self)", + "bindsnet.environment.environment.Environment.reset": "(self)", + "bindsnet.environment.environment.Environment.step": "(self, a)", + "bindsnet.environment.environment.GymEnvironment": "(name, render_mode='rgb_array', encoder=, **kwargs)", + "bindsnet.environment.environment.GymEnvironment.close": "(self)", + "bindsnet.environment.environment.GymEnvironment.preprocess": "(self)", + "bindsnet.environment.environment.GymEnvironment.render": "(self)", + "bindsnet.environment.environment.GymEnvironment.reset": "(self, seed=None)", + "bindsnet.environment.environment.GymEnvironment.step": "(self, a)", + "bindsnet.environment.environment.GymEnvironment.update_history": "(self)", + "bindsnet.environment.environment.GymEnvironment.update_index": "(self)", + "bindsnet.evaluation.evaluation.all_activity": "(spikes, assignments, n_labels)", + "bindsnet.evaluation.evaluation.assign_labels": "(spikes, labels, n_labels, rates=None, alpha=1.0)", + "bindsnet.evaluation.evaluation.logreg_fit": "(spikes, labels, logreg)", + "bindsnet.evaluation.evaluation.logreg_predict": "(spikes, logreg)", + "bindsnet.evaluation.evaluation.ngram": "(spikes, ngram_scores, n_labels, n)", + "bindsnet.evaluation.evaluation.proportion_weighting": "(spikes, assignments, proportions, n_labels)", + "bindsnet.evaluation.evaluation.update_ngram_scores": "(spikes, labels, n_labels, n, ngram_scores)", + "bindsnet.learning.MCC_learning.DiehlAndCook": "(connection, feature_value, range=None, nu=None, reduction=None, decay=0.0, **kwargs)", + "bindsnet.learning.MCC_learning.DiehlAndCook.reset_state_variables": "(self)", + "bindsnet.learning.MCC_learning.Hebbian": "(connection, feature_value, range=None, nu=None, reduction=None, decay=0.0, **kwargs)", + "bindsnet.learning.MCC_learning.Hebbian.reset_state_variables": "(self)", + "bindsnet.learning.MCC_learning.MCC_LearningRule": "(connection, feature_value, range=None, nu=None, reduction=None, decay=0.0, enforce_polarity=False, **kwargs)", + "bindsnet.learning.MCC_learning.MCC_LearningRule.reset_state_variables": "(self)", + "bindsnet.learning.MCC_learning.MCC_LearningRule.update": "(self, **kwargs)", + "bindsnet.learning.MCC_learning.MSTDP": "(connection, feature_value, range=None, nu=None, reduction=None, decay=0.0, enforce_polarity=False, **kwargs)", + "bindsnet.learning.MCC_learning.MSTDP.reset_state_variables": "(self)", + "bindsnet.learning.MCC_learning.MSTDPET": "(connection, feature_value, range=None, nu=None, reduction=None, decay=0.0, enforce_polarity=False, **kwargs)", + "bindsnet.learning.MCC_learning.MSTDPET.reset_state_variables": "(self)", + "bindsnet.learning.MCC_learning.NoOp": "(**args)", + "bindsnet.learning.MCC_learning.NoOp.reset_state_variables": "(self)", + "bindsnet.learning.MCC_learning.NoOp.update": "(self, **kwargs)", + "bindsnet.learning.MCC_learning.PostPre": "(connection, feature_value, range=None, nu=None, reduction=None, decay=0.0, enforce_polarity=False, **kwargs)", + "bindsnet.learning.MCC_learning.PostPre.reset_state_variables": "(self)", + "bindsnet.learning.learning.DiehlAndCook": "(connection, nu=None, reduction=None, weight_decay=0.0, **kwargs)", + "bindsnet.learning.learning.Hebbian": "(connection, nu=None, reduction=None, weight_decay=0.0, **kwargs)", + "bindsnet.learning.learning.LearningRule": "(connection, nu=None, reduction=None, weight_decay=0.0, **kwargs)", + "bindsnet.learning.learning.LearningRule.update": "(self)", + "bindsnet.learning.learning.MSTDP": "(connection, nu=None, reduction=None, weight_decay=0.0, **kwargs)", + "bindsnet.learning.learning.MSTDPET": "(connection, nu=None, reduction=None, weight_decay=0.0, **kwargs)", + "bindsnet.learning.learning.NoOp": "(connection, nu=None, reduction=None, weight_decay=0.0, **kwargs)", + "bindsnet.learning.learning.NoOp.update": "(self, **kwargs)", + "bindsnet.learning.learning.PostPre": "(connection, nu=None, reduction=None, weight_decay=0.0, **kwargs)", + "bindsnet.learning.learning.Rmax": "(connection, nu=None, reduction=None, weight_decay=0.0, **kwargs)", + "bindsnet.learning.learning.WeightDependentPostPre": "(connection, nu=None, reduction=None, weight_decay=0.0, **kwargs)", + "bindsnet.learning.reward.AbstractReward": "()", + "bindsnet.learning.reward.AbstractReward.compute": "(self, **kwargs)", + "bindsnet.learning.reward.AbstractReward.update": "(self, **kwargs)", + "bindsnet.learning.reward.MovingAvgRPE": "(**kwargs)", + "bindsnet.learning.reward.MovingAvgRPE.compute": "(self, **kwargs)", + "bindsnet.learning.reward.MovingAvgRPE.update": "(self, **kwargs)", + "bindsnet.models.models.DiehlAndCook2015": "(n_inpt, device='cpu', batch_size=None, sparse=False, n_neurons=100, exc=22.5, inh=17.5, dt=1.0, nu=(0.0001, 0.01), reduction=None, wmin=0.0, wmax=1.0, w_dtype=torch.float32, norm=78.4, theta_plus=0.05, tc_theta_decay=10000000.0, inpt_shape=None, inh_thresh=-40.0, exc_thresh=-52.0, learning_rule=None, learning_rule_kwargs=None)", + "bindsnet.models.models.DiehlAndCook2015v2": "(n_inpt, n_neurons=100, inh=17.5, dt=1.0, nu=(0.0001, 0.01), reduction=None, wmin=0.0, wmax=1.0, norm=78.4, theta_plus=0.05, tc_theta_decay=10000000.0, inpt_shape=None, exc_thresh=-52.0)", + "bindsnet.models.models.IncreasingInhibitionNetwork": "(n_input, n_neurons=100, start_inhib=1.0, max_inhib=100.0, dt=1.0, nu=(0.0001, 0.01), reduction=None, wmin=0.0, wmax=1.0, norm=78.4, theta_plus=0.05, tc_theta_decay=10000000.0, inpt_shape=None, exc_thresh=-52.0)", + "bindsnet.models.models.LocallyConnectedNetwork": "(n_inpt, input_shape, kernel_size, stride, n_filters, inh=25.0, dt=1.0, nu=(0.0001, 0.01), reduction=None, theta_plus=0.05, tc_theta_decay=10000000.0, wmin=0.0, wmax=1.0, norm=0.2, exc_thresh=-52.0)", + "bindsnet.models.models.TwoLayerNetwork": "(n_inpt, n_neurons=100, dt=1.0, wmin=0.0, wmax=1.0, nu=(0.0001, 0.01), reduction=None, norm=78.4)", + "bindsnet.network.monitors.AbstractMonitor": "()", + "bindsnet.network.monitors.Monitor": "(obj, state_vars, time=None, batch_size=1, device='cpu', sparse=False)", + "bindsnet.network.monitors.Monitor.get": "(self, var)", + "bindsnet.network.monitors.Monitor.record": "(self)", + "bindsnet.network.monitors.Monitor.reset_state_variables": "(self)", + "bindsnet.network.monitors.NetworkMonitor": "(network, layers=None, connections=None, state_vars=None, time=None)", + "bindsnet.network.monitors.NetworkMonitor.get": "(self)", + "bindsnet.network.monitors.NetworkMonitor.record": "(self)", + "bindsnet.network.monitors.NetworkMonitor.reset_state_variables": "(self)", + "bindsnet.network.monitors.NetworkMonitor.save": "(self, path, fmt='npz')", + "bindsnet.network.network.Network": "(dt=1.0, batch_size=1, learning=True, reward_fn=None)", + "bindsnet.network.network.Network.add_connection": "(self, connection, source, target)", + "bindsnet.network.network.Network.add_layer": "(self, layer, name)", + "bindsnet.network.network.Network.add_monitor": "(self, monitor, name)", + "bindsnet.network.network.Network.clone": "(self)", + "bindsnet.network.network.Network.reset_state_variables": "(self)", + "bindsnet.network.network.Network.run": "(self, inputs, time, one_step=False, **kwargs)", + "bindsnet.network.network.Network.save": "(self, file_name)", + "bindsnet.network.network.Network.train": "(self, mode=True)", + "bindsnet.network.network.load": "(file_name, map_location='cpu', learning=None, weights_only=False)", + "bindsnet.network.nodes.AbstractInput": "()", + "bindsnet.network.nodes.AdaptiveLIFNodes": "(n=None, shape=None, traces=False, traces_additive=False, tc_trace=20.0, trace_scale=1.0, sum_input=False, rest=-65.0, reset=-65.0, thresh=-52.0, refrac=5, tc_decay=100.0, theta_plus=0.05, tc_theta_decay=10000000.0, lbound=None, **kwargs)", + "bindsnet.network.nodes.AdaptiveLIFNodes.compute_decays": "(self, dt)", + "bindsnet.network.nodes.AdaptiveLIFNodes.forward": "(self, x)", + "bindsnet.network.nodes.AdaptiveLIFNodes.reset_state_variables": "(self)", + "bindsnet.network.nodes.AdaptiveLIFNodes.set_batch_size": "(self, batch_size)", + "bindsnet.network.nodes.BoostedLIFNodes": "(n=None, shape=None, traces=False, traces_additive=False, tc_trace=20.0, trace_scale=1.0, sum_input=False, thresh=13.0, refrac=5, tc_decay=100.0, **kwargs)", + "bindsnet.network.nodes.BoostedLIFNodes.compute_decays": "(self, dt)", + "bindsnet.network.nodes.BoostedLIFNodes.forward": "(self, x)", + "bindsnet.network.nodes.BoostedLIFNodes.reset_state_variables": "(self)", + "bindsnet.network.nodes.BoostedLIFNodes.set_batch_size": "(self, batch_size)", + "bindsnet.network.nodes.CSRMNodes": "(n=None, shape=None, traces=False, traces_additive=False, tc_trace=20.0, trace_scale=1.0, sum_input=False, rest=-65.0, thresh=-52.0, responseKernel='ExponentialKernel', refractoryKernel='EtaKernel', tau=1, res_window_size=20, ref_window_size=10, reset_const=50, tc_decay=100.0, theta_plus=0.05, tc_theta_decay=10000000.0, lbound=None, **kwargs)", + "bindsnet.network.nodes.CSRMNodes.AlphaKernel": "(self, dt)", + "bindsnet.network.nodes.CSRMNodes.AlphaKernelSLAYER": "(self, dt)", + "bindsnet.network.nodes.CSRMNodes.EtaKernel": "(self, dt)", + "bindsnet.network.nodes.CSRMNodes.ExponentialKernel": "(self, dt)", + "bindsnet.network.nodes.CSRMNodes.LaplacianKernel": "(self, dt)", + "bindsnet.network.nodes.CSRMNodes.RectangularKernel": "(self, dt)", + "bindsnet.network.nodes.CSRMNodes.TriangularKernel": "(self, dt)", + "bindsnet.network.nodes.CSRMNodes.compute_decays": "(self, dt)", + "bindsnet.network.nodes.CSRMNodes.forward": "(self, x)", + "bindsnet.network.nodes.CSRMNodes.reset_state_variables": "(self)", + "bindsnet.network.nodes.CSRMNodes.set_batch_size": "(self, batch_size)", + "bindsnet.network.nodes.CurrentLIFNodes": "(n=None, shape=None, traces=False, traces_additive=False, tc_trace=20.0, trace_scale=1.0, sum_input=False, thresh=-52.0, rest=-65.0, reset=-65.0, refrac=5, tc_decay=100.0, tc_i_decay=2.0, lbound=None, **kwargs)", + "bindsnet.network.nodes.CurrentLIFNodes.compute_decays": "(self, dt)", + "bindsnet.network.nodes.CurrentLIFNodes.forward": "(self, x)", + "bindsnet.network.nodes.CurrentLIFNodes.reset_state_variables": "(self)", + "bindsnet.network.nodes.CurrentLIFNodes.set_batch_size": "(self, batch_size)", + "bindsnet.network.nodes.DiehlAndCookNodes": "(n=None, shape=None, traces=False, traces_additive=False, tc_trace=20.0, trace_scale=1.0, sum_input=False, thresh=-52.0, rest=-65.0, reset=-65.0, refrac=5, tc_decay=100.0, theta_plus=0.05, tc_theta_decay=10000000.0, lbound=None, one_spike=True, **kwargs)", + "bindsnet.network.nodes.DiehlAndCookNodes.compute_decays": "(self, dt)", + "bindsnet.network.nodes.DiehlAndCookNodes.forward": "(self, x)", + "bindsnet.network.nodes.DiehlAndCookNodes.reset_state_variables": "(self)", + "bindsnet.network.nodes.DiehlAndCookNodes.set_batch_size": "(self, batch_size)", + "bindsnet.network.nodes.IFNodes": "(n=None, shape=None, traces=False, traces_additive=False, tc_trace=20.0, trace_scale=1.0, sum_input=False, thresh=-52.0, reset=-65.0, refrac=5, lbound=None, **kwargs)", + "bindsnet.network.nodes.IFNodes.forward": "(self, x)", + "bindsnet.network.nodes.IFNodes.reset_state_variables": "(self)", + "bindsnet.network.nodes.IFNodes.set_batch_size": "(self, batch_size)", + "bindsnet.network.nodes.Input": "(n=None, shape=None, traces=False, traces_additive=False, tc_trace=20.0, trace_scale=1.0, sum_input=False, **kwargs)", + "bindsnet.network.nodes.Input.forward": "(self, x)", + "bindsnet.network.nodes.Input.reset_state_variables": "(self)", + "bindsnet.network.nodes.IzhikevichNodes": "(n=None, shape=None, traces=False, traces_additive=False, tc_trace=20.0, trace_scale=1.0, sum_input=False, excitatory=1, thresh=45.0, rest=-65.0, lbound=None, **kwargs)", + "bindsnet.network.nodes.IzhikevichNodes.forward": "(self, x)", + "bindsnet.network.nodes.IzhikevichNodes.reset_state_variables": "(self)", + "bindsnet.network.nodes.IzhikevichNodes.set_batch_size": "(self, batch_size)", + "bindsnet.network.nodes.LIFNodes": "(n=None, shape=None, traces=False, traces_additive=False, tc_trace=20.0, trace_scale=1.0, sum_input=False, thresh=-52.0, rest=-65.0, reset=-65.0, refrac=5, tc_decay=100.0, lbound=None, **kwargs)", + "bindsnet.network.nodes.LIFNodes.compute_decays": "(self, dt)", + "bindsnet.network.nodes.LIFNodes.forward": "(self, x)", + "bindsnet.network.nodes.LIFNodes.reset_state_variables": "(self)", + "bindsnet.network.nodes.LIFNodes.set_batch_size": "(self, batch_size)", + "bindsnet.network.nodes.McCullochPitts": "(n=None, shape=None, traces=False, traces_additive=False, tc_trace=20.0, trace_scale=1.0, sum_input=False, thresh=1.0, **kwargs)", + "bindsnet.network.nodes.McCullochPitts.forward": "(self, x)", + "bindsnet.network.nodes.McCullochPitts.reset_state_variables": "(self)", + "bindsnet.network.nodes.McCullochPitts.set_batch_size": "(self, batch_size)", + "bindsnet.network.nodes.Nodes": "(n=None, shape=None, traces=False, traces_additive=False, tc_trace=20.0, trace_scale=1.0, sum_input=False, learning=True, **kwargs)", + "bindsnet.network.nodes.Nodes.compute_decays": "(self, dt)", + "bindsnet.network.nodes.Nodes.forward": "(self, x)", + "bindsnet.network.nodes.Nodes.reset_state_variables": "(self)", + "bindsnet.network.nodes.Nodes.set_batch_size": "(self, batch_size)", + "bindsnet.network.nodes.Nodes.train": "(self, mode=True)", + "bindsnet.network.nodes.SRM0Nodes": "(n=None, shape=None, traces=False, traces_additive=False, tc_trace=20.0, trace_scale=1.0, sum_input=False, thresh=-50.0, rest=-70.0, reset=-70.0, refrac=5, tc_decay=10.0, lbound=None, eps_0=1.0, rho_0=1.0, d_thresh=5.0, **kwargs)", + "bindsnet.network.nodes.SRM0Nodes.compute_decays": "(self, dt)", + "bindsnet.network.nodes.SRM0Nodes.forward": "(self, x)", + "bindsnet.network.nodes.SRM0Nodes.reset_state_variables": "(self)", + "bindsnet.network.nodes.SRM0Nodes.set_batch_size": "(self, batch_size)", + "bindsnet.network.topology.AbstractConnection": "(source, target, nu=None, reduction=None, weight_decay=0.0, **kwargs)", + "bindsnet.network.topology.AbstractConnection.cast_dtype_if_needed": "(w, w_dtype)", + "bindsnet.network.topology.AbstractConnection.compute": "(self, s)", + "bindsnet.network.topology.AbstractConnection.reset_state_variables": "(self)", + "bindsnet.network.topology.AbstractConnection.update": "(self, **kwargs)", + "bindsnet.network.topology.AbstractMulticompartmentConnection": "(source, target, device, pipeline=None, **kwargs)", + "bindsnet.network.topology.AbstractMulticompartmentConnection.append_pipeline": "(self, feature)", + "bindsnet.network.topology.AbstractMulticompartmentConnection.compute": "(self, s)", + "bindsnet.network.topology.AbstractMulticompartmentConnection.insert_pipeline": "(self, feature, index)", + "bindsnet.network.topology.AbstractMulticompartmentConnection.remove_pipeline": "(self, feature)", + "bindsnet.network.topology.AbstractMulticompartmentConnection.reset_state_variables": "(self)", + "bindsnet.network.topology.AbstractMulticompartmentConnection.update": "(self, **kwargs)", + "bindsnet.network.topology.Connection": "(source, target, nu=None, reduction=None, weight_decay=0.0, w_dtype=torch.float32, **kwargs)", + "bindsnet.network.topology.Connection.compute": "(self, s)", + "bindsnet.network.topology.Connection.compute_window": "(self, s)", + "bindsnet.network.topology.Connection.normalize": "(self)", + "bindsnet.network.topology.Connection.reset_state_variables": "(self)", + "bindsnet.network.topology.Connection.update": "(self, **kwargs)", + "bindsnet.network.topology.Conv1dConnection": "(source, target, kernel_size, stride=1, padding=0, dilation=1, nu=None, reduction=None, weight_decay=0.0, w_dtype=torch.float32, **kwargs)", + "bindsnet.network.topology.Conv1dConnection.compute": "(self, s)", + "bindsnet.network.topology.Conv1dConnection.normalize": "(self)", + "bindsnet.network.topology.Conv1dConnection.reset_state_variables": "(self)", + "bindsnet.network.topology.Conv1dConnection.update": "(self, **kwargs)", + "bindsnet.network.topology.Conv2dConnection": "(source, target, kernel_size, stride=1, padding=0, dilation=1, nu=None, reduction=None, weight_decay=0.0, w_dtype=torch.float32, **kwargs)", + "bindsnet.network.topology.Conv2dConnection.compute": "(self, s)", + "bindsnet.network.topology.Conv2dConnection.normalize": "(self)", + "bindsnet.network.topology.Conv2dConnection.reset_state_variables": "(self)", + "bindsnet.network.topology.Conv2dConnection.update": "(self, **kwargs)", + "bindsnet.network.topology.Conv3dConnection": "(source, target, kernel_size, stride=1, padding=0, dilation=1, nu=None, reduction=None, weight_decay=0.0, w_dtype=torch.float32, **kwargs)", + "bindsnet.network.topology.Conv3dConnection.compute": "(self, s)", + "bindsnet.network.topology.Conv3dConnection.normalize": "(self)", + "bindsnet.network.topology.Conv3dConnection.reset_state_variables": "(self)", + "bindsnet.network.topology.Conv3dConnection.update": "(self, **kwargs)", + "bindsnet.network.topology.LocalConnection": "(source, target, kernel_size, stride, n_filters, nu=None, reduction=None, weight_decay=0.0, w_dtype=torch.float32, **kwargs)", + "bindsnet.network.topology.LocalConnection.compute": "(self, s)", + "bindsnet.network.topology.LocalConnection.normalize": "(self)", + "bindsnet.network.topology.LocalConnection.reset_state_variables": "(self)", + "bindsnet.network.topology.LocalConnection.update": "(self, **kwargs)", + "bindsnet.network.topology.LocalConnection1D": "(source, target, kernel_size, stride, n_filters, nu=None, reduction=None, weight_decay=0.0, w_dtype=torch.float32, **kwargs)", + "bindsnet.network.topology.LocalConnection1D.compute": "(self, s)", + "bindsnet.network.topology.LocalConnection1D.normalize": "(self)", + "bindsnet.network.topology.LocalConnection1D.reset_state_variables": "(self)", + "bindsnet.network.topology.LocalConnection1D.update": "(self, **kwargs)", + "bindsnet.network.topology.LocalConnection2D": "(source, target, kernel_size, stride, n_filters, nu=None, reduction=None, weight_decay=0.0, w_dtype=torch.float32, **kwargs)", + "bindsnet.network.topology.LocalConnection2D.compute": "(self, s)", + "bindsnet.network.topology.LocalConnection2D.normalize": "(self)", + "bindsnet.network.topology.LocalConnection2D.reset_state_variables": "(self)", + "bindsnet.network.topology.LocalConnection2D.update": "(self, **kwargs)", + "bindsnet.network.topology.LocalConnection3D": "(source, target, kernel_size, stride, n_filters, nu=None, reduction=None, weight_decay=0.0, w_dtype=torch.float32, **kwargs)", + "bindsnet.network.topology.LocalConnection3D.compute": "(self, s)", + "bindsnet.network.topology.LocalConnection3D.normalize": "(self)", + "bindsnet.network.topology.LocalConnection3D.reset_state_variables": "(self)", + "bindsnet.network.topology.LocalConnection3D.update": "(self, **kwargs)", + "bindsnet.network.topology.MaxPoo3dConnection": "(source, target, kernel_size, stride=1, padding=0, dilation=1, **kwargs)", + "bindsnet.network.topology.MaxPoo3dConnection.compute": "(self, s)", + "bindsnet.network.topology.MaxPoo3dConnection.normalize": "(self)", + "bindsnet.network.topology.MaxPoo3dConnection.reset_state_variables": "(self)", + "bindsnet.network.topology.MaxPoo3dConnection.update": "(self, **kwargs)", + "bindsnet.network.topology.MaxPool1dConnection": "(source, target, kernel_size, stride=1, padding=0, dilation=1, **kwargs)", + "bindsnet.network.topology.MaxPool1dConnection.compute": "(self, s)", + "bindsnet.network.topology.MaxPool1dConnection.normalize": "(self)", + "bindsnet.network.topology.MaxPool1dConnection.reset_state_variables": "(self)", + "bindsnet.network.topology.MaxPool1dConnection.update": "(self, **kwargs)", + "bindsnet.network.topology.MaxPool2dConnection": "(source, target, kernel_size, stride=1, padding=0, dilation=1, **kwargs)", + "bindsnet.network.topology.MaxPool2dConnection.compute": "(self, s)", + "bindsnet.network.topology.MaxPool2dConnection.normalize": "(self)", + "bindsnet.network.topology.MaxPool2dConnection.reset_state_variables": "(self)", + "bindsnet.network.topology.MaxPool2dConnection.update": "(self, **kwargs)", + "bindsnet.network.topology.MeanFieldConnection": "(source, target, nu=None, weight_decay=0.0, w_dtype=torch.float32, **kwargs)", + "bindsnet.network.topology.MeanFieldConnection.compute": "(self, s)", + "bindsnet.network.topology.MeanFieldConnection.normalize": "(self)", + "bindsnet.network.topology.MeanFieldConnection.reset_state_variables": "(self)", + "bindsnet.network.topology.MeanFieldConnection.update": "(self, **kwargs)", + "bindsnet.network.topology.MulticompartmentConnection": "(source, target, device, pipeline=[], manual_update=False, traces=False, sparse_compute=False, **kwargs)", + "bindsnet.network.topology.MulticompartmentConnection.compute": "(self, s)", + "bindsnet.network.topology.MulticompartmentConnection.compute_window": "(self, s)", + "bindsnet.network.topology.MulticompartmentConnection.normalize": "(self)", + "bindsnet.network.topology.MulticompartmentConnection.reset_state_variables": "(self)", + "bindsnet.network.topology.MulticompartmentConnection.update": "(self, **kwargs)", + "bindsnet.network.topology.SparseConnection": "(*args, **kwargs)", + "bindsnet.network.topology_features.AbstractFeature": "(name, value=None, value_dtype=torch.float32, range=None, clamp_frequency=1, norm=None, learning_rule=None, nu=None, reduction=None, enforce_polarity=False, decay=0.0, parent_feature=None, sparse=False, batch_size=1, **kwargs)", + "bindsnet.network.topology_features.AbstractFeature.assert_feature_in_range": "(self)", + "bindsnet.network.topology_features.AbstractFeature.assert_valid_range": "(self)", + "bindsnet.network.topology_features.AbstractFeature.assert_valid_shape": "(self, source_shape, target_shape, f)", + "bindsnet.network.topology_features.AbstractFeature.cast_dtype_if_needed": "(value, value_dtype)", + "bindsnet.network.topology_features.AbstractFeature.compute": "(self, s)", + "bindsnet.network.topology_features.AbstractFeature.link": "(self, parent_feature)", + "bindsnet.network.topology_features.AbstractFeature.normalize": "(self)", + "bindsnet.network.topology_features.AbstractFeature.prime_feature": "(self, connection, device, **kwargs)", + "bindsnet.network.topology_features.AbstractFeature.reset_state_variables": "(self)", + "bindsnet.network.topology_features.AbstractFeature.update": "(self, **kwargs)", + "bindsnet.network.topology_features.AbstractSubFeature": "(name, parent_feature)", + "bindsnet.network.topology_features.AbstractSubFeature.compute": "(self, s)", + "bindsnet.network.topology_features.AdaptationBaseOtherSynaps": "(name, value=None, value_dtype=torch.float32, ann_values=None, const_update_rate=0.1, const_decay=0.01, sparse=False, batch_size=1)", + "bindsnet.network.topology_features.AdaptationBaseOtherSynaps.compute": "(self, s)", + "bindsnet.network.topology_features.AdaptationBaseOtherSynaps.reset_state_variables": "(self)", + "bindsnet.network.topology_features.AdaptationBaseSynapsHistory": "(name, value=None, value_dtype=torch.float32, ann_values=None, const_update_rate=0.1, const_decay=0.001, sparse=False, batch_size=1)", + "bindsnet.network.topology_features.AdaptationBaseSynapsHistory.compute": "(self, s)", + "bindsnet.network.topology_features.AdaptationBaseSynapsHistory.reset_state_variables": "(self)", + "bindsnet.network.topology_features.Bias": "(name, value=None, value_dtype=torch.float32, range=None, norm=None, sparse=False, batch_size=1)", + "bindsnet.network.topology_features.Bias.compute": "(self, s)", + "bindsnet.network.topology_features.Bias.prime_feature": "(self, connection, device, **kwargs)", + "bindsnet.network.topology_features.Degradation": "(name, value=None, value_dtype=torch.float32, degrade_function=None, parent_feature=None, sparse=False, batch_size=1)", + "bindsnet.network.topology_features.Degradation.compute": "(self, s)", + "bindsnet.network.topology_features.Intensity": "(name, value=None, value_dtype=torch.float32, range=None, sparse=False, batch_size=1)", + "bindsnet.network.topology_features.Intensity.compute": "(self, s)", + "bindsnet.network.topology_features.Intensity.prime_feature": "(self, connection, device, **kwargs)", + "bindsnet.network.topology_features.Mask": "(name, value=None, sparse=False, batch_size=1)", + "bindsnet.network.topology_features.Mask.compute": "(self, s)", + "bindsnet.network.topology_features.Mask.prime_feature": "(self, connection, device, **kwargs)", + "bindsnet.network.topology_features.MeanField": "()", + "bindsnet.network.topology_features.MeanField.compute": "(self, s)", + "bindsnet.network.topology_features.MeanField.prime_feature": "(self, connection, device, **kwargs)", + "bindsnet.network.topology_features.Normalization": "(name, parent_feature)", + "bindsnet.network.topology_features.Probability": "(name, value=None, value_dtype=torch.float32, range=None, norm=None, learning_rule=None, nu=None, reduction=None, decay=0.0, parent_feature=None, sparse=False, batch_size=1)", + "bindsnet.network.topology_features.Probability.assert_valid_range": "(self)", + "bindsnet.network.topology_features.Probability.compute": "(self, s)", + "bindsnet.network.topology_features.Probability.prime_feature": "(self, connection, device, **kwargs)", + "bindsnet.network.topology_features.Probability.sparse_bernoulli": "(self)", + "bindsnet.network.topology_features.Updating": "(name, parent_feature)", + "bindsnet.network.topology_features.Weight": "(name, value=None, value_dtype=torch.float32, range=None, norm=None, norm_frequency='sample', learning_rule=None, nu=None, reduction=None, enforce_polarity=False, decay=0.0, sparse=False, batch_size=1, **kwargs)", + "bindsnet.network.topology_features.Weight.compute": "(self, s)", + "bindsnet.network.topology_features.Weight.normalize": "(self, time_step_norm=False)", + "bindsnet.network.topology_features.Weight.prime_feature": "(self, connection, device, **kwargs)", + "bindsnet.pipeline.action.select_first_spike": "(pipeline, **kwargs)", + "bindsnet.pipeline.action.select_highest": "(pipeline, **kwargs)", + "bindsnet.pipeline.action.select_multinomial": "(pipeline, **kwargs)", + "bindsnet.pipeline.action.select_random": "(pipeline, **kwargs)", + "bindsnet.pipeline.action.select_softmax": "(pipeline, **kwargs)", + "bindsnet.pipeline.base_pipeline.BasePipeline": "(network, **kwargs)", + "bindsnet.pipeline.base_pipeline.BasePipeline.get_spike_data": "(self)", + "bindsnet.pipeline.base_pipeline.BasePipeline.get_voltage_data": "(self)", + "bindsnet.pipeline.base_pipeline.BasePipeline.init_fn": "(self)", + "bindsnet.pipeline.base_pipeline.BasePipeline.plots": "(self, batch, step_out)", + "bindsnet.pipeline.base_pipeline.BasePipeline.reset_state_variables": "(self)", + "bindsnet.pipeline.base_pipeline.BasePipeline.step": "(self, batch, **kwargs)", + "bindsnet.pipeline.base_pipeline.BasePipeline.step_": "(self, batch, **kwargs)", + "bindsnet.pipeline.base_pipeline.BasePipeline.test": "(self)", + "bindsnet.pipeline.base_pipeline.BasePipeline.train": "(self)", + "bindsnet.pipeline.base_pipeline.recursive_to": "(item, device)", + "bindsnet.pipeline.dataloader_pipeline.DataLoaderPipeline": "(network, train_ds, test_ds=None, **kwargs)", + "bindsnet.pipeline.dataloader_pipeline.DataLoaderPipeline.test": "(self)", + "bindsnet.pipeline.dataloader_pipeline.DataLoaderPipeline.train": "(self)", + "bindsnet.pipeline.dataloader_pipeline.TorchVisionDatasetPipeline": "(network, train_ds, pipeline_analyzer=None, **kwargs)", + "bindsnet.pipeline.dataloader_pipeline.TorchVisionDatasetPipeline.init_fn": "(self)", + "bindsnet.pipeline.dataloader_pipeline.TorchVisionDatasetPipeline.plots": "(self, batch, *args)", + "bindsnet.pipeline.dataloader_pipeline.TorchVisionDatasetPipeline.step_": "(self, batch, **kwargs)", + "bindsnet.pipeline.dataloader_pipeline.TorchVisionDatasetPipeline.test_step": "(self)", + "bindsnet.pipeline.environment_pipeline.EnvironmentPipeline": "(network, environment, action_function=None, encoding=None, **kwargs)", + "bindsnet.pipeline.environment_pipeline.EnvironmentPipeline.env_step": "(self)", + "bindsnet.pipeline.environment_pipeline.EnvironmentPipeline.init_fn": "(self)", + "bindsnet.pipeline.environment_pipeline.EnvironmentPipeline.plots": "(self, gym_batch, *args)", + "bindsnet.pipeline.environment_pipeline.EnvironmentPipeline.reset_state_variables": "(self)", + "bindsnet.pipeline.environment_pipeline.EnvironmentPipeline.step_": "(self, gym_batch, **kwargs)", + "bindsnet.pipeline.environment_pipeline.EnvironmentPipeline.train": "(self, **kwargs)", + "bindsnet.preprocessing.preprocessing.AbstractPreprocessor": "()", + "bindsnet.preprocessing.preprocessing.AbstractPreprocessor.process": "(self, csvfile, use_cache=True, cachedfile='./processed/data.pt')", + "bindsnet.utils.col2im_indices": "(cols, x_shape, kernel_height, kernel_width, padding=(0, 0), stride=(1, 1))", + "bindsnet.utils.get_square_assignments": "(assignments, n_sqrt)", + "bindsnet.utils.get_square_weights": "(weights, n_sqrt, side)", + "bindsnet.utils.im2col_indices": "(x, kernel_height, kernel_width, padding=(0, 0), stride=(1, 1))", + "bindsnet.utils.reshape_conv2d_weights": "(weights)", + "bindsnet.utils.reshape_local_connection_2d_weights": "(w, n_filters, kernel_size, conv_size, input_sqrt)", + "bindsnet.utils.reshape_locally_connected_weights": "(w, n_filters, kernel_size, conv_size, locations, input_sqrt)" + } +} diff --git a/test/network/test_all_classes.py b/test/network/test_all_classes.py new file mode 100644 index 00000000..ad265a7b --- /dev/null +++ b/test/network/test_all_classes.py @@ -0,0 +1,960 @@ +"""Construct-and-run smoke tests for every public class in the core modules. + +The classes are found by walking the modules (``inspect.getmembers``), not by a +hand-written list, so a class added later is exercised (or the test tells its author +to add a shape spec) without anyone remembering to write a test for it. + +Each case builds the object, runs 10 steps on seeded Bernoulli input and checks that +outputs are finite and have the right shapes. Learning-rule cases also check that the +weights moved, and the table of which rule supports which connection type is pinned. + +Classes or combinations that crash on master are listed in ``KNOWN_BUGS`` at the end +with their exact error. When someone fixes one, its reproducer stops raising, the test +fails with "DID NOT RAISE", and the entry must be removed (and the normal test +widened) in the same change. +""" + +import inspect + +import pytest +import torch + +import bindsnet.learning as learning +import bindsnet.learning.MCC_learning as mcc_learning +import bindsnet.learning.reward as reward +import bindsnet.models as models +import bindsnet.network.monitors as monitors +import bindsnet.network.nodes as nodes +import bindsnet.network.topology as topology +import bindsnet.network.topology_features as tf +from bindsnet.network import Network + +STEPS = 10 +BATCH = 2 +RATE = 0.5 + + +def _public_classes(module, base): + """Concrete classes defined in bindsnet that subclass ``base`` (not ``base``).""" + found = {} + for name, cls in inspect.getmembers(module, inspect.isclass): + if name.startswith("_") or not cls.__module__.startswith("bindsnet"): + continue + if not issubclass(cls, base) or cls is base or inspect.isabstract(cls): + continue + found[cls.__name__] = cls + return found + + +NODE_CLASSES = _public_classes(nodes, nodes.Nodes) +CONNECTION_CLASSES = { + **_public_classes(topology, topology.AbstractConnection), + **_public_classes(topology, topology.AbstractMulticompartmentConnection), +} +RULE_CLASSES = _public_classes(learning, learning.LearningRule) +MCC_RULE_CLASSES = _public_classes(mcc_learning, mcc_learning.MCC_LearningRule) +MODEL_CLASSES = _public_classes(models, Network) + + +def _spikes(shape, batch=BATCH, seed=0): + """Seeded Bernoulli spikes of shape ``[STEPS, batch, *shape]``.""" + gen = torch.Generator().manual_seed(seed) + p = torch.full((STEPS, batch, *shape), RATE) + return torch.bernoulli(p, generator=gen).bool() + + +def _check_layers(net, batch=BATCH): + """Every layer: spikes shaped [batch, *layer.shape]; state tensors finite.""" + for name, layer in net.layers.items(): + want = (batch, *layer.shape) + assert tuple(layer.s.shape) == want, f"{name}.s {tuple(layer.s.shape)}" + assert layer.s.dtype == torch.bool, f"{name}.s dtype {layer.s.dtype}" + for attr in ("v", "x", "theta"): + t = getattr(layer, attr, None) + if isinstance(t, torch.Tensor) and t.dim() > 0: + assert torch.isfinite(t).all(), f"{name}.{attr} not finite" + + +def _weights(conn): + """The connection's weight tensor (dense, or the MCC Weight feature's value).""" + if isinstance(conn, topology.AbstractMulticompartmentConnection): + for f in conn.pipeline: + if isinstance(f, tf.Weight): + v = f.value + return v.to_dense() if v.is_sparse else v + return None + w = getattr(conn, "w", None) + if isinstance(w, torch.Tensor) and w.is_sparse: + return w.to_dense() + return w + + +# --------------------------------------------------------------------------- # +# Nodes # +# --------------------------------------------------------------------------- # + +NODE_SHAPE = (3, 4) +# Node classes that only work with a one-dimensional shape (see KNOWN_BUGS). +NODE_1D_ONLY = {"CSRMNodes", "IzhikevichNodes"} + + +def _run_nodes(name, shape, batch=BATCH): + torch.manual_seed(0) + cls = NODE_CLASSES[name] + n = int(torch.tensor(shape).prod()) + net = Network(dt=1.0, batch_size=batch) + net.add_layer(nodes.Input(shape=shape, traces=True), name="in") + net.add_layer(cls(n=n, shape=shape, traces=True), name="out") + inputs = {"in": _spikes(shape, batch)} + if isinstance(net.layers["out"], nodes.AbstractInput): + inputs["out"] = _spikes(shape, batch, seed=1) + else: + w = torch.rand(n, n, generator=torch.Generator().manual_seed(2)) * 4 + conn = topology.Connection(net.layers["in"], net.layers["out"], w=w) + net.add_connection(conn, source="in", target="out") + net.run(inputs=inputs, time=STEPS) + return net + + +@pytest.mark.parametrize("name", sorted(NODE_CLASSES)) +def test_nodes_construct_and_run(name): + """Every Nodes subclass runs 10 steps with finite state of the right shape. + + Caught: ``self.v = self.v / 0`` inserted at the end of ``BoostedLIFNodes.forward``. + """ + shape = (12,) if name in NODE_1D_ONLY else NODE_SHAPE + net = _run_nodes(name, shape) + _check_layers(net) + + +# --------------------------------------------------------------------------- # +# Connections # +# --------------------------------------------------------------------------- # + +# name -> (source shape, target shape, constructor keyword arguments). +CONNECTION_SPECS = { + "Connection": ((20,), (10,), {}), + "SparseConnection": ((20,), (10,), {}), + "MeanFieldConnection": ((20,), (10,), {}), + "Conv1dConnection": ((2, 12), (3, 10), {"kernel_size": 3}), + "Conv2dConnection": ((2, 8, 8), (3, 6, 6), {"kernel_size": 3}), + "Conv3dConnection": ((1, 6, 6, 6), (2, 4, 4, 4), {"kernel_size": 3}), + "MaxPool1dConnection": ((2, 8), (2, 4), {"kernel_size": 2, "stride": 2}), + "MaxPool2dConnection": ((2, 8, 8), (2, 4, 4), {"kernel_size": 2, "stride": 2}), + "MaxPoo3dConnection": ( + (2, 4, 4, 4), + (2, 2, 2, 2), + {"kernel_size": 2, "stride": 2}, + ), + "LocalConnection": ( + (1, 8, 8), + (2, 3, 3), + {"kernel_size": 4, "stride": 2, "n_filters": 2}, + ), + "LocalConnection1D": ( + (2, 12), + (2, 4), + {"kernel_size": 3, "stride": 3, "n_filters": 2}, + ), + "LocalConnection2D": ( + (2, 8, 8), + (3, 3, 3), + {"kernel_size": 4, "stride": 2, "n_filters": 3}, + ), + "LocalConnection3D": ( + (1, 6, 6, 6), + (2, 3, 3, 3), + {"kernel_size": 2, "stride": 2, "n_filters": 2}, + ), + "MulticompartmentConnection": ((20,), (10,), {}), +} +# A connection class not listed above is tried with this dense spec, and +# test_every_connection_class_has_a_shape_spec fails until it is listed. +DEFAULT_SPEC = ((20,), (10,), {}) +# Extra arguments that are not optional in practice (see KNOWN_BUGS). +MAXPOOL = {"MaxPool1dConnection", "MaxPool2dConnection", "MaxPoo3dConnection"} +# Connection classes whose compute() only works for batch size 1 (see KNOWN_BUGS). +CONNECTION_BATCH1_ONLY = {"LocalConnection"} + + +def _build(conn_name, rule=None, batch=BATCH, spec=None, **extra): + """Network ``in -> out`` joined by one ``conn_name`` connection.""" + torch.manual_seed(0) + if spec is None: + spec = CONNECTION_SPECS.get(conn_name, DEFAULT_SPEC) + src_shape, tgt_shape, kwargs = spec + kwargs = {**kwargs, **extra} + cls = CONNECTION_CLASSES[conn_name] + additive = rule is learning.Rmax + src = nodes.Input(shape=src_shape, traces=True, traces_additive=additive) + if additive: # Rmax needs stochastic SRM0 targets (Vasilaki et al. 2009). + tgt = nodes.SRM0Nodes(shape=tgt_shape, traces=True, traces_additive=True) + else: + tgt = nodes.LIFNodes(shape=tgt_shape, traces=True, thresh=-60.0) + net = Network(dt=1.0, batch_size=batch) + net.add_layer(src, name="in") + net.add_layer(tgt, name="out") + if cls is topology.MulticompartmentConnection: + if not isinstance(tgt.shape, list): # tuple shapes crash (KNOWN_BUGS) + tgt.shape = list(tgt.shape) + w = torch.rand(src.n, tgt.n) + weight = tf.Weight(name="w", value=w, range=(0.0, 1.0), **kwargs) + conn = cls(source=src, target=tgt, device="cpu", pipeline=[weight]) + else: + if rule is not None: + kwargs.update(update_rule=rule, nu=(1e-2, 1e-2)) + if conn_name != "SparseConnection": # sparse w rejects wmin/wmax + kwargs.update(wmin=0.0, wmax=1.0) + conn = cls(src, tgt, **kwargs) + net.add_connection(conn, source="in", target="out") + return net, conn, src_shape + + +@pytest.mark.parametrize("name", sorted(CONNECTION_CLASSES)) +def test_connection_construct_and_run(name): + """Every connection class runs 10 steps; target state finite and shaped. + + Caught: ``.view_as(indices)`` changed to ``.view(-1)`` in ``MaxPool1dConnection.compute``. + """ + batch = 1 if name in CONNECTION_BATCH1_ONLY else BATCH + extra = {"decay": 0.5} if name in MAXPOOL else {} + net, conn, src_shape = _build(name, batch=batch, **extra) + net.run(inputs={"in": _spikes(src_shape, batch)}, time=STEPS) + _check_layers(net, batch) + out = conn.compute(net.layers["in"].s) + assert torch.isfinite(out).all() + # The pre-activation must broadcast onto the target voltage. + target_v = torch.zeros(batch, *net.layers["out"].shape) + assert (target_v + out).shape == target_v.shape, tuple(out.shape) + w = _weights(conn) + if w is not None: + assert torch.isfinite(w).all() + + +def test_every_connection_class_has_a_shape_spec(): + """A new connection class must get an entry in CONNECTION_SPECS. + + Caught: deleting the ``"MaxPoo3dConnection"`` entry from CONNECTION_SPECS. + """ + missing = sorted(set(CONNECTION_CLASSES) - set(CONNECTION_SPECS)) + assert not missing, f"add source/target shapes for {missing} to CONNECTION_SPECS" + + +# --------------------------------------------------------------------------- # +# Learning rules on every learnable connection type # +# --------------------------------------------------------------------------- # + +LEARNABLE = [ + "Connection", + "SparseConnection", + "LocalConnection", + "LocalConnection1D", + "LocalConnection2D", + "LocalConnection3D", + "Conv1dConnection", + "Conv2dConnection", + "Conv3dConnection", +] +DENSE_LIKE = {"Connection", "SparseConnection", "LocalConnection"} + +# Which (rule, connection) pairs are supported. Every other pair must raise +# NotImplementedError when the connection is built. Changing support must change +# this table, so the change is visible in review. +SUPPORTED = { + "NoOp": set(LEARNABLE), + "PostPre": set(LEARNABLE), + "WeightDependentPostPre": set(LEARNABLE) - {"SparseConnection"}, + "Hebbian": set(LEARNABLE), + "MSTDP": set(LEARNABLE), + "MSTDPET": set(LEARNABLE), + "DiehlAndCook": DENSE_LIKE - {"SparseConnection"}, + "Rmax": DENSE_LIKE, +} +# Pairs that pass the construction check but crash when run (see KNOWN_BUGS). +BROKEN_PAIRS = { + ("WeightDependentPostPre", "SparseConnection"), + ("DiehlAndCook", "SparseConnection"), +} +RULE_BATCH1_ONLY = {"Rmax"} + + +@pytest.mark.parametrize("conn_name", LEARNABLE) +@pytest.mark.parametrize("rule_name", sorted(RULE_CLASSES)) +def test_rule_on_connection(rule_name, conn_name): + """Each rule runs on each supported connection and moves the weights. + + Caught: both ``self.connection.w +=`` lines removed from ``Hebbian._conv2d_connection_update``. + """ + assert rule_name in SUPPORTED, f"add {rule_name} to SUPPORTED" + rule = RULE_CLASSES[rule_name] + if (rule_name, conn_name) in BROKEN_PAIRS: + pytest.skip("crashes on master; pinned in KNOWN_BUGS") + if conn_name not in SUPPORTED[rule_name]: + with pytest.raises(NotImplementedError): + _build(conn_name, rule=rule) + return + batch = BATCH + if rule_name in RULE_BATCH1_ONLY or conn_name in CONNECTION_BATCH1_ONLY: + batch = 1 + net, conn, src_shape = _build(conn_name, rule=rule, batch=batch) + w0 = _weights(conn).clone() + net.run(inputs={"in": _spikes(src_shape, batch)}, time=STEPS, reward=1.0) + _check_layers(net, batch) + w1 = _weights(conn) + assert w1.shape == w0.shape + assert torch.isfinite(w1).all() + if rule_name == "NoOp": + assert torch.equal(w1, w0) + else: + assert not torch.equal(w1, w0), "weights did not change" + + +MCC_RULE_BATCH1_ONLY = {"MSTDPET"} + + +@pytest.mark.parametrize("rule_name", sorted(MCC_RULE_CLASSES)) +def test_mcc_rule_on_multicompartment_connection(rule_name): + """Each MCC rule runs on a MulticompartmentConnection Weight and moves it. + + Caught: both ``addmm_`` updates removed from ``MCC_learning.Hebbian._connection_update``. + """ + rule = MCC_RULE_CLASSES[rule_name] + batch = 1 if rule_name in MCC_RULE_BATCH1_ONLY else BATCH + net, conn, src_shape = _build( + "MulticompartmentConnection", batch=batch, learning_rule=rule, nu=(1e-2, 1e-2) + ) + w0 = _weights(conn).clone() + net.run(inputs={"in": _spikes(src_shape, batch)}, time=STEPS, reward=1.0) + _check_layers(net, batch) + w1 = _weights(conn) + assert w1.shape == w0.shape + assert torch.isfinite(w1).all() + if rule_name == "NoOp": + assert torch.equal(w1, w0) + else: + assert not torch.equal(w1, w0), "weights did not change" + + +# --------------------------------------------------------------------------- # +# Models # +# --------------------------------------------------------------------------- # + +# name -> list of (constructor keyword arguments, batch size), one case each. +MODEL_SPECS = { + "TwoLayerNetwork": [({"n_inpt": 16, "n_neurons": 8}, BATCH)], + "DiehlAndCook2015": [ + ({"n_inpt": 16, "n_neurons": 8}, BATCH), + # Batch 1 and no learning check: see KNOWN_BUGS (sparse-batch2, no-learning). + ({"n_inpt": 16, "n_neurons": 8, "sparse": True, "batch_size": 1}, 1), + ( + { + "n_inpt": 16, + "n_neurons": 8, + "learning_rule": mcc_learning.DiehlAndCook, + "learning_rule_kwargs": {"x_tar": 0.4}, + }, + BATCH, + ), + ], + "DiehlAndCook2015v2": [({"n_inpt": 16, "n_neurons": 8}, BATCH)], + "IncreasingInhibitionNetwork": [({"n_input": 16, "n_neurons": 9}, BATCH)], + # Batch 1: it is built on LocalConnection (see KNOWN_BUGS). + "LocallyConnectedNetwork": [ + ( + { + "n_inpt": 64, + "input_shape": [8, 8], + "kernel_size": 4, + "stride": 2, + "n_filters": 2, + }, + 1, + ) + ], +} +MODEL_NO_LEARNING_CHECK = {("DiehlAndCook2015", 1)} +MODEL_CASES = [ + (name, i) + for name in sorted(MODEL_CLASSES) + for i in range(len(MODEL_SPECS.get(name, [None]))) +] + + +def _run_model(name, kwargs, batch, learn=True): + torch.manual_seed(0) + net = MODEL_CLASSES[name](**kwargs) + net.train(learn) + inputs = { + k: _spikes(layer.shape, batch) + for k, layer in net.layers.items() + if isinstance(layer, nodes.AbstractInput) + } + assert inputs, "model has no Input layer" + net.run(inputs=inputs, time=STEPS) + return net + + +@pytest.mark.parametrize("name,case", MODEL_CASES) +def test_model_construct_and_run(name, case): + """Every model in bindsnet.models builds, runs 10 steps and learns. + + Caught: ``update_rule=PostPre`` changed to ``update_rule=None`` in ``DiehlAndCook2015v2``. + """ + assert name in MODEL_SPECS, f"add constructor arguments for {name} to MODEL_SPECS" + kwargs, batch = MODEL_SPECS[name][case] + net = _run_model(name, kwargs, batch) + _check_layers(net, batch) + # Same seed and input with learning off. Weight normalisation runs in both, so + # any difference comes from the learning rule. + frozen = _run_model(name, kwargs, batch, learn=False) + learned = [] + for k, c in net.connections.items(): + w = _weights(c) + assert torch.isfinite(w).all(), f"{k} weights not finite" + if not torch.equal(w, _weights(frozen.connections[k])): + learned.append(k) + if (name, case) not in MODEL_NO_LEARNING_CHECK: + assert learned, "no connection learned in 10 steps" + + +# --------------------------------------------------------------------------- # +# MulticompartmentConnection features # +# --------------------------------------------------------------------------- # + +FEATURE_CLASSES = { + **_public_classes(tf, tf.AbstractFeature), + **_public_classes(tf, tf.AbstractSubFeature), +} +SRC_N, TGT_N = 6, 4 + + +def _rand(*shape): + return torch.rand(*shape, generator=torch.Generator().manual_seed(3)) + + +def _ann(): + return [torch.zeros(1, 1), torch.zeros(1, 1)] + + +# name -> function(weight feature) returning the pipeline to test. +FEATURE_PIPELINES = { + "Weight": lambda w: [w], + "Bias": lambda w: [w, tf.Bias("b", _rand(SRC_N, TGT_N), range=(-1.0, 1.0))], + "Mask": lambda w: [w, tf.Mask("m", _rand(SRC_N, TGT_N) > 0.5)], + "Probability": lambda w: [tf.Probability("p", _rand(SRC_N, TGT_N)), w], + "Intensity": lambda w: [w, tf.Intensity("i", _rand(SRC_N, TGT_N), range=(0, 2))], + "Degradation": lambda w: [ + w, + tf.Degradation("d", _rand(SRC_N, TGT_N), degrade_function=lambda v: v / 2), + ], + "MeanField": None, # cannot be built into a pipeline (KNOWN_BUGS) + "AdaptationBaseSynapsHistory": lambda w: [ + w, + tf.AdaptationBaseSynapsHistory("a", _rand(SRC_N, TGT_N), ann_values=_ann()), + ], + "AdaptationBaseOtherSynaps": lambda w: [ + w, + tf.AdaptationBaseOtherSynaps("a", _rand(SRC_N, TGT_N), ann_values=_ann()), + ], + "Normalization": lambda w: [tf.Normalization("n", parent_feature=w), w], + "Updating": lambda w: [tf.Updating("u", parent_feature=w), w], +} + + +FEATURE_BATCH1_ONLY = {"AdaptationBaseSynapsHistory", "AdaptationBaseOtherSynaps"} +SUB_FEATURES = {"Normalization", "Updating"} + + +@pytest.mark.parametrize("name", sorted(FEATURE_CLASSES)) +def test_feature_in_pipeline(name): + """Every topology feature runs inside a MulticompartmentConnection for 10 steps. + + Caught: ``return self.value`` changed to ``return self.value * float("nan")`` in ``Bias.compute``. + """ + assert name in FEATURE_PIPELINES, f"add a pipeline for {name} to FEATURE_PIPELINES" + if FEATURE_PIPELINES[name] is None: + pytest.skip(KNOWN_BUGS[f"{name}-feature"][0]) + torch.manual_seed(0) + weight = tf.Weight("w", _rand(SRC_N, TGT_N) * 4, norm=8.0) + pipeline = FEATURE_PIPELINES[name](weight) + src = nodes.Input(n=SRC_N, traces=True) + tgt = nodes.LIFNodes(n=TGT_N, traces=True) + net = Network(dt=1.0, batch_size=BATCH) + net.add_layer(src, name="in") + net.add_layer(tgt, name="out") + # Sub-features cannot be primed (KNOWN_BUGS): build without them, then set + # the pipeline, as test_connections.py does. + primable = [f for f in pipeline if isinstance(f, tf.AbstractFeature)] + conn = topology.MulticompartmentConnection( + source=src, target=tgt, device="cpu", pipeline=primable + ) + conn.pipeline = pipeline + net.add_connection(conn, source="in", target="out") + batch = 1 if name in FEATURE_BATCH1_ONLY else BATCH + if name in SUB_FEATURES: # Network.run calls update(), which they lack + s = _spikes((SRC_N,), batch)[0] + net.layers["in"].set_batch_size(batch) + else: + net.run(inputs={"in": _spikes((SRC_N,), batch)}, time=STEPS) + _check_layers(net, batch) + s = src.s + out = conn.compute(s) + assert tuple(out.shape) == (batch, TGT_N) + assert torch.isfinite(out).all() + if name not in SUB_FEATURES: # they also lack normalize() and reset + conn.normalize() + conn.reset_state_variables() + + +# --------------------------------------------------------------------------- # +# Monitors, reward functions and Network.run options # +# --------------------------------------------------------------------------- # + +MONITOR_CLASSES = _public_classes(monitors, monitors.AbstractMonitor) +REWARD_CLASSES = _public_classes(reward, reward.AbstractReward) + + +def _small_net(**kwargs): + torch.manual_seed(0) + net = Network(dt=1.0, batch_size=BATCH, **kwargs) + net.add_layer(nodes.Input(n=20, traces=True), name="in") + net.add_layer(nodes.LIFNodes(n=10, traces=True, thresh=-60.0), name="out") + conn = topology.Connection( + net.layers["in"], net.layers["out"], update_rule=learning.MSTDP, nu=1e-2 + ) + net.add_connection(conn, source="in", target="out") + return net, conn + + +@pytest.mark.parametrize("name", sorted(MONITOR_CLASSES)) +def test_monitor_records_and_saves(name, tmp_path): + """Every monitor records [time, batch, ...] tensors; NetworkMonitor saves both formats. + + Caught: ``[1:]`` removed from the fixed-time branch of ``NetworkMonitor.record`` (recording grows). + """ + assert name in ("Monitor", "NetworkMonitor"), f"add a case for {name}" + net, _ = _small_net() + if name == "Monitor": + mon = monitors.Monitor(net.layers["out"], ["s", "v"], time=STEPS) + net.add_monitor(mon, name="m") + spikes = _spikes((20,)) + net.run(inputs={"in": spikes}, time=STEPS, reward=1.0) + assert tuple(mon.get("s").shape) == (STEPS, BATCH, 10) + assert tuple(mon.get("v").shape) == (STEPS, BATCH, 10) + assert torch.isfinite(mon.get("v")).all() + return + mon = monitors.NetworkMonitor(net, state_vars=["s", "v", "w"], time=STEPS) + net.add_monitor(mon, name="m") + spikes = _spikes((20,)) + net.run(inputs={"in": spikes}, time=STEPS, reward=1.0) + rec = mon.get() + assert torch.equal(rec["in"]["s"].bool(), spikes) + assert tuple(rec["out"]["v"].shape) == (STEPS, BATCH, 10) + assert tuple(rec[("in", "out")]["w"].shape) == (STEPS, 20, 10) + mon.save(str(tmp_path / "rec.npz"), fmt="npz") + mon.save(str(tmp_path / "rec.pt"), fmt="pickle") + assert (tmp_path / "rec.npz").exists() and (tmp_path / "rec.pt").exists() + # Without a fixed time the recording grows by one row per step. + net, _ = _small_net() + grow = monitors.NetworkMonitor(net, state_vars=["s", "w"]) + net.add_monitor(grow, name="g") + net.run(inputs={"in": spikes}, time=STEPS, reward=1.0) + assert tuple(grow.get()["out"]["s"].shape) == (STEPS, BATCH, 10) + assert tuple(grow.get()[("in", "out")]["w"].shape) == (STEPS, 20, 10) + grow.reset_state_variables() + assert grow.get()["out"]["s"].numel() == 0 + + +@pytest.mark.parametrize("name", sorted(REWARD_CLASSES)) +def test_reward_fn_modulates_learning(name): + """A reward_fn changes the reward the rule sees; update() moves its prediction. + + Caught: ``kwargs["reward"] = self.reward_fn.compute(**kwargs)`` removed from ``Network.run``. + """ + plain, conn_a = _small_net() + fn_net, conn_b = _small_net(reward_fn=REWARD_CLASSES[name]) + fn_net.reward_fn.update(accumulated_reward=torch.tensor(30.0), steps=10) + spikes = _spikes((20,)) + plain.run(inputs={"in": spikes}, time=STEPS, reward=1.0) + fn_net.run(inputs={"in": spikes}, time=STEPS, reward=1.0) + assert not torch.equal(conn_a.w, conn_b.w) + + +def test_network_run_options(): + """Network.run honours injects_v, a_plus/a_minus (float and dict) and 1-D/2-D inputs. + + Caught: ``self.layers[l].v += inject_v`` removed from ``Network.run``. + """ + # Voltage injection alone makes a silent layer spike. + net, _ = _small_net() + no_input = torch.zeros(STEPS, BATCH, 20, dtype=torch.bool) + net.run(inputs={"in": no_input}, time=STEPS, reward=1.0) + assert not net.layers["out"].s.any() + net.reset_state_variables() + m = monitors.Monitor(net.layers["out"], ["s"], time=STEPS) + net.add_monitor(m, name="m") + net.run( + inputs={"in": no_input}, + time=STEPS, + reward=1.0, + injects_v={"out": torch.full((10,), 20.0)}, + ) + assert m.get("s").any() + net.reset_state_variables() + per_step = torch.full((STEPS, BATCH, 10), 20.0) + net.run( + inputs={"in": no_input}, time=STEPS, reward=1.0, injects_v={"out": per_step} + ) + assert m.get("s").any() + + # a_plus = a_minus = 0 switches MSTDP off; a dict keyed by connection too. + spikes = _spikes((20,)) + for a in (0.0, {("in", "out"): 0.0}): + net, conn = _small_net() + w0 = conn.w.clone() + net.run(inputs={"in": spikes}, time=STEPS, reward=1.0, a_plus=a, a_minus=a) + assert torch.equal(conn.w, w0) + net, conn = _small_net() + w0 = conn.w.clone() + net.run(inputs={"in": spikes}, time=STEPS, reward=1.0) + assert not torch.equal(conn.w, w0) + + # A [time, n] input becomes batch 1; an [n] input runs one step. + net, _ = _small_net() + net.run(inputs={"in": spikes[:, 0]}, time=STEPS, reward=1.0) + assert net.batch_size == 1 + net.run(inputs={"in": spikes[0, 0]}, time=1, reward=1.0) + assert tuple(net.layers["in"].s.shape) == (1, 20) + + +# --------------------------------------------------------------------------- # +# Known bugs on master, pinned as strict expected failures # +# --------------------------------------------------------------------------- # + + +def _local_connection_batch2(): + net, _, shape = _build("LocalConnection", batch=2) + net.run(inputs={"in": _spikes(shape, 2)}, time=STEPS) + + +def _rmax_batch2(): + net, _, shape = _build("Connection", rule=learning.Rmax, batch=2) + net.run(inputs={"in": _spikes(shape, 2)}, time=STEPS, reward=1.0) + + +def _sparse_pair(rule_name): + def run(): + net, _, shape = _build( + "SparseConnection", + rule=RULE_CLASSES[rule_name], + batch=1, + wmin=0.0, + wmax=1.0, + ) + net.run(inputs={"in": _spikes(shape, 1)}, time=STEPS, reward=1.0) + + return run + + +def _maxpool_without_decay(name): + def run(): + net, _, shape = _build(name) + net.run(inputs={"in": _spikes(shape)}, time=STEPS) + + return run + + +def _maxpool_one_channel(name): + dims = {"MaxPool1dConnection": 1, "MaxPool2dConnection": 2}.get(name, 3) + spec = ((1,) + (4,) * dims, (1,) + (2,) * dims, {"kernel_size": 2, "stride": 2}) + + def run(): + net, _, shape = _build(name, spec=spec, decay=0.5) + net.run(inputs={"in": _spikes(shape)}, time=STEPS) + + return run + + +def _mean_field_batch_isolation(): + _, conn, _ = _build("MeanFieldConnection") + s = torch.zeros(2, 20) + s[1] = 1.0 # sample 0 silent, sample 1 all spiking + out = conn.compute(s).expand(2, 10) + assert torch.equal(out[0], torch.zeros(10)), "sample 1 leaked into sample 0" + + +def _mcc_mstdpet_batch2(): + net, _, shape = _build( + "MulticompartmentConnection", + learning_rule=mcc_learning.MSTDPET, + nu=(1e-2, 1e-2), + ) + net.run(inputs={"in": _spikes(shape)}, time=STEPS, reward=1.0) + + +def _mcc_tuple_shape(): + torch.manual_seed(0) + src = nodes.Input(shape=(4,)) + tgt = nodes.LIFNodes(shape=(3,)) + net = Network(dt=1.0, batch_size=BATCH) + net.add_layer(src, name="in") + net.add_layer(tgt, name="out") + weight = tf.Weight(name="w", value=torch.rand(4, 3)) + conn = topology.MulticompartmentConnection( + source=src, target=tgt, device="cpu", pipeline=[weight] + ) + net.add_connection(conn, source="in", target="out") + net.run(inputs={"in": _spikes((4,))}, time=STEPS) + + +def _mean_field_feature(): + src, tgt = nodes.Input(n=SRC_N), nodes.LIFNodes(n=TGT_N) + weight = tf.Weight("w", _rand(SRC_N, TGT_N)) + topology.MulticompartmentConnection( + source=src, target=tgt, device="cpu", pipeline=[tf.MeanField(), weight] + ) + + +def _subfeature_in_constructor(cls): + def run(): + src, tgt = nodes.Input(n=SRC_N), nodes.LIFNodes(n=TGT_N) + weight = tf.Weight("w", _rand(SRC_N, TGT_N), norm=2.0) + topology.MulticompartmentConnection( + source=src, + target=tgt, + device="cpu", + pipeline=[cls("sub", parent_feature=weight), weight], + ) + + return run + + +def _adaptation_batch2(cls): + def run(): + src = nodes.Input(n=SRC_N, traces=True) + tgt = nodes.LIFNodes(n=TGT_N, traces=True) + net = Network(dt=1.0, batch_size=2) + net.add_layer(src, name="in") + net.add_layer(tgt, name="out") + feat = cls("a", _rand(SRC_N, TGT_N), ann_values=_ann()) + conn = topology.MulticompartmentConnection( + source=src, target=tgt, device="cpu", pipeline=[feat] + ) + net.add_connection(conn, source="in", target="out") + net.run(inputs={"in": _spikes((SRC_N,), 2)}, time=STEPS) + + return run + + +def _subfeature_network_run(cls): + def run(): + src, tgt = nodes.Input(n=SRC_N), nodes.LIFNodes(n=TGT_N) + net = Network(dt=1.0) + net.add_layer(src, name="in") + net.add_layer(tgt, name="out") + weight = tf.Weight("w", _rand(SRC_N, TGT_N), norm=2.0) + conn = topology.MulticompartmentConnection( + source=src, target=tgt, device="cpu", pipeline=[weight] + ) + conn.pipeline = [cls("sub", parent_feature=weight), weight] + net.add_connection(conn, source="in", target="out") + net.run(inputs={"in": _spikes((SRC_N,), 1)}, time=STEPS) + + return run + + +def _network_monitor_reset(): + net, _ = _small_net() + mon = monitors.NetworkMonitor(net, state_vars=["s", "w"], time=STEPS) + mon.reset_state_variables() + + +def _nodes_2d(name): + return lambda: _run_nodes(name, NODE_SHAPE) + + +def _sparse_dc2015_learns(): + kwargs = {"n_inpt": 16, "n_neurons": 8, "batch_size": 1} + kwargs.update(exc_thresh=-62.0, norm=None) # spikes in 10 steps; no rescaling + for sparse in (False, True): + torch.manual_seed(0) + w0 = _weights( + MODEL_CLASSES["DiehlAndCook2015"](**kwargs).connections[("X", "Ae")] + ) + net = _run_model("DiehlAndCook2015", {**kwargs, "sparse": sparse}, 1) + w1 = _weights(net.connections[("X", "Ae")]) + assert not torch.equal( + w1.reshape(w0.shape), w0 + ), f"sparse={sparse}: no learning" + + +def _sparse_dc2015_batch2(): + kwargs = {"n_inpt": 16, "n_neurons": 8, "sparse": True, "batch_size": 2} + _run_model("DiehlAndCook2015", kwargs, 2) + + +# id -> (what is wrong and where, exception type, message fragment, reproducer). +KNOWN_BUGS = { + "CSRMNodes-2d-shape": ( + "CSRMNodes.forward uses einsum 'i,kij->kj', so only one-dimensional " + "shapes work", + RuntimeError, + "einsum", + _nodes_2d("CSRMNodes"), + ), + "IzhikevichNodes-2d-shape": ( + "IzhikevichNodes sizes its parameters by n, not by shape, so a " + "multi-dimensional shape gives a size mismatch (nodes.py forward)", + RuntimeError, + "must match the size", + _nodes_2d("IzhikevichNodes"), + ), + "LocalConnection-batch2": ( + "LocalConnection.compute reshapes to target.shape without the batch " + "dimension (topology.py, `a_post.view(*self.target.shape)`)", + RuntimeError, + "is invalid for input of size", + _local_connection_batch2, + ), + "MeanFieldConnection-batch-mixing": ( + "MeanFieldConnection.compute returns s.float().mean() * w, the mean over " + "the whole batch, so one sample's input depends on the other samples", + AssertionError, + "leaked", + _mean_field_batch_isolation, + ), + "MeanField-feature": ( + "topology_features.MeanField.__init__ does not call the parent constructor, " + "so the feature has no name and MulticompartmentConnection raises " + "AttributeError when it indexes the pipeline (topology.py)", + AttributeError, + "has no attribute 'name'", + _mean_field_feature, + ), + **{ + f"{cls.__name__}-in-constructor": ( + f"{cls.__name__} (a sub-feature) in the pipeline passed to " + "MulticompartmentConnection raises AttributeError, because the " + "constructor calls prime_feature, which sub-features do not have", + AttributeError, + "prime_feature", + _subfeature_in_constructor(cls), + ) + for cls in (tf.Normalization, tf.Updating) + }, + **{ + f"{cls.__name__}-batch2": ( + f"{cls.__name__} fails for batch > 1 with an expand size mismatch " + "(topology_features.py)", + RuntimeError, + "expanded size", + _adaptation_batch2(cls), + ) + for cls in (tf.AdaptationBaseSynapsHistory, tf.AdaptationBaseOtherSynaps) + }, + **{ + f"{cls.__name__}-network-run": ( + f"{cls.__name__} in a pipeline breaks Network.run: " + "MulticompartmentConnection.update calls update() on every pipeline " + "entry and sub-features have none (nor normalize() or reset_state_variables())", + AttributeError, + "has no attribute 'update'", + _subfeature_network_run(cls), + ) + for cls in (tf.Normalization, tf.Updating) + }, + "NetworkMonitor-reset-with-time": ( + "NetworkMonitor.reset_state_variables with time set looks connections up " + "in network.layers instead of network.connections (monitors.py, last " + "loop), so it raises KeyError; Network.reset_state_variables and a batch " + "size change in Network.run call it", + KeyError, + "in", + _network_monitor_reset, + ), + "MulticompartmentConnection-tuple-shape": ( + "MulticompartmentConnection.compute builds [batch] + target.shape, which " + "raises TypeError when the target Nodes got its shape as a tuple", + TypeError, + "can only concatenate list", + _mcc_tuple_shape, + ), + "Rmax-batch2": ( + "Rmax._connection_update flattens the batch with view(-1), so batch > 1 " + "gives a size mismatch (learning.py)", + RuntimeError, + "must match the size", + _rmax_batch2, + ), + "WeightDependentPostPre-SparseConnection": ( + "WeightDependentPostPre needs finite wmin and wmax; with them the update " + "does add(sparse, dense), which torch does not support (learning.py)", + RuntimeError, + r"add\(sparse, dense\)", + _sparse_pair("WeightDependentPostPre"), + ), + "DiehlAndCook-SparseConnection": ( + "DiehlAndCook needs a finite wmax; with it the update adds a dense " + "tensor to the sparse weights and fails (learning.py)", + RuntimeError, + "dense-sparse addition", + _sparse_pair("DiehlAndCook"), + ), + "MCC_learning.MSTDPET-batch2": ( + "MCC_learning.MSTDPET flattens source and target spikes with view(-1), so " + "batch > 1 gives a size mismatch (MCC_learning.py)", + RuntimeError, + "must match the size", + _mcc_mstdpet_batch2, + ), + "DiehlAndCook2015-sparse-no-learning": ( + "DiehlAndCook2015(sparse=True) never changes its input weights: " + "MCC_LearningRule.update clamps a sparse feature by building a new tensor " + "and binds it to rule.feature_value only (MCC_learning.py, the " + "to_dense().clamp_().to_sparse() line), so Weight.value, which compute() " + "reads, keeps its initial values. The dense model learns in the same run", + AssertionError, + "sparse=True: no learning", + _sparse_dc2015_learns, + ), + "DiehlAndCook2015-sparse-batch2": ( + "DiehlAndCook2015(sparse=True) with batch_size > 1: " + "MulticompartmentConnection.compute views the sparse feature as " + "[source.n, target.n] (topology.py)", + RuntimeError, + "is invalid for input of size", + _sparse_dc2015_batch2, + ), + **{ + f"{name}-no-decay": ( + f"{name} has no default decay; compute() multiplies None (topology.py)", + TypeError, + "NoneType", + _maxpool_without_decay(name), + ) + for name in sorted(MAXPOOL) + }, + **{ + f"{name}-one-channel": ( + f"{name}.compute calls s.float().squeeze(), which drops a channel " + "dimension of size 1, so the firing-rate update has the wrong shape " + "for batch > 1 (topology.py)", + RuntimeError, + "doesn't match the broadcast shape", + _maxpool_one_channel(name), + ) + for name in sorted(MAXPOOL) + }, +} + + +@pytest.mark.parametrize("bug_id", sorted(KNOWN_BUGS)) +def test_known_bug_still_fails(bug_id): + """Pins each known crash, with its error, so a fix is noticed. + + Caught: a ``return`` added as the first line of ``_rmax_batch2`` fails with DID NOT RAISE. + """ + reason, exc_type, match, reproduce = KNOWN_BUGS[bug_id] + with pytest.raises(exc_type, match=match): + reproduce() + pytest.xfail(reason) diff --git a/test/regen_api_surface.py b/test/regen_api_surface.py new file mode 100644 index 00000000..57ebadec --- /dev/null +++ b/test/regen_api_surface.py @@ -0,0 +1,71 @@ +"""Regenerate test/api_surface.json, the public API snapshot. + +Usage (from the repository root): + + python test/regen_api_surface.py --reason "removed the unused foo argument" + +The reason is required and is stored in the file, together with the date, the +commit and the Python and torch versions, so the pull request diff says why the +public API changed. Earlier reasons are kept in ``history``. +""" + +import argparse +import datetime +import json +import platform +import subprocess +import sys +from pathlib import Path + +import torch + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from test_api_surface import SNAPSHOT, collect_surface # noqa: E402 + + +def _commit(): + try: + out = subprocess.run( + ["git", "rev-parse", "--short", "HEAD"], + capture_output=True, + text=True, + check=True, + cwd=Path(__file__).resolve().parent, + ) + return out.stdout.strip() + except (OSError, subprocess.CalledProcessError): + return "unknown" + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "--reason", + required=True, + help="why the public API changed (stored in api_surface.json)", + ) + args = parser.parse_args(argv) + reason = args.reason.strip() + if not reason: + parser.error("--reason must not be empty") + + history = [] + if SNAPSHOT.exists(): + old = json.loads(SNAPSHOT.read_text()) + history = old.get("history", []) + meta = { + "reason": reason, + "date": datetime.date.today().isoformat(), + "commit": _commit(), + "python": platform.python_version(), + "torch": torch.__version__, + } + history.append({"date": meta["date"], "commit": meta["commit"], "reason": reason}) + surface = collect_surface() + data = {"meta": meta, "history": history, "surface": surface} + SNAPSHOT.write_text(json.dumps(data, indent=1) + "\n") + print(f"wrote {len(surface)} signatures to {SNAPSHOT}") + + +if __name__ == "__main__": + main() diff --git a/test/regression/README.md b/test/regression/README.md new file mode 100644 index 00000000..f4abc9af --- /dev/null +++ b/test/regression/README.md @@ -0,0 +1,79 @@ +# Regression tests: golden outputs and invariants + +These tests catch a change in simulation results that nobody declared. They need no +dataset, no network access and no GPU, and they run on every pull request. + +## Files + +- `networks.py`: sixteen small seeded networks (one per neuron type, connection type + and learning rule that BindsNET ships, some at `dt = 0.5` so a dropped time-step + factor shows). Also the code that runs a network and records every layer's spikes, + voltages and traces at every step, plus all weights. +- `golden/*.pt.xz`: the recorded outputs of each network, compressed. Each file also + stores the torch version, Python version, machine, date, commit, the reason it was + made, and how close every neuron came to its firing threshold. +- `test_golden.py`: runs each network again and compares with its golden file. Spikes + must match exactly. Decimal values must match within 0.000001. The test is skipped + when the installed torch differs from the one in the file in its first two version + numbers, because torch does not promise identical random numbers across versions. +- `test_invariants.py`: checks that need no stored file, so they hold on any + computer. Running for T steps equals running twice for T/2. A reset network equals a + fresh one. Saving and loading the state gives the same run. Learning off leaves the + weights untouched. A batch of identical samples equals one sample. Pair STDP has no + time-step factor and the reward-modulated rule MSTDPET does. +- `regen.py`: rewrites the golden files. +- `test_op_counts.py`: counts how many tensor operations (PyTorch kernel calls) one + call makes: one `update()` of every learning rule at batch 1 and 4, one simulation + step of a 784-input, 100-neuron `DiehlAndCook2015` network at batch 1 and 16, and one + `LIFNodes.forward` at batch 1 and 4. The count does not depend on the computer or on + the clock, so it is exact everywhere. The test fails when a count grows (the code + does more work per step); a smaller count passes with a warning. +- `op_counts.json`: the stored counts, per operation name, with the torch version, + date, commit and reason. Workloads that cannot run on `master` are listed under + `excluded` with the reason. +- `regen_op_counts.py`: rewrites `op_counts.json`. It also refuses to run without + `--reason`, and measures every workload twice, writing nothing if the two differ. + `--check` compares with the file and writes nothing. +- `conftest.py`: one thread and deterministic algorithms for every test here. + +## When a golden test fails + +Your change altered a result. Either that is the point of the change, or it is a bug. + +- If it is a bug, fix the code until the test passes again. +- If it is intended, regenerate the files in their own commit and say why: + +```shell +python test/regression/regen.py --reason "why the results changed" +``` + +The script refuses to run without a reason and stores it in every file. Results-changing +fixes are released in version 0.4.0, not in a 0.3.x patch release, so the pull request +will wait for that release. See `CONTRIBUTING.md`, "Tests that guard results". + +## When an operation-count test fails + +Your change makes a hot path call more tensor operations than before, which usually +means it is slower. Either find a way without the extra operations, or, if the extra +work is needed (for example a correctness fix), regenerate the counts in the same pull +request and say why: + +```shell +python test/regression/regen_op_counts.py --reason "why the hot path does more work" +``` + +The failure message lists which operations changed. Speed itself is measured +separately by the informational workflow `.github/workflows/bench.yml`, which times +the base and head of every pull request and writes the result to the job summary; +it never blocks a merge. + +## Expected failures + +Some tests are marked as expected failures (`xfail`, strict). Each names a known bug +in the code it exercises. When the bug is fixed the test fails with "DID NOT RAISE" or +by passing; remove the mark in the same pull request as the fix. + +## Wrong-change checks + +Every test's docstring names one deliberate wrong change that made it fail when the +test was written. A test that cannot fail guards nothing. diff --git a/test/regression/conftest.py b/test/regression/conftest.py new file mode 100644 index 00000000..4246facb --- /dev/null +++ b/test/regression/conftest.py @@ -0,0 +1,21 @@ +"""Shared set-up for the regression tests: CPU determinism, restored afterwards.""" + +import sys +from pathlib import Path + +import pytest +import torch + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + + +@pytest.fixture(autouse=True) +def deterministic_cpu(): + """One thread and deterministic algorithms for each test, then restore.""" + threads = torch.get_num_threads() + det = torch.are_deterministic_algorithms_enabled() + torch.set_num_threads(1) + torch.use_deterministic_algorithms(True) + yield + torch.use_deterministic_algorithms(det) + torch.set_num_threads(threads) diff --git a/test/regression/golden/conn_postpre_b1.pt.xz b/test/regression/golden/conn_postpre_b1.pt.xz new file mode 100644 index 00000000..9bf06fd5 Binary files /dev/null and b/test/regression/golden/conn_postpre_b1.pt.xz differ diff --git a/test/regression/golden/conn_postpre_b8.pt.xz b/test/regression/golden/conn_postpre_b8.pt.xz new file mode 100644 index 00000000..3fda7fbe Binary files /dev/null and b/test/regression/golden/conn_postpre_b8.pt.xz differ diff --git a/test/regression/golden/conn_postpre_dt05.pt.xz b/test/regression/golden/conn_postpre_dt05.pt.xz new file mode 100644 index 00000000..e80d89b0 Binary files /dev/null and b/test/regression/golden/conn_postpre_dt05.pt.xz differ diff --git a/test/regression/golden/conv2d_if_izh.pt.xz b/test/regression/golden/conv2d_if_izh.pt.xz new file mode 100644 index 00000000..01554df1 Binary files /dev/null and b/test/regression/golden/conv2d_if_izh.pt.xz differ diff --git a/test/regression/golden/csrm_window.pt.xz b/test/regression/golden/csrm_window.pt.xz new file mode 100644 index 00000000..2e3058b3 Binary files /dev/null and b/test/regression/golden/csrm_window.pt.xz differ diff --git a/test/regression/golden/dc2015_b1.pt.xz b/test/regression/golden/dc2015_b1.pt.xz new file mode 100644 index 00000000..07bfb41d Binary files /dev/null and b/test/regression/golden/dc2015_b1.pt.xz differ diff --git a/test/regression/golden/dc2015_b16.pt.xz b/test/regression/golden/dc2015_b16.pt.xz new file mode 100644 index 00000000..558550bc Binary files /dev/null and b/test/regression/golden/dc2015_b16.pt.xz differ diff --git a/test/regression/golden/encode_clamp.pt.xz b/test/regression/golden/encode_clamp.pt.xz new file mode 100644 index 00000000..5cd4c2fb Binary files /dev/null and b/test/regression/golden/encode_clamp.pt.xz differ diff --git a/test/regression/golden/encode_clamp_dt05.pt.xz b/test/regression/golden/encode_clamp_dt05.pt.xz new file mode 100644 index 00000000..ec7d64ff Binary files /dev/null and b/test/regression/golden/encode_clamp_dt05.pt.xz differ diff --git a/test/regression/golden/local2d_postpre.pt.xz b/test/regression/golden/local2d_postpre.pt.xz new file mode 100644 index 00000000..b797016d Binary files /dev/null and b/test/regression/golden/local2d_postpre.pt.xz differ diff --git a/test/regression/golden/meanfield_decay.pt.xz b/test/regression/golden/meanfield_decay.pt.xz new file mode 100644 index 00000000..b612ab0f Binary files /dev/null and b/test/regression/golden/meanfield_decay.pt.xz differ diff --git a/test/regression/golden/meanfield_norm.pt.xz b/test/regression/golden/meanfield_norm.pt.xz new file mode 100644 index 00000000..2e98fef6 Binary files /dev/null and b/test/regression/golden/meanfield_norm.pt.xz differ diff --git a/test/regression/golden/mstdp_mstdpet.pt.xz b/test/regression/golden/mstdp_mstdpet.pt.xz new file mode 100644 index 00000000..4ad4b132 Binary files /dev/null and b/test/regression/golden/mstdp_mstdpet.pt.xz differ diff --git a/test/regression/golden/mstdp_mstdpet_dt05.pt.xz b/test/regression/golden/mstdp_mstdpet_dt05.pt.xz new file mode 100644 index 00000000..3fcaf0c5 Binary files /dev/null and b/test/regression/golden/mstdp_mstdpet_dt05.pt.xz differ diff --git a/test/regression/golden/rules_mix.pt.xz b/test/regression/golden/rules_mix.pt.xz new file mode 100644 index 00000000..08aa5039 Binary files /dev/null and b/test/regression/golden/rules_mix.pt.xz differ diff --git a/test/regression/golden/rules_mix_dt05.pt.xz b/test/regression/golden/rules_mix_dt05.pt.xz new file mode 100644 index 00000000..b1563dec Binary files /dev/null and b/test/regression/golden/rules_mix_dt05.pt.xz differ diff --git a/test/regression/networks.py b/test/regression/networks.py new file mode 100644 index 00000000..af1408ed --- /dev/null +++ b/test/regression/networks.py @@ -0,0 +1,590 @@ +""" +Sixteen small seeded networks used by the golden-output and invariant tests. + +Ported from the review tool's ``canaries.py`` (builders and the ``spikes`` input +generator only). Each builder takes ``dt`` and returns ``(network, make_inputs)``; +``make_inputs(steps, batch, gen)`` returns the ``inputs`` dict for ``Network.run``, +drawing every random number from the private generator ``gen``. + +Determinism: ``build`` calls ``torch.manual_seed(0)`` before building (some +builders draw initial weights from the global generator), and ``inputs`` +reseeds the global generator before every trial, because the network itself may +draw random numbers while it runs (``DiehlAndCookNodes`` with ``one_spike`` uses +``torch.multinomial``; ``SRM0Nodes`` fires stochastically). + +Some networks run at dt = 0.5 ms: at dt = 1 a dropped or misplaced ``dt`` factor +multiplies by one and is invisible. +""" + +import io +import lzma +from dataclasses import dataclass, field +from pathlib import Path +from typing import Callable, Dict, List, Tuple + +import torch + +from bindsnet.network.monitors import Monitor + + +@dataclass +class Spec: + build: Callable + trials: List[Tuple[int, int, int]] # (batch, steps, input seed) + norm: bool = False # a connection has weight normalisation + run_kwargs: Dict = field(default_factory=dict) + dt: float = 1.0 + + +SPECS: Dict[str, Spec] = {} + + +def network(name, trials, norm=False, run_kwargs=None, dt=1.0): + """Register a builder ``fn(dt)`` under ``name``.""" + + def wrap(fn): + SPECS[name] = Spec(fn, trials, norm, run_kwargs or {}, dt) + return fn + + return wrap + + +def spikes(gen, steps, batch, shape, rate): + """Bernoulli spike train of shape [steps, batch, *shape] from a private generator.""" + return (torch.rand(steps, batch, *shape, generator=gen) < rate).byte() + + +# --------------------------------------------------------------------------- # +# Builders +# --------------------------------------------------------------------------- # + + +def _dc2015(batch, dt): + from bindsnet.models import DiehlAndCook2015 + + net = DiehlAndCook2015( + n_inpt=196, n_neurons=40, batch_size=batch, inpt_shape=(1, 14, 14), dt=dt + ) + return net, lambda T, b, g: {"X": spikes(g, T, b, (1, 14, 14), 0.25)} + + +@network("dc2015_b1", trials=[(1, 60, 1), (1, 40, 2)], norm=True) +def dc2015_b1(dt): + return _dc2015(1, dt) + + +@network("dc2015_b16", trials=[(16, 60, 1), (16, 40, 2)], norm=True) +def dc2015_b16(dt): + return _dc2015(16, dt) + + +def _dense_postpre(batch, dt): + from bindsnet.learning import PostPre + from bindsnet.network import Network + from bindsnet.network.nodes import Input, LIFNodes + from bindsnet.network.topology import Connection + + net = Network(dt=dt, batch_size=batch) + net.add_layer(Input(n=100, traces=True), "in") + net.add_layer(LIFNodes(n=50, traces=True), "out") + net.add_connection( + Connection( + net.layers["in"], + net.layers["out"], + nu=(1e-3, 1e-2), + update_rule=PostPre, + wmin=0.0, + wmax=1.0, + norm=20.0, + ), + "in", + "out", + ) + return net, lambda T, b, g: {"in": spikes(g, T, b, (100,), 0.04)} + + +@network("conn_postpre_b1", trials=[(1, 60, 1), (1, 40, 2)], norm=True) +def conn_postpre_b1(dt): + return _dense_postpre(1, dt) + + +@network("conn_postpre_b8", trials=[(8, 60, 1), (8, 40, 2)], norm=True) +def conn_postpre_b8(dt): + return _dense_postpre(8, dt) + + +@network("conn_postpre_dt05", trials=[(1, 80, 1), (1, 60, 2)], norm=True, dt=0.5) +def conn_postpre_dt05(dt): + return _dense_postpre(1, dt) + + +@network("local2d_postpre", trials=[(1, 60, 1), (1, 40, 2)]) +def local2d_postpre(dt): + from bindsnet.learning import PostPre + from bindsnet.network import Network + from bindsnet.network.nodes import Input, LIFNodes + from bindsnet.network.topology import LocalConnection2D + + net = Network(dt=dt) + net.add_layer(Input(shape=[1, 20, 20], traces=True), "in") + net.add_layer(LIFNodes(shape=[8, 4, 4], traces=True), "out") + net.add_connection( + LocalConnection2D( + net.layers["in"], + net.layers["out"], + kernel_size=8, + stride=4, + n_filters=8, + nu=(1e-3, 1e-2), + update_rule=PostPre, + wmin=0.0, + wmax=1.0, + ), + "in", + "out", + ) + return net, lambda T, b, g: {"in": spikes(g, T, b, (1, 20, 20), 0.06)} + + +def _mstdp(dt): + from bindsnet.learning import MSTDP, MSTDPET + from bindsnet.network import Network + from bindsnet.network.nodes import Input, LIFNodes + from bindsnet.network.topology import Connection + + net = Network(dt=dt) + net.add_layer(Input(n=100), "in") + net.add_layer(LIFNodes(n=30), "out1") + net.add_layer(LIFNodes(n=30), "out2") + for target, rule in (("out1", MSTDP), ("out2", MSTDPET)): + net.add_connection( + Connection( + net.layers["in"], + net.layers[target], + nu=1e-2, + update_rule=rule, + wmin=0.0, + wmax=1.0, + ), + "in", + target, + ) + return net, lambda T, b, g: {"in": spikes(g, T, b, (100,), 0.04)} + + +@network("mstdp_mstdpet", trials=[(1, 60, 1), (1, 40, 2)], run_kwargs={"reward": 1.0}) +def mstdp_mstdpet(dt): + return _mstdp(dt) + + +# MSTDPET keeps Florian's dt (eq. 2.7); at dt = 1 dropping it changes nothing. +@network( + "mstdp_mstdpet_dt05", + trials=[(1, 80, 1), (1, 60, 2)], + run_kwargs={"reward": 1.0}, + dt=0.5, +) +def mstdp_mstdpet_dt05(dt): + return _mstdp(dt) + + +# The third trial changes the batch size (Network.run resizes the layers). +@network("csrm_window", trials=[(1, 30, 1), (1, 20, 2), (3, 20, 3)]) +def csrm_window(dt): + from bindsnet.network import Network + from bindsnet.network.nodes import CSRMNodes, Input + from bindsnet.network.topology import Connection + + net = Network(dt=dt) + net.add_layer(Input(n=20), "in") + net.add_layer(CSRMNodes(n=10, res_window_size=5, ref_window_size=3), "out") + net.add_connection( + Connection(net.layers["in"], net.layers["out"], w=4.0 * torch.rand(20, 10)), + "in", + "out", + ) + return net, lambda T, b, g: {"in": spikes(g, T, b, (20,), 0.3).float()} + + +def _meanfield(dt, **mf_kwargs): + from bindsnet.network import Network + from bindsnet.network.nodes import Input, LIFNodes + from bindsnet.network.topology import MeanFieldConnection + + net = Network(dt=dt) + net.add_layer(Input(n=20), "in") + net.add_layer(LIFNodes(n=10), "out") + net.add_connection( + MeanFieldConnection(net.layers["in"], net.layers["out"], **mf_kwargs), + "in", + "out", + ) + return net, lambda T, b, g: {"in": spikes(g, T, b, (20,), 0.3)} + + +# weight_decay reaches the learning rule only if it is forwarded by name (#816). +@network("meanfield_decay", trials=[(1, 60, 1), (1, 40, 2)]) +def meanfield_decay(dt): + return _meanfield(dt, w=torch.full((10,), 20.0), weight_decay=0.01) + + +@network("meanfield_norm", trials=[(1, 60, 1), (1, 40, 2)], norm=True) +def meanfield_norm(dt): + return _meanfield(dt, w=torch.linspace(5.0, 25.0, 10), norm=100.0) + + +# bindsnet's own encoders; scalar nu, clamp, unclamp and injects_v are +# Network.run options that no other network passes. +def _clamps(): + on = torch.zeros(20, dtype=torch.bool) + off = torch.zeros(20, dtype=torch.bool) + on[:2], off[2:4] = True, True + return { + "clamp": {"out": on}, + "unclamp": {"out": off}, + "injects_v": {"out": torch.full((20,), 0.5)}, + } + + +def _encoders(dt): + from bindsnet.encoding import bernoulli, poisson + from bindsnet.learning import PostPre + from bindsnet.network import Network + from bindsnet.network.nodes import Input, LIFNodes + from bindsnet.network.topology import Connection + + net = Network(dt=dt) + net.add_layer(Input(n=64, traces=True), "in_p") + net.add_layer(Input(n=32, traces=True), "in_b") + net.add_layer(LIFNodes(n=20, traces=True), "out") + for src in ("in_p", "in_b"): + net.add_connection( + Connection( + net.layers[src], + net.layers["out"], + nu=1e-2, # one number: LearningRule expands it to (pre, post) + update_rule=PostPre, + wmin=0.0, + wmax=1.0, + ), + src, + "out", + ) + + def inputs(T, b, g): + rates = 64.0 * torch.rand(b, 64, generator=g) # Hz + probs = torch.rand(b, 32, generator=g) + return { + "in_p": poisson(datum=rates, time=T * dt, dt=dt), + "in_b": bernoulli(datum=probs, time=T * dt, dt=dt, max_prob=0.3), + } + + return net, inputs + + +@network("encode_clamp", trials=[(1, 60, 1), (1, 40, 2)], run_kwargs=_clamps) +def encode_clamp(dt): + return _encoders(dt) + + +@network( + "encode_clamp_dt05", trials=[(1, 80, 1), (1, 60, 2)], run_kwargs=_clamps, dt=0.5 +) +def encode_clamp_dt05(dt): + return _encoders(dt) + + +# Conv2dConnection, IFNodes and IzhikevichNodes. +@network("conv2d_if_izh", trials=[(1, 60, 1), (1, 40, 2)]) +def conv2d_if_izh(dt): + from bindsnet.learning import PostPre + from bindsnet.network import Network + from bindsnet.network.nodes import IFNodes, Input, IzhikevichNodes + from bindsnet.network.topology import Connection, Conv2dConnection + + net = Network(dt=dt) + net.add_layer(Input(shape=[1, 12, 12], traces=True), "in") + net.add_layer(IFNodes(shape=[4, 8, 8], traces=True, thresh=-60.0), "conv") + net.add_layer(IzhikevichNodes(n=16), "izh") + net.add_connection( + Conv2dConnection( + net.layers["in"], + net.layers["conv"], + kernel_size=5, + nu=(1e-3, 1e-2), + update_rule=PostPre, + wmin=0.0, + wmax=1.0, + ), + "in", + "conv", + ) + net.add_connection( + Connection(net.layers["conv"], net.layers["izh"], w=0.5 * torch.rand(256, 16)), + "conv", + "izh", + ) + return net, lambda T, b, g: {"in": spikes(g, T, b, (1, 12, 12), 0.2)} + + +# Hebbian, WeightDependentPostPre (with AdaptiveLIFNodes) and Rmax (with SRM0Nodes, +# which fire stochastically from the global generator, reseeded per trial). +def _rules(dt): + from bindsnet.learning import Hebbian, Rmax, WeightDependentPostPre + from bindsnet.network import Network + from bindsnet.network.nodes import AdaptiveLIFNodes, Input, LIFNodes, SRM0Nodes + from bindsnet.network.topology import Connection + + net = Network(dt=dt) + net.add_layer(Input(n=50, traces=True), "in") + net.add_layer(Input(n=50, traces=True, traces_additive=True), "in_add") + net.add_layer(AdaptiveLIFNodes(n=10, traces=True), "alif") + net.add_layer(LIFNodes(n=10, traces=True), "heb") + net.add_layer(SRM0Nodes(n=10, traces=True), "srm") + for src, tgt, rule, nu in ( + ("in", "alif", WeightDependentPostPre, (1e-3, 1e-2)), + ("in", "heb", Hebbian, (1e-4, 1e-4)), + ("in_add", "srm", Rmax, 1e-2), + ): + net.add_connection( + Connection( + net.layers[src], + net.layers[tgt], + nu=nu, + update_rule=rule, + wmin=0.0, + wmax=1.0, + w=0.3 * torch.rand(50, 10), + ), + src, + tgt, + ) + return net, lambda T, b, g: { + "in": spikes(g, T, b, (50,), 0.1), + "in_add": spikes(g, T, b, (50,), 0.1), + } + + +@network("rules_mix", trials=[(1, 60, 1), (1, 40, 2)], run_kwargs={"reward": 1.0}) +def rules_mix(dt): + return _rules(dt) + + +@network( + "rules_mix_dt05", + trials=[(1, 80, 1), (1, 60, 2)], + run_kwargs={"reward": 1.0}, + dt=0.5, +) +def rules_mix_dt05(dt): + return _rules(dt) + + +# --------------------------------------------------------------------------- # +# Running and recording +# --------------------------------------------------------------------------- # + +PREFIX = "_reg_" + + +def deterministic(): + """CPU determinism settings required for exact comparisons.""" + torch.set_num_threads(1) + torch.use_deterministic_algorithms(True) + + +def run_kwargs(spec): + kw = spec.run_kwargs + return kw() if callable(kw) else dict(kw) + + +def build(name, learning=True, dt=None): + """Seed, build network ``name``, attach monitors on every layer. + + Monitors record ``s``, ``v`` (when present), ``x`` (when the layer keeps + traces) and ``theta`` (when present) at every step. + """ + spec = SPECS[name] + torch.manual_seed(0) + net, make = spec.build(spec.dt if dt is None else dt) + net.train(learning) + for lname, layer in list(net.layers.items()): + names = ["s"] + for var in ("v", "theta"): + if isinstance(getattr(layer, var, None), torch.Tensor): + names.append(var) + if getattr(layer, "traces", False): + names.append("x") + net.add_monitor(Monitor(layer, names), name=PREFIX + lname) + return net, make + + +def inputs(make, steps, batch, seed): + """Inputs from a private generator; also reseeds the global generator.""" + torch.manual_seed(1000 + seed) + gen = torch.Generator().manual_seed(seed) + return make(steps, batch, gen) + + +def run(net, spec, x, extra=None): + """One ``Network.run`` call over all steps of the inputs ``x``.""" + steps = next(iter(x.values())).size(0) + kw = run_kwargs(spec) + kw.update(extra or {}) + net.run(inputs=x, time=steps * spec.dt, **kw) + + +def feature_tensors(net): + """Tensors held by each connection's pipeline features (``value``, ``range``...).""" + out = {} + for (src, tgt), conn in net.connections.items(): + for i, feature in enumerate(getattr(conn, "pipeline", None) or []): + for attr, val in sorted(vars(feature).items()): + if isinstance(val, torch.Tensor) and not attr.startswith("_"): + t = val.to_dense() if val.is_sparse else val + out[f"{src}_to_{tgt}.{i}.{attr}"] = t + return out + + +def collect(net, prefix=""): + """Every monitored recording (emptying the monitors) and the state_dict.""" + out = {} + for mname, mon in net.monitors.items(): + if mname.startswith(PREFIX): + for var in mon.state_vars: + t = mon.get(var).detach().cpu() + if var == "s": + t = t.bool() + out[f"{prefix}{mname[len(PREFIX):]}.{var}"] = t + # MulticompartmentConnection keeps its weights in pipeline features, which are + # not submodules, so they are missing from the state_dict; record them here. + for key, t in feature_tensors(net).items(): + out[f"{prefix}feature.{key}"] = t.detach().cpu().clone() + seen = set() # layers also appear under each connection's source and target + for key, t in net.state_dict().items(): + t = t.detach() + if t.is_sparse: + t = t.to_dense() + ident = (t.data_ptr(), tuple(t.shape), t.dtype) + if t.numel() and ident in seen: + continue + seen.add(ident) + out[f"{prefix}state.{key}"] = t.cpu().clone() + return out + + +def run_standard(name): + """Build, run every trial (reset between trials), record after each trial. + + Returns ``(tensors, errors, margins)``. A trial that raises is recorded as + ``errors["trial"] = ": "``; what its monitors and the + state_dict hold at that moment is still recorded, and later trials are not + run. ``margins`` is the smallest threshold margin per layer over all trials + (see ``threshold_margins``). + """ + spec = SPECS[name] + net, make = build(name) + out, errors, margins = {}, {}, {} + for i, (batch, steps, seed) in enumerate(spec.trials): + if i: + net.reset_state_variables() + try: + run(net, spec, inputs(make, steps, batch, seed)) + except Exception as e: # noqa: BLE001 - a crash is a result to pin + errors[f"trial{i}"] = f"{type(e).__name__}: {e}" + rec = collect(net, f"trial{i}.") + for lname, m in threshold_margins(net, rec, f"trial{i}.").items(): + margins[lname] = min(m, margins.get(lname, float("inf"))) + out.update(rec) + if errors: + break + return out, errors, margins + + +def threshold_margins(net, rec, prefix): + """Smallest distance between voltage and threshold at the spike check. + + For every layer with ``v`` and ``thresh`` (``SRM0Nodes`` excluded: it fires + stochastically, not by threshold), over all steps and neurons where the + recorded ``v`` is the value that was compared with the threshold: steps where + the neuron did not spike (spiking neurons are reset before recording), plus + the spiking steps of ``IzhikevichNodes`` and ``CSRMNodes``, which do not reset + on the step they spike. The adaptive threshold ``theta`` is taken back to its + value at the check by removing that step's ``theta_plus * s.sum(0)``. + """ + from bindsnet.network.nodes import CSRMNodes, IzhikevichNodes, SRM0Nodes + + margins = {} + for lname, layer in net.layers.items(): + if isinstance(layer, SRM0Nodes) or not hasattr(layer, "thresh"): + continue + key = f"{prefix}{lname}." + if key + "v" not in rec: + continue + v, s = rec[key + "v"].double(), rec[key + "s"] + if not v.numel(): # the trial crashed before its first step + continue + thresh = torch.as_tensor(layer.thresh).double() + if key + "theta" in rec: + theta = rec[key + "theta"].double() + if layer.learning: + theta = theta - layer.theta_plus.double() * s.double().sum(1) + thresh = thresh + theta.unsqueeze(1) + no_reset = isinstance(layer, (IzhikevichNodes, CSRMNodes)) + mask = torch.ones_like(s) if no_reset else ~s + d = (v - thresh).abs()[mask] + margins[lname] = float(d.min()) if d.numel() else float("inf") + return margins + + +# --------------------------------------------------------------------------- # +# Golden files +# --------------------------------------------------------------------------- # + +GOLDEN_DIR = Path(__file__).resolve().parent / "golden" +MARGIN = 1e-5 # smallest allowed distance between v and threshold (step 6) +FLOAT_ATOL = 1e-6 # floats may differ at about 1e-8 across CPUs; spikes may not + + +def golden_path(name): + return GOLDEN_DIR / f"{name}.pt.xz" + + +def save_golden(name, payload): + """Write ``payload`` with ``torch.save``, compressed with xz (lzma).""" + buf = io.BytesIO() + torch.save(payload, buf) + GOLDEN_DIR.mkdir(exist_ok=True) + golden_path(name).write_bytes(lzma.compress(buf.getvalue(), preset=9)) + + +def load_golden(name): + raw = lzma.decompress(golden_path(name).read_bytes()) + return torch.load(io.BytesIO(raw), weights_only=True) + + +def compare(expected, actual): + """List of human-readable differences between two ``{key: tensor}`` dicts. + + Spikes and every other non-float tensor: ``torch.equal``. Floats: + ``torch.allclose(atol=FLOAT_ATOL, rtol=0)``. + """ + problems = [] + for key in sorted(set(expected) - set(actual)): + problems.append(f"{key}: missing from this run") + for key in sorted(set(actual) - set(expected)): + problems.append(f"{key}: not in the golden file") + for key in sorted(set(expected) & set(actual)): + e, a = expected[key], actual[key] + if e.shape != a.shape or e.dtype != a.dtype: + problems.append( + f"{key}: golden {e.dtype}{list(e.shape)}, now {a.dtype}{list(a.shape)}" + ) + elif e.is_floating_point(): + if not torch.allclose(e, a, atol=FLOAT_ATOL, rtol=0, equal_nan=True): + diff = (e - a).abs().nan_to_num(float("inf")).max().item() + problems.append(f"{key}: max abs diff {diff:.3e} > {FLOAT_ATOL}") + elif not torch.equal(e, a): + n = int((e != a).sum()) + problems.append(f"{key}: {n} of {e.numel()} elements differ (exact)") + return problems diff --git a/test/regression/op_counts.json b/test/regression/op_counts.json new file mode 100644 index 00000000..3a17e277 --- /dev/null +++ b/test/regression/op_counts.json @@ -0,0 +1,435 @@ +{ + "meta": { + "torch": "2.14.1+cu130", + "python": "3.11.9", + "date": "2026-10-24", + "commit": "db7d19303af15e38dc5598cd728065930bee3eef", + "reason": "initial operation counts (Phase 5 of the test hardening plan)" + }, + "excluded": { + "rule.Rmax.b4": "Rmax crashes at batch > 1 on master (view(-1) over the batch, learning.py); pinned in test/network/test_all_classes.py KNOWN_BUGS", + "mcc.MSTDPET.b4": "MCC_learning.MSTDPET crashes at batch > 1 on master (MCC_learning.py); pinned in test/network/test_all_classes.py KNOWN_BUGS" + }, + "totals": { + "mcc.DiehlAndCook.b1": 14, + "mcc.DiehlAndCook.b4": 14, + "mcc.Hebbian.b1": 15, + "mcc.Hebbian.b4": 15, + "mcc.MSTDP.b1": 19, + "mcc.MSTDP.b4": 19, + "mcc.MSTDPET.b1": 24, + "mcc.NoOp.b1": 0, + "mcc.NoOp.b4": 0, + "mcc.PostPre.b1": 19, + "mcc.PostPre.b4": 19, + "nodes.LIFNodes_forward.b1": 12, + "nodes.LIFNodes_forward.b4": 12, + "rule.DiehlAndCook.b1": 18, + "rule.DiehlAndCook.b4": 18, + "rule.Hebbian.b1": 18, + "rule.Hebbian.b4": 18, + "rule.MSTDP.b1": 28, + "rule.MSTDP.b4": 28, + "rule.MSTDPET.b1": 33, + "rule.MSTDPET.b4": 33, + "rule.NoOp.b1": 3, + "rule.NoOp.b4": 3, + "rule.PostPre.b1": 24, + "rule.PostPre.b4": 24, + "rule.Rmax.b1": 24, + "rule.WeightDependentPostPre.b1": 35, + "rule.WeightDependentPostPre.b4": 35, + "run.DiehlAndCook2015_784x100.b1": 75, + "run.DiehlAndCook2015_784x100.b16": 75 + }, + "counts": { + "mcc.DiehlAndCook.b1": { + "aten::_local_scalar_dense": 1, + "aten::_to_copy": 1, + "aten::add_.Tensor": 1, + "aten::clamp_": 1, + "aten::mm": 1, + "aten::mul.Tensor": 2, + "aten::rsub.Scalar": 1, + "aten::select.int": 2, + "aten::sub.Tensor": 1, + "aten::t": 1, + "aten::view": 2 + }, + "mcc.DiehlAndCook.b4": { + "aten::_local_scalar_dense": 1, + "aten::_to_copy": 1, + "aten::add_.Tensor": 1, + "aten::clamp_": 1, + "aten::mm": 1, + "aten::mul.Tensor": 2, + "aten::rsub.Scalar": 1, + "aten::select.int": 2, + "aten::sub.Tensor": 1, + "aten::t": 1, + "aten::view": 2 + }, + "mcc.Hebbian.b1": { + "aten::_to_copy": 2, + "aten::addmm_": 2, + "aten::clamp_": 1, + "aten::mul.Tensor": 2, + "aten::select.int": 2, + "aten::t": 2, + "aten::view": 4 + }, + "mcc.Hebbian.b4": { + "aten::_to_copy": 2, + "aten::addmm_": 2, + "aten::clamp_": 1, + "aten::mul.Tensor": 2, + "aten::select.int": 2, + "aten::t": 2, + "aten::view": 4 + }, + "mcc.MSTDP.b1": { + "aten::_local_scalar_dense": 1, + "aten::_to_copy": 2, + "aten::add_.Tensor": 2, + "aten::addmm_": 2, + "aten::clamp_": 1, + "aten::clone": 2, + "aten::mul.Tensor": 2, + "aten::mul_.Tensor": 2, + "aten::select.int": 1, + "aten::t": 2, + "aten::view": 2 + }, + "mcc.MSTDP.b4": { + "aten::_local_scalar_dense": 1, + "aten::_to_copy": 2, + "aten::add_.Tensor": 2, + "aten::addmm_": 2, + "aten::clamp_": 1, + "aten::clone": 2, + "aten::mul.Tensor": 2, + "aten::mul_.Tensor": 2, + "aten::select.int": 1, + "aten::t": 2, + "aten::view": 2 + }, + "mcc.MSTDPET.b1": { + "aten::_to_copy": 2, + "aten::add.Tensor": 1, + "aten::add_.Tensor": 4, + "aten::clamp_": 1, + "aten::div.Tensor": 1, + "aten::mul.Tensor": 7, + "aten::mul_.Tensor": 3, + "aten::select.int": 1, + "aten::view": 4 + }, + "mcc.NoOp.b1": {}, + "mcc.NoOp.b4": {}, + "mcc.PostPre.b1": { + "aten::_local_scalar_dense": 2, + "aten::_to_copy": 2, + "aten::addmm_": 2, + "aten::clamp_": 1, + "aten::mul.Tensor": 2, + "aten::select.int": 4, + "aten::t": 2, + "aten::view": 4 + }, + "mcc.PostPre.b4": { + "aten::_local_scalar_dense": 2, + "aten::_to_copy": 2, + "aten::addmm_": 2, + "aten::clamp_": 1, + "aten::mul.Tensor": 2, + "aten::select.int": 4, + "aten::t": 2, + "aten::view": 4 + }, + "nodes.LIFNodes_forward.b1": { + "aten::add_.Tensor": 2, + "aten::ge.Tensor": 1, + "aten::gt.Scalar": 1, + "aten::masked_fill_.Scalar": 1, + "aten::masked_fill_.Tensor": 3, + "aten::mul_.Tensor": 2, + "aten::sub_.Tensor": 2 + }, + "nodes.LIFNodes_forward.b4": { + "aten::add_.Tensor": 2, + "aten::ge.Tensor": 1, + "aten::gt.Scalar": 1, + "aten::masked_fill_.Scalar": 1, + "aten::masked_fill_.Tensor": 3, + "aten::mul_.Tensor": 2, + "aten::sub_.Tensor": 2 + }, + "rule.DiehlAndCook.b1": { + "aten::_local_scalar_dense": 2, + "aten::_to_copy": 1, + "aten::add_.Tensor": 1, + "aten::any": 2, + "aten::clamp_.Tensor": 1, + "aten::mm": 1, + "aten::mul.Tensor": 2, + "aten::ne.Scalar": 1, + "aten::select.int": 2, + "aten::sub.Tensor": 2, + "aten::t": 1, + "aten::view": 2 + }, + "rule.DiehlAndCook.b4": { + "aten::_local_scalar_dense": 2, + "aten::_to_copy": 1, + "aten::add_.Tensor": 1, + "aten::any": 2, + "aten::clamp_.Tensor": 1, + "aten::mm": 1, + "aten::mul.Tensor": 2, + "aten::ne.Scalar": 1, + "aten::select.int": 2, + "aten::sub.Tensor": 2, + "aten::t": 1, + "aten::view": 2 + }, + "rule.Hebbian.b1": { + "aten::_local_scalar_dense": 1, + "aten::_to_copy": 2, + "aten::addmm_": 2, + "aten::any": 1, + "aten::clamp_.Tensor": 1, + "aten::mul.Tensor": 2, + "aten::ne.Scalar": 1, + "aten::select.int": 2, + "aten::t": 2, + "aten::view": 4 + }, + "rule.Hebbian.b4": { + "aten::_local_scalar_dense": 1, + "aten::_to_copy": 2, + "aten::addmm_": 2, + "aten::any": 1, + "aten::clamp_.Tensor": 1, + "aten::mul.Tensor": 2, + "aten::ne.Scalar": 1, + "aten::select.int": 2, + "aten::t": 2, + "aten::view": 4 + }, + "rule.MSTDP.b1": { + "aten::_local_scalar_dense": 3, + "aten::_to_copy": 2, + "aten::add.Tensor": 1, + "aten::add_.Tensor": 3, + "aten::any": 1, + "aten::bmm": 2, + "aten::clamp_.Tensor": 1, + "aten::mul.Tensor": 4, + "aten::mul_.Tensor": 2, + "aten::ne.Scalar": 1, + "aten::select.int": 1, + "aten::sum.dim_IntList": 1, + "aten::unsqueeze": 4, + "aten::view": 2 + }, + "rule.MSTDP.b4": { + "aten::_local_scalar_dense": 3, + "aten::_to_copy": 2, + "aten::add.Tensor": 1, + "aten::add_.Tensor": 3, + "aten::any": 1, + "aten::bmm": 2, + "aten::clamp_.Tensor": 1, + "aten::mul.Tensor": 4, + "aten::mul_.Tensor": 2, + "aten::ne.Scalar": 1, + "aten::select.int": 1, + "aten::sum.dim_IntList": 1, + "aten::unsqueeze": 4, + "aten::view": 2 + }, + "rule.MSTDPET.b1": { + "aten::_local_scalar_dense": 4, + "aten::_to_copy": 2, + "aten::add.Tensor": 1, + "aten::add_.Tensor": 4, + "aten::any": 1, + "aten::bmm": 2, + "aten::clamp_.Tensor": 1, + "aten::div.Tensor": 1, + "aten::mul.Tensor": 5, + "aten::mul_.Tensor": 3, + "aten::ne.Scalar": 1, + "aten::select.int": 1, + "aten::sum.dim_IntList": 1, + "aten::unsqueeze": 4, + "aten::view": 2 + }, + "rule.MSTDPET.b4": { + "aten::_local_scalar_dense": 4, + "aten::_to_copy": 2, + "aten::add.Tensor": 1, + "aten::add_.Tensor": 4, + "aten::any": 1, + "aten::bmm": 2, + "aten::clamp_.Tensor": 1, + "aten::div.Tensor": 1, + "aten::mul.Tensor": 5, + "aten::mul_.Tensor": 3, + "aten::ne.Scalar": 1, + "aten::select.int": 1, + "aten::sum.dim_IntList": 1, + "aten::unsqueeze": 4, + "aten::view": 2 + }, + "rule.NoOp.b1": { + "aten::_local_scalar_dense": 1, + "aten::any": 1, + "aten::ne.Scalar": 1 + }, + "rule.NoOp.b4": { + "aten::_local_scalar_dense": 1, + "aten::any": 1, + "aten::ne.Scalar": 1 + }, + "rule.PostPre.b1": { + "aten::_local_scalar_dense": 3, + "aten::_to_copy": 2, + "aten::addmm_": 2, + "aten::any": 3, + "aten::clamp_.Tensor": 1, + "aten::mul.Tensor": 2, + "aten::ne.Scalar": 1, + "aten::select.int": 4, + "aten::t": 2, + "aten::view": 4 + }, + "rule.PostPre.b4": { + "aten::_local_scalar_dense": 3, + "aten::_to_copy": 2, + "aten::addmm_": 2, + "aten::any": 3, + "aten::clamp_.Tensor": 1, + "aten::mul.Tensor": 2, + "aten::ne.Scalar": 1, + "aten::select.int": 4, + "aten::t": 2, + "aten::view": 4 + }, + "rule.Rmax.b1": { + "aten::_local_scalar_dense": 1, + "aten::_to_copy": 1, + "aten::add.Tensor": 1, + "aten::add_.Tensor": 2, + "aten::any": 1, + "aten::clamp_.Tensor": 1, + "aten::div.Tensor": 2, + "aten::mul.Tensor": 5, + "aten::mul_.Tensor": 1, + "aten::ne.Scalar": 1, + "aten::reciprocal": 1, + "aten::rsub.Scalar": 1, + "aten::select.int": 1, + "aten::sub.Tensor": 1, + "aten::unsqueeze": 1, + "aten::view": 3 + }, + "rule.WeightDependentPostPre.b1": { + "aten::_local_scalar_dense": 3, + "aten::_to_copy": 2, + "aten::add_.Tensor": 2, + "aten::any": 3, + "aten::bmm": 2, + "aten::clamp_.Tensor": 1, + "aten::mul.Tensor": 4, + "aten::ne.Scalar": 1, + "aten::rsub.Scalar": 1, + "aten::select.int": 4, + "aten::sub.Tensor": 2, + "aten::sum.dim_IntList": 2, + "aten::unsqueeze": 4, + "aten::view": 4 + }, + "rule.WeightDependentPostPre.b4": { + "aten::_local_scalar_dense": 3, + "aten::_to_copy": 2, + "aten::add_.Tensor": 2, + "aten::any": 3, + "aten::bmm": 2, + "aten::clamp_.Tensor": 1, + "aten::mul.Tensor": 4, + "aten::ne.Scalar": 1, + "aten::rsub.Scalar": 1, + "aten::select.int": 4, + "aten::sub.Tensor": 2, + "aten::sum.dim_IntList": 2, + "aten::unsqueeze": 4, + "aten::view": 4 + }, + "run.DiehlAndCook2015_784x100.b1": { + "aten::_local_scalar_dense": 3, + "aten::_to_copy": 7, + "aten::add.Tensor": 1, + "aten::add_.Tensor": 8, + "aten::addmm_": 2, + "aten::any": 1, + "aten::clamp_": 1, + "aten::eq.Scalar": 1, + "aten::ge.Tensor": 2, + "aten::gt.Scalar": 1, + "aten::index_put_": 1, + "aten::le.Scalar": 1, + "aten::lift_fresh": 1, + "aten::masked_fill_.Scalar": 1, + "aten::masked_fill_.Tensor": 6, + "aten::mm": 3, + "aten::mul.Tensor": 5, + "aten::mul_.Tensor": 6, + "aten::reciprocal": 1, + "aten::select.int": 5, + "aten::sub_.Tensor": 4, + "aten::sum.dim_IntList": 2, + "aten::t": 2, + "aten::unsqueeze": 1, + "aten::view": 7, + "aten::zeros": 2 + }, + "run.DiehlAndCook2015_784x100.b16": { + "aten::_local_scalar_dense": 3, + "aten::_to_copy": 7, + "aten::add.Tensor": 1, + "aten::add_.Tensor": 8, + "aten::addmm_": 2, + "aten::any": 1, + "aten::clamp_": 1, + "aten::eq.Scalar": 1, + "aten::ge.Tensor": 2, + "aten::gt.Scalar": 1, + "aten::index_put_": 1, + "aten::le.Scalar": 1, + "aten::lift_fresh": 1, + "aten::masked_fill_.Scalar": 1, + "aten::masked_fill_.Tensor": 6, + "aten::mm": 3, + "aten::mul.Tensor": 5, + "aten::mul_.Tensor": 6, + "aten::reciprocal": 1, + "aten::select.int": 5, + "aten::sub_.Tensor": 4, + "aten::sum.dim_IntList": 2, + "aten::t": 2, + "aten::unsqueeze": 1, + "aten::view": 7, + "aten::zeros": 2 + } + }, + "history": [ + { + "torch": "2.14.1+cu130", + "python": "3.11.9", + "date": "2026-10-24", + "commit": "db7d19303af15e38dc5598cd728065930bee3eef", + "reason": "initial operation counts (Phase 5 of the test hardening plan)" + } + ] +} diff --git a/test/regression/regen.py b/test/regression/regen.py new file mode 100644 index 00000000..1a917775 --- /dev/null +++ b/test/regression/regen.py @@ -0,0 +1,87 @@ +""" +Regenerate the golden files of the regression tests. + +Run from the repository root: + + python test/regression/regen.py --reason "why the results changed" [--only a,b] + +Regenerating is a statement that the simulated results changed on purpose, so a +reason is required; it is written into each file's ``meta``. A torch upgrade +must regenerate in its own commit. Before writing, every network must pass the +threshold-margin check (voltage at least ``MARGIN`` away from the threshold at +every recorded spike check), which keeps exact spike comparison safe on other +CPUs; a network that fails is not written and the script exits with status 1. +""" + +import argparse +import datetime +import platform +import subprocess +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE)) + +import networks as N # noqa: E402 +import torch # noqa: E402 + + +def _commit(): + root = HERE.parent.parent + + def git(*args): + return subprocess.run( + ["git", *args], cwd=root, capture_output=True, text=True + ).stdout.strip() + + head = git("rev-parse", "HEAD") or "unknown" + dirty = git("status", "--porcelain", "--", "bindsnet") + return head + ("+uncommitted bindsnet changes" if dirty else "") + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[1]) + ap.add_argument("--reason", required=True, help="why the golden files change") + ap.add_argument("--only", default="", help="comma-separated network names") + args = ap.parse_args(argv) + if not args.reason.strip(): + ap.error("--reason must not be empty") + + names = [n for n in args.only.split(",") if n] or list(N.SPECS) + unknown = sorted(set(names) - set(N.SPECS)) + if unknown: + ap.error(f"unknown networks {unknown}; known: {list(N.SPECS)}") + + N.deterministic() + base_meta = { + "torch": str(torch.__version__), + "python": platform.python_version(), + "machine": platform.machine(), + "date": datetime.date.today().isoformat(), + "commit": _commit(), + "reason": args.reason, + } + failed = [] + for name in names: + tensors, errors, margins = N.run_standard(name) + low = {k: m for k, m in margins.items() if not m > N.MARGIN} + line = ", ".join(f"{k} {m:.2e}" for k, m in margins.items()) or "none" + status = "FAIL margin" if low else "ok" + print(f"{name:<20s} {status:<12s} margins: {line} errors: {errors or 'none'}") + if low: + failed.append(name) + continue + meta = dict(base_meta, margins=margins, margin_required=N.MARGIN) + N.save_golden(name, {"meta": meta, "errors": errors, "tensors": tensors}) + if failed: + print( + f"not written (threshold margin <= {N.MARGIN}): {failed}. Change the " + "network's seed or input rate in networks.py and record the change." + ) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/regression/regen_op_counts.py b/test/regression/regen_op_counts.py new file mode 100644 index 00000000..049d58f7 --- /dev/null +++ b/test/regression/regen_op_counts.py @@ -0,0 +1,112 @@ +""" +Regenerate test/regression/op_counts.json, the stored operation counts. + +Run from the repository root: + + python test/regression/regen_op_counts.py --reason "why the counts changed" + python test/regression/regen_op_counts.py --check # compare only, write nothing + +Writing is a statement that a hot path now does a different amount of work on +purpose, so a reason is required; it is stored in the file's ``meta`` and +appended to its ``history``. Every workload is measured twice, each time on a +freshly built object; if the two counts differ the script writes nothing and +exits with status 1 (an unstable count cannot guard anything). +""" + +import argparse +import datetime +import json +import platform +import subprocess +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE)) + +import test_op_counts as T # noqa: E402 +import torch # noqa: E402 + + +def _commit(): + def git(*args): + return subprocess.run( + ["git", *args], cwd=HERE.parent.parent, capture_output=True, text=True + ).stdout.strip() + + head = git("rev-parse", "HEAD") or "unknown" + dirty = git("status", "--porcelain", "--", "bindsnet/*.py") + return head + ("+uncommitted bindsnet code changes" if dirty else "") + + +def measure(): + """Workload -> per-operation counts; raises if two measurements differ.""" + torch.set_num_threads(1) + torch.use_deterministic_algorithms(True) + counts, unstable = {}, [] + for name in T.COUNTED: + first, second = T.count_ops(name), T.count_ops(name) + if first != second: + unstable.append(f"{name}: {sum(first.values())} vs {sum(second.values())}") + counts[name] = first + if unstable: + raise RuntimeError("unstable counts (nothing written): " + "; ".join(unstable)) + return counts + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0]) + ap.add_argument("--reason", help="why the stored counts change (required to write)") + ap.add_argument( + "--check", action="store_true", help="compare with the file, write nothing" + ) + args = ap.parse_args(argv) + if not args.check and not (args.reason or "").strip(): + ap.error("--reason is required (and must not be empty) to write the file") + + try: + counts = measure() + except RuntimeError as e: + print(e) + return 1 + totals = {k: sum(v.values()) for k, v in counts.items()} + + if args.check: + old = T.load_counts()["counts"] + changed = 0 + for name, total in totals.items(): + before = sum(old[name].values()) if name in old else None + mark = "" if old.get(name) == counts[name] else " CHANGED" + changed += bool(mark) + print(f"{name:<40s} {total:6d} stored {before}{mark}") + print(f"{changed} workload(s) differ from {T.COUNTS_FILE.name}") + return 1 if changed else 0 + + history = [] + if T.COUNTS_FILE.exists(): + old = T.load_counts() + history = old.get("history", []) + meta = { + "torch": str(torch.__version__), + "python": platform.python_version(), + "date": datetime.date.today().isoformat(), + "commit": _commit(), + "reason": args.reason.strip(), + } + history.append(meta) + data = { + "meta": meta, + "excluded": T.EXCLUDED, + "totals": totals, + "counts": counts, + "history": history, + } + T.COUNTS_FILE.write_text(json.dumps(data, indent=1, sort_keys=False) + "\n") + for name, total in totals.items(): + print(f"{name:<40s} {total:6d}") + print(f"wrote {T.COUNTS_FILE} ({len(counts)} workloads)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/regression/test_golden.py b/test/regression/test_golden.py new file mode 100644 index 00000000..5fd5e4e2 --- /dev/null +++ b/test/regression/test_golden.py @@ -0,0 +1,52 @@ +""" +Golden-output regression tests: every simulated spike, voltage, trace and weight +of sixteen small seeded networks must match the files in ``golden/``. + +Caught on 2026-10-24 (each deliberate wrong change was applied, the tests run, +then reverted; the decision log in logs/test_hardening_2026-10/PLAN.md lists the +networks that failed for each): + +* ``MSTDPET`` without the ``dt`` factor of Florian eq. 2.7; +* ``LIFNodes.forward`` updating traces before the spike check and reset; +* ``PostPre`` switching its layers to additive (all-to-all) traces. + +Spikes and other non-float tensors must be identical; floats must agree within +``atol=1e-6``. If a change to the results is intended, regenerate with +``python test/regression/regen.py --reason ""`` and commit the new files. +""" + +import networks as N +import pytest +import torch + + +def _torch_minor(version): + return ".".join(str(version).split("+")[0].split(".")[:2]) + + +@pytest.mark.parametrize("name", list(N.SPECS)) +def test_golden(name): + """Compare everything network ``name`` records with its golden file. + Caught: MSTDPET without dt, LIF traces before reset, PostPre additive traces.""" + path = N.golden_path(name) + assert path.exists(), f"no golden file {path}; run regen.py --reason" + golden = N.load_golden(name) + made_with = golden["meta"]["torch"] + if _torch_minor(made_with) != _torch_minor(torch.__version__): + pytest.skip( + f"golden files were made with torch {made_with}; " + "regenerate with regen --reason" + ) + + tensors, errors, _ = N.run_standard(name) + + assert errors == golden["errors"], ( + f"{name}: errors now {errors}, golden {golden['errors']}. If this is " + "intended, regenerate with regen.py --reason." + ) + problems = N.compare(golden["tensors"], tensors) + assert not problems, ( + f"{name}: {len(problems)} recorded tensors differ from the golden file " + f"(made {golden['meta']['date']}, commit {golden['meta']['commit']}):\n " + + "\n ".join(problems[:20]) + ) diff --git a/test/regression/test_invariants.py b/test/regression/test_invariants.py new file mode 100644 index 00000000..0fa17ccf --- /dev/null +++ b/test/regression/test_invariants.py @@ -0,0 +1,429 @@ +""" +Invariant tests: the code compared with itself, so they are exact on any CPU and +need no golden file. Parametrised over the sixteen networks in ``networks.py``. + +Each test's docstring names the deliberate wrong change it was shown to catch +(applied, run, reverted on 2026-10-24). +""" + +import io + +import networks as N +import pytest +import torch + +from bindsnet.network.monitors import Monitor +from bindsnet.network.nodes import SRM0Nodes + +NAMES = list(N.SPECS) +SEED_A, SEED_B = 11, 12 # input seeds of the two trials used below + + +def _params(known): + """``NAMES`` as pytest params; names in ``known`` get an xfail mark. + + ``known[name]`` is ``(reason, exception type or None, strict)``. + """ + out = [] + for n in NAMES: + if n in known: + reason, raises, strict = known[n] + mark = pytest.mark.xfail(strict=strict, reason=reason, raises=raises) + out.append(pytest.param(n, marks=mark)) + else: + out.append(n) + return out + + +# Known failures on master db7d193 (2026-10-24); see the decision log in +# logs/test_hardening_2026-10/PLAN.md. A fix makes the strict xfail fail, which +# is the signal to remove the entry. +MEANFIELD_NORM = ( + "MeanFieldConnection.normalize assigns a plain tensor to the parameter w " + "(TypeError; open PR #813)", + TypeError, + True, +) +CSRM_WINDOW = ( + "Connection.s_w (CSRM spike window) is neither reset by " + "reset_state_variables nor kept in the state_dict (open PR #815)", + None, + True, +) +MCC_WEIGHTS = ( + "MulticompartmentConnection keeps its Weight feature outside the state_dict, " + "so DiehlAndCook2015's weights are not saved or loaded by state_dict", + None, + True, +) +MSTDP_STATE = ( + "MSTDP and MSTDPET keep p_plus, p_minus, eligibility (and eligibility_trace) " + "outside the state_dict and reset_state_variables does not clear them", + None, + True, +) +RMAX_STATE = ( + "Rmax keeps eligibility_trace outside the state_dict and " + "reset_state_variables does not clear it", + None, + True, +) + + +def _first_trial(name): + spec = N.SPECS[name] + batch, steps, _ = spec.trials[0] + return spec, batch, steps + + +def _disable_norm(net): + """Turn off weight normalisation (``Network.run`` renormalises after every call).""" + for conn in net.connections.values(): + if hasattr(conn, "norm"): + conn.norm = None + for feature in getattr(conn, "pipeline", None) or []: + if hasattr(feature, "norm"): + feature.norm = None + + +def _match_batch(net, batch): + """Resize every layer to ``batch`` the way ``Network.run`` does on its first call + (``DiehlAndCook2015`` builds its layers with batch 1 whatever ``batch_size`` is).""" + if net.batch_size != batch: + net.batch_size = batch + for layer in net.layers.values(): + layer.set_batch_size(batch) + + +def _differences(a, b): + """Keys whose tensors are not ``torch.equal`` (or exist on one side only).""" + keys = sorted(set(a) | set(b)) + return [ + k + for k in keys + if k not in a + or k not in b + or a[k].shape != b[k].shape + or not torch.equal(a[k], b[k]) + ] + + +def _assert_equal(a, b, what): + bad = _differences(a, b) + assert not bad, f"{what}: {len(bad)} tensors differ: {bad[:10]}" + + +# --------------------------------------------------------------------------- # +# 1. run(T) == run(T/2) twice +# --------------------------------------------------------------------------- # + + +@pytest.mark.parametrize("name", NAMES) +def test_split_run_equals_single_run(name): + """run(T) equals two run(T/2) calls (weight normalisation turned off). + Caught: Network.run resetting every layer at the start of each call.""" + spec, batch, steps = _first_trial(name) + + def go(split): + net, make = N.build(name) + _disable_norm(net) + x = N.inputs(make, steps, batch, SEED_A) + parts = ( + [ + {k: v[: steps // 2] for k, v in x.items()}, + {k: v[steps // 2 :] for k, v in x.items()}, + ] + if split + else [x] + ) + for part in parts: + N.run(net, spec, part) + return N.collect(net) + + _assert_equal(go(False), go(True), f"{name} run(T) vs 2 x run(T/2)") + + +# --------------------------------------------------------------------------- # +# 2 and 3. reset_state_variables() equals a fresh network +# --------------------------------------------------------------------------- # + + +def _reset_path(name, learning): + spec, batch, steps = _first_trial(name) + net, make = N.build(name, learning=learning) + N.run(net, spec, N.inputs(make, steps, batch, SEED_A)) + net.reset_state_variables() + N.run(net, spec, N.inputs(make, steps, batch, SEED_B)) + return N.collect(net), net + + +RESET_XFAIL = {"csrm_window": CSRM_WINDOW, "meanfield_norm": MEANFIELD_NORM} + + +@pytest.mark.parametrize("name", _params(RESET_XFAIL)) +def test_reset_equals_fresh_learning_off(name): + """Learning off: trial, reset, trial equals a fresh network running the second + trial. Caught: LIFNodes.reset_state_variables not zeroing refrac_count.""" + spec, batch, steps = _first_trial(name) + after_reset, _ = _reset_path(name, learning=False) + + net, make = N.build(name, learning=False) + for conn in net.connections.values(): # the first run() of the reset path + conn.normalize() # normalised the weights once at its end + N.run(net, spec, N.inputs(make, steps, batch, SEED_B)) + _assert_equal(after_reset, N.collect(net), f"{name} reset vs fresh") + + +RESET_LEARN_XFAIL = { + "dc2015_b1": MCC_WEIGHTS, + "dc2015_b16": MCC_WEIGHTS, + "mstdp_mstdpet": MSTDP_STATE, + "mstdp_mstdpet_dt05": MSTDP_STATE, + "csrm_window": CSRM_WINDOW, + "meanfield_norm": MEANFIELD_NORM, + "rules_mix": RMAX_STATE, + "rules_mix_dt05": RMAX_STATE, +} + + +@pytest.mark.parametrize("name", _params(RESET_LEARN_XFAIL)) +def test_reset_equals_fresh_learning_on(name): + """Learning on: after reset, the second trial equals a fresh network that loaded + the first trial's state_dict. Caught: PostPre caching the previous step's post + trace on the rule object.""" + spec, batch, steps = _first_trial(name) + after_reset, _ = _reset_path(name, learning=True) + + first, make = N.build(name) + N.run(first, spec, N.inputs(make, steps, batch, SEED_A)) + net, make = N.build(name) + _match_batch(net, batch) + net.load_state_dict(first.state_dict()) + net.reset_state_variables() + N.run(net, spec, N.inputs(make, steps, batch, SEED_B)) + _assert_equal(after_reset, N.collect(net), f"{name} reset vs fresh+state_dict") + + +# --------------------------------------------------------------------------- # +# 4. state_dict round trip +# --------------------------------------------------------------------------- # + +ROUND_TRIP_XFAIL = { + "dc2015_b1": MCC_WEIGHTS, + "dc2015_b16": MCC_WEIGHTS, + "csrm_window": CSRM_WINDOW, + "meanfield_norm": MEANFIELD_NORM, +} + + +@pytest.mark.parametrize("name", _params(ROUND_TRIP_XFAIL)) +def test_state_dict_round_trip(name): + """Learning off: a network rebuilt from another seed that loads a saved + state_dict continues exactly like the original. Caught: LIFNodes keeping + refrac_count as a plain attribute instead of a buffer.""" + spec, batch, steps = _first_trial(name) + net, make = N.build(name, learning=False) + N.run(net, spec, N.inputs(make, steps, batch, SEED_A)) + N.collect(net) # empty the monitors + + buf = io.BytesIO() + torch.save(net.state_dict(), buf) + buf.seek(0) + torch.manual_seed(12345) # a different build seed: nothing may come from it + spec_build = N.SPECS[name].build + other, _ = spec_build(spec.dt) + other.train(False) + for lname, mon in net.monitors.items(): + other.add_monitor( + Monitor(other.layers[lname[len(N.PREFIX) :]], mon.state_vars), name=lname + ) + _match_batch(other, batch) + other.load_state_dict(torch.load(buf, weights_only=True)) + + # inputs() also reseeds the global generator, which SRM0Nodes draw from. + N.run(net, spec, N.inputs(make, steps, batch, SEED_B)) + N.run(other, spec, N.inputs(make, steps, batch, SEED_B)) + _assert_equal(N.collect(net), N.collect(other), f"{name} state_dict round trip") + + +# --------------------------------------------------------------------------- # +# 5. learning off leaves the weights alone +# --------------------------------------------------------------------------- # + + +@pytest.mark.parametrize("name", NAMES) +def test_learning_off_keeps_weights(name): + """learning=False leaves every connection's w and b bit-identical (weight + normalisation turned off). Caught: AbstractConnection.update ignoring learning.""" + spec, batch, steps = _first_trial(name) + net, make = N.build(name, learning=False) + _disable_norm(net) + + def params(): + out = { + f"{src}->{tgt}.{pname}": p.detach().clone() + for (src, tgt), conn in net.connections.items() + for pname, p in conn.named_parameters() + } + out.update({k: t.clone() for k, t in N.feature_tensors(net).items()}) + return out + + before = params() + assert before, f"{name}: no connection parameters found" + N.run(net, spec, N.inputs(make, steps, batch, SEED_A)) + _assert_equal(before, params(), f"{name} weights after a run with learning off") + + +# --------------------------------------------------------------------------- # +# 6. batch 1 versus batch N of the same sample +# --------------------------------------------------------------------------- # + +BATCH_N = 3 +BATCH_XFAIL = { + "meanfield_norm": MEANFIELD_NORM, +} +# Floats are compared with a relative tolerance as well as the absolute one: +# batched kernels sum in a different order, which moves a float32 value by a +# couple of units in the last place (measured: CSRMNodes v off by 7.6e-6 at +# |v| = 53.8, relative 2.0e-7, csrm_window batch 1 vs 3). CLAUDE.md allows +# summation-order differences of about 1e-7 for batch > 1, stated here. +BATCH_RTOL = 1e-6 + + +@pytest.mark.parametrize("name", _params(BATCH_XFAIL)) +def test_batch_of_copies_equals_batch_one(name): + """Learning off: every sample of a batch of N copies equals the batch-1 run; + spikes exact, floats within atol 1e-6 plus rtol 1e-6 (summation order). one_spike is turned off and SRM0Nodes layers + are skipped, because both draw random numbers per sample. Caught: + Connection.compute summing presynaptic spikes over the batch.""" + spec, _, steps = _first_trial(name) + x1 = N.inputs(N.build(name)[1], steps, 1, SEED_A) + xn = {k: v.repeat(1, BATCH_N, *([1] * (v.dim() - 2))) for k, v in x1.items()} + + def go(x): + net, _ = N.build(name, learning=False) + for layer in net.layers.values(): + if getattr(layer, "one_spike", False): + layer.one_spike = False # random tie-break: one draw per sample + N.run(net, spec, x) + stochastic = { # fire from random draws, one per sample + ln for ln, layer in net.layers.items() if isinstance(layer, SRM0Nodes) + } + return { + k: v + for k, v in N.collect(net).items() + if not k.startswith(("state.", "feature.")) + and k.split(".")[0] not in stochastic + and v.dim() > 1 + } + + one, many = go(x1), go(xn) + problems = [] + for key, ref in one.items(): + if ref.size(1) != 1 or many[key].size(1) != BATCH_N: + continue # not a per-sample tensor (e.g. theta) + for i in range(BATCH_N): + got = many[key][:, i : i + 1] + ok = ( + torch.allclose(got, ref, atol=1e-6, rtol=BATCH_RTOL) + if ref.is_floating_point() + else torch.equal(got, ref) + ) + if not ok: + problems.append(f"{key}[sample {i}]") + assert not problems, f"{name}: batch of copies differs: {problems[:10]}" + + +# --------------------------------------------------------------------------- # +# 7. dt scaling of the learning rules +# --------------------------------------------------------------------------- # + +TC_TRACE = 20.0 # Nodes default trace time constant, ms + + +def _postpre_dw(dt, t_pre, t_post, nu, total=40.0): + """Weight change of one synapse for one pre and one post spike (times in ms). + + The post spike is forced with a per-step ``clamp``; the weight (0.5 mV) is far + too small to make the LIF neuron fire by itself. + """ + from bindsnet.learning import PostPre + from bindsnet.network import Network + from bindsnet.network.nodes import Input, LIFNodes + from bindsnet.network.topology import Connection + + net = Network(dt=dt) + net.add_layer(Input(n=1, traces=True, tc_trace=TC_TRACE), "pre") + net.add_layer(LIFNodes(n=1, traces=True, tc_trace=TC_TRACE), "post") + conn = Connection( + net.layers["pre"], + net.layers["post"], + nu=nu, + update_rule=PostPre, + w=torch.full((1, 1), 0.5), + wmin=-10.0, + wmax=10.0, + ) + net.add_connection(conn, "pre", "post") + steps = int(round(total / dt)) + pre = torch.zeros(steps, 1, 1, dtype=torch.bool) + post = torch.zeros(steps, 1, dtype=torch.bool) + pre[int(round(t_pre / dt))] = True + post[int(round(t_post / dt))] = True + net.run(inputs={"pre": pre}, time=total, clamp={"post": post}) + return float(conn.w.item()) - 0.5 + + +@pytest.mark.parametrize("order", ["pre_then_post", "post_then_pre"]) +def test_postpre_has_no_dt_factor(order): + """PostPre weight change per spike pair is the same at dt = 1 and dt = 0.5 + and equals nu * exp(-gap / tc_trace) (CLAUDE.md: pair STDP has no dt factor). + Caught: PostPre._connection_update multiplying its update by dt.""" + nu = (1e-2, 2e-2) + gap = 6.0 # ms between the two spikes + t_first, t_second = 10.0, 10.0 + gap + if order == "pre_then_post": + times = dict(t_pre=t_first, t_post=t_second) + expected = nu[1] * torch.exp(torch.tensor(-gap / TC_TRACE)).item() + else: + times = dict(t_pre=t_second, t_post=t_first) + expected = -nu[0] * torch.exp(torch.tensor(-gap / TC_TRACE)).item() + for dt in (1.0, 0.5): + dw = _postpre_dw(dt, nu=nu, **times) + assert dw == pytest.approx(expected, rel=1e-5), f"dt={dt}: dw={dw}" + + +def test_mstdpet_weight_step_scales_with_dt(): + """MSTDPET's weight step from a stored eligibility trace is + nu * dt * r * exp(-dt / tc_e) * z (Florian eq. 2.7 with eq. 2.8, no new spikes), + so the ratio between dt = 0.5 and dt = 1 is 0.5 * exp(0.5 / tc_e). + Caught: MSTDPET._connection_update without the dt factor.""" + from bindsnet.learning import MSTDPET + from bindsnet.network.nodes import Input, LIFNodes + from bindsnet.network.topology import Connection + + nu, tc_e, z0, reward = 1e-2, 25.0, 0.8, 1.0 + + def step(dt): + src, tgt = Input(n=1), LIFNodes(n=1) + src.batch_size = tgt.batch_size = 1 + src.s = torch.zeros(1, 1, dtype=torch.bool) + tgt.s = torch.zeros(1, 1, dtype=torch.bool) + conn = Connection( + src, tgt, nu=nu, update_rule=MSTDPET, tc_e_trace=tc_e, wmin=-10.0, wmax=10.0 + ) + conn.dt = dt + with torch.no_grad(): + conn.w.zero_() + conn.update(reward=reward) # creates zero traces; no spikes, no change + conn.update_rule.eligibility_trace.fill_(z0) + conn.update(reward=reward) + return float(conn.w.item()) + + for dt in (1.0, 0.5): + expected = nu * dt * reward * torch.exp(torch.tensor(-dt / tc_e)).item() * z0 + assert step(dt) == pytest.approx(expected, rel=1e-5), f"dt={dt}" + ratio = step(0.5) / step(1.0) + assert ratio == pytest.approx( + 0.5 * torch.exp(torch.tensor(0.5 / tc_e)).item(), rel=1e-5 + ) diff --git a/test/regression/test_op_counts.py b/test/regression/test_op_counts.py new file mode 100644 index 00000000..14e8a921 --- /dev/null +++ b/test/regression/test_op_counts.py @@ -0,0 +1,268 @@ +""" +Operation-count tests: a slower hot path shows up as more tensor operations. + +Each workload is one call on a small, seeded, fixed-shape object: one +``update()`` of every learning rule (``bindsnet.learning`` on a dense +``Connection`` and ``bindsnet.learning.MCC_learning`` on a +``MulticompartmentConnection``) at batch 1 and 4, one ``Network.run`` step of +``DiehlAndCook2015`` (784 inputs, 100 neurons) at batch 1 and 16, and one +``LIFNodes.forward`` at batch 1 and 4. A ``TorchDispatchMode`` counts every ATen +kernel the call dispatches, per operation name. The counts do not depend on the +CPU, the thread count or the clock, so they are exact on any machine. + +The stored counts are in ``op_counts.json``. A test fails when a workload's +total count grows. A drop passes and is reported as a warning with the numbers +(update the file so the lower count becomes the new ceiling). Regenerate with + + python test/regression/regen_op_counts.py --reason "why the counts changed" + +Counts can change between torch versions (operations are decomposed +differently), so the tests skip when torch's major.minor differs from the file. +""" + +import importlib +import inspect +import json +import warnings +from collections import Counter +from pathlib import Path + +import pytest +import torch +from torch.utils._python_dispatch import TorchDispatchMode + +import bindsnet.learning.learning as learning +import bindsnet.learning.MCC_learning as mcc_learning +from bindsnet.network import Network +from bindsnet.network.nodes import Input, LIFNodes, SRM0Nodes +from bindsnet.network.topology import Connection, MulticompartmentConnection +from bindsnet.network.topology_features import Weight + +COUNTS_FILE = Path(__file__).resolve().parent / "op_counts.json" + +N_SRC, N_TGT = 20, 10 # learning-rule connection size +WARMUP = 5 # steps run before counting, so traces and eligibilities are non-zero +RULE_BATCHES = (1, 4) +DC_BATCHES = (1, 16) +LIF_BATCHES = (1, 4) +LIF_N = 100 + +# Workloads that are not counted, with the reason (copied into op_counts.json). +EXCLUDED = { + "rule.Rmax.b4": "Rmax crashes at batch > 1 on master (view(-1) over the batch, " + "learning.py); pinned in test/network/test_all_classes.py KNOWN_BUGS", + "mcc.MSTDPET.b4": "MCC_learning.MSTDPET crashes at batch > 1 on master " + "(MCC_learning.py); pinned in test/network/test_all_classes.py KNOWN_BUGS", +} + + +class OpCounter(TorchDispatchMode): + """Counts every ATen operation dispatched while active, by operation name.""" + + def __init__(self): + super().__init__() + self.counts = Counter() + + def __torch_dispatch__(self, func, types, args=(), kwargs=None): + self.counts[func.name()] += 1 + return func(*args, **(kwargs or {})) + + +def _rule_classes(module, base): + return { + name: cls + for name, cls in inspect.getmembers(module, inspect.isclass) + if issubclass(cls, base) + and cls is not base + and cls.__module__ == module.__name__ + } + + +RULES = _rule_classes(learning, learning.LearningRule) +MCC_RULES = _rule_classes(mcc_learning, mcc_learning.MCC_LearningRule) + + +def _pattern(batch, n, offset): + """Fixed spike pattern with spikes in every sample (every third neuron).""" + idx = torch.arange(batch * n).view(batch, n) + return (idx + offset) % 3 == 0 + + +def _rule_workload(rule, batch, mcc): + """A fresh in->out network after a seeded warm-up; returns the update call.""" + torch.manual_seed(0) + gen = torch.Generator().manual_seed(1) + additive = rule is learning.Rmax + src = Input(n=N_SRC, traces=True, traces_additive=additive) + if additive: # Rmax needs stochastic SRM0 targets (Vasilaki et al. 2009). + tgt = SRM0Nodes(n=N_TGT, traces=True, traces_additive=True) + else: + tgt = LIFNodes(n=N_TGT, traces=True, thresh=-60.0) + w = torch.rand(N_SRC, N_TGT, generator=gen) + if mcc: + weight = Weight( + name="w", + value=w, + range=(0.0, 1.0), + learning_rule=rule, + nu=(1e-2, 1e-2), + ) + conn = MulticompartmentConnection( + source=src, target=tgt, device="cpu", pipeline=[weight] + ) + else: + conn = Connection( + src, tgt, w=w, update_rule=rule, nu=(1e-2, 1e-2), wmin=0.0, wmax=1.0 + ) + net = Network(dt=1.0, batch_size=batch) + net.add_layer(src, name="in") + net.add_layer(tgt, name="out") + net.add_connection(conn, source="in", target="out") + x = torch.rand(WARMUP, batch, N_SRC, generator=gen) < 0.5 + net.run(inputs={"in": x}, time=WARMUP, reward=1.0) + # Spikes on both sides, so every pre- and post-synaptic branch runs. + src.s = _pattern(batch, N_SRC, 0) + tgt.s = _pattern(batch, N_TGT, 1) + return lambda: conn.update(mask=None, learning=True, reward=1.0) + + +def _dc2015_workload(batch): + from bindsnet.models import DiehlAndCook2015 + + torch.manual_seed(0) + gen = torch.Generator().manual_seed(1) + net = DiehlAndCook2015( + n_inpt=784, n_neurons=100, batch_size=batch, inpt_shape=(1, 28, 28) + ) + x = torch.rand(WARMUP + 1, batch, 1, 28, 28, generator=gen) < 0.2 + net.run(inputs={"X": x[:WARMUP]}, time=WARMUP) + step = {"X": x[WARMUP:]} + return lambda: net.run(inputs=step, time=1) + + +def _lif_workload(batch): + torch.manual_seed(0) + gen = torch.Generator().manual_seed(1) + net = Network(dt=1.0, batch_size=batch) + layer = LIFNodes(n=LIF_N, traces=True) + net.add_layer(layer, name="lif") + x = torch.rand(WARMUP + 1, batch, LIF_N, generator=gen) * 20.0 + net.run(inputs={"lif": x[:WARMUP]}, time=WARMUP) + step = x[WARMUP] + return lambda: layer.forward(step) + + +def workloads(): + """Workload name -> function that builds it and returns the call to count.""" + out = {} + for name, cls in sorted(RULES.items()): + for b in RULE_BATCHES: + out[f"rule.{name}.b{b}"] = lambda c=cls, b=b: _rule_workload(c, b, False) + for name, cls in sorted(MCC_RULES.items()): + for b in RULE_BATCHES: + out[f"mcc.{name}.b{b}"] = lambda c=cls, b=b: _rule_workload(c, b, True) + for b in DC_BATCHES: + out[f"run.DiehlAndCook2015_784x100.b{b}"] = lambda b=b: _dc2015_workload(b) + for b in LIF_BATCHES: + out[f"nodes.LIFNodes_forward.b{b}"] = lambda b=b: _lif_workload(b) + return out + + +WORKLOADS = workloads() +COUNTED = sorted(set(WORKLOADS) - set(EXCLUDED)) + + +def count_ops(name): + """Build workload ``name`` fresh and count the operations of one call.""" + call = WORKLOADS[name]() + with OpCounter() as counter: + call() + return dict(sorted(counter.counts.items())) + + +def load_counts(): + return json.loads(COUNTS_FILE.read_text()) + + +def _major_minor(version): + return tuple(version.split("+")[0].split(".")[:2]) + + +@pytest.fixture(scope="module") +def stored(): + data = load_counts() + if _major_minor(data["meta"]["torch"]) != _major_minor(torch.__version__): + pytest.skip( + f"op counts were made with torch {data['meta']['torch']}; regenerate " + "with test/regression/regen_op_counts.py --reason" + ) + return data + + +def _diff(old, new): + keys = sorted(set(old) | set(new)) + rows = [ + f" {k}: {old.get(k, 0)} -> {new.get(k, 0)}" + for k in keys + if old.get(k, 0) != new.get(k, 0) + ] + return "\n".join(rows) or " (no per-operation change)" + + +@pytest.mark.parametrize("name", COUNTED) +def test_op_count_does_not_grow(name, stored): + """The operation count of one call must not grow above the stored count. + + Caught: a second ``self.connection.w.clamp_(wmin, wmax)`` in + ``LearningRule.update`` (13 rule.* workloads failed, +1 each); and + ``self.v = self.v.clone()`` added to ``LIFNodes.forward`` (4 failed: both + nodes.LIFNodes_forward and both run.DiehlAndCook2015, +1 each). Removing an + operation instead gives a warning and passes. + """ + assert name in stored["counts"], ( + f"no stored count for new workload {name}; run " + "test/regression/regen_op_counts.py --reason '...'" + ) + old = stored["counts"][name] + new = count_ops(name) + old_total, new_total = sum(old.values()), sum(new.values()) + assert new_total <= old_total, ( + f"{name}: {new_total} operations per call, stored {old_total} " + f"(+{new_total - old_total}). A hot path does more work. If intended, " + "regenerate with test/regression/regen_op_counts.py --reason '...'.\n" + + _diff(old, new) + ) + if new != old: + warnings.warn( + f"{name}: operation count {old_total} -> {new_total} (not a failure; " + "regenerate op_counts.json so the new count becomes the ceiling)\n" + + _diff(old, new) + ) + + +def test_workload_list_matches_file(stored): + """Every workload has a stored count or a stated exclusion, and nothing else. + + Caught: a new class ``class Extra(PostPre): pass`` appended to learning.py + (no stored count), and deleting the rule.Hebbian.b4 entry from op_counts.json. + """ + have = set(stored["counts"]) + missing = sorted(set(COUNTED) - have) + extra = sorted(have - set(COUNTED)) + assert not missing and not extra, ( + f"op_counts.json is out of date: missing {missing}, no longer measured " + f"{extra}; regenerate with test/regression/regen_op_counts.py --reason" + ) + assert stored.get("excluded", {}) == EXCLUDED + + +def test_regen_requires_reason(): + """The regeneration script refuses to run without a non-empty reason. + + Caught: the ``ap.error`` check for a missing or blank --reason disabled. + """ + regen = importlib.import_module("regen_op_counts") + with pytest.raises(SystemExit): + regen.main([]) + with pytest.raises(SystemExit): + regen.main(["--reason", " "]) diff --git a/test/test_api_surface.py b/test/test_api_surface.py new file mode 100644 index 00000000..a7967d1c --- /dev/null +++ b/test/test_api_surface.py @@ -0,0 +1,140 @@ +"""Public API snapshot: every public class, method and function in ``bindsnet``. + +``test/api_surface.json`` stores the call signature of each public name. When a +parameter is added, removed, renamed, reordered or gets a new default, this test +fails, and the fix is to regenerate the file in the same pull request, so the +change shows up in the diff for the reviewer: + + python test/regen_api_surface.py --reason "why the public API changed" + +Regeneration refuses to run without ``--reason`` and stores the reason in the file. + +Only names, parameter kinds and defaults are recorded. Type annotations are left out +on purpose: Python 3.11, 3.12 and 3.13 print them differently, and changing an +annotation does not break callers. +""" + +import importlib +import importlib.util +import inspect +import json +import pkgutil +import re +from pathlib import Path + +import pytest +import torch + +import bindsnet + +SNAPSHOT = Path(__file__).with_name("api_surface.json") +_ADDRESS = re.compile(r" at 0x[0-9a-fA-F]+") + + +def _default_text(value): + """A default value as text that is the same on every machine and run.""" + if isinstance(value, torch.Tensor): + return f"Tensor(shape={list(value.shape)}, dtype={value.dtype})" + if inspect.isclass(value) or inspect.isfunction(value): + return f"{value.__module__}.{value.__qualname__}" + return _ADDRESS.sub("", repr(value)) + + +def _signature_text(obj): + """``(a, b=1, *args, c=None, **kwargs)`` without annotations.""" + try: + sig = inspect.signature(obj) + except (TypeError, ValueError): + return "" + parts = [] + seen_kw_only = False + params = list(sig.parameters.values()) + for i, p in enumerate(params): + if p.kind is p.KEYWORD_ONLY and not seen_kw_only: + parts.append("*") + seen_kw_only = True + if p.kind is p.VAR_POSITIONAL: + seen_kw_only = True + parts.append(f"*{p.name}") + continue + if p.kind is p.VAR_KEYWORD: + parts.append(f"**{p.name}") + continue + text = p.name + if p.default is not p.empty: + text += f"={_default_text(p.default)}" + parts.append(text) + is_last_pos_only = p.kind is p.POSITIONAL_ONLY and ( + i + 1 == len(params) or params[i + 1].kind is not p.POSITIONAL_ONLY + ) + if is_last_pos_only: + parts.append("/") + return "(" + ", ".join(parts) + ")" + + +def collect_surface(): + """Map ``module.Name[.method]`` to its signature text, for all of bindsnet.""" + surface = {} + modules = [bindsnet.__name__] + [ + m.name for m in pkgutil.walk_packages(bindsnet.__path__, "bindsnet.") + ] + for modname in sorted(modules): + module = importlib.import_module(modname) + for name, obj in vars(module).items(): + if name.startswith("_") or getattr(obj, "__module__", None) != modname: + continue + key = f"{modname}.{name}" + if inspect.isclass(obj): + surface[key] = _signature_text(obj) + for attr, member in vars(obj).items(): + if attr.startswith("_"): + continue + if isinstance(member, (staticmethod, classmethod)): + member = member.__func__ + if inspect.isfunction(member): + surface[f"{key}.{attr}"] = _signature_text(member) + elif inspect.isfunction(obj): + surface[key] = _signature_text(obj) + return dict(sorted(surface.items())) + + +def test_public_api_matches_snapshot(): + """Every public signature in bindsnet equals the one in test/api_surface.json. + + Caught: renaming ``tc_trace`` to ``tc_traces`` in ``Nodes.__init__``. + """ + stored = json.loads(SNAPSHOT.read_text())["surface"] + live = collect_surface() + removed = sorted(set(stored) - set(live)) + added = sorted(set(live) - set(stored)) + changed = sorted(k for k in set(stored) & set(live) if stored[k] != live[k]) + lines = [f"removed: {k}" for k in removed] + lines += [f"added: {k} {live[k]}" for k in added] + for k in changed: + lines += [f"changed: {k}", f" was {stored[k]}", f" now {live[k]}"] + assert not lines, ( + "The public API differs from test/api_surface.json. If this is intended, " + 'run: python test/regen_api_surface.py --reason ""\n' + "\n".join(lines) + ) + + +def test_regen_refuses_without_reason(tmp_path, monkeypatch): + """regen_api_surface.py exits without writing when --reason is missing or blank. + + Caught: ``required=True`` removed from the ``--reason`` argument. + """ + spec = importlib.util.spec_from_file_location( + "regen_api_surface", Path(__file__).with_name("regen_api_surface.py") + ) + regen = importlib.util.module_from_spec(spec) + spec.loader.exec_module(regen) + target = tmp_path / "api_surface.json" + monkeypatch.setattr(regen, "SNAPSHOT", target) + for argv in ([], ["--reason", " "]): + with pytest.raises(SystemExit): + regen.main(argv) + assert not target.exists() + regen.main(["--reason", "test run"]) + data = json.loads(target.read_text()) + assert data["meta"]["reason"] == "test run" + assert data["history"][-1]["reason"] == "test run"