use std::collections::{HashMap, hash_map::Entry};
use rand::RngExt;
use crate::backend::{DataSource, Exploration, Failure, RunError, TestCaseResult, TestRunner};
use crate::native::core::{
BUFFER_SIZE, ChoiceNode, ChoiceValue, MAX_SHRINKING_SECONDS, NativeTestCase, Span, Spans,
Status, sort_key,
};
use crate::native::data_source::NativeDataSource;
use crate::native::database::{
DirectoryTestCaseDatabase, TestCaseDatabase, deserialize_choices, serialize_choices,
};
use crate::native::rng::EngineRng;
use crate::native::shrinker::{ShrinkRun, Shrinker};
use crate::runner::{Backend, Database, HealthCheck, Phase, Settings, Verbosity};
#[derive(Clone)]
pub struct RunResult {
pub status: Status,
pub nodes: Vec<ChoiceNode>,
pub spans: Vec<Span>,
pub origin: Option<String>,
pub target_observations: HashMap<String, f64>,
}
const RANDOM_GENERATION_BATCH: u64 = 10;
const SPAN_MUTATION_ATTEMPTS: usize = 5;
const FILTER_TOO_MUCH_THRESHOLD: u64 = 50;
const INVALID_TARGET_RATE: f64 = 0.01;
const INVALID_TARGET_CONFIDENCE: f64 = 0.99;
const TOO_SLOW_THRESHOLD: std::time::Duration = std::time::Duration::from_secs(30);
const HEALTH_CHECK_MAX_VALID: u64 = 10;
const MAX_OVERRUN_DRAWS: u64 = 20;
#[derive(Debug)]
pub struct ShrunkCounterexample {
choices: Vec<ChoiceValue>,
nodes: Option<Vec<ChoiceNode>>,
blob: String,
}
pub(crate) fn replay_counterexample(
counterexample: ShrunkCounterexample,
run_case: &mut dyn FnMut(Box<dyn DataSource + Send + Sync>),
) -> Option<Failure> {
let ntc = NativeTestCase::for_choices(
&counterexample.choices,
counterexample.nodes.as_deref(),
None,
);
let (data_source, handle) = NativeDataSource::new(ntc);
run_case(Box::new(data_source));
match NativeDataSource::take_outcome(&handle) {
TestCaseResult::Interesting(mut failure) => {
failure.reproduce_blob = Some(counterexample.blob);
Some(failure)
}
_ => None,
}
}
pub struct NativeTestRunner;
impl TestRunner for NativeTestRunner {
type Counterexample = ShrunkCounterexample;
fn explore(
&self,
settings: &Settings,
database_key: Option<&str>,
run_case: &mut dyn FnMut(Box<dyn DataSource + Send + Sync>),
) -> Result<Exploration<ShrunkCounterexample>, RunError> {
run_main(
settings,
database_key,
run_case,
TOO_SLOW_THRESHOLD,
std::time::Duration::from_secs(MAX_SHRINKING_SECONDS),
)
}
fn replay_final(
&self,
counterexample: ShrunkCounterexample,
run_case: &mut dyn FnMut(Box<dyn DataSource + Send + Sync>),
) -> Result<Failure, RunError> {
replay_counterexample(counterexample, run_case)
.ok_or_else(|| RunError::Flaky(flaky_diagnostic()))
}
}
pub(crate) struct ReproduceRunner {
pub(crate) blob: String,
}
impl TestRunner for ReproduceRunner {
type Counterexample = ShrunkCounterexample;
fn explore(
&self,
_settings: &Settings,
_database_key: Option<&str>,
_run_case: &mut dyn FnMut(Box<dyn DataSource + Send + Sync>),
) -> Result<Exploration<ShrunkCounterexample>, RunError> {
let Some(choices) = crate::native::blob::decode_failure(&self.blob) else {
panic!(
"reproduce_failure: the supplied failure blob could not be decoded. \
It may be corrupt or from an incompatible Hegel version."
);
};
Ok(Exploration::Counterexamples(vec![ShrunkCounterexample {
choices,
nodes: None,
blob: self.blob.clone(),
}]))
}
fn replay_final(
&self,
counterexample: ShrunkCounterexample,
run_case: &mut dyn FnMut(Box<dyn DataSource + Send + Sync>),
) -> Result<Failure, RunError> {
replay_counterexample(counterexample, run_case).ok_or_else(|| {
RunError::StaleBlob(
"reproduce_failure: the supplied failure blob no longer \
reproduces a failure. The failure may have been fixed, or \
the blob is stale."
.to_string(),
)
})
}
}
pub(crate) fn run_single_case(
settings: &Settings,
database_key: Option<&str>,
run_case: &mut dyn FnMut(Box<dyn DataSource + Send + Sync>),
) -> Option<Failure> {
let mut rng = create_rng(settings, database_key);
let ntc = NativeTestCase::new_random(rng.spawn());
let (data_source, handle) = NativeDataSource::new(ntc);
run_case(Box::new(data_source));
match NativeDataSource::take_outcome(&handle) {
TestCaseResult::Interesting(failure) => Some(failure),
_ => None,
}
}
fn run_main(
settings: &Settings,
database_key: Option<&str>,
run_case: &mut dyn FnMut(Box<dyn DataSource + Send + Sync>),
too_slow_threshold: std::time::Duration,
shrink_budget: std::time::Duration,
) -> Result<Exploration<ShrunkCounterexample>, RunError> {
Engine::new(settings, database_key, run_case).run(too_slow_threshold, shrink_budget)
}
impl<'a> Engine<'a> {
fn run(
&mut self,
too_slow_threshold: std::time::Duration,
shrink_budget: std::time::Duration,
) -> Result<Exploration<ShrunkCounterexample>, RunError> {
let settings = self.settings;
let database_key = self.database_key;
let max_test_cases = settings.test_cases;
let verbosity = settings.verbosity;
let mut target_schedule = crate::native::targeting::TargetingSchedule::new(max_test_cases);
let target_enabled = settings.phases.contains(&Phase::Target);
let invalid_budget = invalid_thresholds(INVALID_TARGET_RATE, INVALID_TARGET_CONFIDENCE);
let mut replay_aligned = false;
let report_multiple = settings.report_multiple_failures;
if settings.phases.contains(&Phase::Reuse) {
if let (Some(_), Some(key)) = (self.db(), database_key) {
let key_bytes = key.as_bytes().to_vec();
let secondary_key = crate::native::data_tree::sub_key(&key_bytes, b"secondary");
let mut values = self.db().map(|db| db.fetch(&key_bytes)).unwrap_or_default();
values.sort_by(|a, b| shortlex(a, b));
replay_aligned = !values.is_empty();
let primary_count = values.len();
let desired_factor = if settings.phases.contains(&Phase::Generate) {
0.1
} else {
1.0
};
let desired_size =
(((max_test_cases as f64) * desired_factor).ceil() as usize).max(2);
if values.len() < desired_size {
let mut extra = self
.db()
.map(|db| db.fetch(&secondary_key))
.unwrap_or_default();
let shortfall = desired_size - values.len();
if extra.len() > shortfall {
for i in 0..shortfall {
let j = self.rng.random_range(i..extra.len());
extra.swap(i, j);
}
extra.truncate(shortfall);
}
extra.sort_by(|a, b| shortlex(a, b));
values.extend(extra);
}
let mut found_interesting_in_primary = false;
for (i, raw) in values.into_iter().enumerate() {
if i >= primary_count && found_interesting_in_primary {
break;
}
let Some(stored_choices) = deserialize_choices(&raw) else {
if let Some(db) = self.db() {
db.delete(&key_bytes, &raw);
db.delete(&secondary_key, &raw);
}
continue;
};
let ntc =
NativeTestCase::for_probe(&stored_choices, self.rng.spawn(), BUFFER_SIZE);
let (run, mismatch) = self.test_function(ntc);
if let Some(msg) = mismatch {
return Err(RunError::NonDeterministic(msg));
}
if run.status == Status::Interesting {
if i < primary_count {
found_interesting_in_primary = true;
if run.nodes.len() != stored_choices.len() {
replay_aligned = false;
}
} else {
replay_aligned = false;
}
if !report_multiple {
break;
}
} else {
if let Some(db) = self.db() {
db.delete(&key_bytes, &raw);
db.delete(&secondary_key, &raw);
}
}
}
if self.interesting.is_empty() {
replay_aligned = false;
}
}
}
let shrink_enabled = settings.phases.contains(&Phase::Shrink);
let found_in_reuse = !self.interesting.is_empty();
if settings.phases.contains(&Phase::Generate)
&& !self.test_is_trivial
&& self.within_invalid_budget(invalid_budget)
&& !found_in_reuse
{
let (run, mismatch) = self.test_function(NativeTestCase::for_simplest(BUFFER_SIZE));
if let Some(msg) = mismatch {
return Err(RunError::NonDeterministic(msg));
}
if let Some(msg) = large_initial_check(
run.status == Status::EarlyStop,
run.status,
run.nodes.len(),
settings
.suppress_health_check
.contains(&HealthCheck::LargeInitialTestCase),
) {
return Err(RunError::HealthCheck(msg));
}
}
while settings.phases.contains(&Phase::Generate)
&& !found_in_reuse
&& !self.test_is_trivial
&& self.valid_test_cases < max_test_cases
&& self.within_invalid_budget(invalid_budget)
&& !self.tree_root.is_exhausted
&& should_generate_more(
self.interesting.is_empty(),
self.calls,
self.first_bug_at,
self.last_bug_at,
shrink_enabled,
report_multiple,
self.first_bug_time.map(|t| t.elapsed()),
)
{
for _ in 0..RANDOM_GENERATION_BATCH {
if self.test_is_trivial
|| self.valid_test_cases >= max_test_cases
|| !self.within_invalid_budget(invalid_budget)
|| self.tree_root.is_exhausted
|| !should_generate_more(
self.interesting.is_empty(),
self.calls,
self.first_bug_at,
self.last_bug_at,
shrink_enabled,
report_multiple,
self.first_bug_time.map(|t| t.elapsed()),
)
{
break;
}
let batch_rng = self.rng.spawn();
let prefix =
crate::native::data_tree::generate_novel_prefix(&self.tree_root, &mut self.rng);
let ntc = if prefix.is_empty() {
NativeTestCase::new_random(batch_rng)
} else {
NativeTestCase::for_probe(&prefix, batch_rng, BUFFER_SIZE)
};
if verbosity == Verbosity::Verbose {
eprintln!("Running test case");
}
let (run, mismatch) = self.test_function(ntc);
if let Some(msg) = mismatch {
return Err(RunError::NonDeterministic(msg));
}
if verbosity == Verbosity::Debug {
eprintln!(
"test case #{}: status = {:?}, choices = {}",
self.calls,
run.status,
run.nodes.len()
);
}
if self.interesting.is_empty() {
if run.status == Status::Invalid
&& self.invalid_test_cases >= FILTER_TOO_MUCH_THRESHOLD
&& self.valid_test_cases < HEALTH_CHECK_MAX_VALID
&& !settings
.suppress_health_check
.contains(&HealthCheck::FilterTooMuch)
{
return Err(RunError::HealthCheck(format!(
"FailedHealthCheck: FilterTooMuch — it looks like this \
test is filtering out too many inputs. \
{} inputs were filtered out by assume() \
while only {} valid inputs were \
generated. If this is expected, suppress the check with \
suppress_health_check = [HealthCheck::FilterTooMuch].",
self.invalid_test_cases, self.valid_test_cases
)));
}
if let Some(msg) = too_large_check(
self.valid_test_cases,
self.overrun_test_cases,
settings
.suppress_health_check
.contains(&HealthCheck::TestCasesTooLarge),
) {
return Err(RunError::HealthCheck(msg));
}
if let Some(msg) = too_slow_check(
self.valid_test_cases,
self.total_test_time,
too_slow_threshold,
settings
.suppress_health_check
.contains(&HealthCheck::TooSlow),
) {
return Err(RunError::HealthCheck(msg));
}
}
if target_enabled
&& self.interesting.is_empty()
&& !self.targeting.is_empty()
&& target_schedule.should_fire(self.valid_test_cases)
{
let mut optimiser = crate::native::targeting::Optimiser {
engine: &mut *self,
max_valid: max_test_cases,
max_calls: max_test_cases * 10,
};
optimiser.optimise_targets();
}
if run.status == Status::Valid
&& (self.valid_test_cases >= HEALTH_CHECK_MAX_VALID
|| !self.interesting.is_empty())
{
self.try_span_mutation(&run.nodes, &run.spans);
}
}
}
if self.tree_root.is_exhausted
&& self.valid_test_cases == 0
&& self.interesting.is_empty()
&& !self.test_is_trivial
&& !settings
.suppress_health_check
.contains(&HealthCheck::FilterTooMuch)
&& self.invalid_test_cases > 0
{
return Err(RunError::HealthCheck(format!(
"FailedHealthCheck: FilterTooMuch — every reachable input was \
filtered out by assume() before any valid input was generated. \
{} inputs were filtered out across the full search \
space. If this is expected, suppress the check with \
suppress_health_check = [HealthCheck::FilterTooMuch].",
self.invalid_test_cases
)));
}
if !self.interesting.is_empty()
&& !replay_aligned
&& settings.phases.contains(&Phase::Shrink)
{
if verbosity == Verbosity::Debug {
let total: usize = self.interesting.values().map(|n| n.len()).sum();
eprintln!(
"Shrinking: {} origin(s), initial total length = {}",
self.interesting.len(),
total
);
}
if let (Some(_), Some(key)) = (self.db(), database_key) {
let key_bytes = key.as_bytes().to_vec();
let secondary_key = crate::native::data_tree::sub_key(&key_bytes, b"secondary");
let mut entries = self
.db()
.map(|db| db.fetch(&secondary_key))
.unwrap_or_default();
entries.sort_by(|a, b| shortlex(a, b));
let primary_max: Option<Vec<u8>> = self
.interesting
.values()
.map(|nodes| {
let choices: Vec<ChoiceValue> =
nodes.iter().map(|n| n.value.clone()).collect();
serialize_choices(&choices)
})
.max_by(|a, b| shortlex(a, b));
for raw in entries {
if primary_max
.as_ref()
.is_some_and(|m| shortlex(&raw, m) == std::cmp::Ordering::Greater)
{
break;
}
if let Some(stored_choices) = deserialize_choices(&raw) {
let ntc = NativeTestCase::for_choices(&stored_choices, None, None);
let _ = self.test_function(ntc);
}
if let Some(db) = self.db() {
db.delete(&secondary_key, &raw);
}
}
}
let shrink_deadline = std::time::Instant::now() + shrink_budget;
let mut shrink_timed_out = false;
let mut shrunk_origins: std::collections::HashSet<String> =
std::collections::HashSet::new();
loop {
for (stray_origin, stray_nodes) in self.take_stray_interesting() {
self.persister.record(&stray_origin, &stray_nodes);
update_interesting(&mut self.interesting, stray_origin, stray_nodes);
}
let mut pending: Vec<String> = self
.interesting
.keys()
.filter(|o| !shrunk_origins.contains(o.as_str()))
.cloned()
.collect();
if pending.is_empty() {
break;
}
pending.sort();
let origin = pending.remove(0);
let initial = self.interesting.get(&origin).cloned().unwrap_or_default();
let choices: Vec<ChoiceValue> = initial.iter().map(|n| n.value.clone()).collect();
let verify_ntc = NativeTestCase::for_choices(&choices, Some(&initial), None);
let (verify, _) = self.test_function(verify_ntc);
if verify.status != Status::Interesting {
return Err(RunError::Flaky(flaky_diagnostic()));
}
let target_origin = origin.clone();
let initial_spans = Spans::from(verify.spans.clone());
let shrunk = {
let this: &mut Engine<'_> = &mut *self;
let mut shrinker = Shrinker::with_probe(
Box::new(|req: ShrinkRun| {
if verbosity == Verbosity::Verbose {
eprintln!("Running test case");
}
let result = match req {
ShrinkRun::Full(nodes) => {
this.run_shrink_with_origin(nodes, &target_origin)
}
ShrinkRun::Probe {
prefix,
seed,
max_size,
} => this.run_probe_with_origin(
prefix,
seed,
max_size,
&target_origin,
),
};
this.calls += 1;
if result.0 {
this.persister.record(&target_origin, &result.1);
}
result
}),
verify.nodes,
initial_spans,
);
shrinker.deadline = Some(shrink_deadline);
let _ = shrinker.initial_coarse_reduction();
if verbosity == Verbosity::Debug {
shrinker.set_debug(|msg| eprintln!("{msg}"));
}
shrinker.shrink();
shrink_timed_out |= shrinker.timed_out;
shrinker.current_nodes
};
self.interesting.insert(origin.clone(), shrunk);
shrunk_origins.insert(origin);
}
if shrink_timed_out && verbosity != Verbosity::Quiet {
eprintln!("{}", slow_shrink_warning());
}
if verbosity == Verbosity::Debug {
let total: usize = self.interesting.values().map(|n| n.len()).sum();
eprintln!(
"Shrinking complete: {} origin(s), final total length = {}",
self.interesting.len(),
total
);
}
} else if self.interesting.is_empty() && verbosity == Verbosity::Debug {
} else if replay_aligned && verbosity == Verbosity::Debug {
eprintln!("Skipping shrink: reused aligned database replay");
}
if let (Some(db), Some(key)) = (self.db(), database_key) {
let key_bytes = key.as_bytes();
let secondary_key = crate::native::data_tree::sub_key(key_bytes, b"secondary");
let new_entries: std::collections::HashSet<Vec<u8>> = self
.interesting
.values()
.map(|nodes| {
let choices: Vec<ChoiceValue> = nodes.iter().map(|n| n.value.clone()).collect();
serialize_choices(&choices)
})
.collect();
let primary_now = db.fetch(key_bytes);
for old in primary_now {
if !new_entries.contains(&old) {
db.move_value(key_bytes, &secondary_key, &old);
}
}
for new_bytes in &new_entries {
db.save(key_bytes, new_bytes);
}
}
if verbosity == Verbosity::Debug {
eprintln!(
"Test done. interesting_test_cases={}",
self.interesting.len()
);
}
let mut origins_sorted: Vec<(String, Vec<ChoiceNode>)> =
std::mem::take(&mut self.interesting).into_iter().collect();
origins_sorted.sort_by(|a, b| sort_key(&b.1).cmp(&sort_key(&a.1)));
if !settings.report_multiple_failures {
if let Some(last) = origins_sorted.pop() {
origins_sorted.clear();
origins_sorted.push(last);
}
}
let counterexamples: Vec<ShrunkCounterexample> = origins_sorted
.into_iter()
.map(|(_origin, nodes)| {
let choices: Vec<ChoiceValue> = nodes.iter().map(|n| n.value.clone()).collect();
let blob = crate::native::blob::encode_failure(&choices);
ShrunkCounterexample {
choices,
nodes: Some(nodes),
blob,
}
})
.collect();
if counterexamples.is_empty() {
Ok(Exploration::Passed)
} else {
Ok(Exploration::Counterexamples(counterexamples))
}
}
}
const MIN_TEST_CALLS: u64 = 10;
const POST_BUG_EXTRA_CALLS: u64 = 1000;
pub(crate) fn too_slow_check(
valid_test_cases: u64,
total_test_time: std::time::Duration,
threshold: std::time::Duration,
suppressed: bool,
) -> Option<String> {
if valid_test_cases < HEALTH_CHECK_MAX_VALID && total_test_time > threshold && !suppressed {
Some(format!(
"FailedHealthCheck: TooSlow — input generation is slow: \
only {valid_test_cases} valid inputs after {:?} (threshold \
{:?}). Slow generation makes property testing much less \
effective. If this is expected, suppress the check with \
suppress_health_check = [HealthCheck::TooSlow].",
total_test_time, threshold
))
} else {
None
}
}
pub(crate) fn too_large_check(
valid_test_cases: u64,
overrun_test_cases: u64,
suppressed: bool,
) -> Option<String> {
if valid_test_cases < HEALTH_CHECK_MAX_VALID
&& overrun_test_cases >= MAX_OVERRUN_DRAWS
&& !suppressed
{
Some(format!(
"FailedHealthCheck: TestCasesTooLarge — generated inputs routinely \
exceeded the maximum size: {valid_test_cases} inputs were generated \
successfully, while {overrun_test_cases} inputs overran the buffer during \
generation. Testing with inputs this large is slow and shrinks \
poorly. Try reducing the amount of data generated, e.g. a smaller \
min_size on collections like gs::vecs(). If this is expected, \
suppress the check with \
suppress_health_check = [HealthCheck::TestCasesTooLarge]."
))
} else {
None
}
}
pub(crate) fn large_initial_check(
overran: bool,
status: Status,
node_count: usize,
suppressed: bool,
) -> Option<String> {
if suppressed {
return None;
}
let too_large =
overran || (status == Status::Valid && node_count.saturating_mul(2) > BUFFER_SIZE);
if too_large {
Some(
"FailedHealthCheck: LargeInitialTestCase — the smallest natural input \
for this test is very large, which makes it hard to generate and \
shrink good inputs. Consider reducing the amount of data generated, \
or introducing small alternatives (e.g. `gs::one_of` with an empty \
option). If this is expected, suppress the check with \
suppress_health_check = [HealthCheck::LargeInitialTestCase]."
.to_string(),
)
} else {
None
}
}
pub(crate) fn flaky_diagnostic() -> String {
"Flaky test detected: Your test produced different outcomes \
when run with the same generated data — it failed when it \
previously succeeded, or succeeded when it previously failed. \
This usually means your test depends on external state such as \
global variables, system time, or external random number generators."
.to_string()
}
pub(crate) fn slow_shrink_warning() -> String {
format!(
"WARNING: Shrinking has been running for more than {MAX_SHRINKING_SECONDS} seconds \
and is making very slow progress, so it has been stopped. The smallest failing \
example found so far will be reported. Re-running the test will resume shrinking \
from there, and may take this long again before stopping."
)
}
fn invalid_thresholds(r: f64, c: f64) -> (u64, u64) {
let base = ((1.0 - c).ln() / (1.0 - r).ln()).ceil() - 1.0;
let per_valid = (1.0 / r).ceil();
(base as u64, per_valid as u64)
}
fn within_invalid_budget(
invalid_test_cases: u64,
overrun_test_cases: u64,
valid_test_cases: u64,
budget: (u64, u64),
) -> bool {
let (base, per_valid) = budget;
(invalid_test_cases + overrun_test_cases) <= base + per_valid * valid_test_cases
}
fn shortlex(a: &[u8], b: &[u8]) -> std::cmp::Ordering {
a.len().cmp(&b.len()).then_with(|| a.cmp(b))
}
fn should_generate_more(
no_bug_yet: bool,
calls: u64,
first_bug_at: Option<u64>,
last_bug_at: Option<u64>,
shrink_enabled: bool,
report_multiple: bool,
first_bug_elapsed: Option<std::time::Duration>,
) -> bool {
if no_bug_yet {
return true;
}
if !shrink_enabled || !report_multiple {
return false;
}
if first_bug_elapsed.is_some_and(|d| d > std::time::Duration::from_secs(10)) {
return false;
}
let Some(first) = first_bug_at else {
return false;
};
let last = last_bug_at.unwrap_or(first);
let heuristic = first
.saturating_add(POST_BUG_EXTRA_CALLS)
.min(last.saturating_mul(2));
calls < MIN_TEST_CALLS || calls < heuristic
}
fn update_interesting(
interesting: &mut HashMap<String, Vec<ChoiceNode>>,
origin: String,
nodes: Vec<ChoiceNode>,
) {
match interesting.entry(origin) {
Entry::Vacant(e) => {
e.insert(nodes);
}
Entry::Occupied(mut e) => {
if sort_key(&nodes) < sort_key(e.get()) {
e.insert(nodes);
}
}
}
}
struct Persister<'a> {
db: Option<Box<dyn TestCaseDatabase>>,
database_key: Option<&'a str>,
last_saved: HashMap<String, Vec<ChoiceNode>>,
}
impl<'a> Persister<'a> {
fn new(db: Option<Box<dyn TestCaseDatabase>>, database_key: Option<&'a str>) -> Self {
Persister {
db,
database_key,
last_saved: HashMap::new(),
}
}
fn record(&mut self, origin: &str, nodes: &[ChoiceNode]) {
let Some(db) = self.db.as_deref() else { return };
let Some(key) = self.database_key else { return };
let key_bytes = key.as_bytes();
let new_choices: Vec<ChoiceValue> = nodes.iter().map(|n| n.value.clone()).collect();
let new_bytes = serialize_choices(&new_choices);
let needs_save = match self.last_saved.get(origin) {
None => true,
Some(prev) => sort_key(nodes) < sort_key(prev),
};
if !needs_save {
return;
}
if let Some(prev) = self.last_saved.get(origin) {
let prev_choices: Vec<ChoiceValue> = prev.iter().map(|n| n.value.clone()).collect();
let prev_bytes = serialize_choices(&prev_choices);
let secondary_key = crate::native::data_tree::sub_key(key_bytes, b"secondary");
db.move_value(key_bytes, &secondary_key, &prev_bytes);
}
db.save(key_bytes, &new_bytes);
self.last_saved.insert(origin.to_string(), nodes.to_vec());
}
}
pub(crate) struct Engine<'a> {
settings: &'a Settings,
database_key: Option<&'a str>,
run_case: &'a mut dyn FnMut(Box<dyn DataSource + Send + Sync>),
rng: EngineRng,
persister: Persister<'a>,
cache: HashMap<Vec<ChoiceValue>, RunResult>,
stray_interesting: Vec<(String, Vec<ChoiceNode>)>,
pub(crate) tree_root: crate::native::data_tree::DataTreeNode,
pub(crate) interesting: HashMap<String, Vec<ChoiceNode>>,
pub(crate) targeting: crate::native::targeting::TargetingState,
pub(crate) calls: u64,
pub(crate) valid_test_cases: u64,
pub(crate) invalid_test_cases: u64,
pub(crate) overrun_test_cases: u64,
pub(crate) total_test_time: std::time::Duration,
pub(crate) test_is_trivial: bool,
pub(crate) first_bug_at: Option<u64>,
pub(crate) last_bug_at: Option<u64>,
pub(crate) first_bug_time: Option<std::time::Instant>,
}
impl<'a> Engine<'a> {
pub(crate) fn new(
settings: &'a Settings,
database_key: Option<&'a str>,
run_case: &'a mut dyn FnMut(Box<dyn DataSource + Send + Sync>),
) -> Self {
let db: Option<Box<dyn TestCaseDatabase>> = match &settings.database {
Database::Path(path) => Some(Box::new(DirectoryTestCaseDatabase::new(path))),
Database::Unset => Some(Box::new(DirectoryTestCaseDatabase::new(".hegel/examples"))),
Database::Disabled => None,
};
Engine {
settings,
database_key,
run_case,
rng: create_rng(settings, database_key),
persister: Persister::new(db, database_key),
cache: HashMap::new(),
stray_interesting: Vec::new(),
tree_root: crate::native::data_tree::DataTreeNode::default(),
interesting: HashMap::new(),
targeting: crate::native::targeting::TargetingState::new(),
calls: 0,
valid_test_cases: 0,
invalid_test_cases: 0,
overrun_test_cases: 0,
total_test_time: std::time::Duration::ZERO,
test_is_trivial: false,
first_bug_at: None,
last_bug_at: None,
first_bug_time: None,
}
}
fn db(&self) -> Option<&dyn TestCaseDatabase> {
self.persister.db.as_deref()
}
pub(crate) fn rng_spawn(&mut self) -> EngineRng {
self.rng.spawn()
}
pub(crate) fn test_function(&mut self, ntc: NativeTestCase) -> (RunResult, Option<String>) {
let tc_start = std::time::Instant::now();
let run = self.execute(ntc);
let mismatch = self.record_run(&run, tc_start.elapsed(), true);
(run, mismatch)
}
fn record_run(
&mut self,
run: &RunResult,
elapsed: std::time::Duration,
feed_tree: bool,
) -> Option<String> {
let mismatch = if feed_tree {
crate::native::data_tree::record_tree(&mut self.tree_root, &run.nodes, run.status, &[])
} else {
None
};
self.calls += 1;
self.total_test_time += elapsed;
if run.nodes.is_empty() && run.status >= Status::Invalid {
self.test_is_trivial = true;
}
if run.status >= Status::Valid && !run.target_observations.is_empty() {
let choices: Vec<ChoiceValue> = run.nodes.iter().map(|n| n.value.clone()).collect();
self.targeting.record(&choices, &run.target_observations);
}
match run.status {
Status::Valid => self.valid_test_cases += 1,
Status::Invalid => self.invalid_test_cases += 1,
Status::EarlyStop => self.overrun_test_cases += 1,
Status::Interesting => {
if self.first_bug_at.is_none() {
self.first_bug_at = Some(self.calls);
self.first_bug_time = Some(std::time::Instant::now());
}
self.last_bug_at = Some(self.calls);
let origin = run.origin.clone().unwrap_or_default();
self.persister.record(&origin, &run.nodes);
update_interesting(&mut self.interesting, origin, run.nodes.clone());
}
}
mismatch
}
fn within_invalid_budget(&self, budget: (u64, u64)) -> bool {
within_invalid_budget(
self.invalid_test_cases,
self.overrun_test_cases,
self.valid_test_cases,
budget,
)
}
fn note_stray(
&mut self,
status: Status,
origin: Option<&str>,
nodes: &[ChoiceNode],
target_origin: &str,
) {
if status != Status::Interesting {
return;
}
if let Some(o) = origin {
if o != target_origin {
self.stray_interesting.push((o.to_string(), nodes.to_vec()));
}
}
}
fn take_stray_interesting(&mut self) -> Vec<(String, Vec<ChoiceNode>)> {
std::mem::take(&mut self.stray_interesting)
}
fn execute(&mut self, ntc: NativeTestCase) -> RunResult {
let (data_source, handle) = NativeDataSource::new(ntc);
(self.run_case)(Box::new(data_source));
let nodes = NativeDataSource::take_nodes(&handle);
let spans = NativeDataSource::take_spans(&handle);
let target_observations = NativeDataSource::take_target_observations(&handle);
let tc_result = NativeDataSource::take_outcome(&handle);
let (status, origin) = match tc_result {
TestCaseResult::Valid => (Status::Valid, None),
TestCaseResult::Invalid => (Status::Invalid, None),
TestCaseResult::Overrun => (Status::EarlyStop, None),
TestCaseResult::Interesting(f) => (Status::Interesting, Some(f.origin)),
};
RunResult {
status,
nodes,
spans,
origin,
target_observations,
}
}
fn run_shrink_with_origin(
&mut self,
candidate_nodes: &[ChoiceNode],
target_origin: &str,
) -> (bool, Vec<ChoiceNode>, Spans) {
let key: Vec<ChoiceValue> = candidate_nodes.iter().map(|n| n.value.clone()).collect();
if let Some(cached) = self.cache.get(&key) {
let matches = cached.status == Status::Interesting
&& cached.origin.as_deref() == Some(target_origin);
let (status, origin, nodes, spans) = (
cached.status,
cached.origin.clone(),
cached.nodes.clone(),
Spans::from(cached.spans.clone()),
);
self.note_stray(status, origin.as_deref(), &nodes, target_origin);
return (matches, nodes, spans);
}
let ntc = NativeTestCase::for_choices(&key, Some(candidate_nodes), None);
let run = self.execute(ntc);
let matches =
run.status == Status::Interesting && run.origin.as_deref() == Some(target_origin);
self.note_stray(run.status, run.origin.as_deref(), &run.nodes, target_origin);
let spans = Spans::from(run.spans.clone());
self.cache.insert(key, run.clone());
(matches, run.nodes, spans)
}
fn run_probe_with_origin(
&mut self,
prefix: &[ChoiceValue],
seed: u64,
max_size: usize,
target_origin: &str,
) -> (bool, Vec<ChoiceNode>, Spans) {
let rng = EngineRng::seeded(seed);
let ntc = NativeTestCase::for_probe(prefix, rng, max_size);
let run = self.execute(ntc);
let matches =
run.status == Status::Interesting && run.origin.as_deref() == Some(target_origin);
self.note_stray(run.status, run.origin.as_deref(), &run.nodes, target_origin);
let key: Vec<ChoiceValue> = run.nodes.iter().map(|n| n.value.clone()).collect();
let spans = Spans::from(run.spans.clone());
self.cache.insert(key, run.clone());
(matches, run.nodes, spans)
}
fn cached_run(&mut self, choices: &[ChoiceValue]) -> (RunResult, bool) {
if let Some(cached) = self.cache.get(choices) {
return (cached.clone(), false);
}
if let Some(status) = crate::native::data_tree::simulate(&self.tree_root, choices) {
if status != Status::Interesting {
let result = RunResult {
status,
nodes: Vec::new(),
spans: Vec::new(),
origin: None,
target_observations: HashMap::new(),
};
return (result, false);
}
}
let ntc = NativeTestCase::for_choices(choices, None, None);
let tc_start = std::time::Instant::now();
let run = self.execute(ntc);
self.record_run(&run, tc_start.elapsed(), false);
self.cache.insert(choices.to_vec(), run.clone());
(run, true)
}
}
impl<'a> Engine<'a> {
fn try_span_mutation(&mut self, nodes: &[ChoiceNode], spans: &[Span]) {
let mut by_label: rustc_hash::FxHashMap<&str, rustc_hash::FxHashSet<(usize, usize)>> =
rustc_hash::FxHashMap::default();
for span in spans.iter() {
by_label
.entry(span.label.as_str())
.or_default()
.insert((span.start, span.end));
}
let multi: Vec<Vec<(usize, usize)>> = by_label
.into_values()
.filter(|v| v.len() >= 2)
.map(|v| {
let mut items: Vec<(usize, usize)> = v.into_iter().collect();
items.sort();
items
})
.collect();
if multi.is_empty() {
return;
}
let values: Vec<ChoiceValue> = nodes.iter().map(|n| n.value.clone()).collect();
for _ in 0..SPAN_MUTATION_ATTEMPTS {
if self.valid_test_cases >= self.settings.test_cases {
break;
}
let group_idx = self.rng.random_range(0..multi.len());
let group = &multi[group_idx];
let i_a = self.rng.random_range(0..group.len());
let mut i_b = self.rng.random_range(0..group.len() - 1);
if i_b >= i_a {
i_b += 1;
}
let (mut start_a, mut end_a) = group[i_a];
let (mut start_b, mut end_b) = group[i_b];
if start_a > start_b {
std::mem::swap(&mut start_a, &mut start_b);
std::mem::swap(&mut end_a, &mut end_b);
}
let attempt: Vec<ChoiceValue> = if start_a <= start_b && end_b <= end_a {
let mut out = Vec::with_capacity(values.len() + (start_b - start_a));
out.extend_from_slice(&values[..start_b]);
out.extend_from_slice(&values[start_a..]);
out
} else {
let (donor_start, donor_end) = if self.rng.random::<bool>() {
(start_a, end_a)
} else {
(start_b, end_b)
};
let replacement: &[ChoiceValue] = &values[donor_start..donor_end];
let mid = if end_a <= start_b {
&values[end_a..start_b]
} else {
&[][..]
};
let mut out = Vec::new();
out.extend_from_slice(&values[..start_a]);
out.extend_from_slice(replacement);
out.extend_from_slice(mid);
out.extend_from_slice(replacement);
out.extend_from_slice(&values[end_b..]);
out
};
let (run, _executed) = self.cached_run(&attempt);
if run.status == Status::Interesting {
return;
}
}
}
}
fn create_rng(settings: &Settings, database_key: Option<&str>) -> EngineRng {
if settings.resolved_backend(crate::antithesis::is_running_in_antithesis()) == Backend::Urandom
{
return EngineRng::urandom();
}
if let Some(seed) = settings.seed {
EngineRng::seeded(seed)
} else if settings.derandomize {
let key = database_key.unwrap_or("unnamed-test");
EngineRng::seeded(hash_string(key))
} else {
EngineRng::from_os()
}
}
fn hash_string(s: &str) -> u64 {
let mut hash: u64 = 0xcbf29ce484222325;
for byte in s.bytes() {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x100000001b3);
}
hash
}
#[cfg(test)]
#[path = "../../tests/embedded/native/test_runner_tests.rs"]
mod tests;