Skip to main content

wyrd/foundation/
kind.rs

1//! Closed [`KnotKind`] catalog and related op enums (D-dispatch).
2//!
3//! Author and asset form: host path and emit names stay open strings until
4//! bind interns them. Runtime dispatch uses bind-time tags derived from these
5//! variants rather than matching `KnotKind` every settle.
6
7use crate::foundation::signal::Signal;
8
9#[cfg(feature = "schema")]
10use schemars::JsonSchema;
11#[cfg(feature = "serde")]
12use serde::{Deserialize, Serialize};
13#[cfg(feature = "schema")]
14use std::{borrow::ToOwned, boxed::Box, vec};
15
16/// Which numeric wire path this weave was authored for.
17///
18/// Must match the crate feature selected at compile time (`signal-f32` or
19/// `signal-i32`); validate rejects a mismatch.
20#[derive(Copy, Clone, Debug, PartialEq, Eq)]
21#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
22#[cfg_attr(feature = "schema", derive(JsonSchema))]
23pub enum NumericPath {
24    /// `f32` wire representation (`signal-f32` builds).
25    #[cfg_attr(feature = "serde", serde(rename = "f32"))]
26    F32,
27    /// Fixed-point i32 Q16 wire representation (`signal-i32` builds).
28    #[cfg_attr(feature = "serde", serde(rename = "i32q16"))]
29    I32Q16,
30}
31
32/// Semantic domain carried by a monomorphic [`Signal`] wire.
33///
34/// Domains are graph-time contracts. They do not change the runtime wire
35/// representation selected by `signal-f32` or `signal-i32`.
36#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
37#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
38#[cfg_attr(feature = "schema", derive(JsonSchema))]
39#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
40pub enum SignalDomain {
41    /// Exact false/true values (`ZERO`/`ONE`).
42    Bool,
43    /// Continuous numeric values.
44    Level,
45    /// Whole-number values.
46    Count,
47}
48
49impl SignalDomain {
50    /// Whether [`KnotKind`] numeric ops (Calc, Map, Threshold, …) may use this domain.
51    pub const fn is_numeric(self) -> bool {
52        matches!(self, SignalDomain::Level | SignalDomain::Count)
53    }
54}
55
56impl NumericPath {
57    /// Path encoded by the active cargo feature for this build.
58    pub fn compiled() -> Self {
59        #[cfg(feature = "signal-f32")]
60        {
61            NumericPath::F32
62        }
63        #[cfg(feature = "signal-i32")]
64        {
65            NumericPath::I32Q16
66        }
67    }
68}
69
70/// Comparison operator for [`KnotKind::Compare`].
71#[derive(Copy, Clone, Debug, PartialEq, Eq)]
72#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
73#[cfg_attr(feature = "schema", derive(JsonSchema))]
74pub enum CompareOp {
75    /// `lhs` equals `rhs`.
76    Eq,
77    /// `lhs` does not equal `rhs`.
78    Ne,
79    /// `lhs` is strictly less than `rhs` (numeric domains only).
80    Lt,
81    /// `lhs` is less than or equal to `rhs` (numeric domains only).
82    Lte,
83    /// `lhs` is strictly greater than `rhs` (numeric domains only).
84    Gt,
85    /// `lhs` is greater than or equal to `rhs` (numeric domains only).
86    Gte,
87}
88
89impl CompareOp {
90    /// Whether this comparison is defined for `domain`.
91    ///
92    /// Boolean signals support equality only; numeric domains also support
93    /// ordering comparisons.
94    pub const fn supports_domain(self, domain: SignalDomain) -> bool {
95        !matches!(domain, SignalDomain::Bool) || matches!(self, CompareOp::Eq | CompareOp::Ne)
96    }
97}
98
99/// Timer behavior for [`KnotKind::Timer`].
100#[derive(Copy, Clone, Debug, PartialEq, Eq)]
101#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
102#[cfg_attr(feature = "schema", derive(JsonSchema))]
103pub enum TimerMode {
104    /// Countdown reloaded while the `feed` port stays truthy.
105    FedCountdown,
106    /// Hold `active` for `ticks` after a rising edge on `start`.
107    PulseHold,
108}
109
110/// Binary arithmetic for [`KnotKind::Calc`] (prefer over path-local `signal_ops`).
111#[derive(Copy, Clone, Debug, PartialEq, Eq)]
112#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
113#[cfg_attr(feature = "schema", derive(JsonSchema))]
114pub enum CalcOp {
115    /// Saturating add of `a` and `b`.
116    Add,
117    /// Saturating subtract `b` from `a`.
118    Sub,
119    /// Multiply `a` and `b` (Level saturates; Count truncates toward zero).
120    Mul,
121    /// Divide `a` by `b` (Level float div; Count truncates toward zero).
122    Div,
123}
124
125/// Simultaneous set/reset priority for [`KnotKind::Flag`].
126#[derive(Copy, Clone, Debug, PartialEq, Eq)]
127#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
128#[cfg_attr(feature = "schema", derive(JsonSchema))]
129pub enum FlagPriority {
130    /// Simultaneous set and reset clears the latch.
131    ResetWins,
132    /// Simultaneous set and reset holds the latch set.
133    SetWins,
134}
135
136/// Author / asset knot kind. Host path and emit names stay open strings until bind.
137///
138/// Closed enum: port tables, validate, and loom dispatch all key off these
139/// variants. Adding a kind requires catalog ports plus runtime eval.
140#[derive(Clone, Debug, PartialEq)]
141#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
142#[cfg_attr(feature = "schema", derive(JsonSchema))]
143pub enum KnotKind {
144    /// Fixed authored value seeded on `out` before topo eval.
145    Constant {
146        /// Domain contract for `out`.
147        domain: SignalDomain,
148        /// Literal emitted each settle.
149        value: Signal,
150    },
151    /// Host sense source: loom copies the bound value onto `out` each settle.
152    SignalIn {
153        /// Expected domain of the host-bound signal.
154        domain: SignalDomain,
155    },
156    /// One-shot truthy pulse on the first settle after bind.
157    OnStart,
158    /// Boolean invert: truthy `in` → falsey `out`, and vice versa.
159    Not,
160    /// Conjunction: `out` truthy only when every `in_*` port is truthy.
161    And {
162        /// Number of boolean inputs (`in_0` … `in_{arity-1}`).
163        arity: u8,
164    },
165    /// Disjunction: `out` truthy when any `in_*` port is truthy.
166    Or {
167        /// Number of boolean inputs (`in_0` … `in_{arity-1}`).
168        arity: u8,
169    },
170    /// Relational compare of `lhs` against `rhs` (or `rhs_const`) into boolean `out`.
171    Compare {
172        /// Domain shared by `lhs` and `rhs`.
173        domain: SignalDomain,
174        /// Comparison applied each settle.
175        op: CompareOp,
176        /// Domain-encoded fallback when the `rhs` port is unconnected.
177        rhs_const: Option<Signal>,
178    },
179    /// One-tick pulse when `in` rises from falsey to truthy.
180    RisingFromZero,
181    /// Set/reset/toggle latch with configurable simultaneous priority.
182    Flag {
183        /// Tie-break when `set` and `reset` are both truthy in one settle.
184        priority: FlagPriority,
185        /// Rising edge on `toggle` flips the latch when true.
186        enable_toggle: bool,
187    },
188    /// Saturating counter: rising `inc`/`dec`, level `reset` clears to zero.
189    Counter,
190    /// Boolean `active` from countdown or pulse-hold rune state.
191    Timer {
192        /// Countdown reload vs pulse-hold behavior.
193        mode: TimerMode,
194        /// Duration in loom settle ticks.
195        ticks: u16,
196    },
197    /// Ring-buffer delay: `out` lags `in` by `ticks` settle passes.
198    Delay {
199        /// Delay depth in loom settle ticks.
200        ticks: u16,
201    },
202    /// Binary arithmetic on `a` and `b` into `out`.
203    Calc {
204        /// Numeric domain for operands and result.
205        domain: SignalDomain,
206        /// Operation applied each settle.
207        op: CalcOp,
208    },
209    /// Linear rescale of `in` across authored input and output ranges.
210    Map {
211        /// Numeric domain for `in` and `out`.
212        domain: SignalDomain,
213        /// Input range low endpoint (bind-time constant).
214        in_min: Signal,
215        /// Input range high endpoint (bind-time constant).
216        in_max: Signal,
217        /// Output range low endpoint (bind-time constant).
218        out_min: Signal,
219        /// Output range high endpoint (bind-time constant).
220        out_max: Signal,
221    },
222    /// Absolute value of `in` in the declared domain.
223    Abs {
224        /// Numeric domain for `in` and `out`.
225        domain: SignalDomain,
226    },
227    /// Negation of `in` in the declared domain.
228    Neg {
229        /// Numeric domain for `in` and `out`.
230        domain: SignalDomain,
231    },
232    /// Multiplex: falsey `sel` → `a`, truthy `sel` → `b`.
233    Select,
234    /// Quantize `in` into `steps` bins over the in range, map to the out range.
235    Digitize {
236        /// Numeric domain for `in` and `out`.
237        domain: SignalDomain,
238        /// Bin count across the input span.
239        steps: u16,
240        /// Input range low endpoint (bind-time constant).
241        in_min: Signal,
242        /// Input range high endpoint (bind-time constant).
243        in_max: Signal,
244        /// Output range low endpoint (bind-time constant).
245        out_min: Signal,
246        /// Output range high endpoint (bind-time constant).
247        out_max: Signal,
248    },
249    /// Gate a continuous signal with optional hysteresis; edge pulse outs.
250    Threshold {
251        /// Numeric domain for `in` and threshold constants.
252        domain: SignalDomain,
253        /// Upper crossing level (or sole threshold when hysteresis is off).
254        high: Signal,
255        /// Lower release level when hysteresis is on.
256        low: Signal,
257        /// Latch `out` between `low` and `high` instead of a single cutoff.
258        use_hysteresis: bool,
259    },
260    /// Seeded PRNG sample into `[min, max]` ports; optional rising `gate`.
261    Random {
262        /// Numeric domain for sample and range ports.
263        domain: SignalDomain,
264        /// Resample only on a rising edge of `gate` when true.
265        require_gate: bool,
266    },
267    /// Square root of `in` using the declared numeric domain's representation.
268    Sqrt {
269        /// Numeric domain for `in` and `out`.
270        domain: SignalDomain,
271    },
272    /// Exclusive-or of two boolean inputs into `out`.
273    Xor,
274    /// One-tick pulse when `in` falls from truthy to falsey.
275    FallingToZero,
276    /// One-tick pulse when `in` truthiness changes in either direction.
277    Change,
278    /// Saturate `in` between authored `min` and `max`.
279    Clamp {
280        /// Numeric domain for `in`, bounds, and `out`.
281        domain: SignalDomain,
282        /// Lower clamp bound (bind-time constant).
283        min: Signal,
284        /// Upper clamp bound (bind-time constant).
285        max: Signal,
286    },
287    /// Explicit conversion between two distinct signal domains.
288    Convert {
289        /// Source domain on `in`.
290        from: SignalDomain,
291        /// Target domain on `out`.
292        to: SignalDomain,
293    },
294    /// Write `in` to a host-bound signal path each settle.
295    SignalOut {
296        /// Open host path string until bind interns it.
297        path: std::string::String,
298        /// Expected domain of the host-bound signal.
299        domain: SignalDomain,
300    },
301    /// Queue a named host command when `trigger` is truthy.
302    EmitCommand {
303        /// Open command name string until bind interns it.
304        name: std::string::String,
305    },
306}
307
308impl KnotKind {
309    /// Two-input And knot (`arity` 2).
310    pub fn and2() -> Self {
311        KnotKind::And { arity: 2 }
312    }
313
314    /// Two-input Or knot (`arity` 2).
315    pub fn or2() -> Self {
316        KnotKind::Or { arity: 2 }
317    }
318
319    /// Boolean Not knot.
320    pub fn not() -> Self {
321        KnotKind::Not
322    }
323
324    /// SignalIn sense source in `domain`.
325    pub fn signal_in(domain: SignalDomain) -> Self {
326        KnotKind::SignalIn { domain }
327    }
328
329    /// Constant source with explicit `value` and `domain`.
330    pub fn constant(value: Signal, domain: SignalDomain) -> Self {
331        KnotKind::Constant { domain, value }
332    }
333
334    /// Count-domain constant from whole number `n`.
335    pub fn constant_count(n: i32) -> Self {
336        KnotKind::Constant {
337            domain: SignalDomain::Count,
338            value: crate::foundation::signal::from_count(n),
339        }
340    }
341
342    /// Bool-domain constant (`ONE` when true, `ZERO` when false).
343    pub fn constant_bool(value: bool) -> Self {
344        KnotKind::Constant {
345            domain: SignalDomain::Bool,
346            value: if value {
347                crate::foundation::signal::ONE
348            } else {
349                crate::foundation::signal::ZERO
350            },
351        }
352    }
353
354    /// Level-domain constant from an author float (~0..=1).
355    pub fn constant_level(value: f32) -> Self {
356        KnotKind::Constant {
357            domain: SignalDomain::Level,
358            value: crate::foundation::signal::from_level(value),
359        }
360    }
361
362    /// SignalOut sink bound to host `path` in `domain`.
363    pub fn signal_out(path: impl Into<std::string::String>, domain: SignalDomain) -> Self {
364        KnotKind::SignalOut {
365            path: path.into(),
366            domain,
367        }
368    }
369
370    /// EmitCommand knot for host command `name`.
371    pub fn emit_command(name: impl Into<std::string::String>) -> Self {
372        KnotKind::EmitCommand { name: name.into() }
373    }
374
375    /// Rising-edge detector: pulse when `in` crosses from falsey to truthy.
376    pub fn rising_from_zero() -> Self {
377        KnotKind::RisingFromZero
378    }
379
380    /// Compare knot with `op`, optional baked-in `rhs_const`, in `domain`.
381    pub fn compare(op: CompareOp, rhs_const: Option<Signal>, domain: SignalDomain) -> Self {
382        KnotKind::Compare {
383            domain,
384            op,
385            rhs_const,
386        }
387    }
388
389    /// Saturating counter rune with default `inc`/`dec`/`reset` ports.
390    pub fn counter() -> Self {
391        KnotKind::Counter
392    }
393
394    /// Timer rune with `mode` behavior lasting `ticks` settle passes.
395    pub fn timer(mode: TimerMode, ticks: u16) -> Self {
396        KnotKind::Timer { mode, ticks }
397    }
398
399    /// Flag latch with simultaneous `priority` and optional `toggle` edge.
400    pub fn flag(priority: FlagPriority, enable_toggle: bool) -> Self {
401        KnotKind::Flag {
402            priority,
403            enable_toggle,
404        }
405    }
406
407    /// Multiplex knot: falsey `sel` passes `a`, truthy `sel` passes `b`.
408    pub fn select() -> Self {
409        KnotKind::Select
410    }
411
412    /// Calc knot applying `op` in `domain`.
413    pub fn calc(op: CalcOp, domain: SignalDomain) -> Self {
414        KnotKind::Calc { domain, op }
415    }
416
417    /// Map knot with explicit input and output range endpoints.
418    pub fn map(
419        in_min: Signal,
420        in_max: Signal,
421        out_min: Signal,
422        out_max: Signal,
423        domain: SignalDomain,
424    ) -> Self {
425        KnotKind::Map {
426            domain,
427            in_min,
428            in_max,
429            out_min,
430            out_max,
431        }
432    }
433
434    /// Abs knot in `domain`.
435    pub fn abs(domain: SignalDomain) -> Self {
436        KnotKind::Abs { domain }
437    }
438
439    /// Neg knot in `domain`.
440    pub fn neg(domain: SignalDomain) -> Self {
441        KnotKind::Neg { domain }
442    }
443
444    /// Digitize with `steps` bins over 0..ONE → 0..ONE. Steps of 0 become 1.
445    pub fn digitize(steps: u16, domain: SignalDomain) -> Self {
446        KnotKind::Digitize {
447            domain,
448            steps: steps.max(1),
449            in_min: crate::foundation::signal::ZERO,
450            in_max: crate::foundation::signal::ONE,
451            out_min: crate::foundation::signal::ZERO,
452            out_max: crate::foundation::signal::ONE,
453        }
454    }
455
456    /// Level thresholds use half-scale hysteresis; Count thresholds use 0/1.
457    pub fn threshold_default(domain: SignalDomain) -> Self {
458        if domain == SignalDomain::Count {
459            return KnotKind::Threshold {
460                domain,
461                high: crate::foundation::signal::from_count(1),
462                low: crate::foundation::signal::from_count(0),
463                use_hysteresis: true,
464            };
465        }
466        #[cfg(feature = "signal-f32")]
467        {
468            KnotKind::Threshold {
469                domain,
470                high: 0.5,
471                low: 0.4,
472                use_hysteresis: true,
473            }
474        }
475        #[cfg(feature = "signal-i32")]
476        {
477            let one = crate::foundation::signal::ONE;
478            KnotKind::Threshold {
479                domain,
480                high: one / 2,
481                low: one * 2 / 5, // 0.4
482                use_hysteresis: true,
483            }
484        }
485    }
486
487    /// Random sampler; resamples on rising `gate` when `require_gate` is true.
488    pub fn random(require_gate: bool, domain: SignalDomain) -> Self {
489        KnotKind::Random {
490            domain,
491            require_gate,
492        }
493    }
494
495    /// Sqrt knot in `domain`.
496    pub fn sqrt(domain: SignalDomain) -> Self {
497        KnotKind::Sqrt { domain }
498    }
499
500    /// Boolean xor of `a` and `b`.
501    pub fn xor() -> Self {
502        KnotKind::Xor
503    }
504
505    /// Falling-edge detector: pulse when `in` crosses from truthy to falsey.
506    pub fn falling_to_zero() -> Self {
507        KnotKind::FallingToZero
508    }
509
510    /// Any-truthiness-change edge pulse on `in`.
511    pub fn change() -> Self {
512        KnotKind::Change
513    }
514
515    /// Clamp knot saturating `in` between `min` and `max` in `domain`.
516    pub fn clamp(min: Signal, max: Signal, domain: SignalDomain) -> Self {
517        KnotKind::Clamp { domain, min, max }
518    }
519
520    /// Cross-domain converter from `from` to `to` (must differ).
521    pub fn convert(from: SignalDomain, to: SignalDomain) -> Self {
522        KnotKind::Convert { from, to }
523    }
524
525    /// Whether all authored domain choices are legal for this knot kind.
526    pub fn has_valid_domains(&self) -> bool {
527        match self {
528            KnotKind::Compare { domain, op, .. } => op.supports_domain(*domain),
529            KnotKind::Calc { domain, .. }
530            | KnotKind::Map { domain, .. }
531            | KnotKind::Abs { domain }
532            | KnotKind::Neg { domain }
533            | KnotKind::Digitize { domain, .. }
534            | KnotKind::Threshold { domain, .. }
535            | KnotKind::Random { domain, .. }
536            | KnotKind::Sqrt { domain }
537            | KnotKind::Clamp { domain, .. } => domain.is_numeric(),
538            KnotKind::Convert { from, to } => from != to,
539            _ => true,
540        }
541    }
542
543    /// And/Or input arity when applicable.
544    pub fn arity(&self) -> Option<u8> {
545        match self {
546            KnotKind::And { arity } => Some(*arity),
547            KnotKind::Or { arity } => Some(*arity),
548            _ => None,
549        }
550    }
551}
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556    use crate::foundation::signal::{from_count, from_level, ONE, ZERO};
557
558    #[test]
559    fn helpers_and_arity() {
560        assert!(matches!(KnotKind::or2(), KnotKind::Or { arity: 2 }));
561        assert!(matches!(KnotKind::not(), KnotKind::Not));
562        assert!(matches!(
563            KnotKind::constant_count(7),
564            KnotKind::Constant { value, .. } if value == from_count(7)
565        ));
566        assert!(matches!(
567            KnotKind::constant_bool(false),
568            KnotKind::Constant {
569                domain: SignalDomain::Bool,
570                value: ZERO,
571            }
572        ));
573        assert!(matches!(
574            KnotKind::constant_level(0.25),
575            KnotKind::Constant {
576                domain: SignalDomain::Level,
577                value,
578            } if value == from_level(0.25)
579        ));
580        assert!(matches!(
581            KnotKind::emit_command("go"),
582            KnotKind::EmitCommand { name } if name == "go"
583        ));
584        assert_eq!(KnotKind::and2().arity(), Some(2));
585        assert_eq!(KnotKind::or2().arity(), Some(2));
586        assert_eq!(KnotKind::not().arity(), None);
587        assert_eq!(NumericPath::compiled(), NumericPath::compiled());
588        let _ = ONE;
589        let _ = KnotKind::signal_in(SignalDomain::Bool);
590        let _ = KnotKind::signal_out("p", SignalDomain::Bool);
591        let _ = KnotKind::rising_from_zero();
592        let _ = KnotKind::compare(CompareOp::Eq, None, SignalDomain::Bool);
593        let _ = KnotKind::counter();
594        let _ = KnotKind::timer(TimerMode::PulseHold, 1);
595        let _ = KnotKind::flag(FlagPriority::SetWins, false);
596        let _ = KnotKind::constant(ONE, SignalDomain::Bool);
597        let _ = KnotKind::select();
598        let _ = KnotKind::calc(CalcOp::Add, SignalDomain::Count);
599        let _ = KnotKind::map(crate::ZERO, ONE, crate::ZERO, ONE, SignalDomain::Level);
600        let _ = KnotKind::abs(SignalDomain::Level);
601        let _ = KnotKind::neg(SignalDomain::Count);
602        let _ = KnotKind::digitize(4, SignalDomain::Level);
603        let _ = KnotKind::threshold_default(SignalDomain::Level);
604        let _ = KnotKind::random(false, SignalDomain::Count);
605        let _ = KnotKind::sqrt(SignalDomain::Count);
606        let _ = KnotKind::xor();
607        let _ = KnotKind::falling_to_zero();
608        let _ = KnotKind::change();
609        let _ = KnotKind::clamp(crate::ZERO, ONE, SignalDomain::Level);
610        let _ = KnotKind::convert(SignalDomain::Count, SignalDomain::Level);
611    }
612
613    #[test]
614    fn domain_legality_is_catalog_owned() {
615        assert!(KnotKind::compare(CompareOp::Eq, None, SignalDomain::Bool).has_valid_domains());
616        assert!(!KnotKind::compare(CompareOp::Lt, None, SignalDomain::Bool).has_valid_domains());
617        assert!(KnotKind::calc(CalcOp::Mul, SignalDomain::Count).has_valid_domains());
618        assert!(!KnotKind::calc(CalcOp::Mul, SignalDomain::Bool).has_valid_domains());
619        assert!(KnotKind::convert(SignalDomain::Bool, SignalDomain::Level).has_valid_domains());
620        assert!(!KnotKind::convert(SignalDomain::Bool, SignalDomain::Bool).has_valid_domains());
621        assert!(KnotKind::signal_in(SignalDomain::Bool).has_valid_domains());
622
623        let numeric_kinds = [
624            KnotKind::map(ZERO, ONE, ZERO, ONE, SignalDomain::Level),
625            KnotKind::abs(SignalDomain::Level),
626            KnotKind::neg(SignalDomain::Count),
627            KnotKind::digitize(2, SignalDomain::Level),
628            KnotKind::threshold_default(SignalDomain::Level),
629            KnotKind::random(false, SignalDomain::Count),
630            KnotKind::sqrt(SignalDomain::Level),
631            KnotKind::clamp(ZERO, ONE, SignalDomain::Count),
632        ];
633        assert!(numeric_kinds.iter().all(KnotKind::has_valid_domains));
634    }
635}