From 3e494cbff12f3815b19a0bf943af73fd36e9d89c Mon Sep 17 00:00:00 2001 From: stacknil Date: Sat, 29 Aug 2026 00:27:03 +0800 Subject: [PATCH 1/5] feat(episodes): materialize opt-in candidate v2 --- scripts/episode_candidate_core.py | 79 +++++++-- scripts/evaluate_episode_candidate.py | 230 ++++++++++++++++++++++---- 2 files changed, 263 insertions(+), 46 deletions(-) diff --git a/scripts/episode_candidate_core.py b/scripts/episode_candidate_core.py index 58a7ddc..64024ba 100644 --- a/scripts/episode_candidate_core.py +++ b/scripts/episode_candidate_core.py @@ -33,6 +33,22 @@ def span_seconds(self) -> int: return int((self.last_seen - self.first_seen).total_seconds()) +@dataclass(frozen=True) +class CoreGapContrast: + bridge_event_ids: tuple[str, ...] + left_mean_gap_microseconds: Fraction + right_mean_gap_microseconds: Fraction + bridge_mean_gap_microseconds: Fraction + required_bridge_mean_gap_microseconds: Fraction + + @property + def passes(self) -> bool: + return ( + self.bridge_mean_gap_microseconds + >= self.required_bridge_mean_gap_microseconds + ) + + def parse_timestamp(value: str) -> datetime: try: parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) @@ -162,6 +178,16 @@ def _duration_microseconds(start: datetime, end: datetime) -> int: ) +def window_mean_gap_microseconds(candidate: CandidateWindow) -> Fraction: + """Return a candidate's exact mean inter-event gap in microseconds.""" + if candidate.event_count < 2: + raise ValueError("candidate must contain at least two events") + return Fraction( + _duration_microseconds(candidate.first_seen, candidate.last_seen), + candidate.event_count - 1, + ) + + def _validated_selection( events: Sequence[dict[str, Any]], selected: Sequence[CandidateWindow] ) -> tuple[ @@ -229,14 +255,8 @@ def _windows_have_minimum_gap_contrast( minimum_ratio: int | Fraction, ) -> bool: for left, right in zip(chronological, chronological[1:]): - left_mean_gap = Fraction( - _duration_microseconds(left.first_seen, left.last_seen), - left.event_count - 1, - ) - right_mean_gap = Fraction( - _duration_microseconds(right.first_seen, right.last_seen), - right.event_count - 1, - ) + left_mean_gap = window_mean_gap_microseconds(left) + right_mean_gap = window_mean_gap_microseconds(right) bridge_event_count = bisect_left(timestamps, right.first_seen) - bisect_right( timestamps, left.last_seen ) @@ -312,12 +332,47 @@ def selection_has_minimum_core_gap_contrast( minimum_ratio: int | Fraction = 2, ) -> bool: """Evaluate exact mean-gap contrast on minimum-span threshold cores.""" + _, contrasts = minimum_core_gap_contrasts( + events, selected, threshold, minimum_ratio + ) + return all(contrast.passes for contrast in contrasts) + + +def minimum_core_gap_contrasts( + events: Sequence[dict[str, Any]], + selected: Sequence[CandidateWindow], + threshold: int, + minimum_ratio: int | Fraction = 2, +) -> tuple[list[CandidateWindow], list[CoreGapContrast]]: + """Return minimum-span cores and exact adjacent contrast evidence.""" ratio = _validated_minimum_ratio(minimum_ratio) cores = minimum_span_threshold_cores(events, selected, threshold) - timestamps = [ - parse_timestamp(str(event["timestamp"])) for event in ordered_events(events) - ] - return _windows_have_minimum_gap_contrast(timestamps, cores, ratio) + ordered = ordered_events(events) + timestamps = [parse_timestamp(str(event["timestamp"])) for event in ordered] + contrasts: list[CoreGapContrast] = [] + for left, right in zip(cores, cores[1:]): + bridge_start = bisect_right(timestamps, left.last_seen) + bridge_end = bisect_left(timestamps, right.first_seen) + left_mean_gap = window_mean_gap_microseconds(left) + right_mean_gap = window_mean_gap_microseconds(right) + bridge_mean_gap = Fraction( + _duration_microseconds(left.last_seen, right.first_seen), + bridge_end - bridge_start + 1, + ) + contrasts.append( + CoreGapContrast( + bridge_event_ids=tuple( + str(event["event_id"]) + for event in ordered[bridge_start:bridge_end] + ), + left_mean_gap_microseconds=left_mean_gap, + right_mean_gap_microseconds=right_mean_gap, + bridge_mean_gap_microseconds=bridge_mean_gap, + required_bridge_mean_gap_microseconds=ratio + * max(left_mean_gap, right_mean_gap), + ) + ) + return cores, contrasts def activity_segments( diff --git a/scripts/evaluate_episode_candidate.py b/scripts/evaluate_episode_candidate.py index 48c429c..bc9301d 100644 --- a/scripts/evaluate_episode_candidate.py +++ b/scripts/evaluate_episode_candidate.py @@ -7,6 +7,7 @@ import json import sys from datetime import datetime +from fractions import Fraction from pathlib import Path from typing import Any, Iterable, Sequence @@ -20,9 +21,11 @@ from .episode_candidate_core import ( CandidateWindow, enumerate_candidate_windows, + minimum_core_gap_contrasts, ordered_events, parse_timestamp, select_window_separated_candidates, + window_mean_gap_microseconds, ) else: from episode_baseline_contract import ( @@ -34,11 +37,19 @@ from episode_candidate_core import ( CandidateWindow, enumerate_candidate_windows, + minimum_core_gap_contrasts, ordered_events, parse_timestamp, select_window_separated_candidates, + window_mean_gap_microseconds, ) + +ALGORITHM_V1 = "window-separated-v1" +ALGORITHM_V2 = "window-separated-core-contrast-v2" +SUPPORTED_ALGORITHMS = frozenset((ALGORITHM_V1, ALGORITHM_V2)) + + def _candidate_membership_index( records: Iterable[tuple[str, Sequence[str]]], ) -> dict[str, tuple[str, ...]]: @@ -99,12 +110,106 @@ def _validate_baseline_reference(baseline_reference: str) -> None: raise ValueError("baseline_reference must be a privacy-safe file name") +def _validate_algorithm(algorithm: str) -> str: + if algorithm not in SUPPORTED_ALGORITHMS: + raise ValueError("unsupported candidate algorithm") + return algorithm + + +def _fraction_json(value: Fraction | int) -> dict[str, int]: + fraction = Fraction(value) + return { + "numerator": fraction.numerator, + "denominator": fraction.denominator, + } + + +def materialize_core_contrast_admission( + events: Sequence[dict[str, Any]], + selected: Sequence[CandidateWindow], + threshold: int, +) -> dict[str, Any]: + chronological = sorted( + selected, + key=lambda candidate: ( + candidate.first_seen, + candidate.last_seen, + candidate.event_ids, + ), + ) + cores, contrasts = minimum_core_gap_contrasts( + events, chronological, threshold + ) + threshold_cores = [] + for candidate, core in zip(chronological, cores): + threshold_cores.append( + { + "candidate_id": candidate.candidate_id, + "event_ids": list(core.event_ids), + "first_seen": canonical_oracle_timestamp(core.first_seen), + "last_seen": canonical_oracle_timestamp(core.last_seen), + "mean_gap_microseconds": _fraction_json( + window_mean_gap_microseconds(core) + ), + } + ) + + adjacent_contrasts = [] + for left, right, contrast in zip( + chronological, chronological[1:], contrasts + ): + adjacent_contrasts.append( + { + "left_candidate_id": left.candidate_id, + "right_candidate_id": right.candidate_id, + "bridge_event_ids": list(contrast.bridge_event_ids), + "bridge_mean_gap_microseconds": _fraction_json( + contrast.bridge_mean_gap_microseconds + ), + "required_bridge_mean_gap_microseconds": _fraction_json( + contrast.required_bridge_mean_gap_microseconds + ), + "passes": contrast.passes, + "reason_code": ( + "minimum_core_gap_contrast_met" + if contrast.passes + else "minimum_core_gap_contrast_below_minimum" + ), + } + ) + + if not chronological: + admitted = False + reason_code = "no_selected_episode" + elif len(chronological) == 1: + admitted = True + reason_code = "single_selected_episode" + else: + admitted = all(contrast.passes for contrast in contrasts) + reason_code = ( + "all_adjacent_core_contrasts_met" + if admitted + else "adjacent_core_contrast_below_minimum" + ) + + return { + "policy_id": "minimum_span_threshold_core_gap_contrast", + "minimum_ratio": {"numerator": 2, "denominator": 1}, + "admitted": admitted, + "reason_code": reason_code, + "threshold_cores": threshold_cores, + "adjacent_contrasts": adjacent_contrasts, + } + + def _build_oracle( fixture: dict[str, Any], segments: Sequence[Sequence[dict[str, Any]]], baseline_episode_count: int, baseline_reference: str, + algorithm: str, ) -> dict[str, Any]: + algorithm = _validate_algorithm(algorithm) rule = fixture["rule"] window_seconds = int(rule["window_seconds"]) threshold = int(rule["threshold"]) @@ -161,33 +266,59 @@ def _build_oracle( ), } ) - output_segments.append( - { - "segment_id": segment_id, - "event_ids": event_ids, - "first_seen": canonical_oracle_timestamp( - parse_timestamp(str(events[0]["timestamp"])) - ), - "last_seen": canonical_oracle_timestamp( - parse_timestamp(str(events[-1]["timestamp"])) - ), - "candidate_windows": [ - _candidate_json(candidate, memberships, selected_ids) - for candidate in candidates - ], - "selected_episodes": episodes, - "event_decisions": event_decisions, - } - ) + output_segment = { + "segment_id": segment_id, + "event_ids": event_ids, + "first_seen": canonical_oracle_timestamp( + parse_timestamp(str(events[0]["timestamp"])) + ), + "last_seen": canonical_oracle_timestamp( + parse_timestamp(str(events[-1]["timestamp"])) + ), + "candidate_windows": [ + _candidate_json(candidate, memberships, selected_ids) + for candidate in candidates + ], + "selected_episodes": episodes, + "event_decisions": event_decisions, + } + if algorithm == ALGORITHM_V2: + output_segment["admission"] = materialize_core_contrast_admission( + events, selected, threshold + ) + output_segments.append(output_segment) - oracle = { - "format": "loglens.episode_candidate_oracle.v1", - "fixture_id": fixture["fixture_id"], - "algorithm": { + if algorithm == ALGORITHM_V1: + format_id = "loglens.episode_candidate_oracle.v1" + algorithm_metadata = { "id": "research.window_separated_weighted_intervals", "version": "1", "status": "candidate", - }, + } + notes = [ + "Research-only candidate; Detector::analyze() and loglens.report.v3 are unchanged.", + "Selected windows require a gap strictly greater than one rule window; exact-boundary gaps remain one cooldown episode.", + "Selection maximizes covered evidence count, then minimizes total span and episode count, with a chronological final tie-break.", + "Exhaustive candidate materialization and overlap reporting have super-quadratic worst-case cost; hard limits keep this fixture tool bounded, not production-ready.", + ] + else: + format_id = "loglens.episode_candidate_oracle.v2" + algorithm_metadata = { + "id": "research.window_separated_minimum_core_contrast", + "version": "2", + "status": "candidate", + } + notes = [ + "Research-only candidate; Detector::analyze() and loglens.report.v3 are unchanged.", + "Candidate selection remains window-separated v1; admission is a separate policy projection and rejected selections stay visible for audit.", + "Admission uses exact mean-gap arithmetic on minimum-span threshold-sized cores while retaining intervening events as bridge evidence.", + "The fixed 2x research ratio is not production calibration, and this oracle does not change report-v3 or runtime findings.", + ] + + oracle = { + "format": format_id, + "fixture_id": fixture["fixture_id"], + "algorithm": algorithm_metadata, "rule": rule_projection(rule), "segments": output_segments, "comparison": { @@ -195,12 +326,7 @@ def _build_oracle( "baseline_episode_count": baseline_episode_count, "candidate_episode_count": episode_index, "continuous_segment_split": episode_index > baseline_episode_count, - "notes": [ - "Research-only candidate; Detector::analyze() and loglens.report.v3 are unchanged.", - "Selected windows require a gap strictly greater than one rule window; exact-boundary gaps remain one cooldown episode.", - "Selection maximizes covered evidence count, then minimizes total span and episode count, with a chronological final tie-break.", - "Exhaustive candidate materialization and overlap reporting have super-quadratic worst-case cost; hard limits keep this fixture tool bounded, not production-ready.", - ], + "notes": notes, }, } return oracle @@ -210,19 +336,45 @@ def evaluate_fixture( fixture: dict[str, Any], baseline: dict[str, Any], baseline_reference: str = "baseline.expected.json", + algorithm: str = ALGORITHM_V1, ) -> dict[str, Any]: _validate_baseline_reference(baseline_reference) + algorithm = _validate_algorithm(algorithm) context = validate_fixture_baseline(fixture, baseline) oracle = _build_oracle( - fixture, context.segments, context.episode_count, baseline_reference + fixture, + context.segments, + context.episode_count, + baseline_reference, + algorithm, ) - _validate_oracle_cross_references(fixture, oracle) + _validate_oracle_cross_references(fixture, oracle, algorithm) return oracle def _validate_oracle_cross_references( - fixture: dict[str, Any], oracle: dict[str, Any] + fixture: dict[str, Any], oracle: dict[str, Any], algorithm: str ) -> None: + expected_identity = { + ALGORITHM_V1: ( + "loglens.episode_candidate_oracle.v1", + { + "id": "research.window_separated_weighted_intervals", + "version": "1", + "status": "candidate", + }, + ), + ALGORITHM_V2: ( + "loglens.episode_candidate_oracle.v2", + { + "id": "research.window_separated_minimum_core_contrast", + "version": "2", + "status": "candidate", + }, + ), + }[algorithm] + if (oracle.get("format"), oracle.get("algorithm")) != expected_identity: + raise ValueError("oracle format and algorithm must match the selected version") if oracle.get("fixture_id") != fixture.get("fixture_id"): raise ValueError("oracle fixture_id must match the research fixture") if oracle.get("rule") != rule_projection(fixture["rule"]): @@ -235,6 +387,8 @@ def _validate_oracle_cross_references( selected_count = 0 episode_indexes: list[int] = [] for segment in oracle["segments"]: + if ("admission" in segment) != (algorithm == ALGORITHM_V2): + raise ValueError("oracle admission must match the selected version") events = list(segment["event_ids"]) segment_ids.extend(events) decisions = [decision["event_id"] for decision in segment["event_decisions"]] @@ -336,12 +490,18 @@ def validate_oracle( baseline: dict[str, Any], oracle: dict[str, Any], baseline_reference: str = "baseline.expected.json", + algorithm: str = ALGORITHM_V1, ) -> None: _validate_baseline_reference(baseline_reference) + algorithm = _validate_algorithm(algorithm) context = validate_fixture_baseline(fixture, baseline) - _validate_oracle_cross_references(fixture, oracle) + _validate_oracle_cross_references(fixture, oracle, algorithm) expected = _build_oracle( - fixture, context.segments, context.episode_count, baseline_reference + fixture, + context.segments, + context.episode_count, + baseline_reference, + algorithm, ) if oracle != expected: raise ValueError("oracle does not match the canonical fixture derivation") @@ -355,6 +515,7 @@ def main(argv: Sequence[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--fixture", type=Path, required=True) parser.add_argument("--baseline", type=Path, required=True) + parser.add_argument("--algorithm", default=ALGORITHM_V1) parser.add_argument( "--check", type=Path, @@ -370,6 +531,7 @@ def main(argv: Sequence[str] | None = None) -> int: _load(args.fixture), _load(args.baseline), baseline_reference=args.baseline.name, + algorithm=args.algorithm, ) rendered = json.dumps(oracle, indent=2, ensure_ascii=False) + "\n" if ( From 8c7f50cd0a1cb773014aa68772c44dd40a1d890c Mon Sep 17 00:00:00 2001 From: stacknil Date: Sat, 29 Aug 2026 00:27:19 +0800 Subject: [PATCH 2/5] test(episodes): lock candidate oracle compatibility --- tests/test_episode_candidate.py | 105 +++++++++++++++++- tests/test_episode_candidate_compatibility.py | 89 +++++++++++++++ tests/test_episode_candidate_validation.py | 64 +++++++++-- 3 files changed, 247 insertions(+), 11 deletions(-) create mode 100644 tests/test_episode_candidate_compatibility.py diff --git a/tests/test_episode_candidate.py b/tests/test_episode_candidate.py index 24e1f22..de89f32 100644 --- a/tests/test_episode_candidate.py +++ b/tests/test_episode_candidate.py @@ -10,10 +10,16 @@ sys.path.insert(0, str(REPO_ROOT)) from scripts.evaluate_episode_candidate import ( # noqa: E402 + ALGORITHM_V2, evaluate_fixture, + materialize_core_contrast_admission, validate_oracle, ) -from scripts.episode_candidate_core import parse_timestamp # noqa: E402 +from scripts.episode_candidate_core import ( # noqa: E402 + enumerate_candidate_windows, + parse_timestamp, + select_window_separated_candidates, +) FIXTURE_ROOT = ( @@ -129,6 +135,103 @@ def test_equivalent_timezone_offsets_produce_the_same_oracle(self) -> None: evaluate_fixture(self.fixture, self.baseline), ) + def test_v2_materializes_core_contrast_admission_without_changing_selection( + self, + ) -> None: + v1 = evaluate_fixture(self.fixture, self.baseline) + v2 = evaluate_fixture( + self.fixture, self.baseline, algorithm=ALGORITHM_V2 + ) + admission = v2["segments"][0]["admission"] + + self.assertEqual(v2["format"], "loglens.episode_candidate_oracle.v2") + self.assertEqual( + v2["algorithm"], + { + "id": "research.window_separated_minimum_core_contrast", + "version": "2", + "status": "candidate", + }, + ) + self.assertEqual( + v2["segments"][0]["selected_episodes"], + v1["segments"][0]["selected_episodes"], + ) + 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)], + ], + ) + self.assertEqual( + [core["mean_gap_microseconds"] for core in admission["threshold_cores"]], + [ + {"numerator": 30_000_000, "denominator": 1}, + {"numerator": 30_000_000, "denominator": 1}, + ], + ) + self.assertEqual( + admission["adjacent_contrasts"], + [ + { + "left_candidate_id": "candidate:line:1..line:5", + "right_candidate_id": "candidate:line:11..line:15", + "bridge_event_ids": [f"line:{index}" for index in range(6, 11)], + "bridge_mean_gap_microseconds": { + "numerator": 540_000_000, + "denominator": 1, + }, + "required_bridge_mean_gap_microseconds": { + "numerator": 60_000_000, + "denominator": 1, + }, + "passes": True, + "reason_code": "minimum_core_gap_contrast_met", + } + ], + ) + self.assertTrue(admission["admitted"]) + self.assertEqual( + admission["reason_code"], "all_adjacent_core_contrasts_met" + ) + validate_oracle( + self.fixture, self.baseline, v2, algorithm=ALGORITHM_V2 + ) + + 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) + ] + selected = select_window_separated_candidates( + enumerate_candidate_windows(events, 5, 600), 600 + ) + + admission = materialize_core_contrast_admission(events, selected, 5) + + self.assertEqual(len(selected), 2) + self.assertFalse(admission["admitted"]) + self.assertEqual( + admission["reason_code"], "adjacent_core_contrast_below_minimum" + ) + self.assertEqual( + admission["adjacent_contrasts"][0]["reason_code"], + "minimum_core_gap_contrast_below_minimum", + ) + self.assertFalse(admission["adjacent_contrasts"][0]["passes"]) + class IsolatedDenseBurstsFixtureTests(unittest.TestCase): def setUp(self) -> None: diff --git a/tests/test_episode_candidate_compatibility.py b/tests/test_episode_candidate_compatibility.py new file mode 100644 index 0000000..14fd6fc --- /dev/null +++ b/tests/test_episode_candidate_compatibility.py @@ -0,0 +1,89 @@ +import json +import sys +import tempfile +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT)) + +from scripts.evaluate_episode_candidate import ( # noqa: E402 + ALGORITHM_V1, + evaluate_fixture, + main, +) + + +FIXTURE_ROOTS = ( + REPO_ROOT + / "tests" + / "fixtures" + / "episode_semantics_v0.7" + / "continuous_background_two_peaks", + REPO_ROOT + / "tests" + / "fixtures" + / "episode_semantics_v0.7" + / "isolated_dense_bursts", +) + + +def canonical_bytes(value: dict[str, object]) -> bytes: + return (json.dumps(value, indent=2, ensure_ascii=False) + "\n").encode() + + +class CandidateV1CompatibilityTests(unittest.TestCase): + def test_default_and_explicit_v1_match_committed_bytes(self) -> None: + for root in FIXTURE_ROOTS: + with self.subTest(fixture=root.name): + fixture = json.loads( + (root / "fixture.json").read_text(encoding="utf-8") + ) + baseline = json.loads( + (root / "baseline.expected.json").read_text(encoding="utf-8") + ) + expected = ( + root / "candidate.window-separated-v1.expected.json" + ).read_bytes() + + self.assertEqual( + canonical_bytes(evaluate_fixture(fixture, baseline)), expected + ) + self.assertEqual( + canonical_bytes( + evaluate_fixture( + fixture, baseline, algorithm=ALGORITHM_V1 + ) + ), + expected, + ) + + def test_cli_default_and_explicit_v1_match_committed_bytes(self) -> None: + root = FIXTURE_ROOTS[0] + expected = (root / "candidate.window-separated-v1.expected.json").read_bytes() + for label, algorithm_args in ( + ("default", []), + ("explicit", ["--algorithm", ALGORITHM_V1]), + ): + with self.subTest(label=label), tempfile.TemporaryDirectory() as directory: + output = Path(directory) / "oracle.json" + + status = main( + [ + "--fixture", + str(root / "fixture.json"), + "--baseline", + str(root / "baseline.expected.json"), + "--output", + str(output), + *algorithm_args, + ] + ) + + self.assertEqual(status, 0) + self.assertEqual(output.read_bytes(), expected) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_episode_candidate_validation.py b/tests/test_episode_candidate_validation.py index 43c017c..99ff48e 100644 --- a/tests/test_episode_candidate_validation.py +++ b/tests/test_episode_candidate_validation.py @@ -18,6 +18,7 @@ sys.path.insert(0, str(REPO_ROOT)) from scripts.evaluate_episode_candidate import ( # noqa: E402 + ALGORITHM_V2, evaluate_fixture, main, validate_oracle, @@ -31,13 +32,15 @@ / "episode_semantics_v0.7" / "continuous_background_two_peaks" ) -CANDIDATE_ORACLE_ROOTS = ( - FIXTURE_ROOT, +CANDIDATE_ORACLES = ( + FIXTURE_ROOT / "candidate.window-separated-v1.expected.json", + FIXTURE_ROOT / "candidate.window-separated-core-contrast-v2.expected.json", REPO_ROOT / "tests" / "fixtures" / "episode_semantics_v0.7" - / "isolated_dense_bursts", + / "isolated_dense_bursts" + / "candidate.window-separated-v1.expected.json", ) @@ -60,15 +63,41 @@ def test_committed_oracles_conform_to_draft_2020_12_schema(self) -> None: ) Draft202012Validator.check_schema(schema) validator = Draft202012Validator(schema, format_checker=FormatChecker()) - for root in CANDIDATE_ORACLE_ROOTS: - with self.subTest(fixture=root.name): - oracle = json.loads( - (root / "candidate.window-separated-v1.expected.json").read_text( - encoding="utf-8" - ) - ) + for path in CANDIDATE_ORACLES: + with self.subTest(oracle=path.name): + oracle = json.loads(path.read_text(encoding="utf-8")) self.assertEqual(list(validator.iter_errors(oracle)), []) + @unittest.skipIf( + Draft202012Validator is None, + "install requirements-test.txt to validate the JSON Schema", + ) + def test_schema_rejects_cross_version_algorithm_and_admission_shapes( + self, + ) -> None: + schema = json.loads( + (FIXTURE_ROOT / "candidate-oracle.schema.json").read_text(encoding="utf-8") + ) + validator = Draft202012Validator(schema, format_checker=FormatChecker()) + v1 = json.loads(CANDIDATE_ORACLES[0].read_text(encoding="utf-8")) + v2 = json.loads(CANDIDATE_ORACLES[1].read_text(encoding="utf-8")) + v1_with_admission = copy.deepcopy(v1) + v1_with_admission["segments"][0]["admission"] = copy.deepcopy( + v2["segments"][0]["admission"] + ) + v2_without_admission = copy.deepcopy(v2) + del v2_without_admission["segments"][0]["admission"] + v2_with_v1_algorithm = copy.deepcopy(v2) + v2_with_v1_algorithm["algorithm"] = copy.deepcopy(v1["algorithm"]) + + for label, oracle in ( + ("v1 with admission", v1_with_admission), + ("v2 without admission", v2_without_admission), + ("v2 with v1 algorithm", v2_with_v1_algorithm), + ): + with self.subTest(label=label): + self.assertNotEqual(list(validator.iter_errors(oracle)), []) + def test_validator_rejects_episode_evidence_drift(self) -> None: oracle = evaluate_fixture(self.fixture, self.baseline) oracle["segments"][0]["selected_episodes"][0]["event_ids"].pop() @@ -140,6 +169,21 @@ def test_cli_returns_two_without_disclosing_invalid_input_path(self) -> None: self.assertIn("invalid JSON", stderr.getvalue()) self.assertNotIn(directory, stderr.getvalue()) + def test_unknown_algorithm_fails_closed(self) -> None: + with self.assertRaisesRegex(ValueError, "unsupported candidate algorithm"): + evaluate_fixture(self.fixture, self.baseline, algorithm="unknown") + + def test_v2_validator_rejects_admission_drift(self) -> None: + oracle = evaluate_fixture( + self.fixture, self.baseline, algorithm=ALGORITHM_V2 + ) + oracle["segments"][0]["admission"]["admitted"] = False + + with self.assertRaisesRegex(ValueError, "canonical fixture derivation"): + validate_oracle( + self.fixture, self.baseline, oracle, algorithm=ALGORITHM_V2 + ) + if __name__ == "__main__": unittest.main() From f8affa6eecd053ca7b38a8d95a91bee70bb3ddb8 Mon Sep 17 00:00:00 2001 From: stacknil Date: Sat, 29 Aug 2026 00:27:27 +0800 Subject: [PATCH 3/5] docs(episodes): version candidate oracle contract --- docs/adr/0001-episode-semantics-boundaries.md | 37 ++ .../candidate-oracle.schema.json | 197 +++++++++- ...w-separated-core-contrast-v2.expected.json | 338 ++++++++++++++++++ 3 files changed, 570 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/episode_semantics_v0.7/continuous_background_two_peaks/candidate.window-separated-core-contrast-v2.expected.json diff --git a/docs/adr/0001-episode-semantics-boundaries.md b/docs/adr/0001-episode-semantics-boundaries.md index 2144e45..f6c5d56 100644 --- a/docs/adr/0001-episode-semantics-boundaries.md +++ b/docs/adr/0001-episode-semantics-boundaries.md @@ -273,6 +273,42 @@ processes, change candidate-v1 selection, or authorize a detector, CLI, report, oracle-schema, or evaluator change. Any candidate-v2 materialization remains a separate compatibility decision. +## Candidate-v2 materialization contract + +Candidate v2 is now available only through the evaluator's explicit +`--algorithm window-separated-core-contrast-v2` option. Omitting the option, or +selecting `window-separated-v1`, retains the canonical v1 serialization byte for +byte. No existing v1 artifact is backfilled or rewritten. + +The v2 oracle deliberately separates two decisions: + +1. `selected_episodes` records the unchanged window-separated-v1 evidence + selection. +2. Each segment's `admission` records whether the selected evidence passes the + fixed minimum-span threshold-core contrast policy. + +A failed admission therefore does not erase selected episodes, candidates, or +event decisions. It emits `admitted: false`, a stable reason code, the selected +threshold cores, and every adjacent contrast for inspection. This keeps a policy +rejection auditable instead of turning it into missing evidence. A segment with +one selected episode is admitted vacuously; a segment with no selected episode +is rejected explicitly. + +All ratios and mean gaps use exact numerator/denominator objects. The v2 schema +requires `admission` and pins the v2 algorithm identity, while the same combined +Draft 2020-12 schema rejects admission fields in v1 and pins the v1 identity. +The committed v2 golden covers the positive continuous-background control; a +focused evaluator test covers the uniform-background rejection without adding a +second large generated fixture. + +This is an additive research-artifact migration, not a runtime report migration. +`Detector::analyze()`, the CLI finding path, and `loglens.report.v3` remain +unchanged. The main risk is consumers treating the fixed 2x research admission +as calibrated production policy. Compatibility is maintained by the opt-in +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. + 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 @@ -320,4 +356,5 @@ and `loglens.report.v3` remain unchanged. - [`Candidate evaluator`](../../scripts/evaluate_episode_candidate.py) - [`Candidate regression tests`](../../tests/test_episode_candidate.py) - [`Continuous-background candidate oracle`](../../tests/fixtures/episode_semantics_v0.7/continuous_background_two_peaks/candidate.window-separated-v1.expected.json) +- [`Continuous-background candidate-v2 oracle`](../../tests/fixtures/episode_semantics_v0.7/continuous_background_two_peaks/candidate.window-separated-core-contrast-v2.expected.json) - [`Isolated-burst candidate oracle`](../../tests/fixtures/episode_semantics_v0.7/isolated_dense_bursts/candidate.window-separated-v1.expected.json) diff --git a/tests/fixtures/episode_semantics_v0.7/continuous_background_two_peaks/candidate-oracle.schema.json b/tests/fixtures/episode_semantics_v0.7/continuous_background_two_peaks/candidate-oracle.schema.json index fdc28ef..16978e6 100644 --- a/tests/fixtures/episode_semantics_v0.7/continuous_background_two_peaks/candidate-oracle.schema.json +++ b/tests/fixtures/episode_semantics_v0.7/continuous_background_two_peaks/candidate-oracle.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/stacknil/LogLens/blob/main/tests/fixtures/episode_semantics_v0.7/continuous_background_two_peaks/candidate-oracle.schema.json", "title": "LogLens v0.7 episode candidate oracle", - "description": "A comparison-oriented result format for a future episode-segmentation candidate. It records candidate windows, selected episodes, event-level inclusion decisions, and the baseline comparison without changing the v0.6 report contract.", + "description": "A versioned comparison-oriented result format for future episode-segmentation candidates. V1 records selection; opt-in v2 adds a separate admission decision without changing the v0.6 report contract.", "type": "object", "additionalProperties": false, "required": [ @@ -15,7 +15,10 @@ ], "properties": { "format": { - "const": "loglens.episode_candidate_oracle.v1" + "enum": [ + "loglens.episode_candidate_oracle.v1", + "loglens.episode_candidate_oracle.v2" + ] }, "fixture_id": { "type": "string", @@ -38,6 +41,52 @@ "$ref": "#/$defs/comparison" } }, + "allOf": [ + { + "if": { + "properties": { + "format": { + "const": "loglens.episode_candidate_oracle.v1" + } + }, + "required": ["format"] + }, + "then": { + "properties": { + "algorithm": { + "const": { + "id": "research.window_separated_weighted_intervals", + "version": "1", + "status": "candidate" + } + }, + "segments": { + "items": { + "not": { + "required": ["admission"] + } + } + } + } + }, + "else": { + "properties": { + "algorithm": { + "const": { + "id": "research.window_separated_minimum_core_contrast", + "version": "2", + "status": "candidate" + } + }, + "segments": { + "items": { + "required": ["admission"] + } + } + } + } + } + ], "$defs": { "algorithm": { "type": "object", @@ -281,6 +330,147 @@ } } }, + "rational": { + "type": "object", + "additionalProperties": false, + "required": ["numerator", "denominator"], + "properties": { + "numerator": { + "type": "integer", + "minimum": 0 + }, + "denominator": { + "type": "integer", + "minimum": 1 + } + } + }, + "threshold_core": { + "type": "object", + "additionalProperties": false, + "required": [ + "candidate_id", + "event_ids", + "first_seen", + "last_seen", + "mean_gap_microseconds" + ], + "properties": { + "candidate_id": { + "type": "string", + "minLength": 1 + }, + "event_ids": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "first_seen": { + "type": "string", + "format": "date-time" + }, + "last_seen": { + "type": "string", + "format": "date-time" + }, + "mean_gap_microseconds": { + "$ref": "#/$defs/rational" + } + } + }, + "adjacent_contrast": { + "type": "object", + "additionalProperties": false, + "required": [ + "left_candidate_id", + "right_candidate_id", + "bridge_event_ids", + "bridge_mean_gap_microseconds", + "required_bridge_mean_gap_microseconds", + "passes", + "reason_code" + ], + "properties": { + "left_candidate_id": { + "type": "string", + "minLength": 1 + }, + "right_candidate_id": { + "type": "string", + "minLength": 1 + }, + "bridge_event_ids": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "bridge_mean_gap_microseconds": { + "$ref": "#/$defs/rational" + }, + "required_bridge_mean_gap_microseconds": { + "$ref": "#/$defs/rational" + }, + "passes": { + "type": "boolean" + }, + "reason_code": { + "enum": [ + "minimum_core_gap_contrast_met", + "minimum_core_gap_contrast_below_minimum" + ] + } + } + }, + "admission": { + "type": "object", + "additionalProperties": false, + "required": [ + "policy_id", + "minimum_ratio", + "admitted", + "reason_code", + "threshold_cores", + "adjacent_contrasts" + ], + "properties": { + "policy_id": { + "const": "minimum_span_threshold_core_gap_contrast" + }, + "minimum_ratio": { + "$ref": "#/$defs/rational" + }, + "admitted": { + "type": "boolean" + }, + "reason_code": { + "enum": [ + "no_selected_episode", + "single_selected_episode", + "all_adjacent_core_contrasts_met", + "adjacent_core_contrast_below_minimum" + ] + }, + "threshold_cores": { + "type": "array", + "items": { + "$ref": "#/$defs/threshold_core" + } + }, + "adjacent_contrasts": { + "type": "array", + "items": { + "$ref": "#/$defs/adjacent_contrast" + } + } + } + }, "segment": { "type": "object", "additionalProperties": false, @@ -332,6 +522,9 @@ "items": { "$ref": "#/$defs/event_decision" } + }, + "admission": { + "$ref": "#/$defs/admission" } } }, diff --git a/tests/fixtures/episode_semantics_v0.7/continuous_background_two_peaks/candidate.window-separated-core-contrast-v2.expected.json b/tests/fixtures/episode_semantics_v0.7/continuous_background_two_peaks/candidate.window-separated-core-contrast-v2.expected.json new file mode 100644 index 0000000..db05408 --- /dev/null +++ b/tests/fixtures/episode_semantics_v0.7/continuous_background_two_peaks/candidate.window-separated-core-contrast-v2.expected.json @@ -0,0 +1,338 @@ +{ + "format": "loglens.episode_candidate_oracle.v2", + "fixture_id": "episode_semantics_v0.7.continuous_background_two_peaks", + "algorithm": { + "id": "research.window_separated_minimum_core_contrast", + "version": "2", + "status": "candidate" + }, + "rule": { + "rule_id": "brute_force", + "grouping_key": "source_ip", + "subject": "203.0.113.77", + "threshold": 5, + "window_seconds": 600, + "window_boundary": "inclusive" + }, + "segments": [ + { + "segment_id": "segment:1", + "event_ids": [ + "line:1", + "line:2", + "line:3", + "line:4", + "line:5", + "line:6", + "line:7", + "line:8", + "line:9", + "line:10", + "line:11", + "line:12", + "line:13", + "line:14", + "line:15" + ], + "first_seen": "2026-03-10T09:00:00Z", + "last_seen": "2026-03-10T09:58:00Z", + "candidate_windows": [ + { + "candidate_id": "candidate:line:1..line:5", + "event_ids": [ + "line:1", + "line:2", + "line:3", + "line:4", + "line:5" + ], + "first_seen": "2026-03-10T09:00:00Z", + "last_seen": "2026-03-10T09:02:00Z", + "threshold_crossing_event_ids": [ + "line:5" + ], + "overlap_event_ids": [], + "score": { + "metric": "event_count_then_compactness", + "value": 5, + "details": { + "event_count": 5, + "span_seconds": 120 + } + }, + "decision": { + "selected": true, + "reason_code": "selected", + "reason": "Selected by maximum covered events, then minimum total span and episode count." + } + }, + { + "candidate_id": "candidate:line:11..line:15", + "event_ids": [ + "line:11", + "line:12", + "line:13", + "line:14", + "line:15" + ], + "first_seen": "2026-03-10T09:56:00Z", + "last_seen": "2026-03-10T09:58:00Z", + "threshold_crossing_event_ids": [ + "line:15" + ], + "overlap_event_ids": [], + "score": { + "metric": "event_count_then_compactness", + "value": 5, + "details": { + "event_count": 5, + "span_seconds": 120 + } + }, + "decision": { + "selected": true, + "reason_code": "selected", + "reason": "Selected by maximum covered events, then minimum total span and episode count." + } + } + ], + "selected_episodes": [ + { + "episode_index": 1, + "finding_id": "finding:brute_force:584fd14b544a7959", + "candidate_id": "candidate:line:1..line:5", + "event_ids": [ + "line:1", + "line:2", + "line:3", + "line:4", + "line:5" + ], + "first_seen": "2026-03-10T09:00:00Z", + "last_seen": "2026-03-10T09:02:00Z", + "inclusion_reason": "Window is part of the optimal window-separated evidence set." + }, + { + "episode_index": 2, + "finding_id": "finding:brute_force:a885dcb623777120", + "candidate_id": "candidate:line:11..line:15", + "event_ids": [ + "line:11", + "line:12", + "line:13", + "line:14", + "line:15" + ], + "first_seen": "2026-03-10T09:56:00Z", + "last_seen": "2026-03-10T09:58:00Z", + "inclusion_reason": "Window is part of the optimal window-separated evidence set." + } + ], + "event_decisions": [ + { + "event_id": "line:1", + "decision": "included", + "reason_code": "selected_window", + "candidate_ids": [ + "candidate:line:1..line:5" + ], + "reason": "Included exactly once by a selected candidate window." + }, + { + "event_id": "line:2", + "decision": "included", + "reason_code": "selected_window", + "candidate_ids": [ + "candidate:line:1..line:5" + ], + "reason": "Included exactly once by a selected candidate window." + }, + { + "event_id": "line:3", + "decision": "included", + "reason_code": "selected_window", + "candidate_ids": [ + "candidate:line:1..line:5" + ], + "reason": "Included exactly once by a selected candidate window." + }, + { + "event_id": "line:4", + "decision": "included", + "reason_code": "selected_window", + "candidate_ids": [ + "candidate:line:1..line:5" + ], + "reason": "Included exactly once by a selected candidate window." + }, + { + "event_id": "line:5", + "decision": "included", + "reason_code": "selected_window", + "candidate_ids": [ + "candidate:line:1..line:5" + ], + "reason": "Included exactly once by a selected candidate window." + }, + { + "event_id": "line:6", + "decision": "excluded", + "reason_code": "bridge_background", + "candidate_ids": [], + "reason": "Retained as background evidence outside every selected dense window." + }, + { + "event_id": "line:7", + "decision": "excluded", + "reason_code": "bridge_background", + "candidate_ids": [], + "reason": "Retained as background evidence outside every selected dense window." + }, + { + "event_id": "line:8", + "decision": "excluded", + "reason_code": "bridge_background", + "candidate_ids": [], + "reason": "Retained as background evidence outside every selected dense window." + }, + { + "event_id": "line:9", + "decision": "excluded", + "reason_code": "bridge_background", + "candidate_ids": [], + "reason": "Retained as background evidence outside every selected dense window." + }, + { + "event_id": "line:10", + "decision": "excluded", + "reason_code": "bridge_background", + "candidate_ids": [], + "reason": "Retained as background evidence outside every selected dense window." + }, + { + "event_id": "line:11", + "decision": "included", + "reason_code": "selected_window", + "candidate_ids": [ + "candidate:line:11..line:15" + ], + "reason": "Included exactly once by a selected candidate window." + }, + { + "event_id": "line:12", + "decision": "included", + "reason_code": "selected_window", + "candidate_ids": [ + "candidate:line:11..line:15" + ], + "reason": "Included exactly once by a selected candidate window." + }, + { + "event_id": "line:13", + "decision": "included", + "reason_code": "selected_window", + "candidate_ids": [ + "candidate:line:11..line:15" + ], + "reason": "Included exactly once by a selected candidate window." + }, + { + "event_id": "line:14", + "decision": "included", + "reason_code": "selected_window", + "candidate_ids": [ + "candidate:line:11..line:15" + ], + "reason": "Included exactly once by a selected candidate window." + }, + { + "event_id": "line:15", + "decision": "included", + "reason_code": "selected_window", + "candidate_ids": [ + "candidate:line:11..line:15" + ], + "reason": "Included exactly once by a selected candidate window." + } + ], + "admission": { + "policy_id": "minimum_span_threshold_core_gap_contrast", + "minimum_ratio": { + "numerator": 2, + "denominator": 1 + }, + "admitted": true, + "reason_code": "all_adjacent_core_contrasts_met", + "threshold_cores": [ + { + "candidate_id": "candidate:line:1..line:5", + "event_ids": [ + "line:1", + "line:2", + "line:3", + "line:4", + "line:5" + ], + "first_seen": "2026-03-10T09:00:00Z", + "last_seen": "2026-03-10T09:02:00Z", + "mean_gap_microseconds": { + "numerator": 30000000, + "denominator": 1 + } + }, + { + "candidate_id": "candidate:line:11..line:15", + "event_ids": [ + "line:11", + "line:12", + "line:13", + "line:14", + "line:15" + ], + "first_seen": "2026-03-10T09:56:00Z", + "last_seen": "2026-03-10T09:58:00Z", + "mean_gap_microseconds": { + "numerator": 30000000, + "denominator": 1 + } + } + ], + "adjacent_contrasts": [ + { + "left_candidate_id": "candidate:line:1..line:5", + "right_candidate_id": "candidate:line:11..line:15", + "bridge_event_ids": [ + "line:6", + "line:7", + "line:8", + "line:9", + "line:10" + ], + "bridge_mean_gap_microseconds": { + "numerator": 540000000, + "denominator": 1 + }, + "required_bridge_mean_gap_microseconds": { + "numerator": 60000000, + "denominator": 1 + }, + "passes": true, + "reason_code": "minimum_core_gap_contrast_met" + } + ] + } + } + ], + "comparison": { + "baseline_reference": "baseline.expected.json", + "baseline_episode_count": 1, + "candidate_episode_count": 2, + "continuous_segment_split": true, + "notes": [ + "Research-only candidate; Detector::analyze() and loglens.report.v3 are unchanged.", + "Candidate selection remains window-separated v1; admission is a separate policy projection and rejected selections stay visible for audit.", + "Admission uses exact mean-gap arithmetic on minimum-span threshold-sized cores while retaining intervening events as bridge evidence.", + "The fixed 2x research ratio is not production calibration, and this oracle does not change report-v3 or runtime findings." + ] + } +} From 00b8a75e4776b0cb53406838e7ae42fdaf5d0113 Mon Sep 17 00:00:00 2001 From: stacknil Date: Sat, 29 Aug 2026 00:29:11 +0800 Subject: [PATCH 4/5] fix(episodes): fail closed without selected evidence --- scripts/episode_candidate_core.py | 4 ++-- tests/test_episode_candidate_core.py | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/scripts/episode_candidate_core.py b/scripts/episode_candidate_core.py index 64024ba..a5069c4 100644 --- a/scripts/episode_candidate_core.py +++ b/scripts/episode_candidate_core.py @@ -332,10 +332,10 @@ def selection_has_minimum_core_gap_contrast( minimum_ratio: int | Fraction = 2, ) -> bool: """Evaluate exact mean-gap contrast on minimum-span threshold cores.""" - _, contrasts = minimum_core_gap_contrasts( + cores, contrasts = minimum_core_gap_contrasts( events, selected, threshold, minimum_ratio ) - return all(contrast.passes for contrast in contrasts) + return bool(cores) and all(contrast.passes for contrast in contrasts) def minimum_core_gap_contrasts( diff --git a/tests/test_episode_candidate_core.py b/tests/test_episode_candidate_core.py index 77fa490..52c366e 100644 --- a/tests/test_episode_candidate_core.py +++ b/tests/test_episode_candidate_core.py @@ -150,6 +150,13 @@ def test_gap_contrast_accepts_a_single_selected_episode(self) -> None: self.assertTrue(selection_has_minimum_gap_contrast(events, selected)) + def test_minimum_core_gap_contrast_rejects_no_selected_episode(self) -> None: + events = make_events([0, 30, 60, 90]) + + self.assertFalse( + selection_has_minimum_core_gap_contrast(events, [], threshold=5) + ) + def test_gap_contrast_is_not_monotone_under_maximal_window_padding(self) -> None: dense_cores = make_events( [0, 30, 60, 90, 120, 650, 700, 1260, 1290, 1320, 1350, 1380] From 519ab1aa1e4929ed76341fe06a1fd2386be5d8d5 Mon Sep 17 00:00:00 2001 From: stacknil Date: Sat, 29 Aug 2026 14:47:16 +0800 Subject: [PATCH 5/5] fix(episodes): pin candidate v2 admission ratio --- .../candidate-oracle.schema.json | 5 ++++- tests/test_episode_candidate_validation.py | 6 ++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/fixtures/episode_semantics_v0.7/continuous_background_two_peaks/candidate-oracle.schema.json b/tests/fixtures/episode_semantics_v0.7/continuous_background_two_peaks/candidate-oracle.schema.json index 16978e6..a20b9c5 100644 --- a/tests/fixtures/episode_semantics_v0.7/continuous_background_two_peaks/candidate-oracle.schema.json +++ b/tests/fixtures/episode_semantics_v0.7/continuous_background_two_peaks/candidate-oracle.schema.json @@ -444,7 +444,10 @@ "const": "minimum_span_threshold_core_gap_contrast" }, "minimum_ratio": { - "$ref": "#/$defs/rational" + "const": { + "numerator": 2, + "denominator": 1 + } }, "admitted": { "type": "boolean" diff --git a/tests/test_episode_candidate_validation.py b/tests/test_episode_candidate_validation.py index 99ff48e..dbac90b 100644 --- a/tests/test_episode_candidate_validation.py +++ b/tests/test_episode_candidate_validation.py @@ -89,11 +89,17 @@ def test_schema_rejects_cross_version_algorithm_and_admission_shapes( del v2_without_admission["segments"][0]["admission"] v2_with_v1_algorithm = copy.deepcopy(v2) v2_with_v1_algorithm["algorithm"] = copy.deepcopy(v1["algorithm"]) + v2_with_drifted_ratio = copy.deepcopy(v2) + v2_with_drifted_ratio["segments"][0]["admission"]["minimum_ratio"] = { + "numerator": 3, + "denominator": 1, + } for label, oracle in ( ("v1 with admission", v1_with_admission), ("v2 without admission", v2_without_admission), ("v2 with v1 algorithm", v2_with_v1_algorithm), + ("v2 with drifted ratio", v2_with_drifted_ratio), ): with self.subTest(label=label): self.assertNotEqual(list(validator.iter_errors(oracle)), [])