Skip to main content

nibli_reason/
compute.rs

1use super::*;
2
3// ─── Injectable compute dispatch (per-KB; see `KnowledgeBase::set_compute_dispatch`) ───
4
5/// Single-predicate external compute dispatch function (stored on `KnowledgeBaseInner`).
6pub(crate) type EvalFn = fn(&str, &[LogicalTerm]) -> Result<bool, String>;
7/// Batch external compute dispatch function (stored on `KnowledgeBaseInner`).
8pub(crate) type BatchEvalFn = fn(&[ComputeRequest]) -> Vec<Result<bool, String>>;
9
10pub(super) fn extract_num_value(
11    term: &LogicalTerm,
12    subs: &HashMap<String, GroundTerm>,
13) -> Option<f64> {
14    match term {
15        LogicalTerm::Number(n) => Some(*n),
16        LogicalTerm::Variable(v) => {
17            let gt = subs.get(v.as_str())?;
18            gt.as_f64()
19        }
20        _ => None,
21    }
22}
23
24/// Flat-path numeric comparison. Returns `None` when this is not a numeric
25/// comparison at all (non-numeric args or an unknown relation — the caller
26/// falls through to normal predicate lookup), and a VERDICT otherwise: finite
27/// operands decide `True`/`False`; a NON-FINITE operand is
28/// `Unknown(NonFinite)` — mirroring the event-decomposed path's guard below.
29/// A comparison over ±inf/NaN is meaningless, and returning `None` for it
30/// would degrade to `PredicateNotFound` → a confident FALSE.
31pub(super) fn try_numeric_comparison(
32    rel: &str,
33    args: &[LogicalTerm],
34    subs: &HashMap<String, GroundTerm>,
35) -> Option<QueryResult> {
36    let a = extract_num_value(args.get(0)?, subs)?;
37    let b = extract_num_value(args.get(1)?, subs)?;
38    let holds = match rel {
39        "greater" => a > b,
40        "less" => a < b,
41        "num_equal" => a == b,
42        _ => return None,
43    };
44    if !a.is_finite() || !b.is_finite() {
45        return Some(QueryResult::Unknown(UnknownReason::NonFinite));
46    }
47    Some(if holds {
48        QueryResult::True
49    } else {
50        QueryResult::False
51    })
52}
53
54pub(super) fn try_arithmetic_evaluation(
55    rel: &str,
56    args: &[LogicalTerm],
57    subs: &HashMap<String, GroundTerm>,
58) -> Option<bool> {
59    let x1 = extract_num_value(args.get(0)?, subs)?;
60    let x2 = extract_num_value(args.get(1)?, subs)?;
61    let x3 = extract_num_value(args.get(2)?, subs)?;
62    // The relation match + tolerant-equality comparison is shared with the nibli-host
63    // host fast path (and the Python reference backend) so the three agree.
64    nibli_types::eval_arithmetic(rel, &[x1, x2, x3])
65}
66
67/// Convert a GroundTerm back to a LogicalTerm for compute backend dispatch.
68pub(super) fn ground_term_to_logical_term(gt: &GroundTerm) -> LogicalTerm {
69    match gt {
70        GroundTerm::Constant(c) => LogicalTerm::Constant(c.clone()),
71        GroundTerm::Number(bits) => LogicalTerm::Number(f64::from_bits(*bits)),
72        GroundTerm::Description(d) => LogicalTerm::Description(d.clone()),
73        GroundTerm::Unspecified => LogicalTerm::Unspecified,
74        GroundTerm::PatternVar(v) => LogicalTerm::Variable(v.clone()),
75        GroundTerm::SkolemFn(name, _) => LogicalTerm::Constant(name.clone()),
76        GroundTerm::DepPair(_, _) => LogicalTerm::Unspecified,
77    }
78}
79
80/// Witness-surface conversion: unlike the compute-dispatch conversion above
81/// (which only needs an opaque token), dependent Skolem terms keep their
82/// functional form — SkolemFn("sk_1", adam) renders as `sk_1(adam)` — so
83/// distinct dependent witnesses stay distinguishable in `[Find]` results and
84/// `ExistsWitness` proof steps.
85pub(super) fn witness_term_to_logical_term(gt: &GroundTerm) -> LogicalTerm {
86    match gt {
87        GroundTerm::SkolemFn(..) | GroundTerm::DepPair(..) => {
88            LogicalTerm::Constant(gt.to_display_string())
89        }
90        other => ground_term_to_logical_term(other),
91    }
92}
93
94// ─── Decomposed numeric-group evaluation ────────────────────────────────────
95//
96// Neo-Davidsonian event decomposition compiles a surface numeric proposition to
97// `∃ev. head(ev) ∧ rel_x1(ev, a) ∧ rel_x2(ev, b) ∧ ...` — the head
98// (ComputeNode for registered compute predicates, a plain Predicate for the
99// query-time comparisons greater/less/num_equal) carries ONLY the event variable;
100// the operands live in sibling role predicates. The flat evaluators above
101// read the head's own args and never see the numbers, so every surface
102// numeric query used to return FALSE.
103
104/// The verdict of a numeric-group evaluation, tagged with the route taken
105/// (the tag feeds the traced evaluator's ComputeCheck step).
106pub(super) struct NumericGroupVerdict {
107    pub relation: String,
108    /// "numeric" (comparison), "arithmetic" (built-in), "backend" (dispatch ok),
109    /// or "backend_unavailable" (dispatch failed → Unknown, never False).
110    pub method: &'static str,
111    pub verdict: QueryResult,
112}
113
114/// Evaluate an event-decomposed numeric group at its `∃ev` boundary.
115///
116/// Fires only on the EXACT group shape (the strictness is the soundness
117/// guard): the body's And-tree must consist of one head — `ComputeNode(rel,
118/// [Var ev])`, or `Predicate(rel, [Var ev])` with rel ∈ {greater, less, num_equal}
119/// — plus role predicates `rel_xN(Var ev, arg)` for the same rel with
120/// contiguous N starting at 1. Any other conjunct (pair modifier roles,
121/// tense nodes in hand-built buffers, a different event variable) returns
122/// None and normal evaluation proceeds, so asserted facts stay reachable.
123///
124/// Routing is by RELATION NAME, arithmetic-first (matching the documented
125/// design, nibli-host's host evaluate(), and the batch path): comparison →
126/// built-in arithmetic → (ComputeNode heads only) external backend dispatch.
127/// A backend error (or no backend at all) yields `Unknown(BackendUnavailable)`
128/// — method "backend_unavailable", with NO store fallback on this path (there
129/// is nothing cached to honor: this path never ingests) — so no-backend
130/// configs neither error nor hang.
131///
132/// A computed `false` is DEFINITIVE, matching the flat ComputeNode/Predicate
133/// arms (the store-shadowing policy question is tracked in TODO.md
134/// §Compute / fact lifecycle).
135///
136/// Deliberately performs NO auto-ingestion: unlike the flat path, whose
137/// ground fact is byte-identical on every query (HashSet-deduped), ingesting
138/// a group would mint a fresh Skolem event per query and accumulate
139/// duplicate facts. Recomputation is free.
140pub(super) fn try_evaluate_numeric_group(
141    inner: &KnowledgeBaseInner,
142    buffer: &LogicBuffer,
143    exists_var: &str,
144    body_id: u32,
145    subs: &HashMap<String, GroundTerm>,
146) -> Option<NumericGroupVerdict> {
147    // Flatten the And-tree; bail on anything that is not And/Predicate/Compute.
148    let mut conjuncts: Vec<u32> = Vec::new();
149    let mut stack = vec![body_id];
150    while let Some(id) = stack.pop() {
151        match get_node(buffer, id).ok()? {
152            LogicNode::AndNode((l, r)) => {
153                stack.push(*l);
154                stack.push(*r);
155            }
156            LogicNode::Predicate(_) | LogicNode::ComputeNode(_) => conjuncts.push(id),
157            _ => return None,
158        }
159    }
160
161    // Identify exactly one head over our event variable.
162    let is_head_var =
163        |args: &[LogicalTerm]| matches!(args, [LogicalTerm::Variable(v)] if v == exists_var);
164    let mut head: Option<(&str, bool)> = None; // (relation, head_is_compute_node)
165    for &id in &conjuncts {
166        match get_node(buffer, id).ok()? {
167            LogicNode::ComputeNode((rel, args)) if is_head_var(args) => {
168                if head.is_some() {
169                    return None; // two heads — not a single group
170                }
171                head = Some((rel.as_str(), true));
172            }
173            LogicNode::Predicate((rel, args))
174                if is_head_var(args)
175                    && nibli_types::relations::is_numeric_comparison(rel.as_str()) =>
176            {
177                if head.is_some() {
178                    return None;
179                }
180                head = Some((rel.as_str(), false));
181            }
182            _ => {}
183        }
184    }
185    let (rel, head_is_compute) = head?;
186
187    // Every other conjunct must be a role predicate rel_xN(Var ev, arg).
188    let role_prefix = format!("{rel}_x");
189    let mut roles: Vec<Option<&LogicalTerm>> = Vec::new();
190    for &id in &conjuncts {
191        match get_node(buffer, id).ok()? {
192            LogicNode::ComputeNode((r, args)) if is_head_var(args) && r.as_str() == rel => {}
193            LogicNode::Predicate((r, args)) if is_head_var(args) && r.as_str() == rel => {}
194            LogicNode::Predicate((r, args)) if r.starts_with(&role_prefix) => {
195                let n: usize = r[role_prefix.len()..].parse().ok()?;
196                if n == 0 {
197                    return None;
198                }
199                match args.as_slice() {
200                    [LogicalTerm::Variable(v), arg] if v == exists_var => {
201                        if roles.len() < n {
202                            roles.resize(n, None);
203                        }
204                        if roles[n - 1].is_some() {
205                            return None; // duplicate role index
206                        }
207                        roles[n - 1] = Some(arg);
208                    }
209                    _ => return None,
210                }
211            }
212            _ => return None, // unrelated conjunct — not a pure group
213        }
214    }
215    // Roles must be contiguous x1..=xN (nibli-semantics always emits the full arity).
216    if roles.is_empty() || roles.iter().any(|r| r.is_none()) {
217        return None;
218    }
219    let collected: Vec<LogicalTerm> = roles.into_iter().map(|r| r.unwrap().clone()).collect();
220
221    // Non-finite numeric input (a literal too large for an f64 overflows to ±inf), or a
222    // finite-operand arithmetic whose RESULT overflows, makes any comparison/arithmetic
223    // undetermined — surface `Unknown(NonFinite)`, NEVER a confident TRUE/FALSE. (Returning
224    // None here would degrade to `PredicateNotFound` → a confident FALSE.) Divide-by-zero
225    // over finite operands stays a decided FALSE: `eval_arithmetic` returns `Some(false)`,
226    // not `None`, for it.
227    {
228        let operands: Vec<f64> = collected
229            .iter()
230            .filter_map(|t| extract_num_value(t, subs))
231            .collect();
232        let non_finite = match rel {
233            r if nibli_types::relations::is_builtin_arithmetic(r) => {
234                operands.len() == 3 && nibli_types::eval_arithmetic(rel, &operands).is_none()
235            }
236            r if nibli_types::relations::is_numeric_comparison(r) => {
237                operands.len() >= 2 && operands.iter().take(2).any(|n| !n.is_finite())
238            }
239            _ => false,
240        };
241        if non_finite {
242            return Some(NumericGroupVerdict {
243                relation: rel.to_string(),
244                method: "non_finite",
245                verdict: QueryResult::Unknown(UnknownReason::NonFinite),
246            });
247        }
248    }
249
250    // Route by relation name, arithmetic-first. (The non-finite guard above
251    // already returned for non-finite comparison operands, so the verdict here
252    // is always definitive; the match keeps the two guards mirror-consistent.)
253    if let Some(verdict) = try_numeric_comparison(rel, &collected, subs) {
254        return Some(NumericGroupVerdict {
255            relation: rel.to_string(),
256            method: if matches!(verdict, QueryResult::Unknown(_)) {
257                "non_finite"
258            } else {
259                "numeric"
260            },
261            verdict,
262        });
263    }
264    if let Some(holds) = try_arithmetic_evaluation(rel, &collected, subs) {
265        return Some(NumericGroupVerdict {
266            relation: rel.to_string(),
267            method: "arithmetic",
268            verdict: bool_verdict(holds),
269        });
270    }
271    if head_is_compute {
272        // External dispatch: every non-Unspecified role must resolve numeric.
273        let resolved = resolve_args_for_dispatch(&collected, subs);
274        let dispatchable = resolved
275            .iter()
276            .all(|t| matches!(t, LogicalTerm::Number(_) | LogicalTerm::Unspecified));
277        if dispatchable {
278            return Some(match dispatch_to_backend(inner, rel, &resolved) {
279                Ok(holds) => NumericGroupVerdict {
280                    relation: rel.to_string(),
281                    method: "backend",
282                    verdict: bool_verdict(holds),
283                },
284                // Backend unreachable/unregistered: the computation is genuinely
285                // undetermined — surface Unknown(BackendUnavailable), never FALSE.
286                // (This path does not auto-assert, so there is no cached result to
287                // honor; returning here is equivalent to the no-witness fallback.)
288                Err(_) => NumericGroupVerdict {
289                    relation: rel.to_string(),
290                    method: "backend_unavailable",
291                    verdict: QueryResult::Unknown(UnknownReason::BackendUnavailable),
292                },
293            });
294        }
295    }
296    None
297}
298
299/// True → `QueryResult::True`, false → `QueryResult::False`.
300fn bool_verdict(holds: bool) -> QueryResult {
301    if holds {
302        QueryResult::True
303    } else {
304        QueryResult::False
305    }
306}
307
308pub(super) fn resolve_args_for_dispatch(
309    args: &[LogicalTerm],
310    subs: &HashMap<String, GroundTerm>,
311) -> Vec<LogicalTerm> {
312    args.iter()
313        .map(|a| match a {
314            LogicalTerm::Variable(v) => {
315                if let Some(gt) = subs.get(v.as_str()) {
316                    ground_term_to_logical_term(gt)
317                } else {
318                    a.clone()
319                }
320            }
321            _ => a.clone(),
322        })
323        .collect()
324}
325
326/// Forward a single predicate to the operator-configured external backend.
327///
328/// On `Ok(true)` the caller (the ComputeNode arm in `reasoning.rs`) auto-asserts the
329/// predicate as a ground fact via `assert_typed_fact`, which downstream rules may then
330/// chain on. The channel is unauthenticated — see the trust-boundary note on
331/// `register_compute_dispatch`. `assert_typed_fact` invalidates the predicate result
332/// cache on every insert, so an auto-asserted fact never leaves a stale verdict cached
333/// within the same query.
334pub(super) fn dispatch_to_backend(
335    inner: &KnowledgeBaseInner,
336    rel: &str,
337    args: &[LogicalTerm],
338) -> Result<bool, String> {
339    match inner.compute_eval {
340        Some(eval) => eval(rel, args),
341        None => Err("Compute backend not registered".to_string()),
342    }
343}
344
345/// Batch compute request.
346pub struct ComputeRequest {
347    pub relation: String,
348    pub args: Vec<LogicalTerm>,
349}
350
351fn dispatch_batch_to_backend(
352    inner: &KnowledgeBaseInner,
353    requests: &[ComputeRequest],
354) -> Vec<Result<bool, String>> {
355    match inner.compute_batch_eval {
356        Some(batch_eval) => batch_eval(requests),
357        None => requests
358            .iter()
359            .map(|_| Err("Compute backend not registered".to_string()))
360            .collect(),
361    }
362}
363
364/// Build a typed StoredFact from resolved LogicalTerm arguments.
365pub(super) fn build_ground_fact_from_resolved(
366    rel: &str,
367    resolved_args: &[LogicalTerm],
368) -> Option<StoredFact> {
369    for arg in resolved_args {
370        if matches!(arg, LogicalTerm::Variable(_)) {
371            return None;
372        }
373    }
374    let args: Vec<GroundTerm> = resolved_args
375        .iter()
376        .map(|arg| match arg {
377            LogicalTerm::Number(n) => GroundTerm::from_f64(*n),
378            LogicalTerm::Constant(c) => GroundTerm::Constant(c.clone()),
379            LogicalTerm::Description(d) => GroundTerm::Description(d.clone()),
380            LogicalTerm::Unspecified => GroundTerm::Unspecified,
381            LogicalTerm::Variable(v) => {
382                unreachable!("Variable '{}' in compute result — should be ground", v)
383            }
384        })
385        .collect();
386    Some(StoredFact::Bare(GroundFact::new(rel, args)))
387}
388
389/// Result of batch compute: boolean results + facts to ingest into the KB.
390/// The caller is responsible for ingesting the deferred facts, which allows
391/// the domain member slice borrow to be released before mutating the KB.
392pub(super) struct BatchComputeResult {
393    pub results: Vec<bool>,
394    pub deferred_facts: Vec<StoredFact>,
395}
396
397pub(super) fn batch_evaluate_compute_for_members(
398    inner: &KnowledgeBaseInner,
399    rel: &str,
400    args: &[LogicalTerm],
401    var: &str,
402    members: &[GroundTerm],
403    subs: &HashMap<String, GroundTerm>,
404) -> Option<BatchComputeResult> {
405    let mut results = vec![false; members.len()];
406    let mut deferred_facts = Vec::new();
407    let mut pending: Vec<(usize, Vec<LogicalTerm>)> = Vec::new();
408
409    for (i, member) in members.iter().enumerate() {
410        let mut s = subs.clone();
411        s.insert(var.to_string(), member.clone());
412
413        if let Some(r) = try_arithmetic_evaluation(rel, args, &s) {
414            results[i] = r;
415            if r {
416                let resolved = resolve_args_for_dispatch(args, &s);
417                if let Some(fact) = build_ground_fact_from_resolved(rel, &resolved) {
418                    deferred_facts.push(fact);
419                }
420            }
421        } else {
422            let resolved = resolve_args_for_dispatch(args, &s);
423            pending.push((i, resolved));
424        }
425    }
426
427    if pending.is_empty() {
428        return Some(BatchComputeResult {
429            results,
430            deferred_facts,
431        });
432    }
433
434    let requests: Vec<ComputeRequest> = pending
435        .iter()
436        .map(|(_, resolved)| ComputeRequest {
437            relation: rel.to_string(),
438            args: resolved.clone(),
439        })
440        .collect();
441    let batch_results = dispatch_batch_to_backend(inner, &requests);
442
443    for (batch_idx, result) in batch_results.into_iter().enumerate() {
444        let member_idx = pending[batch_idx].0;
445        match result {
446            Ok(r) => {
447                results[member_idx] = r;
448                if r {
449                    if let Some(fact) = build_ground_fact_from_resolved(rel, &pending[batch_idx].1)
450                    {
451                        deferred_facts.push(fact);
452                    }
453                }
454            }
455            Err(_) => return None,
456        }
457    }
458    Some(BatchComputeResult {
459        results,
460        deferred_facts,
461    })
462}