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

2
.gitignore vendored
View File

@@ -4,5 +4,7 @@ web/dist
**/.DS_Store
*.log
sled_data/
/data/
.claude/
config.local.toml
.env

172
Cargo.lock generated
View File

@@ -122,6 +122,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.11.1"
@@ -212,6 +218,30 @@ dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crunchy"
version = "0.2.4"
@@ -365,6 +395,16 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
dependencies = [
"libc",
"winapi",
]
[[package]]
name = "futures-channel"
version = "0.3.32"
@@ -425,6 +465,15 @@ dependencies = [
"slab",
]
[[package]]
name = "fxhash"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
dependencies = [
"byteorder",
]
[[package]]
name = "generator"
version = "0.8.8"
@@ -790,6 +839,15 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0175f63815ce00183bf755155ad0cb48c65226c5d17a724e369c25418d2b7699"
[[package]]
name = "instant"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
dependencies = [
"cfg-if",
]
[[package]]
name = "ipnet"
version = "2.12.0"
@@ -996,7 +1054,7 @@ version = "0.10.78"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222"
dependencies = [
"bitflags",
"bitflags 2.11.1",
"cfg-if",
"foreign-types",
"libc",
@@ -1043,6 +1101,17 @@ dependencies = [
"num-traits",
]
[[package]]
name = "parking_lot"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99"
dependencies = [
"instant",
"lock_api",
"parking_lot_core 0.8.6",
]
[[package]]
name = "parking_lot"
version = "0.12.5"
@@ -1050,7 +1119,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [
"lock_api",
"parking_lot_core",
"parking_lot_core 0.9.12",
]
[[package]]
name = "parking_lot_core"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc"
dependencies = [
"cfg-if",
"instant",
"libc",
"redox_syscall 0.2.16",
"smallvec",
"winapi",
]
[[package]]
@@ -1061,7 +1144,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"redox_syscall 0.5.18",
"smallvec",
"windows-link",
]
@@ -1166,13 +1249,22 @@ dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "redox_syscall"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a"
dependencies = [
"bitflags 1.3.2",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
"bitflags 2.11.1",
]
[[package]]
@@ -1255,7 +1347,7 @@ version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"bitflags 2.11.1",
"errno",
"libc",
"linux-raw-sys",
@@ -1328,7 +1420,7 @@ version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags",
"bitflags 2.11.1",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
@@ -1468,6 +1560,22 @@ version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "sled"
version = "0.34.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f96b4737c2ce5987354855aed3797279def4ebf734436c6aa4552cf8e169935"
dependencies = [
"crc32fast",
"crossbeam-epoch",
"crossbeam-utils",
"fs2",
"fxhash",
"libc",
"log",
"parking_lot 0.11.2",
]
[[package]]
name = "smallvec"
version = "1.15.1"
@@ -1489,11 +1597,14 @@ name = "sophia-bin"
version = "0.0.1"
dependencies = [
"anyhow",
"reqwest",
"serde",
"serde_json",
"sophia-core",
"sophia-llm",
"sophia-server",
"sophia-sim",
"sophia-store",
"tokio",
"toml",
"tracing",
@@ -1538,7 +1649,9 @@ dependencies = [
"sophia-core",
"sophia-llm",
"sophia-sim",
"sophia-store",
"tokio",
"tokio-stream",
"tower",
"tower-http",
"tracing",
@@ -1565,7 +1678,10 @@ version = "0.0.1"
dependencies = [
"anyhow",
"serde",
"serde_json",
"sled",
"sophia-core",
"thiserror",
"tracing",
]
@@ -1624,7 +1740,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags",
"bitflags 2.11.1",
"core-foundation 0.9.4",
"system-configuration-sys",
]
@@ -1700,7 +1816,7 @@ dependencies = [
"bytes",
"libc",
"mio",
"parking_lot",
"parking_lot 0.12.5",
"pin-project-lite",
"signal-hook-registry",
"socket2",
@@ -1739,6 +1855,18 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-stream"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
dependencies = [
"futures-core",
"pin-project-lite",
"tokio",
"tokio-util",
]
[[package]]
name = "tokio-tungstenite"
version = "0.24.0"
@@ -1827,7 +1955,7 @@ version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
dependencies = [
"bitflags",
"bitflags 2.11.1",
"bytes",
"futures-core",
"futures-util",
@@ -2166,7 +2294,7 @@ version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags",
"bitflags 2.11.1",
"hashbrown 0.15.5",
"indexmap",
"semver",
@@ -2182,6 +2310,28 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-link"
version = "0.2.1"
@@ -2372,7 +2522,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags",
"bitflags 2.11.1",
"indexmap",
"log",
"serde",

View File

@@ -56,11 +56,19 @@ rand = { version = "0.8", features = ["small_rng"] }
# Async helpers
futures-util = { version = "0.3", default-features = false, features = ["std", "sink"] }
tokio-stream = { version = "0.1", features = ["sync"] }
bytes = "1"
# Config
toml = "0.8"
# Persistence (Stage 6) — embedded KV with serde_json-encoded values. We
# pick JSON over bincode because `Manifest` and `MemoryKind` are
# internally-tagged enums (`#[serde(tag = "kind")]`), which require a
# self-describing format on the wire. Snapshots are 60-second cadence;
# JSON's verbosity isn't a real cost at MVP scale.
sled = "0.34"
[profile.release]
lto = "thin"
codegen-units = 1

156
README.md Normal file
View File

@@ -0,0 +1,156 @@
# Sophia
A knowledge engine where each idea is an autonomous agent — an *Engram* — that
moves, resonates with similar ideas, forms synapses with them, and answers
questions by gathering its own perspective into a single integrated reply.
The whole system is a 3D simulation you can watch: ingested text becomes glowing
dots that fly out from the centre of a torus, drift toward similar peers, and
light up when a query's broadcast wave touches them.
```
ingest text → embed → spawn engram at center
spiral into the tube
gravity pulls toward similar engrams
co-resonating pairs form synapses
query broadcast → cosine + 1-hop synaptic →
N responders contribute snippets →
streamed integrated answer
```
## Stack
- **Rust** workspace — domain types, event-driven sim, axum HTTP/WS server,
sled persistence, LM Studio client
- **Three.js + WebGL** — additive-blended particle cloud, post-processing
bloom, custom GLSL for per-engram pulse and hue variance
- **LM Studio** — local LLM (`gemma-4-e4b`) for introspection + query
responders + integrator; embeddings via `nomic-embed-text-v1.5`
The full design lives in [`docs/system-analysis.md`](docs/system-analysis.md).
## Quickstart
You need:
- Rust 1.80+ (via `rustup`)
- Node 20+ + `npm`
- LM Studio running locally with both `google/gemma-4-e4b` and
`text-embedding-nomic-embed-text-v1.5` loaded; copy your developer API
token from LM Studio into `config.local.toml`:
```toml
[lm_studio]
api_token = "lms-..."
```
Build the frontend once:
```bash
(cd web && npm install && npm run build)
```
Then run the auto-ingesting demo:
```bash
cargo run --release -- demo
# → http://127.0.0.1:7777
```
The bin boots the server, hydrates any saved galaxies (`data/`), then spawns a
background task that ingests `assets/demo_corpus.txt` (~50 paragraphs across
cooking, ML, and history). Open the URL and you should see three jewel-tone
clusters form within ~30 s.
Without `demo`, the server boots empty and you ingest manually via the bottom-
right text area, or:
```bash
curl -X POST -H 'content-type: application/json' \
-d '{"texts":["Pasta carbonara uses ...","..."]}' \
http://127.0.0.1:7777/api/galaxy/<gid>/ingest
```
## Asking a question
Use the bottom-left **ask** box. The query materialises as a pinned engram at
the donut center; relevant engrams light up briefly and contribute a snippet;
the integrated answer streams back over SSE. Try:
- `What did we ingest about pasta?`
- `How does gradient descent work?`
- `When did the Berlin Wall fall?`
## API
| route | what |
| --- | --- |
| `GET /healthz` | process status + LM Studio reachability |
| `GET /api/stats` | live HUD numbers (`ticks_per_sec`, `llm_queue_depth`, …) |
| `GET /api/galaxy` | list galaxies |
| `POST /api/galaxy` | `{name}` → new galaxy |
| `DELETE /api/galaxy/:id` | remove from sim + disk |
| `POST /api/galaxy/:id/seed?n=200` | spawn N synthetic engrams |
| `POST /api/galaxy/:id/ingest` | `{texts: [...]}` |
| `POST /api/galaxy/:id/resize` | `{major_radius, minor_radius}` |
| `POST /api/galaxy/:id/query` | `{text}` → `{query_id}` |
| `GET /api/query/:id` | latest snapshot |
| `GET /api/query/:id/stream` | SSE updates of running answer |
| `GET /api/galaxy/:gid/engrams/:eid` | inspector payload (manifest, memories…) |
| `WS /ws/galaxy/:id/events` | event stream (positions, synapses, query lifecycle) |
## Layout
```
crates/
sophia-core/ # domain types, no I/O
sophia-sim/ # event scheduler, physics, kiddo spatial index, broadcast
sophia-llm/ # LM Studio client + caveman prompts (introspection, query)
sophia-store/ # sled+JSON snapshot persistence
sophia-server/ # axum HTTP + WS, query orchestrator, SSE answer stream
sophia-bin/ # main; loads config, hydrates store, runs demo subcommand
web/ # Vite + TypeScript + Three.js
assets/ # demo corpus
data/ # sled DB (gitignored)
docs/ # design + plan
```
## Configuration
[`config.toml`](config.toml) is versioned and contains defaults; copy what you
want to override into `config.local.toml` (gitignored — for the LM Studio
token, or for tweaking the snapshot interval, etc.). Keys:
- `[server]` — host, port, static dir
- `[storage]` — `data_dir`, `snapshot_interval_secs`
- `[lm_studio]` — base URL, model names, API token, parallel-op cap
- `[galaxy_defaults]` — `major_radius`, `minor_radius`
- `[token_caps]` — per-call input/output caps for each LLM use site
(note: `gemma-4-e4b` is a thinking model; output caps must budget for
hidden reasoning *plus* visible content)
## Persistence
Each galaxy snapshots to one row in a sled DB at `data/`, JSON-encoded, every
60 s. Kill the server with `Ctrl-C` or `kill -9`; on restart it loads the
latest snapshot and resumes — same engram positions, slates, manifests,
memories, and synapses.
## Stage map
The implementation grew incrementally; each stage produced a runnable demo:
0. Scaffolding — workspace + black canvas
1. Engrams in space — physics, scheduler, position WS stream
2. Slate + LM Studio — embed, ingest, manifest
3. Gravity + clustering + synapses
4. Birth introspection (taxonomy/goals) + memories + click-to-inspect
5. Queries as conversations — broadcast, responders, integrator, SSE
6. Persistence + replay-on-boot
7. Polish — HUD (FPS, ticks/sec, LLM queue), `demo` subcommand
Each is verifiable end-to-end and the full plan is in
[`docs/system-analysis.md`](docs/system-analysis.md).

50
assets/demo_corpus.txt Normal file
View File

@@ -0,0 +1,50 @@
Pasta carbonara uses guanciale, eggs, pecorino romano, and freshly cracked black pepper.
Spaghetti aglio e olio combines garlic, olive oil, chili flakes, and parsley over al dente pasta.
Risotto requires arborio rice, slowly added warm broth, and constant stirring to release starch.
Pesto alla genovese blends basil, pine nuts, garlic, parmesan, pecorino, and olive oil.
Cacio e pepe is just pasta, pecorino, and pepper — emulsion comes from starchy pasta water.
Lasagne bolognese layers ragu, bechamel, and pasta sheets between melted parmesan.
Amatriciana sauces guanciale and tomato together with a touch of pecorino.
Gnocchi alla romana use semolina baked with butter and cheese instead of potato.
Tiramisu layers savoiardi soaked in espresso with mascarpone whipped with eggs and sugar.
Sourdough bread relies on a wild yeast starter and slow bulk fermentation for flavor.
Bechamel sauce is built from a roux of butter and flour cooked into milk.
Caramelization happens to sugars above 160 C; the Maillard reaction needs proteins and amines.
Sushi rice is seasoned with vinegar, sugar, and salt while still warm so seasoning soaks evenly.
Korean kimchi ferments napa cabbage with chili paste, garlic, ginger, and salted shrimp.
French croissants get their flake from laminated layers of cold butter folded into yeasted dough.
Backpropagation computes gradients of a loss with respect to neural network weights via the chain rule.
Gradient descent updates model parameters in the direction that reduces the loss the most.
Stochastic gradient descent samples mini-batches so each step costs less and adds useful noise.
Adam adapts a per-parameter learning rate using running moments of the gradients.
Convolutional neural networks share weights across spatial locations to exploit translation invariance.
Recurrent networks process sequences by feeding state from one step into the next.
Transformers attend over a whole sequence at once instead of recurring through it.
Self-attention computes a weighted sum of values where weights are softmax of query-key dot products.
Layer normalization stabilizes training by normalizing activations along the feature dimension.
Dropout randomly zeroes activations during training as a regularizer.
Residual connections let very deep networks train by giving gradients an additive path back.
Word embeddings map discrete tokens to dense vectors learned end-to-end with the task.
A loss function turns predictions and targets into a single scalar to minimize.
Cross-entropy loss compares a predicted distribution to a one-hot target.
Reinforcement learning learns a policy by trial-and-error rewards in an environment.
Markov decision processes describe RL with states, actions, transitions, and rewards.
Q-learning estimates the value of taking each action in each state.
Policy gradient methods directly adjust the policy to maximize expected reward.
The Roman Empire fell in 476 CE when Romulus Augustulus, the last western emperor, was deposed.
Constantinople fell to the Ottomans under Mehmed II in 1453, ending the Byzantine Empire.
The Magna Carta of 1215 limited the power of the English monarchy and protected baronial rights.
The printing press, perfected by Gutenberg around 1440, accelerated the spread of literacy in Europe.
The Treaty of Westphalia in 1648 ended the Thirty Years War and established sovereign-state diplomacy.
The French Revolution began in 1789 with the storming of the Bastille on July 14.
The Industrial Revolution started in late-eighteenth-century Britain with mechanized textile production.
The American Civil War ran from 1861 to 1865 over slavery, states' rights, and union.
World War I lasted from 1914 to 1918 and was triggered by the assassination of Archduke Franz Ferdinand.
The Russian Revolution of 1917 replaced the tsarist autocracy with Bolshevik rule.
The Great Depression began with the 1929 Wall Street Crash and lasted through the 1930s.
World War II ended in 1945 after Germany surrendered in May and Japan in September.
The United Nations was founded in 1945 in San Francisco to prevent another global war.
The Cold War divided the world between US and Soviet spheres from roughly 1947 to 1991.
The Berlin Wall fell on November 9, 1989, symbolizing the end of the Cold War in Europe.
The Apollo 11 mission landed humans on the Moon for the first time on July 20, 1969.
The European Union was established by the Maastricht Treaty in 1993, succeeding the EEC.

View File

@@ -6,6 +6,14 @@ host = "127.0.0.1"
port = 7777
static_dir = "web/dist"
# Persistence (Stage 6). `data_dir` is a directory; sled creates and manages
# files inside it. Snapshots are written every `snapshot_interval_secs`
# while the server runs; on boot any existing galaxy snapshots are loaded
# and re-instantiated in the sim.
[storage]
data_dir = "data"
snapshot_interval_secs = 60
[lm_studio]
# LM Studio's OpenAI-compatible REST endpoint.
base_url = "http://127.0.0.1:1234/v1"
@@ -35,16 +43,21 @@ major_radius = 100.0
minor_radius = 30.0
# Caveman token caps per §13.5.
# Note: gemma-4-e4b is a *thinking* model — it emits a hidden reasoning
# stream before its visible content. Every `_out` cap therefore needs to
# budget for the hidden reasoning *plus* the visible answer. Numbers
# verified against logs: a 150-cap responder hit "response had no content"
# every time (all budget eaten by reasoning).
[token_caps]
introspection_in = 400
introspection_out = 200
introspection_in = 600
introspection_out = 900
peer_msg_in = 100
peer_msg_out = 100
synthesis_in = 300
synthesis_out = 80
query_responder_in = 200
query_responder_out = 150
query_integrator_in = 800
query_integrator_out = 300
hard_ceiling_in = 1024
hard_ceiling_out = 400
query_responder_in = 400
query_responder_out = 700
query_integrator_in = 1200
query_integrator_out = 1200
hard_ceiling_in = 1500
hard_ceiling_out = 1500

View File

@@ -15,10 +15,13 @@ sophia-core = { workspace = true }
sophia-server = { workspace = true }
sophia-sim = { workspace = true }
sophia-llm = { workspace = true }
sophia-store = { workspace = true }
anyhow = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
toml = { workspace = true }
reqwest = { workspace = true }

View File

@@ -1,19 +1,31 @@
//! Sophia binary. Reads `config.toml`, initializes tracing, starts the server.
//! Sophia binary. Reads `config.toml`, initializes tracing, opens the
//! persistence store, hydrates any saved galaxies into the sim, and starts
//! the server. A background task snapshots the sim to disk on a fixed
//! cadence (Stage 6).
//!
//! `sophia demo` (Stage 7) — same boot path, plus a background task that
//! waits for the server to be reachable then ingests a curated corpus
//! (compiled into the binary). Useful for screen recordings and first-run
//! demos: a single command takes you from cold boot to a populated galaxy.
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{Context, Result};
use serde::Deserialize;
use sophia_core::GalaxyShape;
use sophia_llm::{LmStudioClient, LmStudioConfig, TokenCapsAll};
use sophia_server::{serve, ServerConfig};
use sophia_sim::SimHandle;
use sophia_store::Store;
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
#[derive(Debug, Deserialize)]
struct Config {
server: ServerSection,
#[serde(default)]
storage: StorageSection,
lm_studio: LmStudioConfig,
#[allow(dead_code)] // wired in to LLM call sites in Stages 4+
token_caps: TokenCapsAll,
#[serde(default)]
galaxy_defaults: GalaxyDefaultsSection,
@@ -26,6 +38,21 @@ struct ServerSection {
static_dir: String,
}
#[derive(Debug, Deserialize)]
struct StorageSection {
data_dir: String,
snapshot_interval_secs: u64,
}
impl Default for StorageSection {
fn default() -> Self {
Self {
data_dir: "data".to_string(),
snapshot_interval_secs: 60,
}
}
}
#[derive(Debug, Deserialize)]
struct GalaxyDefaultsSection {
major_radius: f32,
@@ -92,11 +119,175 @@ fn load_config() -> Result<Config> {
Ok(cfg)
}
/// Hydrate the sim from disk. Returns the count of galaxies restored.
/// If the store is empty, materializes a fresh "default" galaxy and writes
/// the first snapshot so a subsequent kill+restart already finds it.
async fn hydrate_or_seed(sim: &SimHandle, store: &Store) -> Result<usize> {
let saved = store.list_galaxies().context("listing stored galaxies")?;
if saved.is_empty() {
let info = sim.create_galaxy("default".to_string()).await?;
tracing::info!("no saved galaxies — created default {:?}", info.id);
// Persist the empty galaxy now so the very next restart rehydrates
// (with the same id) instead of creating a fresh one — keeps the
// browser's bookmarked galaxy id stable across restarts.
let snaps = sim.snapshot_all().await?;
for snap in &snaps {
store.save_galaxy(snap)?;
}
return Ok(0);
}
let n = saved.len();
for snap in saved {
let id = snap.galaxy.id;
let engrams = snap.engrams.len();
let synapses = snap.synapses.len();
match sim.hydrate_galaxy(snap).await {
Ok(info) => {
tracing::info!(
"hydrated galaxy {:?} ({}, {} engrams, {} synapses)",
info.id,
info.name,
engrams,
synapses
);
}
Err(e) => {
tracing::warn!("hydration failed for {:?}: {e}", id);
}
}
}
Ok(n)
}
/// Stage 7: corpus to ingest when invoked as `sophia demo`. Compiled into
/// the binary so the command works from any cwd.
const DEMO_CORPUS: &str = include_str!("../../../assets/demo_corpus.txt");
/// Stage 7 demo task. Polls `/healthz` until the server is reachable, then
/// posts the curated corpus into the first galaxy. Logs progress; failures
/// are non-fatal — the server stays up either way.
fn spawn_demo_ingest(host: String, port: u16) {
tokio::spawn(async move {
let base = format!("http://{host}:{port}");
let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(60))
.build()
{
Ok(c) => c,
Err(e) => {
tracing::warn!("demo: reqwest client failed: {e}");
return;
}
};
// Wait for server reachable. Cap at 30 s so a misconfigured boot
// doesn't loop forever.
let deadline = std::time::Instant::now() + Duration::from_secs(30);
let mut reachable = false;
while std::time::Instant::now() < deadline {
if client.get(format!("{base}/healthz")).send().await.is_ok() {
reachable = true;
break;
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
if !reachable {
tracing::warn!("demo: server never became reachable, aborting ingest");
return;
}
// Pick the first galaxy (the auto-created or just-hydrated default).
let galaxies: Vec<serde_json::Value> = match client
.get(format!("{base}/api/galaxy"))
.send()
.await
.and_then(|r| r.error_for_status())
{
Ok(r) => match r.json().await {
Ok(g) => g,
Err(e) => {
tracing::warn!("demo: parsing galaxies failed: {e}");
return;
}
},
Err(e) => {
tracing::warn!("demo: listing galaxies failed: {e}");
return;
}
};
let Some(first) = galaxies.first() else {
tracing::warn!("demo: no galaxies to ingest into");
return;
};
let Some(gid) = first.get("id").and_then(|v| v.as_str()) else {
tracing::warn!("demo: galaxy entry missing id");
return;
};
let texts: Vec<&str> = DEMO_CORPUS
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.collect();
tracing::info!("demo: ingesting {} paragraphs into galaxy {}", texts.len(), gid);
let body = serde_json::json!({ "texts": texts });
match client
.post(format!("{base}/api/galaxy/{gid}/ingest"))
.json(&body)
.send()
.await
.and_then(|r| r.error_for_status())
{
Ok(_) => {
tracing::info!(
"demo: ingest succeeded — open {base} to watch the cluster form"
);
}
Err(e) => {
tracing::warn!("demo: ingest failed: {e}");
}
}
});
}
/// Background task: every `interval`, snapshot every galaxy and write to
/// disk. Errors are logged and ignored — we don't want a transient disk
/// hiccup to take down the simulation.
fn spawn_snapshot_task(sim: SimHandle, store: Store, interval: Duration) {
tokio::spawn(async move {
let mut ticker = tokio::time::interval(interval);
// First tick fires immediately by default; we want the first save
// *after* an interval has passed so boot doesn't double-write.
ticker.tick().await;
loop {
ticker.tick().await;
match sim.snapshot_all().await {
Ok(snaps) => {
for snap in &snaps {
if let Err(e) = store.save_galaxy(snap) {
tracing::warn!("snapshot save failed: {e}");
}
}
tracing::debug!("snapshot tick: persisted {} galaxies", snaps.len());
}
Err(e) => {
tracing::warn!("snapshot_all failed: {e}");
}
}
}
});
}
#[tokio::main]
async fn main() -> Result<()> {
init_tracing();
let cfg = load_config()?;
// Stage 7 CLI: only one subcommand right now (`demo`). Anything else
// boots the server normally.
let demo_mode = std::env::args().nth(1).as_deref() == Some("demo");
let server_cfg = ServerConfig {
host: cfg.server.host,
port: cfg.server.port,
@@ -125,9 +316,28 @@ async fn main() -> Result<()> {
default_shape.minor_radius
);
let sim = sophia_sim::spawn_sim(default_shape);
let info = sim.create_galaxy("default".to_string()).await?;
tracing::info!("default galaxy ready: id={:?} name={}", info.id, info.name);
let store = Store::open(&cfg.storage.data_dir)
.with_context(|| format!("opening sled store at {}", cfg.storage.data_dir))?;
tracing::info!("persistence store open at {}", cfg.storage.data_dir);
serve(server_cfg, sim, llm).await
let sim = sophia_sim::spawn_sim(default_shape);
let restored = hydrate_or_seed(&sim, &store).await?;
tracing::info!("hydrated {restored} galaxies from disk");
spawn_snapshot_task(
sim.clone(),
store.clone(),
Duration::from_secs(cfg.storage.snapshot_interval_secs.max(1)),
);
tracing::info!(
"snapshot task scheduled every {}s",
cfg.storage.snapshot_interval_secs
);
if demo_mode {
tracing::info!("demo mode: will auto-ingest curated corpus once server is reachable");
spawn_demo_ingest(server_cfg.host.clone(), server_cfg.port);
}
serve(server_cfg, sim, llm, cfg.token_caps, store).await
}

View File

@@ -10,6 +10,8 @@ use serde::{Deserialize, Serialize};
use crate::engram::EngramState;
use crate::ids::{EngramId, GalaxyId, SynapseId};
use crate::manifest::Manifest;
use crate::memory::{Introspection, Memory};
use crate::query::{QueryId, QueryStatus};
use crate::synapse::Synapse;
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -62,6 +64,10 @@ pub struct EngramDetail {
pub manifest: Option<Manifest>,
pub slate_dim: Option<usize>,
pub slate_norm: Option<f32>,
pub introspection: Introspection,
pub memories: Vec<Memory>,
/// True for Query-Engrams (Stage 5).
pub pinned: bool,
}
/// Position frame: a downsampled bundle of all engram positions at a moment in
@@ -97,6 +103,37 @@ pub enum SimEvent {
major_radius: f32,
minor_radius: f32,
},
/// An engram's lifecycle state changed (e.g., responder lit up while
/// answering a query, then returned to idle). Frontend uses this to
/// re-paint the engram's color in real time.
EngramStateChanged {
id: EngramId,
state: EngramState,
},
/// A query was started — a Query-Engram materialized at `position` with
/// the question text. Frontend uses this to register the query for the
/// answer panel + visual highlight.
QueryStarted {
query: QueryId,
engram: EngramId,
position: [f32; 3],
text: String,
},
/// The integrator produced a new running answer. `version` increments
/// monotonically per query; clients can ignore stale frames.
QueryAnswerUpdated {
query: QueryId,
version: u32,
status: QueryStatus,
answer: String,
},
/// Terminal event for a query. The Query-Engram has been moved to the
/// `Memorize` state and the SSE stream is closed.
QueryFinished {
query: QueryId,
status: QueryStatus,
responder_count: u32,
},
/// Position frame is sent over the wire as a binary frame; this variant
/// carries the in-process payload.
#[serde(skip)]

View File

@@ -1,8 +1,11 @@
use std::collections::VecDeque;
use glam::Vec3;
use serde::{Deserialize, Serialize};
use crate::ids::EngramId;
use crate::manifest::Manifest;
use crate::memory::{Introspection, Memory};
use crate::slate::Slate;
/// Engram state machine — see §5 of `docs/system-analysis.md`.
@@ -31,7 +34,9 @@ impl EngramState {
///
/// Stage 2 adds `manifest` (the source content) and `slate` (an embedding for
/// fast similarity). Stage 4 adds taxonomy/goals/memories on top of that.
#[derive(Debug, Clone)]
/// Stage 6 adds Serialize/Deserialize so engrams can round-trip through the
/// `sophia-store` snapshot file.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Engram {
pub id: EngramId,
/// Slot index for the visualization's `InstancedMesh`. Assigned at birth,
@@ -51,4 +56,14 @@ pub struct Engram {
/// Embedding vector — System-1 layer of the Universal Slate. `None` for
/// synthetic seed engrams.
pub slate: Option<Slate>,
/// LLM-generated self-description (Stage 4). Filled async after birth;
/// empty until the introspection task completes.
pub introspection: Introspection,
/// Per-engram memory log (Stage 4). Bounded — older entries evicted from
/// the front when full.
pub memories: VecDeque<Memory>,
/// Pinned engrams skip physics integration — they sit at their birth
/// position permanently. Used for Query-Engrams (Stage 5), which
/// materialize at the galaxy center and shouldn't drift into the tube.
pub pinned: bool,
}

View File

@@ -59,8 +59,9 @@ pub struct Galaxy {
pub id: GalaxyId,
pub name: String,
/// Galaxy origin in world coords. Always `Vec3::ZERO` in v1; reserved for
/// future multi-galaxy layouts.
#[serde(skip)]
/// future multi-galaxy layouts. Serialized so persistence round-trips
/// the full state (Stage 6) — defaults to ZERO if missing from disk.
#[serde(default)]
pub center: Vec3,
pub shape: GalaxyShape,
}

View File

@@ -8,6 +8,9 @@ pub mod engram;
pub mod galaxy;
pub mod ids;
pub mod manifest;
pub mod memory;
pub mod persistence;
pub mod query;
pub mod slate;
pub mod synapse;
@@ -16,6 +19,9 @@ pub use engram::{Engram, EngramState};
pub use galaxy::{Galaxy, GalaxyShape};
pub use ids::{EngramId, GalaxyId, SynapseId};
pub use manifest::Manifest;
pub use memory::{Introspection, Memory, MemoryKind, MAX_MEMORIES};
pub use persistence::GalaxySnapshot;
pub use query::{QueryId, QueryStatus};
pub use slate::Slate;
pub use synapse::{canonical_pair, Synapse};

View File

@@ -0,0 +1,52 @@
//! Per-Engram memory entries — a running log of meaningful events from the
//! engram's own perspective. Stage 4 only records synapse formation; Stage 5
//! will add conversation memorialization.
use serde::{Deserialize, Serialize};
use crate::ids::EngramId;
use crate::query::QueryId;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum MemoryKind {
Born,
Introspected,
/// A synapse formed with another engram.
SynapseFormed { with: EngramId, weight: f32 },
/// This engram contributed a snippet to a query's running answer.
/// `snippet` is the engram's own LLM-generated POV on the question.
QueryParticipated { query: QueryId, snippet: String },
/// (Query-engrams only.) The query completed and produced a final answer.
/// Stored on the Query-Engram itself so the inspector can show the
/// resolution after the fact.
QueryAnswered { answer: String, responder_count: u32 },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Memory {
/// Time relative to the start of the simulation (ms).
pub at_ms: u32,
pub kind: MemoryKind,
}
/// Hard cap on a single engram's memory list. Older entries are evicted from
/// the front of the deque when full. Stage 4 picks 64 — generous enough for
/// the demo, small enough not to bloat the Hello/inspector payloads.
pub const MAX_MEMORIES: usize = 64;
/// Output of LLM-driven self-introspection at birth (Stage 4). Stored on the
/// `Engram` itself, not the `Manifest`, since it's the engram's *interpretation*
/// of its source content rather than the source itself.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Introspection {
pub taxonomy: Vec<String>,
pub goals: Vec<String>,
pub open_questions: Vec<String>,
}
impl Introspection {
pub fn is_empty(&self) -> bool {
self.taxonomy.is_empty() && self.goals.is_empty() && self.open_questions.is_empty()
}
}

View File

@@ -0,0 +1,31 @@
//! Persistence types (Stage 6).
//!
//! `GalaxySnapshot` is the on-disk shape of a galaxy: its metadata plus the
//! full set of Engrams and Synapses it contains. This is what the periodic
//! snapshot task in `sophia-bin` writes to the sled store, and what the
//! `SimHandle::hydrate_galaxy` command reads back at boot.
//!
//! Pinned engrams (Stage 5 Query-Engrams) are intentionally excluded from
//! snapshots. Queries are ephemeral — their orchestrator task is gone after
//! a server restart, and a half-rendered query-engram with no SSE channel
//! would be confusing in the UI. Letting them die with the process is the
//! cleaner default.
use serde::{Deserialize, Serialize};
use crate::engram::Engram;
use crate::galaxy::Galaxy;
use crate::synapse::Synapse;
/// One galaxy's full persistent state. Encoded with `bincode` and stored
/// under the galaxy's id in the sled tree.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GalaxySnapshot {
pub galaxy: Galaxy,
/// All non-pinned engrams. The order is preserved so `instance_idx`
/// values remain meaningful after a round-trip — the sim re-uses each
/// engram's stored `instance_idx` rather than re-numbering, so the
/// position-frame slot mapping stays stable across restarts.
pub engrams: Vec<Engram>,
pub synapses: Vec<Synapse>,
}

View File

@@ -0,0 +1,45 @@
//! Query domain types (Stage 5).
//!
//! A "query" is a question that materializes as a special pinned Engram at
//! the galaxy center. The Query-Engram broadcasts to nearby resonating
//! Engrams; their snippets are integrated into a streaming answer.
//!
//! `QueryId` is distinct from `EngramId` even though every query has a
//! backing query-engram — keeping them separate lets the orchestrator track
//! query state (responders, snippets, answer versions) independently of the
//! engram's lifecycle in the world.
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct QueryId(pub Uuid);
impl QueryId {
pub fn new() -> Self {
Self(Uuid::now_v7())
}
}
impl Default for QueryId {
fn default() -> Self {
Self::new()
}
}
/// Lifecycle phase of a query, as exposed to SSE consumers.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum QueryStatus {
/// Query-Engram materialized; broadcast not yet started.
Pending,
/// Phase A/B retrieval in flight; responders being polled.
Responding,
/// All responses gathered; integrator producing final answer.
Integrating,
/// Final answer published; query-engram transitioned to Memorize.
Done,
/// Aborted (e.g. embedding failure or sim shutdown).
Failed,
}

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

View File

@@ -7,9 +7,10 @@ rust-version.workspace = true
authors.workspace = true
[dependencies]
sophia-core = { workspace = true }
sophia-sim = { workspace = true }
sophia-llm = { workspace = true }
sophia-core = { workspace = true }
sophia-sim = { workspace = true }
sophia-llm = { workspace = true }
sophia-store = { workspace = true }
anyhow = { workspace = true }
tokio = { workspace = true }
@@ -22,4 +23,5 @@ tower = { workspace = true }
tower-http = { workspace = true }
futures-util = { workspace = true }
tokio-stream = { workspace = true }
bytes = { workspace = true }

View File

@@ -1,23 +1,32 @@
//! HTTP / WebSocket server.
use std::convert::Infallible;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use anyhow::Result;
use axum::extract::{Path, Query, State, WebSocketUpgrade};
use axum::http::StatusCode;
use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
use axum::response::{IntoResponse, Response};
use axum::routing::{delete, get, post};
use axum::{Json, Router};
use futures_util::stream::{self, Stream, StreamExt};
use serde::Deserialize;
use sophia_core::{EngramId, GalaxyId, GalaxyShape, Manifest, Slate};
use sophia_llm::{LmStudioClient, EMBED_BATCH_LIMIT};
use sophia_core::{EngramId, GalaxyId, GalaxyShape, Manifest, QueryId, QueryStatus, Slate};
use sophia_llm::{introspect_text, LmStudioClient, TokenCapsAll, EMBED_BATCH_LIMIT};
use sophia_sim::{IngestItem, SimHandle};
use sophia_store::Store;
use tokio_stream::wrappers::BroadcastStream;
use tower_http::services::{ServeDir, ServeFile};
use tower_http::trace::TraceLayer;
mod query;
mod ws;
use query::QueryRegistry;
#[derive(Debug, Clone)]
pub struct ServerConfig {
pub host: String,
@@ -29,29 +38,54 @@ pub struct ServerConfig {
struct AppState {
sim: SimHandle,
llm: LmStudioClient,
token_caps: TokenCapsAll,
queries: Arc<QueryRegistry>,
store: Store,
}
pub fn build_router(cfg: &ServerConfig, sim: SimHandle, llm: LmStudioClient) -> Router {
pub fn build_router(
cfg: &ServerConfig,
sim: SimHandle,
llm: LmStudioClient,
token_caps: TokenCapsAll,
store: Store,
) -> 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 });
let state = Arc::new(AppState {
sim,
llm,
token_caps,
queries: Arc::new(QueryRegistry::new()),
store,
});
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", delete(delete_galaxy))
.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/:id/query", post(start_query))
.route("/api/galaxy/:gid/engrams/:eid", get(get_engram))
.route("/api/query/:id", get(get_query))
.route("/api/query/:id/stream", get(query_stream))
.route("/api/stats", get(stats))
.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);
pub async fn serve(
cfg: ServerConfig,
sim: SimHandle,
llm: LmStudioClient,
token_caps: TokenCapsAll,
store: Store,
) -> Result<()> {
let app = build_router(&cfg, sim, llm, token_caps, store);
let addr = format!("{}:{}", cfg.host, cfg.port);
let listener = tokio::net::TcpListener::bind(&addr).await?;
tracing::info!("sophia-server listening on http://{}", addr);
@@ -86,6 +120,26 @@ async fn healthz(State(s): State<Arc<AppState>>) -> Response {
.into_response()
}
/// `GET /api/stats` — cheap snapshot for the HUD. Polled at ~1 Hz by the
/// browser; every field is integer so the panel stays readable. The
/// `llm_queue_depth` is `parallel_ops - available_permits` (i.e. how many
/// LLM calls are currently in flight).
async fn stats(State(s): State<Arc<AppState>>) -> Response {
let sim_stats = s.sim.stats().await;
let permits_free = s.llm.available_permits();
let parallel_ops = s.llm.parallel_ops();
let in_flight = parallel_ops.saturating_sub(permits_free);
Json(serde_json::json!({
"ticks_per_sec": sim_stats.ticks_per_sec,
"engrams_total": sim_stats.engrams_total,
"synapses_total": sim_stats.synapses_total,
"galaxies": sim_stats.galaxies,
"llm_queue_depth": in_flight,
"llm_parallel_cap": parallel_ops,
}))
.into_response()
}
#[derive(Deserialize)]
struct CreateGalaxyBody {
name: String,
@@ -96,7 +150,22 @@ async fn create_galaxy(
Json(body): Json<CreateGalaxyBody>,
) -> Response {
match s.sim.create_galaxy(body.name).await {
Ok(info) => Json(info).into_response(),
Ok(info) => {
// Persist immediately so a kill+restart in the first 60 s
// doesn't lose the new galaxy. Failure here is non-fatal —
// the next periodic snapshot tick will pick it up.
if let Ok(snaps) = s.sim.snapshot_all().await {
for snap in &snaps {
if snap.galaxy.id == info.id {
if let Err(e) = s.store.save_galaxy(snap) {
tracing::warn!("initial save of new galaxy failed: {e}");
}
break;
}
}
}
Json(info).into_response()
}
Err(e) => sim_error(e),
}
}
@@ -108,9 +177,22 @@ async fn list_galaxies(State(s): State<Arc<AppState>>) -> Response {
}
}
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()
async fn delete_galaxy(
State(s): State<Arc<AppState>>,
Path(id): Path<GalaxyId>,
) -> Response {
if let Err(e) = s.sim.delete_galaxy(id).await {
return sim_error(e);
}
if let Err(e) = s.store.delete_galaxy(id) {
// Sim succeeded but disk didn't — log and report; the galaxy is
// gone from memory so this is a 500-ish situation, but the next
// periodic snapshot will repair (it just won't write the deleted
// galaxy back since it's not in `snapshot_all`).
tracing::warn!("sim deleted galaxy {:?} but store delete failed: {e}", id);
return (StatusCode::INTERNAL_SERVER_ERROR, format!("store: {e}")).into_response();
}
(StatusCode::NO_CONTENT, "").into_response()
}
#[derive(Deserialize)]
@@ -171,6 +253,9 @@ async fn ingest_galaxy(
}
}
// Pair (text, slate) → IngestItem, but keep texts so we can launch a
// post-ingest introspection task per engram below.
let texts_for_introspection: Vec<String> = texts.clone();
let items: Vec<IngestItem> = texts
.into_iter()
.zip(all_vecs)
@@ -181,7 +266,40 @@ async fn ingest_galaxy(
.collect();
tracing::info!("ingest: {} items into galaxy {:?}", items.len(), id);
match s.sim.ingest(id, items).await {
let result = s.sim.ingest(id, items).await;
// On success, fire-and-forget one LLM introspection task per engram.
// The LM Studio client's semaphore (parallel_ops) bounds total
// concurrency, so even 200 spawned futures only run N at a time.
// Updates trickle in over the next several seconds via
// `SimHandle::update_introspection`.
if let Ok(ids) = &result {
for (engram_id, text) in ids.iter().zip(texts_for_introspection) {
let llm = s.llm.clone();
let sim = s.sim.clone();
let caps = s.token_caps.introspection();
let galaxy_id = id;
let engram_id = *engram_id;
tokio::spawn(async move {
match introspect_text(&llm, &text, caps).await {
Ok(intro) => {
if intro.is_empty() {
tracing::debug!(?engram_id, "introspection returned empty");
} else if let Err(e) =
sim.update_introspection(galaxy_id, engram_id, intro).await
{
tracing::warn!(?engram_id, "update_introspection failed: {e}");
}
}
Err(e) => {
tracing::warn!(?engram_id, "introspect_text failed: {e}");
}
}
});
}
}
match result {
Ok(ids) => Json(serde_json::json!({ "engram_ids": ids })).into_response(),
Err(e) => sim_error(e),
}
@@ -230,6 +348,99 @@ async fn ws_events(
upgrade.on_upgrade(move |socket| ws::run_galaxy_socket(socket, s.sim.clone(), id))
}
#[derive(Deserialize)]
struct QueryBody {
text: String,
}
/// `POST /api/galaxy/:id/query` — embed the question, materialize a Query-Engram,
/// kick off the orchestrator. Returns 202 + `{query_id}` immediately so the
/// client can SSE-subscribe before the first responder fires.
async fn start_query(
State(s): State<Arc<AppState>>,
Path(id): Path<GalaxyId>,
Json(body): Json<QueryBody>,
) -> Response {
match query::start_query(
s.sim.clone(),
s.llm.clone(),
s.token_caps.clone(),
s.queries.clone(),
id,
body.text,
)
.await
{
Ok(qid) => (
StatusCode::ACCEPTED,
Json(serde_json::json!({ "query_id": qid })),
)
.into_response(),
Err(msg) => (StatusCode::BAD_REQUEST, msg).into_response(),
}
}
/// `GET /api/query/:id` — current snapshot. Useful for clients that miss the
/// initial SSE event or want a one-shot read.
async fn get_query(
State(s): State<Arc<AppState>>,
Path(id): Path<QueryId>,
) -> Response {
match s.queries.get(id).await {
Some(entry) => Json(entry.snapshot().await).into_response(),
None => (StatusCode::NOT_FOUND, "query not found").into_response(),
}
}
/// `GET /api/query/:id/stream` — Server-Sent Events of `QueryUpdate` JSON.
/// First event is always the latest known snapshot (so a late subscriber
/// catches up without polling); subsequent events come from the per-query
/// broadcast channel. The stream ends when the orchestrator publishes a
/// terminal status (`Done` or `Failed`).
async fn query_stream(
State(s): State<Arc<AppState>>,
Path(id): Path<QueryId>,
) -> Response {
let Some(entry) = s.queries.get(id).await else {
return (StatusCode::NOT_FOUND, "query not found").into_response();
};
let initial = entry.snapshot().await;
let receiver = entry.subscribe();
let live = BroadcastStream::new(receiver).filter_map(|res| async move {
match res {
Ok(update) => Some(update),
Err(e) => {
tracing::debug!("sse lagged: {e}");
None
}
}
});
// Emit the initial snapshot first, then forward live updates until a
// terminal frame (`done`/`failed`) — the terminal frame is INCLUDED,
// and the stream closes immediately after. Standard `take_while` drops
// the matching item; `unfold` gives us "include and then end" by
// collapsing the state to None after emitting the terminal frame.
let combined = stream::once(async move { initial }).chain(live);
let stream = stream::unfold(Some(Box::pin(combined)), |state| async move {
let mut s = state?;
let item = s.next().await?;
let next_state = if matches!(item.status, QueryStatus::Done | QueryStatus::Failed) {
None
} else {
Some(s)
};
Some((item, next_state))
});
let sse_stream: std::pin::Pin<Box<dyn Stream<Item = Result<SseEvent, Infallible>> + Send>> =
Box::pin(stream.map(|u| {
let payload = serde_json::to_string(&u).unwrap_or_else(|_| "{}".into());
Ok(SseEvent::default().data(payload))
}));
Sse::new(sse_stream)
.keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))
.into_response()
}
fn sim_error(e: sophia_sim::SimError) -> Response {
use sophia_sim::SimError::*;
let status = match e {

View File

@@ -0,0 +1,393 @@
//! Stage 5 query orchestration.
//!
//! A "query" is a conversation initiated by the user. Lifecycle:
//!
//! 1. `POST /api/galaxy/:id/query {text}` — embed the question, materialize a
//! pinned Query-Engram in the sim, register a per-query SSE bus, spawn the
//! background orchestrator, return `{query_id}` immediately (HTTP 202).
//! 2. Orchestrator (this module): `sim.broadcast_query(...)` chooses the
//! responders (Phase A cosine + Phase B 1-hop synaptic). Each responder
//! is asked, in parallel under the LM Studio semaphore, what *its* content
//! contributes to the question. As snippets arrive they're recorded on
//! the responder (visual light-up) and integrated into a running answer
//! every `INTEGRATION_INTERVAL`.
//! 3. When all responses are gathered (or the timeout hits), one final
//! integration produces the canonical answer; `sim.finish_query(...)`
//! transitions the Query-Engram to Memorize and the SSE stream closes.
//!
//! Decisions worth noting:
//!
//! - Responder cap (`MAX_RESPONDERS = 12`): keeps the LLM queue from being
//! monopolized by one query and bounds the integrator's input length.
//! - Re-integration is *time-driven* (every 2 s) rather than per-snippet —
//! batching snippets into a single integrator call costs less and gives
//! the answer time to refine.
//! - Per-query SSE channel buffer is small (16); the latest snapshot is
//! stored separately so a slow client can resync to current state on
//! connect rather than replaying every interim version.
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use sophia_core::{GalaxyId, QueryId, QueryStatus, Slate};
use sophia_llm::{integrate_responses, respond_to_query, LmStudioClient, TokenCapsAll};
use sophia_sim::SimHandle;
use tokio::sync::{broadcast, Mutex, RwLock};
use tokio::task::JoinSet;
use tracing::{debug, warn};
/// Maximum number of responders we ask per query. Larger numbers don't help
/// the answer much (the integrator can only fit ~510 distinct viewpoints
/// in its 800-token input cap) and starve the LLM queue.
const MAX_RESPONDERS: usize = 12;
/// How often the integrator re-runs while new snippets are arriving. Every
/// 2 s gives the user a visible refinement cadence without burning LLM
/// budget on near-identical re-integrations.
const INTEGRATION_INTERVAL: Duration = Duration::from_millis(2_000);
/// Hard cap on time spent waiting for responders. After this we stop polling,
/// run a final integration on whatever snippets we have, and finish. Tuned
/// for `gemma-4-e4b` thinking-mode latency: each responder call takes
/// ~2040 s once the hidden reasoning budget is large enough to actually
/// produce visible content, and LM Studio serialises some of them despite
/// the parallel-op semaphore on our side.
const RESPONDER_TIMEOUT: Duration = Duration::from_secs(120);
/// SSE channel buffer per query. SSE consumers that lag past this will see
/// old versions dropped; the registry's `latest` snapshot covers the rest.
const SSE_CAPACITY: usize = 16;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueryUpdate {
pub query_id: QueryId,
pub version: u32,
pub status: QueryStatus,
pub answer: String,
pub responder_count: u32,
}
#[derive(Debug)]
pub struct QueryEntry {
sender: broadcast::Sender<QueryUpdate>,
latest: Mutex<QueryUpdate>,
}
/// Process-wide registry. Cheaply cloneable Arc holds an `RwLock` over the
/// `HashMap<QueryId, Arc<QueryEntry>>` — typical access is read-heavy
/// (subscribers + GET /api/query/:id), with a single write per new query.
#[derive(Default)]
pub struct QueryRegistry {
inner: RwLock<HashMap<QueryId, Arc<QueryEntry>>>,
}
impl QueryRegistry {
pub fn new() -> Self {
Self::default()
}
async fn register(&self, query_id: QueryId, initial: QueryUpdate) -> Arc<QueryEntry> {
let (sender, _) = broadcast::channel(SSE_CAPACITY);
let entry = Arc::new(QueryEntry {
sender,
latest: Mutex::new(initial),
});
self.inner.write().await.insert(query_id, entry.clone());
entry
}
pub async fn get(&self, query_id: QueryId) -> Option<Arc<QueryEntry>> {
self.inner.read().await.get(&query_id).cloned()
}
}
impl QueryEntry {
pub fn subscribe(&self) -> broadcast::Receiver<QueryUpdate> {
self.sender.subscribe()
}
pub async fn snapshot(&self) -> QueryUpdate {
self.latest.lock().await.clone()
}
async fn publish(&self, update: QueryUpdate) {
*self.latest.lock().await = update.clone();
// Fan-out send; drop silently if no one is subscribed.
let _ = self.sender.send(update);
}
}
/// Start a query. Embeds the question, materializes the Query-Engram, kicks
/// off the orchestrator. Returns the new `QueryId` immediately so the caller
/// can SSE-subscribe before the first responder fires.
pub async fn start_query(
sim: SimHandle,
llm: LmStudioClient,
caps: TokenCapsAll,
registry: Arc<QueryRegistry>,
galaxy: GalaxyId,
text: String,
) -> Result<QueryId, String> {
let trimmed = text.trim().to_string();
if trimmed.is_empty() {
return Err("query text must not be empty".into());
}
// Embed the question. This blocks the request for the duration of one
// LM Studio embedding call (typically <100 ms). Doing it inline keeps
// the orchestrator simple — no "pending embedding" state.
let embedding = llm
.embed(&trimmed)
.await
.map_err(|e| format!("embed: {e}"))?;
let slate = Slate(embedding);
let query_id = QueryId::new();
let query_engram = sim
.start_query_engram(galaxy, query_id, trimmed.clone(), slate)
.await
.map_err(|e| format!("start_query_engram: {e}"))?;
// Seed registry with a Pending snapshot so SSE subscribers get something
// immediate even before broadcast finishes.
let initial = QueryUpdate {
query_id,
version: 0,
status: QueryStatus::Pending,
answer: String::new(),
responder_count: 0,
};
let entry = registry.register(query_id, initial.clone()).await;
tokio::spawn(orchestrate_query(OrchestrateCtx {
sim,
llm,
caps,
entry,
galaxy,
query_id,
query_engram,
question: trimmed,
}));
Ok(query_id)
}
struct OrchestrateCtx {
sim: SimHandle,
llm: LmStudioClient,
caps: TokenCapsAll,
entry: Arc<QueryEntry>,
galaxy: GalaxyId,
query_id: QueryId,
query_engram: sophia_core::EngramId,
question: String,
}
/// The orchestrator. Owns the per-query lifecycle from broadcast to finish.
async fn orchestrate_query(ctx: OrchestrateCtx) {
let OrchestrateCtx {
sim, llm, caps, entry, galaxy, query_id, query_engram, question,
} = ctx;
let responders = match sim.broadcast_query(galaxy, query_engram, MAX_RESPONDERS).await {
Ok(rs) => rs,
Err(e) => {
warn!(?query_id, "broadcast_query failed: {e}");
publish_terminal(&entry, query_id, QueryStatus::Failed, String::new(), 0).await;
return;
}
};
if responders.is_empty() {
let answer = "No engrams resonated with this question.".to_string();
let _ = sim
.publish_query_answer(galaxy, query_id, 1, QueryStatus::Done, answer.clone())
.await;
let _ = sim
.finish_query(galaxy, query_id, query_engram, answer.clone(), 0)
.await;
publish_terminal(&entry, query_id, QueryStatus::Done, answer, 0).await;
return;
}
debug!(
?query_id,
responders = responders.len(),
"broadcasting query to responders"
);
// Fan out one responder LLM task per pick. Each task fetches the
// responder's source text from the sim, asks the LLM for that engram's
// POV, then records the snippet. The LM Studio semaphore inside
// `respond_to_query` is what keeps concurrent calls bounded — we do
// *not* serialize here.
let snippets: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let mut tasks = JoinSet::new();
for (responder_id, score) in responders.iter().copied() {
let sim = sim.clone();
let llm = llm.clone();
let caps = caps.query_responder();
let question = question.clone();
let snippets = Arc::clone(&snippets);
tasks.spawn(async move {
let text = match sim.get_engram_text(galaxy, responder_id).await {
Ok(Some(t)) => t,
_ => return,
};
let snippet = match respond_to_query(&llm, &question, &text, caps).await {
Ok(Some(s)) => s,
Ok(None) => {
debug!(?responder_id, score, "responder passed");
return;
}
Err(e) => {
warn!(?responder_id, "responder failed: {e}");
return;
}
};
// Record on the engram (visual light-up + memory entry).
if let Err(e) = sim
.record_query_participation(galaxy, query_id, responder_id, snippet.clone())
.await
{
warn!(?responder_id, "record_query_participation: {e}");
}
snippets.lock().await.push(snippet);
});
}
// Run the integrator on a fixed cadence while responders are in flight.
// We don't try to "wake on snippet" — a 2 s tick is plenty fast for the
// user, and lets multiple snippets batch into one integrator call.
let mut version: u32 = 0;
let deadline = tokio::time::Instant::now() + RESPONDER_TIMEOUT;
let mut ticker = tokio::time::interval(INTEGRATION_INTERVAL);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
tokio::select! {
biased;
// All responder tasks have completed (or panicked).
_ = wait_for_join_set(&mut tasks) => {
break;
}
_ = ticker.tick() => {
let snapshot = snippets.lock().await.clone();
if snapshot.is_empty() {
continue;
}
version += 1;
run_integration(
&sim, &llm, caps.query_integrator(), &entry, galaxy,
query_id, version, &question, &snapshot, QueryStatus::Responding,
).await;
}
_ = tokio::time::sleep_until(deadline) => {
warn!(?query_id, "responder timeout — finishing with partial set");
tasks.shutdown().await;
break;
}
}
}
let final_snippets = snippets.lock().await.clone();
let responder_count = final_snippets.len() as u32;
// Final integration. If we already have a recent answer and no new
// snippets arrived in the final window we still re-run once — the
// integrator is allowed to refine even with the same input.
version += 1;
let final_answer = if final_snippets.is_empty() {
"No responder produced a usable snippet.".to_string()
} else {
match integrate_responses(
&llm,
&question,
&final_snippets,
caps.query_integrator(),
).await {
Ok(a) if !a.is_empty() => a,
Ok(_) => "Integrator returned an empty answer.".to_string(),
Err(e) => {
warn!(?query_id, "final integration failed: {e}");
entry.snapshot().await.answer
}
}
};
let _ = sim
.publish_query_answer(galaxy, query_id, version, QueryStatus::Done, final_answer.clone())
.await;
let _ = sim
.finish_query(galaxy, query_id, query_engram, final_answer.clone(), responder_count)
.await;
publish_terminal(&entry, query_id, QueryStatus::Done, final_answer, responder_count).await;
}
/// Run one integration pass and publish the result to both the sim's WS bus
/// and the per-query SSE bus. Increments `responder_count` from the snapshot
/// length so the UI can show "synthesizing 5 perspectives…".
#[allow(clippy::too_many_arguments)]
async fn run_integration(
sim: &SimHandle,
llm: &LmStudioClient,
caps: sophia_llm::TokenCaps,
entry: &QueryEntry,
galaxy: GalaxyId,
query_id: QueryId,
version: u32,
question: &str,
snippets: &[String],
status: QueryStatus,
) {
let answer = match integrate_responses(llm, question, snippets, caps).await {
Ok(a) => a,
Err(e) => {
warn!(?query_id, "integrator failed: {e}");
return;
}
};
if answer.is_empty() {
return;
}
let responder_count = snippets.len() as u32;
let _ = sim
.publish_query_answer(galaxy, query_id, version, status, answer.clone())
.await;
entry
.publish(QueryUpdate {
query_id,
version,
status,
answer,
responder_count,
})
.await;
}
async fn publish_terminal(
entry: &QueryEntry,
query_id: QueryId,
status: QueryStatus,
answer: String,
responder_count: u32,
) {
let snapshot = entry.snapshot().await;
let version = snapshot.version.saturating_add(1);
entry
.publish(QueryUpdate {
query_id,
version,
status,
answer,
responder_count,
})
.await;
}
/// Drain a `JoinSet` until empty. Used as a `select!` arm to wait for "all
/// spawned tasks done" without holding a mutable borrow across awaits.
async fn wait_for_join_set(tasks: &mut JoinSet<()>) {
while tasks.join_next().await.is_some() {}
}

View File

@@ -12,8 +12,10 @@ use tracing::warn;
use sophia_core::{
Engram, EngramDetail, EngramId, EngramSnapshot, EngramState, GalaxyId, GalaxyInfo, GalaxyShape,
Manifest, PositionFrame, SimEvent, Slate, SynapseDto, Vec3,
GalaxySnapshot, Introspection, Manifest, Memory, MemoryKind, PositionFrame, QueryId,
QueryStatus, SimEvent, Slate, SynapseDto, Vec3, MAX_MEMORIES,
};
use std::collections::{HashMap, HashSet, VecDeque};
/// One item to ingest into the sim. The server pre-computes the slate via the
/// LM Studio embedding endpoint and hands the sim a fully-formed payload.
@@ -70,6 +72,23 @@ const GRAVITY_MAX_ACC: f32 = 30.0;
/// than the gravity threshold so weak co-residence doesn't link everyone.
const SYNAPSE_THRESHOLD: f32 = 0.62;
// ---- Stage 5: query broadcast tuning ----
/// Minimum cosine similarity for an engram to qualify as a Phase A responder.
/// Tuned for `nomic-embed-text-v1.5`: directly relevant text typically scores
/// 0.55+, weakly related 0.350.50, unrelated 0.30 or below.
const QUERY_PHASE_A_THRESHOLD: f32 = 0.45;
/// Synapse weight cutoff for Phase B 1-hop expansion. A synapse weaker than
/// this isn't strong enough evidence to drag its endpoint into the conversation.
const QUERY_PHASE_B_SYNAPSE_THRESHOLD: f32 = 0.55;
/// Fraction of `max_responders` reserved for Phase A (spatial). Phase B fills
/// whatever's left so synaptic expansion always gets at least *some* slots.
const QUERY_PHASE_A_FRAC: f32 = 0.75;
/// Per-responder duration of the "lit up" Conversing visual on the WS bus.
/// The orchestrator does NOT explicitly turn the responder back to Idle —
/// instead it schedules a deferred event after this delay, keeping the sim
/// authoritative for the transition (no client-side timers required).
const RESPONDER_LIGHTUP_MS: u64 = 4_000;
#[derive(Debug, Error)]
pub enum SimError {
#[error("simulation has shut down")]
@@ -104,6 +123,12 @@ enum SimCmd {
engram: EngramId,
reply: oneshot::Sender<Result<Option<EngramDetail>, SimError>>,
},
UpdateIntrospection {
galaxy: GalaxyId,
engram: EngramId,
introspection: Introspection,
reply: oneshot::Sender<Result<(), SimError>>,
},
Subscribe {
galaxy: GalaxyId,
reply: oneshot::Sender<Result<SubscribeReply, SimError>>,
@@ -113,6 +138,107 @@ enum SimCmd {
shape: GalaxyShape,
reply: oneshot::Sender<Result<GalaxyInfo, SimError>>,
},
/// Stage 5: materialize a pinned Query-Engram at the galaxy center.
/// Returns the new engram's id so the orchestrator can refer to it.
StartQueryEngram {
galaxy: GalaxyId,
query: QueryId,
text: String,
slate: Slate,
reply: oneshot::Sender<Result<EngramId, SimError>>,
},
/// Stage 5: run Phase A (cosine scan) + Phase B (1-hop synaptic) and
/// return the chosen responders sorted by score (high→low). Capped at
/// `max_responders`.
BroadcastQuery {
galaxy: GalaxyId,
query_engram: EngramId,
max_responders: usize,
reply: oneshot::Sender<Result<Vec<(EngramId, f32)>, SimError>>,
},
/// Stage 5: fetch the source text for a responder so the orchestrator
/// can build the per-responder LLM prompt. Returns `None` for synthetic
/// engrams (no manifest).
GetEngramText {
galaxy: GalaxyId,
engram: EngramId,
reply: oneshot::Sender<Result<Option<String>, SimError>>,
},
/// Stage 5: a responder produced a snippet for a query. Append a memory
/// on the responder, briefly flip its state to `Conversing` (auto-reverts
/// after `RESPONDER_LIGHTUP_MS`).
RecordQueryParticipation {
galaxy: GalaxyId,
query: QueryId,
engram: EngramId,
snippet: String,
reply: oneshot::Sender<Result<(), SimError>>,
},
/// Stage 5: publish a new running answer over the WS bus. The orchestrator
/// also pushes to its per-query SSE channel separately — this event is
/// only for clients that want to subscribe over the global galaxy bus.
PublishQueryAnswer {
galaxy: GalaxyId,
query: QueryId,
version: u32,
status: QueryStatus,
answer: String,
reply: oneshot::Sender<Result<(), SimError>>,
},
/// Stage 5: query is done. Transition the Query-Engram to `Memorize`,
/// store the final answer in its memory log, emit `QueryFinished`.
FinishQuery {
galaxy: GalaxyId,
query: QueryId,
query_engram: EngramId,
final_answer: String,
responder_count: u32,
reply: oneshot::Sender<Result<(), SimError>>,
},
/// Stage 6: snapshot every galaxy to a serializable form. Used by the
/// periodic snapshot task in the bin to write to the sled store. Pinned
/// (query) engrams are excluded — they're ephemeral.
SnapshotAll {
reply: oneshot::Sender<Vec<GalaxySnapshot>>,
},
/// Stage 6: re-create a galaxy in the world from a stored snapshot.
/// Re-uses every engram's stored `instance_idx` so the dense slot table
/// is identical to pre-shutdown. Re-schedules `BroadcastFrame`,
/// `RebuildIndex`, and one `EngramTick` per (non-pinned) engram so motion
/// resumes immediately. Returns `InvalidShape` if the snapshot's shape
/// fails validation.
HydrateGalaxy {
snapshot: GalaxySnapshot,
reply: oneshot::Sender<Result<GalaxyInfo, SimError>>,
},
/// Stage 6: remove a galaxy from the world. Caller is responsible for
/// also removing its on-disk snapshot (see `Store::delete_galaxy`).
DeleteGalaxy {
galaxy: GalaxyId,
reply: oneshot::Sender<Result<(), SimError>>,
},
/// Stage 7: cheap stats snapshot for the HUD. Returns total events
/// processed per galaxy in the last second + total engram count.
/// Polled at 1 Hz from the browser.
GetStats {
reply: oneshot::Sender<SimStats>,
},
}
/// Lightweight stats payload returned by `SimHandle::stats`. Used by the
/// HUD's "events/sec" + "engrams" counters. Numbers are best-effort
/// snapshots, not exact (the sim counts as it processes; the read happens
/// asynchronously).
#[derive(Debug, Clone, Default)]
pub struct SimStats {
/// Sum of ticks across all galaxies in the last 1-second window.
pub ticks_per_sec: u32,
/// Sum of all non-pinned engrams across galaxies.
pub engrams_total: u32,
/// Number of live galaxies.
pub galaxies: u32,
/// Sum of all synapses across galaxies.
pub synapses_total: u32,
}
#[derive(Clone)]
@@ -165,6 +291,22 @@ impl SimHandle {
rx.await.map_err(|_| SimError::Shutdown)?
}
/// Apply async-LLM-generated introspection to an existing engram. Called
/// from the server after a background introspection task completes.
pub async fn update_introspection(
&self,
galaxy: GalaxyId,
engram: EngramId,
introspection: Introspection,
) -> Result<(), SimError> {
let (reply, rx) = oneshot::channel();
self.tx
.send(SimCmd::UpdateIntrospection { galaxy, engram, introspection, reply })
.await
.map_err(|_| SimError::Shutdown)?;
rx.await.map_err(|_| SimError::Shutdown)?
}
/// Subscribe to a galaxy's event stream. Returns initial state plus the
/// live receiver so the WS client can replay then follow.
pub async fn subscribe(
@@ -190,6 +332,151 @@ impl SimHandle {
.map_err(|_| SimError::Shutdown)?;
rx.await.map_err(|_| SimError::Shutdown)?
}
/// Stage 5: materialize a Query-Engram at the galaxy center.
pub async fn start_query_engram(
&self,
galaxy: GalaxyId,
query: QueryId,
text: String,
slate: Slate,
) -> Result<EngramId, SimError> {
let (reply, rx) = oneshot::channel();
self.tx
.send(SimCmd::StartQueryEngram { galaxy, query, text, slate, reply })
.await
.map_err(|_| SimError::Shutdown)?;
rx.await.map_err(|_| SimError::Shutdown)?
}
/// Stage 5: select up to `max_responders` engrams whose slates resonate
/// with the query, optionally extending via 1-hop synaptic neighbours.
pub async fn broadcast_query(
&self,
galaxy: GalaxyId,
query_engram: EngramId,
max_responders: usize,
) -> Result<Vec<(EngramId, f32)>, SimError> {
let (reply, rx) = oneshot::channel();
self.tx
.send(SimCmd::BroadcastQuery { galaxy, query_engram, max_responders, reply })
.await
.map_err(|_| SimError::Shutdown)?;
rx.await.map_err(|_| SimError::Shutdown)?
}
/// Stage 5: fetch the source text of an engram (for building the
/// per-responder LLM prompt). Returns `None` for synthetic engrams.
pub async fn get_engram_text(
&self,
galaxy: GalaxyId,
engram: EngramId,
) -> Result<Option<String>, SimError> {
let (reply, rx) = oneshot::channel();
self.tx
.send(SimCmd::GetEngramText { galaxy, engram, reply })
.await
.map_err(|_| SimError::Shutdown)?;
rx.await.map_err(|_| SimError::Shutdown)?
}
/// Stage 5: record a responder's contribution + light it up.
pub async fn record_query_participation(
&self,
galaxy: GalaxyId,
query: QueryId,
engram: EngramId,
snippet: String,
) -> Result<(), SimError> {
let (reply, rx) = oneshot::channel();
self.tx
.send(SimCmd::RecordQueryParticipation { galaxy, query, engram, snippet, reply })
.await
.map_err(|_| SimError::Shutdown)?;
rx.await.map_err(|_| SimError::Shutdown)?
}
/// Stage 5: publish an updated running answer to WS subscribers.
pub async fn publish_query_answer(
&self,
galaxy: GalaxyId,
query: QueryId,
version: u32,
status: QueryStatus,
answer: String,
) -> Result<(), SimError> {
let (reply, rx) = oneshot::channel();
self.tx
.send(SimCmd::PublishQueryAnswer { galaxy, query, version, status, answer, reply })
.await
.map_err(|_| SimError::Shutdown)?;
rx.await.map_err(|_| SimError::Shutdown)?
}
/// Stage 5: terminal step — store the final answer in the Query-Engram's
/// memory log, transition to Memorize, emit QueryFinished.
pub async fn finish_query(
&self,
galaxy: GalaxyId,
query: QueryId,
query_engram: EngramId,
final_answer: String,
responder_count: u32,
) -> Result<(), SimError> {
let (reply, rx) = oneshot::channel();
self.tx
.send(SimCmd::FinishQuery {
galaxy,
query,
query_engram,
final_answer,
responder_count,
reply,
})
.await
.map_err(|_| SimError::Shutdown)?;
rx.await.map_err(|_| SimError::Shutdown)?
}
/// Stage 6: snapshot every galaxy. Used by the periodic snapshot task.
pub async fn snapshot_all(&self) -> Result<Vec<GalaxySnapshot>, SimError> {
let (reply, rx) = oneshot::channel();
self.tx
.send(SimCmd::SnapshotAll { reply })
.await
.map_err(|_| SimError::Shutdown)?;
rx.await.map_err(|_| SimError::Shutdown)
}
/// Stage 6: re-create a galaxy from a stored snapshot.
pub async fn hydrate_galaxy(&self, snapshot: GalaxySnapshot) -> Result<GalaxyInfo, SimError> {
let (reply, rx) = oneshot::channel();
self.tx
.send(SimCmd::HydrateGalaxy { snapshot, reply })
.await
.map_err(|_| SimError::Shutdown)?;
rx.await.map_err(|_| SimError::Shutdown)?
}
/// Stage 6: drop a galaxy from the world.
pub async fn delete_galaxy(&self, galaxy: GalaxyId) -> Result<(), SimError> {
let (reply, rx) = oneshot::channel();
self.tx
.send(SimCmd::DeleteGalaxy { galaxy, reply })
.await
.map_err(|_| SimError::Shutdown)?;
rx.await.map_err(|_| SimError::Shutdown)?
}
/// Stage 7: snapshot of recent activity for the HUD. Cheap — never
/// blocks the sim; falls back to defaults if the sim is shutting down.
pub async fn stats(&self) -> SimStats {
let (reply, rx) = oneshot::channel();
if self.tx.send(SimCmd::GetStats { reply }).await.is_err() {
return SimStats::default();
}
rx.await.unwrap_or_default()
}
}
/// Spawn the simulation task. `default_shape` is used for any new galaxy
@@ -203,10 +490,22 @@ pub fn spawn_sim(default_shape: GalaxyShape) -> SimHandle {
let mut indexes: std::collections::HashMap<GalaxyId, KiddoIndex> = Default::default();
let mut rng = SmallRng::seed_from_u64(0xC0DE_5071);
let started = Instant::now();
// Stage 7 stats: rolling 1-second tick counter. We bump
// `tick_window_count` for every event we drain, and roll it into
// `ticks_per_sec` once a second has elapsed since `tick_window_start`.
let mut tick_window_start = Instant::now();
let mut tick_window_count: u32 = 0;
let mut ticks_per_sec: u32 = 0;
loop {
// Pick whichever happens first: a new command or the next due event.
let now = Instant::now();
// Roll the tick window if a second has elapsed.
if now.duration_since(tick_window_start) >= Duration::from_secs(1) {
ticks_per_sec = tick_window_count;
tick_window_count = 0;
tick_window_start = now;
}
let next_at = scheduler.next_at();
let timeout = match next_at {
Some(at) => at.saturating_duration_since(now),
@@ -215,13 +514,17 @@ pub fn spawn_sim(default_shape: GalaxyShape) -> SimHandle {
tokio::select! {
cmd = rx.recv() => {
let Some(cmd) = cmd else { break }; // all handles dropped
handle_cmd(cmd, &mut world, &mut scheduler, &mut indexes, &mut rng, default_shape);
handle_cmd(
cmd, &mut world, &mut scheduler, &mut indexes,
&mut rng, default_shape, started, ticks_per_sec,
);
}
_ = tokio::time::sleep(timeout) => {
// Drain all due events.
let now = Instant::now();
while let Some(event) = scheduler.pop_due(now) {
handle_event(event, &mut world, &mut scheduler, &mut indexes, &mut rng, started);
tick_window_count = tick_window_count.saturating_add(1);
}
}
}
@@ -230,6 +533,7 @@ pub fn spawn_sim(default_shape: GalaxyShape) -> SimHandle {
SimHandle { tx }
}
#[allow(clippy::too_many_arguments)]
fn handle_cmd(
cmd: SimCmd,
world: &mut World,
@@ -237,6 +541,8 @@ fn handle_cmd(
indexes: &mut std::collections::HashMap<GalaxyId, KiddoIndex>,
_rng: &mut SmallRng,
default_shape: GalaxyShape,
started: Instant,
ticks_per_sec: u32,
) {
match cmd {
SimCmd::CreateGalaxy { name, reply } => {
@@ -280,6 +586,9 @@ fn handle_cmd(
manifest: e.manifest.clone(),
slate_dim: e.slate.as_ref().map(|s| s.dim()),
slate_norm: e.slate.as_ref().map(|s| s.norm()),
introspection: e.introspection.clone(),
memories: e.memories.iter().cloned().collect(),
pinned: e.pinned,
})
});
match detail {
@@ -291,6 +600,19 @@ fn handle_cmd(
}
}
}
SimCmd::UpdateIntrospection { galaxy, engram, introspection, reply } => {
let res = world
.galaxies
.get_mut(&galaxy)
.ok_or(SimError::UnknownGalaxy)
.and_then(|g| {
let e = g.engrams.get_mut(&engram).ok_or(SimError::UnknownGalaxy)?;
e.introspection = introspection;
push_memory(&mut e.memories, ms_since(started), MemoryKind::Introspected);
Ok(())
});
let _ = reply.send(res);
}
SimCmd::Subscribe { galaxy, reply } => {
let res = world
.galaxies
@@ -308,6 +630,186 @@ fn handle_cmd(
};
let _ = reply.send(res);
}
SimCmd::StartQueryEngram { galaxy, query, text, slate, reply } => {
let res = world
.galaxies
.get_mut(&galaxy)
.ok_or(SimError::UnknownGalaxy)
.map(|g| materialize_query_engram(g, galaxy, query, text, slate, started));
let _ = reply.send(res);
}
SimCmd::BroadcastQuery { galaxy, query_engram, max_responders, reply } => {
let res = world
.galaxies
.get(&galaxy)
.ok_or(SimError::UnknownGalaxy)
.map(|g| compute_query_responders(g, query_engram, max_responders));
let _ = reply.send(res);
}
SimCmd::GetEngramText { galaxy, engram, reply } => {
let res = world
.galaxies
.get(&galaxy)
.ok_or(SimError::UnknownGalaxy)
.map(|g| {
g.engrams.get(&engram).and_then(|e| {
e.manifest
.as_ref()
.map(|Manifest::Text { content }| content.clone())
})
});
let _ = reply.send(res);
}
SimCmd::RecordQueryParticipation { galaxy, query, engram, snippet, reply } => {
let res = world
.galaxies
.get_mut(&galaxy)
.ok_or(SimError::UnknownGalaxy)
.map(|g| {
if let Some(e) = g.engrams.get_mut(&engram) {
push_memory(
&mut e.memories,
ms_since(started),
MemoryKind::QueryParticipated { query, snippet },
);
e.state = EngramState::Conversing;
g.emit(SimEvent::EngramStateChanged {
id: engram,
state: EngramState::Conversing,
});
// Schedule the auto-revert. If the engram gets re-lit
// for a different query before this fires, the
// `expected` guard will skip the revert.
scheduler.schedule(
Instant::now() + Duration::from_millis(RESPONDER_LIGHTUP_MS),
Event::RevertState {
galaxy,
engram,
expected: EngramState::Conversing,
revert_to: EngramState::Idle,
},
);
}
});
let _ = reply.send(res);
}
SimCmd::PublishQueryAnswer { galaxy, query, version, status, answer, reply } => {
let res = world
.galaxies
.get(&galaxy)
.ok_or(SimError::UnknownGalaxy)
.map(|g| {
g.emit(SimEvent::QueryAnswerUpdated { query, version, status, answer });
});
let _ = reply.send(res);
}
SimCmd::FinishQuery {
galaxy, query, query_engram, final_answer, responder_count, reply,
} => {
let res = world
.galaxies
.get_mut(&galaxy)
.ok_or(SimError::UnknownGalaxy)
.map(|g| {
if let Some(e) = g.engrams.get_mut(&query_engram) {
e.state = EngramState::Memorize;
push_memory(
&mut e.memories,
ms_since(started),
MemoryKind::QueryAnswered {
answer: final_answer,
responder_count,
},
);
g.emit(SimEvent::EngramStateChanged {
id: query_engram,
state: EngramState::Memorize,
});
}
g.emit(SimEvent::QueryFinished {
query,
status: QueryStatus::Done,
responder_count,
});
});
let _ = reply.send(res);
}
SimCmd::SnapshotAll { reply } => {
let snaps = world
.galaxies
.values()
.map(|g| g.to_snapshot())
.collect();
let _ = reply.send(snaps);
}
SimCmd::HydrateGalaxy { snapshot, reply } => {
let res = match snapshot.galaxy.shape.validate() {
Err(msg) => Err(SimError::InvalidShape(msg)),
Ok(()) => {
let id = snapshot.galaxy.id;
let state = GalaxyState::from_snapshot(snapshot);
let info = state.info();
state.emit(state.torus_event());
// Re-schedule the periodic galaxy events first so they
// start firing at their normal cadence.
let now = Instant::now();
scheduler.schedule(now + FRAME_INTERVAL, Event::BroadcastFrame { galaxy: id });
scheduler.schedule(now + REBUILD_INTERVAL, Event::RebuildIndex { galaxy: id });
// One EngramTick per non-pinned engram. Stagger them
// across one tick interval so they don't all fire
// simultaneously and clobber the scheduler heap on
// boot — also gives the spatial index time to rebuild
// before gravity kicks in.
let n = state.engrams.len().max(1) as u64;
let stagger_step = TICK_INTERVAL.as_micros() as u64 / n.max(1);
for (i, engram_id) in state
.engrams
.keys()
.copied()
.enumerate()
{
let offset = Duration::from_micros(stagger_step * i as u64);
scheduler.schedule(
now + TICK_INTERVAL + offset,
Event::EngramTick { galaxy: id, engram: engram_id },
);
}
indexes.insert(id, KiddoIndex::empty());
world.galaxies.insert(id, state);
Ok(info)
}
};
let _ = reply.send(res);
}
SimCmd::DeleteGalaxy { galaxy, reply } => {
let res = if world.galaxies.remove(&galaxy).is_some() {
indexes.remove(&galaxy);
Ok(())
} else {
Err(SimError::UnknownGalaxy)
};
let _ = reply.send(res);
}
SimCmd::GetStats { reply } => {
let mut engrams_total: u32 = 0;
let mut synapses_total: u32 = 0;
for g in world.galaxies.values() {
// Pinned (query) engrams are excluded from the public count
// so the HUD doesn't blink during queries.
engrams_total += g
.engrams
.values()
.filter(|e| !e.pinned)
.count() as u32;
synapses_total += g.synapses.len() as u32;
}
let _ = reply.send(SimStats {
ticks_per_sec,
engrams_total,
galaxies: world.galaxies.len() as u32,
synapses_total,
});
}
}
}
@@ -387,6 +889,56 @@ fn ingest_galaxy(
Ok(ids)
}
/// Materialize a Query-Engram (Stage 5): pinned at the galaxy center with the
/// question text + slate, state `Searching`. Returns the new engram's id. No
/// `EngramTick` is scheduled — pinned engrams don't move and don't form
/// spontaneous synapses; their behaviour is driven entirely by the orchestrator.
fn materialize_query_engram(
g: &mut GalaxyState,
_galaxy: GalaxyId,
_query: QueryId,
text: String,
slate: Slate,
started: Instant,
) -> EngramId {
let id = EngramId::new();
let instance_idx = g.slot_to_id.len() as u32;
let position = g.galaxy.center;
let mut memories = VecDeque::new();
push_memory(&mut memories, ms_since(started), MemoryKind::Born);
let engram = Engram {
id,
instance_idx,
position,
velocity: Vec3::ZERO,
size: 1.0,
state: EngramState::Searching,
age: 0,
manifest: Some(Manifest::Text { content: text.clone() }),
slate: Some(slate),
introspection: Introspection::default(),
memories,
pinned: true,
};
g.slot_to_id.push(id);
g.engrams.insert(id, engram.clone());
let snapshot = EngramSnapshot {
id,
instance_idx,
position: position.to_array(),
size: engram.size,
state: engram.state,
};
g.emit(SimEvent::EngramCreated { snapshot });
g.emit(SimEvent::QueryStarted {
query: _query,
engram: id,
position: position.to_array(),
text,
});
id
}
/// Materialize one engram with the pre-allocated id at the galaxy's birth
/// point with a random initial velocity, emit `EngramCreated`, and schedule
/// its first `EngramTick`.
@@ -397,6 +949,7 @@ fn materialize_engram(
payload: SpawnPayload,
scheduler: &mut Scheduler,
rng: &mut SmallRng,
started: Instant,
) {
let instance_idx = g.slot_to_id.len() as u32;
let birth = g.galaxy.shape.birth_point(g.galaxy.center);
@@ -406,6 +959,8 @@ fn materialize_engram(
SpawnPayload::Synthetic => (None, None),
SpawnPayload::Manifested { manifest, slate } => (Some(manifest), Some(slate)),
};
let mut memories = VecDeque::new();
push_memory(&mut memories, ms_since(started), MemoryKind::Born);
let engram = Engram {
id,
instance_idx,
@@ -416,6 +971,9 @@ fn materialize_engram(
age: 0,
manifest,
slate,
introspection: Introspection::default(),
memories,
pinned: false,
};
g.slot_to_id.push(id);
g.engrams.insert(id, engram.clone());
@@ -433,6 +991,19 @@ fn materialize_engram(
);
}
/// Append a memory to the front-bounded VecDeque, evicting the oldest entry
/// once `MAX_MEMORIES` is reached.
fn push_memory(memories: &mut VecDeque<Memory>, at_ms: u32, kind: MemoryKind) {
while memories.len() >= MAX_MEMORIES {
memories.pop_front();
}
memories.push_back(Memory { at_ms, kind });
}
fn ms_since(started: Instant) -> u32 {
started.elapsed().as_millis() as u32
}
/// Walk the kiddo index for `engram_id`'s neighbours and return:
/// - `gravity_acc`: cosine-weighted attraction toward similar peers (only
/// contributes inside the tube; physics gates this further by position).
@@ -492,6 +1063,97 @@ fn compute_gravity_and_candidates(
(acc, candidates)
}
/// Stage 5 broadcast retrieval. Two passes:
///
/// **Phase A — global cosine scan.** Score every non-pinned engram's slate
/// against the query, keep those above `QUERY_PHASE_A_THRESHOLD`, take the
/// top `QUERY_PHASE_A_FRAC * max_responders` by score.
///
/// **Phase B — 1-hop synaptic expansion.** For each Phase A pick, walk its
/// synapses and add any neighbour we haven't already chosen. The neighbour's
/// effective score is `cosine_to_query * synapse_weight` (synaptic
/// attenuation per the system-analysis doc). Fill the remaining
/// `max_responders - phase_a` slots in score-sorted order.
///
/// We do NOT use the spatial kiddo index here. Query-Engrams sit at the
/// galaxy center while normal engrams cluster in the tube ~major_radius
/// away — a single radius doesn't cover both. A linear scan over engrams
/// is fine at MVP scale (a few thousand) and avoids per-query index
/// rebuilds. The kiddo index stays where it earns its keep: per-tick
/// gravity for engrams already in the tube.
fn compute_query_responders(
g: &GalaxyState,
query_engram: EngramId,
max_responders: usize,
) -> Vec<(EngramId, f32)> {
if max_responders == 0 {
return Vec::new();
}
let Some(q) = g.engrams.get(&query_engram) else { return Vec::new(); };
let Some(q_slate) = q.slate.as_ref() else { return Vec::new(); };
// Phase A.
let mut scored: Vec<(EngramId, f32)> = g
.engrams
.values()
.filter(|e| e.id != query_engram && !e.pinned)
.filter_map(|e| {
let s = e.slate.as_ref()?;
let cos = q_slate.cosine(s);
(cos >= QUERY_PHASE_A_THRESHOLD).then_some((e.id, cos))
})
.collect();
scored.sort_by(|a, b| {
b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)
});
let phase_a_budget = ((max_responders as f32) * QUERY_PHASE_A_FRAC).ceil() as usize;
let phase_a_budget = phase_a_budget.min(max_responders).max(1);
let mut chosen: Vec<(EngramId, f32)> = scored.into_iter().take(phase_a_budget).collect();
let mut chosen_set: HashSet<EngramId> =
chosen.iter().map(|(id, _)| *id).collect();
// Always exclude the query-engram itself from synaptic expansion just in
// case some legacy synapse pointed at it.
chosen_set.insert(query_engram);
// Phase B: build adjacency once, then walk.
let phase_b_budget = max_responders.saturating_sub(chosen.len());
if phase_b_budget == 0 || g.synapses.is_empty() {
return chosen;
}
let mut adjacency: HashMap<EngramId, Vec<(EngramId, f32)>> =
HashMap::with_capacity(g.engrams.len());
for syn in g.synapses.values() {
if syn.weight < QUERY_PHASE_B_SYNAPSE_THRESHOLD {
continue;
}
adjacency.entry(syn.a).or_default().push((syn.b, syn.weight));
adjacency.entry(syn.b).or_default().push((syn.a, syn.weight));
}
let mut additions: Vec<(EngramId, f32)> = Vec::new();
for (seed_id, _) in chosen.iter() {
if let Some(neighbours) = adjacency.get(seed_id) {
for (neighbour, weight) in neighbours {
if !chosen_set.insert(*neighbour) {
continue;
}
let Some(n) = g.engrams.get(neighbour) else { continue; };
if n.pinned {
continue;
}
let Some(n_slate) = n.slate.as_ref() else { continue; };
let effective = q_slate.cosine(n_slate) * weight;
additions.push((*neighbour, effective));
}
}
}
// Take the strongest Phase B additions to fill remaining slots.
additions.sort_by(|a, b| {
b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)
});
chosen.extend(additions.into_iter().take(phase_b_budget));
chosen
}
/// Closest point on the torus centerline to `p` in xy-plane (mirrors the
/// math in `physics::spine_point` but doesn't pull `physics` in here).
fn nearest_spine_xy(p: Vec3, major_r: f32) -> Vec3 {
@@ -515,7 +1177,7 @@ fn handle_event(
match event {
Event::Spawn { galaxy, id, payload } => {
let Some(g) = world.galaxies.get_mut(&galaxy) else { return };
materialize_engram(g, galaxy, id, payload, scheduler, rng);
materialize_engram(g, galaxy, id, payload, scheduler, rng, started);
}
Event::EngramTick { galaxy, engram } => {
let Some(g) = world.galaxies.get_mut(&galaxy) else { return };
@@ -535,11 +1197,27 @@ fn handle_event(
return;
}
// After the tick, materialise any qualifying synapses and emit
// events for the newly-formed ones.
// After the tick, materialise any qualifying synapses, emit
// events for the newly-formed ones, and append a memory entry
// on both endpoints so the inspector can show them.
for (peer, weight) in synapse_candidates {
if let Some(syn) = g.try_form_synapse(engram, peer, weight) {
g.emit(SimEvent::SynapseCreated { synapse: SynapseDto::from(&syn) });
let now_ms = ms_since(started);
if let Some(e_a) = g.engrams.get_mut(&syn.a) {
push_memory(
&mut e_a.memories,
now_ms,
MemoryKind::SynapseFormed { with: syn.b, weight },
);
}
if let Some(e_b) = g.engrams.get_mut(&syn.b) {
push_memory(
&mut e_b.memories,
now_ms,
MemoryKind::SynapseFormed { with: syn.a, weight },
);
}
}
}
@@ -568,11 +1246,14 @@ fn handle_event(
}
Event::RebuildIndex { galaxy } => {
// Index rebuild only — no bbox recompute (the torus is fixed-size,
// resized only via SimCmd::Resize).
// resized only via SimCmd::Resize). Pinned (query) engrams are
// skipped: the index drives gravity + per-tick neighbour search,
// and queries don't participate in either.
if let Some(g) = world.galaxies.get(&galaxy) {
let points: Vec<(EngramId, [f32; 3])> = g
.engrams
.values()
.filter(|e| !e.pinned)
.map(|e| (e.id, e.position.to_array()))
.collect();
if let Some(idx) = indexes.get_mut(&galaxy) {
@@ -584,5 +1265,15 @@ fn handle_event(
Event::RebuildIndex { galaxy },
);
}
Event::RevertState { galaxy, engram, expected, revert_to } => {
if let Some(g) = world.galaxies.get_mut(&galaxy) {
if let Some(e) = g.engrams.get_mut(&engram) {
if e.state == expected {
e.state = revert_to;
g.emit(SimEvent::EngramStateChanged { id: engram, state: revert_to });
}
}
}
}
}
}

View File

@@ -9,4 +9,4 @@ mod physics;
mod scheduler;
mod world;
pub use handle::{spawn_sim, IngestItem, SimError, SimHandle};
pub use handle::{spawn_sim, IngestItem, SimError, SimHandle, SimStats};

View File

@@ -75,6 +75,14 @@ pub fn tick(
gravity_acc: Vec3,
rng: &mut SmallRng,
) {
// Pinned engrams (Stage 5 Query-Engrams) sit perfectly still at their
// birth position — skip the entire integration. Age still advances so
// memory timestamps and any future age-gated logic stay consistent.
if engram.pinned {
engram.age = engram.age.saturating_add(1);
return;
}
let curiosity_factor = (-(engram.age as f32) / CURIOSITY_TAU_TICKS).exp();
// Curiosity: random impulse, decaying with age.

View File

@@ -4,7 +4,7 @@ use std::cmp::Ordering;
use std::collections::BinaryHeap;
use std::time::Instant;
use sophia_core::{EngramId, GalaxyId, Manifest, Slate};
use sophia_core::{EngramId, EngramState, GalaxyId, Manifest, Slate};
/// Payload for a queued spawn. Synthetic seed engrams have no manifest/slate;
/// ingested engrams carry the pre-embedded text.
@@ -28,6 +28,19 @@ pub enum Event {
/// Recompute density-driven bbox + rebuild spatial index. Fires every
/// ~500 ms and reschedules itself.
RebuildIndex { galaxy: GalaxyId },
/// Stage 5: revert an engram's state (typically Conversing → Idle)
/// after a responder's light-up window expires. Fired once and not
/// rescheduled. The `expected` state guards against racing transitions
/// — only revert if the engram is still in the state the lit-up event
/// originally set (the orchestrator may light up the same engram twice
/// for two queries; we don't want the older revert to clobber a fresher
/// one).
RevertState {
galaxy: GalaxyId,
engram: EngramId,
expected: EngramState,
revert_to: EngramState,
},
}
#[derive(Debug)]

View File

@@ -4,7 +4,7 @@ use std::collections::{HashMap, HashSet};
use sophia_core::{
canonical_pair, Engram, EngramId, EngramSnapshot, Galaxy, GalaxyId, GalaxyInfo, GalaxyShape,
SimEvent, Synapse, SynapseDto, SynapseId,
GalaxySnapshot, SimEvent, Synapse, SynapseDto, SynapseId,
};
use tokio::sync::broadcast;
@@ -120,6 +120,82 @@ impl GalaxyState {
minor_radius: self.galaxy.shape.minor_radius,
}
}
/// Serialize the persistent half of this galaxy's state. Pinned engrams
/// (Stage 5 Query-Engrams) are dropped — queries are ephemeral and
/// shouldn't outlive the orchestrator that owns them. Synapses that
/// pointed at a pinned engram (none currently exist; future-proofing)
/// are also dropped.
pub fn to_snapshot(&self) -> GalaxySnapshot {
let engrams: Vec<Engram> = self
.slot_to_id
.iter()
.filter_map(|id| self.engrams.get(id))
.filter(|e| !e.pinned)
.cloned()
.collect();
let kept_ids: HashSet<EngramId> = engrams.iter().map(|e| e.id).collect();
let synapses: Vec<Synapse> = self
.synapses
.values()
.filter(|s| kept_ids.contains(&s.a) && kept_ids.contains(&s.b))
.cloned()
.collect();
GalaxySnapshot {
galaxy: self.galaxy.clone(),
engrams,
synapses,
}
}
/// Re-instantiate a galaxy from a stored snapshot. Re-uses each engram's
/// stored `instance_idx` so the dense slot table matches pre-shutdown
/// (the WS PositionFrame slot ordering depends on it). The caller (sim
/// loop) is responsible for re-scheduling per-engram ticks and the
/// recurring `BroadcastFrame` / `RebuildIndex` events.
pub fn from_snapshot(snap: GalaxySnapshot) -> Self {
let (bus, _) = broadcast::channel(BROADCAST_CAPACITY);
let mut engrams: HashMap<EngramId, Engram> = HashMap::with_capacity(snap.engrams.len());
let mut max_idx: u32 = 0;
for e in &snap.engrams {
if e.instance_idx + 1 > max_idx {
max_idx = e.instance_idx + 1;
}
}
// Build a dense slot_to_id keyed by the stored instance_idx. Any
// gaps (shouldn't happen but defensive) get a placeholder UUID that
// will simply never resolve to an engram in `engrams.get(...)`,
// keeping the indexing math correct.
let mut slot_to_id: Vec<EngramId> = vec![EngramId::default(); max_idx as usize];
for e in snap.engrams {
let idx = e.instance_idx as usize;
if idx < slot_to_id.len() {
slot_to_id[idx] = e.id;
}
engrams.insert(e.id, e);
}
let mut synapses: HashMap<SynapseId, Synapse> = HashMap::with_capacity(snap.synapses.len());
let mut synapse_pairs: HashSet<(EngramId, EngramId)> = HashSet::with_capacity(snap.synapses.len());
let mut synapse_count: HashMap<EngramId, usize> = HashMap::new();
for syn in snap.synapses {
let pair = canonical_pair(syn.a, syn.b);
if !synapse_pairs.insert(pair) {
continue;
}
*synapse_count.entry(pair.0).or_insert(0) += 1;
*synapse_count.entry(pair.1).or_insert(0) += 1;
synapses.insert(syn.id, syn);
}
Self {
galaxy: snap.galaxy,
engrams,
slot_to_id,
synapses,
synapse_pairs,
synapse_count,
bus,
}
}
}
pub struct World {

View File

@@ -10,5 +10,8 @@ authors.workspace = true
sophia-core = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sled = { workspace = true }

View File

@@ -1,7 +1,109 @@
//! Persistence: sled-backed event log + snapshot.
//! Persistence: sled-backed snapshot store (Stage 6).
//!
//! See `docs/system-analysis.md` §13.3 (event-sourced + graph read model).
//! Per the doc: "the event log == the canonical memory store" — anything
//! memorable is persisted; transient state is not.
//! Each galaxy is one key in a sled `Db`: key = the galaxy's UUID bytes,
//! value = a JSON-encoded `GalaxySnapshot`. The bin's snapshot task
//! overwrites the value every 60 s; on boot we iterate the tree, decode
//! each entry, and hand them to the sim for hydration.
//!
//! Stage 0: stub. Real content arrives in Stage 6.
//! **Why JSON, not bincode**: `Manifest` and `MemoryKind` are
//! internally-tagged enums (`#[serde(tag = "kind")]`). Internally tagged
//! enums require a self-describing format on the wire — bincode calls
//! `deserialize_any` to read the tag, which it doesn't support. JSON is
//! self-describing and handles them natively. At MVP scale (a few hundred
//! engrams) the verbosity is irrelevant; if storage grows we can swap in
//! msgpack/cbor without touching the sim.
//!
//! See `docs/system-analysis.md` §13.3: the design calls for an event log
//! with periodic snapshots, but per the same section "no
//! event-replay-from-genesis." For MVP that means we only ever need the
//! snapshot side — no event log, no per-event append. If the model later
//! needs time-travel debugging we can add the log without disturbing the
//! snapshot path (different sled tree).
//!
//! Sled defaults to `flush_every_ms = 500`, so even a hard kill loses at
//! most ~half a second of writes — acceptable for snapshot data that's
//! already 60 s stale by definition.
use std::path::Path;
use sophia_core::{GalaxyId, GalaxySnapshot};
use thiserror::Error;
use tracing::{debug, warn};
#[derive(Debug, Error)]
pub enum StoreError {
#[error("sled error: {0}")]
Sled(#[from] sled::Error),
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
}
/// Cheaply cloneable handle to the on-disk store. Internally holds a
/// `sled::Db`; cloning shares the same database connection.
#[derive(Clone)]
pub struct Store {
db: sled::Db,
}
impl Store {
/// Open or create the store at `path`. The path is treated as a
/// directory — sled creates it (and the sled internal files inside) if
/// needed.
pub fn open(path: impl AsRef<Path>) -> Result<Self, StoreError> {
let db = sled::open(path.as_ref())?;
Ok(Self { db })
}
/// Iterate every stored galaxy snapshot. Decode failures are logged and
/// skipped — a corrupt entry shouldn't take down the boot path.
pub fn list_galaxies(&self) -> Result<Vec<GalaxySnapshot>, StoreError> {
let mut out = Vec::new();
for kv in self.db.iter() {
let (k, v) = kv?;
match serde_json::from_slice::<GalaxySnapshot>(&v) {
Ok(snap) => out.push(snap),
Err(e) => {
warn!("skipping corrupt galaxy entry (key={} bytes): {e}", k.len());
}
}
}
Ok(out)
}
/// Insert or overwrite the stored snapshot for one galaxy. Sled flushes
/// asynchronously (`flush_every_ms`); we don't `flush()` here because
/// snapshot writes happen on a 60 s cadence and a half-second of in-mem
/// buffering is fine.
pub fn save_galaxy(&self, snapshot: &GalaxySnapshot) -> Result<(), StoreError> {
let bytes = serde_json::to_vec(snapshot)?;
self.db.insert(galaxy_key(snapshot.galaxy.id), bytes)?;
debug!(
"saved galaxy {:?} ({} engrams, {} synapses)",
snapshot.galaxy.id,
snapshot.engrams.len(),
snapshot.synapses.len()
);
Ok(())
}
/// Remove a galaxy from disk. Idempotent — missing keys are not an error.
pub fn delete_galaxy(&self, id: GalaxyId) -> Result<(), StoreError> {
self.db.remove(galaxy_key(id))?;
Ok(())
}
/// Force a synchronous flush. Called on graceful shutdown (and useful
/// in tests). Otherwise sled's background flusher is sufficient.
pub fn flush(&self) -> Result<(), StoreError> {
self.db.flush()?;
Ok(())
}
}
/// 16-byte key for one galaxy. Using the raw UUID bytes (rather than the
/// hyphenated string) keeps the key compact and lexicographically equivalent
/// to UUID v7's time order — which makes `Db::iter()` walk galaxies in
/// creation order without us doing any sorting.
fn galaxy_key(id: GalaxyId) -> [u8; 16] {
*id.0.as_bytes()
}

View File

@@ -94,6 +94,127 @@
}
#ingest button:hover { background: #232c36; }
#ingest button:disabled { opacity: 0.5; cursor: progress; }
#query {
position: fixed;
bottom: 12px;
left: 12px;
width: 380px;
background: rgba(0,0,0,0.55);
padding: 10px;
border-radius: 4px;
font-size: 12px;
}
#query-form { display: flex; gap: 6px; }
#query-input {
flex: 1;
font: inherit;
background: #0c1014;
color: #cfd6df;
border: 1px solid #2a323d;
border-radius: 3px;
padding: 5px 8px;
}
#query-submit {
font: inherit;
background: #1a2129;
color: #cfd6df;
border: 1px solid #2a323d;
padding: 4px 12px;
border-radius: 3px;
cursor: pointer;
}
#query-submit:hover { background: #232c36; }
#query-submit:disabled { opacity: 0.5; cursor: progress; }
#query-status {
margin-top: 6px;
opacity: 0.55;
font-size: 11px;
min-height: 1.2em;
}
#query-answer {
margin-top: 4px;
white-space: pre-wrap;
line-height: 1.45;
max-height: 180px;
overflow-y: auto;
padding: 6px 8px;
background: rgba(255,255,255,0.03);
border-radius: 3px;
min-height: 2em;
}
#query-answer:empty::before {
content: "ask what your knowledge knows…";
opacity: 0.4;
}
#inspector {
position: fixed;
top: 12px;
left: 50%;
transform: translateX(-50%);
width: 460px;
max-height: 70vh;
overflow-y: auto;
background: rgba(0,0,0,0.78);
padding: 12px 14px;
border-radius: 4px;
border: 1px solid #2a323d;
font-size: 12px;
line-height: 1.45;
display: none;
}
#inspector.open { display: block; }
#inspector h3 {
margin: 0 0 6px;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.06em;
opacity: 0.6;
font-weight: 500;
}
#inspector .field { margin-bottom: 8px; }
#inspector .label {
opacity: 0.45;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 2px;
}
#inspector .value { color: #e1e6ec; }
#inspector .muted { color: #6b7480; }
#inspector ul {
margin: 2px 0 0 14px;
padding: 0;
}
#inspector ul li { margin-bottom: 1px; }
#inspector .row {
display: flex;
justify-content: space-between;
align-items: center;
}
#inspector .close {
cursor: pointer;
background: transparent;
border: none;
color: #cfd6df;
font-size: 16px;
line-height: 1;
padding: 0 4px;
opacity: 0.7;
}
#inspector .close:hover { opacity: 1; }
#inspector .manifest {
white-space: pre-wrap;
max-height: 8em;
overflow-y: auto;
font-size: 11px;
opacity: 0.85;
}
#inspector .id {
font-family: ui-monospace, monospace;
font-size: 10px;
opacity: 0.5;
word-break: break-all;
}
</style>
</head>
<body>
@@ -105,6 +226,9 @@
<div><span class="k">torus</span> <span id="hud-torus"></span></div>
<div><span class="k">engrams</span> <span id="hud-count">0</span></div>
<div><span class="k">synapses</span> <span id="hud-synapses">0</span></div>
<div><span class="k">sim</span> <span id="hud-ticks"></span></div>
<div><span class="k">llm queue</span> <span id="hud-llm-queue"></span></div>
<div><span class="k">fps</span> <span id="hud-fps"></span></div>
<div><span class="k">lm studio</span> <span id="hud-lm"></span></div>
<div><span class="k">health</span> <span id="hud-health"></span></div>
</div>
@@ -119,6 +243,27 @@
<button id="resize-btn">resize</button>
</div>
</div>
<div id="inspector">
<div class="row">
<h3>engram</h3>
<button class="close" id="inspector-close" title="close">×</button>
</div>
<div class="field id" id="inspector-id"></div>
<div class="field"><span class="label">state</span><div class="value" id="inspector-state"></div></div>
<div class="field"><span class="label">manifest</span><div class="manifest" id="inspector-manifest"></div></div>
<div class="field"><span class="label">taxonomy</span><div class="value" id="inspector-taxonomy"></div></div>
<div class="field"><span class="label">goals</span><div class="value" id="inspector-goals"></div></div>
<div class="field"><span class="label">open questions</span><div class="value" id="inspector-questions"></div></div>
<div class="field"><span class="label">memories</span><div class="value" id="inspector-memories"></div></div>
</div>
<div id="query">
<form id="query-form">
<input id="query-input" type="text" placeholder="What did we ingest about…?" autocomplete="off" />
<button id="query-submit" type="submit">ask</button>
</form>
<div id="query-status"></div>
<div id="query-answer"></div>
</div>
<div id="ingest">
<textarea id="ingest-text" placeholder="One engram per line. Empty lines are skipped.&#10;e.g.&#10;Pasta carbonara uses guanciale, eggs, pecorino, and pepper.&#10;Backpropagation computes gradients of a loss with respect to weights.&#10;The fall of Constantinople occurred in 1453."></textarea>
<div class="row">

View File

@@ -105,6 +105,18 @@ export class EngramMesh {
/** Optional hook fired when an engram's base color is set/updated. The
* trail renderer subscribes so head + tail share the same colour. */
public onColorAssigned: ((idx: number, r: number, g: number, b: number) => void) | null = null;
/** Reverse lookup: instance_idx → engram UUID. Filled on upsert; used by
* the click-picker to map a raycast hit back to an engram id. */
public readonly idxToId: string[] = [];
/** Forward lookup: engram UUID → instance_idx. Mirrors `idxToId` so live
* state-change events (which carry the UUID, not the slot index) can find
* the right vertex to re-paint. */
private readonly idToIdx = new Map<string, number>();
/** Expose the underlying `THREE.Points` so the scene can raycast against it. */
pointsObject(): THREE.Points {
return this.points;
}
constructor(scene: THREE.Scene, capacity = 5000) {
this.capacity = capacity;
@@ -125,6 +137,12 @@ export class EngramMesh {
geom.setAttribute("aSize", this.sizeAttr);
geom.setAttribute("aHash", this.hashAttr);
geom.setDrawRange(0, 0);
// Set a permanent oversized bounding sphere. Without this, three.js
// computes one once based on the initial all-zero positions (radius 0)
// and Points.raycast() short-circuits — every click misses. Recomputing
// per frame is expensive; a giant fixed sphere always passes the early
// reject and the per-vertex test then runs normally.
geom.boundingSphere = new THREE.Sphere(new THREE.Vector3(0, 0, 0), 100_000);
this.material = new THREE.ShaderMaterial({
vertexShader: VERT_SHADER,
@@ -197,6 +215,28 @@ export class EngramMesh {
if (idx + 1 > this.maxIdx) this.maxIdx = idx + 1;
this.points.geometry.setDrawRange(0, this.maxIdx);
this.idxToId[idx] = e.id;
this.idToIdx.set(e.id, idx);
}
/**
* Re-paint the dot for an existing engram when its lifecycle state
* changes (Stage 5: responder lights up to Conversing while answering a
* query, Query-Engram transitions Searching→Memorize at completion).
* Silently ignored if the engram isn't known yet — state-change events
* for unfamiliar ids can race ahead of the corresponding `engram_created`
* over the WS bus during a reconnect window.
*/
setStateById(id: string, state: string): void {
const idx = this.idToIdx.get(id);
if (idx === undefined) return;
const color = STATE_COLOR[state] ?? STATE_COLOR.idle;
const colorArr = this.colorAttr.array as Float32Array;
colorArr[idx * 3] = color[0];
colorArr[idx * 3 + 1] = color[1];
colorArr[idx * 3 + 2] = color[2];
this.colorAttr.needsUpdate = true;
this.onColorAssigned?.(idx, color[0], color[1], color[2]);
}
applyPositionFrame(frame: PositionFrame): void {

179
web/src/inspector.ts Normal file
View File

@@ -0,0 +1,179 @@
import * as THREE from "three";
import type { EngramMesh } from "./engram_mesh";
/**
* Click-to-inspect: raycast the mouse into the engram point-cloud, fetch
* `/api/galaxy/:gid/engrams/:eid` for the picked engram, and render the
* result in the side panel. Re-clicking an already-open engram refreshes
* the panel (handy because introspection arrives async after birth).
*/
type EngramDetail = {
id: string;
instance_idx: number;
position: [number, number, number];
size: number;
state: string;
age: number;
manifest: { kind: string; content?: string } | null;
slate_dim: number | null;
slate_norm: number | null;
introspection: {
taxonomy: string[];
goals: string[];
open_questions: string[];
};
memories: Array<{
at_ms: number;
kind: { kind: string; with?: string; weight?: number };
}>;
};
// World-space radius around each engram vertex that counts as a "hit" when
// raycasting. Has to be generous because the rendered dot is enlarged by
// bloom + sprite scaling — the user expects to be able to click anywhere
// the dot *appears*, not the exact 1-unit vertex position.
const PICK_THRESHOLD = 12;
// Pixels of cursor movement between mousedown and mouseup that still count
// as a "click" rather than a drag. Trackpads in particular need slack.
const CLICK_DRAG_TOLERANCE_PX = 8;
const panel = document.getElementById("inspector");
const idEl = document.getElementById("inspector-id");
const stateEl = document.getElementById("inspector-state");
const manifestEl = document.getElementById("inspector-manifest");
const taxonomyEl = document.getElementById("inspector-taxonomy");
const goalsEl = document.getElementById("inspector-goals");
const questionsEl = document.getElementById("inspector-questions");
const memoriesEl = document.getElementById("inspector-memories");
const closeBtn = document.getElementById("inspector-close");
closeBtn?.addEventListener("click", () => {
panel?.classList.remove("open");
});
let lastPickedId: string | null = null;
function shortId(id: string): string {
return id.length > 12 ? `${id.slice(0, 8)}${id.slice(-4)}` : id;
}
function bullets(items: string[]): string {
if (!items || items.length === 0) return `<span class="muted">…awaiting LLM…</span>`;
return `<ul>${items.map((s) => `<li>${escapeHtml(s)}</li>`).join("")}</ul>`;
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function fmtMemories(mems: EngramDetail["memories"]): string {
if (!mems || mems.length === 0) return `<span class="muted">none yet</span>`;
const items = mems.map((m) => {
const t = (m.at_ms / 1000).toFixed(1) + "s";
let body: string;
switch (m.kind.kind) {
case "born":
body = "born";
break;
case "introspected":
body = "introspected (LLM)";
break;
case "synapse_formed":
body = `${shortId(m.kind.with ?? "")} (sim ${(m.kind.weight ?? 0).toFixed(2)})`;
break;
default:
body = m.kind.kind;
}
return `<li><span class="muted">+${t}</span> ${escapeHtml(body)}</li>`;
});
return `<ul>${items.join("")}</ul>`;
}
function render(d: EngramDetail): void {
if (!panel) return;
if (idEl) idEl.textContent = d.id;
if (stateEl) stateEl.textContent = `${d.state} · age ${d.age} · slot ${d.instance_idx}`;
if (manifestEl) {
manifestEl.textContent = d.manifest?.content ?? "(synthetic seed — no manifest)";
}
if (taxonomyEl) taxonomyEl.innerHTML = bullets(d.introspection?.taxonomy ?? []);
if (goalsEl) goalsEl.innerHTML = bullets(d.introspection?.goals ?? []);
if (questionsEl) questionsEl.innerHTML = bullets(d.introspection?.open_questions ?? []);
if (memoriesEl) memoriesEl.innerHTML = fmtMemories(d.memories ?? []);
panel.classList.add("open");
}
async function fetchAndRender(galaxyId: string, engramId: string): Promise<void> {
try {
const res = await fetch(`/api/galaxy/${galaxyId}/engrams/${engramId}`);
if (!res.ok) {
console.warn(`engram fetch ${res.status}`);
return;
}
const detail = (await res.json()) as EngramDetail;
render(detail);
} catch (e) {
console.warn("engram fetch failed", e);
}
}
/**
* Wire up click-picking on the canvas. The engram point cloud is already in
* the scene; we raycast against it and look up the engram id from the hit
* vertex index.
*/
export function attachInspector(opts: {
canvas: HTMLCanvasElement;
camera: THREE.Camera;
engrams: EngramMesh;
galaxyId: string;
}): void {
const raycaster = new THREE.Raycaster();
raycaster.params.Points = { threshold: PICK_THRESHOLD };
const mouse = new THREE.Vector2();
let downX = 0, downY = 0;
// Use mousedown + mouseup with a tiny movement tolerance so dragging the
// OrbitControls doesn't trigger a pick.
opts.canvas.addEventListener("mousedown", (ev) => {
downX = ev.clientX;
downY = ev.clientY;
});
opts.canvas.addEventListener("mouseup", (ev) => {
const moved = Math.hypot(ev.clientX - downX, ev.clientY - downY);
if (moved > CLICK_DRAG_TOLERANCE_PX) return;
const rect = opts.canvas.getBoundingClientRect();
mouse.x = ((ev.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((ev.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, opts.camera);
const hits = raycaster.intersectObject(opts.engrams.pointsObject(), false);
console.debug(`[inspector] click ndc=(${mouse.x.toFixed(2)},${mouse.y.toFixed(2)}) hits=${hits.length}`);
if (hits.length === 0) return;
// Pick the closest hit.
const hit = hits[0];
const idx = hit.index;
if (idx === undefined) return;
const id = opts.engrams.idxToId[idx];
if (!id) {
console.warn(`[inspector] hit at idx ${idx} has no engram id`);
return;
}
console.debug(`[inspector] picked engram ${id}`);
lastPickedId = id;
void fetchAndRender(opts.galaxyId, id);
});
// Light auto-refresh — re-fetch the currently-open engram every 3s so the
// user sees introspection populate and memories grow without re-clicking.
setInterval(() => {
if (!panel?.classList.contains("open")) return;
if (!lastPickedId) return;
void fetchAndRender(opts.galaxyId, lastPickedId);
}, 3000);
}

View File

@@ -11,6 +11,9 @@ const ingestBtn = document.getElementById("ingest-btn") as HTMLButtonElement | n
const ingestText = document.getElementById("ingest-text") as HTMLTextAreaElement | null;
const torusEl = document.getElementById("hud-torus");
const synapsesEl = document.getElementById("hud-synapses");
const ticksEl = document.getElementById("hud-ticks");
const llmQueueEl = document.getElementById("hud-llm-queue");
const fpsEl = document.getElementById("hud-fps");
const resizeBtn = document.getElementById("resize-btn") as HTMLButtonElement | null;
const majorInput = document.getElementById("resize-major") as HTMLInputElement | null;
const minorInput = document.getElementById("resize-minor") as HTMLInputElement | null;
@@ -93,8 +96,36 @@ async function bootstrap(): Promise<void> {
if (minorInput && document.activeElement !== minorInput) minorInput.value = String(minorR);
},
onSynapses: (n) => { if (synapsesEl) synapsesEl.textContent = String(n); },
onFps: (fps) => { if (fpsEl) fpsEl.textContent = fps.toFixed(0); },
});
// Poll /api/stats once a second for sim ticks/sec + LLM queue depth.
// The HUD numbers it drives (sim, llm queue) are server-side state, so
// the browser can't compute them locally — keep this loop lightweight
// and silent on transient fetch errors so a brief network hiccup doesn't
// pollute the console.
type StatsResponse = {
ticks_per_sec: number;
engrams_total: number;
synapses_total: number;
galaxies: number;
llm_queue_depth: number;
llm_parallel_cap: number;
};
setInterval(async () => {
try {
const r = await fetch("/api/stats");
if (!r.ok) return;
const s = (await r.json()) as StatsResponse;
if (ticksEl) ticksEl.textContent = `${s.ticks_per_sec}/s`;
if (llmQueueEl) {
llmQueueEl.textContent = `${s.llm_queue_depth} / ${s.llm_parallel_cap}`;
}
} catch {
// ignore — server might be restarting
}
}, 1000);
if (seedBtn && seedNInput) {
seedBtn.addEventListener("click", async () => {
const n = Math.max(1, Math.min(5000, parseInt(seedNInput.value, 10) || 200));

128
web/src/query_panel.ts Normal file
View File

@@ -0,0 +1,128 @@
/**
* Stage 5 query panel.
*
* Owns the bottom-left text input + answer area + SSE consumer. Submission
* does `POST /api/galaxy/:id/query` and immediately opens an EventSource on
* `/api/query/:id/stream`; the running answer streams in as the integrator
* re-runs. Closes the EventSource on terminal status (`done` / `failed`).
*
* The visual feedback in the 3D scene (responder light-up, Query-Engram
* appearing at the donut center) lives in `scene.ts` — driven by separate
* WS events. This module only manages the side panel.
*/
import type { QueryStatus } from "./ws_client";
type QueryUpdate = {
query_id: string;
version: number;
status: QueryStatus;
answer: string;
responder_count: number;
};
export type QueryPanelOptions = {
galaxyId: string;
};
const TERMINAL: QueryStatus[] = ["done", "failed"];
export function attachQueryPanel(opts: QueryPanelOptions): void {
const form = document.getElementById("query-form") as HTMLFormElement | null;
const input = document.getElementById("query-input") as HTMLInputElement | null;
const submit = document.getElementById("query-submit") as HTMLButtonElement | null;
const answerEl = document.getElementById("query-answer");
const statusEl = document.getElementById("query-status");
if (!form || !input || !submit || !answerEl || !statusEl) {
console.warn("query panel markup missing — skipping wireup");
return;
}
let activeStream: EventSource | null = null;
function setBusy(busy: boolean): void {
submit!.disabled = busy;
input!.disabled = busy;
}
function renderUpdate(u: QueryUpdate): void {
answerEl!.textContent = u.answer || "(thinking…)";
const phase =
u.status === "pending"
? "broadcasting"
: u.status === "responding"
? `synthesising · ${u.responder_count} perspective${u.responder_count === 1 ? "" : "s"}`
: u.status === "integrating"
? "integrating"
: u.status === "done"
? `done · ${u.responder_count} perspective${u.responder_count === 1 ? "" : "s"}`
: "failed";
statusEl!.textContent = `v${u.version} · ${phase}`;
}
function closeStream(): void {
if (activeStream) {
activeStream.close();
activeStream = null;
}
}
async function submitQuery(text: string): Promise<void> {
setBusy(true);
closeStream();
answerEl!.textContent = "(asking…)";
statusEl!.textContent = "submitting";
let queryId: string;
try {
const res = await fetch(`/api/galaxy/${opts.galaxyId}/query`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text }),
});
if (!res.ok) {
const body = await res.text();
throw new Error(`HTTP ${res.status}: ${body}`);
}
const body = (await res.json()) as { query_id: string };
queryId = body.query_id;
} catch (e) {
statusEl!.textContent = "failed";
answerEl!.textContent = `submit failed: ${(e as Error).message}`;
setBusy(false);
return;
}
activeStream = new EventSource(`/api/query/${queryId}/stream`);
activeStream.onmessage = (ev) => {
try {
const update = JSON.parse(ev.data) as QueryUpdate;
renderUpdate(update);
if (TERMINAL.includes(update.status)) {
closeStream();
setBusy(false);
}
} catch (err) {
console.warn("query SSE decode failed", err);
}
};
activeStream.onerror = () => {
// EventSource auto-reconnects on transient errors; but once the
// server has finished and closed the channel, the reconnect loop
// would just pile up 404s. The terminal-status check above already
// closed the stream in the success path; this handler covers the
// failure path where the channel never opened cleanly.
if (activeStream && activeStream.readyState === EventSource.CLOSED) {
statusEl!.textContent = "stream closed";
setBusy(false);
}
};
}
form.addEventListener("submit", (ev) => {
ev.preventDefault();
const text = input!.value.trim();
if (!text) return;
void submitQuery(text);
});
}

View File

@@ -8,6 +8,8 @@ import { OutputPass } from "three/examples/jsm/postprocessing/OutputPass.js";
import { EngramMesh } from "./engram_mesh";
import { EngramTrails } from "./engram_trails";
import { SynapseMesh } from "./synapse_mesh";
import { attachInspector } from "./inspector";
import { attachQueryPanel } from "./query_panel";
import type { EngramSnapshot, EventHandlers, SynapseDto } from "./ws_client";
import { connectGalaxy } from "./ws_client";
@@ -16,6 +18,8 @@ export type SceneHooks = {
onGalaxyName: (name: string) => void;
onTorus: (majorRadius: number, minorRadius: number) => void;
onSynapses?: (count: number) => void;
/** Rolling FPS, updated about once per second from inside the rAF loop. */
onFps?: (fps: number) => void;
};
// State-color table mirrored from EngramMesh; needed here so we can pre-paint
@@ -174,6 +178,11 @@ export function startScene(canvas: HTMLCanvasElement, galaxyId: string, hooks: S
synapses.applyPositionFrame(frame);
hooks.onCount(engrams.count());
},
onEngramStateChanged: (id, state) => {
// Re-paint the dot for an engram whose state flipped (Stage 5
// responder light-up + Query-Engram → Memorize on completion).
engrams.setStateById(id, state);
},
onClose: () => {
console.warn("ws closed — refresh to reconnect");
},
@@ -181,6 +190,9 @@ export function startScene(canvas: HTMLCanvasElement, galaxyId: string, hooks: S
};
connectGalaxy(galaxyId, handlers);
attachInspector({ canvas, camera, engrams, galaxyId });
attachQueryPanel({ galaxyId });
function onResize(): void {
const w = window.innerWidth;
const h = window.innerHeight;
@@ -193,10 +205,22 @@ export function startScene(canvas: HTMLCanvasElement, galaxyId: string, hooks: S
window.addEventListener("resize", onResize);
const startedAt = performance.now();
// FPS: rolling counter — accumulate frames, emit roughly once a second.
let fpsWindowStart = startedAt;
let fpsFrames = 0;
function animate(): void {
controls.update();
engrams.tick((performance.now() - startedAt) / 1000);
const nowMs = performance.now();
engrams.tick((nowMs - startedAt) / 1000);
composer.render();
fpsFrames += 1;
const elapsed = nowMs - fpsWindowStart;
if (elapsed >= 1000) {
const fps = (fpsFrames * 1000) / elapsed;
hooks.onFps?.(fps);
fpsFrames = 0;
fpsWindowStart = nowMs;
}
requestAnimationFrame(animate);
}
animate();

View File

@@ -33,6 +33,13 @@ export type SynapseDto = {
weight: number;
};
export type QueryStatus =
| "pending"
| "responding"
| "integrating"
| "done"
| "failed";
export type SimEventMsg =
| {
type: "hello";
@@ -47,6 +54,27 @@ export type SimEventMsg =
center: [number, number, number];
major_radius: number;
minor_radius: number;
}
| { type: "engram_state_changed"; id: string; state: string }
| {
type: "query_started";
query: string;
engram: string;
position: [number, number, number];
text: string;
}
| {
type: "query_answer_updated";
query: string;
version: number;
status: QueryStatus;
answer: string;
}
| {
type: "query_finished";
query: string;
status: QueryStatus;
responder_count: number;
};
export type PositionFrame = {
@@ -69,6 +97,20 @@ export type EventHandlers = {
majorRadius: number,
minorRadius: number,
) => void;
onEngramStateChanged?: (id: string, state: string) => void;
onQueryStarted?: (
queryId: string,
engramId: string,
position: [number, number, number],
text: string,
) => void;
onQueryAnswerUpdated?: (
queryId: string,
version: number,
status: QueryStatus,
answer: string,
) => void;
onQueryFinished?: (queryId: string, status: QueryStatus, responderCount: number) => void;
onPositionFrame?: (frame: PositionFrame) => void;
onClose?: (ev: CloseEvent) => void;
onError?: (ev: Event) => void;
@@ -97,6 +139,18 @@ export function connectGalaxy(galaxyId: string, handlers: EventHandlers): WebSoc
case "torus_updated":
handlers.onTorusUpdated?.(msg.center, msg.major_radius, msg.minor_radius);
break;
case "engram_state_changed":
handlers.onEngramStateChanged?.(msg.id, msg.state);
break;
case "query_started":
handlers.onQueryStarted?.(msg.query, msg.engram, msg.position, msg.text);
break;
case "query_answer_updated":
handlers.onQueryAnswerUpdated?.(msg.query, msg.version, msg.status, msg.answer);
break;
case "query_finished":
handlers.onQueryFinished?.(msg.query, msg.status, msg.responder_count);
break;
}
} else {
const frame = decodePositionFrame(ev.data as ArrayBuffer);