Implement Sophia MVP stages 4-7 (introspection, queries, persistence, polish)

Brings the system from "engrams cluster + form synapses" to a complete
end-to-end demo: ingest text, watch it cluster, ask questions, restart
with state intact.

- Stage 4: birth introspection (taxonomy/goals/open_questions via LLM,
  bounded by the global parallel-op semaphore), per-engram memory log,
  click-to-inspect side panel.
- Stage 5: queries as conversations. POST /api/galaxy/:id/query embeds
  the question, materializes a pinned Query-Engram at the donut center,
  runs broadcast retrieval (global cosine scan + 1-hop synaptic
  expansion with attenuation) and fans out responder LLM calls. The
  integrator runs every 2s on accumulated snippets and streams the
  refining answer back over SSE; responders briefly transition to
  Conversing on the WS bus so the right dots light up.
- Stage 6: snapshot persistence. sled-backed store keyed by galaxy id,
  JSON-encoded values (bincode chokes on internally-tagged enums like
  Manifest/MemoryKind), 60s periodic snapshot task, hydrate-on-boot,
  DELETE /api/galaxy/:id wired through. State survives kill -9.
- Stage 7: HUD additions (sim ticks/sec, LLM queue depth, FPS) via a
  new GET /api/stats polled at 1Hz. `sophia demo` subcommand boots the
  server then auto-ingests a 50-paragraph corpus baked into the binary
  with include_str!. README quickstart added.

Token caps for query_responder/integrator bumped (gemma-4-e4b is a
thinking model — output budget must cover hidden reasoning + visible
answer, otherwise content comes back empty). Pinned engrams skip
physics; their tick scheduling is also skipped at materialization so
they stay perfectly still at the donut center.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-28 11:53:11 +02:00
parent 8688f632bf
commit bae084cd76
36 changed files with 3137 additions and 58 deletions

View File

@@ -96,6 +96,20 @@ impl LmStudioClient {
&self.inner.cfg
}
/// How many parallel-op permits are currently free. Equal to `parallel_ops`
/// when idle, drops as concurrent chat/embedding calls run. Used by the
/// HUD's "LLM queue" indicator — a number that consistently sits at 0
/// means the LLM is the bottleneck.
pub fn available_permits(&self) -> usize {
self.inner.parallel.available_permits()
}
/// The configured ceiling. Same as `config().parallel_ops`, exposed for
/// callers that already have the client handle but not the config.
pub fn parallel_ops(&self) -> usize {
self.inner.cfg.parallel_ops.max(1)
}
/// 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.

View File

@@ -0,0 +1,90 @@
//! LLM-driven introspection: ask the chat model to summarise one piece of
//! text into a small structured `Introspection` record (taxonomy, goals,
//! open questions). Caveman budget per §13.5.
use serde::Deserialize;
use sophia_core::Introspection;
use crate::budget::TokenCaps;
use crate::client::{ChatMessage, LmError, LmStudioClient};
const SYSTEM_PROMPT: &str = "\
You summarize one short piece of knowledge for a knowledge graph.\n\
Output ONLY valid JSON matching this schema:\n\
{\n \"taxonomy\": [string, ...],\n \"goals\": [string, ...],\n \"open_questions\": [string, ...]\n}\n\
- taxonomy: 3 to 5 short topical labels, from general to specific.\n\
- goals: 1 to 3 short statements of what this knowledge enables or answers.\n\
- open_questions: 1 to 3 short questions this knowledge invites but doesn't answer.\n\
Each item must be at most 12 words. No prose outside the JSON.";
/// Truncate excessively long input so we stay inside the input cap. Caveman
/// budget tolerates ~400 input tokens total — content beyond ~1200 chars
/// (≈300 tokens) is unlikely to add useful introspection, so we cut.
const MAX_INPUT_CHARS: usize = 1200;
#[derive(Debug, Deserialize)]
struct IntrospectionPayload {
#[serde(default)]
taxonomy: Vec<String>,
#[serde(default)]
goals: Vec<String>,
#[serde(default)]
open_questions: Vec<String>,
}
/// Introspect a single piece of text. Returns an empty `Introspection` if
/// the LLM responds with non-JSON or empty content; the caller can decide
/// whether to retry. Caps input length defensively.
pub async fn introspect_text(
client: &LmStudioClient,
text: &str,
caps: TokenCaps,
) -> Result<Introspection, LmError> {
let trimmed = if text.chars().count() > MAX_INPUT_CHARS {
let head: String = text.chars().take(MAX_INPUT_CHARS).collect();
format!("{head}")
} else {
text.to_string()
};
let messages = [
ChatMessage::system(SYSTEM_PROMPT),
ChatMessage::user(format!("Content:\n\"\"\"\n{trimmed}\n\"\"\"")),
];
let raw = client.chat(&messages, caps).await?;
Ok(parse_introspection(&raw))
}
/// Best-effort JSON extraction. Models occasionally wrap output in ```json
/// fences; we strip those before parsing. On failure returns empty.
fn parse_introspection(raw: &str) -> Introspection {
let json_text = strip_code_fences(raw.trim());
let parsed: Result<IntrospectionPayload, _> = serde_json::from_str(json_text);
match parsed {
Ok(p) => Introspection {
taxonomy: cleanup(p.taxonomy),
goals: cleanup(p.goals),
open_questions: cleanup(p.open_questions),
},
Err(_) => Introspection::default(),
}
}
fn strip_code_fences(s: &str) -> &str {
let s = s.trim();
let stripped = s.strip_prefix("```json").or_else(|| s.strip_prefix("```"));
if let Some(rest) = stripped {
let rest = rest.trim_start_matches('\n');
rest.strip_suffix("```").unwrap_or(rest).trim()
} else {
s
}
}
fn cleanup(items: Vec<String>) -> Vec<String> {
items
.into_iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.take(8) // hard upper bound, just in case
.collect()
}

View File

@@ -4,6 +4,10 @@
mod budget;
mod client;
mod introspect;
mod query;
pub use budget::{TokenCaps, TokenCapsAll};
pub use client::{ChatMessage, ChatRole, LmError, LmStudioClient, LmStudioConfig, EMBED_BATCH_LIMIT};
pub use introspect::introspect_text;
pub use query::{integrate_responses, respond_to_query};

View File

@@ -0,0 +1,92 @@
//! Query-time LLM prompts (Stage 5).
//!
//! Two distinct calls per query:
//!
//! - `respond_to_query`: each resonating engram (the "responder") is asked to
//! say what *its own* knowledge contributes to the question. Caveman cap
//! 200 in / 150 out — these are short, single-perspective snippets.
//! - `integrate_responses`: the Query-Engram folds the snippets it has
//! received so far into a single running answer. Cap 800 in / 300 out.
//! Re-runs every couple of seconds while new snippets arrive, so the
//! browser sees the answer refine.
use crate::budget::TokenCaps;
use crate::client::{ChatMessage, LmError, LmStudioClient};
const RESPONDER_SYSTEM: &str = "\
You are one fragment of knowledge inside a larger memory. \
A query has been broadcast through the network. \
Speak ONLY for what your own content contributes to the question. \
Do not summarize the question. Do not speculate beyond your content. \
If your content is irrelevant, reply with the single word: PASS. \
Otherwise: 13 short sentences, plain prose, no headings, no bullets.";
const INTEGRATOR_SYSTEM: &str = "\
You are the answering process for a question. You receive snippets from \
fragments of knowledge that resonated with the question. Synthesize them \
into ONE coherent answer to the user's question. Cite no sources. \
Do not list the snippets. Do not say things like \"based on the snippets\". \
Plain prose, 26 sentences. If the snippets are insufficient, say so briefly.";
/// Truncate one responder's source content to keep the per-call payload small.
const MAX_RESPONDER_INPUT_CHARS: usize = 600;
/// Ask one resonating engram for its take on the question. Returns `None` if
/// the responder explicitly opts out (`PASS`) or returns empty content.
/// Keeping `Option<String>` rather than `Result<...>` lets the orchestrator
/// distinguish "this engram had nothing useful to say" from "the LLM call
/// failed" — both are fine, but only the latter should be logged.
pub async fn respond_to_query(
client: &LmStudioClient,
question: &str,
responder_text: &str,
caps: TokenCaps,
) -> Result<Option<String>, LmError> {
let trimmed = if responder_text.chars().count() > MAX_RESPONDER_INPUT_CHARS {
let head: String = responder_text.chars().take(MAX_RESPONDER_INPUT_CHARS).collect();
format!("{head}")
} else {
responder_text.to_string()
};
let user = format!(
"Question:\n{question}\n\nYour content:\n\"\"\"\n{trimmed}\n\"\"\""
);
let messages = [
ChatMessage::system(RESPONDER_SYSTEM),
ChatMessage::user(user),
];
let raw = client.chat(&messages, caps).await?;
let cleaned = raw.trim();
if cleaned.is_empty() || cleaned.eq_ignore_ascii_case("PASS") {
return Ok(None);
}
Ok(Some(cleaned.to_string()))
}
/// Fold the current set of responder snippets into one running answer. The
/// orchestrator calls this repeatedly as more snippets arrive.
pub async fn integrate_responses(
client: &LmStudioClient,
question: &str,
snippets: &[String],
caps: TokenCaps,
) -> Result<String, LmError> {
if snippets.is_empty() {
return Ok(String::new());
}
// Number the snippets so the integrator can reason about distinct
// perspectives without us having to add ids.
let mut buf = String::new();
for (i, s) in snippets.iter().enumerate() {
buf.push_str(&format!("[{}] {}\n", i + 1, s.trim()));
}
let user = format!(
"Question:\n{question}\n\nSnippets:\n{buf}\nAnswer the question now."
);
let messages = [
ChatMessage::system(INTEGRATOR_SYSTEM),
ChatMessage::user(user),
];
let raw = client.chat(&messages, caps).await?;
Ok(raw.trim().to_string())
}