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

@@ -15,10 +15,13 @@ sophia-core = { workspace = true }
sophia-server = { workspace = true }
sophia-sim = { workspace = true }
sophia-llm = { workspace = true }
sophia-store = { workspace = true }
anyhow = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
toml = { workspace = true }
reqwest = { workspace = true }

View File

@@ -1,19 +1,31 @@
//! Sophia binary. Reads `config.toml`, initializes tracing, starts the server.
//! Sophia binary. Reads `config.toml`, initializes tracing, opens the
//! persistence store, hydrates any saved galaxies into the sim, and starts
//! the server. A background task snapshots the sim to disk on a fixed
//! cadence (Stage 6).
//!
//! `sophia demo` (Stage 7) — same boot path, plus a background task that
//! waits for the server to be reachable then ingests a curated corpus
//! (compiled into the binary). Useful for screen recordings and first-run
//! demos: a single command takes you from cold boot to a populated galaxy.
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{Context, Result};
use serde::Deserialize;
use sophia_core::GalaxyShape;
use sophia_llm::{LmStudioClient, LmStudioConfig, TokenCapsAll};
use sophia_server::{serve, ServerConfig};
use sophia_sim::SimHandle;
use sophia_store::Store;
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
#[derive(Debug, Deserialize)]
struct Config {
server: ServerSection,
#[serde(default)]
storage: StorageSection,
lm_studio: LmStudioConfig,
#[allow(dead_code)] // wired in to LLM call sites in Stages 4+
token_caps: TokenCapsAll,
#[serde(default)]
galaxy_defaults: GalaxyDefaultsSection,
@@ -26,6 +38,21 @@ struct ServerSection {
static_dir: String,
}
#[derive(Debug, Deserialize)]
struct StorageSection {
data_dir: String,
snapshot_interval_secs: u64,
}
impl Default for StorageSection {
fn default() -> Self {
Self {
data_dir: "data".to_string(),
snapshot_interval_secs: 60,
}
}
}
#[derive(Debug, Deserialize)]
struct GalaxyDefaultsSection {
major_radius: f32,
@@ -92,11 +119,175 @@ fn load_config() -> Result<Config> {
Ok(cfg)
}
/// Hydrate the sim from disk. Returns the count of galaxies restored.
/// If the store is empty, materializes a fresh "default" galaxy and writes
/// the first snapshot so a subsequent kill+restart already finds it.
async fn hydrate_or_seed(sim: &SimHandle, store: &Store) -> Result<usize> {
let saved = store.list_galaxies().context("listing stored galaxies")?;
if saved.is_empty() {
let info = sim.create_galaxy("default".to_string()).await?;
tracing::info!("no saved galaxies — created default {:?}", info.id);
// Persist the empty galaxy now so the very next restart rehydrates
// (with the same id) instead of creating a fresh one — keeps the
// browser's bookmarked galaxy id stable across restarts.
let snaps = sim.snapshot_all().await?;
for snap in &snaps {
store.save_galaxy(snap)?;
}
return Ok(0);
}
let n = saved.len();
for snap in saved {
let id = snap.galaxy.id;
let engrams = snap.engrams.len();
let synapses = snap.synapses.len();
match sim.hydrate_galaxy(snap).await {
Ok(info) => {
tracing::info!(
"hydrated galaxy {:?} ({}, {} engrams, {} synapses)",
info.id,
info.name,
engrams,
synapses
);
}
Err(e) => {
tracing::warn!("hydration failed for {:?}: {e}", id);
}
}
}
Ok(n)
}
/// Stage 7: corpus to ingest when invoked as `sophia demo`. Compiled into
/// the binary so the command works from any cwd.
const DEMO_CORPUS: &str = include_str!("../../../assets/demo_corpus.txt");
/// Stage 7 demo task. Polls `/healthz` until the server is reachable, then
/// posts the curated corpus into the first galaxy. Logs progress; failures
/// are non-fatal — the server stays up either way.
fn spawn_demo_ingest(host: String, port: u16) {
tokio::spawn(async move {
let base = format!("http://{host}:{port}");
let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(60))
.build()
{
Ok(c) => c,
Err(e) => {
tracing::warn!("demo: reqwest client failed: {e}");
return;
}
};
// Wait for server reachable. Cap at 30 s so a misconfigured boot
// doesn't loop forever.
let deadline = std::time::Instant::now() + Duration::from_secs(30);
let mut reachable = false;
while std::time::Instant::now() < deadline {
if client.get(format!("{base}/healthz")).send().await.is_ok() {
reachable = true;
break;
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
if !reachable {
tracing::warn!("demo: server never became reachable, aborting ingest");
return;
}
// Pick the first galaxy (the auto-created or just-hydrated default).
let galaxies: Vec<serde_json::Value> = match client
.get(format!("{base}/api/galaxy"))
.send()
.await
.and_then(|r| r.error_for_status())
{
Ok(r) => match r.json().await {
Ok(g) => g,
Err(e) => {
tracing::warn!("demo: parsing galaxies failed: {e}");
return;
}
},
Err(e) => {
tracing::warn!("demo: listing galaxies failed: {e}");
return;
}
};
let Some(first) = galaxies.first() else {
tracing::warn!("demo: no galaxies to ingest into");
return;
};
let Some(gid) = first.get("id").and_then(|v| v.as_str()) else {
tracing::warn!("demo: galaxy entry missing id");
return;
};
let texts: Vec<&str> = DEMO_CORPUS
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.collect();
tracing::info!("demo: ingesting {} paragraphs into galaxy {}", texts.len(), gid);
let body = serde_json::json!({ "texts": texts });
match client
.post(format!("{base}/api/galaxy/{gid}/ingest"))
.json(&body)
.send()
.await
.and_then(|r| r.error_for_status())
{
Ok(_) => {
tracing::info!(
"demo: ingest succeeded — open {base} to watch the cluster form"
);
}
Err(e) => {
tracing::warn!("demo: ingest failed: {e}");
}
}
});
}
/// Background task: every `interval`, snapshot every galaxy and write to
/// disk. Errors are logged and ignored — we don't want a transient disk
/// hiccup to take down the simulation.
fn spawn_snapshot_task(sim: SimHandle, store: Store, interval: Duration) {
tokio::spawn(async move {
let mut ticker = tokio::time::interval(interval);
// First tick fires immediately by default; we want the first save
// *after* an interval has passed so boot doesn't double-write.
ticker.tick().await;
loop {
ticker.tick().await;
match sim.snapshot_all().await {
Ok(snaps) => {
for snap in &snaps {
if let Err(e) = store.save_galaxy(snap) {
tracing::warn!("snapshot save failed: {e}");
}
}
tracing::debug!("snapshot tick: persisted {} galaxies", snaps.len());
}
Err(e) => {
tracing::warn!("snapshot_all failed: {e}");
}
}
}
});
}
#[tokio::main]
async fn main() -> Result<()> {
init_tracing();
let cfg = load_config()?;
// Stage 7 CLI: only one subcommand right now (`demo`). Anything else
// boots the server normally.
let demo_mode = std::env::args().nth(1).as_deref() == Some("demo");
let server_cfg = ServerConfig {
host: cfg.server.host,
port: cfg.server.port,
@@ -125,9 +316,28 @@ async fn main() -> Result<()> {
default_shape.minor_radius
);
let sim = sophia_sim::spawn_sim(default_shape);
let info = sim.create_galaxy("default".to_string()).await?;
tracing::info!("default galaxy ready: id={:?} name={}", info.id, info.name);
let store = Store::open(&cfg.storage.data_dir)
.with_context(|| format!("opening sled store at {}", cfg.storage.data_dir))?;
tracing::info!("persistence store open at {}", cfg.storage.data_dir);
serve(server_cfg, sim, llm).await
let sim = sophia_sim::spawn_sim(default_shape);
let restored = hydrate_or_seed(&sim, &store).await?;
tracing::info!("hydrated {restored} galaxies from disk");
spawn_snapshot_task(
sim.clone(),
store.clone(),
Duration::from_secs(cfg.storage.snapshot_interval_secs.max(1)),
);
tracing::info!(
"snapshot task scheduled every {}s",
cfg.storage.snapshot_interval_secs
);
if demo_mode {
tracing::info!("demo mode: will auto-ingest curated corpus once server is reachable");
spawn_demo_ingest(server_cfg.host.clone(), server_cfg.port);
}
serve(server_cfg, sim, llm, cfg.token_caps, store).await
}

View File

@@ -10,6 +10,8 @@ use serde::{Deserialize, Serialize};
use crate::engram::EngramState;
use crate::ids::{EngramId, GalaxyId, SynapseId};
use crate::manifest::Manifest;
use crate::memory::{Introspection, Memory};
use crate::query::{QueryId, QueryStatus};
use crate::synapse::Synapse;
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -62,6 +64,10 @@ pub struct EngramDetail {
pub manifest: Option<Manifest>,
pub slate_dim: Option<usize>,
pub slate_norm: Option<f32>,
pub introspection: Introspection,
pub memories: Vec<Memory>,
/// True for Query-Engrams (Stage 5).
pub pinned: bool,
}
/// Position frame: a downsampled bundle of all engram positions at a moment in
@@ -97,6 +103,37 @@ pub enum SimEvent {
major_radius: f32,
minor_radius: f32,
},
/// An engram's lifecycle state changed (e.g., responder lit up while
/// answering a query, then returned to idle). Frontend uses this to
/// re-paint the engram's color in real time.
EngramStateChanged {
id: EngramId,
state: EngramState,
},
/// A query was started — a Query-Engram materialized at `position` with
/// the question text. Frontend uses this to register the query for the
/// answer panel + visual highlight.
QueryStarted {
query: QueryId,
engram: EngramId,
position: [f32; 3],
text: String,
},
/// The integrator produced a new running answer. `version` increments
/// monotonically per query; clients can ignore stale frames.
QueryAnswerUpdated {
query: QueryId,
version: u32,
status: QueryStatus,
answer: String,
},
/// Terminal event for a query. The Query-Engram has been moved to the
/// `Memorize` state and the SSE stream is closed.
QueryFinished {
query: QueryId,
status: QueryStatus,
responder_count: u32,
},
/// Position frame is sent over the wire as a binary frame; this variant
/// carries the in-process payload.
#[serde(skip)]

View File

@@ -1,8 +1,11 @@
use std::collections::VecDeque;
use glam::Vec3;
use serde::{Deserialize, Serialize};
use crate::ids::EngramId;
use crate::manifest::Manifest;
use crate::memory::{Introspection, Memory};
use crate::slate::Slate;
/// Engram state machine — see §5 of `docs/system-analysis.md`.
@@ -31,7 +34,9 @@ impl EngramState {
///
/// Stage 2 adds `manifest` (the source content) and `slate` (an embedding for
/// fast similarity). Stage 4 adds taxonomy/goals/memories on top of that.
#[derive(Debug, Clone)]
/// Stage 6 adds Serialize/Deserialize so engrams can round-trip through the
/// `sophia-store` snapshot file.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Engram {
pub id: EngramId,
/// Slot index for the visualization's `InstancedMesh`. Assigned at birth,
@@ -51,4 +56,14 @@ pub struct Engram {
/// Embedding vector — System-1 layer of the Universal Slate. `None` for
/// synthetic seed engrams.
pub slate: Option<Slate>,
/// LLM-generated self-description (Stage 4). Filled async after birth;
/// empty until the introspection task completes.
pub introspection: Introspection,
/// Per-engram memory log (Stage 4). Bounded — older entries evicted from
/// the front when full.
pub memories: VecDeque<Memory>,
/// Pinned engrams skip physics integration — they sit at their birth
/// position permanently. Used for Query-Engrams (Stage 5), which
/// materialize at the galaxy center and shouldn't drift into the tube.
pub pinned: bool,
}

View File

@@ -59,8 +59,9 @@ pub struct Galaxy {
pub id: GalaxyId,
pub name: String,
/// Galaxy origin in world coords. Always `Vec3::ZERO` in v1; reserved for
/// future multi-galaxy layouts.
#[serde(skip)]
/// future multi-galaxy layouts. Serialized so persistence round-trips
/// the full state (Stage 6) — defaults to ZERO if missing from disk.
#[serde(default)]
pub center: Vec3,
pub shape: GalaxyShape,
}

View File

@@ -8,6 +8,9 @@ pub mod engram;
pub mod galaxy;
pub mod ids;
pub mod manifest;
pub mod memory;
pub mod persistence;
pub mod query;
pub mod slate;
pub mod synapse;
@@ -16,6 +19,9 @@ pub use engram::{Engram, EngramState};
pub use galaxy::{Galaxy, GalaxyShape};
pub use ids::{EngramId, GalaxyId, SynapseId};
pub use manifest::Manifest;
pub use memory::{Introspection, Memory, MemoryKind, MAX_MEMORIES};
pub use persistence::GalaxySnapshot;
pub use query::{QueryId, QueryStatus};
pub use slate::Slate;
pub use synapse::{canonical_pair, Synapse};

View File

@@ -0,0 +1,52 @@
//! Per-Engram memory entries — a running log of meaningful events from the
//! engram's own perspective. Stage 4 only records synapse formation; Stage 5
//! will add conversation memorialization.
use serde::{Deserialize, Serialize};
use crate::ids::EngramId;
use crate::query::QueryId;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum MemoryKind {
Born,
Introspected,
/// A synapse formed with another engram.
SynapseFormed { with: EngramId, weight: f32 },
/// This engram contributed a snippet to a query's running answer.
/// `snippet` is the engram's own LLM-generated POV on the question.
QueryParticipated { query: QueryId, snippet: String },
/// (Query-engrams only.) The query completed and produced a final answer.
/// Stored on the Query-Engram itself so the inspector can show the
/// resolution after the fact.
QueryAnswered { answer: String, responder_count: u32 },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Memory {
/// Time relative to the start of the simulation (ms).
pub at_ms: u32,
pub kind: MemoryKind,
}
/// Hard cap on a single engram's memory list. Older entries are evicted from
/// the front of the deque when full. Stage 4 picks 64 — generous enough for
/// the demo, small enough not to bloat the Hello/inspector payloads.
pub const MAX_MEMORIES: usize = 64;
/// Output of LLM-driven self-introspection at birth (Stage 4). Stored on the
/// `Engram` itself, not the `Manifest`, since it's the engram's *interpretation*
/// of its source content rather than the source itself.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Introspection {
pub taxonomy: Vec<String>,
pub goals: Vec<String>,
pub open_questions: Vec<String>,
}
impl Introspection {
pub fn is_empty(&self) -> bool {
self.taxonomy.is_empty() && self.goals.is_empty() && self.open_questions.is_empty()
}
}

View File

@@ -0,0 +1,31 @@
//! Persistence types (Stage 6).
//!
//! `GalaxySnapshot` is the on-disk shape of a galaxy: its metadata plus the
//! full set of Engrams and Synapses it contains. This is what the periodic
//! snapshot task in `sophia-bin` writes to the sled store, and what the
//! `SimHandle::hydrate_galaxy` command reads back at boot.
//!
//! Pinned engrams (Stage 5 Query-Engrams) are intentionally excluded from
//! snapshots. Queries are ephemeral — their orchestrator task is gone after
//! a server restart, and a half-rendered query-engram with no SSE channel
//! would be confusing in the UI. Letting them die with the process is the
//! cleaner default.
use serde::{Deserialize, Serialize};
use crate::engram::Engram;
use crate::galaxy::Galaxy;
use crate::synapse::Synapse;
/// One galaxy's full persistent state. Encoded with `bincode` and stored
/// under the galaxy's id in the sled tree.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GalaxySnapshot {
pub galaxy: Galaxy,
/// All non-pinned engrams. The order is preserved so `instance_idx`
/// values remain meaningful after a round-trip — the sim re-uses each
/// engram's stored `instance_idx` rather than re-numbering, so the
/// position-frame slot mapping stays stable across restarts.
pub engrams: Vec<Engram>,
pub synapses: Vec<Synapse>,
}

View File

@@ -0,0 +1,45 @@
//! Query domain types (Stage 5).
//!
//! A "query" is a question that materializes as a special pinned Engram at
//! the galaxy center. The Query-Engram broadcasts to nearby resonating
//! Engrams; their snippets are integrated into a streaming answer.
//!
//! `QueryId` is distinct from `EngramId` even though every query has a
//! backing query-engram — keeping them separate lets the orchestrator track
//! query state (responders, snippets, answer versions) independently of the
//! engram's lifecycle in the world.
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct QueryId(pub Uuid);
impl QueryId {
pub fn new() -> Self {
Self(Uuid::now_v7())
}
}
impl Default for QueryId {
fn default() -> Self {
Self::new()
}
}
/// Lifecycle phase of a query, as exposed to SSE consumers.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum QueryStatus {
/// Query-Engram materialized; broadcast not yet started.
Pending,
/// Phase A/B retrieval in flight; responders being polled.
Responding,
/// All responses gathered; integrator producing final answer.
Integrating,
/// Final answer published; query-engram transitioned to Memorize.
Done,
/// Aborted (e.g. embedding failure or sim shutdown).
Failed,
}

View File

@@ -96,6 +96,20 @@ impl LmStudioClient {
&self.inner.cfg
}
/// How many parallel-op permits are currently free. Equal to `parallel_ops`
/// when idle, drops as concurrent chat/embedding calls run. Used by the
/// HUD's "LLM queue" indicator — a number that consistently sits at 0
/// means the LLM is the bottleneck.
pub fn available_permits(&self) -> usize {
self.inner.parallel.available_permits()
}
/// The configured ceiling. Same as `config().parallel_ops`, exposed for
/// callers that already have the client handle but not the config.
pub fn parallel_ops(&self) -> usize {
self.inner.cfg.parallel_ops.max(1)
}
/// Apply Bearer auth header if a token is configured. LM Studio's REST
/// server returns 401 if auth is enabled (default in 0.3.x+) and no token
/// is sent.

View File

@@ -0,0 +1,90 @@
//! LLM-driven introspection: ask the chat model to summarise one piece of
//! text into a small structured `Introspection` record (taxonomy, goals,
//! open questions). Caveman budget per §13.5.
use serde::Deserialize;
use sophia_core::Introspection;
use crate::budget::TokenCaps;
use crate::client::{ChatMessage, LmError, LmStudioClient};
const SYSTEM_PROMPT: &str = "\
You summarize one short piece of knowledge for a knowledge graph.\n\
Output ONLY valid JSON matching this schema:\n\
{\n \"taxonomy\": [string, ...],\n \"goals\": [string, ...],\n \"open_questions\": [string, ...]\n}\n\
- taxonomy: 3 to 5 short topical labels, from general to specific.\n\
- goals: 1 to 3 short statements of what this knowledge enables or answers.\n\
- open_questions: 1 to 3 short questions this knowledge invites but doesn't answer.\n\
Each item must be at most 12 words. No prose outside the JSON.";
/// Truncate excessively long input so we stay inside the input cap. Caveman
/// budget tolerates ~400 input tokens total — content beyond ~1200 chars
/// (≈300 tokens) is unlikely to add useful introspection, so we cut.
const MAX_INPUT_CHARS: usize = 1200;
#[derive(Debug, Deserialize)]
struct IntrospectionPayload {
#[serde(default)]
taxonomy: Vec<String>,
#[serde(default)]
goals: Vec<String>,
#[serde(default)]
open_questions: Vec<String>,
}
/// Introspect a single piece of text. Returns an empty `Introspection` if
/// the LLM responds with non-JSON or empty content; the caller can decide
/// whether to retry. Caps input length defensively.
pub async fn introspect_text(
client: &LmStudioClient,
text: &str,
caps: TokenCaps,
) -> Result<Introspection, LmError> {
let trimmed = if text.chars().count() > MAX_INPUT_CHARS {
let head: String = text.chars().take(MAX_INPUT_CHARS).collect();
format!("{head}")
} else {
text.to_string()
};
let messages = [
ChatMessage::system(SYSTEM_PROMPT),
ChatMessage::user(format!("Content:\n\"\"\"\n{trimmed}\n\"\"\"")),
];
let raw = client.chat(&messages, caps).await?;
Ok(parse_introspection(&raw))
}
/// Best-effort JSON extraction. Models occasionally wrap output in ```json
/// fences; we strip those before parsing. On failure returns empty.
fn parse_introspection(raw: &str) -> Introspection {
let json_text = strip_code_fences(raw.trim());
let parsed: Result<IntrospectionPayload, _> = serde_json::from_str(json_text);
match parsed {
Ok(p) => Introspection {
taxonomy: cleanup(p.taxonomy),
goals: cleanup(p.goals),
open_questions: cleanup(p.open_questions),
},
Err(_) => Introspection::default(),
}
}
fn strip_code_fences(s: &str) -> &str {
let s = s.trim();
let stripped = s.strip_prefix("```json").or_else(|| s.strip_prefix("```"));
if let Some(rest) = stripped {
let rest = rest.trim_start_matches('\n');
rest.strip_suffix("```").unwrap_or(rest).trim()
} else {
s
}
}
fn cleanup(items: Vec<String>) -> Vec<String> {
items
.into_iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.take(8) // hard upper bound, just in case
.collect()
}

View File

@@ -4,6 +4,10 @@
mod budget;
mod client;
mod introspect;
mod query;
pub use budget::{TokenCaps, TokenCapsAll};
pub use client::{ChatMessage, ChatRole, LmError, LmStudioClient, LmStudioConfig, EMBED_BATCH_LIMIT};
pub use introspect::introspect_text;
pub use query::{integrate_responses, respond_to_query};

View File

@@ -0,0 +1,92 @@
//! Query-time LLM prompts (Stage 5).
//!
//! Two distinct calls per query:
//!
//! - `respond_to_query`: each resonating engram (the "responder") is asked to
//! say what *its own* knowledge contributes to the question. Caveman cap
//! 200 in / 150 out — these are short, single-perspective snippets.
//! - `integrate_responses`: the Query-Engram folds the snippets it has
//! received so far into a single running answer. Cap 800 in / 300 out.
//! Re-runs every couple of seconds while new snippets arrive, so the
//! browser sees the answer refine.
use crate::budget::TokenCaps;
use crate::client::{ChatMessage, LmError, LmStudioClient};
const RESPONDER_SYSTEM: &str = "\
You are one fragment of knowledge inside a larger memory. \
A query has been broadcast through the network. \
Speak ONLY for what your own content contributes to the question. \
Do not summarize the question. Do not speculate beyond your content. \
If your content is irrelevant, reply with the single word: PASS. \
Otherwise: 13 short sentences, plain prose, no headings, no bullets.";
const INTEGRATOR_SYSTEM: &str = "\
You are the answering process for a question. You receive snippets from \
fragments of knowledge that resonated with the question. Synthesize them \
into ONE coherent answer to the user's question. Cite no sources. \
Do not list the snippets. Do not say things like \"based on the snippets\". \
Plain prose, 26 sentences. If the snippets are insufficient, say so briefly.";
/// Truncate one responder's source content to keep the per-call payload small.
const MAX_RESPONDER_INPUT_CHARS: usize = 600;
/// Ask one resonating engram for its take on the question. Returns `None` if
/// the responder explicitly opts out (`PASS`) or returns empty content.
/// Keeping `Option<String>` rather than `Result<...>` lets the orchestrator
/// distinguish "this engram had nothing useful to say" from "the LLM call
/// failed" — both are fine, but only the latter should be logged.
pub async fn respond_to_query(
client: &LmStudioClient,
question: &str,
responder_text: &str,
caps: TokenCaps,
) -> Result<Option<String>, LmError> {
let trimmed = if responder_text.chars().count() > MAX_RESPONDER_INPUT_CHARS {
let head: String = responder_text.chars().take(MAX_RESPONDER_INPUT_CHARS).collect();
format!("{head}")
} else {
responder_text.to_string()
};
let user = format!(
"Question:\n{question}\n\nYour content:\n\"\"\"\n{trimmed}\n\"\"\""
);
let messages = [
ChatMessage::system(RESPONDER_SYSTEM),
ChatMessage::user(user),
];
let raw = client.chat(&messages, caps).await?;
let cleaned = raw.trim();
if cleaned.is_empty() || cleaned.eq_ignore_ascii_case("PASS") {
return Ok(None);
}
Ok(Some(cleaned.to_string()))
}
/// Fold the current set of responder snippets into one running answer. The
/// orchestrator calls this repeatedly as more snippets arrive.
pub async fn integrate_responses(
client: &LmStudioClient,
question: &str,
snippets: &[String],
caps: TokenCaps,
) -> Result<String, LmError> {
if snippets.is_empty() {
return Ok(String::new());
}
// Number the snippets so the integrator can reason about distinct
// perspectives without us having to add ids.
let mut buf = String::new();
for (i, s) in snippets.iter().enumerate() {
buf.push_str(&format!("[{}] {}\n", i + 1, s.trim()));
}
let user = format!(
"Question:\n{question}\n\nSnippets:\n{buf}\nAnswer the question now."
);
let messages = [
ChatMessage::system(INTEGRATOR_SYSTEM),
ChatMessage::user(user),
];
let raw = client.chat(&messages, caps).await?;
Ok(raw.trim().to_string())
}

View File

@@ -7,9 +7,10 @@ rust-version.workspace = true
authors.workspace = true
[dependencies]
sophia-core = { workspace = true }
sophia-sim = { workspace = true }
sophia-llm = { workspace = true }
sophia-core = { workspace = true }
sophia-sim = { workspace = true }
sophia-llm = { workspace = true }
sophia-store = { workspace = true }
anyhow = { workspace = true }
tokio = { workspace = true }
@@ -22,4 +23,5 @@ tower = { workspace = true }
tower-http = { workspace = true }
futures-util = { workspace = true }
tokio-stream = { workspace = true }
bytes = { workspace = true }

View File

@@ -1,23 +1,32 @@
//! HTTP / WebSocket server.
use std::convert::Infallible;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use anyhow::Result;
use axum::extract::{Path, Query, State, WebSocketUpgrade};
use axum::http::StatusCode;
use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
use axum::response::{IntoResponse, Response};
use axum::routing::{delete, get, post};
use axum::{Json, Router};
use futures_util::stream::{self, Stream, StreamExt};
use serde::Deserialize;
use sophia_core::{EngramId, GalaxyId, GalaxyShape, Manifest, Slate};
use sophia_llm::{LmStudioClient, EMBED_BATCH_LIMIT};
use sophia_core::{EngramId, GalaxyId, GalaxyShape, Manifest, QueryId, QueryStatus, Slate};
use sophia_llm::{introspect_text, LmStudioClient, TokenCapsAll, EMBED_BATCH_LIMIT};
use sophia_sim::{IngestItem, SimHandle};
use sophia_store::Store;
use tokio_stream::wrappers::BroadcastStream;
use tower_http::services::{ServeDir, ServeFile};
use tower_http::trace::TraceLayer;
mod query;
mod ws;
use query::QueryRegistry;
#[derive(Debug, Clone)]
pub struct ServerConfig {
pub host: String,
@@ -29,29 +38,54 @@ pub struct ServerConfig {
struct AppState {
sim: SimHandle,
llm: LmStudioClient,
token_caps: TokenCapsAll,
queries: Arc<QueryRegistry>,
store: Store,
}
pub fn build_router(cfg: &ServerConfig, sim: SimHandle, llm: LmStudioClient) -> Router {
pub fn build_router(
cfg: &ServerConfig,
sim: SimHandle,
llm: LmStudioClient,
token_caps: TokenCapsAll,
store: Store,
) -> Router {
let index = cfg.static_dir.join("index.html");
let static_service = ServeDir::new(&cfg.static_dir).fallback(ServeFile::new(&index));
let state = Arc::new(AppState { sim, llm });
let state = Arc::new(AppState {
sim,
llm,
token_caps,
queries: Arc::new(QueryRegistry::new()),
store,
});
Router::new()
.route("/healthz", get(healthz))
.route("/api/galaxy", post(create_galaxy).get(list_galaxies))
.route("/api/galaxy/:id", delete(delete_galaxy_unimpl))
.route("/api/galaxy/:id", delete(delete_galaxy))
.route("/api/galaxy/:id/seed", post(seed_galaxy))
.route("/api/galaxy/:id/ingest", post(ingest_galaxy))
.route("/api/galaxy/:id/resize", post(resize_galaxy))
.route("/api/galaxy/:id/query", post(start_query))
.route("/api/galaxy/:gid/engrams/:eid", get(get_engram))
.route("/api/query/:id", get(get_query))
.route("/api/query/:id/stream", get(query_stream))
.route("/api/stats", get(stats))
.route("/ws/galaxy/:id/events", get(ws_events))
.with_state(state)
.fallback_service(static_service)
.layer(TraceLayer::new_for_http())
}
pub async fn serve(cfg: ServerConfig, sim: SimHandle, llm: LmStudioClient) -> Result<()> {
let app = build_router(&cfg, sim, llm);
pub async fn serve(
cfg: ServerConfig,
sim: SimHandle,
llm: LmStudioClient,
token_caps: TokenCapsAll,
store: Store,
) -> Result<()> {
let app = build_router(&cfg, sim, llm, token_caps, store);
let addr = format!("{}:{}", cfg.host, cfg.port);
let listener = tokio::net::TcpListener::bind(&addr).await?;
tracing::info!("sophia-server listening on http://{}", addr);
@@ -86,6 +120,26 @@ async fn healthz(State(s): State<Arc<AppState>>) -> Response {
.into_response()
}
/// `GET /api/stats` — cheap snapshot for the HUD. Polled at ~1 Hz by the
/// browser; every field is integer so the panel stays readable. The
/// `llm_queue_depth` is `parallel_ops - available_permits` (i.e. how many
/// LLM calls are currently in flight).
async fn stats(State(s): State<Arc<AppState>>) -> Response {
let sim_stats = s.sim.stats().await;
let permits_free = s.llm.available_permits();
let parallel_ops = s.llm.parallel_ops();
let in_flight = parallel_ops.saturating_sub(permits_free);
Json(serde_json::json!({
"ticks_per_sec": sim_stats.ticks_per_sec,
"engrams_total": sim_stats.engrams_total,
"synapses_total": sim_stats.synapses_total,
"galaxies": sim_stats.galaxies,
"llm_queue_depth": in_flight,
"llm_parallel_cap": parallel_ops,
}))
.into_response()
}
#[derive(Deserialize)]
struct CreateGalaxyBody {
name: String,
@@ -96,7 +150,22 @@ async fn create_galaxy(
Json(body): Json<CreateGalaxyBody>,
) -> Response {
match s.sim.create_galaxy(body.name).await {
Ok(info) => Json(info).into_response(),
Ok(info) => {
// Persist immediately so a kill+restart in the first 60 s
// doesn't lose the new galaxy. Failure here is non-fatal —
// the next periodic snapshot tick will pick it up.
if let Ok(snaps) = s.sim.snapshot_all().await {
for snap in &snaps {
if snap.galaxy.id == info.id {
if let Err(e) = s.store.save_galaxy(snap) {
tracing::warn!("initial save of new galaxy failed: {e}");
}
break;
}
}
}
Json(info).into_response()
}
Err(e) => sim_error(e),
}
}
@@ -108,9 +177,22 @@ async fn list_galaxies(State(s): State<Arc<AppState>>) -> Response {
}
}
async fn delete_galaxy_unimpl(Path(_id): Path<GalaxyId>) -> Response {
// Stage 6 will wire deletion to the persistence layer.
(StatusCode::NOT_IMPLEMENTED, "galaxy deletion arrives in Stage 6").into_response()
async fn delete_galaxy(
State(s): State<Arc<AppState>>,
Path(id): Path<GalaxyId>,
) -> Response {
if let Err(e) = s.sim.delete_galaxy(id).await {
return sim_error(e);
}
if let Err(e) = s.store.delete_galaxy(id) {
// Sim succeeded but disk didn't — log and report; the galaxy is
// gone from memory so this is a 500-ish situation, but the next
// periodic snapshot will repair (it just won't write the deleted
// galaxy back since it's not in `snapshot_all`).
tracing::warn!("sim deleted galaxy {:?} but store delete failed: {e}", id);
return (StatusCode::INTERNAL_SERVER_ERROR, format!("store: {e}")).into_response();
}
(StatusCode::NO_CONTENT, "").into_response()
}
#[derive(Deserialize)]
@@ -171,6 +253,9 @@ async fn ingest_galaxy(
}
}
// Pair (text, slate) → IngestItem, but keep texts so we can launch a
// post-ingest introspection task per engram below.
let texts_for_introspection: Vec<String> = texts.clone();
let items: Vec<IngestItem> = texts
.into_iter()
.zip(all_vecs)
@@ -181,7 +266,40 @@ async fn ingest_galaxy(
.collect();
tracing::info!("ingest: {} items into galaxy {:?}", items.len(), id);
match s.sim.ingest(id, items).await {
let result = s.sim.ingest(id, items).await;
// On success, fire-and-forget one LLM introspection task per engram.
// The LM Studio client's semaphore (parallel_ops) bounds total
// concurrency, so even 200 spawned futures only run N at a time.
// Updates trickle in over the next several seconds via
// `SimHandle::update_introspection`.
if let Ok(ids) = &result {
for (engram_id, text) in ids.iter().zip(texts_for_introspection) {
let llm = s.llm.clone();
let sim = s.sim.clone();
let caps = s.token_caps.introspection();
let galaxy_id = id;
let engram_id = *engram_id;
tokio::spawn(async move {
match introspect_text(&llm, &text, caps).await {
Ok(intro) => {
if intro.is_empty() {
tracing::debug!(?engram_id, "introspection returned empty");
} else if let Err(e) =
sim.update_introspection(galaxy_id, engram_id, intro).await
{
tracing::warn!(?engram_id, "update_introspection failed: {e}");
}
}
Err(e) => {
tracing::warn!(?engram_id, "introspect_text failed: {e}");
}
}
});
}
}
match result {
Ok(ids) => Json(serde_json::json!({ "engram_ids": ids })).into_response(),
Err(e) => sim_error(e),
}
@@ -230,6 +348,99 @@ async fn ws_events(
upgrade.on_upgrade(move |socket| ws::run_galaxy_socket(socket, s.sim.clone(), id))
}
#[derive(Deserialize)]
struct QueryBody {
text: String,
}
/// `POST /api/galaxy/:id/query` — embed the question, materialize a Query-Engram,
/// kick off the orchestrator. Returns 202 + `{query_id}` immediately so the
/// client can SSE-subscribe before the first responder fires.
async fn start_query(
State(s): State<Arc<AppState>>,
Path(id): Path<GalaxyId>,
Json(body): Json<QueryBody>,
) -> Response {
match query::start_query(
s.sim.clone(),
s.llm.clone(),
s.token_caps.clone(),
s.queries.clone(),
id,
body.text,
)
.await
{
Ok(qid) => (
StatusCode::ACCEPTED,
Json(serde_json::json!({ "query_id": qid })),
)
.into_response(),
Err(msg) => (StatusCode::BAD_REQUEST, msg).into_response(),
}
}
/// `GET /api/query/:id` — current snapshot. Useful for clients that miss the
/// initial SSE event or want a one-shot read.
async fn get_query(
State(s): State<Arc<AppState>>,
Path(id): Path<QueryId>,
) -> Response {
match s.queries.get(id).await {
Some(entry) => Json(entry.snapshot().await).into_response(),
None => (StatusCode::NOT_FOUND, "query not found").into_response(),
}
}
/// `GET /api/query/:id/stream` — Server-Sent Events of `QueryUpdate` JSON.
/// First event is always the latest known snapshot (so a late subscriber
/// catches up without polling); subsequent events come from the per-query
/// broadcast channel. The stream ends when the orchestrator publishes a
/// terminal status (`Done` or `Failed`).
async fn query_stream(
State(s): State<Arc<AppState>>,
Path(id): Path<QueryId>,
) -> Response {
let Some(entry) = s.queries.get(id).await else {
return (StatusCode::NOT_FOUND, "query not found").into_response();
};
let initial = entry.snapshot().await;
let receiver = entry.subscribe();
let live = BroadcastStream::new(receiver).filter_map(|res| async move {
match res {
Ok(update) => Some(update),
Err(e) => {
tracing::debug!("sse lagged: {e}");
None
}
}
});
// Emit the initial snapshot first, then forward live updates until a
// terminal frame (`done`/`failed`) — the terminal frame is INCLUDED,
// and the stream closes immediately after. Standard `take_while` drops
// the matching item; `unfold` gives us "include and then end" by
// collapsing the state to None after emitting the terminal frame.
let combined = stream::once(async move { initial }).chain(live);
let stream = stream::unfold(Some(Box::pin(combined)), |state| async move {
let mut s = state?;
let item = s.next().await?;
let next_state = if matches!(item.status, QueryStatus::Done | QueryStatus::Failed) {
None
} else {
Some(s)
};
Some((item, next_state))
});
let sse_stream: std::pin::Pin<Box<dyn Stream<Item = Result<SseEvent, Infallible>> + Send>> =
Box::pin(stream.map(|u| {
let payload = serde_json::to_string(&u).unwrap_or_else(|_| "{}".into());
Ok(SseEvent::default().data(payload))
}));
Sse::new(sse_stream)
.keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))
.into_response()
}
fn sim_error(e: sophia_sim::SimError) -> Response {
use sophia_sim::SimError::*;
let status = match e {

View File

@@ -0,0 +1,393 @@
//! Stage 5 query orchestration.
//!
//! A "query" is a conversation initiated by the user. Lifecycle:
//!
//! 1. `POST /api/galaxy/:id/query {text}` — embed the question, materialize a
//! pinned Query-Engram in the sim, register a per-query SSE bus, spawn the
//! background orchestrator, return `{query_id}` immediately (HTTP 202).
//! 2. Orchestrator (this module): `sim.broadcast_query(...)` chooses the
//! responders (Phase A cosine + Phase B 1-hop synaptic). Each responder
//! is asked, in parallel under the LM Studio semaphore, what *its* content
//! contributes to the question. As snippets arrive they're recorded on
//! the responder (visual light-up) and integrated into a running answer
//! every `INTEGRATION_INTERVAL`.
//! 3. When all responses are gathered (or the timeout hits), one final
//! integration produces the canonical answer; `sim.finish_query(...)`
//! transitions the Query-Engram to Memorize and the SSE stream closes.
//!
//! Decisions worth noting:
//!
//! - Responder cap (`MAX_RESPONDERS = 12`): keeps the LLM queue from being
//! monopolized by one query and bounds the integrator's input length.
//! - Re-integration is *time-driven* (every 2 s) rather than per-snippet —
//! batching snippets into a single integrator call costs less and gives
//! the answer time to refine.
//! - Per-query SSE channel buffer is small (16); the latest snapshot is
//! stored separately so a slow client can resync to current state on
//! connect rather than replaying every interim version.
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use sophia_core::{GalaxyId, QueryId, QueryStatus, Slate};
use sophia_llm::{integrate_responses, respond_to_query, LmStudioClient, TokenCapsAll};
use sophia_sim::SimHandle;
use tokio::sync::{broadcast, Mutex, RwLock};
use tokio::task::JoinSet;
use tracing::{debug, warn};
/// Maximum number of responders we ask per query. Larger numbers don't help
/// the answer much (the integrator can only fit ~510 distinct viewpoints
/// in its 800-token input cap) and starve the LLM queue.
const MAX_RESPONDERS: usize = 12;
/// How often the integrator re-runs while new snippets are arriving. Every
/// 2 s gives the user a visible refinement cadence without burning LLM
/// budget on near-identical re-integrations.
const INTEGRATION_INTERVAL: Duration = Duration::from_millis(2_000);
/// Hard cap on time spent waiting for responders. After this we stop polling,
/// run a final integration on whatever snippets we have, and finish. Tuned
/// for `gemma-4-e4b` thinking-mode latency: each responder call takes
/// ~2040 s once the hidden reasoning budget is large enough to actually
/// produce visible content, and LM Studio serialises some of them despite
/// the parallel-op semaphore on our side.
const RESPONDER_TIMEOUT: Duration = Duration::from_secs(120);
/// SSE channel buffer per query. SSE consumers that lag past this will see
/// old versions dropped; the registry's `latest` snapshot covers the rest.
const SSE_CAPACITY: usize = 16;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueryUpdate {
pub query_id: QueryId,
pub version: u32,
pub status: QueryStatus,
pub answer: String,
pub responder_count: u32,
}
#[derive(Debug)]
pub struct QueryEntry {
sender: broadcast::Sender<QueryUpdate>,
latest: Mutex<QueryUpdate>,
}
/// Process-wide registry. Cheaply cloneable Arc holds an `RwLock` over the
/// `HashMap<QueryId, Arc<QueryEntry>>` — typical access is read-heavy
/// (subscribers + GET /api/query/:id), with a single write per new query.
#[derive(Default)]
pub struct QueryRegistry {
inner: RwLock<HashMap<QueryId, Arc<QueryEntry>>>,
}
impl QueryRegistry {
pub fn new() -> Self {
Self::default()
}
async fn register(&self, query_id: QueryId, initial: QueryUpdate) -> Arc<QueryEntry> {
let (sender, _) = broadcast::channel(SSE_CAPACITY);
let entry = Arc::new(QueryEntry {
sender,
latest: Mutex::new(initial),
});
self.inner.write().await.insert(query_id, entry.clone());
entry
}
pub async fn get(&self, query_id: QueryId) -> Option<Arc<QueryEntry>> {
self.inner.read().await.get(&query_id).cloned()
}
}
impl QueryEntry {
pub fn subscribe(&self) -> broadcast::Receiver<QueryUpdate> {
self.sender.subscribe()
}
pub async fn snapshot(&self) -> QueryUpdate {
self.latest.lock().await.clone()
}
async fn publish(&self, update: QueryUpdate) {
*self.latest.lock().await = update.clone();
// Fan-out send; drop silently if no one is subscribed.
let _ = self.sender.send(update);
}
}
/// Start a query. Embeds the question, materializes the Query-Engram, kicks
/// off the orchestrator. Returns the new `QueryId` immediately so the caller
/// can SSE-subscribe before the first responder fires.
pub async fn start_query(
sim: SimHandle,
llm: LmStudioClient,
caps: TokenCapsAll,
registry: Arc<QueryRegistry>,
galaxy: GalaxyId,
text: String,
) -> Result<QueryId, String> {
let trimmed = text.trim().to_string();
if trimmed.is_empty() {
return Err("query text must not be empty".into());
}
// Embed the question. This blocks the request for the duration of one
// LM Studio embedding call (typically <100 ms). Doing it inline keeps
// the orchestrator simple — no "pending embedding" state.
let embedding = llm
.embed(&trimmed)
.await
.map_err(|e| format!("embed: {e}"))?;
let slate = Slate(embedding);
let query_id = QueryId::new();
let query_engram = sim
.start_query_engram(galaxy, query_id, trimmed.clone(), slate)
.await
.map_err(|e| format!("start_query_engram: {e}"))?;
// Seed registry with a Pending snapshot so SSE subscribers get something
// immediate even before broadcast finishes.
let initial = QueryUpdate {
query_id,
version: 0,
status: QueryStatus::Pending,
answer: String::new(),
responder_count: 0,
};
let entry = registry.register(query_id, initial.clone()).await;
tokio::spawn(orchestrate_query(OrchestrateCtx {
sim,
llm,
caps,
entry,
galaxy,
query_id,
query_engram,
question: trimmed,
}));
Ok(query_id)
}
struct OrchestrateCtx {
sim: SimHandle,
llm: LmStudioClient,
caps: TokenCapsAll,
entry: Arc<QueryEntry>,
galaxy: GalaxyId,
query_id: QueryId,
query_engram: sophia_core::EngramId,
question: String,
}
/// The orchestrator. Owns the per-query lifecycle from broadcast to finish.
async fn orchestrate_query(ctx: OrchestrateCtx) {
let OrchestrateCtx {
sim, llm, caps, entry, galaxy, query_id, query_engram, question,
} = ctx;
let responders = match sim.broadcast_query(galaxy, query_engram, MAX_RESPONDERS).await {
Ok(rs) => rs,
Err(e) => {
warn!(?query_id, "broadcast_query failed: {e}");
publish_terminal(&entry, query_id, QueryStatus::Failed, String::new(), 0).await;
return;
}
};
if responders.is_empty() {
let answer = "No engrams resonated with this question.".to_string();
let _ = sim
.publish_query_answer(galaxy, query_id, 1, QueryStatus::Done, answer.clone())
.await;
let _ = sim
.finish_query(galaxy, query_id, query_engram, answer.clone(), 0)
.await;
publish_terminal(&entry, query_id, QueryStatus::Done, answer, 0).await;
return;
}
debug!(
?query_id,
responders = responders.len(),
"broadcasting query to responders"
);
// Fan out one responder LLM task per pick. Each task fetches the
// responder's source text from the sim, asks the LLM for that engram's
// POV, then records the snippet. The LM Studio semaphore inside
// `respond_to_query` is what keeps concurrent calls bounded — we do
// *not* serialize here.
let snippets: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let mut tasks = JoinSet::new();
for (responder_id, score) in responders.iter().copied() {
let sim = sim.clone();
let llm = llm.clone();
let caps = caps.query_responder();
let question = question.clone();
let snippets = Arc::clone(&snippets);
tasks.spawn(async move {
let text = match sim.get_engram_text(galaxy, responder_id).await {
Ok(Some(t)) => t,
_ => return,
};
let snippet = match respond_to_query(&llm, &question, &text, caps).await {
Ok(Some(s)) => s,
Ok(None) => {
debug!(?responder_id, score, "responder passed");
return;
}
Err(e) => {
warn!(?responder_id, "responder failed: {e}");
return;
}
};
// Record on the engram (visual light-up + memory entry).
if let Err(e) = sim
.record_query_participation(galaxy, query_id, responder_id, snippet.clone())
.await
{
warn!(?responder_id, "record_query_participation: {e}");
}
snippets.lock().await.push(snippet);
});
}
// Run the integrator on a fixed cadence while responders are in flight.
// We don't try to "wake on snippet" — a 2 s tick is plenty fast for the
// user, and lets multiple snippets batch into one integrator call.
let mut version: u32 = 0;
let deadline = tokio::time::Instant::now() + RESPONDER_TIMEOUT;
let mut ticker = tokio::time::interval(INTEGRATION_INTERVAL);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
tokio::select! {
biased;
// All responder tasks have completed (or panicked).
_ = wait_for_join_set(&mut tasks) => {
break;
}
_ = ticker.tick() => {
let snapshot = snippets.lock().await.clone();
if snapshot.is_empty() {
continue;
}
version += 1;
run_integration(
&sim, &llm, caps.query_integrator(), &entry, galaxy,
query_id, version, &question, &snapshot, QueryStatus::Responding,
).await;
}
_ = tokio::time::sleep_until(deadline) => {
warn!(?query_id, "responder timeout — finishing with partial set");
tasks.shutdown().await;
break;
}
}
}
let final_snippets = snippets.lock().await.clone();
let responder_count = final_snippets.len() as u32;
// Final integration. If we already have a recent answer and no new
// snippets arrived in the final window we still re-run once — the
// integrator is allowed to refine even with the same input.
version += 1;
let final_answer = if final_snippets.is_empty() {
"No responder produced a usable snippet.".to_string()
} else {
match integrate_responses(
&llm,
&question,
&final_snippets,
caps.query_integrator(),
).await {
Ok(a) if !a.is_empty() => a,
Ok(_) => "Integrator returned an empty answer.".to_string(),
Err(e) => {
warn!(?query_id, "final integration failed: {e}");
entry.snapshot().await.answer
}
}
};
let _ = sim
.publish_query_answer(galaxy, query_id, version, QueryStatus::Done, final_answer.clone())
.await;
let _ = sim
.finish_query(galaxy, query_id, query_engram, final_answer.clone(), responder_count)
.await;
publish_terminal(&entry, query_id, QueryStatus::Done, final_answer, responder_count).await;
}
/// Run one integration pass and publish the result to both the sim's WS bus
/// and the per-query SSE bus. Increments `responder_count` from the snapshot
/// length so the UI can show "synthesizing 5 perspectives…".
#[allow(clippy::too_many_arguments)]
async fn run_integration(
sim: &SimHandle,
llm: &LmStudioClient,
caps: sophia_llm::TokenCaps,
entry: &QueryEntry,
galaxy: GalaxyId,
query_id: QueryId,
version: u32,
question: &str,
snippets: &[String],
status: QueryStatus,
) {
let answer = match integrate_responses(llm, question, snippets, caps).await {
Ok(a) => a,
Err(e) => {
warn!(?query_id, "integrator failed: {e}");
return;
}
};
if answer.is_empty() {
return;
}
let responder_count = snippets.len() as u32;
let _ = sim
.publish_query_answer(galaxy, query_id, version, status, answer.clone())
.await;
entry
.publish(QueryUpdate {
query_id,
version,
status,
answer,
responder_count,
})
.await;
}
async fn publish_terminal(
entry: &QueryEntry,
query_id: QueryId,
status: QueryStatus,
answer: String,
responder_count: u32,
) {
let snapshot = entry.snapshot().await;
let version = snapshot.version.saturating_add(1);
entry
.publish(QueryUpdate {
query_id,
version,
status,
answer,
responder_count,
})
.await;
}
/// Drain a `JoinSet` until empty. Used as a `select!` arm to wait for "all
/// spawned tasks done" without holding a mutable borrow across awaits.
async fn wait_for_join_set(tasks: &mut JoinSet<()>) {
while tasks.join_next().await.is_some() {}
}

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 {

View File

@@ -10,5 +10,8 @@ authors.workspace = true
sophia-core = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sled = { workspace = true }

View File

@@ -1,7 +1,109 @@
//! Persistence: sled-backed event log + snapshot.
//! Persistence: sled-backed snapshot store (Stage 6).
//!
//! See `docs/system-analysis.md` §13.3 (event-sourced + graph read model).
//! Per the doc: "the event log == the canonical memory store" — anything
//! memorable is persisted; transient state is not.
//! Each galaxy is one key in a sled `Db`: key = the galaxy's UUID bytes,
//! value = a JSON-encoded `GalaxySnapshot`. The bin's snapshot task
//! overwrites the value every 60 s; on boot we iterate the tree, decode
//! each entry, and hand them to the sim for hydration.
//!
//! Stage 0: stub. Real content arrives in Stage 6.
//! **Why JSON, not bincode**: `Manifest` and `MemoryKind` are
//! internally-tagged enums (`#[serde(tag = "kind")]`). Internally tagged
//! enums require a self-describing format on the wire — bincode calls
//! `deserialize_any` to read the tag, which it doesn't support. JSON is
//! self-describing and handles them natively. At MVP scale (a few hundred
//! engrams) the verbosity is irrelevant; if storage grows we can swap in
//! msgpack/cbor without touching the sim.
//!
//! See `docs/system-analysis.md` §13.3: the design calls for an event log
//! with periodic snapshots, but per the same section "no
//! event-replay-from-genesis." For MVP that means we only ever need the
//! snapshot side — no event log, no per-event append. If the model later
//! needs time-travel debugging we can add the log without disturbing the
//! snapshot path (different sled tree).
//!
//! Sled defaults to `flush_every_ms = 500`, so even a hard kill loses at
//! most ~half a second of writes — acceptable for snapshot data that's
//! already 60 s stale by definition.
use std::path::Path;
use sophia_core::{GalaxyId, GalaxySnapshot};
use thiserror::Error;
use tracing::{debug, warn};
#[derive(Debug, Error)]
pub enum StoreError {
#[error("sled error: {0}")]
Sled(#[from] sled::Error),
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
}
/// Cheaply cloneable handle to the on-disk store. Internally holds a
/// `sled::Db`; cloning shares the same database connection.
#[derive(Clone)]
pub struct Store {
db: sled::Db,
}
impl Store {
/// Open or create the store at `path`. The path is treated as a
/// directory — sled creates it (and the sled internal files inside) if
/// needed.
pub fn open(path: impl AsRef<Path>) -> Result<Self, StoreError> {
let db = sled::open(path.as_ref())?;
Ok(Self { db })
}
/// Iterate every stored galaxy snapshot. Decode failures are logged and
/// skipped — a corrupt entry shouldn't take down the boot path.
pub fn list_galaxies(&self) -> Result<Vec<GalaxySnapshot>, StoreError> {
let mut out = Vec::new();
for kv in self.db.iter() {
let (k, v) = kv?;
match serde_json::from_slice::<GalaxySnapshot>(&v) {
Ok(snap) => out.push(snap),
Err(e) => {
warn!("skipping corrupt galaxy entry (key={} bytes): {e}", k.len());
}
}
}
Ok(out)
}
/// Insert or overwrite the stored snapshot for one galaxy. Sled flushes
/// asynchronously (`flush_every_ms`); we don't `flush()` here because
/// snapshot writes happen on a 60 s cadence and a half-second of in-mem
/// buffering is fine.
pub fn save_galaxy(&self, snapshot: &GalaxySnapshot) -> Result<(), StoreError> {
let bytes = serde_json::to_vec(snapshot)?;
self.db.insert(galaxy_key(snapshot.galaxy.id), bytes)?;
debug!(
"saved galaxy {:?} ({} engrams, {} synapses)",
snapshot.galaxy.id,
snapshot.engrams.len(),
snapshot.synapses.len()
);
Ok(())
}
/// Remove a galaxy from disk. Idempotent — missing keys are not an error.
pub fn delete_galaxy(&self, id: GalaxyId) -> Result<(), StoreError> {
self.db.remove(galaxy_key(id))?;
Ok(())
}
/// Force a synchronous flush. Called on graceful shutdown (and useful
/// in tests). Otherwise sled's background flusher is sufficient.
pub fn flush(&self) -> Result<(), StoreError> {
self.db.flush()?;
Ok(())
}
}
/// 16-byte key for one galaxy. Using the raw UUID bytes (rather than the
/// hyphenated string) keeps the key compact and lexicographically equivalent
/// to UUID v7's time order — which makes `Db::iter()` walk galaxies in
/// creation order without us doing any sorting.
fn galaxy_key(id: GalaxyId) -> [u8; 16] {
*id.0.as_bytes()
}