diff --git a/docs/adr/0001-episode-semantics-boundaries.md b/docs/adr/0001-episode-semantics-boundaries.md index f6c5d56..af31db5 100644 --- a/docs/adr/0001-episode-semantics-boundaries.md +++ b/docs/adr/0001-episode-semantics-boundaries.md @@ -309,6 +309,29 @@ algorithm and strict format-version binding. Rollback is removal of the v2 algorithm branch, schema definitions, and v2 golden; the v1 writer and artifacts do not require migration. +### Mixed contrast means whole-selection rejection, not missing evidence + +A focused three-candidate control resolves the first admission-atomicity +question without changing the v2 schema. The first and second threshold cores +have an `18x` bridge contrast, while the second and third have a `1x` contrast. +All three candidates remain selected; the adjacent decisions are therefore +`[pass, fail]` and the segment-level `admitted` value is false. + +The aggregate means only that the complete v1 selection cannot be adopted +unchanged. It does not mean that every selected candidate or every boundary is +invalid. V2 must retain both local contrast records, including the passing +boundary, when the aggregate rejects the selection. Changing the aggregate from +`all` to `any` makes this control fail, so the test observes whole-selection +admission rather than merely counting contrasts. + +This accepts the conservative all-adjacencies gate for the current research +oracle. It does not authorize dropping all findings in a runtime projection. +Partial adoption would require a separate boundary-driven partition contract: +how failed boundaries merge evidence, how an interior candidate with one pass +and one fail is assigned, and how resulting finding identities remain stable. +The first mixed control answers the aggregate-semantics question, so no cadence, +ratio, or candidate-count sweep is warranted in this slice. + Together, the two fixtures and focused tie/shared-evidence/background controls accept the recovery, null-control, deterministic tie-break, and publication single-consumption hypotheses for their bounded cases. The background control diff --git a/tests/test_episode_candidate.py b/tests/test_episode_candidate.py index de89f32..300dc65 100644 --- a/tests/test_episode_candidate.py +++ b/tests/test_episode_candidate.py @@ -16,6 +16,7 @@ validate_oracle, ) from scripts.episode_candidate_core import ( # noqa: E402 + activity_segments, enumerate_candidate_windows, parse_timestamp, select_window_separated_candidates, @@ -38,6 +39,22 @@ ) +def make_events(offsets: list[int]) -> list[dict[str, object]]: + origin = parse_timestamp("2026-03-10T09:00:00Z") + return [ + { + "event_id": f"line:{index}", + "line_number": index, + "timestamp": (origin + timedelta(seconds=offset)) + .isoformat() + .replace("+00:00", "Z"), + "event_type": "ssh_failed_password", + "source_ip": "203.0.113.77", + } + for index, offset in enumerate(offsets, start=1) + ] + + class ContinuousBackgroundFixtureTests(unittest.TestCase): def setUp(self) -> None: self.fixture = json.loads( @@ -202,19 +219,7 @@ def test_v2_materializes_core_contrast_admission_without_changing_selection( def test_v2_materializes_uniform_background_rejection_without_hiding_selection( self, ) -> None: - origin = parse_timestamp("2026-03-10T09:00:00Z") - events = [ - { - "event_id": f"line:{index}", - "line_number": index, - "timestamp": (origin + timedelta(seconds=150 * (index - 1))) - .isoformat() - .replace("+00:00", "Z"), - "event_type": "ssh_failed_password", - "source_ip": "203.0.113.77", - } - for index in range(1, 15) - ] + events = make_events([150 * offset for offset in range(14)]) selected = select_window_separated_candidates( enumerate_candidate_windows(events, 5, 600), 600 ) @@ -232,6 +237,101 @@ def test_v2_materializes_uniform_background_rejection_without_hiding_selection( ) self.assertFalse(admission["adjacent_contrasts"][0]["passes"]) + def test_v2_mixed_contrasts_reject_unchanged_selection_and_keep_boundaries( + self, + ) -> None: + events = make_events( + [ + 0, + 30, + 60, + 90, + 120, + 660, + 1200, + 1740, + 2280, + 2820, + 3360, + 3390, + 3420, + 3450, + 3480, + *range(3630, 5581, 150), + ] + ) + selected = select_window_separated_candidates( + enumerate_candidate_windows(events, 5, 600), 600 + ) + + admission = materialize_core_contrast_admission(events, selected, 5) + + self.assertEqual(len(activity_segments(events, 600)), 1) + self.assertEqual( + [candidate.candidate_id for candidate in selected], + [ + "candidate:line:1..line:5", + "candidate:line:11..line:18", + "candidate:line:23..line:27", + ], + ) + self.assertFalse(admission["admitted"]) + self.assertEqual( + admission["reason_code"], "adjacent_core_contrast_below_minimum" + ) + self.assertEqual( + [core["event_ids"] for core in admission["threshold_cores"]], + [ + [f"line:{index}" for index in range(1, 6)], + [f"line:{index}" for index in range(11, 16)], + [f"line:{index}" for index in range(23, 28)], + ], + ) + self.assertEqual( + [ + ( + contrast["left_candidate_id"], + contrast["right_candidate_id"], + contrast["passes"], + contrast["reason_code"], + ) + for contrast in admission["adjacent_contrasts"] + ], + [ + ( + "candidate:line:1..line:5", + "candidate:line:11..line:18", + True, + "minimum_core_gap_contrast_met", + ), + ( + "candidate:line:11..line:18", + "candidate:line:23..line:27", + False, + "minimum_core_gap_contrast_below_minimum", + ), + ], + ) + self.assertEqual( + [ + ( + contrast["bridge_mean_gap_microseconds"], + contrast["required_bridge_mean_gap_microseconds"], + ) + for contrast in admission["adjacent_contrasts"] + ], + [ + ( + {"numerator": 540_000_000, "denominator": 1}, + {"numerator": 60_000_000, "denominator": 1}, + ), + ( + {"numerator": 150_000_000, "denominator": 1}, + {"numerator": 300_000_000, "denominator": 1}, + ), + ], + ) + class IsolatedDenseBurstsFixtureTests(unittest.TestCase): def setUp(self) -> None: