Implement Sophia MVP stages 4-7 (introspection, queries, persistence, polish)

Brings the system from "engrams cluster + form synapses" to a complete
end-to-end demo: ingest text, watch it cluster, ask questions, restart
with state intact.

- Stage 4: birth introspection (taxonomy/goals/open_questions via LLM,
  bounded by the global parallel-op semaphore), per-engram memory log,
  click-to-inspect side panel.
- Stage 5: queries as conversations. POST /api/galaxy/:id/query embeds
  the question, materializes a pinned Query-Engram at the donut center,
  runs broadcast retrieval (global cosine scan + 1-hop synaptic
  expansion with attenuation) and fans out responder LLM calls. The
  integrator runs every 2s on accumulated snippets and streams the
  refining answer back over SSE; responders briefly transition to
  Conversing on the WS bus so the right dots light up.
- Stage 6: snapshot persistence. sled-backed store keyed by galaxy id,
  JSON-encoded values (bincode chokes on internally-tagged enums like
  Manifest/MemoryKind), 60s periodic snapshot task, hydrate-on-boot,
  DELETE /api/galaxy/:id wired through. State survives kill -9.
- Stage 7: HUD additions (sim ticks/sec, LLM queue depth, FPS) via a
  new GET /api/stats polled at 1Hz. `sophia demo` subcommand boots the
  server then auto-ingests a 50-paragraph corpus baked into the binary
  with include_str!. README quickstart added.

Token caps for query_responder/integrator bumped (gemma-4-e4b is a
thinking model — output budget must cover hidden reasoning + visible
answer, otherwise content comes back empty). Pinned engrams skip
physics; their tick scheduling is also skipped at materialization so
they stay perfectly still at the donut center.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-28 11:53:11 +02:00
parent 8688f632bf
commit bae084cd76
36 changed files with 3137 additions and 58 deletions

View File

@@ -12,8 +12,10 @@ use tracing::warn;
use sophia_core::{
Engram, EngramDetail, EngramId, EngramSnapshot, EngramState, GalaxyId, GalaxyInfo, GalaxyShape,
Manifest, PositionFrame, SimEvent, Slate, SynapseDto, Vec3,
GalaxySnapshot, Introspection, Manifest, Memory, MemoryKind, PositionFrame, QueryId,
QueryStatus, SimEvent, Slate, SynapseDto, Vec3, MAX_MEMORIES,
};
use std::collections::{HashMap, HashSet, VecDeque};
/// 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.
@@ -70,6 +72,23 @@ const GRAVITY_MAX_ACC: f32 = 30.0;
/// than the gravity threshold so weak co-residence doesn't link everyone.
const SYNAPSE_THRESHOLD: f32 = 0.62;
// ---- Stage 5: query broadcast tuning ----
/// Minimum cosine similarity for an engram to qualify as a Phase A responder.
/// Tuned for `nomic-embed-text-v1.5`: directly relevant text typically scores
/// 0.55+, weakly related 0.350.50, unrelated 0.30 or below.
const QUERY_PHASE_A_THRESHOLD: f32 = 0.45;
/// Synapse weight cutoff for Phase B 1-hop expansion. A synapse weaker than
/// this isn't strong enough evidence to drag its endpoint into the conversation.
const QUERY_PHASE_B_SYNAPSE_THRESHOLD: f32 = 0.55;
/// Fraction of `max_responders` reserved for Phase A (spatial). Phase B fills
/// whatever's left so synaptic expansion always gets at least *some* slots.
const QUERY_PHASE_A_FRAC: f32 = 0.75;
/// Per-responder duration of the "lit up" Conversing visual on the WS bus.
/// The orchestrator does NOT explicitly turn the responder back to Idle —
/// instead it schedules a deferred event after this delay, keeping the sim
/// authoritative for the transition (no client-side timers required).
const RESPONDER_LIGHTUP_MS: u64 = 4_000;
#[derive(Debug, Error)]
pub enum SimError {
#[error("simulation has shut down")]
@@ -104,6 +123,12 @@ enum SimCmd {
engram: EngramId,
reply: oneshot::Sender<Result<Option<EngramDetail>, SimError>>,
},
UpdateIntrospection {
galaxy: GalaxyId,
engram: EngramId,
introspection: Introspection,
reply: oneshot::Sender<Result<(), SimError>>,
},
Subscribe {
galaxy: GalaxyId,
reply: oneshot::Sender<Result<SubscribeReply, SimError>>,
@@ -113,6 +138,107 @@ enum SimCmd {
shape: GalaxyShape,
reply: oneshot::Sender<Result<GalaxyInfo, SimError>>,
},
/// Stage 5: materialize a pinned Query-Engram at the galaxy center.
/// Returns the new engram's id so the orchestrator can refer to it.
StartQueryEngram {
galaxy: GalaxyId,
query: QueryId,
text: String,
slate: Slate,
reply: oneshot::Sender<Result<EngramId, SimError>>,
},
/// Stage 5: run Phase A (cosine scan) + Phase B (1-hop synaptic) and
/// return the chosen responders sorted by score (high→low). Capped at
/// `max_responders`.
BroadcastQuery {
galaxy: GalaxyId,
query_engram: EngramId,
max_responders: usize,
reply: oneshot::Sender<Result<Vec<(EngramId, f32)>, SimError>>,
},
/// Stage 5: fetch the source text for a responder so the orchestrator
/// can build the per-responder LLM prompt. Returns `None` for synthetic
/// engrams (no manifest).
GetEngramText {
galaxy: GalaxyId,
engram: EngramId,
reply: oneshot::Sender<Result<Option<String>, SimError>>,
},
/// Stage 5: a responder produced a snippet for a query. Append a memory
/// on the responder, briefly flip its state to `Conversing` (auto-reverts
/// after `RESPONDER_LIGHTUP_MS`).
RecordQueryParticipation {
galaxy: GalaxyId,
query: QueryId,
engram: EngramId,
snippet: String,
reply: oneshot::Sender<Result<(), SimError>>,
},
/// Stage 5: publish a new running answer over the WS bus. The orchestrator
/// also pushes to its per-query SSE channel separately — this event is
/// only for clients that want to subscribe over the global galaxy bus.
PublishQueryAnswer {
galaxy: GalaxyId,
query: QueryId,
version: u32,
status: QueryStatus,
answer: String,
reply: oneshot::Sender<Result<(), SimError>>,
},
/// Stage 5: query is done. Transition the Query-Engram to `Memorize`,
/// store the final answer in its memory log, emit `QueryFinished`.
FinishQuery {
galaxy: GalaxyId,
query: QueryId,
query_engram: EngramId,
final_answer: String,
responder_count: u32,
reply: oneshot::Sender<Result<(), SimError>>,
},
/// Stage 6: snapshot every galaxy to a serializable form. Used by the
/// periodic snapshot task in the bin to write to the sled store. Pinned
/// (query) engrams are excluded — they're ephemeral.
SnapshotAll {
reply: oneshot::Sender<Vec<GalaxySnapshot>>,
},
/// Stage 6: re-create a galaxy in the world from a stored snapshot.
/// Re-uses every engram's stored `instance_idx` so the dense slot table
/// is identical to pre-shutdown. Re-schedules `BroadcastFrame`,
/// `RebuildIndex`, and one `EngramTick` per (non-pinned) engram so motion
/// resumes immediately. Returns `InvalidShape` if the snapshot's shape
/// fails validation.
HydrateGalaxy {
snapshot: GalaxySnapshot,
reply: oneshot::Sender<Result<GalaxyInfo, SimError>>,
},
/// Stage 6: remove a galaxy from the world. Caller is responsible for
/// also removing its on-disk snapshot (see `Store::delete_galaxy`).
DeleteGalaxy {
galaxy: GalaxyId,
reply: oneshot::Sender<Result<(), SimError>>,
},
/// Stage 7: cheap stats snapshot for the HUD. Returns total events
/// processed per galaxy in the last second + total engram count.
/// Polled at 1 Hz from the browser.
GetStats {
reply: oneshot::Sender<SimStats>,
},
}
/// Lightweight stats payload returned by `SimHandle::stats`. Used by the
/// HUD's "events/sec" + "engrams" counters. Numbers are best-effort
/// snapshots, not exact (the sim counts as it processes; the read happens
/// asynchronously).
#[derive(Debug, Clone, Default)]
pub struct SimStats {
/// Sum of ticks across all galaxies in the last 1-second window.
pub ticks_per_sec: u32,
/// Sum of all non-pinned engrams across galaxies.
pub engrams_total: u32,
/// Number of live galaxies.
pub galaxies: u32,
/// Sum of all synapses across galaxies.
pub synapses_total: u32,
}
#[derive(Clone)]
@@ -165,6 +291,22 @@ impl SimHandle {
rx.await.map_err(|_| SimError::Shutdown)?
}
/// Apply async-LLM-generated introspection to an existing engram. Called
/// from the server after a background introspection task completes.
pub async fn update_introspection(
&self,
galaxy: GalaxyId,
engram: EngramId,
introspection: Introspection,
) -> Result<(), SimError> {
let (reply, rx) = oneshot::channel();
self.tx
.send(SimCmd::UpdateIntrospection { galaxy, engram, introspection, 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(
@@ -190,6 +332,151 @@ impl SimHandle {
.map_err(|_| SimError::Shutdown)?;
rx.await.map_err(|_| SimError::Shutdown)?
}
/// Stage 5: materialize a Query-Engram at the galaxy center.
pub async fn start_query_engram(
&self,
galaxy: GalaxyId,
query: QueryId,
text: String,
slate: Slate,
) -> Result<EngramId, SimError> {
let (reply, rx) = oneshot::channel();
self.tx
.send(SimCmd::StartQueryEngram { galaxy, query, text, slate, reply })
.await
.map_err(|_| SimError::Shutdown)?;
rx.await.map_err(|_| SimError::Shutdown)?
}
/// Stage 5: select up to `max_responders` engrams whose slates resonate
/// with the query, optionally extending via 1-hop synaptic neighbours.
pub async fn broadcast_query(
&self,
galaxy: GalaxyId,
query_engram: EngramId,
max_responders: usize,
) -> Result<Vec<(EngramId, f32)>, SimError> {
let (reply, rx) = oneshot::channel();
self.tx
.send(SimCmd::BroadcastQuery { galaxy, query_engram, max_responders, reply })
.await
.map_err(|_| SimError::Shutdown)?;
rx.await.map_err(|_| SimError::Shutdown)?
}
/// Stage 5: fetch the source text of an engram (for building the
/// per-responder LLM prompt). Returns `None` for synthetic engrams.
pub async fn get_engram_text(
&self,
galaxy: GalaxyId,
engram: EngramId,
) -> Result<Option<String>, SimError> {
let (reply, rx) = oneshot::channel();
self.tx
.send(SimCmd::GetEngramText { galaxy, engram, reply })
.await
.map_err(|_| SimError::Shutdown)?;
rx.await.map_err(|_| SimError::Shutdown)?
}
/// Stage 5: record a responder's contribution + light it up.
pub async fn record_query_participation(
&self,
galaxy: GalaxyId,
query: QueryId,
engram: EngramId,
snippet: String,
) -> Result<(), SimError> {
let (reply, rx) = oneshot::channel();
self.tx
.send(SimCmd::RecordQueryParticipation { galaxy, query, engram, snippet, reply })
.await
.map_err(|_| SimError::Shutdown)?;
rx.await.map_err(|_| SimError::Shutdown)?
}
/// Stage 5: publish an updated running answer to WS subscribers.
pub async fn publish_query_answer(
&self,
galaxy: GalaxyId,
query: QueryId,
version: u32,
status: QueryStatus,
answer: String,
) -> Result<(), SimError> {
let (reply, rx) = oneshot::channel();
self.tx
.send(SimCmd::PublishQueryAnswer { galaxy, query, version, status, answer, reply })
.await
.map_err(|_| SimError::Shutdown)?;
rx.await.map_err(|_| SimError::Shutdown)?
}
/// Stage 5: terminal step — store the final answer in the Query-Engram's
/// memory log, transition to Memorize, emit QueryFinished.
pub async fn finish_query(
&self,
galaxy: GalaxyId,
query: QueryId,
query_engram: EngramId,
final_answer: String,
responder_count: u32,
) -> Result<(), SimError> {
let (reply, rx) = oneshot::channel();
self.tx
.send(SimCmd::FinishQuery {
galaxy,
query,
query_engram,
final_answer,
responder_count,
reply,
})
.await
.map_err(|_| SimError::Shutdown)?;
rx.await.map_err(|_| SimError::Shutdown)?
}
/// Stage 6: snapshot every galaxy. Used by the periodic snapshot task.
pub async fn snapshot_all(&self) -> Result<Vec<GalaxySnapshot>, SimError> {
let (reply, rx) = oneshot::channel();
self.tx
.send(SimCmd::SnapshotAll { reply })
.await
.map_err(|_| SimError::Shutdown)?;
rx.await.map_err(|_| SimError::Shutdown)
}
/// Stage 6: re-create a galaxy from a stored snapshot.
pub async fn hydrate_galaxy(&self, snapshot: GalaxySnapshot) -> Result<GalaxyInfo, SimError> {
let (reply, rx) = oneshot::channel();
self.tx
.send(SimCmd::HydrateGalaxy { snapshot, reply })
.await
.map_err(|_| SimError::Shutdown)?;
rx.await.map_err(|_| SimError::Shutdown)?
}
/// Stage 6: drop a galaxy from the world.
pub async fn delete_galaxy(&self, galaxy: GalaxyId) -> Result<(), SimError> {
let (reply, rx) = oneshot::channel();
self.tx
.send(SimCmd::DeleteGalaxy { galaxy, reply })
.await
.map_err(|_| SimError::Shutdown)?;
rx.await.map_err(|_| SimError::Shutdown)?
}
/// Stage 7: snapshot of recent activity for the HUD. Cheap — never
/// blocks the sim; falls back to defaults if the sim is shutting down.
pub async fn stats(&self) -> SimStats {
let (reply, rx) = oneshot::channel();
if self.tx.send(SimCmd::GetStats { reply }).await.is_err() {
return SimStats::default();
}
rx.await.unwrap_or_default()
}
}
/// Spawn the simulation task. `default_shape` is used for any new galaxy
@@ -203,10 +490,22 @@ pub fn spawn_sim(default_shape: GalaxyShape) -> SimHandle {
let mut indexes: std::collections::HashMap<GalaxyId, KiddoIndex> = Default::default();
let mut rng = SmallRng::seed_from_u64(0xC0DE_5071);
let started = Instant::now();
// Stage 7 stats: rolling 1-second tick counter. We bump
// `tick_window_count` for every event we drain, and roll it into
// `ticks_per_sec` once a second has elapsed since `tick_window_start`.
let mut tick_window_start = Instant::now();
let mut tick_window_count: u32 = 0;
let mut ticks_per_sec: u32 = 0;
loop {
// Pick whichever happens first: a new command or the next due event.
let now = Instant::now();
// Roll the tick window if a second has elapsed.
if now.duration_since(tick_window_start) >= Duration::from_secs(1) {
ticks_per_sec = tick_window_count;
tick_window_count = 0;
tick_window_start = now;
}
let next_at = scheduler.next_at();
let timeout = match next_at {
Some(at) => at.saturating_duration_since(now),
@@ -215,13 +514,17 @@ pub fn spawn_sim(default_shape: GalaxyShape) -> SimHandle {
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);
handle_cmd(
cmd, &mut world, &mut scheduler, &mut indexes,
&mut rng, default_shape, started, ticks_per_sec,
);
}
_ = 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);
tick_window_count = tick_window_count.saturating_add(1);
}
}
}
@@ -230,6 +533,7 @@ pub fn spawn_sim(default_shape: GalaxyShape) -> SimHandle {
SimHandle { tx }
}
#[allow(clippy::too_many_arguments)]
fn handle_cmd(
cmd: SimCmd,
world: &mut World,
@@ -237,6 +541,8 @@ fn handle_cmd(
indexes: &mut std::collections::HashMap<GalaxyId, KiddoIndex>,
_rng: &mut SmallRng,
default_shape: GalaxyShape,
started: Instant,
ticks_per_sec: u32,
) {
match cmd {
SimCmd::CreateGalaxy { name, reply } => {
@@ -280,6 +586,9 @@ fn handle_cmd(
manifest: e.manifest.clone(),
slate_dim: e.slate.as_ref().map(|s| s.dim()),
slate_norm: e.slate.as_ref().map(|s| s.norm()),
introspection: e.introspection.clone(),
memories: e.memories.iter().cloned().collect(),
pinned: e.pinned,
})
});
match detail {
@@ -291,6 +600,19 @@ fn handle_cmd(
}
}
}
SimCmd::UpdateIntrospection { galaxy, engram, introspection, reply } => {
let res = world
.galaxies
.get_mut(&galaxy)
.ok_or(SimError::UnknownGalaxy)
.and_then(|g| {
let e = g.engrams.get_mut(&engram).ok_or(SimError::UnknownGalaxy)?;
e.introspection = introspection;
push_memory(&mut e.memories, ms_since(started), MemoryKind::Introspected);
Ok(())
});
let _ = reply.send(res);
}
SimCmd::Subscribe { galaxy, reply } => {
let res = world
.galaxies
@@ -308,6 +630,186 @@ fn handle_cmd(
};
let _ = reply.send(res);
}
SimCmd::StartQueryEngram { galaxy, query, text, slate, reply } => {
let res = world
.galaxies
.get_mut(&galaxy)
.ok_or(SimError::UnknownGalaxy)
.map(|g| materialize_query_engram(g, galaxy, query, text, slate, started));
let _ = reply.send(res);
}
SimCmd::BroadcastQuery { galaxy, query_engram, max_responders, reply } => {
let res = world
.galaxies
.get(&galaxy)
.ok_or(SimError::UnknownGalaxy)
.map(|g| compute_query_responders(g, query_engram, max_responders));
let _ = reply.send(res);
}
SimCmd::GetEngramText { galaxy, engram, reply } => {
let res = world
.galaxies
.get(&galaxy)
.ok_or(SimError::UnknownGalaxy)
.map(|g| {
g.engrams.get(&engram).and_then(|e| {
e.manifest
.as_ref()
.map(|Manifest::Text { content }| content.clone())
})
});
let _ = reply.send(res);
}
SimCmd::RecordQueryParticipation { galaxy, query, engram, snippet, reply } => {
let res = world
.galaxies
.get_mut(&galaxy)
.ok_or(SimError::UnknownGalaxy)
.map(|g| {
if let Some(e) = g.engrams.get_mut(&engram) {
push_memory(
&mut e.memories,
ms_since(started),
MemoryKind::QueryParticipated { query, snippet },
);
e.state = EngramState::Conversing;
g.emit(SimEvent::EngramStateChanged {
id: engram,
state: EngramState::Conversing,
});
// Schedule the auto-revert. If the engram gets re-lit
// for a different query before this fires, the
// `expected` guard will skip the revert.
scheduler.schedule(
Instant::now() + Duration::from_millis(RESPONDER_LIGHTUP_MS),
Event::RevertState {
galaxy,
engram,
expected: EngramState::Conversing,
revert_to: EngramState::Idle,
},
);
}
});
let _ = reply.send(res);
}
SimCmd::PublishQueryAnswer { galaxy, query, version, status, answer, reply } => {
let res = world
.galaxies
.get(&galaxy)
.ok_or(SimError::UnknownGalaxy)
.map(|g| {
g.emit(SimEvent::QueryAnswerUpdated { query, version, status, answer });
});
let _ = reply.send(res);
}
SimCmd::FinishQuery {
galaxy, query, query_engram, final_answer, responder_count, reply,
} => {
let res = world
.galaxies
.get_mut(&galaxy)
.ok_or(SimError::UnknownGalaxy)
.map(|g| {
if let Some(e) = g.engrams.get_mut(&query_engram) {
e.state = EngramState::Memorize;
push_memory(
&mut e.memories,
ms_since(started),
MemoryKind::QueryAnswered {
answer: final_answer,
responder_count,
},
);
g.emit(SimEvent::EngramStateChanged {
id: query_engram,
state: EngramState::Memorize,
});
}
g.emit(SimEvent::QueryFinished {
query,
status: QueryStatus::Done,
responder_count,
});
});
let _ = reply.send(res);
}
SimCmd::SnapshotAll { reply } => {
let snaps = world
.galaxies
.values()
.map(|g| g.to_snapshot())
.collect();
let _ = reply.send(snaps);
}
SimCmd::HydrateGalaxy { snapshot, reply } => {
let res = match snapshot.galaxy.shape.validate() {
Err(msg) => Err(SimError::InvalidShape(msg)),
Ok(()) => {
let id = snapshot.galaxy.id;
let state = GalaxyState::from_snapshot(snapshot);
let info = state.info();
state.emit(state.torus_event());
// Re-schedule the periodic galaxy events first so they
// start firing at their normal cadence.
let now = Instant::now();
scheduler.schedule(now + FRAME_INTERVAL, Event::BroadcastFrame { galaxy: id });
scheduler.schedule(now + REBUILD_INTERVAL, Event::RebuildIndex { galaxy: id });
// One EngramTick per non-pinned engram. Stagger them
// across one tick interval so they don't all fire
// simultaneously and clobber the scheduler heap on
// boot — also gives the spatial index time to rebuild
// before gravity kicks in.
let n = state.engrams.len().max(1) as u64;
let stagger_step = TICK_INTERVAL.as_micros() as u64 / n.max(1);
for (i, engram_id) in state
.engrams
.keys()
.copied()
.enumerate()
{
let offset = Duration::from_micros(stagger_step * i as u64);
scheduler.schedule(
now + TICK_INTERVAL + offset,
Event::EngramTick { galaxy: id, engram: engram_id },
);
}
indexes.insert(id, KiddoIndex::empty());
world.galaxies.insert(id, state);
Ok(info)
}
};
let _ = reply.send(res);
}
SimCmd::DeleteGalaxy { galaxy, reply } => {
let res = if world.galaxies.remove(&galaxy).is_some() {
indexes.remove(&galaxy);
Ok(())
} else {
Err(SimError::UnknownGalaxy)
};
let _ = reply.send(res);
}
SimCmd::GetStats { reply } => {
let mut engrams_total: u32 = 0;
let mut synapses_total: u32 = 0;
for g in world.galaxies.values() {
// Pinned (query) engrams are excluded from the public count
// so the HUD doesn't blink during queries.
engrams_total += g
.engrams
.values()
.filter(|e| !e.pinned)
.count() as u32;
synapses_total += g.synapses.len() as u32;
}
let _ = reply.send(SimStats {
ticks_per_sec,
engrams_total,
galaxies: world.galaxies.len() as u32,
synapses_total,
});
}
}
}
@@ -387,6 +889,56 @@ fn ingest_galaxy(
Ok(ids)
}
/// Materialize a Query-Engram (Stage 5): pinned at the galaxy center with the
/// question text + slate, state `Searching`. Returns the new engram's id. No
/// `EngramTick` is scheduled — pinned engrams don't move and don't form
/// spontaneous synapses; their behaviour is driven entirely by the orchestrator.
fn materialize_query_engram(
g: &mut GalaxyState,
_galaxy: GalaxyId,
_query: QueryId,
text: String,
slate: Slate,
started: Instant,
) -> EngramId {
let id = EngramId::new();
let instance_idx = g.slot_to_id.len() as u32;
let position = g.galaxy.center;
let mut memories = VecDeque::new();
push_memory(&mut memories, ms_since(started), MemoryKind::Born);
let engram = Engram {
id,
instance_idx,
position,
velocity: Vec3::ZERO,
size: 1.0,
state: EngramState::Searching,
age: 0,
manifest: Some(Manifest::Text { content: text.clone() }),
slate: Some(slate),
introspection: Introspection::default(),
memories,
pinned: true,
};
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 });
g.emit(SimEvent::QueryStarted {
query: _query,
engram: id,
position: position.to_array(),
text,
});
id
}
/// 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`.
@@ -397,6 +949,7 @@ fn materialize_engram(
payload: SpawnPayload,
scheduler: &mut Scheduler,
rng: &mut SmallRng,
started: Instant,
) {
let instance_idx = g.slot_to_id.len() as u32;
let birth = g.galaxy.shape.birth_point(g.galaxy.center);
@@ -406,6 +959,8 @@ fn materialize_engram(
SpawnPayload::Synthetic => (None, None),
SpawnPayload::Manifested { manifest, slate } => (Some(manifest), Some(slate)),
};
let mut memories = VecDeque::new();
push_memory(&mut memories, ms_since(started), MemoryKind::Born);
let engram = Engram {
id,
instance_idx,
@@ -416,6 +971,9 @@ fn materialize_engram(
age: 0,
manifest,
slate,
introspection: Introspection::default(),
memories,
pinned: false,
};
g.slot_to_id.push(id);
g.engrams.insert(id, engram.clone());
@@ -433,6 +991,19 @@ fn materialize_engram(
);
}
/// Append a memory to the front-bounded VecDeque, evicting the oldest entry
/// once `MAX_MEMORIES` is reached.
fn push_memory(memories: &mut VecDeque<Memory>, at_ms: u32, kind: MemoryKind) {
while memories.len() >= MAX_MEMORIES {
memories.pop_front();
}
memories.push_back(Memory { at_ms, kind });
}
fn ms_since(started: Instant) -> u32 {
started.elapsed().as_millis() as u32
}
/// 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).
@@ -492,6 +1063,97 @@ fn compute_gravity_and_candidates(
(acc, candidates)
}
/// Stage 5 broadcast retrieval. Two passes:
///
/// **Phase A — global cosine scan.** Score every non-pinned engram's slate
/// against the query, keep those above `QUERY_PHASE_A_THRESHOLD`, take the
/// top `QUERY_PHASE_A_FRAC * max_responders` by score.
///
/// **Phase B — 1-hop synaptic expansion.** For each Phase A pick, walk its
/// synapses and add any neighbour we haven't already chosen. The neighbour's
/// effective score is `cosine_to_query * synapse_weight` (synaptic
/// attenuation per the system-analysis doc). Fill the remaining
/// `max_responders - phase_a` slots in score-sorted order.
///
/// We do NOT use the spatial kiddo index here. Query-Engrams sit at the
/// galaxy center while normal engrams cluster in the tube ~major_radius
/// away — a single radius doesn't cover both. A linear scan over engrams
/// is fine at MVP scale (a few thousand) and avoids per-query index
/// rebuilds. The kiddo index stays where it earns its keep: per-tick
/// gravity for engrams already in the tube.
fn compute_query_responders(
g: &GalaxyState,
query_engram: EngramId,
max_responders: usize,
) -> Vec<(EngramId, f32)> {
if max_responders == 0 {
return Vec::new();
}
let Some(q) = g.engrams.get(&query_engram) else { return Vec::new(); };
let Some(q_slate) = q.slate.as_ref() else { return Vec::new(); };
// Phase A.
let mut scored: Vec<(EngramId, f32)> = g
.engrams
.values()
.filter(|e| e.id != query_engram && !e.pinned)
.filter_map(|e| {
let s = e.slate.as_ref()?;
let cos = q_slate.cosine(s);
(cos >= QUERY_PHASE_A_THRESHOLD).then_some((e.id, cos))
})
.collect();
scored.sort_by(|a, b| {
b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)
});
let phase_a_budget = ((max_responders as f32) * QUERY_PHASE_A_FRAC).ceil() as usize;
let phase_a_budget = phase_a_budget.min(max_responders).max(1);
let mut chosen: Vec<(EngramId, f32)> = scored.into_iter().take(phase_a_budget).collect();
let mut chosen_set: HashSet<EngramId> =
chosen.iter().map(|(id, _)| *id).collect();
// Always exclude the query-engram itself from synaptic expansion just in
// case some legacy synapse pointed at it.
chosen_set.insert(query_engram);
// Phase B: build adjacency once, then walk.
let phase_b_budget = max_responders.saturating_sub(chosen.len());
if phase_b_budget == 0 || g.synapses.is_empty() {
return chosen;
}
let mut adjacency: HashMap<EngramId, Vec<(EngramId, f32)>> =
HashMap::with_capacity(g.engrams.len());
for syn in g.synapses.values() {
if syn.weight < QUERY_PHASE_B_SYNAPSE_THRESHOLD {
continue;
}
adjacency.entry(syn.a).or_default().push((syn.b, syn.weight));
adjacency.entry(syn.b).or_default().push((syn.a, syn.weight));
}
let mut additions: Vec<(EngramId, f32)> = Vec::new();
for (seed_id, _) in chosen.iter() {
if let Some(neighbours) = adjacency.get(seed_id) {
for (neighbour, weight) in neighbours {
if !chosen_set.insert(*neighbour) {
continue;
}
let Some(n) = g.engrams.get(neighbour) else { continue; };
if n.pinned {
continue;
}
let Some(n_slate) = n.slate.as_ref() else { continue; };
let effective = q_slate.cosine(n_slate) * weight;
additions.push((*neighbour, effective));
}
}
}
// Take the strongest Phase B additions to fill remaining slots.
additions.sort_by(|a, b| {
b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)
});
chosen.extend(additions.into_iter().take(phase_b_budget));
chosen
}
/// 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 {
@@ -515,7 +1177,7 @@ fn handle_event(
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);
materialize_engram(g, galaxy, id, payload, scheduler, rng, started);
}
Event::EngramTick { galaxy, engram } => {
let Some(g) = world.galaxies.get_mut(&galaxy) else { return };
@@ -535,11 +1197,27 @@ fn handle_event(
return;
}
// After the tick, materialise any qualifying synapses and emit
// events for the newly-formed ones.
// After the tick, materialise any qualifying synapses, emit
// events for the newly-formed ones, and append a memory entry
// on both endpoints so the inspector can show them.
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) });
let now_ms = ms_since(started);
if let Some(e_a) = g.engrams.get_mut(&syn.a) {
push_memory(
&mut e_a.memories,
now_ms,
MemoryKind::SynapseFormed { with: syn.b, weight },
);
}
if let Some(e_b) = g.engrams.get_mut(&syn.b) {
push_memory(
&mut e_b.memories,
now_ms,
MemoryKind::SynapseFormed { with: syn.a, weight },
);
}
}
}
@@ -568,11 +1246,14 @@ fn handle_event(
}
Event::RebuildIndex { galaxy } => {
// Index rebuild only — no bbox recompute (the torus is fixed-size,
// resized only via SimCmd::Resize).
// resized only via SimCmd::Resize). Pinned (query) engrams are
// skipped: the index drives gravity + per-tick neighbour search,
// and queries don't participate in either.
if let Some(g) = world.galaxies.get(&galaxy) {
let points: Vec<(EngramId, [f32; 3])> = g
.engrams
.values()
.filter(|e| !e.pinned)
.map(|e| (e.id, e.position.to_array()))
.collect();
if let Some(idx) = indexes.get_mut(&galaxy) {
@@ -584,5 +1265,15 @@ fn handle_event(
Event::RebuildIndex { galaxy },
);
}
Event::RevertState { galaxy, engram, expected, revert_to } => {
if let Some(g) = world.galaxies.get_mut(&galaxy) {
if let Some(e) = g.engrams.get_mut(&engram) {
if e.state == expected {
e.state = revert_to;
g.emit(SimEvent::EngramStateChanged { id: engram, state: revert_to });
}
}
}
}
}
}

View File

@@ -9,4 +9,4 @@ mod physics;
mod scheduler;
mod world;
pub use handle::{spawn_sim, IngestItem, SimError, SimHandle};
pub use handle::{spawn_sim, IngestItem, SimError, SimHandle, SimStats};

View File

@@ -75,6 +75,14 @@ pub fn tick(
gravity_acc: Vec3,
rng: &mut SmallRng,
) {
// Pinned engrams (Stage 5 Query-Engrams) sit perfectly still at their
// birth position — skip the entire integration. Age still advances so
// memory timestamps and any future age-gated logic stay consistent.
if engram.pinned {
engram.age = engram.age.saturating_add(1);
return;
}
let curiosity_factor = (-(engram.age as f32) / CURIOSITY_TAU_TICKS).exp();
// Curiosity: random impulse, decaying with age.

View File

@@ -4,7 +4,7 @@ use std::cmp::Ordering;
use std::collections::BinaryHeap;
use std::time::Instant;
use sophia_core::{EngramId, GalaxyId, Manifest, Slate};
use sophia_core::{EngramId, EngramState, GalaxyId, Manifest, Slate};
/// Payload for a queued spawn. Synthetic seed engrams have no manifest/slate;
/// ingested engrams carry the pre-embedded text.
@@ -28,6 +28,19 @@ pub enum Event {
/// Recompute density-driven bbox + rebuild spatial index. Fires every
/// ~500 ms and reschedules itself.
RebuildIndex { galaxy: GalaxyId },
/// Stage 5: revert an engram's state (typically Conversing → Idle)
/// after a responder's light-up window expires. Fired once and not
/// rescheduled. The `expected` state guards against racing transitions
/// — only revert if the engram is still in the state the lit-up event
/// originally set (the orchestrator may light up the same engram twice
/// for two queries; we don't want the older revert to clobber a fresher
/// one).
RevertState {
galaxy: GalaxyId,
engram: EngramId,
expected: EngramState,
revert_to: EngramState,
},
}
#[derive(Debug)]

View File

@@ -4,7 +4,7 @@ use std::collections::{HashMap, HashSet};
use sophia_core::{
canonical_pair, Engram, EngramId, EngramSnapshot, Galaxy, GalaxyId, GalaxyInfo, GalaxyShape,
SimEvent, Synapse, SynapseDto, SynapseId,
GalaxySnapshot, SimEvent, Synapse, SynapseDto, SynapseId,
};
use tokio::sync::broadcast;
@@ -120,6 +120,82 @@ impl GalaxyState {
minor_radius: self.galaxy.shape.minor_radius,
}
}
/// Serialize the persistent half of this galaxy's state. Pinned engrams
/// (Stage 5 Query-Engrams) are dropped — queries are ephemeral and
/// shouldn't outlive the orchestrator that owns them. Synapses that
/// pointed at a pinned engram (none currently exist; future-proofing)
/// are also dropped.
pub fn to_snapshot(&self) -> GalaxySnapshot {
let engrams: Vec<Engram> = self
.slot_to_id
.iter()
.filter_map(|id| self.engrams.get(id))
.filter(|e| !e.pinned)
.cloned()
.collect();
let kept_ids: HashSet<EngramId> = engrams.iter().map(|e| e.id).collect();
let synapses: Vec<Synapse> = self
.synapses
.values()
.filter(|s| kept_ids.contains(&s.a) && kept_ids.contains(&s.b))
.cloned()
.collect();
GalaxySnapshot {
galaxy: self.galaxy.clone(),
engrams,
synapses,
}
}
/// Re-instantiate a galaxy from a stored snapshot. Re-uses each engram's
/// stored `instance_idx` so the dense slot table matches pre-shutdown
/// (the WS PositionFrame slot ordering depends on it). The caller (sim
/// loop) is responsible for re-scheduling per-engram ticks and the
/// recurring `BroadcastFrame` / `RebuildIndex` events.
pub fn from_snapshot(snap: GalaxySnapshot) -> Self {
let (bus, _) = broadcast::channel(BROADCAST_CAPACITY);
let mut engrams: HashMap<EngramId, Engram> = HashMap::with_capacity(snap.engrams.len());
let mut max_idx: u32 = 0;
for e in &snap.engrams {
if e.instance_idx + 1 > max_idx {
max_idx = e.instance_idx + 1;
}
}
// Build a dense slot_to_id keyed by the stored instance_idx. Any
// gaps (shouldn't happen but defensive) get a placeholder UUID that
// will simply never resolve to an engram in `engrams.get(...)`,
// keeping the indexing math correct.
let mut slot_to_id: Vec<EngramId> = vec![EngramId::default(); max_idx as usize];
for e in snap.engrams {
let idx = e.instance_idx as usize;
if idx < slot_to_id.len() {
slot_to_id[idx] = e.id;
}
engrams.insert(e.id, e);
}
let mut synapses: HashMap<SynapseId, Synapse> = HashMap::with_capacity(snap.synapses.len());
let mut synapse_pairs: HashSet<(EngramId, EngramId)> = HashSet::with_capacity(snap.synapses.len());
let mut synapse_count: HashMap<EngramId, usize> = HashMap::new();
for syn in snap.synapses {
let pair = canonical_pair(syn.a, syn.b);
if !synapse_pairs.insert(pair) {
continue;
}
*synapse_count.entry(pair.0).or_insert(0) += 1;
*synapse_count.entry(pair.1).or_insert(0) += 1;
synapses.insert(syn.id, syn);
}
Self {
galaxy: snap.galaxy,
engrams,
slot_to_id,
synapses,
synapse_pairs,
synapse_count,
bus,
}
}
}
pub struct World {