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:
24
crates/sophia-bin/Cargo.toml
Normal file
24
crates/sophia-bin/Cargo.toml
Normal 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 }
|
||||
133
crates/sophia-bin/src/main.rs
Normal file
133
crates/sophia-bin/src/main.rs
Normal 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
|
||||
}
|
||||
13
crates/sophia-core/Cargo.toml
Normal file
13
crates/sophia-core/Cargo.toml
Normal 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 }
|
||||
104
crates/sophia-core/src/dto.rs
Normal file
104
crates/sophia-core/src/dto.rs
Normal 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),
|
||||
}
|
||||
54
crates/sophia-core/src/engram.rs
Normal file
54
crates/sophia-core/src/engram.rs
Normal 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>,
|
||||
}
|
||||
77
crates/sophia-core/src/galaxy.rs
Normal file
77
crates/sophia-core/src/galaxy.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
50
crates/sophia-core/src/ids.rs
Normal file
50
crates/sophia-core/src/ids.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
23
crates/sophia-core/src/lib.rs
Normal file
23
crates/sophia-core/src/lib.rs
Normal 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;
|
||||
26
crates/sophia-core/src/manifest.rs
Normal file
26
crates/sophia-core/src/manifest.rs
Normal 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}…")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
35
crates/sophia-core/src/slate.rs
Normal file
35
crates/sophia-core/src/slate.rs
Normal 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
|
||||
}
|
||||
}
|
||||
25
crates/sophia-core/src/synapse.rs
Normal file
25
crates/sophia-core/src/synapse.rs
Normal 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) }
|
||||
}
|
||||
19
crates/sophia-llm/Cargo.toml
Normal file
19
crates/sophia-llm/Cargo.toml
Normal 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 }
|
||||
49
crates/sophia-llm/src/budget.rs
Normal file
49
crates/sophia-llm/src/budget.rs
Normal 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 }
|
||||
}
|
||||
}
|
||||
229
crates/sophia-llm/src/client.rs
Normal file
229
crates/sophia-llm/src/client.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
9
crates/sophia-llm/src/lib.rs
Normal file
9
crates/sophia-llm/src/lib.rs
Normal 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};
|
||||
25
crates/sophia-server/Cargo.toml
Normal file
25
crates/sophia-server/Cargo.toml
Normal 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 }
|
||||
241
crates/sophia-server/src/lib.rs
Normal file
241
crates/sophia-server/src/lib.rs
Normal 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()
|
||||
}
|
||||
108
crates/sophia-server/src/ws.rs
Normal file
108
crates/sophia-server/src/ws.rs
Normal 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()
|
||||
}
|
||||
20
crates/sophia-sim/Cargo.toml
Normal file
20
crates/sophia-sim/Cargo.toml
Normal 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 }
|
||||
588
crates/sophia-sim/src/handle.rs
Normal file
588
crates/sophia-sim/src/handle.rs
Normal 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.3–0.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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
42
crates/sophia-sim/src/index.rs
Normal file
42
crates/sophia-sim/src/index.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
12
crates/sophia-sim/src/lib.rs
Normal file
12
crates/sophia-sim/src/lib.rs
Normal 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};
|
||||
151
crates/sophia-sim/src/physics.rs
Normal file
151
crates/sophia-sim/src/physics.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
90
crates/sophia-sim/src/scheduler.rs
Normal file
90
crates/sophia-sim/src/scheduler.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
153
crates/sophia-sim/src/world.rs
Normal file
153
crates/sophia-sim/src/world.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
14
crates/sophia-store/Cargo.toml
Normal file
14
crates/sophia-store/Cargo.toml
Normal 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 }
|
||||
7
crates/sophia-store/src/lib.rs
Normal file
7
crates/sophia-store/src/lib.rs
Normal 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.
|
||||
Reference in New Issue
Block a user