Ad-hoc verification (factorial via recursive CALL_LINK) surfaced a real defect: each exec_cell had its own private stack, but the design (and the doc contract) is that callees run on the SHARED data stack so a caller passes args by leaving them on the stack and reads the callee result there after RET. With per-frame stacks, a callee that popped its argument faulted with PcOutOfRange. - exec_cell now takes &mut Vec<u8> (the one shared stack) instead of owning a fresh one; run() owns it and threads it through recursion. - Added regression test shared_stack_passes_args_across_cube_edges (caller leaves 21, callee doubles it via Store/Load, caller sees 42) so the convention is locked by ./check, not just ad-hoc. The prior commit's unit tests didn't exercise cross-frame stack args, which is why the bug slipped through; suite is now 50 tests green (incl. 14 cubecode).
526 lines
18 KiB
Rust
526 lines
18 KiB
Rust
//! The cube VM: a safe, deterministic, stack-based interpreter whose code
|
|
//! lives as CUBELinux records and whose call graph is the cube's association
|
|
//! graph (PDF Package 4).
|
|
//!
|
|
//! Why a bytecode VM and not a JIT/translator: the PDF's use cases are a
|
|
//! "scripting language or DSL whose runtime walks cube links to load and
|
|
//! dispatch functions" and an "AI experimentation stack that stores every
|
|
//! model version, metric, and run as linked CZYX records." Both are about
|
|
//! *addressable, introspectable code as data* — exactly what a record-addressed
|
|
//! VM gives you. Executing raw foreign machine code in user space on this
|
|
//! hardware is neither safe nor necessary to demonstrate the design; the
|
|
//! body is always decoded through [`crate::opcode`] first, so the cube never
|
|
//! executes something that didn't survive the codec.
|
|
//!
|
|
//! The interpreter is deterministic and free of `unsafe` and floating point,
|
|
//! so two runs of the same program against the same store always yield the
|
|
//! same result — a prerequisite for the PDF's "navigate and reconstruct
|
|
//! experiments" goal.
|
|
|
|
use crate::opcode::{decode, Op};
|
|
use cubecoords::{CubeHeader, Czyx};
|
|
use cubestore::{CubeBackend, CubeStore};
|
|
|
|
/// Number of local variable slots per VM frame. A free const (not tied to
|
|
/// the generic `B`) so the per-frame array can be sized in a const context.
|
|
/// Kept equal to [`Vm::MAX_LOCALS`].
|
|
const LOCALS: usize = 16;
|
|
///
|
|
/// Decision: a small, explicit set. `0` is reserved as a no-op probe so
|
|
/// host linkage is testable without side effects; the rest exercise the
|
|
/// store (load/store adjacent records) and the data stack, which is what a
|
|
/// real kernel layer would do. Kept `pub` so a host embedding the VM can
|
|
/// match on the same ids.
|
|
pub const SYS_NOP: u8 = 0;
|
|
/// Push the number of `linked_records` (call-graph degree) of the current
|
|
/// cell onto the data stack. Lets a program introspect its own graph.
|
|
pub const SYS_DEGREE: u8 = 1;
|
|
/// Push `1` if record `i` in `linked_records` exists in the store, else `0`.
|
|
pub const SYS_LINKED_EXISTS: u8 = 2;
|
|
/// Print a newline-terminated diagnostic built from the top `n` stack bytes
|
|
/// (popped) to a side channel. Maps to a kernel-style `write` without
|
|
/// touching the host process's stdio directly (collected in `Vm::output`).
|
|
pub const SYS_TRACE: u8 = 3;
|
|
|
|
/// Result of running the VM to completion (or a fault).
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
pub enum RunResult {
|
|
/// Program halted normally.
|
|
Halted {
|
|
/// The value left on the data stack (the returned result), or `None`
|
|
/// if the stack is empty.
|
|
top: Option<u8>,
|
|
},
|
|
/// A fault occurred.
|
|
Fault {
|
|
/// The cell where the fault happened.
|
|
cell: Czyx,
|
|
/// The instruction index within that cell's decoded code.
|
|
pc: usize,
|
|
/// Why execution stopped.
|
|
reason: Fault,
|
|
},
|
|
}
|
|
|
|
/// Why execution stopped.
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
pub enum Fault {
|
|
/// `CALL_LINK n` referenced a link index past `linked_records.len()`.
|
|
BadLink(u8),
|
|
/// The linked record does not exist in the store.
|
|
MissingCallee(Czyx),
|
|
/// The callee's body was not valid bytecode.
|
|
BadCalleeBytecode(Czyx),
|
|
/// The call stack exceeded [`Vm::MAX_CALL_DEPTH`].
|
|
CallStackOverflow,
|
|
/// Division or modulo by zero.
|
|
DivByZero,
|
|
/// `pc` ran past the end of the instruction stream without `RET`/`HALT`.
|
|
PcOutOfRange,
|
|
/// A `LOAD`/`STORE` used a local slot >= [`Vm::MAX_LOCALS`].
|
|
BadLocal(u8),
|
|
/// `SYSCALL id` had an unknown id.
|
|
UnknownSyscall(u8),
|
|
}
|
|
|
|
/// The cube VM.
|
|
pub struct Vm<B: CubeBackend> {
|
|
store: CubeStore<B>,
|
|
/// Diagnostic output accumulated from `SYSCALL 3`.
|
|
output: Vec<u8>,
|
|
/// Maximum nesting of `CALL_LINK` frames.
|
|
max_call_depth: usize,
|
|
}
|
|
|
|
impl<B: CubeBackend> Vm<B> {
|
|
/// Hard ceiling on call nesting (defends against infinite cube loops).
|
|
pub const MAX_CALL_DEPTH: usize = 64;
|
|
/// Number of local slots per frame.
|
|
pub const MAX_LOCALS: usize = 16;
|
|
|
|
/// Wrap a store.
|
|
pub fn new(store: CubeStore<B>) -> Self {
|
|
Vm {
|
|
store,
|
|
output: Vec::new(),
|
|
max_call_depth: Self::MAX_CALL_DEPTH,
|
|
}
|
|
}
|
|
|
|
/// Diagnostic bytes emitted by `SYSCALL 3` during the last run.
|
|
pub fn output(&self) -> &[u8] {
|
|
&self.output
|
|
}
|
|
|
|
/// Set the call-depth ceiling (default [`Vm::MAX_CALL_DEPTH`]).
|
|
pub fn set_max_call_depth(&mut self, d: usize) {
|
|
self.max_call_depth = d;
|
|
}
|
|
|
|
/// Run the entry cell to completion, returning its result or a fault.
|
|
///
|
|
/// `entry` must exist in the store and hold valid bytecode. Calls follow
|
|
/// `linked_records` edges; recursion depth is bounded. All frames share a
|
|
/// single data stack, so a caller passes arguments by leaving them on the
|
|
/// stack and reads a callee's result (which the callee leaves there) after
|
|
/// `RET` returns.
|
|
pub fn run(&mut self, entry: Czyx) -> RunResult {
|
|
self.output.clear();
|
|
let mut stack: Vec<u8> = Vec::new();
|
|
match self.exec_cell(entry, 0, &mut stack) {
|
|
Ok(v) => RunResult::Halted { top: v },
|
|
Err((cell, pc, fault)) => RunResult::Fault {
|
|
cell,
|
|
pc,
|
|
reason: fault,
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Execute one cell. `depth` is the current call nesting. `stack` is the
|
|
/// single shared data stack (callees run on it), so arguments and return
|
|
/// values flow across cube edges via the stack.
|
|
fn exec_cell(
|
|
&mut self,
|
|
label: Czyx,
|
|
depth: usize,
|
|
stack: &mut Vec<u8>,
|
|
) -> Result<Option<u8>, (Czyx, usize, Fault)> {
|
|
// Fetch and decode the callee record.
|
|
let (header, body) =
|
|
self.store
|
|
.get_record(&label)
|
|
.ok_or((label, 0, Fault::MissingCallee(label)))?;
|
|
let code = decode(&body).map_err(|_| (label, 0, Fault::BadCalleeBytecode(label)))?;
|
|
|
|
let mut locals: [u8; LOCALS] = [0; LOCALS];
|
|
let mut pc: usize = 0;
|
|
|
|
while pc < code.len() {
|
|
let op = code[pc];
|
|
match op {
|
|
Op::Nop => {}
|
|
Op::Halt => {
|
|
return Ok(stack.pop());
|
|
}
|
|
Op::Const(v) => stack.push(v),
|
|
Op::Load(i) => {
|
|
if (i as usize) >= Self::MAX_LOCALS {
|
|
return Err((label, pc, Fault::BadLocal(i)));
|
|
}
|
|
stack.push(locals[i as usize]);
|
|
}
|
|
Op::Store(i) => {
|
|
if (i as usize) >= Self::MAX_LOCALS {
|
|
return Err((label, pc, Fault::BadLocal(i)));
|
|
}
|
|
locals[i as usize] = stack.pop().ok_or((label, pc, Fault::PcOutOfRange))?;
|
|
}
|
|
Op::Add | Op::Sub | Op::Mul | Op::Div | Op::Mod | Op::And | Op::Or | Op::Xor => {
|
|
let (b, a) = (stack.pop(), stack.pop());
|
|
let (b, a) = (
|
|
b.ok_or((label, pc, Fault::PcOutOfRange))?,
|
|
b2(a, label, pc)?,
|
|
);
|
|
stack.push(bin_op(op, a, b).map_err(|f| (label, pc, f))?);
|
|
}
|
|
Op::Shl | Op::Shr => {
|
|
let (b, a) = (stack.pop(), stack.pop());
|
|
let (b, a) = (
|
|
b.ok_or((label, pc, Fault::PcOutOfRange))?,
|
|
b2(a, label, pc)?,
|
|
);
|
|
stack.push(shift_op(op, a, b));
|
|
}
|
|
Op::Eq | Op::Ne | Op::Lt | Op::Gt | Op::Le | Op::Ge => {
|
|
let (b, a) = (stack.pop(), stack.pop());
|
|
let (b, a) = (
|
|
b.ok_or((label, pc, Fault::PcOutOfRange))?,
|
|
b2(a, label, pc)?,
|
|
);
|
|
stack.push(cmp_op(op, a, b));
|
|
}
|
|
Op::Jmp(t) => {
|
|
pc = t as usize;
|
|
continue;
|
|
}
|
|
Op::Jz(t) => {
|
|
let v = stack.pop().ok_or((label, pc, Fault::PcOutOfRange))?;
|
|
if v == 0 {
|
|
pc = t as usize;
|
|
continue;
|
|
}
|
|
}
|
|
Op::Jnz(t) => {
|
|
let v = stack.pop().ok_or((label, pc, Fault::PcOutOfRange))?;
|
|
if v != 0 {
|
|
pc = t as usize;
|
|
continue;
|
|
}
|
|
}
|
|
Op::CallLink(n) => {
|
|
if (n as usize) >= header.linked_records.len() {
|
|
return Err((label, pc, Fault::BadLink(n)));
|
|
}
|
|
if depth + 1 > self.max_call_depth {
|
|
return Err((label, pc, Fault::CallStackOverflow));
|
|
}
|
|
let callee = header.linked_records[n as usize];
|
|
let r = self.exec_cell(callee, depth + 1, stack)?;
|
|
// Callee leaves its result on the shared stack; propagate
|
|
// it up so the caller can read it after RET returns.
|
|
if let Some(v) = r {
|
|
stack.push(v);
|
|
}
|
|
}
|
|
Op::Ret => {
|
|
return Ok(stack.pop());
|
|
}
|
|
Op::Syscall(id) => {
|
|
let v = self.syscall(id, &header, stack);
|
|
if let Err(f) = v {
|
|
return Err((label, pc, f));
|
|
}
|
|
}
|
|
Op::Dup => {
|
|
let v = *stack.last().ok_or((label, pc, Fault::PcOutOfRange))?;
|
|
stack.push(v);
|
|
}
|
|
Op::Drop => {
|
|
stack.pop().ok_or((label, pc, Fault::PcOutOfRange))?;
|
|
}
|
|
}
|
|
pc += 1;
|
|
}
|
|
// Ran off the end without HALT/RET: treat as a clean return of the
|
|
// top value if any (lenient), but flag runaway programs via the
|
|
// caller's depth limit instead.
|
|
Ok(stack.pop())
|
|
}
|
|
|
|
/// Host syscall dispatch. `stack` is the *shared* data stack (callees
|
|
/// run on it) and `header` is the current cell's metadata.
|
|
fn syscall(&mut self, id: u8, header: &CubeHeader, stack: &mut Vec<u8>) -> Result<(), Fault> {
|
|
match id {
|
|
SYS_NOP => {}
|
|
SYS_DEGREE => {
|
|
stack.push(header.linked_records.len() as u8);
|
|
}
|
|
SYS_LINKED_EXISTS => {
|
|
let i = stack.pop().ok_or(Fault::PcOutOfRange)?;
|
|
let exists = header
|
|
.linked_records
|
|
.get(i as usize)
|
|
.map(|c| self.store.get_record(c).is_some())
|
|
.unwrap_or(false);
|
|
stack.push(if exists { 1 } else { 0 });
|
|
}
|
|
SYS_TRACE => {
|
|
// Pop every byte currently on the stack and emit it as a
|
|
// newline-terminated diagnostic line (low byte of each value).
|
|
let mut line: Vec<u8> = std::mem::take(stack);
|
|
line.push(b'\n');
|
|
self.output.extend_from_slice(&line);
|
|
}
|
|
other => return Err(Fault::UnknownSyscall(other)),
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn b2(v: Option<u8>, cell: Czyx, pc: usize) -> Result<u8, (Czyx, usize, Fault)> {
|
|
v.ok_or((cell, pc, Fault::PcOutOfRange))
|
|
}
|
|
|
|
fn bin_op(op: Op, a: u8, b: u8) -> Result<u8, Fault> {
|
|
Ok(match op {
|
|
Op::Add => a.wrapping_add(b),
|
|
Op::Sub => a.wrapping_sub(b),
|
|
Op::Mul => a.wrapping_mul(b),
|
|
Op::Div => {
|
|
if b == 0 {
|
|
return Err(Fault::DivByZero);
|
|
}
|
|
a / b
|
|
}
|
|
Op::Mod => {
|
|
if b == 0 {
|
|
return Err(Fault::DivByZero);
|
|
}
|
|
a % b
|
|
}
|
|
Op::And => a & b,
|
|
Op::Or => a | b,
|
|
Op::Xor => a ^ b,
|
|
_ => unreachable!("bin_op called on non-binary op"),
|
|
})
|
|
}
|
|
|
|
fn shift_op(op: Op, a: u8, b: u8) -> u8 {
|
|
let s = (b & 7) as u32;
|
|
match op {
|
|
Op::Shl => a.wrapping_shl(s),
|
|
_ => a.wrapping_shr(s),
|
|
}
|
|
}
|
|
|
|
fn cmp_op(op: Op, a: u8, b: u8) -> u8 {
|
|
let r = match op {
|
|
Op::Eq => a == b,
|
|
Op::Ne => a != b,
|
|
Op::Lt => a < b,
|
|
Op::Gt => a > b,
|
|
Op::Le => a <= b,
|
|
_ => a >= b,
|
|
};
|
|
r as u8
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use cubecoords::Czyx;
|
|
use cubestore::HashBackend;
|
|
|
|
fn cell_code(
|
|
_label: Czyx,
|
|
links: &[Czyx],
|
|
ops: &[Op],
|
|
doc_type: &str,
|
|
) -> (CubeHeader, Vec<u8>) {
|
|
let mut h = CubeHeader::new();
|
|
h.title = Some("t".into());
|
|
h.doc_type = Some(doc_type.into());
|
|
h.linked_records = links.to_vec();
|
|
h.refresh_flags();
|
|
(h, crate::opcode::encode(ops))
|
|
}
|
|
|
|
#[test]
|
|
fn arithmetic_chain() {
|
|
let mut store = CubeStore::new(HashBackend::new());
|
|
let entry = Czyx::new(1, 1, 1, 1);
|
|
let (h, b) = cell_code(
|
|
entry,
|
|
&[],
|
|
&[
|
|
Op::Const(3),
|
|
Op::Const(4),
|
|
Op::Add, // 7
|
|
Op::Const(2),
|
|
Op::Mul, // 14
|
|
Op::Halt,
|
|
],
|
|
"fn",
|
|
);
|
|
store.put_record(entry, &h, &b);
|
|
let mut vm = Vm::new(store);
|
|
assert_eq!(vm.run(entry), RunResult::Halted { top: Some(14) });
|
|
}
|
|
|
|
#[test]
|
|
fn div_by_zero_faults() {
|
|
let mut store = CubeStore::new(HashBackend::new());
|
|
let entry = Czyx::new(1, 1, 1, 1);
|
|
let (h, b) = cell_code(
|
|
entry,
|
|
&[],
|
|
&[Op::Const(5), Op::Const(0), Op::Div, Op::Halt],
|
|
"fn",
|
|
);
|
|
store.put_record(entry, &h, &b);
|
|
let mut vm = Vm::new(store);
|
|
assert_eq!(
|
|
vm.run(entry),
|
|
RunResult::Fault {
|
|
cell: entry,
|
|
pc: 2,
|
|
reason: Fault::DivByZero
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn call_link_follows_cube_edge() {
|
|
let mut store = CubeStore::new(HashBackend::new());
|
|
let entry = Czyx::new(1, 1, 1, 1);
|
|
let leaf = Czyx::new(2, 0, 0, 1);
|
|
// leaf: push 9, return it
|
|
let (lh, lb) = cell_code(leaf, &[], &[Op::Const(9), Op::Ret], "fn");
|
|
store.put_record(leaf, &lh, &lb);
|
|
// entry: call link 0 (leaf), then add 1, halt -> 10
|
|
let (h, b) = cell_code(
|
|
entry,
|
|
&[leaf],
|
|
&[Op::CallLink(0), Op::Const(1), Op::Add, Op::Halt],
|
|
"fn",
|
|
);
|
|
store.put_record(entry, &h, &b);
|
|
let mut vm = Vm::new(store);
|
|
assert_eq!(vm.run(entry), RunResult::Halted { top: Some(10) });
|
|
}
|
|
|
|
#[test]
|
|
fn shared_stack_passes_args_across_cube_edges() {
|
|
// Locks in the calling convention: the caller leaves the argument on
|
|
// the SHARED data stack; the callee pops it via Store into a local,
|
|
// computes, and returns a value the caller then uses. Without a shared
|
|
// stack this would fault (callee sees an empty frame stack).
|
|
let mut store = CubeStore::new(HashBackend::new());
|
|
let entry = Czyx::new(1, 1, 1, 1);
|
|
let double = Czyx::new(2, 0, 0, 1);
|
|
// double: Store 0 (pop arg), Load 0, Const 2, Mul, Ret -> arg*2
|
|
let (dh, db) = cell_code(
|
|
double,
|
|
&[],
|
|
&[Op::Store(0), Op::Load(0), Op::Const(2), Op::Mul, Op::Ret],
|
|
"fn",
|
|
);
|
|
store.put_record(double, &dh, &db);
|
|
// entry: Const 21, CallLink 0 (double), Halt -> 42
|
|
let (h, b) = cell_code(
|
|
entry,
|
|
&[double],
|
|
&[Op::Const(21), Op::CallLink(0), Op::Halt],
|
|
"fn",
|
|
);
|
|
store.put_record(entry, &h, &b);
|
|
let mut vm = Vm::new(store);
|
|
assert_eq!(vm.run(entry), RunResult::Halted { top: Some(42) });
|
|
}
|
|
|
|
#[test]
|
|
fn bad_link_index_faults() {
|
|
let mut store = CubeStore::new(HashBackend::new());
|
|
let entry = Czyx::new(1, 1, 1, 1);
|
|
let (h, b) = cell_code(entry, &[], &[Op::CallLink(3), Op::Halt], "fn");
|
|
store.put_record(entry, &h, &b);
|
|
let mut vm = Vm::new(store);
|
|
assert_eq!(
|
|
vm.run(entry),
|
|
RunResult::Fault {
|
|
cell: entry,
|
|
pc: 0,
|
|
reason: Fault::BadLink(3)
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn recursion_depth_bounded() {
|
|
let mut store = CubeStore::new(HashBackend::new());
|
|
let a = Czyx::new(1, 0, 0, 1);
|
|
let b = Czyx::new(2, 0, 0, 1);
|
|
// a -> b -> a (mutual recursion via link 0 on each side)
|
|
let (ha, ba) = cell_code(a, &[b], &[Op::CallLink(0), Op::Halt], "fn");
|
|
let (hb, bb) = cell_code(b, &[a], &[Op::CallLink(0), Op::Halt], "fn");
|
|
store.put_record(a, &ha, &ba);
|
|
store.put_record(b, &hb, &bb);
|
|
let mut vm = Vm::new(store);
|
|
vm.set_max_call_depth(8);
|
|
match vm.run(a) {
|
|
RunResult::Fault {
|
|
reason: Fault::CallStackOverflow,
|
|
..
|
|
} => {}
|
|
other => panic!("expected overflow, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn syscall_trace_emits_output() {
|
|
let mut store = CubeStore::new(HashBackend::new());
|
|
let entry = Czyx::new(1, 1, 1, 1);
|
|
let (h, b) = cell_code(
|
|
entry,
|
|
&[],
|
|
&[
|
|
Op::Const(b'H'),
|
|
Op::Const(b'i'),
|
|
Op::Syscall(SYS_TRACE),
|
|
Op::Halt,
|
|
],
|
|
"fn",
|
|
);
|
|
store.put_record(entry, &h, &b);
|
|
let mut vm = Vm::new(store);
|
|
assert_eq!(vm.run(entry), RunResult::Halted { top: None });
|
|
assert_eq!(vm.output(), b"Hi\n");
|
|
}
|
|
|
|
#[test]
|
|
fn syscall_degree_introspects_links() {
|
|
let mut store = CubeStore::new(HashBackend::new());
|
|
let entry = Czyx::new(1, 1, 1, 1);
|
|
let links = [
|
|
Czyx::new(9, 0, 0, 1),
|
|
Czyx::new(9, 0, 0, 2),
|
|
Czyx::new(9, 0, 0, 3),
|
|
];
|
|
let (h, b) = cell_code(entry, &links, &[Op::Syscall(SYS_DEGREE), Op::Halt], "fn");
|
|
store.put_record(entry, &h, &b);
|
|
let mut vm = Vm::new(store);
|
|
assert_eq!(vm.run(entry), RunResult::Halted { top: Some(3) });
|
|
}
|
|
}
|