Domain: event-time session analytics

Issue:
Customers whose event feeds arrive slightly out of order are getting session analytics that don't add up. Activity that was plainly one continuous session comes out split into several, with the counts and revenue divided among the pieces; some reported sessions have a recorded time span that doesn't even cover the events credited to them; and sessions are being retired to the finalized store while activity that belongs to them is still arriving well inside the allowed lateness. The totals for a key can change with the arrival order of the very same events, and that should never matter: as long as every event is admitted within the allowed lateness, the sessions, their boundaries, and their aggregates must come out exactly as if the feed had arrived sorted by event time. Replaying an affected feed pre-sorted produces correct numbers every time, but preprocessing is not an option for us — results are queried live between arrivals, and the operational counters are consumed downstream as events are processed, not after the fact. Feeds that already arrive in order are unaffected and must keep behaving exactly as they do today.

Repository files:
content: # Present so pytest adds the repository root to sys.path, making `import src`
# resolve from the test suite.

path: conftest.py

content: # Pure standard library unless the environment image pre-bakes wheels.

path: requirements.lock

content: """Event-time session analytics: gap sessions, watermarks, per-session aggregates."""

from src.config import SessionConfig
from src.pipeline import SessionPipeline

__all__ = ["SessionConfig", "SessionPipeline"]

path: src/__init__.py

content: """Per-session aggregates: base protocol, implementations, and the spec registry."""

from src.aggregates.base import Aggregate
from src.aggregates.basic import Count, Max, Min, Sum
from src.aggregates.distinct import DistinctCount
from src.aggregates.registry import build_aggregates, parse_spec

__all__ = [
    "Aggregate",
    "Count",
    "Sum",
    "Min",
    "Max",
    "DistinctCount",
    "build_aggregates",
    "parse_spec",
]

path: src/aggregates/__init__.py

content: """Aggregate protocol.

An aggregate is a per-session accumulation over the events credited to
that session. ``update`` folds one event in; ``value`` reports the current
result and must be a pure read. Whatever set of events a session ends up
owning, its aggregate values must equal what a fresh aggregate would
produce if fed exactly those events — aggregates describe the session's
events, not the order or batching in which the pipeline happened to
process them.
"""

from __future__ import annotations

from abc import ABC, abstractmethod


class Aggregate(ABC):
    @abstractmethod
    def update(self, event):
        """Fold one event into the accumulation."""

    @abstractmethod
    def value(self):
        """The current result. Pure: no state change."""

path: src/aggregates/base.py

content: """Count, Sum, Min, Max.

Field-based aggregates read one named field from each event. Events that
lack the field (or hold a non-numeric value in it) simply do not
contribute — analytics feeds are heterogeneous and a missing field is
normal, not an error.
"""

from __future__ import annotations

from src.aggregates.base import Aggregate


def _numeric_field(event, field):
    value = event.values.get(field)
    if isinstance(value, bool) or not isinstance(value, (int, float)):
        return None
    return value


class Count(Aggregate):
    """Number of events in the session."""

    def __init__(self):
        self._n = 0

    def update(self, event):
        self._n += 1

    def value(self):
        return self._n


class Sum(Aggregate):
    """Sum of a numeric field over contributing events (0 when none)."""

    def __init__(self, field):
        self._field = field
        self._total = 0

    def update(self, event):
        value = _numeric_field(event, self._field)
        if value is not None:
            self._total += value

    def value(self):
        return self._total


class Min(Aggregate):
    """Minimum of a numeric field (None when no event contributed)."""

    def __init__(self, field):
        self._field = field
        self._min = None

    def update(self, event):
        value = _numeric_field(event, self._field)
        if value is not None and (self._min is None or value < self._min):
            self._min = value

    def value(self):
        return self._min


class Max(Aggregate):
    """Maximum of a numeric field (None when no event contributed)."""

    def __init__(self, field):
        self._field = field
        self._max = None

    def update(self, event):
        value = _numeric_field(event, self._field)
        if value is not None and (self._max is None or value > self._max):
            self._max = value

    def value(self):
        return self._max

path: src/aggregates/basic.py

content: """Distinct-value cardinality."""

from __future__ import annotations

from src.aggregates.base import Aggregate


class DistinctCount(Aggregate):
    """Exact number of distinct values a field took across the session's
    events. Events lacking the field do not contribute. The count is exact
    set cardinality — the same value observed many times counts once.
    """

    def __init__(self, field):
        self._field = field
        self._seen = set()

    def update(self, event):
        if self._field in event.values:
            self._seen.add(event.values[self._field])

    def value(self):
        return len(self._seen)

path: src/aggregates/distinct.py

content: """Aggregate spec registry.

A spec is a compact string naming an aggregate kind and, for field-based
kinds, the field it reads:

* ``"count"`` — number of events
* ``"sum:<field>"`` / ``"min:<field>"`` / ``"max:<field>"``
* ``"distinct:<field>"`` — distinct-value cardinality

``parse_spec`` validates a spec; ``build_aggregates`` instantiates a fresh
aggregate per configured name (sessions never share aggregate state).
"""

from __future__ import annotations

from src.aggregates.basic import Count, Max, Min, Sum
from src.aggregates.distinct import DistinctCount
from src.errors import ConfigError

_FIELD_KINDS = {
    "sum": Sum,
    "min": Min,
    "max": Max,
    "distinct": DistinctCount,
}


def parse_spec(spec):
    """Validate a spec string and return (kind, field-or-None)."""
    if not isinstance(spec, str) or not spec:
        raise ConfigError(f"aggregate spec must be a non-empty string, got {spec!r}")
    if spec == "count":
        return ("count", None)
    kind, sep, field = spec.partition(":")
    if not sep or not field:
        raise ConfigError(
            f"aggregate spec {spec!r} must be 'count' or '<kind>:<field>'"
        )
    if kind not in _FIELD_KINDS:
        raise ConfigError(
            f"unknown aggregate kind {kind!r} (have: count, {', '.join(sorted(_FIELD_KINDS))})"
        )
    return (kind, field)


def build_aggregates(specs):
    """Fresh aggregate instances for a mapping of name -> spec."""
    built = {}
    for name, spec in specs.items():
        kind, field = parse_spec(spec)
        if kind == "count":
            built[name] = Count()
        else:
            built[name] = _FIELD_KINDS[kind](field)
    return built

path: src/aggregates/registry.py

content: """Pipeline configuration.

A :class:`SessionConfig` fixes the three things that define a deployment's
sessionization semantics:

* ``gap`` — the session gap, in event-time units. Two consecutive events of
  the same key belong to the same session exactly when they are no more
  than ``gap`` apart in event time.
* ``allowed_lateness`` — how far behind the watermark an event may arrive
  and still be admitted. Events later than that are dropped and counted.
* ``aggregates`` — named aggregate specs computed per session (see
  :mod:`src.aggregates.registry` for the spec syntax).
"""

from __future__ import annotations

import math
from dataclasses import dataclass, field
from typing import Mapping

from src.aggregates.registry import parse_spec
from src.errors import ConfigError


@dataclass(frozen=True)
class SessionConfig:
    gap: float
    allowed_lateness: float = 0.0
    aggregates: Mapping[str, str] = field(default_factory=dict)

    def __post_init__(self):
        if isinstance(self.gap, bool) or not isinstance(self.gap, (int, float)):
            raise ConfigError(f"gap must be a number, got {self.gap!r}")
        if not math.isfinite(self.gap) or self.gap <= 0:
            raise ConfigError(f"gap must be a positive finite number, got {self.gap!r}")
        lateness = self.allowed_lateness
        if isinstance(lateness, bool) or not isinstance(lateness, (int, float)):
            raise ConfigError(f"allowed_lateness must be a number, got {lateness!r}")
        if not math.isfinite(lateness) or lateness < 0:
            raise ConfigError(
                f"allowed_lateness must be a non-negative finite number, got {lateness!r}"
            )
        for name, spec in self.aggregates.items():
            if not isinstance(name, str) or not name:
                raise ConfigError(f"aggregate name must be a non-empty string, got {name!r}")
            parse_spec(spec)  # raises ConfigError on a bad spec

path: src/config.py

content: """Exception types for the session analytics pipeline."""


class PipelineError(Exception):
    """Base class for all pipeline errors."""


class ConfigError(PipelineError):
    """Invalid pipeline configuration (gap, lateness, aggregate specs)."""


class EventError(PipelineError):
    """Malformed event: bad key, timestamp, or field values."""


class WatermarkError(PipelineError):
    """Illegal watermark movement (watermarks only advance)."""


class QueryError(PipelineError):
    """Invalid read request against the pipeline state."""

path: src/errors.py

content: """Event model.

An event is an immutable observation: a key (the entity the event belongs
to, e.g. a user or device id), an event-time timestamp, and a flat mapping
of field values. Event time is the time the thing *happened*, assigned by
the producer — it is unrelated to when the event reaches this pipeline.
"""

from __future__ import annotations

import math
from dataclasses import dataclass, field
from typing import Mapping, Union

from src.errors import EventError

FieldValue = Union[int, float, str]


@dataclass(frozen=True)
class Event:
    key: str
    ts: float
    values: Mapping[str, FieldValue] = field(default_factory=dict)


def make_event(key, ts, values=None):
    """Validate raw inputs and build an :class:`Event`.

    Raises :class:`EventError` on a non-string/empty key, a non-finite or
    non-numeric timestamp, or field values outside int/float/str.
    """
    if not isinstance(key, str) or not key:
        raise EventError(f"event key must be a non-empty string, got {key!r}")
    if isinstance(ts, bool) or not isinstance(ts, (int, float)):
        raise EventError(f"event timestamp must be a number, got {ts!r}")
    if not math.isfinite(ts):
        raise EventError(f"event timestamp must be finite, got {ts!r}")
    clean = {}
    for name, value in (values or {}).items():
        if not isinstance(name, str) or not name:
            raise EventError(f"field name must be a non-empty string, got {name!r}")
        if isinstance(value, bool) or not isinstance(value, (int, float, str)):
            raise EventError(
                f"field {name!r} must be int, float, or str, got {value!r}"
            )
        clean[name] = value
    return Event(key=key, ts=float(ts), values=clean)

path: src/events.py

content: """Finalization sweep.

A session is finalized only when it is beyond the reach of every event
that could still be admitted: once ``watermark - allowed_lateness`` has
moved strictly past ``session.end + gap``, no admissible timestamp can
belong to the session or bridge anything into it, so its boundaries and
aggregates are settled facts. Finalized sessions move to the store and
are counted at that moment.
"""

from __future__ import annotations


def sweep(sessionizer, clock, config, store, metrics):
    """Retire every settled open session into the store."""
    horizon = clock.horizon()
    if horizon is None:
        return 0
    expired = sessionizer.take_expired(horizon, config.gap)
    for session in expired:
        store.add(session)
        metrics.increment("sessions_finalized")
    return len(expired)

path: src/eviction.py

content: """Operational counters.

These counters are the pipeline's operational record and are consumed
directly by capacity dashboards and billing reconciliation. They describe
what the pipeline actually did, at the moment it did it: an event is
counted when it is observed, a session is counted as opened at the moment
a genuinely new session is created for an incoming event, and counted as
finalized at the moment the watermark retires it. Counters never move
retroactively.
"""

from __future__ import annotations

COUNTERS = (
    "events_observed",
    "events_dropped_late",
    "sessions_opened",
    "sessions_finalized",
)


class Metrics:
    def __init__(self):
        self._counts = {name: 0 for name in COUNTERS}

    def increment(self, name, amount=1):
        if name not in self._counts:
            raise KeyError(f"unknown counter {name!r}")
        self._counts[name] += amount

    def get(self, name):
        if name not in self._counts:
            raise KeyError(f"unknown counter {name!r}")
        return self._counts[name]

    def snapshot(self):
        """An immutable copy of all counters."""
        return dict(self._counts)

path: src/metrics.py

content: """The session analytics pipeline facade.

Wires the pieces together behind one object:

* ``observe(key, ts, values)`` admits one event (or drops it as late).
* ``advance_watermark(ts)`` moves event-time progress forward and retires
  every session the lateness horizon has settled.
* ``open_sessions`` / ``finalized`` answer live queries as immutable views.

The pipeline is deterministic end to end: feed it the same admissible
events and watermark advances, and every query along the way answers the
same — that determinism is what makes the analytics auditable.
"""

from __future__ import annotations

from src import eviction
from src.events import make_event
from src.metrics import Metrics
from src.query import view_of
from src.store import FinalizedStore
from src.tracker import Sessionizer
from src.watermark import WatermarkClock


class SessionPipeline:
    def __init__(self, config):
        self._config = config
        self._clock = WatermarkClock(config.allowed_lateness)
        self._metrics = Metrics()
        self._store = FinalizedStore()
        self._sessionizer = Sessionizer(config, self._clock, self._metrics)

    @property
    def watermark(self):
        return self._clock.watermark

    def observe(self, key, ts, values=None):
        """Admit one event. True if credited, False if dropped as late."""
        event = make_event(key, ts, values)
        return self._sessionizer.observe(event)

    def advance_watermark(self, ts):
        """Advance event-time progress; returns how many sessions settled."""
        self._clock.advance(ts)
        return eviction.sweep(
            self._sessionizer, self._clock, self._config, self._store, self._metrics
        )

    def open_sessions(self, key=None):
        """Views of the currently open sessions, in start order."""
        views = [view_of(s) for s in self._sessionizer.open_sessions(key)]
        return sorted(views, key=lambda v: (v.key, v.start))

    def finalized(self, key=None):
        """Views of finalized sessions from the store."""
        if key is not None:
            return self._store.sessions(key)
        return self._store.all_sessions()

    def metrics_snapshot(self):
        return self._metrics.snapshot()

path: src/pipeline.py

content: """Read views.

Queries run live, between arrivals: a view reflects exactly the events
credited so far, and two pipelines that have admitted the same set of
events must answer the same queries the same way, whatever order the
events arrived in.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import Mapping


@dataclass(frozen=True)
class SessionView:
    key: str
    start: float
    end: float
    values: Mapping[str, object]

    def value(self, name):
        if name not in self.values:
            raise KeyError(f"no aggregate named {name!r}")
        return self.values[name]


def view_of(session):
    """Immutable snapshot of a session's identity, span, and aggregates."""
    return SessionView(
        key=session.key,
        start=session.start,
        end=session.end,
        values=dict(session.aggregate_values()),
    )

path: src/query.py

content: """Plain-text session and metrics summaries for operator tooling."""

from __future__ import annotations


def format_sessions(views):
    """One line per session view: ``key [start, end] name=value, ...``."""
    lines = []
    for view in views:
        parts = ", ".join(
            f"{name}={view.values[name]}" for name in sorted(view.values)
        )
        span = f"[{_num(view.start)}, {_num(view.end)}]"
        lines.append(f"{view.key} {span} {parts}".rstrip())
    return "\n".join(lines)


def format_metrics(snapshot):
    """One ``counter=value`` line per counter, in name order."""
    return "\n".join(f"{name}={snapshot[name]}" for name in sorted(snapshot))


def _num(value):
    if isinstance(value, float) and value.is_integer():
        return str(int(value))
    return str(value)

path: src/report.py

content: """Open-session state.

A session's boundaries are part of its meaning: ``start`` is the event
time of the earliest event credited to the session and ``end`` the event
time of the latest — the recorded span always covers every event the
session owns. Aggregate values likewise describe exactly the credited
events (see :mod:`src.aggregates.base`).
"""

from __future__ import annotations

from src.aggregates.registry import build_aggregates


class Session:
    __slots__ = ("key", "start", "end", "_aggregates")

    def __init__(self, key, event, aggregate_specs):
        self.key = key
        self.start = event.ts
        self.end = event.ts
        self._aggregates = build_aggregates(aggregate_specs)
        self._apply(event)

    def _apply(self, event):
        for aggregate in self._aggregates.values():
            aggregate.update(event)

    def extend(self, event):
        """Credit a further event to this session."""
        self.end = event.ts
        self._apply(event)

    def aggregate_values(self):
        """Current aggregate results, keyed by configured name."""
        return {name: agg.value() for name, agg in self._aggregates.items()}

    def __repr__(self):
        return f"Session(key={self.key!r}, start={self.start}, end={self.end})"

path: src/sessions.py

content: """Finalized-session store.

Append-only home of settled sessions. Reads return immutable views; the
store never reorders history beyond presenting each key's sessions in
start order.
"""

from __future__ import annotations

from src.query import view_of


class FinalizedStore:
    def __init__(self):
        self._by_key = {}

    def add(self, session):
        self._by_key.setdefault(session.key, []).append(session)

    def sessions(self, key):
        """Finalized sessions for a key, in start order."""
        views = [view_of(s) for s in self._by_key.get(key, ())]
        return sorted(views, key=lambda v: v.start)

    def all_sessions(self):
        """Every finalized session, ordered by (key, start)."""
        views = []
        for key in sorted(self._by_key):
            views.extend(self.sessions(key))
        return views

    def count(self):
        return sum(len(v) for v in self._by_key.values())

path: src/store.py

content: """Sessionization: assigning admissible events to sessions.

The session model is defined over *event time*: a session is a maximal
run of a key's events, ordered by event time, in which consecutive events
are no more than the configured ``gap`` apart. Arrival order is transport
noise — any admissible event must land exactly as it would have landed
arriving in timestamp order, because dashboards are queried live between
arrivals and the sessions they show are the product.

Assignment happens at observe time, event by event; the pipeline never
re-derives sessions from a replay buffer (the operational counters in
:mod:`src.metrics` record assignment as it happens and are consumed
downstream as it happens).
"""

from __future__ import annotations

from src.sessions import Session


class Sessionizer:
    def __init__(self, config, clock, metrics):
        self._config = config
        self._clock = clock
        self._metrics = metrics
        self._open = {}

    def observe(self, event):
        """Admit one event. Returns True if it was credited to a session,
        False if it was dropped as late."""
        self._metrics.increment("events_observed")
        if not self._clock.admissible(event.ts):
            self._metrics.increment("events_dropped_late")
            return False
        sessions = self._open.setdefault(event.key, [])
        last = sessions[-1] if sessions else None
        if last is not None and event.ts - last.end <= self._config.gap:
            last.extend(event)
        else:
            sessions.append(Session(event.key, event, self._config.aggregates))
            self._metrics.increment("sessions_opened")
        return True

    def open_sessions(self, key=None):
        """Open sessions for one key (or all keys), in start order."""
        if key is not None:
            return list(self._open.get(key, ()))
        merged = []
        for k in sorted(self._open):
            merged.extend(self._open[k])
        return merged

    def take_expired(self, horizon, gap):
        """Remove and return sessions no admissible event could still touch.

        A session is only retired once the lateness horizon has passed so
        far beyond its end that no admissible event could extend it or
        connect anything to it (see :mod:`src.eviction`).
        """
        expired = []
        for key in sorted(self._open):
            keep = []
            for session in self._open[key]:
                if horizon is not None and horizon > session.end + gap:
                    expired.append(session)
                else:
                    keep.append(session)
            if keep:
                self._open[key] = keep
            else:
                del self._open[key]
        return expired

path: src/tracker.py

content: """Watermark clock.

The watermark is the pipeline's statement about event-time progress: once
the watermark stands at W, the producer has promised that (almost) all
events with timestamps below W have been delivered. ``allowed_lateness``
is the slack granted to stragglers — an event is *admissible* while its
timestamp is no earlier than ``watermark - allowed_lateness``, and is
dropped as late otherwise.

Watermarks only move forward. Before the first advance, every event is
admissible.
"""

from __future__ import annotations

from src.errors import WatermarkError


class WatermarkClock:
    def __init__(self, allowed_lateness=0.0):
        self._allowed_lateness = float(allowed_lateness)
        self._watermark = None

    @property
    def watermark(self):
        return self._watermark

    @property
    def allowed_lateness(self):
        return self._allowed_lateness

    def advance(self, ts):
        ts = float(ts)
        if self._watermark is not None and ts < self._watermark:
            raise WatermarkError(
                f"watermark cannot move backwards ({self._watermark} -> {ts})"
            )
        self._watermark = ts

    def admissible(self, ts):
        """Whether an event at ``ts`` may still be admitted."""
        if self._watermark is None:
            return True
        return ts >= self._watermark - self._allowed_lateness

    def horizon(self):
        """The oldest event time that is still admissible, or None."""
        if self._watermark is None:
            return None
        return self._watermark - self._allowed_lateness

path: src/watermark.py

content: from src.config import SessionConfig
from src.pipeline import SessionPipeline


def pipeline(**aggregates):
    return SessionPipeline(
        SessionConfig(gap=30, allowed_lateness=0, aggregates=aggregates)
    )


class TestBasicAggregates:
    def test_count_and_sum(self):
        p = pipeline(n="count", rev="sum:amount")
        p.observe("u1", 100, {"amount": 5})
        p.observe("u1", 110, {"amount": 7})
        p.observe("u1", 120, {"amount": 2})
        view = p.open_sessions("u1")[0]
        assert view.value("n") == 3
        assert view.value("rev") == 14

    def test_min_and_max(self):
        p = pipeline(lo="min:amount", hi="max:amount")
        p.observe("u1", 100, {"amount": 9})
        p.observe("u1", 110, {"amount": 3})
        p.observe("u1", 120, {"amount": 6})
        view = p.open_sessions("u1")[0]
        assert view.value("lo") == 3
        assert view.value("hi") == 9

    def test_min_max_none_without_contributions(self):
        p = pipeline(lo="min:amount", hi="max:amount")
        p.observe("u1", 100, {"page": "home"})
        view = p.open_sessions("u1")[0]
        assert view.value("lo") is None
        assert view.value("hi") is None

    def test_missing_field_does_not_contribute(self):
        p = pipeline(rev="sum:amount")
        p.observe("u1", 100, {"amount": 5})
        p.observe("u1", 110, {"page": "home"})
        assert p.open_sessions("u1")[0].value("rev") == 5

    def test_non_numeric_value_ignored_by_numeric_kinds(self):
        p = pipeline(rev="sum:amount", lo="min:amount")
        p.observe("u1", 100, {"amount": 5})
        p.observe("u1", 110, {"amount": "gift-card"})
        view = p.open_sessions("u1")[0]
        assert view.value("rev") == 5
        assert view.value("lo") == 5


class TestDistinct:
    def test_repeats_count_once(self):
        p = pipeline(pages="distinct:page")
        for ts, page in ((100, "home"), (110, "pricing"), (120, "home")):
            p.observe("u1", ts, {"page": page})
        assert p.open_sessions("u1")[0].value("pages") == 2

    def test_sessions_do_not_share_aggregate_state(self):
        p = pipeline(pages="distinct:page")
        p.observe("u1", 100, {"page": "home"})
        p.observe("u1", 200, {"page": "home"})  # gap 30: separate session
        views = p.open_sessions("u1")
        assert [v.value("pages") for v in views] == [1, 1]

path: tests/public/test_aggregates.py

content: import pytest

from src.aggregates.registry import parse_spec
from src.config import SessionConfig
from src.errors import ConfigError, EventError
from src.events import make_event


class TestMakeEvent:
    def test_valid_event_normalizes_timestamp(self):
        event = make_event("u1", 100, {"amount": 5, "page": "home"})
        assert event.key == "u1"
        assert event.ts == 100.0 and isinstance(event.ts, float)
        assert event.values == {"amount": 5, "page": "home"}

    def test_rejects_empty_or_non_string_key(self):
        with pytest.raises(EventError):
            make_event("", 100)
        with pytest.raises(EventError):
            make_event(7, 100)

    def test_rejects_non_numeric_or_bool_timestamp(self):
        with pytest.raises(EventError):
            make_event("u1", "100")
        with pytest.raises(EventError):
            make_event("u1", True)

    def test_rejects_non_finite_timestamp(self):
        with pytest.raises(EventError):
            make_event("u1", float("inf"))

    def test_rejects_bad_field_values(self):
        with pytest.raises(EventError):
            make_event("u1", 100, {"amount": [1, 2]})
        with pytest.raises(EventError):
            make_event("u1", 100, {"": 5})


class TestSessionConfig:
    def test_valid_config(self):
        config = SessionConfig(
            gap=30, allowed_lateness=10, aggregates={"n": "count", "rev": "sum:amount"}
        )
        assert config.gap == 30
        assert config.allowed_lateness == 10

    def test_rejects_non_positive_gap(self):
        with pytest.raises(ConfigError):
            SessionConfig(gap=0)
        with pytest.raises(ConfigError):
            SessionConfig(gap=-5)

    def test_rejects_negative_lateness(self):
        with pytest.raises(ConfigError):
            SessionConfig(gap=30, allowed_lateness=-1)

    def test_rejects_bad_aggregate_specs(self):
        with pytest.raises(ConfigError):
            SessionConfig(gap=30, aggregates={"x": "median:amount"})
        with pytest.raises(ConfigError):
            SessionConfig(gap=30, aggregates={"x": "sum"})
        with pytest.raises(ConfigError):
            SessionConfig(gap=30, aggregates={"x": ""})


class TestParseSpec:
    def test_count_has_no_field(self):
        assert parse_spec("count") == ("count", None)

    def test_field_kinds(self):
        assert parse_spec("sum:amount") == ("sum", "amount")
        assert parse_spec("distinct:page") == ("distinct", "page")

path: tests/public/test_events_config.py

content: import pytest

from src.config import SessionConfig
from src.pipeline import SessionPipeline


def pipeline(gap=30, lateness=0, aggregates=None):
    return SessionPipeline(
        SessionConfig(
            gap=gap,
            allowed_lateness=lateness,
            aggregates=aggregates or {"n": "count"},
        )
    )


class TestSingleSession:
    def test_events_within_gap_share_one_session(self):
        p = pipeline()
        for ts in (100, 120, 145):
            assert p.observe("u1", ts) is True
        views = p.open_sessions("u1")
        assert len(views) == 1
        assert (views[0].start, views[0].end) == (100.0, 145.0)
        assert views[0].value("n") == 3

    def test_gap_boundary_is_inclusive(self):
        p = pipeline(gap=30)
        p.observe("u1", 100)
        p.observe("u1", 130)  # exactly gap apart: same session
        views = p.open_sessions("u1")
        assert len(views) == 1
        assert views[0].end == 130.0

    def test_just_over_gap_starts_a_new_session(self):
        p = pipeline(gap=30)
        p.observe("u1", 100)
        p.observe("u1", 131)
        views = p.open_sessions("u1")
        assert [(v.start, v.end) for v in views] == [(100.0, 100.0), (131.0, 131.0)]


class TestMultiKey:
    def test_keys_sessionize_independently(self):
        p = pipeline(gap=30)
        p.observe("u1", 100)
        p.observe("u2", 105)
        p.observe("u1", 120)
        assert len(p.open_sessions("u1")) == 1
        assert len(p.open_sessions("u2")) == 1
        assert p.open_sessions("u1")[0].end == 120.0

    def test_open_sessions_ordering_and_filter(self):
        p = pipeline(gap=10)
        p.observe("b", 100)
        p.observe("a", 100)
        p.observe("a", 150)
        views = p.open_sessions()
        assert [(v.key, v.start) for v in views] == [
            ("a", 100.0),
            ("a", 150.0),
            ("b", 100.0),
        ]
        assert [v.start for v in p.open_sessions("a")] == [100.0, 150.0]


class TestViewsAndCounters:
    def test_unknown_aggregate_name_raises(self):
        p = pipeline()
        p.observe("u1", 100)
        view = p.open_sessions("u1")[0]
        with pytest.raises(KeyError):
            view.value("missing")

    def test_sessions_opened_counts_new_sessions(self):
        p = pipeline(gap=10)
        p.observe("u1", 100)
        p.observe("u1", 105)
        p.observe("u1", 200)
        p.observe("u2", 100)
        snapshot = p.metrics_snapshot()
        assert snapshot["sessions_opened"] == 3
        assert snapshot["events_observed"] == 4
        assert snapshot["events_dropped_late"] == 0

path: tests/public/test_ordered_sessions.py

content: import pytest

from src import report
from src.config import SessionConfig
from src.errors import WatermarkError
from src.pipeline import SessionPipeline


def pipeline(gap=10, lateness=0, aggregates=None):
    return SessionPipeline(
        SessionConfig(
            gap=gap,
            allowed_lateness=lateness,
            aggregates=aggregates or {"n": "count"},
        )
    )


class TestWatermark:
    def test_watermark_only_advances(self):
        p = pipeline()
        p.advance_watermark(100)
        p.advance_watermark(100)  # equal is fine
        with pytest.raises(WatermarkError):
            p.advance_watermark(99)

    def test_everything_admissible_before_first_advance(self):
        p = pipeline()
        assert p.watermark is None
        assert p.observe("u1", 5) is True

    def test_late_event_is_dropped_and_counted(self):
        p = pipeline(gap=10, lateness=20)
        p.observe("u1", 100)
        p.advance_watermark(200)  # horizon 180
        assert p.observe("u1", 150) is False
        snapshot = p.metrics_snapshot()
        assert snapshot["events_dropped_late"] == 1
        assert snapshot["events_observed"] == 2

    def test_event_exactly_at_horizon_is_admissible(self):
        p = pipeline(gap=10, lateness=20)
        p.advance_watermark(200)  # horizon 180
        assert p.observe("u1", 180) is True
        assert len(p.open_sessions("u1")) == 1


class TestFinalization:
    def test_session_settles_only_strictly_past_end_plus_gap(self):
        p = pipeline(gap=10)
        p.observe("u1", 100)
        assert p.advance_watermark(110) == 0  # horizon == end + gap: not settled
        assert len(p.open_sessions("u1")) == 1
        assert p.advance_watermark(111) == 1
        assert p.open_sessions("u1") == []
        finalized = p.finalized("u1")
        assert [(v.start, v.end) for v in finalized] == [(100.0, 100.0)]

    def test_lateness_delays_finalization(self):
        p = pipeline(gap=10, lateness=50)
        p.observe("u1", 100)
        assert p.advance_watermark(160) == 0  # horizon 110 == end + gap
        assert p.advance_watermark(161) == 1  # horizon 111 > 110

    def test_store_orders_by_key_then_start(self):
        p = pipeline(gap=10)
        p.observe("b", 100)
        p.observe("a", 130)
        p.observe("a", 160)
        p.advance_watermark(500)
        views = p.finalized()
        assert [(v.key, v.start) for v in views] == [
            ("a", 130.0),
            ("a", 160.0),
            ("b", 100.0),
        ]
        assert p.metrics_snapshot()["sessions_finalized"] == 3


class TestReport:
    def test_format_sessions(self):
        p = pipeline(gap=30, aggregates={"n": "count", "rev": "sum:amount"})
        p.observe("u1", 100, {"amount": 5})
        p.observe("u1", 120, {"amount": 3})
        text = report.format_sessions(p.open_sessions())
        assert text == "u1 [100, 120] n=2, rev=8"

    def test_format_metrics(self):
        p = pipeline()
        p.observe("u1", 100)
        text = report.format_metrics(p.metrics_snapshot())
        assert text.splitlines() == [
            "events_dropped_late=0",
            "events_observed=1",
            "sessions_finalized=0",
            "sessions_opened=1",
        ]

path: tests/public/test_watermark_lifecycle.py

Reference patch:
--- a/src/aggregates/base.py
+++ b/src/aggregates/base.py
@@ -22,3 +22,8 @@ class Aggregate(ABC):
     @abstractmethod
     def value(self):
         """The current result. Pure: no state change."""
+
+    @abstractmethod
+    def combine(self, other):
+        """Fold another aggregate of the same kind into this one (used
+        when two sessions turn out to be one)."""
--- a/src/aggregates/basic.py
+++ b/src/aggregates/basic.py
@@ -30,6 +30,9 @@ class Count(Aggregate):
     def value(self):
         return self._n
 
+    def combine(self, other):
+        self._n += other._n
+
 
 class Sum(Aggregate):
     """Sum of a numeric field over contributing events (0 when none)."""
@@ -46,6 +49,9 @@ class Sum(Aggregate):
     def value(self):
         return self._total
 
+    def combine(self, other):
+        self._total += other._total
+
 
 class Min(Aggregate):
     """Minimum of a numeric field (None when no event contributed)."""
@@ -62,6 +68,10 @@ class Min(Aggregate):
     def value(self):
         return self._min
 
+    def combine(self, other):
+        if other._min is not None and (self._min is None or other._min < self._min):
+            self._min = other._min
+
 
 class Max(Aggregate):
     """Maximum of a numeric field (None when no event contributed)."""
@@ -77,3 +87,7 @@ class Max(Aggregate):
 
     def value(self):
         return self._max
+
+    def combine(self, other):
+        if other._max is not None and (self._max is None or other._max > self._max):
+            self._max = other._max
--- a/src/aggregates/distinct.py
+++ b/src/aggregates/distinct.py
@@ -21,3 +21,6 @@ class DistinctCount(Aggregate):
 
     def value(self):
         return len(self._seen)
+
+    def combine(self, other):
+        self._seen |= other._seen
new file mode 100644
--- /dev/null
+++ b/src/assign.py
@@ -0,0 +1,41 @@
+"""Event-time placement of an admissible event among a key's open sessions.
+
+Open sessions for a key are kept in start order, and any two neighbours
+are separated by more than the gap (otherwise they would be one session).
+An incoming timestamp therefore lands in exactly one of three ways: it is
+close enough to a single session to join it (in its interior, within the
+gap after its end, or within the gap before its start), it falls between
+two sessions within the gap of both — in which case those sessions were
+always one session and must be unified — or it is beyond the gap of every
+neighbour and opens a new session at its position.
+"""
+
+from __future__ import annotations
+
+from bisect import bisect_right
+
+OPEN = "open"
+ATTACH = "attach"
+MERGE = "merge"
+
+
+def place(sessions, ts, gap):
+    """Decide where ``ts`` lands among ``sessions`` (sorted by start).
+
+    Returns ``(OPEN, insert_index)``, ``(ATTACH, session_index)``, or
+    ``(MERGE, left_index)`` — merge unifies ``left_index`` with
+    ``left_index + 1``.
+    """
+    starts = [session.start for session in sessions]
+    index = bisect_right(starts, ts)
+    pred = sessions[index - 1] if index > 0 else None
+    succ = sessions[index] if index < len(sessions) else None
+    near_pred = pred is not None and ts - pred.end <= gap
+    near_succ = succ is not None and succ.start - ts <= gap
+    if near_pred and near_succ:
+        return (MERGE, index - 1)
+    if near_pred:
+        return (ATTACH, index - 1)
+    if near_succ:
+        return (ATTACH, index)
+    return (OPEN, index)
--- a/src/sessions.py
+++ b/src/sessions.py
@@ -28,9 +28,19 @@ class Session:
 
     def extend(self, event):
         """Credit a further event to this session."""
-        self.end = event.ts
+        if event.ts < self.start:
+            self.start = event.ts
+        if event.ts > self.end:
+            self.end = event.ts
         self._apply(event)
 
+    def merge_from(self, other):
+        """Unify another session's events into this one."""
+        self.start = min(self.start, other.start)
+        self.end = max(self.end, other.end)
+        for name, aggregate in self._aggregates.items():
+            aggregate.combine(other._aggregates[name])
+
     def aggregate_values(self):
         """Current aggregate results, keyed by configured name."""
         return {name: agg.value() for name, agg in self._aggregates.items()}
--- a/src/tracker.py
+++ b/src/tracker.py
@@ -15,6 +15,7 @@ downstream as it happens).
 
 from __future__ import annotations
 
+from src import assign
 from src.sessions import Session
 
 
@@ -33,11 +34,18 @@ class Sessionizer:
             self._metrics.increment("events_dropped_late")
             return False
         sessions = self._open.setdefault(event.key, [])
-        last = sessions[-1] if sessions else None
-        if last is not None and event.ts - last.end <= self._config.gap:
-            last.extend(event)
+        action, index = assign.place(sessions, event.ts, self._config.gap)
+        if action == assign.MERGE:
+            keep = sessions[index]
+            other = sessions.pop(index + 1)
+            keep.extend(event)
+            keep.merge_from(other)
+        elif action == assign.ATTACH:
+            sessions[index].extend(event)
         else:
-            sessions.append(Session(event.key, event, self._config.aggregates))
+            sessions.insert(
+                index, Session(event.key, event, self._config.aggregates)
+            )
             self._metrics.increment("sessions_opened")
         return True
 


Hidden acceptance tests (diff):
diff --git a/tests/hidden/test_hidden.py b/tests/hidden/test_hidden.py
new file mode 100644
--- /dev/null
+++ b/tests/hidden/test_hidden.py
@@ -0,0 +1,177 @@
+"""Hidden FAIL_TO_PASS suite: out-of-order arrival must sessionize exactly
+as in-order arrival would. Never agent-visible (DEC-015).
+
+Every test feeds at least one admissible out-of-order event, so all ten
+fail on the buggy fixture (extension-only, newest-session-only
+assignment). They are fused: view correctness, boundary honesty, and
+exact observe-time counter history are asserted together, so a fix that
+rebuilds sessions from a sorted replay buffer (correct views, false
+history) or repairs boundaries without fixing assignment cannot pass.
+"""
+
+from src.config import SessionConfig
+from src.pipeline import SessionPipeline
+
+
+def pipeline(gap=30, lateness=100, aggregates=None):
+    return SessionPipeline(
+        SessionConfig(
+            gap=gap,
+            allowed_lateness=lateness,
+            aggregates=aggregates
+            or {"n": "count", "rev": "sum:amount", "pages": "distinct:page"},
+        )
+    )
+
+
+def spans(views):
+    return [(v.start, v.end) for v in views]
+
+
+def test_late_event_joins_the_session_it_belongs_to():
+    p = pipeline()
+    p.observe("u1", 100, {"amount": 5, "page": "a"})
+    p.observe("u1", 110, {"amount": 3, "page": "b"})
+    p.observe("u1", 200, {"amount": 4, "page": "c"})
+    p.observe("u1", 215, {"amount": 2, "page": "a"})
+    p.observe("u1", 120, {"amount": 6, "page": "d"})  # belongs to the first session
+    views = p.open_sessions("u1")
+    assert spans(views) == [(100.0, 120.0), (200.0, 215.0)]
+    assert views[0].value("n") == 3 and views[0].value("rev") == 14
+    assert views[0].value("pages") == 3
+    assert views[1].value("n") == 2 and views[1].value("rev") == 6
+    assert p.metrics_snapshot()["sessions_opened"] == 2
+
+
+def test_bridging_event_unifies_two_sessions():
+    p = pipeline()
+    p.observe("u2", 100, {"amount": 5})
+    p.observe("u2", 110, {"amount": 3})
+    p.observe("u2", 160, {"amount": 4})
+    p.observe("u2", 130, {"amount": 2})  # within gap of both runs: one session
+    views = p.open_sessions("u2")
+    assert spans(views) == [(100.0, 160.0)]
+    assert views[0].value("n") == 4 and views[0].value("rev") == 14
+    snapshot = p.metrics_snapshot()
+    assert snapshot["sessions_opened"] == 2  # the bridge opened nothing
+
+
+def test_earlier_event_extends_a_session_backwards():
+    p = pipeline()
+    p.observe("u3", 200, {"amount": 7})
+    p.observe("u3", 175, {"amount": 1})  # within gap, before the start
+    views = p.open_sessions("u3")
+    assert spans(views) == [(175.0, 200.0)]
+    assert views[0].value("n") == 2 and views[0].value("rev") == 8
+    assert p.metrics_snapshot()["sessions_opened"] == 1
+
+
+def test_stragglers_repair_many_sessions_without_phantom_opens():
+    p = pipeline()
+    for ts in (100, 160, 300, 400):
+        p.observe("u4", ts)
+    p.observe("u4", 130)  # unifies the first two runs
+    p.observe("u4", 330)  # extends the third forward
+    p.observe("u4", 370)  # joins the fourth from before its start
+    views = p.open_sessions("u4")
+    assert spans(views) == [(100.0, 160.0), (300.0, 330.0), (370.0, 400.0)]
+    assert [v.value("n") for v in views] == [3, 2, 2]
+    assert p.metrics_snapshot()["sessions_opened"] == 4
+
+
+def test_arrival_order_does_not_change_results():
+    sorted_feed = [100, 105, 120, 150, 155, 300, 310, 500, 505, 530]
+    shuffled = [100, 150, 300, 105, 500, 120, 310, 530, 155, 505]
+    results = []
+    for feed in (sorted_feed, shuffled):
+        p = pipeline()
+        for ts in feed:
+            p.observe("u5", ts, {"amount": 1})
+        open_views = p.open_sessions("u5")
+        p.advance_watermark(2000)
+        results.append((open_views, p.finalized("u5")))
+    assert results[0] == results[1]
+    assert spans(results[0][1]) == [(100.0, 155.0), (300.0, 310.0), (500.0, 530.0)]
+    assert [v.value("n") for v in results[0][1]] == [5, 2, 3]
+
+
+def test_live_queries_are_correct_at_every_checkpoint():
+    p = pipeline()
+    p.observe("u6", 100, {"amount": 5})
+    assert spans(p.open_sessions("u6")) == [(100.0, 100.0)]
+    p.observe("u6", 160, {"amount": 4})
+    assert spans(p.open_sessions("u6")) == [(100.0, 100.0), (160.0, 160.0)]
+    p.observe("u6", 130, {"amount": 2})
+    checkpoint = p.open_sessions("u6")
+    assert spans(checkpoint) == [(100.0, 160.0)]
+    assert checkpoint[0].value("n") == 3 and checkpoint[0].value("rev") == 11
+    p.observe("u6", 75, {"amount": 1})
+    final = p.open_sessions("u6")
+    assert spans(final) == [(75.0, 160.0)]
+    assert final[0].value("n") == 4 and final[0].value("rev") == 12
+
+
+def test_counter_history_records_what_actually_happened():
+    p = pipeline()
+    p.observe("u7", 100)
+    p.observe("u7", 160)
+    p.observe("u7", 130)  # unifies: opened stays 2
+    p.observe("u7", 400)
+    p.advance_watermark(300)  # horizon 200: only the unified session settles
+    p.observe("u7", 95)  # beyond lateness: dropped
+    snapshot = p.metrics_snapshot()
+    assert snapshot["events_observed"] == 5
+    assert snapshot["events_dropped_late"] == 1
+    assert snapshot["sessions_opened"] == 3
+    assert snapshot["sessions_finalized"] == 1
+    assert spans(p.finalized("u7")) == [(100.0, 160.0)]
+    assert p.finalized("u7")[0].value("n") == 3
+    assert spans(p.open_sessions("u7")) == [(400.0, 400.0)]
+
+
+def test_distinct_counts_survive_unification_exactly():
+    p = pipeline()
+    p.observe("u8", 100, {"page": "a"})
+    p.observe("u8", 110, {"page": "b"})
+    p.observe("u8", 160, {"page": "b"})
+    p.observe("u8", 170, {"page": "c"})
+    p.observe("u8", 130, {"page": "a"})  # unify: distinct pages are {a, b, c}
+    views = p.open_sessions("u8")
+    assert spans(views) == [(100.0, 170.0)]
+    assert views[0].value("n") == 5
+    assert views[0].value("pages") == 3
+
+
+def test_finalization_uses_honest_boundaries():
+    p = pipeline()
+    p.observe("u9", 100)
+    p.observe("u9", 110)
+    p.observe("u9", 50)  # separate earlier activity, admissible
+    p.advance_watermark(185)  # horizon 85: only the [50, 50] session settles
+    assert spans(p.finalized("u9")) == [(50.0, 50.0)]
+    assert p.finalized("u9")[0].value("n") == 1
+    assert spans(p.open_sessions("u9")) == [(100.0, 110.0)]
+    assert p.metrics_snapshot()["sessions_finalized"] == 1
+    p.advance_watermark(245)  # horizon 145: now the [100, 110] session settles
+    assert spans(p.finalized("u9")) == [(50.0, 50.0), (100.0, 110.0)]
+    assert p.open_sessions("u9") == []
+
+
+def test_keys_stay_isolated_through_repairs():
+    p = pipeline()
+    p.observe("k1", 100, {"amount": 1})
+    p.observe("k2", 105, {"amount": 10})
+    p.observe("k1", 160, {"amount": 2})
+    p.observe("k2", 130, {"amount": 20})
+    p.observe("k1", 130, {"amount": 4})  # unifies k1 only
+    p.advance_watermark(1000)
+    views = p.finalized()
+    assert [(v.key,) + s for v, s in zip(views, spans(views))] == [
+        ("k1", 100.0, 160.0),
+        ("k2", 105.0, 130.0),
+    ]
+    assert [v.value("n") for v in views] == [3, 2]
+    assert [v.value("rev") for v in views] == [7, 30]
+    snapshot = p.metrics_snapshot()
+    assert snapshot["sessions_opened"] == 3
+    assert snapshot["sessions_finalized"] == 2