1use std::collections::BTreeMap;
17
18#[derive(Debug, thiserror::Error)]
19pub enum MindStateError {
20 #[error("serialization error: {0}")]
21 Serialization(#[from] serde_json::Error),
22}
23
24#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
25pub struct MindState {
26 pub request: String,
27 pub repo_context: String,
28 pub evidence: Vec<EvidenceItem>,
29 pub traces: Vec<TraceRef>,
30 pub basis_version_id: String,
31 pub config_overrides: BTreeMap<String, String>,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
35pub struct EvidenceItem {
36 pub key: String,
37 pub content: String,
38 pub score: OrderedFloat,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
42pub struct TraceRef {
43 pub question_sig: String,
44 pub strategy_tags: Vec<String>,
45 pub score: OrderedFloat,
46}
47
48#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
49pub struct OrderedFloat(pub f64);
50
51impl PartialEq for OrderedFloat {
52 fn eq(&self, other: &Self) -> bool {
53 self.0.to_bits() == other.0.to_bits()
54 }
55}
56
57impl Eq for OrderedFloat {}
58
59impl PartialOrd for OrderedFloat {
60 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
61 Some(self.cmp(other))
62 }
63}
64
65impl Ord for OrderedFloat {
66 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
67 self.0.total_cmp(&other.0)
68 }
69}
70
71impl MindState {
72 pub fn render(&self) -> Result<String, MindStateError> {
73 let mut evidence = self.evidence.clone();
74 evidence.sort();
75
76 let mut traces = self.traces.clone();
77 traces.sort();
78
79 let rendered = RenderedMindState {
80 request: &self.request,
81 repo_context: &self.repo_context,
82 evidence: &evidence,
83 traces: &traces,
84 basis_version_id: &self.basis_version_id,
85 config_overrides: &self.config_overrides,
86 };
87
88 Ok(serde_json::to_string_pretty(&rendered)?)
89 }
90
91 pub fn hash(&self) -> Result<String, MindStateError> {
92 let rendered = self.render()?;
93 Ok(blake3::hash(rendered.as_bytes()).to_hex().to_string())
94 }
95}
96
97pub fn compute_question_sig(request: &str, repo_context: &str) -> String {
98 blake3::hash(format!("{request}\n{repo_context}").as_bytes())
99 .to_hex()
100 .to_string()
101}
102
103pub fn budget_evidence(mut evidence: Vec<EvidenceItem>, budget: usize) -> Vec<EvidenceItem> {
104 evidence.truncate(budget);
105 evidence
106}
107
108#[derive(serde::Serialize)]
109struct RenderedMindState<'a> {
110 request: &'a str,
111 repo_context: &'a str,
112 evidence: &'a [EvidenceItem],
113 traces: &'a [TraceRef],
114 basis_version_id: &'a str,
115 config_overrides: &'a BTreeMap<String, String>,
116}
117
118#[cfg(test)]
119mod wave1_tests {
120 use super::*;
121
122 fn sample_mindstate(evidence: Vec<EvidenceItem>, traces: Vec<TraceRef>) -> MindState {
123 MindState {
124 request: "fix flaky test".into(),
125 repo_context: "workspace".into(),
126 evidence,
127 traces,
128 basis_version_id: "v1".into(),
129 config_overrides: BTreeMap::from([("mode".into(), "strict".into())]),
130 }
131 }
132
133 #[test]
134 fn render_and_hash_are_stable_across_input_ordering() {
135 let a = sample_mindstate(
136 vec![
137 EvidenceItem {
138 key: "b".into(),
139 content: "second".into(),
140 score: OrderedFloat(0.2),
141 },
142 EvidenceItem {
143 key: "a".into(),
144 content: "first".into(),
145 score: OrderedFloat(0.9),
146 },
147 ],
148 vec![
149 TraceRef {
150 question_sig: "q2".into(),
151 strategy_tags: vec!["fallback".into()],
152 score: OrderedFloat(0.1),
153 },
154 TraceRef {
155 question_sig: "q1".into(),
156 strategy_tags: vec!["primary".into()],
157 score: OrderedFloat(0.8),
158 },
159 ],
160 );
161 let b = sample_mindstate(
162 a.evidence.iter().cloned().rev().collect(),
163 a.traces.iter().cloned().rev().collect(),
164 );
165
166 assert_eq!(a.render().unwrap(), b.render().unwrap());
167 assert_eq!(a.hash().unwrap(), b.hash().unwrap());
168 }
169
170 #[test]
171 fn compute_question_sig_is_deterministic_and_input_sensitive() {
172 let baseline = compute_question_sig("question", "repo");
173 assert_eq!(baseline, compute_question_sig("question", "repo"));
174 assert_ne!(baseline, compute_question_sig("question changed", "repo"));
175 }
176
177 #[test]
178 fn budget_evidence_truncates_without_reordering() {
179 let evidence = vec![
180 EvidenceItem {
181 key: "a".into(),
182 content: "first".into(),
183 score: OrderedFloat(0.9),
184 },
185 EvidenceItem {
186 key: "b".into(),
187 content: "second".into(),
188 score: OrderedFloat(0.8),
189 },
190 EvidenceItem {
191 key: "c".into(),
192 content: "third".into(),
193 score: OrderedFloat(0.7),
194 },
195 ];
196
197 let budgeted = budget_evidence(evidence, 2);
198 assert_eq!(budgeted.len(), 2);
199 assert_eq!(budgeted[0].key, "a");
200 assert_eq!(budgeted[1].key, "b");
201 }
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207
208 fn sample_state() -> MindState {
209 MindState {
210 request: "How do we fix the failing test?".into(),
211 repo_context: "workspace-a".into(),
212 evidence: vec![
213 EvidenceItem {
214 key: "zeta".into(),
215 content: "later evidence".into(),
216 score: OrderedFloat(0.2),
217 },
218 EvidenceItem {
219 key: "alpha".into(),
220 content: "earlier evidence".into(),
221 score: OrderedFloat(0.8),
222 },
223 ],
224 traces: vec![
225 TraceRef {
226 question_sig: "sig-b".into(),
227 strategy_tags: vec!["mechanical".into()],
228 score: OrderedFloat(0.1),
229 },
230 TraceRef {
231 question_sig: "sig-a".into(),
232 strategy_tags: vec!["refactor".into()],
233 score: OrderedFloat(0.9),
234 },
235 ],
236 basis_version_id: "v1".into(),
237 config_overrides: BTreeMap::from([
238 ("mode".into(), "strict".into()),
239 ("budget".into(), "small".into()),
240 ]),
241 }
242 }
243
244 #[test]
245 fn render_sorts_evidence_and_traces_deterministically() {
246 let rendered = sample_state().render().unwrap();
247 let parsed: serde_json::Value = serde_json::from_str(&rendered).unwrap();
248
249 assert_eq!(parsed["evidence"][0]["key"], "alpha");
250 assert_eq!(parsed["evidence"][1]["key"], "zeta");
251 assert_eq!(parsed["traces"][0]["question_sig"], "sig-a");
252 assert_eq!(parsed["traces"][1]["question_sig"], "sig-b");
253 }
254
255 #[test]
256 fn hash_is_order_independent_for_equivalent_content() {
257 let first = sample_state();
258 let mut second = sample_state();
259 second.evidence.reverse();
260 second.traces.reverse();
261
262 assert_eq!(first.hash().unwrap(), second.hash().unwrap());
263 }
264
265 #[test]
266 fn compute_question_sig_changes_with_inputs() {
267 let baseline = compute_question_sig("request", "repo-a");
268 let different_repo = compute_question_sig("request", "repo-b");
269 let different_request = compute_question_sig("request-2", "repo-a");
270
271 assert_ne!(baseline, different_repo);
272 assert_ne!(baseline, different_request);
273 }
274
275 #[test]
276 fn budget_evidence_truncates_without_reordering() {
277 let evidence = sample_state().evidence;
278 let limited = budget_evidence(evidence.clone(), 1);
279
280 assert_eq!(limited.len(), 1);
281 assert_eq!(limited[0].key, evidence[0].key);
282 assert_eq!(limited[0].content, evidence[0].content);
283 }
284}