Skip to main content

sim_lib_discrete_search/
word.rs

1//! Constrained fixed-alphabet word search fixture.
2
3use std::collections::BTreeSet;
4
5use crate::{SearchError, SearchProblem, SearchStep};
6
7/// Search state for the constrained word problem.
8#[derive(Clone, Debug, PartialEq, Eq)]
9pub struct WordSearchState {
10    /// Prefix chosen so far.
11    pub prefix: Vec<String>,
12    /// Accumulated minimization score.
13    pub score: i64,
14}
15
16/// Finished constrained word.
17#[derive(Clone, Debug, PartialEq, Eq)]
18pub struct WordSearchSolution {
19    /// Complete word.
20    pub word: Vec<String>,
21    /// Minimization score used by branch-and-bound and A-star policy.
22    pub score: i64,
23}
24
25/// Deterministic fixed-alphabet word problem used by Rust and Lisp specimens.
26#[derive(Clone, Debug, PartialEq, Eq)]
27pub struct ConstrainedWordProblem {
28    alphabet: Vec<String>,
29    length: usize,
30    required_first: String,
31    required_last: String,
32    forbidden_pairs: BTreeSet<(String, String)>,
33    costly_symbol: Option<String>,
34}
35
36impl ConstrainedWordProblem {
37    /// Build the default constrained word fixture.
38    pub fn new(
39        alphabet: Vec<String>,
40        length: usize,
41        required_first: String,
42        required_last: String,
43    ) -> Result<Self, SearchError> {
44        if alphabet.is_empty() {
45            return Err(SearchError::InvalidProblem(
46                "alphabet must not be empty".to_string(),
47            ));
48        }
49        if length < 2 {
50            return Err(SearchError::InvalidProblem(
51                "word length must be at least two".to_string(),
52            ));
53        }
54        let unique = alphabet.iter().collect::<BTreeSet<_>>();
55        if unique.len() != alphabet.len() {
56            return Err(SearchError::InvalidProblem(
57                "alphabet entries must be unique".to_string(),
58            ));
59        }
60        if !unique.contains(&required_first) || !unique.contains(&required_last) {
61            return Err(SearchError::InvalidProblem(
62                "required endpoints must be in the alphabet".to_string(),
63            ));
64        }
65        let costly_symbol = alphabet.get(1).cloned();
66        let mut forbidden_pairs = BTreeSet::new();
67        if alphabet.len() >= 3 {
68            forbidden_pairs.insert((required_last.clone(), alphabet[1].clone()));
69        }
70        Ok(Self {
71            alphabet,
72            length,
73            required_first,
74            required_last,
75            forbidden_pairs,
76            costly_symbol,
77        })
78    }
79
80    /// Add one forbidden adjacent pair.
81    pub fn with_forbidden_pair(mut self, left: String, right: String) -> Self {
82        self.forbidden_pairs.insert((left, right));
83        self
84    }
85
86    /// Return the alphabet in deterministic choice order.
87    pub fn alphabet(&self) -> &[String] {
88        &self.alphabet
89    }
90
91    /// Return the target word length.
92    pub fn length(&self) -> usize {
93        self.length
94    }
95}
96
97impl SearchProblem for ConstrainedWordProblem {
98    type State = WordSearchState;
99    type Choice = String;
100    type Output = WordSearchSolution;
101
102    fn initial_state(&self) -> Self::State {
103        WordSearchState {
104            prefix: Vec::new(),
105            score: 0,
106        }
107    }
108
109    fn expand(&self, state: &Self::State, out: &mut Vec<Self::Choice>) {
110        if state.prefix.len() < self.length {
111            out.extend(self.alphabet.iter().cloned());
112        }
113    }
114
115    fn apply(&self, state: &Self::State, choice: &Self::Choice) -> SearchStep<Self::State> {
116        let position = state.prefix.len();
117        if position == 0 && choice != &self.required_first {
118            return SearchStep::pruned("prefix does not match required first symbol");
119        }
120        if position + 1 == self.length && choice != &self.required_last {
121            return SearchStep::pruned("suffix does not match required last symbol");
122        }
123        if state.prefix.last() == Some(choice) {
124            return SearchStep::pruned("adjacent symbols must differ");
125        }
126        if let Some(previous) = state.prefix.last()
127            && self
128                .forbidden_pairs
129                .contains(&(previous.clone(), choice.clone()))
130        {
131            return SearchStep::pruned("forbidden adjacent pair");
132        }
133
134        let mut prefix = state.prefix.clone();
135        prefix.push(choice.clone());
136        let score = state.score + i64::from(self.costly_symbol.as_ref() == Some(choice));
137        SearchStep::Continue(WordSearchState { prefix, score })
138    }
139
140    fn propagate(&self, state: Self::State) -> SearchStep<Self::State> {
141        if state.prefix.len() > self.length {
142            return SearchStep::infeasible("prefix exceeds target length");
143        }
144        let remaining = self.length - state.prefix.len();
145        if remaining == 1 && state.prefix.last() == Some(&self.required_last) {
146            return SearchStep::pruned("required suffix would repeat");
147        }
148        SearchStep::Continue(state)
149    }
150
151    fn finish(&self, state: &Self::State) -> Option<Self::Output> {
152        (state.prefix.len() == self.length && state.prefix.last() == Some(&self.required_last))
153            .then(|| WordSearchSolution {
154                word: state.prefix.clone(),
155                score: state.score,
156            })
157    }
158
159    fn score_state(&self, state: &Self::State) -> i64 {
160        state.score
161    }
162
163    fn estimate_remaining(&self, _state: &Self::State) -> i64 {
164        0
165    }
166
167    fn bound(&self, state: &Self::State) -> Option<i64> {
168        Some(state.score)
169    }
170
171    fn output_score(&self, output: &Self::Output) -> Option<i64> {
172        Some(output.score)
173    }
174}
175
176/// Render the constrained word specimen in a stable line format.
177pub fn render_constrained_word_demo(
178    solutions: &[WordSearchSolution],
179    receipt_digest: &str,
180) -> String {
181    let mut out = String::new();
182    for solution in solutions {
183        out.push_str(&solution.word.join(""));
184        out.push('\t');
185        out.push_str(&solution.score.to_string());
186        out.push('\n');
187    }
188    out.push_str("receipt\t");
189    out.push_str(receipt_digest);
190    out.push('\n');
191    out
192}