Choice over more than 255 labels by walking a label tree (contract SQL surface, "More than 255 options", label tree mode).
Each internal node is one Choice over its children. A node's score is
the product of the edge probabilities from the root, kept as
Σ ln max(p, 1e-9). That is a real probability, and it can only fall
with depth, so a scored node bounds every leaf below it. The
cookbook's geometric mean exp(mean(ln p)) is not used: it rises with
depth and prefers deep paths of confident edges over a shallow likely
one.
The search is best-first, K nodes per round (K = 3 by default): each
round asks the Choices of the K best unexpanded nodes, which the
caller sends in one request. Nothing is dropped for being outside the
K; a node is dropped only when its bound shows it cannot change the
result. So the answer is exact: the best leaf, and the runner-up the
separation exp(top − second) is measured against.
With tau, the search returns the deepest node whose path probability
is at least τ (ties at one depth to the likelier), and never expands a
node below τ. Since sibling probabilities share their parent's, at
most 1/τ nodes per depth qualify.
A node with one child is not a question (jev_protocol::Choice): its
edge is ×1 and it is passed through without a request.
Two things ride in a round beside its K nodes, and neither changes the result:
- Lookahead ([
Lookahead]): the Choices of each asked node's likeliest children, within a token budget, so one round trip can score two levels. An answer that arrives before its node is scored is held, and applied without a request once it is. - The order twin ([
TwinBelow]): a node whose top two children come back close is not expanded; it is queued again and asked in reverse order, and the two answers' log-probabilities are averaged. Closeness is only known from the answer, so the twin goes in a later round, in its turn by score like any node: it costs a round trip only when nothing else is left to ask, and nothing when its node is pruned first.
Sans-I/O: [Search::step] says which Choices to ask, [Tree::choice]
builds each one (options describing their subtree, instructions
naming the path, in [Ask::order]), and [Search::answer] takes its
probabilities in the order sent. Sending them is the caller's.
50use jev_protocol::{Choice, Json, MAX_CHOICE_OPTIONS, Noul};
The floor under an edge probability, so a 0 costs ln 1e-9 rather
than sending a path to −∞ and making every such path tie.
54pub const PROBABILITY_FLOOR: f64 = 1e-9;
The vendor's beam width (hierarchical-classification cookbook); not measured by us.
58pub const DEFAULT_K: NonZeroUsize = NonZeroUsize::new(3).unwrap();
A node of a [Tree], valid only for the tree that issued it.
A label hierarchy as the caller writes it.
71impl Branch { 72 pub fn leaf(label: impl Into<String>) -> Self { 73 Branch { label: label.into(), children: Vec::new() } 74 } 75 76 pub fn node(label: impl Into<String>, children: impl IntoIterator<Item = Branch>) -> Self { 77 Branch { label: label.into(), children: children.into_iter().collect() } 78 } 79} 80 81impl Branch {
A root over paths, each a sequence of labels from the top down.
Siblings keep the order in which their label first appears, since
a node's options are sent in that order. Every path ends at a leaf,
so a path that is a prefix of another is refused rather than
silently dropped from the answers a search can return.
87 pub fn from_paths<P, L>(paths: P) -> Result<Branch, PathError> 88 where 89 P: IntoIterator<Item = L>, 90 L: IntoIterator, 91 L::Item: Into<String>, 92 { 93 let mut root = Branch::leaf(""); 94 let mut ends: HashSet<Vec<String>> = HashSet::new(); 95 for path in paths { 96 let path: Vec<String> = path.into_iter().map(Into::into).collect(); 97 if path.is_empty() { 98 return Err(PathError::Empty); 99 } 100 let mut at = &mut root; 101 for (i, label) in path.iter().enumerate() { 102 if ends.contains(&path[..i]) && i > 0 { 103 return Err(PathError::LeafAndGroup { path: path[..i].to_vec() }); 104 } 105 let c = match at.children.iter().position(|c| c.label == *label) { 106 Some(c) => c, 107 None => { 108 at.children.push(Branch::leaf(label.clone())); 109 at.children.len() - 1 110 } 111 }; 112 at = &mut at.children[c]; 113 } 114 if !at.children.is_empty() { 115 return Err(PathError::LeafAndGroup { path }); 116 } 117 if !ends.insert(path.clone()) { 118 return Err(PathError::Repeated { path }); 119 } 120 } 121 Ok(root) 122 } 123}
Paths that do not describe a tree of leaves.
134impl std::fmt::Display for PathError { 135 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 136 match self { 137 PathError::Empty => write!(f, "a label path is empty"), 138 PathError::Repeated { path } => write!(f, "the label path {path:?} is given twice"), 139 PathError::LeafAndGroup { path } => { 140 write!(f, "the label path {path:?} is both a label and the parent of other labels") 141 } 142 } 143 } 144} 145 146impl std::error::Error for PathError {} 147 148#[derive(Clone, Debug, PartialEq, Eq)] 149pub enum TreeError {
The root has no children, so there is nothing to choose.
151 NoChoice,
More children than one Choice can carry.
158impl std::fmt::Display for TreeError { 159 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 160 match self { 161 TreeError::NoChoice => write!(f, "the label tree has no labels under its root"), 162 TreeError::Fanout { path, children } => write!( 163 f, 164 "the label tree node {path:?} has {children} children; a Choice takes at most {MAX_CHOICE_OPTIONS}" 165 ), 166 TreeError::EmptyLabel { path } => write!(f, "a label under {path:?} is empty"), 167 TreeError::DuplicateLabel { path, label } => { 168 write!(f, "the label {label:?} appears twice under {path:?}") 169 } 170 } 171 } 172} 173 174impl std::error::Error for TreeError {} 175 176#[derive(Clone, Debug)] 177struct Node { 178 label: String, 179 parent: Option<NodeId>, 180 children: Vec<NodeId>, 181 depth: u32,
Edges from this node down to its deepest leaf.
183 height: u32,
A validated label hierarchy: every node has at most 255 children with unique, non-empty labels, so every node's Choice can be built.
195impl Tree {
The root's own label is never sent; its children are the first Choice.
207 fn add(&mut self, b: Branch, parent: Option<NodeId>, depth: u32) -> Result<NodeId, TreeError> { 208 let id = NodeId(self.nodes.len() as u32); 209 self.nodes.push(Node { label: b.label, parent, children: Vec::new(), depth, height: 0, leaves: 1 }); 210 if b.children.len() > MAX_CHOICE_OPTIONS { 211 return Err(TreeError::Fanout { path: self.path_owned(id), children: b.children.len() }); 212 } 213 let mut seen = HashSet::new(); 214 for c in &b.children { 215 if c.label.is_empty() { 216 return Err(TreeError::EmptyLabel { path: self.path_owned(id) }); 217 } 218 if !seen.insert(c.label.clone()) { 219 return Err(TreeError::DuplicateLabel { path: self.path_owned(id), label: c.label.clone() }); 220 } 221 } 222 let (mut height, mut leaves) = (0, 0); 223 for c in b.children { 224 let child = self.add(c, Some(id), depth + 1)?; 225 self.nodes[id.0 as usize].children.push(child); 226 height = height.max(self.nodes[child.0 as usize].height + 1); 227 leaves += self.nodes[child.0 as usize].leaves; 228 } 229 self.nodes[id.0 as usize].height = height; 230 self.nodes[id.0 as usize].leaves = leaves.max(1); 231 Ok(id) 232 } 233 234 fn node(&self, n: NodeId) -> &Node { 235 &self.nodes[n.0 as usize] 236 } 237 238 pub fn root(&self) -> NodeId { 239 NodeId(0) 240 } 241 242 pub fn label(&self, n: NodeId) -> &str { 243 &self.node(n).label 244 }
In the caller's order, which is the order the Choice sends.
Edges from the root; the root is 0.
The labels from below the root down to n.
The leaves under n, depth first in the caller's order.
n's Choice over its children, in order (the caller's, or
reversed for an order-debiasing twin), each label the option key
(contract SQL surface). The instructions are
question and, below the root, the path to n; an internal
child's description lists its direct children and a sample of the
leaves under it, so the model sees what lives under a branch
(vendor guidance; its hierarchical cookbook does neither). A leaf
child has no description. None for a node with fewer than two
children, which [Search] never asks.
Every piece is written in a fixed form, so equal nodes give equal bytes and one cache key.
301 pub fn choice(&self, n: NodeId, order: Order, question: &str, describe: Describe) -> Option<Choice> { 302 let children = self.children(n); 303 if children.len() < 2 { 304 return None; 305 } 306 let path = self.path(n); 307 let instructions = if path.is_empty() { 308 question.to_owned() 309 } else { 310 format!("{question}\n\nWithin: {}", path.join(" > ")) 311 }; 312 let option = |&c: &NodeId| (self.label(c).to_owned(), self.describe(c, describe).map(|d| Json::text(&d))); 313 let options: Vec<_> = match order { 314 Order::Caller => children.iter().map(option).collect(), 315 Order::Reversed => children.iter().rev().map(option).collect(), 316 }; 317 Some(Choice::new(Json::text(&instructions), options).expect("a Tree node's children make a valid Choice")) 318 }
The "does any label fit" Noul ([crate::gate]), with the tree shown
as the root is described: its top-level labels and a sample of
leaves, so its size does not grow with the tree. None for a tree
with one leaf, which the search answers without a request.
324 pub fn gate(&self, question: &str, describe: Describe) -> Option<Noul> { 325 let root = self.root(); 326 if self.leaves(root).len() < 2 { 327 return None; 328 } 329 let labels = self.describe(root, describe).expect("a root with children is described"); 330 Some(crate::gate::gate(question, &labels)) 331 }
333 fn describe(&self, n: NodeId, describe: Describe) -> Option<String> { 334 let children = self.children(n); 335 if children.is_empty() { 336 return None; 337 } 338 let listed = children.len().min(describe.children); 339 let mut text = format!( 340 "Contains: {}", 341 children[..listed].iter().map(|&c| self.label(c)).collect::<Vec<_>>().join(", ") 342 ); 343 if listed < children.len() { 344 text.push_str(&format!(", and {} more", children.len() - listed)); 345 } 346 // Leaves that are not already listed as direct children. 347 let sample: Vec<&str> = self 348 .leaves(n) 349 .into_iter() 350 .filter(|l| self.node(*l).parent != Some(n)) 351 .take(describe.leaves) 352 .map(|l| self.label(l)) 353 .collect(); 354 if !sample.is_empty() { 355 text.push_str(&format!(". For example: {}", sample.join(", "))); 356 } 357 Some(text) 358 } 359}
How much of a subtree an option's description shows. The defaults are ours and unmeasured; the label-tree release gate (Testing) measures them with K.
Direct children listed, in the caller's order.
367 pub children: usize,
The order a Choice's options are sent in.
A probability threshold for early stop, in (0, 1].
Speculative Choices carried in a round: for each node asked, the
Choices of its m likeliest children, while the round's estimated
tokens stay within budget. So one round trip scores two levels.
"Likeliest" is judged before the parent is answered, since both ride
in one request, so it is a prior: the children with the most leaves
under them (a uniform prior over leaves), ties in the caller's order.
m and the prior are unmeasured; the label-tree release gate
(Testing) measures them.
Estimated input tokens for the whole round, the asked nodes' Choices included. The asked nodes are sent even when they alone exceed it.
418impl Default for Lookahead {
Below jev-1.13.0's 64k tokens per request, leaving room for the request's own overhead.
A node whose top two children are within this separation
(p_top / p_second, floored) is a close call, and its Choice is asked
again in reverse order; the two answers' log-probabilities are
averaged (permutation self-consistency, arXiv 2310.07712), since the
measured order bias favours whatever is listed last. Greater than 1.
Scored nodes asked per round, all in one request. A twin takes a slot, since it is its node's own question; lookahead Choices ride outside it.
449 pub k: NonZeroUsize,
Unset: always return a leaf.
451 pub tau: Option<Tau>,
Unset: no speculative Choices.
453 pub lookahead: Option<Lookahead>,
458impl Default for Params {
The twin's 1.5× is ours and unmeasured, like [Lookahead]'s
defaults.
One Choice a round asks. Build it with [Tree::choice] in
[Ask::order], and answer it with [Search::answer].
A scored node, best first.
471 Node(NodeId),
A close call's second asking, in reverse order.
473 Twin(NodeId),
Ask these Choices, in one request, and [Search::answer] each.
exp(score_top − score_second): near 1 is ambiguous. None
when the tree admits one leaf only.
Under tau: the deepest node at or above τ.
Not asked in the current round, or already answered.
518 NotAsked(Ask),
One probability per option, in the order sent.
524impl std::fmt::Display for AnswerError { 525 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 526 match self { 527 AnswerError::NotAsked(a) => write!(f, "label tree Choice {a:?} was not asked"), 528 AnswerError::Count { node, expected, got } => { 529 write!(f, "label tree node {node:?} has {expected} options, answered with {got}") 530 } 531 AnswerError::NotAProbability { node, value } => { 532 write!(f, "label tree node {node:?} was answered with {value}, not a probability") 533 } 534 } 535 } 536} 537 538impl std::error::Error for AnswerError {} 539 540#[derive(Clone, Copy, Debug)] 541struct Entry { 542 score: f64, 543 node: NodeId, 544}
One label-tree search: best-first, exact, K nodes per round.
Lookahead and twins change what a round carries, never the result: an answer that arrives early is held until its node is scored, and the node is then expanded without a request. The pruning is the same bound either way.
Scored and not yet expanded or finalized, best first. A queued
node with an entry in answers is a close call awaiting its twin.
558 queue: Vec<Entry>,
Asked this round; score is None for a lookahead, whose node is
not scored yet.
561 pending: Vec<(Ask, Option<f64>)>,
Answers not yet applied, in child order: a lookahead's, until its node is scored, and a close call's first, until its twin.
564 answers: HashMap<NodeId, Vec<f64>>,
Leaves proven best, in order (no tau).
566 finalized: Vec<Entry>,
571impl<'t> Search<'t> { 572 pub fn new(tree: &'t Tree, params: Params) -> Self { 573 let mut s = Search { 574 tree, 575 params, 576 queue: Vec::new(), 577 pending: Vec::new(), 578 answers: HashMap::new(), 579 finalized: Vec::new(), 580 stop: None, 581 }; 582 s.push(Entry { score: 0.0, node: tree.root() }); 583 s 584 } 585 586 fn ln_tau(&self) -> Option<f64> { 587 self.params.tau.map(|t| t.get().ln()) 588 }
Scores a node, passing through one-child nodes at ×1, and expands it at once if its answer is already held.
592 fn push(&mut self, mut e: Entry) { 593 loop { 594 if let Some(ln_tau) = self.ln_tau() { 595 if e.score < ln_tau { 596 return; 597 } 598 if self.better_stop(e) { 599 self.stop = Some(e); 600 } 601 } 602 match self.tree.children(e.node) { 603 [only] => e.node = *only, 604 _ => break, 605 } 606 } 607 if self.params.tau.is_some() && self.tree.is_leaf(e.node) { 608 return; 609 } 610 if let Some(first) = self.answers.get(&e.node) 611 && !self.is_close(first) 612 { 613 let first = self.answers.remove(&e.node).expect("just read"); 614 let ln: Vec<f64> = first.iter().map(|p| ln_floored(*p)).collect(); 615 return self.expand(e, &ln); 616 } 617 self.enqueue(e); 618 }
620 fn enqueue(&mut self, e: Entry) { 621 let at = self.queue.partition_point(|q| rank(q, &e).is_lt()); 622 self.queue.insert(at, e); 623 } 624 625 fn expand(&mut self, parent: Entry, ln: &[f64]) { 626 let children = self.tree.children(parent.node); 627 for (&child, &l) in children.iter().zip(ln) { 628 self.push(Entry { score: parent.score + l, node: child }); 629 } 630 }
Whether this first answer, in child order, needs a twin.
633 fn is_close(&self, probabilities: &[f64]) -> bool { 634 let Some(twin) = self.params.twin else { return false }; 635 let (mut top, mut second) = (0.0f64, 0.0f64); 636 for &p in probabilities { 637 let p = p.max(PROBABILITY_FLOOR); 638 if p > top { 639 (top, second) = (p, top); 640 } else if p > second { 641 second = p; 642 } 643 } 644 top / second <= twin.get() 645 }
Whether anything under e could replace the current stop.
The next round to ask, or the result. Asking again before every asked Choice is answered returns the same round.
tokens estimates one Choice's input tokens, for the lookahead
budget; it is not called without [Params::lookahead].
672 pub fn step(&mut self, tokens: impl FnMut(Ask) -> usize) -> Step { 673 if !self.pending.is_empty() { 674 return self.round(); 675 } 676 if self.params.tau.is_some() { 677 return self.step_tau(tokens); 678 } 679 // A leaf at the head of the queue beats every leaf still unscored, 680 // because scores only fall with depth. 681 while let Some(head) = self.queue.first() { 682 if !self.tree.is_leaf(head.node) { 683 break; 684 } 685 self.finalized.push(self.queue.remove(0)); 686 if self.finalized.len() == 2 { 687 return Step::Done(self.leaf_outcome()); 688 } 689 } 690 if self.queue.is_empty() { 691 return Step::Done(self.leaf_outcome()); 692 } 693 // Only the best two leaves matter: a node no likelier than the 694 // second-best known leaf cannot change them. 695 let needed = 2 - self.finalized.len(); 696 let bound = self.queue.iter().filter(|e| self.tree.is_leaf(e.node)).nth(needed - 1).map(|e| e.score); 697 if let Some(bound) = bound { 698 let tree = self.tree; 699 self.queue.retain(|e| tree.is_leaf(e.node) || e.score > bound); 700 } 701 self.take_round(|tree, e| !tree.is_leaf(e.node), tokens) 702 }
704 fn step_tau(&mut self, tokens: impl FnMut(Ask) -> usize) -> Step { 705 let queue = std::mem::take(&mut self.queue); 706 self.queue = queue.into_iter().filter(|e| self.could_beat_stop(e)).collect(); 707 if self.queue.is_empty() { 708 let s = self.stop.expect("the root is always at or above τ"); 709 return Step::Done(Outcome::Stop { 710 node: s.node, 711 depth: self.tree.depth(s.node), 712 leaf: self.tree.is_leaf(s.node), 713 probability: s.score.exp(), 714 }); 715 } 716 self.take_round(|_, _| true, tokens) 717 } 718 719 fn take_round(&mut self, askable: impl Fn(&Tree, &Entry) -> bool, mut tokens: impl FnMut(Ask) -> usize) -> Step { 720 let k = self.params.k.get(); 721 let mut i = 0; 722 while i < self.queue.len() && self.pending.len() < k { 723 if askable(self.tree, &self.queue[i]) { 724 let e = self.queue.remove(i); 725 let ask = if self.answers.contains_key(&e.node) { Ask::Twin(e.node) } else { Ask::Node(e.node) }; 726 self.pending.push((ask, Some(e.score))); 727 } else { 728 i += 1; 729 } 730 } 731 if let Some(look) = self.params.lookahead { 732 self.add_lookahead(look, &mut tokens); 733 } 734 self.round() 735 }
Fills the round with the asked nodes' likeliest children's Choices, best-ranked asked node first, within the budget.
739 fn add_lookahead(&mut self, look: Lookahead, tokens: &mut impl FnMut(Ask) -> usize) { 740 let mut used: usize = self.pending.iter().map(|(a, _)| tokens(*a)).sum(); 741 let parents: Vec<NodeId> = self.pending.iter().map(|(a, _)| a.node()).collect(); 742 for parent in parents { 743 let mut children = self.tree.children(parent).to_vec(); 744 // Stable, so ties keep the caller's order. 745 children.sort_by_key(|&c| std::cmp::Reverse(self.tree.node(c).leaves)); 746 for c in children.into_iter().take(look.m) { 747 let Some(n) = self.askable_below(c) else { continue }; 748 if self.answers.contains_key(&n) || self.pending.iter().any(|(a, _)| a.node() == n) { 749 continue; 750 } 751 let ask = Ask::Lookahead(n); 752 let cost = tokens(ask); 753 if used + cost > look.budget { 754 continue; 755 } 756 used += cost; 757 self.pending.push((ask, None)); 758 } 759 } 760 }
The node a Choice would be asked for once n is scored: n
passed through its one-child chain, or None at a leaf.
A Choice's answer: one probability per option, in the order it was
sent ([Ask::order]).
785 pub fn answer(&mut self, ask: Ask, probabilities: &[f64]) -> Result<(), AnswerError> { 786 let at = self.pending.iter().position(|(a, _)| *a == ask).ok_or(AnswerError::NotAsked(ask))?; 787 let node = ask.node(); 788 let children = self.tree.children(node); 789 if probabilities.len() != children.len() { 790 return Err(AnswerError::Count { node, expected: children.len(), got: probabilities.len() }); 791 } 792 if let Some(&value) = probabilities.iter().find(|p| !(0.0..=1.0).contains(*p)) { 793 return Err(AnswerError::NotAProbability { node, value }); 794 } 795 let mut in_child_order = probabilities.to_vec(); 796 if ask.order() == Order::Reversed { 797 in_child_order.reverse(); 798 } 799 let (_, score) = self.pending.remove(at); 800 match ask { 801 Ask::Node(_) => { 802 let e = Entry { score: score.expect("an asked node is scored"), node }; 803 if self.is_close(&in_child_order) { 804 self.answers.insert(node, in_child_order); 805 self.enqueue(e); 806 } else { 807 let ln: Vec<f64> = in_child_order.iter().map(|p| ln_floored(*p)).collect(); 808 self.expand(e, &ln); 809 } 810 } 811 Ask::Twin(_) => { 812 let e = Entry { score: score.expect("a twin's node is scored"), node }; 813 let first = self.answers.remove(&node).expect("a twin follows a first answer"); 814 let ln: Vec<f64> = 815 first.iter().zip(&in_child_order).map(|(a, b)| (ln_floored(*a) + ln_floored(*b)) / 2.0).collect(); 816 self.expand(e, &ln); 817 } 818 Ask::Lookahead(_) => { 819 self.answers.insert(node, in_child_order); 820 // Its parent may have been answered first in this round. 821 if let Some(i) = self.queue.iter().position(|e| e.node == node) { 822 let e = self.queue.remove(i); 823 self.push(e); 824 } 825 } 826 } 827 Ok(()) 828 } 829}
Best first; equal scores in tree order, so a search is deterministic.
Runs a search against a fixed answer per internal node, by path and in child order (reversed for a twin), and returns the rounds.
846 fn run_rounds( 847 tree: &Tree, 848 params: Params, 849 answer: impl Fn(&[&str], Order) -> Vec<f64>, 850 ) -> (Outcome, Vec<Vec<Ask>>) { 851 let mut s = Search::new(tree, params); 852 let mut rounds = Vec::new(); 853 loop { 854 match s.step(|_| 100) { 855 Step::Done(o) => return (o, rounds), 856 Step::Ask(round) => { 857 assert!(!round.is_empty()); 858 assert!(round.iter().filter(|a| !matches!(a, Ask::Lookahead(_))).count() <= params.k.get()); 859 // Lookahead first, so a held answer is applied when its 860 // parent's lands, as well as the other way round. 861 for &a in round.iter().rev() { 862 s.answer(a, &answer(&tree.path(a.node()), a.order())).unwrap(); 863 } 864 rounds.push(round); 865 } 866 } 867 } 868 }
An answer by path that is the same in either order.
The search alone: no lookahead, no twins.
886 #[test] 887 fn the_gate_shows_the_tree_as_the_root_is_described() { 888 let tree = Tree::new(Branch::from_paths([vec!["A", "a1"], vec!["A", "a2"], vec!["B"]]).unwrap()).unwrap(); 889 let want = crate::gate::gate("q", "Contains: A, B. For example: a1, a2"); 890 assert_eq!(tree.gate("q", Describe::default()), Some(want)); 891 let one = Tree::new(Branch::from_paths([vec!["A"]]).unwrap()).unwrap(); 892 assert_eq!(one.gate("q", Describe::default()), None); 893 } 894 895 #[test] 896 fn from_paths_groups_by_prefix_in_first_order() { 897 let root = Branch::from_paths([vec!["B", "y"], vec!["A"], vec!["B", "x"], vec!["C", "z", "1"]]).unwrap(); 898 let want = Branch::node( 899 "", 900 [ 901 Branch::node("B", [Branch::leaf("y"), Branch::leaf("x")]), 902 Branch::leaf("A"), 903 Branch::node("C", [Branch::node("z", [Branch::leaf("1")])]), 904 ], 905 ); 906 assert_eq!(root, want); 907 assert!(Tree::new(root).is_ok()); 908 } 909 910 #[test] 911 fn from_paths_refuses_what_is_not_a_tree_of_leaves() { 912 let none: [Vec<&str>; 1] = [vec![]]; 913 assert_eq!(Branch::from_paths(none), Err(PathError::Empty)); 914 assert_eq!( 915 Branch::from_paths([vec!["A", "x"], vec!["A", "x"]]), 916 Err(PathError::Repeated { path: vec!["A".into(), "x".into()] }) 917 ); 918 // Either order: the group first, or the leaf first. 919 assert_eq!( 920 Branch::from_paths([vec!["A", "x"], vec!["A"]]), 921 Err(PathError::LeafAndGroup { path: vec!["A".into()] }) 922 ); 923 assert_eq!( 924 Branch::from_paths([vec!["A"], vec!["A", "x"]]), 925 Err(PathError::LeafAndGroup { path: vec!["A".into()] }) 926 ); 927 }
With [plain] params, the Choices asked.
935 fn chain(label: &str, depth: usize, leaf: &str) -> Branch { 936 (0..depth).rev().fold(Branch::leaf(leaf), |below, i| { 937 Branch::node(format!("{label}{i}"), [below, Branch::leaf(format!("{label}{i}-other"))]) 938 }) 939 } 940 941 fn leaf_of(o: &Outcome) -> NodeId { 942 match o { 943 Outcome::Leaf { leaf, .. } => *leaf, 944 other => panic!("{other:?}"), 945 } 946 } 947 948 fn sent(c: &Choice) -> String { 949 jev_protocol::question_bytes(&jev_protocol::Question::Choice(c.clone())) 950 .map(|b| String::from_utf8(b).unwrap()) 951 .unwrap() 952 }
Options describe their subtree; instructions name the path; a leaf has no description; a one-child node has no Choice.
956 #[test] 957 fn node_choices() { 958 let tree = Tree::new(Branch::node( 959 "root", 960 [ 961 Branch::node("Animals", [ 962 Branch::node("Birds", [Branch::leaf("Owl"), Branch::leaf("Wren")]), 963 Branch::leaf("Fish"), 964 ]), 965 Branch::leaf("Plants"), 966 Branch::node("Rocks", [Branch::leaf("Granite")]), 967 ], 968 )) 969 .unwrap(); 970 let root = tree.root(); 971 let c = tree.choice(root, Order::Caller, "What is it?", Describe::default()).unwrap(); 972 assert_eq!( 973 sent(&c), 974 r#"{"type":"choice","instructions":"What is it?","criteria":{"Animals":"Contains: Birds, Fish. For example: Owl, Wren","Plants":null,"Rocks":"Contains: Granite"}}"# 975 ); 976 let twin = tree.choice(root, Order::Reversed, "What is it?", Describe::default()).unwrap(); 977 assert_eq!( 978 sent(&twin), 979 r#"{"type":"choice","instructions":"What is it?","criteria":{"Rocks":"Contains: Granite","Plants":null,"Animals":"Contains: Birds, Fish. For example: Owl, Wren"}}"# 980 ); 981 let animals = tree.children(root)[0]; 982 let birds = tree.children(animals)[0]; 983 let c = tree.choice(birds, Order::Caller, "What is it?", Describe::default()).unwrap(); 984 assert_eq!( 985 sent(&c), 986 r#"{"type":"choice","instructions":"What is it?\n\nWithin: Animals > Birds","criteria":{"Owl":null,"Wren":null}}"# 987 ); 988 let rocks = tree.children(root)[2]; 989 assert!(tree.choice(rocks, Order::Caller, "q", Describe::default()).is_none()); 990 let short = tree.choice(root, Order::Caller, "q", Describe { children: 1, leaves: 1 }).unwrap(); 991 assert!(sent(&short).contains(r#""Animals":"Contains: Birds, and 1 more. For example: Owl""#), "{}", sent(&short)); 992 }
Contract: five 0.9 edges are a 0.59 path and lose to one 0.8 edge, which the geometric mean (0.90 against 0.8) would get backwards.
996 #[test] 997 fn five_confident_edges_lose_to_one_likelier_edge() { 998 // root → deep (0.9) → d1 (0.9) → d2 (0.9) → d3 (0.9) → deep-leaf (0.9); 999 // root → shallow (0.8, a leaf). 1000 let deep = Branch::node( 1001 "deep", 1002 [ 1003 Branch::node( 1004 "d1", 1005 [ 1006 Branch::node( 1007 "d2", 1008 [ 1009 Branch::node("d3", [Branch::leaf("deep-leaf"), Branch::leaf("d3-x")]), 1010 Branch::leaf("d2-x"), 1011 ], 1012 ), 1013 Branch::leaf("d1-x"), 1014 ], 1015 ), 1016 Branch::leaf("deep-x"), 1017 ], 1018 ); 1019 let tree = Tree::new(Branch::node("", [deep, Branch::leaf("shallow")])).unwrap(); 1020 let (o, _) = run(&tree, plain(), |path| match path { 1021 [] => vec![0.9, 0.8], 1022 _ => vec![0.9, 0.1], 1023 }); 1024 let Outcome::Leaf { leaf, probability, separation } = o else { panic!("{o:?}") }; 1025 assert_eq!(tree.path(leaf), ["shallow"]); 1026 assert!((probability - 0.8).abs() < 1e-12, "{probability}"); 1027 // The runner-up is `deep-leaf` itself, at 0.9⁵ ≈ 0.59. 1028 assert!((0.9f64.powi(5) - 0.59).abs() < 0.001); 1029 assert!((separation.unwrap() - 0.8 / 0.9f64.powi(5)).abs() < 1e-9, "{separation:?}"); 1030 }
Exactness: the result equals exhaustive enumeration of every leaf, for every K, on a tree where greedy descent is wrong.
1034 #[test] 1035 fn exact_against_exhaustive() { 1036 let tree = Tree::new(Branch::node( 1037 "", 1038 [ 1039 Branch::node("a", [Branch::leaf("a1"), Branch::leaf("a2"), Branch::leaf("a3")]), 1040 Branch::node("b", [Branch::leaf("b1"), Branch::node("b2", [Branch::leaf("b2x"), Branch::leaf("b2y")])]), 1041 Branch::node("c", [Branch::leaf("c1"), Branch::leaf("c2")]), 1042 ], 1043 )) 1044 .unwrap(); 1045 // Greedy takes `a` (0.4) but its leaves split three ways; `b1` is 1046 // 0.35 × 0.95. 1047 let answer = |path: &[&str]| -> Vec<f64> { 1048 match path { 1049 [] => vec![0.4, 0.35, 0.25], 1050 ["a"] => vec![0.34, 0.33, 0.33], 1051 ["b"] => vec![0.95, 0.05], 1052 ["b", "b2"] => vec![0.5, 0.5], 1053 ["c"] => vec![0.6, 0.4], 1054 other => panic!("{other:?} asked"), 1055 } 1056 }; 1057 let mut leaves: Vec<(f64, Vec<&str>)> = Vec::new(); 1058 let mut stack = vec![(tree.root(), 0.0)]; 1059 while let Some((n, s)) = stack.pop() { 1060 if tree.is_leaf(n) { 1061 leaves.push((s, tree.path(n))); 1062 continue; 1063 } 1064 for (&c, p) in tree.children(n).iter().zip(answer(&tree.path(n))) { 1065 stack.push((c, s + p.max(PROBABILITY_FLOOR).ln())); 1066 } 1067 } 1068 leaves.sort_by(|a, b| b.0.total_cmp(&a.0)); 1069 // Lookahead and twins change the rounds, never the result. 1070 let extras = [ 1071 (None, None), 1072 (Some(Lookahead::default()), None), 1073 (Some(Lookahead { m: 1, budget: 250 }), TwinBelow::new(1.5)), 1074 (Some(Lookahead::default()), TwinBelow::new(1.5)), 1075 ]; 1076 for k in 1..=5 { 1077 for (lookahead, twin) in extras { 1078 let params = Params { k: NonZeroUsize::new(k).unwrap(), tau: None, lookahead, twin }; 1079 let (o, _) = run_rounds(&tree, params, fixed(answer)); 1080 let Outcome::Leaf { leaf, separation, .. } = o else { panic!() }; 1081 assert_eq!(tree.path(leaf), leaves[0].1, "K={k} {params:?}"); 1082 assert!((separation.unwrap() - (leaves[0].0 - leaves[1].0).exp()).abs() < 1e-12, "{params:?}"); 1083 } 1084 } 1085 }
A confident path never asks the unlikely subtrees below it.
1088 #[test] 1089 fn prunes_what_cannot_win() { 1090 let wide = |l: &str| Branch::node(l, (0..50).map(|i| chain(&format!("{l}{i}-"), 3, &format!("{l}{i}!")))); 1091 let tree = Tree::new(Branch::node("", [wide("x"), wide("y"), wide("z")])).unwrap(); 1092 let (o, asked) = run(&tree, plain(), |path| match path.len() { 1093 0 => vec![0.98, 0.01, 0.01], 1094 1 => std::iter::once(0.9).chain(std::iter::repeat_n(0.1 / 49.0, 49)).collect(), 1095 _ => vec![0.9, 0.1], 1096 }); 1097 assert_eq!(tree.path(leaf_of(&o)).last(), Some(&"x0!")); 1098 // The best path, and its runner-ups' first Choices; nowhere near 1099 // the 3 + 150 + 450 internal nodes. 1100 assert!(asked <= 12, "{asked}"); 1101 }
1103 #[test] 1104 fn one_child_is_not_asked() { 1105 let tree = Tree::new(Branch::node( 1106 "", 1107 [Branch::node("only", [Branch::node("still", [Branch::leaf("p"), Branch::leaf("q")])])], 1108 )) 1109 .unwrap(); 1110 let (o, asked) = run(&tree, plain(), |path| { 1111 assert_eq!(path, ["only", "still"]); 1112 vec![0.3, 0.7] 1113 }); 1114 let Outcome::Leaf { leaf, probability, .. } = o else { panic!() }; 1115 assert_eq!(tree.path(leaf), ["only", "still", "q"]); 1116 assert!((probability - 0.7).abs() < 1e-12); 1117 assert_eq!(asked, 1); 1118 } 1119 1120 #[test] 1121 fn a_single_leaf_has_no_separation() { 1122 let tree = Tree::new(Branch::node("", [Branch::leaf("sole")])).unwrap(); 1123 let (o, asked) = run(&tree, plain(), |_| unreachable!()); 1124 assert_eq!(o, Outcome::Leaf { leaf: NodeId(1), probability: 1.0, separation: None }); 1125 assert_eq!(asked, 0); 1126 } 1127 1128 #[test] 1129 fn a_zero_is_floored_not_minus_infinity() { 1130 let tree = Tree::new(Branch::node( 1131 "", 1132 [Branch::node("a", [Branch::leaf("a1"), Branch::leaf("a2")]), Branch::leaf("b")], 1133 )) 1134 .unwrap(); 1135 let (o, _) = run(&tree, plain(), |path| match path { 1136 [] => vec![1.0, 0.0], 1137 _ => vec![0.0, 0.0], 1138 }); 1139 let Outcome::Leaf { leaf, separation, .. } = o else { panic!() }; 1140 // a1 and a2 tie at 1e-9; `b` (also 1e-9) is not ranked below −∞. 1141 assert_eq!(tree.path(leaf), ["a", "a1"]); 1142 assert_eq!(separation, Some(1.0)); 1143 }
Lookahead scores two levels per round trip on a chain, and asks nothing it was not going to use there.
1147 #[test] 1148 fn lookahead_halves_the_round_trips() { 1149 let tree = Tree::new(chain("n", 8, "end")).unwrap(); 1150 let answer = |_: &[&str]| vec![0.9, 0.1]; 1151 let k1 = Params { k: NonZeroUsize::MIN, ..plain() }; 1152 let (o, rounds) = run_rounds(&tree, k1, fixed(answer)); 1153 assert_eq!(tree.path(leaf_of(&o)).last(), Some(&"end")); 1154 assert_eq!(rounds.len(), 8); 1155 let look = Params { lookahead: Some(Lookahead { m: 1, budget: 1_000 }), ..k1 }; 1156 let (o2, rounds) = run_rounds(&tree, look, fixed(answer)); 1157 assert_eq!(o2, o); 1158 assert_eq!(rounds.len(), 4, "{rounds:?}"); 1159 assert!(rounds.iter().all(|r| matches!(r[..], [Ask::Node(_), Ask::Lookahead(_)])), "{rounds:?}"); 1160 }
Lookahead fills the round up to the budget, likeliest child (most leaves) first, and never past it.
1164 #[test] 1165 fn lookahead_stays_within_the_budget() { 1166 let tree = Tree::new(Branch::node( 1167 "", 1168 [ 1169 Branch::node("small", [Branch::leaf("s1"), Branch::leaf("s2")]), 1170 Branch::node("big", [Branch::leaf("b1"), Branch::leaf("b2"), Branch::leaf("b3")]), 1171 Branch::node("mid", [Branch::leaf("m1"), Branch::leaf("m2")]), 1172 ], 1173 )) 1174 .unwrap(); 1175 let [small, big, mid] = tree.children(tree.root()) else { panic!() }; 1176 let first = |budget| { 1177 let mut s = Search::new(&tree, Params { lookahead: Some(Lookahead { m: 3, budget }), ..plain() }); 1178 let Step::Ask(round) = s.step(|_| 100) else { panic!() }; 1179 round 1180 }; 1181 let root = Ask::Node(tree.root()); 1182 assert_eq!(first(99), [root]); 1183 assert_eq!(first(250), [root, Ask::Lookahead(*big)]); 1184 // Equal leaf counts keep the caller's order. 1185 assert_eq!(first(400), [root, Ask::Lookahead(*big), Ask::Lookahead(*small), Ask::Lookahead(*mid)]); 1186 }
A close call is asked again in reverse, and the two answers' log-probabilities are averaged, which undoes a bias toward the first-listed option.
1191 #[test] 1192 fn a_close_call_gets_an_order_twin() { 1193 let tree = Tree::new(Branch::node("", [Branch::leaf("a"), Branch::leaf("b")])).unwrap(); 1194 // Listed first gains 0.1: truly a 0.45, b 0.55. 1195 let biased = |_: &[&str], order: Order| match order { 1196 Order::Caller => vec![0.55, 0.45], 1197 Order::Reversed => vec![0.65, 0.35], 1198 }; 1199 let (o, rounds) = run_rounds(&tree, plain(), biased); 1200 assert_eq!(tree.path(leaf_of(&o)), ["a"]); 1201 assert_eq!(rounds.len(), 1); 1202 1203 let twin = Params { twin: TwinBelow::new(1.5), ..plain() }; 1204 let (o, rounds) = run_rounds(&tree, twin, biased); 1205 assert_eq!(rounds, [vec![Ask::Node(tree.root())], vec![Ask::Twin(tree.root())]]); 1206 let Outcome::Leaf { leaf, probability, separation } = o else { panic!() }; 1207 assert_eq!(tree.path(leaf), ["b"]); 1208 let (a, b) = ((0.55f64 * 0.35).sqrt(), (0.45f64 * 0.65).sqrt()); 1209 assert!((probability - b).abs() < 1e-12, "{probability}"); 1210 assert!((separation.unwrap() - b / a).abs() < 1e-12); 1211 1212 // Not close: no twin. 1213 let (_, rounds) = run_rounds(&tree, twin, fixed(|_| vec![0.2, 0.8])); 1214 assert_eq!(rounds.len(), 1); 1215 assert!(TwinBelow::new(1.0).is_none()); 1216 assert!(TwinBelow::new(f64::INFINITY).is_none()); 1217 }
A close call found by lookahead waits for its twin too.
1220 #[test] 1221 fn a_close_lookahead_answer_is_twinned() { 1222 let tree = Tree::new(Branch::node( 1223 "", 1224 [Branch::node("a", [Branch::leaf("a1"), Branch::leaf("a2")]), Branch::leaf("b")], 1225 )) 1226 .unwrap(); 1227 let a = tree.children(tree.root())[0]; 1228 let (o, rounds) = run_rounds(&tree, Params::default(), fixed(|path| match path { 1229 [] => vec![0.9, 0.1], 1230 _ => vec![0.5, 0.5], 1231 })); 1232 assert_eq!(rounds, [vec![Ask::Node(tree.root()), Ask::Lookahead(a)], vec![Ask::Twin(a)]]); 1233 assert_eq!(tree.path(leaf_of(&o)), ["a", "a1"]); 1234 }
1236 fn tau(t: f64) -> Params { 1237 Params { tau: Tau::new(t), ..plain() } 1238 } 1239 1240 #[test] 1241 fn tau_stops_where_the_path_falls_below_it() { 1242 let tree = Tree::new(chain("n", 4, "end")).unwrap(); 1243 // Root is n0; each level keeps 0.8 on the chain: 0.8, 0.64, 0.51, 0.41. 1244 let answer = |_: &[&str]| vec![0.8, 0.2]; 1245 let (o, asked) = run(&tree, tau(0.5), answer); 1246 let Outcome::Stop { node, depth, leaf, probability } = o else { panic!("{o:?}") }; 1247 assert_eq!(tree.path(node), ["n1", "n2", "n3"]); 1248 assert_eq!(depth, 3); 1249 assert!(!leaf); 1250 assert!((probability - 0.512).abs() < 1e-12); 1251 // n3 is asked, since only its answer shows its children fall 1252 // below 0.5; nothing below τ is asked. 1253 assert_eq!(asked, 4); 1254 1255 let (o, _) = run(&tree, tau(0.4), answer); 1256 let Outcome::Stop { node, leaf, .. } = o else { panic!() }; 1257 assert_eq!(tree.path(node), ["n1", "n2", "n3", "end"]); 1258 assert!(leaf); 1259 } 1260 1261 #[test] 1262 fn tau_prefers_depth_then_probability() { 1263 let tree = Tree::new(Branch::node( 1264 "", 1265 [ 1266 Branch::node("a", [Branch::leaf("a1"), Branch::leaf("a2")]), 1267 Branch::node("b", [Branch::node("b1", [Branch::leaf("x"), Branch::leaf("y")]), Branch::leaf("b2")]), 1268 ], 1269 )) 1270 .unwrap(); 1271 let (o, _) = run(&tree, tau(0.2), |path| match path { 1272 [] => vec![0.6, 0.4], 1273 ["a"] => vec![0.5, 0.5], 1274 ["b"] => vec![0.6, 0.4], 1275 ["b", "b1"] => vec![0.5, 0.5], 1276 other => panic!("{other:?}"), 1277 }); 1278 // a1/a2 are 0.3 at depth 2; b1 is 0.24 at depth 2, and x/y 0.12 < τ. 1279 // So depth 2, likeliest: a1. 1280 let Outcome::Stop { node, depth, .. } = o else { panic!() }; 1281 assert_eq!((tree.path(node), depth), (vec!["a", "a1"], 2)); 1282 } 1283 1284 #[test] 1285 fn tau_is_in_zero_to_one() { 1286 assert!(Tau::new(0.0).is_none()); 1287 assert!(Tau::new(1.5).is_none()); 1288 assert!(Tau::new(f64::NAN).is_none()); 1289 assert!(Tau::new(1.0).is_some()); 1290 } 1291 1292 #[test] 1293 fn trees_that_cannot_be_asked_are_refused() { 1294 assert_eq!(Tree::new(Branch::leaf("")).unwrap_err(), TreeError::NoChoice); 1295 let wide = Branch::node("", (0..256).map(|i| Branch::leaf(i.to_string()))); 1296 assert!(matches!(Tree::new(wide), Err(TreeError::Fanout { children: 256, .. }))); 1297 assert!(Tree::new(Branch::node("", (0..255).map(|i| Branch::leaf(i.to_string())))).is_ok()); 1298 let dup = Branch::node("", [Branch::node("a", [Branch::leaf("x"), Branch::leaf("x")])]); 1299 assert_eq!( 1300 Tree::new(dup).unwrap_err(), 1301 TreeError::DuplicateLabel { path: vec!["a".into()], label: "x".into() } 1302 ); 1303 let empty = Branch::node("", [Branch::leaf("")]); 1304 assert_eq!(Tree::new(empty).unwrap_err(), TreeError::EmptyLabel { path: vec![] }); 1305 } 1306 1307 #[test] 1308 fn answers_are_checked() { 1309 let tree = Tree::new(Branch::node("", [Branch::leaf("a"), Branch::leaf("b")])).unwrap(); 1310 let mut s = Search::new(&tree, Params::default()); 1311 let root = Ask::Node(tree.root()); 1312 let Step::Ask(round) = s.step(|_| 100) else { panic!() }; 1313 assert_eq!(round, [root]); 1314 assert_eq!(s.step(|_| 100), Step::Ask(round.clone())); 1315 assert_eq!(s.answer(Ask::Node(NodeId(1)), &[1.0]), Err(AnswerError::NotAsked(Ask::Node(NodeId(1))))); 1316 assert_eq!(s.answer(Ask::Twin(tree.root()), &[0.5, 0.5]), Err(AnswerError::NotAsked(Ask::Twin(tree.root())))); 1317 assert!(matches!(s.answer(root, &[1.0]), Err(AnswerError::Count { expected: 2, got: 1, .. }))); 1318 assert!(matches!(s.answer(root, &[0.5, f64::NAN]), Err(AnswerError::NotAProbability { .. }))); 1319 assert!(matches!(s.answer(root, &[0.5, 1.2]), Err(AnswerError::NotAProbability { .. }))); 1320 s.answer(root, &[0.25, 0.75]).unwrap(); 1321 let Step::Done(o) = s.step(|_| 100) else { panic!() }; 1322 assert_eq!(tree.path(leaf_of(&o)), ["b"]); 1323 } 1324}