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        ("atom_concat", 3) => atom_concat_impl(m, arg(0), arg(1), arg(2), NO_SITE),
64        ("=", 2) => {
65            let ok = unify(m, arg(0), arg(1));
66            det(m, ok)
67        }
68        ("\\=", 2) => {
69            let ok = pred::plg_rt_b_neq(mp, arg(0), arg(1)) != 0;
70            det(m, ok)
71        }
72        ("is", 2) => {
73            // Runtime-walked (query/metacall): no compiled call site.
74            let ok = pred::plg_rt_b_is(mp, arg(0), arg(1), crate::machine::NO_SITE) != 0;
75            det(m, ok)
76        }
77        ("compare", 3) => {
78            let ok = pred::plg_rt_b_compare(mp, arg(0), arg(1), arg(2)) != 0;
79            det(m, ok)
80        }
81        (op, 2) if arith_op(op).is_some() => {
82            let ok = pred::plg_rt_b_arith_cmp(
83                mp,
84                arith_op(op).unwrap(),
85                arg(0),
86                arg(1),
87                crate::machine::NO_SITE,
88            ) != 0;
89            det(m, ok)
90        }
91        (op, 2) if order_op(op).is_some() => {
92            let ok = pred::plg_rt_b_term_cmp(mp, order_op(op).unwrap(), arg(0), arg(1)) != 0;
93            det(m, ok)
94        }
95        _ => {
96            let ok = det_builtin(mp, name, arity, &a)?;
97            det(m, ok)
98        }
99    };
100    Some(r)
101}
102
103/// Query-side dispatch for the deterministic builtin vocabulary —
104/// mirrors codegen's DET_BUILTINS table (lower.rs); the diff corpus
105/// guards the pair. Returns None for non-builtins.
106fn det_builtin(mp: *mut Machine, name: &str, arity: u32, a: &[Word]) -> Option<bool> {
107    use crate::builtins::{atomops, miscops, sortops, termops, typecheck};
108    let r = match (name, arity) {
109        ("var", 1) => typecheck::plg_rt_b_var_1(mp, a[0]),
110        ("nonvar", 1) => typecheck::plg_rt_b_nonvar_1(mp, a[0]),
111        ("atom", 1) => typecheck::plg_rt_b_atom_1(mp, a[0]),
112        ("number", 1) => typecheck::plg_rt_b_number_1(mp, a[0]),
113        ("integer", 1) => typecheck::plg_rt_b_integer_1(mp, a[0]),
114        ("float", 1) => typecheck::plg_rt_b_float_1(mp, a[0]),
115        ("compound", 1) => typecheck::plg_rt_b_compound_1(mp, a[0]),
116        ("is_list", 1) => typecheck::plg_rt_b_is_list_1(mp, a[0]),
117        // Runtime-walked (query/metacall): raising builtins get NO_SITE.
118        ("functor", 3) => termops::plg_rt_b_functor_3(mp, a[0], a[1], a[2], NO_SITE),
119        ("arg", 3) => termops::plg_rt_b_arg_3(mp, a[0], a[1], a[2], NO_SITE),
120        ("=..", 2) => termops::plg_rt_b_univ_2(mp, a[0], a[1], NO_SITE),
121        ("copy_term", 2) => termops::plg_rt_b_copy_term_2(mp, a[0], a[1]),
122        ("atom_length", 2) => atomops::plg_rt_b_atom_length_2(mp, a[0], a[1], 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        ("writeq", 1) => miscops::plg_rt_b_writeq_1(mp, a[0]),
135        ("writeln", 1) => miscops::plg_rt_b_writeln_1(mp, a[0]),
136        _ => return None,
137    };
138    Some(r != 0)
139}
140
141/// Atom-only goals (`true`, `fail`, `!`).
142pub fn try_atom_builtin(m: &mut Machine, name: &str) -> Option<i32> {
143    match name {
144        "true" => Some(invoke_k(m)),
145        "nl" => {
146            let ok = crate::builtins::miscops::plg_rt_b_nl_0(m as *mut Machine) != 0;
147            Some(det(m, ok))
148        }
149        "fail" | "false" => Some(0),
150        "!" => {
151            // Cut to the walker barrier: 0 at the query top level,
152            // local inside call-like constructs (\+, once, ->-condition,
153            // call/N, findall goals). cut_to stops at catch frames.
154            let h = m.qbarrier;
155            m.cut_to(h);
156            Some(invoke_k(m))
157        }
158        _ => None,
159    }
160}
161
162/// ABI op codes — must match codegen's lower.rs tables.
163fn arith_op(name: &str) -> Option<i32> {
164    Some(match name {
165        "<" => 0,
166        ">" => 1,
167        "=<" => 2,
168        ">=" => 3,
169        "=:=" => 4,
170        "=\\=" => 5,
171        _ => return None,
172    })
173}
174
175fn order_op(name: &str) -> Option<i32> {
176    Some(match name {
177        "==" => 0,
178        "\\==" => 1,
179        "@<" => 2,
180        "@>" => 3,
181        "@=<" => 4,
182        "@>=" => 5,
183        _ => return None,
184    })
185}
186
187/// Snapshot the continuation AND the walker cut barrier (3 cells:
188/// k_fn, k_env, qbarrier) — the runtime mirror of compiled cut slots.
189fn save_k(m: &mut Machine, frame: usize, at: usize) {
190    m.heap[frame + at] = m.k_fn as usize as u64;
191    m.heap[frame + at + 1] = m.k_env;
192    m.heap[frame + at + 2] = m.qbarrier as u64;
193}
194
195/// Restore continuation + barrier from a frame.
196fn load_k(m: &mut Machine, frame: usize, at: usize) -> (ContFn, u64) {
197    let k: ContFn = unsafe { std::mem::transmute(m.heap[frame + at] as usize) };
198    m.qbarrier = m.heap[frame + at + 2] as usize;
199    (k, m.heap[frame + at + 1])
200}
201
202/// `,`/2: run A with a continuation that runs B.
203fn conjunction(m: &mut Machine, a: Word, b: Word) -> i32 {
204    let frame = m.frame_alloc(4);
205    m.heap[frame] = b;
206    save_k(m, frame, 1);
207    m.k_fn = conj_k;
208    m.k_env = frame as u64;
209    call_goal(m, a)
210}
211
212unsafe extern "C" fn conj_k(m: *mut Machine, env: u64) -> i32 {
213    let m = unsafe { &mut *m };
214    let frame = env as usize;
215    let b = m.heap[frame];
216    let (kf, ke) = load_k(m, frame, 1);
217    m.k_fn = kf;
218    m.k_env = ke;
219    call_goal(m, b)
220}
221
222/// `(A ; B)`: push a CP retrying B (with the current k restored), run A.
223fn disjunction(m: &mut Machine, a: Word, b: Word) -> i32 {
224    let frame = m.frame_alloc(4);
225    m.heap[frame] = b;
226    save_k(m, frame, 1);
227    m.push_cp(disj_retry, frame as u64);
228    call_goal(m, a)
229}
230
231unsafe extern "C" fn disj_retry(m: *mut Machine, env: u64) -> i32 {
232    let m = unsafe { &mut *m };
233    let frame = env as usize;
234    let b = m.heap[frame];
235    let (kf, ke) = load_k(m, frame, 1);
236    m.k_fn = kf;
237    m.k_env = ke;
238    call_goal(m, b)
239}
240
241/// `(C -> T ; E)` / `(C -> T)`: commit to C's first solution.
242fn if_then_else(m: &mut Machine, c: Word, t: Word, e: Option<Word>) -> i32 {
243    let h = m.cps.len() as u64; // BEFORE the else CP
244    if let Some(e) = e {
245        let ef = m.frame_alloc(4);
246        m.heap[ef] = e;
247        save_k(m, ef, 1);
248        m.push_cp(disj_retry, ef as u64);
249    }
250    let tf = m.frame_alloc(5);
251    m.heap[tf] = t;
252    save_k(m, tf, 1);
253    m.heap[tf + 4] = h;
254    m.k_fn = ite_then;
255    m.k_env = tf as u64;
256    // The condition is call-like: `!` inside it is local (cuts to the
257    // height AFTER the else CP).
258    m.qbarrier = m.cps.len();
259    call_goal(m, c)
260}
261
262unsafe extern "C" fn ite_then(m: *mut Machine, env: u64) -> i32 {
263    let m = unsafe { &mut *m };
264    let frame = env as usize;
265    let h = m.heap[frame + 4] as usize;
266    m.cps.truncate(h); // commit: kill E and C's alternatives
267    let t = m.heap[frame];
268    let (kf, ke) = load_k(m, frame, 1); // also restores the outer barrier
269    m.k_fn = kf;
270    m.k_env = ke;
271    call_goal(m, t)
272}
273
274/// `once(G)`: commit to G's first solution, then continue.
275fn once(m: &mut Machine, g: Word) -> i32 {
276    let h = m.cps.len() as u64;
277    let frame = m.frame_alloc(4);
278    save_k(m, frame, 0);
279    m.heap[frame + 3] = h;
280    m.k_fn = once_then;
281    m.k_env = frame as u64;
282    m.qbarrier = m.cps.len(); // call-like: `!` inside G is local
283    call_goal(m, g)
284}
285
286unsafe extern "C" fn once_then(m: *mut Machine, env: u64) -> i32 {
287    let m = unsafe { &mut *m };
288    let frame = env as usize;
289    let h = m.heap[frame + 3] as usize;
290    m.cps.truncate(h);
291    let (kf, ke) = load_k(m, frame, 0);
292    m.k_fn = kf;
293    m.k_env = ke;
294    invoke_k(m)
295}
296
297/// `\+ G`: push a CP that CONTINUES (driver rewind undoes G's
298/// bindings); if G succeeds, cut to the pre-NAF height and fail.
299fn naf(m: &mut Machine, g: Word) -> i32 {
300    let h = m.cps.len() as u64;
301    let cf = m.frame_alloc(3);
302    save_k(m, cf, 0);
303    m.push_cp(naf_continue, cf as u64);
304    let ff = m.frame_alloc(1);
305    m.heap[ff] = h;
306    m.k_fn = naf_found;
307    m.k_env = ff as u64;
308    // Call-like: `!` inside G is local (cannot prune the continue CP).
309    m.qbarrier = m.cps.len();
310    call_goal(m, g)
311}
312
313unsafe extern "C" fn naf_continue(m: *mut Machine, env: u64) -> i32 {
314    let m = unsafe { &mut *m };
315    let frame = env as usize;
316    let (kf, ke) = load_k(m, frame, 0);
317    m.k_fn = kf;
318    m.k_env = ke;
319    invoke_k(m)
320}
321
322unsafe extern "C" fn naf_found(m: *mut Machine, env: u64) -> i32 {
323    let m = unsafe { &mut *m };
324    let h = m.heap[env as usize] as usize;
325    m.cps.truncate(h); // removes the continue-CP and G's alternatives
326    0
327}
328
329/// `catch(Goal, Catcher, Recovery)`: push a catch frame (transparent to
330/// normal backtracking, a target for error unwinding in solve::drive
331/// and a barrier for cut), then run Goal with the current continuation.
332fn catch_impl(m: &mut Machine, goal: Word, catcher: Word, recovery: Word) -> i32 {
333    let frame = m.frame_alloc(5);
334    m.heap[frame] = catcher;
335    m.heap[frame + 1] = recovery;
336    save_k(m, frame, 2);
337    m.push_catch_cp(catch_retry, frame as u64);
338    call_goal(m, goal)
339}
340
341/// Backtracking INTO a catch frame (no error in flight) is transparent:
342/// the frame offers no alternatives.
343unsafe extern "C" fn catch_retry(_m: *mut Machine, _env: u64) -> i32 {
344    0
345}
346
347/// `findall(Template, Goal, Bag)`: run Goal to exhaustion in a bounded
348/// sub-search, snapshotting Template per solution; rewind everything;
349/// unify Bag with the collected list. Errors propagate out (a catch
350/// inside Goal is handled by the shared driver within our floor).
351fn findall_impl(m: &mut Machine, template: Word, goal: Word, bag: Word) -> i32 {
352    let floor = m.cps.len();
353    let tmark = m.trail.len();
354    let hmark = m.heap.len();
355    let saved_k = (m.k_fn, m.k_env, m.qbarrier);
356    m.findall_stack.push(Vec::new());
357    let cf = m.frame_alloc(1);
358    m.heap[cf] = template;
359    m.k_fn = findall_collect;
360    m.k_env = cf as u64;
361    m.qbarrier = m.cps.len(); // call-like: `!` inside Goal is local
362    let r = call_goal(m, goal);
363    crate::solve::drive(m, floor, r); // collector always returns 0
364    let results = m.findall_stack.pop().unwrap();
365    m.k_fn = saved_k.0;
366    m.k_env = saved_k.1;
367    m.qbarrier = saved_k.2;
368    if m.error.is_some() {
369        return 0;
370    }
371    // Discard the goal's bindings and workspace, then build the bag.
372    m.rewind_to(tmark, hmark);
373    let mut w = make_atom(plg_shared::atom::ATOM_NIL);
374    for buf in results.iter().rev() {
375        let e = crate::copyterm::restore_from_buf(m, buf);
376        let idx = m.heap.len();
377        m.heap.push(e);
378        m.heap.push(w);
379        w = make(TAG_LST, idx as u64);
380    }
381    let ok = unify(m, bag, w);
382    det(m, ok)
383}
384
385unsafe extern "C" fn findall_collect(m: *mut Machine, env: u64) -> i32 {
386    let m = unsafe { &mut *m };
387    let template = m.heap[env as usize];
388    let buf = crate::copyterm::copy_to_buf(m, template);
389    m.findall_stack
390        .last_mut()
391        .expect("collector outside findall")
392        .push(buf);
393    0 // force backtracking into the next solution
394}
395
396/// `call/N`: extend the goal with extra arguments (ISO 7.8.3 partial
397/// application), then dispatch it.
398fn metacall_extend(m: &mut Machine, goal: Word, extras: &[Word]) -> i32 {
399    let goal = m.deref(goal);
400    // call/N is opaque to cut (ISO): `!` inside the called goal is local.
401    m.qbarrier = m.cps.len();
402    if extras.is_empty() {
403        return call_goal(m, goal);
404    }
405    let extended = match tag_of(goal) {
406        TAG_ATOM => {
407            let f = atom_id(goal);
408            let idx = m.heap.len();
409            m.heap.push(pack_functor(f, extras.len() as u32));
410            m.heap.extend_from_slice(extras);
411            make(TAG_STR, idx as u64)
412        }
413        TAG_STR => {
414            let sidx = payload(goal) as usize;
415            let (f, n) = unpack_functor(m.heap[sidx]);
416            let idx = m.heap.len();
417            m.heap.push(pack_functor(f, n + extras.len() as u32));
418            for i in 0..n as usize {
419                let w = m.heap[sidx + 1 + i];
420                m.heap.push(w);
421            }
422            m.heap.extend_from_slice(extras);
423            make(TAG_STR, idx as u64)
424        }
425        TAG_REF => {
426            crate::errors::instantiation(m, "call/N requires a bound goal");
427            return 0;
428        }
429        _ => {
430            crate::errors::type_error(m, "callable", goal, "Goal is not callable");
431            return 0;
432        }
433    };
434    call_goal(m, extended)
435}
436
437/// `between(Low, High, X)` — the one nondeterministic builtin: with X
438/// unbound it enumerates Low..=High via a runtime-retried choice point.
439fn between_impl(m: &mut Machine, lo_w: Word, hi_w: Word, x_w: Word) -> i32 {
440    let Some(lo) = int_arg(m, lo_w, "between/3") else {
441        return 0;
442    };
443    let Some(hi) = int_arg(m, hi_w, "between/3") else {
444        return 0;
445    };
446    let x = m.deref(x_w);
447    match tag_of(x) {
448        TAG_INT | TAG_BIG => {
449            let xv = if tag_of(x) == TAG_INT {
450                int_value(x)
451            } else {
452                m.heap[payload(x) as usize] as i64
453            };
454            det(m, lo <= xv && xv <= hi)
455        }
456        TAG_REF => {
457            if lo > hi {
458                return 0;
459            }
460            // Frame: [x, current, high, k_fn, k_env, qbarrier].
461            // `current` is mutated in place across retries (untrailed
462            // by design — the frame is control state, not a term).
463            let frame = m.frame_alloc(6);
464            m.heap[frame] = x;
465            m.heap[frame + 1] = lo as u64;
466            m.heap[frame + 2] = hi as u64;
467            save_k(m, frame, 3);
468            if lo < hi {
469                m.push_cp(between_retry, frame as u64);
470            }
471            let w = int_to_word(m, lo);
472            let ok = unify(m, x, w);
473            debug_assert!(ok, "binding a fresh var cannot fail");
474            invoke_k(m)
475        }
476        _ => {
477            crate::errors::type_error(m, "integer", x, "between/3 requires an integer");
478            0
479        }
480    }
481}
482
483unsafe extern "C" fn between_retry(m: *mut Machine, env: u64) -> i32 {
484    let m = unsafe { &mut *m };
485    let frame = env as usize;
486    let x = m.heap[frame];
487    let cur = m.heap[frame + 1] as i64 + 1;
488    let hi = m.heap[frame + 2] as i64;
489    m.heap[frame + 1] = cur as u64;
490    if cur < hi {
491        m.push_cp(between_retry, frame as u64);
492    }
493    let (kf, ke) = load_k(m, frame, 3);
494    m.k_fn = kf;
495    m.k_env = ke;
496    let w = int_to_word(m, cur);
497    let ok = unify(m, x, w);
498    debug_assert!(ok, "x rewound to unbound before retry");
499    invoke_k(m)
500}
501
502/// Build an integer word: immediate when it fits i61, boxed otherwise.
503fn int_to_word(m: &mut Machine, n: i64) -> Word {
504    if (INT_MIN..=INT_MAX).contains(&n) {
505        make_int(n)
506    } else {
507        let idx = m.heap.len();
508        m.heap.push(n as u64);
509        make(TAG_BIG, idx as u64)
510    }
511}
512
513/// Deref an integer argument for between/3 (v1: bounds must be bound
514/// integers).
515fn int_arg(m: &mut Machine, w: Word, who: &str) -> Option<i64> {
516    let w = m.deref(w);
517    match tag_of(w) {
518        TAG_INT => Some(int_value(w)),
519        TAG_BIG => Some(m.heap[payload(w) as usize] as i64),
520        TAG_REF => {
521            let ctx = format!("{who} requires bound integer bounds");
522            crate::errors::instantiation(m, &ctx);
523            None
524        }
525        _ => {
526            let ctx = format!("{who} requires an integer");
527            crate::errors::type_error(m, "integer", w, &ctx);
528            None
529        }
530    }
531}
532
533/// Compiled-code entry for between/3 (uniform predicate signature,
534/// arguments in the A registers — dispatched like a user predicate).
535/// # Safety
536/// Called from generated code with the live Machine pointer.
537#[unsafe(no_mangle)]
538pub unsafe extern "C" fn plg_rt_pred_between_3(m: *mut Machine, _env: u64) -> i32 {
539    let m = unsafe { &mut *m };
540    let (lo, hi, x) = (m.areg[0], m.areg[1], m.areg[2]);
541    between_impl(m, lo, hi, x)
542}
543
544/// The atom name of `w` if it is an atom, else `None` (owned so the caller can
545/// then borrow `m` mutably to intern results).
546fn atom_str_opt(m: &Machine, w: Word) -> Option<String> {
547    (tag_of(w) == TAG_ATOM).then(|| m.atoms.resolve(atom_id(w)).to_string())
548}
549
550/// Bind `a_w`/`b_w` to the prefix/suffix of `sc` split before its `i`-th
551/// character (split on char boundaries so multi-byte atoms are safe). Returns
552/// whether both unified — `false` only when the two args alias one variable
553/// and this split's halves differ (e.g. `atom_concat(X, X, abcd)` at a split
554/// where prefix ≠ suffix), which is a normal per-alternative miss, not an error.
555fn bind_split(m: &mut Machine, a_w: Word, b_w: Word, sc: &str, i: usize) -> bool {
556    let byte = sc.char_indices().nth(i).map_or(sc.len(), |(b, _)| b);
557    let (pre, suf) = sc.split_at(byte);
558    let pid = m.atoms.intern(pre);
559    let sid = m.atoms.intern(suf);
560    unify(m, a_w, make_atom(pid)) && unify(m, b_w, make_atom(sid))
561}
562
563/// `atom_concat(A, B, C)` — ISO 8.16.2, all modes (issue #35). Forward when A
564/// and B are atoms (concatenate, unify with C); otherwise C must be a bound
565/// atom and this is the relational splitter: a known prefix (A bound) or suffix
566/// (B bound) selects the one matching decomposition, and A and B both unbound
567/// enumerate every decomposition on backtracking — the same multi-mode shape
568/// `append/3` has for lists. `site` carries the compiled call site for error
569/// provenance (NO_SITE on the query/metacall path).
570fn atom_concat_impl(m: &mut Machine, a_w: Word, b_w: Word, c_w: Word, site: u32) -> i32 {
571    m.error_site = site;
572    let wa = m.deref(a_w);
573    let wb = m.deref(b_w);
574    let a_atom = atom_str_opt(m, wa);
575    let b_atom = atom_str_opt(m, wb);
576    // A bound non-atom (number, compound, …) is a type_error — ISO admits only
577    // atoms here. An unbound var is fine (it's a split-mode output).
578    if a_atom.is_none() && tag_of(wa) != TAG_REF {
579        crate::errors::type_error(m, "atom", wa, "atom_concat/3: arguments must be atoms");
580        return 0;
581    }
582    if b_atom.is_none() && tag_of(wb) != TAG_REF {
583        crate::errors::type_error(m, "atom", wb, "atom_concat/3: arguments must be atoms");
584        return 0;
585    }
586    // Forward: both bound atoms → concatenate and unify with C.
587    if let (Some(sa), Some(sb)) = (&a_atom, &b_atom) {
588        let id = m.atoms.intern(&format!("{sa}{sb}"));
589        m.error_site = NO_SITE;
590        let ok = unify(m, c_w, make_atom(id));
591        return det(m, ok);
592    }
593    // Split modes need C to be a bound atom.
594    let wc = m.deref(c_w);
595    let Some(sc) = atom_str_opt(m, wc) else {
596        if tag_of(wc) == TAG_REF {
597            // (A or B unbound) and C unbound → not enough to proceed.
598            crate::errors::instantiation(
599                m,
600                "atom_concat/3: arguments not sufficiently instantiated",
601            );
602        } else {
603            crate::errors::type_error(m, "atom", wc, "atom_concat/3: arguments must be atoms");
604        }
605        return 0;
606    };
607    m.error_site = NO_SITE; // no further raises past this point
608    // Known prefix: A bound, B unbound → unique split iff C starts with A.
609    if let Some(sa) = &a_atom {
610        return match sc.strip_prefix(sa.as_str()) {
611            Some(rest) => {
612                let id = m.atoms.intern(rest);
613                let ok = unify(m, b_w, make_atom(id));
614                det(m, ok)
615            }
616            None => 0,
617        };
618    }
619    // Known suffix: B bound, A unbound → unique split iff C ends with B.
620    if let Some(sb) = &b_atom {
621        return match sc.strip_suffix(sb.as_str()) {
622            Some(pre) => {
623                let id = m.atoms.intern(pre);
624                let ok = unify(m, a_w, make_atom(id));
625                det(m, ok)
626            }
627            None => 0,
628        };
629    }
630    // A and B both unbound → enumerate every decomposition of C.
631    // Frame: [a_w, b_w, c_atom_id, cur_index, nchars, k_fn, k_env, qbarrier].
632    let nchars = sc.chars().count();
633    let frame = m.frame_alloc(8);
634    m.heap[frame] = a_w;
635    m.heap[frame + 1] = b_w;
636    m.heap[frame + 2] = atom_id(wc) as u64;
637    m.heap[frame + 3] = 0;
638    m.heap[frame + 4] = nchars as u64;
639    save_k(m, frame, 5);
640    if nchars >= 1 {
641        m.push_cp(atom_concat_retry, frame as u64);
642    }
643    if bind_split(m, a_w, b_w, &sc, 0) {
644        invoke_k(m)
645    } else {
646        0 // alias miss at split 0 → engine backtracks into the retry
647    }
648}
649
650unsafe extern "C" fn atom_concat_retry(m: *mut Machine, env: u64) -> i32 {
651    let m = unsafe { &mut *m };
652    let frame = env as usize;
653    let cur = m.heap[frame + 3] as usize + 1;
654    let nchars = m.heap[frame + 4] as usize;
655    m.heap[frame + 3] = cur as u64;
656    if cur < nchars {
657        m.push_cp(atom_concat_retry, frame as u64);
658    }
659    let (kf, ke) = load_k(m, frame, 5);
660    m.k_fn = kf;
661    m.k_env = ke;
662    let (a_w, b_w) = (m.heap[frame], m.heap[frame + 1]);
663    let sc = m.atoms.resolve(m.heap[frame + 2] as u32).to_string();
664    if bind_split(m, a_w, b_w, &sc, cur) {
665        invoke_k(m)
666    } else {
667        0
668    }
669}
670
671/// Compiled-code entry for atom_concat/3 (nondeterministic in split mode, so —
672/// like between/3 — dispatched as a predicate with args in the A registers).
673/// `env` carries the call-site id for error provenance (SPANS.md Layer 3).
674/// # Safety
675/// Called from generated code with the live Machine pointer.
676#[unsafe(no_mangle)]
677pub unsafe extern "C" fn plg_rt_pred_atom_concat_3(m: *mut Machine, env: u64) -> i32 {
678    let m = unsafe { &mut *m };
679    let (a, b, c) = (m.areg[0], m.areg[1], m.areg[2]);
680    atom_concat_impl(m, a, b, c, env as u32)
681}
682
683/// Compiled-code entries for control builtins taking goal terms.
684/// # Safety
685/// Called from generated code with the live Machine pointer.
686#[unsafe(no_mangle)]
687pub unsafe extern "C" fn plg_rt_metacall(m: *mut Machine, goal: u64) -> i32 {
688    let m = unsafe { &mut *m };
689    // Goals reaching the walker from compiled code are call-like:
690    // a runtime-walked `!` inside them is local.
691    m.qbarrier = m.cps.len();
692    call_goal(m, goal)
693}
694
695/// Fast-path resolver for the metacall trampoline (#23): if `goal` is a
696/// simple compiled-predicate call (after peeling a single `call/1` wrapper),
697/// marshal its arguments and return the entry function pointer as an integer
698/// for generated IR to `musttail` into — giving `call(pred(...))` tail
699/// recursion constant C stack, like a direct call. Returns 0 for anything the
700/// full walker must handle (builtins, control constructs, `call/N` with extra
701/// args, variables, undefined predicates); the caller then falls back to
702/// `plg_rt_metacall`, which is bounded by the depth guard in `call_goal`.
703///
704/// Sets `qbarrier` exactly as `plg_rt_metacall` does, so cut-transparency of
705/// `call/N` is identical on both paths. The fast path does NOT bump
706/// `metacall_depth`: a `musttail` into the resolved entry leaves no walker
707/// frame to bound (only the slow path re-enters `call_goal`, which guards).
708///
709/// # Safety
710/// Called from generated code with the live Machine pointer.
711#[unsafe(no_mangle)]
712pub unsafe extern "C" fn plg_rt_metacall_resolve(m: *mut Machine, goal: u64) -> u64 {
713    let m = unsafe { &mut *m };
714    m.qbarrier = m.cps.len();
715    match resolve_goal_ptr(m, goal) {
716        Some(f) => f as usize as u64,
717        None => 0,
718    }
719}
720
721fn resolve_goal_ptr(m: &mut Machine, mut goal: Word) -> Option<ContFn> {
722    loop {
723        goal = m.deref(goal);
724        match tag_of(goal) {
725            TAG_ATOM => return crate::solve::resolve_simple(m, atom_id(goal), 0, 0),
726            TAG_STR => {
727                let idx = payload(goal) as usize;
728                let (f, n) = unpack_functor(m.heap[idx]);
729                // Peel `call/1` wrappers *iteratively* so `call(pred(..))`
730                // trampolines — and so even a pathological `call(call(...))`
731                // chain can't overflow the resolver's own stack. `call/N` with
732                // extras (and other shapes) go to the walker, which builds the
733                // extended goal in `metacall_extend`.
734                if n == 1 && m.atoms.resolve(f) == "call" {
735                    goal = m.heap[idx + 1];
736                    continue;
737                }
738                return crate::solve::resolve_simple(m, f, n, idx + 1);
739            }
740            _ => return None,
741        }
742    }
743}
744
745/// # Safety
746/// Called from generated code with the live Machine pointer.
747#[unsafe(no_mangle)]
748pub unsafe extern "C" fn plg_rt_b_catch_3(m: *mut Machine, g: u64, c: u64, r: u64) -> i32 {
749    catch_impl(unsafe { &mut *m }, g, c, r)
750}
751
752/// # Safety
753/// Called from generated code with the live Machine pointer.
754#[unsafe(no_mangle)]
755pub unsafe extern "C" fn plg_rt_b_throw_1(m: *mut Machine, ball: u64) -> i32 {
756    crate::errors::throw_term(unsafe { &mut *m }, ball);
757    0
758}
759
760/// # Safety
761/// Called from generated code with the live Machine pointer.
762#[unsafe(no_mangle)]
763pub unsafe extern "C" fn plg_rt_b_findall_3(m: *mut Machine, t: u64, g: u64, b: u64) -> i32 {
764    findall_impl(unsafe { &mut *m }, t, g, b)
765}
766
767#[cfg(test)]
768mod tests {
769    use crate::machine::Machine;
770    use crate::query::parse_query;
771    use crate::solve::{Outcome, solve};
772    use plg_shared::StringInterner;
773
774    fn run(query: &str) -> (Vec<String>, Option<String>) {
775        let mut m = Machine::new(StringInterner::new(), Vec::new());
776        let goal = parse_query(&mut m, query).unwrap();
777        let outcome = solve(&mut m, goal);
778        let err = match outcome {
779            Outcome::Error => Some(m.error.take().unwrap().message),
780            Outcome::Done => None,
781        };
782        let sols = m
783            .solutions
784            .iter()
785            .map(|s| {
786                s.bindings
787                    .iter()
788                    .map(|(n, _, t)| format!("{n}={t}"))
789                    .collect::<Vec<_>>()
790                    .join(",")
791            })
792            .collect();
793        (sols, err)
794    }
795
796    #[test]
797    fn top_level_is_and_comparison() {
798        assert_eq!(run("X is 2 + 3 * 4").0, vec!["X=14"]);
799        assert_eq!(run("1 < 2").0, vec![""]);
800        assert_eq!(run("2 < 1").0, Vec::<String>::new());
801    }
802
803    #[test]
804    fn top_level_disjunction_enumerates() {
805        assert_eq!(run("(X = 1 ; X = 2)").0, vec!["X=1", "X=2"]);
806    }
807
808    #[test]
809    fn top_level_ite_and_naf() {
810        assert_eq!(run("(1 < 2 -> X = yes ; X = no)").0, vec!["X=yes"]);
811        assert_eq!(run("(2 < 1 -> X = yes ; X = no)").0, vec!["X=no"]);
812        assert_eq!(run("\\+ 2 < 1").0, vec![""]);
813        assert_eq!(run("\\+ 1 < 2").0, Vec::<String>::new());
814        // NAF undoes inner bindings.
815        assert_eq!(run("\\+ (X = 1, 2 < 1), X = ok").0, vec!["X=ok"]);
816    }
817
818    #[test]
819    fn top_level_once_commits() {
820        assert_eq!(run("once((X = 1 ; X = 2))").0, vec!["X=1"]);
821    }
822
823    #[test]
824    fn errors_propagate() {
825        let (_, err) = run("X is 1 // 0");
826        assert!(err.unwrap().contains("zero_divisor"));
827    }
828}
829
830#[cfg(test)]
831mod m4_tests {
832    use crate::machine::Machine;
833    use crate::query::parse_query;
834    use crate::solve::{Outcome, solve};
835    use plg_shared::StringInterner;
836
837    fn run(query: &str) -> (Vec<String>, Option<String>) {
838        let mut m = Machine::new(StringInterner::new(), Vec::new());
839        let goal = parse_query(&mut m, query).unwrap();
840        let outcome = solve(&mut m, goal);
841        let err = match outcome {
842            Outcome::Error => Some(m.error.take().unwrap().message),
843            Outcome::Done => None,
844        };
845        let sols = m
846            .solutions
847            .iter()
848            .map(|s| {
849                s.bindings
850                    .iter()
851                    .map(|(n, _, t)| format!("{n}={t}"))
852                    .collect::<Vec<_>>()
853                    .join(",")
854            })
855            .collect();
856        (sols, err)
857    }
858
859    #[test]
860    fn throw_uncaught_propagates_with_rendered_ball() {
861        let (sols, err) = run("throw(my_ball)");
862        assert!(sols.is_empty());
863        assert_eq!(err.unwrap(), "my_ball");
864    }
865
866    #[test]
867    fn catch_catches_matching_ball_and_runs_recovery() {
868        let (sols, err) = run("catch(throw(oops(1)), oops(N), X = caught(N))");
869        assert!(err.is_none());
870        assert_eq!(sols, vec!["N=1,X=caught(1)"]);
871    }
872
873    #[test]
874    fn catch_passes_nonmatching_ball_outward() {
875        let (sols, err) = run("catch(throw(other), oops(_), X = no)");
876        assert!(sols.is_empty());
877        assert_eq!(err.unwrap(), "other");
878    }
879
880    #[test]
881    fn nested_catch_inner_first() {
882        let (sols, err) = run("catch(catch(throw(b), a, X = inner_a), b, X = outer_b)");
883        assert!(err.is_none());
884        assert_eq!(sols, vec!["X=outer_b"]);
885    }
886
887    #[test]
888    fn catch_traps_builtin_errors() {
889        let (sols, err) = run("catch(X is 1 // 0, error(evaluation_error(E), _), Y = E)");
890        assert!(err.is_none());
891        assert_eq!(sols, vec!["E=zero_divisor,X=_0,Y=zero_divisor"]);
892    }
893
894    #[test]
895    fn catch_is_transparent_to_normal_backtracking() {
896        let (sols, err) = run("catch((X = 1 ; X = 2), _, fail)");
897        assert!(err.is_none());
898        assert_eq!(sols, vec!["X=1", "X=2"]);
899    }
900
901    #[test]
902    fn step_limit_is_not_catchable() {
903        let mut m = Machine::new(StringInterner::new(), Vec::new());
904        m.step_limit = 1;
905        m.steps = 1; // next step() trips the limit
906        assert!(!m.step());
907        let goal = parse_query(&mut m, "catch(true, _, true)").unwrap();
908        // error pre-armed and uncatchable: solve returns Error untouched
909        assert!(matches!(solve(&mut m, goal), Outcome::Error));
910        assert!(m.error.as_ref().unwrap().uncatchable);
911    }
912
913    #[test]
914    fn findall_collects_and_rewinds() {
915        let (sols, err) = run("findall(X, (X = 1 ; X = 2 ; X = 3), L)");
916        assert!(err.is_none());
917        assert_eq!(sols, vec!["L=[1, 2, 3],X=_0"]);
918    }
919
920    #[test]
921    fn findall_empty_on_failure() {
922        let (sols, err) = run("findall(X, fail, L)");
923        assert!(err.is_none());
924        assert_eq!(sols, vec!["L=[],X=_0"]);
925    }
926
927    #[test]
928    fn findall_propagates_errors() {
929        let (sols, err) = run("findall(X, throw(bad), L)");
930        assert!(sols.is_empty());
931        assert_eq!(err.unwrap(), "bad");
932    }
933
934    #[test]
935    fn nested_findall() {
936        let (sols, err) = run("findall(L1, (Y = 2, findall(X, (X = 1 ; X = Y), L1)), L)");
937        assert!(err.is_none());
938        assert_eq!(sols.len(), 1);
939        assert!(sols[0].contains("L=[[1, 2]]"), "{sols:?}");
940    }
941
942    #[test]
943    fn call_n_extends_goals() {
944        let (sols, err) = run("call(=, X, 7)");
945        assert!(err.is_none());
946        assert_eq!(sols, vec!["X=7"]);
947        let (sols, _) = run("G = (X = 1 ; X = 2), call(G)");
948        assert_eq!(sols.len(), 2);
949    }
950
951    #[test]
952    fn call_unbound_is_instantiation_error() {
953        let (sols, err) = run("call(X)");
954        assert!(sols.is_empty());
955        assert!(err.unwrap().contains("instantiation_error"));
956    }
957
958    #[test]
959    fn between_enumerates_and_checks() {
960        let (sols, err) = run("between(1, 4, X)");
961        assert!(err.is_none());
962        assert_eq!(sols, vec!["X=1", "X=2", "X=3", "X=4"]);
963        let (sols, _) = run("between(1, 4, 3)");
964        assert_eq!(sols.len(), 1);
965        let (sols, _) = run("between(1, 4, 9)");
966        assert!(sols.is_empty());
967        let (sols, _) = run("between(3, 1, X)");
968        assert!(sols.is_empty());
969        // single-value range: no choice point churn
970        let (sols, _) = run("between(2, 2, X)");
971        assert_eq!(sols, vec!["X=2"]);
972    }
973
974    #[test]
975    fn findall_with_between() {
976        let (sols, err) = run("findall(X, between(1, 5, X), L)");
977        assert!(err.is_none());
978        assert_eq!(sols, vec!["L=[1, 2, 3, 4, 5],X=_0"]);
979    }
980}
981
982#[cfg(test)]
983mod vocab_invariant {
984    //! Runtime half of the `plg-shared::builtins` invariant
985    //! (docs/design/BUILTIN_VOCAB.md). `det_builtin` above is the
986    //! query-side mirror of codegen's `DET_BUILTINS`; this asserts the
987    //! arms it handles are EXACTLY the arity>0 `Det` rows of `BUILTINS`
988    //! (`nl/0` is Det but dispatched via `try_atom_builtin`, so it is
989    //! excluded here). Referenced only under `#[cfg(test)]` — the `doc`
990    //! strings never reach a compiled program binary.
991    use plg_shared::{BUILTINS, builtins::BuiltinKind};
992    use std::collections::BTreeSet;
993
994    /// Hand mirror of the `det_builtin` match arms. Adding an arm there
995    /// without updating this (or `BUILTINS`) turns the test red.
996    #[rustfmt::skip]
997    const DET_DISPATCH: &[(&str, u32)] = &[
998        ("var", 1), ("nonvar", 1), ("atom", 1), ("number", 1), ("integer", 1),
999        ("float", 1), ("compound", 1), ("is_list", 1), ("functor", 3), ("arg", 3),
1000        ("=..", 2), ("copy_term", 2), ("atom_length", 2),
1001        ("atom_chars", 2), ("number_chars", 2), ("number_codes", 2), ("msort", 2),
1002        ("sort", 2), ("succ", 2), ("plus", 3), ("unify_with_occurs_check", 2),
1003        ("write", 1), ("writeq", 1), ("writeln", 1),
1004    ];
1005
1006    #[test]
1007    fn det_dispatch_equals_shared_det_subset() {
1008        let dispatch: BTreeSet<(&str, u32)> = DET_DISPATCH.iter().copied().collect();
1009        let shared_det: BTreeSet<(&str, u32)> = BUILTINS
1010            .iter()
1011            .filter(|s| s.kind == BuiltinKind::Det && s.arity > 0)
1012            .map(|s| (s.name, s.arity))
1013            .collect();
1014        assert_eq!(
1015            dispatch, shared_det,
1016            "runtime det dispatch diverges from BUILTINS Det subset \
1017             (left = control.rs, right = shared table)"
1018        );
1019    }
1020}