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:
19
crates/sophia-llm/Cargo.toml
Normal file
19
crates/sophia-llm/Cargo.toml
Normal 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 }
|
||||
49
crates/sophia-llm/src/budget.rs
Normal file
49
crates/sophia-llm/src/budget.rs
Normal 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 }
|
||||
}
|
||||
}
|
||||
229
crates/sophia-llm/src/client.rs
Normal file
229
crates/sophia-llm/src/client.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
9
crates/sophia-llm/src/lib.rs
Normal file
9
crates/sophia-llm/src/lib.rs
Normal 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};
|
||||
Reference in New Issue
Block a user