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:
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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user