Skip to main content

graphrefly_core/
boundary.rs

1//! The FFI surface — the only path from Core to user code.
2//!
3//! Mirrors `BindingBoundary` in
4//! `~/src/graphrefly-ts/src/__experiments__/handle-core/core.ts:122–126`.
5//!
6//! # Boundary discipline (handle-protocol cleaving plane)
7//!
8//! The Core never sees user values `T`. When the dispatcher needs to invoke
9//! user code, run a custom equals oracle, or release a value-handle's
10//! refcount, it calls into [`BindingBoundary`]. The binding-side implementation
11//! resolves handles to values, runs the user code, and returns either a new
12//! handle or a no-op signal.
13//!
14//! In a Rust core compiled to a napi-rs / pyo3 / wasm-bindgen cdylib, the
15//! `impl BindingBoundary` lives in the bindings crate; it owns the
16//! value registry (`HashMap<HandleId, T>` plus a value→handle dedup map).
17//!
18//! Per the rust-port session doc Part 2: this trait is the *only* mandatory
19//! FFI crossing per fn-fire. Internal protocol bookkeeping (DIRTY propagation,
20//! batch coalescing, equals-substitution under identity, version counters,
21//! PAUSE/RESUME, INVALIDATE, first-run gate) stays Core-internal — zero FFI.
22
23use smallvec::SmallVec;
24
25use crate::handle::{FnId, HandleId, NodeId, NO_HANDLE};
26use crate::node::SubscriptionId;
27
28/// Per-dep batch data passed to [`BindingBoundary::invoke_fn`].
29///
30/// Mirrors the canonical spec R2.9.b `DepRecord` shape at the FFI boundary.
31/// Each entry represents one dep's state for the current wave:
32///
33/// - `data` — DATA handles accumulated this wave (R1.3.6.b coalescing).
34///   Empty means the dep settled RESOLVED or was not involved.
35/// - `prev_data` — last DATA handle from the end of the previous wave.
36///   [`NO_HANDLE`] if the dep has never emitted DATA.
37/// - `involved` — `true` iff the dep was dirtied-then-settled this wave.
38///   Distinguishes "RESOLVED in wave" (`involved && data.is_empty()`) from
39///   "not involved" (`!involved && data.is_empty()`).
40#[derive(Clone, Debug)]
41pub struct DepBatch {
42    /// DATA handles accumulated this wave. Outside `batch()` scope, at most
43    /// 1 element. Inside `batch()`, K consecutive emits on the same source
44    /// produce K entries per R1.3.6.b coalescing.
45    pub data: SmallVec<[HandleId; 1]>,
46    /// Last DATA handle from the end of the previous wave. [`NO_HANDLE`]
47    /// means the dep has never emitted DATA.
48    pub prev_data: HandleId,
49    /// Whether this dep was involved (dirtied → settled) in the current wave.
50    pub involved: bool,
51}
52
53impl DepBatch {
54    /// The "latest" handle for this dep — the last DATA in the current wave's
55    /// batch, falling back to `prev_data` if no DATA arrived this wave.
56    /// Returns [`NO_HANDLE`] only when the dep has never emitted.
57    #[must_use]
58    pub fn latest(&self) -> HandleId {
59        self.data.last().copied().unwrap_or(self.prev_data)
60    }
61
62    /// Convenience: is this dep in sentinel state (never emitted DATA)?
63    #[must_use]
64    pub fn is_sentinel(&self) -> bool {
65        self.prev_data == NO_HANDLE && self.data.is_empty()
66    }
67}
68
69/// Lifecycle trigger discriminator for [`BindingBoundary::cleanup_for`].
70///
71/// Slice E2 (R2.4.5 / Lock 4.A / Lock 4.A′) — the named-hook cleanup spec
72/// returned by user fns has three independent slots; Core fires each slot
73/// at its own lifecycle moment via `cleanup_for(node_id, CleanupTrigger)`.
74///
75/// All three triggers fire **lock-released** per Slice E D045 handshake
76/// discipline. The binding is responsible for resolving the trigger to its
77/// stored cleanup closure (typically a `node_id → NodeFnCleanup` map) and
78/// invoking the matching slot.
79#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
80pub enum CleanupTrigger {
81    /// **R2.4.5 `onRerun`** — fires before the next fn run within the same
82    /// activation cycle. Core fires this in `fire_regular` between the
83    /// lock-held dep-snapshot phase and the lock-released `invoke_fn` call,
84    /// gated on `has_fired_once == true` (so first-fire never sees an
85    /// `onRerun`). The user closure receives no arguments and is expected
86    /// to release fn-local resources from the previous run before the
87    /// fresh `invoke_fn` allocates new ones.
88    OnRerun,
89    /// **R2.4.5 `onDeactivation`** — fires when subscriber count drops to
90    /// zero. Core fires this from `Subscription::Drop` (alongside the
91    /// existing [`BindingBoundary::producer_deactivate`] call), gated on
92    /// `has_fired_once == true`. Order: cleanup-first (`OnDeactivation`)
93    /// then `producer_deactivate`, because cleanup may release handles the
94    /// producer subscription owns (D056). Per D059, bindings SHOULD clear
95    /// `current_cleanup` on this trigger — the next subscribe + first-fire
96    /// will re-register a fresh closure.
97    OnDeactivation,
98    /// **R2.4.5 `onInvalidate`** — fires when an `[[INVALIDATE]]` arrives at
99    /// the node and clears its cache. Per **R1.3.9.b** strict reading
100    /// (D057): fires **at most once per wave per node**, regardless of
101    /// fan-in shape. Per **R1.3.9.c**: never fires when the node's cache
102    /// is the never-populated sentinel (a node that has not yet emitted
103    /// has nothing to clean up). Per **D058**: fires at cache-clear time,
104    /// not at wire-delivery time — pause buffering doesn't defer the
105    /// hook. **Per D061**: when the wave is panic-discarded mid-flight,
106    /// queued OnInvalidate hooks are dropped silently (the cleanup never
107    /// fires for the panicked wave); see [`BindingBoundary::cleanup_for`]
108    /// rustdoc for the panic-discard guarantee gap and the bindings'
109    /// idempotent-cleanup recommendation.
110    OnInvalidate,
111}
112
113/// A single emission within a [`FnResult::Batch`] — one element of an
114/// `actions.down(msgs)` call. Processed in sequence within the same wave.
115#[derive(Clone, Debug)]
116#[must_use = "FnEmission may contain handles that must be processed or released"]
117pub enum FnEmission {
118    /// DATA payload. Processed via `commit_emission` — sets cache, queues
119    /// Dirty (if not already dirty per R1.3.1.a) + Data, propagates to
120    /// children. No equals substitution in Batch context (R1.3.2.d /
121    /// R1.3.3.c: multi-message waves pass through verbatim).
122    Data(HandleId),
123    /// COMPLETE terminal. Cascades per R1.3.4 / Lock 2.B.
124    Complete,
125    /// ERROR terminal with error-value handle. Cascades per R1.3.4;
126    /// ERROR dominates COMPLETE (Lock 2.B).
127    Error(HandleId),
128}
129
130/// What the binding side returns when the Core invokes a fn via
131/// [`BindingBoundary::invoke_fn`].
132///
133/// Models the three emission modes from the canonical spec:
134/// - `FnResult::Data` — single DATA, equals substitution applies (R1.3.2).
135///   Maps to `actions.emit(v)` in sugar constructors.
136/// - `FnResult::Batch` — multi-message wave, no equals substitution
137///   (R1.3.2.d / R1.3.3.c). Maps to `actions.down(msgs)`.
138/// - `FnResult::Noop` — no emission. RESOLVED if node was DIRTY.
139///
140/// Per R2.4.5, the fn return value in the canonical spec is cleanup hooks
141/// only — all emission is explicit via actions. The Rust Core folds the
142/// emission into `FnResult` as a pragmatic simplification; the binding
143/// layer maps between the two representations.
144#[derive(Clone, Debug)]
145pub enum FnResult {
146    /// fn produced a single value. The Core treats this as outgoing DATA —
147    /// equals-substitution against the cache may rewrite it to RESOLVED
148    /// on the wire (R1.3.2).
149    Data {
150        handle: HandleId,
151        /// For dynamic nodes only: the dep indices fn actually read this run.
152        /// Static derived nodes pass `None`. See dynamic-node semantics in the
153        /// canonical spec §2.8 / Lock 2.B.
154        tracked: Option<Vec<usize>>,
155    },
156
157    /// fn ran but produced no emission this wave. The Core sends RESOLVED
158    /// to subscribers if the node was already DIRTY this wave; otherwise no
159    /// outgoing message.
160    Noop {
161        /// Same as `Data::tracked` — dynamic nodes only.
162        tracked: Option<Vec<usize>>,
163    },
164
165    /// Multi-message wave — models `actions.down(msgs)`. Emissions are
166    /// processed in sequence within the same wave. No equals substitution
167    /// on any Data emission (R1.3.2.d: substitution only on single-DATA
168    /// waves; R1.3.3.c: multi-DATA passes verbatim). DIRTY auto-prefix
169    /// only on first Data per R1.3.1.a.
170    Batch {
171        emissions: SmallVec<[FnEmission; 2]>,
172        /// Same as `Data::tracked` — dynamic nodes only.
173        tracked: Option<Vec<usize>>,
174    },
175}
176
177/// The FFI surface: every Core → user-code crossing goes through one of these
178/// three methods.
179///
180/// # Thread safety
181///
182/// `Send + Sync` because the Core dispatcher is sync but may be called from
183/// multiple binding threads (e.g. multiple Node Workers sharing one Core via
184/// `Arc<Core>`). Implementors must serialize access to the value registry
185/// internally if needed. Free-threaded Python parity is the target — see
186/// `~/src/graphrefly-py` `compat/asyncio.py` for the current shape that
187/// will simplify dramatically once this trait is the substrate.
188pub trait BindingBoundary: Send + Sync {
189    /// Invoke a user function. The Core knows the fn's identity (`fn_id`) and
190    /// the current dep batch data; the binding side dereferences handles,
191    /// runs the fn, registers the output, and returns the new handle.
192    ///
193    /// `dep_data` carries per-dep batch arrays (R1.3.6.b coalescing): outside
194    /// `batch()` scope each dep has at most 1 DATA handle; inside `batch()`,
195    /// K consecutive emits on the same source produce K entries per dep.
196    /// The binding side bulk-dereferences all handles, calls user code, and
197    /// returns the result — one FFI call per fn fire regardless of dep count
198    /// or batch depth.
199    ///
200    /// Errors thrown by user code are reported by returning a [`FnResult::Data`]
201    /// with a handle that resolves to an error value; the Core then forwards
202    /// `[ERROR, handle]` per R1.2.5. (Or the binding side surfaces them via a
203    /// separate channel — exact error-propagation discipline is binding-side.)
204    fn invoke_fn(&self, node_id: NodeId, fn_id: FnId, dep_data: &[DepBatch]) -> FnResult;
205
206    /// Invoke a user function **with the owner-side full `Core` facade in
207    /// hand** (D246 rule 5 / D245 Option A — the producer-build facade
208    /// hand-off, folding D245).
209    ///
210    /// Core calls this — not the parameterless [`Self::invoke_fn`] — for
211    /// every fn-fire dispatch ([`crate::batch`] `fire_regular` Phase 2),
212    /// passing `self as &dyn CoreFull` (the one object-safe
213    /// mutation+inspection+serialize+mailbox facade, [`crate::node::CoreFull`]).
214    /// A binding whose `invoke_fn` builds a *producer* (the operators test
215    /// harness, the napi bench binding) needs a real Core surface during
216    /// the build — `subscribe`/`register_*` for upstream wiring, not just
217    /// the emit-only mailbox. D231 located producer build/teardown
218    /// owner-side with `&Core`; D245 made that concrete at the trait
219    /// surface so the build path receives a Core facade explicitly
220    /// **without** a thread-local / `Core` clone / stored back-reference
221    /// (all β-invalid under the actor model — `Core` is move-only).
222    ///
223    /// The default forwards to [`Self::invoke_fn`] (ignoring `core`), so
224    /// every binding that does **not** build producers is completely
225    /// unaffected — core/structures/storage/graph test harnesses and the
226    /// napi non-producer paths keep their existing parameterless
227    /// `invoke_fn` semantics with zero behavior change. Only a
228    /// producer-building binding overrides this to construct its
229    /// `ProducerCtx` from the passed `&dyn CoreFull`.
230    fn invoke_fn_with_core(
231        &self,
232        node_id: NodeId,
233        fn_id: FnId,
234        dep_data: &[DepBatch],
235        core: &dyn crate::node::CoreFull,
236    ) -> FnResult {
237        let _ = core;
238        self.invoke_fn(node_id, fn_id, dep_data)
239    }
240
241    /// Custom equals oracle. Called only when a node declares
242    /// `EqualsMode::Custom`. Identity equals (the default) is a `u64` compare
243    /// inside the Core — zero FFI per check.
244    ///
245    /// Per `H2 IdentityEqualsIsPureCore` ASSUME in
246    /// `~/src/graphrefly-ts/docs/research/handle-protocol.tla`,
247    /// the binding-side impl MUST extend identity (i.e. always treat
248    /// `a == b` as equal at the handle level). Otherwise a node could observe
249    /// its own cached value as different from itself — a fundamental violation.
250    fn custom_equals(&self, equals_handle: FnId, a: HandleId, b: HandleId) -> bool;
251
252    /// Decrement the refcount on `handle`. Called when the Core no longer
253    /// holds `handle` in any cache slot or message buffer. The binding side
254    /// drops the underlying value when its refcount reaches zero.
255    ///
256    /// Implementing this as a no-op is safe during prototyping; it matters
257    /// for memory pressure under sustained load. The TS prototype's
258    /// `bindings.ts` uses this to drive a `Map<HandleId, { value, refcount }>`.
259    ///
260    /// # Leaf-operation contract (/qa F2/M1, 2026-05-10) — HARD requirement
261    ///
262    /// Implementations MUST NOT re-enter Core from `release_handle`. The
263    /// Core's lock-held release paths (`Drop for CoreState`,
264    /// `Core::reset_for_fresh_lifecycle` Phase 3 / 3b / 5,
265    /// `OperatorScratch::release_handles` callers) invoke
266    /// `release_handle` while the state mutex is held; re-entering Core
267    /// (via `emit` / `subscribe` / `register` / nested `release_handle`
268    /// on a different handle that triggers a final-Drop hook into Core,
269    /// etc.) deadlocks against that lock.
270    ///
271    /// "Re-entry into Core" here means: calling any method on the same
272    /// `Core` instance (or any `Core` that shares state via `Arc::clone`
273    /// of the inner state mutex). Independent Cores are fine.
274    ///
275    /// Safe operations inside `release_handle`:
276    /// - Pure value-registry bookkeeping (decrement refcount, drop the
277    ///   underlying `T` when count reaches zero).
278    /// - Logging / metrics that don't re-enter Core.
279    /// - Calling other binding methods that themselves honor the
280    ///   leaf-op contract (e.g., a binding-internal mutex).
281    ///
282    /// Forbidden operations inside `release_handle`:
283    /// - Any `Core::emit` / `subscribe` / `register*` / `complete` /
284    ///   `error` / `teardown` / `invalidate` / `pause` / `resume` /
285    ///   `set_deps` / `batch` / `begin_batch`.
286    /// - Recursive `BindingBoundary::release_handle` on this binding
287    ///   that fans into Core via a binding-side final-Drop.
288    ///
289    /// Bindings that need lifecycle hooks on value-drop should buffer
290    /// the events and process them asynchronously (e.g., next tick on
291    /// the host runtime) so the Core lock is released before
292    /// re-entrance.
293    fn release_handle(&self, handle: HandleId);
294
295    /// Increment the refcount on `handle`. Called when the Core takes an
296    /// additional reference to an existing handle that the binding side
297    /// already interned — used by the pause buffer (a buffered
298    /// `Data(H)` outlives the cache slot that originally interned `H`, so
299    /// the buffer needs its own refcount share to keep `H` alive across
300    /// later cache replacements), the operator-scratch pipeline (Scan/Reduce
301    /// seed retain, Last default retain), and Phase G's D-α fresh-scratch
302    /// install.
303    ///
304    /// Default: no-op. Bindings that don't track refcounts (e.g., trivial
305    /// always-alive registries) can leave the default. Bindings that do
306    /// (TS `bindings.ts`, the test runtime, the napi-rs bench harness) must
307    /// override to bump the per-handle refcount.
308    ///
309    /// # Leaf-operation contract — HARD requirement
310    ///
311    /// Same leaf-op contract as [`Self::release_handle`]: implementations
312    /// MUST NOT re-enter Core from `retain_handle`. Core call sites
313    /// (notably `make_op_scratch_with_binding` called from Phase G's
314    /// lock-held window) assume the retain is a pure refcount bump.
315    fn retain_handle(&self, _handle: HandleId) {}
316
317    // -----------------------------------------------------------------
318    // Operator FFI surface (Slice C-1, D009). Bulk projection methods
319    // for the built-in operator dispatch path. Default impls panic so
320    // bindings that don't ship operators (e.g., minimal test bindings
321    // that only exercise raw fn-fire) don't pay the registry cost; the
322    // dispatch path only routes here when an `Operator(_)` node fires,
323    // so a binding that doesn't register operator nodes never reaches
324    // these defaults.
325    //
326    // Each method returns the per-input result. Caller (Core) owns the
327    // resulting handles' retains — the binding must `retain_handle`-bump
328    // any handles it returns that share an existing registry slot.
329    // -----------------------------------------------------------------
330
331    /// `OperatorOp::Map` — element-wise transform. For each input handle,
332    /// the binding dereferences to `T`, calls the user `Fn(T) -> R`, and
333    /// returns the new handle. Output length equals input length.
334    fn project_each(&self, _fn_id: FnId, _inputs: &[HandleId]) -> SmallVec<[HandleId; 1]> {
335        unimplemented!("project_each: this binding does not support operators (D009)")
336    }
337
338    /// `OperatorOp::Filter` — element-wise predicate. For each input
339    /// handle, the binding dereferences to `T`, calls the user
340    /// `Fn(T) -> bool`, and returns the boolean. Output length equals
341    /// input length.
342    fn predicate_each(&self, _fn_id: FnId, _inputs: &[HandleId]) -> SmallVec<[bool; 4]> {
343        unimplemented!("predicate_each: this binding does not support operators (D009)")
344    }
345
346    /// `OperatorOp::Scan` / `OperatorOp::Reduce` — left-fold. The binding
347    /// dereferences `acc` to `R` and each input to `T`, runs
348    /// `Fn(R, T) -> R` for each input feeding the previous result forward,
349    /// and returns each intermediate `R` as a fresh handle. Output length
350    /// equals input length (Scan emits each entry; Reduce uses only the
351    /// last). The starting `acc` is owned by Core; the binding must NOT
352    /// release it.
353    fn fold_each(
354        &self,
355        _fn_id: FnId,
356        _acc: HandleId,
357        _inputs: &[HandleId],
358    ) -> SmallVec<[HandleId; 1]> {
359        unimplemented!("fold_each: this binding does not support operators (D009)")
360    }
361
362    /// `OperatorOp::Pairwise` — pack `(prev, current)` into a tuple value.
363    /// Called once per pair (Core iterates and updates `prev` between
364    /// calls). Returns the binding-side handle for the new tuple value.
365    fn pairwise_pack(&self, _fn_id: FnId, _prev: HandleId, _current: HandleId) -> HandleId {
366        unimplemented!("pairwise_pack: this binding does not support operators (D009)")
367    }
368
369    /// `OperatorOp::Combine` / `OperatorOp::WithLatestFrom` — pack N
370    /// handles into a single tuple/array handle (Slice C-2, D020). The
371    /// binding dereferences each input handle to `T`, constructs a tuple
372    /// value (e.g., `[T0, T1, ..., Tn]`), and returns the new handle.
373    /// The returned handle has a pre-bumped retain (caller takes ownership
374    /// without additional `retain_handle` call).
375    fn pack_tuple(&self, _fn_id: FnId, _handles: &[HandleId]) -> HandleId {
376        unimplemented!("pack_tuple: this binding does not support combinator operators (D020)")
377    }
378
379    /// Intern a [`NodeId`] as a value handle. Used by windowing operators
380    /// (`window`, `window_count`) to emit inner sub-node identities as
381    /// DATA payloads. The binding side stores the node id as a value in
382    /// its value registry and returns the corresponding [`HandleId`].
383    /// The returned handle has a pre-bumped retain (caller takes ownership).
384    ///
385    /// Default panics — bindings that ship window operators MUST override.
386    fn intern_node(&self, _node_id: NodeId) -> HandleId {
387        unimplemented!("intern_node: this binding does not support window operators")
388    }
389
390    // -----------------------------------------------------------------
391    // Producer lifecycle (Slice D, D031, D035). Producers are nodes
392    // with no deps + a fn — fn fires once on first subscribe and may
393    // call `Core::subscribe` from inside its body to wire up upstream
394    // sources (the zip / concat / race / takeUntil pattern).
395    //
396    // The binding maintains its own per-producer state (subscription
397    // handles, captured closure state) outside Core. When the LAST
398    // subscriber unsubscribes from a producer, Core invokes
399    // `producer_deactivate(node_id)` so the binding can drop that
400    // state — which transitively drops the producer's upstream
401    // subscriptions via `Subscription::Drop`.
402    //
403    // The hook fires lock-released (after the state lock is dropped),
404    // so the binding's deactivation impl may re-enter Core if needed
405    // (e.g., calling `release_handle` on captured handle shares).
406    //
407    // Symmetric with FnCtx: Core hands the binding a lifecycle signal
408    // and lets the binding shape its ergonomic surface (the
409    // `ProducerCtx` helper in `graphrefly-operators::producer` is one
410    // such shape; bindings may roll their own).
411    // -----------------------------------------------------------------
412
413    /// Called when a producer node loses its last subscriber. The binding
414    /// should drop any per-node state for `node_id` — captured closures,
415    /// recorded upstream `(NodeId, SubscriptionId)` pairs, etc. Default
416    /// no-op for bindings that don't ship producers.
417    ///
418    /// Fires lock-released; re-entrance into Core is permitted.
419    ///
420    /// S2b / D229: core-level RAII `Subscription` is retired (D223/D225 —
421    /// a parameterless `Drop` cannot reach a relocating owned `Core`). The
422    /// binding therefore records upstream subs as `(NodeId,
423    /// SubscriptionId)` and unsubscribes them HERE via `unsub` — a
424    /// `Core::unsubscribe`-capable closure the owner-driven `unsubscribe`
425    /// chain passes in (it holds the `&Core` already, exactly the
426    /// synchronous owner context D225 requires; no `Weak<C>`, no
427    /// `unsafe`, no parameterless-`Drop`). Calling `unsub(up_node,
428    /// up_sub)` is behaviour-identical to the old `Subscription::Drop`
429    /// (it runs the same deregister + Phase-G chain, and re-entrant
430    /// producer cascades work because the call is lock-released).
431    fn producer_deactivate(&self, _node_id: NodeId, _unsub: &dyn Fn(NodeId, SubscriptionId)) {}
432
433    /// R1.3.8.c / Lock 6.A — synthesize an ERROR payload when a paused
434    /// node's pause buffer overflows the configured cap. Called once per
435    /// overflow event (the first drop of a pause cycle); the returned
436    /// handle becomes the payload of `Message::Error` that Core then
437    /// emits via the standard terminal cascade.
438    ///
439    /// Bindings that ship their own diagnostic shape (e.g. structured
440    /// `{ code, nodeId, dropped, configuredMax, lockHeldDurationMs }`
441    /// JSON for the JS / Python sides) override this to intern such a
442    /// value and return its handle.
443    ///
444    /// **Default returns `None`** — Rust core falls back to the silent
445    /// drop + `ResumeReport.dropped` path. This preserves backward
446    /// compatibility for bindings that haven't yet wired up R1.3.8.c.
447    /// New bindings SHOULD implement this method to satisfy the
448    /// canonical-spec invariant.
449    ///
450    /// **Returning `None` consumes the overflow event for the cycle**
451    /// (QA A9, 2026-05-07): the per-pause-cycle `overflow_reported` flag
452    /// is set BEFORE this hook is called, so a `None` return doesn't
453    /// re-attempt synthesis on subsequent overflows in the same cycle.
454    /// If the binding is going to dynamically wire up the hook
455    /// (configuration-driven), do so before any pause cycle that may
456    /// overflow.
457    ///
458    /// Fires lock-released. The binding may re-enter Core (typical
459    /// implementation just calls `intern_value` and returns).
460    ///
461    /// `lock_held_duration_ms` is the wall-clock-monotonic duration in
462    /// milliseconds since the node first transitioned `Active → Paused`
463    /// at the start of this pause cycle; it gives consumers a sense of
464    /// how long a leaked controller held the lockset before overflow.
465    /// Sub-millisecond durations truncate to `0` (QA A8, 2026-05-07).
466    fn synthesize_pause_overflow_error(
467        &self,
468        _node_id: NodeId,
469        _dropped_count: u32,
470        _configured_max: usize,
471        _lock_held_duration_ms: u64,
472    ) -> Option<HandleId> {
473        None
474    }
475
476    // -----------------------------------------------------------------
477    // Cleanup-hook lifecycle (Slice E2 — R2.4.5 / R2.4.6 / Lock 4.A / 4.A′
478    // / Lock 6.D). Decisions: D054 (lifecycle-trigger hooks; binding owns
479    // ctx state), D055 (binding-side `Mutex<HashMap<NodeId, NodeCtxState>>`,
480    // wipe only on resubscribable terminal reset), D056 (cleanup-first
481    // before producer_deactivate), D057 (strict per-wave-per-node dedup),
482    // D058 (fire at cache-clear time), D059 (one-shot current_cleanup on
483    // OnDeactivation), D060 (binding-side panic isolation, drain
484    // iterates-don't-short-circuit), D061 (panic-discard wave drops
485    // deferred queue silently). Full design lives in
486    // `~/src/graphrefly-ts/archive/docs/SESSION-rust-port-fn-ctx-cleanup.md`.
487    //
488    // Bindings opt in by overriding `cleanup_for` and `wipe_ctx`. Default
489    // no-ops keep non-cleanup-aware bindings (e.g. minimal test bindings,
490    // bench harnesses) compiling unchanged.
491    // -----------------------------------------------------------------
492
493    /// Fire a registered user-cleanup hook for `node_id` at the lifecycle
494    /// moment indicated by `trigger`.
495    ///
496    /// # Binding-side contract
497    ///
498    /// Per D055, bindings own a `Mutex<HashMap<NodeId, NodeCtxState>>` where
499    /// `NodeCtxState = { store, current_cleanup }`. On each `invoke_fn` the
500    /// binding overwrites `current_cleanup` with whatever cleanup spec the
501    /// user fn returned. When Core later fires `cleanup_for(node, trigger)`,
502    /// the binding's impl looks up `current_cleanup.<trigger_slot>` and
503    /// invokes it if present. Absent slots are no-ops.
504    ///
505    /// **Lock discipline.** Bindings MUST release their `node_ctx` lock
506    /// before invoking the user closure (clone the closure handle out of
507    /// the map, drop the lock, fire). Holding the binding-side lock
508    /// across the user closure deadlocks if the user re-enters the
509    /// binding's high-level API from inside the cleanup.
510    ///
511    /// **Re-entrance into Core (D045 / D060).** Permitted: `release_handle`,
512    /// `Core::up(other_node, Pause/Resume/Invalidate/Teardown)`, `Core::emit`
513    /// for unrelated nodes, `Core::subscribe` / drop a `Subscription`. Not
514    /// permitted (undefined behavior): `Core::emit(self_node, ...)` from
515    /// inside `OnRerun` (creates a fresh emit during the wave's pre-fire
516    /// window); `Core::subscribe(self_node)` from inside `OnDeactivation`
517    /// (self-resubscribe race during deactivation).
518    ///
519    /// **Panic isolation (D060).** Core stays panic-naive about user code
520    /// — bindings SHOULD wrap user-closure invocations in `catch_unwind`
521    /// and surface failures via the host language's idiom (JS exception
522    /// → console.error, Python panic → warning, Rust panic → log + propagate
523    /// per binding policy). Core's deferred-drain for `OnInvalidate` wraps
524    /// each `cleanup_for` call in `catch_unwind` itself to prevent a single
525    /// panic from short-circuiting the per-wave drain (all queued cleanup
526    /// attempts run; the last panic re-raises after the drain completes).
527    /// `OnRerun` and `OnDeactivation` fire inline lock-released — a
528    /// panicking `cleanup_for` propagates out of `fire_regular` Phase 1.5
529    /// or `Subscription::Drop` respectively (Drop guarantees apply: state
530    /// lock already released).
531    ///
532    /// **Panic-discard guarantee gap (D061).** When a wave is panic-discarded
533    /// (an `invoke_fn` panics mid-wave), the queued `OnInvalidate` cleanup
534    /// hooks are dropped silently — the cascade's recorded cache-clears
535    /// never fire their cleanups. External-resource cleanup (file handles,
536    /// network sockets, external transactions) attached to `OnInvalidate`
537    /// MUST be idempotent at process exit / next successful invalidate
538    /// cycle. `OnRerun` panic-discard is moot (panic in `OnRerun` aborts
539    /// the wave's `fire_regular` before it can corrupt state). `OnDeactivation`
540    /// panic-discard is moot (Drop is invoked during stack unwinding;
541    /// double-panic during drop aborts per `std::process::abort` semantics).
542    ///
543    /// **Per-trigger lifecycle.** See [`CleanupTrigger`] for per-variant
544    /// firing rules. After each trigger fires, the binding decides whether
545    /// to clear `current_cleanup`:
546    /// - `OnRerun`: do NOT clear; the next `invoke_fn` will overwrite.
547    /// - `OnInvalidate`: do NOT clear; multiple INVALIDATEs across waves
548    ///   can re-fire the same closure.
549    /// - `OnDeactivation`: clear (D059) — one-shot per activation cycle;
550    ///   `store` persists separately per R2.4.6.
551    ///
552    /// Default no-op so bindings without cleanup-hook support compile
553    /// unchanged.
554    fn cleanup_for(&self, _node_id: NodeId, _trigger: CleanupTrigger) {}
555
556    // -----------------------------------------------------------------
557    // Snapshot serialization (M4.E1 — D166). The Core operates on
558    // opaque HandleId integers; snapshot persistence needs to cross
559    // the cleaving plane to serialize/deserialize user values as JSON.
560    // Default impls return None / panic so bindings without snapshot
561    // support compile unchanged.
562    // -----------------------------------------------------------------
563
564    /// Serialize a handle's value to JSON for snapshot persistence.
565    /// Returns `None` if the handle is unknown or unresolvable.
566    ///
567    /// Called by `Graph::snapshot()` for each node's cache handle.
568    /// The binding dereferences `handle` to its underlying `T` and
569    /// produces a `serde_json::Value` that will survive serialization
570    /// round-trips (i.e., JSON-safe: no functions, no circular refs).
571    ///
572    /// Default returns `None` — bindings that don't support snapshots
573    /// get `value: null` in the snapshot output.
574    fn serialize_handle(&self, _handle: HandleId) -> Option<serde_json::Value> {
575        None
576    }
577
578    /// Deserialize a JSON value back into a handle for snapshot restore.
579    /// The binding interns the value and returns its `HandleId`.
580    ///
581    /// Called by `Graph::restore()` / `Graph::from_snapshot()` for each
582    /// node's serialized value.
583    ///
584    /// Default panics — bindings that restore snapshots MUST override.
585    fn deserialize_value(&self, _value: serde_json::Value) -> HandleId {
586        unimplemented!("deserialize_value: this binding does not support snapshot restore (D166)")
587    }
588
589    // -----------------------------------------------------------------
590    // Control-operator FFI surface (tap, rescue). Side-effect and
591    // error-recovery callbacks invoked by control operators in
592    // `graphrefly-operators::control`. Default impls panic so bindings
593    // that don't ship control operators compile unchanged.
594    // -----------------------------------------------------------------
595
596    /// Side-effect tap: invoke a user callback with a DATA handle for
597    /// observation purposes. The callback must NOT produce a return
598    /// value or modify the handle's refcount — it is purely for
599    /// side-effects (logging, metrics, debugging).
600    ///
601    /// Called by `tap` and `on_first_data` operators on each (or first)
602    /// DATA emission.
603    fn invoke_tap_fn(&self, _fn_id: FnId, _handle: HandleId) {
604        unimplemented!("invoke_tap_fn: this binding does not support control operators")
605    }
606
607    /// Side-effect tap on ERROR: invoke a user callback with the error
608    /// handle. Purely for observation — must not modify refcounts.
609    fn invoke_tap_error_fn(&self, _fn_id: FnId, _handle: HandleId) {
610        unimplemented!("invoke_tap_error_fn: this binding does not support control operators")
611    }
612
613    /// Side-effect tap on COMPLETE: invoke a user callback.
614    fn invoke_tap_complete_fn(&self, _fn_id: FnId) {
615        unimplemented!("invoke_tap_complete_fn: this binding does not support control operators")
616    }
617
618    /// Error recovery: invoke a user callback with the error handle.
619    /// Returns `Ok(recovered_handle)` if recovery succeeded (the
620    /// binding interns the recovered value and returns its handle with
621    /// a pre-bumped retain). Returns `Err(())` if recovery failed and
622    /// the original error should propagate.
623    #[allow(clippy::result_unit_err)]
624    fn invoke_rescue_fn(&self, _fn_id: FnId, _handle: HandleId) -> Result<HandleId, ()> {
625        unimplemented!("invoke_rescue_fn: this binding does not support control operators")
626    }
627
628    /// Stratify classifier — invoke a user predicate with BOTH the
629    /// latest rules handle AND the current value handle. Returns
630    /// `true` if the value belongs to this branch.
631    ///
632    /// Called by `graphrefly_operators::stratify_branch` on each
633    /// source DATA. The binding-side closure (registered via
634    /// `OperatorBinding::register_stratify_classifier`) typically
635    /// dereferences both handles, looks up its branch's rule by name
636    /// inside the rules array, and runs the rule's `classify(value)`.
637    /// Returning `false` for "rule not found" or "classifier threw"
638    /// matches TS stratify semantics.
639    ///
640    /// Default panics — bindings that ship the stratify operator MUST
641    /// override.
642    fn invoke_stratify_classifier_fn(
643        &self,
644        _fn_id: FnId,
645        _rules_handle: HandleId,
646        _value_handle: HandleId,
647    ) -> bool {
648        unimplemented!("invoke_stratify_classifier_fn: this binding does not support the stratify operator (D199)")
649    }
650
651    /// Wipe the binding-side ctx state for `node_id`.
652    ///
653    /// Called by Core ONLY on resubscribable terminal reset, per **R2.4.6**:
654    /// `ctx.store` is "wiped automatically: on resubscribable terminal
655    /// reset (when a `resubscribable: true` node hits `COMPLETE`/`ERROR`
656    /// and is later resubscribed)". Bindings drop their `NodeCtxState`
657    /// entry for `node_id`, releasing both `store` and any residual
658    /// `current_cleanup`.
659    ///
660    /// Default deactivation does NOT trigger wipe — `store` persists
661    /// across deactivation/reactivation cycles by spec design (mirrored
662    /// here to match canonical; current TS impl wipes on deactivation
663    /// per docstring at `node.ts:189-190` and is on the Phase 13.6.B
664    /// migration list per canonical-spec §11 item 3).
665    ///
666    /// Fires lock-released. Re-entrance into Core is permitted; typical
667    /// implementations just call `node_ctx.lock().remove(&node_id)`.
668    /// Default no-op.
669    fn wipe_ctx(&self, _node_id: NodeId) {}
670}
671
672#[cfg(test)]
673mod tests {
674    use super::*;
675    use crate::handle::{FnId, HandleId, NodeId};
676    use std::sync::atomic::{AtomicU64, Ordering};
677
678    /// Test double mirroring `bindings.ts` `BindingBoundary` test patterns —
679    /// counts FFI crossings per method to verify the cleaving plane's
680    /// "zero FFI on identity-equals path" claim experimentally.
681    #[allow(clippy::struct_field_names)]
682    struct TestBinding {
683        invoke_count: AtomicU64,
684        equals_count: AtomicU64,
685        release_count: AtomicU64,
686    }
687
688    impl TestBinding {
689        fn new() -> Self {
690            Self {
691                invoke_count: AtomicU64::new(0),
692                equals_count: AtomicU64::new(0),
693                release_count: AtomicU64::new(0),
694            }
695        }
696    }
697
698    impl BindingBoundary for TestBinding {
699        fn invoke_fn(&self, _node_id: NodeId, _fn_id: FnId, dep_data: &[DepBatch]) -> FnResult {
700            self.invoke_count.fetch_add(1, Ordering::SeqCst);
701            // Echo first dep's latest handle as result; not realistic but exercises the path.
702            let handle = dep_data.first().map_or(HandleId::new(99), DepBatch::latest);
703            FnResult::Data {
704                handle,
705                tracked: None,
706            }
707        }
708
709        fn custom_equals(&self, _equals_handle: FnId, a: HandleId, b: HandleId) -> bool {
710            self.equals_count.fetch_add(1, Ordering::SeqCst);
711            a == b
712        }
713
714        fn release_handle(&self, _handle: HandleId) {
715            self.release_count.fetch_add(1, Ordering::SeqCst);
716        }
717    }
718
719    #[test]
720    fn boundary_calls_route_correctly() {
721        let b = TestBinding::new();
722        let dep = DepBatch {
723            data: smallvec::smallvec![HandleId::new(7)],
724            prev_data: NO_HANDLE,
725            involved: true,
726        };
727        let result = b.invoke_fn(NodeId::new(1), FnId::new(2), &[dep]);
728        match result {
729            FnResult::Data { handle, .. } => assert_eq!(handle, HandleId::new(7)),
730            FnResult::Noop { .. } | FnResult::Batch { .. } => panic!("expected Data variant"),
731        }
732        assert!(b.custom_equals(FnId::new(3), HandleId::new(7), HandleId::new(7)));
733        assert!(!b.custom_equals(FnId::new(3), HandleId::new(7), HandleId::new(8)));
734        b.release_handle(HandleId::new(7));
735
736        assert_eq!(b.invoke_count.load(Ordering::SeqCst), 1);
737        assert_eq!(b.equals_count.load(Ordering::SeqCst), 2);
738        assert_eq!(b.release_count.load(Ordering::SeqCst), 1);
739    }
740
741    #[test]
742    fn binding_is_send_and_sync() {
743        // Compile-time check: BindingBoundary impls must be Send + Sync.
744        // If TestBinding ever gains a !Send field, this fails to compile.
745        fn assert_send_sync<T: Send + Sync>() {}
746        // dyn-trait variant is the production shape (Core holds Arc<dyn BindingBoundary>).
747        fn assert_dyn_send_sync<T: ?Sized + Send + Sync>() {}
748        assert_send_sync::<TestBinding>();
749        assert_dyn_send_sync::<dyn BindingBoundary>();
750    }
751}