//! Thinkers, and how to reach one. //! //! A node is an address, not a place. The same lance commander can be a closure //! on this thread, a subprocess in this container, or a service on another //! machine, and nothing above it changes: the tree is built from strings. //! //! That is the point of the indirection rather than a flourish. The interesting //! nodes are not all going to be Rust functions - a planner backed by a language //! model wants to run somewhere with a key and a GPU and a long timeout, and a //! unit thinker wants to run a thousand times a second next to the state. Both //! have to hang off the same tree or the tree is a lie. //! //! Three transports, one line protocol - a JSON document per line, in and out. //! Same bytes whether it crosses a function call, a pipe or a socket, so a node //! can be moved by editing a config string and nothing else. //! //! | address | where it runs | //! |---|---| //! | `local:brawler` | a thinker registered in this process | //! | `proc:python3 bots/planner.py` | a child process, over its stdio | //! | `tcp:10.0.0.4:7000` | anything listening on that socket | //! //! Deliberately not HTTP. The line protocol is the same on all three, so a //! remote node is a small adapter rather than a second protocol to keep in //! step; an HTTP transport can be added later without any caller learning about //! it. use std::collections::HashMap; use std::sync::Arc; use anyhow::{anyhow, Context, Result}; use async_trait::async_trait; use sds_core::features::latch::Latches; use sds_core::plan::{ForceDecision, UnitThought}; use sds_core::stance::{Grit, Stance}; use sds_core::wire::{Board, Observation}; use serde::{Deserialize, Serialize}; mod process; mod tcp; pub use process::ProcessNode; pub use tcp::TcpNode; /// What a node is asked. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "ask", rename_all = "snake_case")] pub enum Request { /// Unit level: what could this machine do, and what is it worth. Propose { observation: Observation, /// The map. Carried in the request rather than assumed to be cached, /// because a unit thinker may be a process on another machine that has /// never seen this match before. #[serde(default)] board: Board, unit: i32, /// What the force is currently trying to do. A hint - a unit that can /// only serve the stance badly should say so rather than pretend. stance: Stance, /// What the force refuses to forget. Carried rather than kept here: a /// unit thinker may be a process that has seen none of this match. #[serde(default)] latches: Latches, }, /// Force level: here is everything below you; decide. Command { force: String, thoughts: Vec, stance: Stance, grit: Grit, round: i32, /// What each enemy has left, by unit id: armour plus structure. The /// denominator `sds_core::reconcile` normalises its target terms by, /// and the reason two units can decide that one of them finishes a /// machine rather than both emptying into it. /// /// Empty when the caller did not say. A target the map does not name /// is left out of the coordination terms rather than assumed healthy: /// an unknown denominator is not a large one. #[serde(default)] remaining: std::collections::BTreeMap, /// Every friendly machine still standing, whichever force it belongs /// to. /// /// What `sds_core::features::incoming` shares the enemy's fire out /// over. All of them rather than this force's: two lances that each /// normalise over their own units are two lances that each believe the /// whole enemy line is pointed at them, which is the count the /// allocation exists to remove. /// /// Empty when the caller did not say, and then no unit is given an /// incoming term at all - a feature nobody measured contributes /// nothing, and that is better than a denominator nobody has. #[serde(default)] ours: Vec, }, } /// What a node answers. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "answer", rename_all = "snake_case")] pub enum Response { Thought(Box), Decision(Box), } impl Response { pub fn into_thought(self) -> Result { match self { Response::Thought(t) => Ok(*t), Response::Decision(_) => Err(anyhow!("asked a unit to propose, got a force decision")), } } pub fn into_decision(self) -> Result { match self { Response::Decision(d) => Ok(*d), Response::Thought(_) => Err(anyhow!("asked a force to command, got a unit thought")), } } } /// Something that can think. Implement this to add a node that runs in-process. #[async_trait] pub trait Thinker: Send + Sync { async fn think(&self, request: Request) -> Result; } /// A reachable thinker. #[async_trait] pub trait Node: Send + Sync { fn address(&self) -> &str; async fn think(&self, request: Request) -> Result; } /// A thinker in this process, reached by name. pub struct LocalNode { address: String, thinker: Arc, } impl LocalNode { pub fn new(name: &str, thinker: Arc) -> Self { Self { address: format!("local:{name}"), thinker, } } } #[async_trait] impl Node for LocalNode { fn address(&self) -> &str { &self.address } async fn think(&self, request: Request) -> Result { self.thinker.think(request).await } } /// Builds nodes from address strings. /// /// The registry holds the in-process thinkers a `local:` address can name. /// Everything else is constructed on demand, so a config file naming a machine /// that is not there fails when it is dialled rather than at start-up - which /// is the behaviour you want when one remote planner is optional and the rest /// of the tree is not. #[derive(Default)] pub struct Registry { thinkers: HashMap>, } impl Registry { pub fn new() -> Self { Self::default() } pub fn register(&mut self, name: &str, thinker: Arc) -> &mut Self { self.thinkers.insert(name.to_string(), thinker); self } /// Resolve an address into a node. pub fn resolve(&self, address: &str) -> Result> { let (scheme, rest) = address .split_once(':') .with_context(|| format!("address '{address}' has no scheme"))?; match scheme { "local" => { let thinker = self .thinkers .get(rest) .with_context(|| format!("no thinker registered as '{rest}'"))?; Ok(Arc::new(LocalNode::new(rest, thinker.clone()))) } "proc" => Ok(Arc::new(ProcessNode::spawn(rest)?)), "tcp" => Ok(Arc::new(TcpNode::new(rest))), other => Err(anyhow!("unknown node scheme '{other}' in '{address}'")), } } }