use handwired_rs::{BackpropOptions, BooleanGradient, Circuit, Error, IndexGradient, OutOfRange, Wire};
fn gradients_of(circuit: &Circuit, source: &[f32], output: Wire, options: BackpropOptions) -> Result<Vec<f32>, Error> {
let values = circuit.evaluate_reference(source).unwrap();
let mut seed = vec![0.0; circuit.width()];
seed[output.index()] = 1.0;
circuit.backpropagate_reference(&values, &seed, options)
}
#[test]
fn products_and_selects_are_differentiated_as_a_whole() {
let mut circuit = Circuit::new(3);
let (a, b, bit) = (circuit.source(0), circuit.source(1), circuit.source(2));
circuit.assume_range(a, -10.0, 10.0);
circuit.assume_range(b, -10.0, 10.0);
circuit.assume_bit(bit);
let hinge = circuit.relu(a * 2.0 - b);
let product = circuit.multiply(hinge, a);
let chosen = circuit.select(bit, b, a);
let z = circuit.affine(product + chosen);
let gradients = gradients_of(&circuit, &[3.0, 1.0, 1.0], z, BackpropOptions::default()).unwrap();
assert_eq!(&gradients[..3], &[12.0, -3.0, 2.0]);
let gradients = gradients_of(&circuit, &[-3.0, 1.0, 0.0], z, BackpropOptions::default()).unwrap();
assert_eq!(&gradients[..3], &[0.0, 1.0, -4.0]);
}
#[test]
fn boolean_atoms_need_an_explicit_gradient_rule() {
let mut circuit = Circuit::new(1);
let score = circuit.source(0);
circuit.assume_range(score, 0.0, 1.0);
let bit = circuit.boolean(score);
let doubled = circuit.affine(bit * 2.0);
assert!(matches!(
gradients_of(&circuit, &[0.7], doubled, BackpropOptions::default()),
Err(Error::BooleanGradient { .. })
));
let options = BackpropOptions { boolean_gradient: BooleanGradient::StraightThrough, ..Default::default() };
assert_eq!(gradients_of(&circuit, &[0.7], doubled, options).unwrap()[0], 2.0);
}
#[test]
fn index_reads_route_gradient_to_the_selected_candidate() {
let mut circuit = Circuit::new(4);
let index = circuit.source(0);
circuit.assume_range(index, 0.0, 2.0);
let table: Vec<Wire> = (1..4).map(|position| circuit.source(position)).collect();
let read = circuit.index_read(index, &table, OutOfRange::Clamp);
let source = [1.0, 10.0, 20.0, 40.0];
assert!(matches!(
gradients_of(&circuit, &source, read, BackpropOptions::default()),
Err(Error::IndexGradient { .. })
));
let zero = BackpropOptions { index_gradient: IndexGradient::Zero, ..Default::default() };
assert_eq!(gradients_of(&circuit, &source, read, zero).unwrap(), vec![0.0, 0.0, 1.0, 0.0, 1.0]);
let difference = BackpropOptions { index_gradient: IndexGradient::BackwardDifference, ..Default::default() };
assert_eq!(gradients_of(&circuit, &source, read, difference).unwrap()[0], 10.0);
}
#[handwired_rs::handwired]
fn hit_damage(#[range(1, 20)] params: [f32; 6], #[range(0, 2)] faction: u8, #[range(0, 1)] stat: u8) -> f32 {
let index = (faction * 2 + stat) as usize;
params[index] * 3.0 - 1.0
}
#[test]
fn gradients_reach_the_table_entry_a_computed_index_reads() {
use handwired_rs::dsl::Params;
let compiled = hit_damage_circuit();
compiled.verify(2_000).unwrap();
let circuit = &compiled.circuit;
let output = compiled.outputs[0];
let options = BackpropOptions { index_gradient: IndexGradient::Zero, ..Default::default() };
let params = [2.0, 4.0, 6.0, 8.0, 10.0, 12.0];
for faction in 0..3u8 {
for stat in 0..2u8 {
let mut source = Vec::new();
(params, faction, stat).encode(&mut source);
let gradients = gradients_of(circuit, &source, output, options).unwrap();
let selected = usize::from(faction * 2 + stat);
let want: Vec<f32> = (0..6).map(|cell| if cell == selected { 3.0 } else { 0.0 }).collect();
assert_eq!(&gradients[..6], &want[..], "faction {faction}, stat {stat}");
}
}
let mut source = Vec::new();
(params, 1u8, 0u8).encode(&mut source);
assert!(matches!(
gradients_of(circuit, &source, output, BackpropOptions::default()),
Err(Error::IndexGradient { .. })
));
}
#[test]
fn batched_gradients_sum_the_per_record_backward_passes() {
use handwired_rs::dsl::Params;
let compiled = hit_damage_circuit();
let options = BackpropOptions { index_gradient: IndexGradient::Zero, ..Default::default() };
let records: Vec<([f32; 6], u8, u8)> = (0..997u32)
.map(|n| {
(std::array::from_fn(|cell| 1.0 + ((n as usize * 7 + cell * 3) % 19) as f32), (n % 3) as u8, (n % 2) as u8)
})
.collect();
let weights: Vec<f32> = (0..records.len()).map(|n| [0.5, -1.0, 2.0, 0.0][n % 4]).collect();
let batched = compiled.gradients(&records, 0, Some(&weights), options).unwrap();
let mut expected = vec![0f64; 8];
for (record, &weight) in records.iter().zip(&weights) {
let mut source = Vec::new();
record.encode(&mut source);
let gradients = gradients_of(&compiled.circuit, &source, compiled.outputs[0], options).unwrap();
for (sum, gradient) in expected.iter_mut().zip(&gradients[..8]) {
*sum += (weight * gradient) as f64;
}
}
assert_eq!(batched, expected);
let unweighted = compiled.gradients(&records, 0, None, options).unwrap();
assert_eq!(unweighted[..6].iter().sum::<f64>(), 3.0 * records.len() as f64);
}
#[test]
fn surrogate_gradients_go_through_comparisons_to_their_operands() {
use handwired_rs::{CompareOptions, ComparisonGradient, Relation, emit_compare_with};
let width = 0.5;
let surrogate =
BackpropOptions { comparison_gradient: ComparisonGradient::Surrogate { width }, ..Default::default() };
let sigmoid = |d: f64| 1.0 / (1.0 + (-d / width).exp());
for (grid, end_with_boolean) in [(Some(1.0), false), (None, false), (Some(1.0), true), (None, true)] {
for relation in [Relation::Greater, Relation::AtLeast, Relation::Less, Relation::AtMost, Relation::Equal] {
let mut circuit = Circuit::new(2);
let (a, b) = (circuit.source(0), circuit.source(1));
for wire in [a, b] {
circuit.assume_range(wire, -100.0, 100.0);
if let Some(step) = grid {
circuit.assume_grid(wire, step);
}
}
let bit = emit_compare_with(&mut circuit, a, relation, b, CompareOptions { end_with_boolean });
for (x, y) in [(3.0f32, 2.0f32), (2.0, 3.0), (5.0, 5.0), (-7.0, 4.0)] {
let gradients = gradients_of(&circuit, &[x, y], bit, surrogate).unwrap();
let d = match relation {
Relation::Less | Relation::AtMost => (y - x) as f64,
_ => (x - y) as f64,
};
let s = sigmoid(d);
let slope = if relation == Relation::Equal {
4.0 * s * (1.0 - s) * (1.0 - 2.0 * s) / width
} else {
s * (1.0 - s) / width
};
let (da, db) = match relation {
Relation::Less | Relation::AtMost => (-slope, slope),
_ => (slope, -slope),
};
let close = |got: f32, want: f64| {
assert!(
(got as f64 - want).abs() <= 1e-6 * want.abs().max(1.0),
"{relation:?} {grid:?} {end_with_boolean}: {got} vs {want}"
)
};
close(gradients[0], da);
close(gradients[1], db);
}
}
}
}
#[test]
fn surrogate_gradients_reach_through_max_and_thresholds_in_handwired_code() {
use handwired_rs::ComparisonGradient;
let compiled = threshold_rule_circuit();
compiled.verify(2_000).unwrap();
let surrogate =
BackpropOptions { comparison_gradient: ComparisonGradient::Surrogate { width: 1.0 }, ..Default::default() };
let exact = compiled.gradients(&[(10.0, 9.5)], 0, None, BackpropOptions::default()).unwrap();
let smooth = compiled.gradients(&[(10.0, 9.5)], 0, None, surrogate).unwrap();
assert_eq!(exact[0], 0.0);
assert!(smooth[0] < 0.0 && smooth[1] > 0.0, "{smooth:?}");
let far = compiled.gradients(&[(100.0, 1.0)], 0, None, surrogate).unwrap();
assert!(far[0].abs() < 1e-30, "{far:?}");
}
#[handwired_rs::handwired]
fn threshold_rule(#[range(0, 100)] health: f32, #[range(0, 100)] damage: f32) -> f32 {
let left = (health - damage).max(0.0);
if left <= 0.0 { 1.0 } else { 0.0 }
}
#[handwired_rs::handwired]
fn meets(#[range(1, 7)] threshold: f32, #[range(1, 6)] roll: f32) -> f32 {
if roll >= threshold { 1.0 } else { 0.0 }
}
#[test]
fn a_threshold_tunes_only_with_surrogate_gradients() {
use handwired_rs::ComparisonGradient;
let compiled = meets_circuit();
compiled.verify(2_000).unwrap();
let tune = |options: BackpropOptions| -> f32 {
let mut threshold = 1.5f32;
for _ in 0..200 {
let records: Vec<(f32, f32)> = (1..=6).map(|roll| (threshold, roll as f32)).collect();
let rate = compiled.run_batch(&records).unwrap().iter().sum::<f32>() / 6.0;
let weights = vec![2.0 * (rate - 0.5) / 6.0; 6];
let gradient = compiled.gradients(&records, 0, Some(&weights), options).unwrap()[0];
threshold = (threshold as f64 - 2.0 * gradient).clamp(1.0, 7.0) as f32;
}
threshold
};
assert_eq!(tune(BackpropOptions::default()), 1.5);
let surrogate =
BackpropOptions { comparison_gradient: ComparisonGradient::Surrogate { width: 0.5 }, ..Default::default() };
let tuned = tune(surrogate);
assert!(tuned > 3.0 && tuned <= 4.0, "{tuned}");
}
#[test]
fn relative_surrogate_widths_follow_each_comparisons_own_range() {
use handwired_rs::{CompareOptions, ComparisonGradient, Relation, emit_compare_with};
let mut circuit = Circuit::new(4);
let (roll, needed, health, damage) = (circuit.source(0), circuit.source(1), circuit.source(2), circuit.source(3));
for (wire, high) in [(roll, 6.0), (needed, 6.0), (health, 200.0), (damage, 200.0)] {
circuit.assume_range(wire, if high == 6.0 { 1.0 } else { 0.0 }, high);
}
let hits = emit_compare_with(&mut circuit, roll, Relation::AtLeast, needed, CompareOptions::default());
let dies = emit_compare_with(&mut circuit, damage, Relation::AtLeast, health, CompareOptions::default());
let fraction = 0.1;
let relative = BackpropOptions {
comparison_gradient: ComparisonGradient::RelativeSurrogate { fraction },
..Default::default()
};
let source = [4.0f32, 3.0, 50.0, 47.0];
let slope = |d: f64, width: f64| {
let s = 1.0 / (1.0 + (-d / width).exp());
s * (1.0 - s) / width
};
let dice = gradients_of(&circuit, &source, hits, relative).unwrap();
assert!(
(dice[0] as f64 - slope(1.0, 1.0)).abs() < 1e-6 && (dice[1] as f64 + slope(1.0, 1.0)).abs() < 1e-6,
"{dice:?}"
);
let health_gradients = gradients_of(&circuit, &source, dies, relative).unwrap();
assert!((health_gradients[3] as f64 - slope(-3.0, 40.0)).abs() < 1e-6, "{health_gradients:?}");
assert!((health_gradients[2] as f64 + slope(-3.0, 40.0)).abs() < 1e-6);
let compiled = meets_circuit();
let mut threshold = 1.5f32;
for _ in 0..200 {
let records: Vec<(f32, f32)> = (1..=6).map(|roll| (threshold, roll as f32)).collect();
let rate = compiled.run_batch(&records).unwrap().iter().sum::<f32>() / 6.0;
let weights = vec![2.0 * (rate - 0.5) / 6.0; 6];
let options = BackpropOptions {
comparison_gradient: ComparisonGradient::RelativeSurrogate { fraction: 0.05 },
..Default::default()
};
let gradient = compiled.gradients(&records, 0, Some(&weights), options).unwrap()[0];
threshold = (threshold as f64 - 2.0 * gradient).clamp(1.0, 7.0) as f32;
}
assert!(threshold > 3.0 && threshold <= 4.0, "{threshold}");
}