feat(channels): AggregateChannel unifies BinOp + DeltaChannel with snapshot_frequency knob

New `AggregateChannel(operator, *, snapshot_frequency=1, typ=None)` replaces
the experimental `DeltaChannel`. `snapshot_frequency=1` (default) is today's
`BinaryOperatorAggregate`. `snapshot_frequency=math.inf` is today's pure-delta
behavior. Integer values between bound replay depth — deep-thread reads
become O(snapshot_frequency) instead of O(thread depth).

- `BinaryOperatorAggregate` is now a thin subclass (snapshot_frequency=1),
  preserving isinstance checks and `_is_field_binop` detection.
- `DeltaChannel` (private, experimental, underscored module) is removed.
  Migration: `AggregateChannel(op, snapshot_frequency=math.inf)`.
- `create_checkpoint` is step-aware: non-snapshot steps store DELTA_SENTINEL;
  snapshot steps store the full blob.
- `_get_channel_writes_history` walk is fixed: pending_writes of a
  terminator ancestor encode its state→child transition and are now
  collected BEFORE checking the blob (old code silently dropped them,
  which was hidden because no prior scenario had a FULL blob mid-thread).

Saver API is unchanged. Batched multi-channel walks, `walk_writes`/
`put_channel_snapshot` refactor, `coalesce=` kwarg, and Option A
(channel_versions delta-encoding) are deferred per spec. Design:
`docs/superpowers/specs/2026-04-24-aggregate-channel-design.md`.

Verified:
- snapshot_frequency ∈ {1, 2, 3, 5, 10, math.inf} all reconstruct correctly
  on a 7-invoke / 14-message thread.
- Multi-channel graph with different snapshot_frequency per channel works.
- All 10 migration tests pass unchanged (pre-BinOp-to-Delta migration path).
- Channel unit tests pass (35 tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sydney Runkle
2026-04-24 11:10:19 -04:00
co-authored by Claude Opus 4.7
parent f247a1a647
commit ee5b3fb4ca
14 changed files with 405 additions and 379 deletions
@@ -281,7 +281,14 @@ class BasePostgresSaver(BaseCheckpointSaver[str]):
collected: list[PendingWrite] = [] # newest first; reversed at the end
for cid in ancestors:
# Pre-delta blob terminator: subsumes any writes at this ancestor.
# Collect this ancestor's pending_writes FIRST. They encode the
# transition from state-AT-this-ancestor to state-AT-its-child;
# the ancestor's blob only reflects state AT the ancestor, not
# post-transition. Both pre-delta migration and snapshot-cadence
# cases require these writes folded onto the seed.
for type_tag, write_blob, task_id, _idx in writes_by_cid.get(cid, []):
val = self.serde.loads_typed((type_tag, write_blob))
collected.append((task_id, channel, val))
ver = ver_of.get(cid)
if ver is not None:
seed_blob = blob_by_ver.get(ver)
@@ -290,9 +297,6 @@ class BasePostgresSaver(BaseCheckpointSaver[str]):
if blob_value is not DELTA_SENTINEL:
collected.reverse()
return _ChannelWritesHistory(seed=blob_value, writes=collected)
for type_tag, write_blob, task_id, _idx in writes_by_cid.get(cid, []):
val = self.serde.loads_typed((type_tag, write_blob))
collected.append((task_id, channel, val))
collected.reverse() # oldest → newest
return _ChannelWritesHistory(seed=DELTA_SENTINEL, writes=collected)