Skip to main content

BindingBoundary

Trait BindingBoundary 

Source
pub trait BindingBoundary: Send + Sync {
Show 22 methods // Required methods fn invoke_fn( &self, node_id: NodeId, fn_id: FnId, dep_data: &[DepBatch], ) -> FnResult; fn custom_equals( &self, equals_handle: FnId, a: HandleId, b: HandleId, ) -> bool; fn release_handle(&self, handle: HandleId); // Provided methods fn invoke_fn_with_core( &self, node_id: NodeId, fn_id: FnId, dep_data: &[DepBatch], core: &dyn CoreFull, ) -> FnResult { ... } fn retain_handle(&self, _handle: HandleId) { ... } fn project_each( &self, _fn_id: FnId, _inputs: &[HandleId], ) -> SmallVec<[HandleId; 1]> { ... } fn predicate_each( &self, _fn_id: FnId, _inputs: &[HandleId], ) -> SmallVec<[bool; 4]> { ... } fn fold_each( &self, _fn_id: FnId, _acc: HandleId, _inputs: &[HandleId], ) -> SmallVec<[HandleId; 1]> { ... } fn pairwise_pack( &self, _fn_id: FnId, _prev: HandleId, _current: HandleId, ) -> HandleId { ... } fn pack_tuple(&self, _fn_id: FnId, _handles: &[HandleId]) -> HandleId { ... } fn intern_node(&self, _node_id: NodeId) -> HandleId { ... } fn producer_deactivate( &self, _node_id: NodeId, _unsub: &dyn Fn(NodeId, SubscriptionId), ) { ... } fn synthesize_pause_overflow_error( &self, _node_id: NodeId, _dropped_count: u32, _configured_max: usize, _lock_held_duration_ms: u64, ) -> Option<HandleId> { ... } fn cleanup_for(&self, _node_id: NodeId, _trigger: CleanupTrigger) { ... } fn serialize_handle(&self, _handle: HandleId) -> Option<Value> { ... } fn deserialize_value(&self, _value: Value) -> HandleId { ... } fn invoke_tap_fn(&self, _fn_id: FnId, _handle: HandleId) { ... } fn invoke_tap_error_fn(&self, _fn_id: FnId, _handle: HandleId) { ... } fn invoke_tap_complete_fn(&self, _fn_id: FnId) { ... } fn invoke_rescue_fn( &self, _fn_id: FnId, _handle: HandleId, ) -> Result<HandleId, ()> { ... } fn invoke_stratify_classifier_fn( &self, _fn_id: FnId, _rules_handle: HandleId, _value_handle: HandleId, ) -> bool { ... } fn wipe_ctx(&self, _node_id: NodeId) { ... }
}
Expand description

The FFI surface: every Core → user-code crossing goes through one of these three methods.

§Thread safety

Send + Sync because the Core dispatcher is sync but may be called from multiple binding threads (e.g. multiple Node Workers sharing one Core via Arc<Core>). Implementors must serialize access to the value registry internally if needed. Free-threaded Python parity is the target — see ~/src/graphrefly-py compat/asyncio.py for the current shape that will simplify dramatically once this trait is the substrate.

Required Methods§

Source

fn invoke_fn( &self, node_id: NodeId, fn_id: FnId, dep_data: &[DepBatch], ) -> FnResult

Invoke a user function. The Core knows the fn’s identity (fn_id) and the current dep batch data; the binding side dereferences handles, runs the fn, registers the output, and returns the new handle.

dep_data carries per-dep batch arrays (R1.3.6.b coalescing): outside batch() scope each dep has at most 1 DATA handle; inside batch(), K consecutive emits on the same source produce K entries per dep. The binding side bulk-dereferences all handles, calls user code, and returns the result — one FFI call per fn fire regardless of dep count or batch depth.

Errors thrown by user code are reported by returning a FnResult::Data with a handle that resolves to an error value; the Core then forwards [ERROR, handle] per R1.2.5. (Or the binding side surfaces them via a separate channel — exact error-propagation discipline is binding-side.)

Source

fn custom_equals(&self, equals_handle: FnId, a: HandleId, b: HandleId) -> bool

Custom equals oracle. Called only when a node declares EqualsMode::Custom. Identity equals (the default) is a u64 compare inside the Core — zero FFI per check.

Per H2 IdentityEqualsIsPureCore ASSUME in ~/src/graphrefly-ts/docs/research/handle-protocol.tla, the binding-side impl MUST extend identity (i.e. always treat a == b as equal at the handle level). Otherwise a node could observe its own cached value as different from itself — a fundamental violation.

Source

fn release_handle(&self, handle: HandleId)

Decrement the refcount on handle. Called when the Core no longer holds handle in any cache slot or message buffer. The binding side drops the underlying value when its refcount reaches zero.

Implementing this as a no-op is safe during prototyping; it matters for memory pressure under sustained load. The TS prototype’s bindings.ts uses this to drive a Map<HandleId, { value, refcount }>.

§Leaf-operation contract (/qa F2/M1, 2026-05-10) — HARD requirement

Implementations MUST NOT re-enter Core from release_handle. The Core’s lock-held release paths (Drop for CoreState, Core::reset_for_fresh_lifecycle Phase 3 / 3b / 5, OperatorScratch::release_handles callers) invoke release_handle while the state mutex is held; re-entering Core (via emit / subscribe / register / nested release_handle on a different handle that triggers a final-Drop hook into Core, etc.) deadlocks against that lock.

“Re-entry into Core” here means: calling any method on the same Core instance (or any Core that shares state via Arc::clone of the inner state mutex). Independent Cores are fine.

Safe operations inside release_handle:

  • Pure value-registry bookkeeping (decrement refcount, drop the underlying T when count reaches zero).
  • Logging / metrics that don’t re-enter Core.
  • Calling other binding methods that themselves honor the leaf-op contract (e.g., a binding-internal mutex).

Forbidden operations inside release_handle:

  • Any Core::emit / subscribe / register* / complete / error / teardown / invalidate / pause / resume / set_deps / batch / begin_batch.
  • Recursive BindingBoundary::release_handle on this binding that fans into Core via a binding-side final-Drop.

Bindings that need lifecycle hooks on value-drop should buffer the events and process them asynchronously (e.g., next tick on the host runtime) so the Core lock is released before re-entrance.

Provided Methods§

Source

fn invoke_fn_with_core( &self, node_id: NodeId, fn_id: FnId, dep_data: &[DepBatch], core: &dyn CoreFull, ) -> FnResult

Invoke a user function with the owner-side full Core facade in hand (D246 rule 5 / D245 Option A — the producer-build facade hand-off, folding D245).

Core calls this — not the parameterless Self::invoke_fn — for every fn-fire dispatch ([crate::batch] fire_regular Phase 2), passing self as &dyn CoreFull (the one object-safe mutation+inspection+serialize+mailbox facade, crate::node::CoreFull). A binding whose invoke_fn builds a producer (the operators test harness, the napi bench binding) needs a real Core surface during the build — subscribe/register_* for upstream wiring, not just the emit-only mailbox. D231 located producer build/teardown owner-side with &Core; D245 made that concrete at the trait surface so the build path receives a Core facade explicitly without a thread-local / Core clone / stored back-reference (all β-invalid under the actor model — Core is move-only).

The default forwards to Self::invoke_fn (ignoring core), so every binding that does not build producers is completely unaffected — core/structures/storage/graph test harnesses and the napi non-producer paths keep their existing parameterless invoke_fn semantics with zero behavior change. Only a producer-building binding overrides this to construct its ProducerCtx from the passed &dyn CoreFull.

Source

fn retain_handle(&self, _handle: HandleId)

Increment the refcount on handle. Called when the Core takes an additional reference to an existing handle that the binding side already interned — used by the pause buffer (a buffered Data(H) outlives the cache slot that originally interned H, so the buffer needs its own refcount share to keep H alive across later cache replacements), the operator-scratch pipeline (Scan/Reduce seed retain, Last default retain), and Phase G’s D-α fresh-scratch install.

Default: no-op. Bindings that don’t track refcounts (e.g., trivial always-alive registries) can leave the default. Bindings that do (TS bindings.ts, the test runtime, the napi-rs bench harness) must override to bump the per-handle refcount.

§Leaf-operation contract — HARD requirement

Same leaf-op contract as Self::release_handle: implementations MUST NOT re-enter Core from retain_handle. Core call sites (notably make_op_scratch_with_binding called from Phase G’s lock-held window) assume the retain is a pure refcount bump.

Source

fn project_each( &self, _fn_id: FnId, _inputs: &[HandleId], ) -> SmallVec<[HandleId; 1]>

OperatorOp::Map — element-wise transform. For each input handle, the binding dereferences to T, calls the user Fn(T) -> R, and returns the new handle. Output length equals input length.

Source

fn predicate_each( &self, _fn_id: FnId, _inputs: &[HandleId], ) -> SmallVec<[bool; 4]>

OperatorOp::Filter — element-wise predicate. For each input handle, the binding dereferences to T, calls the user Fn(T) -> bool, and returns the boolean. Output length equals input length.

Source

fn fold_each( &self, _fn_id: FnId, _acc: HandleId, _inputs: &[HandleId], ) -> SmallVec<[HandleId; 1]>

OperatorOp::Scan / OperatorOp::Reduce — left-fold. The binding dereferences acc to R and each input to T, runs Fn(R, T) -> R for each input feeding the previous result forward, and returns each intermediate R as a fresh handle. Output length equals input length (Scan emits each entry; Reduce uses only the last). The starting acc is owned by Core; the binding must NOT release it.

Source

fn pairwise_pack( &self, _fn_id: FnId, _prev: HandleId, _current: HandleId, ) -> HandleId

OperatorOp::Pairwise — pack (prev, current) into a tuple value. Called once per pair (Core iterates and updates prev between calls). Returns the binding-side handle for the new tuple value.

Source

fn pack_tuple(&self, _fn_id: FnId, _handles: &[HandleId]) -> HandleId

OperatorOp::Combine / OperatorOp::WithLatestFrom — pack N handles into a single tuple/array handle (Slice C-2, D020). The binding dereferences each input handle to T, constructs a tuple value (e.g., [T0, T1, ..., Tn]), and returns the new handle. The returned handle has a pre-bumped retain (caller takes ownership without additional retain_handle call).

Source

fn intern_node(&self, _node_id: NodeId) -> HandleId

Intern a NodeId as a value handle. Used by windowing operators (window, window_count) to emit inner sub-node identities as DATA payloads. The binding side stores the node id as a value in its value registry and returns the corresponding HandleId. The returned handle has a pre-bumped retain (caller takes ownership).

Default panics — bindings that ship window operators MUST override.

Source

fn producer_deactivate( &self, _node_id: NodeId, _unsub: &dyn Fn(NodeId, SubscriptionId), )

Called when a producer node loses its last subscriber. The binding should drop any per-node state for node_id — captured closures, recorded upstream (NodeId, SubscriptionId) pairs, etc. Default no-op for bindings that don’t ship producers.

Fires lock-released; re-entrance into Core is permitted.

S2b / D229: core-level RAII Subscription is retired (D223/D225 — a parameterless Drop cannot reach a relocating owned Core). The binding therefore records upstream subs as (NodeId, SubscriptionId) and unsubscribes them HERE via unsub — a Core::unsubscribe-capable closure the owner-driven unsubscribe chain passes in (it holds the &Core already, exactly the synchronous owner context D225 requires; no Weak<C>, no unsafe, no parameterless-Drop). Calling unsub(up_node, up_sub) is behaviour-identical to the old Subscription::Drop (it runs the same deregister + Phase-G chain, and re-entrant producer cascades work because the call is lock-released).

Source

fn synthesize_pause_overflow_error( &self, _node_id: NodeId, _dropped_count: u32, _configured_max: usize, _lock_held_duration_ms: u64, ) -> Option<HandleId>

R1.3.8.c / Lock 6.A — synthesize an ERROR payload when a paused node’s pause buffer overflows the configured cap. Called once per overflow event (the first drop of a pause cycle); the returned handle becomes the payload of Message::Error that Core then emits via the standard terminal cascade.

Bindings that ship their own diagnostic shape (e.g. structured { code, nodeId, dropped, configuredMax, lockHeldDurationMs } JSON for the JS / Python sides) override this to intern such a value and return its handle.

Default returns None — Rust core falls back to the silent drop + ResumeReport.dropped path. This preserves backward compatibility for bindings that haven’t yet wired up R1.3.8.c. New bindings SHOULD implement this method to satisfy the canonical-spec invariant.

Returning None consumes the overflow event for the cycle (QA A9, 2026-05-07): the per-pause-cycle overflow_reported flag is set BEFORE this hook is called, so a None return doesn’t re-attempt synthesis on subsequent overflows in the same cycle. If the binding is going to dynamically wire up the hook (configuration-driven), do so before any pause cycle that may overflow.

Fires lock-released. The binding may re-enter Core (typical implementation just calls intern_value and returns).

lock_held_duration_ms is the wall-clock-monotonic duration in milliseconds since the node first transitioned Active → Paused at the start of this pause cycle; it gives consumers a sense of how long a leaked controller held the lockset before overflow. Sub-millisecond durations truncate to 0 (QA A8, 2026-05-07).

Source

fn cleanup_for(&self, _node_id: NodeId, _trigger: CleanupTrigger)

Fire a registered user-cleanup hook for node_id at the lifecycle moment indicated by trigger.

§Binding-side contract

Per D055, bindings own a Mutex<HashMap<NodeId, NodeCtxState>> where NodeCtxState = { store, current_cleanup }. On each invoke_fn the binding overwrites current_cleanup with whatever cleanup spec the user fn returned. When Core later fires cleanup_for(node, trigger), the binding’s impl looks up current_cleanup.<trigger_slot> and invokes it if present. Absent slots are no-ops.

Lock discipline. Bindings MUST release their node_ctx lock before invoking the user closure (clone the closure handle out of the map, drop the lock, fire). Holding the binding-side lock across the user closure deadlocks if the user re-enters the binding’s high-level API from inside the cleanup.

Re-entrance into Core (D045 / D060). Permitted: release_handle, Core::up(other_node, Pause/Resume/Invalidate/Teardown), Core::emit for unrelated nodes, Core::subscribe / drop a Subscription. Not permitted (undefined behavior): Core::emit(self_node, ...) from inside OnRerun (creates a fresh emit during the wave’s pre-fire window); Core::subscribe(self_node) from inside OnDeactivation (self-resubscribe race during deactivation).

Panic isolation (D060). Core stays panic-naive about user code — bindings SHOULD wrap user-closure invocations in catch_unwind and surface failures via the host language’s idiom (JS exception → console.error, Python panic → warning, Rust panic → log + propagate per binding policy). Core’s deferred-drain for OnInvalidate wraps each cleanup_for call in catch_unwind itself to prevent a single panic from short-circuiting the per-wave drain (all queued cleanup attempts run; the last panic re-raises after the drain completes). OnRerun and OnDeactivation fire inline lock-released — a panicking cleanup_for propagates out of fire_regular Phase 1.5 or Subscription::Drop respectively (Drop guarantees apply: state lock already released).

Panic-discard guarantee gap (D061). When a wave is panic-discarded (an invoke_fn panics mid-wave), the queued OnInvalidate cleanup hooks are dropped silently — the cascade’s recorded cache-clears never fire their cleanups. External-resource cleanup (file handles, network sockets, external transactions) attached to OnInvalidate MUST be idempotent at process exit / next successful invalidate cycle. OnRerun panic-discard is moot (panic in OnRerun aborts the wave’s fire_regular before it can corrupt state). OnDeactivation panic-discard is moot (Drop is invoked during stack unwinding; double-panic during drop aborts per std::process::abort semantics).

Per-trigger lifecycle. See CleanupTrigger for per-variant firing rules. After each trigger fires, the binding decides whether to clear current_cleanup:

  • OnRerun: do NOT clear; the next invoke_fn will overwrite.
  • OnInvalidate: do NOT clear; multiple INVALIDATEs across waves can re-fire the same closure.
  • OnDeactivation: clear (D059) — one-shot per activation cycle; store persists separately per R2.4.6.

Default no-op so bindings without cleanup-hook support compile unchanged.

Source

fn serialize_handle(&self, _handle: HandleId) -> Option<Value>

Serialize a handle’s value to JSON for snapshot persistence. Returns None if the handle is unknown or unresolvable.

Called by Graph::snapshot() for each node’s cache handle. The binding dereferences handle to its underlying T and produces a serde_json::Value that will survive serialization round-trips (i.e., JSON-safe: no functions, no circular refs).

Default returns None — bindings that don’t support snapshots get value: null in the snapshot output.

Source

fn deserialize_value(&self, _value: Value) -> HandleId

Deserialize a JSON value back into a handle for snapshot restore. The binding interns the value and returns its HandleId.

Called by Graph::restore() / Graph::from_snapshot() for each node’s serialized value.

Default panics — bindings that restore snapshots MUST override.

Source

fn invoke_tap_fn(&self, _fn_id: FnId, _handle: HandleId)

Side-effect tap: invoke a user callback with a DATA handle for observation purposes. The callback must NOT produce a return value or modify the handle’s refcount — it is purely for side-effects (logging, metrics, debugging).

Called by tap and on_first_data operators on each (or first) DATA emission.

Source

fn invoke_tap_error_fn(&self, _fn_id: FnId, _handle: HandleId)

Side-effect tap on ERROR: invoke a user callback with the error handle. Purely for observation — must not modify refcounts.

Source

fn invoke_tap_complete_fn(&self, _fn_id: FnId)

Side-effect tap on COMPLETE: invoke a user callback.

Source

fn invoke_rescue_fn( &self, _fn_id: FnId, _handle: HandleId, ) -> Result<HandleId, ()>

Error recovery: invoke a user callback with the error handle. Returns Ok(recovered_handle) if recovery succeeded (the binding interns the recovered value and returns its handle with a pre-bumped retain). Returns Err(()) if recovery failed and the original error should propagate.

Source

fn invoke_stratify_classifier_fn( &self, _fn_id: FnId, _rules_handle: HandleId, _value_handle: HandleId, ) -> bool

Stratify classifier — invoke a user predicate with BOTH the latest rules handle AND the current value handle. Returns true if the value belongs to this branch.

Called by graphrefly_operators::stratify_branch on each source DATA. The binding-side closure (registered via OperatorBinding::register_stratify_classifier) typically dereferences both handles, looks up its branch’s rule by name inside the rules array, and runs the rule’s classify(value). Returning false for “rule not found” or “classifier threw” matches TS stratify semantics.

Default panics — bindings that ship the stratify operator MUST override.

Source

fn wipe_ctx(&self, _node_id: NodeId)

Wipe the binding-side ctx state for node_id.

Called by Core ONLY on resubscribable terminal reset, per R2.4.6: ctx.store is “wiped automatically: on resubscribable terminal reset (when a resubscribable: true node hits COMPLETE/ERROR and is later resubscribed)”. Bindings drop their NodeCtxState entry for node_id, releasing both store and any residual current_cleanup.

Default deactivation does NOT trigger wipe — store persists across deactivation/reactivation cycles by spec design (mirrored here to match canonical; current TS impl wipes on deactivation per docstring at node.ts:189-190 and is on the Phase 13.6.B migration list per canonical-spec §11 item 3).

Fires lock-released. Re-entrance into Core is permitted; typical implementations just call node_ctx.lock().remove(&node_id). Default no-op.

Implementors§