From 0723b257fdbc5c7edd33e325d2019ecb025041ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=97=E5=B9=B3?= Date: Tue, 30 Jun 2026 03:22:18 +0000 Subject: [PATCH] fix(m1): _random_window fallback could return end < start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause (found during 50k sample generation at seed 6975, surfacing as 'ValueError: Number of samples, -187, must be non-negative' from linspace in _inject_drift): _random_window's best-effort fallback after 50 failed non-overlap attempts drew start and end from TWO INDEPENDENT random integers, so end could be < start. The primary loop was consistent (start, start+length). Fix: reuse the same start in the fallback so end >= start always holds (overlap is still allowed in best-effort, just no inversion). - tests/test_synthesis.py: regression sweeping 300 seeds × 4 type combos, asserting every segment's end >= start. 146 tests passing. --- src/tsmm/data/synthesis.py | 6 ++++-- tests/test_synthesis.py | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/tsmm/data/synthesis.py b/src/tsmm/data/synthesis.py index 55e1658..d028d3d 100644 --- a/src/tsmm/data/synthesis.py +++ b/src/tsmm/data/synthesis.py @@ -103,8 +103,10 @@ def _random_window(rng: np.random.Generator, T: int, length: int, occupied=None) cand = (start, start + length) if all(cand[1] <= s or cand[0] >= e for (s, e) in occupied): return cand - # could not find a non-overlapping window; return best effort - return int(rng.integers(0, T - length + 1)), int(rng.integers(0, T - length + 1)) + length + # could not find a non-overlapping window; return best effort (overlap + # allowed) but keep start/end consistent so end >= start always holds. + start = int(rng.integers(0, T - length + 1)) + return start, start + length def _inject_spike(rng, series, T, C, occupied): diff --git a/tests/test_synthesis.py b/tests/test_synthesis.py index c55c671..b8a6223 100644 --- a/tests/test_synthesis.py +++ b/tests/test_synthesis.py @@ -127,3 +127,22 @@ def test_add_missing_reproducible(): a = add_missing(s, rate=0.1, seed=3) b = add_missing(s, rate=0.1, seed=3) assert np.array_equal(a, b, equal_nan=True) + + +# ─── regression: _random_window end >= start (found during 50k generation) ─ +def test_inject_anomaly_end_ge_start_across_many_seeds(): + """Regression: _random_window fallback used to draw end independently, + producing end < start (ValueError in linspace). Sweep many seeds + types.""" + import numpy as np + s0 = generate_series(T=512, C=5, seed=0) + for seed in range(300): + for types in ( + ["drift"], ["level_shift"], ["variance_change"], + ["drift", "level_shift", "variance_change", "spike"], + ): + s = s0.copy() + out, labels, segments = inject_anomaly(s, types=types, seed=seed) + for seg in segments: + assert seg["end"] >= seg["start"], (seed, types, seg) + assert out.shape == s.shape + assert labels.shape == (512,)