experiment API

Luna-Flow/mare_mark/experiment holds the correctness side of a benchmark: reference and relational oracles, input shrinking, and the crossover analysis that turns per-scale verdicts into a scale boundary. The runner uses the oracles and shrinkers; you can also call them directly. See the experiment design.

Source: src/experiment/experiment.mbt.

import {
  "Luna-Flow/mare_mark/model",
  "Luna-Flow/mare_mark/experiment",
}

Oracles

ReferenceOracle

ReferenceOracle computes the expected result of every step and judges an implementation’s outcome against it.

pub struct ReferenceOracle[Input, Expected, Output, Context] {
  id : String
  initial_context : () -> Context
  sequence_length : (Input) -> Int
  compute_expected : (Input, Int, Context) -> @model.OperationResult[Expected, Context]
  validate : (Input, Int, @model.ExecutionOutcome[Expected], @model.ExecutionOutcome[Output]) -> @model.ValidationStatus
  expected_text : (Expected) -> String
}
pub fn[Input, Expected, Output, Context] ReferenceOracle::new(String, () -> Context, (Input) -> Int, (Input, Int, Context) -> @model.OperationResult[Expected, Context], (Input, Int, @model.ExecutionOutcome[Expected], @model.ExecutionOutcome[Output]) -> @model.ValidationStatus, (Expected) -> String) -> Self[Input, Expected, Output, Context]

compute_expected(input, step, context) returns the expected outcome of step step and the oracle’s next context; the sequence stops when it returns no context. validate(input, step, expected, actual) returns the verdict. expected_text renders expected values in evidence. The runner uses the case’s sequence_length, not the oracle’s sequence_length function.

ReferenceOracle::equal

ReferenceOracle::equal builds a one-step oracle from a reference function and an equality.

pub fn[Input, Expected, Output] ReferenceOracle::equal(String, (Input) -> Expected, (Expected, Output) -> Bool) -> Self[Input, Expected, Output, Unit]

The verdict is Valid when both outcomes are Value and the comparator accepts them, Invalid("value mismatch") when the comparator rejects them, and Invalid("outcome mismatch") for any other pair of outcomes. Expected values are rendered as "<expected>".

test "an equality oracle with a tolerance" {
  let oracle = @experiment.ReferenceOracle::equal(
    "sqrt",
    (x : Double) => x.sqrt(),
    (expected : Double, actual : Double) => (expected - actual).abs() <= 1.0e-12,
  )
  let ok = @experiment.validate_reference(oracle, 2.0, 0, Value(2.0.sqrt()), Value(1.4142135623731), "fast", "2")
  let bad = @experiment.validate_reference(oracle, 2.0, 0, Value(2.0.sqrt()), Value(1.5), "fast", "2")
  inspect(ok.status is Valid, content="true")
  inspect(bad.status is Invalid("value mismatch"), content="true")
}

validate_reference

validate_reference applies a reference oracle to one step and wraps the verdict in a Validation without evidence.

pub fn[Input, Expected, Output, Context] validate_reference(ReferenceOracle[Input, Expected, Output, Context], Input, Int, @model.ExecutionOutcome[Expected], @model.ExecutionOutcome[Output], String, String) -> @model.Validation

Arguments: oracle, input, step, expected outcome, actual outcome, implementation id, scale text.

RelationalOracle

RelationalOracle judges two implementations against each other when there is no single reference.

pub struct RelationalOracle[Input, Output] {
  id : String
  validate_pair : (Input, Int, String, @model.ExecutionOutcome[Output], String, @model.ExecutionOutcome[Output]) -> @model.ValidationStatus
}
pub fn[Input, Output] RelationalOracle::new(String, (Input, Int, String, @model.ExecutionOutcome[Output], String, @model.ExecutionOutcome[Output]) -> @model.ValidationStatus) -> Self[Input, Output]

validate_pair(input, step, left_id, left, right_id, right) returns the verdict for the pair; the runner attributes it to the right implementation.

test "a relational oracle" {
  let agree = @experiment.RelationalOracle::new("agree", (_ : Int, _, _, left : @model.ExecutionOutcome[Int], _, right) => {
    match (left, right) {
      (Value(a), Value(b)) if a == b => Valid
      (_, Unsupported(reason)) => Unsupported(reason)
      _ => Invalid("implementations disagree")
    }
  })
  inspect((agree.validate_pair)(1, 0, "a", Value(3), "b", Value(3)) is Valid, content="true")
  inspect((agree.validate_pair)(1, 0, "a", Value(3), "b", Value(4)) is Invalid(_), content="true")
}

OracleSpec

OracleSpec selects the oracles of a case.

pub(all) enum OracleSpec[Input, Expected, Output, Context] {
  Reference(ReferenceOracle[Input, Expected, Output, Context])
  Relational(RelationalOracle[Input, Output])
  ReferenceAndRelational(ReferenceOracle[Input, Expected, Output, Context], RelationalOracle[Input, Output])
}

With ReferenceAndRelational the runner runs both checks and reports each separately.

Shrinking

Shrinker

Shrinker proposes smaller variants of a failing input.

pub struct Shrinker[Input] {
  candidates : (Input) -> Array[Input]
  text : (Input) -> String
  max_steps : Int
}
pub fn[Input] Shrinker::new((Input) -> Array[Input], (Input) -> String, max_steps? : Int) -> Self[Input]

candidates(x) lists smaller inputs, most aggressive first. text renders an accepted candidate for the shrink path. max_steps (default 128) bounds the number of candidates tried.

shrink

shrink minimizes an input while a predicate keeps holding.

pub fn[Input] shrink(Input, Shrinker[Input], (Input) -> Bool) -> (Input, Array[String])

Starting from the input, it repeatedly takes the first candidate for which the predicate is true, until no candidate satisfies it or max_steps candidates have been tried. It returns the last accepted input and the texts of the accepted candidates in order. The initial input is not tested.

test "shrink a failing size" {
  let shrinker = @experiment.Shrinker::new(
    (n : Int) => if n <= 1 { [] } else { [n / 2, n - 1] },
    n => n.to_string(),
  )
  let (smallest, path) = @experiment.shrink(64, shrinker, n => n >= 5)
  inspect(smallest, content="5")
  debug_inspect(path, content="[\"32\", \"16\", \"8\", \"7\", \"6\", \"5\"]")
}

Crossover analysis

ScaleDomain

ScaleDomain lists the scales of an analysis with their order and text.

pub struct ScaleDomain[Scale] {
  values : Array[Scale]
  compare : (Scale, Scale) -> Int
  text : (Scale) -> String
}
pub fn[Scale] ScaleDomain::new(Array[Scale], (Scale, Scale) -> Int, (Scale) -> String) -> Self[Scale]

crossover_from_labels reads values in the given order and does not sort them; pass them sorted.

comparator_label

comparator_label turns a relative delta into a label for crossover analysis.

pub fn comparator_label(Double, Double) -> String

comparator_label(r, t) is "A" when r≤−tr \le -t, "B" when r≥tr \ge t and "Unknown" otherwise. Pass the relative delta of A measured against B as the baseline, so that "A" means A is faster.

crossover_from_labels

crossover_from_labels finds the scale at which the preferred implementation changes.

pub fn[Scale] crossover_from_labels(ScaleDomain[Scale], Array[String]) -> @model.CrossoverResult[Scale]

labels[i] is the verdict at values[i]. A transition is a pair of adjacent labels that differ and are both not "Unknown".

SituationResult
no labels, or a different number of labels and valuesInconclusive("label/domain length mismatch", labels)
no transitionNoCrossover("no stable label transition", labels)
exactly one transition, between i−1i-1 and iiFound(ScaleBoundary(values[i-1], values[i]), "piecewise-confirmed", labels)
more than one transitionNonMonotonic(labels)
test "find a crossover" {
  let domain = @experiment.ScaleDomain::new([16, 64, 256, 1024], (a, b) => a.compare(b), n => n.to_string())
  let labels = [-12.0, -4.0, 6.0, 15.0].map(r => @experiment.comparator_label(r, 3.0))
  debug_inspect(labels, content="[\"A\", \"A\", \"B\", \"B\"]")
  guard @experiment.crossover_from_labels(domain, labels) is Found(boundary, _, _) else {
    fail("expected a crossover")
  }
  inspect(boundary.below, content="64")
  inspect(boundary.at_or_above, content="256")
}