Skip to content

Extension Guide

This guide explains how to extend Achronyme with new IR instructions, circuit builtins, optimization passes, and VM native functions.

In ir/src/types.rs, add a new variant to the Instruction enum:

pub enum Instruction {
// ... existing variants ...
MyOp { result: SsaVar, operand: SsaVar },
}

In the same file, update three methods:

// result_var() — return the result variable
Instruction::MyOp { result, .. } => *result,
// has_side_effects() — true if the instruction must not be eliminated
// Add to the matches! if it has side effects
// operands() — return all input SSA variables
Instruction::MyOp { operand, .. } => vec![*operand],

In ir/src/lower.rs, handle the new instruction in the appropriate method (e.g., lower_call for builtins, lower_expr for operators):

"my_op" => {
let arg = self.lower_expr(args[0])?;
let result = self.program.fresh_var();
self.program.push(Instruction::MyOp { result, operand: arg });
Ok(EnvValue::Scalar(result))
}

In ir/src/eval.rs, handle the instruction in the evaluate function:

Instruction::MyOp { result, operand } => {
let val = values[operand];
let out = /* compute result */;
values.insert(*result, out);
}

Constant folding (ir/src/passes/const_fold.rs): Add folding logic if the operation can be computed at compile time on constants.

Dead code elimination (ir/src/passes/dce.rs): If the instruction is pure (no side effects), add it to the eliminable set. If it has side effects, keep it conservative.

Boolean propagation (ir/src/passes/bool_prop.rs): If the result is always boolean, add it as a seed.

In compiler/src/r1cs_backend.rs, add a match arm in compile_ir:

Instruction::MyOp { result, operand } => {
let lc_op = self.vars[operand].clone();
// Build constraints...
let lc_result = /* ... */;
self.vars.insert(*result, lc_result);
}

In compiler/src/plonkish_backend.rs, add a match arm in compile_ir:

Instruction::MyOp { result, operand } => {
let val = self.materialize_val(/* ... */)?;
// Emit gate rows...
self.vals.insert(*result, PlonkVal::Cell(cell));
}

If the instruction allocates intermediate variables, record a WitnessOp in compiler/src/witness_gen.rs.

Circuit builtins are high-level functions that lower to one or more IR instructions.

No changes needed — the parser already handles function calls generically.

In ir/src/lower.rs, add a match arm in lower_call:

"my_builtin" => {
if args.len() != 2 {
return Err(IrError::WrongArgumentCount { ... });
}
let a = self.lower_expr_scalar(&args[0])?;
let b = self.lower_expr_scalar(&args[1])?;
let result = self.program.fresh_var();
self.program.push(Instruction::MyOp { result, lhs: a, rhs: b });
Ok(EnvValue::Scalar(result))
}

Follow steps 4-8 from “Adding a New IR Instruction” above.

Create ir/src/passes/my_pass.rs:

use crate::types::{IrProgram, Instruction, SsaVar};
pub fn my_pass(program: &mut IrProgram) {
// Walk program.instructions and transform
}

In ir/src/passes/mod.rs, add the module and call it from optimize:

mod my_pass;
pub fn optimize(program: &mut IrProgram) {
const_fold::const_fold(program);
dce::dce(program);
bool_prop::bool_prop(program);
my_pass::my_pass(program); // new pass
}
  • Forward passes (like const_fold, bool_prop) iterate program.instructions front-to-back, building up state
  • Backward passes (like dce) iterate back-to-front, tracking which results are used
  • Passes should be O(n) in instruction count
  • Avoid quadratic behavior — use HashMap<SsaVar, ...> for lookups

In vm/src/stdlib/core.rs, add the implementation:

pub fn native_my_func(vm: &mut VM, args: &[Value]) -> Result<Value, RuntimeError> {
let arg = args[0];
// ... process ...
Ok(Value::int(result))
}

The signature is always fn(&mut VM, &[Value]) -> Result<Value, RuntimeError>.

In vm/src/specs.rs, add an entry to NATIVE_TABLE:

pub const NATIVE_TABLE: &[NativeSpec] = &[
// ... existing entries ...
NativeSpec { name: "my_func", arity: 1 },
];

In the same file, update the constant:

pub const NATIVE_COUNT: usize = 24; // was 23

The compile-time assertion will catch any mismatch.

In vm/src/machine/native.rs, add the match arm in bootstrap_natives:

"my_func" => stdlib::core::native_my_func,

In vm/src/opcode.rs, add a constant:

pub const MY_OP: u8 = /* next available number */;

In compiler/src/compiler.rs, emit the opcode during compilation:

// In the appropriate compilation method
self.emit(opcode::MY_OP, a, b, c);

In vm/src/machine/vm.rs, add a match arm in interpret_inner:

opcode::MY_OP => {
let a = inst_a!(inst);
let b = inst_b!(inst);
// ... execute ...
}

In the bytecode compiler, ensure current_line is set before emitting:

self.current_line = node.line as u32;
self.emit(opcode::MY_OP, a, b, c);

This enables error location tracking ([line N] in func: Error).

When adding a new instruction or builtin, verify:

  • ir/src/types.rsInstruction enum + result_var + has_side_effects + operands
  • ir/src/lower.rs — emit instruction from AST
  • ir/src/eval.rs — concrete evaluation
  • ir/src/passes/const_fold.rs — fold on constants (if applicable)
  • ir/src/passes/dce.rs — mark as eliminable or conservative
  • ir/src/passes/bool_prop.rs — propagate boolean-ness (if applicable)
  • compiler/src/r1cs_backend.rs — R1CS constraint generation
  • compiler/src/plonkish_backend.rs — Plonkish gate emission
  • compiler/src/witness_gen.rs — witness computation (if intermediate wires)
  • Tests in all relevant crates