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:
25
crates/sophia-server/Cargo.toml
Normal file
25
crates/sophia-server/Cargo.toml
Normal 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 }
|
||||
241
crates/sophia-server/src/lib.rs
Normal file
241
crates/sophia-server/src/lib.rs
Normal 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()
|
||||
}
|
||||
108
crates/sophia-server/src/ws.rs
Normal file
108
crates/sophia-server/src/ws.rs
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user