Skip to main content

sim_lib_discrete_search/
control.rs

1//! Search controls and work charging policy.
2
3use std::time::Duration;
4
5/// Deterministic frontier policy used by [`crate::solve`].
6#[derive(Clone, Copy, Debug, PartialEq, Eq)]
7pub enum SearchOrder {
8    /// Depth-first search with choices visited in sorted order.
9    DepthFirst,
10    /// Breadth-first search with choices visited in sorted order.
11    BreadthFirst,
12    /// Best-first search ordered by `SearchProblem::score_state`.
13    BestFirst,
14    /// A-star search ordered by `score_state + estimate_remaining`.
15    AStar,
16    /// Beam search ordered like A-star while retaining at most `width` frontier
17    /// nodes after each expansion.
18    Beam {
19        /// Maximum number of frontier nodes retained by the beam.
20        width: usize,
21    },
22}
23
24impl SearchOrder {
25    /// Stable label used in receipts and policy digests.
26    pub fn label(self) -> &'static str {
27        match self {
28            Self::DepthFirst => "depth-first",
29            Self::BreadthFirst => "breadth-first",
30            Self::BestFirst => "best-first",
31            Self::AStar => "a-star",
32            Self::Beam { .. } => "beam",
33        }
34    }
35
36    pub(crate) fn policy_material(self) -> String {
37        match self {
38            Self::Beam { width } => format!("order=beam,width={width}"),
39            other => format!("order={}", other.label()),
40        }
41    }
42}
43
44/// Work charges applied by the engine for each observable operation class.
45#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46pub struct WorkCosts {
47    /// Cost charged before expanding a state's choices.
48    pub expand: u64,
49    /// Cost charged before scoring or prioritizing a child state.
50    pub score: u64,
51    /// Cost charged before running propagation on a child state.
52    pub propagate: u64,
53    /// Cost charged before emitting a finished output.
54    pub emit: u64,
55}
56
57impl Default for WorkCosts {
58    fn default() -> Self {
59        Self {
60            expand: 1,
61            score: 1,
62            propagate: 1,
63            emit: 1,
64        }
65    }
66}
67
68/// Bounds, ordering, seed, and accounting policy for one search run.
69#[derive(Clone, Debug, PartialEq, Eq)]
70pub struct SearchControl {
71    /// Deterministic frontier ordering policy.
72    pub order: SearchOrder,
73    /// Caller-supplied seed recorded in the receipt digest.
74    pub seed: u64,
75    /// Optional maximum charged work.
76    pub max_work: Option<u64>,
77    /// Optional maximum number of emitted outputs.
78    pub max_results: Option<usize>,
79    /// Optional maximum frontier length.
80    pub max_frontier: Option<usize>,
81    /// Optional maximum `frontier + results` node count.
82    pub max_memory_nodes: Option<usize>,
83    /// Optional wall-clock deadline for the run.
84    pub max_time: Option<Duration>,
85    /// Whether lower-bound pruning uses the best emitted output score.
86    pub branch_and_bound: bool,
87    /// Per-operation work charges.
88    pub costs: WorkCosts,
89}
90
91impl Default for SearchControl {
92    fn default() -> Self {
93        Self {
94            order: SearchOrder::DepthFirst,
95            seed: 0,
96            max_work: None,
97            max_results: None,
98            max_frontier: None,
99            max_memory_nodes: None,
100            max_time: None,
101            branch_and_bound: false,
102            costs: WorkCosts::default(),
103        }
104    }
105}
106
107impl SearchControl {
108    /// Return a copy with a different deterministic frontier order.
109    pub fn with_order(mut self, order: SearchOrder) -> Self {
110        self.order = order;
111        self
112    }
113
114    /// Return a copy with a different receipt seed.
115    pub fn with_seed(mut self, seed: u64) -> Self {
116        self.seed = seed;
117        self
118    }
119
120    /// Return a copy with a maximum charged-work bound.
121    pub fn with_max_work(mut self, max_work: u64) -> Self {
122        self.max_work = Some(max_work);
123        self
124    }
125
126    /// Return a copy with a maximum emitted-result bound.
127    pub fn with_max_results(mut self, max_results: usize) -> Self {
128        self.max_results = Some(max_results);
129        self
130    }
131
132    /// Return a copy with a maximum frontier bound.
133    pub fn with_max_frontier(mut self, max_frontier: usize) -> Self {
134        self.max_frontier = Some(max_frontier);
135        self
136    }
137
138    /// Return a copy with a maximum `frontier + results` bound.
139    pub fn with_max_memory_nodes(mut self, max_memory_nodes: usize) -> Self {
140        self.max_memory_nodes = Some(max_memory_nodes);
141        self
142    }
143
144    /// Return a copy with a wall-clock deadline.
145    pub fn with_max_time(mut self, max_time: Duration) -> Self {
146        self.max_time = Some(max_time);
147        self
148    }
149
150    /// Return a copy with branch-and-bound pruning enabled or disabled.
151    pub fn with_branch_and_bound(mut self, enabled: bool) -> Self {
152        self.branch_and_bound = enabled;
153        self
154    }
155
156    /// Return a copy with explicit per-operation work costs.
157    pub fn with_costs(mut self, costs: WorkCosts) -> Self {
158        self.costs = costs;
159        self
160    }
161
162    pub(crate) fn validate(&self) -> Result<(), String> {
163        if matches!(self.order, SearchOrder::Beam { width: 0 }) {
164            return Err("beam width must be greater than zero".to_string());
165        }
166        if self.costs.expand == 0
167            || self.costs.score == 0
168            || self.costs.propagate == 0
169            || self.costs.emit == 0
170        {
171            return Err("work costs must be positive".to_string());
172        }
173        Ok(())
174    }
175
176    pub(crate) fn policy_material(&self) -> String {
177        format!(
178            "{};seed={};max_work={};max_results={};max_frontier={};max_memory_nodes={};max_time_ns={};branch_and_bound={};costs={},{},{},{}",
179            self.order.policy_material(),
180            self.seed,
181            option_u64(self.max_work),
182            option_usize(self.max_results),
183            option_usize(self.max_frontier),
184            option_usize(self.max_memory_nodes),
185            self.max_time
186                .map(|duration| duration.as_nanos().to_string())
187                .unwrap_or_else(|| "none".to_string()),
188            self.branch_and_bound,
189            self.costs.expand,
190            self.costs.score,
191            self.costs.propagate,
192            self.costs.emit,
193        )
194    }
195}
196
197fn option_u64(value: Option<u64>) -> String {
198    value
199        .map(|value| value.to_string())
200        .unwrap_or_else(|| "none".to_string())
201}
202
203fn option_usize(value: Option<usize>) -> String {
204    value
205        .map(|value| value.to_string())
206        .unwrap_or_else(|| "none".to_string())
207}