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

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) }
}