experiment tutorial
This tutorial covers the correctness tools of a benchmark: an oracle with a tolerance, a relational check between implementations, a shrinker that makes failures small, and a crossover analysis that tells you at which size to switch implementations. Every example is a complete test.
Quick start
moon add Luna-Flow/mare_mark@0.3.0
import {
"Luna-Flow/mare_mark/model",
"Luna-Flow/mare_mark/experiment",
}
test "is the fast sum right?" {
let oracle = @experiment.ReferenceOracle::equal(
"exact-sum",
(xs : Array[Int]) => xs.fold(init=0, (a, b) => a + b),
(expected : Int, actual : Int) => expected == actual,
)
let input = [1, 2, 3, 4]
let verdict = @experiment.validate_reference(oracle, input, 0, Value(10), Value(10), "fast-sum", "4")
inspect(verdict.status is Valid, content="true")
}
Everyday tasks
Compare floating-point results with a tolerance
A reassociated sum differs from the left-to-right sum in the last bits. Accept a relative error instead of demanding equality:
test "relative tolerance" {
let close = (expected : Double, actual : Double) => {
(expected - actual).abs() <= 1.0e-12 * expected.abs().max(1.0)
}
let oracle = @experiment.ReferenceOracle::equal("sum", (xs : Array[Double]) => xs.fold(init=0.0, (a, b) => a + b), close)
let xs = [0.1, 0.2, 0.3]
let pairwise = xs[0] + (xs[1] + xs[2])
let verdict = @experiment.validate_reference(oracle, xs, 0, Value(0.1 + 0.2 + 0.3), Value(pairwise), "pairwise", "3")
inspect(verdict.status is Valid, content="true")
}
Check implementations against each other
Without a trusted reference, check that implementations agree:
test "implementations must agree" {
let agree : @experiment.RelationalOracle[Int, Int] = @experiment.RelationalOracle::new("agree", (_, _, _, left, _, right) => {
match (left, right) {
(Value(a), Value(b)) => if a == b { Valid } else { Invalid("results differ") }
_ => Invalid("missing result")
}
})
let spec : @experiment.OracleSpec[Int, Int, Int, Unit] = Relational(agree)
guard spec is Relational(oracle) else { fail("unexpected spec") }
inspect((oracle.validate_pair)(5, 0, "loop", Value(10), "formula", Value(10)) is Valid, content="true")
}
Pass the OracleSpec to @runner.BenchSpec::advanced; the runner checks
every pair of implementations.
Make a failure small
A list function fails for every list containing a negative number. Shrink the failing input by dropping elements:
test "shrink a list" {
let shrinker = @experiment.Shrinker::new(
(xs : Array[Int]) => Array::makei(xs.length(), i => {
let smaller = xs.copy()
smaller.remove(i) |> ignore
smaller
}),
xs => xs.length().to_string(),
)
let failing = [3, 8, -2, 7, 1]
let (minimal, _) = @experiment.shrink(failing, shrinker, xs => xs.any(x => x < 0))
debug_inspect(minimal, content="[-2]")
}
The runner calls the same algorithm when a BenchSpec has a shrinker, and
writes the minimal input into the failure event.
Find where to switch implementations
Compare two implementations at several sizes, label each size, and look for one clean transition:
test "crossover between two algorithms" {
let sizes = [8, 32, 128, 512, 2048]
let relative_delta_of_insertion_vs_merge = [-35.0, -12.0, 1.0, 18.0, 60.0]
let labels = relative_delta_of_insertion_vs_merge.map(r => @experiment.comparator_label(r, 5.0))
debug_inspect(labels, content="[\"A\", \"A\", \"Unknown\", \"B\", \"B\"]")
let domain = @experiment.ScaleDomain::new(sizes, (a, b) => a.compare(b), n => n.to_string())
let result = @experiment.crossover_from_labels(domain, labels)
inspect(result is NoCrossover(_, _), content="true")
let sharper = [-35.0, -12.0, -6.0, 18.0, 60.0].map(r => @experiment.comparator_label(r, 5.0))
guard @experiment.crossover_from_labels(domain, sharper) is Found(boundary, _, _) else { fail("none") }
inspect(boundary.at_or_above, content="512")
}
The first sequence has an Unknown between the A and B regions; the
analysis does not guess where in that gap the boundary lies and reports no
crossover. Measure the gap more finely, or decide the policy yourself.
Going further
- Turn a
Foundboundary into a@model.DeploymentPolicy::Piecewise; see the model tutorial. - Combine a reference and a relational oracle with
ReferenceAndRelational. - The experiment design proves that shrinking terminates and keeps the failure.
Common pitfalls
- Unsorted scales.
crossover_from_labelsuses the order you give. - Equality for floating-point results. Use a tolerance that matches the algorithm’s error bound.
- Shrinkers that grow the input. Candidates should be smaller, or the budget is spent without progress.
- Reading
NonMonotonicas a crossover. It is not; the preference flips.
Next steps
- experiment API, experiment design.
- runner tutorial for oracles inside a run.