logicaffeine_compile/vm/pebble.rs
1//! Pebbling — the reverse-liveness pass that decides which journal entries a recording run actually
2//! NEEDS, driving the Bennett history tape toward the Landauer floor.
3//!
4//! **The idea.** The tape is not fundamental. Most journaled bits are *recomputable, not destroyed*:
5//! a scratch temporary `t = f(a,b)` with its inputs still live is a pure function of live state, and
6//! reverse execution only reads live registers for `Bijection` ops (`RevAdd`'s `place`/`delta`, …) and
7//! collection reverses — every `Journaled` overwrite restores its destination straight from the tape
8//! without reading its operands. So a scratch write that only feeds `Journaled` results has an old
9//! value **no reverse step ever reads**; recording it was a naive-forward artifact. Eliding it is
10//! sound and shrinks the tape.
11//!
12//! **P1 rule (sound set-membership).** A Journaled single-destination overwrite is `Elide`d iff its
13//! register is (a) not program-observable — not named in its frame ([`CompiledProgram::named_regs`] /
14//! [`CompiledFunction::named_regs`](crate::vm::CompiledFunction::named_regs)) — AND (b) never a reverse-read operand
15//! ([`crate::vm::reverse::reverse_reads`]). Both the recorder ([`crate::vm::Vm`]'s `record_op`) and the
16//! reverse executor consult the plan, so an elided entry is neither pushed nor popped.
17//!
18//! Empty `named_regs` (hand-built bytecode / no name info) ⇒ everything is treated observable ⇒
19//! nothing is elided ⇒ the reverse executor's full-fidelity behaviour is preserved byte-for-byte.
20//!
21//! [`CompiledFunction::named_regs`]: crate::vm::CompiledFunction::named_regs
22
23use std::collections::HashSet;
24
25use crate::vm::instruction::{CompiledProgram, Op, Reg};
26use crate::vm::reverse::{self, ReverseMode};
27
28/// What the recorder should do with a Journaled write's tape entry.
29#[derive(Clone, Copy, PartialEq, Eq, Debug)]
30pub enum JournalDecision {
31 /// Journal the destroyed value — the reverse executor will read it back.
32 Keep,
33 /// Skip the entry: the destroyed value is program-invisible scratch that no reverse step reads.
34 Elide,
35 /// (`Adiabatic`, P3) Skip the entry; recompute the value on reverse from its pure defining segment.
36 Recompute,
37}
38
39/// Per-pc journaling decisions for a recording run in `mode`. `FullState` keeps everything (today's
40/// behaviour, made explicit); the pebbling modes elide scratch whose destroyed value no reverse step
41/// reads. Indexed by absolute pc in [`CompiledProgram::code`].
42pub fn plan(prog: &CompiledProgram, mode: ReverseMode) -> Vec<JournalDecision> {
43 plan_budgeted(prog, mode, 0)
44}
45
46/// [`plan`] with an explicit pebble CHECKPOINT budget (corner 2). Under `Adiabatic`, `budget` recomputable
47/// chain intermediates are KEPT on the tape (journaled once — `landauer = 0`, since a recomputable
48/// scratch is first-def so its displaced value is `Nothing`) instead of upgraded to `Recompute`. A
49/// reverse step that reads a node above a checkpoint then re-walks only down to the nearest checkpoint,
50/// not to the chain root — the Bennett space-for-time trade. `budget = 0` is the recompute-everything
51/// corner (`O(1)` tape, chain·reads time); higher budgets bound the re-walk length.
52pub fn plan_budgeted(
53 prog: &CompiledProgram,
54 mode: ReverseMode,
55 budget: usize,
56) -> Vec<JournalDecision> {
57 let n = prog.code.len();
58 if mode == ReverseMode::FullState {
59 return vec![JournalDecision::Keep; n];
60 }
61 // FRAME-OVERLAP SOUNDNESS (Piece 1). A callee runs at `base = args_start`, so its registers ALIAS
62 // the caller's slots `[args_start..]` — a register that reads as "scratch" in a callee frame can be
63 // the SAME absolute slot as a NAMED variable in the caller. We resolve this exactly: compute each
64 // function's possible absolute BASE(s) from the call graph, then elide a write only if NONE of the
65 // absolute slots it can occupy is a Main-observable slot. Recursion / dynamic dispatch give
66 // unbounded / unknown bases → elision is disabled (sound).
67 let bases = match frame_bases(prog) {
68 Some(b) => b,
69 None => return vec![JournalDecision::Keep; n],
70 };
71 // The absolute slots compared at reverse end = Main's named registers. Empty `named_regs` (hand-
72 // built bytecode) ⇒ EVERY Main slot is observable, so nothing touching Main's frame is elided —
73 // preserving the reverse executor's full-fidelity tests.
74 let main_size = prog.register_count;
75 let main_all = prog.named_regs.is_empty();
76 let main_observable = |slot: usize| -> bool {
77 slot < main_size && (main_all || prog.named_regs.get(slot).copied().unwrap_or(false))
78 };
79 // Registers any reverse step reads live (Bijection operands + collection handles) — pinned Keep.
80 let mut reverse_read: HashSet<Reg> = HashSet::new();
81 for op in &prog.code {
82 reverse_read.extend(reverse::reverse_reads(op));
83 }
84 let mut plan = (0..n)
85 .map(|pc| {
86 let op = &prog.code[pc];
87 let Some(r) = reverse::overwritten_reg(op) else {
88 return JournalDecision::Keep;
89 };
90 if !reverse::records(op) || reverse_read.contains(&r) {
91 return JournalDecision::Keep;
92 }
93 let frame = frame_of(prog, pc);
94 // Named in its own frame? (Empty `named_regs` ⇒ observable ⇒ keep.)
95 let f_named = match frame {
96 None => main_all || prog.named_regs.get(r as usize).copied().unwrap_or(true),
97 Some(f) => {
98 let nm = &prog.functions[f].named_regs;
99 nm.is_empty() || nm.get(r as usize).copied().unwrap_or(true)
100 }
101 };
102 if f_named {
103 return JournalDecision::Keep;
104 }
105 // Elide only if NONE of the absolute slots this write can occupy (over every possible base)
106 // is a Main-observable slot.
107 let touches_observable = match frame {
108 None => main_observable(r as usize),
109 Some(f) => bases[f].iter().any(|&b| main_observable(b + r as usize)),
110 };
111 if touches_observable {
112 JournalDecision::Keep
113 } else {
114 JournalDecision::Elide
115 }
116 })
117 .collect::<Vec<_>>();
118 // ADIABATIC (Piece 2): upgrade each recomputable reverse-live scratch DEF from `Keep` to
119 // `Recompute` — re-run on reverse from stable live inputs instead of journaled, pushing the tape
120 // BELOW Observable while keeping full fidelity. Corner 2: a `budget` of chain intermediates stays
121 // `Keep` (a CHECKPOINT) so downstream recomputes stop there instead of re-walking to the root.
122 if mode == ReverseMode::Adiabatic {
123 let checkpoints = checkpoint_defs(prog, budget);
124 for (_frame, _r, def_pc, _op) in recomputable(prog) {
125 if !checkpoints.contains(&def_pc) {
126 plan[def_pc] = JournalDecision::Recompute;
127 }
128 }
129 }
130 plan
131}
132
133/// The recomputable-chain intermediates to KEEP as checkpoints (corner 2) — the `budget` nodes that
134/// most shorten the reverse re-walk. An INTERMEDIATE is a recomputable node whose register is an input
135/// to another recomputable def in the same frame (so it sits *inside* a chain, not at its top). We
136/// checkpoint the intermediates CLOSEST to the chain tops (highest `def_pc`) first: keeping a node near
137/// the top caps every above-it read at a re-walk of length 1, the largest marginal saving per pebble
138/// (the Bennett "cache near the read" heuristic). `budget = 0` ⇒ none (recompute everything).
139pub fn checkpoint_defs(prog: &CompiledProgram, budget: usize) -> HashSet<usize> {
140 if budget == 0 {
141 return HashSet::new();
142 }
143 let rec = recomputable(prog);
144 // (frame, input-reg) pairs consumed by some recomputable def — the interior edges of the chains.
145 let mut consumed: HashSet<(Option<usize>, Reg)> = HashSet::new();
146 for (frame, _r, _pc, op) in &rec {
147 for input in def_inputs(op) {
148 consumed.insert((*frame, input));
149 }
150 }
151 let mut intermediates: Vec<usize> = rec
152 .iter()
153 .filter(|(frame, r, _pc, _op)| consumed.contains(&(*frame, *r)))
154 .map(|(_f, _r, pc, _op)| *pc)
155 .collect();
156 // Highest def_pc first (closest to the chain tops), then take `budget` of them.
157 intermediates.sort_unstable_by(|a, b| b.cmp(a));
158 intermediates.into_iter().take(budget).collect()
159}
160
161/// The two input registers of a pure two-operand recomputable def (kept in lockstep with
162/// `recomputable`'s accepted-op set). Empty for anything else.
163fn def_inputs(op: &Op) -> Vec<Reg> {
164 match *op {
165 Op::Add { lhs, rhs, .. }
166 | Op::Sub { lhs, rhs, .. }
167 | Op::Mul { lhs, rhs, .. }
168 | Op::BitXor { lhs, rhs, .. }
169 | Op::Div { lhs, rhs, .. }
170 | Op::ExactDiv { lhs, rhs, .. }
171 | Op::FloorDiv { lhs, rhs, .. }
172 | Op::Mod { lhs, rhs, .. }
173 | Op::Pow { lhs, rhs, .. }
174 | Op::BitAnd { lhs, rhs, .. }
175 | Op::BitOr { lhs, rhs, .. }
176 | Op::Shl { lhs, rhs, .. }
177 | Op::Shr { lhs, rhs, .. } => vec![lhs, rhs],
178 _ => vec![],
179 }
180}
181
182/// The pc range `[lo, hi)` of a frame — Main is `[0, first-function-entry)`, function `i` is
183/// `[entry_pc_i, next-entry)` (bodies are contiguous in `entry_pc` order; see `vm/compiler.rs`).
184fn frame_range(prog: &CompiledProgram, frame: Option<usize>) -> (usize, usize) {
185 match frame {
186 None => (
187 0,
188 prog.functions
189 .iter()
190 .map(|f| f.entry_pc)
191 .min()
192 .unwrap_or(prog.code.len()),
193 ),
194 Some(i) => {
195 let lo = prog.functions[i].entry_pc;
196 let hi = prog
197 .functions
198 .iter()
199 .map(|f| f.entry_pc)
200 .filter(|&e| e > lo)
201 .min()
202 .unwrap_or(prog.code.len());
203 (lo, hi)
204 }
205 }
206}
207
208/// Reverse-live scratch registers that can be RECOMPUTED on reverse instead of journaled — Bennett
209/// uncompute — keyed by the FRAME they live in (register numbers are frame-relative). Each is a
210/// SCRATCH register that some reverse step in its frame reads live (so `Observable`/`FullState` must
211/// journal it), with a SINGLE pure-arithmetic def whose inputs are STABLE within the frame, and that
212/// (via the frame-overlap bases) aliases NO Main-observable slot. Re-running the def during reverse
213/// with `self.base` set to the frame reconstructs it exactly. Returns `(frame, register, def_pc,
214/// def_op)`. Dynamic dispatch (`CallValue`, unknown callee) yields nothing; recursion is handled via
215/// the bounded frame bases (corner 4) — each activation recomputes at its own live base.
216pub fn recomputable(prog: &CompiledProgram) -> Vec<(Option<usize>, Reg, usize, Op)> {
217 if prog
218 .code
219 .iter()
220 .any(|op| matches!(op, Op::CallValue { .. }))
221 {
222 return Vec::new();
223 }
224 let bases = match frame_bases(prog) {
225 Some(b) => b,
226 None => return Vec::new(),
227 };
228 let main_size = prog.register_count;
229 let main_all = prog.named_regs.is_empty();
230 let main_observable = |slot: usize| {
231 slot < main_size && (main_all || prog.named_regs.get(slot).copied().unwrap_or(false))
232 };
233 // The registers an op WRITES (mutates) — a fresh overwrite, a collection mutation, or a Bijection's
234 // in-place `place`/`a`/`b`. NOT a Bijection's `delta` (read-only, surfaced by `reverse_reads`).
235 let writes = |op: &Op| -> Vec<Reg> {
236 match *op {
237 Op::RevAdd { place, .. } | Op::RevSub { place, .. } | Op::RevXor { place, .. } => {
238 vec![place]
239 }
240 Op::RevNeg { place } => vec![place],
241 Op::RevSwap { a, b } => vec![a, b],
242 _ => reverse::overwritten_reg(op)
243 .into_iter()
244 .chain(reverse::mutated_collection_reg(op))
245 .collect(),
246 }
247 };
248 let frames = std::iter::once(None).chain((0..prog.functions.len()).map(Some));
249 let mut out = Vec::new();
250 for frame in frames {
251 let (lo, hi) = frame_range(prog, frame);
252 let named: &[bool] = match frame {
253 None => &prog.named_regs,
254 Some(i) => &prog.functions[i].named_regs,
255 };
256 if named.is_empty() {
257 continue; // no name info for this frame ⇒ do not recompute (safe fallback)
258 }
259 let mut reverse_read: Vec<Reg> = Vec::new();
260 for pc in lo..hi {
261 for r in reverse::reverse_reads(&prog.code[pc]) {
262 if !reverse_read.contains(&r) {
263 reverse_read.push(r);
264 }
265 }
266 }
267 for r in reverse_read {
268 if named.get(r as usize).copied().unwrap_or(true) {
269 continue; // named — must be journaled to restore its value
270 }
271 // Frame-overlap: skip if any absolute slot this register can occupy is Main-observable.
272 let aliases_observable = match frame {
273 None => main_observable(r as usize),
274 Some(i) => bases[i].iter().any(|&b| main_observable(b + r as usize)),
275 };
276 if aliases_observable {
277 continue;
278 }
279 let defs: Vec<usize> = (lo..hi)
280 .filter(|&pc| reverse::overwritten_reg(&prog.code[pc]) == Some(r))
281 .collect();
282 if defs.len() != 1 {
283 continue;
284 }
285 let def_pc = defs[0];
286 let def_op = prog.code[def_pc];
287 // The whole pure two-operand family is recomputable (corner 3): re-running any of these from
288 // the same live inputs reproduces the exact value. Keep in lockstep with `recompute_into`.
289 let (lhs, rhs) = match def_op {
290 Op::Add { lhs, rhs, .. }
291 | Op::Sub { lhs, rhs, .. }
292 | Op::Mul { lhs, rhs, .. }
293 | Op::BitXor { lhs, rhs, .. }
294 | Op::Div { lhs, rhs, .. }
295 | Op::ExactDiv { lhs, rhs, .. }
296 | Op::FloorDiv { lhs, rhs, .. }
297 | Op::Mod { lhs, rhs, .. }
298 | Op::Pow { lhs, rhs, .. }
299 | Op::BitAnd { lhs, rhs, .. }
300 | Op::BitOr { lhs, rhs, .. }
301 | Op::Shl { lhs, rhs, .. }
302 | Op::Shr { lhs, rhs, .. } => (lhs, rhs),
303 _ => continue,
304 };
305 // Stability WITHIN the frame: neither the register nor its inputs are re-written after the
306 // def, so the reverse-time recompute reproduces the exact forward value.
307 let modified =
308 |reg: Reg| ((def_pc + 1)..hi).any(|pc| writes(&prog.code[pc]).contains(®));
309 if modified(r) || modified(lhs) || modified(rhs) {
310 continue;
311 }
312 out.push((frame, r, def_pc, def_op));
313 }
314 }
315 out
316}
317
318/// A static summary of a mode's plan: how many Journaled entries survive vs. are elided.
319#[derive(Debug, Clone)]
320pub struct PebbleReport {
321 pub mode: ReverseMode,
322 pub ops: usize,
323 pub journaled: usize,
324 pub elided: usize,
325}
326
327/// Summarise the plan for `mode` — the count of Journaled writes kept vs. elided (the static
328/// space budget; the dynamic erased-byte floor comes from a recording run's tape).
329pub fn report(prog: &CompiledProgram, mode: ReverseMode) -> PebbleReport {
330 let plan = plan(prog, mode);
331 let (mut journaled, mut elided) = (0usize, 0usize);
332 for (pc, op) in prog.code.iter().enumerate() {
333 if reverse::records(op) {
334 match plan[pc] {
335 JournalDecision::Keep => journaled += 1,
336 JournalDecision::Elide | JournalDecision::Recompute => elided += 1,
337 }
338 }
339 }
340 PebbleReport {
341 mode,
342 ops: prog.code.len(),
343 journaled,
344 elided,
345 }
346}
347
348/// The function that owns `pc` — the one with the greatest `entry_pc ≤ pc` (Main is compiled first,
349/// then each function body appended contiguously; see `vm/compiler.rs` Pass 2a/2b). `None` = Main.
350pub fn frame_of(prog: &CompiledProgram, pc: usize) -> Option<usize> {
351 let mut best: Option<usize> = None;
352 for (i, f) in prog.functions.iter().enumerate() {
353 if f.entry_pc <= pc && best.map_or(true, |b| f.entry_pc > prog.functions[b].entry_pc) {
354 best = Some(i);
355 }
356 }
357 best
358}
359
360/// Each function's set of possible absolute BASE offsets — the register-window origins it can run at,
361/// propagated from Main (base 0) through the call graph (`callee_base = caller_base + args_start`).
362///
363/// RECURSION-SAFE (corner 4). The elision decision only ever asks `main_observable(base + r)`, which
364/// is `false` whenever `base + r ≥ main_size`. So a base `≥ main_size` is USELESS — it can never make
365/// any slot observable — and we drop it. Dropping useless bases truncates a recursion's
366/// arithmetic-progression bases (`b₀, b₀+A, b₀+2A, …`) to a FINITE set inside `[0, main_size)`, so the
367/// monotone-insertion fixpoint TERMINATES even when the call graph has a cycle — no bail on recursion.
368/// This loses nothing: every base that could make a slot observable satisfies `base < main_size` and is
369/// kept; the multi-hop overlap (a grandchild aliasing a grandparent-Main slot) is captured because the
370/// propagated `base` already sums every hop's `args_start`. Only dynamic dispatch (`CallValue` — the
371/// callee, hence its base, is not statically known) still returns `None` and disables elision. An
372/// uncalled (or only-beyond-Main) function keeps an empty set — sound, its scratch aliases no Main slot.
373fn frame_bases(prog: &CompiledProgram) -> Option<Vec<Vec<usize>>> {
374 if prog
375 .code
376 .iter()
377 .any(|op| matches!(op, Op::CallValue { .. }))
378 {
379 return None;
380 }
381 let n = prog.functions.len();
382 let main_size = prog.register_count;
383 // Static call sites: (caller frame, args_start, callee index).
384 let sites: Vec<(Option<usize>, usize, usize)> = prog
385 .code
386 .iter()
387 .enumerate()
388 .filter_map(|(pc, op)| match op {
389 Op::Call {
390 func, args_start, ..
391 } => Some((frame_of(prog, pc), *args_start as usize, *func as usize)),
392 _ => None,
393 })
394 .collect();
395 let mut bases: Vec<std::collections::BTreeSet<usize>> = vec![Default::default(); n];
396 // Monotone insertion into the finite domain `frames × [0, main_size)` converges in at most that
397 // many steps; the cap is a backstop that the bounded domain guarantees is never hit.
398 let cap = n * (main_size + 1) + n + 2;
399 for _ in 0..cap {
400 let mut changed = false;
401 for &(caller, args_start, callee) in &sites {
402 let caller_bases: Vec<usize> = match caller {
403 None => vec![0],
404 Some(c) => bases[c].iter().copied().collect(),
405 };
406 for cb in caller_bases {
407 let b = cb + args_start;
408 // Drop bases ≥ main_size (they can never alias a Main-observable slot) — this is what
409 // makes the recursion fixpoint finite.
410 if b < main_size && bases[callee].insert(b) {
411 changed = true;
412 }
413 }
414 }
415 if !changed {
416 return Some(bases.into_iter().map(|s| s.into_iter().collect()).collect());
417 }
418 }
419 None
420}
421
422#[cfg(test)]
423#[path = "../../../logicaffeine_tests/unit/logicaffeine_compile/vm/pebble/tests.rs"]
424mod tests;