Source code for zephon.ops.accumulators.counting
# Copyright 2025 DatologyAI
# SPDX-License-Identifier: Apache-2.0
"""Counting accumulator that batches elements by count, optionally with time.
Elements are routed to per-lane buffers (keyed by :func:`lane_of`), so each
emitted batch is lane-pure. This is critical for deterministic shuffle:
``batch_seed`` must not depend on the cross-lane interleaving order from the
source stream, and it lets a per-lane flush sentinel reset one lane alone.
"""
from __future__ import annotations
import time
from collections.abc import Sequence
from typing import TypeVar
from zephon._internal.stream import (
RunnerStreamIn as _RunnerStreamIn,
)
from zephon._internal.stream import (
lane_of as _lane_of,
)
from zephon.ops.accumulators.base import Accumulator, ReadyBatch
# Bounded to RunnerStreamIn: the accumulator only batches pipeline elements,
# which is exactly what lane_of can key by (SampleRecord/SampleBatch/EngineSample).
T = TypeVar("T", bound=_RunnerStreamIn)
[docs]
class CountingAccumulator(Accumulator[T]):
"""Accumulator that batches elements by count, optionally with time-based flushing.
Elements are routed to per-lane buffers keyed by :func:`lane_of`, so each
emitted batch is lane-pure. ``lane_of`` extracts the lane from whichever
element type the accumulator carries (a ``SampleRecord``, or a raw
``EngineSample`` tuple at the fetch stage), so the buffer key is always the
lane id — which is what lets a per-lane flush sentinel reset one lane alone.
When ``max_latency_ms`` is None, this is a pure count-based accumulator
suitable for deterministic execution. When set, batches are also flushed
after the timeout expires (per lane).
When ``drop_last`` is True, ``flush()`` discards partial batches instead
of emitting them.
"""
def __init__(
self,
max_batch: int,
max_latency_ms: int | None = None,
*,
drop_last: bool = False,
) -> None:
if max_batch <= 0:
raise ValueError("max_batch must be positive")
self._max_batch = max_batch
self._max_latency_ms = max_latency_ms
self._drop_last = drop_last
self._buffers: dict[int, list[T]] = {}
self._first_ts_ns: dict[int, int] = {}
[docs]
def has_pending_data(self, lane_id: int | None = None) -> bool:
"""Return True if buffered elements remain (in ``lane_id`` if given)."""
if lane_id is None:
return any(self._buffers.values())
return bool(self._buffers.get(lane_id))
[docs]
def push_many(self, elems: Sequence[T]) -> list[ReadyBatch[T]]:
"""Accumulate elements and emit based on count or time thresholds."""
if self._max_latency_ms is None:
return self._push_many_count_only(elems)
return self._push_many_timed(elems)
def _push_many_count_only(self, elems: Sequence[T]) -> list[ReadyBatch[T]]:
"""Fast path for deterministic (count-only) mode -- no clock reads."""
ready: list[ReadyBatch[T]] = []
bufs = self._buffers
max_batch = self._max_batch
lane_fn = _lane_of
for elem in elems:
k = lane_fn(elem)
buf = bufs.get(k)
if buf is None:
buf = []
bufs[k] = buf
buf.append(elem)
if len(buf) >= max_batch:
ready.append((buf, 0))
bufs[k] = []
return ready
def _push_many_timed(self, elems: Sequence[T]) -> list[ReadyBatch[T]]:
"""Latency-aware path -- amortises perf_counter_ns calls."""
ready: list[ReadyBatch[T]] = []
now_ns = time.perf_counter_ns()
max_latency_ns = self._max_latency_ms * 1_000_000 # type: ignore[operator]
lane_fn = _lane_of
for elem in elems:
k = lane_fn(elem)
buf = self._buffers.get(k)
if buf is None:
buf = []
self._buffers[k] = buf
if not buf:
self._first_ts_ns[k] = now_ns
buf.append(elem)
if len(buf) >= self._max_batch:
now_ns = time.perf_counter_ns()
ready.append(self._emit_batch_for_key(k, now_ns))
else:
first = self._first_ts_ns.get(k)
if first is not None and (now_ns - first) >= max_latency_ns:
now_ns = time.perf_counter_ns()
ready.append(self._emit_batch_for_key(k, now_ns))
return ready
[docs]
def flush(
self, *, reset: bool = False, lane_id: int | None = None
) -> list[ReadyBatch[T]]:
"""Emit any remaining buffered elements (or discard if drop_last).
Buffers are keyed by lane, so ``lane_id`` is the buffer key for a
per-lane flush.
"""
keys = [lane_id] if lane_id is not None else list(self._buffers)
ready: list[ReadyBatch[T]] = []
for k in keys:
if not self._drop_last and self._buffers.get(k):
ready.append(self._emit_batch_for_key(k))
self._buffers.pop(k, None)
self._first_ts_ns.pop(k, None)
return ready
def _emit_batch_for_key(self, key: int, now_ns: int | None = None) -> ReadyBatch[T]:
"""Emit the buffer for a single lane as a batch."""
buf = self._buffers.get(key, [])
first = self._first_ts_ns.pop(key, None)
if now_ns is None:
now_ns = time.perf_counter_ns()
wait_ns = now_ns - first if first is not None else 0
self._buffers[key] = []
return (buf, wait_ns)
__all__ = ["CountingAccumulator"]