Skip to main content

kanonak_expression/
lib.rs

1//! Kanonak expression runtime (expressionRuntimeVersion "1").
2//!
3//! A small, deterministic tree-walker that folds a `kanonak.org/transformations`
4//! (`tx`) + `kanonak.org/math` expression tree to a single number. A faithful
5//! port of the reference TypeScript kernel, verified against the shared parity
6//! vectors — including the determinism traps (Round half-away-from-zero, floored
7//! Modulo, Sign(0)=0, comparisons as 1/0).
8//!
9//! Three layers, exactly as the reference establishes:
10//!
11//!   1. DISPATCH — `operator_arity` derives an operator's operand shape from its
12//!      `tx` superclass: UnaryNumericOp -> unary `value`; BinaryArithmetic /
13//!      BinaryComparison -> binary; BooleanLogic -> n-ary `operands`; plus the
14//!      two structural shapes the hierarchy can't imply (`Not`'s `operand`,
15//!      `Clip`'s ternary).
16//!   2. PRIMITIVES — the authored, determinism-bearing folds (`unary` / `binary`).
17//!   3. THE FOLD — [`evaluate`]: operators recurse + apply a primitive; literals
18//!      yield their numeric value; EVERYTHING ELSE (a typed VarRef, a domain
19//!      `Step`/`Time`/`Smooth`, any future leaf) is handed to the caller's
20//!      `resolve`. The runtime never privileges `tx.VarRef` — it is just one leaf
21//!      a domain may resolve.
22//!
23//! Value domain: uniform `f64`. Booleans and comparison results are `1.0`/`0.0`.
24//!
25//! Operator/literal type tags are matched against `&'static str` literals (the
26//! frozen canonical URIs) — no allocation in the evaluation hot path, which
27//! matters for the per-step integrators (e.g. RK4) that re-evaluate an equation
28//! thousands of times.
29
30use serde_json::Value;
31
32/// The frozen expression-runtime version (determinism contract). Not hashed.
33pub const EXPRESSION_RUNTIME_VERSION: &str = "1";
34
35/// An evaluation error. Determinism traps (Divide/Modulo by zero, Ln/Log10 of
36/// <=0, Sqrt of <0) and structural problems raise this — never `NaN`/`Inf`.
37#[derive(Debug, Clone)]
38pub struct ExpressionError(pub String);
39
40impl std::fmt::Display for ExpressionError {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        write!(f, "{}", self.0)
43    }
44}
45
46impl std::error::Error for ExpressionError {}
47
48fn err<T>(msg: impl Into<String>) -> Result<T, ExpressionError> {
49    Err(ExpressionError(msg.into()))
50}
51
52/// Resolve any node the kernel does not recognise as an operator or literal — a
53/// binding (`tx.VarRef`, a domain's typed `refersTo` VarRef) or a domain leaf
54/// (`Step`, `Time`, `Smooth`…) — to a number. `ctx` is opaque caller state.
55/// `recurse` is handed back so a domain leaf containing sub-expressions can
56/// recurse into the kernel.
57pub type Resolve<'a, C> =
58    &'a dyn Fn(&Value, &mut C, &mut dyn FnMut(&Value, &mut C) -> Result<f64, ExpressionError>)
59        -> Result<f64, ExpressionError>;
60
61/// Resolve an identity leaf inside an ordered comparison — any operand node
62/// that is not a `tx.UriLiteral` — to a member's canonical versionless URI
63/// (`publisher/package/name`). The identity-domain mirror of [`Resolve`]: the
64/// kernel owns the constant leaf, the caller owns bindings.
65pub type ResolveRef<'a, C> = &'a dyn Fn(&Value, &mut C) -> Result<String, ExpressionError>;
66
67/// The transitive closures ordered comparisons consult, keyed by the ordering
68/// property's canonical URI, then by member: `closures[property][from]` is the
69/// set of members `from` reaches. Flat, already-closed data — typically the SDK
70/// reasoner's `prp-trp` saturation emitted at code-generation time. The kernel
71/// does set membership only; it never computes a closure, resolves a package,
72/// or reasons.
73pub type ClosureTable =
74    std::collections::HashMap<String, std::collections::HashMap<String, Vec<String>>>;
75
76/// Optional evaluation context for the ordered comparisons (`IsAtLeast`,
77/// `Dominates`). Absent (or missing a needed entry), an ordered comparison
78/// fails loudly — never a silent false from a missing table.
79pub struct EvalOptions<'a, C> {
80    pub closures: Option<&'a ClosureTable>,
81    pub resolve_ref: Option<ResolveRef<'a, C>>,
82}
83
84/// One node of an evaluation trace — the verdict tree [`explain`] returns.
85/// Mirrors the expression: `typ` is the node's type URI, `value` its result
86/// (`1.0`/`0.0` for booleans), `children` the operand traces in evaluation
87/// order. A short-circuited operand is simply ABSENT from `children` — the
88/// trace is truthful about what ran. Ordered comparisons carry their resolved
89/// operand identities as `left_ref`/`right_ref` instead of children. This is a
90/// runtime return shape, not an ontology class.
91#[derive(Debug, Clone)]
92pub struct TraceNode {
93    pub typ: String,
94    pub value: f64,
95    pub children: Vec<TraceNode>,
96    pub left_ref: Option<String>,
97    pub right_ref: Option<String>,
98}
99
100/// Operand shape per operator, derived from the `tx` superclass hierarchy.
101enum Arity {
102    Unary { operand: &'static str },
103    Binary { left: &'static str, right: &'static str },
104    Nary { operands: &'static str },
105    Ternary { a: &'static str, b: &'static str, c: &'static str },
106}
107
108const ARITH: Arity = Arity::Binary { left: "arithLeft", right: "arithRight" };
109const COMPARE: Arity = Arity::Binary { left: "compareLeft", right: "compareRight" };
110const VALUE: Arity = Arity::Unary { operand: "value" };
111
112/// The frozen dispatch table — maps each operator URI to its operand shape.
113/// `Not` is a direct Expression subclass with boolean (not numeric-unary)
114/// semantics, so it is handled explicitly in `evaluate`, not via this table.
115fn operator_arity(typ: &str) -> Option<Arity> {
116    match typ {
117        // BinaryArithmetic -> arithLeft/arithRight.
118        "kanonak.org/transformations/Add"
119        | "kanonak.org/transformations/Subtract"
120        | "kanonak.org/transformations/Multiply"
121        | "kanonak.org/transformations/Divide"
122        | "kanonak.org/math/Power"
123        | "kanonak.org/math/Modulo"
124        | "kanonak.org/math/Minimum"
125        | "kanonak.org/math/Maximum" => Some(ARITH),
126        // UnaryNumericOp -> value.
127        "kanonak.org/transformations/Abs"
128        | "kanonak.org/transformations/Negate"
129        | "kanonak.org/math/Exp"
130        | "kanonak.org/math/Ln"
131        | "kanonak.org/math/Log10"
132        | "kanonak.org/math/Sqrt"
133        | "kanonak.org/math/Floor"
134        | "kanonak.org/math/Ceil"
135        | "kanonak.org/math/Round"
136        | "kanonak.org/math/Sign" => Some(VALUE),
137        // BinaryComparison -> compareLeft/compareRight.
138        "kanonak.org/transformations/Equals"
139        | "kanonak.org/transformations/GreaterThan"
140        | "kanonak.org/transformations/LessThan"
141        | "kanonak.org/transformations/GreaterThanOrEqual"
142        | "kanonak.org/transformations/LessThanOrEqual" => Some(COMPARE),
143        // BooleanLogic -> operands list.
144        "kanonak.org/transformations/And" | "kanonak.org/transformations/Or" => {
145            Some(Arity::Nary { operands: "operands" })
146        }
147        // Clip ternary.
148        "kanonak.org/math/Clip" => {
149            Some(Arity::Ternary { a: "clipValue", b: "clipLower", c: "clipUpper" })
150        }
151        _ => None,
152    }
153}
154
155/// Floored modulo (the host `%` truncates toward zero): Modulo(-7,3) = 2.
156fn floored_mod(a: f64, b: f64) -> Result<f64, ExpressionError> {
157    if b == 0.0 {
158        return err("Modulo by zero");
159    }
160    Ok(a - b * (a / b).floor())
161}
162
163/// Round half away from zero: Round(-2.5) = -3, Round(2.5) = 3.
164fn round_half_away(a: f64) -> f64 {
165    // sign(x) * floor(abs(x) + 0.5), avoiding any half-to-even native rounding.
166    if a < 0.0 {
167        -(((-a) + 0.5).floor())
168    } else {
169        (a + 0.5).floor()
170    }
171}
172
173fn sign(x: f64) -> f64 {
174    if x > 0.0 {
175        1.0
176    } else if x < 0.0 {
177        -1.0
178    } else {
179        0.0
180    }
181}
182
183fn truthy(n: f64) -> bool {
184    n != 0.0
185}
186
187fn boolnum(b: bool) -> f64 {
188    if b {
189        1.0
190    } else {
191        0.0
192    }
193}
194
195/// Unary primitive fold for `typ`, applied to `x`. The authored,
196/// determinism-bearing table — matched per language.
197fn unary(typ: &str, x: f64) -> Result<f64, ExpressionError> {
198    match typ {
199        "kanonak.org/transformations/Abs" => Ok(x.abs()),
200        "kanonak.org/transformations/Negate" => Ok(-x),
201        "kanonak.org/math/Exp" => Ok(x.exp()),
202        "kanonak.org/math/Ln" => {
203            if x > 0.0 {
204                Ok(x.ln())
205            } else {
206                err("Ln of a non-positive number")
207            }
208        }
209        "kanonak.org/math/Log10" => {
210            if x > 0.0 {
211                Ok(x.log10())
212            } else {
213                err("Log10 of a non-positive number")
214            }
215        }
216        "kanonak.org/math/Sqrt" => {
217            if x >= 0.0 {
218                Ok(x.sqrt())
219            } else {
220                err("Sqrt of a negative number")
221            }
222        }
223        "kanonak.org/math/Floor" => Ok(x.floor()),
224        "kanonak.org/math/Ceil" => Ok(x.ceil()),
225        "kanonak.org/math/Round" => Ok(round_half_away(x)),
226        "kanonak.org/math/Sign" => Ok(sign(x)),
227        _ => err(format!("{typ} has no unary primitive")),
228    }
229}
230
231/// Binary primitive fold for `typ`, applied to `(a, b)`.
232fn binary(typ: &str, a: f64, b: f64) -> Result<f64, ExpressionError> {
233    match typ {
234        "kanonak.org/transformations/Add" => Ok(a + b),
235        "kanonak.org/transformations/Subtract" => Ok(a - b),
236        "kanonak.org/transformations/Multiply" => Ok(a * b),
237        "kanonak.org/transformations/Divide" => {
238            if b == 0.0 {
239                err("Divide by zero")
240            } else {
241                Ok(a / b)
242            }
243        }
244        "kanonak.org/math/Power" => Ok(a.powf(b)),
245        "kanonak.org/math/Modulo" => floored_mod(a, b),
246        "kanonak.org/math/Minimum" => Ok(a.min(b)),
247        "kanonak.org/math/Maximum" => Ok(a.max(b)),
248        "kanonak.org/transformations/Equals" => Ok(boolnum(a == b)),
249        "kanonak.org/transformations/GreaterThan" => Ok(boolnum(a > b)),
250        "kanonak.org/transformations/LessThan" => Ok(boolnum(a < b)),
251        "kanonak.org/transformations/GreaterThanOrEqual" => Ok(boolnum(a >= b)),
252        "kanonak.org/transformations/LessThanOrEqual" => Ok(boolnum(a <= b)),
253        _ => err(format!("{typ} has no binary primitive")),
254    }
255}
256
257/// Numeric value of a literal node, or `None` if it is not a literal.
258fn literal_value(node: &Value, typ: &str) -> Option<f64> {
259    match typ {
260        "kanonak.org/transformations/IntegerLiteral" => node.get("integerLiteral").and_then(as_number),
261        "kanonak.org/transformations/DecimalLiteral" => node.get("decimalLiteral").and_then(as_number),
262        "kanonak.org/transformations/BooleanLiteral" => {
263            let v = node.get("booleanLiteral");
264            let truthy = matches!(v, Some(Value::Bool(true)))
265                || matches!(v, Some(Value::String(s)) if s == "true");
266            Some(boolnum(truthy))
267        }
268        _ => None,
269    }
270}
271
272fn as_number(v: &Value) -> Option<f64> {
273    match v {
274        Value::Number(n) => n.as_f64(),
275        Value::String(s) => s.parse::<f64>().ok(),
276        Value::Bool(b) => Some(boolnum(*b)),
277        _ => None,
278    }
279}
280
281/// The node's `type` tag, borrowed (no allocation).
282fn node_type(node: &Value) -> Result<&str, ExpressionError> {
283    match node.get("type").and_then(|t| t.as_str()) {
284        Some(t) => Ok(t),
285        None => err("node is missing a 'type'"),
286    }
287}
288
289fn operand<'a>(node: &'a Value, typ: &str, key: &str) -> Result<&'a Value, ExpressionError> {
290    match node.get(key) {
291        Some(v) if v.is_object() => Ok(v),
292        _ => err(format!("{typ} is missing operand '{key}'")),
293    }
294}
295
296/// The identity an ordered comparison compares — a member's canonical
297/// versionless URI. `tx.UriLiteral` is the kernel-known constant leaf (its
298/// `refTo` IS the identity, the way a literal's value is its number); every
299/// other node is the caller's, through `resolve_ref`.
300fn identity_of<C>(
301    node: &Value,
302    ctx: &mut C,
303    options: Option<&EvalOptions<C>>,
304) -> Result<String, ExpressionError> {
305    let typ = node_type(node)?;
306    if typ == "kanonak.org/transformations/UriLiteral" {
307        return match node.get("refTo").and_then(|v| v.as_str()) {
308            Some(s) if !s.is_empty() => Ok(s.to_string()),
309            _ => err("UriLiteral is missing refTo"),
310        };
311    }
312    match options.and_then(|o| o.resolve_ref) {
313        Some(resolve_ref) => resolve_ref(node, ctx),
314        None => err(format!("No resolveRef supplied for identity leaf '{typ}'")),
315    }
316}
317
318/// Fold an ordered comparison (`IsAtLeast` / `Dominates`) to `1.0`/`0.0` plus
319/// the resolved operand identities. The ordering is the supplied closure for
320/// the node's `viaProperty` — membership in already-closed data, nothing more.
321/// Identity is canonical versionless URI string equality, matching
322/// `tx.Equals`' identity rule. `IsAtLeast` folds reflexivity into the operator
323/// (same member → 1); `Dominates` is strict (same member → 0). Two members
324/// with no path yield 0 — fail-closed — but a MISSING closure table is a
325/// configuration failure and errors loudly.
326fn fold_ordered<C>(
327    node: &Value,
328    typ: &str,
329    ctx: &mut C,
330    options: Option<&EvalOptions<C>>,
331) -> Result<(f64, String, String), ExpressionError> {
332    let via = match node.get("viaProperty").and_then(|v| v.as_str()) {
333        Some(s) if !s.is_empty() => s,
334        _ => return err(format!("{typ} is missing viaProperty")),
335    };
336    let left = identity_of(operand(node, typ, "compareLeft")?, ctx, options)?;
337    let right = identity_of(operand(node, typ, "compareRight")?, ctx, options)?;
338    let closure = match options.and_then(|o| o.closures).and_then(|c| c.get(via)) {
339        Some(c) => c,
340        None => return err(format!("No closure supplied for ordering property '{via}'")),
341    };
342    let value = if left == right {
343        boolnum(typ == "kanonak.org/transformations/IsAtLeast")
344    } else {
345        boolnum(closure.get(&left).map_or(false, |set| set.iter().any(|m| m == &right)))
346    };
347    Ok((value, left, right))
348}
349
350/// Evaluate an expression tree to a number. Operators fold via the frozen
351/// dispatch + primitive tables; literals yield their numeric value; any other
352/// node is delegated to `resolve`.
353pub fn evaluate<C>(
354    node: &Value,
355    ctx: &mut C,
356    resolve: Resolve<C>,
357) -> Result<f64, ExpressionError> {
358    evaluate_with_options(node, ctx, resolve, None)
359}
360
361/// [`evaluate`] with the ordered-comparison evaluation context (closures +
362/// identity-leaf resolution). `options` is only consulted when an `IsAtLeast` /
363/// `Dominates` node is reached; `None` is valid for trees without them.
364pub fn evaluate_with_options<C>(
365    node: &Value,
366    ctx: &mut C,
367    resolve: Resolve<C>,
368    options: Option<&EvalOptions<C>>,
369) -> Result<f64, ExpressionError> {
370    fn go<C>(
371        node: &Value,
372        ctx: &mut C,
373        resolve: Resolve<C>,
374        options: Option<&EvalOptions<C>>,
375    ) -> Result<f64, ExpressionError> {
376        let typ = node_type(node)?;
377
378        if let Some(arity) = operator_arity(typ) {
379            return match arity {
380                Arity::Unary { operand: key } => {
381                    let x = go(operand(node, typ, key)?, ctx, resolve, options)?;
382                    unary(typ, x)
383                }
384                Arity::Binary { left, right } => {
385                    let a = go(operand(node, typ, left)?, ctx, resolve, options)?;
386                    let b = go(operand(node, typ, right)?, ctx, resolve, options)?;
387                    binary(typ, a, b)
388                }
389                Arity::Nary { operands } => {
390                    let items = match node.get(operands).and_then(|v| v.as_array()) {
391                        Some(arr) => arr,
392                        None => return err(format!("{typ} expects an '{operands}' list")),
393                    };
394                    let is_and = typ == "kanonak.org/transformations/And";
395                    // Short-circuit; empty And vacuously true, empty Or vacuously false.
396                    for item in items {
397                        let v = truthy(go(item, ctx, resolve, options)?);
398                        if is_and && !v {
399                            return Ok(0.0);
400                        }
401                        if !is_and && v {
402                            return Ok(1.0);
403                        }
404                    }
405                    Ok(boolnum(is_and))
406                }
407                Arity::Ternary { a, b, c } => {
408                    // Only Clip today: clamp clipValue into [clipLower, clipUpper].
409                    let v = go(operand(node, typ, a)?, ctx, resolve, options)?;
410                    let lo = go(operand(node, typ, b)?, ctx, resolve, options)?;
411                    let hi = go(operand(node, typ, c)?, ctx, resolve, options)?;
412                    Ok(v.max(lo).min(hi))
413                }
414            };
415        }
416
417        if typ == "kanonak.org/transformations/Not" {
418            let inner = go(operand(node, typ, "operand")?, ctx, resolve, options)?;
419            return Ok(boolnum(!truthy(inner)));
420        }
421
422        if typ == "kanonak.org/transformations/IsAtLeast"
423            || typ == "kanonak.org/transformations/Dominates"
424        {
425            return fold_ordered(node, typ, ctx, options).map(|(v, _, _)| v);
426        }
427
428        if let Some(lit) = literal_value(node, typ) {
429            return Ok(lit);
430        }
431
432        // Not an operator or literal — a binding or domain leaf. The caller owns it.
433        let mut recurse =
434            |n: &Value, c: &mut C| -> Result<f64, ExpressionError> { go(n, c, resolve, options) };
435        resolve(node, ctx, &mut recurse)
436    }
437
438    go(node, ctx, resolve, options)
439}
440
441/// Evaluate an expression tree and return the verdict tree — the regex-debugger
442/// view: every evaluated node, its own result, and (for ordered comparisons)
443/// the identities it compared. The root's `value` is exactly what [`evaluate`]
444/// returns for the same inputs; the conformance suite runs every vector through
445/// both and requires agreement, so the two entry points cannot drift. Kept
446/// separate from `evaluate` so the hot path never pays for trace allocation.
447/// Errors propagate exactly as in `evaluate` — a failed evaluation yields an
448/// error, not a partial trace.
449pub fn explain<C>(
450    node: &Value,
451    ctx: &mut C,
452    resolve: Resolve<C>,
453    options: Option<&EvalOptions<C>>,
454) -> Result<TraceNode, ExpressionError> {
455    fn leaf(typ: &str, value: f64) -> TraceNode {
456        TraceNode { typ: typ.to_string(), value, children: Vec::new(), left_ref: None, right_ref: None }
457    }
458    fn parent(typ: &str, value: f64, children: Vec<TraceNode>) -> TraceNode {
459        TraceNode { typ: typ.to_string(), value, children, left_ref: None, right_ref: None }
460    }
461
462    fn go<C>(
463        node: &Value,
464        ctx: &mut C,
465        resolve: Resolve<C>,
466        options: Option<&EvalOptions<C>>,
467    ) -> Result<TraceNode, ExpressionError> {
468        let typ = node_type(node)?;
469
470        if let Some(arity) = operator_arity(typ) {
471            return match arity {
472                Arity::Unary { operand: key } => {
473                    let x = go(operand(node, typ, key)?, ctx, resolve, options)?;
474                    let value = unary(typ, x.value)?;
475                    Ok(parent(typ, value, vec![x]))
476                }
477                Arity::Binary { left, right } => {
478                    let a = go(operand(node, typ, left)?, ctx, resolve, options)?;
479                    let b = go(operand(node, typ, right)?, ctx, resolve, options)?;
480                    let value = binary(typ, a.value, b.value)?;
481                    Ok(parent(typ, value, vec![a, b]))
482                }
483                Arity::Nary { operands } => {
484                    let items = match node.get(operands).and_then(|v| v.as_array()) {
485                        Some(arr) => arr,
486                        None => return err(format!("{typ} expects an '{operands}' list")),
487                    };
488                    let is_and = typ == "kanonak.org/transformations/And";
489                    let mut children = Vec::new();
490                    for item in items {
491                        let child = go(item, ctx, resolve, options)?;
492                        let v = truthy(child.value);
493                        children.push(child);
494                        // Same short-circuit as `evaluate`: operands after the
495                        // deciding one are never evaluated and never appear.
496                        if is_and && !v {
497                            return Ok(parent(typ, 0.0, children));
498                        }
499                        if !is_and && v {
500                            return Ok(parent(typ, 1.0, children));
501                        }
502                    }
503                    Ok(parent(typ, boolnum(is_and), children))
504                }
505                Arity::Ternary { a, b, c } => {
506                    let v = go(operand(node, typ, a)?, ctx, resolve, options)?;
507                    let lo = go(operand(node, typ, b)?, ctx, resolve, options)?;
508                    let hi = go(operand(node, typ, c)?, ctx, resolve, options)?;
509                    let value = v.value.max(lo.value).min(hi.value);
510                    Ok(parent(typ, value, vec![v, lo, hi]))
511                }
512            };
513        }
514
515        if typ == "kanonak.org/transformations/Not" {
516            let x = go(operand(node, typ, "operand")?, ctx, resolve, options)?;
517            let value = boolnum(!truthy(x.value));
518            return Ok(parent(typ, value, vec![x]));
519        }
520
521        if typ == "kanonak.org/transformations/IsAtLeast"
522            || typ == "kanonak.org/transformations/Dominates"
523        {
524            let (value, left, right) = fold_ordered(node, typ, ctx, options)?;
525            return Ok(TraceNode {
526                typ: typ.to_string(),
527                value,
528                children: Vec::new(),
529                left_ref: Some(left),
530                right_ref: Some(right),
531            });
532        }
533
534        if let Some(lit) = literal_value(node, typ) {
535            return Ok(leaf(typ, lit));
536        }
537
538        // Numeric recursion for subtrees the caller's `resolve` re-enters: those
539        // folds happen inside the caller and are invisible to the trace. Only
540        // kernel-visited nodes appear.
541        let mut recurse = |n: &Value, c: &mut C| -> Result<f64, ExpressionError> {
542            evaluate_with_options(n, c, resolve, options)
543        };
544        let value = resolve(node, ctx, &mut recurse)?;
545        Ok(leaf(typ, value))
546    }
547
548    go(node, ctx, resolve, options)
549}