Skip to content

Commit 2a4e363

Browse files
committed
feat(bisect): add guessing game example
In doing this, I improved some of the comments/assertions on the consumer interface that became apparent when implementing the example. I found some bugs by hand while testing and proptest also found the same bugs.
1 parent 637df10 commit 2a4e363

File tree

3 files changed

+197
-9
lines changed

3 files changed

+197
-9
lines changed
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Seeds for failure cases proptest has generated in the past. It is
2+
# automatically read and these particular cases re-run before any
3+
# novel cases are generated.
4+
#
5+
# It is recommended to check this file in to source control so that
6+
# everyone who runs the test benefits from these saved cases.
7+
cc 67760b0a17386062ba77f3c425a851fa115bcdcb9fbe2697c66e2a1867e600a7 # shrinks to inputs = [Less, Less, Less, Greater, Less, Less, Less]
8+
cc 30da0c32ee62618d38b87f1ef1fe8b135a6703ca4b5965e29da5ecf47f5860e9 # shrinks to input = 0
9+
cc f7618c6f857bd07b2c8f1b086c2ca175e73d1a6189b1d14d7ffb0f7ddd35ae68 # shrinks to input = 100

scm-bisect/examples/guessing_game.rs

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
use std::cmp::Ordering;
2+
use std::collections::HashSet;
3+
use std::convert::Infallible;
4+
use std::io;
5+
use std::ops::RangeInclusive;
6+
7+
use indexmap::IndexMap;
8+
use scm_bisect::search;
9+
10+
type Node = isize;
11+
12+
#[derive(Debug)]
13+
struct Graph;
14+
15+
impl search::Graph for Graph {
16+
type Node = Node;
17+
18+
type Error = Infallible;
19+
20+
fn is_ancestor(
21+
&self,
22+
ancestor: Self::Node,
23+
descendant: Self::Node,
24+
) -> Result<bool, Self::Error> {
25+
// Note that a node is always considered an ancestor of itself.
26+
Ok(ancestor <= descendant)
27+
}
28+
}
29+
30+
#[derive(Debug)]
31+
struct Strategy {
32+
range: RangeInclusive<Node>,
33+
}
34+
35+
impl search::Strategy<Graph> for Strategy {
36+
type Error = Infallible;
37+
38+
fn midpoint(
39+
&self,
40+
_graph: &Graph,
41+
success_bounds: &HashSet<Node>,
42+
failure_bounds: &HashSet<Node>,
43+
_statuses: &IndexMap<Node, search::Status>,
44+
) -> Result<Option<Node>, Self::Error> {
45+
let lower_bound = success_bounds
46+
.iter()
47+
.max()
48+
.copied()
49+
.unwrap_or_else(|| self.range.start() - 1);
50+
let upper_bound = failure_bounds
51+
.iter()
52+
.min()
53+
.copied()
54+
.unwrap_or_else(|| self.range.end() + 1);
55+
let midpoint = if lower_bound < upper_bound - 1 {
56+
(lower_bound + upper_bound) / 2
57+
} else {
58+
return Ok(None);
59+
};
60+
assert!(self.range.contains(&midpoint));
61+
Ok(Some(midpoint))
62+
}
63+
}
64+
65+
fn play<E>(mut read_input: impl FnMut(isize) -> Result<Ordering, E>) -> Result<Option<isize>, E> {
66+
let search_range = 0..=100;
67+
let mut search = search::Search::new(Graph, search_range.clone());
68+
let strategy = Strategy {
69+
range: search_range,
70+
};
71+
72+
let result = loop {
73+
let guess = {
74+
let mut guess = search.search(&strategy).unwrap();
75+
match guess.next_to_search.next() {
76+
Some(guess) => guess.unwrap(),
77+
None => {
78+
break None;
79+
}
80+
}
81+
};
82+
let input = read_input(guess)?;
83+
match input {
84+
Ordering::Less => search.notify(guess, search::Status::Failure).unwrap(),
85+
Ordering::Greater => search.notify(guess, search::Status::Success).unwrap(),
86+
Ordering::Equal => {
87+
break Some(guess);
88+
}
89+
}
90+
};
91+
Ok(result)
92+
}
93+
94+
fn main() -> io::Result<()> {
95+
println!("Think of a number between 0 and 100.");
96+
let result = play(|guess| -> io::Result<_> {
97+
println!("Is your number {guess}? [<=>]");
98+
let result = loop {
99+
let mut input = String::new();
100+
std::io::stdin().read_line(&mut input)?;
101+
match input.trim() {
102+
"<" => break Ordering::Less,
103+
"=" => break Ordering::Equal,
104+
">" => break Ordering::Greater,
105+
_ => println!("Please enter '<', '=', or '>'."),
106+
}
107+
};
108+
Ok(result)
109+
})?;
110+
match result {
111+
Some(result) => println!("I win! Your number was: {result}"),
112+
None => println!("I give up!"),
113+
}
114+
Ok(())
115+
}
116+
117+
#[cfg(test)]
118+
mod tests {
119+
use super::*;
120+
121+
proptest::proptest! {
122+
#[test]
123+
fn test_no_crashes_on_valid_input(inputs: Vec<Ordering>) {
124+
struct Exit;
125+
let mut iter = inputs.into_iter();
126+
let _: Result<Option<isize>, Exit> = play(move |_| iter.next().ok_or(Exit));
127+
}
128+
129+
#[test]
130+
fn test_finds_number(input in 0..=100_isize) {
131+
let result = play(|guess| -> Result<Ordering, Infallible> { Ok(input.cmp(&guess)) });
132+
assert_eq!(result, Ok(Some(input)));
133+
}
134+
}
135+
}

scm-bisect/src/search.rs

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,25 @@ pub trait Graph: Debug {
1616
/// An error type.
1717
type Error: std::error::Error;
1818

19-
/// Return whether or not `node` is an ancestor of `descendant`.
19+
/// Return whether or not `node` is an ancestor of `descendant`. A node `X``
20+
/// is said to be an "ancestor" of node `Y` if one of the following is true:
21+
///
22+
/// - `X == Y`
23+
/// - `X` is an immediate parent of `Y`.
24+
/// - `X` is an ancestor of an immediate parent of `Y` (defined
25+
/// recursively).
2026
fn is_ancestor(
2127
&self,
2228
ancestor: Self::Node,
2329
descendant: Self::Node,
2430
) -> Result<bool, Self::Error>;
2531

2632
/// Filter `nodes` to only include nodes that are not ancestors of any other
27-
/// node in `nodes`. This is not strictly necessary, but it makes the
28-
/// intermediate results more sensible.
33+
/// node in `nodes`. This is not strictly necessary, but it improves
34+
/// performance as some operations are linear in the size of the success
35+
/// bounds, and it can make the intermediate results more sensible.
36+
///
37+
/// This operation is called `heads` in e.g. Mercurial.
2938
#[instrument]
3039
fn simplify_success_bounds(
3140
&self,
@@ -35,8 +44,11 @@ pub trait Graph: Debug {
3544
}
3645

3746
/// Filter `nodes` to only include nodes that are not descendants of any
38-
/// other node in `nodes`. This is not strictly necessary, but it makes the
39-
/// intermediate results more sensible.
47+
/// other node in `nodes`. This is not strictly necessary, but it improves
48+
/// performance as some operations are linear in the size of the failure
49+
/// bounds, and it can make the intermediate results more sensible.
50+
///
51+
/// This operation is called `roots` in e.g. Mercurial.
4052
#[instrument]
4153
fn simplify_failure_bounds(
4254
&self,
@@ -102,6 +114,10 @@ pub trait Strategy<G: Graph>: Debug {
102114
/// For example, linear search would return a node immediately "after"
103115
/// the node(s) in `success_bounds`, while binary search would return the
104116
/// node in the middle of `success_bounds` and `failure_bounds`.`
117+
///
118+
/// NOTE: This must not return a value that has already been included in the
119+
/// success or failure bounds, since then you would search it again in a
120+
/// loop indefinitely. In that case, you must return `None` instead.
105121
fn midpoint(
106122
&self,
107123
graph: &G,
@@ -150,7 +166,10 @@ pub struct EagerSolution<Node: Debug + Hash + Eq> {
150166

151167
#[allow(missing_docs)]
152168
#[derive(Debug, thiserror::Error)]
153-
pub enum SearchError<TGraphError, TStrategyError> {
169+
pub enum SearchError<TNode, TGraphError, TStrategyError> {
170+
#[error("node {node:?} has already been classified as a {status:?} node, but was returned as a new midpoint to search; this would loop indefinitely")]
171+
AlreadySearchedMidpoint { node: TNode, status: Status },
172+
154173
#[error(transparent)]
155174
Graph(TGraphError),
156175

@@ -247,8 +266,8 @@ impl<G: Graph> Search<G> {
247266
&'a self,
248267
strategy: &'a S,
249268
) -> Result<
250-
LazySolution<G::Node, SearchError<G::Error, S::Error>>,
251-
SearchError<G::Error, S::Error>,
269+
LazySolution<G::Node, SearchError<G::Node, G::Error, S::Error>>,
270+
SearchError<G::Node, G::Error, S::Error>,
252271
> {
253272
let success_bounds = self.success_bounds().map_err(SearchError::Graph)?;
254273
let failure_bounds = self.failure_bounds().map_err(SearchError::Graph)?;
@@ -268,7 +287,7 @@ impl<G: Graph> Search<G> {
268287
}
269288

270289
impl<'a, G: Graph, S: Strategy<G>> Iterator for Iter<'a, G, S> {
271-
type Item = Result<G::Node, SearchError<G::Error, S::Error>>;
290+
type Item = Result<G::Node, SearchError<G::Node, G::Error, S::Error>>;
272291

273292
fn next(&mut self) -> Option<Self::Item> {
274293
while let Some(state) = self.states.pop_front() {
@@ -290,6 +309,31 @@ impl<G: Graph> Search<G> {
290309
Err(err) => return Some(Err(SearchError::Strategy(err))),
291310
};
292311

312+
for success_node in success_bounds.iter() {
313+
match self.graph.is_ancestor(node.clone(), success_node.clone()) {
314+
Ok(true) => {
315+
return Some(Err(SearchError::AlreadySearchedMidpoint {
316+
node,
317+
status: Status::Success,
318+
}));
319+
}
320+
Ok(false) => (),
321+
Err(err) => return Some(Err(SearchError::Graph(err))),
322+
}
323+
}
324+
for failure_node in failure_bounds.iter() {
325+
match self.graph.is_ancestor(failure_node.clone(), node.clone()) {
326+
Ok(true) => {
327+
return Some(Err(SearchError::AlreadySearchedMidpoint {
328+
node,
329+
status: Status::Failure,
330+
}));
331+
}
332+
Ok(false) => (),
333+
Err(err) => return Some(Err(SearchError::Graph(err))),
334+
}
335+
}
336+
293337
// Speculate failure:
294338
self.states.push_back(State {
295339
success_bounds: success_bounds.clone(),

0 commit comments

Comments
 (0)