use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum MutationTarget {
Word(usize),
Phrase(usize, usize),
SystemPrompt,
Temperature,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum MutationMetric {
Perplexity,
OutputLength,
TopTokenChange,
SentimentShift,
}
impl MutationMetric {
pub fn display_name(&self) -> &'static str {
match self {
MutationMetric::Perplexity => "Mean Perplexity",
MutationMetric::OutputLength => "Output Length (tokens)",
MutationMetric::TopTokenChange => "Top-Token Changed",
MutationMetric::SentimentShift => "Sentiment Shift",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MutationSpec {
pub target: MutationTarget,
pub variants: Vec<String>,
pub metric: MutationMetric,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VariantResult {
pub variant: String,
pub prompt: String,
pub metric_value: f64,
pub stats: HashMap<String, f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExperimentResult {
pub base_prompt: String,
pub spec: MutationSpec,
pub results: Vec<VariantResult>,
}
impl ExperimentResult {
pub fn to_markdown_table(&self) -> String {
let metric_name = self.spec.metric.display_name();
let mut md = format!(
"## Mutation Experiment Results\n\n\
**Base prompt:** `{}`\n\
**Target:** {:?}\n\
**Metric:** {}\n\n\
| Rank | Variant | Prompt (snippet) | {} |\n\
|------|---------|-----------------|---{}---|\n",
truncate(&self.base_prompt, 60),
self.spec.target,
metric_name,
metric_name,
"-".repeat(metric_name.len()),
);
for (rank, result) in self.results.iter().enumerate() {
let snippet = truncate(&result.prompt, 40);
md.push_str(&format!(
"| {} | `{}` | `{}` | {:.4} |\n",
rank + 1,
result.variant,
snippet,
result.metric_value,
));
}
md
}
pub fn best(&self) -> Option<&VariantResult> {
self.results.first()
}
pub fn worst(&self) -> Option<&VariantResult> {
self.results.last()
}
}
pub struct MutationLab {
pub base_prompt: String,
}
impl MutationLab {
pub fn new(base_prompt: impl Into<String>) -> Self {
Self {
base_prompt: base_prompt.into(),
}
}
pub fn apply_variant(&self, target: &MutationTarget, variant: &str) -> String {
match target {
MutationTarget::Word(idx) => {
let mut words: Vec<&str> = self.base_prompt.split_whitespace().collect();
if *idx < words.len() {
words[*idx] = variant;
}
words.join(" ")
}
MutationTarget::Phrase(start, end) => {
let base = &self.base_prompt;
let start = (*start).min(base.len());
let end = (*end).min(base.len());
if start > end {
return base.clone();
}
format!("{}{}{}", &base[..start], variant, &base[end..])
}
MutationTarget::SystemPrompt => {
variant.to_string()
}
MutationTarget::Temperature => {
self.base_prompt.clone()
}
}
}
pub fn run_experiment_simulated(&self, spec: &MutationSpec) -> ExperimentResult {
let mut results: Vec<VariantResult> = spec
.variants
.iter()
.map(|v| {
let prompt = self.apply_variant(&spec.target, v);
let metric_value = simulate_metric(v, &spec.metric);
let mut stats = HashMap::new();
stats.insert("simulated".to_string(), 1.0);
stats.insert("variant_len".to_string(), v.len() as f64);
VariantResult {
variant: v.clone(),
prompt,
metric_value,
stats,
}
})
.collect();
results.sort_by(|a, b| {
a.metric_value
.partial_cmp(&b.metric_value)
.unwrap_or(std::cmp::Ordering::Equal)
});
ExperimentResult {
base_prompt: self.base_prompt.clone(),
spec: spec.clone(),
results,
}
}
pub fn run_experiment_with_values(
&self,
spec: &MutationSpec,
measured_values: &[f64],
) -> ExperimentResult {
assert_eq!(
measured_values.len(),
spec.variants.len(),
"measured_values must have the same length as spec.variants"
);
let mut results: Vec<VariantResult> = spec
.variants
.iter()
.zip(measured_values.iter())
.map(|(v, &mv)| {
let prompt = self.apply_variant(&spec.target, v);
let mut stats = HashMap::new();
stats.insert("variant_len".to_string(), v.len() as f64);
VariantResult {
variant: v.clone(),
prompt,
metric_value: mv,
stats,
}
})
.collect();
results.sort_by(|a, b| {
a.metric_value
.partial_cmp(&b.metric_value)
.unwrap_or(std::cmp::Ordering::Equal)
});
ExperimentResult {
base_prompt: self.base_prompt.clone(),
spec: spec.clone(),
results,
}
}
}
fn simulate_metric(variant: &str, metric: &MutationMetric) -> f64 {
let hash = fnv1a_hash(variant.as_bytes());
let normalized = (hash as f64) / (u64::MAX as f64); match metric {
MutationMetric::Perplexity => 1.0 + normalized * 10.0, MutationMetric::OutputLength => (normalized * 500.0).floor(),
MutationMetric::TopTokenChange => if normalized > 0.5 { 1.0 } else { 0.0 },
MutationMetric::SentimentShift => normalized * 2.0 - 1.0, }
}
fn fnv1a_hash(data: &[u8]) -> u64 {
let mut hash: u64 = 14_695_981_039_346_656_037;
for &byte in data {
hash ^= byte as u64;
hash = hash.wrapping_mul(1_099_511_628_211);
}
hash
}
fn truncate(s: &str, max_chars: usize) -> String {
let mut chars = s.chars();
let mut result = String::new();
for _ in 0..max_chars {
match chars.next() {
Some(c) => result.push(c),
None => return result,
}
}
if chars.next().is_some() {
result.push_str("...");
}
result
}
#[cfg(test)]
mod tests {
use super::*;
fn make_spec(metric: MutationMetric) -> MutationSpec {
MutationSpec {
target: MutationTarget::Word(1),
variants: vec!["alpha".to_string(), "beta".to_string(), "gamma".to_string()],
metric,
}
}
#[test]
fn apply_variant_word() {
let lab = MutationLab::new("The quick brown fox");
let result = lab.apply_variant(&MutationTarget::Word(1), "lazy");
assert_eq!(result, "The lazy brown fox");
}
#[test]
fn apply_variant_word_out_of_range() {
let lab = MutationLab::new("Hello world");
let result = lab.apply_variant(&MutationTarget::Word(5), "test");
assert_eq!(result, "Hello world");
}
#[test]
fn apply_variant_phrase() {
let lab = MutationLab::new("Hello world");
let result = lab.apply_variant(&MutationTarget::Phrase(6, 11), "Rust");
assert_eq!(result, "Hello Rust");
}
#[test]
fn apply_variant_system_prompt() {
let lab = MutationLab::new("User prompt");
let result = lab.apply_variant(&MutationTarget::SystemPrompt, "You are a pirate.");
assert_eq!(result, "You are a pirate.");
}
#[test]
fn apply_variant_temperature() {
let lab = MutationLab::new("My prompt");
let result = lab.apply_variant(&MutationTarget::Temperature, "0.9");
assert_eq!(result, "My prompt");
}
#[test]
fn run_experiment_simulated_sorted_ascending() {
let lab = MutationLab::new("Explain {topic} to a child.");
let spec = make_spec(MutationMetric::Perplexity);
let result = lab.run_experiment_simulated(&spec);
assert_eq!(result.results.len(), 3);
let vals: Vec<f64> = result.results.iter().map(|r| r.metric_value).collect();
for w in vals.windows(2) {
assert!(w[0] <= w[1], "Expected ascending order: {w:?}");
}
}
#[test]
fn run_experiment_with_values_correct_order() {
let lab = MutationLab::new("Base prompt");
let spec = MutationSpec {
target: MutationTarget::Word(0),
variants: vec!["A".to_string(), "B".to_string(), "C".to_string()],
metric: MutationMetric::OutputLength,
};
let values = vec![30.0, 10.0, 20.0];
let result = lab.run_experiment_with_values(&spec, &values);
assert_eq!(result.results[0].variant, "B");
assert_eq!(result.results[1].variant, "C");
assert_eq!(result.results[2].variant, "A");
}
#[test]
fn markdown_table_contains_required_sections() {
let lab = MutationLab::new("Prompt");
let spec = make_spec(MutationMetric::Perplexity);
let result = lab.run_experiment_simulated(&spec);
let md = result.to_markdown_table();
assert!(md.contains("Rank"), "table should have Rank column");
assert!(md.contains("Variant"), "table should have Variant column");
assert!(md.contains("Mean Perplexity"), "table should include metric name");
assert!(md.contains("alpha") || md.contains("beta") || md.contains("gamma"));
}
#[test]
fn best_and_worst_helpers() {
let lab = MutationLab::new("Prompt");
let spec = MutationSpec {
target: MutationTarget::Word(0),
variants: vec!["low".to_string(), "high".to_string()],
metric: MutationMetric::Perplexity,
};
let result = lab.run_experiment_with_values(&spec, &[1.5, 8.0]);
let best = result.best().expect("best should exist");
let worst = result.worst().expect("worst should exist");
assert!((best.metric_value - 1.5).abs() < f64::EPSILON);
assert!((worst.metric_value - 8.0).abs() < f64::EPSILON);
}
#[test]
fn fnv1a_hash_deterministic() {
let h1 = fnv1a_hash(b"hello");
let h2 = fnv1a_hash(b"hello");
assert_eq!(h1, h2);
let h3 = fnv1a_hash(b"world");
assert_ne!(h1, h3);
}
#[test]
fn truncate_short_string_unchanged() {
assert_eq!(truncate("hi", 10), "hi");
}
#[test]
fn truncate_long_string_adds_ellipsis() {
let s = "a".repeat(50);
let t = truncate(&s, 10);
assert!(t.ends_with("..."), "expected ellipsis, got: {t}");
assert!(t.len() <= 13, "should not be much longer than max");
}
}