Skip to main content

logicaffeine_compile/vm/
journal.rs

1//! The history tape — the Bennett/Landauer "garbage" of reverse execution.
2//!
3//! A forward run of the VM produces `(final state, tape)`; [`Vm::run_reverse`](crate::vm::Vm) walks
4//! the instruction stream backward and consumes the tape to reconstruct the EXACT initial state.
5//! Each information-losing op appends the minimal destroyed bits (an undo-log delta, NOT a full
6//! snapshot); a [`RevClass::Bijection`](crate::vm::reverse::RevClass) op appends NOTHING — it
7//! self-inverts algebraically. So the tape's size is the erased-bit (Landauer energy) cost, and it
8//! is ZERO for an all-abelian run — the zero-erasure ideal the adiabatic chip is built on.
9//! Bennett uncompute (R4) shrinks it further toward that floor.
10
11use crate::vm::value::Value;
12
13/// One recorded delta on the [`HistoryTape`]. Records only what a forward op DESTROYED, so reverse
14/// execution can restore it. Zero-cost/control entries (a shown line, a branch direction) carry no
15/// erased data — they are re-derivable — and so contribute nothing to the erased-bit measure.
16#[derive(Debug)]
17pub enum JournalEntry {
18    /// A register slot (`base + r`) was overwritten; restore `old` on reverse.
19    RegOverwrite { slot: usize, old: Value },
20    /// A promoted global was overwritten (or first-defined, `old == None`); restore it on reverse.
21    GlobalOverwrite { idx: usize, old: Option<Value> },
22    /// Several register slots were overwritten at once (a tuple destructure). Reverse restores each.
23    MultiRegOverwrite { slots: Vec<(usize, Value)> },
24    /// A `Show` appended one output line; reverse truncates the output buffer. The content is
25    /// re-derivable on a re-forward, so no data is erased.
26    ShowLine,
27    /// A NONDETERMINISTIC read (clock / random) drew `drawn` into a register. Reading does not mutate
28    /// the world, so reverse simply restores the register's prior value (`dst_old`). Critically,
29    /// `drawn` is RECORDED — the entropy is captured on the tape (a deterministic replay could feed it
30    /// back), rather than silently discarded, which is what makes reversing a nondeterministic program
31    /// honest rather than a lie.
32    NondetRead {
33        dst_slot: usize,
34        dst_old: Value,
35        drawn: Value,
36    },
37    /// A conditional branch was taken (`true`) or fell through (`false`); reverse follows it back.
38    /// One bit of control flow, not erased data.
39    Branch { taken: bool },
40    /// An in-place collection mutation (push / index-set / add / remove). `old` is the register's
41    /// pre-mutation value — an `Rc` bump for a shared List/Map/Set, or a deep copy for a by-value
42    /// container. Because that bump forces the op's copy-on-write, the original stays pristine, so
43    /// reverse restores `old` exactly (bit-identical, including aliasing). If instead the register
44    /// still holds the SAME `Rc` (an in-place mutation — only when value-semantics is off), reverse
45    /// declines rather than mis-restore.
46    CollectionMutate { slot: usize, old: Value },
47    /// A `ListPop`: it both shrinks a list AND overwrites a destination register. Reverse restores
48    /// the pristine pre-pop list (`list_old`, which — COW-forced — still holds the popped element)
49    /// and the destination's prior value (`dst_old`), so no push-back or empty-flag is needed.
50    ListPop {
51        list_slot: usize,
52        list_old: Value,
53        dst_slot: usize,
54        dst_old: Value,
55    },
56    /// An `IterNext`. `advanced` = it yielded an element (idx < len): reverse restores the
57    /// destination's prior value and rewinds the top iterator's index by one. `!advanced` = it was
58    /// exhausted (a pure jump to the loop exit): reverse does nothing.
59    IterNextUndo {
60        advanced: bool,
61        dst_slot: usize,
62        dst_old: Value,
63    },
64    /// An `IterPop` dropped the top iterator (a snapshot + cursor). Reverse re-pushes it so the loop
65    /// body's `IterNext`s can be rewound.
66    IterPopUndo { items: Vec<Value>, idx: usize },
67    /// A CRDT interior mutation (`CrdtAppend`/`CrdtResolve` on an `Rc<RefCell<CrdtValue>>`). Unlike a
68    /// by-value container, the mutation happens IN PLACE through the shared handle, so an `Rc`-bump
69    /// snapshot would share the mutated interior, not preserve it. Instead `snapshot` is a DEEP clone
70    /// of the pre-mutation `CrdtValue` (wrapped in a fresh handle); reverse copies it back THROUGH the
71    /// register's live handle (`*rc.borrow_mut() = …`), so every alias observes the restore.
72    CrdtInterior { slot: usize, snapshot: Value },
73    /// A `CrdtResolve` onto a nested CRDT-register FIELD (`registers[obj_slot]`'s `field`) — the
74    /// field's `Rc<RefCell<CrdtValue>>` interior is resolved in place. Like
75    /// [`JournalEntry::CrdtInterior`] but the handle lives inside a struct field, so `snapshot` (a
76    /// deep pre-resolve clone) is copied back through that field's live handle. (A resolve onto a
77    /// plain field is a `CollectionMutate` instead.)
78    CrdtFieldInterior {
79        obj_slot: usize,
80        field: String,
81        snapshot: Value,
82    },
83    /// An IN-PLACE integer division `a = a / b` — the quotient is kept in `dst_slot`, the REMAINDER is
84    /// the only bit truly discarded. Reverse reconstructs `a_old = q·b + r` from the kept quotient and
85    /// the divisor, so the Landauer cost is the remainder's few bits (`< log₂ b`), not the whole
86    /// dividend word. The divisor is a recomputable input (stored here for a self-contained reverse,
87    /// but it is not erased information, so it costs nothing).
88    DivReconstruct {
89        dst_slot: usize,
90        divisor: Value,
91        remainder: Value,
92    },
93    /// An IN-PLACE bit shift (`a = a << k` / `a = a >> k`; `Shl`/`Shr`/`DivPow2` with `dst == lhs`).
94    /// Only the shifted-OUT bits are destroyed — the rest of `a_old` survives in the kept result — so
95    /// the tape records just those `k` bits (`dropped`: the low `k` bits for a right shift, the top `k`
96    /// bits right-aligned for a left shift). Reverse recombines them with the kept result, so the
97    /// Landauer cost is `k` bits, not the whole 64-bit word.
98    ShiftReconstruct {
99        dst_slot: usize,
100        dropped: i64,
101        k: u32,
102        left: bool,
103    },
104    /// An IN-PLACE bitwise mask (`a = a & b` / `a = a | b`; `BitAnd`/`BitOr` with `dst == lhs`). The
105    /// bits the live mask `b` cannot recover are `saved`: for AND the bits where `b = 0`, for OR the
106    /// bits where `b = 1`. Reverse re-merges them under the complementary mask. The Landauer cost is
107    /// exactly the count of unrecoverable positions (`64 − popcount b` for AND, `popcount b` for OR).
108    MaskReconstruct {
109        dst_slot: usize,
110        saved: i64,
111        mask: i64,
112        and: bool,
113    },
114    /// An IN-PLACE integer modulo (`a = a % b`; `Mod` with `dst == lhs`). The remainder is kept in the
115    /// destination; the QUOTIENT is the discarded information. Reverse rebuilds `a_old = q·b + r` from
116    /// the saved quotient and the live divisor. The Landauer cost is the quotient's magnitude bits.
117    ModReconstruct {
118        dst_slot: usize,
119        quotient: i64,
120        divisor: Value,
121    },
122    /// A `Call` grew the register file (a callee window). Reverse truncates it back to `restore_len`
123    /// after the callee's body has been reversed.
124    CallUndo { restore_len: usize },
125    /// A `CallValue` (closure invocation): like `Call` it grew the register file, and it also bound
126    /// captures into the callee frame. `captures` are the pre-binding `(slot, old_value)` pairs (an
127    /// overlap capture slot survives the truncate, so its prior caller value must be restored).
128    /// Reverse restores the capture slots, then truncates back to `restore_len`.
129    CallValueUndo {
130        restore_len: usize,
131        captures: Vec<(usize, Value)>,
132    },
133    /// A `Return`/`ReturnNothing` tore down the callee frame. Reverse rebuilds it: regrow the
134    /// registers to `frame_from + frame_vals.len()` restoring the callee's saved window, restore the
135    /// iterator stack, and undo the return-value write (`rv_slot`←`rv_old`) and the argument-window
136    /// clear (`arg_lo`..←`arg_old`) — leaving the machine exactly in the callee's pre-return state so
137    /// the callee body can be walked backward.
138    ReturnUndo {
139        frame_from: usize,
140        frame_vals: Vec<Value>,
141        iter_from: usize,
142        iter_vals: Vec<(Vec<Value>, usize)>,
143        rv_slot: usize,
144        rv_old: Value,
145        arg_lo: usize,
146        arg_old: Vec<Value>,
147    },
148}
149
150impl JournalEntry {
151    /// The erased-DATA cost of this entry in bytes — the Landauer energy proxy. Control/derivable
152    /// entries cost 0; a value-overwrite costs the machine word it displaced.
153    fn erased_bytes(&self) -> usize {
154        use std::mem::size_of;
155        match self {
156            JournalEntry::RegOverwrite { .. }
157            | JournalEntry::CollectionMutate { .. }
158            | JournalEntry::CrdtInterior { .. }
159            | JournalEntry::CrdtFieldInterior { .. } => size_of::<Value>(),
160            // Divisor + remainder are held for a self-contained reverse (peak-tape cost).
161            JournalEntry::DivReconstruct { .. } => 2 * size_of::<Value>(),
162            // A shift/mask entry holds one saved machine word; a modulo entry holds quotient + divisor.
163            JournalEntry::ShiftReconstruct { .. } | JournalEntry::MaskReconstruct { .. } => {
164                size_of::<Value>()
165            }
166            JournalEntry::ModReconstruct { .. } => 2 * size_of::<Value>(),
167            JournalEntry::ListPop { .. } => 2 * size_of::<Value>(),
168            JournalEntry::IterNextUndo { advanced, .. } => {
169                usize::from(*advanced) * size_of::<Value>()
170            }
171            JournalEntry::IterPopUndo { items, .. } => items.len() * size_of::<Value>(),
172            JournalEntry::CallUndo { .. } => 0,
173            JournalEntry::CallValueUndo { captures, .. } => captures.len() * size_of::<Value>(),
174            JournalEntry::ReturnUndo {
175                frame_vals,
176                arg_old,
177                ..
178            } => (frame_vals.len() + arg_old.len() + 1) * size_of::<Value>(),
179            JournalEntry::GlobalOverwrite { old, .. } => {
180                old.as_ref().map_or(0, |_| size_of::<Value>())
181            }
182            JournalEntry::MultiRegOverwrite { slots } => slots.len() * size_of::<Value>(),
183            // Only the overwritten register is erased data; `drawn` is captured INPUT entropy.
184            JournalEntry::NondetRead { .. } => size_of::<Value>(),
185            JournalEntry::ShowLine | JournalEntry::Branch { .. } => 0,
186        }
187    }
188
189    /// The GENUINELY-irreversible information this entry records — the true Landauer cost. Refines
190    /// [`JournalEntry::erased_bytes`]: a write that overwrote the DEFAULT (`Nothing`) destroyed no
191    /// information (the pristine initial state is known, so reverse re-derives it — the same reason
192    /// `GlobalOverwrite { old: None }` is free), and so costs 0. What remains is the information the
193    /// program truly erased — an overwrite of a live value, a lattice merge, a captured entropy read.
194    fn landauer_bytes(&self) -> usize {
195        use std::mem::size_of;
196        let w = size_of::<Value>();
197        let cost = |v: &Value| if v.is_nothing() { 0 } else { w };
198        match self {
199            JournalEntry::RegOverwrite { old, .. } | JournalEntry::CollectionMutate { old, .. } => {
200                cost(old)
201            }
202            // A CRDT interior snapshot records a join-semilattice state the merge/resolve destroyed —
203            // genuinely irreducible information.
204            JournalEntry::CrdtInterior { .. } | JournalEntry::CrdtFieldInterior { .. } => w,
205            // Only the remainder is erased (the divisor is a recomputable input, kept quotient aside).
206            // A remainder is `< |b|`, so it fits in `⌈log₂|b|⌉` bits — the true Landauer cost.
207            JournalEntry::DivReconstruct { divisor, .. } => {
208                let d = divisor.as_int().unwrap_or(0).unsigned_abs();
209                let bits = if d <= 1 {
210                    0
211                } else {
212                    (u64::BITS - (d - 1).leading_zeros()) as usize
213                };
214                bits.div_ceil(8)
215            }
216            // A shift destroys exactly the `k` shifted-out bits — the reconstruction recovers the rest
217            // from the kept result, so that is the whole erased-information cost.
218            JournalEntry::ShiftReconstruct { k, .. } => (*k as usize).div_ceil(8),
219            // A mask destroys the positions the live mask can't recover — `64 − popcount` for AND (the
220            // cleared bits), `popcount` for OR (the forced bits).
221            JournalEntry::MaskReconstruct { mask, and, .. } => {
222                let m = *mask as u64;
223                let bits = if *and {
224                    (u64::BITS - m.count_ones()) as usize
225                } else {
226                    m.count_ones() as usize
227                };
228                bits.div_ceil(8)
229            }
230            // Only the quotient is erased (the divisor is a live recomputable input; the remainder is
231            // kept in the destination). It fits in `⌈log₂|q|⌉` bits — the true Landauer cost.
232            JournalEntry::ModReconstruct { quotient, .. } => {
233                let q = quotient.unsigned_abs();
234                let bits = if q == 0 {
235                    0
236                } else {
237                    (u64::BITS - q.leading_zeros()) as usize
238                };
239                bits.div_ceil(8)
240            }
241            JournalEntry::ListPop {
242                list_old, dst_old, ..
243            } => cost(list_old) + cost(dst_old),
244            JournalEntry::IterNextUndo {
245                advanced, dst_old, ..
246            } => {
247                if *advanced {
248                    cost(dst_old)
249                } else {
250                    0
251                }
252            }
253            JournalEntry::IterPopUndo { items, .. } => items.iter().map(cost).sum(),
254            JournalEntry::CallUndo { .. } => 0,
255            JournalEntry::CallValueUndo { captures, .. } => {
256                captures.iter().map(|(_, v)| cost(v)).sum()
257            }
258            JournalEntry::ReturnUndo {
259                frame_vals,
260                arg_old,
261                rv_old,
262                ..
263            } => {
264                frame_vals.iter().map(cost).sum::<usize>()
265                    + arg_old.iter().map(cost).sum::<usize>()
266                    + cost(rv_old)
267            }
268            JournalEntry::GlobalOverwrite { old, .. } => old.as_ref().map_or(0, cost),
269            JournalEntry::MultiRegOverwrite { slots } => slots.iter().map(|(_, v)| cost(v)).sum(),
270            // The overwritten dst is the erased part; `drawn` is captured INPUT entropy, not erased.
271            JournalEntry::NondetRead { dst_old, .. } => cost(dst_old),
272            JournalEntry::ShowLine | JournalEntry::Branch { .. } => 0,
273        }
274    }
275}
276
277/// The append-forward / pop-backward garbage tape of a recording forward run. TWO stacks:
278///
279///   • `entries` — the erased-DATA deltas (register/global overwrites, output lines). These are the
280///     Landauer garbage; [`HistoryTape::erased_bytes`]/[`HistoryTape::len`] count them, and they are
281///     EMPTY for an all-abelian run.
282///   • `steps` — the executed program-counter trace (one entry per executed op), the control
283///     skeleton. Reverse execution walks it backward to replay the exact executed path — branches
284///     and loops fall out for free (a loop is just the trace with repeated pcs). This is control
285///     overhead (re-derivable by re-running forward, so zero erased DATA and Bennett-uncomputable),
286///     NOT part of the erased-bit count.
287#[derive(Debug, Default)]
288pub struct HistoryTape {
289    entries: Vec<JournalEntry>,
290    /// The executed `(pc, base)` trace. `base` is the register-window base the op ran in — carried
291    /// so reverse execution restores the correct frame when walking back through a function call.
292    steps: Vec<(usize, usize)>,
293}
294
295impl HistoryTape {
296    /// A fresh, empty tape.
297    pub fn new() -> Self {
298        HistoryTape {
299            entries: Vec::new(),
300            steps: Vec::new(),
301        }
302    }
303
304    /// Append an erased-data delta (forward execution).
305    pub fn push(&mut self, entry: JournalEntry) {
306        self.entries.push(entry);
307    }
308
309    /// Pop the most recent erased-data delta (reverse execution).
310    pub fn pop(&mut self) -> Option<JournalEntry> {
311        self.entries.pop()
312    }
313
314    /// Record that the op at `pc` (running in window `base`) executed (forward) — the control trace.
315    pub fn push_step(&mut self, pc: usize, base: usize) {
316        self.steps.push((pc, base));
317    }
318
319    /// Pop the most recently executed `(pc, base)` (reverse execution walks the path backward).
320    pub fn pop_step(&mut self) -> Option<(usize, usize)> {
321        self.steps.pop()
322    }
323
324    /// The number of recorded erased-data deltas.
325    pub fn len(&self) -> usize {
326        self.entries.len()
327    }
328
329    /// Whether the DATA tape is empty. `true` after an all-abelian (bijection-only) run — the
330    /// zero-erasure signature (a program may still have a non-empty control-flow `steps` trace).
331    pub fn is_empty(&self) -> bool {
332        self.entries.is_empty()
333    }
334
335    /// The number of executed ops on the control-flow trace.
336    pub fn step_count(&self) -> usize {
337        self.steps.len()
338    }
339
340    /// The number of recorded nondeterministic reads (clock / random) — the captured entropy count.
341    pub fn nondet_count(&self) -> usize {
342        self.entries
343            .iter()
344            .filter(|e| matches!(e, JournalEntry::NondetRead { .. }))
345            .count()
346    }
347
348    /// Total erased DATA on the tape, in bytes — the Landauer energy proxy. Exactly `0` for an
349    /// all-abelian run (every op self-inverts, nothing is journaled), regardless of how many times a
350    /// reversible loop body ran.
351    pub fn erased_bytes(&self) -> usize {
352        self.entries.iter().map(JournalEntry::erased_bytes).sum()
353    }
354
355    /// The GENUINELY-irreversible information on the tape — the true Landauer (energy) number. Below
356    /// [`HistoryTape::erased_bytes`] because a write that overwrote the DEFAULT (`Nothing`) destroyed
357    /// no information: pebbling elides the recomputable scratch, and this excludes the zero-information
358    /// first-writes, so a pure computation into an output measures **0**.
359    pub fn landauer_bytes(&self) -> usize {
360        self.entries.iter().map(JournalEntry::landauer_bytes).sum()
361    }
362}
363
364#[cfg(test)]
365#[path = "../../../logicaffeine_tests/unit/logicaffeine_compile/vm/journal/tests.rs"]
366mod tests;