diff --git a/offchain/Cargo.toml b/offchain/Cargo.toml index 03796d0..62a6f91 100644 --- a/offchain/Cargo.toml +++ b/offchain/Cargo.toml @@ -7,6 +7,14 @@ edition = "2021" name = "gp_tester" path = "src/bin/gp_tester.rs" +[[bin]] +name = "gp_spec_tester" +path = "src/bin/gp_spec_tester.rs" + +[[bin]] +name = "symreg_experiment" +path = "src/bin/symreg_experiment.rs" + [dependencies] serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/offchain/src/bin/gp_spec_tester.rs b/offchain/src/bin/gp_spec_tester.rs new file mode 100644 index 0000000..b579b3b --- /dev/null +++ b/offchain/src/bin/gp_spec_tester.rs @@ -0,0 +1,36 @@ +use anyhow::Result; +use rand::thread_rng; +use rand::Rng; + +// Suppose you have these in your library: +use offchain::gp::generate_spec::ranmdom_code_fixed; +use offchain::gp::mutation::mutate_by_index; +use offchain::compiler::ast::UntypedAst; + +fn main() -> Result<()> { + let mut rng = thread_rng(); + + // We'll produce, say, 3 random ASTs ("subjects"). + let num_subjects = 3; + let max_points = 6; + + for i in 0..num_subjects { + // Generate a random AST + let original = ranmdom_code_fixed(&mut rng, max_points); + println!("=== Subject #{} ===", i); + println!("Original AST:\n{:#?}", original); + + // Let's do 2 successive mutations on it + let mut prev = original.clone(); + for mut_idx in 0..2 { + let mutated = mutate_by_index(&prev, &mut rng, max_points); + println!("Mutation #{} result:\n{:#?}", mut_idx, mutated); + prev = mutated; // so the next iteration mutates from the last result + } + + println!("=== End of Subject #{} ===\n", i); + } + + println!("Done generating & mutating random ASTs!"); + Ok(()) +} diff --git a/offchain/src/bin/symreg_experiment.rs b/offchain/src/bin/symreg_experiment.rs new file mode 100644 index 0000000..116c4e7 --- /dev/null +++ b/offchain/src/bin/symreg_experiment.rs @@ -0,0 +1,170 @@ +// src/bin/symreg_experiment.rs + +use anyhow::Result; +use rand::{thread_rng, Rng}; +use offchain::compiler::ast::{UntypedAst, Push3Ast}; +use offchain::helpers::artifact::get_creation_code; +use offchain::runner::revm_runner::{ + EvmRunner, + Push3InterpreterInputs, + Push3InterpreterOutputs +}; +use offchain::gp::generate_spec::{ranmdom_code_fixed}; +use offchain::gp::mutation::mutate_by_index; +use offchain::compiler::push3_describtor::make_sublist_descriptor; +use ethers::types::U256; +use ethers::abi::{encode, Token}; + +// 1) Generate (x, y) samples for f(x) = 3x^2 + x + 3 +fn generate_samples() -> Vec<(i32, i32)> { + let mut samples = Vec::new(); + for x in -5..=5 { + let y = 3 * x * x + x + 3; + samples.push((x, y)); + } + samples +} + +// 2) Evaluate a single AST on a single input `x`, returning the integer +// that ends up on top of the final int stack (or 0 if empty). +fn evaluate_ast_on_x( + runner: &mut EvmRunner, + ast: &UntypedAst, + x: i32, +) -> i32 { + // a) Convert AST => push3 bytecode + let code_bytes = ast.to_bytecode(); + let code_len = code_bytes.len() as u32; + + // b) Build a sublist descriptor + let descriptor = make_sublist_descriptor(0, code_len); + + // c) Provide x in `init_int_stack` + let inputs = Push3InterpreterInputs { + code: code_bytes, + init_code_stack: Vec::new(), + init_exec_stack: vec![descriptor], + init_int_stack: vec![x as i128], + }; + + // d) Actually run the interpreter + let outputs = match runner.run_interpreter(&inputs) { + Ok(o) => o, + Err(_) => { + // If the call fails or reverts, return 0 + return 0; + } + }; + + // e) If final_int_stack is empty => 0, else top item + if outputs.final_int_stack.is_empty() { + 0 + } else { + *outputs.final_int_stack.last().unwrap() as i32 + } +} + +// 3) Evaluate an AST on all (x, y) samples => compute MSE +fn evaluate_fitness( + runner: &mut EvmRunner, + ast: &UntypedAst, + samples: &[(i32, i32)] +) -> f64 { + let mut error_sum = 0.0; + for &(x, target_y) in samples { + let predicted = evaluate_ast_on_x(runner, ast, x); + let diff = (predicted - target_y) as f64; + error_sum += diff * diff; + } + error_sum / samples.len() as f64 +} + +fn main() -> Result<()> { + // A) Generate samples for f(x)=3x^2 + x + 3 + let samples = generate_samples(); + + // B) Create an ephemeral EVM runner (Push3Interpreter) + let creation_hex_filename = "../onchain/out/Push3Interpreter.sol/Push3Interpreter.json"; + let creation_bytes = get_creation_code(creation_hex_filename)?; + let mut runner = EvmRunner::new(creation_bytes)?; + + // C) Setup GP parameters + let pop_size = 1000; // population size + let max_points = 15; // max "size" for random code + let generations = 100; // how many generations we run + let early_stop_threshold = 1.0; // if error < this => we can stop + + let mut rng = thread_rng(); + + // D) Initialize random population + let mut population: Vec = (0..pop_size) + .map(|_| ranmdom_code_fixed(&mut rng, max_points)) + .collect(); + + // E) Main GP Loop + for gen in 0..generations { + // 1) Evaluate each => store (ast, error) + let mut scored: Vec<(UntypedAst, f64)> = population.into_iter() + .map(|ast| { + let err = evaluate_fitness(&mut runner, &ast, &samples); + (ast, err) + }) + .collect(); + + // 2) Sort ascending by error + scored.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap()); + + // 3) Print best error + let best_err = scored[0].1; + println!("Generation {gen}, best error = {best_err}"); + + // 4) Check if best is below threshold => early stop + if best_err < early_stop_threshold { + println!("Best error < {early_stop_threshold}, stopping early!"); + population = scored.into_iter().map(|(a, _)| a).collect(); + break; + } + + // 5) Keep best half + let half = scored.len() / 2; + let survivors = &scored[..half]; + + // 6) Refill population: half mutated, half fresh random + let mut new_pop = Vec::with_capacity(scored.len()); + // a) keep survivors + for &(ref ast, _) in survivors { + new_pop.push(ast.clone()); + } + + // b) how many to add? + let remainder_size = pop_size - new_pop.len(); + // we do half mutated, half new random + let mutated_count = remainder_size / 2; + let random_count = remainder_size - mutated_count; + + // c) add mutated + for _ in 0..mutated_count { + let idx = rng.gen_range(0..half); + let mutated = mutate_by_index(&survivors[idx].0, &mut rng, max_points); + new_pop.push(mutated); + } + + // d) add brand-new random + for _ in 0..random_count { + let fresh = ranmdom_code_fixed(&mut rng, max_points); + new_pop.push(fresh); + } + + // store for next generation + population = new_pop; + } + + // F) Final Print + println!("\n=== Final Population ==="); + for (i, ast) in population.iter().enumerate() { + let err = evaluate_fitness(&mut runner, ast, &samples); + println!("Subject #{i}, err={err}, ast={:?}", ast); + } + + Ok(()) +} diff --git a/offchain/src/gp/generate_spec.rs b/offchain/src/gp/generate_spec.rs new file mode 100644 index 0000000..15a2100 --- /dev/null +++ b/offchain/src/gp/generate_spec.rs @@ -0,0 +1,121 @@ +use crate::compiler::ast::{UntypedAst, OpCode}; +use rand::Rng; +use rand::prelude::SliceRandom; + +#[derive(Clone, Debug)] +pub enum InstructionAtom { + /// A normal opcode, e.g. `Plus`, `Minus`, `Dup`, etc. + Opcode(OpCode), + /// An ephemeral random constant placeholder. + /// If chosen, we generate a random literal on the spot. + EphemeralInt, + // If you want ephemeral floats, booleans, etc., add more variants +} + +/// A small struct to hold our entire “instruction set.” +#[derive(Clone, Debug)] +pub struct InstructionSet { + pub atoms: Vec, +} + +impl InstructionSet { + pub fn new_default() -> Self { + // Example: Some opcodes + ephemeral constant + // Tweak as you like + use InstructionAtom::*; + use OpCode::*; + + Self { + atoms: vec![ + Opcode(Noop), + Opcode(Plus), + Opcode(Minus), + Opcode(Mult), + Opcode(Dup), + Opcode(Pop), + EphemeralInt, + ], + } + } + + /// Pick a random atom from this set. + /// If it's `EphemeralInt`, we produce `UntypedAst::IntLiteral(...)`. + /// If it's `Opcode(...)`, we produce `UntypedAst::Instruction(...)`. + pub fn random_atom_as_ast(&self, rng: &mut impl Rng) -> UntypedAst { + let idx = rng.gen_range(0..self.atoms.len()); + match &self.atoms[idx] { + InstructionAtom::Opcode(op) => UntypedAst::Instruction(op.clone()), + InstructionAtom::EphemeralInt => { + // For ephemeral int, produce a random literal in some range + let val = rng.gen_range(-10..10); + UntypedAst::IntLiteral(val) + } + } + } +} + +pub fn ranmdom_code_fixed(rng: &mut impl Rng, max_points: usize) -> UntypedAst { + let instr_set = InstructionSet::new_default(); + return random_code(rng, &instr_set, max_points); +} + +/// The "entry point": random_code(max_points). +/// 1) Choose actual_points in [1..=max_points]. +/// 2) Return the result of random_code_with_size. +pub fn random_code(rng: &mut impl Rng, instr_set: &InstructionSet, max_points: usize) -> UntypedAst { + let actual_points = rng.gen_range(1..=max_points); + random_code_with_size(rng, instr_set, actual_points) +} + +/// The main logic: +/// If points == 1 => pick a single instruction/ephemeral +/// Else => +/// - We "decompose" (points - 1) among sub-codes +/// - For each sub-points, we recursively call random_code_with_size +/// - We return a Sublist of those children, possibly in random order +pub fn random_code_with_size( + rng: &mut impl Rng, + instr_set: &InstructionSet, + points: usize, +) -> UntypedAst { + use UntypedAst::*; + + // Base case + if points == 1 { + // Choose a random "atom" from instr_set + return instr_set.random_atom_as_ast(rng); + } + + // If `points > 1`, let's produce a Sublist + // We'll break (points - 1) into sub-points via `decompose` + let subpoints_list = decompose(rng, points - 1, points - 1); + // subpoints_list is e.g. [2, 3, 5] and sums to (points-1) + + // Then for each "subpoints", we do random_code_with_size + let mut sub_asts: Vec = subpoints_list + .into_iter() + .map(|sp| random_code_with_size(rng, instr_set, sp)) + .collect(); + + // The spec says "Return a list containing the results, in random order" + // so let's shuffle sub_asts + sub_asts.shuffle(rng); + + Sublist(sub_asts) +} + +/// Decompose a number into random parts. +/// +/// - If number is 1 or max_parts is 1 => return [number]. +/// - Otherwise pick a random split "this_part" and recurse on the remainder. +fn decompose(rng: &mut impl Rng, number: usize, max_parts: usize) -> Vec { + if number == 1 || max_parts == 1 { + return vec![number]; + } + // pick a random "this_part" in [1 .. (number-1)] + let this_part = rng.gen_range(1..number); + let mut remainder = decompose(rng, number - this_part, max_parts - 1); + let mut result = vec![this_part]; + result.append(&mut remainder); + result +} diff --git a/offchain/src/gp/mod.rs b/offchain/src/gp/mod.rs index 3681485..3b107c3 100644 --- a/offchain/src/gp/mod.rs +++ b/offchain/src/gp/mod.rs @@ -1,3 +1,5 @@ pub mod population; pub mod genetic_ops; pub mod generate; +pub mod generate_spec; +pub mod mutation; diff --git a/offchain/src/gp/mutation.rs b/offchain/src/gp/mutation.rs new file mode 100644 index 0000000..0b02133 --- /dev/null +++ b/offchain/src/gp/mutation.rs @@ -0,0 +1,86 @@ +use rand::Rng; +use crate::compiler::ast::UntypedAst; +use crate::gp::generate_spec::ranmdom_code_fixed; // or push3-based generator + +/// A "path" is a list of indices leading from the root to a child. +/// For example, [] is the root, [0] is the root's first child, [0,1] is that child's second child, etc. +pub type Path = Vec; + +/// Enumerate all nodes in a DFS order (root first). +/// For each node, we store the `Path` (list of child indices). +pub fn enum_nodes_dfs(ast: &UntypedAst) -> Vec { + let mut paths = Vec::new(); + dfs_helper(ast, &mut paths, &mut vec![]); + paths +} + +/// Recursive helper: `current_path` is a stack we push child indices onto. +/// `paths` is the global list of final results. +fn dfs_helper(ast: &UntypedAst, paths: &mut Vec, current_path: &mut Vec) { + // Record the current path as a valid node + paths.push(current_path.clone()); + + match ast { + UntypedAst::Sublist(children) => { + // For each child, push its index, recurse, then pop + for (i, child) in children.iter().enumerate() { + current_path.push(i); + dfs_helper(child, paths, current_path); + current_path.pop(); + } + } + UntypedAst::IntLiteral(_) | UntypedAst::Instruction(_) => { + // Leaf node => no children + } + } +} + +/// Replace the node at `path` in `original` with `replacement`, returning a new AST. +/// If `path` is empty => we replace the root entirely. +pub fn replace_subtree( + original: &UntypedAst, + path: &[usize], + replacement: UntypedAst +) -> UntypedAst { + if path.is_empty() { + // means we’re replacing the root + return replacement; + } + // otherwise, we need to walk the sublist chain + match original { + UntypedAst::Sublist(children) => { + // let path_head = path[0], path_tail = path[1..] + let (first_idx, tail_path) = (path[0], &path[1..]); + let mut new_children = children.clone(); + new_children[first_idx] = replace_subtree(&children[first_idx], tail_path, replacement); + UntypedAst::Sublist(new_children) + } + // If the path is not empty but we’re not in a Sublist => error or do nothing + UntypedAst::IntLiteral(_) | UntypedAst::Instruction(_) => { + // Possibly, if the path is invalid, we ignore. Or we treat it as "replace anyway." + // Here, we might treat it as an error or a no-op. + original.clone() + } + } +} + +/// Produces a new AST by choosing exactly one node in `original` at random +/// (by enumerating them), and replacing it with a new random subtree. +pub fn mutate_by_index( + original: &UntypedAst, + rng: &mut impl Rng, + max_points: usize, +) -> UntypedAst { + // 1) Enumerate all nodes => get a vector of `Path` + let all_paths = enum_nodes_dfs(original); + // pick a random path + let idx = rng.gen_range(0..all_paths.len()); + let chosen_path = &all_paths[idx]; + + // 2) Generate a new subtree with our "fixed" random code generator + let new_subtree = ranmdom_code_fixed(rng, max_points); + + // 3) Replace the subtree at `chosen_path` in `original` with `new_subtree` + replace_subtree(original, chosen_path, new_subtree) +} +