use std::collections::HashSet;
use crate::ObjectId;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Candidates {
ids: Vec<ObjectId>,
parents: Vec<Vec<usize>>,
counted: Vec<bool>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub struct Candidate {
pub id: ObjectId,
pub parents: Vec<usize>,
pub changes_paths: bool,
}
impl Candidate {
pub fn new(id: ObjectId, parents: Vec<usize>) -> Self {
Self {
id,
parents,
changes_paths: true,
}
}
#[must_use]
pub fn unchanged(mut self) -> Self {
self.changes_paths = false;
self
}
}
#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
#[non_exhaustive]
pub enum CandidatesError {
#[error("bisect candidates must include the new commit")]
Empty,
#[error("candidate {index} names parent {parent}, which is not a candidate")]
Parent {
index: usize,
parent: usize,
},
#[error("candidate {id} appears more than once")]
Duplicate {
id: ObjectId,
},
#[error("bisect candidate parents form a cycle")]
Cycle,
}
fn has_cycle(parents: &[Vec<usize>]) -> bool {
let mut children = vec![0usize; parents.len()];
for list in parents {
for &parent in list {
children[parent] += 1;
}
}
let mut ready: Vec<usize> = (0..parents.len()).filter(|&i| children[i] == 0).collect();
let mut visited = 0;
while let Some(index) = ready.pop() {
visited += 1;
for &parent in &parents[index] {
children[parent] -= 1;
if children[parent] == 0 {
ready.push(parent);
}
}
}
visited != parents.len()
}
impl Candidates {
pub fn new(commits: impl IntoIterator<Item = Candidate>) -> Result<Self, CandidatesError> {
let mut ids = Vec::new();
let mut parents = Vec::new();
let mut counted = Vec::new();
for commit in commits {
ids.push(commit.id);
parents.push(commit.parents);
counted.push(commit.changes_paths);
}
if ids.is_empty() {
return Err(CandidatesError::Empty);
}
let mut seen = HashSet::with_capacity(ids.len());
for id in &ids {
if !seen.insert(*id) {
return Err(CandidatesError::Duplicate { id: *id });
}
}
for (index, list) in parents.iter_mut().enumerate() {
if let Some(&parent) = list.iter().find(|&&p| p >= ids.len() || p == index) {
return Err(CandidatesError::Parent { index, parent });
}
let mut unique = HashSet::with_capacity(list.len());
list.retain(|p| unique.insert(*p));
}
if has_cycle(&parents) {
return Err(CandidatesError::Cycle);
}
Ok(Self {
ids,
parents,
counted,
})
}
pub fn ids(&self) -> &[ObjectId] {
&self.ids
}
pub fn parents(&self, index: usize) -> &[usize] {
&self.parents[index]
}
pub fn changes_paths(&self, index: usize) -> bool {
self.counted[index]
}
pub fn testable(&self) -> impl Iterator<Item = ObjectId> + '_ {
self.ids
.iter()
.zip(&self.counted)
.filter_map(|(id, counted)| counted.then_some(*id))
}
pub fn select(&self, skipped: &HashSet<ObjectId>) -> Selection {
let weights = Weights::new(self);
let total = weights.total;
match total {
0 => return Selection::Untestable,
1 => {
let only = self.testable().next().unwrap_or(self.ids[0]);
return if only == self.ids[0] {
Selection::Found(only)
} else if skipped.contains(&only) {
Selection::OnlySkipped(vec![only])
} else {
Selection::Test {
commit: only,
remaining: 0,
steps: 0,
}
};
}
_ => {}
}
let steps = estimate_steps(total);
if !self.ids.iter().any(|id| skipped.contains(id)) {
let chosen = weights.halfway(self).unwrap_or_else(|| weights.last_best());
return Selection::Test {
commit: self.ids[chosen],
remaining: (total - weights.of(chosen)).saturating_sub(1),
steps,
};
}
let ranked = weights.ranked(self);
let remaining = (total - weights.of(ranked[0])).saturating_sub(1);
let unskipped: Vec<usize> = ranked
.iter()
.copied()
.filter(|&i| !skipped.contains(&self.ids[i]))
.collect();
let chosen = match unskipped.first() {
None => None,
Some(&best) if best == ranked[0] => Some(best),
Some(_) => {
let pick = skip_away(unskipped.len());
match unskipped[pick] {
0 if pick > 0 => Some(unskipped[pick - 1]),
index => Some(index),
}
}
};
match chosen {
Some(index) if index != 0 => Selection::Test {
commit: self.ids[index],
remaining,
steps,
},
_ => {
let mut possible: Vec<ObjectId> = ranked
.iter()
.filter(|&&i| i != 0)
.map(|&i| self.ids[i])
.collect();
possible.push(self.ids[0]);
Selection::OnlySkipped(possible)
}
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum Selection {
Test {
commit: ObjectId,
remaining: usize,
steps: usize,
},
Found(ObjectId),
OnlySkipped(Vec<ObjectId>),
Untestable,
}
pub fn estimate_steps(total: usize) -> usize {
if total < 2 {
return 0;
}
let exponent = total.ilog2() as usize;
let power = 1usize << exponent;
let extra = total - power;
exponent - 1 + usize::from(3 * extra >= power)
}
fn skip_away(count: usize) -> usize {
const MODULO: u64 = 32768;
let seed = (count as u64)
.wrapping_mul(1_103_515_245)
.wrapping_add(12345)
& 0xffff_ffff;
let random = (seed >> 16) % MODULO;
let scaled = (count as u64) * random / MODULO;
let position = scaled * random.isqrt() / MODULO.isqrt();
(position as usize).min(count - 1)
}
struct Weights {
reach: Vec<usize>,
counted: Vec<bool>,
total: usize,
}
impl Weights {
fn new(candidates: &Candidates) -> Self {
let size = candidates.ids.len();
let counted = candidates.counted.clone();
let total = counted.iter().filter(|&&c| c).count();
let mut reach = vec![0; size];
let mut known = vec![false; size];
let mut stamp = vec![usize::MAX; size];
let mut stack = Vec::new();
for (index, parents) in candidates.parents.iter().enumerate() {
match parents.len() {
0 => reach[index] = usize::from(counted[index]),
1 => continue,
_ => {
reach[index] = count_reachable(candidates, index, &mut stamp, &mut stack);
}
}
known[index] = true;
}
let mut pending: Vec<usize> = (0..size).filter(|&i| !known[i]).collect();
while !pending.is_empty() {
let before = pending.len();
pending.retain(|&index| {
let parent = candidates.parents[index][0];
if !known[parent] {
return true;
}
reach[index] = reach[parent] + usize::from(counted[index]);
known[index] = true;
false
});
assert!(pending.len() < before, "bisect candidates contain a cycle");
}
Self {
reach,
counted,
total,
}
}
fn of(&self, index: usize) -> usize {
self.reach[index]
}
fn distance(&self, index: usize) -> usize {
self.reach[index].min(self.total - self.reach[index])
}
fn is_halfway(&self, index: usize) -> bool {
self.counted[index] && (2 * self.reach[index]).abs_diff(self.total) <= 1
}
fn halfway(&self, candidates: &Candidates) -> Option<usize> {
let order = (0..self.reach.len()).rev();
if let Some(found) = order
.clone()
.find(|&i| candidates.parents[i].len() > 1 && self.is_halfway(i))
{
return Some(found);
}
let mut known: Vec<bool> = candidates.parents.iter().map(|p| p.len() != 1).collect();
loop {
let mut changed = false;
for index in order.clone() {
if known[index] || !known[candidates.parents[index][0]] {
continue;
}
known[index] = true;
changed = true;
if self.is_halfway(index) {
return Some(index);
}
}
if !changed {
return None;
}
}
}
fn last_best(&self) -> usize {
(0..self.reach.len())
.filter(|&i| self.counted[i])
.max_by_key(|&i| (self.distance(i), i))
.unwrap_or(0)
}
fn ranked(&self, candidates: &Candidates) -> Vec<usize> {
let mut order: Vec<usize> = (0..candidates.ids.len())
.filter(|&i| self.counted[i])
.collect();
order.sort_by(|&a, &b| {
self.distance(b)
.cmp(&self.distance(a))
.then_with(|| candidates.ids[a].cmp(&candidates.ids[b]))
});
order
}
}
fn count_reachable(
candidates: &Candidates,
start: usize,
stamp: &mut [usize],
stack: &mut Vec<usize>,
) -> usize {
let mut count = 0;
stack.clear();
stack.push(start);
stamp[start] = start;
while let Some(index) = stack.pop() {
count += usize::from(candidates.counted[index]);
for &parent in &candidates.parents[index] {
if stamp[parent] != start {
stamp[parent] = start;
stack.push(parent);
}
}
}
count
}
#[cfg(test)]
mod tests {
use super::*;
fn id(n: usize) -> ObjectId {
let mut bytes = [0; 20];
bytes[..8].copy_from_slice(&(n as u64).to_be_bytes());
ObjectId::Sha1(bytes)
}
fn line(count: usize) -> Candidates {
Candidates::new((0..count).map(|i| {
let parents = if i + 1 < count { vec![i + 1] } else { vec![] };
Candidate::new(id(count - i), parents)
}))
.unwrap()
}
#[test]
fn step_estimates_match_git() {
let thresholds = [
(2, 0),
(3, 1),
(5, 1),
(6, 2),
(10, 2),
(11, 3),
(21, 3),
(22, 4),
];
for (total, steps) in thresholds {
assert_eq!(estimate_steps(total), steps, "{total}");
}
assert_eq!(estimate_steps(42), 4);
assert_eq!(estimate_steps(43), 5);
}
#[test]
fn skip_positions_match_git() {
let observed = [
(69, 20),
(68, 61),
(41, 0),
(40, 16),
(35, 33),
(33, 30),
(16, 1),
(7, 3),
(5, 1),
(2, 0),
];
for (count, position) in observed {
assert_eq!(skip_away(count), position, "{count}");
}
}
#[test]
fn line_picks_lower_middle() {
let Selection::Test {
commit, remaining, ..
} = line(5).select(&HashSet::new())
else {
panic!()
};
assert_eq!((commit, remaining), (id(2), 2));
let Selection::Test {
commit, remaining, ..
} = line(3).select(&HashSet::new())
else {
panic!()
};
assert_eq!((commit, remaining), (id(2), 0));
}
#[test]
fn single_candidate_is_found() {
assert_eq!(line(1).select(&HashSet::new()), Selection::Found(id(1)));
}
#[test]
fn all_skipped_lists_new_last() {
let skipped = [id(1), id(2)].into_iter().collect();
assert_eq!(
line(3).select(&skipped),
Selection::OnlySkipped(vec![id(1), id(2), id(3)])
);
}
#[test]
fn unchanged_commits_connect_but_are_not_counted() {
let commits = (2..=10).rev().map(|n| {
let parents = if n > 2 { vec![10 - n + 1] } else { vec![] };
let candidate = Candidate::new(id(n), parents);
if n % 3 == 0 {
candidate
} else {
candidate.unchanged()
}
});
let candidates = Candidates::new(commits).unwrap();
assert_eq!(
candidates.select(&HashSet::new()),
Selection::Test {
commit: id(3),
remaining: 1,
steps: 1
}
);
}
#[test]
fn rejects_bad_parents() {
assert_eq!(
Candidates::new([Candidate::new(id(1), vec![0])]),
Err(CandidatesError::Parent {
index: 0,
parent: 0
})
);
assert_eq!(Candidates::new([]), Err(CandidatesError::Empty));
}
}