diff --git a/docs/adr/0001-episode-semantics-boundaries.md b/docs/adr/0001-episode-semantics-boundaries.md index 44074e1..6564c6e 100644 --- a/docs/adr/0001-episode-semantics-boundaries.md +++ b/docs/adr/0001-episode-semantics-boundaries.md @@ -151,10 +151,20 @@ does not change the ordered oracle. This accepts the candidate as compatible with already-correct isolated segmentation for this bounded null control; it does not expand the candidate's production scope. -Together, the two fixtures accept the recovery and null-control hypotheses for -their bounded cases only. Maximal-window ties, shared evidence, background -calibration, and a production complexity design still require independent -evidence. `Detector::analyze()` and `loglens.report.v3` remain unchanged. +A focused maximal-window tie control uses six events at offsets 0, 1, 2, 3, +600, and 601 seconds. With threshold five and the inclusive 600-second window, +the only candidates are `line:1` through `line:5` and `line:2` through +`line:6`. Both cover five events, span 600 seconds, and require one episode, so +the chronological window key is the first objective that can distinguish them. +The earlier candidate wins for both forward and reversed candidate input. This +accepts the final tie-break and candidate-order invariance for that bounded +control; it does not settle the separate shared-evidence policy. + +Together, the two fixtures and focused tie control accept the recovery, +null-control, and deterministic tie-break hypotheses for their bounded cases +only. Shared evidence, background calibration, and a production complexity +design still require independent evidence. `Detector::analyze()` and +`loglens.report.v3` remain unchanged. ## Alternatives considered diff --git a/tests/test_episode_candidate_core.py b/tests/test_episode_candidate_core.py index 6cd2717..5155d16 100644 --- a/tests/test_episode_candidate_core.py +++ b/tests/test_episode_candidate_core.py @@ -69,6 +69,26 @@ def test_overlapping_candidates_materialize_one_maximal_window(self) -> None: self.assertEqual(len(selected), 1) self.assertEqual(selected[0].event_ids, tuple(f"line:{i}" for i in range(1, 7))) + def test_equal_score_windows_prefer_chronological_key(self) -> None: + candidates = enumerate_candidate_windows( + make_events([0, 1, 2, 3, 600, 601]), 5, 600 + ) + expected = [tuple(f"line:{i}" for i in range(1, 6))] + + self.assertEqual( + [candidate.event_ids for candidate in candidates], + [ + tuple(f"line:{i}" for i in range(1, 6)), + tuple(f"line:{i}" for i in range(2, 7)), + ], + ) + for ordered_candidates in (candidates, list(reversed(candidates))): + selected = select_window_separated_candidates(ordered_candidates, 600) + + self.assertEqual( + [candidate.event_ids for candidate in selected], expected + ) + def test_research_size_limits_fail_closed(self) -> None: with self.assertRaisesRegex(ValueError, "at most 200 events"): enumerate_candidate_windows(make_events(list(range(201))), 5, 600)