use crate::engine::{stable_id, SymbolicAnswer};
use crate::event_log::EventLog;
use crate::language::detect as detect_language;
use crate::links_format::format_lino_record;
use crate::seed;
use crate::solver::SolverConfig;
use crate::solver_handlers::finalize_simple;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Op {
symbol: char,
}
impl Op {
const fn new(symbol: char) -> Self {
Self { symbol }
}
const fn symbol(self) -> char {
self.symbol
}
fn apply(self, lhs: i64, rhs: i64) -> Option<i64> {
match self.symbol {
'+' => Some(lhs.saturating_add(rhs)),
'-' => Some(lhs.saturating_sub(rhs)),
'*' => Some(lhs.saturating_mul(rhs)),
'/' => (rhs != 0).then(|| lhs / rhs),
'%' => (rhs != 0).then(|| lhs % rhs),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct SearchProblem {
numbers: Vec<i64>,
target: i64,
ops: Vec<Op>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Candidate {
order: Vec<usize>,
ops: Vec<Op>,
}
impl Candidate {
fn evaluate(&self, numbers: &[i64]) -> Option<i64> {
let mut acc = numbers[self.order[0]];
for (index, op) in self.ops.iter().enumerate() {
acc = op.apply(acc, numbers[self.order[index + 1]])?;
}
Some(acc)
}
fn render(&self, numbers: &[i64]) -> String {
let mut out = numbers[self.order[0]].to_string();
for (index, op) in self.ops.iter().enumerate() {
out.push(' ');
out.push(op.symbol());
out.push(' ');
out.push_str(&numbers[self.order[index + 1]].to_string());
}
out
}
}
struct Prng {
state: u64,
}
impl Prng {
const fn seeded(seed: u64) -> Self {
Self {
state: seed ^ 0x9e37_79b9_7f4a_7c15,
}
}
const fn next_u64(&mut self) -> u64 {
self.state = self.state.wrapping_add(0x9e37_79b9_7f4a_7c15);
let mut z = self.state;
z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
z ^ (z >> 31)
}
fn below(&mut self, bound: usize) -> usize {
if bound == 0 {
0
} else {
usize::try_from(self.next_u64() % bound as u64).unwrap_or(0)
}
}
}
fn seed_from_prompt(prompt: &str) -> u64 {
let mut hash = 0xcbf2_9ce4_8422_2325_u64;
for byte in prompt.bytes() {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
hash
}
pub fn try_budget_search(
prompt: &str,
log: &mut EventLog,
config: SolverConfig,
) -> Option<SymbolicAnswer> {
let problem = parse_search_problem(prompt)?;
log.append("search:problem:target", problem.target.to_string());
log.append("search:problem:numbers", join_numbers(&problem.numbers));
log.append("search:problem:ops", join_ops(&problem.ops));
log.append("search:budget", config.compute_budget.to_string());
record_generated_tests(log, &problem);
let outcome = run_search(prompt, log, &problem, config.compute_budget);
match outcome {
Some(solution) => {
log.append(
"search:solution",
format!("{} = {}", solution.expression, problem.target),
);
record_skill_proposal(prompt, log, &problem, &solution);
Some(build_answer(
prompt,
log,
&problem,
&solution,
config.compute_budget,
))
}
None => {
None
}
}
}
struct SearchSolution {
expression: String,
evaluations: u32,
}
fn run_search(
prompt: &str,
log: &mut EventLog,
problem: &SearchProblem,
budget: u32,
) -> Option<SearchSolution> {
if budget == 0 {
log.append("search:exhausted:evaluations", 0.to_string());
log.append("search:exhausted:budget", 0.to_string());
return None;
}
let mut prng = Prng::seeded(seed_from_prompt(prompt));
let mut evaluations: u32 = 0;
let mut best: Option<(Candidate, i64)> = None;
let random_budget = budget.div_ceil(2);
let mut population: Vec<(Candidate, i64)> = Vec::new();
while evaluations < random_budget {
let candidate = random_candidate(&mut prng, problem);
let diff = score(&candidate, problem);
evaluations += 1;
if diff == 0 {
return Some(finish_solution(
log,
problem,
&candidate,
evaluations,
"random",
));
}
remember_best(&mut best, &candidate, diff);
insert_population(&mut population, candidate, diff, POPULATION);
}
log.append("search:random:sampled", evaluations.to_string());
log.append(
"search:random:best_diff",
best.as_ref()
.map_or(i64::MAX, |(_, diff)| *diff)
.to_string(),
);
let mut generation: u32 = 0;
while evaluations < budget {
generation += 1;
let child = breed(&mut prng, &population, problem);
let diff = score(&child, problem);
evaluations += 1;
if diff == 0 {
log.append("search:evolutionary:generation", generation.to_string());
log.append("search:evolutionary:best_diff", 0.to_string());
return Some(finish_solution(
log,
problem,
&child,
evaluations,
"evolutionary",
));
}
remember_best(&mut best, &child, diff);
insert_population(&mut population, child, diff, POPULATION);
if generation.is_multiple_of(GENERATION_LOG_STRIDE) {
log.append("search:evolutionary:generation", generation.to_string());
log.append(
"search:evolutionary:best_diff",
best.as_ref()
.map_or(i64::MAX, |(_, diff)| *diff)
.to_string(),
);
}
}
log.append("search:exhausted:evaluations", evaluations.to_string());
log.append(
"search:exhausted:best_diff",
best.as_ref()
.map_or(i64::MAX, |(_, diff)| *diff)
.to_string(),
);
None
}
const POPULATION: usize = 8;
const GENERATION_LOG_STRIDE: u32 = 16;
fn finish_solution(
log: &mut EventLog,
problem: &SearchProblem,
candidate: &Candidate,
evaluations: u32,
phase: &'static str,
) -> SearchSolution {
log.append("search:candidate:phase", phase.to_owned());
log.append("search:candidate:evaluations", evaluations.to_string());
log.append(
"search:candidate:expression",
candidate.render(&problem.numbers),
);
SearchSolution {
expression: candidate.render(&problem.numbers),
evaluations,
}
}
fn score(candidate: &Candidate, problem: &SearchProblem) -> i64 {
candidate
.evaluate(&problem.numbers)
.map_or(i64::MAX, |value| (value - problem.target).abs())
}
fn remember_best(best: &mut Option<(Candidate, i64)>, candidate: &Candidate, diff: i64) {
if best.as_ref().is_none_or(|(_, current)| diff < *current) {
*best = Some((candidate.clone(), diff));
}
}
fn insert_population(
population: &mut Vec<(Candidate, i64)>,
candidate: Candidate,
diff: i64,
capacity: usize,
) {
if population
.iter()
.any(|(existing, _)| existing == &candidate)
{
return;
}
population.push((candidate, diff));
population.sort_by_key(|entry| entry.1);
population.truncate(capacity);
}
fn random_candidate(prng: &mut Prng, problem: &SearchProblem) -> Candidate {
let order = random_permutation(prng, problem.numbers.len());
let ops = (0..problem.numbers.len().saturating_sub(1))
.map(|_| problem.ops[prng.below(problem.ops.len())])
.collect();
Candidate { order, ops }
}
fn random_permutation(prng: &mut Prng, len: usize) -> Vec<usize> {
let mut order: Vec<usize> = (0..len).collect();
for i in (1..len).rev() {
let j = prng.below(i + 1);
order.swap(i, j);
}
order
}
fn breed(prng: &mut Prng, population: &[(Candidate, i64)], problem: &SearchProblem) -> Candidate {
if population.is_empty() {
return random_candidate(prng, problem);
}
let parent_a = &population[prng.below(population.len())].0;
let parent_b = &population[prng.below(population.len())].0;
let mut order = parent_a.order.clone();
let mut ops: Vec<Op> = parent_a
.ops
.iter()
.zip(parent_b.ops.iter())
.map(|(a, b)| if prng.next_u64() & 1 == 0 { *a } else { *b })
.collect();
if !ops.is_empty() && prng.next_u64() & 1 == 0 {
let slot = prng.below(ops.len());
ops[slot] = problem.ops[prng.below(problem.ops.len())];
} else if order.len() >= 2 {
let i = prng.below(order.len());
let j = prng.below(order.len());
order.swap(i, j);
}
Candidate { order, ops }
}
fn record_generated_tests(log: &mut EventLog, problem: &SearchProblem) {
log.append(
"search:test:each_number_once",
join_numbers(&problem.numbers),
);
log.append("search:test:only_operators", join_ops(&problem.ops));
log.append("search:test:evaluates_to", problem.target.to_string());
}
#[allow(clippy::literal_string_with_formatting_args)]
fn build_answer(
prompt: &str,
log: &mut EventLog,
problem: &SearchProblem,
solution: &SearchSolution,
budget: u32,
) -> SymbolicAnswer {
let language = detect_language(prompt);
let template = seed::response_for("budget_search_solution", language.slug())
.or_else(|| seed::response_for("budget_search_solution", "en"))
.unwrap_or_default();
let substitutions = [
("{expression}", solution.expression.clone()),
("{target}", problem.target.to_string()),
("{budget}", budget.to_string()),
("{evaluations}", solution.evaluations.to_string()),
("{trace_id}", stable_id("search", prompt)),
];
let body = substitutions
.iter()
.fold(template, |acc, (placeholder, value)| {
acc.replace(placeholder, value)
});
finalize_simple(
prompt,
log,
"budget_search_solution",
"response:search:solution",
&body,
0.9,
)
}
fn record_skill_proposal(
prompt: &str,
log: &mut EventLog,
problem: &SearchProblem,
solution: &SearchSolution,
) {
let skill_id = stable_id(
"search_skill",
&format!(
"reachability:{}:{}",
problem.numbers.len(),
solution.expression
),
);
let record = format_lino_record(
&skill_id,
&[
("record_type", "candidate_skill".to_owned()),
("skill_id", skill_id.clone()),
("method", "budget_search".to_owned()),
(
"route",
format!("reachability:{}-operand", problem.numbers.len()),
),
("source_span", prompt.to_owned()),
("status", "proposed".to_owned()),
("has_tests", "false".to_owned()),
("has_benchmark_delta", "false".to_owned()),
("promotable", "false".to_owned()),
],
);
log.append("search:skill", record);
log.append("search:skill:promotable", "0".to_owned());
}
fn join_numbers(numbers: &[i64]) -> String {
numbers
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ")
}
fn join_ops(ops: &[Op]) -> String {
ops.iter()
.map(|op| op.symbol().to_string())
.collect::<Vec<_>>()
.join(" ")
}
fn parse_search_problem(prompt: &str) -> Option<SearchProblem> {
let lower = prompt.to_lowercase();
let lexicon = seed::lexicon();
if !lexicon.mentions_role_raw(seed::ROLE_REACHABILITY_OPERAND_FRAMING, &lower)
|| !lexicon.mentions_role_raw(seed::ROLE_REACHABILITY_SEARCH_CUE, &lower)
{
return None;
}
let integers = extract_integers_with_positions(&lower);
if integers.len() < 3 {
return None;
}
let marker_positions = target_marker_positions(&lower);
if marker_positions.is_empty() {
return None;
}
let target_index = integers
.iter()
.enumerate()
.min_by_key(|(_, (_, position))| distance_to_nearest(*position, &marker_positions))
.map(|(index, _)| index)?;
let target = integers[target_index].0;
let numbers: Vec<i64> = integers
.iter()
.enumerate()
.filter(|(index, _)| *index != target_index)
.map(|(_, (value, _))| *value)
.collect();
if numbers.len() < 2 || numbers.len() > MAX_OPERANDS {
return None;
}
let ops = parse_ops(&lower);
Some(SearchProblem {
numbers,
target,
ops,
})
}
const MAX_OPERANDS: usize = 6;
fn target_marker_positions(lower: &str) -> Vec<usize> {
let mut positions = Vec::new();
for marker in seed::lexicon().words_for_role(seed::ROLE_REACHABILITY_TARGET_MARKER) {
let mut from = 0;
while let Some(offset) = lower[from..].find(&marker) {
let absolute = from + offset;
positions.push(absolute);
from = absolute + marker.len();
}
}
positions
}
fn distance_to_nearest(position: usize, marker_positions: &[usize]) -> usize {
marker_positions
.iter()
.map(|&marker| position.abs_diff(marker))
.min()
.unwrap_or(usize::MAX)
}
fn parse_ops(lower: &str) -> Vec<Op> {
let operators = seed::lexicon().arithmetic_operators();
let mut ops: Vec<Op> = operators
.iter()
.filter(|operator| {
symbol_present(lower, operator.symbol)
|| operator.spelled.iter().any(|word| lower.contains(word))
})
.map(|operator| Op::new(operator.symbol))
.collect();
if ops.is_empty() {
ops = operators
.iter()
.map(|operator| Op::new(operator.symbol))
.collect();
}
ops
}
fn symbol_present(lower: &str, symbol: char) -> bool {
let chars: Vec<char> = lower.chars().collect();
let arithmetic_context =
|neighbor: Option<char>| neighbor.is_none_or(|c| c.is_ascii_digit() || c.is_whitespace());
for (index, ¤t) in chars.iter().enumerate() {
if current != symbol {
continue;
}
let before = index.checked_sub(1).map(|prev| chars[prev]);
let after = chars.get(index + 1).copied();
if arithmetic_context(before) || arithmetic_context(after) {
return true;
}
}
false
}
fn extract_integers_with_positions(span: &str) -> Vec<(i64, usize)> {
let mut numbers = Vec::new();
let mut current = String::new();
let mut start = 0;
for (offset, ch) in span.char_indices() {
if ch.is_ascii_digit() {
if current.is_empty() {
start = offset;
}
current.push(ch);
} else if !current.is_empty() {
if let Ok(value) = current.parse::<i64>() {
numbers.push((value, start));
}
current.clear();
}
}
if !current.is_empty() {
if let Ok(value) = current.parse::<i64>() {
numbers.push((value, start));
}
}
numbers
}