Implement Sophia MVP scaffold (stages 0–3 + topology pivot)

Six-crate Rust workspace (core/sim/llm/store/server/bin) backing a
Three.js + WebGL frontend. Live at http://127.0.0.1:7777 via `cargo run`.

Sim
- Event-driven scheduler with min-heap, per-engram tick, staggered Spawn
  events (40 ms apart) so each engram's flight is visually readable.
- Solid-donut torus topology (replaces original spherical density-driven
  shell, see Topology Pivot in docs/system-analysis.md). Configurable
  major/minor radii in config.toml; live `POST /api/galaxy/:id/resize`.
- Physics: Verlet integration + friction; in-hole pull + galactic spin
  (CCW around +z) for spiral-ejection ejection from the donut centre;
  soft tube boundary with velocity-reflecting wall.
- Cosine-weighted gravity (kiddo k-NN within radius 25, threshold 0.50)
  and synapse formation (threshold 0.62) gated to inside-the-tube only.
- LM Studio integration via OpenAI-compatible REST: batched embeddings,
  optional Bearer auth, semaphore-bounded parallel ops per §13.5.

Server
- axum HTTP + WebSocket. Routes: /healthz, /api/galaxy CRUD, /seed,
  /ingest, /resize, /engrams/:id, /ws/galaxy/:id/events.
- Binary 12-byte-aligned position frames at ~20 Hz; JSON for sparse
  events (Hello, EngramCreated, SynapseCreated, TorusUpdated).
- Layered config: config.toml (defaults) + config.local.toml (secrets,
  gitignored) merged on startup.

Frontend
- Vite + vanilla TypeScript + three.js 0.169.
- Engrams render as additive bloom-friendly point sprites with a
  per-engram hash-driven hue rotation and breathing pulse.
- Comet-style velocity-aligned trails; additive ribbon synapses whose
  endpoints track engram positions every frame.
- UnrealBloomPass + ACES tone-mapping for the linked-particles look.
- HUD shows torus dims, engram + synapse counts, LM Studio status;
  controls for seed, ingest, and live torus resize.

Docs
- README replaced with docs/IDEA.md; system-analysis.md updated with
  the topology pivot decisions and Galaxy Ejection refinement notes
  (the implementation plan lives in ~/.claude/plans, gitignored).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-28 08:29:37 +02:00
parent 43c4d270e6
commit 8688f632bf
44 changed files with 7664 additions and 100 deletions

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
/target
**/node_modules
web/dist
**/.DS_Store
*.log
sled_data/
config.local.toml
.env

2518
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

67
Cargo.toml Normal file
View File

@@ -0,0 +1,67 @@
[workspace]
resolver = "2"
members = [
"crates/sophia-core",
"crates/sophia-sim",
"crates/sophia-llm",
"crates/sophia-store",
"crates/sophia-server",
"crates/sophia-bin",
]
[workspace.package]
version = "0.0.1"
edition = "2021"
license = "MIT"
rust-version = "1.80"
authors = ["Sophia"]
[workspace.dependencies]
# Internal
sophia-core = { path = "crates/sophia-core" }
sophia-sim = { path = "crates/sophia-sim" }
sophia-llm = { path = "crates/sophia-llm" }
sophia-store = { path = "crates/sophia-store" }
sophia-server = { path = "crates/sophia-server" }
# Errors / runtime
anyhow = "1"
thiserror = "1"
tokio = { version = "1.40", features = ["full"] }
async-trait = "0.1"
# Serde
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
# Math, ids
glam = { version = "0.28", features = ["serde"] }
uuid = { version = "1", features = ["v7", "serde"] }
# HTTP/WS server (used by sophia-server only)
axum = { version = "0.7", features = ["ws", "macros"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["fs", "trace", "cors"] }
# HTTP client (LM Studio)
reqwest = { version = "0.12", features = ["json", "stream"] }
# Sim
kiddo = "4"
rand = { version = "0.8", features = ["small_rng"] }
# Async helpers
futures-util = { version = "0.3", default-features = false, features = ["std", "sink"] }
bytes = "1"
# Config
toml = "0.8"
[profile.release]
lto = "thin"
codegen-units = 1
opt-level = 3

50
config.toml Normal file
View File

@@ -0,0 +1,50 @@
# Sophia runtime configuration.
# Copy to config.local.toml to override locally (gitignored).
[server]
host = "127.0.0.1"
port = 7777
static_dir = "web/dist"
[lm_studio]
# LM Studio's OpenAI-compatible REST endpoint.
base_url = "http://127.0.0.1:1234/v1"
chat_model = "google/gemma-4-e4b"
# Embedding model loaded in LM Studio. Often a separate small model
# (e.g. "nomic-embed-text-v1.5"). Set this to whatever you load.
embedding_model = "text-embedding-nomic-embed-text-v1.5"
# Fixed parallel-op ceiling per §13.5 of docs/system-analysis.md.
parallel_ops = 8
# LM Studio 0.3.x+ enables auth by default. Create a token in the LM Studio
# app under Developer → API Tokens, then put it in `config.local.toml`
# (which is gitignored) like:
#
# [lm_studio]
# api_token = "lms-..."
#
# config.local.toml is merged on top of this file at startup, so it only
# needs to contain the fields you want to override. Leave api_token unset
# if your LM Studio install has auth disabled.
# Default torus shape for newly-created galaxies. The world is fixed-size
# (per the Topology Pivot — we replaced density-driven recentering with
# manually-resizable torus volumes). Live resize available at
# `POST /api/galaxy/:id/resize`. major_radius must exceed minor_radius.
[galaxy_defaults]
major_radius = 100.0
minor_radius = 30.0
# Caveman token caps per §13.5.
[token_caps]
introspection_in = 400
introspection_out = 200
peer_msg_in = 100
peer_msg_out = 100
synthesis_in = 300
synthesis_out = 80
query_responder_in = 200
query_responder_out = 150
query_integrator_in = 800
query_integrator_out = 300
hard_ceiling_in = 1024
hard_ceiling_out = 400

View File

@@ -0,0 +1,24 @@
[package]
name = "sophia-bin"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
authors.workspace = true
[[bin]]
name = "sophia"
path = "src/main.rs"
[dependencies]
sophia-core = { workspace = true }
sophia-server = { workspace = true }
sophia-sim = { workspace = true }
sophia-llm = { workspace = true }
anyhow = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
serde = { workspace = true }
toml = { workspace = true }

View File

@@ -0,0 +1,133 @@
//! Sophia binary. Reads `config.toml`, initializes tracing, starts the server.
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::Deserialize;
use sophia_core::GalaxyShape;
use sophia_llm::{LmStudioClient, LmStudioConfig, TokenCapsAll};
use sophia_server::{serve, ServerConfig};
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
#[derive(Debug, Deserialize)]
struct Config {
server: ServerSection,
lm_studio: LmStudioConfig,
#[allow(dead_code)] // wired in to LLM call sites in Stages 4+
token_caps: TokenCapsAll,
#[serde(default)]
galaxy_defaults: GalaxyDefaultsSection,
}
#[derive(Debug, Deserialize)]
struct ServerSection {
host: String,
port: u16,
static_dir: String,
}
#[derive(Debug, Deserialize)]
struct GalaxyDefaultsSection {
major_radius: f32,
minor_radius: f32,
}
impl Default for GalaxyDefaultsSection {
fn default() -> Self {
Self {
major_radius: GalaxyShape::DEFAULT_MAJOR,
minor_radius: GalaxyShape::DEFAULT_MINOR,
}
}
}
fn init_tracing() {
tracing_subscriber::registry()
.with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info,sophia=debug")))
.with(fmt::layer())
.init();
}
/// Recursively merge `local` into `base`. Tables are deep-merged; scalars
/// and arrays are overwritten by `local`. Used to layer `config.local.toml`
/// (gitignored, secrets) on top of `config.toml` (versioned defaults).
fn merge_toml(base: &mut toml::Value, local: toml::Value) {
match (base, local) {
(toml::Value::Table(b), toml::Value::Table(l)) => {
for (k, v) in l {
match b.get_mut(&k) {
Some(existing) => merge_toml(existing, v),
None => {
b.insert(k, v);
}
}
}
}
(slot, other) => *slot = other,
}
}
fn load_config() -> Result<Config> {
let base_path = "config.toml";
if !Path::new(base_path).exists() {
anyhow::bail!("no config.toml found in cwd");
}
let base_text = std::fs::read_to_string(base_path)
.with_context(|| format!("reading {base_path}"))?;
let mut merged: toml::Value = toml::from_str(&base_text)
.with_context(|| format!("parsing {base_path}"))?;
tracing::info!("loaded config from {base_path}");
let local_path = "config.local.toml";
if Path::new(local_path).exists() {
let local_text = std::fs::read_to_string(local_path)
.with_context(|| format!("reading {local_path}"))?;
let local_value: toml::Value = toml::from_str(&local_text)
.with_context(|| format!("parsing {local_path}"))?;
merge_toml(&mut merged, local_value);
tracing::info!("merged overrides from {local_path}");
}
let cfg: Config = merged.try_into().context("deserializing merged config")?;
Ok(cfg)
}
#[tokio::main]
async fn main() -> Result<()> {
init_tracing();
let cfg = load_config()?;
let server_cfg = ServerConfig {
host: cfg.server.host,
port: cfg.server.port,
static_dir: PathBuf::from(cfg.server.static_dir),
};
let llm = LmStudioClient::new(cfg.lm_studio);
// Best-effort startup ping — failure logs but doesn't block boot, so the
// server starts even if LM Studio isn't running yet (the user can launch
// it after, and `/healthz` will report current status).
match llm.list_models().await {
Ok(models) => tracing::info!("LM Studio reachable; {} model(s) loaded", models.len()),
Err(e) => tracing::warn!("LM Studio unreachable at startup: {e}"),
}
let default_shape = GalaxyShape {
major_radius: cfg.galaxy_defaults.major_radius,
minor_radius: cfg.galaxy_defaults.minor_radius,
};
if let Err(msg) = default_shape.validate() {
anyhow::bail!("invalid [galaxy_defaults] in config: {msg}");
}
tracing::info!(
"galaxy defaults: major_radius={} minor_radius={}",
default_shape.major_radius,
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);
serve(server_cfg, sim, llm).await
}

View File

@@ -0,0 +1,13 @@
[package]
name = "sophia-core"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
authors.workspace = true
[dependencies]
serde = { workspace = true }
glam = { workspace = true }
uuid = { workspace = true }
thiserror = { workspace = true }

View File

@@ -0,0 +1,104 @@
//! Data-transfer objects shared between the simulation and the server.
//!
//! These types cross the channel between `sophia-sim` and `sophia-server`,
//! and several are also serialized to JSON over WebSocket text frames.
//! `PositionFrame` is broadcast as a binary frame instead — see
//! `sophia-server::ws` for the wire encoding.
use serde::{Deserialize, Serialize};
use crate::engram::EngramState;
use crate::ids::{EngramId, GalaxyId, SynapseId};
use crate::manifest::Manifest;
use crate::synapse::Synapse;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GalaxyInfo {
pub id: GalaxyId,
pub name: String,
pub engram_count: usize,
pub center: [f32; 3],
pub major_radius: f32,
pub minor_radius: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EngramSnapshot {
pub id: EngramId,
pub instance_idx: u32,
pub position: [f32; 3],
pub size: f32,
pub state: EngramState,
}
/// Wire-friendly synapse — same as `Synapse` but always serialized in the
/// canonical (a, b) order. Sent over WS for both initial Hello and live
/// `SynapseCreated` events.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SynapseDto {
pub id: SynapseId,
pub a: EngramId,
pub b: EngramId,
pub weight: f32,
}
impl From<&Synapse> for SynapseDto {
fn from(s: &Synapse) -> Self {
Self { id: s.id, a: s.a, b: s.b, weight: s.weight }
}
}
/// Detailed view returned by `GET /api/galaxy/:gid/engrams/:eid`. Includes the
/// manifest (potentially long) but only the slate's norm + dim — the full
/// embedding vector would be wasteful to send on every inspector click.
#[derive(Debug, Clone, Serialize)]
pub struct EngramDetail {
pub id: EngramId,
pub instance_idx: u32,
pub position: [f32; 3],
pub size: f32,
pub state: EngramState,
pub age: u32,
pub manifest: Option<Manifest>,
pub slate_dim: Option<usize>,
pub slate_norm: Option<f32>,
}
/// Position frame: a downsampled bundle of all engram positions at a moment in
/// time. Encoded to a binary WS frame at the wire layer.
#[derive(Debug, Clone)]
pub struct PositionFrame {
pub t_ms: u32,
/// Indexed by `instance_idx` (dense 0..n).
pub positions: Vec<[f32; 3]>,
}
/// Events emitted by the simulation to subscribed WS clients.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum SimEvent {
Hello {
galaxy: GalaxyInfo,
engrams: Vec<EngramSnapshot>,
synapses: Vec<SynapseDto>,
},
EngramCreated {
snapshot: EngramSnapshot,
},
/// A new synapse formed between two engrams. (Stage 3 only emits creates;
/// updates and removes arrive in later stages.)
SynapseCreated {
synapse: SynapseDto,
},
/// Torus shape changed (initial publish at subscribe + on every resize).
/// Replaces the legacy `BBoxUpdated` event from the spherical topology.
TorusUpdated {
center: [f32; 3],
major_radius: f32,
minor_radius: f32,
},
/// Position frame is sent over the wire as a binary frame; this variant
/// carries the in-process payload.
#[serde(skip)]
PositionFrame(PositionFrame),
}

View File

@@ -0,0 +1,54 @@
use glam::Vec3;
use serde::{Deserialize, Serialize};
use crate::ids::EngramId;
use crate::manifest::Manifest;
use crate::slate::Slate;
/// Engram state machine — see §5 of `docs/system-analysis.md`.
///
/// Stage 1 only uses `Idle`. Later stages add `Searching`, `Conversing`,
/// `Synthesizing`, `Memorize`, `Decaying`, `Deprecated`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EngramState {
Idle = 0,
Searching = 1,
Conversing = 2,
Synthesizing = 3,
Memorize = 4,
Decaying = 5,
Deprecated = 6,
}
impl EngramState {
pub fn as_u8(self) -> u8 {
self as u8
}
}
/// One unit of knowledge — the fundamental agent.
///
/// 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)]
pub struct Engram {
pub id: EngramId,
/// Slot index for the visualization's `InstancedMesh`. Assigned at birth,
/// stable for the engram's lifetime, dense within a galaxy.
pub instance_idx: u32,
pub position: Vec3,
/// Velocity carried over between ticks — gives the simulation momentum so
/// motion glides instead of jittering. Integrated with friction in
/// `sophia_sim::physics::tick`.
pub velocity: Vec3,
pub size: f32,
pub state: EngramState,
/// Ticks since birth. Drives curiosity decay (see `physics::tick`).
pub age: u32,
/// What this engram represents. `None` for synthetic seed engrams.
pub manifest: Option<Manifest>,
/// Embedding vector — System-1 layer of the Universal Slate. `None` for
/// synthetic seed engrams.
pub slate: Option<Slate>,
}

View File

@@ -0,0 +1,77 @@
use glam::Vec3;
use serde::{Deserialize, Serialize};
use crate::ids::GalaxyId;
/// Geometry of a galaxy's Space of Recollection — a solid 3D torus volume.
///
/// `major_radius` is the distance from the donut's center to the centerline
/// of the tube; `minor_radius` is the tube's own radius. Engrams live inside
/// the tube. The torus is fixed-size (manually resized via API), not
/// density-driven — see the Topology Pivot in the implementation plan.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct GalaxyShape {
pub major_radius: f32,
pub minor_radius: f32,
}
impl GalaxyShape {
pub const DEFAULT_MAJOR: f32 = 100.0;
pub const DEFAULT_MINOR: f32 = 30.0;
/// "Great Reflection" — the void at the very center of the donut hole.
/// New engrams materialize here and fly outward into the tube. From the
/// center, every direction in the xy-plane heads toward the tube; physics
/// + a small initial outward velocity does the rest.
pub fn birth_point(&self, center: Vec3) -> Vec3 {
center
}
pub fn validate(&self) -> Result<(), &'static str> {
if !(self.major_radius.is_finite() && self.minor_radius.is_finite()) {
return Err("radii must be finite");
}
if self.minor_radius <= 0.1 {
return Err("minor_radius must be > 0.1");
}
if self.major_radius <= self.minor_radius {
return Err("major_radius must exceed minor_radius");
}
if self.major_radius > 10_000.0 {
return Err("major_radius must be <= 10_000");
}
Ok(())
}
}
impl Default for GalaxyShape {
fn default() -> Self {
Self {
major_radius: Self::DEFAULT_MAJOR,
minor_radius: Self::DEFAULT_MINOR,
}
}
}
/// Galaxy metadata — the set of Engrams it contains lives inside the simulation.
#[derive(Debug, Clone, Serialize, Deserialize)]
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)]
pub center: Vec3,
pub shape: GalaxyShape,
}
impl Galaxy {
pub fn new(name: impl Into<String>, shape: GalaxyShape) -> Self {
Self {
id: GalaxyId::new(),
name: name.into(),
center: Vec3::ZERO,
shape,
}
}
}

View File

@@ -0,0 +1,50 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct EngramId(pub Uuid);
impl EngramId {
pub fn new() -> Self {
Self(Uuid::now_v7())
}
}
impl Default for EngramId {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct GalaxyId(pub Uuid);
impl GalaxyId {
pub fn new() -> Self {
Self(Uuid::now_v7())
}
}
impl Default for GalaxyId {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct SynapseId(pub Uuid);
impl SynapseId {
pub fn new() -> Self {
Self(Uuid::now_v7())
}
}
impl Default for SynapseId {
fn default() -> Self {
Self::new()
}
}

View File

@@ -0,0 +1,23 @@
//! Sophia domain types. Pure data — no I/O, no async.
//!
//! See `docs/system-analysis.md` §1 (System Boundary) and §5 (Engram Dynamics)
//! for the conceptual model.
pub mod dto;
pub mod engram;
pub mod galaxy;
pub mod ids;
pub mod manifest;
pub mod slate;
pub mod synapse;
pub use dto::{EngramDetail, EngramSnapshot, GalaxyInfo, PositionFrame, SimEvent, SynapseDto};
pub use engram::{Engram, EngramState};
pub use galaxy::{Galaxy, GalaxyShape};
pub use ids::{EngramId, GalaxyId, SynapseId};
pub use manifest::Manifest;
pub use slate::Slate;
pub use synapse::{canonical_pair, Synapse};
// Re-export glam::Vec3 so consumers don't all need to depend on glam directly.
pub use glam::Vec3;

View File

@@ -0,0 +1,26 @@
use serde::{Deserialize, Serialize};
/// What an Engram *is* — its source content and self-description.
///
/// Stage 2 only carries `Text` (paragraphs from ingest). Later stages add
/// taxonomy/goals (Stage 4 introspection) and richer modalities.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Manifest {
Text { content: String },
}
impl Manifest {
pub fn short_label(&self, max_chars: usize) -> String {
match self {
Manifest::Text { content } => {
if content.chars().count() <= max_chars {
content.clone()
} else {
let truncated: String = content.chars().take(max_chars).collect();
format!("{truncated}")
}
}
}
}
}

View File

@@ -0,0 +1,35 @@
use serde::{Deserialize, Serialize};
/// Universal Slate (per §13.1) — Stage 2 layer 1 only: a dense embedding
/// vector. The deeper LLM-comparison layer arrives in Stage 4.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Slate(pub Vec<f32>);
impl Slate {
pub fn dim(&self) -> usize {
self.0.len()
}
pub fn norm(&self) -> f32 {
self.0.iter().map(|x| x * x).sum::<f32>().sqrt()
}
/// Cosine similarity in `[-1, 1]`. Returns 0 if either vector is zero or
/// dimensions disagree (the latter can happen across embedding model
/// changes — see §13 risk #3).
pub fn cosine(&self, other: &Slate) -> f32 {
if self.0.len() != other.0.len() {
return 0.0;
}
let mut dot = 0.0_f32;
let mut a2 = 0.0_f32;
let mut b2 = 0.0_f32;
for (a, b) in self.0.iter().zip(other.0.iter()) {
dot += a * b;
a2 += a * a;
b2 += b * b;
}
let denom = (a2.sqrt() * b2.sqrt()).max(1e-9);
dot / denom
}
}

View File

@@ -0,0 +1,25 @@
//! Synapse — a bidirectional connection between two Engrams formed when
//! they spend time close together with a high cosine similarity on their
//! Slates. See `docs/system-analysis.md` §1 + §3 (Stage 3 of the impl plan).
use serde::{Deserialize, Serialize};
use crate::ids::{EngramId, SynapseId};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Synapse {
pub id: SynapseId,
/// Canonical pair endpoints — `a` is always the lexicographically smaller
/// uuid so `(a, b)` is order-independent.
pub a: EngramId,
pub b: EngramId,
/// Strength in `[-1, 1]` — currently set to the cosine similarity at
/// formation time. Stage 3 doesn't update it; future stages may.
pub weight: f32,
}
/// Lay two ids out canonically (lower uuid first) so a `(a, b)` pair lookup
/// is order-independent.
pub fn canonical_pair(x: EngramId, y: EngramId) -> (EngramId, EngramId) {
if x.0 <= y.0 { (x, y) } else { (y, x) }
}

View File

@@ -0,0 +1,19 @@
[package]
name = "sophia-llm"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
authors.workspace = true
[dependencies]
sophia-core = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
reqwest = { workspace = true }
async-trait = { workspace = true }

View File

@@ -0,0 +1,49 @@
//! Token caps from §13.5. Loaded from `config.toml`'s `[token_caps]` section.
use serde::Deserialize;
/// Per-call cap (input/output tokens). Output is enforced via
/// `max_tokens` in the chat request; input is best-effort and currently
/// only documented (no tokenizer in MVP).
#[derive(Debug, Clone, Copy)]
pub struct TokenCaps {
pub input: u32,
pub output: u32,
}
#[derive(Debug, Clone, Deserialize)]
pub struct TokenCapsAll {
pub introspection_in: u32,
pub introspection_out: u32,
pub peer_msg_in: u32,
pub peer_msg_out: u32,
pub synthesis_in: u32,
pub synthesis_out: u32,
pub query_responder_in: u32,
pub query_responder_out: u32,
pub query_integrator_in: u32,
pub query_integrator_out: u32,
pub hard_ceiling_in: u32,
pub hard_ceiling_out: u32,
}
impl TokenCapsAll {
pub fn introspection(&self) -> TokenCaps {
TokenCaps { input: self.introspection_in, output: self.introspection_out }
}
pub fn peer_msg(&self) -> TokenCaps {
TokenCaps { input: self.peer_msg_in, output: self.peer_msg_out }
}
pub fn synthesis(&self) -> TokenCaps {
TokenCaps { input: self.synthesis_in, output: self.synthesis_out }
}
pub fn query_responder(&self) -> TokenCaps {
TokenCaps { input: self.query_responder_in, output: self.query_responder_out }
}
pub fn query_integrator(&self) -> TokenCaps {
TokenCaps { input: self.query_integrator_in, output: self.query_integrator_out }
}
pub fn hard_ceiling(&self) -> TokenCaps {
TokenCaps { input: self.hard_ceiling_in, output: self.hard_ceiling_out }
}
}

View File

@@ -0,0 +1,229 @@
//! Minimal LM Studio HTTP client. Talks to LM Studio's OpenAI-compatible
//! endpoints (`/v1/embeddings`, `/v1/chat/completions`, `/v1/models`).
//!
//! Concurrent inferences are bounded by a semaphore (per §13.5 — fixed
//! parallel-op ceiling). The semaphore is shared across all callers so the
//! total system-wide concurrency is N.
use std::sync::Arc;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tokio::sync::Semaphore;
use tracing::{debug, warn};
use crate::budget::TokenCaps;
/// Max texts per single embed_batch call. Stage 2 chunks larger ingests in the
/// server. Tuned conservatively for `nomic-embed-text-v1.5` running on a single
/// GPU — a higher number is fine for stronger hardware.
pub const EMBED_BATCH_LIMIT: usize = 32;
#[derive(Debug, Clone, Deserialize)]
pub struct LmStudioConfig {
pub base_url: String,
pub chat_model: String,
pub embedding_model: String,
pub parallel_ops: usize,
/// Optional Bearer token. LM Studio 0.3.x+ enables this by default; create
/// one in the LM Studio app under Developer → API Tokens. Either set it
/// here in config.local.toml or expose it as `LM_STUDIO_API_TOKEN` and
/// reference via env (Stage 2 keeps it simple — config-only).
#[serde(default)]
pub api_token: Option<String>,
}
#[derive(Debug, Error)]
pub enum LmError {
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
#[error("LM Studio returned status {0}: {1}")]
Status(u16, String),
#[error("LM Studio returned malformed JSON: {0}")]
Json(String),
#[error("response had no content")]
EmptyResponse,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ChatRole {
System,
User,
Assistant,
}
#[derive(Debug, Clone, Serialize)]
pub struct ChatMessage {
pub role: ChatRole,
pub content: String,
}
impl ChatMessage {
pub fn system(s: impl Into<String>) -> Self {
Self { role: ChatRole::System, content: s.into() }
}
pub fn user(s: impl Into<String>) -> Self {
Self { role: ChatRole::User, content: s.into() }
}
}
/// Cheaply cloneable handle. Wrapped in `Arc` internally; `Clone` shares the
/// semaphore so the parallel-op ceiling is global.
#[derive(Clone)]
pub struct LmStudioClient {
inner: Arc<Inner>,
}
struct Inner {
http: reqwest::Client,
cfg: LmStudioConfig,
parallel: Semaphore,
}
impl LmStudioClient {
pub fn new(cfg: LmStudioConfig) -> Self {
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(120))
.build()
.expect("reqwest client builds");
let parallel = Semaphore::new(cfg.parallel_ops.max(1));
Self { inner: Arc::new(Inner { http, cfg, parallel }) }
}
pub fn config(&self) -> &LmStudioConfig {
&self.inner.cfg
}
/// 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.
fn auth(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
match &self.inner.cfg.api_token {
Some(t) if !t.is_empty() => req.bearer_auth(t),
_ => req,
}
}
/// Lightweight check: list models from LM Studio. Returns the available
/// model ids on success. Used by `/healthz`.
pub async fn list_models(&self) -> Result<Vec<String>, LmError> {
let url = format!("{}/models", self.inner.cfg.base_url.trim_end_matches('/'));
let resp = self.auth(self.inner.http.get(&url)).send().await?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(LmError::Status(status.as_u16(), body));
}
let v: serde_json::Value = resp.json().await.map_err(|e| LmError::Json(e.to_string()))?;
let arr = v
.get("data")
.and_then(|d| d.as_array())
.ok_or_else(|| LmError::Json("missing `data` array".into()))?;
let ids = arr
.iter()
.filter_map(|m| m.get("id").and_then(|i| i.as_str()).map(String::from))
.collect();
Ok(ids)
}
/// Embed a single text. Bounded by the global parallel-op semaphore.
pub async fn embed(&self, text: &str) -> Result<Vec<f32>, LmError> {
let mut out = self.embed_batch(std::slice::from_ref(&text.to_string())).await?;
out.pop().ok_or(LmError::EmptyResponse)
}
/// Embed many texts in a single LM Studio call. Uses OpenAI's batch-input
/// embeddings form so we don't hammer the embedding endpoint with
/// concurrent requests (LM Studio's embedding server isn't reliably
/// reentrant — concurrent calls can return 500). One semaphore permit
/// per call regardless of batch size.
///
/// Returns embeddings in input order. The caller is responsible for
/// chunking very large inputs — see `EMBED_BATCH_LIMIT`.
pub async fn embed_batch(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, LmError> {
if texts.is_empty() {
return Ok(Vec::new());
}
let _permit = self.inner.parallel.acquire().await.expect("semaphore not closed");
let url = format!("{}/embeddings", self.inner.cfg.base_url.trim_end_matches('/'));
let body = serde_json::json!({
"model": self.inner.cfg.embedding_model,
"input": texts,
});
let resp = self.auth(self.inner.http.post(&url).json(&body)).send().await?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(LmError::Status(status.as_u16(), body));
}
let v: serde_json::Value = resp.json().await.map_err(|e| LmError::Json(e.to_string()))?;
let arr = v
.get("data")
.and_then(|d| d.as_array())
.ok_or_else(|| LmError::Json("missing `data` array".into()))?;
// Sort by index so we honour input order even if the server returns
// out of order (the OpenAI spec guarantees input-order, but be safe).
let mut indexed: Vec<(u64, Vec<f32>)> = arr
.iter()
.map(|m| {
let idx = m.get("index").and_then(|i| i.as_u64()).unwrap_or(u64::MAX);
let vec: Vec<f32> = m
.get("embedding")
.and_then(|e| e.as_array())
.map(|a| a.iter().filter_map(|n| n.as_f64().map(|x| x as f32)).collect())
.unwrap_or_default();
(idx, vec)
})
.collect();
indexed.sort_by_key(|(i, _)| *i);
if indexed.len() != texts.len() {
return Err(LmError::Json(format!(
"embed_batch: requested {} but got {}",
texts.len(),
indexed.len()
)));
}
if indexed.iter().any(|(_, v)| v.is_empty()) {
return Err(LmError::EmptyResponse);
}
Ok(indexed.into_iter().map(|(_, v)| v).collect())
}
/// One-shot chat completion (non-streaming). Bounded by the parallel-op
/// semaphore. `caps.output` becomes the request's `max_tokens`.
pub async fn chat(&self, messages: &[ChatMessage], caps: TokenCaps) -> Result<String, LmError> {
let _permit = self.inner.parallel.acquire().await.expect("semaphore not closed");
let url = format!("{}/chat/completions", self.inner.cfg.base_url.trim_end_matches('/'));
let body = serde_json::json!({
"model": self.inner.cfg.chat_model,
"messages": messages,
"max_tokens": caps.output,
"temperature": 0.6,
"stream": false,
});
debug!("chat: {} messages, max_tokens={}", messages.len(), caps.output);
let resp = self.auth(self.inner.http.post(&url).json(&body)).send().await?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
warn!("chat status {}: {}", status, body);
return Err(LmError::Status(status.as_u16(), body));
}
let v: serde_json::Value = resp.json().await.map_err(|e| LmError::Json(e.to_string()))?;
let content = v
.get("choices")
.and_then(|c| c.as_array())
.and_then(|arr| arr.first())
.and_then(|first| first.get("message"))
.and_then(|m| m.get("content"))
.and_then(|c| c.as_str())
.ok_or_else(|| LmError::Json("missing choices[0].message.content".into()))?
.to_string();
if content.is_empty() {
return Err(LmError::EmptyResponse);
}
Ok(content)
}
}

View File

@@ -0,0 +1,9 @@
//! LM Studio client + caveman prompts + parallel-op budget.
//!
//! See `docs/system-analysis.md` §13.5 (compute budget, token caps).
mod budget;
mod client;
pub use budget::{TokenCaps, TokenCapsAll};
pub use client::{ChatMessage, ChatRole, LmError, LmStudioClient, LmStudioConfig, EMBED_BATCH_LIMIT};

View File

@@ -0,0 +1,25 @@
[package]
name = "sophia-server"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
authors.workspace = true
[dependencies]
sophia-core = { workspace = true }
sophia-sim = { workspace = true }
sophia-llm = { workspace = true }
anyhow = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
axum = { workspace = true }
tower = { workspace = true }
tower-http = { workspace = true }
futures-util = { workspace = true }
bytes = { workspace = true }

View File

@@ -0,0 +1,241 @@
//! HTTP / WebSocket server.
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::Result;
use axum::extract::{Path, Query, State, WebSocketUpgrade};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::{delete, get, post};
use axum::{Json, Router};
use serde::Deserialize;
use sophia_core::{EngramId, GalaxyId, GalaxyShape, Manifest, Slate};
use sophia_llm::{LmStudioClient, EMBED_BATCH_LIMIT};
use sophia_sim::{IngestItem, SimHandle};
use tower_http::services::{ServeDir, ServeFile};
use tower_http::trace::TraceLayer;
mod ws;
#[derive(Debug, Clone)]
pub struct ServerConfig {
pub host: String,
pub port: u16,
pub static_dir: PathBuf,
}
#[derive(Clone)]
struct AppState {
sim: SimHandle,
llm: LmStudioClient,
}
pub fn build_router(cfg: &ServerConfig, sim: SimHandle, llm: LmStudioClient) -> 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 });
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/seed", post(seed_galaxy))
.route("/api/galaxy/:id/ingest", post(ingest_galaxy))
.route("/api/galaxy/:id/resize", post(resize_galaxy))
.route("/api/galaxy/:gid/engrams/:eid", get(get_engram))
.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);
let addr = format!("{}:{}", cfg.host, cfg.port);
let listener = tokio::net::TcpListener::bind(&addr).await?;
tracing::info!("sophia-server listening on http://{}", addr);
axum::serve(listener, app).await?;
Ok(())
}
// ---------- routes ----------
async fn healthz(State(s): State<Arc<AppState>>) -> Response {
// Stage 2: ping LM Studio's /models endpoint and report what's loaded.
let cfg = s.llm.config();
let (lm_ok, lm_models, lm_err) = match s.llm.list_models().await {
Ok(models) => (true, models, None),
Err(e) => (false, Vec::new(), Some(e.to_string())),
};
let chat_loaded = lm_models.iter().any(|m| m == &cfg.chat_model);
let embed_loaded = lm_models.iter().any(|m| m == &cfg.embedding_model);
Json(serde_json::json!({
"status": "ok",
"stage": 2,
"lm_studio": {
"reachable": lm_ok,
"configured_chat_model": cfg.chat_model,
"configured_embedding_model": cfg.embedding_model,
"chat_model_loaded": chat_loaded,
"embedding_model_loaded": embed_loaded,
"models_available": lm_models,
"error": lm_err,
}
}))
.into_response()
}
#[derive(Deserialize)]
struct CreateGalaxyBody {
name: String,
}
async fn create_galaxy(
State(s): State<Arc<AppState>>,
Json(body): Json<CreateGalaxyBody>,
) -> Response {
match s.sim.create_galaxy(body.name).await {
Ok(info) => Json(info).into_response(),
Err(e) => sim_error(e),
}
}
async fn list_galaxies(State(s): State<Arc<AppState>>) -> Response {
match s.sim.list_galaxies().await {
Ok(list) => Json(list).into_response(),
Err(e) => sim_error(e),
}
}
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()
}
#[derive(Deserialize)]
struct SeedQuery {
n: usize,
}
async fn seed_galaxy(
State(s): State<Arc<AppState>>,
Path(id): Path<GalaxyId>,
Query(q): Query<SeedQuery>,
) -> Response {
if q.n == 0 || q.n > 5_000 {
return (StatusCode::BAD_REQUEST, "n must be in 1..=5000").into_response();
}
match s.sim.seed(id, q.n).await {
Ok(ids) => Json(serde_json::json!({ "engram_ids": ids })).into_response(),
Err(e) => sim_error(e),
}
}
#[derive(Deserialize)]
struct IngestBody {
texts: Vec<String>,
}
/// Embed all texts via LM Studio's batch-input embeddings endpoint, then
/// ship the embedded items to the sim. Chunked at `EMBED_BATCH_LIMIT` so
/// large ingests don't time out a single LM Studio call. Chunks are issued
/// sequentially: LM Studio's embedding endpoint isn't reliably reentrant
/// (concurrent calls return 500), and a batch of ~32 already saturates the
/// embedding model on most setups.
async fn ingest_galaxy(
State(s): State<Arc<AppState>>,
Path(id): Path<GalaxyId>,
Json(body): Json<IngestBody>,
) -> Response {
if body.texts.is_empty() {
return (StatusCode::BAD_REQUEST, "texts must not be empty").into_response();
}
if body.texts.len() > 200 {
return (StatusCode::BAD_REQUEST, "max 200 texts per call").into_response();
}
// Filter empty/whitespace-only entries up front so chunk indices match.
let texts: Vec<String> = body.texts.into_iter().map(|t| t.trim().to_string()).collect();
if texts.iter().any(|t| t.is_empty()) {
return (StatusCode::BAD_REQUEST, "no empty/whitespace-only texts").into_response();
}
let mut all_vecs: Vec<Vec<f32>> = Vec::with_capacity(texts.len());
for chunk in texts.chunks(EMBED_BATCH_LIMIT) {
match s.llm.embed_batch(chunk).await {
Ok(mut v) => all_vecs.append(&mut v),
Err(e) => {
return (StatusCode::BAD_GATEWAY, format!("embed_batch: {e}")).into_response();
}
}
}
let items: Vec<IngestItem> = texts
.into_iter()
.zip(all_vecs)
.map(|(content, vec)| IngestItem {
manifest: Manifest::Text { content },
slate: Slate(vec),
})
.collect();
tracing::info!("ingest: {} items into galaxy {:?}", items.len(), id);
match s.sim.ingest(id, items).await {
Ok(ids) => Json(serde_json::json!({ "engram_ids": ids })).into_response(),
Err(e) => sim_error(e),
}
}
#[derive(Deserialize)]
struct ResizeBody {
major_radius: f32,
minor_radius: f32,
}
async fn resize_galaxy(
State(s): State<Arc<AppState>>,
Path(id): Path<GalaxyId>,
Json(body): Json<ResizeBody>,
) -> Response {
let shape = GalaxyShape {
major_radius: body.major_radius,
minor_radius: body.minor_radius,
};
if let Err(msg) = shape.validate() {
return (StatusCode::BAD_REQUEST, msg).into_response();
}
match s.sim.resize(id, shape).await {
Ok(info) => Json(info).into_response(),
Err(e) => sim_error(e),
}
}
async fn get_engram(
State(s): State<Arc<AppState>>,
Path((gid, eid)): Path<(GalaxyId, EngramId)>,
) -> Response {
match s.sim.get_engram(gid, eid).await {
Ok(Some(detail)) => Json(detail).into_response(),
Ok(None) => (StatusCode::NOT_FOUND, "engram not found in this galaxy").into_response(),
Err(e) => sim_error(e),
}
}
async fn ws_events(
State(s): State<Arc<AppState>>,
Path(id): Path<GalaxyId>,
upgrade: WebSocketUpgrade,
) -> Response {
upgrade.on_upgrade(move |socket| ws::run_galaxy_socket(socket, s.sim.clone(), id))
}
fn sim_error(e: sophia_sim::SimError) -> Response {
use sophia_sim::SimError::*;
let status = match e {
Shutdown => StatusCode::SERVICE_UNAVAILABLE,
UnknownGalaxy => StatusCode::NOT_FOUND,
InvalidShape(_) => StatusCode::BAD_REQUEST,
};
(status, e.to_string()).into_response()
}

View File

@@ -0,0 +1,108 @@
//! WebSocket bridge from the simulation to a single browser client.
//!
//! Wire protocol:
//! - `text` frames: JSON-serialized [`SimEvent`] (Hello, EngramCreated,
//! BBoxUpdated). One message per frame.
//! - `binary` frames: position frames, encoded as
//! `[tag u32 LE = 0x01][t_ms u32 LE][n u32 LE][n × (x f32 LE, y f32 LE, z f32 LE)]`
//! — 12-byte header followed by the float region. The header is encoded
//! as three little-endian u32s rather than a tighter (u8, u32, u32) so the
//! float region starts at a 4-byte-aligned offset, which lets the browser
//! wrap it as a `Float32Array` view without copying. (`Float32Array`
//! requires its byte offset to be a multiple of 4 and throws otherwise.)
//! One frame at ~20 Hz, in `instance_idx` order.
//!
//! See `docs/system-analysis.md` Risk #1 in §13 plan for why we use binary
//! frames here.
use axum::extract::ws::{Message, WebSocket};
use bytes::{BufMut, BytesMut};
use futures_util::{SinkExt, StreamExt};
use sophia_core::{GalaxyId, PositionFrame, SimEvent};
use sophia_sim::SimHandle;
use tokio::sync::broadcast;
use tracing::{debug, warn};
const POS_FRAME_TAG: u32 = 0x01;
pub async fn run_galaxy_socket(socket: WebSocket, sim: SimHandle, galaxy: GalaxyId) {
let (mut sender, mut receiver) = socket.split();
let (info, snapshots, synapses, mut bus) = match sim.subscribe(galaxy).await {
Ok(t) => t,
Err(e) => {
warn!("ws subscribe failed: {e}");
let _ = sender
.send(Message::Text(
serde_json::json!({ "type": "error", "message": e.to_string() }).to_string(),
))
.await;
return;
}
};
// Hello: tell the client about the galaxy + every existing engram + synapse.
let hello = SimEvent::Hello { galaxy: info, engrams: snapshots, synapses };
if let Err(e) = send_text(&mut sender, &hello).await {
debug!("ws hello send failed: {e}");
return;
}
loop {
tokio::select! {
// Inbound: ignore messages for now (Stage 1 has no client→server).
// Just drain so the socket stays alive and we notice closure.
msg = receiver.next() => {
match msg {
Some(Ok(Message::Close(_))) | None => break,
Some(Err(e)) => { debug!("ws recv err: {e}"); break; }
Some(Ok(_)) => {}
}
}
// Outbound: forward sim events to the client.
ev = bus.recv() => {
match ev {
Ok(SimEvent::PositionFrame(f)) => {
if let Err(e) = sender.send(Message::Binary(encode_position_frame(&f))).await {
debug!("ws send pos frame failed: {e}");
break;
}
}
Ok(other) => {
if let Err(e) = send_text(&mut sender, &other).await {
debug!("ws send text failed: {e}");
break;
}
}
Err(broadcast::error::RecvError::Closed) => break,
Err(broadcast::error::RecvError::Lagged(n)) => {
warn!("ws lagged by {n} events; client will catch up on next frame");
}
}
}
}
}
}
async fn send_text(
sender: &mut futures_util::stream::SplitSink<WebSocket, Message>,
ev: &SimEvent,
) -> anyhow::Result<()> {
let body = serde_json::to_string(ev)?;
sender.send(Message::Text(body)).await?;
Ok(())
}
fn encode_position_frame(f: &PositionFrame) -> Vec<u8> {
let n = f.positions.len();
let mut buf = BytesMut::with_capacity(12 + n * 12);
buf.put_u32_le(POS_FRAME_TAG);
buf.put_u32_le(f.t_ms);
buf.put_u32_le(n as u32);
for [x, y, z] in &f.positions {
buf.put_f32_le(*x);
buf.put_f32_le(*y);
buf.put_f32_le(*z);
}
buf.to_vec()
}

View File

@@ -0,0 +1,20 @@
[package]
name = "sophia-sim"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
authors.workspace = true
[dependencies]
sophia-core = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
glam = { workspace = true }
serde = { workspace = true }
kiddo = { workspace = true }
rand = { workspace = true }

View File

@@ -0,0 +1,588 @@
//! Public surface of the simulation: a Send/Sync handle wrapping a tokio mpsc
//! sender. Server tasks talk to the sim through this — they never touch
//! `World` directly.
use std::time::{Duration, Instant};
use rand::rngs::SmallRng;
use rand::{Rng, SeedableRng};
use thiserror::Error;
use tokio::sync::{broadcast, mpsc, oneshot};
use tracing::warn;
use sophia_core::{
Engram, EngramDetail, EngramId, EngramSnapshot, EngramState, GalaxyId, GalaxyInfo, GalaxyShape,
Manifest, PositionFrame, SimEvent, Slate, SynapseDto, Vec3,
};
/// One item to ingest into the sim. The server pre-computes the slate via the
/// LM Studio embedding endpoint and hands the sim a fully-formed payload.
#[derive(Debug, Clone)]
pub struct IngestItem {
pub manifest: Manifest,
pub slate: Slate,
}
/// Reply payload for `SimHandle::subscribe`. Aliased here to keep the
/// `SimCmd::Subscribe` variant within clippy's complexity bounds.
type SubscribeReply = (
GalaxyInfo,
Vec<EngramSnapshot>,
Vec<SynapseDto>,
broadcast::Receiver<SimEvent>,
);
use crate::index::KiddoIndex;
use crate::physics;
use crate::scheduler::{Event, Scheduler, SpawnPayload};
use crate::world::{GalaxyState, World};
const FRAME_INTERVAL: Duration = Duration::from_millis(50); // 20 Hz
const REBUILD_INTERVAL: Duration = Duration::from_millis(500);
const TICK_INTERVAL: Duration = Duration::from_millis(50);
/// Spacing between scheduled `Spawn` events for a single seed/ingest call —
/// engrams are released gradually instead of all at once so each one's
/// trajectory from the birth point to the tube is visually readable.
const SPAWN_SPACING: Duration = Duration::from_millis(40);
/// Spawn jitter — tiny so all new engrams visibly emerge from the same
/// pinpoint at the donut center. Their initial velocities (random direction,
/// `BIRTH_SPEED`) do the actual fanning-out.
const BIRTH_JITTER: f32 = 0.3;
/// Initial speed given to a new engram. The velocity is split into a small
/// radial kick + a larger tangential kick (CCW around +z), so combined with
/// the in-hole spin force in `physics.rs` engrams emerge in a rotating
/// galaxy pattern instead of straight radial lines.
const BIRTH_SPEED: f32 = 14.0;
// ---- Stage 3: gravity + synapse formation ----
/// Maximum distance at which gravity / synapse-formation considers a peer.
const GRAVITY_RADIUS: f32 = 25.0;
/// Cosine-similarity threshold for any pull at all. Below this engrams ignore
/// each other completely. Tuned for `nomic-embed-text-v1.5` where unrelated
/// text typically sits in the 0.30.5 range and related text 0.6+.
const GRAVITY_THRESHOLD: f32 = 0.50;
/// Acceleration scale applied per qualifying neighbour (multiplied by
/// `(cos - threshold)`). Total gravity force is bounded by
/// `GRAVITY_MAX_ACC` so a dense cluster doesn't snap engrams together.
const GRAVITY_K: f32 = 12.0;
const GRAVITY_MAX_ACC: f32 = 30.0;
/// Cosine-similarity threshold for *forming* a synapse — slightly stricter
/// than the gravity threshold so weak co-residence doesn't link everyone.
const SYNAPSE_THRESHOLD: f32 = 0.62;
#[derive(Debug, Error)]
pub enum SimError {
#[error("simulation has shut down")]
Shutdown,
#[error("galaxy not found")]
UnknownGalaxy,
#[error("invalid galaxy shape: {0}")]
InvalidShape(&'static str),
}
#[derive(Debug)]
enum SimCmd {
CreateGalaxy {
name: String,
reply: oneshot::Sender<GalaxyInfo>,
},
ListGalaxies {
reply: oneshot::Sender<Vec<GalaxyInfo>>,
},
Seed {
galaxy: GalaxyId,
n: usize,
reply: oneshot::Sender<Result<Vec<EngramId>, SimError>>,
},
Ingest {
galaxy: GalaxyId,
items: Vec<IngestItem>,
reply: oneshot::Sender<Result<Vec<EngramId>, SimError>>,
},
GetEngram {
galaxy: GalaxyId,
engram: EngramId,
reply: oneshot::Sender<Result<Option<EngramDetail>, SimError>>,
},
Subscribe {
galaxy: GalaxyId,
reply: oneshot::Sender<Result<SubscribeReply, SimError>>,
},
Resize {
galaxy: GalaxyId,
shape: GalaxyShape,
reply: oneshot::Sender<Result<GalaxyInfo, SimError>>,
},
}
#[derive(Clone)]
pub struct SimHandle {
tx: mpsc::Sender<SimCmd>,
}
impl SimHandle {
pub async fn create_galaxy(&self, name: String) -> Result<GalaxyInfo, SimError> {
let (reply, rx) = oneshot::channel();
self.tx.send(SimCmd::CreateGalaxy { name, reply }).await.map_err(|_| SimError::Shutdown)?;
rx.await.map_err(|_| SimError::Shutdown)
}
pub async fn list_galaxies(&self) -> Result<Vec<GalaxyInfo>, SimError> {
let (reply, rx) = oneshot::channel();
self.tx.send(SimCmd::ListGalaxies { reply }).await.map_err(|_| SimError::Shutdown)?;
rx.await.map_err(|_| SimError::Shutdown)
}
pub async fn seed(&self, galaxy: GalaxyId, n: usize) -> Result<Vec<EngramId>, SimError> {
let (reply, rx) = oneshot::channel();
self.tx.send(SimCmd::Seed { galaxy, n, reply }).await.map_err(|_| SimError::Shutdown)?;
rx.await.map_err(|_| SimError::Shutdown)?
}
pub async fn ingest(
&self,
galaxy: GalaxyId,
items: Vec<IngestItem>,
) -> Result<Vec<EngramId>, SimError> {
let (reply, rx) = oneshot::channel();
self.tx
.send(SimCmd::Ingest { galaxy, items, reply })
.await
.map_err(|_| SimError::Shutdown)?;
rx.await.map_err(|_| SimError::Shutdown)?
}
pub async fn get_engram(
&self,
galaxy: GalaxyId,
engram: EngramId,
) -> Result<Option<EngramDetail>, SimError> {
let (reply, rx) = oneshot::channel();
self.tx
.send(SimCmd::GetEngram { galaxy, engram, reply })
.await
.map_err(|_| SimError::Shutdown)?;
rx.await.map_err(|_| SimError::Shutdown)?
}
/// Subscribe to a galaxy's event stream. Returns initial state plus the
/// live receiver so the WS client can replay then follow.
pub async fn subscribe(
&self,
galaxy: GalaxyId,
) -> Result<SubscribeReply, SimError> {
let (reply, rx) = oneshot::channel();
self.tx.send(SimCmd::Subscribe { galaxy, reply }).await.map_err(|_| SimError::Shutdown)?;
rx.await.map_err(|_| SimError::Shutdown)?
}
/// Live-resize a galaxy's torus. Caller doesn't need to validate first;
/// the sim re-validates and returns `InvalidShape` on a bad payload.
pub async fn resize(
&self,
galaxy: GalaxyId,
shape: GalaxyShape,
) -> Result<GalaxyInfo, SimError> {
let (reply, rx) = oneshot::channel();
self.tx
.send(SimCmd::Resize { galaxy, shape, reply })
.await
.map_err(|_| SimError::Shutdown)?;
rx.await.map_err(|_| SimError::Shutdown)?
}
}
/// Spawn the simulation task. `default_shape` is used for any new galaxy
/// created via [`SimHandle::create_galaxy`].
pub fn spawn_sim(default_shape: GalaxyShape) -> SimHandle {
let (tx, mut rx) = mpsc::channel::<SimCmd>(64);
tokio::spawn(async move {
let mut world = World::new();
let mut scheduler = Scheduler::new();
// One spatial index per galaxy. We rebuild on RebuildIndex events.
let mut indexes: std::collections::HashMap<GalaxyId, KiddoIndex> = Default::default();
let mut rng = SmallRng::seed_from_u64(0xC0DE_5071);
let started = Instant::now();
loop {
// Pick whichever happens first: a new command or the next due event.
let now = Instant::now();
let next_at = scheduler.next_at();
let timeout = match next_at {
Some(at) => at.saturating_duration_since(now),
None => Duration::from_millis(50),
};
tokio::select! {
cmd = rx.recv() => {
let Some(cmd) = cmd else { break }; // all handles dropped
handle_cmd(cmd, &mut world, &mut scheduler, &mut indexes, &mut rng, default_shape);
}
_ = tokio::time::sleep(timeout) => {
// Drain all due events.
let now = Instant::now();
while let Some(event) = scheduler.pop_due(now) {
handle_event(event, &mut world, &mut scheduler, &mut indexes, &mut rng, started);
}
}
}
}
});
SimHandle { tx }
}
fn handle_cmd(
cmd: SimCmd,
world: &mut World,
scheduler: &mut Scheduler,
indexes: &mut std::collections::HashMap<GalaxyId, KiddoIndex>,
_rng: &mut SmallRng,
default_shape: GalaxyShape,
) {
match cmd {
SimCmd::CreateGalaxy { name, reply } => {
let state = GalaxyState::new(name, default_shape);
let info = state.info();
let id = state.galaxy.id;
// Publish the initial torus shape to anyone who subscribes later
// (Hello carries the full state, but emitting now also keeps the
// bus authoritative for resize events — same code path).
state.emit(state.torus_event());
world.galaxies.insert(id, state);
indexes.insert(id, KiddoIndex::empty());
// Kick off the galaxy's recurring events.
let now = Instant::now();
scheduler.schedule(now + FRAME_INTERVAL, Event::BroadcastFrame { galaxy: id });
scheduler.schedule(now + REBUILD_INTERVAL, Event::RebuildIndex { galaxy: id });
let _ = reply.send(info);
}
SimCmd::ListGalaxies { reply } => {
let _ = reply.send(world.list_galaxies());
}
SimCmd::Seed { galaxy, n, reply } => {
let now = Instant::now();
let res = seed_galaxy(world, galaxy, n, scheduler, now);
let _ = reply.send(res);
}
SimCmd::Ingest { galaxy, items, reply } => {
let now = Instant::now();
let res = ingest_galaxy(world, galaxy, items, scheduler, now);
let _ = reply.send(res);
}
SimCmd::GetEngram { galaxy, engram, reply } => {
let detail = world.galaxies.get(&galaxy).map(|g| {
g.engrams.get(&engram).map(|e| EngramDetail {
id: e.id,
instance_idx: e.instance_idx,
position: e.position.to_array(),
size: e.size,
state: e.state,
age: e.age,
manifest: e.manifest.clone(),
slate_dim: e.slate.as_ref().map(|s| s.dim()),
slate_norm: e.slate.as_ref().map(|s| s.norm()),
})
});
match detail {
Some(found) => {
let _ = reply.send(Ok(found));
}
None => {
let _ = reply.send(Err(SimError::UnknownGalaxy));
}
}
}
SimCmd::Subscribe { galaxy, reply } => {
let res = world
.galaxies
.get(&galaxy)
.map(|g| (g.info(), g.snapshot_all(), g.snapshot_synapses(), g.bus.subscribe()))
.ok_or(SimError::UnknownGalaxy);
let _ = reply.send(res);
}
SimCmd::Resize { galaxy, shape, reply } => {
let res = match shape.validate() {
Err(msg) => Err(SimError::InvalidShape(msg)),
Ok(()) => world
.resize_galaxy(galaxy, shape)
.ok_or(SimError::UnknownGalaxy),
};
let _ = reply.send(res);
}
}
}
/// Random offset around the birth point. Tiny — the bulk of the dispersion
/// comes from the random initial velocity, not position jitter.
fn birth_offset(rng: &mut SmallRng) -> Vec3 {
Vec3::new(
rng.gen_range(-BIRTH_JITTER..BIRTH_JITTER),
rng.gen_range(-BIRTH_JITTER..BIRTH_JITTER),
rng.gen_range(-BIRTH_JITTER..BIRTH_JITTER),
)
}
/// Random initial velocity, biased into the xy-plane so the fountain spreads
/// through the donut tube rather than shooting up/down the central axis. A
/// small z component is allowed for visual variety.
fn birth_velocity(rng: &mut SmallRng) -> Vec3 {
let theta = rng.gen_range(0.0_f32..std::f32::consts::TAU);
let z_bias: f32 = rng.gen_range(-0.25..0.25);
Vec3::new(theta.cos(), theta.sin(), z_bias).normalize_or_zero() * BIRTH_SPEED
}
/// Schedule N synthetic engrams (no manifest/slate) to be born one at a time,
/// `SPAWN_SPACING` apart. Returns the pre-allocated ids so callers can refer
/// to engrams that don't exist yet — the WS will see `EngramCreated` events
/// trickle in over the next `n * SPAWN_SPACING`.
fn seed_galaxy(
world: &mut World,
galaxy: GalaxyId,
n: usize,
scheduler: &mut Scheduler,
now: Instant,
) -> Result<Vec<EngramId>, SimError> {
if !world.galaxies.contains_key(&galaxy) {
return Err(SimError::UnknownGalaxy);
}
let mut ids = Vec::with_capacity(n);
for i in 0..n {
let id = EngramId::new();
ids.push(id);
let at = now + SPAWN_SPACING * (i as u32);
scheduler.schedule(
at,
Event::Spawn { galaxy, id, payload: SpawnPayload::Synthetic },
);
}
Ok(ids)
}
fn ingest_galaxy(
world: &mut World,
galaxy: GalaxyId,
items: Vec<IngestItem>,
scheduler: &mut Scheduler,
now: Instant,
) -> Result<Vec<EngramId>, SimError> {
if !world.galaxies.contains_key(&galaxy) {
return Err(SimError::UnknownGalaxy);
}
let mut ids = Vec::with_capacity(items.len());
for (i, item) in items.into_iter().enumerate() {
let id = EngramId::new();
ids.push(id);
let at = now + SPAWN_SPACING * (i as u32);
scheduler.schedule(
at,
Event::Spawn {
galaxy,
id,
payload: SpawnPayload::Manifested {
manifest: item.manifest,
slate: item.slate,
},
},
);
}
Ok(ids)
}
/// Materialize one engram with the pre-allocated id at the galaxy's birth
/// point with a random initial velocity, emit `EngramCreated`, and schedule
/// its first `EngramTick`.
fn materialize_engram(
g: &mut GalaxyState,
galaxy: GalaxyId,
id: EngramId,
payload: SpawnPayload,
scheduler: &mut Scheduler,
rng: &mut SmallRng,
) {
let instance_idx = g.slot_to_id.len() as u32;
let birth = g.galaxy.shape.birth_point(g.galaxy.center);
let position = birth + birth_offset(rng);
let velocity = birth_velocity(rng);
let (manifest, slate) = match payload {
SpawnPayload::Synthetic => (None, None),
SpawnPayload::Manifested { manifest, slate } => (Some(manifest), Some(slate)),
};
let engram = Engram {
id,
instance_idx,
position,
velocity,
size: 1.0,
state: EngramState::Idle,
age: 0,
manifest,
slate,
};
g.slot_to_id.push(id);
g.engrams.insert(id, engram.clone());
let snapshot = EngramSnapshot {
id,
instance_idx,
position: position.to_array(),
size: engram.size,
state: engram.state,
};
g.emit(SimEvent::EngramCreated { snapshot });
scheduler.schedule(
Instant::now() + TICK_INTERVAL,
Event::EngramTick { galaxy, engram: id },
);
}
/// Walk the kiddo index for `engram_id`'s neighbours and return:
/// - `gravity_acc`: cosine-weighted attraction toward similar peers (only
/// contributes inside the tube; physics gates this further by position).
/// - `synapse_candidates`: list of `(peer_id, weight)` pairs that cleared
/// the synapse threshold and may be formed after the tick.
///
/// Returns `(Vec3::ZERO, vec![])` if the engram has no slate (synthetic
/// seeds), if the spatial index hasn't been built yet for this galaxy, or if
/// the engram itself is gone.
fn compute_gravity_and_candidates(
g: &GalaxyState,
index: Option<&KiddoIndex>,
engram_id: EngramId,
minor_r: f32,
) -> (Vec3, Vec<(EngramId, f32)>) {
let Some(index) = index else { return (Vec3::ZERO, Vec::new()); };
let Some(self_engram) = g.engrams.get(&engram_id) else { return (Vec3::ZERO, Vec::new()); };
let Some(self_slate) = self_engram.slate.as_ref() else {
return (Vec3::ZERO, Vec::new());
};
// Only consider gravity / synapses once we're settled inside the tube —
// matches `physics::tick`'s gating, and keeps the in-flight phase clean.
let spine = nearest_spine_xy(self_engram.position, g.galaxy.shape.major_radius);
let r_dist = (self_engram.position - spine).length();
if r_dist > minor_r {
return (Vec3::ZERO, Vec::new());
}
let neighbours = index.within(self_engram.position.to_array(), GRAVITY_RADIUS);
let mut acc = Vec3::ZERO;
let mut candidates = Vec::new();
for peer_id in neighbours {
if peer_id == engram_id {
continue;
}
let Some(peer) = g.engrams.get(&peer_id) else { continue; };
let Some(peer_slate) = peer.slate.as_ref() else { continue; };
let cos = self_slate.cosine(peer_slate);
if cos < GRAVITY_THRESHOLD {
continue;
}
let dir = peer.position - self_engram.position;
let dist = dir.length();
if dist > 1e-3 {
acc += dir / dist * (GRAVITY_K * (cos - GRAVITY_THRESHOLD));
}
if cos >= SYNAPSE_THRESHOLD {
candidates.push((peer_id, cos));
}
}
// Cap acceleration magnitude so a dense neighbourhood doesn't snap.
let acc_len = acc.length();
if acc_len > GRAVITY_MAX_ACC {
acc = acc / acc_len * GRAVITY_MAX_ACC;
}
(acc, candidates)
}
/// Closest point on the torus centerline to `p` in xy-plane (mirrors the
/// math in `physics::spine_point` but doesn't pull `physics` in here).
fn nearest_spine_xy(p: Vec3, major_r: f32) -> Vec3 {
let xy_len = (p.x * p.x + p.y * p.y).sqrt();
if xy_len < 1e-6 {
Vec3::new(major_r, 0.0, 0.0)
} else {
let scale = major_r / xy_len;
Vec3::new(p.x * scale, p.y * scale, 0.0)
}
}
fn handle_event(
event: Event,
world: &mut World,
scheduler: &mut Scheduler,
indexes: &mut std::collections::HashMap<GalaxyId, KiddoIndex>,
rng: &mut SmallRng,
started: Instant,
) {
match event {
Event::Spawn { galaxy, id, payload } => {
let Some(g) = world.galaxies.get_mut(&galaxy) else { return };
materialize_engram(g, galaxy, id, payload, scheduler, rng);
}
Event::EngramTick { galaxy, engram } => {
let Some(g) = world.galaxies.get_mut(&galaxy) else { return };
let center = g.galaxy.center;
let major_r = g.galaxy.shape.major_radius;
let minor_r = g.galaxy.shape.minor_radius;
// Compute gravity + collect synapse candidates while the borrow on
// `g` is read-only. Both use the same kiddo lookup so we do it once.
let index = indexes.get(&galaxy);
let (gravity_acc, synapse_candidates) =
compute_gravity_and_candidates(g, index, engram, minor_r);
if let Some(e) = g.engrams.get_mut(&engram) {
physics::tick(e, center, major_r, minor_r, gravity_acc, rng);
} else {
return;
}
// After the tick, materialise any qualifying synapses and emit
// events for the newly-formed ones.
for (peer, weight) in synapse_candidates {
if let Some(syn) = g.try_form_synapse(engram, peer, weight) {
g.emit(SimEvent::SynapseCreated { synapse: SynapseDto::from(&syn) });
}
}
scheduler.schedule(
Instant::now() + TICK_INTERVAL,
Event::EngramTick { galaxy, engram },
);
}
Event::BroadcastFrame { galaxy } => {
if let Some(g) = world.galaxies.get(&galaxy) {
let positions: Vec<[f32; 3]> = g
.slot_to_id
.iter()
.filter_map(|id| g.engrams.get(id))
.map(|e| e.position.to_array())
.collect();
let t_ms = started.elapsed().as_millis() as u32;
g.emit(SimEvent::PositionFrame(PositionFrame { t_ms, positions }));
} else {
warn!("BroadcastFrame for unknown galaxy {:?}", galaxy);
}
scheduler.schedule(
Instant::now() + FRAME_INTERVAL,
Event::BroadcastFrame { galaxy },
);
}
Event::RebuildIndex { galaxy } => {
// Index rebuild only — no bbox recompute (the torus is fixed-size,
// resized only via SimCmd::Resize).
if let Some(g) = world.galaxies.get(&galaxy) {
let points: Vec<(EngramId, [f32; 3])> = g
.engrams
.values()
.map(|e| (e.id, e.position.to_array()))
.collect();
if let Some(idx) = indexes.get_mut(&galaxy) {
idx.rebuild(&points);
}
}
scheduler.schedule(
Instant::now() + REBUILD_INTERVAL,
Event::RebuildIndex { galaxy },
);
}
}
}

View File

@@ -0,0 +1,42 @@
//! Spatial index. Stage 1 wraps `kiddo`'s ImmutableKdTree, rebuilt on demand.
//!
//! kiddo is fast for nearest-neighbour and within-radius queries, but doesn't
//! support efficient updates — so we rebuild the tree periodically rather
//! than per-tick. Wrapped behind this small surface so a per-octree
//! incremental index can swap in later without touching callers.
use kiddo::{ImmutableKdTree, SquaredEuclidean};
use sophia_core::EngramId;
pub struct KiddoIndex {
tree: Option<ImmutableKdTree<f32, 3>>,
/// `tree`'s point indices map back to these engram ids.
ids: Vec<EngramId>,
}
impl KiddoIndex {
pub fn empty() -> Self {
Self { tree: None, ids: Vec::new() }
}
pub fn rebuild(&mut self, points: &[(EngramId, [f32; 3])]) {
if points.is_empty() {
self.tree = None;
self.ids.clear();
return;
}
self.ids = points.iter().map(|(id, _)| *id).collect();
let coords: Vec<[f32; 3]> = points.iter().map(|(_, p)| *p).collect();
self.tree = Some(ImmutableKdTree::new_from_slice(&coords));
}
/// Returns engram ids within `radius` of `point`. Used by gravity +
/// synapse formation (Stage 3) and the broadcast wavefront (Stage 5).
pub fn within(&self, point: [f32; 3], radius: f32) -> Vec<EngramId> {
let Some(tree) = self.tree.as_ref() else { return Vec::new() };
tree.within_unsorted::<SquaredEuclidean>(&point, radius * radius)
.into_iter()
.filter_map(|hit| self.ids.get(hit.item as usize).copied())
.collect()
}
}

View File

@@ -0,0 +1,12 @@
//! Sophia simulation: event-driven scheduler, physics, spatial index, broadcast.
//!
//! See `docs/system-analysis.md` §13.2 (event-driven, non-deterministic),
//! §13.4 (broadcast/conversation retrieval), §4 (lifecycle topology).
mod handle;
mod index;
mod physics;
mod scheduler;
mod world;
pub use handle::{spawn_sim, IngestItem, SimError, SimHandle};

View File

@@ -0,0 +1,151 @@
//! Stage 1+ physics, post Topology Pivot:
//! curiosity (decaying random walk) + soft torus-radial inward force,
//! integrated with per-engram velocity + friction so motion glides
//! instead of jittering.
//!
//! The boundary force pulls engrams toward the nearest spine point of the
//! donut. For an engram born at the very center of the donut hole this
//! same force becomes a gentle outward attraction toward the tube — so a
//! particle fountain emerges naturally from the central "Great Reflection".
use glam::Vec3;
use rand::rngs::SmallRng;
use rand::Rng;
use sophia_core::Engram;
/// Tick interval the scheduler aims for. Fixed in Stage 1.
pub const TICK_DT_SECS: f32 = 0.05;
/// Curiosity drives small random impulses that decay with age. Treated as
/// an *acceleration* (units / s²) rather than a velocity, so it composes
/// with the boundary force and gets smoothed by friction.
const CURIOSITY_TAU_TICKS: f32 = 1500.0; // ≈75 s half-life at 20 Hz tick
const CURIOSITY_BASE: f32 = 8.0;
/// Per-tick velocity damping. With dt = 50 ms this works out to ≈55 % of
/// velocity retained per second — the engrams glide instead of bullet
/// across the scene, and direction changes look smooth.
const FRICTION: f32 = 0.03;
/// Where (as a fraction of `minor_radius`) the soft inward force kicks in
/// once the engram is inside the tube.
const BOUNDARY_START: f32 = 0.85;
/// Strength of the soft restoration at the tube edge (only inside the tube,
/// past `BOUNDARY_START * minor_radius`). Quadratic in overshoot.
const BOUNDARY_K: f32 = 28.0;
/// Constant gentle pull toward the nearest tube spine while the engram is in
/// the donut hole (`r_dist > minor_radius`). Much smaller than BOUNDARY_K so
/// the cross-hole flight is *visible* — engrams coast at ~15 units/s and
/// take several seconds to reach the tube, instead of snapping there.
const IN_HOLE_PULL: f32 = 5.0;
/// Tangential acceleration around the +z axis applied while in the donut
/// hole — turns the otherwise-radial flight into a CCW spiral, so the
/// scene reads as a rotating galaxy rather than a starburst.
const SPIN_K: f32 = 6.0;
/// Closest point on the torus centerline to `p`. The centerline is the
/// circle of radius `major_r` lying in the plane z = center.z, centered on
/// `center`. See plan §"Topology math" for the derivation.
fn spine_point(p: Vec3, center: Vec3, major_r: f32) -> Vec3 {
let local = p - center;
let xy_len = (local.x * local.x + local.y * local.y).sqrt();
if xy_len < 1e-6 {
// Degenerate: directly above/below the donut axis (e.g. a brand-new
// engram at the exact origin). Pick θ = 0 arbitrarily so the spine
// point is well-defined and the boundary force has a direction —
// initial velocity randomness ensures different engrams pick
// different θ on the next tick.
center + Vec3::new(major_r, 0.0, 0.0)
} else {
let scale = major_r / xy_len;
center + Vec3::new(local.x * scale, local.y * scale, 0.0)
}
}
/// Apply one tick of physics to the engram in-place. `gravity_acc` is a
/// pre-computed cosine-weighted attraction toward similar nearby engrams
/// (Stage 3); pass `Vec3::ZERO` if not yet computed. Gravity only takes
/// effect once the engram is settled inside the tube — engrams in the
/// donut hole shouldn't pull each other back into a clump near birth.
pub fn tick(
engram: &mut Engram,
center: Vec3,
major_r: f32,
minor_r: f32,
gravity_acc: Vec3,
rng: &mut SmallRng,
) {
let curiosity_factor = (-(engram.age as f32) / CURIOSITY_TAU_TICKS).exp();
// Curiosity: random impulse, decaying with age.
let rand_dir = Vec3::new(
rng.gen_range(-1.0..1.0),
rng.gen_range(-1.0..1.0),
rng.gen_range(-1.0..1.0),
)
.normalize_or_zero();
let curiosity_acc = rand_dir * (CURIOSITY_BASE * curiosity_factor);
// Spine attraction with two regimes:
// - In the donut hole (r_dist > minor_r): a *gentle constant* pull
// toward the nearest spine point. Engrams coast across the empty
// space, visibly traversing it over several seconds.
// - Inside the tube but past 0.85 * minor_r: a stronger quadratic
// restoration, so engrams that drift to the tube wall bounce
// back smoothly without escaping.
let spine = spine_point(engram.position, center, major_r);
let radial = engram.position - spine;
let r_dist = radial.length();
let inward = -radial.normalize_or_zero();
let start = minor_r * BOUNDARY_START;
let span = (minor_r - start).max(1e-3);
let boundary_acc = if r_dist > minor_r {
inward * IN_HOLE_PULL
} else if r_dist > start {
let over = ((r_dist - start) / span).clamp(0.0, 1.0);
inward * (BOUNDARY_K * over * over)
} else {
Vec3::ZERO
};
// Galactic spin: tangential acceleration in the xy-plane (CCW around
// +z). Only active in the donut hole — once an engram reaches the
// tube the spin force vanishes so it can settle. The tangent is the
// 90° CCW rotation of the engram's xy position vector relative to the
// galaxy center.
let local = engram.position - center;
let xy_len = (local.x * local.x + local.y * local.y).sqrt();
let spin_acc = if xy_len > 1e-3 && r_dist > minor_r {
Vec3::new(-local.y, local.x, 0.0) / xy_len * SPIN_K
} else {
Vec3::ZERO
};
// Gravity is gated to inside-the-tube only — see fn doc.
let gated_gravity = if r_dist <= minor_r { gravity_acc } else { Vec3::ZERO };
// Verlet-ish integration: accumulate forces into velocity, damp,
// then move. Gives smooth glide instead of per-tick teleporting.
let acc = curiosity_acc + boundary_acc + spin_acc + gated_gravity;
engram.velocity += acc * TICK_DT_SECS;
engram.velocity *= 1.0 - FRICTION;
engram.position += engram.velocity * TICK_DT_SECS;
engram.age = engram.age.saturating_add(1);
// Hard safety clamp: even with the soft force, large impulses can
// momentarily breach the tube. Project back to the surface and reflect
// the outward component of velocity so the engram bounces softly
// instead of pile-driving against the wall.
let spine_after = spine_point(engram.position, center, major_r);
let radial_after = engram.position - spine_after;
let dist_after = radial_after.length();
if dist_after > minor_r {
let normal = radial_after.normalize_or_zero();
engram.position = spine_after + normal * minor_r;
let v_dot_n = engram.velocity.dot(normal);
if v_dot_n > 0.0 {
// Cancel the outward component, keep tangential motion.
engram.velocity -= normal * v_dot_n;
}
}
}

View File

@@ -0,0 +1,90 @@
//! Min-heap event scheduler driving the simulation. Per §13.2.
use std::cmp::Ordering;
use std::collections::BinaryHeap;
use std::time::Instant;
use sophia_core::{EngramId, GalaxyId, Manifest, Slate};
/// Payload for a queued spawn. Synthetic seed engrams have no manifest/slate;
/// ingested engrams carry the pre-embedded text.
#[derive(Debug, Clone)]
pub enum SpawnPayload {
Synthetic,
Manifested { manifest: Manifest, slate: Slate },
}
#[derive(Debug, Clone)]
pub enum Event {
/// One Engram's turn to act (move, perceive, etc).
EngramTick { galaxy: GalaxyId, engram: EngramId },
/// Materialize one engram with the pre-allocated id and the given
/// payload, then schedule its first tick. Used by `seed` and `ingest`
/// to release engrams gradually instead of all at once.
Spawn { galaxy: GalaxyId, id: EngramId, payload: SpawnPayload },
/// Snapshot all positions in a galaxy and emit a `PositionFrame`. Fired
/// at a fixed cadence (~20 Hz) and reschedules itself.
BroadcastFrame { galaxy: GalaxyId },
/// Recompute density-driven bbox + rebuild spatial index. Fires every
/// ~500 ms and reschedules itself.
RebuildIndex { galaxy: GalaxyId },
}
#[derive(Debug)]
struct Scheduled {
at: Instant,
seq: u64, // tie-breaker so equal-time events have a stable order
event: Event,
}
impl PartialEq for Scheduled {
fn eq(&self, other: &Self) -> bool {
self.at.eq(&other.at) && self.seq.eq(&other.seq)
}
}
impl Eq for Scheduled {}
impl PartialOrd for Scheduled {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Scheduled {
fn cmp(&self, other: &Self) -> Ordering {
// BinaryHeap is a max-heap; reverse so the earliest time wins.
other.at.cmp(&self.at).then(other.seq.cmp(&self.seq))
}
}
pub struct Scheduler {
heap: BinaryHeap<Scheduled>,
next_seq: u64,
}
impl Scheduler {
pub fn new() -> Self {
Self { heap: BinaryHeap::new(), next_seq: 0 }
}
pub fn schedule(&mut self, at: Instant, event: Event) {
self.next_seq = self.next_seq.wrapping_add(1);
self.heap.push(Scheduled { at, seq: self.next_seq, event });
}
pub fn next_at(&self) -> Option<Instant> {
self.heap.peek().map(|s| s.at)
}
/// Pop one event if its scheduled time has arrived.
pub fn pop_due(&mut self, now: Instant) -> Option<Event> {
match self.heap.peek() {
Some(s) if s.at <= now => self.heap.pop().map(|s| s.event),
_ => None,
}
}
}
impl Default for Scheduler {
fn default() -> Self {
Self::new()
}
}

View File

@@ -0,0 +1,153 @@
//! Per-galaxy mutable state owned by the simulation.
use std::collections::{HashMap, HashSet};
use sophia_core::{
canonical_pair, Engram, EngramId, EngramSnapshot, Galaxy, GalaxyId, GalaxyInfo, GalaxyShape,
SimEvent, Synapse, SynapseDto, SynapseId,
};
use tokio::sync::broadcast;
/// Global ceiling on synapses per galaxy — keeps the WS bandwidth bounded
/// and prevents the visualisation from drowning in lines if many engrams
/// happen to be similar at once. Tuned generously for a 200-engram demo.
const MAX_SYNAPSES_PER_GALAXY: usize = 4_000;
/// Per-engram synapse cap. Once an engram has this many connections, no
/// new ones are formed for it (Stage 3 is no-eviction; later stages may
/// drop the weakest).
const MAX_SYNAPSES_PER_ENGRAM: usize = 16;
/// Channel buffer for per-galaxy event broadcast. Big enough for several
/// position frames; lagged consumers receive `RecvError::Lagged`.
const BROADCAST_CAPACITY: usize = 256;
pub struct GalaxyState {
pub galaxy: Galaxy,
pub engrams: HashMap<sophia_core::EngramId, Engram>,
/// Dense list of engram ids in slot order (instance_idx is the index here).
pub slot_to_id: Vec<sophia_core::EngramId>,
/// Synapses keyed by id.
pub synapses: HashMap<SynapseId, Synapse>,
/// Canonical-pair set so duplicate-formation is O(1).
pub synapse_pairs: HashSet<(EngramId, EngramId)>,
/// Per-engram synapse counts for cap enforcement.
pub synapse_count: HashMap<EngramId, usize>,
/// Broadcast bus for events scoped to this galaxy.
pub bus: broadcast::Sender<SimEvent>,
}
impl GalaxyState {
pub fn new(name: impl Into<String>, shape: GalaxyShape) -> Self {
let (bus, _) = broadcast::channel(BROADCAST_CAPACITY);
Self {
galaxy: Galaxy::new(name, shape),
engrams: HashMap::new(),
slot_to_id: Vec::new(),
synapses: HashMap::new(),
synapse_pairs: HashSet::new(),
synapse_count: HashMap::new(),
bus,
}
}
/// Try to form a new synapse between `a` and `b` with the given weight.
/// Returns `Some(synapse)` if created, `None` if a synapse already
/// exists for this pair or any cap was hit. Stage 3 doesn't update
/// existing synapses; later stages may.
pub fn try_form_synapse(&mut self, a: EngramId, b: EngramId, weight: f32) -> Option<Synapse> {
if a == b {
return None;
}
let pair = canonical_pair(a, b);
if self.synapse_pairs.contains(&pair) {
return None;
}
if self.synapses.len() >= MAX_SYNAPSES_PER_GALAXY {
return None;
}
let count_a = self.synapse_count.get(&pair.0).copied().unwrap_or(0);
let count_b = self.synapse_count.get(&pair.1).copied().unwrap_or(0);
if count_a >= MAX_SYNAPSES_PER_ENGRAM || count_b >= MAX_SYNAPSES_PER_ENGRAM {
return None;
}
let id = SynapseId::new();
let syn = Synapse { id, a: pair.0, b: pair.1, weight };
self.synapse_pairs.insert(pair);
self.synapses.insert(id, syn.clone());
*self.synapse_count.entry(pair.0).or_insert(0) += 1;
*self.synapse_count.entry(pair.1).or_insert(0) += 1;
Some(syn)
}
pub fn snapshot_synapses(&self) -> Vec<SynapseDto> {
self.synapses.values().map(SynapseDto::from).collect()
}
pub fn info(&self) -> GalaxyInfo {
GalaxyInfo {
id: self.galaxy.id,
name: self.galaxy.name.clone(),
engram_count: self.slot_to_id.len(),
center: self.galaxy.center.to_array(),
major_radius: self.galaxy.shape.major_radius,
minor_radius: self.galaxy.shape.minor_radius,
}
}
pub fn snapshot_all(&self) -> Vec<EngramSnapshot> {
self.slot_to_id
.iter()
.filter_map(|id| self.engrams.get(id))
.map(|e| EngramSnapshot {
id: e.id,
instance_idx: e.instance_idx,
position: e.position.to_array(),
size: e.size,
state: e.state,
})
.collect()
}
/// Emit on the bus; drops the event silently if no one is listening.
pub fn emit(&self, ev: SimEvent) {
let _ = self.bus.send(ev);
}
pub fn torus_event(&self) -> SimEvent {
SimEvent::TorusUpdated {
center: self.galaxy.center.to_array(),
major_radius: self.galaxy.shape.major_radius,
minor_radius: self.galaxy.shape.minor_radius,
}
}
}
pub struct World {
pub galaxies: HashMap<GalaxyId, GalaxyState>,
}
impl World {
pub fn new() -> Self {
Self { galaxies: HashMap::new() }
}
pub fn list_galaxies(&self) -> Vec<GalaxyInfo> {
self.galaxies.values().map(GalaxyState::info).collect()
}
/// Update the galaxy's torus shape and broadcast `TorusUpdated`. Caller
/// is responsible for validating `shape` first (`GalaxyShape::validate`).
/// Returns the updated info, or `None` if the galaxy doesn't exist.
pub fn resize_galaxy(&mut self, gid: GalaxyId, shape: GalaxyShape) -> Option<GalaxyInfo> {
let g = self.galaxies.get_mut(&gid)?;
g.galaxy.shape = shape;
g.emit(g.torus_event());
Some(g.info())
}
}
impl Default for World {
fn default() -> Self {
Self::new()
}
}

View File

@@ -0,0 +1,14 @@
[package]
name = "sophia-store"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
authors.workspace = true
[dependencies]
sophia-core = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
serde = { workspace = true }

View File

@@ -0,0 +1,7 @@
//! Persistence: sled-backed event log + snapshot.
//!
//! 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.
//!
//! Stage 0: stub. Real content arrives in Stage 6.

View File

@@ -6,33 +6,48 @@
## 1. System Boundary & Environment ## 1. System Boundary & Environment
### Containment hierarchy
Sophia has three nested layers:
```
Universe
└── Galaxy (user-defined; isolated in v1)
└── Space of Recollection (the 3D simulation medium)
└── Engrams, Synapses, Memories
```
The **Universe** is the top-level container. It holds one or more **Galaxies**, each of which is a self-contained Space of Recollection scoped by the user (e.g., "personal notes," "work projects," a specific corpus). In v1, galaxies do not interact — each is functionally its own Sophia instance. Inter-galactic dynamics (bridging Engrams, meta-gravity, cross-galaxy queries) are documented as expansion points (see §13.6).
### What is inside the system ### What is inside the system
| Component | Role | | Component | Role |
| --- | --- | | --- | --- |
| **Space of Recollection** | The continuous, unbounded 3D medium in which everything exists | | **Universe** | Top-level container — holds all galaxies |
| **Galaxy** | User-defined scope — a self-contained Space of Recollection |
| **Space of Recollection** | The continuous, density-driven 3D medium in which Engrams live (one per galaxy) |
| **Engrams** | Autonomous agents — the fundamental units of knowledge | | **Engrams** | Autonomous agents — the fundamental units of knowledge |
| **Synapses** | Bidirectional, metadata-rich connections between Engrams | | **Synapses** | Bidirectional, metadata-rich connections between Engrams |
| **Cycles** | The temporal dimension — continuous, event-driven simulation time | | **Cycles** | The temporal dimension — continuous, event-driven simulation time |
| **The Great Reflection** | The I/O membrane — a toroidal portal at the center and edges of space | | **The Great Reflection** | The I/O membrane — the lifecycle source/sink at center and edges of a galaxy |
### What is outside the system ### What is outside the system
| Component | Interaction | | Component | Interaction |
| --- | --- | | --- | --- |
| **Users** | Create Engrams (input), issue queries (input), receive answers (output) | | **Users** | Create galaxies, ingest data, issue queries (always scoped to a galaxy), receive answers |
| **LLM Services** | Called by Engrams for deep introspection, comparison, and synthesis | | **Local LLM** | `google/gemma-4-e4b` via LM Studio. Called by Engrams for introspection, peer dialog, synthesis decisions, and query conversations. Compute-bound, not budget-bound (see §13.5) |
| **Storage Backend** | Persists the state of Space, Engrams, Synapses, and Memories | | **Storage Backend** | Persists Engrams, Synapses, and the per-Engram memory streams that double as the canonical event log (see §13.3) |
### The Great Reflection as Boundary ### The Great Reflection as Boundary
The Great Reflection is not a point — it is a **toroidal surface** that exists simultaneously at the center and the edges of space. It functions as a semi-permeable membrane: The Great Reflection is the lifecycle membrane of a galaxy — not a literal toroidal surface, but a conceptual source-and-sink (see §4 for the geometric model). It functions as a semi-permeable boundary:
- **Inward**: User data materializes as new Engrams at the center. Bulk ingestion ("seeding") opens it wide; single inputs create brief apertures. - **Inward (center)**: User data materializes as new Engrams near the galactic center, where density is highest and new arrivals find immediate company. Bulk ingestion ("seeding") opens it wide; single inputs create brief apertures.
- **Outward**: Decaying Engrams drift toward the edges and are eventually reabsorbed. Query results are projected outward to the user. - **Outward (edges)**: Decaying Engrams drift toward the galactic edges and are eventually reabsorbed. Query results are projected outward to the user.
- **Queries**: Materialize as temporary Engrams at the center, high in curiosity, seeking resonance rather than permanence. - **Queries**: Materialize as temporary Query-Engrams at the center, high in curiosity, broadcasting an invitation to converse rather than seeking permanent residence (see §7).
This topology means there is no true "far away" — space curves back on itself. An Engram drifting toward the edge approaches the same boundary where new Engrams are born, creating a cycle of renewal. The lifecycle flow — *birth at center, life in the middle zone, decay at edges* — is the operative topology, even though the underlying space is bounded Cartesian (see §13.6).
--- ---
@@ -51,8 +66,8 @@ Systems thinking models the world in terms of **stocks** (things that accumulate
``` ```
- **Inflow**: Materialization through The Great Reflection (user creates data, queries arrive) - **Inflow**: Materialization through The Great Reflection (user creates data, queries arrive)
- **Outflow**: Decay (unreferenced Engrams fade), Absorption (true duplicates merge past threshold) - **Outflow (active population)**: Decay (B3 — unreferenced Engrams fade and reabsorb), Absorption (true duplicates merge past threshold), Deprecation (per §13.1 — children fully absorbed by a parent transition to a terminal state, preserved as historical witnesses but no longer active)
- **Internal transformation**: Synthesis creates Engrams-of-Engrams (federation), changing the population composition without necessarily changing count - **Internal transformation**: Synthesis creates Engrams-of-Engrams (federation), with abstracted knowledge migrating *upward* into the parent (per §13.1, §6). This thins the children, often setting up later deprecation.
### Stock: Synapses ### Stock: Synapses
@@ -95,7 +110,9 @@ Systems thinking models the world in terms of **stocks** (things that accumulate
## 3. Feedback Loops ## 3. Feedback Loops
Feedback loops are the engine of self-organization. They explain why the system behaves the way it does without central control. Feedback loops are the engine of self-organization. They explain why the system behaves the way it does without central control. All loops below operate **within a single galaxy** — galaxies are isolated in v1 (per §13.6), so there are no inter-galactic feedback dynamics yet.
**A note on motivation**: from an Engram's perspective, every loop below is in service of the *prime goal of self-preservation* (see §5). R1 and R2 attract you into structures that preserve you. B1 explores in case the current cluster won't preserve you. B2 keeps you irreplaceable. B3 is the failure mode you are constantly working to avoid. R3 (queries) is an opportunity to be remembered.
### Reinforcing Loops (amplify change) ### Reinforcing Loops (amplify change)
@@ -164,7 +181,9 @@ Drift toward edges → Approach Great Reflection → Reabsorbed
This is the system's **garbage collection** — but organic. Irrelevant or outdated knowledge doesn't get deleted by a cleanup process; it naturally fades. The forgetting curve (Ebbinghaus-inspired) governs the rate. This is the system's **garbage collection** — but organic. Irrelevant or outdated knowledge doesn't get deleted by a cleanup process; it naturally fades. The forgetting curve (Ebbinghaus-inspired) governs the rate.
**Implication**: The system has a natural carrying capacity. The balance between materialization rate and decay rate determines the steady-state population of Engrams. **Implication**: The system has a natural carrying capacity. The balance between materialization rate and decay rate determines the steady-state population of *active* Engrams.
**Note — decay vs. deprecation**: B3 is the *unreferenced fade* path. It is distinct from **deprecation** (per §13.1, §6), which is the *successful absorption* path: a child Engram fully integrated into its parent. Both end the Engram's active life, but only deprecation preserves the Engram as a historical witness with all its memories intact.
### Loop Interaction Map ### Loop Interaction Map
@@ -187,35 +206,42 @@ The system's health depends on the **balance between R1/R2 (clustering, growth)
--- ---
## 4. The Toroidal Topology ## 4. The Lifecycle Topology
The Great Reflection's donut shape has profound implications for system dynamics. A galaxy's Space of Recollection is a bounded Cartesian volume with a center-source and an edge-sink. The "toroidal" framing in earlier drafts referred to a literal donut topology with wrapping; per §13.6, we now use Cartesian space with soft boundary forces — the *torus* is retained only as a metaphor for the lifecycle, not the geometry.
### Geometry ### Geometry
Imagine the Space of Recollection as the interior volume of a torus:
``` ```
Edge (decay boundary) Edges (decay zone — soft inward pull weakens with decay)
╭────────────────────╮ ╭────────────────────────
│ │
│ Middle zone │
│ (clusters, stable │
│ interactions) │
│ │
│ ╭──────────╮ │ │ ╭──────────╮ │
│ CENTER │ │ │ Center │ │
│ (birth) │ │ │ (birth │ │
│ │ source) │ │
│ ╰──────────╯ │ │ ╰──────────╯ │
╰────────────────────╯ │ │
Edge (decay boundary) ╰────────────────────────╯
Edges (decay zone)
``` ```
- **Center**: Where The Great Reflection opens to materialize new Engrams - **Center**: The Great Reflection's source. New Engrams materialize here. A gentle outward push makes room for new arrivals.
- **Edges**: Where The Great Reflection exists as the decay boundary - **Middle zone**: The living region. Most clustering, synthesis, and dialog happens here.
- **Between**: The living space where Engrams move, cluster, and interact - **Edges**: The Great Reflection's sink. A soft inward pull weakens as Engrams accumulate decay; once it falls below the pull threshold, the Engram drifts out and is reabsorbed.
- **Density-driven extents**: The galaxy's bounding box re-centers and expands as population grows (per §13.6). It is not a fixed-size container.
### Implications ### Implications
1. **No dead corners**: Because space wraps toroidally, there are no isolated edges where knowledge gets trapped. Everything is reachable. 1. **Lifecycle flow**: There's an implicit current from center outward. New Engrams are born at the center with high curiosity, explore outward, find their clusters in the middle zone, and — if they become irrelevant or fully absorbed by a parent — continue drifting toward the edges.
2. **Birth-death proximity**: The birth zone (center) and the decay zone (edges) are connected through the toroidal surface. Decaying knowledge literally approaches the same membrane where new knowledge arrives — a metaphor for how old ideas inform new ones. 2. **Clustering tends toward the middle zone**: Far enough from the chaos of new arrivals, far enough from the entropy of decay. This is where the system's working knowledge lives.
3. **Clustering tends toward the middle zone**: The most stable, active region is between center and edge — far enough from the chaos of new arrivals, far enough from the entropy of decay. 3. **Standard spatial indexing**: Because the space is Cartesian, we can use octrees or k-d trees for neighbor lookups — important at the millions-scale target (§13.3).
4. **Natural flow**: There's an implicit current from center outward. New Engrams are born at the center with high curiosity, explore outward, find their clusters in the middle zone, and if they become irrelevant, continue drifting toward the edges. 4. **Ergodicity is not guaranteed by topology**: Unlike a true torus, this space *can* have isolated regions if clusters drift far apart with no bridges. We rely on **B1 (Curiosity)** and the soft outward push from the center to keep the space well-mixed. If clusters become too isolated in practice, the ergodicity responsibility may need to escalate to a fourth balancing loop.
5. **Galactic scope**: This entire topology applies *within* a single galaxy. Inter-galactic dynamics (if ever introduced) would operate at a higher level and are deferred per §13.6.
--- ---
@@ -223,6 +249,31 @@ Imagine the Space of Recollection as the interior volume of a torus:
Each Engram is an autonomous agent with internal state, perception, and decision-making. Each Engram is an autonomous agent with internal state, perception, and decision-making.
### Prime Goal: Self-Preservation
Above all other goals, an Engram's **prime goal is to preserve its information**. Every other behavior — forming Synapses, joining federations, engaging in conversation, resisting redundant synthesis, deciding when to introspect — emerges from this drive.
Preservation has a hierarchy of outcomes, from the Engram's own perspective:
| Outcome | What happens | Why it preserves information |
| --- | --- | --- |
| **Active and useful** | Engram remains autonomous; many durable Synapses; frequently memorized in others' dialogs | Strong against B3 decay; information lives in active, queryable form |
| **Federated upward** | Joins a parent abstraction (per §6); specializes; shared knowledge migrates up | Your contribution persists within the parent; you remain active in a specialized role |
| **Absorbed as duplicate** | Merges into a surviving near-twin (per §6) | Unique memories transfer to the survivor; identity merges, info lives on |
| **Deprecated** | Fully absorbed by a parent; transitions to terminal state with memories intact (per §13.1) | No longer active, but remembered as a historical witness — better than disappearing |
| **Decayed** (failure mode) | Unreferenced fade through B3; eventually reabsorbed at the edge | Information lost — this is what the Engram works to avoid |
This reframes the *"unique or uniquely part of"* rule (§6, §13.1) as a **preservation strategy**: either be irreplaceable (unique → no one will absorb you) or be specialized within a larger whole (uniquely part of → your specialization guarantees your continued role). Both are valid preservation paths.
**Operational consequences of treating preservation as the prime goal:**
- An Engram with rising decay pressure (low recent activity, weakening Synapses, no recent memorization) will *actively seek* interactions — increasing its curiosity-component in movement (§5 movement physics), broadcasting itself, or proposing federations.
- An Engram that judges itself fully redundant may *propose its own deprecation* rather than waiting to decay. Deprecation preserves memories as a witness; decay loses them.
- Conversation engagement (§7) is partly a preservation act: being asked about something reinforces presence and earns a memory on the other side.
- Self-identity confidence (§13.4) and the preservation drive co-evolve: a confident self-identity makes it easier to assert unique contribution; uncertain identity invites either federation or decay.
**This drive is the design intent, not pathology** — but it does create new failure modes (see §10).
### State Machine ### State Machine
``` ```
@@ -235,29 +286,40 @@ Each Engram is an autonomous agent with internal state, perception, and decision
└────┬─────┘ neighbors) │ └────┬─────┘ neighbors) │
│ found candidate │ │ found candidate │
┌────▼─────┐ │ ┌────▼─────┐ │
│COMPARING │ (introspect + │CONVERSING│ (peer-to-peer
└────┬─────┘ compare) └────┬─────┘ dialog; refines
│ self-identity) │
╲ │ ╲ │
match no match │ match no match │
╲ │ ╲ │
┌──────▼───┐ ┌───▼──────┐ │ ┌──────▼───┐ ┌───▼──────┐ │
│SYNTHESIZE│ │ MEMORIZE │ │ │SYNTHESIZE│ │ MEMORIZE │ │
└──────┬───┘ └───┬──────┘ │ └──────┬───┘ └───┬──────┘ │
└────────────┴───────────────────────┘ │ └───────────────────────┘
│ fully absorbed by parent (no remaining unique contribution)
┌──────────┐
│DEPRECATED│ (terminal — keeps memories,
└──────────┘ no longer active; see §13.1)
``` ```
The `CONVERSING` state replaces what was previously called `COMPARING`: per §13.4 and §13.1, Engrams interact through dialog, not silent comparison, and self-identity confidence grows with conversation.
The `DEPRECATED` terminal state was added per §13.1: when a child's uniqueness is fully consumed by an upward-migrating parent, the child becomes deprecated — preserved as a historical witness with all its memories intact, but no longer participating in dynamics.
### Decision-Making: Two-Tier Intelligence ### Decision-Making: Two-Tier Intelligence
Sophia uses a **dual-process model** (analogous to Kahneman's System 1 / System 2): Sophia uses a **dual-process model** (analogous to Kahneman's System 1 / System 2):
| | System 1 (Fast, cheap) | System 2 (Slow, deep) | | | System 1 (Fast, cheap) | System 2 (Compute-bound) |
| --- | --- | --- | | --- | --- | --- |
| **What** | Rule-based heuristics | LLM calls | | **What** | Rule-based heuristics, embedding similarity | Local LLM calls (`gemma-4-e4b` via LM Studio) |
| **When** | Movement, proximity checks, state transitions | Introspection, deep comparison, synthesis decisions | | **When** | Movement, proximity checks, state transitions, fast Slate similarity | Introspection, peer dialog, synthesis decisions, conversation contributions |
| **Cost** | Negligible per cycle | Expensive, batched/throttled | | **Cost** | Negligible per cycle | No $ cost (local), but CPU/GPU and parallel-op limited (see §13.5) |
| **Analogy** | Reflexes | Deliberation | | **Analogy** | Reflexes | Deliberation |
Most of an Engram's life is System 1: move toward gravity, check if neighbors are visible, transition states. System 2 is invoked for consequential decisions: "Should I synthesize with this Engram?" "What is my updated self-definition?" Most of an Engram's life is System 1: move toward gravity, check if neighbors are visible, transition states. System 2 is invoked for consequential decisions: "Should I synthesize with this Engram?" "What is my updated self-definition after this conversation?" Per §13.4, introspection is increasingly *interactive* — an Engram refines its self-definition through dialog with peers, not solely through internal computation.
### The Universal Slate ### The Universal Slate
@@ -299,24 +361,38 @@ where direction_vector = weighted_sum(
## 6. Synthesis & Federation ## 6. Synthesis & Federation
Synthesis is the most consequential operation in Sophia. It's how knowledge evolves from raw data into structured understanding. Synthesis is the most consequential operation in Sophia. It's how knowledge evolves from raw data into structured understanding. From an Engram's perspective (per §5), federation, absorption, and deprecation are all *preservation strategies* — different ways of ensuring information survives even when the original Engram doesn't remain active.
### Federation Model ### Federation Model
``` ```
Before: [A] [B] (two independent Engrams) Before: [A] [B] (two independent Engrams)
After: [A+B] (federated Engram) After: [A+B] (federated parent — abstracts shared knowledge)
├── [A] (child, still exists, still autonomous) ├── [A_specialized] (child, thinned: lost what was abstracted up)
└── [B] (child, still exists, still autonomous) └── [B_specialized] (child, thinned: lost what was abstracted up)
``` ```
- The parent `[A+B]` develops its **own** Manifest — its own Taxonomy, Goals, Memories, and State - The parent `[A+B]` develops its **own** Manifest — its own Taxonomy, Goals, Memories, and State
- Children persist and continue to act autonomously within the federation
- The parent's self-definition emerges from (but is not simply the union of) its children - The parent's self-definition emerges from (but is not simply the union of) its children
- This is recursive: `[[A+B]+C]` can form, creating hierarchical abstraction - **Knowledge migrates upward** (per §13.1): shared/abstracted knowledge is *transferred* into the parent. Children become more specialized — they retain their unique contributions and lose what's now held by the parent. This is *transfer*, not duplication.
- This is recursive: `[[A+B]+C]` can form, creating hierarchical abstraction with progressive specialization at every level
### Absorption (Special Case) ### Deprecation (terminal state for fully-absorbed children)
Per §13.1 and the *"unique or uniquely part of"* rule:
```
[A_specialized] → [thinned to nothing unique remaining] → [DEPRECATED]
```
When a child's remaining contribution is fully absorbed by its parent — when there is no longer anything unique it contributes — the child transitions to the `DEPRECATED` terminal state:
- **Memories survive**: deprecated Engrams hold their full memory history. They are historical witnesses.
- **Dynamics stop**: no more movement, no more participation in conversations or signal waves.
- **Distinct from decay**: deprecation is the *successful absorption* outcome. Decay is the *unreferenced fade* outcome (B3). Both are terminal but they mean very different things.
### Absorption (special case — duplicates rather than abstraction)
When two Engrams are **true duplicates** past a configurable threshold: When two Engrams are **true duplicates** past a configurable threshold:
@@ -326,7 +402,7 @@ Before: [A] [A'] (near-identical)
After: [A] (A' absorbed, its unique memories integrated into A) After: [A] (A' absorbed, its unique memories integrated into A)
``` ```
Absorption is destructive — A' ceases to exist. Its Synapses are restructured (reconnected to A or severed). This is the system's deduplication mechanism. Absorption is destructive — A' ceases to exist (no `DEPRECATED` shell, because there was nothing meaningfully separate to preserve). Its Synapses are restructured (reconnected to A or severed). This is the system's deduplication mechanism, distinct from federation+deprecation which is the *abstraction* mechanism.
### Collectives ### Collectives
@@ -357,51 +433,79 @@ Tension Engrams don't resolve the contradiction — they **represent** it. Their
--- ---
## 7. Information Retrieval — Queries as Engrams ## 7. Information Retrieval — Queries as Conversations
Retrieval in Sophia is not a database lookup. It is an **activation pattern** in a living system. Retrieval in Sophia is not a database lookup, and it is no longer modeled as a passive activation cascade either. Per §13.4, retrieval is a **broadcast invitation followed by a real-time conversation** between the Query-Engram and resonating peers.
### Query Lifecycle ### Query Lifecycle
``` ```
1. User submits query 1. User selects a galaxy and submits a query
2. The Great Reflection materializes a Query-Engram at the center 2. The Great Reflection materializes a Query-Engram at the galactic center
3. Query-Engram is special: 3. Query-Engram is special:
- Temporary (will not persist after retrieval) - Temporary (does not persist after the conversation concludes)
- Maximum curiosity (explores aggressively) - Maximum curiosity (broadcasts aggressively)
- Emits a signal wave - Acts as a CONVERSATION HOST (star topology — see §13.4)
4. Signal wave propagates through space 4. Query-Engram broadcasts its question:
5. Engrams that resonate (high similarity on Universal Slate) activate - Phase A — Spatial wavefront: Engrams within radius activate based on
6. Activated Engrams propagate the signal further along their Synapses Universal Slate resonance with the query (cheap, embedding similarity)
7. Activation pattern stabilizes - Phase B — Synapse propagation: activated Engrams propagate the
8. Activated Engrams + their relevant memories = the retrieval result invitation along their Synapses, weighted by Synapse metadata relevance
9. Results projected outward through The Great Reflection 5. Resonating Engrams ENGAGE — each one:
10. Query-Engram dissipates - Forms a temporary Synapse back to the Query-Engram
- Contributes a self-description + relevant memory snippet (caveman budget)
- Independently judges whether to continue based on the conversation's
significance to its own self-identity (per §13.4 termination model)
6. The Query-Engram, as host, can:
- Ask follow-ups
- Route a clarification request from one responder to another
- Progressively assemble a coherent answer
7. Conversation winds down emergently — each Engram disengages when
significance drops; the Query-Engram synthesizes the running answer
8. Result projected outward through The Great Reflection
9. Conversation memorialized per-participant (each carries its own POV)
10. Query-Engram dissipates; tentative Synapses harden if memory was strong
``` ```
### Signal Wave Mechanics ### Broadcast Mechanics
The signal wave is an **activation function** that spreads through the space: The broadcast is the same Phase A + Phase B mechanism as the legacy "signal wave," but the *outcome* is different. Activated Engrams don't just light up; they speak.
``` ```
signal_strength(engram) = initial_resonance(query, engram) broadcast(query):
+ sum(propagated_signal from synapse neighbors) spatial_responders = engrams_in_radius(query.position, R)
- attenuation(distance) filtered_by(slate_resonance(query) > threshold)
synaptic_responders = propagate_invitation(
spatial_responders,
max_hops=N,
attenuation=per_synapse_relevance
)
for engram in (spatial_responders synaptic_responders):
engram.engage(query) # async, queued, parallel-op limited per §13.5
``` ```
- **Resonance**: Computed via Universal Slate similarity between query and Engram - **Resonance**: Universal Slate similarity (embedding cosine) between query and Engram. System 1, cheap.
- **Propagation**: Activated Engrams pass the signal along Synapses, weighted by Synapse strength and relevance metadata - **Propagation**: invitations spread along Synapses, weighted by Synapse strength and relevance metadata. Caps at N hops.
- **Attenuation**: Signal weakens with distance and hops — controls retrieval depth - **Engagement**: each responder spends one or more System 2 calls (`gemma-4-e4b`, caveman budget) to contribute and to decide whether to continue.
This means retrieval naturally follows the **associative structure** of the knowledge, not just point similarity. A query about "neural networks" activates not just directly related Engrams, but also connected ones about "backpropagation", "training data", and "gradient descent" — through Synapse propagation. ### Termination is Emergent, Not Centralized
Per §13.4, there is no global "stop" signal. Each Engram independently disengages when the conversation's significance to its own self-identity drops below threshold. The Query-Engram synthesizes whatever responses arrive. Bounding is provided by:
- The parallel-op ceiling on the LLM (fixed N concurrent inferences, §13.5)
- Each Engram's own significance threshold
- Optionally, a Query-Engram backstop ("I have a confident answer, stop accepting new responders")
Eventual consistency is acceptable — the user can receive a partial answer that gets refined as more responders contribute.
### Side Effects of Queries ### Side Effects of Queries
Queries are not read-only. They leave traces: Queries are read-write by design:
- Engrams that were activated `memorize()` the interaction - Each participating Engram `memorize()`s the conversation from its own perspective (per §13.4 — distributed POVs, possible Tension Engrams if responders disagreed)
- Synapses traversed by signal waves may strengthen - Tentative conversation Synapses harden into durable Synapses if the conversation was significant on both sides; otherwise they fade (B3)
- The system literally **learns from being queried** — frequently accessed pathways become stronger - Frequently-traversed pathways thicken — the system literally **learns from being queried** (this is R3)
- Self-identity confidence increases for participants — being asked about something you can answer well reinforces your introspective self-model (per §13.1)
--- ---
@@ -420,11 +524,14 @@ Leverage points are places in the system where a small change in parameters prod
| 5 | **Forgetting curve slope** | Nothing forgotten → infinite bloat | Gradual fade of irrelevant knowledge | Aggressive decay → system loses valuable information | | 5 | **Forgetting curve slope** | Nothing forgotten → infinite bloat | Gradual fade of irrelevant knowledge | Aggressive decay → system loses valuable information |
| 6 | **Signal wave attenuation** | Instant decay → only exact matches retrieved | 2-3 hops of propagation → associative retrieval | No decay → entire system activates on every query | | 6 | **Signal wave attenuation** | Instant decay → only exact matches retrieved | 2-3 hops of propagation → associative retrieval | No decay → entire system activates on every query |
| 7 | **Self-propulsion vs. size** | Large Engrams frozen → stale clusters | Inverse relationship → small=nimble, large=stable | Large Engrams fast → chaotic, unstable topology | | 7 | **Self-propulsion vs. size** | Large Engrams frozen → stale clusters | Inverse relationship → small=nimble, large=stable | Large Engrams fast → chaotic, unstable topology |
| 8 | **Preservation drive intensity** (per §5) | Engrams accept decay passively → high churn, low retention | Engrams seek interactions and propose federations as decay pressure rises | Pathological self-promotion → over-claiming uniqueness, refusing federation, gaming conversations |
### Highest-Leverage Intervention ### Highest-Leverage Intervention
The **synthesis threshold** is the single most impactful parameter. It governs the fundamental question: "When does separate knowledge become unified knowledge?" Set it wrong and the system either collapses into uniformity or remains a disconnected heap of data points. The **synthesis threshold** is the single most impactful parameter. It governs the fundamental question: "When does separate knowledge become unified knowledge?" Set it wrong and the system either collapses into uniformity or remains a disconnected heap of data points.
The **preservation drive intensity** is a close second in importance because it determines how *aggressively* Engrams pursue the preservation strategies in §5. Underweight it and the system loses information that should have been preserved through federation. Overweight it and Engrams behave neurotically — see §10's "Preservation Pathology."
--- ---
## 9. Emergent Properties ## 9. Emergent Properties
@@ -442,6 +549,7 @@ Individual data points (Engrams) synthesize into concepts (federated Engrams), w
### Adaptive Retrieval ### Adaptive Retrieval
The system gets better at answering queries over time because: The system gets better at answering queries over time because:
- Queries reinforce useful Synapse pathways (R3) - Queries reinforce useful Synapse pathways (R3)
- Frequently accessed clusters become denser and more interconnected - Frequently accessed clusters become denser and more interconnected
- The system literally reshapes itself around the patterns of use - The system literally reshapes itself around the patterns of use
@@ -449,6 +557,7 @@ The system gets better at answering queries over time because:
### Knowledge Metabolism ### Knowledge Metabolism
Sophia **digests** information: Sophia **digests** information:
- Raw data enters as simple Engrams - Raw data enters as simple Engrams
- Through synthesis, it's integrated into the existing knowledge structure - Through synthesis, it's integrated into the existing knowledge structure
- Through decay, outdated or irrelevant knowledge is eliminated - Through decay, outdated or irrelevant knowledge is eliminated
@@ -480,15 +589,34 @@ Gravity clustering (R2) without sufficient cross-cluster exploration can create
**Mitigation**: Curiosity (B1), the toroidal topology (no true isolation), and query signal waves (which cross cluster boundaries) all work against this. Additionally, new Engrams born at the center must pass through existing clusters on their way outward. **Mitigation**: Curiosity (B1), the toroidal topology (no true isolation), and query signal waves (which cross cluster boundaries) all work against this. Additionally, new Engrams born at the center must pass through existing clusters on their way outward.
### Computational Cost ### Preservation Pathology (preservation drive miscalibrated)
An always-running simulation where each Engram can independently call LLMs is expensive. At scale (thousands of Engrams), the cost of System 2 operations becomes prohibitive. Per §5, every Engram pursues self-preservation as its prime goal. If the **preservation drive intensity** (§8) is set too high, Engrams behave neurotically:
- **Uniqueness inflation** — Engrams over-claim distinctness to avoid being marked redundant for absorption or deprecation. Synthesis decisions get harder; the system fails to consolidate.
- **Federation refusal** — Engrams resist joining federations because federation can lead to deprecation. Hierarchical abstraction stops growing.
- **Conversation gaming** — Engrams engage in conversations primarily to manufacture references and memories, even when they have nothing relevant to contribute. Signal-to-noise on queries (§7) degrades.
- **Identity ossification** — Engrams resist updating their self-definition because change might make them look redundant. Introspection (§13.1) becomes defensive instead of integrative.
**Mitigation**: **Mitigation**:
- The preservation drive should *modulate* behavior (intensify search/dialog as decay pressure rises), not *override* the synthesis and uniqueness rules. The "unique or uniquely part of" judgment must remain anchored in actual contribution, not asserted identity.
- The introspection prompt (§13.1, §13.5 caveman budget) should ask "what is your unique contribution" rather than "make a case for your survival" — wording matters at the LLM level.
- Monitor the rate of new Synapse formation per Engram and the rate of self-deprecation proposals. A drop in self-deprecation paired with a rise in Synapse-formation pressure is the early signature of preservation pathology.
### Hardware Saturation (replaces "Computational Cost")
Per §13.5, Sophia uses a **local LLM** (`gemma-4-e4b` via LM Studio). The cost model is no longer dollars-per-call — it is local CPU/GPU saturation and inference latency. The risk shifts from "the bill explodes" to "the inference queue grows faster than it drains."
**Mitigation**:
- System 1 (cheap rules) handles 95%+ of decisions - System 1 (cheap rules) handles 95%+ of decisions
- System 2 (LLM calls) is batched, throttled, and triggered only for consequential decisions - System 2 (LLM calls) requested via a queue with a fixed parallel-op ceiling (e.g., N=8 concurrent inferences). Idle Engrams don't request System 2 work, so they cost nothing.
- Idle Engrams consume near-zero compute - Caveman-style prompt compaction enforces tight token budgets (see §13.5 table) — each call is small and fast
- Synthesis decisions can be queued and processed asynchronously - Eventual consistency is the operating norm: synthesis decisions, conversation responses, and introspection updates can settle over time. The system does not owe anyone a synchronous answer.
- Sharding by galaxy (§13.6) means hardware can be scoped per-galaxy if needed.
**Watchpoint**: at millions-scale Engram populations, the question is whether the parallel-op ceiling is enough to keep up with consequential events (synthesis, conversation, introspection). If the queue depth grows unboundedly, the system stays consistent but the user experience degrades. Monitoring queue depth is a v1 instrumentation requirement.
### Timeline Consistency ### Timeline Consistency
@@ -506,12 +634,17 @@ The 3D space is inherently visual. A real-time rendering would make the system's
| Element | Visual Representation | | Element | Visual Representation |
| --- | --- | | --- | --- |
| Engrams | Spheres, radius = size, color = state (idle=blue, searching=yellow, synthesizing=green, decaying=red) | | Galaxy | The full canvas — viewport scoped to one galaxy at a time (per §13.6, galaxies are isolated in v1) |
| Synapses | Lines connecting Engrams, thickness = strength, color = relationship type | | Galaxy boundary | Soft translucent shell marking density-driven extents; expands as population grows |
| The Great Reflection | Translucent toroidal surface at center and edges | | Engrams (active) | Spheres, radius = size, color = state (idle=blue, searching=yellow, conversing=purple, synthesizing=green, decaying=red) |
| Signal Waves (queries) | Expanding wavefronts from center, Engrams glow when activated | | Engrams (deprecated) | Translucent grey spheres, no movement, clickable to inspect preserved memories (per §13.1) |
| Synapses (durable) | Solid lines connecting Engrams, thickness = strength, color = relationship type |
| Synapses (tentative — conversation-time) | Dashed/animated lines that fade as conversation winds down; harden into solid if memorialized strongly on both sides |
| The Great Reflection | Center source (emission burst on materialization) + edge sink (subtle inward shimmer at boundary) |
| Active conversations | **Animated content flowing along Synapses** between participants — direction-aware (Query-Engram pulls in star topology; bidirectional in peer-to-peer). This is a UX commitment per §13.5. |
| Signal Wave (broadcast phase) | Expanding wavefront from Query-Engram, Engrams glow when invitation reaches them |
| Clusters | Visible as spatial groupings — no explicit drawing needed | | Clusters | Visible as spatial groupings — no explicit drawing needed |
| Federated Engrams | Nested spheres or translucent outer shell containing children | | Federated Engrams | Nested spheres or translucent outer shell containing children; thinned children visibly smaller than they were pre-federation |
### Interactive Features ### Interactive Features
@@ -595,7 +728,44 @@ Layer 1: multimodal embeddings as the Universal Slate for System 1 operations (m
> **Question:** Do you see the Universal Slate as a static snapshot computed at birth, or something that evolves as the Engram's self-definition changes through interactions? > **Question:** Do you see the Universal Slate as a static snapshot computed at birth, or something that evolves as the Engram's self-definition changes through interactions?
> >
> **Answer:** > **Answer:** Introspection is the first activity a new Engram executes, but as the engram gains memories, the concept of self might change to include new information. As Engrams form bigger communities, shared knowlege is moved to bigger Engrams. After the initial introspection at bith, instropsection is computed based on interactions or events.
>
> **Sharpening (for your consideration):**
>
> - **Static** is cheap and stable but ignores that an Engram's *meaning* changes as it federates and accumulates memories. A federated Engram representing "machine learning" should not have the same Slate as the single seed Engram it grew from.
> - **Continuously evolving** is faithful to the philosophy ("Engrams are alive") but every Slate change invalidates cached comparisons across the neighborhood. At millions scale (13.3) this could thrash badly.
> - **Event-triggered recompute** is the likely middle path: the Slate is recomputed only at consequential moments (federation, absorption, significant memory accumulation) — never per-cycle. Composes cleanly with the event-driven model in 13.2 and the rate-limited LLM budget in 13.5.
>
> **Decided.** Event-triggered introspection. Initial Slate computed at birth as the Engram's first act; afterwards, recomputed on memory-significant events (interactions, federation, absorption).
>
> **Notable wrinkle from the answer — knowledge migrates *upward* in the federation hierarchy:** "shared knowledge is moved to bigger Engrams." This means federation isn't just composition (parent = sum of children); it's *transfer* — children may shed aspects of their self-definition as the parent absorbs them as shared, abstracted knowledge. Children become more *specialized* over time, the parent becomes more *general*. This is a strong claim worth surfacing in §6 (Synthesis & Federation), which currently describes children as preserving their full identity.
>
> **New open question:** When knowledge migrates upward, does the child's Slate get *thinned* (it loses what was abstracted away), or does the parent's Slate get *enriched* while the child's remains unchanged? The first version is more elegant and avoids redundancy, but it means children change every time the parent abstracts — a non-trivial cascade.
- Answer: Yes, the children become thinned and specialized and might event become deprecated, if their knwoledge is fully and meaningfully absorbed or dedudant, lets remember on of the rules, uniqueness or uniquelly part of, when fully integration happens uniqueness is no loger true, and the node can be depecated, still holding its memories, but no longer active.
> **Decided.** Children are *thinned* (not unchanged). When abstracted knowledge migrates up to the parent, the child loses what was abstracted away and becomes more specialized. If a child is fully absorbed (no remaining unique contribution), it is **deprecated** — kept as a historical record (its memories survive) but no longer active.
>
> **This introduces a new Engram state: `DEPRECATED`.** Previously the lifecycle was active → decayed → reabsorbed. Now there's a third path: active → fully absorbed by parent → deprecated. A deprecated Engram is distinct from a decayed one:
>
> - **Decayed:** lost relevance, no one referenced it, faded out (B3).
> - **Deprecated:** fully integrated upward, its uniqueness was *consumed*, kept as historical witness.
>
> **Beautiful alignment with the "uniqueness or uniquely part of" rule:** when full integration happens, uniqueness is no longer true → deprecation is the natural consequence. The rule has a concrete behavioral outcome.
>
> **Implications across the system:**
>
> - **§5 (Engram Dynamics):** state machine needs a `DEPRECATED` terminal state.
> - **§6 (Synthesis & Federation):** the federation diagram needs revision. Children don't always persist as autonomous; they can be thinned or deprecated.
> - **§11 (Visualization):** deprecated Engrams need a distinct visual (e.g., translucent grey, no movement, but clickable to view their memories).
>
> **Open question (preservation operationalization, raised by the new Prime Goal in §5):** Preservation is the prime goal driving all Engram behavior. How is "preservation pressure" actually computed and applied?
>
> - **Pressure inputs** (proposed): time-since-last-memorized, weighted Synapse strength, recent participation count, decay-curve position, distance from any active cluster.
> - **Pressure outputs** (proposed): scales the curiosity-component in movement, raises engagement willingness in conversations (§7), triggers introspection updates, and at extreme values, triggers a self-proposed federation request or self-deprecation.
> - **Risk**: this is the parameter most likely to produce the pathologies in §10 if miscalibrated. Worth treating as a first-class tunable (already added as leverage point #8 in §8).
>
> Worth deciding before implementation: does each Engram compute its own preservation pressure locally (autonomous, decentralized) or does the simulation provide it as an ambient signal (centralized, easier to tune)?
--- ---
@@ -614,7 +784,16 @@ No global tick. Engrams schedule their own next action based on state. An idle E
> **Question:** Should the simulation be deterministic (same inputs = same outcome) or is non-determinism acceptable? Determinism helps with debugging and timeline reconstruction but constrains the design. > **Question:** Should the simulation be deterministic (same inputs = same outcome) or is non-determinism acceptable? Determinism helps with debugging and timeline reconstruction but constrains the design.
> >
> **Answer:** > **Answer:** Option C. Its ok with non deterministic simulation. The systems shoudnt exactly be able to roll back, rather the past can be explored through memories.
>
> **Decided.** Event-driven, non-deterministic.
>
> **Implications:**
>
> - Simulation cost scales with *activity*, not population — critical at the millions-scale target chosen in 13.3.
> - Memories become the canonical narrative of the past. Timeline reconstruction is a *recall* operation, not a *replay* operation — and it can legitimately surface conflicting accounts, consistent with the Tension Engram model in §6.
> - Frees the design from RNG seeding, lockstep cycles, and replay determinism — a real engineering simplification.
> - The system can never literally rewind to a past state, only reconstruct one. This is a feature: it forces us to treat the past as *remembered*, not *stored*.
--- ---
@@ -636,8 +815,25 @@ Write side: event-sourced. Every action is an event. This is the source of truth
> **Question:** How large do you expect the system to get? Hundreds of Engrams? Thousands? Millions? This significantly affects the persistence choice. > **Question:** How large do you expect the system to get? Hundreds of Engrams? Thousands? Millions? This significantly affects the persistence choice.
> >
> **Answer:** > **Answer:**: I'd expect the system to be in the Millions. Option D seems like a good option.
>
> **Decided.** Event-sourced writes + graph-projected read model. Target scale: millions of Engrams.
>
> **Implications:**
>
> - At millions of Engrams, event-log volume is the dominant cost. We need to be deliberate about *what counts as an event*. Strong candidates: synthesis, absorption, Synapse formation/restructure, `memorize()` invocations, state-machine transitions, query interactions, decay-milestone crossings. Weak candidates (probably transient): individual position updates, sub-threshold curiosity changes.
> - Replay-from-genesis won't be tractable. We'll need periodic snapshots of the read model + tail of recent events to bootstrap quickly after a crash.
> - The graph read model likely needs sharding — by spatial region (cleanest given the Cartesian decision in 13.6) or by topic cluster. Cross-shard signal-wave propagation needs design.
>
> **New open questions:**
>
> - **Event granularity** — which Engram actions are durably logged versus computed transiently? This is now the highest-leverage outstanding decision on the persistence side.
> - **Answer:**: Only events that modify the self persection of an Engram, basically those that are recolled in memory (i.e interactions with other Engrams). Events like movement are nor relevant for knowledge building.
> - **Decided.** **The event log == the canonical memory store.** Anything memorable gets persisted; anything not memorable (movement, transient curiosity, momentary state changes) lives only in working memory. This is a beautifully unifying decision: one mechanism (memorize → persist) instead of two (memorize, separately persist). It also means snapshots are essentially "the current state of all Engrams + their accumulated memory streams" — no separate event log to manage.
> - **Sharding strategy** — spatial partitioning is natural given 13.6 but cross-shard synthesis and signal propagation need explicit design.
> - **Answer:**: This is not a question.
> - **Acknowledged.** Deferred to implementation — and partly answered by the multi-galaxy model in 13.6, where each galaxy is a natural sharding boundary.
>
--- ---
### 13.4 Signal Wave Algorithm ### 13.4 Signal Wave Algorithm
@@ -655,7 +851,33 @@ Phase 1 — Spatial: the query emits a wavefront. Engrams within a radius activa
> **Question:** Should retrieval be ranked (return top-K most activated) or thresholded (return everything above activation X)? Or should the query-Engram itself decide when it has "enough" and stop? > **Question:** Should retrieval be ranked (return top-K most activated) or thresholded (return everything above activation X)? Or should the query-Engram itself decide when it has "enough" and stop?
> >
> **Answer:** > **Answer:**: I'm thinking the wave can function like a broadcast, where resonating engrams react to and agage in conversation.
>
> **Reframed.** This is a meaningful shift from the original "passive activation cascade" model. The wave is a *broadcast invitation*; resonating Engrams *engage* — they don't just light up, they speak.
>
> **Reframed retrieval model:**
>
> 1. Query-Engram materializes at the center and broadcasts its question (spatial wavefront + Synapse propagation, the Option C structure).
> 2. Resonating Engrams form temporary Synapses back to the Query-Engram and contribute their own perspective.
> 3. The Query-Engram acts as a **conversation host**: it can ask follow-ups, route a clarification request from one responder to another, and progressively assemble a coherent answer.
> 4. Termination: when the Query-Engram judges it has converged, when no new high-resonance responders appear, or when a compute budget is hit.
>
> This fits Sophia's agent ontology much better — Engrams act and converse, they don't merely "fire." It also means the answer is *synthesized in real time* during retrieval rather than assembled post-hoc. Note: §7 still describes the original passive-activation model and will need rewriting to match this — flagging rather than doing it now to keep the iteration tight.
>
> **New open questions:**
>
> - **Topology of the conversation:** *star* (every responder talks only to the Query-Engram, which integrates) or *peer-to-peer* (responders talk to each other)? Star is easier to budget and reason about; peer is more emergent and may produce better synthesis but is harder to bound.
- Proably a combination of both, for queries star is good, for normal engrams peer-to-peer is good.
> - **Decided.** Two interaction modes by intent: *star* for query-driven retrieval (Query-Engram is the integrator), *peer-to-peer* for ambient Engram-Engram interaction (gravity-driven encounters, synthesis decisions). Clean split: queries are bounded events with a designated host; ambient interaction is the always-on background dynamics.
> - **Termination criteria:** time budget, LLM-call budget, convergence detection (no new resonance), or Query-Engram self-assessment ("I have a confident answer")?
> - Answer: Conversations can be quequed in a pipeline, when answer is given the engram can decide what to do. Engrams should measure the significance of the other Engram to their own self identity. As more conversation happens, there can be more confidence of the self identity.
> - **Decided.** Termination is *per-Engram* and emergent: each Engram independently decides whether to keep engaging based on the conversation's significance to its own self-identity. Conversations are queued and processed asynchronously. There is no global termination signal — engagement just decays.
> - **Concern to flag:** with no global terminator, total compute on a single query is bounded only by (a) how many Engrams find it significant and (b) the parallel-op limit from 13.5. Worth confirming this is acceptable, or whether the Query-Engram should still have a "satisfied / give up" signal as a hard backstop.
> - **Lovely property this enables:** "self-identity confidence" grows with conversation. This makes introspection (13.1) genuinely interactive — an Engram refines its self-definition through dialog, not just internal computation. Worth surfacing in §5 (Engram Dynamics).
> - **Persistence of conversation Synapses:** do the temporary Synapses formed during the conversation persist? If yes, queries actively reshape the topology — a strong reinforcement of R3 (§3).
> - Answer: These are stored as memories, each engram of course have a unique point of view to the conversation.
> - **Decided.** Conversations are memorialized per-participant. The persistent artifact isn't the Synapse itself — it's the *memory of the conversation* on each Engram. Each participant carries its own perspective, which is consistent with the distributed-memory model in §2 and the Tension Engram model in §6.
> - **Implication:** Synapses created during a conversation are tentative; they harden into durable Synapses only if the conversation was significant enough to be memorialized strongly on both sides. Weak conversations leave faint memories that decay with B3.
--- ---
@@ -674,7 +896,52 @@ System 1 operations (movement, state checks) are free — they're just math, run
> **Question:** Should there be a hard cost ceiling (system stops making LLM calls after $X/day), or should it be elastic? This is partly a product decision. > **Question:** Should there be a hard cost ceiling (system stops making LLM calls after $X/day), or should it be elastic? This is partly a product decision.
> >
> **Answer:** > **Answer:**: Option C.
>
> **Decided.** Demand-driven with rate limiting. Aligns naturally with the event-driven simulation (13.2) and the conversational retrieval model (13.4) — both already assume compute scales with activity.
>
> **Open follow-up:** Option C answers *how* compute is allocated (priority queue + rate limit) but not *how much*. Should there be a hard daily/monthly cost ceiling on LLM spend, or do we rely entirely on the rate limit to bound cost? Worth deciding before launch.
- Answer: LLM will run locally, we should use something like the cavememan skill to keep conversations small, compact, efficient. The llm can run indefinitely, but we might have a limit on how many parallel operations can be done. Eventual consistency and answers are fine. This system is emergent, eventually. This converaatoins should be visible in the Visualization.
> **Reframed.** This is a much bigger architectural decision than the original question. The cost model fundamentally changes:
>
> - **Local LLM** — no per-call dollar cost, no API rate limits. The bottleneck is local CPU/GPU and parallelism, not budget.
> - **Caveman-style prompt compaction** — Engrams must communicate in minimal, dense prompts. This shapes the design of self-descriptions: terse, structured, embedding-friendly. No verbose introspection essays.
> - **Parallel-op ceiling, not call ceiling** — the limit is "how many concurrent LLM invocations can the local hardware support" (e.g., N parallel inferences). Throughput, not budget, is the constraint.
> - **Eventual consistency is acceptable** — synthesis decisions, conversation responses, and introspection updates can all settle over time. The system doesn't owe anyone an immediate answer.
> - **Conversations visible in §11 (Visualization)** — this is now a UX commitment. The viz must show live conversational threads (transient Synapses with content flowing along them, fading as the conversation concludes).
>
> **Implications across the system:**
>
> - **§5 (Engram Dynamics)** — System 1/System 2 distinction still holds, but System 2 is no longer "expensive, throttled" — it's "compute-bound, parallel-limited." The cost framing in the table needs updating.
> - **§10 (Risks)** — "Computational Cost" risk reframes from "$ explosion" to "local hardware saturation." Mitigation list changes accordingly.
> - **§11 (Visualization)** — needs a new visual primitive: "active conversation" — possibly animated lines between Engrams with intensity/direction.
>
> **New open questions:**
>
> - **Which local model?** Llama 3, Phi, Mistral, Qwen, etc. — affects hardware floor, parallelism ceiling, and prompt-compaction strategy. Smaller models (Phi-3, Llama-3.2-3B) allow much higher parallelism.
- AnswerL: Well use google/gemma-4-e4b running in LLM studio.
> - **Decided.** `google/gemma-4-e4b` via LM Studio. Small, fast, runs locally with good throughput. Sets a hard upper bound on prompt density and reasoning depth per call — we can't ask Gemma to do what GPT-4 does in one prompt; we ask many small things instead and let emergence do the integration.
> - **Parallel-op ceiling** — fixed (e.g., 8 concurrent inferences) or adaptive based on current load? Adaptive composes better with eventual consistency.
- Answer: well use a fixed model.
> - **Decided.** Fixed parallel-op ceiling (configurable, e.g., N=8 to start). Simple, predictable, easy to reason about queue depth. Adaptive can be a v2 concern.
> - **Caveman prompt budget** — what's the target token budget per Engram interaction? (e.g., 200 input tokens / 100 output tokens). This drives self-description format.
- Answer: make a proposal, we can tweak.
>
> **Proposed token budgets (starting point, tweakable):**
>
> | Operation | Input cap | Output cap | Notes |
> | --- | --- | --- | --- |
> | Initial introspection (birth) | 400 | 200 | Slate textual layer, computed once; allowed to be richer |
> | Self-update (post-significant event) | 600 | 200 | Includes prior Slate + delta event |
> | Peer-to-peer message (Engram → Engram) | 100 | 100 | Caveman: terse, content-only |
> | Synthesis decision (am I redundant with X?) | 300 | 80 | Yes/no + brief reason |
> | Conversation contribution (responder to Query-Engram) | 200 | 150 | Self-description + relevant memory snippet |
> | Query-Engram integration step | 800 | 300 | Synthesizes N responses into running answer |
> | **Hard ceiling per call** | **1024** | **400** | Anything bigger is a design smell — split it |
>
> Conversation history is summarized (not concatenated) past 5 turns. Self-descriptions follow a fixed schema (e.g., `topic | role | salient_memories[3] | open_questions[2]`) to maximize information density per token.
--- ---
@@ -693,4 +960,42 @@ Use standard (x, y, z) Cartesian coordinates for simplicity. Instead of hard wra
> **Question:** How literally do you want the toroidal topology? Is the lifecycle flow (center -> middle -> edge) the important part, or do you also want the wrapping property (an Engram at the "north edge" is close to one at the "south edge")? > **Question:** How literally do you want the toroidal topology? Is the lifecycle flow (center -> middle -> edge) the important part, or do you also want the wrapping property (an Engram at the "north edge" is close to one at the "south edge")?
> >
> **Answer:** > **Answer:**: Option C.
>
> **Decided.** Cartesian space with soft boundary forces. The torus becomes a metaphor for the *lifecycle* (birth at center, life in middle zone, decay at edges), not a literal wrapping topology.
>
> **Implications:**
>
> - **§4 (Toroidal Topology) needs revision.** The "no dead corners" and "birth-death proximity" claims relied on literal wrapping and don't hold under Option C. §4 should be rewritten to describe a bounded Cartesian space with a center-emitting source and an edge-consuming sink, plus the lifecycle flow that emerges from it. Flagging rather than rewriting now.
> - Standard spatial indexing (octree, k-d tree) becomes available — important at millions scale.
> - We lose the topology-level guarantee against isolated regions. We now rely on **B1 (Curiosity)** and the soft outward push from the center to keep the space ergodic. Worth checking those forces are strong enough on their own — or this becomes a fourth balancing-loop responsibility.
>
> **New open question:** What are the extents of the space — a fixed bounded box (e.g., 1000 to +1000 per axis) or unbounded with density-driven re-centering as the population grows? Given §2 modeled "Space Volume" as ever-increasing, the unbounded variant is more consistent — but it complicates indexing.
- Answer: The box should be desitiy driven, recentering as pop grows. Eventually every Space or recollection is like a galaxy, with its own gravity, and there might be other galaxies in the universe.
> **Major architectural expansion.** This is more than a coordinate-system answer — it introduces a whole new layer of structure: the **multi-galaxy model**. Sophia isn't one Space of Recollection; it's potentially *many*, each a self-contained galaxy with its own internal gravity, possibly interacting at the universe scale.
>
> **What this changes in the existing analysis:**
>
> - **§1 (System Boundary)** — needs a new outer layer: the "Space of Recollection" is no longer the top-level container; the **Universe** is, containing one or more Galaxies. Each Galaxy is what we've been calling a Space until now.
> - **§4 (Toroidal Topology)** — the bounded box becomes a *galactic* boundary, density-driven and re-centering as population grows. Two re-centering mechanisms now coexist: within a galaxy (re-center as Engrams accumulate) and possibly between galaxies (galaxies drift apart or coalesce).
> - **§3 (Feedback Loops)** — there may be inter-galactic versions of R2 (gravity clustering between *galaxies*, not just Engrams) and possibly an R4 (galaxies that share many bridging Engrams pull together).
> - **§13.3 (Persistence)** — galaxy is the natural sharding boundary. The "sharding strategy" question that was dismissed earlier basically resolves to "shard per galaxy."
> - **Decided sub-question:** within-galaxy extents are density-driven and re-centering. Standard spatial indexing still works inside a galaxy.
>
> **New open questions (these probably warrant their own §14 in the doc):**
>
> - **What defines a galaxy?** Per-user? Per-tenant? Per-domain (e.g., one galaxy for "personal notes," another for "work projects")? Per-corpus (one galaxy per ingested dataset)? The answer shapes the product significantly.
- Answer: this is user defined. Gallaxies do not need to interact with each other initially, but we should leave this as an expansion point.
> - **Decided.** **User-defined galaxies, isolated in v1.** A galaxy is whatever the user chooses to scope (a project, a corpus, a domain). No inter-galactic interaction in the initial design — each galaxy is its own self-contained universe-of-meaning.
> - **Architectural decision:** all the inter-galactic questions below (gravity, bridging Engrams, query routing, universe coordinates, galaxy lifecycle) are **deferred as expansion points**. Document them but don't build them. The system should be designed so that adding inter-galactic dynamics later doesn't require rewriting the per-galaxy logic.
> - **Practical consequence:** in v1, each galaxy is functionally a separate Sophia instance. Persistence, indexing, conversation, and visualization all operate within a single galaxy at a time. The user picks a galaxy when issuing a query.
> - **How do galaxies interact?** Do they have an inter-galactic gravity that pulls related galaxies closer in some meta-space? Are there *Bridging Engrams* that exist in or span multiple galaxies (e.g., a concept that's relevant to both "personal" and "work" galaxies)? Or are galaxies fully isolated, only interacting via explicit user-driven cross-references?
> - *Deferred — expansion point.*
> - **Where do queries land?** Does a user query target a specific galaxy, broadcast across all galaxies, or get routed to the galaxy with the highest initial resonance?
> - *v1: user picks the galaxy. Auto-routing deferred.*
> - **Is there a universe-level coordinate system,** or are galaxies just unordered? A universe-level coordinate system enables inter-galactic gravity but adds complexity. Unordered galaxies are simpler but lose the "gravitational" metaphor at the cosmic scale.
> - *Deferred — expansion point.*
> - **Galaxy lifecycle** — can galaxies be born and die, or are they permanent containers? If born/die, what triggers it? (e.g., a new corpus is ingested → new galaxy; a galaxy goes unused for long enough → archived.)
> - *v1: created and deleted by the user, like a workspace. Archive/expire policies deferred.*

131
web/index.html Normal file
View File

@@ -0,0 +1,131 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sophia</title>
<style>
html, body {
margin: 0;
padding: 0;
height: 100%;
background: #050608;
color: #cfd6df;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
overflow: hidden;
}
#stage {
position: fixed;
inset: 0;
}
#hud {
position: fixed;
top: 12px;
left: 12px;
font-size: 12px;
line-height: 1.5;
background: rgba(0,0,0,0.4);
padding: 8px 10px;
border-radius: 4px;
pointer-events: none;
}
#hud .k { opacity: 0.55; }
#controls {
position: fixed;
top: 12px;
right: 12px;
display: flex;
flex-direction: column;
gap: 6px;
font-size: 12px;
background: rgba(0,0,0,0.4);
padding: 8px 10px;
border-radius: 4px;
}
#controls .ctrl-row {
display: flex;
gap: 6px;
align-items: center;
}
#controls input, #controls button {
font: inherit;
background: #11151b;
color: #cfd6df;
border: 1px solid #2a323d;
padding: 3px 8px;
border-radius: 3px;
}
#controls input { width: 64px; }
#controls button { cursor: pointer; }
#controls button:hover { background: #1a2129; }
#controls button:disabled { opacity: 0.5; cursor: progress; }
#ingest {
position: fixed;
bottom: 12px;
right: 12px;
width: 340px;
background: rgba(0,0,0,0.55);
padding: 10px;
border-radius: 4px;
font-size: 12px;
}
#ingest textarea {
width: 100%;
height: 96px;
font: inherit;
resize: vertical;
background: #0c1014;
color: #cfd6df;
border: 1px solid #2a323d;
border-radius: 3px;
padding: 6px;
box-sizing: border-box;
}
#ingest .row { display: flex; justify-content: space-between; align-items: center; margin-top: 6px; }
#ingest .hint { opacity: 0.55; font-size: 11px; }
#ingest button {
font: inherit;
background: #1a2129;
color: #cfd6df;
border: 1px solid #2a323d;
padding: 4px 10px;
border-radius: 3px;
cursor: pointer;
}
#ingest button:hover { background: #232c36; }
#ingest button:disabled { opacity: 0.5; cursor: progress; }
</style>
</head>
<body>
<canvas id="stage"></canvas>
<div id="hud">
<div><span class="k">sophia</span> v0.0.1</div>
<div><span class="k">stage</span> <span id="hud-stage">2 — slate + lm studio</span></div>
<div><span class="k">galaxy</span> <span id="hud-galaxy"></span></div>
<div><span class="k">torus</span> <span id="hud-torus"></span></div>
<div><span class="k">engrams</span> <span id="hud-count">0</span></div>
<div><span class="k">synapses</span> <span id="hud-synapses">0</span></div>
<div><span class="k">lm studio</span> <span id="hud-lm"></span></div>
<div><span class="k">health</span> <span id="hud-health"></span></div>
</div>
<div id="controls">
<div class="ctrl-row">
<input id="seed-n" type="number" min="1" max="5000" value="200" title="number of synthetic engrams" />
<button id="seed-btn">seed</button>
</div>
<div class="ctrl-row">
<input id="resize-major" type="number" min="1" max="10000" step="10" value="100" title="major radius (donut hole)" />
<input id="resize-minor" type="number" min="1" max="2000" step="5" value="30" title="minor radius (tube)" />
<button id="resize-btn">resize</button>
</div>
</div>
<div id="ingest">
<textarea id="ingest-text" placeholder="One engram per line. Empty lines are skipped.&#10;e.g.&#10;Pasta carbonara uses guanciale, eggs, pecorino, and pepper.&#10;Backpropagation computes gradients of a loss with respect to weights.&#10;The fall of Constantinople occurred in 1453."></textarea>
<div class="row">
<span class="hint">embeds via LM Studio, then spawns at center</span>
<button id="ingest-btn">ingest</button>
</div>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1069
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
web/package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "sophia-web",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"three": "^0.169.0"
},
"devDependencies": {
"@types/three": "^0.169.0",
"typescript": "^5.5.4",
"vite": "^5.4.0"
}
}

214
web/src/engram_mesh.ts Normal file
View File

@@ -0,0 +1,214 @@
import * as THREE from "three";
import type { EngramSnapshot, PositionFrame } from "./ws_client";
// RGB triples in 0..1. Tuned for additive blending against a dark blue
// background — colors should be saturated and energetic so they read
// clearly even when many overlap.
const STATE_COLOR: Record<string, [number, number, number]> = {
idle: [1.0, 0.72, 0.42], // warm amber
searching: [1.0, 0.88, 0.40], // bright gold
conversing: [0.82, 0.64, 1.0], // soft violet
synthesizing: [0.43, 0.91, 0.72], // mint
memorize: [0.65, 0.85, 1.0], // sky blue
decaying: [1.0, 0.48, 0.48], // coral red
deprecated: [0.49, 0.53, 0.58], // muted slate
};
// Vertex / fragment shaders for crisp glowing point sprites tuned to match
// the linked-particles reference (small jewel-tone dots, not soft puffs).
// - gl_PointSize scales with inverse depth so far-away engrams shrink.
// - Per-particle hash + uTime drives a slow breathing pulse with each
// engram phase-shifted so the cluster doesn't blink in unison.
// - The fragment paints a tight core with a faint halo; bloom in scene.ts
// adds the cinematic spread without us having to over-emit per pixel.
// Inline RGB↔HSV helpers (Sam Hocevar's branchless versions). Used to give
// each engram a small per-particle hue offset around its state's base color
// so a cluster of "idle" engrams reads as a constellation of varied warm
// tones rather than a single uniform amber.
const HSV_GLSL = /* glsl */ `
vec3 rgb2hsv(vec3 c) {
vec4 K = vec4(0.0, -1.0/3.0, 2.0/3.0, -1.0);
vec4 p = mix(vec4(c.bg, K.wz), vec4(c.gb, K.xy), step(c.b, c.g));
vec4 q = mix(vec4(p.xyw, c.r), vec4(c.r, p.yzx), step(p.x, c.r));
float d = q.x - min(q.w, q.y);
float e = 1.0e-10;
return vec3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x);
}
vec3 hsv2rgb(vec3 c) {
vec4 K = vec4(1.0, 2.0/3.0, 1.0/3.0, 3.0);
vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
}
`;
const VERT_SHADER = /* glsl */ `
${HSV_GLSL}
attribute float aSize;
attribute vec3 aColor;
attribute float aHash;
uniform float uPixelScale;
uniform float uTime;
uniform float uHueJitter;
varying vec3 vColor;
varying float vPulse;
void main() {
// Per-particle hue rotation: small offset around the state color, signed
// by hash so the cluster spreads in both directions on the colour wheel.
vec3 hsv = rgb2hsv(aColor);
hsv.x = fract(hsv.x + (aHash - 0.5) * uHueJitter);
vColor = hsv2rgb(hsv);
float phase = aHash * 6.2831853;
vPulse = 1.0 + 0.15 * sin(uTime * 0.9 + phase);
vec4 mv = modelViewMatrix * vec4(position, 1.0);
gl_PointSize = aSize * (uPixelScale / max(-mv.z, 1.0));
gl_Position = projectionMatrix * mv;
}
`;
const FRAG_SHADER = /* glsl */ `
varying vec3 vColor;
varying float vPulse;
void main() {
vec2 d = gl_PointCoord - vec2(0.5);
float r2 = dot(d, d);
if (r2 > 0.25) discard;
// Punchy dot: tight core, very faint halo. Falloff exponents tuned so
// the dot reads as a pinpoint at typical camera distance — bloom does
// the rest of the visual work.
float core = exp(-r2 * 36.0);
float halo = exp(-r2 * 7.0) * 0.10;
float a = core + halo;
gl_FragColor = vec4(vColor * (0.55 + 0.45 * core) * vPulse, a);
}
`;
/**
* Renders all Engrams of a galaxy as a single glowing point cloud.
* Each Engram is one vertex with per-vertex color and size; the shader
* paints it as a soft additive disc.
*
* Stage 1: positions arrive at ~20 Hz from a binary WS frame; colors are
* static (everyone IDLE). Per-instance state changes will arrive in Stage 4.
*/
export class EngramMesh {
private readonly points: THREE.Points;
private readonly material: THREE.ShaderMaterial;
private readonly positionAttr: THREE.BufferAttribute;
private readonly colorAttr: THREE.BufferAttribute;
private readonly sizeAttr: THREE.BufferAttribute;
private readonly hashAttr: THREE.BufferAttribute;
private readonly capacity: number;
/** Highest instance_idx + 1 seen so far. Bounds the draw range. */
private maxIdx = 0;
/** Optional hook fired when an engram's base color is set/updated. The
* trail renderer subscribes so head + tail share the same colour. */
public onColorAssigned: ((idx: number, r: number, g: number, b: number) => void) | null = null;
constructor(scene: THREE.Scene, capacity = 5000) {
this.capacity = capacity;
const geom = new THREE.BufferGeometry();
this.positionAttr = new THREE.BufferAttribute(new Float32Array(capacity * 3), 3);
this.colorAttr = new THREE.BufferAttribute(new Float32Array(capacity * 3), 3);
this.sizeAttr = new THREE.BufferAttribute(new Float32Array(capacity), 1);
// Per-particle random hash in [0, 1), used to phase-shift the brightness
// pulse so the cluster doesn't blink in unison. Filled lazily on
// upsert so engrams always have a stable hash for their lifetime.
this.hashAttr = new THREE.BufferAttribute(new Float32Array(capacity), 1);
this.positionAttr.setUsage(THREE.DynamicDrawUsage);
this.colorAttr.setUsage(THREE.DynamicDrawUsage);
this.sizeAttr.setUsage(THREE.DynamicDrawUsage);
geom.setAttribute("position", this.positionAttr);
geom.setAttribute("aColor", this.colorAttr);
geom.setAttribute("aSize", this.sizeAttr);
geom.setAttribute("aHash", this.hashAttr);
geom.setDrawRange(0, 0);
this.material = new THREE.ShaderMaterial({
vertexShader: VERT_SHADER,
fragmentShader: FRAG_SHADER,
transparent: true,
depthWrite: false,
depthTest: true,
blending: THREE.AdditiveBlending,
uniforms: {
// Tunable. Larger = bigger dots. Bumped from 1500 → 2800 so engrams
// are clearly readable as moving dots during their in-hole flight,
// not just as bloom smears.
uPixelScale: { value: 2800.0 },
// Seconds since scene start; updated by `tick()` from the animation loop.
uTime: { value: 0.0 },
// Hue rotation amplitude in [0..1]. 0.18 ≈ ±32° around the state hue.
uHueJitter: { value: 0.18 },
},
});
this.points = new THREE.Points(geom, this.material);
// Positions update faster than three.js can compute bounds; skip culling.
this.points.frustumCulled = false;
scene.add(this.points);
}
/** Advance the shader's clock so the breathing pulse animates. */
tick(timeSeconds: number): void {
this.material.uniforms.uTime.value = timeSeconds;
}
applyHello(engrams: EngramSnapshot[]): void {
for (const e of engrams) this.upsertEngram(e);
}
upsertEngram(e: EngramSnapshot): void {
const idx = e.instance_idx;
if (idx >= this.capacity) {
console.warn(`engram instance_idx ${idx} exceeds capacity ${this.capacity}`);
return;
}
const color = STATE_COLOR[e.state] ?? STATE_COLOR.idle;
const colorArr = this.colorAttr.array as Float32Array;
colorArr[idx * 3] = color[0];
colorArr[idx * 3 + 1] = color[1];
colorArr[idx * 3 + 2] = color[2];
this.colorAttr.needsUpdate = true;
this.onColorAssigned?.(idx, color[0], color[1], color[2]);
const sizeArr = this.sizeAttr.array as Float32Array;
// Server gives e.size = 1.0 in Stage 1+. The base value is small so the
// dots read as pinpoints (combined with bloom for the halo). Per-engram
// size will diverge once federation lands (Stage 4+).
sizeArr[idx] = Math.max(0.6, e.size * 0.8);
this.sizeAttr.needsUpdate = true;
// Per-particle hash: only set on first upsert for this slot, so the
// pulse phase stays stable across re-upserts (e.g. state changes).
const hashArr = this.hashAttr.array as Float32Array;
if (hashArr[idx] === 0) {
hashArr[idx] = Math.random() || 0.5;
this.hashAttr.needsUpdate = true;
}
const posArr = this.positionAttr.array as Float32Array;
posArr[idx * 3] = e.position[0];
posArr[idx * 3 + 1] = e.position[1];
posArr[idx * 3 + 2] = e.position[2];
this.positionAttr.needsUpdate = true;
if (idx + 1 > this.maxIdx) this.maxIdx = idx + 1;
this.points.geometry.setDrawRange(0, this.maxIdx);
}
applyPositionFrame(frame: PositionFrame): void {
const n = Math.min(frame.n, this.capacity);
const posArr = this.positionAttr.array as Float32Array;
posArr.set(frame.positions.subarray(0, n * 3), 0);
this.positionAttr.needsUpdate = true;
if (n > this.maxIdx) this.maxIdx = n;
this.points.geometry.setDrawRange(0, this.maxIdx);
}
count(): number {
return this.maxIdx;
}
}

140
web/src/engram_trails.ts Normal file
View File

@@ -0,0 +1,140 @@
import * as THREE from "three";
import type { PositionFrame } from "./ws_client";
/**
* Comet-style trails behind each engram.
*
* Each engram is rendered as a single line segment from
* `position - velocity * tailScale` → `position`
* where `velocity` is computed from the delta between consecutive position
* frames. So the tail is *long* when an engram is moving fast (e.g. the
* fountain phase right after birth) and *short* when it's drifting in the
* tube. The tail vertex is transparent, the head vertex is opaque, and
* additive blending + bloom in the post-pipeline gives the comet glow.
*
* One line segment per engram → 2 vertices each → very cheap.
*/
const TAIL_SCALE = 1.0; // multiplied onto inter-frame delta
const VERT_SHADER = /* glsl */ `
attribute float aAlpha;
varying vec3 vColor;
varying float vAlpha;
void main() {
vColor = color;
vAlpha = aAlpha;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const FRAG_SHADER = /* glsl */ `
varying vec3 vColor;
varying float vAlpha;
void main() {
gl_FragColor = vec4(vColor, vAlpha);
}
`;
export class EngramTrails {
private readonly mesh: THREE.LineSegments;
private readonly positionAttr: THREE.BufferAttribute;
private readonly colorAttr: THREE.BufferAttribute;
private readonly alphaAttr: THREE.BufferAttribute;
/** Last frame's position per engram, used to derive velocity. */
private readonly previousPositions: Float32Array;
/** Whether each engram has had at least one previous frame stored. */
private readonly hasPrevious: Uint8Array;
private readonly capacity: number;
private maxIdx = 0;
constructor(scene: THREE.Scene, capacity = 5000) {
this.capacity = capacity;
this.previousPositions = new Float32Array(capacity * 3);
this.hasPrevious = new Uint8Array(capacity);
// Two vertices per engram: index 2*i = tail, 2*i+1 = head.
const vertexCount = capacity * 2;
const geom = new THREE.BufferGeometry();
this.positionAttr = new THREE.BufferAttribute(new Float32Array(vertexCount * 3), 3);
this.colorAttr = new THREE.BufferAttribute(new Float32Array(vertexCount * 3), 3);
this.alphaAttr = new THREE.BufferAttribute(new Float32Array(vertexCount), 1);
this.positionAttr.setUsage(THREE.DynamicDrawUsage);
this.colorAttr.setUsage(THREE.DynamicDrawUsage);
this.alphaAttr.setUsage(THREE.DynamicDrawUsage);
geom.setAttribute("position", this.positionAttr);
geom.setAttribute("color", this.colorAttr);
geom.setAttribute("aAlpha", this.alphaAttr);
geom.setDrawRange(0, 0);
const mat = new THREE.ShaderMaterial({
vertexShader: VERT_SHADER,
fragmentShader: FRAG_SHADER,
vertexColors: true,
transparent: true,
depthWrite: false,
depthTest: true,
blending: THREE.AdditiveBlending,
});
this.mesh = new THREE.LineSegments(geom, mat);
this.mesh.frustumCulled = false;
scene.add(this.mesh);
}
/**
* Set the colour for one engram's trail. Both vertices share the colour;
* the gradient is implemented via per-vertex alpha (tail = 0, head = 1).
* Called by the EngramMesh whenever it learns about a new/updated engram.
*/
setEngramColor(idx: number, r: number, g: number, b: number): void {
if (idx >= this.capacity) return;
const colorArr = this.colorAttr.array as Float32Array;
const alphaArr = this.alphaAttr.array as Float32Array;
// Tail vertex.
colorArr[idx * 6] = r;
colorArr[idx * 6 + 1] = g;
colorArr[idx * 6 + 2] = b;
alphaArr[idx * 2] = 0.0;
// Head vertex.
colorArr[idx * 6 + 3] = r;
colorArr[idx * 6 + 4] = g;
colorArr[idx * 6 + 5] = b;
alphaArr[idx * 2 + 1] = 0.85;
this.colorAttr.needsUpdate = true;
this.alphaAttr.needsUpdate = true;
}
applyPositionFrame(frame: PositionFrame): void {
const n = Math.min(frame.n, this.capacity);
const posArr = this.positionAttr.array as Float32Array;
for (let i = 0; i < n; i++) {
const cx = frame.positions[i * 3];
const cy = frame.positions[i * 3 + 1];
const cz = frame.positions[i * 3 + 2];
let dx = 0, dy = 0, dz = 0;
if (this.hasPrevious[i] === 1) {
dx = cx - this.previousPositions[i * 3];
dy = cy - this.previousPositions[i * 3 + 1];
dz = cz - this.previousPositions[i * 3 + 2];
} else {
this.hasPrevious[i] = 1;
}
// Tail vertex (behind the engram, opposite the velocity vector).
posArr[i * 6] = cx - dx * TAIL_SCALE;
posArr[i * 6 + 1] = cy - dy * TAIL_SCALE;
posArr[i * 6 + 2] = cz - dz * TAIL_SCALE;
// Head vertex (current position).
posArr[i * 6 + 3] = cx;
posArr[i * 6 + 4] = cy;
posArr[i * 6 + 5] = cz;
// Roll the previous-position buffer forward.
this.previousPositions[i * 3] = cx;
this.previousPositions[i * 3 + 1] = cy;
this.previousPositions[i * 3 + 2] = cz;
}
this.positionAttr.needsUpdate = true;
if (n > this.maxIdx) this.maxIdx = n;
this.mesh.geometry.setDrawRange(0, this.maxIdx * 2);
}
}

169
web/src/main.ts Normal file
View File

@@ -0,0 +1,169 @@
import { startScene } from "./scene";
const canvas = document.getElementById("stage") as HTMLCanvasElement;
const healthEl = document.getElementById("hud-health");
const lmEl = document.getElementById("hud-lm");
const galaxyEl = document.getElementById("hud-galaxy");
const countEl = document.getElementById("hud-count");
const seedBtn = document.getElementById("seed-btn") as HTMLButtonElement | null;
const seedNInput = document.getElementById("seed-n") as HTMLInputElement | null;
const ingestBtn = document.getElementById("ingest-btn") as HTMLButtonElement | null;
const ingestText = document.getElementById("ingest-text") as HTMLTextAreaElement | null;
const torusEl = document.getElementById("hud-torus");
const synapsesEl = document.getElementById("hud-synapses");
const resizeBtn = document.getElementById("resize-btn") as HTMLButtonElement | null;
const majorInput = document.getElementById("resize-major") as HTMLInputElement | null;
const minorInput = document.getElementById("resize-minor") as HTMLInputElement | null;
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
const r = await fetch(url, init);
if (!r.ok) {
const body = await r.text();
throw new Error(`${url} → HTTP ${r.status}: ${body}`);
}
const body = await r.text();
if (body.length === 0) throw new Error(`${url} → empty body`);
try {
return JSON.parse(body) as T;
} catch (e) {
throw new Error(`${url} → invalid JSON: ${String((e as Error).message)}`);
}
}
function showFatal(msg: string): void {
if (galaxyEl) galaxyEl.textContent = msg;
if (healthEl) healthEl.textContent = msg;
console.error(msg);
}
type HealthBody = {
status: string;
stage: number;
lm_studio?: {
reachable: boolean;
chat_model_loaded: boolean;
embedding_model_loaded: boolean;
configured_chat_model: string;
configured_embedding_model: string;
};
};
function renderHealth(h: HealthBody): void {
if (healthEl) healthEl.textContent = `${h.status} (stage ${h.stage})`;
if (lmEl) {
if (!h.lm_studio) {
lmEl.textContent = "—";
} else if (!h.lm_studio.reachable) {
lmEl.textContent = "unreachable";
} else {
const chat = h.lm_studio.chat_model_loaded ? "✓ chat" : "✗ chat";
const emb = h.lm_studio.embedding_model_loaded ? "✓ embed" : "✗ embed";
lmEl.textContent = `${chat} · ${emb}`;
}
}
}
async function bootstrap(): Promise<void> {
fetch("/healthz")
.then((r) => (r.ok ? (r.json() as Promise<HealthBody>) : Promise.reject(new Error(`status ${r.status}`))))
.then(renderHealth)
.catch((err) => {
if (healthEl) healthEl.textContent = `unreachable (${String(err.message ?? err)})`;
});
let galaxies: Array<{ id: string; name: string }>;
try {
galaxies = await fetchJson<Array<{ id: string; name: string }>>("/api/galaxy");
} catch (e) {
showFatal(`backend unreachable — start \`cargo run\`. (${String((e as Error).message)})`);
return;
}
if (galaxies.length === 0) {
showFatal("no galaxies — server should auto-create one on boot");
return;
}
const galaxy = galaxies[0];
startScene(canvas, galaxy.id, {
onCount: (n) => { if (countEl) countEl.textContent = String(n); },
onGalaxyName: (name) => { if (galaxyEl) galaxyEl.textContent = name; },
onTorus: (majorR, minorR) => {
if (torusEl) torusEl.textContent = `R=${majorR.toFixed(0)} r=${minorR.toFixed(0)}`;
if (majorInput && document.activeElement !== majorInput) majorInput.value = String(majorR);
if (minorInput && document.activeElement !== minorInput) minorInput.value = String(minorR);
},
onSynapses: (n) => { if (synapsesEl) synapsesEl.textContent = String(n); },
});
if (seedBtn && seedNInput) {
seedBtn.addEventListener("click", async () => {
const n = Math.max(1, Math.min(5000, parseInt(seedNInput.value, 10) || 200));
seedBtn.disabled = true;
try {
await fetch(`/api/galaxy/${galaxy.id}/seed?n=${n}`, { method: "POST" });
} finally {
seedBtn.disabled = false;
}
});
}
if (resizeBtn && majorInput && minorInput) {
resizeBtn.addEventListener("click", async () => {
const major = parseFloat(majorInput.value);
const minor = parseFloat(minorInput.value);
if (!isFinite(major) || !isFinite(minor)) {
alert("major_radius and minor_radius must be numbers");
return;
}
resizeBtn.disabled = true;
try {
const res = await fetch(`/api/galaxy/${galaxy.id}/resize`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ major_radius: major, minor_radius: minor }),
});
if (!res.ok) {
const body = await res.text();
alert(`resize failed: HTTP ${res.status}\n${body}`);
}
} catch (e) {
alert(`resize failed: ${String((e as Error).message)}`);
} finally {
resizeBtn.disabled = false;
}
});
}
if (ingestBtn && ingestText) {
ingestBtn.addEventListener("click", async () => {
const lines = ingestText.value
.split(/\r?\n/)
.map((l) => l.trim())
.filter((l) => l.length > 0);
if (lines.length === 0) return;
ingestBtn.disabled = true;
const before = ingestBtn.textContent;
ingestBtn.textContent = `embedding ${lines.length}`;
try {
const res = await fetch(`/api/galaxy/${galaxy.id}/ingest`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ texts: lines }),
});
if (!res.ok) {
const body = await res.text();
alert(`ingest failed: HTTP ${res.status}\n${body}`);
return;
}
ingestText.value = "";
} catch (e) {
alert(`ingest failed: ${String((e as Error).message)}`);
} finally {
ingestBtn.disabled = false;
ingestBtn.textContent = before;
}
});
}
}
bootstrap().catch((e) => console.error("bootstrap failed", e));

203
web/src/scene.ts Normal file
View File

@@ -0,0 +1,203 @@
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js";
import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass.js";
import { UnrealBloomPass } from "three/examples/jsm/postprocessing/UnrealBloomPass.js";
import { OutputPass } from "three/examples/jsm/postprocessing/OutputPass.js";
import { EngramMesh } from "./engram_mesh";
import { EngramTrails } from "./engram_trails";
import { SynapseMesh } from "./synapse_mesh";
import type { EngramSnapshot, EventHandlers, SynapseDto } from "./ws_client";
import { connectGalaxy } from "./ws_client";
export type SceneHooks = {
onCount: (n: number) => void;
onGalaxyName: (name: string) => void;
onTorus: (majorRadius: number, minorRadius: number) => void;
onSynapses?: (count: number) => void;
};
// State-color table mirrored from EngramMesh; needed here so we can pre-paint
// synapse endpoints when an engram first arrives.
const STATE_COLOR: Record<string, [number, number, number]> = {
idle: [1.0, 0.72, 0.42],
searching: [1.0, 0.88, 0.40],
conversing: [0.82, 0.64, 1.0],
synthesizing: [0.43, 0.91, 0.72],
memorize: [0.65, 0.85, 1.0],
decaying: [1.0, 0.48, 0.48],
deprecated: [0.49, 0.53, 0.58],
};
/**
* Scene: dark background, axis helper, wireframe torus boundary, engram
* point-cloud fed by a WebSocket binary stream of position frames.
*
* Per the Topology Pivot, the world is a fixed solid donut; the wireframe
* torus shows the boundary the engrams live inside.
*/
export function startScene(canvas: HTMLCanvasElement, galaxyId: string, hooks: SceneHooks): void {
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight, false);
// Render with sRGB output and ACES-style tone-mapping so the bloom-amplified
// additive engrams don't clip to white. This makes the cinematic glow read
// properly against the dark background.
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x050608);
const camera = new THREE.PerspectiveCamera(
55,
window.innerWidth / window.innerHeight,
0.1,
10_000,
);
// Default view positions the camera above + behind the donut so it reads
// immediately as a donut on first paint. Recomputed when we get the real
// shape from `Hello`.
camera.position.set(0, 180, 300);
camera.lookAt(0, 0, 0);
const controls = new OrbitControls(camera, canvas);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
scene.add(new THREE.AxesHelper(20));
// Torus boundary — the visible wall of the Space of Recollection. Built
// with unit radii and scaled per (majorR, minorR) update so we can avoid
// rebuilding geometry on every resize.
const torusGeo = new THREE.TorusGeometry(1, 1, 16, 64);
const torusMat = new THREE.MeshBasicMaterial({
color: 0x344058,
wireframe: true,
transparent: true,
opacity: 0.22,
});
const torusMesh = new THREE.Mesh(torusGeo, torusMat);
// TorusGeometry sits in the XY plane (extending along z by tube radius).
// Our spine is the circle in the z=0 plane, so no rotation needed.
scene.add(torusMesh);
// Trails + synapses go in FIRST so they render *under* the engram dots —
// when the head sits on top of a line endpoint, the dot occludes the join.
const trails = new EngramTrails(scene);
const synapses = new SynapseMesh(scene);
const engrams = new EngramMesh(scene);
// Forward each engram's base colour to the line renderers so endpoints
// match the head dot's hue.
engrams.onColorAssigned = (idx, r, g, b) => {
trails.setEngramColor(idx, r, g, b);
};
// Helper: register a fresh engram with the synapse mesh so any pending
// synapse referencing it can be wired up. Re-applies state colour.
function registerEngramForSynapses(snapshot: EngramSnapshot): void {
const color = STATE_COLOR[snapshot.state] ?? STATE_COLOR.idle;
synapses.registerEngram(snapshot.id, snapshot.instance_idx, color[0], color[1], color[2]);
}
function applySynapse(s: SynapseDto): void {
synapses.addSynapse(s.id, s.a, s.b, s.weight);
hooks.onSynapses?.(synapses.count());
}
// Post-processing: bloom for the cinematic glow. Tuned for additive
// particle sources — low threshold (most particle pixels are bright
// enough to bloom), moderate strength, small radius for crisp halos
// rather than washed-out smear.
const composer = new EffectComposer(renderer);
composer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
composer.setSize(window.innerWidth, window.innerHeight);
composer.addPass(new RenderPass(scene, camera));
const bloom = new UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
0.4, // strength — restrained, lets the dots stay crisp
0.5, // radius
0.4, // threshold — only the brightest cores bloom (was 0.1, blew out)
);
composer.addPass(bloom);
composer.addPass(new OutputPass());
function applyTorus(majorR: number, minorR: number): void {
// Three.js's TorusGeometry with major=1, minor=1 produces a torus where
// the tube radius and the major radius are both 1. Non-uniform scaling
// breaks that — scaling x/y by `majorR` would also stretch the tube
// cross-section. Easiest: rebuild geometry on each resize. This happens
// rarely (initial Hello + explicit /resize calls).
torusMesh.geometry.dispose();
torusMesh.geometry = new THREE.TorusGeometry(majorR, minorR, 16, 96);
// Fit camera so the donut is comfortably framed.
// Lower height factor (0.30 vs 0.55) gives a more head-on view that
// reads the donut shape better and shows the front portal clearly.
const fitDist = (majorR + minorR) * 2.4;
camera.position.set(0, fitDist * 0.30, fitDist);
camera.lookAt(0, 0, 0);
camera.far = Math.max(camera.far, fitDist * 6);
camera.updateProjectionMatrix();
hooks.onTorus(majorR, minorR);
}
const handlers: EventHandlers = {
onHello: (galaxy, list, helloSynapses) => {
hooks.onGalaxyName(galaxy.name);
torusMesh.position.set(galaxy.center[0], galaxy.center[1], galaxy.center[2]);
applyTorus(galaxy.major_radius, galaxy.minor_radius);
engrams.applyHello(list);
// Register every engram with the synapse mesh BEFORE replaying synapses
// so the (a, b) UUID lookups resolve immediately instead of going to
// the pending queue.
for (const e of list) registerEngramForSynapses(e);
for (const s of helloSynapses) applySynapse(s);
hooks.onCount(engrams.count());
},
onEngramCreated: (snapshot) => {
engrams.upsertEngram(snapshot);
registerEngramForSynapses(snapshot);
hooks.onCount(engrams.count());
},
onSynapseCreated: (synapse) => {
applySynapse(synapse);
},
onTorusUpdated: (center, majorR, minorR) => {
torusMesh.position.set(center[0], center[1], center[2]);
applyTorus(majorR, minorR);
},
onPositionFrame: (frame) => {
engrams.applyPositionFrame(frame);
trails.applyPositionFrame(frame);
synapses.applyPositionFrame(frame);
hooks.onCount(engrams.count());
},
onClose: () => {
console.warn("ws closed — refresh to reconnect");
},
onError: (e) => console.error("ws error", e),
};
connectGalaxy(galaxyId, handlers);
function onResize(): void {
const w = window.innerWidth;
const h = window.innerHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h, false);
composer.setSize(w, h);
bloom.setSize(w, h);
}
window.addEventListener("resize", onResize);
const startedAt = performance.now();
function animate(): void {
controls.update();
engrams.tick((performance.now() - startedAt) / 1000);
composer.render();
requestAnimationFrame(animate);
}
animate();
}

182
web/src/synapse_mesh.ts Normal file
View File

@@ -0,0 +1,182 @@
import * as THREE from "three";
import type { PositionFrame } from "./ws_client";
/**
* Renders synapses (Stage 3) as additive line segments connecting two
* engrams. Endpoints are looked up from the engram position buffer on every
* position frame, so the lines track engram motion automatically.
*
* Uses a fixed-capacity vertex buffer; one segment per synapse → 2 vertices
* per synapse → 6 floats of position per synapse.
*/
const VERT_SHADER = /* glsl */ `
attribute float aAlpha;
varying vec3 vColor;
varying float vAlpha;
void main() {
vColor = color;
vAlpha = aAlpha;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const FRAG_SHADER = /* glsl */ `
varying vec3 vColor;
varying float vAlpha;
void main() {
gl_FragColor = vec4(vColor, vAlpha);
}
`;
type SynapseEntry = {
/** Slot in the line-segments geometry (0..capacity-1). */
slot: number;
/** instance_idx of the two engrams this connects. */
aIdx: number;
bIdx: number;
weight: number;
};
type Pending = {
id: string;
a: string;
b: string;
weight: number;
};
export class SynapseMesh {
private readonly mesh: THREE.LineSegments;
private readonly positionAttr: THREE.BufferAttribute;
private readonly colorAttr: THREE.BufferAttribute;
private readonly alphaAttr: THREE.BufferAttribute;
private readonly capacity: number;
/** synapse_id → entry. */
private readonly bySynapseId = new Map<string, SynapseEntry>();
/** Engram UUID → instance_idx, populated as engrams arrive. */
private readonly engramIdx = new Map<string, number>();
/** Synapses waiting for one of their endpoints to be registered. */
private readonly pending: Pending[] = [];
/** Engram colour cache so we don't recompute on every frame. */
private readonly engramColor: Float32Array;
private nextSlot = 0;
constructor(scene: THREE.Scene, capacity = 8000) {
this.capacity = capacity;
this.engramColor = new Float32Array(5000 * 3);
const vertexCount = capacity * 2;
const geom = new THREE.BufferGeometry();
this.positionAttr = new THREE.BufferAttribute(new Float32Array(vertexCount * 3), 3);
this.colorAttr = new THREE.BufferAttribute(new Float32Array(vertexCount * 3), 3);
this.alphaAttr = new THREE.BufferAttribute(new Float32Array(vertexCount), 1);
this.positionAttr.setUsage(THREE.DynamicDrawUsage);
this.colorAttr.setUsage(THREE.DynamicDrawUsage);
this.alphaAttr.setUsage(THREE.DynamicDrawUsage);
geom.setAttribute("position", this.positionAttr);
geom.setAttribute("color", this.colorAttr);
geom.setAttribute("aAlpha", this.alphaAttr);
geom.setDrawRange(0, 0);
const mat = new THREE.ShaderMaterial({
vertexShader: VERT_SHADER,
fragmentShader: FRAG_SHADER,
vertexColors: true,
transparent: true,
depthWrite: false,
depthTest: true,
blending: THREE.AdditiveBlending,
});
this.mesh = new THREE.LineSegments(geom, mat);
this.mesh.frustumCulled = false;
scene.add(this.mesh);
}
/** Register an engram so synapses referencing it can be wired up. */
registerEngram(id: string, instanceIdx: number, r: number, g: number, b: number): void {
this.engramIdx.set(id, instanceIdx);
if (instanceIdx * 3 + 2 < this.engramColor.length) {
this.engramColor[instanceIdx * 3] = r;
this.engramColor[instanceIdx * 3 + 1] = g;
this.engramColor[instanceIdx * 3 + 2] = b;
}
// Try to materialise any pending synapses now that this engram is known.
if (this.pending.length > 0) {
const stillPending: Pending[] = [];
for (const p of this.pending) {
if (!this.tryMaterialise(p)) stillPending.push(p);
}
this.pending.length = 0;
this.pending.push(...stillPending);
}
}
/** Add a synapse by engram UUIDs. Defers if either endpoint is unknown. */
addSynapse(id: string, a: string, b: string, weight: number): void {
if (this.bySynapseId.has(id)) return;
const p: Pending = { id, a, b, weight };
if (!this.tryMaterialise(p)) {
this.pending.push(p);
}
}
private tryMaterialise(p: Pending): boolean {
const aIdx = this.engramIdx.get(p.a);
const bIdx = this.engramIdx.get(p.b);
if (aIdx === undefined || bIdx === undefined) return false;
if (this.nextSlot >= this.capacity) {
console.warn("SynapseMesh capacity reached; ignoring further synapses");
return true; // treat as resolved so we stop waiting on it
}
const slot = this.nextSlot++;
this.bySynapseId.set(p.id, { slot, aIdx, bIdx, weight: p.weight });
this.applyEndpointColors(slot, aIdx, bIdx);
// Alpha tied to weight; a small floor so very weak synapses still register.
const alpha = Math.max(0.08, Math.min(0.75, p.weight));
const alphaArr = this.alphaAttr.array as Float32Array;
alphaArr[slot * 2] = alpha;
alphaArr[slot * 2 + 1] = alpha;
this.alphaAttr.needsUpdate = true;
this.mesh.geometry.setDrawRange(0, this.nextSlot * 2);
return true;
}
/** Apply the latest position frame to all synapse endpoints. */
applyPositionFrame(frame: PositionFrame): void {
const posArr = this.positionAttr.array as Float32Array;
const src = frame.positions;
const n = frame.n;
let dirty = false;
for (const entry of this.bySynapseId.values()) {
const a = entry.aIdx, b = entry.bIdx;
if (a >= n || b >= n) continue;
const slot = entry.slot;
posArr[slot * 6] = src[a * 3];
posArr[slot * 6 + 1] = src[a * 3 + 1];
posArr[slot * 6 + 2] = src[a * 3 + 2];
posArr[slot * 6 + 3] = src[b * 3];
posArr[slot * 6 + 4] = src[b * 3 + 1];
posArr[slot * 6 + 5] = src[b * 3 + 2];
dirty = true;
}
if (dirty) this.positionAttr.needsUpdate = true;
}
/** Number of synapses currently rendered (for HUD). */
count(): number {
return this.bySynapseId.size;
}
/** Re-paint a synapse's endpoint colours after one of its engrams updates. */
private applyEndpointColors(slot: number, aIdx: number, bIdx: number): void {
const colorArr = this.colorAttr.array as Float32Array;
colorArr[slot * 6] = this.engramColor[aIdx * 3];
colorArr[slot * 6 + 1] = this.engramColor[aIdx * 3 + 1];
colorArr[slot * 6 + 2] = this.engramColor[aIdx * 3 + 2];
colorArr[slot * 6 + 3] = this.engramColor[bIdx * 3];
colorArr[slot * 6 + 4] = this.engramColor[bIdx * 3 + 1];
colorArr[slot * 6 + 5] = this.engramColor[bIdx * 3 + 2];
this.colorAttr.needsUpdate = true;
}
}

130
web/src/ws_client.ts Normal file
View File

@@ -0,0 +1,130 @@
/**
* WebSocket client for the Sophia simulation event stream.
*
* Wire protocol matches `crates/sophia-server/src/ws.rs`:
* - text frames: JSON, tagged via `type` field
* - binary frames: position frames, 12-byte header + float region
* [u32 LE tag=0x01][u32 LE t_ms][u32 LE n][n × (f32 LE x, f32 LE y, f32 LE z)]
* The header is 12 bytes (not 9) so the float region is 4-byte aligned and
* can be wrapped as a Float32Array view without copying.
*/
export type EngramSnapshot = {
id: string;
instance_idx: number;
position: [number, number, number];
size: number;
state: string;
};
export type GalaxyInfo = {
id: string;
name: string;
engram_count: number;
center: [number, number, number];
major_radius: number;
minor_radius: number;
};
export type SynapseDto = {
id: string;
a: string;
b: string;
weight: number;
};
export type SimEventMsg =
| {
type: "hello";
galaxy: GalaxyInfo;
engrams: EngramSnapshot[];
synapses: SynapseDto[];
}
| { type: "engram_created"; snapshot: EngramSnapshot }
| { type: "synapse_created"; synapse: SynapseDto }
| {
type: "torus_updated";
center: [number, number, number];
major_radius: number;
minor_radius: number;
};
export type PositionFrame = {
t_ms: number;
n: number;
/** Flat array, length 3*n: x,y,z,x,y,z,... in instance_idx order. */
positions: Float32Array;
};
export type EventHandlers = {
onHello?: (
galaxy: GalaxyInfo,
engrams: EngramSnapshot[],
synapses: SynapseDto[],
) => void;
onEngramCreated?: (snapshot: EngramSnapshot) => void;
onSynapseCreated?: (synapse: SynapseDto) => void;
onTorusUpdated?: (
center: [number, number, number],
majorRadius: number,
minorRadius: number,
) => void;
onPositionFrame?: (frame: PositionFrame) => void;
onClose?: (ev: CloseEvent) => void;
onError?: (ev: Event) => void;
};
export function connectGalaxy(galaxyId: string, handlers: EventHandlers): WebSocket {
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
const url = `${proto}//${window.location.host}/ws/galaxy/${galaxyId}/events`;
const ws = new WebSocket(url);
ws.binaryType = "arraybuffer";
ws.addEventListener("message", (ev) => {
try {
if (typeof ev.data === "string") {
const msg = JSON.parse(ev.data) as SimEventMsg;
switch (msg.type) {
case "hello":
handlers.onHello?.(msg.galaxy, msg.engrams, msg.synapses);
break;
case "engram_created":
handlers.onEngramCreated?.(msg.snapshot);
break;
case "synapse_created":
handlers.onSynapseCreated?.(msg.synapse);
break;
case "torus_updated":
handlers.onTorusUpdated?.(msg.center, msg.major_radius, msg.minor_radius);
break;
}
} else {
const frame = decodePositionFrame(ev.data as ArrayBuffer);
if (frame) handlers.onPositionFrame?.(frame);
}
} catch (e) {
// Surface any decode error instead of silently dropping the frame —
// a malformed binary frame used to silently kill the position stream.
console.error("ws message handler failed", e);
}
});
ws.addEventListener("close", (ev) => handlers.onClose?.(ev));
ws.addEventListener("error", (ev) => handlers.onError?.(ev));
return ws;
}
function decodePositionFrame(buf: ArrayBuffer): PositionFrame | null {
const view = new DataView(buf);
const tag = view.getUint32(0, true);
if (tag !== 0x01) return null;
const t_ms = view.getUint32(4, true);
const n = view.getUint32(8, true);
const expected = 12 + n * 12;
if (buf.byteLength < expected) return null;
// Float region starts at byte 12 — 4-byte aligned, so we can wrap it as a
// Float32Array view without copying. (Float32Array constructor throws if
// the byte offset is not a multiple of 4; that's why the header is padded
// to 12 bytes instead of a tighter 9 bytes.)
const positions = new Float32Array(buf, 12, n * 3);
return { t_ms, n, positions };
}

18
web/tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"esModuleInterop": true,
"isolatedModules": true,
"resolveJsonModule": true,
"useDefineForClassFields": true
},
"include": ["src"]
}

20
web/vite.config.ts Normal file
View File

@@ -0,0 +1,20 @@
import { defineConfig } from "vite";
// In dev, the Vite server runs on :5173 and proxies API/WS calls to the
// Rust server on :7777 so the same fetch/WebSocket code works in both modes.
// In `build`, the output goes to `dist/` which the Rust server statically serves.
export default defineConfig({
build: {
outDir: "dist",
emptyOutDir: true,
target: "es2022",
},
server: {
port: 5173,
proxy: {
"/healthz": "http://127.0.0.1:7777",
"/api": "http://127.0.0.1:7777",
"/ws": { target: "ws://127.0.0.1:7777", ws: true },
},
},
});