A WoT fan out (the map); a gather brings the branches back (the
reduce). Everything composes along two orthogonal axes — a collection discipline × a merge
kernel — each child correlated by a stable fan_id. The scenes are in 3D: drag
to rotate, scroll to zoom.
A WoT fan out (the map); a gather brings the branches back (the
reduce). Everything composes along two axes: a collection discipline (§1) × a
kernel (§2). Each child carries a stable fan_id — it is what correlates the map to
its collection. The scenes are in 3D: drag to rotate, scroll to zoom.
How we bring the branches back: adjacent (wired right after the fan), or
collectors correlated by fan_id — perfect (closes at N) vs latent
(agglomerates, pulled at step n), and ambient (multiple collectors, no wiring).
append gathers (concat). Then how the data is processed continuously — TWO opposing strategies: summary (sliding window: we compress) ⟷ transform (we process / compute). rlm = a context that agents process continuously (summary-style, but agent-driven). okf = by reference (§3). Stream on the left → kernel → result on the right.
The 4 designs above are collection disciplines (the dispatch). The OKF,
on the other hand, is a kernel — it changes what the reduce RETURNS when the result is big. Inlining
1.24 M rows of loans into the agent's context blows it up. OKF returns only the handful:
the ref + the evolving schema (number of rows, one description per field). The data
stays in the store; the agent pulls parts of it on demand.
# OKF — the reduce returns a REF + an evolving schema, NEVER the data handle = {"ref": "okf:claims#v1", "schema": {"rows": 1_240_000, "columns": {"branch": {"type": "str", "desc": "bank branch"}, "amount": {"type": "float", "desc": "outstanding €"}}}} set_okf_backend(ClickHouseOkfStore(host="ch")) # the store = a real ClickHouse table okf_read("okf:claims#v1", fields=["branch", "amount"], rows="0:50") # slice -> SELECT ... LIMIT 50 okf_stats("okf:claims#v1", field="amount") # server-side aggregate, not the rows
The schema-only tag shows the agent the schema, never the data:
<tckm domain="okf"/> → okf:claims#v1 — 1,240,000 rows; branch(agency), amount(outstanding €).
Where OKF wins: the compliance agent that has to verify figures against a huge warehouse result
(it runs okf_stats instead of loading the million rows) · passing a dataset between agents
without re-serializing it · a chart builder that only needs the Top-20 of a million rows.
| Discipline | Concrete example | Best suited when |
|---|---|---|
| Adjacent join | a claim split into fraud / coverage / amount → the 3 analyses joined at the end of the chain | the reduce is FIXED, right after the fan; the result transits immediately |
| Perfect · close at N | 8 agencies analyzed in parallel → closes at the 8th, emits the list of 8 | you know the number of branches and you want the barrier (complete list, final aggregation) |
| Latent · pull | a risk KPI that updates while the agencies arrive, read by the report node at the end | the size is open-ended, or you want a live/partial view consumed at a chosen step |
| Ambient · multi | a single fan of agencies feeds IN PARALLEL: the list (perfect), a sliding summary (window) and the dataset (okf) | multiple views of a single fan-out, or a collector far from the fan, with no wiring |
| OKF · ref | the reduction = 1.24 M rows of loans → the agent sees the schema and runs okf_read(rows="0:50") / okf_stats(field="amount") |
the result is BIG and an agent has to query it, not read it in full |
| Kernel | Concrete example | Best suited when |
|---|---|---|
| append | the findings of the 4 workers → ["f0","f1","f2","f3"] |
the default — you just want all the parts together |
| summary (window) | 12 events, window 3 → 4 batches folded into ONE sliding executive summary | strategy A — COMPRESS a long stream on the fly (a live exec-summary), not a list |
| transform | running sum of amounts as the workers arrive (latent); or a single computation at the end of the batch (end) | strategy B (opposite of summary) — PROCESS / compute the data, not compress it |
| rlm | the findings of parallel researchers accumulate in a session's ContextRLMRecord for a following turn |
the reduce must feed a conversational/RLM context managed elsewhere |
| okf | a dataset of 1.24 M rows → a ref + schema, read by slices | the reduced object is big and gets queried (see above) |
A WoT fans out: an event scatters into N children (the map). The
reduce brings them back. We don't code six logics: we decompose along two orthogonal axes
and compose one collection discipline × one merge kernel. Each child of a
fan-out carries a stable fan_id — that is what correlates the scatter to its collection.
adjacent = reduce between two neighboring nodes (the current join). gather =
collectors placed farther down the graph, correlated by fan_id — several
collectors can point at the same fan-out.
src.fan(into=[worker], fan_id="claims", expect="auto") # MAP : fan_id carried on each child
worker >> Reduce("brief", kernel="append") # ADJACENT collection (the current join)
Gather("collect", fan_id="claims", n=8, mode="perfect") # distant COLLECTOR, closes at 8, transits
Gather("pool", fan_id="claims", mode="latent") # open COLLECTOR, pulled at step n
The kernel does NOT know the collection discipline: you plug it onto any
collection. Five kernels — append, sliding window, transform,
rlm, okf.
A concrete reduce = (collection mode) × (kernel). E.g.:
gather(latent) × window(3), gather(perfect, n=8) × append,
adjacent × okf.
Perfect: an expected count n; when n events of the same
fan_id have arrived, the collector closes and transits downstream (push barrier).
Latent: no closure; a StatefulRecord agglomerates continuously and a
step n pulls it (pull).
mode="perfect", n=8 # closes at 8, then transits → generalizes MergeNode.wait_n
mode="latent" # Record gather:claims agglomerates ; "report" runs bag_read("gather:claims")
| Kernel | What it does | Syntax |
|---|---|---|
| append | concat / dict-merge (conflict detected) | kernel="append" |
| sliding window | progressive summary: new = f(summary, last N) | kernel=Window(size=3, summarize=…) |
| transform | handler: latent (incremental) | end (all arrived) | append | kernel=Transform(fn, when="latent") |
| rlm | ingested latently, managed by the RLM context logic | kernel=Rlm(consume_at="report") |
| okf | ref to a serialized object + schema + partial access | kernel=Okf(store=…, partial=True) |
Claims processing: we split by file, then gather in three different ways in parallel — a KPI that summarizes on the fly, the complete list, and a big dataset passed by reference.
hive = Hive(app, name="claims-wot", type="wot", tracer=tracer)
triage, assess, report = hive.stage("triage", triage_fn), assess_agent, report_agent
(hive.from_input()
>> triage
.fan(into=[assess], fan_id="claims", expect="auto") # MAP : 1 file → N
# (1) LATENT collector + sliding window — KPI summarized on the fly
>> Gather("kpi_pool", fan_id="claims", mode="latent",
kernel=Window(size=3, summarize=SUMMARY_PROMPT), consume_at="report")
# (2) PERFECT collector + append — complete list, closes at N, transits
>> Gather("all_claims", fan_id="claims", mode="perfect", kernel="append")
# (3) PERFECT collector + OKF — big dataset → ref + schema, no inline
>> Gather("dataset", fan_id="claims", mode="perfect",
kernel=Okf(store="clickhouse", partial=True))
>> report.to_loop()) # report reads what it needs, without overflow
When the kernel is Okf, the reduce does not inline the data: it writes
the heavy object into a store (reuses NodeRecord/resolve_node) and places in the
bag an item = the ref + the evolving schema (number of rows, one description per field) + an
interaction interface. You then retrieve only certain parts — no overflow.
# OKF item in the bag (compact — what the agent SEES, never the data) :
{ "ref": "okf:claims#v3",
"schema": { "rows": 1240,
"columns": { "branch": {"type":"str", "desc":"agency"},
"risk": {"type":"float", "desc":"score 0-1"} } } }
# rendered into context via a schema-only frame :
<tckm domain="okf" ref="okf:claims#v3"/> → "okf:claims#v3 — 1240 rows; branch, risk"
# the interaction interface — you retrieve ONLY a slice, never everything :
okf_read("okf:claims#v3", fields=["branch","risk"], rows="0:50") # slice
okf_read("okf:claims#v3", where="risk>0.8", limit=20) # filter
okf_stats("okf:claims#v3", field="amount") # aggregate, not the rows
Already there: the adjacent join
(_node_join, MergeNode), the progressive digest (dry_context), the
per-event .transform, the durable agglomeration (ContextRLMRecord), the
by-ref (NodeRecord/resolve_node) and the compact schema (BAG_INFO +
render_bag). New: a stable multi-hop fan_id,
the gather keyed by fan_id, the latent/perfect enum, multiple collectors per
fan-out, the unified OKF record + partial okf_read, the N-message window, the
consume-at-step-n gate. The rest is declarative wiring; the framework executes the barrier.
fan_id + gather seam — implementation specThe prerequisite of the entire "gather" axis. Two halves: fan_id (a stable fan-out id that survives every hop) and gather (the collector keyed by that fan_id, in latent or perfect mode). Neither exists today — here is the plan, anchored on what already exists.
Problem. Today only WotRecord.parent_id exists
(wot_record.py:29) — single-hop lineage: a collector 5 hops away does not know
which fan-out the event comes from. Solution: a stack of frames carried by the record,
minted at the fan, copied at each hop.
# spec : a STACK (handles nested fans AND multi-collector)
@dataclass
class FanFrame:
fan_id: str # stable id of the fan-out
seq: int # index of this child (0..n-1)
expected: int # expected n
class WotRecord(StatefulRecord):
fan_ctx: list[FanFrame] = field(default_factory=list) # top = innermost fan
Minting + stamping — where parent_id is already set
(deployer.py:428):
fid = f"{node}#{parent_event_id}" # deterministic → resume-safe
for i, child in enumerate(items):
child.fan_ctx = [*parent.fan_ctx, FanFrame(fid, i, len(items))] # inherit + push
Surviving the hops — THE critical point. Every seam that produces a new record copies the stack:
def carry_fan(src, dst):
if src.fan_ctx and not dst.fan_ctx:
dst.fan_ctx = list(src.fan_ctx)
return dst
# called : advance()/child() (wot_record.py:46), .transform (core.py:2071),
# the _bag traveler on handoff, header x-fan-ctx (cast.py)
An event belongs to all the fan_ids in its stack ⇒ a gather
matches if its fan_id ∈ the stack (solves nesting + multi-collector). Since
fan_ctx is a field of the StatefulRecord, it rides the snapshot/CRDT on its own
(node_record.py:68) — nothing to do on the Ray serialization side.
| wot_record.py | + FanFrame, + fan_ctx field, preserve in advance()/child() |
| deployer.py:428 | push a FanFrame alongside parent_id |
| loop_tools.py:621 | handoff_agent(list,…) mints the fan_id |
| core.py:2071 | .transform : carry_fan(event, result) |
| cast.py | x-fan-ctx header (events injected by cast) |
Problem. No gather keyed by a runtime fan_id: Cast keys a
correlation_id 1:1 (cast.py:461), the deployer join keys by level
position (deployer.py:441), MergeNode.wait_n keys by static source names
(wot_merge.py:227). Solution: one GatherRecord per
(gather_node, fan_id), in the bag under gather:<fan_id>.
@sr_init(entity="gather", initial="open", states=["open","closed"])
class GatherRecord(StatefulRecord):
fan_id: str = ""; mode: str = "perfect"; expected: int = 0
seen: set = field(default_factory=set) # {seq} — dedup (Ray may redeliver)
kernel: str = "append"; kstate: dict = field(default_factory=dict)
result: Any = None; closed: bool = False
The body (framework-side). Latent & perfect share the path —
apply is always incremental, finalize only serves to close:
async def _node_gather(rec, ev, *, gather_node, fan_id, n, mode, kernel):
if fan_id not in [f.fan_id for f in ev.fan_ctx]:
return ev # not our fan-out → pass-through
g = ensure_gather(gather_node, fan_id, mode, n or fan_expected(ev, fan_id), kernel)
seq = fan_seq(ev, fan_id)
if seq in g.seen: return None # dedup / idempotence
g.seen.add(seq); apply_kernel(g, ev) # INCREMENTAL
if mode == "perfect" and len(g.seen) >= g.expected:
g.result = finalize_kernel(g); g.closed = True
return g.result # closes + TRANSITS downstream
return None # latent, or perfect not full → nothing emitted
Perfect: counts up to expected, finalize, transits
(generalizes MergeNode.wait_n). Latent: never closes; the
GatherRecord agglomerates, a consume_at node pulls it
(channel_read(consume=True), loop_context.py:304 / bag_read).
The kernels plug onto apply/finalize and
reuse what already exists:
KERNELS = { # apply = incremental ; finalize = close
"append": (push, lambda g: g.kstate["xs"]), # _node_join / _merge_dicts
"window": (window_fold, lambda g: g.kstate["digest"]), # extends dry_context
"transform": (user_fn, lambda g: user_fn(end=True)), # .transform
"rlm": (rlm.add_message, lambda g: rlm_record), # ContextRLMRecord
"okf": (okf_write, lambda g: g.kstate["ref"]), # the REF, not the data
}
Placement. wired = direct edge (worker >> Gather(...),
like a join). ambient = the collector is elsewhere and receives a copy of every
event carrying its fan_id ("collectors at defined locations") via a
fan_bus (one topic per fan_id, reuses cast/channel). Timeout/dead-letter
(reuses MergeNode) closes a perfect if a child gets lost.
| gather_record.py (new) | GatherRecord + KERNELS + apply/finalize_kernel |
| loop_wot_deploy.py | _node_gather, body selection, deploy ; fan_bus (ambient) |
| builder.py | .fan(...), Gather(...), Reduce(...) (fluent) |
| wot_compiler.py:374 | recognize a gather node (fan_id+mode+n) instead of the static MergeSpec |
| loop_tools.py | okf_read/okf_stats tools (okf kernel) |
Order: ① fan_ctx + stamping + carry_fan (test: fan_id intact at 3 hops) → ② gather perfect × append (closes at N, emits the list) → ③ plug in the kernels → ④ latent + consume_at → ⑤ ambient + multi-collectors → ⑥ timeout/dedup.