Skip to main content

plg_runtime/
control.rs

1//! Query-level control constructs and deterministic builtins.
2//!
3//! Clause bodies compile control flow to native code; this module only
4//! serves goals built at RUNTIME — the `--query` string today, call/1
5//! metacalls in M4. It walks goal TERMS, never clauses (the rule in
6//! docs/design/LESSONS_FROM_V1.md stays intact).
7//!
8//! The implementations mirror the compiled lowering exactly (same
9//! choice-point shapes, same commit heights), so a goal behaves
10//! identically whether it appears in a clause body or a query.
11
12use crate::builtins::pred;
13use crate::cell::*;
14use crate::machine::{ContFn, Machine, NO_SITE};
15use crate::solve::call_goal;
16use crate::unify::unify;
17
18/// Invoke the current continuation (a goal succeeded deterministically).
19fn invoke_k(m: &mut Machine) -> i32 {
20    let k = m.k_fn;
21    let e = m.k_env;
22    unsafe { k(m as *mut Machine, e) }
23}
24
25fn det(m: &mut Machine, ok: bool) -> i32 {
26    if ok { invoke_k(m) } else { 0 }
27}
28
29/// Try to handle `name/arity` as a control construct or deterministic
30/// builtin. Returns None when it's an ordinary predicate call.
31pub fn try_builtin(m: &mut Machine, name: &str, args_idx: usize, arity: u32) -> Option<i32> {
32    let mp = m as *mut Machine;
33    // Snapshot the argument words: goal args are read-only here and the
34    // heap may grow (frames) before they're consumed.
35    let a: Vec<Word> = (0..arity as usize).map(|i| m.heap[args_idx + i]).collect();
36    let arg = |i: usize| -> Word { a[i] };
37    let r = match (name, arity) {
38        (",", 2) => conjunction(m, arg(0), arg(1)),
39        (";", 2) => {
40            // `(C -> T ; E)` is if-then-else.
41            let lhs = m.deref(arg(0));
42            if tag_of(lhs) == TAG_STR {
43                let idx = payload(lhs) as usize;
44                let (f, n) = unpack_functor(m.heap[idx]);
45                if n == 2 && m.atoms.resolve(f) == "->" {
46                    let (c, t) = (m.heap[idx + 1], m.heap[idx + 2]);
47                    return Some(if_then_else(m, c, t, Some(arg(1))));
48                }
49            }
50            disjunction(m, arg(0), arg(1))
51        }
52        ("->", 2) => if_then_else(m, arg(0), arg(1), None),
53        ("\\+", 1) => naf(m, arg(0)),
54        ("once", 1) => once(m, arg(0)),
55        ("catch", 3) => catch_impl(m, arg(0), arg(1), arg(2)),
56        ("throw", 1) => {
57            crate::errors::throw_term(m, arg(0));
58            0
59        }
60        ("findall", 3) => findall_impl(m, arg(0), arg(1), arg(2)),
61        ("call", n) if n >= 1 => metacall_extend(m, arg(0), &a[1..]),
62        ("between", 3) => between_impl(m, arg(0), arg(1), arg(2)),
63        ("=", 2) => {
64            let ok = unify(m, arg(0), arg(1));
65            det(m, ok)
66        }
67        ("\\=", 2) => {
68            let ok = pred::plg_rt_b_neq(mp, arg(0), arg(1)) != 0;
69            det(m, ok)
70        }
71        ("is", 2) => {
72            // Runtime-walked (query/metacall): no compiled call site.
73            let ok = pred::plg_rt_b_is(mp, arg(0), arg(1), crate::machine::NO_SITE) != 0;
74            det(m, ok)
75        }
76        ("compare", 3) => {
77            let ok = pred::plg_rt_b_compare(mp, arg(0), arg(1), arg(2)) != 0;
78            det(m, ok)
79        }
80        (op, 2) if arith_op(op).is_some() => {
81            let ok = pred::plg_rt_b_arith_cmp(
82                mp,
83                arith_op(op).unwrap(),
84                arg(0),
85                arg(1),
86                crate::machine::NO_SITE,
87            ) != 0;
88            det(m, ok)
89        }
90        (op, 2) if order_op(op).is_some() => {
91            let ok = pred::plg_rt_b_term_cmp(mp, order_op(op).unwrap(), arg(0), arg(1)) != 0;
92            det(m, ok)
93        }
94        _ => {
95            let ok = det_builtin(mp, name, arity, &a)?;
96            det(m, ok)
97        }
98    };
99    Some(r)
100}
101
102/// Query-side dispatch for the deterministic builtin vocabulary —
103/// mirrors codegen's DET_BUILTINS table (lower.rs); the diff corpus
104/// guards the pair. Returns None for non-builtins.
105fn det_builtin(mp: *mut Machine, name: &str, arity: u32, a: &[Word]) -> Option<bool> {
106    use crate::builtins::{atomops, miscops, sortops, termops, typecheck};
107    let r = match (name, arity) {
108        ("var", 1) => typecheck::plg_rt_b_var_1(mp, a[0]),
109        ("nonvar", 1) => typecheck::plg_rt_b_nonvar_1(mp, a[0]),
110        ("atom", 1) => typecheck::plg_rt_b_atom_1(mp, a[0]),
111        ("number", 1) => typecheck::plg_rt_b_number_1(mp, a[0]),
112        ("integer", 1) => typecheck::plg_rt_b_integer_1(mp, a[0]),
113        ("float", 1) => typecheck::plg_rt_b_float_1(mp, a[0]),
114        ("compound", 1) => typecheck::plg_rt_b_compound_1(mp, a[0]),
115        ("is_list", 1) => typecheck::plg_rt_b_is_list_1(mp, a[0]),
116        // Runtime-walked (query/metacall): raising builtins get NO_SITE.
117        ("functor", 3) => termops::plg_rt_b_functor_3(mp, a[0], a[1], a[2], NO_SITE),
118        ("arg", 3) => termops::plg_rt_b_arg_3(mp, a[0], a[1], a[2], NO_SITE),
119        ("=..", 2) => termops::plg_rt_b_univ_2(mp, a[0], a[1], NO_SITE),
120        ("copy_term", 2) => termops::plg_rt_b_copy_term_2(mp, a[0], a[1]),
121        ("atom_length", 2) => atomops::plg_rt_b_atom_length_2(mp, a[0], a[1], NO_SITE),
122        ("atom_concat", 3) => atomops::plg_rt_b_atom_concat_3(mp, a[0], a[1], a[2], NO_SITE),
123        ("atom_chars", 2) => atomops::plg_rt_b_atom_chars_2(mp, a[0], a[1], NO_SITE),
124        ("number_chars", 2) => atomops::plg_rt_b_number_chars_2(mp, a[0], a[1], NO_SITE),
125        ("number_codes", 2) => atomops::plg_rt_b_number_codes_2(mp, a[0], a[1], NO_SITE),
126        ("msort", 2) => sortops::plg_rt_b_msort_2(mp, a[0], a[1], NO_SITE),
127        ("sort", 2) => sortops::plg_rt_b_sort_2(mp, a[0], a[1], NO_SITE),
128        ("succ", 2) => miscops::plg_rt_b_succ_2(mp, a[0], a[1], NO_SITE),
129        ("plus", 3) => miscops::plg_rt_b_plus_3(mp, a[0], a[1], a[2], NO_SITE),
130        ("unify_with_occurs_check", 2) => {
131            miscops::plg_rt_b_unify_with_occurs_check_2(mp, a[0], a[1])
132        }
133        ("write", 1) => miscops::plg_rt_b_write_1(mp, a[0]),
134        ("writeln", 1) => miscops::plg_rt_b_writeln_1(mp, a[0]),
135        _ => return None,
136    };
137    Some(r != 0)
138}
139
140/// Atom-only goals (`true`, `fail`, `!`).
141pub fn try_atom_builtin(m: &mut Machine, name: &str) -> Option<i32> {
142    match name {
143        "true" => Some(invoke_k(m)),
144        "nl" => {
145            let ok = crate::builtins::miscops::plg_rt_b_nl_0(m as *mut Machine) != 0;
146            Some(det(m, ok))
147        }
148        "fail" | "false" => Some(0),
149        "!" => {
150            // Cut to the walker barrier: 0 at the query top level,
151            // local inside call-like constructs (\+, once, ->-condition,
152            // call/N, findall goals). cut_to stops at catch frames.
153            let h = m.qbarrier;
154            m.cut_to(h);
155            Some(invoke_k(m))
156        }
157        _ => None,
158    }
159}
160
161/// ABI op codes — must match codegen's lower.rs tables.
162fn arith_op(name: &str) -> Option<i32> {
163    Some(match name {
164        "<" => 0,
165        ">" => 1,
166        "=<" => 2,
167        ">=" => 3,
168        "=:=" => 4,
169        "=\\=" => 5,
170        _ => return None,
171    })
172}
173
174fn order_op(name: &str) -> Option<i32> {
175    Some(match name {
176        "==" => 0,
177        "\\==" => 1,
178        "@<" => 2,
179        "@>" => 3,
180        "@=<" => 4,
181        "@>=" => 5,
182        _ => return None,
183    })
184}
185
186/// Snapshot the continuation AND the walker cut barrier (3 cells:
187/// k_fn, k_env, qbarrier) — the runtime mirror of compiled cut slots.
188fn save_k(m: &mut Machine, frame: usize, at: usize) {
189    m.heap[frame + at] = m.k_fn as usize as u64;
190    m.heap[frame + at + 1] = m.k_env;
191    m.heap[frame + at + 2] = m.qbarrier as u64;
192}
193
194/// Restore continuation + barrier from a frame.
195fn load_k(m: &mut Machine, frame: usize, at: usize) -> (ContFn, u64) {
196    let k: ContFn = unsafe { std::mem::transmute(m.heap[frame + at] as usize) };
197    m.qbarrier = m.heap[frame + at + 2] as usize;
198    (k, m.heap[frame + at + 1])
199}
200
201/// `,`/2: run A with a continuation that runs B.
202fn conjunction(m: &mut Machine, a: Word, b: Word) -> i32 {
203    let frame = m.frame_alloc(4);
204    m.heap[frame] = b;
205    save_k(m, frame, 1);
206    m.k_fn = conj_k;
207    m.k_env = frame as u64;
208    call_goal(m, a)
209}
210
211unsafe extern "C" fn conj_k(m: *mut Machine, env: u64) -> i32 {
212    let m = unsafe { &mut *m };
213    let frame = env as usize;
214    let b = m.heap[frame];
215    let (kf, ke) = load_k(m, frame, 1);
216    m.k_fn = kf;
217    m.k_env = ke;
218    call_goal(m, b)
219}
220
221/// `(A ; B)`: push a CP retrying B (with the current k restored), run A.
222fn disjunction(m: &mut Machine, a: Word, b: Word) -> i32 {
223    let frame = m.frame_alloc(4);
224    m.heap[frame] = b;
225    save_k(m, frame, 1);
226    m.push_cp(disj_retry, frame as u64);
227    call_goal(m, a)
228}
229
230unsafe extern "C" fn disj_retry(m: *mut Machine, env: u64) -> i32 {
231    let m = unsafe { &mut *m };
232    let frame = env as usize;
233    let b = m.heap[frame];
234    let (kf, ke) = load_k(m, frame, 1);
235    m.k_fn = kf;
236    m.k_env = ke;
237    call_goal(m, b)
238}
239
240/// `(C -> T ; E)` / `(C -> T)`: commit to C's first solution.
241fn if_then_else(m: &mut Machine, c: Word, t: Word, e: Option<Word>) -> i32 {
242    let h = m.cps.len() as u64; // BEFORE the else CP
243    if let Some(e) = e {
244        let ef = m.frame_alloc(4);
245        m.heap[ef] = e;
246        save_k(m, ef, 1);
247        m.push_cp(disj_retry, ef as u64);
248    }
249    let tf = m.frame_alloc(5);
250    m.heap[tf] = t;
251    save_k(m, tf, 1);
252    m.heap[tf + 4] = h;
253    m.k_fn = ite_then;
254    m.k_env = tf as u64;
255    // The condition is call-like: `!` inside it is local (cuts to the
256    // height AFTER the else CP).
257    m.qbarrier = m.cps.len();
258    call_goal(m, c)
259}
260
261unsafe extern "C" fn ite_then(m: *mut Machine, env: u64) -> i32 {
262    let m = unsafe { &mut *m };
263    let frame = env as usize;
264    let h = m.heap[frame + 4] as usize;
265    m.cps.truncate(h); // commit: kill E and C's alternatives
266    let t = m.heap[frame];
267    let (kf, ke) = load_k(m, frame, 1); // also restores the outer barrier
268    m.k_fn = kf;
269    m.k_env = ke;
270    call_goal(m, t)
271}
272
273/// `once(G)`: commit to G's first solution, then continue.
274fn once(m: &mut Machine, g: Word) -> i32 {
275    let h = m.cps.len() as u64;
276    let frame = m.frame_alloc(4);
277    save_k(m, frame, 0);
278    m.heap[frame + 3] = h;
279    m.k_fn = once_then;
280    m.k_env = frame as u64;
281    m.qbarrier = m.cps.len(); // call-like: `!` inside G is local
282    call_goal(m, g)
283}
284
285unsafe extern "C" fn once_then(m: *mut Machine, env: u64) -> i32 {
286    let m = unsafe { &mut *m };
287    let frame = env as usize;
288    let h = m.heap[frame + 3] as usize;
289    m.cps.truncate(h);
290    let (kf, ke) = load_k(m, frame, 0);
291    m.k_fn = kf;
292    m.k_env = ke;
293    invoke_k(m)
294}
295
296/// `\+ G`: push a CP that CONTINUES (driver rewind undoes G's
297/// bindings); if G succeeds, cut to the pre-NAF height and fail.
298fn naf(m: &mut Machine, g: Word) -> i32 {
299    let h = m.cps.len() as u64;
300    let cf = m.frame_alloc(3);
301    save_k(m, cf, 0);
302    m.push_cp(naf_continue, cf as u64);
303    let ff = m.frame_alloc(1);
304    m.heap[ff] = h;
305    m.k_fn = naf_found;
306    m.k_env = ff as u64;
307    // Call-like: `!` inside G is local (cannot prune the continue CP).
308    m.qbarrier = m.cps.len();
309    call_goal(m, g)
310}
311
312unsafe extern "C" fn naf_continue(m: *mut Machine, env: u64) -> i32 {
313    let m = unsafe { &mut *m };
314    let frame = env as usize;
315    let (kf, ke) = load_k(m, frame, 0);
316    m.k_fn = kf;
317    m.k_env = ke;
318    invoke_k(m)
319}
320
321unsafe extern "C" fn naf_found(m: *mut Machine, env: u64) -> i32 {
322    let m = unsafe { &mut *m };
323    let h = m.heap[env as usize] as usize;
324    m.cps.truncate(h); // removes the continue-CP and G's alternatives
325    0
326}
327
328/// `catch(Goal, Catcher, Recovery)`: push a catch frame (transparent to
329/// normal backtracking, a target for error unwinding in solve::drive
330/// and a barrier for cut), then run Goal with the current continuation.
331fn catch_impl(m: &mut Machine, goal: Word, catcher: Word, recovery: Word) -> i32 {
332    let frame = m.frame_alloc(5);
333    m.heap[frame] = catcher;
334    m.heap[frame + 1] = recovery;
335    save_k(m, frame, 2);
336    m.push_catch_cp(catch_retry, frame as u64);
337    call_goal(m, goal)
338}
339
340/// Backtracking INTO a catch frame (no error in flight) is transparent:
341/// the frame offers no alternatives.
342unsafe extern "C" fn catch_retry(_m: *mut Machine, _env: u64) -> i32 {
343    0
344}
345
346/// `findall(Template, Goal, Bag)`: run Goal to exhaustion in a bounded
347/// sub-search, snapshotting Template per solution; rewind everything;
348/// unify Bag with the collected list. Errors propagate out (a catch
349/// inside Goal is handled by the shared driver within our floor).
350fn findall_impl(m: &mut Machine, template: Word, goal: Word, bag: Word) -> i32 {
351    let floor = m.cps.len();
352    let tmark = m.trail.len();
353    let hmark = m.heap.len();
354    let saved_k = (m.k_fn, m.k_env, m.qbarrier);
355    m.findall_stack.push(Vec::new());
356    let cf = m.frame_alloc(1);
357    m.heap[cf] = template;
358    m.k_fn = findall_collect;
359    m.k_env = cf as u64;
360    m.qbarrier = m.cps.len(); // call-like: `!` inside Goal is local
361    let r = call_goal(m, goal);
362    crate::solve::drive(m, floor, r); // collector always returns 0
363    let results = m.findall_stack.pop().unwrap();
364    m.k_fn = saved_k.0;
365    m.k_env = saved_k.1;
366    m.qbarrier = saved_k.2;
367    if m.error.is_some() {
368        return 0;
369    }
370    // Discard the goal's bindings and workspace, then build the bag.
371    m.rewind_to(tmark, hmark);
372    let mut w = make_atom(plg_shared::atom::ATOM_NIL);
373    for buf in results.iter().rev() {
374        let e = crate::copyterm::restore_from_buf(m, buf);
375        let idx = m.heap.len();
376        m.heap.push(e);
377        m.heap.push(w);
378        w = make(TAG_LST, idx as u64);
379    }
380    let ok = unify(m, bag, w);
381    det(m, ok)
382}
383
384unsafe extern "C" fn findall_collect(m: *mut Machine, env: u64) -> i32 {
385    let m = unsafe { &mut *m };
386    let template = m.heap[env as usize];
387    let buf = crate::copyterm::copy_to_buf(m, template);
388    m.findall_stack
389        .last_mut()
390        .expect("collector outside findall")
391        .push(buf);
392    0 // force backtracking into the next solution
393}
394
395/// `call/N`: extend the goal with extra arguments (ISO 7.8.3 partial
396/// application), then dispatch it.
397fn metacall_extend(m: &mut Machine, goal: Word, extras: &[Word]) -> i32 {
398    let goal = m.deref(goal);
399    // call/N is opaque to cut (ISO): `!` inside the called goal is local.
400    m.qbarrier = m.cps.len();
401    if extras.is_empty() {
402        return call_goal(m, goal);
403    }
404    let extended = match tag_of(goal) {
405        TAG_ATOM => {
406            let f = atom_id(goal);
407            let idx = m.heap.len();
408            m.heap.push(pack_functor(f, extras.len() as u32));
409            m.heap.extend_from_slice(extras);
410            make(TAG_STR, idx as u64)
411        }
412        TAG_STR => {
413            let sidx = payload(goal) as usize;
414            let (f, n) = unpack_functor(m.heap[sidx]);
415            let idx = m.heap.len();
416            m.heap.push(pack_functor(f, n + extras.len() as u32));
417            for i in 0..n as usize {
418                let w = m.heap[sidx + 1 + i];
419                m.heap.push(w);
420            }
421            m.heap.extend_from_slice(extras);
422            make(TAG_STR, idx as u64)
423        }
424        TAG_REF => {
425            crate::errors::instantiation(m, "call/N requires a bound goal");
426            return 0;
427        }
428        _ => {
429            crate::errors::type_error(m, "callable", goal, "Goal is not callable");
430            return 0;
431        }
432    };
433    call_goal(m, extended)
434}
435
436/// `between(Low, High, X)` — the one nondeterministic builtin: with X
437/// unbound it enumerates Low..=High via a runtime-retried choice point.
438fn between_impl(m: &mut Machine, lo_w: Word, hi_w: Word, x_w: Word) -> i32 {
439    let Some(lo) = int_arg(m, lo_w, "between/3") else {
440        return 0;
441    };
442    let Some(hi) = int_arg(m, hi_w, "between/3") else {
443        return 0;
444    };
445    let x = m.deref(x_w);
446    match tag_of(x) {
447        TAG_INT | TAG_BIG => {
448            let xv = if tag_of(x) == TAG_INT {
449                int_value(x)
450            } else {
451                m.heap[payload(x) as usize] as i64
452            };
453            det(m, lo <= xv && xv <= hi)
454        }
455        TAG_REF => {
456            if lo > hi {
457                return 0;
458            }
459            // Frame: [x, current, high, k_fn, k_env, qbarrier].
460            // `current` is mutated in place across retries (untrailed
461            // by design — the frame is control state, not a term).
462            let frame = m.frame_alloc(6);
463            m.heap[frame] = x;
464            m.heap[frame + 1] = lo as u64;
465            m.heap[frame + 2] = hi as u64;
466            save_k(m, frame, 3);
467            if lo < hi {
468                m.push_cp(between_retry, frame as u64);
469            }
470            let w = int_to_word(m, lo);
471            let ok = unify(m, x, w);
472            debug_assert!(ok, "binding a fresh var cannot fail");
473            invoke_k(m)
474        }
475        _ => {
476            crate::errors::type_error(m, "integer", x, "between/3 requires an integer");
477            0
478        }
479    }
480}
481
482unsafe extern "C" fn between_retry(m: *mut Machine, env: u64) -> i32 {
483    let m = unsafe { &mut *m };
484    let frame = env as usize;
485    let x = m.heap[frame];
486    let cur = m.heap[frame + 1] as i64 + 1;
487    let hi = m.heap[frame + 2] as i64;
488    m.heap[frame + 1] = cur as u64;
489    if cur < hi {
490        m.push_cp(between_retry, frame as u64);
491    }
492    let (kf, ke) = load_k(m, frame, 3);
493    m.k_fn = kf;
494    m.k_env = ke;
495    let w = int_to_word(m, cur);
496    let ok = unify(m, x, w);
497    debug_assert!(ok, "x rewound to unbound before retry");
498    invoke_k(m)
499}
500
501/// Build an integer word: immediate when it fits i61, boxed otherwise.
502fn int_to_word(m: &mut Machine, n: i64) -> Word {
503    if (INT_MIN..=INT_MAX).contains(&n) {
504        make_int(n)
505    } else {
506        let idx = m.heap.len();
507        m.heap.push(n as u64);
508        make(TAG_BIG, idx as u64)
509    }
510}
511
512/// Deref an integer argument for between/3 (v1: bounds must be bound
513/// integers).
514fn int_arg(m: &mut Machine, w: Word, who: &str) -> Option<i64> {
515    let w = m.deref(w);
516    match tag_of(w) {
517        TAG_INT => Some(int_value(w)),
518        TAG_BIG => Some(m.heap[payload(w) as usize] as i64),
519        TAG_REF => {
520            let ctx = format!("{who} requires bound integer bounds");
521            crate::errors::instantiation(m, &ctx);
522            None
523        }
524        _ => {
525            let ctx = format!("{who} requires an integer");
526            crate::errors::type_error(m, "integer", w, &ctx);
527            None
528        }
529    }
530}
531
532/// Compiled-code entry for between/3 (uniform predicate signature,
533/// arguments in the A registers — dispatched like a user predicate).
534/// # Safety
535/// Called from generated code with the live Machine pointer.
536#[unsafe(no_mangle)]
537pub unsafe extern "C" fn plg_rt_pred_between_3(m: *mut Machine, _env: u64) -> i32 {
538    let m = unsafe { &mut *m };
539    let (lo, hi, x) = (m.areg[0], m.areg[1], m.areg[2]);
540    between_impl(m, lo, hi, x)
541}
542
543/// Compiled-code entries for control builtins taking goal terms.
544/// # Safety
545/// Called from generated code with the live Machine pointer.
546#[unsafe(no_mangle)]
547pub unsafe extern "C" fn plg_rt_metacall(m: *mut Machine, goal: u64) -> i32 {
548    let m = unsafe { &mut *m };
549    // Goals reaching the walker from compiled code are call-like:
550    // a runtime-walked `!` inside them is local.
551    m.qbarrier = m.cps.len();
552    call_goal(m, goal)
553}
554
555/// # Safety
556/// Called from generated code with the live Machine pointer.
557#[unsafe(no_mangle)]
558pub unsafe extern "C" fn plg_rt_b_catch_3(m: *mut Machine, g: u64, c: u64, r: u64) -> i32 {
559    catch_impl(unsafe { &mut *m }, g, c, r)
560}
561
562/// # Safety
563/// Called from generated code with the live Machine pointer.
564#[unsafe(no_mangle)]
565pub unsafe extern "C" fn plg_rt_b_throw_1(m: *mut Machine, ball: u64) -> i32 {
566    crate::errors::throw_term(unsafe { &mut *m }, ball);
567    0
568}
569
570/// # Safety
571/// Called from generated code with the live Machine pointer.
572#[unsafe(no_mangle)]
573pub unsafe extern "C" fn plg_rt_b_findall_3(m: *mut Machine, t: u64, g: u64, b: u64) -> i32 {
574    findall_impl(unsafe { &mut *m }, t, g, b)
575}
576
577#[cfg(test)]
578mod tests {
579    use crate::machine::Machine;
580    use crate::query::parse_query;
581    use crate::solve::{Outcome, solve};
582    use plg_shared::StringInterner;
583
584    fn run(query: &str) -> (Vec<String>, Option<String>) {
585        let mut m = Machine::new(StringInterner::new(), Vec::new());
586        let goal = parse_query(&mut m, query).unwrap();
587        let outcome = solve(&mut m, goal);
588        let err = match outcome {
589            Outcome::Error => Some(m.error.take().unwrap().message),
590            Outcome::Done => None,
591        };
592        let sols = m
593            .solutions
594            .iter()
595            .map(|s| {
596                s.bindings
597                    .iter()
598                    .map(|(n, _, t)| format!("{n}={t}"))
599                    .collect::<Vec<_>>()
600                    .join(",")
601            })
602            .collect();
603        (sols, err)
604    }
605
606    #[test]
607    fn top_level_is_and_comparison() {
608        assert_eq!(run("X is 2 + 3 * 4").0, vec!["X=14"]);
609        assert_eq!(run("1 < 2").0, vec![""]);
610        assert_eq!(run("2 < 1").0, Vec::<String>::new());
611    }
612
613    #[test]
614    fn top_level_disjunction_enumerates() {
615        assert_eq!(run("(X = 1 ; X = 2)").0, vec!["X=1", "X=2"]);
616    }
617
618    #[test]
619    fn top_level_ite_and_naf() {
620        assert_eq!(run("(1 < 2 -> X = yes ; X = no)").0, vec!["X=yes"]);
621        assert_eq!(run("(2 < 1 -> X = yes ; X = no)").0, vec!["X=no"]);
622        assert_eq!(run("\\+ 2 < 1").0, vec![""]);
623        assert_eq!(run("\\+ 1 < 2").0, Vec::<String>::new());
624        // NAF undoes inner bindings.
625        assert_eq!(run("\\+ (X = 1, 2 < 1), X = ok").0, vec!["X=ok"]);
626    }
627
628    #[test]
629    fn top_level_once_commits() {
630        assert_eq!(run("once((X = 1 ; X = 2))").0, vec!["X=1"]);
631    }
632
633    #[test]
634    fn errors_propagate() {
635        let (_, err) = run("X is 1 // 0");
636        assert!(err.unwrap().contains("zero_divisor"));
637    }
638}
639
640#[cfg(test)]
641mod m4_tests {
642    use crate::machine::Machine;
643    use crate::query::parse_query;
644    use crate::solve::{Outcome, solve};
645    use plg_shared::StringInterner;
646
647    fn run(query: &str) -> (Vec<String>, Option<String>) {
648        let mut m = Machine::new(StringInterner::new(), Vec::new());
649        let goal = parse_query(&mut m, query).unwrap();
650        let outcome = solve(&mut m, goal);
651        let err = match outcome {
652            Outcome::Error => Some(m.error.take().unwrap().message),
653            Outcome::Done => None,
654        };
655        let sols = m
656            .solutions
657            .iter()
658            .map(|s| {
659                s.bindings
660                    .iter()
661                    .map(|(n, _, t)| format!("{n}={t}"))
662                    .collect::<Vec<_>>()
663                    .join(",")
664            })
665            .collect();
666        (sols, err)
667    }
668
669    #[test]
670    fn throw_uncaught_propagates_with_rendered_ball() {
671        let (sols, err) = run("throw(my_ball)");
672        assert!(sols.is_empty());
673        assert_eq!(err.unwrap(), "my_ball");
674    }
675
676    #[test]
677    fn catch_catches_matching_ball_and_runs_recovery() {
678        let (sols, err) = run("catch(throw(oops(1)), oops(N), X = caught(N))");
679        assert!(err.is_none());
680        assert_eq!(sols, vec!["N=1,X=caught(1)"]);
681    }
682
683    #[test]
684    fn catch_passes_nonmatching_ball_outward() {
685        let (sols, err) = run("catch(throw(other), oops(_), X = no)");
686        assert!(sols.is_empty());
687        assert_eq!(err.unwrap(), "other");
688    }
689
690    #[test]
691    fn nested_catch_inner_first() {
692        let (sols, err) = run("catch(catch(throw(b), a, X = inner_a), b, X = outer_b)");
693        assert!(err.is_none());
694        assert_eq!(sols, vec!["X=outer_b"]);
695    }
696
697    #[test]
698    fn catch_traps_builtin_errors() {
699        let (sols, err) = run("catch(X is 1 // 0, error(evaluation_error(E), _), Y = E)");
700        assert!(err.is_none());
701        assert_eq!(sols, vec!["E=zero_divisor,X=_0,Y=zero_divisor"]);
702    }
703
704    #[test]
705    fn catch_is_transparent_to_normal_backtracking() {
706        let (sols, err) = run("catch((X = 1 ; X = 2), _, fail)");
707        assert!(err.is_none());
708        assert_eq!(sols, vec!["X=1", "X=2"]);
709    }
710
711    #[test]
712    fn step_limit_is_not_catchable() {
713        let mut m = Machine::new(StringInterner::new(), Vec::new());
714        m.step_limit = 1;
715        m.steps = 1; // next step() trips the limit
716        assert!(!m.step());
717        let goal = parse_query(&mut m, "catch(true, _, true)").unwrap();
718        // error pre-armed and uncatchable: solve returns Error untouched
719        assert!(matches!(solve(&mut m, goal), Outcome::Error));
720        assert!(m.error.as_ref().unwrap().uncatchable);
721    }
722
723    #[test]
724    fn findall_collects_and_rewinds() {
725        let (sols, err) = run("findall(X, (X = 1 ; X = 2 ; X = 3), L)");
726        assert!(err.is_none());
727        assert_eq!(sols, vec!["L=[1, 2, 3],X=_0"]);
728    }
729
730    #[test]
731    fn findall_empty_on_failure() {
732        let (sols, err) = run("findall(X, fail, L)");
733        assert!(err.is_none());
734        assert_eq!(sols, vec!["L=[],X=_0"]);
735    }
736
737    #[test]
738    fn findall_propagates_errors() {
739        let (sols, err) = run("findall(X, throw(bad), L)");
740        assert!(sols.is_empty());
741        assert_eq!(err.unwrap(), "bad");
742    }
743
744    #[test]
745    fn nested_findall() {
746        let (sols, err) = run("findall(L1, (Y = 2, findall(X, (X = 1 ; X = Y), L1)), L)");
747        assert!(err.is_none());
748        assert_eq!(sols.len(), 1);
749        assert!(sols[0].contains("L=[[1, 2]]"), "{sols:?}");
750    }
751
752    #[test]
753    fn call_n_extends_goals() {
754        let (sols, err) = run("call(=, X, 7)");
755        assert!(err.is_none());
756        assert_eq!(sols, vec!["X=7"]);
757        let (sols, _) = run("G = (X = 1 ; X = 2), call(G)");
758        assert_eq!(sols.len(), 2);
759    }
760
761    #[test]
762    fn call_unbound_is_instantiation_error() {
763        let (sols, err) = run("call(X)");
764        assert!(sols.is_empty());
765        assert!(err.unwrap().contains("instantiation_error"));
766    }
767
768    #[test]
769    fn between_enumerates_and_checks() {
770        let (sols, err) = run("between(1, 4, X)");
771        assert!(err.is_none());
772        assert_eq!(sols, vec!["X=1", "X=2", "X=3", "X=4"]);
773        let (sols, _) = run("between(1, 4, 3)");
774        assert_eq!(sols.len(), 1);
775        let (sols, _) = run("between(1, 4, 9)");
776        assert!(sols.is_empty());
777        let (sols, _) = run("between(3, 1, X)");
778        assert!(sols.is_empty());
779        // single-value range: no choice point churn
780        let (sols, _) = run("between(2, 2, X)");
781        assert_eq!(sols, vec!["X=2"]);
782    }
783
784    #[test]
785    fn findall_with_between() {
786        let (sols, err) = run("findall(X, between(1, 5, X), L)");
787        assert!(err.is_none());
788        assert_eq!(sols, vec!["L=[1, 2, 3, 4, 5],X=_0"]);
789    }
790}
791
792#[cfg(test)]
793mod vocab_invariant {
794    //! Runtime half of the `plg-shared::builtins` invariant
795    //! (docs/design/BUILTIN_VOCAB.md). `det_builtin` above is the
796    //! query-side mirror of codegen's `DET_BUILTINS`; this asserts the
797    //! arms it handles are EXACTLY the arity>0 `Det` rows of `BUILTINS`
798    //! (`nl/0` is Det but dispatched via `try_atom_builtin`, so it is
799    //! excluded here). Referenced only under `#[cfg(test)]` — the `doc`
800    //! strings never reach a compiled program binary.
801    use plg_shared::{BUILTINS, builtins::BuiltinKind};
802    use std::collections::BTreeSet;
803
804    /// Hand mirror of the `det_builtin` match arms. Adding an arm there
805    /// without updating this (or `BUILTINS`) turns the test red.
806    #[rustfmt::skip]
807    const DET_DISPATCH: &[(&str, u32)] = &[
808        ("var", 1), ("nonvar", 1), ("atom", 1), ("number", 1), ("integer", 1),
809        ("float", 1), ("compound", 1), ("is_list", 1), ("functor", 3), ("arg", 3),
810        ("=..", 2), ("copy_term", 2), ("atom_length", 2), ("atom_concat", 3),
811        ("atom_chars", 2), ("number_chars", 2), ("number_codes", 2), ("msort", 2),
812        ("sort", 2), ("succ", 2), ("plus", 3), ("unify_with_occurs_check", 2),
813        ("write", 1), ("writeln", 1),
814    ];
815
816    #[test]
817    fn det_dispatch_equals_shared_det_subset() {
818        let dispatch: BTreeSet<(&str, u32)> = DET_DISPATCH.iter().copied().collect();
819        let shared_det: BTreeSet<(&str, u32)> = BUILTINS
820            .iter()
821            .filter(|s| s.kind == BuiltinKind::Det && s.arity > 0)
822            .map(|s| (s.name, s.arity))
823            .collect();
824        assert_eq!(
825            dispatch, shared_det,
826            "runtime det dispatch diverges from BUILTINS Det subset \
827             (left = control.rs, right = shared table)"
828        );
829    }
830}