Skip to main content

logicaffeine_compile/
debug.rs

1//! The bytecode debugger bridge — drives the LOGOS VM one op at a time for the
2//! Studio debug drawer, with breakpoints, step-over / step-out, and **time-travel**
3//! (step backwards through executed ops). Single-task, bytecode tier (`tier: None`)
4//! so granularity is exactly one op and behaviour is identical to a normal run.
5//!
6//! ZERO production cost: nothing here is on the execution path. The stepping rides
7//! the VM's `STEPPED = true` monomorphization (the default `run_until_block` is
8//! byte-for-byte the old hot loop), and this whole module dead-strips from any
9//! binary that never constructs a [`Debugger`].
10//!
11//! Time-travel falls out of the architecture for free: the debugger owns the
12//! [`CompiledProgram`] and rebuilds a fresh VM each step from a saved state
13//! snapshot, so it keeps the full snapshot *history* — stepping back is just
14//! dropping the last snapshot.
15
16use std::collections::{BTreeSet, HashMap};
17
18use serde::Serialize;
19
20use crate::vm::{disassemble, CompiledProgram, DebugVmState, DisasmLine, Vm, VmStep};
21
22/// Browser-safety cap on a single Continue / Step-Over / Step-Out (an infinite loop
23/// must not hang the tab — the run pauses and reports it hit the limit).
24const STEP_LIMIT: usize = 5_000_000;
25
26/// One register in a [`DebugFrame`]. `name` is the source variable when known
27/// (filled by the compiler's debug-info pass; `None` ⇒ show `R{index}`).
28#[derive(Clone, Serialize)]
29pub struct DebugReg {
30    pub index: u16,
31    pub name: Option<String>,
32    /// The value's type — `Int`, `Float`, `List`, `Text`, … (teaches the type system).
33    pub kind: String,
34    pub value: String,
35    /// The value changed in the last executed op (drives the highlight / pulse).
36    pub changed: bool,
37}
38
39/// One call frame's registers. `function` is `None` for Main; `base` is the frame's
40/// start address in the linear register file (its stack base), for the Stack view.
41#[derive(Clone, Serialize)]
42pub struct DebugFrame {
43    pub function: Option<String>,
44    pub base: usize,
45    pub registers: Vec<DebugReg>,
46}
47
48/// One heap object for the Heap view: a list / map / set / struct / text reachable
49/// from a variable or global. `referenced_by` lists every root that points at it, so
50/// `shared` (more than one) makes aliasing visible — the thing assembly debuggers
51/// can't show because they only have raw bytes, not typed objects.
52#[derive(Clone, Serialize, Debug)]
53pub struct HeapObject {
54    /// A short stable label within this snapshot, e.g. `#1`.
55    pub id: String,
56    pub kind: String,
57    pub summary: String,
58    /// The underlying storage layout (`packed Vec<i64>`, `columnar`, …) — teaches how
59    /// the data is really laid out, not just its printed form.
60    pub storage: String,
61    /// Reference count (how many handles point at this allocation).
62    pub rc: usize,
63    pub referenced_by: Vec<String>,
64    /// Referenced by more than one root — an alias.
65    pub shared: bool,
66}
67
68/// A complete picture of the paused VM for the debug drawer (serde for the UI).
69#[derive(Clone, Serialize)]
70pub struct DebugSnapshot {
71    /// The pc the program is stopped at (the op about to execute).
72    pub pc: usize,
73    /// The disassembled current op (empty once finished).
74    pub op_text: String,
75    /// A plain-English description of the op about to run — the teaching narration
76    /// (e.g. "add x(6) + y(7) → R4"). Empty for the long-tail ops.
77    pub narration: String,
78    /// The first-order-logic semantics of the op about to run — the formal meaning of
79    /// this step (e.g. `sum = x + y`, `t ⟺ (i < n)`, `¬cond → goto 12`). Empty for the
80    /// long-tail ops.
81    pub fol: String,
82    /// A **Socratic** prompt for the step about to run — a guiding question that invites
83    /// the learner to predict the outcome before stepping ("x (6) and y (7) — what is
84    /// their sum?"). Empty for ops with nothing to anticipate (the UI then shows the
85    /// plain narration). Matches the engine's voice: concrete values, second person, `—`.
86    pub socratic: String,
87    /// Registers the current op reads / writes — drives the datapath animation.
88    pub op_reads: Vec<u16>,
89    pub op_writes: Option<u16>,
90    /// `"paused" | "done" | "blocked" | "error"`.
91    pub state: String,
92    pub error: Option<String>,
93    /// Position in the execution history (0 = before any op) — the time-travel cursor.
94    pub step: usize,
95    /// Highest step explored so far — the scrubber's maximum (>= `step`).
96    pub total_steps: usize,
97    /// Number of instructions in the program (the bytecode tape length).
98    pub total_ops: usize,
99    /// Call frames, Main first and the current frame last.
100    pub frames: Vec<DebugFrame>,
101    /// Heap objects reachable from the current frame + globals (the Heap view).
102    pub heap: Vec<HeapObject>,
103    /// Promoted globals (name → display value).
104    pub globals: Vec<(String, String)>,
105    /// Output emitted so far (the `Show` lines).
106    pub output: Vec<String>,
107    /// Whether execution is currently stopped on a breakpoint.
108    pub at_breakpoint: bool,
109}
110
111/// One variable's value over the whole recorded execution — a trace on the
112/// [`VarTimeline`] oscilloscope. `points[i]` is the value at explored step
113/// `timeline.start + i`.
114#[derive(Clone, Serialize)]
115pub struct VarTrace {
116    /// Main-frame register slot this variable lives in.
117    pub reg: u16,
118    pub name: String,
119    /// The value's type, sampled the first step it holds one (`Int`, `List`, …).
120    pub kind: String,
121    pub points: Vec<TimelinePoint>,
122}
123
124/// One sample on a [`VarTrace`]: the variable's display value at a single step, and
125/// whether it just changed (the waveform "edge").
126#[derive(Clone, Serialize)]
127pub struct TimelinePoint {
128    pub value: String,
129    /// The slot held a value at this step (always true for Main locals once the
130    /// frame exists; `false` before it is allocated).
131    pub present: bool,
132    /// Differs from the previous step's value — drives the waveform transition.
133    pub changed: bool,
134}
135
136/// A logic-analyzer view of the run: every Main-frame variable's value across the
137/// recorded history, with a playhead at the time-travel cursor. Deterministic replay
138/// makes this exact — it is the program's *entire* observable past, not a sample.
139#[derive(Clone, Serialize)]
140pub struct VarTimeline {
141    /// Step index of column 0 (non-zero only when the history was tail-windowed).
142    pub start: usize,
143    /// Number of columns (explored steps) in the window.
144    pub steps: usize,
145    /// The time-travel cursor's absolute step (place the playhead at `cursor - start`).
146    pub cursor: usize,
147    /// History longer than the window cap was tail-trimmed to the most recent steps.
148    pub truncated: bool,
149    pub vars: Vec<VarTrace>,
150}
151
152/// One variable's **observed invariants** — facts that held over the recorded run
153/// (constant, monotonic, range, distinct count). Dynamic likely-invariants, labelled
154/// as observed (not proven), the empirical companion to the Oracle's static facts.
155#[derive(Clone, Serialize)]
156pub struct VarInsight {
157    pub name: String,
158    pub kind: String,
159    pub facts: Vec<String>,
160}
161
162/// A variable's program-wide **proven** facts (from the Oracle's static abstract
163/// interpretation): a finite integer range, non-negativity, a concrete scalar type.
164/// Every field is a sound guarantee that holds on *every* run — the static companion
165/// to [`VarInsight`]'s observed-this-run facts.
166#[derive(Clone, Serialize, Default)]
167pub struct ProvenFacts {
168    pub scalar: Option<String>,
169    pub int_range: Option<(i64, i64)>,
170    pub nonneg: bool,
171}
172
173impl From<crate::optimize::VarProvenFacts> for ProvenFacts {
174    fn from(v: crate::optimize::VarProvenFacts) -> Self {
175        ProvenFacts {
176            scalar: v.scalar.map(|s| format!("{s:?}")),
177            int_range: v.int_range,
178            nonneg: v.nonneg,
179        }
180    }
181}
182
183impl ProvenFacts {
184    /// Human-readable proven facts, e.g. `["∈ [0, 9]", "type Int"]`. A finite range
185    /// subsumes non-negativity, so `≥ 0` is shown only when the upper bound is open.
186    fn labels(&self) -> Vec<String> {
187        let mut out = Vec::new();
188        match self.int_range {
189            Some((lo, hi)) => out.push(format!("\u{2208} [{lo}, {hi}]")),
190            None if self.nonneg => out.push("\u{2265} 0".to_string()),
191            None => {}
192        }
193        if let Some(s) = &self.scalar {
194            out.push(format!("type {s}"));
195        }
196        out
197    }
198}
199
200/// One variable's proven invariants, pre-rendered for the UI (mirrors [`VarInsight`]).
201#[derive(Clone, Serialize)]
202pub struct ProvenInsight {
203    pub name: String,
204    pub facts: Vec<String>,
205}
206
207/// The verdict of a **live proof** at a breakpoint — whether a predicate is statically
208/// guaranteed across every run (`ProvenTrue`), statically refuted (`ProvenFalse`), or
209/// undecided from the proven facts (`Unknown`, where only the concrete check speaks).
210#[derive(Clone, Serialize, PartialEq, Debug)]
211pub enum ProofVerdict {
212    ProvenTrue,
213    ProvenFalse,
214    Unknown,
215}
216
217/// The result of asserting a predicate at the cursor — the dual lens the debugger is
218/// built for: what is true **now** (concrete, from live values) and what is **proven**
219/// for every run (from the Oracle's static facts, kernel-style interval entailment, no
220/// Z3). `now` is `None` when a term has no live integer value; the verdict is `Unknown`
221/// when a term has no proven range.
222#[derive(Clone, Serialize)]
223pub struct AssertionResult {
224    pub query: String,
225    /// Whether the predicate parsed as a comparison (`a < b`, `x >= 0`, …).
226    pub parsed: bool,
227    /// Concrete truth at the current state (`None` if a term isn't a live integer).
228    pub now: Option<bool>,
229    pub now_detail: String,
230    /// Static guarantee over every run.
231    pub verdict: ProofVerdict,
232    pub verdict_detail: String,
233}
234
235/// One side of a comparison: a variable reference or an integer literal.
236enum Operand {
237    Var(String),
238    Int(i64),
239}
240
241#[derive(Clone, Copy)]
242enum Cmp {
243    Lt,
244    Le,
245    Gt,
246    Ge,
247    Eq,
248    Ne,
249}
250
251/// A node in a **causal provenance tree** — the exact op that produced a value, and
252/// (recursively) the ops that produced its inputs. Because execution is deterministic
253/// and fully recorded, this lineage is exact, not heuristic: the answer to "why is
254/// this value here?" with no guessing.
255#[derive(Clone, Serialize)]
256pub struct CausalNode {
257    /// Explored step whose op produced this value (`0` ⇒ an initial/never-written slot).
258    pub step: usize,
259    /// The producing op's pc, its disassembly, and its English narration.
260    pub pc: usize,
261    pub op_text: String,
262    pub narration: String,
263    /// The slot, its source name if any, and the value it ended up holding.
264    pub reg: u16,
265    pub name: Option<String>,
266    pub kind: String,
267    pub value: String,
268    /// The values this op consumed — each itself traced to where it came from.
269    pub inputs: Vec<CausalNode>,
270}
271
272/// Tail-window cap on the oscilloscope (a runaway loop must not force a million VM
273/// rebuilds nor an unviewable waveform — the most recent steps are what you watch).
274const TIMELINE_MAX_STEPS: usize = 512;
275
276/// Depth / breadth caps on a provenance walk so a deep dependency chain stays a
277/// readable tree rather than an explosion.
278const PROVENANCE_MAX_DEPTH: usize = 24;
279const PROVENANCE_MAX_NODES: usize = 96;
280
281/// Per-frame terminal status (the program is deterministic, so each explored op has
282/// exactly one outcome that travels with its history entry — seeking back to it
283/// correctly reads "paused", not the program's final "done").
284#[derive(Clone)]
285enum Outcome {
286    Running,
287    Done,
288    Blocked,
289    Error(String),
290}
291
292/// One explored point in the execution: the VM state plus its outcome.
293struct Frame {
294    state: DebugVmState,
295    outcome: Outcome,
296}
297
298/// A self-contained stepping debugger over one compiled program.
299///
300/// Execution is deterministic, so `history` is the single execution prefix explored
301/// so far and `cursor` is simply where you are looking. Step / step-back / seek /
302/// restart all just move the cursor; a new VM op is only ever computed when the
303/// cursor reaches the unexplored frontier. That makes step-back, restart, redo, and
304/// the time-travel scrubber instant, and reverse-continue a pure cursor walk.
305pub struct Debugger {
306    program: CompiledProgram,
307    disasm: Vec<DisasmLine>,
308    history: Vec<Frame>,
309    cursor: usize,
310    breakpoints: BTreeSet<usize>,
311    /// Program-wide PROVEN facts per variable name, from the Oracle's abstract
312    /// interpretation (computed once at compile, then read-only). The static
313    /// counterpart to the dynamic [`Debugger::observed_invariants`].
314    proven: HashMap<String, ProvenFacts>,
315}
316
317impl Debugger {
318    /// Compile `src` (exactly as the Studio "Run" path does) and arm a debugger at
319    /// the program's entry. The program is debugged on the bytecode tier with no
320    /// JIT, so stepping is per-op and output matches a normal run.
321    pub fn from_source(src: &str) -> Result<Debugger, String> {
322        let (program, proven) = compile_source(src)?;
323        let disasm = disassemble(&program);
324        let initial = Vm::new(&program).save_debug_state();
325        Ok(Debugger {
326            program,
327            disasm,
328            history: vec![Frame { state: initial, outcome: Outcome::Running }],
329            cursor: 0,
330            breakpoints: BTreeSet::new(),
331            proven,
332        })
333    }
334
335    /// Execute exactly one op (Step Into).
336    pub fn step(&mut self) {
337        self.run_one();
338    }
339
340    /// Execute one op, but run any function it calls to completion (Step Over).
341    pub fn step_over(&mut self) {
342        let start = self.current_depth();
343        if !self.run_one() {
344            return;
345        }
346        let mut budget = STEP_LIMIT;
347        while self.is_paused() && self.current_depth() > start {
348            if self.at_breakpoint() {
349                break;
350            }
351            if !self.run_one() {
352                break;
353            }
354            budget -= 1;
355            if budget == 0 {
356                break;
357            }
358        }
359    }
360
361    /// Run until the current function returns (Step Out); from Main, runs to the end.
362    pub fn step_out(&mut self) {
363        let start = self.current_depth();
364        let mut budget = STEP_LIMIT;
365        loop {
366            if !self.is_paused() || self.current_depth() < start {
367                break;
368            }
369            if !self.run_one() {
370                break;
371            }
372            if self.at_breakpoint() {
373                break;
374            }
375            budget -= 1;
376            if budget == 0 {
377                break;
378            }
379        }
380    }
381
382    /// Run until the next breakpoint, a block, completion, or the step limit (Continue).
383    pub fn resume(&mut self) {
384        let mut budget = STEP_LIMIT;
385        loop {
386            if !self.is_paused() {
387                break;
388            }
389            if !self.run_one() {
390                break;
391            }
392            if self.at_breakpoint() {
393                break;
394            }
395            budget -= 1;
396            if budget == 0 {
397                break;
398            }
399        }
400    }
401
402    /// Run BACKWARD to the previous breakpoint, or the program entry — reverse
403    /// continue. Pure cursor motion over the recorded history, a time-travel feature
404    /// almost no debugger has.
405    pub fn reverse_resume(&mut self) {
406        while self.cursor > 0 {
407            self.cursor -= 1;
408            if self.breakpoints.contains(&self.current().pc()) {
409                break;
410            }
411        }
412    }
413
414    /// Undo the last executed op — time-travel one step backwards.
415    pub fn step_back(&mut self) {
416        self.cursor = self.cursor.saturating_sub(1);
417    }
418
419    /// Jump the time-travel cursor to any already-explored step (the scrubber).
420    pub fn seek(&mut self, step: usize) {
421        self.cursor = step.min(self.history.len().saturating_sub(1));
422    }
423
424    /// Rewind to the program entry, keeping the explored history (re-stepping is then
425    /// instant) and the breakpoints.
426    pub fn restart(&mut self) {
427        self.cursor = 0;
428    }
429
430    /// Toggle a breakpoint on a bytecode pc.
431    pub fn toggle_breakpoint(&mut self, pc: usize) {
432        if !self.breakpoints.remove(&pc) {
433            self.breakpoints.insert(pc);
434        }
435    }
436
437    pub fn set_breakpoint(&mut self, pc: usize) {
438        self.breakpoints.insert(pc);
439    }
440
441    pub fn clear_breakpoint(&mut self, pc: usize) {
442        self.breakpoints.remove(&pc);
443    }
444
445    pub fn breakpoints(&self) -> Vec<usize> {
446        self.breakpoints.iter().copied().collect()
447    }
448
449    /// The full disassembly (the bytecode tape).
450    pub fn disassembly(&self) -> &[DisasmLine] {
451        &self.disasm
452    }
453
454    /// Whether the program is still paused mid-execution at the current cursor (cheap,
455    /// no snapshot build). `false` once it has finished, blocked, or errored here.
456    pub fn is_running(&self) -> bool {
457        self.is_paused()
458    }
459
460    /// Build a serde snapshot of the current paused state for the UI.
461    pub fn snapshot(&self) -> DebugSnapshot {
462        let (view, heap_raw) = self.view_and_heap(self.current());
463        // The previous step's innermost-frame registers, to mark what just changed —
464        // but ONLY when the frame context is the same (same call depth). Crossing into
465        // or out of a function changes which frame is innermost, so comparing by index
466        // would flag unrelated registers; suppress it across that boundary.
467        let prev_inner: HashMap<u16, String> = if self.cursor >= 1 {
468            let pv = self.view_of(&self.history[self.cursor - 1].state);
469            if pv.frames.len() == view.frames.len() {
470                pv.frames
471                    .last()
472                    .map(|f| f.registers.iter().map(|(idx, _kind, val)| (*idx, val.clone())).collect())
473                    .unwrap_or_default()
474            } else {
475                HashMap::new()
476            }
477        } else {
478            HashMap::new()
479        };
480        // Main-frame variable names (`R0` → `x`), captured by the debug compile path.
481        let main_names: HashMap<u16, String> =
482            self.program.reg_names.iter().cloned().collect();
483        let n = view.frames.len();
484        let frames: Vec<DebugFrame> = view
485            .frames
486            .iter()
487            .enumerate()
488            .map(|(fi, f)| {
489                let inner = fi + 1 == n;
490                DebugFrame {
491                    function: f.func.map(|i| format!("fn#{i}")),
492                    base: f.base,
493                    registers: f
494                        .registers
495                        .iter()
496                        .map(|(idx, kind, val)| DebugReg {
497                            index: *idx,
498                            name: if f.func.is_none() {
499                                main_names.get(idx).cloned()
500                            } else {
501                                None
502                            },
503                            kind: kind.clone(),
504                            value: val.clone(),
505                            changed: inner
506                                && prev_inner.get(idx).map(|p| p != val).unwrap_or(false),
507                        })
508                        .collect(),
509                }
510            })
511            .collect();
512        let (state, error) = match &self.cur().outcome {
513            Outcome::Running => ("paused", None),
514            Outcome::Done => ("done", None),
515            Outcome::Blocked => ("blocked", None),
516            Outcome::Error(e) => ("error", Some(e.clone())),
517        };
518        let op_text = self.disasm.get(view.pc).map(|d| d.text.clone()).unwrap_or_default();
519        // Teaching narration + the registers this op touches (for the datapath).
520        let inner_regs: HashMap<u16, (Option<String>, String)> = frames
521            .last()
522            .map(|f: &DebugFrame| {
523                f.registers.iter().map(|r| (r.index, (r.name.clone(), r.value.clone()))).collect()
524            })
525            .unwrap_or_default();
526        let cur_op = self.program.code.get(view.pc).copied();
527        let (narration, op_reads, op_writes) = match (&self.cur().outcome, cur_op) {
528            (Outcome::Error(e), _) => (format!("error: {e}"), Vec::new(), None),
529            (Outcome::Done, _) => ("the program has finished".to_string(), Vec::new(), None),
530            (Outcome::Blocked, _) => ("waiting on a concurrency operation".to_string(), Vec::new(), None),
531            (Outcome::Running, Some(op)) => {
532                let io = crate::vm::op_io(&op);
533                (narrate(&op, &inner_regs, &self.program), io.reads, io.writes)
534            }
535            _ => (String::new(), Vec::new(), None),
536        };
537        let fol = match (&self.cur().outcome, cur_op) {
538            (Outcome::Running, Some(op)) => fol_of_op(&op, &inner_regs, &self.program),
539            _ => String::new(),
540        };
541        let socratic = match (&self.cur().outcome, cur_op) {
542            (Outcome::Running, Some(op)) => socratic_of_op(&op, &inner_regs),
543            (Outcome::Done, _) => "The program has finished — did the result match what you expected?".to_string(),
544            _ => String::new(),
545        };
546        let heap: Vec<HeapObject> = heap_raw
547            .iter()
548            .enumerate()
549            .map(|(i, o)| HeapObject {
550                id: format!("#{}", i + 1),
551                kind: o.kind.clone(),
552                summary: o.summary.clone(),
553                storage: o.storage.clone(),
554                rc: o.rc,
555                referenced_by: o.referenced_by.clone(),
556                shared: o.referenced_by.len() > 1,
557            })
558            .collect();
559        DebugSnapshot {
560            pc: view.pc,
561            op_text,
562            narration,
563            fol,
564            socratic,
565            op_reads,
566            op_writes,
567            state: state.to_string(),
568            error,
569            step: self.cursor,
570            total_steps: self.history.len().saturating_sub(1),
571            total_ops: self.disasm.len(),
572            frames,
573            heap,
574            globals: view.globals.clone(),
575            output: view.output.clone(),
576            at_breakpoint: self.at_breakpoint(),
577        }
578    }
579
580    /// The **variable oscilloscope**: every Main-frame variable's value across the
581    /// recorded execution, with a playhead at the cursor. On-demand (only the Timeline
582    /// tab calls it), tail-windowed to the most recent [`TIMELINE_MAX_STEPS`] steps so
583    /// a long loop stays cheap and viewable.
584    pub fn variable_timeline(&self) -> VarTimeline {
585        let names = self.main_names();
586        let total = self.history.len();
587        let start = total.saturating_sub(TIMELINE_MAX_STEPS);
588        let truncated = start > 0;
589        let window = &self.history[start..];
590        let n = window.len();
591
592        let mut order: Vec<u16> = names.keys().copied().collect();
593        order.sort_unstable();
594        // (kind, value) per (reg, column); None before the slot exists at that step.
595        let mut series: HashMap<u16, Vec<Option<(String, String)>>> =
596            order.iter().map(|r| (*r, vec![None; n])).collect();
597        for (col, frame) in window.iter().enumerate() {
598            let view = self.view_of(&frame.state);
599            if let Some(main) = view.frames.iter().find(|f| f.func.is_none()) {
600                for (idx, kind, val) in &main.registers {
601                    if let Some(slot) = series.get_mut(idx) {
602                        slot[col] = Some((kind.clone(), val.clone()));
603                    }
604                }
605            }
606        }
607
608        let vars = order
609            .iter()
610            .map(|reg| {
611                let name = names.get(reg).cloned().unwrap_or_else(|| format!("R{reg}"));
612                let raw = &series[reg];
613                let mut kind = String::new();
614                let mut points = Vec::with_capacity(n);
615                let mut prev: Option<String> = None;
616                for cell in raw {
617                    match cell {
618                        Some((k, v)) => {
619                            // The settled type: a slot reads `Nothing` before assignment,
620                            // so the last meaningful kind is the variable's real type.
621                            if k != "Nothing" || kind.is_empty() {
622                                kind = k.clone();
623                            }
624                            // An "edge" needs a prior present value to differ from — the
625                            // first time a variable appears is not a transition.
626                            let changed = matches!(&prev, Some(p) if p != v);
627                            points.push(TimelinePoint { value: v.clone(), present: true, changed });
628                            prev = Some(v.clone());
629                        }
630                        None => {
631                            points.push(TimelinePoint {
632                                value: String::new(),
633                                present: false,
634                                changed: false,
635                            });
636                            prev = None;
637                        }
638                    }
639                }
640                VarTrace { reg: *reg, name, kind, points }
641            })
642            // A variable never observed in the window is noise; drop it.
643            .filter(|t| t.points.iter().any(|p| p.present))
644            .collect();
645
646        VarTimeline { start, steps: n, cursor: self.cursor, truncated, vars }
647    }
648
649    /// **Observed invariants** (Daikon-style dynamic detection): for each variable,
650    /// reduce its recorded trace into the facts that held over *this run* — constant,
651    /// monotonic, value range, distinct count. Dynamic, not a static proof (the
652    /// formally-proven counterpart comes from the Oracle), but exact for what happened.
653    pub fn observed_invariants(&self) -> Vec<VarInsight> {
654        let tl = self.variable_timeline();
655        tl.vars
656            .iter()
657            .filter_map(|v| {
658                // Sample only from the variable's first real assignment (its first edge)
659                // — before that the slot is uninitialised and would pollute the facts.
660                let first = v.points.iter().position(|p| p.present && p.changed)?;
661                let vals: Vec<&str> =
662                    v.points[first..].iter().filter(|p| p.present).map(|p| p.value.as_str()).collect();
663                if vals.is_empty() {
664                    return None;
665                }
666                let distinct: BTreeSet<&str> = vals.iter().copied().collect();
667                let nums: Option<Vec<f64>> = vals.iter().map(|s| s.parse::<f64>().ok()).collect();
668                let mut facts = Vec::new();
669                if distinct.len() == 1 {
670                    facts.push(format!("constant {}", vals[0]));
671                } else {
672                    if let Some(ns) = &nums {
673                        let min = ns.iter().cloned().fold(f64::INFINITY, f64::min);
674                        let max = ns.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
675                        facts.push(format!("range [{}, {}]", fmt_num(min), fmt_num(max)));
676                        if ns.windows(2).all(|w| w[1] >= w[0]) {
677                            facts.push("only increases".to_string());
678                        } else if ns.windows(2).all(|w| w[1] <= w[0]) {
679                            facts.push("only decreases".to_string());
680                        }
681                    }
682                    facts.push(format!("{} distinct values", distinct.len()));
683                }
684                Some(VarInsight { name: v.name.clone(), kind: v.kind.clone(), facts })
685            })
686            .collect()
687    }
688
689    /// **Proven invariants**: the Oracle's statically-verified facts per variable
690    /// (range, non-negativity, scalar type) — guarantees that hold on *every* run, not
691    /// just this one. The formal companion to [`Debugger::observed_invariants`]. Only
692    /// variables with at least one non-trivial proven fact are returned, sorted by name.
693    pub fn proven_invariants(&self) -> Vec<ProvenInsight> {
694        let mut out: Vec<ProvenInsight> = self
695            .proven
696            .iter()
697            .filter_map(|(name, pf)| {
698                let facts = pf.labels();
699                if facts.is_empty() {
700                    None
701                } else {
702                    Some(ProvenInsight { name: name.clone(), facts })
703                }
704            })
705            .collect();
706        out.sort_by(|a, b| a.name.cmp(&b.name));
707        out
708    }
709
710    /// **Live proof at a breakpoint**: assert a comparison predicate (`x < y`, `x >= 0`,
711    /// `sum == 13`) and get both lenses — whether it holds **now** (concretely, from the
712    /// live values) and whether it is **proven for every run** (from the Oracle's proven
713    /// ranges, by sound interval entailment; pure Rust, no Z3). The dual answer is the
714    /// point: a thing can be true now yet unproven in general, or proven yet about a value
715    /// not yet reached.
716    pub fn assert_at_cursor(&self, predicate: &str) -> AssertionResult {
717        let query = predicate.trim().to_string();
718        let Some((lhs, cmp, rhs)) = parse_comparison(&query) else {
719            return AssertionResult {
720                query,
721                parsed: false,
722                now: None,
723                now_detail: "couldn't parse \u{2014} try a comparison like `x < y` or `x >= 0`".to_string(),
724                verdict: ProofVerdict::Unknown,
725                verdict_detail: String::new(),
726            };
727        };
728
729        // Concrete: resolve each side to its live integer value at the cursor.
730        let frame = self.snapshot();
731        let live = |o: &Operand| -> Option<i64> {
732            match o {
733                Operand::Int(c) => Some(*c),
734                Operand::Var(name) => frame
735                    .frames
736                    .last()
737                    .and_then(|f| f.registers.iter().find(|r| r.name.as_deref() == Some(name.as_str())))
738                    .and_then(|r| r.value.parse::<i64>().ok()),
739            }
740        };
741        let now = match (live(&lhs), live(&rhs)) {
742            (Some(a), Some(b)) => Some(apply_cmp(a, cmp, b)),
743            _ => None,
744        };
745        let now_detail = {
746            let mut parts = Vec::new();
747            for o in [&lhs, &rhs] {
748                if let Operand::Var(name) = o {
749                    match live(o) {
750                        Some(v) => parts.push(format!("{name} = {v}")),
751                        None => parts.push(format!("{name} = ?")),
752                    }
753                }
754            }
755            parts.join(", ")
756        };
757
758        // Static: resolve each side to its proven range and check entailment.
759        let prange = |o: &Operand| -> Option<(i64, i64)> {
760            match o {
761                Operand::Int(c) => Some((*c, *c)),
762                Operand::Var(name) => self.proven.get(name).and_then(|pf| pf.int_range),
763            }
764        };
765        let (verdict, verdict_detail) = match (prange(&lhs), prange(&rhs)) {
766            (Some(a), Some(b)) => {
767                let v = entail(a, cmp, b);
768                let mut srcs = Vec::new();
769                if let Operand::Var(n) = &lhs {
770                    srcs.push(format!("{n} \u{2208} [{}, {}]", a.0, a.1));
771                }
772                if let Operand::Var(n) = &rhs {
773                    srcs.push(format!("{n} \u{2208} [{}, {}]", b.0, b.1));
774                }
775                let detail = match v {
776                    ProofVerdict::Unknown => "the proven ranges don't decide it".to_string(),
777                    _ => format!("from {}", srcs.join(", ")),
778                };
779                (v, detail)
780            }
781            _ => (ProofVerdict::Unknown, "no proven range for one of the terms".to_string()),
782        };
783
784        AssertionResult { query, parsed: true, now, now_detail, verdict, verdict_detail }
785    }
786
787    /// **Causal provenance**: trace the value currently in innermost-frame register
788    /// `reg` back to the exact op that produced it, and recursively the ops that
789    /// produced *that* op's inputs — the precise answer to "why is this value here?".
790    /// Returns `None` only if the register holds nothing at the cursor.
791    pub fn provenance(&self, reg: u16) -> Option<CausalNode> {
792        let mut budget = PROVENANCE_MAX_NODES;
793        self.trace_value(reg, self.cursor, PROVENANCE_MAX_DEPTH, &mut budget)
794    }
795
796    /// Walk back from `at_step` to the most recent op (at or before it) that wrote
797    /// `reg`, then recurse on that op's source registers as of *its* input state.
798    /// Strictly decreasing `at_step` guarantees termination.
799    fn trace_value(&self, reg: u16, at_step: usize, depth: usize, budget: &mut usize) -> Option<CausalNode> {
800        let (name, kind, value) = self.reg_value_at(at_step, reg)?;
801        if *budget == 0 || depth == 0 {
802            return Some(CausalNode {
803                step: 0,
804                pc: 0,
805                op_text: String::new(),
806                narration: String::new(),
807                reg,
808                name,
809                kind,
810                value,
811                inputs: Vec::new(),
812            });
813        }
814        *budget -= 1;
815
816        // Find the producing op: stepping from state[i-1] runs the op at state[i-1].pc()
817        // and lands its write in state[i]. Scan back for the latest write of `reg`.
818        for i in (1..=at_step).rev() {
819            let producer_pc = self.history[i - 1].state.pc();
820            let Some(op) = self.program.code.get(producer_pc).copied() else { continue };
821            let io = crate::vm::op_io(&op);
822            if io.writes != Some(reg) {
823                continue;
824            }
825            // A `Move` is a compiler-inserted copy (temp → named slot). It carries no
826            // computation, so fold it: the value's real lineage is its source's, just
827            // relabelled as this destination — the tree then matches the SOURCE data
828            // flow ("w = z + x"), not the bytecode's temp shuffles.
829            if let crate::vm::Op::Move { src, .. } = op {
830                if let Some(mut child) = self.trace_value(src, i - 1, depth, budget) {
831                    if let Some((nm, k, v)) = self.reg_value_at(i, reg) {
832                        child.reg = reg;
833                        child.name = nm;
834                        child.kind = k;
835                        child.value = v;
836                    }
837                    return Some(child);
838                }
839            }
840            // Resolve this op's value as of the step it produced, its inputs as of the
841            // input state (i-1) — register numbers are frame-relative to each state.
842            let (pname, pkind, pvalue) = self
843                .reg_value_at(i, reg)
844                .unwrap_or_else(|| (name.clone(), kind.clone(), value.clone()));
845            let inner = self.input_regs(i - 1);
846            let narration = narrate(&op, &inner, &self.program);
847            let op_text = self.disasm.get(producer_pc).map(|d| d.text.clone()).unwrap_or_default();
848            let inputs = io
849                .reads
850                .iter()
851                .filter_map(|r| self.trace_value(*r, i - 1, depth - 1, budget))
852                .collect();
853            return Some(CausalNode {
854                step: i,
855                pc: producer_pc,
856                op_text,
857                narration,
858                reg,
859                name: pname,
860                kind: pkind,
861                value: pvalue,
862                inputs,
863            });
864        }
865
866        // No op in history wrote this slot — it is an initial / constant / parameter.
867        Some(CausalNode {
868            step: 0,
869            pc: 0,
870            op_text: String::new(),
871            narration: String::new(),
872            reg,
873            name,
874            kind,
875            value,
876            inputs: Vec::new(),
877        })
878    }
879
880    // ── internals ────────────────────────────────────────────────────────────
881
882    /// Main-frame variable names (`R0` → `x`), as captured by the debug compile pass.
883    fn main_names(&self) -> HashMap<u16, String> {
884        self.program.reg_names.iter().cloned().collect()
885    }
886
887    /// The `(name, kind, value)` of innermost-frame register `reg` at explored `step`,
888    /// or `None` if the slot is absent there. Names are applied for the Main frame.
889    fn reg_value_at(&self, step: usize, reg: u16) -> Option<(Option<String>, String, String)> {
890        let frame = self.history.get(step)?;
891        let view = self.view_of(&frame.state);
892        let f = view.frames.last()?;
893        let is_main = f.func.is_none();
894        f.registers.iter().find(|(idx, _, _)| *idx == reg).map(|(idx, kind, val)| {
895            let name = if is_main { self.main_names().get(idx).cloned() } else { None };
896            (name, kind.clone(), val.clone())
897        })
898    }
899
900    /// The innermost-frame `(reg → (name, value))` map at `step`, for narrating an op
901    /// in terms of the operands it actually read.
902    fn input_regs(&self, step: usize) -> HashMap<u16, (Option<String>, String)> {
903        let names = self.main_names();
904        let Some(frame) = self.history.get(step) else { return HashMap::new() };
905        let view = self.view_of(&frame.state);
906        let Some(f) = view.frames.last() else { return HashMap::new() };
907        let is_main = f.func.is_none();
908        f.registers
909            .iter()
910            .map(|(idx, _, val)| {
911                let name = if is_main { names.get(idx).cloned() } else { None };
912                (*idx, (name, val.clone()))
913            })
914            .collect()
915    }
916
917    fn cur(&self) -> &Frame {
918        &self.history[self.cursor]
919    }
920
921    fn current(&self) -> &DebugVmState {
922        &self.cur().state
923    }
924
925    fn is_paused(&self) -> bool {
926        matches!(self.cur().outcome, Outcome::Running)
927    }
928
929    fn current_depth(&self) -> usize {
930        self.current().call_depth()
931    }
932
933    fn at_breakpoint(&self) -> bool {
934        self.is_paused() && self.breakpoints.contains(&self.current().pc())
935    }
936
937    /// Rebuild a `tier: None` VM in `st`'s state and read its debug view.
938    fn view_of(&self, st: &DebugVmState) -> crate::vm::DebugView {
939        let mut vm = Vm::new(&self.program);
940        vm.restore_debug_state(st.clone());
941        vm.debug_view()
942    }
943
944    /// Like [`view_of`], plus the heap objects reachable from that state (one VM
945    /// rebuild for both).
946    fn view_and_heap(
947        &self,
948        st: &DebugVmState,
949    ) -> (crate::vm::DebugView, Vec<crate::vm::HeapObjView>) {
950        let mut vm = Vm::new(&self.program);
951        vm.restore_debug_state(st.clone());
952        (vm.debug_view(), vm.debug_heap())
953    }
954
955    /// Advance the cursor by one op. When the cursor is rewound into already-explored
956    /// history this just steps it forward — execution is deterministic, so the cached
957    /// frame is exactly what re-running would produce. At the frontier it computes and
958    /// records the next frame. Returns whether the new position is still running.
959    fn run_one(&mut self) -> bool {
960        if !self.is_paused() {
961            return false;
962        }
963        if self.cursor + 1 < self.history.len() {
964            self.cursor += 1;
965            return self.is_paused();
966        }
967        let cur_state = self.current().clone();
968        let mut vm = Vm::new(&self.program);
969        vm.restore_debug_state(cur_state.clone());
970        let frame = match vm.run_steps(1) {
971            Ok(VmStep::Paused) => Frame { state: vm.save_debug_state(), outcome: Outcome::Running },
972            Ok(VmStep::Done(_)) => Frame { state: vm.save_debug_state(), outcome: Outcome::Done },
973            Ok(VmStep::Blocked) => Frame { state: vm.save_debug_state(), outcome: Outcome::Blocked },
974            // Keep the pre-error state so the snapshot shows the failing op's pc.
975            Err(e) => Frame { state: cur_state, outcome: Outcome::Error(e) },
976        };
977        self.history.push(frame);
978        self.cursor += 1;
979        self.is_paused()
980    }
981}
982
983/// A plain-English description of the op about to run, using variable names and live
984/// values where available (e.g. `add x(6) + y(7) → R4`). The teaching narration; empty
985/// for ops with no pedagogical value, so the UI falls back to the disassembly.
986fn narrate(
987    op: &crate::vm::Op,
988    regs: &HashMap<u16, (Option<String>, String)>,
989    prog: &CompiledProgram,
990) -> String {
991    use crate::vm::Op;
992    let name = |r: u16| regs.get(&r).and_then(|(n, _)| n.clone()).unwrap_or_else(|| format!("R{r}"));
993    let operand = |r: u16| match regs.get(&r) {
994        Some((_, v)) if !v.is_empty() => format!("{}({})", name(r), v),
995        _ => name(r),
996    };
997    match *op {
998        Op::LoadConst { dst, idx } => {
999            format!("load {} into {}", crate::vm::format_constant(prog, idx), name(dst))
1000        }
1001        Op::Move { dst, src } => format!("copy {} into {}", operand(src), name(dst)),
1002        Op::Add { dst, lhs, rhs } => format!("add {} + {} \u{2192} {}", operand(lhs), operand(rhs), name(dst)),
1003        Op::Sub { dst, lhs, rhs } => format!("subtract {} - {} \u{2192} {}", operand(lhs), operand(rhs), name(dst)),
1004        Op::Mul { dst, lhs, rhs } => format!("multiply {} \u{00d7} {} \u{2192} {}", operand(lhs), operand(rhs), name(dst)),
1005        Op::Div { dst, lhs, rhs } | Op::ExactDiv { dst, lhs, rhs } => {
1006            format!("divide {} \u{00f7} {} \u{2192} {}", operand(lhs), operand(rhs), name(dst))
1007        }
1008        Op::Mod { dst, lhs, rhs } => format!("{} mod {} \u{2192} {}", operand(lhs), operand(rhs), name(dst)),
1009        Op::Lt { dst, lhs, rhs } => format!("is {} < {}? \u{2192} {}", operand(lhs), operand(rhs), name(dst)),
1010        Op::Gt { dst, lhs, rhs } => format!("is {} > {}? \u{2192} {}", operand(lhs), operand(rhs), name(dst)),
1011        Op::LtEq { dst, lhs, rhs } => format!("is {} <= {}? \u{2192} {}", operand(lhs), operand(rhs), name(dst)),
1012        Op::GtEq { dst, lhs, rhs } => format!("is {} >= {}? \u{2192} {}", operand(lhs), operand(rhs), name(dst)),
1013        Op::Eq { dst, lhs, rhs } => format!("is {} == {}? \u{2192} {}", operand(lhs), operand(rhs), name(dst)),
1014        Op::NotEq { dst, lhs, rhs } => format!("is {} != {}? \u{2192} {}", operand(lhs), operand(rhs), name(dst)),
1015        Op::AddAssign { dst, src } => format!("append {} onto {}", operand(src), name(dst)),
1016        Op::Not { dst, src } => format!("negate {} \u{2192} {}", operand(src), name(dst)),
1017        Op::Show { src } => format!("print {}", operand(src)),
1018        Op::Return { src } => format!("return {}", operand(src)),
1019        Op::ReturnNothing => "return".to_string(),
1020        Op::Jump { target } => format!("jump to step {target}"),
1021        Op::JumpIfFalse { cond, target } => format!("if {} is false, jump to {target}", operand(cond)),
1022        Op::JumpIfTrue { cond, target } => format!("if {} is true, jump to {target}", operand(cond)),
1023        Op::Index { dst, collection, index } | Op::IndexUnchecked { dst, collection, index } => {
1024            format!("read {}[{}] \u{2192} {}", name(collection), operand(index), name(dst))
1025        }
1026        Op::SetIndex { collection, index, value } | Op::SetIndexUnchecked { collection, index, value } => {
1027            format!("set {}[{}] = {}", name(collection), operand(index), operand(value))
1028        }
1029        Op::Length { dst, collection } => format!("count {} \u{2192} {}", name(collection), name(dst)),
1030        Op::ListPush { list, value } => format!("push {} onto {}", operand(value), name(list)),
1031        Op::NewEmptyList { dst } | Op::NewEmptyListI32 { dst } => format!("make an empty list \u{2192} {}", name(dst)),
1032        Op::Call { .. } => "call a function".to_string(),
1033        Op::Halt => "halt \u{2014} the program is done".to_string(),
1034        _ => String::new(),
1035    }
1036}
1037
1038/// The **first-order-logic semantics** of the op about to run — its formal meaning as
1039/// a formula over the (named) registers: an assignment `dst = lhs + rhs`, a boolean
1040/// definition `dst ⟺ (lhs < rhs)`, a guarded jump `¬cond → goto L`. Symbolic (names,
1041/// not live values), so it reads as the verification condition the step establishes.
1042/// Empty for ops with no crisp logical reading (the UI then falls back to narration).
1043fn fol_of_op(
1044    op: &crate::vm::Op,
1045    regs: &HashMap<u16, (Option<String>, String)>,
1046    prog: &CompiledProgram,
1047) -> String {
1048    use crate::vm::Op;
1049    let name = |r: u16| regs.get(&r).and_then(|(n, _)| n.clone()).unwrap_or_else(|| format!("R{r}"));
1050    match *op {
1051        Op::LoadConst { dst, idx } => format!("{} = {}", name(dst), crate::vm::format_constant(prog, idx)),
1052        Op::Move { dst, src } => format!("{} = {}", name(dst), name(src)),
1053        Op::Add { dst, lhs, rhs } => format!("{} = {} + {}", name(dst), name(lhs), name(rhs)),
1054        Op::Sub { dst, lhs, rhs } => format!("{} = {} \u{2212} {}", name(dst), name(lhs), name(rhs)),
1055        Op::Mul { dst, lhs, rhs } => format!("{} = {} \u{00d7} {}", name(dst), name(lhs), name(rhs)),
1056        Op::Div { dst, lhs, rhs } | Op::ExactDiv { dst, lhs, rhs } => {
1057            format!("{} = {} \u{00f7} {}", name(dst), name(lhs), name(rhs))
1058        }
1059        Op::Mod { dst, lhs, rhs } => format!("{} = {} mod {}", name(dst), name(lhs), name(rhs)),
1060        Op::Lt { dst, lhs, rhs } => format!("{} \u{27fa} ({} < {})", name(dst), name(lhs), name(rhs)),
1061        Op::Gt { dst, lhs, rhs } => format!("{} \u{27fa} ({} > {})", name(dst), name(lhs), name(rhs)),
1062        Op::LtEq { dst, lhs, rhs } => format!("{} \u{27fa} ({} \u{2264} {})", name(dst), name(lhs), name(rhs)),
1063        Op::GtEq { dst, lhs, rhs } => format!("{} \u{27fa} ({} \u{2265} {})", name(dst), name(lhs), name(rhs)),
1064        Op::Eq { dst, lhs, rhs } => format!("{} \u{27fa} ({} = {})", name(dst), name(lhs), name(rhs)),
1065        Op::NotEq { dst, lhs, rhs } => format!("{} \u{27fa} ({} \u{2260} {})", name(dst), name(lhs), name(rhs)),
1066        Op::Not { dst, src } => format!("{} \u{27fa} \u{00ac}{}", name(dst), name(src)),
1067        Op::AddAssign { dst, src } => format!("{} \u{2254} {} \u{29fa} {}", name(dst), name(dst), name(src)),
1068        Op::Index { dst, collection, index } | Op::IndexUnchecked { dst, collection, index } => {
1069            format!("{} = {}[{}]", name(dst), name(collection), name(index))
1070        }
1071        Op::SetIndex { collection, index, value } | Op::SetIndexUnchecked { collection, index, value } => {
1072            format!("{}[{}] \u{2254} {}", name(collection), name(index), name(value))
1073        }
1074        Op::Length { dst, collection } => format!("{} = |{}|", name(dst), name(collection)),
1075        Op::ListPush { list, value } => format!("{} \u{2254} {} \u{2295} {}", name(list), name(list), name(value)),
1076        Op::NewEmptyList { dst } | Op::NewEmptyListI32 { dst } => format!("{} = \u{2205}", name(dst)),
1077        Op::Return { src } => format!("result = {}", name(src)),
1078        Op::Jump { target } => format!("goto {target}"),
1079        Op::JumpIfFalse { cond, target } => format!("\u{00ac}{} \u{2192} goto {target}", name(cond)),
1080        Op::JumpIfTrue { cond, target } => format!("{} \u{2192} goto {target}", name(cond)),
1081        _ => String::new(),
1082    }
1083}
1084
1085/// A **Socratic** prompt for the op about to run — the "ask before telling" move: a
1086/// guiding question grounded in the live operand values that invites the learner to
1087/// predict the outcome, which stepping then reveals. Returns empty for ops with nothing
1088/// to anticipate (a literal load, a bare jump), so the UI falls back to the narration.
1089/// Matches the Socratic engine's voice (concrete values, second person, em-dash).
1090fn socratic_of_op(op: &crate::vm::Op, regs: &HashMap<u16, (Option<String>, String)>) -> String {
1091    use crate::vm::Op;
1092    let name = |r: u16| regs.get(&r).and_then(|(n, _)| n.clone()).unwrap_or_else(|| format!("R{r}"));
1093    // "x (6)" when a live value is known, else just the name.
1094    let nv = |r: u16| match regs.get(&r) {
1095        Some((_, v)) if !v.is_empty() => format!("{} ({})", name(r), v),
1096        _ => name(r),
1097    };
1098    match *op {
1099        Op::Add { lhs, rhs, .. } => format!("{} and {} \u{2014} what is their sum?", nv(lhs), nv(rhs)),
1100        Op::Sub { lhs, rhs, .. } => format!("{} minus {} \u{2014} what's left?", nv(lhs), nv(rhs)),
1101        Op::Mul { lhs, rhs, .. } => format!("{} times {} \u{2014} what do you get?", nv(lhs), nv(rhs)),
1102        Op::Div { lhs, rhs, .. } | Op::ExactDiv { lhs, rhs, .. } => {
1103            format!("{} divided by {} \u{2014} what is the quotient?", nv(lhs), nv(rhs))
1104        }
1105        Op::Mod { lhs, rhs, .. } => format!("What remains when {} is divided by {}?", nv(lhs), nv(rhs)),
1106        Op::Lt { lhs, rhs, .. } => format!("Is {} less than {}?", nv(lhs), nv(rhs)),
1107        Op::Gt { lhs, rhs, .. } => format!("Is {} greater than {}?", nv(lhs), nv(rhs)),
1108        Op::LtEq { lhs, rhs, .. } => format!("Is {} at most {}?", nv(lhs), nv(rhs)),
1109        Op::GtEq { lhs, rhs, .. } => format!("Is {} at least {}?", nv(lhs), nv(rhs)),
1110        Op::Eq { lhs, rhs, .. } => format!("Does {} equal {}?", nv(lhs), nv(rhs)),
1111        Op::NotEq { lhs, rhs, .. } => format!("Are {} and {} different?", nv(lhs), nv(rhs)),
1112        Op::Not { src, .. } => format!("{} \u{2014} what is its negation?", nv(src)),
1113        Op::JumpIfFalse { cond, .. } => {
1114            format!("{} \u{2014} will the program take this branch, or fall through?", nv(cond))
1115        }
1116        Op::JumpIfTrue { cond, .. } => {
1117            format!("{} \u{2014} is the condition met, so the program jumps?", nv(cond))
1118        }
1119        Op::Index { collection, index, .. } | Op::IndexUnchecked { collection, index, .. } => {
1120            format!("What sits at position {} of {}?", nv(index), name(collection))
1121        }
1122        Op::Length { collection, .. } => format!("How many items does {} hold?", name(collection)),
1123        Op::Return { src } => {
1124            format!("The result is about to be {} \u{2014} is that what you predicted?", nv(src))
1125        }
1126        _ => String::new(),
1127    }
1128}
1129
1130/// Parse a comparison predicate `<term> <op> <term>` (e.g. `x < y`, `sum >= 0`). Two-
1131/// char operators are tried before single-char so `<=` isn't read as `<`. A term is an
1132/// integer literal or a variable name. `None` if it isn't a single comparison.
1133fn parse_comparison(s: &str) -> Option<(Operand, Cmp, Operand)> {
1134    for (sym, cmp) in [("<=", Cmp::Le), (">=", Cmp::Ge), ("==", Cmp::Eq), ("!=", Cmp::Ne)] {
1135        if let Some(i) = s.find(sym) {
1136            return build_cmp(&s[..i], &s[i + sym.len()..], cmp);
1137        }
1138    }
1139    for (sym, cmp) in [('<', Cmp::Lt), ('>', Cmp::Gt), ('=', Cmp::Eq)] {
1140        if let Some(i) = s.find(sym) {
1141            return build_cmp(&s[..i], &s[i + 1..], cmp);
1142        }
1143    }
1144    None
1145}
1146
1147fn build_cmp(l: &str, r: &str, cmp: Cmp) -> Option<(Operand, Cmp, Operand)> {
1148    Some((operand_of(l)?, cmp, operand_of(r)?))
1149}
1150
1151fn operand_of(s: &str) -> Option<Operand> {
1152    let t = s.trim();
1153    if t.is_empty() {
1154        return None;
1155    }
1156    Some(match t.parse::<i64>() {
1157        Ok(n) => Operand::Int(n),
1158        Err(_) => Operand::Var(t.to_string()),
1159    })
1160}
1161
1162fn apply_cmp(a: i64, cmp: Cmp, b: i64) -> bool {
1163    match cmp {
1164        Cmp::Lt => a < b,
1165        Cmp::Le => a <= b,
1166        Cmp::Gt => a > b,
1167        Cmp::Ge => a >= b,
1168        Cmp::Eq => a == b,
1169        Cmp::Ne => a != b,
1170    }
1171}
1172
1173/// Decide a comparison between two PROVEN ranges `a ∈ [al, ah]`, `b ∈ [bl, bh]`. Sound:
1174/// `ProvenTrue`/`ProvenFalse` are returned only when the relation holds (resp. fails) for
1175/// EVERY pair in the ranges; overlapping ranges that don't settle it give `Unknown`.
1176fn entail((al, ah): (i64, i64), cmp: Cmp, (bl, bh): (i64, i64)) -> ProofVerdict {
1177    use ProofVerdict::{ProvenFalse, ProvenTrue, Unknown};
1178    let singleton_eq = al == ah && bl == bh && al == bl;
1179    let disjoint = ah < bl || bh < al;
1180    match cmp {
1181        Cmp::Lt => {
1182            if ah < bl {
1183                ProvenTrue
1184            } else if al >= bh {
1185                ProvenFalse
1186            } else {
1187                Unknown
1188            }
1189        }
1190        Cmp::Le => {
1191            if ah <= bl {
1192                ProvenTrue
1193            } else if al > bh {
1194                ProvenFalse
1195            } else {
1196                Unknown
1197            }
1198        }
1199        Cmp::Gt => {
1200            if al > bh {
1201                ProvenTrue
1202            } else if ah <= bl {
1203                ProvenFalse
1204            } else {
1205                Unknown
1206            }
1207        }
1208        Cmp::Ge => {
1209            if al >= bh {
1210                ProvenTrue
1211            } else if ah < bl {
1212                ProvenFalse
1213            } else {
1214                Unknown
1215            }
1216        }
1217        Cmp::Eq => {
1218            if singleton_eq {
1219                ProvenTrue
1220            } else if disjoint {
1221                ProvenFalse
1222            } else {
1223                Unknown
1224            }
1225        }
1226        Cmp::Ne => {
1227            if disjoint {
1228                ProvenTrue
1229            } else if singleton_eq {
1230                ProvenFalse
1231            } else {
1232                Unknown
1233            }
1234        }
1235    }
1236}
1237
1238/// Format an observed numeric bound without a spurious `.0` on whole numbers, so a
1239/// range over integers reads `[1, 5]`, not `[1.0, 5.0]`.
1240fn fmt_num(n: f64) -> String {
1241    if n.fract() == 0.0 && n.abs() < 9.007e15 {
1242        format!("{}", n as i64)
1243    } else {
1244        format!("{n}")
1245    }
1246}
1247
1248/// Compile `src` to **un-optimized** bytecode — faithful to the source so the
1249/// debugger steps `Let`, the arithmetic, and the `Show` as written, rather than the
1250/// run-path optimizer's folded form (which would erase the very variables you are
1251/// debugging). Output is identical either way (optimizations are semantics-
1252/// preserving), so stepping still matches a normal run.
1253///
1254/// Also runs the Oracle's abstract interpretation ONCE here and rolls its per-
1255/// occurrence facts up by variable name — the proven invariants the debugger shows.
1256/// This is the only caller of that rollup, so it is **zero cost** for every non-debug
1257/// compile (the production VM/JIT/AOT paths never touch it).
1258fn compile_source(src: &str) -> Result<(CompiledProgram, HashMap<String, ProvenFacts>), String> {
1259    crate::ui_bridge::with_parsed_program(src, |parsed, interner| {
1260        let (stmts, types, _policies) = parsed?;
1261        let program = crate::vm::Compiler::compile_for_debug(stmts, interner, Some(types))?;
1262        let facts = crate::optimize::oracle_analyze_with(stmts, interner);
1263        let proven = facts
1264            .summarize_variables(stmts)
1265            .into_iter()
1266            .map(|(sym, vf)| (interner.resolve(sym).to_string(), ProvenFacts::from(vf)))
1267            .collect();
1268        Ok((program, proven))
1269    })
1270}
1271
1272#[cfg(test)]
1273mod tests {
1274    use super::*;
1275
1276    const PROG: &str = "## Main\n\nLet x be 6.\nLet y be 7.\nShow x + y.";
1277
1278    fn run_to_done(dbg: &mut Debugger) {
1279        for _ in 0..10_000 {
1280            if dbg.snapshot().state != "paused" {
1281                break;
1282            }
1283            dbg.step();
1284        }
1285    }
1286
1287    /// The pc of the first op whose disassembly starts with `prefix` (self-calibrating
1288    /// so the tests survive a change in the exact bytecode layout).
1289    fn pc_of(dbg: &Debugger, prefix: &str) -> usize {
1290        dbg.disassembly()
1291            .iter()
1292            .find(|l| l.text.starts_with(prefix))
1293            .unwrap_or_else(|| panic!("no `{prefix}` op in the disassembly"))
1294            .pc
1295    }
1296
1297    #[test]
1298    fn arms_at_entry() {
1299        let dbg = Debugger::from_source(PROG).expect("compiles");
1300        let s = dbg.snapshot();
1301        assert_eq!(s.step, 0, "history cursor at the start");
1302        assert_eq!(s.pc, 0, "stopped before the first op");
1303        assert_eq!(s.state, "paused");
1304        assert!(s.output.is_empty());
1305        assert!(s.total_ops > 0, "the program has instructions");
1306        assert!(!s.frames.is_empty(), "at least the Main frame");
1307    }
1308
1309    #[test]
1310    fn stepping_to_done_matches_a_normal_run() {
1311        let mut dbg = Debugger::from_source(PROG).expect("compiles");
1312        run_to_done(&mut dbg);
1313        let s = dbg.snapshot();
1314        assert_eq!(s.state, "done", "reaches completion");
1315        // The debugger's output is exactly the interpreter's output for the same source.
1316        let interp = crate::ui_bridge::interpret_for_ui_sync_with_args(PROG, &[]);
1317        assert_eq!(interp.error, None);
1318        assert_eq!(s.output, interp.lines, "stepped output == single-shot run output");
1319    }
1320
1321    #[test]
1322    fn disassembly_is_faithful_to_the_source() {
1323        let dbg = Debugger::from_source(PROG).expect("compiles");
1324        let texts: Vec<&str> = dbg.disassembly().iter().map(|l| l.text.as_str()).collect();
1325        assert!(texts[0].starts_with("LoadConst"), "first op loads a literal");
1326        assert_eq!(texts.last(), Some(&"Halt"), "program ends in Halt");
1327        assert!(texts.iter().any(|t| t.starts_with("Add")), "the `x + y` add is present");
1328        assert!(texts.iter().any(|t| t.starts_with("Show")), "the `Show` is present");
1329    }
1330
1331    #[test]
1332    fn step_into_advances_one_op() {
1333        let mut dbg = Debugger::from_source(PROG).expect("compiles");
1334        assert!(!dbg.snapshot().op_text.is_empty(), "paused on a real op");
1335        dbg.step();
1336        let s = dbg.snapshot();
1337        assert_eq!(s.step, 1, "history cursor advanced one op");
1338        assert_eq!(s.pc, 1, "straight-line pc advanced");
1339    }
1340
1341    #[test]
1342    fn registers_carry_their_values() {
1343        let mut dbg = Debugger::from_source(PROG).expect("compiles");
1344        // Run up to (not through) the Show — by then x, y, and x+y have been computed.
1345        let show = pc_of(&dbg, "Show");
1346        dbg.set_breakpoint(show);
1347        dbg.resume();
1348        let s = dbg.snapshot();
1349        assert_eq!(s.pc, show);
1350        let main = &s.frames[0];
1351        let values: Vec<&str> = main.registers.iter().map(|r| r.value.as_str()).collect();
1352        assert!(values.contains(&"6"), "x's value is live in a register: {values:?}");
1353        assert!(values.contains(&"7"), "y's value is live in a register: {values:?}");
1354        assert!(values.contains(&"13"), "x + y was computed: {values:?}");
1355        // The op that wrote the sum marked its register changed on the prior step.
1356        assert!(
1357            main.registers.iter().any(|r| r.changed),
1358            "the most recent write is flagged changed"
1359        );
1360    }
1361
1362    #[test]
1363    fn registers_carry_their_type() {
1364        // A learning aid: every live register reports the type of the value it holds,
1365        // so a student watching `x = 6` also sees `x : Int`.
1366        let mut dbg = Debugger::from_source(PROG).expect("compiles");
1367        let show = pc_of(&dbg, "Show");
1368        dbg.set_breakpoint(show);
1369        dbg.resume();
1370        let s = dbg.snapshot();
1371        let typed: Vec<(&str, &str)> = s.frames[0]
1372            .registers
1373            .iter()
1374            .map(|r| (r.kind.as_str(), r.value.as_str()))
1375            .collect();
1376        assert!(typed.contains(&("Int", "6")), "x:6 is typed Int: {typed:?}");
1377        assert!(typed.contains(&("Int", "13")), "x+y:13 is typed Int: {typed:?}");
1378        assert!(
1379            s.frames[0].registers.iter().all(|r| !r.kind.is_empty()),
1380            "no live register is left untyped: {typed:?}"
1381        );
1382    }
1383
1384    #[test]
1385    fn variable_timeline_tracks_each_variable_over_time() {
1386        let mut dbg = Debugger::from_source(PROG).expect("compiles");
1387        dbg.resume();
1388        let tl = dbg.variable_timeline();
1389        let names: Vec<&str> = tl.vars.iter().map(|v| v.name.as_str()).collect();
1390        assert!(names.contains(&"x"), "x is a traced variable: {names:?}");
1391        assert!(names.contains(&"y"), "y is a traced variable: {names:?}");
1392
1393        // Every trace spans the full (windowed) history, so the oscilloscope is aligned.
1394        for v in &tl.vars {
1395            assert_eq!(v.points.len(), tl.steps, "trace {} spans the timeline", v.name);
1396        }
1397        let x = tl.vars.iter().find(|v| v.name == "x").unwrap();
1398        assert_eq!(x.kind, "Int", "x is typed on its trace");
1399        // x takes the value 6 at some edge and is holding 6 once the program has run.
1400        assert!(x.points.iter().any(|p| p.value == "6" && p.changed), "x has a 6-edge");
1401        assert_eq!(x.points.last().unwrap().value, "6", "x ends at 6");
1402        assert_eq!(tl.cursor, dbg.snapshot().step, "playhead is at the cursor");
1403    }
1404
1405    #[test]
1406    fn snapshot_carries_fol_semantics() {
1407        // Each executing op exposes its first-order-logic meaning.
1408        let mut dbg = Debugger::from_source(PROG).expect("compiles");
1409        let mut fols = Vec::new();
1410        while dbg.is_running() {
1411            let s = dbg.snapshot();
1412            if !s.fol.is_empty() {
1413                fols.push(s.fol.clone());
1414            }
1415            dbg.step();
1416        }
1417        assert!(fols.iter().any(|f| f == "R0 = 6"), "literal load reads as R0 = 6: {fols:?}");
1418        assert!(fols.iter().any(|f| f == "x = R0"), "the copy into x reads as x = R0: {fols:?}");
1419        assert!(fols.iter().any(|f| f.contains("x + y")), "the addition reads over named vars: {fols:?}");
1420    }
1421
1422    #[test]
1423    fn fol_renders_a_comparison_as_a_biconditional() {
1424        // `is x < y` compiles to a comparison op whose FOL is a biconditional.
1425        let src = "## Main\n\nLet x be 6.\nLet y be 7.\nLet t be x < y.\nShow t.";
1426        let mut dbg = Debugger::from_source(src).expect("compiles");
1427        let mut fols = Vec::new();
1428        while dbg.is_running() {
1429            let s = dbg.snapshot();
1430            if !s.fol.is_empty() {
1431                fols.push(s.fol.clone());
1432            }
1433            dbg.step();
1434        }
1435        assert!(
1436            fols.iter().any(|f| f.contains("\u{27fa}") && f.contains("x < y")),
1437            "the comparison reads as a biconditional: {fols:?}"
1438        );
1439    }
1440
1441    #[test]
1442    fn socratic_prompt_asks_the_learner_to_predict() {
1443        let mut dbg = Debugger::from_source(PROG).expect("compiles");
1444        let mut qs = Vec::new();
1445        while dbg.is_running() {
1446            let s = dbg.snapshot();
1447            if !s.socratic.is_empty() {
1448                qs.push(s.socratic.clone());
1449            }
1450            dbg.step();
1451        }
1452        assert!(
1453            qs.iter().any(|q| q.ends_with('?') && q.contains("sum") && q.contains("(6)") && q.contains("(7)")),
1454            "the addition asks the learner to predict the sum from the live operands: {qs:?}"
1455        );
1456        assert!(qs.iter().all(|q| q.contains('?')), "every Socratic prompt is a question: {qs:?}");
1457    }
1458
1459    #[test]
1460    fn socratic_poses_a_yes_no_question_for_a_comparison() {
1461        let src = "## Main\n\nLet x be 6.\nLet y be 7.\nLet t be x < y.\nShow t.";
1462        let mut dbg = Debugger::from_source(src).expect("compiles");
1463        let mut qs = Vec::new();
1464        while dbg.is_running() {
1465            let s = dbg.snapshot();
1466            if !s.socratic.is_empty() {
1467                qs.push(s.socratic.clone());
1468            }
1469            dbg.step();
1470        }
1471        assert!(
1472            qs.iter().any(|q| q.starts_with("Is ") && q.contains("less than") && q.ends_with('?')),
1473            "the comparison is posed as a yes/no question: {qs:?}"
1474        );
1475    }
1476
1477    #[test]
1478    fn socratic_done_state_invites_reflection() {
1479        let mut dbg = Debugger::from_source(PROG).expect("compiles");
1480        dbg.resume();
1481        let s = dbg.snapshot();
1482        assert!(s.socratic.contains("expected"), "the finished prompt invites reflection: {}", s.socratic);
1483    }
1484
1485    #[test]
1486    fn live_proof_is_true_now_and_proven_for_every_run() {
1487        let mut dbg = Debugger::from_source(PROG).expect("compiles");
1488        dbg.resume();
1489        let r = dbg.assert_at_cursor("x < y");
1490        assert!(r.parsed, "parsed the comparison");
1491        assert_eq!(r.now, Some(true), "x=6 < y=7 holds now: {}", r.now_detail);
1492        assert_eq!(r.verdict, ProofVerdict::ProvenTrue, "proven for every run: {}", r.verdict_detail);
1493        assert!(r.verdict_detail.contains("[6, 6]"), "cites the proven ranges: {}", r.verdict_detail);
1494    }
1495
1496    #[test]
1497    fn live_proof_statically_refutes_a_false_predicate() {
1498        let mut dbg = Debugger::from_source(PROG).expect("compiles");
1499        dbg.resume();
1500        let r = dbg.assert_at_cursor("x > y");
1501        assert_eq!(r.now, Some(false), "x=6 > y=7 is false now");
1502        assert_eq!(r.verdict, ProofVerdict::ProvenFalse, "refuted for every run: {}", r.verdict_detail);
1503    }
1504
1505    #[test]
1506    fn live_proof_proves_a_constant_equality_and_a_bound() {
1507        let mut dbg = Debugger::from_source(PROG).expect("compiles");
1508        dbg.resume();
1509        assert_eq!(dbg.assert_at_cursor("x == 6").verdict, ProofVerdict::ProvenTrue, "x is provably 6");
1510        assert_eq!(dbg.assert_at_cursor("x >= 0").verdict, ProofVerdict::ProvenTrue, "x is provably non-negative");
1511        assert_eq!(dbg.assert_at_cursor("y <= 100").verdict, ProofVerdict::ProvenTrue, "y is provably under 100");
1512    }
1513
1514    #[test]
1515    fn live_proof_rejects_unparseable_input() {
1516        let dbg = Debugger::from_source(PROG).expect("compiles");
1517        let r = dbg.assert_at_cursor("hello world");
1518        assert!(!r.parsed, "garbage is not a comparison");
1519        assert_eq!(r.verdict, ProofVerdict::Unknown);
1520    }
1521
1522    #[test]
1523    fn proven_invariants_prove_constant_values_and_types() {
1524        // The Oracle statically proves x ≡ 6 and y ≡ 7 (singleton ranges) and Int type —
1525        // facts that hold on EVERY run, available before stepping.
1526        let dbg = Debugger::from_source(PROG).expect("compiles");
1527        let proven = dbg.proven_invariants();
1528        let names: Vec<&str> = proven.iter().map(|p| p.name.as_str()).collect();
1529        assert!(names.contains(&"x"), "x has proven facts: {names:?}");
1530        let x = proven.iter().find(|p| p.name == "x").unwrap();
1531        assert!(x.facts.iter().any(|f| f.contains("[6, 6]")), "x proven \u{2208} [6,6]: {:?}", x.facts);
1532        assert!(x.facts.iter().any(|f| f.contains("Int")), "x proven Int: {:?}", x.facts);
1533        let y = proven.iter().find(|p| p.name == "y").unwrap();
1534        assert!(y.facts.iter().any(|f| f.contains("[7, 7]")), "y proven \u{2208} [7,7]: {:?}", y.facts);
1535    }
1536
1537    #[test]
1538    fn proven_facts_are_available_without_stepping() {
1539        // Proven facts come from compile-time analysis, not execution — present at cursor 0.
1540        let dbg = Debugger::from_source(PROG).expect("compiles");
1541        assert_eq!(dbg.snapshot().step, 0, "fresh debugger, nothing executed");
1542        assert!(!dbg.proven_invariants().is_empty(), "proven facts ready before any step");
1543    }
1544
1545    #[test]
1546    fn observed_invariants_report_a_constant() {
1547        let mut dbg = Debugger::from_source(PROG).expect("compiles");
1548        dbg.resume();
1549        let ins = dbg.observed_invariants();
1550        let x = ins.iter().find(|i| i.name == "x").expect("x has insights");
1551        assert!(
1552            x.facts.iter().any(|f| f.contains("constant") && f.contains('6')),
1553            "x is observed constant 6: {:?}",
1554            x.facts
1555        );
1556    }
1557
1558    #[test]
1559    fn observed_invariants_detect_a_monotonic_range() {
1560        let src = "## Main\n\nLet n be 1.\nSet n to 2.\nSet n to 3.\nShow n.";
1561        let mut dbg = Debugger::from_source(src).expect("compiles");
1562        dbg.resume();
1563        let ins = dbg.observed_invariants();
1564        let n = ins.iter().find(|i| i.name == "n").expect("n has insights");
1565        assert!(
1566            n.facts.iter().any(|f| f.contains("range") && f.contains('1') && f.contains('3')),
1567            "n ranges over [1,3]: {:?}",
1568            n.facts
1569        );
1570        assert!(n.facts.iter().any(|f| f.contains("increase")), "n only increases: {:?}", n.facts);
1571    }
1572
1573    #[test]
1574    fn provenance_explains_why_a_value_exists() {
1575        let mut dbg = Debugger::from_source(PROG).expect("compiles");
1576        dbg.resume();
1577        let snap = dbg.snapshot();
1578        // The slot holding x + y = 13.
1579        let sum = snap.frames.last().unwrap().registers.iter().find(|r| r.value == "13").unwrap().index;
1580        let node = dbg.provenance(sum).expect("13 has a provenance");
1581        assert_eq!(node.value, "13");
1582        assert!(
1583            node.narration.contains("add") || node.op_text.to_lowercase().contains("add"),
1584            "the sum was produced by an add: {} / {}",
1585            node.op_text,
1586            node.narration
1587        );
1588        // Its inputs trace back to the two literals 6 and 7.
1589        let input_vals: Vec<&str> = node.inputs.iter().map(|n| n.value.as_str()).collect();
1590        assert!(input_vals.contains(&"6"), "one input is 6: {input_vals:?}");
1591        assert!(input_vals.contains(&"7"), "one input is 7: {input_vals:?}");
1592    }
1593
1594    #[test]
1595    fn provenance_bottoms_out_at_a_constant_load() {
1596        let mut dbg = Debugger::from_source(PROG).expect("compiles");
1597        dbg.resume();
1598        let snap = dbg.snapshot();
1599        let x = snap
1600            .frames
1601            .last()
1602            .unwrap()
1603            .registers
1604            .iter()
1605            .find(|r| r.name.as_deref() == Some("x"))
1606            .unwrap()
1607            .index;
1608        let node = dbg.provenance(x).expect("x has a provenance");
1609        assert_eq!(node.value, "6");
1610        assert!(node.inputs.is_empty(), "a literal load consumes no registers");
1611        assert!(
1612            node.narration.contains("load") || node.op_text.to_lowercase().contains("load"),
1613            "x came from a load: {} / {}",
1614            node.op_text,
1615            node.narration
1616        );
1617    }
1618
1619    #[test]
1620    fn provenance_chains_through_a_dependent_assignment() {
1621        // z depends on the sum, which depends on x and y — a two-level lineage.
1622        let src = "## Main\n\nLet x be 6.\nLet y be 7.\nLet z be x + y.\nLet w be z + x.\nShow w.";
1623        let mut dbg = Debugger::from_source(src).expect("compiles");
1624        dbg.resume();
1625        let snap = dbg.snapshot();
1626        let w = snap
1627            .frames
1628            .last()
1629            .unwrap()
1630            .registers
1631            .iter()
1632            .find(|r| r.name.as_deref() == Some("w"))
1633            .unwrap()
1634            .index;
1635        let node = dbg.provenance(w).expect("w has a provenance");
1636        assert_eq!(node.value, "19", "w = (6+7) + 6");
1637        // One input is z = 13, and z itself decomposes into 6 and 7.
1638        let z = node.inputs.iter().find(|n| n.value == "13").expect("w reads z=13");
1639        let z_inputs: Vec<&str> = z.inputs.iter().map(|n| n.value.as_str()).collect();
1640        assert!(z_inputs.contains(&"6") && z_inputs.contains(&"7"), "z traces to 6 and 7: {z_inputs:?}");
1641    }
1642
1643    #[test]
1644    fn variable_names_are_resolved() {
1645        let mut dbg = Debugger::from_source(PROG).expect("compiles");
1646        let show = pc_of(&dbg, "Show");
1647        dbg.set_breakpoint(show);
1648        dbg.resume();
1649        let s = dbg.snapshot();
1650        let named: Vec<(&str, &str)> = s.frames[0]
1651            .registers
1652            .iter()
1653            .filter_map(|r| r.name.as_deref().map(|n| (n, r.value.as_str())))
1654            .collect();
1655        assert!(named.contains(&("x", "6")), "x = 6 shown by name: {named:?}");
1656        assert!(named.contains(&("y", "7")), "y = 7 shown by name: {named:?}");
1657    }
1658
1659    #[test]
1660    fn production_compile_carries_no_debug_names() {
1661        // The debugger's compile path captures variable names…
1662        let (dbg_prog, _proven) = compile_source(PROG).expect("debug compile");
1663        assert!(!dbg_prog.reg_names.is_empty(), "debug path records reg_names");
1664        // …but the production compile path records NOTHING — the debug info is gated,
1665        // so it strips out of shipped builds at zero cost.
1666        let prod_prog = crate::ui_bridge::with_parsed_program(PROG, |parsed, interner| {
1667            let (stmts, types, _policies) = parsed?;
1668            crate::vm::Compiler::compile_with_types(stmts, interner, Some(types))
1669        })
1670        .expect("production compile");
1671        assert!(prod_prog.reg_names.is_empty(), "production path captures no debug names");
1672    }
1673
1674    #[test]
1675    fn breakpoint_halts_continue() {
1676        let mut dbg = Debugger::from_source(PROG).expect("compiles");
1677        let show = pc_of(&dbg, "Show");
1678        dbg.set_breakpoint(show);
1679        dbg.resume();
1680        let s = dbg.snapshot();
1681        assert_eq!(s.pc, show, "Continue stopped at the breakpoint");
1682        assert!(s.at_breakpoint);
1683        assert_eq!(s.state, "paused");
1684        assert!(s.output.is_empty(), "the Show has not run yet");
1685        // One more step emits the output.
1686        dbg.step();
1687        assert!(!dbg.snapshot().output.is_empty(), "stepping the Show emits a line");
1688    }
1689
1690    #[test]
1691    fn time_travel_steps_backwards() {
1692        let mut dbg = Debugger::from_source(PROG).expect("compiles");
1693        dbg.step();
1694        dbg.step();
1695        dbg.step();
1696        let three = dbg.snapshot();
1697        assert_eq!(three.step, 3);
1698        let pc_at_three = three.pc;
1699        dbg.step_back();
1700        assert_eq!(dbg.snapshot().step, 2, "rewound one op");
1701        // Forward again reproduces the same state (deterministic replay).
1702        dbg.step();
1703        let again = dbg.snapshot();
1704        assert_eq!(again.step, 3);
1705        assert_eq!(again.pc, pc_at_three, "replay is deterministic");
1706    }
1707
1708    #[test]
1709    fn restart_rewinds_to_entry() {
1710        let mut dbg = Debugger::from_source(PROG).expect("compiles");
1711        run_to_done(&mut dbg);
1712        assert_eq!(dbg.snapshot().state, "done");
1713        dbg.restart();
1714        let s = dbg.snapshot();
1715        assert_eq!(s.step, 0);
1716        assert_eq!(s.pc, 0);
1717        assert_eq!(s.state, "paused");
1718        assert!(s.output.is_empty());
1719    }
1720
1721    // ── Audit: loops, calls, recursion, errors, time-travel ───────────────────
1722
1723    const FUNC: &str =
1724        "## To double (x: Int) -> Int:\n    Return x + x.\n\n## Main\nLet result be double(5).\nShow result.";
1725    const WHILE: &str = "## Main\nLet mutable sum be 0.\nLet mutable i be 1.\nWhile i is at most 5:\n    Set sum to sum + i.\n    Set i to i + 1.\nShow sum.";
1726
1727    /// Step a program op-by-op to completion and return the final snapshot.
1728    fn drive_to_end(src: &str) -> DebugSnapshot {
1729        let mut dbg = Debugger::from_source(src)
1730            .unwrap_or_else(|e| panic!("compile failed: {e}\n--- src ---\n{src}"));
1731        let mut guard = 0;
1732        while dbg.is_running() && guard < 500_000 {
1733            dbg.step();
1734            guard += 1;
1735        }
1736        dbg.snapshot()
1737    }
1738
1739    /// THE soundness invariant: stepping any program op-by-op (rebuilding VM state
1740    /// each step) must produce output BYTE-IDENTICAL to a normal interpreter run.
1741    /// This exercises every save/restore path — `iter_stack` (loops), call frames
1742    /// (functions/recursion), globals, and lists.
1743    #[test]
1744    fn corpus_stepped_output_matches_interpreter() {
1745        let corpus: &[(&str, &str)] = &[
1746            ("while_loop", WHILE),
1747            ("repeat_range", "## Main\nLet mutable total be 0.\nRepeat for i from 1 to 5:\n    Set total to total + i.\nShow total."),
1748            ("for_in", "## Main\nLet mutable sum be 0.\nRepeat for x in [10, 20, 30]:\n    Set sum to sum + x.\nShow sum."),
1749            ("conditional", "## Main\nLet x be 3.\nIf x is greater than 5:\n    Show \"big\".\nOtherwise:\n    Show \"small\"."),
1750            ("function", FUNC),
1751            ("recursion", "## To fib (n: Int) -> Int:\n    If n is less than 2:\n        Return n.\n    Return fib(n - 1) + fib(n - 2).\n\n## Main\nShow fib(6)."),
1752            ("list", "## Main\nLet xs be [1, 2, 3].\nPush 4 to xs.\nShow length of xs."),
1753        ];
1754        for (name, src) in corpus {
1755            let oracle = crate::ui_bridge::interpret_for_ui_sync_with_args(src, &[]);
1756            assert_eq!(oracle.error, None, "{name}: the interpreter itself errored: {:?}", oracle.error);
1757            let snap = drive_to_end(src);
1758            assert_eq!(
1759                snap.state, "done",
1760                "{name}: debugger did not finish (state={}, out={:?})", snap.state, snap.output
1761            );
1762            assert_eq!(snap.output, oracle.lines, "{name}: stepped output diverged from the interpreter");
1763        }
1764    }
1765
1766    #[test]
1767    fn stepping_a_while_loop_runs_all_iterations() {
1768        let snap = drive_to_end(WHILE);
1769        assert_eq!(snap.state, "done");
1770        assert_eq!(snap.output, vec!["15".to_string()], "op-by-op stepping summed 1..=5");
1771    }
1772
1773    #[test]
1774    fn call_stack_descends_into_the_function() {
1775        let mut dbg = Debugger::from_source(FUNC).expect("compiles");
1776        let mut entered = false;
1777        let mut guard = 0;
1778        while dbg.is_running() && guard < 10_000 {
1779            let s = dbg.snapshot();
1780            if s.frames.len() >= 2 {
1781                entered = true;
1782                assert!(s.frames.last().unwrap().function.is_some(), "inner frame is a function");
1783                break;
1784            }
1785            dbg.step();
1786            guard += 1;
1787        }
1788        assert!(entered, "stepping descends into the called function");
1789    }
1790
1791    #[test]
1792    fn step_over_runs_the_call_without_descending() {
1793        let mut dbg = Debugger::from_source(FUNC).expect("compiles");
1794        let call_pc = pc_of(&dbg, "Call");
1795        let mut guard = 0;
1796        while dbg.snapshot().pc != call_pc && dbg.is_running() && guard < 1000 {
1797            dbg.step();
1798            guard += 1;
1799        }
1800        assert_eq!(dbg.snapshot().pc, call_pc, "reached the Call op");
1801        let depth = dbg.snapshot().frames.len();
1802        dbg.step_over();
1803        let s = dbg.snapshot();
1804        assert_eq!(s.frames.len(), depth, "step-over did not leave us inside the callee");
1805        assert!(s.pc > call_pc || s.state == "done", "advanced past the call");
1806    }
1807
1808    #[test]
1809    fn step_out_returns_to_the_caller() {
1810        let mut dbg = Debugger::from_source(FUNC).expect("compiles");
1811        let mut guard = 0;
1812        while dbg.is_running() && dbg.snapshot().frames.len() < 2 && guard < 1000 {
1813            dbg.step();
1814            guard += 1;
1815        }
1816        assert_eq!(dbg.snapshot().frames.len(), 2, "inside the function");
1817        dbg.step_out();
1818        assert!(dbg.snapshot().frames.len() <= 1, "step-out returns to the caller");
1819    }
1820
1821    #[test]
1822    fn runtime_error_surfaces_without_panicking() {
1823        let mut dbg = Debugger::from_source("## Main\nLet x be 1 / 0.\nShow x.").expect("compiles");
1824        let mut guard = 0;
1825        while dbg.is_running() && guard < 100 {
1826            dbg.step();
1827            guard += 1;
1828        }
1829        let s = dbg.snapshot();
1830        assert_eq!(s.state, "error", "division by zero surfaces as an error, not a panic");
1831        assert!(s.error.is_some(), "the error carries a message");
1832    }
1833
1834    #[test]
1835    fn entering_a_function_does_not_flag_spurious_changes() {
1836        let mut dbg = Debugger::from_source(FUNC).expect("compiles");
1837        let mut guard = 0;
1838        while dbg.is_running() && dbg.snapshot().frames.len() < 2 && guard < 1000 {
1839            dbg.step();
1840            guard += 1;
1841        }
1842        let s = dbg.snapshot();
1843        assert_eq!(s.frames.len(), 2, "entered the function");
1844        assert!(
1845            s.frames.last().unwrap().registers.iter().all(|r| !r.changed),
1846            "crossing into a new frame must not flag stale registers as changed"
1847        );
1848    }
1849
1850    #[test]
1851    fn time_travel_across_a_call_restores_the_frame() {
1852        let mut dbg = Debugger::from_source(FUNC).expect("compiles");
1853        let mut guard = 0;
1854        while dbg.is_running() && dbg.snapshot().frames.len() < 2 && guard < 1000 {
1855            dbg.step();
1856            guard += 1;
1857        }
1858        let inside = dbg.snapshot();
1859        assert_eq!(inside.frames.len(), 2, "inside the function");
1860        let (pc, step) = (inside.pc, inside.step);
1861        dbg.step();
1862        dbg.step_back();
1863        let back = dbg.snapshot();
1864        assert_eq!(back.step, step, "rewound to the in-function step");
1865        assert_eq!(back.pc, pc, "pc restored exactly");
1866        assert_eq!(back.frames.len(), 2, "still inside the function after the rewind");
1867    }
1868
1869    #[test]
1870    fn narration_explains_each_step_in_english() {
1871        let mut dbg = Debugger::from_source(PROG).expect("compiles");
1872        let mut narrations = Vec::new();
1873        let mut guard = 0;
1874        while dbg.is_running() && guard < 1000 {
1875            let s = dbg.snapshot();
1876            if !s.narration.is_empty() {
1877                narrations.push(s.narration.clone());
1878            }
1879            dbg.step();
1880            guard += 1;
1881        }
1882        let all = narrations.join(" | ");
1883        assert!(all.contains("add"), "an add step is narrated in English: {all}");
1884        assert!(all.contains("print"), "the print is narrated: {all}");
1885        assert!(
1886            narrations.iter().any(|n| n.contains("x(6)") && n.contains("y(7)")),
1887            "the add narration names the variables and their live values: {narrations:?}"
1888        );
1889    }
1890
1891    #[test]
1892    fn op_io_in_snapshot_targets_the_operands() {
1893        let mut dbg = Debugger::from_source(PROG).expect("compiles");
1894        let add = pc_of(&dbg, "Add");
1895        let mut guard = 0;
1896        while dbg.snapshot().pc != add && dbg.is_running() && guard < 100 {
1897            dbg.step();
1898            guard += 1;
1899        }
1900        let s = dbg.snapshot();
1901        assert_eq!(s.pc, add);
1902        assert!(s.op_writes.is_some(), "the Add writes a destination register");
1903        assert_eq!(s.op_reads.len(), 2, "the Add reads its two operands (for the datapath)");
1904    }
1905
1906    #[test]
1907    fn seek_scrubs_anywhere_in_history() {
1908        let mut dbg = Debugger::from_source(WHILE).expect("compiles");
1909        while dbg.is_running() {
1910            dbg.step();
1911        }
1912        let end = dbg.snapshot();
1913        assert_eq!(end.state, "done");
1914        let total = end.total_steps;
1915        assert!(total > 3, "the loop took several ops");
1916        // Scrub to the very start.
1917        dbg.seek(0);
1918        let s0 = dbg.snapshot();
1919        assert_eq!(s0.step, 0);
1920        assert_eq!(s0.state, "paused");
1921        assert!(s0.output.is_empty(), "no output has happened at the entry");
1922        // A middle step reads "paused" (per-frame outcome), not the program's "done".
1923        dbg.seek(total / 2);
1924        assert_eq!(dbg.snapshot().step, total / 2);
1925        assert_eq!(dbg.snapshot().state, "paused");
1926        // Scrub back to the end → final output restored.
1927        dbg.seek(total);
1928        let again = dbg.snapshot();
1929        assert_eq!(again.step, total);
1930        assert_eq!(again.state, "done");
1931        assert_eq!(again.output, end.output, "scrubbing to the end restores the final output");
1932    }
1933
1934    #[test]
1935    fn reverse_continue_runs_back_to_a_breakpoint() {
1936        let mut dbg = Debugger::from_source(WHILE).expect("compiles");
1937        let show = pc_of(&dbg, "Show");
1938        while dbg.is_running() {
1939            dbg.step();
1940        }
1941        assert_eq!(dbg.snapshot().state, "done");
1942        dbg.set_breakpoint(show);
1943        dbg.reverse_resume();
1944        let s = dbg.snapshot();
1945        assert_eq!(s.pc, show, "reverse-continue landed on the breakpoint");
1946        assert!(s.at_breakpoint);
1947        assert!(s.output.is_empty(), "rewound to the moment before the Show ran");
1948    }
1949
1950    #[test]
1951    fn restart_rewinds_but_keeps_explored_history() {
1952        let mut dbg = Debugger::from_source(WHILE).expect("compiles");
1953        while dbg.is_running() {
1954            dbg.step();
1955        }
1956        let total = dbg.snapshot().total_steps;
1957        dbg.restart();
1958        let s = dbg.snapshot();
1959        assert_eq!(s.step, 0, "back at the entry");
1960        assert_eq!(s.state, "paused");
1961        assert_eq!(s.total_steps, total, "explored history is retained, so re-stepping is instant");
1962    }
1963
1964    #[test]
1965    fn heap_view_lists_a_distinct_object() {
1966        let src = "## Main\nLet xs be [1, 2, 3].\nShow length of xs.";
1967        let mut dbg = Debugger::from_source(src).expect("compiles");
1968        while dbg.is_running() {
1969            dbg.step();
1970        }
1971        let s = dbg.snapshot();
1972        assert!(
1973            s.heap.iter().any(|o| o.kind == "list" && o.referenced_by.contains(&"xs".to_string())),
1974            "the list `xs` shows up as a heap object: {:?}", s.heap
1975        );
1976    }
1977
1978    #[test]
1979    fn heap_view_shows_storage_layout() {
1980        // A list of ints is stored as a PACKED `Vec<i64>`, not boxed values — the
1981        // memory layout the debugger teaches.
1982        let src = "## Main\nLet xs be [1, 2, 3].\nShow length of xs.";
1983        let mut dbg = Debugger::from_source(src).expect("compiles");
1984        while dbg.is_running() {
1985            dbg.step();
1986        }
1987        let s = dbg.snapshot();
1988        let list = s.heap.iter().find(|o| o.kind == "list").expect("a list on the heap");
1989        assert_eq!(list.storage, "packed Vec<i64>", "an int list is densely packed: {list:?}");
1990    }
1991
1992    #[test]
1993    fn heap_view_reveals_aliasing() {
1994        // `Let b be a` aliases the SAME list — the classic beginner trap. The heap view
1995        // must show ONE allocation referenced by both, not two copies.
1996        let src = "## Main\nLet a be [1, 2, 3].\nLet b be a.\nShow a.";
1997        let mut dbg = Debugger::from_source(src).expect("compiles");
1998        while dbg.is_running() {
1999            dbg.step();
2000        }
2001        let s = dbg.snapshot();
2002        let lists: Vec<&HeapObject> = s.heap.iter().filter(|o| o.kind == "list").collect();
2003        assert_eq!(lists.len(), 1, "a and b share ONE list allocation, not two: {:?}", s.heap);
2004        let list = lists[0];
2005        assert!(list.referenced_by.contains(&"a".to_string()), "`a` references it: {list:?}");
2006        assert!(list.referenced_by.contains(&"b".to_string()), "`b` references it: {list:?}");
2007        assert!(list.shared, "aliasing is flagged");
2008    }
2009
2010    #[test]
2011    fn stack_frames_carry_their_base_address() {
2012        let mut dbg = Debugger::from_source(FUNC).expect("compiles");
2013        let mut guard = 0;
2014        while dbg.is_running() && dbg.snapshot().frames.len() < 2 && guard < 1000 {
2015            dbg.step();
2016            guard += 1;
2017        }
2018        let s = dbg.snapshot();
2019        assert_eq!(s.frames.len(), 2, "inside the function");
2020        assert_eq!(s.frames[0].base, 0, "the Main frame starts at stack address 0");
2021        assert!(s.frames[1].base > 0, "the callee frame is stacked above Main (higher address)");
2022    }
2023}