Skip to main content

mindstate_core/
lib.rs

1//! # mindstate-core
2//!
3//! Serializable mindstate payload types for `forge-engine`.
4//!
5//! This crate owns the stable data structures used to render and persist a
6//! compiled mindstate. It does not own retrieval, execution, or scoring logic.
7
8//! # mindstate-core
9//!
10//! Deterministic mindstate rendering and hashing for internal forge flows.
11//!
12//! This Tier 3 crate defines the serializable `MindState` payload and its
13//! reproducible hash/render helpers. Higher-level runtime orchestration belongs
14//! to `forge-engine`.
15
16use 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}