Skip to main content

mangle_analysis/
planner.rs

1// Copyright 2025 Google LLC
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use anyhow::{Result, anyhow};
16use mangle_ir::physical::{self, Aggregate, CmpOp, Condition, DataSource, Expr, Op, Operand};
17use mangle_ir::{Inst, InstId, Ir, NameId};
18use rustc_hash::FxHashSet;
19
20pub struct Planner<'a> {
21    ir: &'a mut Ir,
22    delta_pred: Option<NameId>,
23    fresh_counter: usize,
24    /// When true, emit `Op::HashJoin` for eligible two-premise joins. Off by
25    /// default. Defaults to the value of `MANGLE_HASHJOIN=1` at construction
26    /// time; tests and callers can override with `.with_hash_join(...)`.
27    hash_join: bool,
28}
29
30/// Read the `MANGLE_HASHJOIN` env var once. Used to seed `Planner::hash_join`
31/// so the flag can be set from the shell without code changes. Overridable
32/// per-planner via `with_hash_join`.
33fn hash_join_env_default() -> bool {
34    std::env::var("MANGLE_HASHJOIN")
35        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
36        .unwrap_or(false)
37}
38
39/// Replace the placeholder `Op::Nop` body inside a freshly-built `Op::HashJoin`
40/// with the real body planned after the join.
41fn splice_hash_join_body(op: Op, body: Op) -> Op {
42    match op {
43        Op::HashJoin {
44            build_source,
45            probe_source,
46            join_keys,
47            ..
48        } => Op::HashJoin {
49            build_source,
50            probe_source,
51            join_keys,
52            body: Box::new(body),
53        },
54        other => other,
55    }
56}
57
58impl<'a> Planner<'a> {
59    pub fn new(ir: &'a mut Ir) -> Self {
60        Self {
61            ir,
62            delta_pred: None,
63            fresh_counter: 0,
64            hash_join: hash_join_env_default(),
65        }
66    }
67
68    pub fn with_delta(mut self, delta_pred: NameId) -> Self {
69        self.delta_pred = Some(delta_pred);
70        self
71    }
72
73    /// Override the HashJoin emission flag (seeded from `MANGLE_HASHJOIN` env
74    /// var by default). Primarily for testing and for callers that want to
75    /// opt in or out explicitly.
76    pub fn with_hash_join(mut self, enabled: bool) -> Self {
77        self.hash_join = enabled;
78        self
79    }
80
81    pub fn plan_rule(mut self, rule_id: InstId) -> Result<Op> {
82        let (head, premises, transform) = match self.ir.get(rule_id) {
83            Inst::Rule {
84                head,
85                premises,
86                transform,
87            } => (*head, premises.clone(), transform.clone()),
88            _ => return Err(anyhow!("Not a rule")),
89        };
90
91        // Split transforms into blocks by 'do' statements
92        let blocks = self.split_transforms(transform);
93        let num_blocks = blocks.len();
94
95        let mut ops = Vec::new();
96        let mut current_source: Option<(NameId, Vec<NameId>)> = None;
97        let mut bound_vars = FxHashSet::default();
98
99        for (i, block) in blocks.into_iter().enumerate() {
100            let is_last = i == num_blocks - 1;
101
102            if i == 0 {
103                // Block 0: Premises + Lets
104                if is_last {
105                    // Only one block, no aggregations
106                    let op = self.plan_join_sequence(
107                        premises.clone(),
108                        &mut bound_vars,
109                        |planner, vars| {
110                            planner.plan_transforms_sequence(&block, vars, |p, v| {
111                                p.plan_head_insert(head, v)
112                            })
113                        },
114                    )?;
115                    ops.push(op);
116                } else {
117                    // Materialize to temp
118                    let temp_rel = self.fresh_var("temp_grp");
119                    let mut capture_vars: Vec<NameId> = Vec::new(); // Will be populated by continuation
120
121                    let op = self.plan_join_sequence(
122                        premises.clone(),
123                        &mut bound_vars,
124                        |planner, vars| {
125                            planner.plan_transforms_sequence(&block, vars, |_, v| {
126                                let mut sorted_vars: Vec<NameId> = v.iter().cloned().collect();
127                                sorted_vars.sort();
128                                capture_vars = sorted_vars.clone();
129                                let args =
130                                    sorted_vars.iter().map(|&var| Operand::Var(var)).collect();
131                                Ok(Op::Insert {
132                                    relation: temp_rel,
133                                    args,
134                                })
135                            })
136                        },
137                    )?;
138                    ops.push(op);
139                    current_source = Some((temp_rel, capture_vars));
140                }
141            } else {
142                // Block i > 0: Starts with 'do'
143                let (src_rel, src_vars) = current_source.take().expect("No source for aggregation");
144
145                if is_last {
146                    let op = self.plan_block_k(src_rel, src_vars, &block, |p, v| {
147                        p.plan_head_insert(head, v)
148                    })?;
149                    ops.push(op);
150                } else {
151                    let next_temp = self.fresh_var("temp_grp");
152                    let mut next_vars: Vec<NameId> = Vec::new();
153
154                    let op = self.plan_block_k(src_rel, src_vars, &block, |_, v| {
155                        let mut sorted_vars: Vec<NameId> = v.iter().cloned().collect();
156                        sorted_vars.sort();
157                        next_vars = sorted_vars.clone();
158                        let args = sorted_vars.iter().map(|&var| Operand::Var(var)).collect();
159                        Ok(Op::Insert {
160                            relation: next_temp,
161                            args,
162                        })
163                    })?;
164                    ops.push(op);
165                    current_source = Some((next_temp, next_vars));
166                }
167            }
168        }
169
170        if ops.len() == 1 {
171            Ok(ops.remove(0))
172        } else {
173            Ok(Op::Seq(ops))
174        }
175    }
176
177    fn split_transforms(&self, transforms: Vec<InstId>) -> Vec<Vec<InstId>> {
178        let mut blocks = Vec::new();
179        let mut current = Vec::new();
180        for t in transforms {
181            let inst = self.ir.get(t);
182            if let Inst::Transform { var: None, .. } = inst {
183                blocks.push(current);
184                current = Vec::new();
185            }
186            current.push(t);
187        }
188        blocks.push(current);
189        blocks
190    }
191
192    fn plan_block_k<F>(
193        &mut self,
194        source_rel: NameId,
195        source_vars: Vec<NameId>,
196        block: &[InstId],
197        continuation: F,
198    ) -> Result<Op>
199    where
200        F: FnOnce(&mut Self, &mut FxHashSet<NameId>) -> Result<Op>,
201    {
202        let do_stmt = block[0];
203        let rest = &block[1..];
204
205        let keys_insts = self.get_transform_app_args(do_stmt)?;
206        let mut keys = Vec::new();
207        for k in keys_insts {
208            if let Inst::Var(v) = self.ir.get(k) {
209                keys.push(*v);
210            } else {
211                return Err(anyhow!("GroupBy keys must be variables"));
212            }
213        }
214
215        let mut aggregates = Vec::new();
216        let mut lets = Vec::new();
217        for &t in rest {
218            if let Some(agg) = self.try_parse_aggregate(t)? {
219                aggregates.push(agg);
220            } else {
221                lets.push(t);
222            }
223        }
224
225        let mut inner_vars = FxHashSet::default();
226        for &k in &keys {
227            inner_vars.insert(k);
228        }
229        for agg in &aggregates {
230            inner_vars.insert(agg.var);
231        }
232
233        let body = self.plan_transforms_sequence(&lets, &mut inner_vars, continuation)?;
234
235        Ok(Op::GroupBy {
236            source: source_rel,
237            vars: source_vars,
238            keys,
239            aggregates,
240            body: Box::new(body),
241        })
242    }
243
244    fn plan_transforms_sequence<F>(
245        &mut self,
246        transforms: &[InstId],
247        bound_vars: &mut FxHashSet<NameId>,
248        continuation: F,
249    ) -> Result<Op>
250    where
251        F: FnOnce(&mut Self, &mut FxHashSet<NameId>) -> Result<Op>,
252    {
253        if transforms.is_empty() {
254            return continuation(self, bound_vars);
255        }
256
257        let t_id = transforms[0];
258        let rest = &transforms[1..];
259
260        let inst = self.ir.get(t_id).clone();
261        if let Inst::Transform {
262            var: Some(var),
263            app,
264        } = inst
265        {
266            self.inst_to_expr(app, |planner, expr| {
267                bound_vars.insert(var);
268                let body = planner.plan_transforms_sequence(rest, bound_vars, continuation)?;
269                Ok(Op::Let {
270                    var,
271                    expr,
272                    body: Box::new(body),
273                })
274            })
275        } else {
276            // Should not happen if split_transforms is correct
277            Err(anyhow!("Unexpected transform in sequence"))
278        }
279    }
280
281    fn fresh_var(&mut self, prefix: &str) -> NameId {
282        let id = self.fresh_counter;
283        self.fresh_counter += 1;
284        let name = format!("${}_{}", prefix, id);
285        self.ir.intern_name(name)
286    }
287
288    /// If this premise and the next one are both Atoms of the shape
289    /// `p(V1, V2, ...)` — every arg a fresh, unbound, non-repeating variable
290    /// — and they share at least one variable, consume the next premise and
291    /// return a `HashJoin` op whose `body` is `Op::Nop` (spliced in by the
292    /// caller). Returns `None` if the pattern does not match.
293    fn try_plan_hash_join(
294        &self,
295        predicate: NameId,
296        args: &[InstId],
297        premises: &mut Vec<InstId>,
298        bound_vars: &mut FxHashSet<NameId>,
299    ) -> Result<Option<Op>> {
300        let Some(build_vars) = self.all_fresh_distinct_vars(args, bound_vars) else {
301            return Ok(None);
302        };
303        let next_id = *premises.first().unwrap();
304        let next_inst = self.ir.get(next_id).clone();
305        let Inst::Atom {
306            predicate: next_pred,
307            args: next_args,
308        } = next_inst
309        else {
310            return Ok(None);
311        };
312        if Some(next_pred) == self.delta_pred {
313            return Ok(None);
314        }
315        let Some(probe_vars) = self.all_fresh_distinct_vars(&next_args, bound_vars) else {
316            return Ok(None);
317        };
318        let join_keys: Vec<NameId> = build_vars
319            .iter()
320            .filter(|v| probe_vars.contains(v))
321            .copied()
322            .collect();
323        if join_keys.is_empty() {
324            return Ok(None);
325        }
326
327        // Commit: consume the next premise and mark both sides' vars as bound.
328        premises.remove(0);
329        for v in build_vars.iter().chain(probe_vars.iter()) {
330            bound_vars.insert(*v);
331        }
332
333        Ok(Some(Op::HashJoin {
334            build_source: DataSource::Scan {
335                relation: predicate,
336                vars: build_vars,
337            },
338            probe_source: DataSource::Scan {
339                relation: next_pred,
340                vars: probe_vars,
341            },
342            join_keys,
343            body: Box::new(Op::Nop),
344        }))
345    }
346
347    /// Return the list of variables an atom's args bind, or `None` if any
348    /// arg is already bound, not a `Var`, or repeats. Used to gate the
349    /// HashJoin fast path — the existing nested-Iterate path handles the
350    /// general case.
351    fn all_fresh_distinct_vars(
352        &self,
353        args: &[InstId],
354        bound_vars: &FxHashSet<NameId>,
355    ) -> Option<Vec<NameId>> {
356        let mut out: Vec<NameId> = Vec::with_capacity(args.len());
357        for arg in args {
358            let Inst::Var(v) = self.ir.get(*arg) else {
359                return None;
360            };
361            if bound_vars.contains(v) || out.contains(v) {
362                return None;
363            }
364            out.push(*v);
365        }
366        Some(out)
367    }
368
369    fn plan_join_sequence<F>(
370        &mut self,
371        mut premises: Vec<InstId>,
372        bound_vars: &mut FxHashSet<NameId>,
373        continuation: F,
374    ) -> Result<Op>
375    where
376        F: FnOnce(&mut Self, &mut FxHashSet<NameId>) -> Result<Op>,
377    {
378        if premises.is_empty() {
379            return continuation(self, bound_vars);
380        }
381
382        let current_premise = premises.remove(0);
383        let inst = self.ir.get(current_premise).clone();
384
385        match inst {
386            Inst::Atom { predicate, args }
387                if matches!(
388                    self.ir.resolve_name(predicate),
389                    ":lt"
390                        | ":le"
391                        | ":gt"
392                        | ":ge"
393                        | ":time:lt"
394                        | ":time:le"
395                        | ":time:gt"
396                        | ":time:ge"
397                        | ":duration:lt"
398                        | ":duration:le"
399                        | ":duration:gt"
400                        | ":duration:ge"
401                ) =>
402            {
403                let cmp_op = match self.ir.resolve_name(predicate) {
404                    ":lt" | ":time:lt" | ":duration:lt" => CmpOp::Lt,
405                    ":le" | ":time:le" | ":duration:le" => CmpOp::Le,
406                    ":gt" | ":time:gt" | ":duration:gt" => CmpOp::Gt,
407                    ":ge" | ":time:ge" | ":duration:ge" => CmpOp::Ge,
408                    _ => unreachable!(),
409                };
410                if args.len() != 2 {
411                    return Err(anyhow!("Comparison predicate requires exactly 2 arguments"));
412                }
413                let body = self.plan_join_sequence(premises, bound_vars, continuation)?;
414                self.with_eval(args[0], |this, left_op| {
415                    this.with_eval(args[1], |_this, right_op| {
416                        Ok(Op::Filter {
417                            cond: Condition::Cmp {
418                                op: cmp_op,
419                                left: left_op.clone(),
420                                right: right_op,
421                            },
422                            body: Box::new(body),
423                        })
424                    })
425                })
426            }
427            Inst::Atom { predicate, args }
428                if matches!(
429                    self.ir.resolve_name(predicate),
430                    ":string:starts_with"
431                        | ":string:ends_with"
432                        | ":string:contains"
433                        | ":match_prefix"
434                ) =>
435            {
436                if args.len() != 2 {
437                    return Err(anyhow!("Built-in predicate requires exactly 2 arguments"));
438                }
439                let body = self.plan_join_sequence(premises, bound_vars, continuation)?;
440                self.with_eval(args[0], |this, left_op| {
441                    this.with_eval(args[1], |_this, right_op| {
442                        Ok(Op::Filter {
443                            cond: Condition::Call {
444                                function: predicate,
445                                args: vec![left_op.clone(), right_op],
446                            },
447                            body: Box::new(body),
448                        })
449                    })
450                })
451            }
452            Inst::Atom { predicate, args } if self.ir.resolve_name(predicate) == ":match_field" => {
453                if args.len() != 3 {
454                    return Err(anyhow!(":match_field requires exactly 3 arguments"));
455                }
456                let field_name_id = match self.ir.get(args[1]) {
457                    Inst::Name(n) => *n,
458                    _ => {
459                        return Err(anyhow!(
460                            ":match_field second argument must be a name constant"
461                        ));
462                    }
463                };
464                // Binding mode only when args[2] is an unbound variable.
465                let unbound_var = match self.ir.get(args[2]) {
466                    Inst::Var(v) if !bound_vars.contains(v) => Some(*v),
467                    _ => None,
468                };
469                if let Some(out_var) = unbound_var {
470                    bound_vars.insert(out_var);
471                    let body = self.plan_join_sequence(premises, bound_vars, continuation)?;
472                    self.with_eval(args[0], |_this, struct_op| {
473                        Ok(Op::MatchField {
474                            struct_op,
475                            field: field_name_id,
476                            var: out_var,
477                            body: Box::new(body),
478                        })
479                    })
480                } else {
481                    // Constant or already-bound var: extract into a fresh var, filter for equality.
482                    let fresh = self.fresh_var("mf");
483                    let body = self.plan_join_sequence(premises, bound_vars, continuation)?;
484                    self.with_eval(args[0], |this, struct_op| {
485                        this.with_eval(args[2], |_this, expected_op| {
486                            Ok(Op::MatchField {
487                                struct_op,
488                                field: field_name_id,
489                                var: fresh,
490                                body: Box::new(Op::Filter {
491                                    cond: Condition::Cmp {
492                                        op: CmpOp::Eq,
493                                        left: Operand::Var(fresh),
494                                        right: expected_op,
495                                    },
496                                    body: Box::new(body),
497                                }),
498                            })
499                        })
500                    })
501                }
502            }
503            Inst::Atom { predicate, args } if self.ir.resolve_name(predicate) == ":list:member" => {
504                if args.len() != 2 {
505                    return Err(anyhow!(":list:member requires exactly 2 arguments"));
506                }
507                // Binding mode only when args[0] is an unbound variable.
508                let unbound_elem_var = match self.ir.get(args[0]) {
509                    Inst::Var(v) if !bound_vars.contains(v) => Some(*v),
510                    _ => None,
511                };
512                if let Some(elem_var) = unbound_elem_var {
513                    bound_vars.insert(elem_var);
514                    let body = self.plan_join_sequence(premises, bound_vars, continuation)?;
515                    self.with_eval(args[1], |_this, list_op| {
516                        Ok(Op::IterateList {
517                            source: list_op,
518                            var: elem_var,
519                            body: Box::new(body),
520                        })
521                    })
522                } else {
523                    // Constant or already-bound var: membership test via equality filter.
524                    let fresh = self.fresh_var("listmem");
525                    let body = self.plan_join_sequence(premises, bound_vars, continuation)?;
526                    self.with_eval(args[1], |this, list_op| {
527                        this.with_eval(args[0], |_this, elem_op| {
528                            Ok(Op::IterateList {
529                                source: list_op,
530                                var: fresh,
531                                body: Box::new(Op::Filter {
532                                    cond: Condition::Cmp {
533                                        op: CmpOp::Eq,
534                                        left: Operand::Var(fresh),
535                                        right: elem_op,
536                                    },
537                                    body: Box::new(body),
538                                }),
539                            })
540                        })
541                    })
542                }
543            }
544            Inst::Atom { predicate, args } => {
545                // Fast path: emit HashJoin when the env var
546                // `MANGLE_HASHJOIN=1` is set, neither side is the delta
547                // predicate, and both this premise and the next one are
548                // simple Atoms whose args are all fresh (unbound, unique,
549                // non-constant) variables sharing at least one join key.
550                //
551                // The planner is otherwise conservative: falling through to
552                // the existing nested-Iterate + IndexLookup path is always
553                // safe, so this is purely an opt-in performance path for
554                // the use cases where hash join beats index-nested-loop.
555                if self.hash_join
556                    && self.delta_pred.is_none()
557                    && !premises.is_empty()
558                    && let Some(op) =
559                        self.try_plan_hash_join(predicate, &args, &mut premises, bound_vars)?
560                {
561                    let body_op = self.plan_join_sequence(premises, bound_vars, continuation)?;
562                    return Ok(splice_hash_join_body(op, body_op));
563                }
564
565                let mut scan_vars = Vec::new();
566                let mut new_bindings = Vec::new();
567
568                // Look for a potential index lookup
569                let mut index_lookup: Option<(usize, Operand)> = None;
570
571                for (i, arg) in args.iter().enumerate() {
572                    let arg_inst = self.ir.get(*arg).clone();
573                    match arg_inst {
574                        Inst::Var(v) if bound_vars.contains(&v) => {
575                            if index_lookup.is_none() {
576                                index_lookup = Some((i, Operand::Var(v)));
577                            }
578                        }
579                        Inst::Number(n) => {
580                            if index_lookup.is_none() {
581                                index_lookup =
582                                    Some((i, Operand::Const(physical::Constant::Number(n))));
583                            }
584                        }
585                        Inst::String(s) => {
586                            if index_lookup.is_none() {
587                                index_lookup =
588                                    Some((i, Operand::Const(physical::Constant::String(s))));
589                            }
590                        }
591                        Inst::Name(n) => {
592                            if index_lookup.is_none() {
593                                index_lookup =
594                                    Some((i, Operand::Const(physical::Constant::Name(n))));
595                            }
596                        }
597                        Inst::Float(fl) => {
598                            if index_lookup.is_none() {
599                                index_lookup =
600                                    Some((i, Operand::Const(physical::Constant::Float(fl))));
601                            }
602                        }
603                        Inst::Time(t) => {
604                            if index_lookup.is_none() {
605                                index_lookup =
606                                    Some((i, Operand::Const(physical::Constant::Time(t))));
607                            }
608                        }
609                        Inst::Duration(d) if index_lookup.is_none() => {
610                            index_lookup =
611                                Some((i, Operand::Const(physical::Constant::Duration(d))));
612                        }
613                        _ => {}
614                    }
615                }
616
617                for arg in &args {
618                    if let Inst::Var(v) = self.ir.get(*arg)
619                        && !bound_vars.contains(v)
620                    {
621                        scan_vars.push(*v);
622                        new_bindings.push(*v);
623                        continue;
624                    }
625                    let tmp = self.fresh_var("scan");
626                    scan_vars.push(tmp);
627                    new_bindings.push(tmp);
628                }
629
630                for v in &new_bindings {
631                    bound_vars.insert(*v);
632                }
633
634                let body = self.plan_join_sequence(premises, bound_vars, continuation)?;
635                let wrapped_body = self.apply_constraints(&args, &scan_vars, body)?;
636
637                let source = if let Some((col_idx, key)) = index_lookup {
638                    DataSource::IndexLookup {
639                        relation: predicate,
640                        col_idx,
641                        key,
642                        vars: scan_vars,
643                    }
644                } else if Some(predicate) == self.delta_pred {
645                    DataSource::ScanDelta {
646                        relation: predicate,
647                        vars: scan_vars,
648                    }
649                } else {
650                    DataSource::Scan {
651                        relation: predicate,
652                        vars: scan_vars,
653                    }
654                };
655                Ok(Op::Iterate {
656                    source,
657                    body: Box::new(wrapped_body),
658                })
659            }
660            Inst::Eq(l, r) => {
661                // `X = expr` where X is a fresh variable is a let-binding,
662                // matching mangle-go and the `|> let X = expr` transform form.
663                // If both sides are already bound, fall back to a runtime
664                // equality check.
665                let binding = self
666                    .eq_as_binding(l, r, bound_vars)
667                    .or_else(|| self.eq_as_binding(r, l, bound_vars));
668                if let Some((var, expr_inst)) = binding {
669                    self.inst_to_expr(expr_inst, |planner, expr| {
670                        bound_vars.insert(var);
671                        let body =
672                            planner.plan_join_sequence(premises, bound_vars, continuation)?;
673                        Ok(Op::Let {
674                            var,
675                            expr,
676                            body: Box::new(body),
677                        })
678                    })
679                } else {
680                    let body = self.plan_join_sequence(premises, bound_vars, continuation)?;
681                    self.wrap_eq_check(l, r, body)
682                }
683            }
684            Inst::Ineq(l, r) => {
685                let body = self.plan_join_sequence(premises, bound_vars, continuation)?;
686                self.with_eval(l, |this, left_op| {
687                    this.with_eval(r, |_this, right_op| {
688                        Ok(Op::Filter {
689                            cond: Condition::Cmp {
690                                op: CmpOp::Neq,
691                                left: left_op.clone(),
692                                right: right_op,
693                            },
694                            body: Box::new(body),
695                        })
696                    })
697                })
698            }
699            Inst::NegAtom(inner) => {
700                let inner_inst = self.ir.get(inner).clone();
701                if let Inst::Atom { predicate, args } = inner_inst {
702                    // Built-in predicates need dedicated negated forms: the
703                    // generic path below would look them up as (never-populated)
704                    // relations in the store and the negation would always
705                    // succeed, silently producing wrong results.
706                    let pred_name = self.ir.resolve_name(predicate).to_string();
707                    match pred_name.as_str() {
708                        ":list:member" => {
709                            if args.len() != 2 {
710                                return Err(anyhow!(":list:member requires exactly 2 arguments"));
711                            }
712                            self.require_bound_args(&args, bound_vars, "!:list:member")?;
713                            let body =
714                                self.plan_join_sequence(premises, bound_vars, continuation)?;
715                            return self.with_eval(args[0], |this, elem_op| {
716                                this.with_eval(args[1], |_this, list_op| {
717                                    Ok(Op::Filter {
718                                        cond: Condition::Not(Box::new(Condition::Call {
719                                            function: predicate,
720                                            args: vec![elem_op, list_op],
721                                        })),
722                                        body: Box::new(body),
723                                    })
724                                })
725                            });
726                        }
727                        ":match_field" => {
728                            if args.len() != 3 {
729                                return Err(anyhow!(":match_field requires exactly 3 arguments"));
730                            }
731                            let field_id = match self.ir.get(args[1]) {
732                                Inst::Name(n) => *n,
733                                _ => {
734                                    return Err(anyhow!(
735                                        ":match_field second argument must be a name constant"
736                                    ));
737                                }
738                            };
739                            self.require_bound_args(&args, bound_vars, "!:match_field")?;
740                            let field_op = Operand::Const(physical::Constant::Name(field_id));
741                            let body =
742                                self.plan_join_sequence(premises, bound_vars, continuation)?;
743                            return self.with_eval(args[0], |this, struct_op| {
744                                this.with_eval(args[2], |_this, value_op| {
745                                    Ok(Op::Filter {
746                                        cond: Condition::Not(Box::new(Condition::Call {
747                                            function: predicate,
748                                            args: vec![struct_op, field_op.clone(), value_op],
749                                        })),
750                                        body: Box::new(body),
751                                    })
752                                })
753                            });
754                        }
755                        ":match_prefix"
756                        | ":string:starts_with"
757                        | ":string:ends_with"
758                        | ":string:contains" => {
759                            if args.len() != 2 {
760                                return Err(anyhow!(
761                                    "Built-in predicate requires exactly 2 arguments"
762                                ));
763                            }
764                            self.require_bound_args(&args, bound_vars, &format!("!{pred_name}"))?;
765                            let body =
766                                self.plan_join_sequence(premises, bound_vars, continuation)?;
767                            return self.with_eval(args[0], |this, left_op| {
768                                this.with_eval(args[1], |_this, right_op| {
769                                    Ok(Op::Filter {
770                                        cond: Condition::Not(Box::new(Condition::Call {
771                                            function: predicate,
772                                            args: vec![left_op.clone(), right_op],
773                                        })),
774                                        body: Box::new(body),
775                                    })
776                                })
777                            });
778                        }
779                        ":lt" | ":le" | ":gt" | ":ge" | ":time:lt" | ":time:le" | ":time:gt"
780                        | ":time:ge" | ":duration:lt" | ":duration:le" | ":duration:gt"
781                        | ":duration:ge" => {
782                            if args.len() != 2 {
783                                return Err(anyhow!(
784                                    "Comparison predicate requires exactly 2 arguments"
785                                ));
786                            }
787                            let cmp_op = match pred_name.as_str() {
788                                ":lt" | ":time:lt" | ":duration:lt" => CmpOp::Lt,
789                                ":le" | ":time:le" | ":duration:le" => CmpOp::Le,
790                                ":gt" | ":time:gt" | ":duration:gt" => CmpOp::Gt,
791                                ":ge" | ":time:ge" | ":duration:ge" => CmpOp::Ge,
792                                _ => unreachable!(),
793                            };
794                            self.require_bound_args(&args, bound_vars, &format!("!{pred_name}"))?;
795                            let body =
796                                self.plan_join_sequence(premises, bound_vars, continuation)?;
797                            return self.with_eval(args[0], |this, left_op| {
798                                this.with_eval(args[1], |_this, right_op| {
799                                    Ok(Op::Filter {
800                                        cond: Condition::Not(Box::new(Condition::Cmp {
801                                            op: cmp_op,
802                                            left: left_op.clone(),
803                                            right: right_op,
804                                        })),
805                                        body: Box::new(body),
806                                    })
807                                })
808                            });
809                        }
810                        other if other.starts_with(':') => {
811                            return Err(anyhow!(
812                                "negation of built-in predicate '{other}' is not supported"
813                            ));
814                        }
815                        _ => {}
816                    }
817                    let body = self.plan_join_sequence(premises, bound_vars, continuation)?;
818                    let mut neg_args = Vec::new();
819                    for arg in &args {
820                        let arg_inst = self.ir.get(*arg).clone();
821                        match arg_inst {
822                            Inst::Var(v) => neg_args.push(Operand::Var(v)),
823                            Inst::Number(n) => {
824                                neg_args.push(Operand::Const(physical::Constant::Number(n)))
825                            }
826                            Inst::String(s) => {
827                                neg_args.push(Operand::Const(physical::Constant::String(s)))
828                            }
829                            Inst::Name(n) => {
830                                neg_args.push(Operand::Const(physical::Constant::Name(n)))
831                            }
832                            Inst::Float(fl) => {
833                                neg_args.push(Operand::Const(physical::Constant::Float(fl)))
834                            }
835                            Inst::Time(t) => {
836                                neg_args.push(Operand::Const(physical::Constant::Time(t)))
837                            }
838                            Inst::Duration(d) => {
839                                neg_args.push(Operand::Const(physical::Constant::Duration(d)))
840                            }
841                            _ => return Err(anyhow!("Complex expression in negated atom")),
842                        }
843                    }
844                    Ok(Op::Filter {
845                        cond: Condition::Negation {
846                            relation: predicate,
847                            args: neg_args,
848                        },
849                        body: Box::new(body),
850                    })
851                } else {
852                    Err(anyhow!("NegAtom wraps non-atom"))
853                }
854            }
855            _ => Err(anyhow!("Unsupported premise type: {:?}", inst)),
856        }
857    }
858
859    /// Negated built-in predicates cannot bind variables: every variable
860    /// argument must already be bound by an earlier premise. Negation-as-
861    /// failure with unbound arguments is unsafe (and meaningless).
862    fn require_bound_args(
863        &self,
864        args: &[InstId],
865        bound_vars: &FxHashSet<NameId>,
866        context: &str,
867    ) -> Result<()> {
868        for arg in args {
869            if let Inst::Var(v) = self.ir.get(*arg)
870                && !bound_vars.contains(v)
871            {
872                return Err(anyhow!(
873                    "{context}: all arguments must be bound by earlier premises"
874                ));
875            }
876        }
877        Ok(())
878    }
879
880    fn apply_constraints(
881        &mut self,
882        args: &[InstId],
883        scan_vars: &[NameId],
884        mut body: Op,
885    ) -> Result<Op> {
886        for (i, arg) in args.iter().enumerate().rev() {
887            let scan_var = scan_vars[i];
888            let arg_inst = self.ir.get(*arg).clone();
889            match arg_inst {
890                Inst::Var(v) => {
891                    if v == scan_var {
892                        continue;
893                    }
894                    body = Op::Filter {
895                        cond: Condition::Cmp {
896                            op: CmpOp::Eq,
897                            left: Operand::Var(scan_var),
898                            right: Operand::Var(v),
899                        },
900                        body: Box::new(body),
901                    };
902                }
903                _ => {
904                    body = self.wrap_eval_check(*arg, Operand::Var(scan_var), body)?;
905                }
906            }
907        }
908        Ok(body)
909    }
910
911    /// If `lhs` is a fresh variable (not yet in `bound_vars`), returns the
912    /// variable name and the expression to bind it to. Otherwise `None`.
913    fn eq_as_binding(
914        &self,
915        lhs: InstId,
916        rhs: InstId,
917        bound_vars: &FxHashSet<NameId>,
918    ) -> Option<(NameId, InstId)> {
919        if let Inst::Var(v) = self.ir.get(lhs)
920            && !bound_vars.contains(v)
921        {
922            return Some((*v, rhs));
923        }
924        None
925    }
926
927    fn wrap_eq_check(&mut self, l: InstId, r: InstId, body: Op) -> Result<Op> {
928        self.with_eval(l, |this, op_l| {
929            this.with_eval(r, |_this, op_r| {
930                Ok(Op::Filter {
931                    cond: Condition::Cmp {
932                        op: CmpOp::Eq,
933                        left: op_l,
934                        right: op_r,
935                    },
936                    body: Box::new(body),
937                })
938            })
939        })
940    }
941
942    fn wrap_eval_check(&mut self, inst: InstId, target: Operand, body: Op) -> Result<Op> {
943        self.with_eval(inst, |_this, op| {
944            Ok(Op::Filter {
945                cond: Condition::Cmp {
946                    op: CmpOp::Eq,
947                    left: target,
948                    right: op,
949                },
950                body: Box::new(body),
951            })
952        })
953    }
954
955    fn with_eval<F>(&mut self, inst: InstId, f: F) -> Result<Op>
956    where
957        F: FnOnce(&mut Self, Operand) -> Result<Op>,
958    {
959        let i = self.ir.get(inst).clone();
960        match i {
961            Inst::Var(v) => f(self, Operand::Var(v)),
962            Inst::String(s) => f(self, Operand::Const(physical::Constant::String(s))),
963            Inst::Number(n) => f(self, Operand::Const(physical::Constant::Number(n))),
964            Inst::Name(n) => f(self, Operand::Const(physical::Constant::Name(n))),
965            Inst::Float(fl) => f(self, Operand::Const(physical::Constant::Float(fl))),
966            Inst::Time(t) => f(self, Operand::Const(physical::Constant::Time(t))),
967            Inst::Duration(d) => f(self, Operand::Const(physical::Constant::Duration(d))),
968            Inst::ApplyFn { function, args } => self.with_eval_args(
969                &args,
970                0,
971                Vec::new(),
972                Box::new(|this, ops| {
973                    let tmp = this.fresh_var("call");
974                    let inner = f(this, Operand::Var(tmp))?;
975                    Ok(Op::Let {
976                        var: tmp,
977                        expr: Expr::Call {
978                            function,
979                            args: ops,
980                        },
981                        body: Box::new(inner),
982                    })
983                }),
984            ),
985            // Compound types: route through function calls
986            Inst::List(args) => {
987                let fn_name = self.ir.intern_name("fn:list".to_string());
988                self.with_eval_args(
989                    &args,
990                    0,
991                    Vec::new(),
992                    Box::new(|this, ops| {
993                        let tmp = this.fresh_var("list");
994                        let inner = f(this, Operand::Var(tmp))?;
995                        Ok(Op::Let {
996                            var: tmp,
997                            expr: Expr::Call {
998                                function: fn_name,
999                                args: ops,
1000                            },
1001                            body: Box::new(inner),
1002                        })
1003                    }),
1004                )
1005            }
1006            Inst::Map { keys, values } => {
1007                // Interleave keys and values: [k1, v1, k2, v2, ...]
1008                let mut interleaved = Vec::with_capacity(keys.len() + values.len());
1009                for (k, v) in keys.iter().zip(values.iter()) {
1010                    interleaved.push(*k);
1011                    interleaved.push(*v);
1012                }
1013                let fn_name = self.ir.intern_name("fn:map".to_string());
1014                self.with_eval_args(
1015                    &interleaved,
1016                    0,
1017                    Vec::new(),
1018                    Box::new(|this, ops| {
1019                        let tmp = this.fresh_var("map");
1020                        let inner = f(this, Operand::Var(tmp))?;
1021                        Ok(Op::Let {
1022                            var: tmp,
1023                            expr: Expr::Call {
1024                                function: fn_name,
1025                                args: ops,
1026                            },
1027                            body: Box::new(inner),
1028                        })
1029                    }),
1030                )
1031            }
1032            Inst::Struct { fields, values } => {
1033                // Interleave field names and values: [name1, val1, name2, val2, ...]
1034                let mut interleaved = Vec::with_capacity(fields.len() + values.len());
1035                for (field, val) in fields.iter().zip(values.iter()) {
1036                    // Field names are NameIds, emit as Name constants
1037                    let name_inst = self.ir.add_inst(Inst::Name(*field));
1038                    interleaved.push(name_inst);
1039                    interleaved.push(*val);
1040                }
1041                let fn_name = self.ir.intern_name("fn:struct".to_string());
1042                self.with_eval_args(
1043                    &interleaved,
1044                    0,
1045                    Vec::new(),
1046                    Box::new(|this, ops| {
1047                        let tmp = this.fresh_var("struct");
1048                        let inner = f(this, Operand::Var(tmp))?;
1049                        Ok(Op::Let {
1050                            var: tmp,
1051                            expr: Expr::Call {
1052                                function: fn_name,
1053                                args: ops,
1054                            },
1055                            body: Box::new(inner),
1056                        })
1057                    }),
1058                )
1059            }
1060            _ => Err(anyhow!("Unsupported expression in evaluation")),
1061        }
1062    }
1063
1064    fn inst_to_expr<F>(&mut self, inst: InstId, f: F) -> Result<Op>
1065    where
1066        F: FnOnce(&mut Self, Expr) -> Result<Op>,
1067    {
1068        let i = self.ir.get(inst).clone();
1069        match i {
1070            Inst::ApplyFn { function, args } => self.with_eval_args(
1071                &args,
1072                0,
1073                Vec::new(),
1074                Box::new(|this, ops| {
1075                    f(
1076                        this,
1077                        Expr::Call {
1078                            function,
1079                            args: ops,
1080                        },
1081                    )
1082                }),
1083            ),
1084            _ => self.with_eval(inst, |this, op| f(this, Expr::Value(op))),
1085        }
1086    }
1087
1088    fn with_eval_args(
1089        &mut self,
1090        args: &[InstId],
1091        index: usize,
1092        mut acc: Vec<Operand>,
1093        f: Box<dyn FnOnce(&mut Self, Vec<Operand>) -> Result<Op> + '_>,
1094    ) -> Result<Op> {
1095        if index >= args.len() {
1096            return f(self, acc);
1097        }
1098        self.with_eval(args[index], |this, op| {
1099            acc.push(op);
1100            this.with_eval_args(args, index + 1, acc, f)
1101        })
1102    }
1103
1104    fn plan_head_insert(
1105        &mut self,
1106        head: InstId,
1107        _bound_vars: &mut FxHashSet<NameId>,
1108    ) -> Result<Op> {
1109        let inst = self.ir.get(head).clone();
1110        if let Inst::Atom { predicate, args } = inst {
1111            self.with_eval_args(
1112                &args,
1113                0,
1114                Vec::new(),
1115                Box::new(|_this, ops| {
1116                    Ok(Op::Insert {
1117                        relation: predicate,
1118                        args: ops,
1119                    })
1120                }),
1121            )
1122        } else {
1123            Err(anyhow!("Head must be an atom"))
1124        }
1125    }
1126
1127    fn get_transform_app_args(&self, t_id: InstId) -> Result<Vec<InstId>> {
1128        if let Inst::Transform { app, .. } = self.ir.get(t_id)
1129            && let Inst::ApplyFn { args, .. } = self.ir.get(*app)
1130        {
1131            return Ok(args.clone());
1132        }
1133        Err(anyhow!("Invalid transform structure"))
1134    }
1135
1136    fn try_parse_aggregate(&mut self, t_id: InstId) -> Result<Option<Aggregate>> {
1137        let inst = self.ir.get(t_id).clone();
1138        if let Inst::Transform {
1139            var: Some(var),
1140            app,
1141        } = inst
1142            && let Inst::ApplyFn { function, args } = self.ir.get(app).clone()
1143        {
1144            let func_name = self.ir.resolve_name(function);
1145            if matches!(
1146                func_name,
1147                "fn:sum"
1148                    | "fn:count"
1149                    | "fn:max"
1150                    | "fn:min"
1151                    | "fn:collect"
1152                    | "fn:collect_distinct"
1153                    | "fn:float:sum"
1154                    | "fn:float:max"
1155                    | "fn:float:min"
1156            ) {
1157                let mut op_args = Vec::new();
1158                for arg in args {
1159                    let arg_inst = self.ir.get(arg).clone();
1160                    match arg_inst {
1161                        Inst::Var(v) => op_args.push(Operand::Var(v)),
1162                        Inst::Number(n) => {
1163                            op_args.push(Operand::Const(physical::Constant::Number(n)))
1164                        }
1165                        Inst::Float(fl) => {
1166                            op_args.push(Operand::Const(physical::Constant::Float(fl)))
1167                        }
1168                        _ => {
1169                            return Err(anyhow!(
1170                                "Complex expressions in aggregates not supported yet"
1171                            ));
1172                        }
1173                    }
1174                }
1175                return Ok(Some(Aggregate {
1176                    var,
1177                    func: function,
1178                    args: op_args,
1179                }));
1180            }
1181        }
1182        Ok(None)
1183    }
1184}