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,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()
}
}