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