Skip to main content

spg_engine/eval/
compiled.rs

1//! Compiled expressions — PG's ExprState idea (cut 30, extracted
2//! from `eval.rs`; v7.32 perf knife D / architecture v2 P1).
3//!
4//! Walk the tree ONCE per query, pre-resolve column positions and
5//! collation-fold decisions (both row-invariant), emit a flat
6//! post-order step program; per-row evaluation is a linear loop —
7//! no tree dispatch, no name resolution, no collation lookups.
8//! Anything the compiler doesn't model becomes a `Step::Subtree`
9//! that calls the interpreter for that node, so values AND error
10//! behaviour stay bit-for-bit with `eval_expr` (invariant I3).
11
12use alloc::format;
13use alloc::vec::Vec;
14
15use spg_sql::ast::{BinOp, ColumnName, Expr, Literal, UnOp};
16use spg_storage::{Row, Value};
17
18use super::{
19    EvalContext, EvalError, apply_binary, apply_unary, column_collation, composite_eq, eval_expr,
20    like_match_inner, literal_to_value,
21};
22
23pub(crate) enum Step {
24    /// Pre-resolved column read (position into the row).
25    Column(usize),
26    /// Pre-converted literal.
27    Lit(Value<'static>),
28    /// Pops rhs then lhs, pushes the op result. Eager both-sides
29    /// evaluation — same as the interpreter (no short-circuit).
30    Binary(BinOp),
31    /// Comparison whose operands referenced a CaseInsensitive
32    /// column: ASCII-fold Text operands first (decided at compile
33    /// time; the interpreter re-decides per row).
34    BinaryCi(BinOp),
35    Unary(UnOp),
36    IsNull {
37        negated: bool,
38    },
39    /// v7.32 (architecture v2, P1) — `needle [NOT] IN (literals…)`.
40    /// The membership SET is a COMPILE PRODUCT, not a runtime cache:
41    /// it lives in the step, so there is no "forgot to pass the
42    /// memo" failure mode (the round-25 18.7 s accident is now
43    /// unconstructable — see v7.32-executor-architecture-design.md
44    /// invariant I2). The needle is the preceding sub-program; this
45    /// step pops it. `fallback` is the whole InList node, used only
46    /// when the runtime needle family doesn't match the set
47    /// (e.g. Float needle vs Int set) — same escape the interpreter
48    /// takes, evaluated cold.
49    InSet {
50        set: crate::memoize::InListSet,
51        has_null: bool,
52        negated: bool,
53        fallback: Expr,
54    },
55    /// v7.32 (P1) — `text [NOT] [I]LIKE '<literal pattern>'`. The
56    /// pattern (and its lowercased form for ILIKE) is compiled once;
57    /// the step pops the text operand.
58    Like {
59        pattern: alloc::vec::Vec<char>,
60        negated: bool,
61        case_insensitive: bool,
62    },
63    /// v7.36 (perf — mailrs Ask 1) — pure scalar function call
64    /// (LENGTH, COALESCE, UPPER, etc.) on already-pushed args.
65    /// Pops `n_args` values, calls `apply_function(name, args, ctx)`,
66    /// pushes the result. Replaces the Subtree fallback for the
67    /// "function over bound columns" shape that aggregate arg paths
68    /// like `SUM(LENGTH(text_body))` and `MAX(COALESCE(col, ''))`
69    /// otherwise force the row-materialise eval path. Only the
70    /// `fully_compilable` whitelist (PURE scalars — no NOW / RANDOM
71    /// / sequence accessors) is emitted; everything else stays on
72    /// `Step::Subtree`.
73    /// `name_lower` is pre-lowercased at compile time so the per-
74    /// row dispatch in `apply_function` skips an allocation on
75    /// every input row.
76    Function {
77        name_lower: alloc::string::String,
78        n_args: usize,
79    },
80    /// v7.36 (perf — mailrs Ask 1 SUM(LENGTH(text_body)) zero-copy)
81    /// — `LENGTH(<column>)` / `CHAR_LENGTH(<column>)` /
82    /// `CHARACTER_LENGTH(<column>)` over a bound column. Reads the
83    /// cell by reference, computes the char length WITHOUT cloning
84    /// the underlying `String` — the 1 KB text bodies in
85    /// `user_storage_usage` otherwise pay 25 k × 1 KB heap allocs
86    /// per query just to push a `Value::Text` onto the stack so the
87    /// next Step pops it and asks `s.len()`.
88    ColumnLength {
89        pos: usize,
90    },
91    /// v7.36 — `OCTET_LENGTH(<column>)` — byte count, regardless of
92    /// encoding. Even simpler than `ColumnLength` (no ASCII probe).
93    ColumnOctetLength {
94        pos: usize,
95    },
96    /// v7.36 — `CAST(<expr> AS <ty>)` over an already-pushed value.
97    /// Pure / context-free conversion goes through the same
98    /// `cast_value` dispatcher the interpreter uses.
99    Cast {
100        target: spg_sql::ast::CastTarget,
101    },
102    /// v7.37.5-A2b — `CASE [operand] WHEN x THEN y … ELSE z END`.
103    /// Each `(when, then)` branch and the optional `else` is a
104    /// pre-compiled sub-program; the executor short-circuits on the
105    /// first matching WHEN. Compiles only when **every** sub-program
106    /// is itself `fully_compilable` (so the Case never falls back to
107    /// a Subtree that would force a row materialise — profile-guided
108    /// fix for Track A `COUNT(DISTINCT CASE WHEN ...)` aggregates).
109    /// Searched form has `operand=None` and treats each WHEN as a
110    /// Bool predicate; simple form has `operand=Some(prog)` and
111    /// compares the operand value with each WHEN via `BinOp::Eq`.
112    Case {
113        operand: Option<CompiledExpr>,
114        branches: alloc::vec::Vec<(CompiledExpr, CompiledExpr)>,
115        else_branch: Option<CompiledExpr>,
116    },
117    /// Fallback: interpret this subtree with eval_expr.
118    Subtree(Expr),
119}
120
121pub(crate) struct CompiledExpr {
122    steps: Vec<Step>,
123}
124
125impl CompiledExpr {
126    /// v7.36 (perf — mailrs Phase 1, user_storage_usage hot loop) —
127    /// shape inspector for the aggregate's tight inner. Returns
128    /// `Some(pos)` iff this compiled expression is exactly the
129    /// single step `ColumnLength { pos }` — i.e. `LENGTH(<column>)`
130    /// on a bound text column with no surrounding work.
131    pub(crate) fn as_single_column_length(&self) -> Option<usize> {
132        if self.steps.len() == 1
133            && let Step::ColumnLength { pos } = &self.steps[0]
134        {
135            Some(*pos)
136        } else {
137            None
138        }
139    }
140}
141
142/// Column-position resolution at compile time. Mirrors the happy
143/// layers of `resolve_column`; ANY case that would reach an error
144/// path, an ambiguity, or a miss returns None so the node falls
145/// back to the interpreter (identical runtime error / NULL
146/// semantics).
147fn compile_column_pos(c: &ColumnName, ctx: &EvalContext<'_>) -> Option<usize> {
148    if let Some(q) = &c.qualifier {
149        if let Some(pos) = ctx
150            .columns
151            .iter()
152            .position(|s| composite_eq(&s.name, q, &c.name))
153        {
154            return Some(pos);
155        }
156        // resolve_column's error layers live behind this point:
157        // composites under the qualifier exist (ColumnNotFound) or
158        // the qualifier is unknown (UnknownQualifier) — interpret.
159        let prefix_exists = ctx.columns.iter().any(|s| {
160            s.name.starts_with(q.as_str()) && s.name.as_bytes().get(q.len()) == Some(&b'.')
161        });
162        if prefix_exists {
163            return None;
164        }
165        match ctx.table_alias {
166            // Alias-accepted single-table reference: fall through
167            // to the bare layers (the inner-subquery hot shape).
168            Some(a) if a == q => {}
169            _ => return None,
170        }
171    }
172    if let Some(pos) = ctx.columns.iter().position(|s| s.name == c.name) {
173        return Some(pos);
174    }
175    let mut matches = ctx.columns.iter().enumerate().filter(|(_, s)| {
176        s.name.len() > c.name.len()
177            && s.name.ends_with(c.name.as_str())
178            && s.name.as_bytes()[s.name.len() - c.name.len() - 1] == b'.'
179    });
180    let first = matches.next();
181    if matches.next().is_some() {
182        return None; // ambiguous — interpreter owns the error text
183    }
184    first.map(|(i, _)| i)
185}
186
187fn compile_into(e: &Expr, ctx: &EvalContext<'_>, steps: &mut Vec<Step>) {
188    match e {
189        Expr::Literal(l) => steps.push(Step::Lit(literal_to_value(l))),
190        Expr::Column(c) => match compile_column_pos(c, ctx) {
191            Some(pos) => steps.push(Step::Column(pos)),
192            None => steps.push(Step::Subtree(e.clone())),
193        },
194        Expr::Binary { lhs, op, rhs } => {
195            compile_into(lhs, ctx, steps);
196            compile_into(rhs, ctx, steps);
197            let cmp = matches!(
198                op,
199                BinOp::Eq | BinOp::NotEq | BinOp::Lt | BinOp::LtEq | BinOp::Gt | BinOp::GtEq
200            );
201            let ci = cmp
202                && (matches!(
203                    column_collation(lhs, ctx),
204                    Some(spg_storage::Collation::CaseInsensitive)
205                ) || matches!(
206                    column_collation(rhs, ctx),
207                    Some(spg_storage::Collation::CaseInsensitive)
208                ));
209            steps.push(if ci {
210                Step::BinaryCi(*op)
211            } else {
212                Step::Binary(*op)
213            });
214        }
215        Expr::Unary { op, expr } => {
216            compile_into(expr, ctx, steps);
217            steps.push(Step::Unary(*op));
218        }
219        Expr::IsNull { expr, negated } => {
220            compile_into(expr, ctx, steps);
221            steps.push(Step::IsNull { negated: *negated });
222        }
223        Expr::InList {
224            expr,
225            list,
226            negated,
227        } => {
228            // I2: the set is built at compile time. The gate
229            // (`fully_compilable`) guarantees we only reach here
230            // when the list builds a set and the needle compiles —
231            // but keep the Subtree fallback for defence in depth.
232            match crate::build_in_list_set(list) {
233                Some(entry) if fully_compilable(expr) => {
234                    compile_into(expr, ctx, steps);
235                    steps.push(Step::InSet {
236                        set: entry.set,
237                        has_null: entry.has_null,
238                        negated: *negated,
239                        fallback: e.clone(),
240                    });
241                }
242                _ => steps.push(Step::Subtree(e.clone())),
243            }
244        }
245        Expr::Like {
246            expr,
247            pattern,
248            negated,
249            case_insensitive,
250        } => match literal_text_pattern(pattern) {
251            Some(pat) if fully_compilable(expr) => {
252                // v7.36 (perf — mailrs Phase 1, get_contacts hot
253                // inner) — trivial all-`%` pattern (`%`, `%%`, …)
254                // matches every non-NULL text. Collapse the LIKE
255                // into a `lhs IS NOT NULL` check: emit the operand
256                // then `IsNull { negated: !*negated }`. For ILIKE
257                // `%%` on 25 k rows the per-row `like_match_inner`
258                // → 2-char walk (~30 ns each) becomes a tag check
259                // (~3 ns); the operand still gets evaluated for the
260                // NULL semantics that SQL `LIKE` requires.
261                if !pat.is_empty() && pat.chars().all(|c| c == '%') {
262                    compile_into(expr, ctx, steps);
263                    steps.push(Step::IsNull { negated: !*negated });
264                    return;
265                }
266                compile_into(expr, ctx, steps);
267                let chars: alloc::vec::Vec<char> = if *case_insensitive {
268                    pat.to_lowercase().chars().collect()
269                } else {
270                    pat.chars().collect()
271                };
272                steps.push(Step::Like {
273                    pattern: chars,
274                    negated: *negated,
275                    case_insensitive: *case_insensitive,
276                });
277            }
278            _ => steps.push(Step::Subtree(e.clone())),
279        },
280        // v7.36 — PURE scalar function call: emit args then a
281        // single Function step that pops them. `fully_compilable`
282        // gates the whitelist + recurses into args, so this branch
283        // only fires when the entire subtree is compilable.
284        Expr::FunctionCall { name, args } if is_pure_scalar_function(name) => {
285            // v7.36 — specialise `LENGTH(<column>)` /
286            // `OCTET_LENGTH(<column>)` so the column's `Value::Text`
287            // isn't cloned just to read its length. The general
288            // `Step::Function` path goes through `apply_function`,
289            // which can't borrow off the stack — it copies.
290            let lower = name.to_ascii_lowercase();
291            if args.len() == 1 {
292                if let Expr::Column(c) = &args[0]
293                    && let Some(pos) = compile_column_pos(c, ctx)
294                {
295                    match lower.as_str() {
296                        "length" | "char_length" | "character_length" => {
297                            steps.push(Step::ColumnLength { pos });
298                            return;
299                        }
300                        "octet_length" => {
301                            steps.push(Step::ColumnOctetLength { pos });
302                            return;
303                        }
304                        _ => {}
305                    }
306                }
307            }
308            for a in args {
309                compile_into(a, ctx, steps);
310            }
311            steps.push(Step::Function {
312                name_lower: lower,
313                n_args: args.len(),
314            });
315        }
316        Expr::Cast { expr, target } => {
317            compile_into(expr, ctx, steps);
318            steps.push(Step::Cast {
319                target: target.clone(),
320            });
321        }
322        Expr::Case {
323            operand,
324            branches,
325            else_branch,
326        } => {
327            // Gate by `fully_compilable` at the leaf: if any sub-expr
328            // can't compile natively, the whole Case stays Subtree so
329            // a single Case never escapes to a row-materialise eval.
330            let all_ok = operand.as_deref().is_none_or(fully_compilable)
331                && branches
332                    .iter()
333                    .all(|(w, t)| fully_compilable(w) && fully_compilable(t))
334                && else_branch.as_deref().is_none_or(fully_compilable);
335            if !all_ok {
336                steps.push(Step::Subtree(e.clone()));
337                return;
338            }
339            let op_c = operand.as_deref().map(|o| compile_expr(o, ctx));
340            let branches_c: alloc::vec::Vec<(CompiledExpr, CompiledExpr)> = branches
341                .iter()
342                .map(|(w, t)| (compile_expr(w, ctx), compile_expr(t, ctx)))
343                .collect();
344            let else_c = else_branch.as_deref().map(|el| compile_expr(el, ctx));
345            steps.push(Step::Case {
346                operand: op_c,
347                branches: branches_c,
348                else_branch: else_c,
349            });
350        }
351        other => steps.push(Step::Subtree(other.clone())),
352    }
353}
354
355/// Literal text pattern behind a LIKE/ILIKE, if any.
356fn literal_text_pattern(pattern: &Expr) -> Option<&str> {
357    match pattern {
358        Expr::Literal(Literal::String(s)) => Some(s.as_str()),
359        _ => None,
360    }
361}
362
363/// True when the whole tree consists of nodes the compiler models
364/// natively. Mixed trees stay on the interpreted path: a Subtree
365/// fallback would run WITHOUT the per-query MemoizeCache, and
366/// memo-dependent nodes (InList set fast path — round-25) rebuild
367/// per row there. Measured: compiling a search WHERE with an
368/// InList subtree regressed 634 ms → 18.7 s.
369pub(crate) fn fully_compilable(e: &Expr) -> bool {
370    match e {
371        Expr::Literal(_) | Expr::Column(_) => true,
372        Expr::Binary { lhs, rhs, .. } => fully_compilable(lhs) && fully_compilable(rhs),
373        Expr::Unary { expr, .. } | Expr::IsNull { expr, .. } => fully_compilable(expr),
374        // I2: an InList is compilable ONLY when it becomes a real
375        // InSet (all-literal list + compilable needle). A
376        // non-set-able InList must keep the whole tree off the
377        // compiled path so it never degrades to a memo-less,
378        // O(list) per-row Subtree (the round-25 18.7 s trap).
379        Expr::InList { expr, list, .. } => {
380            fully_compilable(expr) && crate::build_in_list_set(list).is_some()
381        }
382        Expr::Like { expr, pattern, .. } => {
383            fully_compilable(expr) && literal_text_pattern(pattern).is_some()
384        }
385        // v7.36 (perf — mailrs Ask 1) — PURE scalar functions over
386        // compilable args go to `Step::Function`. The whitelist
387        // covers the high-traffic / non-volatile cases; anything
388        // outside (NOW, RANDOM, sequence accessors, EXTRACT-with-
389        // context-dependent fields, etc.) stays on Subtree where
390        // the interpreter has the full ctx.
391        Expr::FunctionCall { name, args } => {
392            is_pure_scalar_function(name) && args.iter().all(fully_compilable)
393        }
394        // v7.36 — CAST over a compilable expression. `cast_value`
395        // is pure / context-free for the scalar targets we care
396        // about (text, ints, floats, bool, dates).
397        Expr::Cast { expr, .. } => fully_compilable(expr),
398        // v7.37.5-A2b — `CASE [operand] WHEN x THEN y … ELSE z END`
399        // when every sub-expression is itself fully-compilable. Hot
400        // shape: Track A's 14 aggregates over
401        // `COUNT(DISTINCT CASE WHEN m.message_id != '' THEN
402        //                          m.message_id
403        //                     ELSE CAST(m.id AS TEXT) END)` — without
404        // this, every Case fell to `arg_compiled = None`, forced
405        // `needs_mat = true` per-row, and triggered a full combined-
406        // row `Vec<Value>` clone for the eval path.
407        Expr::Case {
408            operand,
409            branches,
410            else_branch,
411        } => {
412            operand.as_deref().is_none_or(fully_compilable)
413                && branches
414                    .iter()
415                    .all(|(w, t)| fully_compilable(w) && fully_compilable(t))
416                && else_branch.as_deref().is_none_or(fully_compilable)
417        }
418        _ => false,
419    }
420}
421
422/// v7.36 — PURE scalar function whitelist for `Step::Function`.
423/// "Pure" means: deterministic, context-independent, no side
424/// effects. Aggregate names (sum / count / max / …) are filtered
425/// upstream by the caller — they never reach the compiler. NOW /
426/// RANDOM / sequence accessors are excluded because they need the
427/// `EvalContext`'s clock / sequence resolver and aren't
428/// deterministic. EXTRACT is excluded because the field kind is
429/// parsed off the Expr tree, not an arg.
430fn is_pure_scalar_function(name: &str) -> bool {
431    matches!(
432        name.to_ascii_lowercase().as_str(),
433        // string length + slicing
434        "length"
435            | "char_length"
436            | "character_length"
437            | "octet_length"
438            | "upper"
439            | "lower"
440            | "trim"
441            | "ltrim"
442            | "rtrim"
443            | "btrim"
444            | "left"
445            | "right"
446            | "substring"
447            | "substr"
448            | "replace"
449            | "position"
450            | "strpos"
451            | "concat"
452            | "concat_ws"
453            | "reverse"
454            | "repeat"
455            | "lpad"
456            | "rpad"
457            | "split_part"
458            // null/conditional
459            | "coalesce"
460            | "nullif"
461            | "greatest"
462            | "least"
463            | "ifnull"
464            | "isnull"
465            | "nvl"
466            // numeric
467            | "abs"
468            | "ceil"
469            | "ceiling"
470            | "floor"
471            | "round"
472            | "trunc"
473            | "sqrt"
474            | "power"
475            | "pow"
476            | "mod"
477            | "sign"
478            | "log"
479            | "log10"
480            | "exp"
481            | "ln"
482            // boolean / cast helpers
483            | "cast"
484    )
485}
486
487pub(crate) fn compile_expr(e: &Expr, ctx: &EvalContext<'_>) -> CompiledExpr {
488    let mut steps = Vec::new();
489    compile_into(e, ctx, &mut steps);
490    CompiledExpr { steps }
491}
492
493/// Run a compiled program. `stack` is caller-owned scratch
494/// (cleared here) so tight row loops never touch the allocator
495/// for the machine itself.
496pub(crate) fn eval_compiled(
497    c: &CompiledExpr,
498    row: &Row<'static>,
499    ctx: &EvalContext<'_>,
500    _stack: &mut Vec<Value<'static>>,
501) -> Result<Value<'static>, EvalError> {
502    // v7.37.9 T3 S2 — the public eval_compiled keeps its
503    // `Vec<Value<'static>>` API for callers (post-group projection
504    // path at aggregate.rs:702), but internally drops the borrowed
505    // stack into a local one because the borrow lifetime of the
506    // caller's stack can't bridge the row-borrow `'row` constraint
507    // that eval_compiled_ref needs after S2. This callsite fires
508    // ~50 × per query (LIMIT 50 surviving groups), so the per-call
509    // Vec alloc is negligible compared to the per-row hot path.
510    let rowref = crate::join::RowRef::Owned(row);
511    let mut local_stack: Vec<Value<'_>> = Vec::with_capacity(16);
512    let result = eval_compiled_ref(c, &rowref, ctx, &mut local_stack)?;
513    Ok(result.into_owned())
514}
515
516/// v7.32 (P4 borrow channel, increment 2) — the RowRef-borrowing form of
517/// `eval_compiled`. `Step::Column` borrows its cell straight from the
518/// RowRef (a join tuple resolves it via `tuple_value`, never
519/// materialising a combined Row); only the rare Subtree / InSet
520/// cross-family fallback materialises the row once. Bit-for-bit
521/// equivalent to the Owned path — `eval_compiled` above is now a thin
522/// `RowRef::Owned` wrapper, so there is a single interpreter (invariant
523/// I3); a differential test pins the equivalence.
524// v7.37.9 T3 S1 — row-lifetime stack plumbing. Two lifetimes:
525// `'row` = the RowRef's data lifetime; `'val` = stack value lifetime
526// (must outlive function return). Constraint `'row: 'val` allows the
527// step body to push `Value::Text(Cow::Borrowed(row_cell))` (S2+) while
528// the caller's stack stays at whatever lifetime it declared (often
529// `'static` for Vec<Value<'static>>). S1 keeps every step body forcing
530// `.into_owned()` so behaviour is bit-identical; later stages
531// (S2 Column, S3 Lit, S4 Binary, S6 Function, S7 Case) progressively
532// switch to borrowed push to eliminate per-row String allocs.
533pub(crate) fn eval_compiled_ref<'row, 'val>(
534    c: &'val CompiledExpr,
535    row: &'val crate::join::RowRef<'row>,
536    ctx: &EvalContext<'_>,
537    stack: &mut Vec<Value<'val>>,
538) -> Result<Value<'val>, EvalError>
539where
540    'row: 'val,
541{
542    stack.clear();
543    run_compiled_steps(&c.steps, row, ctx, stack)?;
544    Ok(stack.pop().unwrap_or(Value::Null))
545}
546
547/// v7.37.5-A2b — append-mode entry point for nested sub-programs (the
548/// `Step::Case` executor's per-branch evaluations). Does NOT clear the
549/// stack; pushes the program's result on top of whatever was already
550/// there. Caller uses the `mark` to know where to truncate / pop. Kept
551/// out of public surface — only the Case opcode reaches for it.
552fn eval_compiled_ref_into<'row, 'val>(
553    c: &'val CompiledExpr,
554    row: &'val crate::join::RowRef<'row>,
555    ctx: &EvalContext<'_>,
556    stack: &mut Vec<Value<'val>>,
557    _mark: usize,
558) -> Result<(), EvalError>
559where
560    'row: 'val,
561{
562    run_compiled_steps(&c.steps, row, ctx, stack)
563}
564
565#[inline]
566fn run_compiled_steps<'row, 'val>(
567    steps: &'val [Step],
568    row: &'val crate::join::RowRef<'row>,
569    ctx: &EvalContext<'_>,
570    stack: &mut Vec<Value<'val>>,
571) -> Result<(), EvalError>
572where
573    'row: 'val,
574{
575    // v7.37.9 Phase 1A-ext-2 T1 — counter per call into the Step VM
576    // interpreter. Tells us "how many steps does the average compiled
577    // arg run per row" → narrows the attack target (subtree CSE vs
578    // column-ref-push vs multi-spec combine). Read-only.
579    crate::bump_counter!(STEP_VM_CALL_COUNT);
580    crate::bump_counter!(STEP_VM_STEPS_TOTAL, steps.len() as u64);
581    for step in steps {
582        match step {
583            Step::Column(pos) => {
584                crate::bump_counter!(STEP_VM_COLUMN_FIRE);
585                // v7.37.9 T3 S2 — catalog rows hold `Cow::Owned(String)`
586                // for Text-class variants (per `spg-storage/src/lib.rs:539`
587                // — "Persistent / catalog Values use Value<'static> with
588                // Cow::Owned(...)"). Plain `.clone()` would therefore
589                // still trigger `String::clone()` per cell read. Instead
590                // manually wrap the existing storage into a borrowed Cow
591                // pointing at the same bytes — zero-alloc push.
592                let cell: Value<'val> = match row.get(*pos) {
593                    Some(spg_storage::Value::Text(s)) => {
594                        spg_storage::Value::Text(alloc::borrow::Cow::Borrowed(s.as_ref()))
595                    }
596                    Some(spg_storage::Value::Bytes(b)) => {
597                        spg_storage::Value::Bytes(alloc::borrow::Cow::Borrowed(b.as_ref()))
598                    }
599                    Some(spg_storage::Value::Json(s)) => {
600                        spg_storage::Value::Json(alloc::borrow::Cow::Borrowed(s.as_ref()))
601                    }
602                    Some(spg_storage::Value::Vector(v)) => {
603                        spg_storage::Value::Vector(alloc::borrow::Cow::Borrowed(v.as_ref()))
604                    }
605                    // Copy-light variants: clone is free (just enum copy).
606                    Some(v) => v.clone(),
607                    None => Value::Null,
608                };
609                // Classification counter unchanged (still counts cells
610                // that WERE heap-bearing in the baseline).
611                if matches!(
612                    &cell,
613                    spg_storage::Value::Text(_)
614                        | spg_storage::Value::Bytes(_)
615                        | spg_storage::Value::Json(_)
616                        | spg_storage::Value::Vector(_)
617                ) {
618                    crate::bump_counter!(STEP_VM_COLUMN_HEAP_ALLOC);
619                }
620                stack.push(cell);
621            }
622            Step::Lit(v) => {
623                crate::bump_counter!(STEP_VM_LIT_FIRE);
624                if matches!(
625                    v,
626                    spg_storage::Value::Text(_)
627                        | spg_storage::Value::Bytes(_)
628                        | spg_storage::Value::Json(_)
629                        | spg_storage::Value::Vector(_)
630                ) {
631                    crate::bump_counter!(STEP_VM_LIT_HEAP_ALLOC);
632                }
633                // v7.37.9 T3 S3 — borrow literal storage instead of
634                // String::clone'ing it. Step variants own their
635                // literal (`Value<'static>` enum payload), so we can
636                // safely construct a `Cow::Borrowed(&'static …)` view.
637                // Same pattern as S2's Column path.
638                let pushed: Value<'val> = match v {
639                    spg_storage::Value::Text(s) => {
640                        spg_storage::Value::Text(alloc::borrow::Cow::Borrowed(s.as_ref()))
641                    }
642                    spg_storage::Value::Bytes(b) => {
643                        spg_storage::Value::Bytes(alloc::borrow::Cow::Borrowed(b.as_ref()))
644                    }
645                    spg_storage::Value::Json(s) => {
646                        spg_storage::Value::Json(alloc::borrow::Cow::Borrowed(s.as_ref()))
647                    }
648                    spg_storage::Value::Vector(vec) => {
649                        spg_storage::Value::Vector(alloc::borrow::Cow::Borrowed(vec.as_ref()))
650                    }
651                    other => other.clone(),
652                };
653                stack.push(pushed);
654            }
655            Step::Binary(op) => {
656                crate::bump_counter!(STEP_VM_BINARY_FIRE);
657                // v7.37.9 T3 S4 — try the by-ref fast path first
658                // (comparison + 3VL ops). For those, operand bytes are
659                // read but never stored in the result; we avoid the
660                // .into_owned() that would clone every Cow::Borrowed
661                // Text/Bytes/Json/Vector pushed by S2/S3. For ops that
662                // build owned results (arithmetic, concat, json get,
663                // etc.) apply_binary_by_ref returns None and we fall
664                // through to the owning path.
665                let n = stack.len();
666                if n >= 2 {
667                    if let Some(result) = super::apply_binary_by_ref(
668                        *op,
669                        &stack[n - 2],
670                        &stack[n - 1],
671                    )? {
672                        stack.truncate(n - 2);
673                        stack.push(result);
674                        continue;
675                    }
676                }
677                let r = stack.pop().unwrap_or(Value::Null).into_owned();
678                let l = stack.pop().unwrap_or(Value::Null).into_owned();
679                stack.push(apply_binary(*op, l, r)?);
680            }
681            Step::BinaryCi(op) => {
682                let fold = |v: Value<'static>| match v {
683                    Value::Text(s) => Value::text(s.to_ascii_lowercase()),
684                    other => other,
685                };
686                let r = fold(stack.pop().unwrap_or(Value::Null).into_owned());
687                let l = fold(stack.pop().unwrap_or(Value::Null).into_owned());
688                stack.push(apply_binary(*op, l, r)?);
689            }
690            Step::Unary(op) => {
691                let v = stack.pop().unwrap_or(Value::Null).into_owned();
692                stack.push(apply_unary(*op, v)?);
693            }
694            Step::IsNull { negated } => {
695                let v = stack.pop().unwrap_or(Value::Null);
696                let is_null = matches!(v, Value::Null);
697                stack.push(Value::Bool(if *negated { !is_null } else { is_null }));
698            }
699            Step::InSet {
700                set,
701                has_null,
702                negated,
703                fallback,
704            } => {
705                let needle = stack.pop().unwrap_or(Value::Null);
706                let contained = match (&needle, set) {
707                    // Non-empty list + NULL needle → NULL (NOT NULL
708                    // is still NULL) — matches the interpreter and
709                    // eval_with_in_sets.
710                    (Value::Null, _) => {
711                        stack.push(Value::Null);
712                        continue;
713                    }
714                    (Value::SmallInt(n), crate::memoize::InListSet::Int(s)) => {
715                        s.contains(&i64::from(*n))
716                    }
717                    (Value::Int(n), crate::memoize::InListSet::Int(s)) => {
718                        s.contains(&i64::from(*n))
719                    }
720                    (Value::BigInt(n), crate::memoize::InListSet::Int(s)) => s.contains(n),
721                    (Value::Text(t), crate::memoize::InListSet::Text(s)) => s.contains(t.as_ref()),
722                    // Cross-family needle: take the interpreter's
723                    // exact coercion / error path on the whole node.
724                    _ => {
725                        stack.push(eval_expr(fallback, &row.as_row(), ctx)?);
726                        continue;
727                    }
728                };
729                let inner = if contained {
730                    Value::Bool(true)
731                } else if *has_null {
732                    Value::Null
733                } else {
734                    Value::Bool(false)
735                };
736                stack.push(match (negated, inner) {
737                    (true, Value::Bool(b)) => Value::Bool(!b),
738                    (_, v) => v,
739                });
740            }
741            Step::Like {
742                pattern,
743                negated,
744                case_insensitive,
745            } => {
746                let v = stack.pop().unwrap_or(Value::Null).into_owned();
747                match v {
748                    Value::Null => stack.push(Value::Null),
749                    Value::Text(t) => {
750                        let text: Vec<char> = if *case_insensitive {
751                            t.to_lowercase().chars().collect()
752                        } else {
753                            t.chars().collect()
754                        };
755                        let m = like_match_inner(&text, 0, pattern, 0);
756                        stack.push(Value::Bool(if *negated { !m } else { m }));
757                    }
758                    other => {
759                        return Err(EvalError::TypeMismatch {
760                            detail: format!(
761                                "LIKE requires text operands, got {:?}",
762                                other.data_type()
763                            ),
764                        });
765                    }
766                }
767            }
768            Step::ColumnLength { pos } => {
769                // v7.36 — zero-copy LENGTH on a column. Read the
770                // cell by reference; compute char count without
771                // cloning the underlying `String`. Saves 25 k ×
772                // ~1 KB heap clones on the user_storage_usage shape.
773                let v = row.get(*pos).unwrap_or(&Value::Null);
774                let pushed = match v {
775                    Value::Null => Value::Null,
776                    Value::Text(s) => {
777                        let n = if s.is_ascii() {
778                            i32::try_from(s.len()).unwrap_or(i32::MAX)
779                        } else {
780                            i32::try_from(s.chars().count()).unwrap_or(i32::MAX)
781                        };
782                        Value::Int(n)
783                    }
784                    Value::Bytes(b) => Value::Int(i32::try_from(b.len()).unwrap_or(i32::MAX)),
785                    other => {
786                        return Err(EvalError::TypeMismatch {
787                            detail: format!(
788                                "length() needs text or bytea, got {:?}",
789                                other.data_type()
790                            ),
791                        });
792                    }
793                };
794                stack.push(pushed);
795            }
796            Step::ColumnOctetLength { pos } => {
797                let v = row.get(*pos).unwrap_or(&Value::Null);
798                let pushed = match v {
799                    Value::Null => Value::Null,
800                    Value::Text(s) => Value::Int(i32::try_from(s.len()).unwrap_or(i32::MAX)),
801                    Value::Bytes(b) => Value::Int(i32::try_from(b.len()).unwrap_or(i32::MAX)),
802                    other => {
803                        return Err(EvalError::TypeMismatch {
804                            detail: format!(
805                                "octet_length() needs text or bytea, got {:?}",
806                                other.data_type()
807                            ),
808                        });
809                    }
810                };
811                stack.push(pushed);
812            }
813            Step::Function { name_lower, n_args } => {
814                crate::bump_counter!(STEP_VM_FUNCTION_FIRE);
815                let start = stack.len().saturating_sub(*n_args);
816                // `apply_function` borrows the trailing `n_args`
817                // values off the stack; we then truncate + push the
818                // result. `name_lower` is pre-lowercased at compile
819                // time, so dispatch skips the per-row
820                // `to_ascii_lowercase()` allocation.
821                // v7.37.9 T3 S6 — apply_function_lower signature relaxed
822                // to `&[Value<'_>]`; pass the borrowed stack slice
823                // directly. Eliminates the Vec materialise + per-arg
824                // String::clone that S1 introduced as a placeholder.
825                let result =
826                    super::functions::apply_function_lower(name_lower, &stack[start..], ctx)?;
827                stack.truncate(start);
828                stack.push(result);
829            }
830            Step::Cast { target } => {
831                crate::bump_counter!(STEP_VM_CAST_FIRE);
832                let v = stack.pop().unwrap_or(Value::Null).into_owned();
833                stack.push(super::cast::cast_value(v, target.clone())?);
834            }
835            Step::Case {
836                operand,
837                branches,
838                else_branch,
839            } => {
840                crate::bump_counter!(STEP_VM_CASE_FIRE);
841                // v7.37.5-A2b — short-circuit Case executor. Mirrors
842                // `Expr::Case` interpreter semantics bit-for-bit (each
843                // WHEN evaluates with its own scratch stack; first
844                // match wins; ELSE = NULL when absent). The outer
845                // `stack` is reused (truncated back to its pre-Case
846                // mark after each sub-program); allocator-free per
847                // branch — the prior version allocated a fresh
848                // `Vec<Value>` per sub-program which showed up as
849                // ~3 % `drop_in_place<Vec<Value>>` self time.
850                let mark = stack.len();
851                // v7.37.9 T3 S7 — Case sub-program lifetime threads
852                // through naturally via S1's `'row: 'val`. Operand /
853                // when / matched / else results are pushed by sub-progs
854                // into our same stack; we pop them as `Value<'val>` and
855                // keep them at that lifetime instead of forcing
856                // into_owned. The simple-form operand match (Eq) uses
857                // apply_binary_by_ref to avoid the operand clone +
858                // pop-side into_owned the S1 placeholder was paying.
859                let operand_value: Option<Value<'val>> = if let Some(op) = operand {
860                    eval_compiled_ref_into(op, row, ctx, stack, mark)?;
861                    Some(stack.pop().unwrap_or(Value::Null))
862                } else {
863                    None
864                };
865                stack.truncate(mark);
866                let mut matched_value: Option<Value<'val>> = None;
867                for (when_c, then_c) in branches {
868                    eval_compiled_ref_into(when_c, row, ctx, stack, mark)?;
869                    let when_v = stack.pop().unwrap_or(Value::Null);
870                    stack.truncate(mark);
871                    let matched = match &operand_value {
872                        None => matches!(when_v, Value::Bool(true)),
873                        Some(op_v) => {
874                            // Try the by-ref comparison fast path; fall
875                            // back to owning apply_binary only if the
876                            // by-ref path returns None (non-comparison
877                            // op, which Eq never is).
878                            let eq_result = match super::apply_binary_by_ref(
879                                BinOp::Eq,
880                                op_v,
881                                &when_v,
882                            )? {
883                                Some(v) => v,
884                                None => apply_binary(
885                                    BinOp::Eq,
886                                    op_v.clone().into_owned(),
887                                    when_v.clone().into_owned(),
888                                )?,
889                            };
890                            matches!(eq_result, Value::Bool(true))
891                        }
892                    };
893                    if matched {
894                        eval_compiled_ref_into(then_c, row, ctx, stack, mark)?;
895                        matched_value = Some(stack.pop().unwrap_or(Value::Null));
896                        stack.truncate(mark);
897                        break;
898                    }
899                }
900                let v: Value<'val> = match matched_value {
901                    Some(v) => v,
902                    None => match else_branch {
903                        Some(el) => {
904                            eval_compiled_ref_into(el, row, ctx, stack, mark)?;
905                            let v = stack.pop().unwrap_or(Value::Null);
906                            stack.truncate(mark);
907                            v
908                        }
909                        None => Value::Null,
910                    },
911                };
912                stack.push(v);
913            }
914            Step::Subtree(e) => stack.push(eval_expr(e, &row.as_row(), ctx)?),
915        }
916    }
917    Ok(())
918}
919
920/// v7.37.9 Phase 1A-ext-2 T1 — Step VM internal step-type counters.
921/// Read-only diagnostic; gates no behaviour. Used by counter_dump.rs
922/// to ground-truth subtree CSE / column-ref-push / multi-spec-combine
923/// attack ROI estimates.
924pub static STEP_VM_CALL_COUNT: core::sync::atomic::AtomicU64 =
925    core::sync::atomic::AtomicU64::new(0);
926pub static STEP_VM_STEPS_TOTAL: core::sync::atomic::AtomicU64 =
927    core::sync::atomic::AtomicU64::new(0);
928pub static STEP_VM_COLUMN_FIRE: core::sync::atomic::AtomicU64 =
929    core::sync::atomic::AtomicU64::new(0);
930pub static STEP_VM_LIT_FIRE: core::sync::atomic::AtomicU64 =
931    core::sync::atomic::AtomicU64::new(0);
932pub static STEP_VM_BINARY_FIRE: core::sync::atomic::AtomicU64 =
933    core::sync::atomic::AtomicU64::new(0);
934pub static STEP_VM_FUNCTION_FIRE: core::sync::atomic::AtomicU64 =
935    core::sync::atomic::AtomicU64::new(0);
936pub static STEP_VM_CAST_FIRE: core::sync::atomic::AtomicU64 =
937    core::sync::atomic::AtomicU64::new(0);
938pub static STEP_VM_CASE_FIRE: core::sync::atomic::AtomicU64 =
939    core::sync::atomic::AtomicU64::new(0);
940
941/// v7.37.9 Round 3 — heap-alloc counters specifically for the T3
942/// structural attack's ROI estimate. Step::Column / Step::Lit hits
943/// pay a String alloc when the cell variant is heap-bearing
944/// (Text/Bytes/Json/Vector). T3 stack-lifetime push-by-borrow
945/// would eliminate these for the bulk of per-row work.
946pub static STEP_VM_COLUMN_HEAP_ALLOC: core::sync::atomic::AtomicU64 =
947    core::sync::atomic::AtomicU64::new(0);
948pub static STEP_VM_LIT_HEAP_ALLOC: core::sync::atomic::AtomicU64 =
949    core::sync::atomic::AtomicU64::new(0);