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:
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
52
crates/sophia-core/src/memory.rs
Normal file
52
crates/sophia-core/src/memory.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
31
crates/sophia-core/src/persistence.rs
Normal file
31
crates/sophia-core/src/persistence.rs
Normal 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>,
|
||||
}
|
||||
45
crates/sophia-core/src/query.rs
Normal file
45
crates/sophia-core/src/query.rs
Normal 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,
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
90
crates/sophia-llm/src/introspect.rs
Normal file
90
crates/sophia-llm/src/introspect.rs
Normal 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()
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
92
crates/sophia-llm/src/query.rs
Normal file
92
crates/sophia-llm/src/query.rs
Normal 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: 1–3 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, 2–6 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())
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
393
crates/sophia-server/src/query.rs
Normal file
393
crates/sophia-server/src/query.rs
Normal 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 ~5–10 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
|
||||
/// ~20–40 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() {}
|
||||
}
|
||||
@@ -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.35–0.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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user