From 44cc5e6c01eca6992d7333697a8dd9508c8fd5ef Mon Sep 17 00:00:00 2001 From: ultrahighsuper Date: Tue, 14 Jul 2026 14:38:04 +0900 Subject: [PATCH] fix(strategy): expose --spectral-alpha for the decaying-spectrum fill storage.generate takes a spectral_alpha knob (the k^-alpha singular-value decay for --fill decaying-spectrum), but the strategy CLI had no flag for it and runner.run/compare didn't thread it through, so every decaying-spectrum run was hardcoded to alpha=1.0 -- the only generate() parameter with no CLI path. Now that decaying-spectrum is a scored track (floor 0.90), contributors need to sweep the decay rate to see where a transform holds vs. degrades. Add --spectral-alpha (float, default 1.0, >= 0; negatives grow the tail and are rejected at the CLI boundary like --n/--data-rank) and thread it through runner.run/compare into storage.generate. End-to-end (N=512, M=48): --spectral-alpha 0.5 -> rel_err 0.70; 3.0 -> rel_err 0.004. Adds CLI-validation and pure-NumPy plumbing/effect tests. Fixes #231 --- strategy/cli.py | 11 +++++-- strategy/runner.py | 14 +++++---- tests/test_cli_errors.py | 16 +++++++++++ tests/test_spectral_alpha.py | 56 ++++++++++++++++++++++++++++++++++++ 4 files changed, 89 insertions(+), 8 deletions(-) create mode 100644 tests/test_spectral_alpha.py diff --git a/strategy/cli.py b/strategy/cli.py index a7139d7..55d669e 100644 --- a/strategy/cli.py +++ b/strategy/cli.py @@ -38,6 +38,10 @@ def build_parser() -> argparse.ArgumentParser: p.add_argument("--data-rank", type=int, default=None, help="rank of generated matrices when --fill lowrank " "(default n//32)") + p.add_argument("--spectral-alpha", type=float, default=1.0, + help="singular-value decay exponent k^-alpha for " + "--fill decaying-spectrum (default 1.0; larger = faster " + "decay). Ignored by other fills.") p.add_argument("--seed", type=int, default=0) p.add_argument("--verify", action="store_true", help="report reconstruction error vs a float64 reference") @@ -53,6 +57,8 @@ def main(argv=None) -> int: raise ValueError(f"--n must be a positive integer, got {args.n}") if args.data_rank is not None and args.data_rank < 1: raise ValueError(f"--data-rank must be a positive integer, got {args.data_rank}") + if args.spectral_alpha < 0: + raise ValueError(f"--spectral-alpha must be >= 0, got {args.spectral_alpha}") cfg = Config( device=args.device, dtype=args.dtype, @@ -67,10 +73,11 @@ def main(argv=None) -> int: ) if args.compare: runner.compare(args.n, cfg, fill=args.fill, data_rank=args.data_rank, - keep=args.keep) + keep=args.keep, spectral_alpha=args.spectral_alpha) return 0 info = runner.run(args.n, cfg, fill=args.fill, verify=args.verify, - keep=args.keep, data_rank=args.data_rank) + keep=args.keep, data_rank=args.data_rank, + spectral_alpha=args.spectral_alpha) except (ValueError, RuntimeError, MemoryError) as e: print(f"error: {e}", file=sys.stderr) return 2 diff --git a/strategy/runner.py b/strategy/runner.py index 10bc50d..63fcf45 100644 --- a/strategy/runner.py +++ b/strategy/runner.py @@ -31,7 +31,8 @@ def _timed(fn, backend) -> float: def run(n: int, cfg: Config, fill: str = "lowrank", verify: bool = False, - keep: bool = False, data_rank: int | None = None) -> dict: + keep: bool = False, data_rank: int | None = None, + spectral_alpha: float = 1.0) -> dict: """Generate A, B, compute C = A @ B with the subspace strategy, report. Default fill is 'lowrank' because that is the regime the strategy targets; @@ -54,9 +55,9 @@ def run(n: int, cfg: Config, fill: str = "lowrank", verify: bool = False, try: A = storage.generate(n, dt, on_disk, pa if on_disk else None, cfg.seed, fill, - data_rank=data_rank) + data_rank=data_rank, spectral_alpha=spectral_alpha) B = storage.generate(n, dt, on_disk, pb if on_disk else None, cfg.seed + 1, fill, - data_rank=data_rank) + data_rank=data_rank, spectral_alpha=spectral_alpha) C = storage.allocate(n, dt, on_disk, pc if on_disk else None) info = {} @@ -121,7 +122,8 @@ def _rel_frobenius_streamed(Ce, Cs, block_bytes: int = 256 * 1024**2) -> float: def compare(n: int, cfg: Config, fill: str = "lowrank", - data_rank: int | None = None, keep: bool = False) -> dict: + data_rank: int | None = None, keep: bool = False, + spectral_alpha: float = 1.0) -> dict: """Run the exact baseline and the subspace strategy on the SAME inputs and report time, throughput and the smart strategy's reconstruction error.""" backend = Backend(cfg.device, cfg.verbose) @@ -135,9 +137,9 @@ def compare(n: int, cfg: Config, fill: str = "lowrank", try: A = storage.generate(n, dt, on_disk, pa if on_disk else None, cfg.seed, fill, - data_rank=data_rank) + data_rank=data_rank, spectral_alpha=spectral_alpha) B = storage.generate(n, dt, on_disk, pb if on_disk else None, cfg.seed + 1, fill, - data_rank=data_rank) + data_rank=data_rank, spectral_alpha=spectral_alpha) Ce = storage.allocate(n, dt, on_disk, pe if on_disk else None) Cs = storage.allocate(n, dt, on_disk, ps if on_disk else None) diff --git a/tests/test_cli_errors.py b/tests/test_cli_errors.py index 4b071d4..723b76c 100644 --- a/tests/test_cli_errors.py +++ b/tests/test_cli_errors.py @@ -47,6 +47,22 @@ def test_bad_data_rank_exits_cleanly(argv, capsys): assert "error:" in capsys.readouterr().err +# --spectral-alpha is the k^-alpha decay exponent for --fill decaying-spectrum; +# a negative exponent would *grow* the tail (not a decaying spectrum), so it is +# rejected on CPU before any device work, like the other knobs. +STRATEGY_BAD_SPECTRAL_ALPHA_ARGS = [ + ["--n", "8", "--spectral-alpha", "-0.5"], + ["--n", "8", "--spectral-alpha", "-1"], +] + + +@pytest.mark.parametrize("argv", STRATEGY_BAD_SPECTRAL_ALPHA_ARGS, ids=lambda a: " ".join(a)) +def test_bad_spectral_alpha_exits_cleanly(argv, capsys): + rc = strategy_cli.main(argv) + assert rc == 2, f"expected exit 2 for {argv}, got {rc}" + assert "error:" in capsys.readouterr().err + + def test_positive_data_rank_is_unaffected(capsys): # --data-rank 1 (smallest valid rank) must not be rejected by validation -- # this guards against the check being off-by-one. This test runs without a diff --git a/tests/test_spectral_alpha.py b/tests/test_spectral_alpha.py new file mode 100644 index 0000000..302bf4d --- /dev/null +++ b/tests/test_spectral_alpha.py @@ -0,0 +1,56 @@ +"""``--spectral-alpha`` exposes storage's k^-alpha decay exponent -- previously +the only ``storage.generate`` knob with no CLI path, so ``--fill +decaying-spectrum`` was stuck at the default alpha=1.0. These pure-NumPy checks +pin (a) that the flag is parsed and (b) that the exponent actually steepens the +singular-value decay, so a larger alpha concentrates more energy in the leading +components. +""" +from __future__ import annotations + +import numpy as np + +from strategy import storage +from strategy.cli import build_parser + + +def test_spectral_alpha_is_parsed(): + args = build_parser().parse_args(["--fill", "decaying-spectrum", + "--spectral-alpha", "2.5"]) + assert args.spectral_alpha == 2.5 + # default is 1.0 (matches storage.generate's default) + assert build_parser().parse_args([]).spectral_alpha == 1.0 + + +def _leading_energy_fraction(mat, k): + s = np.linalg.svd(np.asarray(mat, dtype=np.float64), compute_uv=False) + return float((s[:k] ** 2).sum() / (s ** 2).sum()) + + +def test_larger_alpha_steepens_the_spectrum(): + n, R = 96, 32 + gentle = storage.generate(n, np.float64, False, None, seed=0, + fill="decaying-spectrum", data_rank=R, + spectral_alpha=0.5) + steep = storage.generate(n, np.float64, False, None, seed=0, + fill="decaying-spectrum", data_rank=R, + spectral_alpha=3.0) + # A steeper exponent must pack more of the energy into the top few components. + assert _leading_energy_fraction(steep, 4) > _leading_energy_fraction(gentle, 4) + + +def test_alpha_zero_is_flat_like_lowrank_scale(): + # alpha=0 -> k^0 = 1 for every component -> uniform weights (no decay). + n, R = 64, 16 + flat = storage.generate(n, np.float64, False, None, seed=1, + fill="decaying-spectrum", data_rank=R, + spectral_alpha=0.0) + s = np.linalg.svd(np.asarray(flat, dtype=np.float64), compute_uv=False) + # rank is exactly R and the top-R singular values are of comparable scale + # (no k^-alpha taper), unlike a steep spectrum. + assert np.count_nonzero(s > 1e-9 * s[0]) == R + assert s[R - 1] / s[0] > 0.05 + + +if __name__ == "__main__": + import pytest + raise SystemExit(pytest.main([__file__, "-q"]))