logicaffeine_compile/vm/binisa.rs
1//! The **binary Silicon ISA** — the register bytecode as a stable, self-contained,
2//! chip-decodable byte stream. The text sibling is [`crate::vm::isa`] (`emit_isa`, the
3//! human-readable assembly listing); this is the machine form of the SAME opcode set.
4//!
5//! Two roles, one artifact:
6//!
7//! 1. **Wire transport.** [`encode_program`] / [`decode_program`] turn a whole
8//! [`CompiledProgram`] into a versioned, checksummed, SELF-CONTAINED frame. The frame
9//! carries its own symbol string-table, so a receiver with a *fresh* [`Interner`]
10//! re-interns and runs it byte-identically. This is what makes "send the language
11//! bytecode over the wire and execute it" real: the raw bytecode's `Symbol`s are
12//! interner-relative (see [`crate::vm::tier_cache`]) and thus NOT portable across
13//! machines; the frame lowers every symbol to a wire-local id into the string table
14//! and the decoder re-interns them on the far side.
15//!
16//! 2. **Chip ISA.** [`opcode`] is the STABLE opcode number a hardware decoder shares.
17//! It is keyed on [`Op::name`] through an APPEND-ONLY table ([`ISA_OPCODES`]), so the
18//! number for a given op can never drift when the `Op` enum is reordered — the exact
19//! property a fabricated decoder needs (its wired constants outlive the source order).
20//! The reversible-core opcodes ([`RevAdd`](Op::RevAdd) … [`RevSwap`](Op::RevSwap)) sit
21//! at pinned low numbers so the adiabatic datapath's control decode is fixed.
22
23use std::collections::HashMap;
24
25use serde::{Deserialize, Serialize};
26
27use crate::intern::{Interner, Symbol};
28use crate::vm::instruction::{
29 BoundaryType, CompiledFunction, CompiledProgram, Constant, EnumTypeDef, FuncIdx, Op,
30 StructTypeDef,
31};
32use crate::vm::native_tier::{ParamKind, SlotKind};
33
34/// The frame magic — `LOGOS Bytecode, format 1`. A receiver rejects any buffer that does
35/// not open with these four bytes (a foreign or truncated frame is not a program).
36pub const ISA_MAGIC: &[u8; 4] = b"LGB1";
37
38/// The wire-format version. Bumped when the frame layout changes incompatibly; a decoder
39/// rejects a version it does not understand rather than mis-parsing it.
40pub const ISA_VERSION: u32 = 3;
41
42/// The APPEND-ONLY opcode table: `opcode(op) == position of op.name() in this slice`.
43/// Order IS the ABI — a new op appends at the end and keeps every prior number fixed; the
44/// totality lock (this module's unit test `opcode_map_is_total_over_the_isa`) forces a newly-added `Op` to
45/// be registered here, and the append-only lock pins the reversible-core numbers so a chip
46/// decoder built against them can never silently mismatch. Never reorder or delete an entry.
47pub const ISA_OPCODES: &[&str] = &[
48 // --- reversible / adiabatic core (pinned low, the charge-recovery datapath) ---
49 "RevAdd", // 0
50 "RevSub", // 1
51 "RevXor", // 2
52 "RevNeg", // 3
53 "RevSwap", // 4
54 // --- data movement ---
55 "LoadConst",
56 "Move",
57 "EnsureOwned",
58 // --- integer / arithmetic ALU ---
59 "Add",
60 "AddAssign",
61 "Sub",
62 "Mul",
63 "Div",
64 "ExactDiv",
65 "FloorDiv",
66 "Mod",
67 "DivPow2",
68 "MagicDivU",
69 "Pow",
70 // --- bitwise ---
71 "BitXor",
72 "BitAnd",
73 "BitOr",
74 "Shl",
75 "Shr",
76 "Not",
77 // --- comparison ---
78 "Lt",
79 "Gt",
80 "LtEq",
81 "GtEq",
82 "Eq",
83 "ApproxEq",
84 "NotEq",
85 // --- text / sequence value ops ---
86 "Concat",
87 "SeqConcat",
88 // --- control flow ---
89 "Jump",
90 "JumpIfFalse",
91 "JumpIfTrue",
92 "Call",
93 "CallBuiltin",
94 "CallValue",
95 "MakeClosure",
96 "Return",
97 "ReturnNothing",
98 "Halt",
99 // --- policy / capability ---
100 "CheckPolicy",
101 // --- globals ---
102 "GlobalGet",
103 "GlobalSet",
104 // --- collections ---
105 "ListPushField",
106 "NewList",
107 "NewEmptyList",
108 "NewEmptyListI32",
109 "NewEmptySet",
110 "NewEmptyMap",
111 "NewRange",
112 "ListPush",
113 "SetAdd",
114 "RemoveFrom",
115 "SetIndex",
116 "SetIndexUnchecked",
117 "Index",
118 "IndexUnchecked",
119 "RegionBoundsGuard",
120 "Length",
121 "Contains",
122 "FormatValue",
123 "SliceOp",
124 "DeepClone",
125 "NewTuple",
126 "UnionOp",
127 "IntersectOp",
128 "ListPop",
129 "DestructureTuple",
130 // --- temporal ---
131 "LoadToday",
132 "LoadNow",
133 // --- structs / enums ---
134 "NewStruct",
135 "StructInsert",
136 "GetField",
137 "NewInductive",
138 "TestArm",
139 "BindArm",
140 // --- CRDTs ---
141 "CrdtBump",
142 "CrdtMerge",
143 "NewCrdt",
144 "CrdtAppend",
145 "CrdtResolve",
146 // --- iteration ---
147 "IterPrepare",
148 "IterNext",
149 "IterPop",
150 // --- misc runtime ---
151 "Sleep",
152 "Show",
153 "Args",
154 "FailWith",
155 // --- concurrency ---
156 "ChanNew",
157 "ChanSend",
158 "ChanRecv",
159 "ChanTrySend",
160 "ChanTryRecv",
161 "ChanClose",
162 "Spawn",
163 "SpawnHandle",
164 "TaskAwait",
165 "TaskAbort",
166 "SelectArmRecv",
167 "SelectArmTimeout",
168 "SelectWait",
169 // --- networking ---
170 "NetConnect",
171 "NetListen",
172 "NetSend",
173 "NetStream",
174 "NetAwait",
175 "NetMakePeer",
176 "NetSync",
177 "Obs",
178 // --- CRDT lattice join (appended: the wire map is append-only, so existing opcodes never shift) ---
179 "CrdtJoin",
180 // --- 256-bit BigInt wide lane (one opcode; the operation rides the chip word's dst high nibble) ---
181 "BigIntWide",
182 // --- tcp.lg client sockets (one opcode; the operation rides the `kind` field) — an off-chip
183 // coprocessor service like the Net ops ---
184 "Tcp",
185 // --- the `Serve` HTTP keystone (async-tier): the accept loop lives in the async io driver, which
186 // re-enters the handler function as bytecode; an off-host service like the Net/Tcp ops ---
187 "Serve",
188 // --- fixed-capacity list allocation (append-only; carries the shared sequence proof) ---
189 "NewEmptyListFixed",
190 // --- runtime capacity hints (append-only; the hint remains visible to every backend) ---
191 "NewEmptyListWithCapacity",
192 "NewEmptyMapWithCapacity",
193];
194
195/// The stable opcode number for an op — the value a hardware decoder keys on. Total over the
196/// ISA (a new op that forgets to register in [`ISA_OPCODES`] panics here, and the totality
197/// lock catches it in CI first).
198pub fn opcode(op: &Op) -> u16 {
199 opcode_of_name(op.name())
200 .unwrap_or_else(|| panic!("op {:?} is not registered in ISA_OPCODES", op.name()))
201}
202
203/// The stable opcode number for an op *name*, or `None` if the name is not in the ISA table.
204pub fn opcode_of_name(name: &str) -> Option<u16> {
205 ISA_OPCODES
206 .iter()
207 .position(|&n| n == name)
208 .map(|i| i as u16)
209}
210
211/// One compiled function, with every interner-relative [`Symbol`] lowered to a wire-local id
212/// into [`WireProgram::symbols`]. The mirror of [`CompiledFunction`] that is serde-clean and
213/// portable (the real struct's `name`/`captures` symbols cannot be moved between interners).
214#[derive(Serialize, Deserialize)]
215struct WireFunction {
216 name: u32,
217 entry_pc: u64,
218 param_count: u16,
219 register_count: u64,
220 captures: Vec<(u32, Option<u16>)>,
221 named_regs: Vec<bool>,
222 param_kinds: Vec<Option<ParamKind>>,
223 ret_kind: Option<SlotKind>,
224 param_types: Vec<Option<BoundaryType>>,
225 return_type: Option<BoundaryType>,
226 mutable_param_regs: Vec<u16>,
227 cow_exempt_regs: Vec<u16>,
228}
229
230/// The serde-serialized body of an ISA frame: a whole [`CompiledProgram`] with a self-contained
231/// symbol string-table. Every `Symbol` anywhere in the program is replaced by an index into
232/// `symbols`; the decoder re-interns those strings against the target interner and rewrites the
233/// indices back. Debug-only fields (`reg_names`) are dropped — they never affect execution.
234#[derive(Serialize, Deserialize)]
235struct WireProgram {
236 symbols: Vec<String>,
237 constants: Vec<Constant>,
238 code: Vec<Op>,
239 register_count: u64,
240 functions: Vec<WireFunction>,
241 fn_index: Vec<(u32, u16)>,
242 globals: Vec<String>,
243 named_regs: Vec<bool>,
244 loop_locals: Vec<(u64, Vec<bool>)>,
245 struct_types: Vec<StructTypeDef>,
246 enum_types: Vec<EnumTypeDef>,
247 global_types: Vec<Option<BoundaryType>>,
248}
249
250/// Accumulates the wire symbol table during encoding: each distinct interner symbol gets one
251/// dense wire-local id, its string captured once from the source interner.
252struct SymTab<'i> {
253 interner: &'i Interner,
254 strings: Vec<String>,
255 map: HashMap<Symbol, u32>,
256}
257
258impl<'i> SymTab<'i> {
259 fn new(interner: &'i Interner) -> Self {
260 SymTab {
261 interner,
262 strings: Vec::new(),
263 map: HashMap::new(),
264 }
265 }
266
267 fn lower(&mut self, s: Symbol) -> u32 {
268 if let Some(&id) = self.map.get(&s) {
269 return id;
270 }
271 let id = self.strings.len() as u32;
272 self.strings.push(self.interner.resolve(s).to_string());
273 self.map.insert(s, id);
274 id
275 }
276}
277
278/// FNV-1a 64 over the payload — a corruption tripwire, not a cryptographic seal. A decoder
279/// rejects a frame whose stored digest does not match its recomputed one.
280fn checksum(bytes: &[u8]) -> u64 {
281 let mut h: u64 = 0xcbf2_9ce4_8422_2325;
282 for &b in bytes {
283 h ^= b as u64;
284 h = h.wrapping_mul(0x0000_0100_0000_01b3);
285 }
286 h
287}
288
289/// Serialize a whole compiled program into a self-contained, portable ISA frame:
290/// `MAGIC ‖ version ‖ checksum ‖ bincode(WireProgram)`. The `interner` is the one the program
291/// was compiled against — every symbol string is captured from it so the frame stands alone.
292pub fn encode_program(program: &CompiledProgram, interner: &Interner) -> Vec<u8> {
293 let mut tab = SymTab::new(interner);
294
295 // Code: the ONLY symbol-carrying op is `CheckPolicy` (its policy predicate). Lower it to a
296 // wire id; every other op references names through constants / function indices already.
297 let mut code = program.code.clone();
298 for op in &mut code {
299 if let Op::CheckPolicy { predicate, .. } = op {
300 *predicate = Symbol::from_index(tab.lower(*predicate) as usize);
301 }
302 }
303
304 let functions: Vec<WireFunction> = program
305 .functions
306 .iter()
307 .map(|f| WireFunction {
308 name: tab.lower(f.name),
309 entry_pc: f.entry_pc as u64,
310 param_count: f.param_count,
311 register_count: f.register_count as u64,
312 captures: f
313 .captures
314 .iter()
315 .map(|(s, g)| (tab.lower(*s), *g))
316 .collect(),
317 named_regs: f.named_regs.clone(),
318 param_kinds: f.param_kinds.clone(),
319 ret_kind: f.ret_kind,
320 param_types: f.param_types.clone(),
321 return_type: f.return_type.clone(),
322 mutable_param_regs: f.mutable_param_regs.clone(),
323 cow_exempt_regs: f.cow_exempt_regs.clone(),
324 })
325 .collect();
326
327 // `fn_index` is a HashMap — sort by function index so the emitted frame is byte-deterministic
328 // regardless of hash iteration order (a chip / cache wants a reproducible artifact).
329 let mut fn_index: Vec<(u32, u16)> = program
330 .fn_index
331 .iter()
332 .map(|(s, fi)| (tab.lower(*s), *fi))
333 .collect();
334 fn_index.sort_unstable_by_key(|&(_, fi)| fi);
335
336 // `loop_locals` is ALSO a HashMap (keyed by absolute pc) — same treatment, sorted by that key.
337 let mut loop_locals: Vec<(u64, Vec<bool>)> = program
338 .loop_locals
339 .iter()
340 .map(|(k, v)| (*k as u64, v.clone()))
341 .collect();
342 loop_locals.sort_unstable_by_key(|&(pc, _)| pc);
343
344 // `struct_types`/`enum_types` are `Vec`s on `CompiledProgram`, but the discovery-pass registry
345 // they are built FROM (`TypeRegistry::iter_types`) is itself a `HashMap<Symbol, TypeDef>` whose
346 // iteration order is reseeded (and so reordered) every process run — the exact hazard
347 // `TypeRegistry`'s own variant-lookup helper already documents and sorts around. Two encodes of
348 // the identical source therefore end up with two DIFFERENT type-definition orders unless this
349 // boundary re-canonicalizes them: sort by name, since every consumer (the AOT backend, `vm/wasm/
350 // kind.rs`'s named-layout resolution) addresses a struct/enum by NAME, never by its position in
351 // this list — reordering the list changes nothing else.
352 let mut struct_types = program.struct_types.clone();
353 struct_types.sort_by(|a, b| a.name.cmp(&b.name));
354 let mut enum_types = program.enum_types.clone();
355 enum_types.sort_by(|a, b| a.name.cmp(&b.name));
356
357 let wire = WireProgram {
358 symbols: tab.strings,
359 constants: program.constants.clone(),
360 code,
361 register_count: program.register_count as u64,
362 functions,
363 fn_index,
364 globals: program.globals.clone(),
365 named_regs: program.named_regs.clone(),
366 loop_locals,
367 struct_types,
368 enum_types,
369 global_types: program.global_types.clone(),
370 };
371
372 let payload = bincode::serialize(&wire).expect("WireProgram serializes");
373 let mut out = Vec::with_capacity(payload.len() + 16);
374 out.extend_from_slice(ISA_MAGIC);
375 out.extend_from_slice(&ISA_VERSION.to_le_bytes());
376 out.extend_from_slice(&checksum(&payload).to_le_bytes());
377 out.extend_from_slice(&payload);
378 out
379}
380
381/// Decode an ISA frame back into an executable [`CompiledProgram`], re-interning its symbol
382/// table against `interner` (which may be BRAND NEW — the frame is self-contained). Returns
383/// `None` on any corruption: bad magic, wrong version, checksum mismatch, or malformed payload
384/// — a corrupt frame is not a program and is rejected, never half-executed.
385pub fn decode_program(bytes: &[u8], interner: &mut Interner) -> Option<CompiledProgram> {
386 if bytes.len() < 16 || &bytes[0..4] != ISA_MAGIC {
387 return None;
388 }
389 let version = u32::from_le_bytes(bytes[4..8].try_into().ok()?);
390 if version != ISA_VERSION {
391 return None;
392 }
393 let stored = u64::from_le_bytes(bytes[8..16].try_into().ok()?);
394 let payload = &bytes[16..];
395 if checksum(payload) != stored {
396 return None;
397 }
398 let wire: WireProgram = bincode::deserialize(payload).ok()?;
399
400 // Re-intern the wire string table → real symbols in the target interner.
401 let real: Vec<Symbol> = wire.symbols.iter().map(|s| interner.intern(s)).collect();
402 let remap = |wid: usize| -> Symbol { real.get(wid).copied().unwrap_or(Symbol::EMPTY) };
403
404 let mut code = wire.code;
405 for op in &mut code {
406 if let Op::CheckPolicy { predicate, .. } = op {
407 *predicate = remap(predicate.index());
408 }
409 }
410
411 let functions: Vec<CompiledFunction> = wire
412 .functions
413 .into_iter()
414 .map(|wf| CompiledFunction {
415 name: remap(wf.name as usize),
416 entry_pc: wf.entry_pc as usize,
417 param_count: wf.param_count,
418 register_count: wf.register_count as usize,
419 captures: wf
420 .captures
421 .into_iter()
422 .map(|(w, g)| (remap(w as usize), g))
423 .collect(),
424 named_regs: wf.named_regs,
425 param_kinds: wf.param_kinds,
426 ret_kind: wf.ret_kind,
427 param_types: wf.param_types,
428 return_type: wf.return_type,
429 mutable_param_regs: wf.mutable_param_regs,
430 cow_exempt_regs: wf.cow_exempt_regs,
431 })
432 .collect();
433
434 let fn_index: HashMap<Symbol, FuncIdx> = wire
435 .fn_index
436 .into_iter()
437 .map(|(w, fi)| (remap(w as usize), fi))
438 .collect();
439
440 Some(CompiledProgram {
441 // Not serialized: a wire form that predates the table has none, and a backend that finds
442 // none falls back to tracing a construction exactly as it always did.
443 declared_types: Vec::new(),
444 constants: wire.constants,
445 code,
446 register_count: wire.register_count as usize,
447 functions,
448 fn_index,
449 globals: wire.globals,
450 named_regs: wire.named_regs,
451 loop_locals: wire
452 .loop_locals
453 .into_iter()
454 .map(|(k, v)| (k as usize, v))
455 .collect(),
456 reg_names: Vec::new(),
457 fn_debug: Vec::new(),
458 struct_types: wire.struct_types,
459 enum_types: wire.enum_types,
460 global_types: wire.global_types,
461 // Observation is elided before wire serialization (the wire frame carries
462 // only the compiled/release program), so a decoded program observes nothing.
463 observation_sites: Vec::new(),
464 // Not serialized: a wire form that predates the table simply has none.
465 site_at: Vec::new(),
466 })
467}
468
469#[cfg(test)]
470#[path = "../../../logicaffeine_tests/unit/logicaffeine_compile/vm/binisa/tests.rs"]
471mod tests;