Skip to main content

brain_edge/dto/
reasoning.rs

1//! Reasoning verbs: plan / reason.
2
3// The DTOs below mirror the HTTP contract one-for-one: the JSON field names are
4// the API, and `tools/http_manifest.py` emits every one of them — with its type
5// and serde attributes — into `contract/http-routes.json`, which the three SDK
6// clients are checked against. A doc comment on each of ~312 fields would
7// restate the field name; the ones that carry meaning beyond their name have
8// one written below.
9#![allow(missing_docs)]
10
11use brain_db_sdk::new_id;
12use brain_db_sdk::wire::types::{
13    InferenceKind, InferenceStep, ObservationInput, PlanBudget, PlanRequest, PlanState, PlanStep,
14    PlanStrategy, ReasonRequest, TransitionKind,
15};
16use serde::{Deserialize, Serialize};
17
18use super::parse_memory_id;
19
20/// A plan/reason endpoint: either free `text` or an existing `memory_id`.
21#[derive(Debug, Deserialize)]
22pub struct EndpointSpec {
23    #[serde(default)]
24    pub text: Option<String>,
25    #[serde(default)]
26    pub memory_id: Option<String>,
27}
28
29impl EndpointSpec {
30    fn resolve(&self, field: &str) -> Result<Resolved, String> {
31        match (&self.memory_id, &self.text) {
32            (Some(id), _) => Ok(Resolved::Memory(parse_memory_id(id)?)),
33            (None, Some(t)) if !t.trim().is_empty() => Ok(Resolved::Text(t.clone())),
34            _ => Err(format!("{field} needs `text` or `memory_id`")),
35        }
36    }
37
38    fn plan_state(&self, field: &str) -> Result<PlanState, String> {
39        Ok(match self.resolve(field)? {
40            Resolved::Memory(id) => PlanState::ByMemoryId(id),
41            Resolved::Text(t) => PlanState::ByText(t),
42        })
43    }
44
45    fn observation(&self, field: &str) -> Result<ObservationInput, String> {
46        Ok(match self.resolve(field)? {
47            Resolved::Memory(id) => ObservationInput::ByMemoryId(id),
48            Resolved::Text(t) => ObservationInput::ByText(t),
49        })
50    }
51}
52
53enum Resolved {
54    Memory(u128),
55    Text(String),
56}
57
58fn parse_strategy(s: &str) -> Result<PlanStrategy, String> {
59    Ok(match s.trim().to_ascii_lowercase().as_str() {
60        "auto" => PlanStrategy::Auto,
61        "astar" | "a_star" | "a*" => PlanStrategy::AStar,
62        "mcts" => PlanStrategy::Mcts,
63        "attractor_rollout" | "attractor" => PlanStrategy::AttractorRollout,
64        other => return Err(format!("unknown plan strategy `{other}`")),
65    })
66}
67
68/// `POST /v1/plan` body.
69#[derive(Debug, Deserialize)]
70pub struct PlanBody {
71    pub start: EndpointSpec,
72    pub goal: EndpointSpec,
73    #[serde(default = "default_max_steps")]
74    pub max_steps: u32,
75    #[serde(default = "default_wall_ms")]
76    pub max_wall_time_ms: u32,
77    #[serde(default = "default_branches")]
78    pub max_branches: u32,
79    #[serde(default)]
80    pub strategy: Option<String>,
81}
82
83fn default_max_steps() -> u32 {
84    8
85}
86fn default_wall_ms() -> u32 {
87    5_000
88}
89fn default_branches() -> u32 {
90    32
91}
92
93impl PlanBody {
94    /// Build the wire request (mints a fresh `request_id`).
95    pub fn to_request(&self) -> Result<PlanRequest, String> {
96        let strategy_hint = match &self.strategy {
97            Some(s) => Some(parse_strategy(s)?),
98            None => None,
99        };
100        Ok(PlanRequest {
101            start: self.start.plan_state("start")?,
102            goal: self.goal.plan_state("goal")?,
103            budget: PlanBudget {
104                max_steps: self.max_steps,
105                max_wall_time_ms: self.max_wall_time_ms,
106                max_branches_explored: self.max_branches,
107            },
108            strategy_hint,
109            session_filter: None,
110            request_id: Some(new_id()),
111            txn_id: None,
112            trace: false,
113            act_as: None,
114        })
115    }
116}
117
118/// One planned step.
119#[derive(Debug, Serialize)]
120pub struct PlanStepDto {
121    pub step_index: u32,
122    pub memory_id: String,
123    pub text: String,
124    pub transition_kind: String,
125    pub confidence: f32,
126    pub estimated_distance_to_goal: f32,
127}
128
129impl From<PlanStep> for PlanStepDto {
130    fn from(s: PlanStep) -> Self {
131        Self {
132            step_index: s.step_index,
133            memory_id: s.memory_id.to_string(),
134            text: s.text,
135            transition_kind: transition_kind_str(&s.transition_kind),
136            confidence: s.confidence,
137            estimated_distance_to_goal: s.estimated_distance_to_goal,
138        }
139    }
140}
141
142/// `POST /v1/plan` response.
143#[derive(Debug, Serialize)]
144pub struct PlanResponseDto {
145    pub steps: Vec<PlanStepDto>,
146}
147
148impl From<Vec<PlanStep>> for PlanResponseDto {
149    fn from(v: Vec<PlanStep>) -> Self {
150        Self {
151            steps: v.into_iter().map(PlanStepDto::from).collect(),
152        }
153    }
154}
155
156fn transition_kind_str(k: &TransitionKind) -> String {
157    match k {
158        TransitionKind::Initial => "initial".into(),
159        TransitionKind::Causal => "causal".into(),
160        TransitionKind::Temporal => "temporal".into(),
161        TransitionKind::Similarity => "similarity".into(),
162        TransitionKind::Other(s) => s.clone(),
163    }
164}
165
166/// `POST /v1/reason` body.
167#[derive(Debug, Deserialize)]
168pub struct ReasonBody {
169    pub observation: EndpointSpec,
170    #[serde(default = "default_depth")]
171    pub depth: u32,
172    #[serde(default = "default_threshold")]
173    pub confidence_threshold: f32,
174    #[serde(default = "default_max_inferences")]
175    pub max_inferences: u32,
176    #[serde(default = "default_wall_ms")]
177    pub budget_wall_time_ms: u32,
178}
179
180fn default_depth() -> u32 {
181    3
182}
183fn default_threshold() -> f32 {
184    0.5
185}
186fn default_max_inferences() -> u32 {
187    10
188}
189
190impl ReasonBody {
191    /// Build the wire request (mints a fresh `request_id`).
192    pub fn to_request(&self) -> Result<ReasonRequest, String> {
193        Ok(ReasonRequest {
194            observation: self.observation.observation("observation")?,
195            depth: self.depth,
196            confidence_threshold: self.confidence_threshold,
197            session_filter: None,
198            max_inferences: self.max_inferences,
199            budget_wall_time_ms: self.budget_wall_time_ms,
200            request_id: Some(new_id()),
201            txn_id: None,
202            trace: false,
203            act_as: None,
204        })
205    }
206}
207
208/// One inference step.
209#[derive(Debug, Serialize)]
210pub struct InferenceStepDto {
211    pub step_index: u32,
212    pub claim: String,
213    pub supporting_memories: Vec<String>,
214    pub contradicting_memories: Vec<String>,
215    pub confidence: f32,
216    pub inference_kind: String,
217}
218
219impl From<InferenceStep> for InferenceStepDto {
220    fn from(s: InferenceStep) -> Self {
221        Self {
222            step_index: s.step_index,
223            claim: s.claim,
224            supporting_memories: s
225                .supporting_memories
226                .iter()
227                .map(ToString::to_string)
228                .collect(),
229            contradicting_memories: s
230                .contradicting_memories
231                .iter()
232                .map(ToString::to_string)
233                .collect(),
234            confidence: s.confidence,
235            inference_kind: inference_kind_str(&s.inference_kind),
236        }
237    }
238}
239
240/// `POST /v1/reason` response.
241#[derive(Debug, Serialize)]
242pub struct ReasonResponseDto {
243    pub inferences: Vec<InferenceStepDto>,
244}
245
246impl From<Vec<InferenceStep>> for ReasonResponseDto {
247    fn from(v: Vec<InferenceStep>) -> Self {
248        Self {
249            inferences: v.into_iter().map(InferenceStepDto::from).collect(),
250        }
251    }
252}
253
254fn inference_kind_str(k: &InferenceKind) -> String {
255    match k {
256        InferenceKind::CausalExplanation => "causal_explanation".into(),
257        InferenceKind::EvidenceAccumulation => "evidence_accumulation".into(),
258        InferenceKind::AnalogicalInference => "analogical_inference".into(),
259        InferenceKind::Other(s) => s.clone(),
260    }
261}
262
263#[cfg(test)]
264mod reasoning_tests {
265    use super::*;
266
267    fn text_endpoint(t: &str) -> EndpointSpec {
268        EndpointSpec {
269            text: Some(t.into()),
270            memory_id: None,
271        }
272    }
273
274    #[test]
275    fn plan_body_resolves_endpoints_parses_strategy_and_keeps_budget() {
276        let body = PlanBody {
277            start: text_endpoint("at home"),
278            goal: EndpointSpec {
279                text: None,
280                memory_id: Some("42".into()),
281            },
282            max_steps: 12,
283            max_wall_time_ms: 2_000,
284            max_branches: 16,
285            strategy: Some(" A* ".into()),
286        };
287        let req = body.to_request().unwrap();
288        assert!(matches!(req.start, PlanState::ByText(ref t) if t == "at home"));
289        assert!(matches!(req.goal, PlanState::ByMemoryId(42)));
290        assert!(matches!(req.strategy_hint, Some(PlanStrategy::AStar)));
291        assert_eq!(req.budget.max_steps, 12);
292        assert_eq!(req.budget.max_wall_time_ms, 2_000);
293        assert_eq!(req.budget.max_branches_explored, 16);
294        assert!(req.request_id.is_some());
295    }
296
297    #[test]
298    fn plan_body_rejects_empty_endpoint_and_unknown_strategy() {
299        // An endpoint with neither text nor memory_id is an error.
300        let missing = PlanBody {
301            start: EndpointSpec {
302                text: None,
303                memory_id: None,
304            },
305            goal: text_endpoint("b"),
306            max_steps: 8,
307            max_wall_time_ms: 5_000,
308            max_branches: 32,
309            strategy: None,
310        };
311        assert!(missing.to_request().is_err());
312
313        // A blank text does not count as a supplied endpoint.
314        let blank = PlanBody {
315            start: text_endpoint("   "),
316            goal: text_endpoint("b"),
317            max_steps: 8,
318            max_wall_time_ms: 5_000,
319            max_branches: 32,
320            strategy: None,
321        };
322        assert!(blank.to_request().is_err());
323
324        let bad_strategy = PlanBody {
325            start: text_endpoint("a"),
326            goal: text_endpoint("b"),
327            max_steps: 8,
328            max_wall_time_ms: 5_000,
329            max_branches: 32,
330            strategy: Some("teleport".into()),
331        };
332        assert!(bad_strategy.to_request().is_err());
333    }
334
335    #[test]
336    fn plan_response_maps_steps_and_transition_kinds() {
337        let steps = vec![
338            PlanStep {
339                step_index: 0,
340                memory_id: 7,
341                text: "start".into(),
342                transition_kind: TransitionKind::Initial,
343                confidence: 1.0,
344                estimated_distance_to_goal: 3.0,
345            },
346            PlanStep {
347                step_index: 1,
348                memory_id: 8,
349                text: "next".into(),
350                transition_kind: TransitionKind::Other("teleport".into()),
351                confidence: 0.7,
352                estimated_distance_to_goal: 1.0,
353            },
354        ];
355        let dto = PlanResponseDto::from(steps);
356        assert_eq!(dto.steps.len(), 2);
357        assert_eq!(dto.steps[0].memory_id, "7");
358        assert_eq!(dto.steps[0].transition_kind, "initial");
359        assert_eq!(dto.steps[1].transition_kind, "teleport"); // Other passthrough
360    }
361
362    #[test]
363    fn reason_body_resolves_observation_and_carries_budget() {
364        let body = ReasonBody {
365            observation: EndpointSpec {
366                text: None,
367                memory_id: Some("9".into()),
368            },
369            depth: 4,
370            confidence_threshold: 0.6,
371            max_inferences: 7,
372            budget_wall_time_ms: 3_000,
373        };
374        let req = body.to_request().unwrap();
375        assert!(matches!(req.observation, ObservationInput::ByMemoryId(9)));
376        assert_eq!(req.depth, 4);
377        assert_eq!(req.confidence_threshold.to_bits(), 0.6f32.to_bits());
378        assert_eq!(req.max_inferences, 7);
379        assert_eq!(req.budget_wall_time_ms, 3_000);
380        assert!(req.request_id.is_some());
381
382        assert!(
383            ReasonBody {
384                observation: EndpointSpec {
385                    text: None,
386                    memory_id: None,
387                },
388                depth: 3,
389                confidence_threshold: 0.5,
390                max_inferences: 10,
391                budget_wall_time_ms: 5_000,
392            }
393            .to_request()
394            .is_err()
395        );
396    }
397
398    #[test]
399    fn reason_response_maps_inferences_and_kinds() {
400        let steps = vec![InferenceStep {
401            step_index: 0,
402            claim: "it rained".into(),
403            supporting_memories: vec![1, 2],
404            contradicting_memories: vec![3],
405            confidence: 0.8,
406            inference_kind: InferenceKind::CausalExplanation,
407        }];
408        let dto = ReasonResponseDto::from(steps);
409        assert_eq!(dto.inferences.len(), 1);
410        assert_eq!(dto.inferences[0].claim, "it rained");
411        assert_eq!(dto.inferences[0].supporting_memories, vec!["1", "2"]);
412        assert_eq!(dto.inferences[0].contradicting_memories, vec!["3"]);
413        assert_eq!(dto.inferences[0].inference_kind, "causal_explanation");
414    }
415}