Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions docs/adr/0001-episode-semantics-boundaries.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
79 changes: 67 additions & 12 deletions scripts/episode_candidate_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down Expand Up @@ -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[
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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."""
cores, contrasts = minimum_core_gap_contrasts(
events, selected, threshold, minimum_ratio
)
return bool(cores) and 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(
Expand Down
Loading
Loading