1#![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#[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#[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 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#[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#[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#[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 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#[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#[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 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 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"); }
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}