Implement Sophia MVP scaffold (stages 0–3 + topology pivot)

Six-crate Rust workspace (core/sim/llm/store/server/bin) backing a
Three.js + WebGL frontend. Live at http://127.0.0.1:7777 via `cargo run`.

Sim
- Event-driven scheduler with min-heap, per-engram tick, staggered Spawn
  events (40 ms apart) so each engram's flight is visually readable.
- Solid-donut torus topology (replaces original spherical density-driven
  shell, see Topology Pivot in docs/system-analysis.md). Configurable
  major/minor radii in config.toml; live `POST /api/galaxy/:id/resize`.
- Physics: Verlet integration + friction; in-hole pull + galactic spin
  (CCW around +z) for spiral-ejection ejection from the donut centre;
  soft tube boundary with velocity-reflecting wall.
- Cosine-weighted gravity (kiddo k-NN within radius 25, threshold 0.50)
  and synapse formation (threshold 0.62) gated to inside-the-tube only.
- LM Studio integration via OpenAI-compatible REST: batched embeddings,
  optional Bearer auth, semaphore-bounded parallel ops per §13.5.

Server
- axum HTTP + WebSocket. Routes: /healthz, /api/galaxy CRUD, /seed,
  /ingest, /resize, /engrams/:id, /ws/galaxy/:id/events.
- Binary 12-byte-aligned position frames at ~20 Hz; JSON for sparse
  events (Hello, EngramCreated, SynapseCreated, TorusUpdated).
- Layered config: config.toml (defaults) + config.local.toml (secrets,
  gitignored) merged on startup.

Frontend
- Vite + vanilla TypeScript + three.js 0.169.
- Engrams render as additive bloom-friendly point sprites with a
  per-engram hash-driven hue rotation and breathing pulse.
- Comet-style velocity-aligned trails; additive ribbon synapses whose
  endpoints track engram positions every frame.
- UnrealBloomPass + ACES tone-mapping for the linked-particles look.
- HUD shows torus dims, engram + synapse counts, LM Studio status;
  controls for seed, ingest, and live torus resize.

Docs
- README replaced with docs/IDEA.md; system-analysis.md updated with
  the topology pivot decisions and Galaxy Ejection refinement notes
  (the implementation plan lives in ~/.claude/plans, gitignored).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-28 08:29:37 +02:00
parent 43c4d270e6
commit 8688f632bf
44 changed files with 7664 additions and 100 deletions

View File

@@ -0,0 +1,24 @@
[package]
name = "sophia-bin"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
authors.workspace = true
[[bin]]
name = "sophia"
path = "src/main.rs"
[dependencies]
sophia-core = { workspace = true }
sophia-server = { workspace = true }
sophia-sim = { workspace = true }
sophia-llm = { workspace = true }
anyhow = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
serde = { workspace = true }
toml = { workspace = true }

View File

@@ -0,0 +1,133 @@
//! Sophia binary. Reads `config.toml`, initializes tracing, starts the server.
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::Deserialize;
use sophia_core::GalaxyShape;
use sophia_llm::{LmStudioClient, LmStudioConfig, TokenCapsAll};
use sophia_server::{serve, ServerConfig};
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
#[derive(Debug, Deserialize)]
struct Config {
server: ServerSection,
lm_studio: LmStudioConfig,
#[allow(dead_code)] // wired in to LLM call sites in Stages 4+
token_caps: TokenCapsAll,
#[serde(default)]
galaxy_defaults: GalaxyDefaultsSection,
}
#[derive(Debug, Deserialize)]
struct ServerSection {
host: String,
port: u16,
static_dir: String,
}
#[derive(Debug, Deserialize)]
struct GalaxyDefaultsSection {
major_radius: f32,
minor_radius: f32,
}
impl Default for GalaxyDefaultsSection {
fn default() -> Self {
Self {
major_radius: GalaxyShape::DEFAULT_MAJOR,
minor_radius: GalaxyShape::DEFAULT_MINOR,
}
}
}
fn init_tracing() {
tracing_subscriber::registry()
.with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info,sophia=debug")))
.with(fmt::layer())
.init();
}
/// Recursively merge `local` into `base`. Tables are deep-merged; scalars
/// and arrays are overwritten by `local`. Used to layer `config.local.toml`
/// (gitignored, secrets) on top of `config.toml` (versioned defaults).
fn merge_toml(base: &mut toml::Value, local: toml::Value) {
match (base, local) {
(toml::Value::Table(b), toml::Value::Table(l)) => {
for (k, v) in l {
match b.get_mut(&k) {
Some(existing) => merge_toml(existing, v),
None => {
b.insert(k, v);
}
}
}
}
(slot, other) => *slot = other,
}
}
fn load_config() -> Result<Config> {
let base_path = "config.toml";
if !Path::new(base_path).exists() {
anyhow::bail!("no config.toml found in cwd");
}
let base_text = std::fs::read_to_string(base_path)
.with_context(|| format!("reading {base_path}"))?;
let mut merged: toml::Value = toml::from_str(&base_text)
.with_context(|| format!("parsing {base_path}"))?;
tracing::info!("loaded config from {base_path}");
let local_path = "config.local.toml";
if Path::new(local_path).exists() {
let local_text = std::fs::read_to_string(local_path)
.with_context(|| format!("reading {local_path}"))?;
let local_value: toml::Value = toml::from_str(&local_text)
.with_context(|| format!("parsing {local_path}"))?;
merge_toml(&mut merged, local_value);
tracing::info!("merged overrides from {local_path}");
}
let cfg: Config = merged.try_into().context("deserializing merged config")?;
Ok(cfg)
}
#[tokio::main]
async fn main() -> Result<()> {
init_tracing();
let cfg = load_config()?;
let server_cfg = ServerConfig {
host: cfg.server.host,
port: cfg.server.port,
static_dir: PathBuf::from(cfg.server.static_dir),
};
let llm = LmStudioClient::new(cfg.lm_studio);
// Best-effort startup ping — failure logs but doesn't block boot, so the
// server starts even if LM Studio isn't running yet (the user can launch
// it after, and `/healthz` will report current status).
match llm.list_models().await {
Ok(models) => tracing::info!("LM Studio reachable; {} model(s) loaded", models.len()),
Err(e) => tracing::warn!("LM Studio unreachable at startup: {e}"),
}
let default_shape = GalaxyShape {
major_radius: cfg.galaxy_defaults.major_radius,
minor_radius: cfg.galaxy_defaults.minor_radius,
};
if let Err(msg) = default_shape.validate() {
anyhow::bail!("invalid [galaxy_defaults] in config: {msg}");
}
tracing::info!(
"galaxy defaults: major_radius={} minor_radius={}",
default_shape.major_radius,
default_shape.minor_radius
);
let sim = sophia_sim::spawn_sim(default_shape);
let info = sim.create_galaxy("default".to_string()).await?;
tracing::info!("default galaxy ready: id={:?} name={}", info.id, info.name);
serve(server_cfg, sim, llm).await
}

View File

@@ -0,0 +1,13 @@
[package]
name = "sophia-core"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
authors.workspace = true
[dependencies]
serde = { workspace = true }
glam = { workspace = true }
uuid = { workspace = true }
thiserror = { workspace = true }

View File

@@ -0,0 +1,104 @@
//! Data-transfer objects shared between the simulation and the server.
//!
//! These types cross the channel between `sophia-sim` and `sophia-server`,
//! and several are also serialized to JSON over WebSocket text frames.
//! `PositionFrame` is broadcast as a binary frame instead — see
//! `sophia-server::ws` for the wire encoding.
use serde::{Deserialize, Serialize};
use crate::engram::EngramState;
use crate::ids::{EngramId, GalaxyId, SynapseId};
use crate::manifest::Manifest;
use crate::synapse::Synapse;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GalaxyInfo {
pub id: GalaxyId,
pub name: String,
pub engram_count: usize,
pub center: [f32; 3],
pub major_radius: f32,
pub minor_radius: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EngramSnapshot {
pub id: EngramId,
pub instance_idx: u32,
pub position: [f32; 3],
pub size: f32,
pub state: EngramState,
}
/// Wire-friendly synapse — same as `Synapse` but always serialized in the
/// canonical (a, b) order. Sent over WS for both initial Hello and live
/// `SynapseCreated` events.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SynapseDto {
pub id: SynapseId,
pub a: EngramId,
pub b: EngramId,
pub weight: f32,
}
impl From<&Synapse> for SynapseDto {
fn from(s: &Synapse) -> Self {
Self { id: s.id, a: s.a, b: s.b, weight: s.weight }
}
}
/// Detailed view returned by `GET /api/galaxy/:gid/engrams/:eid`. Includes the
/// manifest (potentially long) but only the slate's norm + dim — the full
/// embedding vector would be wasteful to send on every inspector click.
#[derive(Debug, Clone, Serialize)]
pub struct EngramDetail {
pub id: EngramId,
pub instance_idx: u32,
pub position: [f32; 3],
pub size: f32,
pub state: EngramState,
pub age: u32,
pub manifest: Option<Manifest>,
pub slate_dim: Option<usize>,
pub slate_norm: Option<f32>,
}
/// Position frame: a downsampled bundle of all engram positions at a moment in
/// time. Encoded to a binary WS frame at the wire layer.
#[derive(Debug, Clone)]
pub struct PositionFrame {
pub t_ms: u32,
/// Indexed by `instance_idx` (dense 0..n).
pub positions: Vec<[f32; 3]>,
}
/// Events emitted by the simulation to subscribed WS clients.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum SimEvent {
Hello {
galaxy: GalaxyInfo,
engrams: Vec<EngramSnapshot>,
synapses: Vec<SynapseDto>,
},
EngramCreated {
snapshot: EngramSnapshot,
},
/// A new synapse formed between two engrams. (Stage 3 only emits creates;
/// updates and removes arrive in later stages.)
SynapseCreated {
synapse: SynapseDto,
},
/// Torus shape changed (initial publish at subscribe + on every resize).
/// Replaces the legacy `BBoxUpdated` event from the spherical topology.
TorusUpdated {
center: [f32; 3],
major_radius: f32,
minor_radius: f32,
},
/// Position frame is sent over the wire as a binary frame; this variant
/// carries the in-process payload.
#[serde(skip)]
PositionFrame(PositionFrame),
}

View File

@@ -0,0 +1,54 @@
use glam::Vec3;
use serde::{Deserialize, Serialize};
use crate::ids::EngramId;
use crate::manifest::Manifest;
use crate::slate::Slate;
/// Engram state machine — see §5 of `docs/system-analysis.md`.
///
/// Stage 1 only uses `Idle`. Later stages add `Searching`, `Conversing`,
/// `Synthesizing`, `Memorize`, `Decaying`, `Deprecated`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EngramState {
Idle = 0,
Searching = 1,
Conversing = 2,
Synthesizing = 3,
Memorize = 4,
Decaying = 5,
Deprecated = 6,
}
impl EngramState {
pub fn as_u8(self) -> u8 {
self as u8
}
}
/// One unit of knowledge — the fundamental agent.
///
/// Stage 2 adds `manifest` (the source content) and `slate` (an embedding for
/// fast similarity). Stage 4 adds taxonomy/goals/memories on top of that.
#[derive(Debug, Clone)]
pub struct Engram {
pub id: EngramId,
/// Slot index for the visualization's `InstancedMesh`. Assigned at birth,
/// stable for the engram's lifetime, dense within a galaxy.
pub instance_idx: u32,
pub position: Vec3,
/// Velocity carried over between ticks — gives the simulation momentum so
/// motion glides instead of jittering. Integrated with friction in
/// `sophia_sim::physics::tick`.
pub velocity: Vec3,
pub size: f32,
pub state: EngramState,
/// Ticks since birth. Drives curiosity decay (see `physics::tick`).
pub age: u32,
/// What this engram represents. `None` for synthetic seed engrams.
pub manifest: Option<Manifest>,
/// Embedding vector — System-1 layer of the Universal Slate. `None` for
/// synthetic seed engrams.
pub slate: Option<Slate>,
}

View File

@@ -0,0 +1,77 @@
use glam::Vec3;
use serde::{Deserialize, Serialize};
use crate::ids::GalaxyId;
/// Geometry of a galaxy's Space of Recollection — a solid 3D torus volume.
///
/// `major_radius` is the distance from the donut's center to the centerline
/// of the tube; `minor_radius` is the tube's own radius. Engrams live inside
/// the tube. The torus is fixed-size (manually resized via API), not
/// density-driven — see the Topology Pivot in the implementation plan.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct GalaxyShape {
pub major_radius: f32,
pub minor_radius: f32,
}
impl GalaxyShape {
pub const DEFAULT_MAJOR: f32 = 100.0;
pub const DEFAULT_MINOR: f32 = 30.0;
/// "Great Reflection" — the void at the very center of the donut hole.
/// New engrams materialize here and fly outward into the tube. From the
/// center, every direction in the xy-plane heads toward the tube; physics
/// + a small initial outward velocity does the rest.
pub fn birth_point(&self, center: Vec3) -> Vec3 {
center
}
pub fn validate(&self) -> Result<(), &'static str> {
if !(self.major_radius.is_finite() && self.minor_radius.is_finite()) {
return Err("radii must be finite");
}
if self.minor_radius <= 0.1 {
return Err("minor_radius must be > 0.1");
}
if self.major_radius <= self.minor_radius {
return Err("major_radius must exceed minor_radius");
}
if self.major_radius > 10_000.0 {
return Err("major_radius must be <= 10_000");
}
Ok(())
}
}
impl Default for GalaxyShape {
fn default() -> Self {
Self {
major_radius: Self::DEFAULT_MAJOR,
minor_radius: Self::DEFAULT_MINOR,
}
}
}
/// Galaxy metadata — the set of Engrams it contains lives inside the simulation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Galaxy {
pub id: GalaxyId,
pub name: String,
/// Galaxy origin in world coords. Always `Vec3::ZERO` in v1; reserved for
/// future multi-galaxy layouts.
#[serde(skip)]
pub center: Vec3,
pub shape: GalaxyShape,
}
impl Galaxy {
pub fn new(name: impl Into<String>, shape: GalaxyShape) -> Self {
Self {
id: GalaxyId::new(),
name: name.into(),
center: Vec3::ZERO,
shape,
}
}
}

View File

@@ -0,0 +1,50 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct EngramId(pub Uuid);
impl EngramId {
pub fn new() -> Self {
Self(Uuid::now_v7())
}
}
impl Default for EngramId {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct GalaxyId(pub Uuid);
impl GalaxyId {
pub fn new() -> Self {
Self(Uuid::now_v7())
}
}
impl Default for GalaxyId {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct SynapseId(pub Uuid);
impl SynapseId {
pub fn new() -> Self {
Self(Uuid::now_v7())
}
}
impl Default for SynapseId {
fn default() -> Self {
Self::new()
}
}

View File

@@ -0,0 +1,23 @@
//! Sophia domain types. Pure data — no I/O, no async.
//!
//! See `docs/system-analysis.md` §1 (System Boundary) and §5 (Engram Dynamics)
//! for the conceptual model.
pub mod dto;
pub mod engram;
pub mod galaxy;
pub mod ids;
pub mod manifest;
pub mod slate;
pub mod synapse;
pub use dto::{EngramDetail, EngramSnapshot, GalaxyInfo, PositionFrame, SimEvent, SynapseDto};
pub use engram::{Engram, EngramState};
pub use galaxy::{Galaxy, GalaxyShape};
pub use ids::{EngramId, GalaxyId, SynapseId};
pub use manifest::Manifest;
pub use slate::Slate;
pub use synapse::{canonical_pair, Synapse};
// Re-export glam::Vec3 so consumers don't all need to depend on glam directly.
pub use glam::Vec3;

View File

@@ -0,0 +1,26 @@
use serde::{Deserialize, Serialize};
/// What an Engram *is* — its source content and self-description.
///
/// Stage 2 only carries `Text` (paragraphs from ingest). Later stages add
/// taxonomy/goals (Stage 4 introspection) and richer modalities.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Manifest {
Text { content: String },
}
impl Manifest {
pub fn short_label(&self, max_chars: usize) -> String {
match self {
Manifest::Text { content } => {
if content.chars().count() <= max_chars {
content.clone()
} else {
let truncated: String = content.chars().take(max_chars).collect();
format!("{truncated}")
}
}
}
}
}

View File

@@ -0,0 +1,35 @@
use serde::{Deserialize, Serialize};
/// Universal Slate (per §13.1) — Stage 2 layer 1 only: a dense embedding
/// vector. The deeper LLM-comparison layer arrives in Stage 4.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Slate(pub Vec<f32>);
impl Slate {
pub fn dim(&self) -> usize {
self.0.len()
}
pub fn norm(&self) -> f32 {
self.0.iter().map(|x| x * x).sum::<f32>().sqrt()
}
/// Cosine similarity in `[-1, 1]`. Returns 0 if either vector is zero or
/// dimensions disagree (the latter can happen across embedding model
/// changes — see §13 risk #3).
pub fn cosine(&self, other: &Slate) -> f32 {
if self.0.len() != other.0.len() {
return 0.0;
}
let mut dot = 0.0_f32;
let mut a2 = 0.0_f32;
let mut b2 = 0.0_f32;
for (a, b) in self.0.iter().zip(other.0.iter()) {
dot += a * b;
a2 += a * a;
b2 += b * b;
}
let denom = (a2.sqrt() * b2.sqrt()).max(1e-9);
dot / denom
}
}

View File

@@ -0,0 +1,25 @@
//! Synapse — a bidirectional connection between two Engrams formed when
//! they spend time close together with a high cosine similarity on their
//! Slates. See `docs/system-analysis.md` §1 + §3 (Stage 3 of the impl plan).
use serde::{Deserialize, Serialize};
use crate::ids::{EngramId, SynapseId};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Synapse {
pub id: SynapseId,
/// Canonical pair endpoints — `a` is always the lexicographically smaller
/// uuid so `(a, b)` is order-independent.
pub a: EngramId,
pub b: EngramId,
/// Strength in `[-1, 1]` — currently set to the cosine similarity at
/// formation time. Stage 3 doesn't update it; future stages may.
pub weight: f32,
}
/// Lay two ids out canonically (lower uuid first) so a `(a, b)` pair lookup
/// is order-independent.
pub fn canonical_pair(x: EngramId, y: EngramId) -> (EngramId, EngramId) {
if x.0 <= y.0 { (x, y) } else { (y, x) }
}

View File

@@ -0,0 +1,19 @@
[package]
name = "sophia-llm"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
authors.workspace = true
[dependencies]
sophia-core = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
reqwest = { workspace = true }
async-trait = { workspace = true }

View File

@@ -0,0 +1,49 @@
//! Token caps from §13.5. Loaded from `config.toml`'s `[token_caps]` section.
use serde::Deserialize;
/// Per-call cap (input/output tokens). Output is enforced via
/// `max_tokens` in the chat request; input is best-effort and currently
/// only documented (no tokenizer in MVP).
#[derive(Debug, Clone, Copy)]
pub struct TokenCaps {
pub input: u32,
pub output: u32,
}
#[derive(Debug, Clone, Deserialize)]
pub struct TokenCapsAll {
pub introspection_in: u32,
pub introspection_out: u32,
pub peer_msg_in: u32,
pub peer_msg_out: u32,
pub synthesis_in: u32,
pub synthesis_out: u32,
pub query_responder_in: u32,
pub query_responder_out: u32,
pub query_integrator_in: u32,
pub query_integrator_out: u32,
pub hard_ceiling_in: u32,
pub hard_ceiling_out: u32,
}
impl TokenCapsAll {
pub fn introspection(&self) -> TokenCaps {
TokenCaps { input: self.introspection_in, output: self.introspection_out }
}
pub fn peer_msg(&self) -> TokenCaps {
TokenCaps { input: self.peer_msg_in, output: self.peer_msg_out }
}
pub fn synthesis(&self) -> TokenCaps {
TokenCaps { input: self.synthesis_in, output: self.synthesis_out }
}
pub fn query_responder(&self) -> TokenCaps {
TokenCaps { input: self.query_responder_in, output: self.query_responder_out }
}
pub fn query_integrator(&self) -> TokenCaps {
TokenCaps { input: self.query_integrator_in, output: self.query_integrator_out }
}
pub fn hard_ceiling(&self) -> TokenCaps {
TokenCaps { input: self.hard_ceiling_in, output: self.hard_ceiling_out }
}
}

View File

@@ -0,0 +1,229 @@
//! Minimal LM Studio HTTP client. Talks to LM Studio's OpenAI-compatible
//! endpoints (`/v1/embeddings`, `/v1/chat/completions`, `/v1/models`).
//!
//! Concurrent inferences are bounded by a semaphore (per §13.5 — fixed
//! parallel-op ceiling). The semaphore is shared across all callers so the
//! total system-wide concurrency is N.
use std::sync::Arc;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tokio::sync::Semaphore;
use tracing::{debug, warn};
use crate::budget::TokenCaps;
/// Max texts per single embed_batch call. Stage 2 chunks larger ingests in the
/// server. Tuned conservatively for `nomic-embed-text-v1.5` running on a single
/// GPU — a higher number is fine for stronger hardware.
pub const EMBED_BATCH_LIMIT: usize = 32;
#[derive(Debug, Clone, Deserialize)]
pub struct LmStudioConfig {
pub base_url: String,
pub chat_model: String,
pub embedding_model: String,
pub parallel_ops: usize,
/// Optional Bearer token. LM Studio 0.3.x+ enables this by default; create
/// one in the LM Studio app under Developer → API Tokens. Either set it
/// here in config.local.toml or expose it as `LM_STUDIO_API_TOKEN` and
/// reference via env (Stage 2 keeps it simple — config-only).
#[serde(default)]
pub api_token: Option<String>,
}
#[derive(Debug, Error)]
pub enum LmError {
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
#[error("LM Studio returned status {0}: {1}")]
Status(u16, String),
#[error("LM Studio returned malformed JSON: {0}")]
Json(String),
#[error("response had no content")]
EmptyResponse,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ChatRole {
System,
User,
Assistant,
}
#[derive(Debug, Clone, Serialize)]
pub struct ChatMessage {
pub role: ChatRole,
pub content: String,
}
impl ChatMessage {
pub fn system(s: impl Into<String>) -> Self {
Self { role: ChatRole::System, content: s.into() }
}
pub fn user(s: impl Into<String>) -> Self {
Self { role: ChatRole::User, content: s.into() }
}
}
/// Cheaply cloneable handle. Wrapped in `Arc` internally; `Clone` shares the
/// semaphore so the parallel-op ceiling is global.
#[derive(Clone)]
pub struct LmStudioClient {
inner: Arc<Inner>,
}
struct Inner {
http: reqwest::Client,
cfg: LmStudioConfig,
parallel: Semaphore,
}
impl LmStudioClient {
pub fn new(cfg: LmStudioConfig) -> Self {
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(120))
.build()
.expect("reqwest client builds");
let parallel = Semaphore::new(cfg.parallel_ops.max(1));
Self { inner: Arc::new(Inner { http, cfg, parallel }) }
}
pub fn config(&self) -> &LmStudioConfig {
&self.inner.cfg
}
/// Apply Bearer auth header if a token is configured. LM Studio's REST
/// server returns 401 if auth is enabled (default in 0.3.x+) and no token
/// is sent.
fn auth(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
match &self.inner.cfg.api_token {
Some(t) if !t.is_empty() => req.bearer_auth(t),
_ => req,
}
}
/// Lightweight check: list models from LM Studio. Returns the available
/// model ids on success. Used by `/healthz`.
pub async fn list_models(&self) -> Result<Vec<String>, LmError> {
let url = format!("{}/models", self.inner.cfg.base_url.trim_end_matches('/'));
let resp = self.auth(self.inner.http.get(&url)).send().await?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(LmError::Status(status.as_u16(), body));
}
let v: serde_json::Value = resp.json().await.map_err(|e| LmError::Json(e.to_string()))?;
let arr = v
.get("data")
.and_then(|d| d.as_array())
.ok_or_else(|| LmError::Json("missing `data` array".into()))?;
let ids = arr
.iter()
.filter_map(|m| m.get("id").and_then(|i| i.as_str()).map(String::from))
.collect();
Ok(ids)
}
/// Embed a single text. Bounded by the global parallel-op semaphore.
pub async fn embed(&self, text: &str) -> Result<Vec<f32>, LmError> {
let mut out = self.embed_batch(std::slice::from_ref(&text.to_string())).await?;
out.pop().ok_or(LmError::EmptyResponse)
}
/// Embed many texts in a single LM Studio call. Uses OpenAI's batch-input
/// embeddings form so we don't hammer the embedding endpoint with
/// concurrent requests (LM Studio's embedding server isn't reliably
/// reentrant — concurrent calls can return 500). One semaphore permit
/// per call regardless of batch size.
///
/// Returns embeddings in input order. The caller is responsible for
/// chunking very large inputs — see `EMBED_BATCH_LIMIT`.
pub async fn embed_batch(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, LmError> {
if texts.is_empty() {
return Ok(Vec::new());
}
let _permit = self.inner.parallel.acquire().await.expect("semaphore not closed");
let url = format!("{}/embeddings", self.inner.cfg.base_url.trim_end_matches('/'));
let body = serde_json::json!({
"model": self.inner.cfg.embedding_model,
"input": texts,
});
let resp = self.auth(self.inner.http.post(&url).json(&body)).send().await?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(LmError::Status(status.as_u16(), body));
}
let v: serde_json::Value = resp.json().await.map_err(|e| LmError::Json(e.to_string()))?;
let arr = v
.get("data")
.and_then(|d| d.as_array())
.ok_or_else(|| LmError::Json("missing `data` array".into()))?;
// Sort by index so we honour input order even if the server returns
// out of order (the OpenAI spec guarantees input-order, but be safe).
let mut indexed: Vec<(u64, Vec<f32>)> = arr
.iter()
.map(|m| {
let idx = m.get("index").and_then(|i| i.as_u64()).unwrap_or(u64::MAX);
let vec: Vec<f32> = m
.get("embedding")
.and_then(|e| e.as_array())
.map(|a| a.iter().filter_map(|n| n.as_f64().map(|x| x as f32)).collect())
.unwrap_or_default();
(idx, vec)
})
.collect();
indexed.sort_by_key(|(i, _)| *i);
if indexed.len() != texts.len() {
return Err(LmError::Json(format!(
"embed_batch: requested {} but got {}",
texts.len(),
indexed.len()
)));
}
if indexed.iter().any(|(_, v)| v.is_empty()) {
return Err(LmError::EmptyResponse);
}
Ok(indexed.into_iter().map(|(_, v)| v).collect())
}
/// One-shot chat completion (non-streaming). Bounded by the parallel-op
/// semaphore. `caps.output` becomes the request's `max_tokens`.
pub async fn chat(&self, messages: &[ChatMessage], caps: TokenCaps) -> Result<String, LmError> {
let _permit = self.inner.parallel.acquire().await.expect("semaphore not closed");
let url = format!("{}/chat/completions", self.inner.cfg.base_url.trim_end_matches('/'));
let body = serde_json::json!({
"model": self.inner.cfg.chat_model,
"messages": messages,
"max_tokens": caps.output,
"temperature": 0.6,
"stream": false,
});
debug!("chat: {} messages, max_tokens={}", messages.len(), caps.output);
let resp = self.auth(self.inner.http.post(&url).json(&body)).send().await?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
warn!("chat status {}: {}", status, body);
return Err(LmError::Status(status.as_u16(), body));
}
let v: serde_json::Value = resp.json().await.map_err(|e| LmError::Json(e.to_string()))?;
let content = v
.get("choices")
.and_then(|c| c.as_array())
.and_then(|arr| arr.first())
.and_then(|first| first.get("message"))
.and_then(|m| m.get("content"))
.and_then(|c| c.as_str())
.ok_or_else(|| LmError::Json("missing choices[0].message.content".into()))?
.to_string();
if content.is_empty() {
return Err(LmError::EmptyResponse);
}
Ok(content)
}
}

View File

@@ -0,0 +1,9 @@
//! LM Studio client + caveman prompts + parallel-op budget.
//!
//! See `docs/system-analysis.md` §13.5 (compute budget, token caps).
mod budget;
mod client;
pub use budget::{TokenCaps, TokenCapsAll};
pub use client::{ChatMessage, ChatRole, LmError, LmStudioClient, LmStudioConfig, EMBED_BATCH_LIMIT};

View File

@@ -0,0 +1,25 @@
[package]
name = "sophia-server"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
authors.workspace = true
[dependencies]
sophia-core = { workspace = true }
sophia-sim = { workspace = true }
sophia-llm = { workspace = true }
anyhow = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
axum = { workspace = true }
tower = { workspace = true }
tower-http = { workspace = true }
futures-util = { workspace = true }
bytes = { workspace = true }

View File

@@ -0,0 +1,241 @@
//! HTTP / WebSocket server.
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::Result;
use axum::extract::{Path, Query, State, WebSocketUpgrade};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::{delete, get, post};
use axum::{Json, Router};
use serde::Deserialize;
use sophia_core::{EngramId, GalaxyId, GalaxyShape, Manifest, Slate};
use sophia_llm::{LmStudioClient, EMBED_BATCH_LIMIT};
use sophia_sim::{IngestItem, SimHandle};
use tower_http::services::{ServeDir, ServeFile};
use tower_http::trace::TraceLayer;
mod ws;
#[derive(Debug, Clone)]
pub struct ServerConfig {
pub host: String,
pub port: u16,
pub static_dir: PathBuf,
}
#[derive(Clone)]
struct AppState {
sim: SimHandle,
llm: LmStudioClient,
}
pub fn build_router(cfg: &ServerConfig, sim: SimHandle, llm: LmStudioClient) -> Router {
let index = cfg.static_dir.join("index.html");
let static_service = ServeDir::new(&cfg.static_dir).fallback(ServeFile::new(&index));
let state = Arc::new(AppState { sim, llm });
Router::new()
.route("/healthz", get(healthz))
.route("/api/galaxy", post(create_galaxy).get(list_galaxies))
.route("/api/galaxy/:id", delete(delete_galaxy_unimpl))
.route("/api/galaxy/:id/seed", post(seed_galaxy))
.route("/api/galaxy/:id/ingest", post(ingest_galaxy))
.route("/api/galaxy/:id/resize", post(resize_galaxy))
.route("/api/galaxy/:gid/engrams/:eid", get(get_engram))
.route("/ws/galaxy/:id/events", get(ws_events))
.with_state(state)
.fallback_service(static_service)
.layer(TraceLayer::new_for_http())
}
pub async fn serve(cfg: ServerConfig, sim: SimHandle, llm: LmStudioClient) -> Result<()> {
let app = build_router(&cfg, sim, llm);
let addr = format!("{}:{}", cfg.host, cfg.port);
let listener = tokio::net::TcpListener::bind(&addr).await?;
tracing::info!("sophia-server listening on http://{}", addr);
axum::serve(listener, app).await?;
Ok(())
}
// ---------- routes ----------
async fn healthz(State(s): State<Arc<AppState>>) -> Response {
// Stage 2: ping LM Studio's /models endpoint and report what's loaded.
let cfg = s.llm.config();
let (lm_ok, lm_models, lm_err) = match s.llm.list_models().await {
Ok(models) => (true, models, None),
Err(e) => (false, Vec::new(), Some(e.to_string())),
};
let chat_loaded = lm_models.iter().any(|m| m == &cfg.chat_model);
let embed_loaded = lm_models.iter().any(|m| m == &cfg.embedding_model);
Json(serde_json::json!({
"status": "ok",
"stage": 2,
"lm_studio": {
"reachable": lm_ok,
"configured_chat_model": cfg.chat_model,
"configured_embedding_model": cfg.embedding_model,
"chat_model_loaded": chat_loaded,
"embedding_model_loaded": embed_loaded,
"models_available": lm_models,
"error": lm_err,
}
}))
.into_response()
}
#[derive(Deserialize)]
struct CreateGalaxyBody {
name: String,
}
async fn create_galaxy(
State(s): State<Arc<AppState>>,
Json(body): Json<CreateGalaxyBody>,
) -> Response {
match s.sim.create_galaxy(body.name).await {
Ok(info) => Json(info).into_response(),
Err(e) => sim_error(e),
}
}
async fn list_galaxies(State(s): State<Arc<AppState>>) -> Response {
match s.sim.list_galaxies().await {
Ok(list) => Json(list).into_response(),
Err(e) => sim_error(e),
}
}
async fn delete_galaxy_unimpl(Path(_id): Path<GalaxyId>) -> Response {
// Stage 6 will wire deletion to the persistence layer.
(StatusCode::NOT_IMPLEMENTED, "galaxy deletion arrives in Stage 6").into_response()
}
#[derive(Deserialize)]
struct SeedQuery {
n: usize,
}
async fn seed_galaxy(
State(s): State<Arc<AppState>>,
Path(id): Path<GalaxyId>,
Query(q): Query<SeedQuery>,
) -> Response {
if q.n == 0 || q.n > 5_000 {
return (StatusCode::BAD_REQUEST, "n must be in 1..=5000").into_response();
}
match s.sim.seed(id, q.n).await {
Ok(ids) => Json(serde_json::json!({ "engram_ids": ids })).into_response(),
Err(e) => sim_error(e),
}
}
#[derive(Deserialize)]
struct IngestBody {
texts: Vec<String>,
}
/// Embed all texts via LM Studio's batch-input embeddings endpoint, then
/// ship the embedded items to the sim. Chunked at `EMBED_BATCH_LIMIT` so
/// large ingests don't time out a single LM Studio call. Chunks are issued
/// sequentially: LM Studio's embedding endpoint isn't reliably reentrant
/// (concurrent calls return 500), and a batch of ~32 already saturates the
/// embedding model on most setups.
async fn ingest_galaxy(
State(s): State<Arc<AppState>>,
Path(id): Path<GalaxyId>,
Json(body): Json<IngestBody>,
) -> Response {
if body.texts.is_empty() {
return (StatusCode::BAD_REQUEST, "texts must not be empty").into_response();
}
if body.texts.len() > 200 {
return (StatusCode::BAD_REQUEST, "max 200 texts per call").into_response();
}
// Filter empty/whitespace-only entries up front so chunk indices match.
let texts: Vec<String> = body.texts.into_iter().map(|t| t.trim().to_string()).collect();
if texts.iter().any(|t| t.is_empty()) {
return (StatusCode::BAD_REQUEST, "no empty/whitespace-only texts").into_response();
}
let mut all_vecs: Vec<Vec<f32>> = Vec::with_capacity(texts.len());
for chunk in texts.chunks(EMBED_BATCH_LIMIT) {
match s.llm.embed_batch(chunk).await {
Ok(mut v) => all_vecs.append(&mut v),
Err(e) => {
return (StatusCode::BAD_GATEWAY, format!("embed_batch: {e}")).into_response();
}
}
}
let items: Vec<IngestItem> = texts
.into_iter()
.zip(all_vecs)
.map(|(content, vec)| IngestItem {
manifest: Manifest::Text { content },
slate: Slate(vec),
})
.collect();
tracing::info!("ingest: {} items into galaxy {:?}", items.len(), id);
match s.sim.ingest(id, items).await {
Ok(ids) => Json(serde_json::json!({ "engram_ids": ids })).into_response(),
Err(e) => sim_error(e),
}
}
#[derive(Deserialize)]
struct ResizeBody {
major_radius: f32,
minor_radius: f32,
}
async fn resize_galaxy(
State(s): State<Arc<AppState>>,
Path(id): Path<GalaxyId>,
Json(body): Json<ResizeBody>,
) -> Response {
let shape = GalaxyShape {
major_radius: body.major_radius,
minor_radius: body.minor_radius,
};
if let Err(msg) = shape.validate() {
return (StatusCode::BAD_REQUEST, msg).into_response();
}
match s.sim.resize(id, shape).await {
Ok(info) => Json(info).into_response(),
Err(e) => sim_error(e),
}
}
async fn get_engram(
State(s): State<Arc<AppState>>,
Path((gid, eid)): Path<(GalaxyId, EngramId)>,
) -> Response {
match s.sim.get_engram(gid, eid).await {
Ok(Some(detail)) => Json(detail).into_response(),
Ok(None) => (StatusCode::NOT_FOUND, "engram not found in this galaxy").into_response(),
Err(e) => sim_error(e),
}
}
async fn ws_events(
State(s): State<Arc<AppState>>,
Path(id): Path<GalaxyId>,
upgrade: WebSocketUpgrade,
) -> Response {
upgrade.on_upgrade(move |socket| ws::run_galaxy_socket(socket, s.sim.clone(), id))
}
fn sim_error(e: sophia_sim::SimError) -> Response {
use sophia_sim::SimError::*;
let status = match e {
Shutdown => StatusCode::SERVICE_UNAVAILABLE,
UnknownGalaxy => StatusCode::NOT_FOUND,
InvalidShape(_) => StatusCode::BAD_REQUEST,
};
(status, e.to_string()).into_response()
}

View File

@@ -0,0 +1,108 @@
//! WebSocket bridge from the simulation to a single browser client.
//!
//! Wire protocol:
//! - `text` frames: JSON-serialized [`SimEvent`] (Hello, EngramCreated,
//! BBoxUpdated). One message per frame.
//! - `binary` frames: position frames, encoded as
//! `[tag u32 LE = 0x01][t_ms u32 LE][n u32 LE][n × (x f32 LE, y f32 LE, z f32 LE)]`
//! — 12-byte header followed by the float region. The header is encoded
//! as three little-endian u32s rather than a tighter (u8, u32, u32) so the
//! float region starts at a 4-byte-aligned offset, which lets the browser
//! wrap it as a `Float32Array` view without copying. (`Float32Array`
//! requires its byte offset to be a multiple of 4 and throws otherwise.)
//! One frame at ~20 Hz, in `instance_idx` order.
//!
//! See `docs/system-analysis.md` Risk #1 in §13 plan for why we use binary
//! frames here.
use axum::extract::ws::{Message, WebSocket};
use bytes::{BufMut, BytesMut};
use futures_util::{SinkExt, StreamExt};
use sophia_core::{GalaxyId, PositionFrame, SimEvent};
use sophia_sim::SimHandle;
use tokio::sync::broadcast;
use tracing::{debug, warn};
const POS_FRAME_TAG: u32 = 0x01;
pub async fn run_galaxy_socket(socket: WebSocket, sim: SimHandle, galaxy: GalaxyId) {
let (mut sender, mut receiver) = socket.split();
let (info, snapshots, synapses, mut bus) = match sim.subscribe(galaxy).await {
Ok(t) => t,
Err(e) => {
warn!("ws subscribe failed: {e}");
let _ = sender
.send(Message::Text(
serde_json::json!({ "type": "error", "message": e.to_string() }).to_string(),
))
.await;
return;
}
};
// Hello: tell the client about the galaxy + every existing engram + synapse.
let hello = SimEvent::Hello { galaxy: info, engrams: snapshots, synapses };
if let Err(e) = send_text(&mut sender, &hello).await {
debug!("ws hello send failed: {e}");
return;
}
loop {
tokio::select! {
// Inbound: ignore messages for now (Stage 1 has no client→server).
// Just drain so the socket stays alive and we notice closure.
msg = receiver.next() => {
match msg {
Some(Ok(Message::Close(_))) | None => break,
Some(Err(e)) => { debug!("ws recv err: {e}"); break; }
Some(Ok(_)) => {}
}
}
// Outbound: forward sim events to the client.
ev = bus.recv() => {
match ev {
Ok(SimEvent::PositionFrame(f)) => {
if let Err(e) = sender.send(Message::Binary(encode_position_frame(&f))).await {
debug!("ws send pos frame failed: {e}");
break;
}
}
Ok(other) => {
if let Err(e) = send_text(&mut sender, &other).await {
debug!("ws send text failed: {e}");
break;
}
}
Err(broadcast::error::RecvError::Closed) => break,
Err(broadcast::error::RecvError::Lagged(n)) => {
warn!("ws lagged by {n} events; client will catch up on next frame");
}
}
}
}
}
}
async fn send_text(
sender: &mut futures_util::stream::SplitSink<WebSocket, Message>,
ev: &SimEvent,
) -> anyhow::Result<()> {
let body = serde_json::to_string(ev)?;
sender.send(Message::Text(body)).await?;
Ok(())
}
fn encode_position_frame(f: &PositionFrame) -> Vec<u8> {
let n = f.positions.len();
let mut buf = BytesMut::with_capacity(12 + n * 12);
buf.put_u32_le(POS_FRAME_TAG);
buf.put_u32_le(f.t_ms);
buf.put_u32_le(n as u32);
for [x, y, z] in &f.positions {
buf.put_f32_le(*x);
buf.put_f32_le(*y);
buf.put_f32_le(*z);
}
buf.to_vec()
}

View File

@@ -0,0 +1,20 @@
[package]
name = "sophia-sim"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
authors.workspace = true
[dependencies]
sophia-core = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
glam = { workspace = true }
serde = { workspace = true }
kiddo = { workspace = true }
rand = { workspace = true }

View File

@@ -0,0 +1,588 @@
//! Public surface of the simulation: a Send/Sync handle wrapping a tokio mpsc
//! sender. Server tasks talk to the sim through this — they never touch
//! `World` directly.
use std::time::{Duration, Instant};
use rand::rngs::SmallRng;
use rand::{Rng, SeedableRng};
use thiserror::Error;
use tokio::sync::{broadcast, mpsc, oneshot};
use tracing::warn;
use sophia_core::{
Engram, EngramDetail, EngramId, EngramSnapshot, EngramState, GalaxyId, GalaxyInfo, GalaxyShape,
Manifest, PositionFrame, SimEvent, Slate, SynapseDto, Vec3,
};
/// One item to ingest into the sim. The server pre-computes the slate via the
/// LM Studio embedding endpoint and hands the sim a fully-formed payload.
#[derive(Debug, Clone)]
pub struct IngestItem {
pub manifest: Manifest,
pub slate: Slate,
}
/// Reply payload for `SimHandle::subscribe`. Aliased here to keep the
/// `SimCmd::Subscribe` variant within clippy's complexity bounds.
type SubscribeReply = (
GalaxyInfo,
Vec<EngramSnapshot>,
Vec<SynapseDto>,
broadcast::Receiver<SimEvent>,
);
use crate::index::KiddoIndex;
use crate::physics;
use crate::scheduler::{Event, Scheduler, SpawnPayload};
use crate::world::{GalaxyState, World};
const FRAME_INTERVAL: Duration = Duration::from_millis(50); // 20 Hz
const REBUILD_INTERVAL: Duration = Duration::from_millis(500);
const TICK_INTERVAL: Duration = Duration::from_millis(50);
/// Spacing between scheduled `Spawn` events for a single seed/ingest call —
/// engrams are released gradually instead of all at once so each one's
/// trajectory from the birth point to the tube is visually readable.
const SPAWN_SPACING: Duration = Duration::from_millis(40);
/// Spawn jitter — tiny so all new engrams visibly emerge from the same
/// pinpoint at the donut center. Their initial velocities (random direction,
/// `BIRTH_SPEED`) do the actual fanning-out.
const BIRTH_JITTER: f32 = 0.3;
/// Initial speed given to a new engram. The velocity is split into a small
/// radial kick + a larger tangential kick (CCW around +z), so combined with
/// the in-hole spin force in `physics.rs` engrams emerge in a rotating
/// galaxy pattern instead of straight radial lines.
const BIRTH_SPEED: f32 = 14.0;
// ---- Stage 3: gravity + synapse formation ----
/// Maximum distance at which gravity / synapse-formation considers a peer.
const GRAVITY_RADIUS: f32 = 25.0;
/// Cosine-similarity threshold for any pull at all. Below this engrams ignore
/// each other completely. Tuned for `nomic-embed-text-v1.5` where unrelated
/// text typically sits in the 0.30.5 range and related text 0.6+.
const GRAVITY_THRESHOLD: f32 = 0.50;
/// Acceleration scale applied per qualifying neighbour (multiplied by
/// `(cos - threshold)`). Total gravity force is bounded by
/// `GRAVITY_MAX_ACC` so a dense cluster doesn't snap engrams together.
const GRAVITY_K: f32 = 12.0;
const GRAVITY_MAX_ACC: f32 = 30.0;
/// Cosine-similarity threshold for *forming* a synapse — slightly stricter
/// than the gravity threshold so weak co-residence doesn't link everyone.
const SYNAPSE_THRESHOLD: f32 = 0.62;
#[derive(Debug, Error)]
pub enum SimError {
#[error("simulation has shut down")]
Shutdown,
#[error("galaxy not found")]
UnknownGalaxy,
#[error("invalid galaxy shape: {0}")]
InvalidShape(&'static str),
}
#[derive(Debug)]
enum SimCmd {
CreateGalaxy {
name: String,
reply: oneshot::Sender<GalaxyInfo>,
},
ListGalaxies {
reply: oneshot::Sender<Vec<GalaxyInfo>>,
},
Seed {
galaxy: GalaxyId,
n: usize,
reply: oneshot::Sender<Result<Vec<EngramId>, SimError>>,
},
Ingest {
galaxy: GalaxyId,
items: Vec<IngestItem>,
reply: oneshot::Sender<Result<Vec<EngramId>, SimError>>,
},
GetEngram {
galaxy: GalaxyId,
engram: EngramId,
reply: oneshot::Sender<Result<Option<EngramDetail>, SimError>>,
},
Subscribe {
galaxy: GalaxyId,
reply: oneshot::Sender<Result<SubscribeReply, SimError>>,
},
Resize {
galaxy: GalaxyId,
shape: GalaxyShape,
reply: oneshot::Sender<Result<GalaxyInfo, SimError>>,
},
}
#[derive(Clone)]
pub struct SimHandle {
tx: mpsc::Sender<SimCmd>,
}
impl SimHandle {
pub async fn create_galaxy(&self, name: String) -> Result<GalaxyInfo, SimError> {
let (reply, rx) = oneshot::channel();
self.tx.send(SimCmd::CreateGalaxy { name, reply }).await.map_err(|_| SimError::Shutdown)?;
rx.await.map_err(|_| SimError::Shutdown)
}
pub async fn list_galaxies(&self) -> Result<Vec<GalaxyInfo>, SimError> {
let (reply, rx) = oneshot::channel();
self.tx.send(SimCmd::ListGalaxies { reply }).await.map_err(|_| SimError::Shutdown)?;
rx.await.map_err(|_| SimError::Shutdown)
}
pub async fn seed(&self, galaxy: GalaxyId, n: usize) -> Result<Vec<EngramId>, SimError> {
let (reply, rx) = oneshot::channel();
self.tx.send(SimCmd::Seed { galaxy, n, reply }).await.map_err(|_| SimError::Shutdown)?;
rx.await.map_err(|_| SimError::Shutdown)?
}
pub async fn ingest(
&self,
galaxy: GalaxyId,
items: Vec<IngestItem>,
) -> Result<Vec<EngramId>, SimError> {
let (reply, rx) = oneshot::channel();
self.tx
.send(SimCmd::Ingest { galaxy, items, reply })
.await
.map_err(|_| SimError::Shutdown)?;
rx.await.map_err(|_| SimError::Shutdown)?
}
pub async fn get_engram(
&self,
galaxy: GalaxyId,
engram: EngramId,
) -> Result<Option<EngramDetail>, SimError> {
let (reply, rx) = oneshot::channel();
self.tx
.send(SimCmd::GetEngram { galaxy, engram, reply })
.await
.map_err(|_| SimError::Shutdown)?;
rx.await.map_err(|_| SimError::Shutdown)?
}
/// Subscribe to a galaxy's event stream. Returns initial state plus the
/// live receiver so the WS client can replay then follow.
pub async fn subscribe(
&self,
galaxy: GalaxyId,
) -> Result<SubscribeReply, SimError> {
let (reply, rx) = oneshot::channel();
self.tx.send(SimCmd::Subscribe { galaxy, reply }).await.map_err(|_| SimError::Shutdown)?;
rx.await.map_err(|_| SimError::Shutdown)?
}
/// Live-resize a galaxy's torus. Caller doesn't need to validate first;
/// the sim re-validates and returns `InvalidShape` on a bad payload.
pub async fn resize(
&self,
galaxy: GalaxyId,
shape: GalaxyShape,
) -> Result<GalaxyInfo, SimError> {
let (reply, rx) = oneshot::channel();
self.tx
.send(SimCmd::Resize { galaxy, shape, reply })
.await
.map_err(|_| SimError::Shutdown)?;
rx.await.map_err(|_| SimError::Shutdown)?
}
}
/// Spawn the simulation task. `default_shape` is used for any new galaxy
/// created via [`SimHandle::create_galaxy`].
pub fn spawn_sim(default_shape: GalaxyShape) -> SimHandle {
let (tx, mut rx) = mpsc::channel::<SimCmd>(64);
tokio::spawn(async move {
let mut world = World::new();
let mut scheduler = Scheduler::new();
// One spatial index per galaxy. We rebuild on RebuildIndex events.
let mut indexes: std::collections::HashMap<GalaxyId, KiddoIndex> = Default::default();
let mut rng = SmallRng::seed_from_u64(0xC0DE_5071);
let started = Instant::now();
loop {
// Pick whichever happens first: a new command or the next due event.
let now = Instant::now();
let next_at = scheduler.next_at();
let timeout = match next_at {
Some(at) => at.saturating_duration_since(now),
None => Duration::from_millis(50),
};
tokio::select! {
cmd = rx.recv() => {
let Some(cmd) = cmd else { break }; // all handles dropped
handle_cmd(cmd, &mut world, &mut scheduler, &mut indexes, &mut rng, default_shape);
}
_ = tokio::time::sleep(timeout) => {
// Drain all due events.
let now = Instant::now();
while let Some(event) = scheduler.pop_due(now) {
handle_event(event, &mut world, &mut scheduler, &mut indexes, &mut rng, started);
}
}
}
}
});
SimHandle { tx }
}
fn handle_cmd(
cmd: SimCmd,
world: &mut World,
scheduler: &mut Scheduler,
indexes: &mut std::collections::HashMap<GalaxyId, KiddoIndex>,
_rng: &mut SmallRng,
default_shape: GalaxyShape,
) {
match cmd {
SimCmd::CreateGalaxy { name, reply } => {
let state = GalaxyState::new(name, default_shape);
let info = state.info();
let id = state.galaxy.id;
// Publish the initial torus shape to anyone who subscribes later
// (Hello carries the full state, but emitting now also keeps the
// bus authoritative for resize events — same code path).
state.emit(state.torus_event());
world.galaxies.insert(id, state);
indexes.insert(id, KiddoIndex::empty());
// Kick off the galaxy's recurring events.
let now = Instant::now();
scheduler.schedule(now + FRAME_INTERVAL, Event::BroadcastFrame { galaxy: id });
scheduler.schedule(now + REBUILD_INTERVAL, Event::RebuildIndex { galaxy: id });
let _ = reply.send(info);
}
SimCmd::ListGalaxies { reply } => {
let _ = reply.send(world.list_galaxies());
}
SimCmd::Seed { galaxy, n, reply } => {
let now = Instant::now();
let res = seed_galaxy(world, galaxy, n, scheduler, now);
let _ = reply.send(res);
}
SimCmd::Ingest { galaxy, items, reply } => {
let now = Instant::now();
let res = ingest_galaxy(world, galaxy, items, scheduler, now);
let _ = reply.send(res);
}
SimCmd::GetEngram { galaxy, engram, reply } => {
let detail = world.galaxies.get(&galaxy).map(|g| {
g.engrams.get(&engram).map(|e| EngramDetail {
id: e.id,
instance_idx: e.instance_idx,
position: e.position.to_array(),
size: e.size,
state: e.state,
age: e.age,
manifest: e.manifest.clone(),
slate_dim: e.slate.as_ref().map(|s| s.dim()),
slate_norm: e.slate.as_ref().map(|s| s.norm()),
})
});
match detail {
Some(found) => {
let _ = reply.send(Ok(found));
}
None => {
let _ = reply.send(Err(SimError::UnknownGalaxy));
}
}
}
SimCmd::Subscribe { galaxy, reply } => {
let res = world
.galaxies
.get(&galaxy)
.map(|g| (g.info(), g.snapshot_all(), g.snapshot_synapses(), g.bus.subscribe()))
.ok_or(SimError::UnknownGalaxy);
let _ = reply.send(res);
}
SimCmd::Resize { galaxy, shape, reply } => {
let res = match shape.validate() {
Err(msg) => Err(SimError::InvalidShape(msg)),
Ok(()) => world
.resize_galaxy(galaxy, shape)
.ok_or(SimError::UnknownGalaxy),
};
let _ = reply.send(res);
}
}
}
/// Random offset around the birth point. Tiny — the bulk of the dispersion
/// comes from the random initial velocity, not position jitter.
fn birth_offset(rng: &mut SmallRng) -> Vec3 {
Vec3::new(
rng.gen_range(-BIRTH_JITTER..BIRTH_JITTER),
rng.gen_range(-BIRTH_JITTER..BIRTH_JITTER),
rng.gen_range(-BIRTH_JITTER..BIRTH_JITTER),
)
}
/// Random initial velocity, biased into the xy-plane so the fountain spreads
/// through the donut tube rather than shooting up/down the central axis. A
/// small z component is allowed for visual variety.
fn birth_velocity(rng: &mut SmallRng) -> Vec3 {
let theta = rng.gen_range(0.0_f32..std::f32::consts::TAU);
let z_bias: f32 = rng.gen_range(-0.25..0.25);
Vec3::new(theta.cos(), theta.sin(), z_bias).normalize_or_zero() * BIRTH_SPEED
}
/// Schedule N synthetic engrams (no manifest/slate) to be born one at a time,
/// `SPAWN_SPACING` apart. Returns the pre-allocated ids so callers can refer
/// to engrams that don't exist yet — the WS will see `EngramCreated` events
/// trickle in over the next `n * SPAWN_SPACING`.
fn seed_galaxy(
world: &mut World,
galaxy: GalaxyId,
n: usize,
scheduler: &mut Scheduler,
now: Instant,
) -> Result<Vec<EngramId>, SimError> {
if !world.galaxies.contains_key(&galaxy) {
return Err(SimError::UnknownGalaxy);
}
let mut ids = Vec::with_capacity(n);
for i in 0..n {
let id = EngramId::new();
ids.push(id);
let at = now + SPAWN_SPACING * (i as u32);
scheduler.schedule(
at,
Event::Spawn { galaxy, id, payload: SpawnPayload::Synthetic },
);
}
Ok(ids)
}
fn ingest_galaxy(
world: &mut World,
galaxy: GalaxyId,
items: Vec<IngestItem>,
scheduler: &mut Scheduler,
now: Instant,
) -> Result<Vec<EngramId>, SimError> {
if !world.galaxies.contains_key(&galaxy) {
return Err(SimError::UnknownGalaxy);
}
let mut ids = Vec::with_capacity(items.len());
for (i, item) in items.into_iter().enumerate() {
let id = EngramId::new();
ids.push(id);
let at = now + SPAWN_SPACING * (i as u32);
scheduler.schedule(
at,
Event::Spawn {
galaxy,
id,
payload: SpawnPayload::Manifested {
manifest: item.manifest,
slate: item.slate,
},
},
);
}
Ok(ids)
}
/// Materialize one engram with the pre-allocated id at the galaxy's birth
/// point with a random initial velocity, emit `EngramCreated`, and schedule
/// its first `EngramTick`.
fn materialize_engram(
g: &mut GalaxyState,
galaxy: GalaxyId,
id: EngramId,
payload: SpawnPayload,
scheduler: &mut Scheduler,
rng: &mut SmallRng,
) {
let instance_idx = g.slot_to_id.len() as u32;
let birth = g.galaxy.shape.birth_point(g.galaxy.center);
let position = birth + birth_offset(rng);
let velocity = birth_velocity(rng);
let (manifest, slate) = match payload {
SpawnPayload::Synthetic => (None, None),
SpawnPayload::Manifested { manifest, slate } => (Some(manifest), Some(slate)),
};
let engram = Engram {
id,
instance_idx,
position,
velocity,
size: 1.0,
state: EngramState::Idle,
age: 0,
manifest,
slate,
};
g.slot_to_id.push(id);
g.engrams.insert(id, engram.clone());
let snapshot = EngramSnapshot {
id,
instance_idx,
position: position.to_array(),
size: engram.size,
state: engram.state,
};
g.emit(SimEvent::EngramCreated { snapshot });
scheduler.schedule(
Instant::now() + TICK_INTERVAL,
Event::EngramTick { galaxy, engram: id },
);
}
/// Walk the kiddo index for `engram_id`'s neighbours and return:
/// - `gravity_acc`: cosine-weighted attraction toward similar peers (only
/// contributes inside the tube; physics gates this further by position).
/// - `synapse_candidates`: list of `(peer_id, weight)` pairs that cleared
/// the synapse threshold and may be formed after the tick.
///
/// Returns `(Vec3::ZERO, vec![])` if the engram has no slate (synthetic
/// seeds), if the spatial index hasn't been built yet for this galaxy, or if
/// the engram itself is gone.
fn compute_gravity_and_candidates(
g: &GalaxyState,
index: Option<&KiddoIndex>,
engram_id: EngramId,
minor_r: f32,
) -> (Vec3, Vec<(EngramId, f32)>) {
let Some(index) = index else { return (Vec3::ZERO, Vec::new()); };
let Some(self_engram) = g.engrams.get(&engram_id) else { return (Vec3::ZERO, Vec::new()); };
let Some(self_slate) = self_engram.slate.as_ref() else {
return (Vec3::ZERO, Vec::new());
};
// Only consider gravity / synapses once we're settled inside the tube —
// matches `physics::tick`'s gating, and keeps the in-flight phase clean.
let spine = nearest_spine_xy(self_engram.position, g.galaxy.shape.major_radius);
let r_dist = (self_engram.position - spine).length();
if r_dist > minor_r {
return (Vec3::ZERO, Vec::new());
}
let neighbours = index.within(self_engram.position.to_array(), GRAVITY_RADIUS);
let mut acc = Vec3::ZERO;
let mut candidates = Vec::new();
for peer_id in neighbours {
if peer_id == engram_id {
continue;
}
let Some(peer) = g.engrams.get(&peer_id) else { continue; };
let Some(peer_slate) = peer.slate.as_ref() else { continue; };
let cos = self_slate.cosine(peer_slate);
if cos < GRAVITY_THRESHOLD {
continue;
}
let dir = peer.position - self_engram.position;
let dist = dir.length();
if dist > 1e-3 {
acc += dir / dist * (GRAVITY_K * (cos - GRAVITY_THRESHOLD));
}
if cos >= SYNAPSE_THRESHOLD {
candidates.push((peer_id, cos));
}
}
// Cap acceleration magnitude so a dense neighbourhood doesn't snap.
let acc_len = acc.length();
if acc_len > GRAVITY_MAX_ACC {
acc = acc / acc_len * GRAVITY_MAX_ACC;
}
(acc, candidates)
}
/// Closest point on the torus centerline to `p` in xy-plane (mirrors the
/// math in `physics::spine_point` but doesn't pull `physics` in here).
fn nearest_spine_xy(p: Vec3, major_r: f32) -> Vec3 {
let xy_len = (p.x * p.x + p.y * p.y).sqrt();
if xy_len < 1e-6 {
Vec3::new(major_r, 0.0, 0.0)
} else {
let scale = major_r / xy_len;
Vec3::new(p.x * scale, p.y * scale, 0.0)
}
}
fn handle_event(
event: Event,
world: &mut World,
scheduler: &mut Scheduler,
indexes: &mut std::collections::HashMap<GalaxyId, KiddoIndex>,
rng: &mut SmallRng,
started: Instant,
) {
match event {
Event::Spawn { galaxy, id, payload } => {
let Some(g) = world.galaxies.get_mut(&galaxy) else { return };
materialize_engram(g, galaxy, id, payload, scheduler, rng);
}
Event::EngramTick { galaxy, engram } => {
let Some(g) = world.galaxies.get_mut(&galaxy) else { return };
let center = g.galaxy.center;
let major_r = g.galaxy.shape.major_radius;
let minor_r = g.galaxy.shape.minor_radius;
// Compute gravity + collect synapse candidates while the borrow on
// `g` is read-only. Both use the same kiddo lookup so we do it once.
let index = indexes.get(&galaxy);
let (gravity_acc, synapse_candidates) =
compute_gravity_and_candidates(g, index, engram, minor_r);
if let Some(e) = g.engrams.get_mut(&engram) {
physics::tick(e, center, major_r, minor_r, gravity_acc, rng);
} else {
return;
}
// After the tick, materialise any qualifying synapses and emit
// events for the newly-formed ones.
for (peer, weight) in synapse_candidates {
if let Some(syn) = g.try_form_synapse(engram, peer, weight) {
g.emit(SimEvent::SynapseCreated { synapse: SynapseDto::from(&syn) });
}
}
scheduler.schedule(
Instant::now() + TICK_INTERVAL,
Event::EngramTick { galaxy, engram },
);
}
Event::BroadcastFrame { galaxy } => {
if let Some(g) = world.galaxies.get(&galaxy) {
let positions: Vec<[f32; 3]> = g
.slot_to_id
.iter()
.filter_map(|id| g.engrams.get(id))
.map(|e| e.position.to_array())
.collect();
let t_ms = started.elapsed().as_millis() as u32;
g.emit(SimEvent::PositionFrame(PositionFrame { t_ms, positions }));
} else {
warn!("BroadcastFrame for unknown galaxy {:?}", galaxy);
}
scheduler.schedule(
Instant::now() + FRAME_INTERVAL,
Event::BroadcastFrame { galaxy },
);
}
Event::RebuildIndex { galaxy } => {
// Index rebuild only — no bbox recompute (the torus is fixed-size,
// resized only via SimCmd::Resize).
if let Some(g) = world.galaxies.get(&galaxy) {
let points: Vec<(EngramId, [f32; 3])> = g
.engrams
.values()
.map(|e| (e.id, e.position.to_array()))
.collect();
if let Some(idx) = indexes.get_mut(&galaxy) {
idx.rebuild(&points);
}
}
scheduler.schedule(
Instant::now() + REBUILD_INTERVAL,
Event::RebuildIndex { galaxy },
);
}
}
}

View File

@@ -0,0 +1,42 @@
//! Spatial index. Stage 1 wraps `kiddo`'s ImmutableKdTree, rebuilt on demand.
//!
//! kiddo is fast for nearest-neighbour and within-radius queries, but doesn't
//! support efficient updates — so we rebuild the tree periodically rather
//! than per-tick. Wrapped behind this small surface so a per-octree
//! incremental index can swap in later without touching callers.
use kiddo::{ImmutableKdTree, SquaredEuclidean};
use sophia_core::EngramId;
pub struct KiddoIndex {
tree: Option<ImmutableKdTree<f32, 3>>,
/// `tree`'s point indices map back to these engram ids.
ids: Vec<EngramId>,
}
impl KiddoIndex {
pub fn empty() -> Self {
Self { tree: None, ids: Vec::new() }
}
pub fn rebuild(&mut self, points: &[(EngramId, [f32; 3])]) {
if points.is_empty() {
self.tree = None;
self.ids.clear();
return;
}
self.ids = points.iter().map(|(id, _)| *id).collect();
let coords: Vec<[f32; 3]> = points.iter().map(|(_, p)| *p).collect();
self.tree = Some(ImmutableKdTree::new_from_slice(&coords));
}
/// Returns engram ids within `radius` of `point`. Used by gravity +
/// synapse formation (Stage 3) and the broadcast wavefront (Stage 5).
pub fn within(&self, point: [f32; 3], radius: f32) -> Vec<EngramId> {
let Some(tree) = self.tree.as_ref() else { return Vec::new() };
tree.within_unsorted::<SquaredEuclidean>(&point, radius * radius)
.into_iter()
.filter_map(|hit| self.ids.get(hit.item as usize).copied())
.collect()
}
}

View File

@@ -0,0 +1,12 @@
//! Sophia simulation: event-driven scheduler, physics, spatial index, broadcast.
//!
//! See `docs/system-analysis.md` §13.2 (event-driven, non-deterministic),
//! §13.4 (broadcast/conversation retrieval), §4 (lifecycle topology).
mod handle;
mod index;
mod physics;
mod scheduler;
mod world;
pub use handle::{spawn_sim, IngestItem, SimError, SimHandle};

View File

@@ -0,0 +1,151 @@
//! Stage 1+ physics, post Topology Pivot:
//! curiosity (decaying random walk) + soft torus-radial inward force,
//! integrated with per-engram velocity + friction so motion glides
//! instead of jittering.
//!
//! The boundary force pulls engrams toward the nearest spine point of the
//! donut. For an engram born at the very center of the donut hole this
//! same force becomes a gentle outward attraction toward the tube — so a
//! particle fountain emerges naturally from the central "Great Reflection".
use glam::Vec3;
use rand::rngs::SmallRng;
use rand::Rng;
use sophia_core::Engram;
/// Tick interval the scheduler aims for. Fixed in Stage 1.
pub const TICK_DT_SECS: f32 = 0.05;
/// Curiosity drives small random impulses that decay with age. Treated as
/// an *acceleration* (units / s²) rather than a velocity, so it composes
/// with the boundary force and gets smoothed by friction.
const CURIOSITY_TAU_TICKS: f32 = 1500.0; // ≈75 s half-life at 20 Hz tick
const CURIOSITY_BASE: f32 = 8.0;
/// Per-tick velocity damping. With dt = 50 ms this works out to ≈55 % of
/// velocity retained per second — the engrams glide instead of bullet
/// across the scene, and direction changes look smooth.
const FRICTION: f32 = 0.03;
/// Where (as a fraction of `minor_radius`) the soft inward force kicks in
/// once the engram is inside the tube.
const BOUNDARY_START: f32 = 0.85;
/// Strength of the soft restoration at the tube edge (only inside the tube,
/// past `BOUNDARY_START * minor_radius`). Quadratic in overshoot.
const BOUNDARY_K: f32 = 28.0;
/// Constant gentle pull toward the nearest tube spine while the engram is in
/// the donut hole (`r_dist > minor_radius`). Much smaller than BOUNDARY_K so
/// the cross-hole flight is *visible* — engrams coast at ~15 units/s and
/// take several seconds to reach the tube, instead of snapping there.
const IN_HOLE_PULL: f32 = 5.0;
/// Tangential acceleration around the +z axis applied while in the donut
/// hole — turns the otherwise-radial flight into a CCW spiral, so the
/// scene reads as a rotating galaxy rather than a starburst.
const SPIN_K: f32 = 6.0;
/// Closest point on the torus centerline to `p`. The centerline is the
/// circle of radius `major_r` lying in the plane z = center.z, centered on
/// `center`. See plan §"Topology math" for the derivation.
fn spine_point(p: Vec3, center: Vec3, major_r: f32) -> Vec3 {
let local = p - center;
let xy_len = (local.x * local.x + local.y * local.y).sqrt();
if xy_len < 1e-6 {
// Degenerate: directly above/below the donut axis (e.g. a brand-new
// engram at the exact origin). Pick θ = 0 arbitrarily so the spine
// point is well-defined and the boundary force has a direction —
// initial velocity randomness ensures different engrams pick
// different θ on the next tick.
center + Vec3::new(major_r, 0.0, 0.0)
} else {
let scale = major_r / xy_len;
center + Vec3::new(local.x * scale, local.y * scale, 0.0)
}
}
/// Apply one tick of physics to the engram in-place. `gravity_acc` is a
/// pre-computed cosine-weighted attraction toward similar nearby engrams
/// (Stage 3); pass `Vec3::ZERO` if not yet computed. Gravity only takes
/// effect once the engram is settled inside the tube — engrams in the
/// donut hole shouldn't pull each other back into a clump near birth.
pub fn tick(
engram: &mut Engram,
center: Vec3,
major_r: f32,
minor_r: f32,
gravity_acc: Vec3,
rng: &mut SmallRng,
) {
let curiosity_factor = (-(engram.age as f32) / CURIOSITY_TAU_TICKS).exp();
// Curiosity: random impulse, decaying with age.
let rand_dir = Vec3::new(
rng.gen_range(-1.0..1.0),
rng.gen_range(-1.0..1.0),
rng.gen_range(-1.0..1.0),
)
.normalize_or_zero();
let curiosity_acc = rand_dir * (CURIOSITY_BASE * curiosity_factor);
// Spine attraction with two regimes:
// - In the donut hole (r_dist > minor_r): a *gentle constant* pull
// toward the nearest spine point. Engrams coast across the empty
// space, visibly traversing it over several seconds.
// - Inside the tube but past 0.85 * minor_r: a stronger quadratic
// restoration, so engrams that drift to the tube wall bounce
// back smoothly without escaping.
let spine = spine_point(engram.position, center, major_r);
let radial = engram.position - spine;
let r_dist = radial.length();
let inward = -radial.normalize_or_zero();
let start = minor_r * BOUNDARY_START;
let span = (minor_r - start).max(1e-3);
let boundary_acc = if r_dist > minor_r {
inward * IN_HOLE_PULL
} else if r_dist > start {
let over = ((r_dist - start) / span).clamp(0.0, 1.0);
inward * (BOUNDARY_K * over * over)
} else {
Vec3::ZERO
};
// Galactic spin: tangential acceleration in the xy-plane (CCW around
// +z). Only active in the donut hole — once an engram reaches the
// tube the spin force vanishes so it can settle. The tangent is the
// 90° CCW rotation of the engram's xy position vector relative to the
// galaxy center.
let local = engram.position - center;
let xy_len = (local.x * local.x + local.y * local.y).sqrt();
let spin_acc = if xy_len > 1e-3 && r_dist > minor_r {
Vec3::new(-local.y, local.x, 0.0) / xy_len * SPIN_K
} else {
Vec3::ZERO
};
// Gravity is gated to inside-the-tube only — see fn doc.
let gated_gravity = if r_dist <= minor_r { gravity_acc } else { Vec3::ZERO };
// Verlet-ish integration: accumulate forces into velocity, damp,
// then move. Gives smooth glide instead of per-tick teleporting.
let acc = curiosity_acc + boundary_acc + spin_acc + gated_gravity;
engram.velocity += acc * TICK_DT_SECS;
engram.velocity *= 1.0 - FRICTION;
engram.position += engram.velocity * TICK_DT_SECS;
engram.age = engram.age.saturating_add(1);
// Hard safety clamp: even with the soft force, large impulses can
// momentarily breach the tube. Project back to the surface and reflect
// the outward component of velocity so the engram bounces softly
// instead of pile-driving against the wall.
let spine_after = spine_point(engram.position, center, major_r);
let radial_after = engram.position - spine_after;
let dist_after = radial_after.length();
if dist_after > minor_r {
let normal = radial_after.normalize_or_zero();
engram.position = spine_after + normal * minor_r;
let v_dot_n = engram.velocity.dot(normal);
if v_dot_n > 0.0 {
// Cancel the outward component, keep tangential motion.
engram.velocity -= normal * v_dot_n;
}
}
}

View File

@@ -0,0 +1,90 @@
//! Min-heap event scheduler driving the simulation. Per §13.2.
use std::cmp::Ordering;
use std::collections::BinaryHeap;
use std::time::Instant;
use sophia_core::{EngramId, GalaxyId, Manifest, Slate};
/// Payload for a queued spawn. Synthetic seed engrams have no manifest/slate;
/// ingested engrams carry the pre-embedded text.
#[derive(Debug, Clone)]
pub enum SpawnPayload {
Synthetic,
Manifested { manifest: Manifest, slate: Slate },
}
#[derive(Debug, Clone)]
pub enum Event {
/// One Engram's turn to act (move, perceive, etc).
EngramTick { galaxy: GalaxyId, engram: EngramId },
/// Materialize one engram with the pre-allocated id and the given
/// payload, then schedule its first tick. Used by `seed` and `ingest`
/// to release engrams gradually instead of all at once.
Spawn { galaxy: GalaxyId, id: EngramId, payload: SpawnPayload },
/// Snapshot all positions in a galaxy and emit a `PositionFrame`. Fired
/// at a fixed cadence (~20 Hz) and reschedules itself.
BroadcastFrame { galaxy: GalaxyId },
/// Recompute density-driven bbox + rebuild spatial index. Fires every
/// ~500 ms and reschedules itself.
RebuildIndex { galaxy: GalaxyId },
}
#[derive(Debug)]
struct Scheduled {
at: Instant,
seq: u64, // tie-breaker so equal-time events have a stable order
event: Event,
}
impl PartialEq for Scheduled {
fn eq(&self, other: &Self) -> bool {
self.at.eq(&other.at) && self.seq.eq(&other.seq)
}
}
impl Eq for Scheduled {}
impl PartialOrd for Scheduled {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Scheduled {
fn cmp(&self, other: &Self) -> Ordering {
// BinaryHeap is a max-heap; reverse so the earliest time wins.
other.at.cmp(&self.at).then(other.seq.cmp(&self.seq))
}
}
pub struct Scheduler {
heap: BinaryHeap<Scheduled>,
next_seq: u64,
}
impl Scheduler {
pub fn new() -> Self {
Self { heap: BinaryHeap::new(), next_seq: 0 }
}
pub fn schedule(&mut self, at: Instant, event: Event) {
self.next_seq = self.next_seq.wrapping_add(1);
self.heap.push(Scheduled { at, seq: self.next_seq, event });
}
pub fn next_at(&self) -> Option<Instant> {
self.heap.peek().map(|s| s.at)
}
/// Pop one event if its scheduled time has arrived.
pub fn pop_due(&mut self, now: Instant) -> Option<Event> {
match self.heap.peek() {
Some(s) if s.at <= now => self.heap.pop().map(|s| s.event),
_ => None,
}
}
}
impl Default for Scheduler {
fn default() -> Self {
Self::new()
}
}

View File

@@ -0,0 +1,153 @@
//! Per-galaxy mutable state owned by the simulation.
use std::collections::{HashMap, HashSet};
use sophia_core::{
canonical_pair, Engram, EngramId, EngramSnapshot, Galaxy, GalaxyId, GalaxyInfo, GalaxyShape,
SimEvent, Synapse, SynapseDto, SynapseId,
};
use tokio::sync::broadcast;
/// Global ceiling on synapses per galaxy — keeps the WS bandwidth bounded
/// and prevents the visualisation from drowning in lines if many engrams
/// happen to be similar at once. Tuned generously for a 200-engram demo.
const MAX_SYNAPSES_PER_GALAXY: usize = 4_000;
/// Per-engram synapse cap. Once an engram has this many connections, no
/// new ones are formed for it (Stage 3 is no-eviction; later stages may
/// drop the weakest).
const MAX_SYNAPSES_PER_ENGRAM: usize = 16;
/// Channel buffer for per-galaxy event broadcast. Big enough for several
/// position frames; lagged consumers receive `RecvError::Lagged`.
const BROADCAST_CAPACITY: usize = 256;
pub struct GalaxyState {
pub galaxy: Galaxy,
pub engrams: HashMap<sophia_core::EngramId, Engram>,
/// Dense list of engram ids in slot order (instance_idx is the index here).
pub slot_to_id: Vec<sophia_core::EngramId>,
/// Synapses keyed by id.
pub synapses: HashMap<SynapseId, Synapse>,
/// Canonical-pair set so duplicate-formation is O(1).
pub synapse_pairs: HashSet<(EngramId, EngramId)>,
/// Per-engram synapse counts for cap enforcement.
pub synapse_count: HashMap<EngramId, usize>,
/// Broadcast bus for events scoped to this galaxy.
pub bus: broadcast::Sender<SimEvent>,
}
impl GalaxyState {
pub fn new(name: impl Into<String>, shape: GalaxyShape) -> Self {
let (bus, _) = broadcast::channel(BROADCAST_CAPACITY);
Self {
galaxy: Galaxy::new(name, shape),
engrams: HashMap::new(),
slot_to_id: Vec::new(),
synapses: HashMap::new(),
synapse_pairs: HashSet::new(),
synapse_count: HashMap::new(),
bus,
}
}
/// Try to form a new synapse between `a` and `b` with the given weight.
/// Returns `Some(synapse)` if created, `None` if a synapse already
/// exists for this pair or any cap was hit. Stage 3 doesn't update
/// existing synapses; later stages may.
pub fn try_form_synapse(&mut self, a: EngramId, b: EngramId, weight: f32) -> Option<Synapse> {
if a == b {
return None;
}
let pair = canonical_pair(a, b);
if self.synapse_pairs.contains(&pair) {
return None;
}
if self.synapses.len() >= MAX_SYNAPSES_PER_GALAXY {
return None;
}
let count_a = self.synapse_count.get(&pair.0).copied().unwrap_or(0);
let count_b = self.synapse_count.get(&pair.1).copied().unwrap_or(0);
if count_a >= MAX_SYNAPSES_PER_ENGRAM || count_b >= MAX_SYNAPSES_PER_ENGRAM {
return None;
}
let id = SynapseId::new();
let syn = Synapse { id, a: pair.0, b: pair.1, weight };
self.synapse_pairs.insert(pair);
self.synapses.insert(id, syn.clone());
*self.synapse_count.entry(pair.0).or_insert(0) += 1;
*self.synapse_count.entry(pair.1).or_insert(0) += 1;
Some(syn)
}
pub fn snapshot_synapses(&self) -> Vec<SynapseDto> {
self.synapses.values().map(SynapseDto::from).collect()
}
pub fn info(&self) -> GalaxyInfo {
GalaxyInfo {
id: self.galaxy.id,
name: self.galaxy.name.clone(),
engram_count: self.slot_to_id.len(),
center: self.galaxy.center.to_array(),
major_radius: self.galaxy.shape.major_radius,
minor_radius: self.galaxy.shape.minor_radius,
}
}
pub fn snapshot_all(&self) -> Vec<EngramSnapshot> {
self.slot_to_id
.iter()
.filter_map(|id| self.engrams.get(id))
.map(|e| EngramSnapshot {
id: e.id,
instance_idx: e.instance_idx,
position: e.position.to_array(),
size: e.size,
state: e.state,
})
.collect()
}
/// Emit on the bus; drops the event silently if no one is listening.
pub fn emit(&self, ev: SimEvent) {
let _ = self.bus.send(ev);
}
pub fn torus_event(&self) -> SimEvent {
SimEvent::TorusUpdated {
center: self.galaxy.center.to_array(),
major_radius: self.galaxy.shape.major_radius,
minor_radius: self.galaxy.shape.minor_radius,
}
}
}
pub struct World {
pub galaxies: HashMap<GalaxyId, GalaxyState>,
}
impl World {
pub fn new() -> Self {
Self { galaxies: HashMap::new() }
}
pub fn list_galaxies(&self) -> Vec<GalaxyInfo> {
self.galaxies.values().map(GalaxyState::info).collect()
}
/// Update the galaxy's torus shape and broadcast `TorusUpdated`. Caller
/// is responsible for validating `shape` first (`GalaxyShape::validate`).
/// Returns the updated info, or `None` if the galaxy doesn't exist.
pub fn resize_galaxy(&mut self, gid: GalaxyId, shape: GalaxyShape) -> Option<GalaxyInfo> {
let g = self.galaxies.get_mut(&gid)?;
g.galaxy.shape = shape;
g.emit(g.torus_event());
Some(g.info())
}
}
impl Default for World {
fn default() -> Self {
Self::new()
}
}

View File

@@ -0,0 +1,14 @@
[package]
name = "sophia-store"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
authors.workspace = true
[dependencies]
sophia-core = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
serde = { workspace = true }

View File

@@ -0,0 +1,7 @@
//! Persistence: sled-backed event log + snapshot.
//!
//! See `docs/system-analysis.md` §13.3 (event-sourced + graph read model).
//! Per the doc: "the event log == the canonical memory store" — anything
//! memorable is persisted; transient state is not.
//!
//! Stage 0: stub. Real content arrives in Stage 6.