Tachikoma · Docs · Web-of-Trust

Fan-out — map & reduce in a Web-of-Trust

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.

Governed map · reduce pattern Collection adjacent · perfect · latent · ambient Kernels append · window · transform · rlm · okf

The fan-out — map & reduce in a WoT

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.

1 · The dispatch — the collection disciplines

How we bring the branches back: adjacent (wired right after the fan), or collectors correlated by fan_idperfect (closes at N) vs latent (agglomerates, pulled at step n), and ambient (multiple collectors, no wiring).

drag to rotate · scroll to zoom

2 · The kernels — what the reduce COMPUTES

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.

payloads → kernel → result

3 · OKF — the reduce by reference (the big dataset)

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.

reduced object 1,240,000 rows ClickHouse table reduce okf handle — in the bag { ref, schema } rows: 1,240,000 branch(agency), amount(outstanding €) compact — never the data agent compact context okf_read(rows="0:50") · okf_stats(field) — slice / aggregate, never everything
# 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.

4 · Use cases — which pattern when

DisciplineConcrete example Best suited when
Adjacent joina 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 N8 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 · pulla 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 · multia 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 · refthe 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
KernelConcrete example Best suited when
appendthe 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
transformrunning 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
rlmthe findings of parallel researchers accumulate in a session's ContextRLMRecord for a following turn the reduce must feed a conversational/RLM context managed elsewhere
okfa dataset of 1.24 M rows → a ref + schema, read by slices the reduced object is big and gets queried (see above)
08

The reduce strategies — how to bring back a fan-out

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.

Axis A — collection & closure

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

Axis B — the kernel (merge function)

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.

Latent vs Perfect — the closure discipline

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")

The five kernels — how they are written

KernelWhat it does Syntax
appendconcat / dict-merge (conflict detected) kernel="append"
sliding windowprogressive summary: new = f(summary, last N) kernel=Window(size=3, summarize=…)
transformhandler: latent (incremental) | end (all arrived) | append kernel=Transform(fn, when="latent")
rlmingested latently, managed by the RLM context logic kernel=Rlm(consume_at="report")
okfref to a serialized object + schema + partial access kernel=Okf(store=…, partial=True)

A hand-built WoT — three collectors on a single fan-out

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

OKF — the ref is in the bag, with its schema and its partial-access interface

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.

09

The fan_id + gather seam — implementation spec

The 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.

File diff

wot_record.py+ FanFrame, + fan_ctx field, preserve in advance()/child()
deployer.py:428push a FanFrame alongside parent_id
loop_tools.py:621handoff_agent(list,…) mints the fan_id
core.py:2071.transform : carry_fan(event, result)
cast.pyx-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 pathapply 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.

File diff

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:374recognize a gather node (fan_id+mode+n) instead of the static MergeSpec
loop_tools.pyokf_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.