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