Something went wrong. Try again.
bayes for days
Something went wrong. Try again.
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255//! 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, at the top of a round: what would you be doing? /// /// Asked before movement, and answered without one hex having been looked /// at - see `sds_core::situation` for why that is a hard boundary rather /// than a convention. The force proposes; the coordinator harmonises the /// answers and hands each force an order back on [`Request::Command`]. Consider { force: String, round: i32, /// This force's own machines, still standing. #[serde(default)] ours: Vec<sds_core::wire::Unit>, /// Every enemy machine this side can see. #[serde(default)] theirs: Vec<sds_core::wire::Unit>, /// The tactic the force is on now, by name, or `None` in its first /// round. #[serde(default)] held: Option<String>, }, /// Force level: here is everything below you; decide. Command { force: String, thoughts: Vec<UnitThought>, 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<i32, f32>, /// 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<sds_core::wire::Unit>, /// What this force proposed on [`Request::Consider`], carried back down /// so the decision it produces records what was asked for beside what /// was ordered. `None` when the caller did not harmonise. #[serde(default)] proposal: Option<sds_core::situation::Proposal>, /// The tactic the coordinator has put this force on for this round. /// /// An order, not a preference: the force proposed on /// [`Request::Consider`] and the coordinator has already weighed that /// against what it was on. `None` when the caller did not harmonise, /// and then the force falls back to its formation's opening, which is /// what it did before there was a coordinator to ask. #[serde(default)] tactic: Option<String>, },}
/// What a node answers.#[derive(Debug, Clone, Serialize, Deserialize)]#[serde(tag = "answer", rename_all = "snake_case")]pub enum Response { Thought(Box<UnitThought>), Decision(Box<ForceDecision>), /// What a force would be doing, and everything it scored getting there. Considered(Box<sds_core::situation::Considered>),}
impl Response { pub fn into_thought(self) -> Result<UnitThought> { match self { Response::Thought(t) => Ok(*t), other => Err(anyhow!("asked a unit to propose, got {}", other.kind())), } }
pub fn into_decision(self) -> Result<ForceDecision> { match self { Response::Decision(d) => Ok(*d), other => Err(anyhow!("asked a force to command, got {}", other.kind())), } }
pub fn into_considered(self) -> Result<sds_core::situation::Considered> { match self { Response::Considered(c) => Ok(*c), other => Err(anyhow!("asked a force to consider, got {}", other.kind())), } }
fn kind(&self) -> &'static str { match self { Response::Thought(_) => "a unit thought", Response::Decision(_) => "a force decision", Response::Considered(_) => "a tactic proposal", } }}
/// 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<Response>;}
/// A reachable thinker.#[async_trait]pub trait Node: Send + Sync { fn address(&self) -> &str; async fn think(&self, request: Request) -> Result<Response>;}
/// A thinker in this process, reached by name.pub struct LocalNode { address: String, thinker: Arc<dyn Thinker>,}
impl LocalNode { pub fn new(name: &str, thinker: Arc<dyn Thinker>) -> 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<Response> { 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<String, Arc<dyn Thinker>>,}
impl Registry { pub fn new() -> Self { Self::default() }
pub fn register(&mut self, name: &str, thinker: Arc<dyn Thinker>) -> &mut Self { self.thinkers.insert(name.to_string(), thinker); self }
/// Resolve an address into a node. pub fn resolve(&self, address: &str) -> Result<Arc<dyn Node>> { 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}'")), } }}