use std::{cmp::Ordering, collections::BinaryHeap, ops::Range};
pub const DEFAULT_MAX_TRANSIENT_RETRIES: usize = 2;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BisectBudget {
#[default]
Auto,
MaxProbes(usize),
FullResolution,
}
impl BisectBudget {
pub fn effective_cap(self, n: usize) -> usize {
match self {
Self::Auto => 2 * ceil_log2(n) + 1,
Self::MaxProbes(max) => max.max(1),
Self::FullResolution => usize::MAX,
}
}
}
fn ceil_log2(n: usize) -> usize {
if n <= 1 {
return 0;
}
(usize::BITS - (n - 1).leading_zeros()) as usize
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct PendingRange {
start: usize,
end: usize,
}
impl PendingRange {
fn len(&self) -> usize {
self.end - self.start
}
}
impl Ord for PendingRange {
fn cmp(&self, other: &Self) -> Ordering {
self.len()
.cmp(&other.len())
.then_with(|| other.start.cmp(&self.start))
}
}
impl PartialOrd for PendingRange {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
#[derive(Debug)]
pub enum ProbeVerdict<E> {
Clean,
Failed(E),
Transient(E),
}
#[derive(Debug, PartialEq, Eq)]
pub enum ItemOutcome<E> {
Complete,
Failed(E),
Unresolved,
}
#[derive(Debug)]
pub struct BisectOutcomes<E> {
pub items: Vec<ItemOutcome<E>>,
pub probes_used: usize,
pub transient_retries: usize,
pub last_error: Option<E>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TransientLimitExceeded {
pub probes_used: usize,
pub transient_retries: usize,
}
impl std::fmt::Display for TransientLimitExceeded {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"bisect abandoned after {} probes and {} transient retries",
self.probes_used, self.transient_retries
)
}
}
impl std::error::Error for TransientLimitExceeded {}
#[derive(Debug)]
pub struct BisectSearch<E> {
pending: BinaryHeap<PendingRange>,
resolved: Vec<Option<ItemOutcome<E>>>,
probes_used: usize,
cap: usize,
transient_retries: usize,
max_transient_retries: usize,
last_error: Option<E>,
}
impl<E> BisectSearch<E> {
pub fn new(n: usize, budget: BisectBudget) -> Self {
let mut pending = BinaryHeap::new();
if n > 0 {
pending.push(PendingRange { start: 0, end: n });
}
Self {
pending,
resolved: (0..n).map(|_| None).collect(),
probes_used: 0,
cap: budget.effective_cap(n),
transient_retries: 0,
max_transient_retries: DEFAULT_MAX_TRANSIENT_RETRIES,
last_error: None,
}
}
#[must_use]
pub fn with_max_transient_retries(mut self, max: usize) -> Self {
self.max_transient_retries = max;
self
}
pub fn next_range(&mut self) -> Option<Range<usize>> {
if self.probes_used >= self.cap {
return None;
}
let range = self.pending.pop()?;
self.probes_used += 1;
Some(range.start..range.end)
}
pub fn report(
&mut self,
range: Range<usize>,
verdict: ProbeVerdict<E>,
) -> Result<(), TransientLimitExceeded> {
let range = PendingRange {
start: range.start,
end: range.end,
};
match verdict {
ProbeVerdict::Clean => {
for slot in &mut self.resolved[range.start..range.end] {
*slot = Some(ItemOutcome::Complete);
}
}
ProbeVerdict::Transient(error) => {
self.last_error = Some(error);
self.probes_used = self.probes_used.saturating_sub(1);
if self.transient_retries >= self.max_transient_retries {
return Err(TransientLimitExceeded {
probes_used: self.probes_used,
transient_retries: self.transient_retries,
});
}
self.transient_retries += 1;
self.pending.push(range);
}
ProbeVerdict::Failed(error) if range.len() == 1 => {
self.resolved[range.start] = Some(ItemOutcome::Failed(error));
}
ProbeVerdict::Failed(error) => {
self.last_error = Some(error);
let mid = range.start + range.len() / 2;
self.pending.push(PendingRange {
start: range.start,
end: mid,
});
self.pending.push(PendingRange {
start: mid,
end: range.end,
});
}
}
Ok(())
}
pub fn probes_used(&self) -> usize {
self.probes_used
}
pub fn transient_retries(&self) -> usize {
self.transient_retries
}
pub fn last_error(&self) -> Option<&E> {
self.last_error.as_ref()
}
pub fn into_outcomes(self) -> BisectOutcomes<E> {
let items = self
.resolved
.into_iter()
.map(|slot| slot.unwrap_or(ItemOutcome::Unresolved))
.collect();
BisectOutcomes {
items,
probes_used: self.probes_used,
transient_retries: self.transient_retries,
last_error: self.last_error,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn run(
n: usize,
budget: BisectBudget,
culprits: &[usize],
) -> (Vec<Range<usize>>, BisectOutcomes<String>) {
let mut search = BisectSearch::new(n, budget);
let mut probes = Vec::new();
while let Some(range) = search.next_range() {
probes.push(range.clone());
let verdict = if culprits.iter().any(|c| range.contains(c)) {
ProbeVerdict::Failed(format!("bad {range:?}"))
} else {
ProbeVerdict::Clean
};
search.report(range, verdict).expect("no transients here");
}
(probes, search.into_outcomes())
}
#[test]
fn auto_budget_matches_the_documented_formula() {
assert_eq!(BisectBudget::Auto.effective_cap(1), 1);
assert_eq!(BisectBudget::Auto.effective_cap(10), 9);
assert_eq!(BisectBudget::Auto.effective_cap(25), 11);
assert_eq!(BisectBudget::Auto.effective_cap(100), 15);
}
#[test]
fn max_probes_zero_clamps_to_one() {
assert_eq!(BisectBudget::MaxProbes(0).effective_cap(50), 1);
assert_eq!(BisectBudget::MaxProbes(3).effective_cap(50), 3);
}
#[test]
fn a_clean_batch_probes_exactly_once() {
let (probes, outcomes) = run(5, BisectBudget::Auto, &[]);
assert_eq!(probes, vec![0..5]);
assert_eq!(outcomes.probes_used, 1);
assert!(
outcomes
.items
.iter()
.all(|o| matches!(o, ItemOutcome::Complete))
);
}
#[test]
fn ranges_are_probed_largest_first_earliest_start_on_ties() {
let (probes, _) = run(8, BisectBudget::FullResolution, &[0]);
assert_eq!(probes[0], 0..8);
assert_eq!(probes[1], 0..4);
assert_eq!(probes[2], 4..8);
assert!(
probes.windows(2).all(|w| {
let (a, b) = (w[0].len(), w[1].len());
a > b || (a == b && w[0].start < w[1].start) || a < b
}),
"probe order was {probes:?}"
);
}
#[test]
fn a_single_culprit_is_isolated_and_its_siblings_are_salvaged() {
let (_, outcomes) = run(8, BisectBudget::Auto, &[3]);
for (idx, outcome) in outcomes.items.iter().enumerate() {
if idx == 3 {
assert!(
matches!(outcome, ItemOutcome::Failed(_)),
"index 3: {outcome:?}"
);
} else {
assert_eq!(outcome, &ItemOutcome::Complete, "index {idx}");
}
}
}
#[test]
fn scattered_culprits_do_not_poison_their_clean_siblings() {
let (_, outcomes) = run(16, BisectBudget::FullResolution, &[0, 8]);
let completed = outcomes
.items
.iter()
.filter(|o| matches!(o, ItemOutcome::Complete))
.count();
assert_eq!(completed, 14);
assert!(matches!(outcomes.items[0], ItemOutcome::Failed(_)));
assert!(matches!(outcomes.items[8], ItemOutcome::Failed(_)));
}
#[test]
fn full_resolution_resolves_every_item_of_an_all_bad_batch() {
let (_, outcomes) = run(6, BisectBudget::FullResolution, &[0, 1, 2, 3, 4, 5]);
assert!(
outcomes
.items
.iter()
.all(|o| matches!(o, ItemOutcome::Failed(_)))
);
}
#[test]
fn max_probes_one_is_equivalent_to_resolving_nothing() {
let (probes, outcomes) = run(5, BisectBudget::MaxProbes(1), &[2]);
assert_eq!(probes, vec![0..5]);
assert!(
outcomes
.items
.iter()
.all(|o| matches!(o, ItemOutcome::Unresolved))
);
assert!(
outcomes.last_error.is_some(),
"the batch-level error is kept for the caller"
);
}
#[test]
fn every_input_gets_exactly_one_outcome_even_under_budget_exhaustion() {
let (_, outcomes) = run(10, BisectBudget::MaxProbes(3), &[0]);
assert_eq!(outcomes.items.len(), 10);
}
#[test]
fn a_transient_probe_is_re_run_whole_and_refunded() {
let mut search: BisectSearch<String> = BisectSearch::new(8, BisectBudget::MaxProbes(1));
let first = search.next_range().expect("a first probe");
assert_eq!(first, 0..8);
search
.report(first, ProbeVerdict::Transient("40P01".into()))
.expect("within the allowance");
let retry = search.next_range().expect("the refunded re-probe");
assert_eq!(retry, 0..8);
assert_eq!(search.transient_retries(), 1);
search.report(retry, ProbeVerdict::Clean).expect("clean");
let outcomes = search.into_outcomes();
assert_eq!(outcomes.probes_used, 1);
assert_eq!(outcomes.transient_retries, 1);
assert!(
outcomes
.items
.iter()
.all(|o| matches!(o, ItemOutcome::Complete))
);
}
#[test]
fn the_transient_allowance_is_bounded() {
let mut search: BisectSearch<String> =
BisectSearch::new(4, BisectBudget::FullResolution).with_max_transient_retries(2);
for _ in 0..2 {
let range = search.next_range().expect("a probe");
search
.report(range, ProbeVerdict::Transient("40001".into()))
.expect("within the allowance");
}
let range = search.next_range().expect("a probe");
let limit = search
.report(range, ProbeVerdict::Transient("40001".into()))
.expect_err("the allowance is spent");
assert_eq!(limit.transient_retries, 2);
}
#[test]
fn an_empty_batch_probes_nothing() {
let (probes, outcomes) = run(0, BisectBudget::Auto, &[]);
assert!(probes.is_empty());
assert!(outcomes.items.is_empty());
}
}