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,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};