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