Skip to main content

graphrefly/
operators.rs

1//! Free-standing operator factories (D6/D24/D40).
2//!
3//! Operators are graph-layer node sugar, not verbs and not conformance parity.
4//! Each factory is a Rust-native ctx-body plus the real factory name used by
5//! `describe`; graph-bound construction goes through `Graph::init_node`.
6
7use std::marker::PhantomData;
8use std::rc::Rc;
9
10use crate::ctx::{Ctx, DepTerminal};
11use crate::dispatcher::Dispatcher;
12use crate::node::{Core, GraphArena, Node, NodeOpts, Pausable};
13use crate::protocol::{AnyValue, Message};
14
15/// A free-standing operator/source definition.
16pub struct Operator<T> {
17    /// `factory` field for factory.
18    pub factory: &'static str,
19    /// `body` field for body.
20    pub body: Rc<dyn Fn(&Ctx)>,
21    /// `opts` field for opts.
22    pub opts: NodeOpts,
23    _t: PhantomData<fn() -> T>,
24}
25
26impl<T> Clone for Operator<T> {
27    fn clone(&self) -> Self {
28        Self {
29            factory: self.factory,
30            body: self.body.clone(),
31            opts: self.opts.clone(),
32            _t: PhantomData,
33        }
34    }
35}
36
37impl<T> Operator<T> {
38    /// Creates or computes `new`.
39    pub fn new(factory: &'static str, body: impl Fn(&Ctx) + 'static) -> Self {
40        Self::with_opts(factory, NodeOpts::default(), body)
41    }
42
43    /// Creates or computes `with_opts`.
44    pub fn with_opts(
45        factory: &'static str,
46        mut opts: NodeOpts,
47        body: impl Fn(&Ctx) + 'static,
48    ) -> Self {
49        if opts.factory.is_none() {
50            opts.factory = Some(factory.to_owned());
51        }
52        Self {
53            factory,
54            body: Rc::new(body),
55            opts,
56            _t: PhantomData,
57        }
58    }
59}
60
61/// Instantiate an operator as a bare node; graph-bound callers should prefer
62/// `Graph::init_node` so the node is registered for inspection.
63pub fn init_node<T: 'static>(op: Operator<T>, deps: Vec<Core>, caller_opts: NodeOpts) -> Node<T> {
64    init_node_in_arena(op, &GraphArena::default(), deps, caller_opts)
65}
66
67pub(crate) fn init_node_in_arena<T: 'static>(
68    op: Operator<T>,
69    arena: &GraphArena,
70    deps: Vec<Core>,
71    caller_opts: NodeOpts,
72) -> Node<T> {
73    init_node_in_arena_with_dispatcher(
74        op,
75        arena,
76        crate::dispatcher::default_dispatcher(),
77        deps,
78        caller_opts,
79    )
80}
81
82pub(crate) fn init_node_in_arena_with_dispatcher<T: 'static>(
83    op: Operator<T>,
84    arena: &GraphArena,
85    dispatcher: Dispatcher,
86    deps: Vec<Core>,
87    caller_opts: NodeOpts,
88) -> Node<T> {
89    let mut opts = merge_node_opts(&op.opts, caller_opts);
90    if opts.factory.is_none() {
91        opts.factory = Some(op.factory.to_owned());
92    }
93    Node::derived_opts_in_arena_with_dispatcher(arena, dispatcher, deps, opts, move |ctx| {
94        (op.body)(ctx)
95    })
96}
97
98/// map: emit `fn(value)` for every upstream DATA occurrence.
99pub fn map<S: 'static, T: 'static>(f: impl Fn(&S) -> T + 'static) -> Operator<T> {
100    Operator::new("map", move |ctx| {
101        for value in ctx.batch::<S>(0) {
102            ctx.emit(f(value.as_ref()));
103        }
104    })
105}
106
107/// filter: forward values matching `pred`; skipped waves settle as undirty RESOLVED.
108pub fn filter<S: Clone + 'static>(pred: impl Fn(&S) -> bool + 'static) -> Operator<S> {
109    Operator::new("filter", move |ctx| {
110        for value in ctx.batch::<S>(0) {
111            if pred(value.as_ref()) {
112                ctx.emit((*value).clone());
113            }
114        }
115    })
116}
117
118/// scan: stateful accumulator, emitting each intermediate accumulator value.
119pub fn scan<S: 'static, T: Clone + 'static>(
120    reducer: impl Fn(T, &S) -> T + 'static,
121    seed: T,
122) -> Operator<T> {
123    Operator::new("scan", move |ctx| {
124        let mut acc = ctx
125            .state_get::<T>()
126            .map(|v| (*v).clone())
127            .unwrap_or_else(|| seed.clone());
128        for value in ctx.batch::<S>(0) {
129            acc = reducer(acc, value.as_ref());
130            ctx.emit(acc.clone());
131        }
132        ctx.state_set(acc);
133    })
134}
135
136/// take: emit the first `n` DATA values, then COMPLETE.
137pub fn take<S: Clone + 'static>(n: usize) -> Operator<S> {
138    Operator::new("take", move |ctx| {
139        if n == 0 {
140            ctx.down(vec![Message::Complete]);
141            return;
142        }
143        let mut count = ctx.state_get::<usize>().map_or(0, |v| *v);
144        if count >= n {
145            return;
146        }
147        for value in ctx.batch::<S>(0) {
148            if count >= n {
149                break;
150            }
151            count += 1;
152            let out: AnyValue = Rc::new((*value).clone());
153            if count >= n {
154                ctx.down(vec![Message::Data(out), Message::Complete]);
155            } else {
156                ctx.down(vec![Message::Data(out)]);
157            }
158        }
159        ctx.state_set(count);
160    })
161}
162
163/// distinct_until_changed: opt-in dedup at the operator layer (D49).
164pub fn distinct_until_changed<S: Clone + 'static>(
165    eq: impl Fn(&S, &S) -> bool + 'static,
166) -> Operator<S> {
167    Operator::new("distinctUntilChanged", move |ctx| {
168        let mut last = ctx.state_get::<S>().map(|v| (*v).clone());
169        for value in ctx.batch::<S>(0) {
170            if last.as_ref().is_some_and(|prev| eq(prev, value.as_ref())) {
171                continue;
172            }
173            last = Some((*value).clone());
174            ctx.emit((*value).clone());
175        }
176        if let Some(value) = last {
177            ctx.state_set(value);
178        }
179    })
180}
181
182/// merge: interleave DATA from any dep; first-run gate is partial.
183pub fn merge<T: Clone + 'static>() -> Operator<T> {
184    Operator::with_opts(
185        "merge",
186        NodeOpts {
187            partial: true,
188            ..NodeOpts::default()
189        },
190        move |ctx| {
191            for i in 0..ctx.dep_len() {
192                for value in ctx.batch::<T>(i) {
193                    ctx.emit((*value).clone());
194                }
195            }
196        },
197    )
198}
199
200/// reduce: accumulate the whole source and emit one final value on source COMPLETE.
201pub fn reduce<S: 'static, T: Clone + 'static>(
202    reducer: impl Fn(T, &S) -> T + 'static,
203    seed: T,
204) -> Operator<T> {
205    Operator::with_opts(
206        "reduce",
207        NodeOpts {
208            complete_when_deps_complete: false,
209            terminal_as_real_input: true,
210            ..NodeOpts::default()
211        },
212        move |ctx| {
213            let mut acc = ctx
214                .state_get::<T>()
215                .map(|v| (*v).clone())
216                .unwrap_or_else(|| seed.clone());
217            for value in ctx.batch::<S>(0) {
218                acc = reducer(acc, value.as_ref());
219            }
220            ctx.state_set(acc.clone());
221            if is_complete(ctx.terminal(0)) {
222                ctx.down(vec![Message::Data(Rc::new(acc)), Message::Complete]);
223            }
224        },
225    )
226}
227
228/// pairwise: emit `(previous, current)` for each consecutive DATA occurrence.
229pub fn pairwise<S: Clone + 'static>() -> Operator<(S, S)> {
230    Operator::new("pairwise", move |ctx| {
231        let mut prev = ctx.state_get::<S>().map(|v| (*v).clone());
232        for value in ctx.batch::<S>(0) {
233            if let Some(p) = prev.clone() {
234                ctx.emit((p, (*value).clone()));
235            }
236            prev = Some((*value).clone());
237        }
238        if let Some(value) = prev {
239            ctx.state_set(value);
240        }
241    })
242}
243
244/// skip: drop the first `n` DATA occurrences, then pass through.
245pub fn skip<S: Clone + 'static>(n: usize) -> Operator<S> {
246    Operator::new("skip", move |ctx| {
247        let mut count = ctx.state_get::<usize>().map_or(0, |v| *v);
248        for value in ctx.batch::<S>(0) {
249            if count < n {
250                count += 1;
251            } else {
252                ctx.emit((*value).clone());
253            }
254        }
255        ctx.state_set(count);
256    })
257}
258
259/// take_while: emit while `pred` holds; first failed value completes without emission.
260pub fn take_while<S: Clone + 'static>(pred: impl Fn(&S) -> bool + 'static) -> Operator<S> {
261    Operator::new("takeWhile", move |ctx| {
262        for value in ctx.batch::<S>(0) {
263            if pred(value.as_ref()) {
264                ctx.emit((*value).clone());
265            } else {
266                ctx.down(vec![Message::Complete]);
267                return;
268            }
269        }
270    })
271}
272
273/// first: emit the first value matching `pred` (or the first value), then COMPLETE.
274pub fn first<S: Clone + 'static>(pred: impl Fn(&S) -> bool + 'static) -> Operator<S> {
275    Operator::new("first", move |ctx| {
276        for value in ctx.batch::<S>(0) {
277            if pred(value.as_ref()) {
278                ctx.down(vec![
279                    Message::Data(Rc::new((*value).clone())),
280                    Message::Complete,
281                ]);
282                return;
283            }
284        }
285    })
286}
287
288/// first_any: emit the first value, then COMPLETE.
289pub fn first_any<S: Clone + 'static>() -> Operator<S> {
290    first(|_: &S| true)
291}
292
293/// last: emit the last matching value on source COMPLETE; no match -> bare COMPLETE.
294pub fn last<S: Clone + 'static>(pred: impl Fn(&S) -> bool + 'static) -> Operator<S> {
295    Operator::with_opts(
296        "last",
297        NodeOpts {
298            complete_when_deps_complete: false,
299            terminal_as_real_input: true,
300            ..NodeOpts::default()
301        },
302        move |ctx| {
303            for value in ctx.batch::<S>(0) {
304                if pred(value.as_ref()) {
305                    ctx.state_set((*value).clone());
306                }
307            }
308            if is_complete(ctx.terminal(0)) {
309                if let Some(value) = ctx.state_get::<S>() {
310                    ctx.down(vec![
311                        Message::Data(Rc::new((*value).clone())),
312                        Message::Complete,
313                    ]);
314                } else {
315                    ctx.down(vec![Message::Complete]);
316                }
317            }
318        },
319    )
320}
321
322/// last_any: emit the last value on source COMPLETE.
323pub fn last_any<S: Clone + 'static>() -> Operator<S> {
324    last(|_: &S| true)
325}
326
327/// find: emit the first matching value, then COMPLETE; no match -> bare COMPLETE.
328pub fn find<S: Clone + 'static>(pred: impl Fn(&S) -> bool + 'static) -> Operator<S> {
329    Operator::with_opts(
330        "find",
331        NodeOpts {
332            complete_when_deps_complete: false,
333            terminal_as_real_input: true,
334            ..NodeOpts::default()
335        },
336        move |ctx| {
337            for value in ctx.batch::<S>(0) {
338                if pred(value.as_ref()) {
339                    ctx.down(vec![
340                        Message::Data(Rc::new((*value).clone())),
341                        Message::Complete,
342                    ]);
343                    return;
344                }
345            }
346            if is_complete(ctx.terminal(0)) {
347                ctx.down(vec![Message::Complete]);
348            }
349        },
350    )
351}
352
353/// element_at: emit the zero-based `index` value, then COMPLETE; out of range -> bare COMPLETE.
354pub fn element_at<S: Clone + 'static>(index: usize) -> Operator<S> {
355    Operator::with_opts(
356        "elementAt",
357        NodeOpts {
358            complete_when_deps_complete: false,
359            terminal_as_real_input: true,
360            ..NodeOpts::default()
361        },
362        move |ctx| {
363            let mut count = ctx.state_get::<usize>().map_or(0, |v| *v);
364            for value in ctx.batch::<S>(0) {
365                if count == index {
366                    ctx.down(vec![
367                        Message::Data(Rc::new((*value).clone())),
368                        Message::Complete,
369                    ]);
370                    return;
371                }
372                count += 1;
373            }
374            ctx.state_set(count);
375            if is_complete(ctx.terminal(0)) {
376                ctx.down(vec![Message::Complete]);
377            }
378        },
379    )
380}
381
382/// tap: run a side-effect for each DATA occurrence and pass values through unchanged.
383pub fn tap<S: Clone + 'static>(f: impl Fn(&S) + 'static) -> Operator<S> {
384    Operator::new("tap", move |ctx| {
385        for value in ctx.batch::<S>(0) {
386            f(value.as_ref());
387            ctx.emit((*value).clone());
388        }
389    })
390}
391
392/// on_first_data: run a side-effect exactly once, then pass all values through.
393pub fn on_first_data<S: Clone + 'static>(f: impl Fn(&S) + 'static) -> Operator<S> {
394    on_first_data_where(f, |_| true)
395}
396
397/// on_first_data_where: run a side-effect once on the first value satisfying `where_pred`.
398pub fn on_first_data_where<S: Clone + 'static>(
399    f: impl Fn(&S) + 'static,
400    where_pred: impl Fn(&S) -> bool + 'static,
401) -> Operator<S> {
402    Operator::new("onFirstData", move |ctx| {
403        let mut fired = ctx.state_get::<bool>().is_some_and(|v| *v);
404        for value in ctx.batch::<S>(0) {
405            if !fired && where_pred(value.as_ref()) {
406                fired = true;
407                f(value.as_ref());
408            }
409            ctx.emit((*value).clone());
410        }
411        ctx.state_set(fired);
412    })
413}
414
415/// tap_first: alias-shaped Rust helper for [`on_first_data`].
416pub fn tap_first<S: Clone + 'static>(f: impl Fn(&S) + 'static) -> Operator<S> {
417    on_first_data(f)
418}
419
420#[derive(Clone)]
421struct SettleState<S> {
422    last: Option<S>,
423    quiet: usize,
424    waves: usize,
425    done: bool,
426}
427
428/// settle_by: forward DATA and COMPLETE after `quiet_waves` quiet waves or `max_waves`.
429pub fn settle_by<S: Clone + 'static>(
430    quiet_waves: usize,
431    max_waves: Option<usize>,
432    equals: impl Fn(&S, &S) -> bool + 'static,
433) -> Operator<S> {
434    assert!(quiet_waves > 0, "settle: quiet_waves must be positive");
435    if let Some(max) = max_waves {
436        assert!(max > 0, "settle: max_waves must be positive when set");
437    }
438    Operator::new("settle", move |ctx| {
439        let mut st = ctx
440            .state_get::<SettleState<S>>()
441            .map(|v| (*v).clone())
442            .unwrap_or(SettleState {
443                last: None,
444                quiet: 0,
445                waves: 0,
446                done: false,
447            });
448        if st.done {
449            return;
450        }
451        st.waves += 1;
452        let mut saw_change = false;
453        for value in ctx.batch::<S>(0) {
454            let next = (*value).clone();
455            if st.last.as_ref().is_none_or(|prev| !equals(prev, &next)) {
456                saw_change = true;
457            }
458            st.last = Some(next.clone());
459            ctx.emit(next);
460        }
461        st.quiet = if saw_change { 0 } else { st.quiet + 1 };
462        let settled = st.last.is_some() && st.quiet >= quiet_waves;
463        let exhausted = max_waves.is_some_and(|max| st.waves >= max);
464        if settled || exhausted {
465            st.done = true;
466            ctx.state_set(st);
467            ctx.down(vec![Message::Complete]);
468        } else {
469            ctx.state_set(st);
470        }
471    })
472}
473
474/// settle: [`settle_by`] using `PartialEq`.
475pub fn settle<S: Clone + PartialEq + 'static>(
476    quiet_waves: usize,
477    max_waves: Option<usize>,
478) -> Operator<S> {
479    settle_by(quiet_waves, max_waves, |a, b| a == b)
480}
481
482/// rescue: absorb upstream ERROR and replace it with `recover(error_message)`.
483pub fn rescue<S: Clone + 'static>(recover: impl Fn(&str) -> S + 'static) -> Operator<S> {
484    Operator::with_opts(
485        "rescue",
486        NodeOpts {
487            error_when_deps_error: false,
488            complete_when_deps_complete: false,
489            terminal_as_real_input: true,
490            ..NodeOpts::default()
491        },
492        move |ctx| {
493            for value in ctx.batch::<S>(0) {
494                ctx.emit((*value).clone());
495            }
496            match ctx.terminal(0) {
497                Some(DepTerminal::Complete) => ctx.down(vec![Message::Complete]),
498                Some(DepTerminal::Error(error)) => ctx.emit(recover(error.as_ref())),
499                None => {}
500            }
501        },
502    )
503}
504
505/// catch_error: alias-shaped Rust helper for [`rescue`].
506pub fn catch_error<S: Clone + 'static>(recover: impl Fn(&str) -> S + 'static) -> Operator<S> {
507    rescue(recover)
508}
509
510/// valve: forward source DATA while boolean control dep is true.
511pub fn valve<S: Clone + 'static>() -> Operator<S> {
512    Operator::with_opts(
513        "valve",
514        NodeOpts {
515            partial: true,
516            complete_when_deps_complete: false,
517            terminal_as_real_input: true,
518            ..NodeOpts::default()
519        },
520        move |ctx| {
521            match ctx.terminal(0) {
522                Some(DepTerminal::Complete) => {
523                    ctx.down(vec![Message::Complete]);
524                    return;
525                }
526                Some(DepTerminal::Error(error)) => {
527                    ctx.down(vec![Message::Error(error.to_string().into())]);
528                    return;
529                }
530                None => {}
531            }
532
533            let control = ctx.data::<bool>(1).is_some_and(|v| *v);
534            if !control {
535                return;
536            }
537
538            let source_batch = ctx.batch::<S>(0);
539            if source_batch.is_empty() {
540                let control_fired = !ctx.batch::<bool>(1).is_empty();
541                if control_fired {
542                    if let Some(latest) = ctx.data::<S>(0) {
543                        ctx.emit((*latest).clone());
544                    }
545                }
546            } else {
547                for value in source_batch {
548                    ctx.emit((*value).clone());
549                }
550            }
551        },
552    )
553}
554
555fn is_complete(terminal: Option<&DepTerminal>) -> bool {
556    matches!(terminal, Some(DepTerminal::Complete))
557}
558
559fn is_default_node_opts(opts: &NodeOpts) -> bool {
560    opts.factory.is_none()
561        && opts.pool == Default::default()
562        && opts.pausable == Pausable::True
563        && !opts.partial
564        && opts.pull_id.is_none()
565        && opts.complete_when_deps_complete
566        && opts.error_when_deps_error
567        && !opts.terminal_as_real_input
568        && opts.versioning.is_none()
569}
570
571fn merge_node_opts(base: &NodeOpts, caller: NodeOpts) -> NodeOpts {
572    if is_default_node_opts(&caller) {
573        return base.clone();
574    }
575    let default = NodeOpts::default();
576    let mut opts = base.clone();
577    if caller.factory.is_some() {
578        opts.factory = caller.factory;
579    }
580    if caller.pool != default.pool {
581        opts.pool = caller.pool;
582    }
583    if caller.pausable != default.pausable {
584        opts.pausable = caller.pausable;
585    }
586    if caller.partial {
587        opts.partial = true;
588    }
589    if caller.pull_id.is_some() {
590        opts.pull_id = caller.pull_id;
591    }
592    if caller.complete_when_deps_complete != default.complete_when_deps_complete {
593        opts.complete_when_deps_complete = caller.complete_when_deps_complete;
594    }
595    if caller.error_when_deps_error != default.error_when_deps_error {
596        opts.error_when_deps_error = caller.error_when_deps_error;
597    }
598    if caller.terminal_as_real_input {
599        opts.terminal_as_real_input = true;
600    }
601    if caller.versioning.is_some() {
602        opts.versioning = caller.versioning;
603    }
604    opts
605}