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