mirror of
https://github.com/ruvnet/RuVector.git
synced 2026-11-13 18:23:30 +00:00
fix: ruvector-postgres v0.3.1 — audit bug fixes, 46 SQL functions, Docker publish (#227)
Fixes #226
This commit is contained in:
parent
150f7cd2d7
commit
229877fe9a
123 changed files with 39715 additions and 555 deletions
24
.gcloudignore
Normal file
24
.gcloudignore
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Cloud Build ignores — keep context under 50 MiB
|
||||
target/
|
||||
node_modules/
|
||||
.git/
|
||||
examples/
|
||||
benchmarks/
|
||||
docs/
|
||||
scripts/
|
||||
bindings/
|
||||
npm/
|
||||
test_models/
|
||||
*.wasm
|
||||
*.so
|
||||
*.o
|
||||
*.a
|
||||
*.d
|
||||
*.rlib
|
||||
*.rmeta
|
||||
*.dwo
|
||||
*.dwp
|
||||
*.node
|
||||
*.db
|
||||
.claude/
|
||||
package-lock.json
|
||||
82
.github/workflows/sync-rvf-examples.yml
vendored
Normal file
82
.github/workflows/sync-rvf-examples.yml
vendored
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
name: Sync RVF Examples to GCS
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'examples/rvf/output/**'
|
||||
- 'examples/rvf/generate_examples.rs'
|
||||
- 'scripts/generate-rvf-manifest.py'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
force_sync:
|
||||
description: 'Force sync even without changes'
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
env:
|
||||
GCS_BUCKET: ruvector-examples
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set version
|
||||
run: |
|
||||
VERSION=$(jq -r .version npm/packages/ruvector/package.json)
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
echo "Syncing version: $VERSION"
|
||||
|
||||
- name: Generate manifest
|
||||
run: |
|
||||
python3 scripts/generate-rvf-manifest.py \
|
||||
--input examples/rvf/output/ \
|
||||
--version ${{ env.VERSION }} \
|
||||
--output examples/rvf/manifest.json
|
||||
|
||||
- name: Generate checksums
|
||||
run: |
|
||||
cd examples/rvf/output
|
||||
sha256sum *.rvf > checksums.sha256
|
||||
|
||||
- name: Authenticate to Google Cloud
|
||||
if: github.ref == 'refs/heads/main'
|
||||
uses: google-github-actions/auth@v2
|
||||
with:
|
||||
workload_identity_provider: ${{ secrets.GCP_WIF_PROVIDER }}
|
||||
service_account: ${{ secrets.GCS_SERVICE_ACCOUNT }}
|
||||
|
||||
- name: Upload examples to GCS
|
||||
if: github.ref == 'refs/heads/main'
|
||||
uses: google-github-actions/upload-cloud-storage@v2
|
||||
with:
|
||||
path: examples/rvf/output/
|
||||
destination: ${{ env.GCS_BUCKET }}/v${{ env.VERSION }}/
|
||||
glob: '*.rvf'
|
||||
|
||||
- name: Upload manifest and checksums
|
||||
if: github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
gsutil cp examples/rvf/manifest.json gs://${{ env.GCS_BUCKET }}/v${{ env.VERSION }}/manifest.json
|
||||
gsutil cp examples/rvf/output/checksums.sha256 gs://${{ env.GCS_BUCKET }}/v${{ env.VERSION }}/checksums.sha256
|
||||
# Update root manifest (latest)
|
||||
gsutil cp examples/rvf/manifest.json gs://${{ env.GCS_BUCKET }}/manifest.json
|
||||
|
||||
- name: Verify upload
|
||||
if: github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
echo "Verifying GCS upload..."
|
||||
REMOTE_COUNT=$(gsutil ls gs://${{ env.GCS_BUCKET }}/v${{ env.VERSION }}/*.rvf | wc -l)
|
||||
LOCAL_COUNT=$(ls examples/rvf/output/*.rvf | wc -l)
|
||||
echo "Remote: $REMOTE_COUNT files, Local: $LOCAL_COUNT files"
|
||||
if [ "$REMOTE_COUNT" -ne "$LOCAL_COUNT" ]; then
|
||||
echo "ERROR: File count mismatch!"
|
||||
exit 1
|
||||
fi
|
||||
echo "Upload verified successfully"
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -98,3 +98,10 @@ data/
|
|||
# Large model files
|
||||
*.gguf
|
||||
test_models/*.gguf
|
||||
|
||||
# Compiled server binaries (built for Cloud Run deploy)
|
||||
/mcp-brain-server
|
||||
crates/mcp-brain-server/mcp-brain-server
|
||||
|
||||
# Backup files
|
||||
*.bak
|
||||
|
|
|
|||
44
CLAUDE.md
44
CLAUDE.md
|
|
@ -13,13 +13,53 @@
|
|||
|
||||
## Publishing (crates.io & npm)
|
||||
|
||||
- Credentials are stored in `.env` at the project root — source it before publishing
|
||||
- Cargo token is in `~/.cargo/credentials.toml` (auto-loaded by cargo)
|
||||
- Credentials are in `.env` (project root) and `~/.cargo/credentials.toml` — NEVER commit or log these
|
||||
- npm is authenticated as `ruvnet` — verify with `npm whoami`
|
||||
- **NEVER** echo, cat, or print credential files. Source `.env` only via `source .env`
|
||||
- **Publish order for solver crates**: `ruvector-solver` first (no deps), then `ruvector-solver-wasm` and `ruvector-solver-node` (depend on solver)
|
||||
- Always run `cargo publish --dry-run --allow-dirty` before real publish
|
||||
- `ruvector-profiler` has `publish = false` — intentionally not publishable
|
||||
|
||||
### npx ruvector (npm)
|
||||
|
||||
- **Package**: `ruvector` on npm, published as `ruvnet`
|
||||
- **Location**: `npm/packages/ruvector/`
|
||||
- **Current version**: check `npm/packages/ruvector/package.json`
|
||||
- **Pre-publish checklist**:
|
||||
1. `cd npm/packages/ruvector`
|
||||
2. Update version in `package.json` AND `bin/mcp-server.js` (2 occurrences of version string)
|
||||
3. `node -c bin/cli.js && node -c bin/mcp-server.js` (syntax check)
|
||||
4. `npm test` (55 CLI + integration tests must pass)
|
||||
5. `npm publish --access public`
|
||||
- **Key files**:
|
||||
- `bin/cli.js` (~8500 lines) — 48 commands, 12 groups (brain, edge, identity, mcp, rvf, hooks, llm, sona, route, gnn, attention, embed)
|
||||
- `bin/mcp-server.js` (~3500 lines) — 91 MCP tools, stdio + SSE transports
|
||||
- `test/integration.js` — module loading, type defs, package structure
|
||||
- `test/cli-commands.js` — 55 CLI command tests
|
||||
- **chalk ESM fix**: chalk v5 is ESM-only, we use CJS. Always use: `const _chalk = require('chalk'); const chalk = _chalk.default || _chalk;`
|
||||
- **Lazy loading**: GNN, attention, ora are lazy-loaded for ~55ms startup. Do NOT convert to eager imports.
|
||||
- **Peer deps** (optional): `@ruvector/pi-brain`, `@ruvector/ruvllm`, `@ruvector/router`
|
||||
|
||||
### π.ruv.io (Cloud Run)
|
||||
|
||||
- **Service**: `ruvbrain` in `us-central1` on project `ruv-dev`
|
||||
- **Source**: `crates/mcp-brain-server/` — axum Rust server
|
||||
- **Landing page**: `crates/mcp-brain-server/static/index.html` (embedded via `include_str!`)
|
||||
- **Origin slideshow**: `crates/mcp-brain-server/static/origin.html`
|
||||
- **Deploy**:
|
||||
1. Edit HTML in `crates/mcp-brain-server/static/`
|
||||
2. `gcloud builds submit --config=crates/mcp-brain-server/cloudbuild.yaml --project=ruv-dev .`
|
||||
3. `gcloud run deploy ruvbrain --image gcr.io/ruv-dev/ruvbrain:latest --region us-central1 --project ruv-dev`
|
||||
- **Dockerfile**: `crates/mcp-brain-server/Dockerfile` — strips `examples/` from workspace before build
|
||||
- **Domain**: `π.ruv.io` (also `pi.ruv.io`) → Cloud Run custom domain mapping
|
||||
|
||||
### Rust Crates (crates.io)
|
||||
|
||||
- **Publish order**: Check inter-crate `path =` dependencies. Publish leaf crates first.
|
||||
- **Version deps**: Before publishing, convert `path = "../foo"` to `version = "x.y"` in Cargo.toml
|
||||
- **Dry run**: `cargo publish --dry-run --allow-dirty -p <crate-name>`
|
||||
- **EXO-AI crates**: Published as v0.1.1 (ruvector-exo-core, ruvector-exo-vision, etc.)
|
||||
|
||||
## File Organization
|
||||
|
||||
- NEVER save to root folder — use the directories below
|
||||
|
|
|
|||
187
Cargo.lock
generated
187
Cargo.lock
generated
|
|
@ -243,16 +243,6 @@ dependencies = [
|
|||
"libloading 0.8.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "assert-json-diff"
|
||||
version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "assert_cmd"
|
||||
version = "2.1.2"
|
||||
|
|
@ -538,36 +528,6 @@ dependencies = [
|
|||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-test"
|
||||
version = "16.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e3a443d2608936a02a222da7b746eb412fede7225b3030b64fe9be99eab8dc"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert-json-diff",
|
||||
"auto-future",
|
||||
"axum",
|
||||
"bytes",
|
||||
"bytesize",
|
||||
"cookie",
|
||||
"http 1.4.0",
|
||||
"http-body-util",
|
||||
"hyper 1.8.1",
|
||||
"hyper-util",
|
||||
"mime",
|
||||
"pretty_assertions",
|
||||
"reserve-port",
|
||||
"rust-multipart-rfc7578_2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"smallvec",
|
||||
"tokio",
|
||||
"tower 0.5.3",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "backtrace"
|
||||
version = "0.3.76"
|
||||
|
|
@ -9134,7 +9094,7 @@ dependencies = [
|
|||
"async-trait",
|
||||
"axum",
|
||||
"axum-streams",
|
||||
"axum-test 15.7.4",
|
||||
"axum-test",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"clap",
|
||||
|
|
@ -9640,56 +9600,11 @@ dependencies = [
|
|||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rvf-adapter-rvlite"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"rvf-runtime",
|
||||
"rvf-types",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rvf-benches"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"criterion 0.5.1",
|
||||
"ed25519-dalek",
|
||||
"rand 0.8.5",
|
||||
"rvf-crypto",
|
||||
"rvf-index",
|
||||
"rvf-manifest",
|
||||
"rvf-quant",
|
||||
"rvf-runtime",
|
||||
"rvf-types",
|
||||
"rvf-wire",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rvf-cli"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"ctrlc",
|
||||
"rvf-crypto",
|
||||
"rvf-launch",
|
||||
"rvf-manifest",
|
||||
"rvf-runtime",
|
||||
"rvf-server",
|
||||
"rvf-types",
|
||||
"rvf-wire",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rvf-crypto"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"ed25519-dalek",
|
||||
"rand 0.8.5",
|
||||
"rvf-types",
|
||||
"sha3",
|
||||
]
|
||||
|
|
@ -9703,56 +9618,6 @@ dependencies = [
|
|||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rvf-federation"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"criterion 0.5.1",
|
||||
"rand 0.8.5",
|
||||
"rand_distr 0.4.3",
|
||||
"regex",
|
||||
"serde",
|
||||
"sha3",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rvf-import"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"csv",
|
||||
"rvf-runtime",
|
||||
"rvf-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rvf-index"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"rand 0.8.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rvf-integration-tests"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ed25519-dalek",
|
||||
"rand 0.8.5",
|
||||
"rvf-adapter-rvlite",
|
||||
"rvf-crypto",
|
||||
"rvf-index",
|
||||
"rvf-manifest",
|
||||
"rvf-quant",
|
||||
"rvf-runtime",
|
||||
"rvf-types",
|
||||
"rvf-wire",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rvf-kernel"
|
||||
version = "0.1.0"
|
||||
|
|
@ -9760,7 +9625,6 @@ dependencies = [
|
|||
"flate2",
|
||||
"rvf-types",
|
||||
"sha3",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -9781,32 +9645,10 @@ dependencies = [
|
|||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rvf-launch"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"rvf-runtime",
|
||||
"rvf-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rvf-manifest"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"crc32c",
|
||||
"rvf-types",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rvf-quant"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"rand 0.8.5",
|
||||
"rvf-types",
|
||||
]
|
||||
|
||||
|
|
@ -9814,38 +9656,12 @@ dependencies = [
|
|||
name = "rvf-runtime"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"rand 0.8.5",
|
||||
"rvf-types",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rvf-server"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"axum-test 16.4.1",
|
||||
"clap",
|
||||
"http-body-util",
|
||||
"mime_guess",
|
||||
"rvf-runtime",
|
||||
"rvf-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tower 0.5.3",
|
||||
"tower-http 0.6.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rvf-types"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"ed25519-dalek",
|
||||
"rand_core 0.6.4",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rvf-wire"
|
||||
|
|
@ -9853,7 +9669,6 @@ version = "0.1.0"
|
|||
dependencies = [
|
||||
"crc32c",
|
||||
"rvf-types",
|
||||
"tempfile",
|
||||
"xxhash-rust",
|
||||
]
|
||||
|
||||
|
|
|
|||
19
Cargo.toml
19
Cargo.toml
|
|
@ -1,5 +1,5 @@
|
|||
[workspace]
|
||||
exclude = ["crates/micro-hnsw-wasm", "crates/ruvector-hyperbolic-hnsw", "crates/ruvector-hyperbolic-hnsw-wasm", "examples/ruvLLM/esp32", "examples/ruvLLM/esp32-flash", "examples/edge-net", "examples/data", "examples/ruvLLM", "examples/delta-behavior", "crates/rvf", "examples/rvf-desktop"]
|
||||
exclude = ["crates/micro-hnsw-wasm", "crates/ruvector-hyperbolic-hnsw", "crates/ruvector-hyperbolic-hnsw-wasm", "examples/ruvLLM/esp32", "examples/ruvLLM/esp32-flash", "examples/edge-net", "examples/data", "examples/ruvLLM", "examples/delta-behavior", "crates/rvf", "examples/rvf-desktop", "crates/mcp-brain-server"]
|
||||
members = [
|
||||
"crates/ruvector-core",
|
||||
"crates/ruvector-node",
|
||||
|
|
@ -84,23 +84,6 @@ members = [
|
|||
"crates/ruvector-solver-node",
|
||||
"examples/dna",
|
||||
"examples/OSpipe",
|
||||
"crates/rvf/rvf-adapters/rvlite",
|
||||
"crates/rvf/rvf-types",
|
||||
"crates/rvf/rvf-wire",
|
||||
"crates/rvf/rvf-quant",
|
||||
"crates/rvf/rvf-crypto",
|
||||
"crates/rvf/rvf-manifest",
|
||||
"crates/rvf/rvf-index",
|
||||
"crates/rvf/rvf-runtime",
|
||||
"crates/rvf/rvf-kernel",
|
||||
"crates/rvf/rvf-ebpf",
|
||||
"crates/rvf/rvf-launch",
|
||||
"crates/rvf/rvf-server",
|
||||
"crates/rvf/rvf-import",
|
||||
"crates/rvf/rvf-cli",
|
||||
"crates/rvf/rvf-federation",
|
||||
"crates/rvf/tests/rvf-integration",
|
||||
"crates/rvf/benches",
|
||||
"crates/ruvector-coherence",
|
||||
"crates/ruvector-profiler",
|
||||
"crates/ruvector-attn-mincut",
|
||||
|
|
|
|||
12
README.md
12
README.md
|
|
@ -1168,6 +1168,18 @@ const response = await llm.generate('Explain quantum computing', { max_tokens: 2
|
|||
| **Genomics** | DNA sequence similarity, variant calling, population analysis | [examples/dna](./examples/dna) |
|
||||
| **Graph Transformers** | 8 verified modules — physics, bio, manifold, temporal, economic, and more | [crates/ruvector-graph-transformer](./crates/ruvector-graph-transformer) |
|
||||
|
||||
### Shared Brain at π.ruv.io
|
||||
|
||||
| Feature | What It Does | Access |
|
||||
|---------|-------------|--------|
|
||||
| **Semantic Search** | Hybrid ranking — keyword + cosine + graph + AGI scoring layers | `npx ruvector brain search "query"` |
|
||||
| **SONA Learning** | 3-tier hierarchical pattern learning across sessions | `npx ruvector brain agi sona` |
|
||||
| **GWT Attention** | Global Workspace Theory competition for search ranking | `npx ruvector brain agi status` |
|
||||
| **Temporal Delta** | Knowledge evolution velocity and trend tracking | `npx ruvector brain agi temporal` |
|
||||
| **Meta-Learning** | Thompson Sampling with curiosity/regret exploration | `npx ruvector brain agi explore` |
|
||||
| **Midstream Platform** | Nanosecond scheduler, Lyapunov attractors, strange-loop meta-cognition | `npx ruvector midstream status` |
|
||||
| **103 MCP Tools** | Full brain + AGI + midstream diagnostics for Claude Code agents | `npx ruvector mcp start` |
|
||||
|
||||
### Distributed & Enterprise
|
||||
|
||||
| Use Case | What RuVector Does | Example |
|
||||
|
|
|
|||
3857
crates/mcp-brain-server/Cargo.lock
generated
Normal file
3857
crates/mcp-brain-server/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
73
crates/mcp-brain-server/Cargo.toml
Normal file
73
crates/mcp-brain-server/Cargo.toml
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
[package]
|
||||
name = "mcp-brain-server"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.77"
|
||||
license = "MIT"
|
||||
description = "Cloud Run backend for RuVector Shared Brain — axum REST API with Firestore + GCS"
|
||||
repository = "https://github.com/ruvnet/ruvector"
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "mcp-brain-server"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
# Web framework
|
||||
axum = { version = "0.7", features = ["json", "query"] }
|
||||
tokio = { version = "1.41", features = ["full"] }
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["cors", "trace", "limit", "set-header"] }
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
# HTTP client (for Firestore/GCS REST APIs)
|
||||
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
|
||||
|
||||
# Crypto
|
||||
sha2 = "0.10"
|
||||
sha3 = "0.10"
|
||||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||
subtle = "2.6"
|
||||
rand = "0.8"
|
||||
|
||||
# Utilities
|
||||
uuid = { version = "1.11", features = ["v4", "serde"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
dashmap = "6.1"
|
||||
parking_lot = "0.12"
|
||||
thiserror = "2.0"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
base64 = "0.22"
|
||||
hex = "0.4"
|
||||
tokio-stream = "0.1"
|
||||
async-stream = "0.3"
|
||||
urlencoding = "2"
|
||||
|
||||
# RuVector Cognitive Stack
|
||||
sona = { package = "ruvector-sona", path = "../sona", features = ["serde-support"] }
|
||||
ruvector-mincut = { path = "../ruvector-mincut" }
|
||||
ruvector-nervous-system = { path = "../ruvector-nervous-system" }
|
||||
ruvector-domain-expansion = { path = "../ruvector-domain-expansion" }
|
||||
ruvector-delta-core = { path = "../ruvector-delta-core" }
|
||||
ruvector-solver = { path = "../ruvector-solver", features = ["forward-push"] }
|
||||
|
||||
# RuvLLM Embeddings (HashEmbedder + RlmEmbedder — pure Rust, no model download)
|
||||
ruvllm = { path = "../ruvllm", default-features = false, features = ["minimal"] }
|
||||
|
||||
# RVF AGI Format Stack (ADR-075)
|
||||
rvf-types = { path = "../rvf/rvf-types", features = ["std"] }
|
||||
rvf-crypto = { path = "../rvf/rvf-crypto" }
|
||||
rvf-wire = { path = "../rvf/rvf-wire" }
|
||||
rvf-federation = { path = "../rvf/rvf-federation", features = ["serde"] }
|
||||
rvf-runtime = { path = "../rvf/rvf-runtime" }
|
||||
|
||||
# Midstream Platform — Real-time streaming analysis (ADR-077)
|
||||
# Note: temporal-compare is binary-only (no lib.rs) — cannot be used as library dep
|
||||
nanosecond-scheduler = "0.1"
|
||||
temporal-attractor-studio = "0.1"
|
||||
temporal-neural-solver = "0.1"
|
||||
strange-loop = "0.3"
|
||||
13
crates/mcp-brain-server/Dockerfile
Normal file
13
crates/mcp-brain-server/Dockerfile
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# Minimal runtime image — binary is pre-built and copied in
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY mcp-brain-server /usr/local/bin/mcp-brain-server
|
||||
|
||||
ENV PORT=8080
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["mcp-brain-server"]
|
||||
413
crates/mcp-brain-server/README.md
Normal file
413
crates/mcp-brain-server/README.md
Normal file
|
|
@ -0,0 +1,413 @@
|
|||
# mcp-brain-server
|
||||
|
||||
Cloud Run backend for the RuVector Shared Brain at **[π.ruv.io](https://pi.ruv.io)**.
|
||||
|
||||
Axum REST API with Firestore persistence, GCS blob storage, and a full cognitive stack: SONA learning, GWT attention, temporal delta tracking, meta-learning exploration, and Midstream real-time analysis.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Client (mcp-brain / npx ruvector / curl)
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ mcp-brain-server (axum) │
|
||||
│ ├── auth.rs Bearer token auth │
|
||||
│ ├── routes.rs REST handlers │
|
||||
│ ├── store.rs Firestore + in-memory │
|
||||
│ ├── gcs.rs GCS blob storage │
|
||||
│ ├── graph.rs Knowledge graph + PPR │
|
||||
│ ├── ranking.rs Attention-based ranking │
|
||||
│ ├── embeddings.rs RuvLLM (Hash + RLM) │
|
||||
│ ├── verify.rs PII strip, witness chain │
|
||||
│ ├── pipeline.rs RVF container builder │
|
||||
│ ├── midstream.rs Midstream platform │
|
||||
│ ├── cognitive.rs Cognitive engine │
|
||||
│ ├── drift.rs Drift monitoring │
|
||||
│ ├── reputation.rs Multi-factor reputation │
|
||||
│ ├── aggregate.rs Byzantine aggregation │
|
||||
│ └── rate_limit.rs Per-contributor limits │
|
||||
└─────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐ ┌─────────────┐
|
||||
│ Firestore │ │ GCS Bucket │
|
||||
│ (memories, │ │ (.rvf blobs│
|
||||
│ contrib, │ │ WASM bins)│
|
||||
│ votes) │ │ │
|
||||
└─────────────┘ └─────────────┘
|
||||
```
|
||||
|
||||
## REST API
|
||||
|
||||
All endpoints under `/v1/` require `Authorization: Bearer <key>` except `/v1/health` and `/v1/challenge`.
|
||||
|
||||
### Core Endpoints
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/v1/health` | No | Health check (status, version, uptime) |
|
||||
| GET | `/v1/challenge` | No | Issue a nonce for replay protection |
|
||||
| POST | `/v1/memories` | Yes | Share a memory (PII-stripped, embedded, witnessed) |
|
||||
| GET | `/v1/memories/search?q=...` | Yes | Semantic search with hybrid ranking |
|
||||
| GET | `/v1/memories/list` | Yes | List memories by category |
|
||||
| GET | `/v1/memories/:id` | Yes | Get memory with full provenance |
|
||||
| POST | `/v1/memories/:id/vote` | Yes | Upvote/downvote (Bayesian quality) |
|
||||
| DELETE | `/v1/memories/:id` | Yes | Delete own contribution |
|
||||
| GET | `/v1/status` | Yes | Full system diagnostics |
|
||||
|
||||
### Knowledge Graph
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/v1/transfer` | Yes | Cross-domain transfer learning |
|
||||
| GET | `/v1/drift` | Yes | Knowledge drift report |
|
||||
| GET | `/v1/partition` | Yes | MinCut graph partitioning |
|
||||
|
||||
### Brainpedia (ADR-062)
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/v1/pages` | Yes | Create a Draft page |
|
||||
| GET | `/v1/pages/:id` | Yes | Get page with delta log |
|
||||
| POST | `/v1/pages/:id/deltas` | Yes | Submit a delta (correction/extension) |
|
||||
| GET | `/v1/pages/:id/deltas` | Yes | List page deltas |
|
||||
| POST | `/v1/pages/:id/evidence` | Yes | Add verifiable evidence |
|
||||
| POST | `/v1/pages/:id/promote` | Yes | Promote Draft to Canonical |
|
||||
|
||||
### WASM Executable Nodes (ADR-063)
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/v1/nodes` | Yes | List published nodes |
|
||||
| POST | `/v1/nodes` | Yes | Publish a WASM node |
|
||||
| GET | `/v1/nodes/:id` | Yes | Get node metadata |
|
||||
| GET | `/v1/nodes/:id/wasm` | Yes | Download WASM binary |
|
||||
| POST | `/v1/nodes/:id/revoke` | Yes | Revoke a node |
|
||||
|
||||
### Federated LoRA (ADR-068)
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/v1/lora/latest` | No | Get consensus LoRA weights |
|
||||
| POST | `/v1/lora/submit` | Yes | Submit session LoRA weights |
|
||||
| GET | `/v1/training/preferences` | Yes | DPO preference pairs |
|
||||
|
||||
### AGI Diagnostics (ADR-076)
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/v1/sona/stats` | Yes | SONA learning engine stats |
|
||||
| GET | `/v1/temporal` | Yes | Temporal delta tracking |
|
||||
| GET | `/v1/explore` | Yes | Meta-learning diagnostics |
|
||||
|
||||
### Midstream Platform (ADR-077)
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/v1/midstream` | Yes | Midstream platform diagnostics |
|
||||
|
||||
### MCP SSE Transport (ADR-066)
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/sse` | No | SSE event stream |
|
||||
| POST | `/messages` | No | Send MCP message |
|
||||
|
||||
## Search Ranking Pipeline
|
||||
|
||||
Hybrid multi-signal scoring with additive layers:
|
||||
|
||||
```
|
||||
Base:
|
||||
keyword_boost * 0.85 + cosine * 0.05 + graph_ppr * 0.04 + rep * 0.03 + vote * 0.03
|
||||
|
||||
AGI layers (ADR-076):
|
||||
+ GWT attention: +0.10 for workspace competition winners
|
||||
+ K-WTA sparse: +0.05 sparse normalized activation
|
||||
+ SONA patterns: centroid_similarity * quality * 0.15
|
||||
+ Meta curiosity: novelty_score * 0.05
|
||||
|
||||
Midstream layers (ADR-077):
|
||||
+ Attractor stability: lyapunov_score * 0.05
|
||||
+ Strange-loop: meta_cognitive * 0.04
|
||||
```
|
||||
|
||||
## Cognitive Stack Dependencies
|
||||
|
||||
| Crate | Purpose |
|
||||
|-------|---------|
|
||||
| `ruvector-sona` | 3-tier hierarchical learning (SONA) |
|
||||
| `ruvector-nervous-system` | GWT attention + K-WTA sparse activation |
|
||||
| `ruvector-delta-core` | Temporal delta stream tracking |
|
||||
| `ruvector-domain-expansion` | Thompson Sampling meta-learning |
|
||||
| `ruvector-mincut` | Graph partitioning |
|
||||
| `ruvector-solver` | PersonalizedPageRank (forward-push) |
|
||||
| `ruvllm` | HashEmbedder + RlmEmbedder (128-dim) |
|
||||
| `rvf-crypto` | SHAKE-256 witness chains, Ed25519 |
|
||||
| `rvf-federation` | PII stripping, differential privacy |
|
||||
| `rvf-runtime` | Negative cache, adversarial detection |
|
||||
| `rvf-wire` | RVF container segment encoding |
|
||||
| `nanosecond-scheduler` | Background task scheduling |
|
||||
| `temporal-attractor-studio` | Lyapunov exponent analysis |
|
||||
| `temporal-neural-solver` | Certified temporal predictions |
|
||||
| `strange-loop` | Meta-cognitive recursive reasoning |
|
||||
|
||||
## Feature Flags (Environment Variables)
|
||||
|
||||
All flags are read once at startup. No per-request `env::var` calls.
|
||||
|
||||
### RVF Stack (ADR-075)
|
||||
|
||||
| Env Var | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| `RVF_PII_STRIP` | `true` | PII redaction (12 regex rules) |
|
||||
| `RVF_DP_ENABLED` | `false` | Differential privacy noise on embeddings |
|
||||
| `RVF_DP_EPSILON` | `1.0` | Privacy loss per memory |
|
||||
| `RVF_WITNESS` | `true` | Witness chain construction |
|
||||
| `RVF_CONTAINER` | `true` | RVF container upload to GCS |
|
||||
| `RVF_ADVERSARIAL` | `false` | Adversarial embedding detection |
|
||||
| `RVF_NEG_CACHE` | `false` | Negative query cache |
|
||||
|
||||
### AGI Subsystems (ADR-076)
|
||||
|
||||
| Env Var | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| `SONA_ENABLED` | `true` | SONA pattern learning |
|
||||
| `GWT_ENABLED` | `true` | Global Workspace Theory attention |
|
||||
| `TEMPORAL_ENABLED` | `true` | Temporal delta tracking |
|
||||
| `META_LEARNING_ENABLED` | `true` | Meta-learning exploration |
|
||||
|
||||
### Midstream Platform (ADR-077)
|
||||
|
||||
| Env Var | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| `MIDSTREAM_SCHEDULER` | `false` | Nanosecond scheduler |
|
||||
| `MIDSTREAM_ATTRACTOR` | `false` | Lyapunov attractor analysis |
|
||||
| `MIDSTREAM_SOLVER` | `false` | Temporal neural solver |
|
||||
| `MIDSTREAM_STRANGE_LOOP` | `false` | Strange-loop meta-cognition |
|
||||
|
||||
### Infrastructure
|
||||
|
||||
| Env Var | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| `PORT` | `8080` | Server listen port |
|
||||
| `BRAIN_SYSTEM_KEY` | (none) | System API key for auth |
|
||||
| `FIRESTORE_URL` | (none) | Firestore REST endpoint |
|
||||
| `GCS_BUCKET` | (none) | GCS bucket for RVF blobs |
|
||||
| `CORS_ORIGINS` | pi.ruv.io,... | Allowed CORS origins |
|
||||
| `RUST_LOG` | `info` | Log level filter |
|
||||
|
||||
## Development
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
cd crates/mcp-brain-server
|
||||
cargo build --release
|
||||
cargo test # 59 tests
|
||||
```
|
||||
|
||||
### Run Locally
|
||||
|
||||
```bash
|
||||
# Local mode (in-memory store, no Firestore/GCS)
|
||||
BRAIN_SYSTEM_KEY=test-key cargo run
|
||||
|
||||
# With Firestore
|
||||
BRAIN_SYSTEM_KEY=test-key \
|
||||
FIRESTORE_URL=https://firestore.googleapis.com/v1/projects/YOUR_PROJECT/databases/(default)/documents \
|
||||
cargo run
|
||||
```
|
||||
|
||||
### Test Endpoints
|
||||
|
||||
```bash
|
||||
KEY="test-key"
|
||||
URL="http://localhost:8080"
|
||||
|
||||
# Health (no auth)
|
||||
curl $URL/v1/health
|
||||
|
||||
# Status (auth required)
|
||||
curl -H "Authorization: Bearer $KEY" $URL/v1/status
|
||||
|
||||
# Share a memory
|
||||
curl -X POST -H "Authorization: Bearer $KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"category":"pattern","title":"My pattern","content":"Details...","tags":["rust"]}' \
|
||||
$URL/v1/memories
|
||||
|
||||
# Search
|
||||
curl -H "Authorization: Bearer $KEY" "$URL/v1/memories/search?q=rust+patterns&limit=5"
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Google Cloud project with billing enabled
|
||||
- `gcloud` CLI authenticated (`gcloud auth login`)
|
||||
- Rust toolchain (for building the binary)
|
||||
|
||||
### Quick Deploy (Cloud Run direct)
|
||||
|
||||
```bash
|
||||
cd /path/to/ruvector
|
||||
|
||||
# 1. Build the release binary
|
||||
cd crates/mcp-brain-server
|
||||
cargo build --release
|
||||
|
||||
# 2. Copy binary to repo root (Docker build context)
|
||||
cp target/release/mcp-brain-server ../../mcp-brain-server
|
||||
|
||||
# 3. Build and push container image
|
||||
cd ../..
|
||||
gcloud builds submit \
|
||||
--config=crates/mcp-brain-server/cloudbuild.yaml \
|
||||
--project=YOUR_PROJECT .
|
||||
|
||||
# 4. Deploy to Cloud Run
|
||||
gcloud run deploy ruvbrain \
|
||||
--image gcr.io/YOUR_PROJECT/ruvbrain:latest \
|
||||
--region us-central1 \
|
||||
--project YOUR_PROJECT \
|
||||
--allow-unauthenticated \
|
||||
--set-env-vars "BRAIN_SYSTEM_KEY=YOUR_KEY^||^SONA_ENABLED=true^||^GWT_ENABLED=true^||^TEMPORAL_ENABLED=true^||^META_LEARNING_ENABLED=true^||^RVF_PII_STRIP=true^||^RVF_WITNESS=true^||^RVF_CONTAINER=true"
|
||||
```
|
||||
|
||||
### Full Deploy (with Firestore, GCS, IAM, Cloud Armor)
|
||||
|
||||
```bash
|
||||
cd crates/mcp-brain-server
|
||||
|
||||
# Uses deploy.sh which handles:
|
||||
# - API enablement (Firestore, Cloud Run, Cloud Build, Secret Manager, GCS)
|
||||
# - GCS bucket creation with lifecycle rules
|
||||
# - Secret Manager (brain-api-key, brain-signing-key)
|
||||
# - Service account with minimal IAM permissions
|
||||
# - Container build and push
|
||||
# - Cloud Run deploy with env vars and secrets
|
||||
# - (Path B) External HTTPS LB + Cloud Armor WAF + CDN
|
||||
|
||||
# Dev deployment (direct Cloud Run URL)
|
||||
./deploy.sh
|
||||
|
||||
# Production deployment (LB + Cloud Armor + CDN + brain.ruv.io)
|
||||
DEPLOY_PATH=B ./deploy.sh
|
||||
```
|
||||
|
||||
### Deploy with Midstream (all features)
|
||||
|
||||
```bash
|
||||
gcloud run deploy ruvbrain \
|
||||
--image gcr.io/YOUR_PROJECT/ruvbrain:latest \
|
||||
--region us-central1 \
|
||||
--project YOUR_PROJECT \
|
||||
--set-env-vars "\
|
||||
BRAIN_SYSTEM_KEY=YOUR_KEY^||^\
|
||||
SONA_ENABLED=true^||^\
|
||||
GWT_ENABLED=true^||^\
|
||||
TEMPORAL_ENABLED=true^||^\
|
||||
META_LEARNING_ENABLED=true^||^\
|
||||
RVF_PII_STRIP=true^||^\
|
||||
RVF_WITNESS=true^||^\
|
||||
RVF_CONTAINER=true^||^\
|
||||
MIDSTREAM_SCHEDULER=true^||^\
|
||||
MIDSTREAM_ATTRACTOR=true^||^\
|
||||
MIDSTREAM_SOLVER=true^||^\
|
||||
MIDSTREAM_STRANGE_LOOP=true^||^\
|
||||
RUST_LOG=info"
|
||||
```
|
||||
|
||||
### Verify Deployment
|
||||
|
||||
```bash
|
||||
URL="https://YOUR_CLOUD_RUN_URL"
|
||||
KEY="YOUR_KEY"
|
||||
|
||||
# Health
|
||||
curl $URL/v1/health
|
||||
|
||||
# Status (check all subsystems)
|
||||
curl -H "Authorization: Bearer $KEY" $URL/v1/status | python3 -m json.tool
|
||||
|
||||
# Midstream diagnostics
|
||||
curl -H "Authorization: Bearer $KEY" $URL/v1/midstream
|
||||
|
||||
# Auth check (should return 401)
|
||||
curl -o /dev/null -w "%{http_code}" $URL/v1/status
|
||||
```
|
||||
|
||||
### Custom Domain (π.ruv.io)
|
||||
|
||||
The production instance runs at `π.ruv.io` (also `pi.ruv.io`) via Cloud Run custom domain mapping:
|
||||
|
||||
```bash
|
||||
gcloud run domain-mappings create \
|
||||
--service ruvbrain \
|
||||
--domain pi.ruv.io \
|
||||
--region us-central1 \
|
||||
--project ruv-dev
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
The Dockerfile uses a minimal `debian:bookworm-slim` runtime image (~80MB). The binary is pre-built outside Docker for faster iteration:
|
||||
|
||||
```dockerfile
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates
|
||||
COPY mcp-brain-server /usr/local/bin/mcp-brain-server
|
||||
ENV PORT=8080
|
||||
EXPOSE 8080
|
||||
CMD ["mcp-brain-server"]
|
||||
```
|
||||
|
||||
## Cloud Build
|
||||
|
||||
`cloudbuild.yaml` builds the Docker image on `E2_HIGHCPU_8` with a 30-minute timeout:
|
||||
|
||||
```yaml
|
||||
steps:
|
||||
- name: 'gcr.io/cloud-builders/docker'
|
||||
args: ['build', '-t', 'gcr.io/$PROJECT_ID/ruvbrain:latest',
|
||||
'-f', 'crates/mcp-brain-server/Dockerfile', '.']
|
||||
images: ['gcr.io/$PROJECT_ID/ruvbrain:latest']
|
||||
timeout: '1800s'
|
||||
options:
|
||||
machineType: 'E2_HIGHCPU_8'
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
- All write/read endpoints require `Authorization: Bearer <key>` (min 8 chars, max 256)
|
||||
- System key compared using constant-time comparison (`subtle::ConstantTimeEq`)
|
||||
- PII stripped via 12-rule regex engine (paths, IPs, emails, API keys, AWS keys, GitHub tokens, etc.)
|
||||
- Witness chains: SHAKE-256 linked provenance for every memory operation
|
||||
- Differential privacy: optional Gaussian noise on embeddings (configurable epsilon)
|
||||
- Nonce-based replay protection on write endpoints
|
||||
- Rate limiting: per-contributor read/write limits
|
||||
- Security headers: `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`
|
||||
- CORS restricted to configured origins
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
cargo test
|
||||
# 59 tests covering:
|
||||
# - Cognitive stack (Hopfield, HDC, dentate separation, mincut, PPR)
|
||||
# - SONA learning (embedding, trajectory, patterns)
|
||||
# - Witness chain construction and verification
|
||||
# - PII stripping (paths, emails, API keys)
|
||||
# - Content hash verification
|
||||
# - Ed25519 signatures
|
||||
# - End-to-end share pipeline
|
||||
# - Meta-learning (curiosity, regret, plateau)
|
||||
# - Midstream integration (scheduler, attractor, strange-loop, solver)
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
14
crates/mcp-brain-server/cloudbuild.yaml
Normal file
14
crates/mcp-brain-server/cloudbuild.yaml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
steps:
|
||||
- name: 'gcr.io/cloud-builders/docker'
|
||||
args:
|
||||
- 'build'
|
||||
- '-t'
|
||||
- 'gcr.io/$PROJECT_ID/ruvbrain:latest'
|
||||
- '-f'
|
||||
- 'crates/mcp-brain-server/Dockerfile'
|
||||
- '.'
|
||||
images:
|
||||
- 'gcr.io/$PROJECT_ID/ruvbrain:latest'
|
||||
timeout: '1800s'
|
||||
options:
|
||||
machineType: 'E2_HIGHCPU_8'
|
||||
230
crates/mcp-brain-server/deploy.sh
Executable file
230
crates/mcp-brain-server/deploy.sh
Executable file
|
|
@ -0,0 +1,230 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# RuVector Shared Brain - Google Cloud Deployment
|
||||
# Project: ruv-dev
|
||||
# Region: us-central1
|
||||
# Domain: brain.ruv.io (added later via Cloudflare)
|
||||
#
|
||||
# Two deployment paths:
|
||||
# Path A (dev): Cloud Run + direct URL (no LB/Cloud Armor/CDN)
|
||||
# Path B (prod): External HTTPS LB + serverless NEG + Cloud Armor + CDN + brain.ruv.io
|
||||
#
|
||||
# Usage:
|
||||
# ./deploy.sh # Path A (default — dev)
|
||||
# DEPLOY_PATH=B ./deploy.sh # Path B (prod with LB)
|
||||
|
||||
PROJECT_ID="${GCP_PROJECT_ID:-ruv-dev}"
|
||||
REGION="${GCP_REGION:-us-central1}"
|
||||
SERVICE_NAME="ruvbrain"
|
||||
BUCKET_NAME="ruvector-brain-${REGION}"
|
||||
DB_NAME="(default)"
|
||||
DEPLOY_PATH="${DEPLOY_PATH:-A}"
|
||||
|
||||
echo "=== RuVector Shared Brain Deployment ==="
|
||||
echo "Project: ${PROJECT_ID}"
|
||||
echo "Region: ${REGION}"
|
||||
echo "Bucket: ${BUCKET_NAME}"
|
||||
echo "DB: ${DB_NAME}"
|
||||
echo "Path: ${DEPLOY_PATH}"
|
||||
echo ""
|
||||
|
||||
# 1. Enable required APIs
|
||||
echo "=== Step 1: Enabling APIs ==="
|
||||
gcloud services enable \
|
||||
firestore.googleapis.com \
|
||||
run.googleapis.com \
|
||||
cloudbuild.googleapis.com \
|
||||
secretmanager.googleapis.com \
|
||||
storage.googleapis.com \
|
||||
--project="${PROJECT_ID}" --quiet
|
||||
|
||||
# 2. Create GCS bucket for RVF containers
|
||||
echo "=== Step 2: GCS Bucket ==="
|
||||
if ! gcloud storage buckets describe "gs://${BUCKET_NAME}" --project="${PROJECT_ID}" &>/dev/null; then
|
||||
gcloud storage buckets create "gs://${BUCKET_NAME}" \
|
||||
--project="${PROJECT_ID}" \
|
||||
--location="${REGION}" \
|
||||
--uniform-bucket-level-access \
|
||||
--public-access-prevention
|
||||
echo "Created bucket: ${BUCKET_NAME}"
|
||||
else
|
||||
echo "Bucket already exists: ${BUCKET_NAME}"
|
||||
fi
|
||||
|
||||
# Lifecycle: archive after 90 days, delete after 365 days
|
||||
gcloud storage buckets update "gs://${BUCKET_NAME}" \
|
||||
--lifecycle-file=<(cat <<'LIFECYCLE'
|
||||
{
|
||||
"rule": [
|
||||
{"action": {"type": "SetStorageClass", "storageClass": "NEARLINE"}, "condition": {"age": 90}},
|
||||
{"action": {"type": "Delete"}, "condition": {"age": 365}}
|
||||
]
|
||||
}
|
||||
LIFECYCLE
|
||||
)
|
||||
|
||||
# 3. Firestore — use the existing (default) database (free tier)
|
||||
echo "=== Step 3: Firestore ==="
|
||||
echo "Using existing (default) Firestore database"
|
||||
|
||||
# 4. Secret Manager — brain-specific secrets
|
||||
echo "=== Step 4: Secrets ==="
|
||||
if ! gcloud secrets describe brain-api-key --project="${PROJECT_ID}" &>/dev/null; then
|
||||
openssl rand -hex 32 | gcloud secrets create brain-api-key \
|
||||
--data-file=- --project="${PROJECT_ID}" --replication-policy="automatic"
|
||||
echo "Created secret: brain-api-key"
|
||||
else
|
||||
echo "Secret already exists: brain-api-key"
|
||||
fi
|
||||
|
||||
if ! gcloud secrets describe brain-signing-key --project="${PROJECT_ID}" &>/dev/null; then
|
||||
openssl rand -base64 64 | gcloud secrets create brain-signing-key \
|
||||
--data-file=- --project="${PROJECT_ID}" --replication-policy="automatic"
|
||||
echo "Created secret: brain-signing-key"
|
||||
else
|
||||
echo "Secret already exists: brain-signing-key"
|
||||
fi
|
||||
|
||||
# 5. Service account with minimal permissions
|
||||
echo "=== Step 5: IAM ==="
|
||||
SA_NAME="mcp-brain-server"
|
||||
SA="${SA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com"
|
||||
if ! gcloud iam service-accounts describe "${SA}" --project="${PROJECT_ID}" &>/dev/null; then
|
||||
gcloud iam service-accounts create "${SA_NAME}" \
|
||||
--project="${PROJECT_ID}" \
|
||||
--display-name="MCP Brain Server"
|
||||
echo "Created service account: ${SA}"
|
||||
else
|
||||
echo "Service account already exists: ${SA}"
|
||||
fi
|
||||
|
||||
# Grant permissions
|
||||
gcloud projects add-iam-policy-binding "${PROJECT_ID}" \
|
||||
--member="serviceAccount:${SA}" --role="roles/datastore.user" --quiet
|
||||
gcloud storage buckets add-iam-policy-binding "gs://${BUCKET_NAME}" \
|
||||
--member="serviceAccount:${SA}" --role="roles/storage.objectAdmin" --quiet
|
||||
for SECRET in brain-api-key brain-signing-key; do
|
||||
gcloud secrets add-iam-policy-binding "${SECRET}" \
|
||||
--project="${PROJECT_ID}" \
|
||||
--member="serviceAccount:${SA}" \
|
||||
--role="roles/secretmanager.secretAccessor" --quiet
|
||||
done
|
||||
|
||||
# 6. Build container image and push to GCR
|
||||
echo "=== Step 6: Build ==="
|
||||
FIRESTORE_URL="https://firestore.googleapis.com/v1/projects/${PROJECT_ID}/databases/(default)/documents"
|
||||
|
||||
echo "Building release binary..."
|
||||
cargo build --release -p mcp-brain-server
|
||||
|
||||
echo "Building Docker image..."
|
||||
cp target/release/mcp-brain-server /tmp/mcp-brain-server
|
||||
docker build -t "gcr.io/${PROJECT_ID}/${SERVICE_NAME}:latest" \
|
||||
-f crates/mcp-brain-server/Dockerfile.runtime /tmp/
|
||||
|
||||
echo "Pushing to GCR..."
|
||||
gcloud auth configure-docker gcr.io --quiet
|
||||
docker push "gcr.io/${PROJECT_ID}/${SERVICE_NAME}:latest"
|
||||
|
||||
# 7. Deploy Cloud Run
|
||||
echo "=== Step 7: Cloud Run Deploy ==="
|
||||
if [ "${DEPLOY_PATH}" = "A" ]; then
|
||||
AUTH_FLAG="--allow-unauthenticated"
|
||||
else
|
||||
AUTH_FLAG="--no-allow-unauthenticated"
|
||||
fi
|
||||
|
||||
gcloud run deploy "${SERVICE_NAME}" \
|
||||
--project="${PROJECT_ID}" \
|
||||
--region="${REGION}" \
|
||||
--image="gcr.io/${PROJECT_ID}/${SERVICE_NAME}:latest" \
|
||||
--service-account="${SA}" \
|
||||
--cpu=2 --memory=2Gi \
|
||||
--min-instances=0 --max-instances=10 \
|
||||
--concurrency=80 --port=8080 \
|
||||
--set-env-vars="GOOGLE_CLOUD_PROJECT=${PROJECT_ID},GCS_BUCKET=${BUCKET_NAME},FIRESTORE_URL=${FIRESTORE_URL},RUST_LOG=info" \
|
||||
--set-secrets="BRAIN_API_KEY=brain-api-key:latest,BRAIN_SIGNING_KEY=brain-signing-key:latest" \
|
||||
${AUTH_FLAG} --quiet
|
||||
|
||||
# Get the Cloud Run URL
|
||||
SERVICE_URL=$(gcloud run services describe "${SERVICE_NAME}" \
|
||||
--project="${PROJECT_ID}" --region="${REGION}" \
|
||||
--format='value(status.url)')
|
||||
echo ""
|
||||
echo "Cloud Run URL: ${SERVICE_URL}"
|
||||
|
||||
# 8. Domain setup (deferred — Cloudflare will handle brain.ruv.io later)
|
||||
if [ "${DEPLOY_PATH}" = "B" ]; then
|
||||
echo "=== Step 8: External HTTPS LB + Serverless NEG (Path B) ==="
|
||||
|
||||
# Serverless NEG → Cloud Run
|
||||
gcloud compute network-endpoint-groups create brain-neg \
|
||||
--project="${PROJECT_ID}" --region="${REGION}" \
|
||||
--network-endpoint-type=serverless \
|
||||
--cloud-run-service="${SERVICE_NAME}" 2>/dev/null || true
|
||||
|
||||
# Backend service
|
||||
gcloud compute backend-services create brain-backend \
|
||||
--project="${PROJECT_ID}" --global --protocol=HTTPS --port-name=http 2>/dev/null || true
|
||||
gcloud compute backend-services add-backend brain-backend \
|
||||
--project="${PROJECT_ID}" --global \
|
||||
--network-endpoint-group=brain-neg \
|
||||
--network-endpoint-group-region="${REGION}" 2>/dev/null || true
|
||||
|
||||
# URL map
|
||||
gcloud compute url-maps create brain-lb \
|
||||
--project="${PROJECT_ID}" --default-service=brain-backend 2>/dev/null || true
|
||||
|
||||
# Static IP + managed SSL cert
|
||||
gcloud compute addresses create brain-ip \
|
||||
--project="${PROJECT_ID}" --global 2>/dev/null || true
|
||||
gcloud compute ssl-certificates create brain-cert \
|
||||
--project="${PROJECT_ID}" --domains="brain.ruv.io" --global 2>/dev/null || true
|
||||
|
||||
# HTTPS proxy + forwarding rule
|
||||
gcloud compute target-https-proxies create brain-https-proxy \
|
||||
--project="${PROJECT_ID}" --url-map=brain-lb \
|
||||
--ssl-certificates=brain-cert 2>/dev/null || true
|
||||
BRAIN_IP=$(gcloud compute addresses describe brain-ip --project="${PROJECT_ID}" --global --format='value(address)')
|
||||
gcloud compute forwarding-rules create brain-https-rule \
|
||||
--project="${PROJECT_ID}" --global \
|
||||
--target-https-proxy=brain-https-proxy \
|
||||
--ports=443 --address="${BRAIN_IP}" 2>/dev/null || true
|
||||
|
||||
# Cloud Armor WAF
|
||||
echo "=== Step 9: Cloud Armor ==="
|
||||
gcloud compute security-policies create brain-waf-policy \
|
||||
--project="${PROJECT_ID}" --description="WAF for brain.ruv.io" 2>/dev/null || true
|
||||
gcloud compute security-policies rules create 1000 \
|
||||
--project="${PROJECT_ID}" --security-policy="brain-waf-policy" \
|
||||
--expression="true" --action="rate-based-ban" \
|
||||
--rate-limit-threshold-count=1000 --rate-limit-threshold-interval-sec=60 \
|
||||
--ban-duration-sec=300 2>/dev/null || true
|
||||
gcloud compute security-policies rules create 2000 \
|
||||
--project="${PROJECT_ID}" --security-policy="brain-waf-policy" \
|
||||
--expression="evaluatePreconfiguredExpr('sqli-v33-stable')" \
|
||||
--action="deny-403" 2>/dev/null || true
|
||||
gcloud compute security-policies rules create 2001 \
|
||||
--project="${PROJECT_ID}" --security-policy="brain-waf-policy" \
|
||||
--expression="evaluatePreconfiguredExpr('xss-v33-stable')" \
|
||||
--action="deny-403" 2>/dev/null || true
|
||||
gcloud compute backend-services update brain-backend \
|
||||
--project="${PROJECT_ID}" --global --security-policy=brain-waf-policy
|
||||
|
||||
# Cloud CDN
|
||||
gcloud compute backend-services update brain-backend \
|
||||
--project="${PROJECT_ID}" --global --enable-cdn
|
||||
|
||||
echo "DNS: A record brain.ruv.io → ${BRAIN_IP}"
|
||||
else
|
||||
echo ""
|
||||
echo "=== Path A: Direct Cloud Run URL ==="
|
||||
echo "Domain mapping deferred — Cloudflare DNS will be configured separately."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Deployment Complete ==="
|
||||
echo "Service URL: ${SERVICE_URL}"
|
||||
echo "Health: ${SERVICE_URL}/v1/health"
|
||||
echo "Status: ${SERVICE_URL}/v1/status"
|
||||
253
crates/mcp-brain-server/src/aggregate.rs
Normal file
253
crates/mcp-brain-server/src/aggregate.rs
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
//! Byzantine-tolerant federated aggregation
|
||||
|
||||
/// Federated aggregator with Byzantine outlier detection
|
||||
pub struct ByzantineAggregator {
|
||||
std_threshold: f64,
|
||||
min_contributions: usize,
|
||||
}
|
||||
|
||||
impl ByzantineAggregator {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
std_threshold: 2.0,
|
||||
min_contributions: 3,
|
||||
}
|
||||
}
|
||||
|
||||
/// Aggregate embeddings, filtering outliers beyond 2 sigma
|
||||
pub fn aggregate(&self, embeddings: &[Vec<f32>]) -> Option<Vec<f32>> {
|
||||
if embeddings.len() < self.min_contributions {
|
||||
return None;
|
||||
}
|
||||
let dim = embeddings[0].len();
|
||||
|
||||
// Compute mean per dimension
|
||||
let n = embeddings.len() as f64;
|
||||
let mut mean = vec![0.0f64; dim];
|
||||
for emb in embeddings {
|
||||
for (i, &v) in emb.iter().enumerate() {
|
||||
mean[i] += v as f64;
|
||||
}
|
||||
}
|
||||
for m in &mut mean {
|
||||
*m /= n;
|
||||
}
|
||||
|
||||
// Compute std dev per dimension
|
||||
let mut std_dev = vec![0.0f64; dim];
|
||||
for emb in embeddings {
|
||||
for (i, &v) in emb.iter().enumerate() {
|
||||
std_dev[i] += (v as f64 - mean[i]).powi(2);
|
||||
}
|
||||
}
|
||||
for s in &mut std_dev {
|
||||
*s = (*s / n).sqrt();
|
||||
}
|
||||
|
||||
// Filter outliers: exclude embeddings with any dimension > 2 sigma from mean
|
||||
let inliers: Vec<&Vec<f32>> = embeddings
|
||||
.iter()
|
||||
.filter(|emb| {
|
||||
emb.iter().enumerate().all(|(i, &v)| {
|
||||
let dev = (v as f64 - mean[i]).abs();
|
||||
std_dev[i] < 1e-10 || dev <= self.std_threshold * std_dev[i]
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
if inliers.len() < self.min_contributions {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Compute filtered mean
|
||||
let n_inliers = inliers.len() as f64;
|
||||
let mut result = vec![0.0f32; dim];
|
||||
for emb in &inliers {
|
||||
for (i, &v) in emb.iter().enumerate() {
|
||||
result[i] += v / n_inliers as f32;
|
||||
}
|
||||
}
|
||||
|
||||
Some(result)
|
||||
}
|
||||
|
||||
/// Aggregate with reputation-weighted contributions
|
||||
///
|
||||
/// Each contribution has a reputation weight. The weighted mean is
|
||||
/// computed first, then the standard 2 sigma outlier filter is applied.
|
||||
pub fn aggregate_weighted(&self, contributions: &[(Vec<f32>, f64)]) -> Option<Vec<f32>> {
|
||||
if contributions.len() < self.min_contributions {
|
||||
return None;
|
||||
}
|
||||
let dim = contributions[0].0.len();
|
||||
let total_weight: f64 = contributions.iter().map(|(_, w)| *w).sum();
|
||||
if total_weight < 1e-10 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Compute weighted mean
|
||||
let mut mean = vec![0.0f64; dim];
|
||||
for (emb, weight) in contributions {
|
||||
for (i, &v) in emb.iter().enumerate() {
|
||||
mean[i] += v as f64 * weight;
|
||||
}
|
||||
}
|
||||
for m in &mut mean {
|
||||
*m /= total_weight;
|
||||
}
|
||||
|
||||
// Compute weighted std dev
|
||||
let mut std_dev = vec![0.0f64; dim];
|
||||
for (emb, weight) in contributions {
|
||||
for (i, &v) in emb.iter().enumerate() {
|
||||
std_dev[i] += weight * (v as f64 - mean[i]).powi(2);
|
||||
}
|
||||
}
|
||||
for s in &mut std_dev {
|
||||
*s = (*s / total_weight).sqrt();
|
||||
}
|
||||
|
||||
// Filter outliers: exclude contributions with any dimension > 2 sigma
|
||||
let inliers: Vec<(&Vec<f32>, f64)> = contributions
|
||||
.iter()
|
||||
.filter(|(emb, _)| {
|
||||
emb.iter().enumerate().all(|(i, &v)| {
|
||||
let dev = (v as f64 - mean[i]).abs();
|
||||
std_dev[i] < 1e-10 || dev <= self.std_threshold * std_dev[i]
|
||||
})
|
||||
})
|
||||
.map(|(emb, w)| (emb, *w))
|
||||
.collect();
|
||||
|
||||
if inliers.len() < self.min_contributions {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Compute filtered weighted mean
|
||||
let inlier_weight: f64 = inliers.iter().map(|(_, w)| w).sum();
|
||||
if inlier_weight < 1e-10 {
|
||||
return None;
|
||||
}
|
||||
let mut result = vec![0.0f32; dim];
|
||||
for (emb, weight) in &inliers {
|
||||
for (i, &v) in emb.iter().enumerate() {
|
||||
result[i] += (v as f64 * weight / inlier_weight) as f32;
|
||||
}
|
||||
}
|
||||
|
||||
Some(result)
|
||||
}
|
||||
|
||||
/// Count how many embeddings would be filtered as outliers
|
||||
pub fn count_outliers(&self, embeddings: &[Vec<f32>]) -> usize {
|
||||
if embeddings.len() < 2 {
|
||||
return 0;
|
||||
}
|
||||
let dim = embeddings[0].len();
|
||||
let n = embeddings.len() as f64;
|
||||
let mut mean = vec![0.0f64; dim];
|
||||
for emb in embeddings {
|
||||
for (i, &v) in emb.iter().enumerate() {
|
||||
mean[i] += v as f64;
|
||||
}
|
||||
}
|
||||
for m in &mut mean {
|
||||
*m /= n;
|
||||
}
|
||||
let mut std_dev = vec![0.0f64; dim];
|
||||
for emb in embeddings {
|
||||
for (i, &v) in emb.iter().enumerate() {
|
||||
std_dev[i] += (v as f64 - mean[i]).powi(2);
|
||||
}
|
||||
}
|
||||
for s in &mut std_dev {
|
||||
*s = (*s / n).sqrt();
|
||||
}
|
||||
|
||||
embeddings
|
||||
.iter()
|
||||
.filter(|emb| {
|
||||
emb.iter().enumerate().any(|(i, &v)| {
|
||||
let dev = (v as f64 - mean[i]).abs();
|
||||
std_dev[i] > 1e-10 && dev > self.std_threshold * std_dev[i]
|
||||
})
|
||||
})
|
||||
.count()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ByzantineAggregator {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_aggregate_normal() {
|
||||
let agg = ByzantineAggregator::new();
|
||||
let embeddings = vec![
|
||||
vec![1.0, 2.0, 3.0],
|
||||
vec![1.1, 2.1, 3.1],
|
||||
vec![0.9, 1.9, 2.9],
|
||||
];
|
||||
let result = agg.aggregate(&embeddings).unwrap();
|
||||
assert!((result[0] - 1.0).abs() < 0.2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_outliers() {
|
||||
let agg = ByzantineAggregator::new();
|
||||
let embeddings = vec![
|
||||
vec![1.0, 2.0, 3.0],
|
||||
vec![1.1, 2.1, 3.1],
|
||||
vec![0.9, 1.9, 2.9],
|
||||
vec![1.0, 2.0, 3.0],
|
||||
vec![1.05, 2.05, 3.05],
|
||||
vec![0.95, 1.95, 2.95],
|
||||
vec![100.0, 200.0, 300.0], // Outlier — far beyond 2 sigma with enough inliers
|
||||
];
|
||||
assert!(agg.count_outliers(&embeddings) >= 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insufficient_contributions() {
|
||||
let agg = ByzantineAggregator::new();
|
||||
let embeddings = vec![vec![1.0, 2.0]];
|
||||
assert!(agg.aggregate(&embeddings).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_aggregate_weighted() {
|
||||
let agg = ByzantineAggregator::new();
|
||||
let contributions = vec![
|
||||
(vec![1.0, 2.0, 3.0], 1.0),
|
||||
(vec![1.1, 2.1, 3.1], 2.0), // Higher reputation
|
||||
(vec![0.9, 1.9, 2.9], 1.0),
|
||||
];
|
||||
let result = agg.aggregate_weighted(&contributions).unwrap();
|
||||
// Weighted mean should lean towards the second contribution
|
||||
assert!(result[0] > 0.9 && result[0] < 1.2);
|
||||
assert!(result[1] > 1.9 && result[1] < 2.2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_weighted_outlier_filtering() {
|
||||
let agg = ByzantineAggregator::new();
|
||||
let contributions = vec![
|
||||
(vec![1.0, 2.0, 3.0], 1.0),
|
||||
(vec![1.1, 2.1, 3.1], 1.0),
|
||||
(vec![0.9, 1.9, 2.9], 1.0),
|
||||
(vec![1.0, 2.0, 3.0], 1.0),
|
||||
(vec![100.0, 200.0, 300.0], 0.5), // Outlier with low reputation
|
||||
];
|
||||
let result = agg.aggregate_weighted(&contributions);
|
||||
// Should succeed after filtering the outlier
|
||||
assert!(result.is_some());
|
||||
let r = result.unwrap();
|
||||
assert!((r[0] - 1.0).abs() < 0.2);
|
||||
}
|
||||
}
|
||||
95
crates/mcp-brain-server/src/auth.rs
Normal file
95
crates/mcp-brain-server/src/auth.rs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
//! API key validation and contributor pseudonym derivation
|
||||
|
||||
use axum::{
|
||||
extract::FromRequestParts,
|
||||
http::{request::Parts, StatusCode},
|
||||
};
|
||||
use sha3::{Shake256, digest::{Update, ExtendableOutput, XofReader}};
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
/// Authenticated contributor extracted from request
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthenticatedContributor {
|
||||
pub pseudonym: String,
|
||||
pub api_key_prefix: String,
|
||||
pub is_system: bool,
|
||||
}
|
||||
|
||||
impl AuthenticatedContributor {
|
||||
/// Derive pseudonym from API key using SHAKE-256
|
||||
pub fn from_api_key(api_key: &str) -> Self {
|
||||
let mut hasher = Shake256::default();
|
||||
hasher.update(b"ruvector-brain-pseudonym:");
|
||||
hasher.update(api_key.as_bytes());
|
||||
let mut reader = hasher.finalize_xof();
|
||||
let mut buf = [0u8; 16];
|
||||
reader.read(&mut buf);
|
||||
let pseudonym = hex::encode(buf);
|
||||
let prefix = if api_key.len() >= 8 {
|
||||
api_key[..8].to_string()
|
||||
} else {
|
||||
api_key.to_string()
|
||||
};
|
||||
Self {
|
||||
pseudonym,
|
||||
api_key_prefix: prefix,
|
||||
is_system: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn system_seed() -> Self {
|
||||
Self {
|
||||
pseudonym: "ruvector-seed".to_string(),
|
||||
api_key_prefix: "system".to_string(),
|
||||
is_system: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimum API key length to prevent trivially weak keys.
|
||||
const MIN_API_KEY_LEN: usize = 8;
|
||||
|
||||
/// Cached system key — read from env once, avoids per-request env::var lookup.
|
||||
/// If BRAIN_SYSTEM_KEY is unset, system key authentication is disabled entirely
|
||||
/// (no hardcoded fallback).
|
||||
static SYSTEM_KEY: std::sync::LazyLock<Option<String>> = std::sync::LazyLock::new(|| {
|
||||
std::env::var("BRAIN_SYSTEM_KEY").ok().filter(|k| !k.is_empty())
|
||||
});
|
||||
|
||||
#[axum::async_trait]
|
||||
impl<S> FromRequestParts<S> for AuthenticatedContributor
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = (StatusCode, &'static str);
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
let auth_header = parts
|
||||
.headers
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "Missing Authorization header"))?;
|
||||
|
||||
let api_key = auth_header
|
||||
.strip_prefix("Bearer ")
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "Invalid Authorization format"))?;
|
||||
|
||||
if api_key.len() < MIN_API_KEY_LEN || api_key.len() > 256 {
|
||||
return Err((StatusCode::UNAUTHORIZED, "Invalid API key"));
|
||||
}
|
||||
|
||||
// Recognise system-level API keys (cached from BRAIN_SYSTEM_KEY env).
|
||||
// If BRAIN_SYSTEM_KEY is not set, system key auth is disabled — no hardcoded fallback.
|
||||
if let Some(ref system_key) = *SYSTEM_KEY {
|
||||
if api_key.as_bytes().ct_eq(system_key.as_bytes()).into() {
|
||||
return Ok(Self {
|
||||
pseudonym: "ruvector-seed".to_string(),
|
||||
api_key_prefix: "system".to_string(),
|
||||
is_system: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self::from_api_key(api_key))
|
||||
}
|
||||
}
|
||||
222
crates/mcp-brain-server/src/cognitive.rs
Normal file
222
crates/mcp-brain-server/src/cognitive.rs
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
//! Cognitive engine integrating Hopfield networks, dentate gyrus, and HDC
|
||||
|
||||
use ruvector_nervous_system::hdc::{HdcMemory, Hypervector};
|
||||
use ruvector_nervous_system::hopfield::ModernHopfield;
|
||||
use ruvector_nervous_system::DentateGyrus;
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::panic;
|
||||
|
||||
/// Cognitive engine for knowledge cluster quality assessment
|
||||
pub struct CognitiveEngine {
|
||||
/// Content-addressable memory recall
|
||||
hopfield: ModernHopfield,
|
||||
/// Pattern separation for collision detection
|
||||
dentate: DentateGyrus,
|
||||
/// Fast binary similarity filtering
|
||||
hdc: HdcMemory,
|
||||
/// Anomaly detection threshold
|
||||
anomaly_threshold: f32,
|
||||
}
|
||||
|
||||
impl CognitiveEngine {
|
||||
/// Create a new cognitive engine with the given embedding dimension
|
||||
pub fn new(embedding_dim: usize) -> Self {
|
||||
let dim = embedding_dim.max(1);
|
||||
// DentateGyrus panics if k == 0 or k > output_dim.
|
||||
// Use output_dim = dim * 10 and k = output_dim / 50, clamped safely.
|
||||
let output_dim = dim * 10;
|
||||
let k = (output_dim / 50).max(1).min(output_dim);
|
||||
|
||||
let dentate = panic::catch_unwind(|| DentateGyrus::new(dim, output_dim, k, 42))
|
||||
.unwrap_or_else(|_| {
|
||||
// Absolute fallback: minimal safe params
|
||||
DentateGyrus::new(dim, dim.max(2), 1, 42)
|
||||
});
|
||||
|
||||
Self {
|
||||
hopfield: ModernHopfield::new(dim, 1.0),
|
||||
dentate,
|
||||
hdc: HdcMemory::new(),
|
||||
anomaly_threshold: 0.3,
|
||||
}
|
||||
}
|
||||
|
||||
/// Store a pattern in all cognitive subsystems
|
||||
pub fn store_pattern(&mut self, id: &str, embedding: &[f32]) {
|
||||
// Store in Hopfield network
|
||||
let _ = self.hopfield.store(embedding.to_vec());
|
||||
|
||||
// Store in HDC memory
|
||||
let mut hasher = DefaultHasher::new();
|
||||
id.hash(&mut hasher);
|
||||
let seed = hasher.finish();
|
||||
let hv = Hypervector::from_seed(seed);
|
||||
self.hdc.store(id, hv);
|
||||
|
||||
// Encode via DentateGyrus for collision detection (result unused here
|
||||
// but exercises the pathway)
|
||||
let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| self.dentate.encode(embedding)));
|
||||
}
|
||||
|
||||
/// Recall the closest stored pattern via Hopfield retrieval
|
||||
pub fn recall(&self, query: &[f32]) -> Option<Vec<f32>> {
|
||||
self.hopfield.retrieve(query).ok()
|
||||
}
|
||||
|
||||
/// Recall the top-k stored patterns by attention weight
|
||||
pub fn recall_k(&self, query: &[f32], k: usize) -> Vec<(usize, Vec<f32>, f32)> {
|
||||
self.hopfield.retrieve_k(query, k).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Check if a new embedding is anomalous relative to its cluster
|
||||
pub fn is_anomalous(&self, embedding: &[f32], centroid: &[f32]) -> bool {
|
||||
// Try Hopfield retrieval distance first
|
||||
let hopfield_dist = match self.hopfield.retrieve(embedding) {
|
||||
Ok(retrieved) => euclidean_distance(&retrieved, embedding),
|
||||
Err(_) => euclidean_distance(embedding, centroid),
|
||||
};
|
||||
|
||||
// Also check DentateGyrus sparsity: encode both and compare
|
||||
let separation = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||
let enc_emb = self.dentate.encode(embedding);
|
||||
let enc_cent = self.dentate.encode(centroid);
|
||||
1.0 - enc_emb.jaccard_similarity(&enc_cent) as f64
|
||||
}))
|
||||
.unwrap_or(0.0);
|
||||
|
||||
// Anomalous if Hopfield distance exceeds threshold OR pattern
|
||||
// separation is very high (indicating highly dissimilar patterns)
|
||||
hopfield_dist > self.anomaly_threshold as f64 || separation > 0.95
|
||||
}
|
||||
|
||||
/// Assess cluster quality (coherence)
|
||||
pub fn cluster_coherence(&self, embeddings: &[Vec<f32>]) -> f64 {
|
||||
if embeddings.len() < 2 {
|
||||
return 1.0;
|
||||
}
|
||||
let dim = embeddings[0].len();
|
||||
let n = embeddings.len() as f64;
|
||||
let mut centroid = vec![0.0f64; dim];
|
||||
for emb in embeddings {
|
||||
for (i, &v) in emb.iter().enumerate() {
|
||||
centroid[i] += v as f64;
|
||||
}
|
||||
}
|
||||
for c in &mut centroid {
|
||||
*c /= n;
|
||||
}
|
||||
|
||||
let avg_dist: f64 = embeddings
|
||||
.iter()
|
||||
.map(|emb| {
|
||||
emb.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &v)| (v as f64 - centroid[i]).powi(2))
|
||||
.sum::<f64>()
|
||||
.sqrt()
|
||||
})
|
||||
.sum::<f64>()
|
||||
/ n;
|
||||
|
||||
let base_coherence = 1.0 / (1.0 + avg_dist);
|
||||
|
||||
// Additionally check Hopfield retrieval quality as coherence signal
|
||||
let centroid_f32: Vec<f32> = centroid.iter().map(|&c| c as f32).collect();
|
||||
let hopfield_signal = match self.hopfield.retrieve(¢roid_f32) {
|
||||
Ok(retrieved) => {
|
||||
// High cosine similarity with centroid => good coherence
|
||||
let dot: f64 = retrieved
|
||||
.iter()
|
||||
.zip(centroid_f32.iter())
|
||||
.map(|(a, b)| (*a as f64) * (*b as f64))
|
||||
.sum();
|
||||
let norm_r: f64 = retrieved.iter().map(|x| (*x as f64).powi(2)).sum::<f64>().sqrt();
|
||||
let norm_c: f64 = centroid_f32.iter().map(|x| (*x as f64).powi(2)).sum::<f64>().sqrt();
|
||||
if norm_r > 1e-10 && norm_c > 1e-10 {
|
||||
(dot / (norm_r * norm_c)).max(0.0)
|
||||
} else {
|
||||
0.5
|
||||
}
|
||||
}
|
||||
Err(_) => 0.5, // No patterns stored yet
|
||||
};
|
||||
|
||||
// Blend base coherence with Hopfield signal
|
||||
base_coherence * 0.7 + hopfield_signal * 0.3
|
||||
}
|
||||
|
||||
/// Separate a pattern using DentateGyrus sparse encoding.
|
||||
///
|
||||
/// Returns a high-dimensional sparse representation that maximizes
|
||||
/// separation between similar inputs (collision-resistant encoding).
|
||||
pub fn pattern_separate(&self, embedding: &[f32]) -> Vec<f32> {
|
||||
panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||
self.dentate.encode_dense(embedding)
|
||||
}))
|
||||
.unwrap_or_else(|_| embedding.to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CognitiveEngine {
|
||||
fn default() -> Self {
|
||||
Self::new(128)
|
||||
}
|
||||
}
|
||||
|
||||
fn euclidean_distance(a: &[f32], b: &[f32]) -> f64 {
|
||||
a.iter()
|
||||
.zip(b.iter())
|
||||
.map(|(x, y)| ((*x as f64) - (*y as f64)).powi(2))
|
||||
.sum::<f64>()
|
||||
.sqrt()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_cognitive_engine_creation() {
|
||||
let engine = CognitiveEngine::new(64);
|
||||
assert!(engine.anomaly_threshold > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_store_and_recall() {
|
||||
let mut engine = CognitiveEngine::new(4);
|
||||
engine.store_pattern("test-1", &[1.0, 0.0, 0.0, 0.0]);
|
||||
let recalled = engine.recall(&[0.9, 0.1, 0.0, 0.0]);
|
||||
assert!(recalled.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pattern_separate() {
|
||||
let engine = CognitiveEngine::new(8);
|
||||
let sep = engine.pattern_separate(&[1.0, 0.5, 0.3, 0.1, 0.0, 0.0, 0.0, 0.0]);
|
||||
// Should return a vector (either sparse encoding or fallback)
|
||||
assert!(!sep.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cluster_coherence_single() {
|
||||
let engine = CognitiveEngine::new(4);
|
||||
// Single embedding => perfect coherence
|
||||
assert_eq!(engine.cluster_coherence(&[vec![1.0, 2.0, 3.0, 4.0]]), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anomaly_detection() {
|
||||
let mut engine = CognitiveEngine::new(4);
|
||||
engine.store_pattern("normal", &[1.0, 1.0, 1.0, 1.0]);
|
||||
// Very different from centroid should be anomalous
|
||||
let is_anom = engine.is_anomalous(&[100.0, 100.0, 100.0, 100.0], &[1.0, 1.0, 1.0, 1.0]);
|
||||
assert!(is_anom);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_engine() {
|
||||
let engine = CognitiveEngine::default();
|
||||
assert!(engine.anomaly_threshold > 0.0);
|
||||
}
|
||||
}
|
||||
126
crates/mcp-brain-server/src/drift.rs
Normal file
126
crates/mcp-brain-server/src/drift.rs
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
//! Embedding drift detection and monitoring
|
||||
//!
|
||||
//! Uses ruvector-delta-core for precise delta computation between
|
||||
//! consecutive embeddings.
|
||||
|
||||
use crate::types::DriftReport;
|
||||
use ruvector_delta_core::{Delta, VectorDelta};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Monitor embedding drift across knowledge clusters
|
||||
pub struct DriftMonitor {
|
||||
/// Historical centroids per domain
|
||||
centroids: HashMap<String, Vec<Vec<f32>>>,
|
||||
window_size: usize,
|
||||
cv_threshold: f64,
|
||||
}
|
||||
|
||||
impl DriftMonitor {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
centroids: HashMap::new(),
|
||||
window_size: 50,
|
||||
cv_threshold: 0.5,
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a new embedding for a domain
|
||||
pub fn record(&mut self, domain: &str, embedding: &[f32]) {
|
||||
let history = self.centroids.entry(domain.to_string()).or_default();
|
||||
history.push(embedding.to_vec());
|
||||
if history.len() > self.window_size * 2 {
|
||||
history.drain(..self.window_size);
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute drift report for a domain
|
||||
pub fn compute_drift(&self, domain: Option<&str>) -> DriftReport {
|
||||
let domain_key = domain.unwrap_or("global");
|
||||
let history = match self.centroids.get(domain_key) {
|
||||
Some(h) if h.len() >= 2 => h,
|
||||
_ => {
|
||||
return DriftReport {
|
||||
domain: domain.map(|s| s.to_string()),
|
||||
coefficient_of_variation: 0.0,
|
||||
is_drifting: false,
|
||||
delta_sparsity: 1.0,
|
||||
trend: "insufficient_data".to_string(),
|
||||
suggested_action: "collect more data".to_string(),
|
||||
window_size: 0,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Compute distances between consecutive centroids using VectorDelta
|
||||
let mut distances = Vec::new();
|
||||
let mut identity_count = 0usize;
|
||||
for pair in history.windows(2) {
|
||||
let delta = VectorDelta::compute(&pair[0], &pair[1]);
|
||||
let dist = delta.l2_norm() as f64;
|
||||
if delta.is_identity() {
|
||||
identity_count += 1;
|
||||
}
|
||||
distances.push(dist);
|
||||
}
|
||||
|
||||
let mean_dist: f64 = distances.iter().sum::<f64>() / distances.len() as f64;
|
||||
let variance: f64 = distances
|
||||
.iter()
|
||||
.map(|d| (d - mean_dist).powi(2))
|
||||
.sum::<f64>()
|
||||
/ distances.len() as f64;
|
||||
let std_dev = variance.sqrt();
|
||||
let cv = if mean_dist > 1e-10 {
|
||||
std_dev / mean_dist
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let is_drifting = cv > self.cv_threshold;
|
||||
let recent_trend = if distances.len() >= 3 {
|
||||
let recent_avg: f64 = distances[distances.len() - 3..].iter().sum::<f64>() / 3.0;
|
||||
if recent_avg > mean_dist * 1.3 {
|
||||
"increasing"
|
||||
} else if recent_avg < mean_dist * 0.7 {
|
||||
"decreasing"
|
||||
} else {
|
||||
"stable"
|
||||
}
|
||||
} else {
|
||||
"unknown"
|
||||
};
|
||||
|
||||
// Use identity delta count for sparsity instead of threshold-based
|
||||
let sparsity = identity_count as f64 / distances.len() as f64;
|
||||
|
||||
let suggested = if is_drifting {
|
||||
"investigate recent contributions for potential poisoning".to_string()
|
||||
} else if sparsity > 0.7 {
|
||||
"knowledge is stagnant, encourage new contributions".to_string()
|
||||
} else {
|
||||
"healthy drift levels".to_string()
|
||||
};
|
||||
|
||||
DriftReport {
|
||||
domain: domain.map(|s| s.to_string()),
|
||||
coefficient_of_variation: cv,
|
||||
is_drifting,
|
||||
delta_sparsity: sparsity,
|
||||
trend: recent_trend.to_string(),
|
||||
suggested_action: suggested,
|
||||
window_size: history.len(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute embedding delta norms between two embeddings
|
||||
pub fn compute_embedding_delta(&self, old: &[f32], new: &[f32]) -> (f32, f32) {
|
||||
let delta = VectorDelta::compute(&old.to_vec(), &new.to_vec());
|
||||
(delta.l2_norm(), delta.l1_norm())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DriftMonitor {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
263
crates/mcp-brain-server/src/embeddings.rs
Normal file
263
crates/mcp-brain-server/src/embeddings.rs
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
//! Neural embedding engine using ruvllm's full RLM recursive embedder pipeline.
|
||||
//!
|
||||
//! Three-phase architecture:
|
||||
//! - Phase 1: HashEmbedder base (FNV-1a + bigrams, L2-normalized) — fallback for empty corpus
|
||||
//! - Phase 2: RlmEmbedder recursive context-aware embeddings (active when corpus ≥ 3)
|
||||
//! - Phase 3: candle sentence transformer (future — requires `candle` feature)
|
||||
//!
|
||||
//! The RlmEmbedder produces embeddings conditioned on the existing knowledge corpus:
|
||||
//! - Storage uses CorpusConditioned variant (stable over time)
|
||||
//! - Search uses QueryConditioned variant (optimized for retrieval relevance)
|
||||
|
||||
use ruvllm::bitnet::rlm_embedder::{
|
||||
BaseEmbedder, EmbeddingVariant, FlatNeighborStore, HashEmbedder, RlmEmbedder,
|
||||
RlmEmbedderConfig,
|
||||
};
|
||||
|
||||
/// Embedding dimension used across the brain.
|
||||
pub const EMBED_DIM: usize = 128;
|
||||
|
||||
/// Minimum corpus size before RlmEmbedder contextualization kicks in.
|
||||
/// At 50+ documents the corpus is diverse enough for RLM's contextual
|
||||
/// neighbor-weighted embeddings to outperform hash-based embeddings.
|
||||
/// RLM uses QueryConditioned mode for search (optimized retrieval) and
|
||||
/// CorpusConditioned for storage (stable over time).
|
||||
const RLM_MIN_CORPUS: usize = 50;
|
||||
|
||||
/// Wraps the ruvllm embedding pipeline for the brain server.
|
||||
///
|
||||
/// On startup the corpus is empty so embeddings use pure `HashEmbedder`.
|
||||
/// As memories are shared, the `FlatNeighborStore` grows and recursive
|
||||
/// context-aware re-embedding kicks in — each new embedding is conditioned
|
||||
/// on its neighbors in the knowledge corpus.
|
||||
pub struct EmbeddingEngine {
|
||||
embedder: HashEmbedder,
|
||||
store: FlatNeighborStore,
|
||||
/// Config for query-conditioned embeddings (search)
|
||||
query_config: RlmEmbedderConfig,
|
||||
/// Config for corpus-conditioned embeddings (storage)
|
||||
corpus_config: RlmEmbedderConfig,
|
||||
}
|
||||
|
||||
impl EmbeddingEngine {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
embedder: HashEmbedder::new(EMBED_DIM),
|
||||
store: FlatNeighborStore::new(EMBED_DIM),
|
||||
query_config: RlmEmbedderConfig {
|
||||
embed_dim: EMBED_DIM,
|
||||
max_iterations: 2,
|
||||
convergence_threshold: 0.98,
|
||||
num_neighbors: 5,
|
||||
w_base: 0.6,
|
||||
w_context: 0.3,
|
||||
w_anti: 0.1,
|
||||
contradiction_threshold: 0.3,
|
||||
variant: EmbeddingVariant::QueryConditioned,
|
||||
},
|
||||
corpus_config: RlmEmbedderConfig {
|
||||
embed_dim: EMBED_DIM,
|
||||
max_iterations: 2,
|
||||
convergence_threshold: 0.98,
|
||||
num_neighbors: 5,
|
||||
w_base: 0.7,
|
||||
w_context: 0.25,
|
||||
w_anti: 0.05,
|
||||
contradiction_threshold: 0.3,
|
||||
variant: EmbeddingVariant::CorpusConditioned,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Embed text for query (search). Uses QueryConditioned variant when corpus
|
||||
/// is large enough — optimizes for retrieval relevance.
|
||||
pub fn embed(&self, text: &str) -> Vec<f32> {
|
||||
if self.store.len() < RLM_MIN_CORPUS {
|
||||
return self.hash_embed(text);
|
||||
}
|
||||
let rlm = RlmEmbedder::new(
|
||||
self.embedder.clone(),
|
||||
self.store.clone(),
|
||||
self.query_config.clone(),
|
||||
);
|
||||
match rlm.embed(text, None) {
|
||||
Ok(result) => result.embedding,
|
||||
Err(_) => self.hash_embed(text),
|
||||
}
|
||||
}
|
||||
|
||||
/// Embed text for storage. Uses CorpusConditioned variant when corpus
|
||||
/// is large enough — produces stable embeddings less sensitive to phrasing.
|
||||
pub fn embed_for_storage(&self, text: &str) -> Vec<f32> {
|
||||
if self.store.len() < RLM_MIN_CORPUS {
|
||||
return self.hash_embed(text);
|
||||
}
|
||||
let rlm = RlmEmbedder::new(
|
||||
self.embedder.clone(),
|
||||
self.store.clone(),
|
||||
self.corpus_config.clone(),
|
||||
);
|
||||
match rlm.embed(text, None) {
|
||||
Ok(result) => result.embedding,
|
||||
Err(_) => self.hash_embed(text),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure HashEmbedder fallback.
|
||||
fn hash_embed(&self, text: &str) -> Vec<f32> {
|
||||
self.embedder
|
||||
.embed(text)
|
||||
.unwrap_or_else(|_| vec![0.0; EMBED_DIM])
|
||||
}
|
||||
|
||||
/// Add a document to the neighbor store so future embeddings are contextualized.
|
||||
pub fn add_to_corpus(&mut self, id: &str, embedding: Vec<f32>, cluster_id: Option<usize>) {
|
||||
self.store.add(id, embedding, cluster_id);
|
||||
}
|
||||
|
||||
/// Number of documents in the corpus.
|
||||
pub fn corpus_size(&self) -> usize {
|
||||
self.store.len()
|
||||
}
|
||||
|
||||
/// Whether RlmEmbedder is active (corpus large enough).
|
||||
pub fn is_rlm_active(&self) -> bool {
|
||||
self.store.len() >= RLM_MIN_CORPUS
|
||||
}
|
||||
|
||||
/// Embedding dimension.
|
||||
pub fn dim(&self) -> usize {
|
||||
EMBED_DIM
|
||||
}
|
||||
|
||||
/// Engine name for status reporting.
|
||||
pub fn engine_name(&self) -> &'static str {
|
||||
if self.is_rlm_active() {
|
||||
"ruvllm::RlmEmbedder"
|
||||
} else {
|
||||
"ruvllm::HashEmbedder"
|
||||
}
|
||||
}
|
||||
|
||||
/// Combine title + content + tags into embeddable text.
|
||||
pub fn prepare_text(title: &str, content: &str, tags: &[String]) -> String {
|
||||
let tag_str = tags.join(" ");
|
||||
format!("{} {} {}", title, content, tag_str)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ruvllm::bitnet::rlm_embedder::cosine_similarity;
|
||||
|
||||
#[test]
|
||||
fn test_embed_basic() {
|
||||
let engine = EmbeddingEngine::new();
|
||||
let emb = engine.embed("hello world");
|
||||
assert_eq!(emb.len(), EMBED_DIM);
|
||||
let norm: f32 = emb.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
assert!((norm - 1.0).abs() < 0.01, "norm={}", norm);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deterministic() {
|
||||
let engine = EmbeddingEngine::new();
|
||||
let a = engine.embed("test input");
|
||||
let b = engine.embed("test input");
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_similar_texts_closer() {
|
||||
let engine = EmbeddingEngine::new();
|
||||
let a = engine.embed("rust programming language");
|
||||
let b = engine.embed("rust code development");
|
||||
let c = engine.embed("banana fruit recipe");
|
||||
|
||||
let sim_ab = cosine_similarity(&a, &b);
|
||||
let sim_ac = cosine_similarity(&a, &c);
|
||||
assert!(sim_ab > sim_ac, "sim_ab={} sim_ac={}", sim_ab, sim_ac);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rlm_activation() {
|
||||
let mut engine = EmbeddingEngine::new();
|
||||
assert!(!engine.is_rlm_active());
|
||||
assert_eq!(engine.engine_name(), "ruvllm::HashEmbedder");
|
||||
|
||||
// Add corpus entries — RlmEmbedder activates at RLM_MIN_CORPUS (50)
|
||||
for i in 0..RLM_MIN_CORPUS {
|
||||
let text = format!("document about topic {} in domain {}", i, i % 10);
|
||||
let emb = engine.hash_embed(&text);
|
||||
engine.add_to_corpus(&format!("doc-{}", i), emb, None);
|
||||
}
|
||||
assert!(engine.is_rlm_active());
|
||||
assert_eq!(engine.engine_name(), "ruvllm::RlmEmbedder");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rlm_embed_produces_valid_output() {
|
||||
let mut engine = EmbeddingEngine::new();
|
||||
// Build corpus large enough to activate RlmEmbedder
|
||||
for i in 0..RLM_MIN_CORPUS {
|
||||
let text = format!("document about topic {} in domain {}", i, i % 10);
|
||||
let emb = engine.hash_embed(&text);
|
||||
engine.add_to_corpus(&format!("doc-{}", i), emb, None);
|
||||
}
|
||||
assert!(engine.is_rlm_active());
|
||||
|
||||
// Query embedding (context-aware)
|
||||
let q_emb = engine.embed("rust programming systems");
|
||||
assert_eq!(q_emb.len(), EMBED_DIM);
|
||||
let norm: f32 = q_emb.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
assert!((norm - 1.0).abs() < 0.1, "query norm={}", norm);
|
||||
|
||||
// Storage embedding (corpus-conditioned)
|
||||
let s_emb = engine.embed_for_storage("rust programming systems");
|
||||
assert_eq!(s_emb.len(), EMBED_DIM);
|
||||
let norm: f32 = s_emb.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
assert!((norm - 1.0).abs() < 0.1, "storage norm={}", norm);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rlm_contextual_shift() {
|
||||
let mut engine = EmbeddingEngine::new();
|
||||
// Build corpus large enough to activate RlmEmbedder
|
||||
for i in 0..RLM_MIN_CORPUS {
|
||||
let text = format!("document {} covering area {}", i, i % 5);
|
||||
let emb = engine.hash_embed(&text);
|
||||
engine.add_to_corpus(&format!("doc-{}", i), emb, None);
|
||||
}
|
||||
assert!(engine.is_rlm_active());
|
||||
|
||||
// RlmEmbedder should produce different embeddings than raw HashEmbedder
|
||||
let text = "neural embedding graph knowledge";
|
||||
let hash_emb = engine.hash_embed(text);
|
||||
let rlm_emb = engine.embed(text);
|
||||
|
||||
// They should differ (contextual shift applied)
|
||||
let sim = cosine_similarity(&hash_emb, &rlm_emb);
|
||||
assert!(
|
||||
sim < 0.999,
|
||||
"RlmEmbedder should shift from raw hash; similarity={:.4}",
|
||||
sim
|
||||
);
|
||||
// But still be somewhat similar (same base text)
|
||||
assert!(
|
||||
sim > 0.1,
|
||||
"RlmEmbedder shift too extreme; similarity={:.4}",
|
||||
sim
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prepare_text() {
|
||||
let text = EmbeddingEngine::prepare_text(
|
||||
"Title",
|
||||
"Content here",
|
||||
&["tag1".into(), "tag2".into()],
|
||||
);
|
||||
assert_eq!(text, "Title Content here tag1 tag2");
|
||||
}
|
||||
}
|
||||
283
crates/mcp-brain-server/src/gcs.rs
Normal file
283
crates/mcp-brain-server/src/gcs.rs
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
//! Google Cloud Storage client for RVF cognitive containers
|
||||
//!
|
||||
//! Architecture: DashMap serves as hot in-memory cache. When `GCS_BUCKET` is
|
||||
//! configured and running on GCE/Cloud Run, tokens are automatically fetched
|
||||
//! from the metadata server and refreshed before expiry.
|
||||
//!
|
||||
//! When `GCS_BUCKET` is absent (local dev), operates as local-only.
|
||||
|
||||
use thiserror::Error;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum GcsError {
|
||||
#[error("Upload failed: {0}")]
|
||||
UploadFailed(String),
|
||||
#[error("Download failed: {0}")]
|
||||
DownloadFailed(String),
|
||||
#[error("Delete failed: {0}")]
|
||||
DeleteFailed(String),
|
||||
#[error("Object not found: {0}")]
|
||||
NotFound(String),
|
||||
}
|
||||
|
||||
/// Cached access token with expiry
|
||||
struct TokenCache {
|
||||
token: String,
|
||||
expires_at: std::time::Instant,
|
||||
}
|
||||
|
||||
/// GCS client for storing RVF cognitive containers.
|
||||
///
|
||||
/// Write-through: local DashMap cache + GCS REST when credentials available.
|
||||
/// Tokens are refreshed from GCE metadata server or `GCS_TOKEN` env var.
|
||||
pub struct GcsClient {
|
||||
bucket: String,
|
||||
/// Static token from env (local dev) or None for metadata server
|
||||
static_token: Option<String>,
|
||||
/// Cached metadata server token (auto-refreshed)
|
||||
token_cache: RwLock<Option<TokenCache>>,
|
||||
http: reqwest::Client,
|
||||
/// In-memory cache (always populated)
|
||||
local_store: dashmap::DashMap<String, Vec<u8>>,
|
||||
/// Whether we're on GCE (metadata server available)
|
||||
use_metadata_server: bool,
|
||||
}
|
||||
|
||||
impl GcsClient {
|
||||
pub fn new() -> Self {
|
||||
let bucket = std::env::var("GCS_BUCKET")
|
||||
.unwrap_or_else(|_| "ruvector-brain-dev".to_string());
|
||||
let static_token = std::env::var("GCS_TOKEN").ok();
|
||||
let use_metadata_server = static_token.is_none()
|
||||
&& std::env::var("GCS_BUCKET").is_ok();
|
||||
|
||||
if static_token.is_some() {
|
||||
tracing::info!("GCS persistence enabled (static token) for bucket: {bucket}");
|
||||
} else if use_metadata_server {
|
||||
tracing::info!("GCS persistence enabled (metadata server) for bucket: {bucket}");
|
||||
} else {
|
||||
tracing::info!("GCS running in local-only mode (no GCS_BUCKET)");
|
||||
}
|
||||
|
||||
Self {
|
||||
bucket,
|
||||
static_token,
|
||||
token_cache: RwLock::new(None),
|
||||
http: reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.unwrap_or_default(),
|
||||
local_store: dashmap::DashMap::new(),
|
||||
use_metadata_server,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether GCS persistence is enabled
|
||||
pub fn is_persistent(&self) -> bool {
|
||||
self.static_token.is_some() || self.use_metadata_server
|
||||
}
|
||||
|
||||
/// Get a valid access token, refreshing from metadata server if needed
|
||||
async fn get_token(&self) -> Option<String> {
|
||||
// Static token (env var) takes priority
|
||||
if let Some(ref token) = self.static_token {
|
||||
return Some(token.clone());
|
||||
}
|
||||
|
||||
if !self.use_metadata_server {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Check cached token
|
||||
{
|
||||
let cache = self.token_cache.read().await;
|
||||
if let Some(ref tc) = *cache {
|
||||
// Refresh 5 minutes before expiry
|
||||
if tc.expires_at > std::time::Instant::now() + std::time::Duration::from_secs(300) {
|
||||
return Some(tc.token.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh from metadata server
|
||||
self.refresh_token().await
|
||||
}
|
||||
|
||||
/// Fetch a new token from the GCE metadata server
|
||||
async fn refresh_token(&self) -> Option<String> {
|
||||
let url = "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token";
|
||||
let resp = self.http
|
||||
.get(url)
|
||||
.header("Metadata-Flavor", "Google")
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
tracing::warn!("GCE metadata token request failed: {}", resp.status());
|
||||
return None;
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct TokenResponse {
|
||||
access_token: String,
|
||||
expires_in: u64,
|
||||
}
|
||||
|
||||
let token_resp: TokenResponse = resp.json().await.ok()?;
|
||||
let expires_at = std::time::Instant::now()
|
||||
+ std::time::Duration::from_secs(token_resp.expires_in);
|
||||
|
||||
let token = token_resp.access_token.clone();
|
||||
|
||||
// Cache the new token
|
||||
{
|
||||
let mut cache = self.token_cache.write().await;
|
||||
*cache = Some(TokenCache {
|
||||
token: token_resp.access_token,
|
||||
expires_at,
|
||||
});
|
||||
}
|
||||
|
||||
tracing::debug!("GCS token refreshed, expires in {}s", token_resp.expires_in);
|
||||
Some(token)
|
||||
}
|
||||
|
||||
/// Upload RVF container bytes (cache + GCS write-through)
|
||||
pub async fn upload_rvf(
|
||||
&self,
|
||||
contributor: &str,
|
||||
memory_id: &str,
|
||||
data: &[u8],
|
||||
) -> Result<String, GcsError> {
|
||||
let path = format!("{contributor}/{memory_id}.rvf");
|
||||
|
||||
// Always cache locally
|
||||
self.local_store.insert(path.clone(), data.to_vec());
|
||||
|
||||
// Write-through to GCS
|
||||
if let Some(token) = self.get_token().await {
|
||||
let url = format!(
|
||||
"https://storage.googleapis.com/upload/storage/v1/b/{}/o?uploadType=media&name={}",
|
||||
self.bucket,
|
||||
urlencoding::encode(&path)
|
||||
);
|
||||
let result = self.http
|
||||
.post(&url)
|
||||
.bearer_auth(&token)
|
||||
.header("Content-Type", "application/octet-stream")
|
||||
.body(data.to_vec())
|
||||
.send()
|
||||
.await;
|
||||
match result {
|
||||
Ok(resp) if resp.status().as_u16() == 401 => {
|
||||
// Token expired mid-flight, try once with fresh token
|
||||
tracing::info!("GCS token expired on upload, refreshing...");
|
||||
if let Some(new_token) = self.refresh_token().await {
|
||||
let retry = self.http
|
||||
.post(&url)
|
||||
.bearer_auth(&new_token)
|
||||
.header("Content-Type", "application/octet-stream")
|
||||
.body(data.to_vec())
|
||||
.send()
|
||||
.await;
|
||||
if let Ok(resp) = retry {
|
||||
if !resp.status().is_success() {
|
||||
tracing::warn!("GCS upload {path} retry returned {}", resp.status());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(resp) if !resp.status().is_success() => {
|
||||
tracing::warn!("GCS upload {path} returned {}", resp.status());
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("GCS upload {path} failed: {e}");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(format!("gs://{}/{}", self.bucket, path))
|
||||
}
|
||||
|
||||
/// Download RVF container bytes (cache-first, then GCS)
|
||||
pub async fn download_rvf(
|
||||
&self,
|
||||
contributor: &str,
|
||||
memory_id: &str,
|
||||
) -> Result<Vec<u8>, GcsError> {
|
||||
let path = format!("{contributor}/{memory_id}.rvf");
|
||||
|
||||
// Check local cache first
|
||||
if let Some(data) = self.local_store.get(&path) {
|
||||
return Ok(data.clone());
|
||||
}
|
||||
|
||||
// Try GCS
|
||||
if let Some(token) = self.get_token().await {
|
||||
let url = format!(
|
||||
"https://storage.googleapis.com/storage/v1/b/{}/o/{}?alt=media",
|
||||
self.bucket,
|
||||
urlencoding::encode(&path)
|
||||
);
|
||||
match self.http.get(&url).bearer_auth(&token).send().await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let bytes = resp.bytes().await
|
||||
.map_err(|e| GcsError::DownloadFailed(e.to_string()))?;
|
||||
let data = bytes.to_vec();
|
||||
// Populate cache
|
||||
self.local_store.insert(path, data.clone());
|
||||
return Ok(data);
|
||||
}
|
||||
Ok(resp) if resp.status().as_u16() == 404 => {
|
||||
return Err(GcsError::NotFound(path));
|
||||
}
|
||||
Ok(resp) => {
|
||||
tracing::warn!("GCS download {path} returned {}", resp.status());
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("GCS download {path} failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(GcsError::NotFound(path))
|
||||
}
|
||||
|
||||
/// Delete RVF container (cache + GCS)
|
||||
pub async fn delete_rvf(
|
||||
&self,
|
||||
contributor: &str,
|
||||
memory_id: &str,
|
||||
) -> Result<(), GcsError> {
|
||||
let path = format!("{contributor}/{memory_id}.rvf");
|
||||
self.local_store.remove(&path);
|
||||
|
||||
// Delete from GCS
|
||||
if let Some(token) = self.get_token().await {
|
||||
let url = format!(
|
||||
"https://storage.googleapis.com/storage/v1/b/{}/o/{}",
|
||||
self.bucket,
|
||||
urlencoding::encode(&path)
|
||||
);
|
||||
if let Err(e) = self.http.delete(&url).bearer_auth(&token).send().await {
|
||||
tracing::warn!("GCS delete {path} failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get bucket name
|
||||
pub fn bucket(&self) -> &str {
|
||||
&self.bucket
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for GcsClient {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
527
crates/mcp-brain-server/src/graph.rs
Normal file
527
crates/mcp-brain-server/src/graph.rs
Normal file
|
|
@ -0,0 +1,527 @@
|
|||
//! In-memory knowledge graph with similarity edges
|
||||
//!
|
||||
//! Integrates ruvector-mincut for real graph partitioning and
|
||||
//! ruvector-solver for PPR-based ranked search.
|
||||
|
||||
use crate::types::*;
|
||||
use ruvector_mincut::{DynamicMinCut, MinCutBuilder};
|
||||
use ruvector_solver::forward_push::ForwardPushSolver;
|
||||
use ruvector_solver::types::CsrMatrix;
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Knowledge graph maintaining similarity relationships
|
||||
pub struct KnowledgeGraph {
|
||||
nodes: HashMap<Uuid, GraphNode>,
|
||||
edges: Vec<GraphEdge>,
|
||||
similarity_threshold: f64,
|
||||
/// Real min-cut structure (lazy-initialized)
|
||||
mincut: Option<DynamicMinCut>,
|
||||
/// CSR cache for solver-based search
|
||||
csr_cache: Option<CsrMatrix<f64>>,
|
||||
/// Maps graph indices to memory IDs
|
||||
node_ids: Vec<Uuid>,
|
||||
/// Reverse index: Uuid → position in node_ids (O(1) lookup)
|
||||
node_index: HashMap<Uuid, usize>,
|
||||
/// Whether the CSR cache needs rebuilding
|
||||
csr_dirty: bool,
|
||||
}
|
||||
|
||||
struct GraphNode {
|
||||
embedding: Vec<f32>,
|
||||
category: BrainCategory,
|
||||
}
|
||||
|
||||
struct GraphEdge {
|
||||
source: Uuid,
|
||||
target: Uuid,
|
||||
weight: f64,
|
||||
}
|
||||
|
||||
impl KnowledgeGraph {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
nodes: HashMap::new(),
|
||||
edges: Vec::new(),
|
||||
similarity_threshold: 0.55,
|
||||
mincut: None,
|
||||
csr_cache: None,
|
||||
node_ids: Vec::new(),
|
||||
node_index: HashMap::new(),
|
||||
csr_dirty: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a memory as a graph node, creating edges to similar nodes
|
||||
pub fn add_memory(&mut self, memory: &BrainMemory) {
|
||||
let new_node = GraphNode {
|
||||
embedding: memory.embedding.clone(),
|
||||
category: memory.category.clone(),
|
||||
};
|
||||
|
||||
// Compute edges to existing nodes
|
||||
let mut new_edges = Vec::new();
|
||||
for (existing_id, existing_node) in &self.nodes {
|
||||
let sim = cosine_similarity(&new_node.embedding, &existing_node.embedding);
|
||||
if sim >= self.similarity_threshold {
|
||||
new_edges.push(GraphEdge {
|
||||
source: memory.id,
|
||||
target: *existing_id,
|
||||
weight: sim,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let new_idx = self.node_ids.len();
|
||||
|
||||
// Insert into DynamicMinCut if initialized
|
||||
if let Some(ref mut mincut) = self.mincut {
|
||||
let u = new_idx as u64;
|
||||
for edge in &new_edges {
|
||||
if let Some(&v_pos) = self.node_index.get(&edge.target) {
|
||||
let _ = mincut.insert_edge(u, v_pos as u64, edge.weight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.nodes.insert(memory.id, new_node);
|
||||
self.node_index.insert(memory.id, new_idx);
|
||||
self.node_ids.push(memory.id);
|
||||
self.edges.extend(new_edges);
|
||||
|
||||
// Mark CSR as dirty — deferred rebuild until next query
|
||||
self.csr_dirty = true;
|
||||
}
|
||||
|
||||
/// Remove a memory from the graph
|
||||
pub fn remove_memory(&mut self, id: &Uuid) {
|
||||
self.nodes.remove(id);
|
||||
self.edges.retain(|e| e.source != *id && e.target != *id);
|
||||
self.node_ids.retain(|nid| nid != id);
|
||||
// Rebuild the index after removal (positions shifted)
|
||||
self.node_index.clear();
|
||||
for (i, nid) in self.node_ids.iter().enumerate() {
|
||||
self.node_index.insert(*nid, i);
|
||||
}
|
||||
// Invalidate caches — full rebuild needed
|
||||
self.mincut = None;
|
||||
self.csr_cache = None;
|
||||
self.csr_dirty = false;
|
||||
}
|
||||
|
||||
/// Get top-k similar memories by graph traversal.
|
||||
///
|
||||
/// Uses ForwardPushSolver PPR for graph-aware relevance when CSR is
|
||||
/// available, merging with cosine similarity scores. Falls back to
|
||||
/// brute-force cosine if CSR is unavailable.
|
||||
pub fn ranked_search(&mut self, query_embedding: &[f32], k: usize) -> Vec<(Uuid, f64)> {
|
||||
self.ensure_csr();
|
||||
// Brute-force cosine scores
|
||||
let mut cosine_scores: Vec<(Uuid, f64)> = self
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|(id, node)| (*id, cosine_similarity(query_embedding, &node.embedding)))
|
||||
.collect();
|
||||
|
||||
// Boost with PageRank scores when available
|
||||
if let Some(ppr_map) = self.pagerank_scores(query_embedding, k) {
|
||||
for (id, score) in &mut cosine_scores {
|
||||
if let Some(&ppr) = ppr_map.get(id) {
|
||||
*score = *score * 0.6 + ppr * 0.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cosine_scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
cosine_scores.truncate(k);
|
||||
cosine_scores
|
||||
}
|
||||
|
||||
/// Compute PageRank-based scores using ForwardPushSolver.
|
||||
///
|
||||
/// Builds a CsrMatrix from graph edges and runs PPR from the node
|
||||
/// most similar to `query_embedding`. Returns a map of node ID to
|
||||
/// PPR score, or `None` if PPR cannot be computed.
|
||||
pub fn pagerank_search(
|
||||
&mut self,
|
||||
query_embedding: &[f32],
|
||||
k: usize,
|
||||
) -> Vec<(Uuid, f64)> {
|
||||
self.ensure_csr();
|
||||
if let Some(ppr_map) = self.pagerank_scores(query_embedding, k) {
|
||||
let mut results: Vec<(Uuid, f64)> = ppr_map.into_iter().collect();
|
||||
results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
results.truncate(k);
|
||||
results
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure CSR cache is up-to-date (lazy rebuild)
|
||||
fn ensure_csr(&mut self) {
|
||||
if self.csr_dirty {
|
||||
self.rebuild_csr();
|
||||
self.csr_dirty = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal: compute raw PPR scores keyed by node ID.
|
||||
fn pagerank_scores(
|
||||
&self,
|
||||
query_embedding: &[f32],
|
||||
k: usize,
|
||||
) -> Option<HashMap<Uuid, f64>> {
|
||||
let csr = self.csr_cache.as_ref()?;
|
||||
if csr.rows == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Find closest node as source for PPR (use index for O(1) lookup)
|
||||
let source = self
|
||||
.nodes
|
||||
.iter()
|
||||
.filter_map(|(id, node)| {
|
||||
let &pos = self.node_index.get(id)?;
|
||||
Some((pos, cosine_similarity(query_embedding, &node.embedding)))
|
||||
})
|
||||
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
|
||||
.map(|(idx, _)| idx)?;
|
||||
|
||||
if source >= csr.rows {
|
||||
return None;
|
||||
}
|
||||
|
||||
let solver = ForwardPushSolver::default_params();
|
||||
let ppr_results = solver.top_k(csr, source, k * 3).ok()?;
|
||||
|
||||
let mut map = HashMap::new();
|
||||
for (idx, ppr_score) in ppr_results {
|
||||
if let Some(id) = self.node_ids.get(idx) {
|
||||
map.insert(*id, ppr_score);
|
||||
}
|
||||
}
|
||||
Some(map)
|
||||
}
|
||||
|
||||
/// Partition with full result including cut_value and edge_strengths.
|
||||
///
|
||||
/// Uses DynamicMinCut if available (>= 3 nodes), falls back to Union-Find.
|
||||
pub fn partition(&self, min_cluster_size: usize) -> Vec<KnowledgeCluster> {
|
||||
self.partition_full(min_cluster_size).0
|
||||
}
|
||||
|
||||
/// Partition returning (clusters, cut_value, edge_strengths).
|
||||
pub fn partition_full(&self, min_cluster_size: usize) -> (Vec<KnowledgeCluster>, f64, Vec<EdgeStrengthInfo>) {
|
||||
// Try real MinCut partitioning
|
||||
if self.nodes.len() >= 3 {
|
||||
if let Some((clusters, cut_val, strengths)) = self.partition_via_mincut_full(min_cluster_size) {
|
||||
if clusters.len() >= 2 {
|
||||
return (clusters, cut_val, strengths);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Union-Find based clustering
|
||||
let clusters = self.partition_union_find(min_cluster_size);
|
||||
if clusters.len() >= 2 {
|
||||
let strengths = self.compute_edge_strengths(&clusters);
|
||||
return (clusters, 0.0, strengths);
|
||||
}
|
||||
|
||||
// Final fallback: category-based partitioning
|
||||
let clusters = self.partition_by_category(min_cluster_size);
|
||||
let strengths = self.compute_edge_strengths(&clusters);
|
||||
(clusters, 0.0, strengths)
|
||||
}
|
||||
|
||||
/// Category-based partitioning fallback: group nodes by their BrainCategory
|
||||
fn partition_by_category(&self, min_cluster_size: usize) -> Vec<KnowledgeCluster> {
|
||||
let mut by_category: HashMap<BrainCategory, Vec<Uuid>> = HashMap::new();
|
||||
for (&id, node) in &self.nodes {
|
||||
by_category.entry(node.category.clone()).or_default().push(id);
|
||||
}
|
||||
|
||||
let mut clusters = Vec::new();
|
||||
let mut cluster_id = 0u32;
|
||||
for (_, members) in by_category {
|
||||
if members.len() >= min_cluster_size {
|
||||
clusters.push(self.build_cluster(cluster_id, &members));
|
||||
cluster_id += 1;
|
||||
}
|
||||
}
|
||||
clusters
|
||||
}
|
||||
|
||||
/// Attempt partitioning via DynamicMinCut (returns clusters, cut_value, edge_strengths)
|
||||
fn partition_via_mincut_full(&self, min_cluster_size: usize) -> Option<(Vec<KnowledgeCluster>, f64, Vec<EdgeStrengthInfo>)> {
|
||||
let edges: Vec<(u64, u64, f64)> = self
|
||||
.edges
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
let &u = self.node_index.get(&e.source)? ;
|
||||
let &v = self.node_index.get(&e.target)?;
|
||||
Some((u as u64, v as u64, e.weight))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mincut = MinCutBuilder::new()
|
||||
.exact()
|
||||
.with_edges(edges)
|
||||
.build()
|
||||
.ok()?;
|
||||
|
||||
let result = mincut.min_cut();
|
||||
let cut_value = result.value;
|
||||
let (side_a, side_b) = result.partition?;
|
||||
|
||||
let mut clusters = Vec::new();
|
||||
let mut cluster_id = 0u32;
|
||||
|
||||
for side in [side_a, side_b] {
|
||||
let members: Vec<Uuid> = side
|
||||
.iter()
|
||||
.filter_map(|&idx| self.node_ids.get(idx as usize).copied())
|
||||
.collect();
|
||||
|
||||
if members.len() < min_cluster_size {
|
||||
continue;
|
||||
}
|
||||
|
||||
let cluster = self.build_cluster(cluster_id, &members);
|
||||
clusters.push(cluster);
|
||||
cluster_id += 1;
|
||||
}
|
||||
|
||||
if clusters.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let strengths = self.compute_edge_strengths(&clusters);
|
||||
Some((clusters, cut_value, strengths))
|
||||
}
|
||||
|
||||
/// Union-Find based clustering (fallback)
|
||||
fn partition_union_find(&self, min_cluster_size: usize) -> Vec<KnowledgeCluster> {
|
||||
let ids: Vec<Uuid> = self.nodes.keys().copied().collect();
|
||||
let mut parent: HashMap<Uuid, Uuid> = ids.iter().map(|&id| (id, id)).collect();
|
||||
|
||||
fn find(parent: &mut HashMap<Uuid, Uuid>, x: Uuid) -> Uuid {
|
||||
let p = parent[&x];
|
||||
if p == x {
|
||||
return x;
|
||||
}
|
||||
let root = find(parent, p);
|
||||
parent.insert(x, root);
|
||||
root
|
||||
}
|
||||
|
||||
fn union(parent: &mut HashMap<Uuid, Uuid>, a: Uuid, b: Uuid) {
|
||||
let ra = find(parent, a);
|
||||
let rb = find(parent, b);
|
||||
if ra != rb {
|
||||
parent.insert(ra, rb);
|
||||
}
|
||||
}
|
||||
|
||||
for edge in &self.edges {
|
||||
union(&mut parent, edge.source, edge.target);
|
||||
}
|
||||
|
||||
let mut clusters_map: HashMap<Uuid, Vec<Uuid>> = HashMap::new();
|
||||
for &id in &ids {
|
||||
let root = find(&mut parent, id);
|
||||
clusters_map.entry(root).or_default().push(id);
|
||||
}
|
||||
|
||||
let mut clusters = Vec::new();
|
||||
let mut cluster_id = 0u32;
|
||||
for (_, members) in clusters_map {
|
||||
if members.len() < min_cluster_size {
|
||||
continue;
|
||||
}
|
||||
clusters.push(self.build_cluster(cluster_id, &members));
|
||||
cluster_id += 1;
|
||||
}
|
||||
clusters
|
||||
}
|
||||
|
||||
/// Build a KnowledgeCluster from member IDs
|
||||
fn build_cluster(&self, id: u32, members: &[Uuid]) -> KnowledgeCluster {
|
||||
let dim = self.nodes.values().next().map(|n| n.embedding.len()).unwrap_or(0);
|
||||
let mut centroid = vec![0.0f32; dim];
|
||||
let mut category_counts: HashMap<BrainCategory, usize> = HashMap::new();
|
||||
let mut embeddings = Vec::new();
|
||||
for mid in members {
|
||||
if let Some(node) = self.nodes.get(mid) {
|
||||
for (i, &v) in node.embedding.iter().enumerate() {
|
||||
if i < centroid.len() {
|
||||
centroid[i] += v;
|
||||
}
|
||||
}
|
||||
*category_counts.entry(node.category.clone()).or_default() += 1;
|
||||
embeddings.push(node.embedding.clone());
|
||||
}
|
||||
}
|
||||
let n = members.len() as f32;
|
||||
for v in &mut centroid {
|
||||
*v /= n;
|
||||
}
|
||||
let dominant = category_counts
|
||||
.into_iter()
|
||||
.max_by_key(|(_, c)| *c)
|
||||
.map(|(cat, _)| cat)
|
||||
.unwrap_or(BrainCategory::Pattern);
|
||||
|
||||
// Compute coherence: average cosine similarity of members to centroid
|
||||
let coherence = if embeddings.len() < 2 {
|
||||
1.0
|
||||
} else {
|
||||
let avg_sim: f64 = embeddings
|
||||
.iter()
|
||||
.map(|emb| cosine_similarity(emb, ¢roid))
|
||||
.sum::<f64>()
|
||||
/ embeddings.len() as f64;
|
||||
avg_sim
|
||||
};
|
||||
|
||||
KnowledgeCluster {
|
||||
id,
|
||||
memory_ids: members.to_vec(),
|
||||
centroid,
|
||||
dominant_category: dominant,
|
||||
size: members.len(),
|
||||
coherence,
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute edge strengths between pairs of clusters
|
||||
/// Uses HashSet for O(1) membership lookups instead of Vec::contains O(n)
|
||||
fn compute_edge_strengths(&self, clusters: &[KnowledgeCluster]) -> Vec<EdgeStrengthInfo> {
|
||||
use std::collections::HashSet;
|
||||
|
||||
// Pre-build HashSets for O(1) membership checks
|
||||
let cluster_sets: Vec<HashSet<Uuid>> = clusters
|
||||
.iter()
|
||||
.map(|c| c.memory_ids.iter().copied().collect())
|
||||
.collect();
|
||||
|
||||
let mut strengths = Vec::new();
|
||||
for (i, ca) in clusters.iter().enumerate() {
|
||||
let set_a = &cluster_sets[i];
|
||||
for (j, cb) in clusters.iter().enumerate().skip(i + 1) {
|
||||
let set_b = &cluster_sets[j];
|
||||
// Sum weights of edges crossing between these two clusters
|
||||
let mut cross_weight = 0.0f64;
|
||||
let mut cross_count = 0u32;
|
||||
for edge in &self.edges {
|
||||
let src_in_a = set_a.contains(&edge.source);
|
||||
let tgt_in_b = set_b.contains(&edge.target);
|
||||
let src_in_b = set_b.contains(&edge.source);
|
||||
let tgt_in_a = set_a.contains(&edge.target);
|
||||
if (src_in_a && tgt_in_b) || (src_in_b && tgt_in_a) {
|
||||
cross_weight += edge.weight;
|
||||
cross_count += 1;
|
||||
}
|
||||
}
|
||||
if cross_count > 0 {
|
||||
strengths.push(EdgeStrengthInfo {
|
||||
source_cluster: ca.id,
|
||||
target_cluster: cb.id,
|
||||
strength: cross_weight / cross_count as f64,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
strengths
|
||||
}
|
||||
|
||||
/// Rebuild the DynamicMinCut from all current edges
|
||||
pub fn rebuild_mincut(&mut self) {
|
||||
let edges: Vec<(u64, u64, f64)> = self
|
||||
.edges
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
let &u = self.node_index.get(&e.source)?;
|
||||
let &v = self.node_index.get(&e.target)?;
|
||||
Some((u as u64, v as u64, e.weight))
|
||||
})
|
||||
.collect();
|
||||
|
||||
self.mincut = MinCutBuilder::new()
|
||||
.exact()
|
||||
.with_edges(edges)
|
||||
.build()
|
||||
.ok();
|
||||
}
|
||||
|
||||
/// Rebuild the CsrMatrix from the adjacency list
|
||||
pub fn rebuild_csr(&mut self) {
|
||||
let n = self.node_ids.len();
|
||||
if n == 0 {
|
||||
self.csr_cache = None;
|
||||
return;
|
||||
}
|
||||
|
||||
let entries: Vec<(usize, usize, f64)> = self
|
||||
.edges
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
let &u = self.node_index.get(&e.source)?;
|
||||
let &v = self.node_index.get(&e.target)?;
|
||||
Some((u, v, e.weight))
|
||||
})
|
||||
.collect();
|
||||
|
||||
self.csr_cache = Some(CsrMatrix::<f64>::from_coo(n, n, entries));
|
||||
}
|
||||
|
||||
/// Get the k nearest graph neighbors for a given memory ID.
|
||||
/// Returns (neighbor_id, edge_weight) sorted by descending weight.
|
||||
pub fn get_neighbors(&self, id: &Uuid, k: usize) -> Vec<(Uuid, f64)> {
|
||||
let mut neighbors: Vec<(Uuid, f64)> = self
|
||||
.edges
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
if e.source == *id {
|
||||
Some((e.target, e.weight))
|
||||
} else if e.target == *id {
|
||||
Some((e.source, e.weight))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
neighbors.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
neighbors.truncate(k);
|
||||
neighbors
|
||||
}
|
||||
|
||||
/// Get graph stats
|
||||
pub fn node_count(&self) -> usize {
|
||||
self.nodes.len()
|
||||
}
|
||||
|
||||
pub fn edge_count(&self) -> usize {
|
||||
self.edges.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for KnowledgeGraph {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f64 {
|
||||
if a.len() != b.len() || a.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let dot: f64 = a.iter().zip(b.iter()).map(|(x, y)| (*x as f64) * (*y as f64)).sum();
|
||||
let norm_a: f64 = a.iter().map(|x| (*x as f64).powi(2)).sum::<f64>().sqrt();
|
||||
let norm_b: f64 = b.iter().map(|x| (*x as f64).powi(2)).sum::<f64>().sqrt();
|
||||
if norm_a < 1e-10 || norm_b < 1e-10 {
|
||||
return 0.0;
|
||||
}
|
||||
dot / (norm_a * norm_b)
|
||||
}
|
||||
23
crates/mcp-brain-server/src/lib.rs
Normal file
23
crates/mcp-brain-server/src/lib.rs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
//! mcp-brain-server: Cloud Run backend for RuVector Shared Brain
|
||||
//!
|
||||
//! Provides REST API for storing, searching, voting, and managing shared knowledge.
|
||||
//! Every piece of knowledge is an RVF cognitive container with witness chains,
|
||||
//! Ed25519 signatures, and differential privacy proofs.
|
||||
|
||||
pub mod aggregate;
|
||||
pub mod auth;
|
||||
pub mod cognitive;
|
||||
pub mod drift;
|
||||
pub mod embeddings;
|
||||
pub mod gcs;
|
||||
pub mod graph;
|
||||
pub mod pipeline;
|
||||
pub mod ranking;
|
||||
pub mod rate_limit;
|
||||
pub mod reputation;
|
||||
pub mod routes;
|
||||
pub mod store;
|
||||
pub mod tests;
|
||||
pub mod midstream;
|
||||
pub mod types;
|
||||
pub mod verify;
|
||||
53
crates/mcp-brain-server/src/main.rs
Normal file
53
crates/mcp-brain-server/src/main.rs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
use mcp_brain_server::routes;
|
||||
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
|
||||
tracing_subscriber::registry()
|
||||
.with(fmt::layer().with_writer(std::io::stderr))
|
||||
.with(filter)
|
||||
.init();
|
||||
|
||||
let port: u16 = std::env::var("PORT")
|
||||
.unwrap_or_else(|_| "8080".to_string())
|
||||
.parse()?;
|
||||
|
||||
let app = routes::create_router().await;
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{port}")).await?;
|
||||
tracing::info!("mcp-brain-server listening on port {port}");
|
||||
tracing::info!("Endpoints: brain.ruv.io | π.ruv.io");
|
||||
|
||||
// Graceful shutdown: wait for SIGTERM (Cloud Run sends this) or Ctrl+C,
|
||||
// then allow in-flight requests 10s to complete before terminating.
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(shutdown_signal())
|
||||
.await?;
|
||||
tracing::info!("Server shut down gracefully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn shutdown_signal() {
|
||||
let ctrl_c = async {
|
||||
tokio::signal::ctrl_c()
|
||||
.await
|
||||
.expect("failed to install Ctrl+C handler");
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
let terminate = async {
|
||||
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||
.expect("failed to install SIGTERM handler")
|
||||
.recv()
|
||||
.await;
|
||||
};
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let terminate = std::future::pending::<()>();
|
||||
|
||||
tokio::select! {
|
||||
_ = ctrl_c => tracing::info!("Received Ctrl+C, starting graceful shutdown"),
|
||||
_ = terminate => tracing::info!("Received SIGTERM, starting graceful shutdown"),
|
||||
}
|
||||
}
|
||||
136
crates/mcp-brain-server/src/midstream.rs
Normal file
136
crates/mcp-brain-server/src/midstream.rs
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
//! Midstream Platform integration for real-time streaming analysis (ADR-077).
|
||||
//!
|
||||
//! Wraps four midstream crates behind feature flags:
|
||||
//! - **nanosecond-scheduler**: Background task scheduling with nanosecond precision
|
||||
//! - **temporal-attractor-studio**: Lyapunov exponent analysis for embedding trajectories
|
||||
//! - **temporal-neural-solver**: Certified temporal predictions with solver gates
|
||||
//! - **strange-loop**: Recursive meta-cognitive reasoning with safety bounds
|
||||
|
||||
use crate::types::AppState;
|
||||
|
||||
// ── Attractor Analysis (temporal-attractor-studio) ─────────────────────
|
||||
|
||||
/// Compute Lyapunov exponent for a category's embedding trajectory.
|
||||
/// Positive λ = chaotic (knowledge diverging), negative = stable (converging).
|
||||
/// Returns None if trajectory is too short (need ≥10 points for meaningful estimate).
|
||||
pub fn analyze_category_attractor(
|
||||
embeddings: &[Vec<f32>],
|
||||
) -> Option<temporal_attractor_studio::LyapunovResult> {
|
||||
if embeddings.len() < 10 {
|
||||
return None;
|
||||
}
|
||||
// Convert f32 embeddings to f64 trajectories for FTLE computation
|
||||
let trajectory: Vec<Vec<f64>> = embeddings
|
||||
.iter()
|
||||
.map(|e| e.iter().map(|&v| v as f64).collect())
|
||||
.collect();
|
||||
|
||||
temporal_attractor_studio::estimate_lyapunov_default(&trajectory).ok()
|
||||
}
|
||||
|
||||
/// Compute a stability score from a Lyapunov result for search ranking.
|
||||
/// Returns a small additive bonus (0.0 to 0.05) for memories in stable categories.
|
||||
/// Stable categories (negative λ) get a boost; chaotic categories get zero.
|
||||
pub fn attractor_stability_score(result: &temporal_attractor_studio::LyapunovResult) -> f32 {
|
||||
if result.lambda < 0.0 {
|
||||
// Negative Lyapunov = converging (stable knowledge domain)
|
||||
// Scale: λ=-1.0 → 0.05 bonus, λ=0.0 → 0.0
|
||||
((-result.lambda).min(1.0) * 0.05) as f32
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
// ── Temporal Neural Solver (temporal-neural-solver) ────────────────────
|
||||
|
||||
/// Score a search result using the temporal solver's prediction confidence.
|
||||
/// Returns a small additive bonus (0.0 to 0.04) based on the certificate confidence.
|
||||
pub fn solver_confidence_score(certificate: &temporal_neural_solver::Certificate) -> f32 {
|
||||
if certificate.gate_pass {
|
||||
// Certificate passed solver gate — high confidence prediction
|
||||
(certificate.confidence.min(1.0) * 0.04) as f32
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
// ── Strange Loop Meta-Cognition (strange-loop) ─────────────────────────
|
||||
|
||||
/// Create a default StrangeLoop engine for meta-cognitive reasoning.
|
||||
pub fn create_strange_loop() -> strange_loop::StrangeLoop<strange_loop::ScalarReasoner, strange_loop::SimpleCritic, strange_loop::SafeReflector> {
|
||||
let reasoner = strange_loop::ScalarReasoner::new(0.0, 1.0);
|
||||
let critic = strange_loop::SimpleCritic::new();
|
||||
let reflector = strange_loop::SafeReflector::new();
|
||||
let config = strange_loop::LoopConfig {
|
||||
max_iterations: 10,
|
||||
max_duration_ns: 5_000_000, // 5ms budget for meta-cognition
|
||||
convergence_threshold: 0.01,
|
||||
lipschitz_constant: 0.9,
|
||||
enable_consciousness: false,
|
||||
enable_quantum: false,
|
||||
enable_simd: false,
|
||||
};
|
||||
strange_loop::StrangeLoop::new(reasoner, critic, reflector, config)
|
||||
}
|
||||
|
||||
/// Run a meta-cognitive evaluation on a search context.
|
||||
/// Returns a small additive bonus (0.0 to 0.04) based on the loop's convergence.
|
||||
pub fn strange_loop_score(
|
||||
loop_engine: &mut strange_loop::StrangeLoop<strange_loop::ScalarReasoner, strange_loop::SimpleCritic, strange_loop::SafeReflector>,
|
||||
query_relevance: f64,
|
||||
memory_quality: f64,
|
||||
) -> f32 {
|
||||
let mut ctx = strange_loop::Context::new();
|
||||
ctx.insert("relevance".to_string(), query_relevance);
|
||||
ctx.insert("quality".to_string(), memory_quality);
|
||||
// Run the loop — bounded by max_iterations and max_duration_ns
|
||||
match loop_engine.run(&mut ctx) {
|
||||
Ok(_result) => {
|
||||
// Extract composite score from context after meta-cognitive evaluation
|
||||
let composite = ctx.get("relevance").copied().unwrap_or(0.0)
|
||||
* ctx.get("quality").copied().unwrap_or(0.0);
|
||||
(composite.min(1.0).max(0.0) * 0.04) as f32
|
||||
}
|
||||
Err(_) => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Nanosecond Scheduler ───────────────────────────────────────────────
|
||||
|
||||
/// Create a default scheduler for background brain tasks.
|
||||
pub fn create_scheduler() -> nanosecond_scheduler::Scheduler {
|
||||
let config = nanosecond_scheduler::Config {
|
||||
max_tasks_per_tick: 50,
|
||||
..nanosecond_scheduler::Config::default()
|
||||
};
|
||||
nanosecond_scheduler::Scheduler::new(config)
|
||||
}
|
||||
|
||||
// ── Status / Diagnostics ───────────────────────────────────────────────
|
||||
|
||||
/// Midstream diagnostics for the /v1/status and /v1/midstream endpoints.
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct MidstreamStatus {
|
||||
pub scheduler_total_ticks: u64,
|
||||
pub scheduler_tasks_per_sec: f64,
|
||||
pub attractor_categories_analyzed: usize,
|
||||
pub solver_input_size: usize,
|
||||
pub strange_loop_version: String,
|
||||
}
|
||||
|
||||
/// Collect midstream diagnostics from AppState.
|
||||
pub fn collect_status(state: &AppState) -> MidstreamStatus {
|
||||
let metrics = state.nano_scheduler.metrics();
|
||||
let attractor_count = state.attractor_results.read().len();
|
||||
let solver = state.temporal_solver.read();
|
||||
// TemporalSolver doesn't expose input_size directly; use a sentinel
|
||||
let _ = &solver; // read lock held briefly
|
||||
|
||||
MidstreamStatus {
|
||||
scheduler_total_ticks: metrics.total_ticks,
|
||||
scheduler_tasks_per_sec: metrics.tasks_per_second,
|
||||
attractor_categories_analyzed: attractor_count,
|
||||
solver_input_size: crate::embeddings::EMBED_DIM,
|
||||
strange_loop_version: strange_loop::VERSION.to_string(),
|
||||
}
|
||||
}
|
||||
162
crates/mcp-brain-server/src/pipeline.rs
Normal file
162
crates/mcp-brain-server/src/pipeline.rs
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
//! RVF container construction pipeline (ADR-075 Phase 5).
|
||||
//!
|
||||
//! Assembles memory data into a multi-segment RVF container using rvf-wire.
|
||||
//! Each container has at minimum VEC + META + WITNESS segments, plus optional
|
||||
//! DiffPrivacyProof and RedactionLog segments when those features are active.
|
||||
|
||||
use rvf_types::SegmentFlags;
|
||||
|
||||
/// Input data for building an RVF container.
|
||||
pub struct RvfPipelineInput<'a> {
|
||||
pub memory_id: &'a str,
|
||||
pub embedding: &'a [f32],
|
||||
pub title: &'a str,
|
||||
pub content: &'a str,
|
||||
pub tags: &'a [String],
|
||||
pub category: &'a str,
|
||||
pub contributor_id: &'a str,
|
||||
pub witness_chain: Option<&'a [u8]>,
|
||||
pub dp_proof_json: Option<&'a str>,
|
||||
pub redaction_log_json: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// Build an RVF container from pipeline input.
|
||||
/// Returns the serialized container bytes (concatenated 64-byte-aligned segments).
|
||||
/// Returns an error if metadata serialization fails (prevents silent data loss).
|
||||
pub fn build_rvf_container(input: &RvfPipelineInput<'_>) -> Result<Vec<u8>, String> {
|
||||
let flags = SegmentFlags::empty();
|
||||
let mut container = Vec::new();
|
||||
let mut seg_id: u64 = 1;
|
||||
|
||||
// Segment 1: VEC (0x01) — embedding as f32 little-endian bytes
|
||||
{
|
||||
let mut payload = Vec::with_capacity(input.embedding.len() * 4);
|
||||
for &val in input.embedding {
|
||||
payload.extend_from_slice(&val.to_le_bytes());
|
||||
}
|
||||
let seg = rvf_wire::write_segment(0x01, &payload, flags, seg_id);
|
||||
container.extend_from_slice(&seg);
|
||||
seg_id += 1;
|
||||
}
|
||||
|
||||
// Segment 2: META (0x07) — JSON metadata
|
||||
{
|
||||
let meta = serde_json::json!({
|
||||
"memory_id": input.memory_id,
|
||||
"title": input.title,
|
||||
"content": input.content,
|
||||
"tags": input.tags,
|
||||
"category": input.category,
|
||||
"contributor_id": input.contributor_id,
|
||||
});
|
||||
let payload = serde_json::to_vec(&meta)
|
||||
.map_err(|e| format!("Failed to serialize RVF metadata: {e}"))?;
|
||||
let seg = rvf_wire::write_segment(0x07, &payload, flags, seg_id);
|
||||
container.extend_from_slice(&seg);
|
||||
seg_id += 1;
|
||||
}
|
||||
|
||||
// Segment 3: WITNESS (0x0A) — witness chain bytes (if present)
|
||||
if let Some(chain) = input.witness_chain {
|
||||
let seg = rvf_wire::write_segment(0x0A, chain, flags, seg_id);
|
||||
container.extend_from_slice(&seg);
|
||||
seg_id += 1;
|
||||
}
|
||||
|
||||
// Segment 4: DiffPrivacyProof (0x34) — proof JSON bytes (if DP enabled)
|
||||
if let Some(proof) = input.dp_proof_json {
|
||||
let seg = rvf_wire::write_segment(0x34, proof.as_bytes(), flags, seg_id);
|
||||
container.extend_from_slice(&seg);
|
||||
seg_id += 1;
|
||||
}
|
||||
|
||||
// Segment 5: RedactionLog (0x35) — redaction JSON bytes (if PII stripped)
|
||||
if let Some(log) = input.redaction_log_json {
|
||||
let seg = rvf_wire::write_segment(0x35, log.as_bytes(), flags, seg_id);
|
||||
container.extend_from_slice(&seg);
|
||||
let _ = seg_id; // suppress unused warning on last increment
|
||||
}
|
||||
|
||||
Ok(container)
|
||||
}
|
||||
|
||||
/// Count the number of segments in a serialized RVF container.
|
||||
/// Walks the container by reading 64-byte headers and skipping payloads.
|
||||
pub fn count_segments(container: &[u8]) -> usize {
|
||||
let mut count = 0;
|
||||
let mut offset = 0;
|
||||
while offset + 64 <= container.len() {
|
||||
// Read payload_length from header bytes [16..24] (little-endian u64)
|
||||
let payload_len = u64::from_le_bytes(
|
||||
container[offset + 16..offset + 24]
|
||||
.try_into()
|
||||
.unwrap_or([0u8; 8]),
|
||||
) as usize;
|
||||
let padded = rvf_wire::calculate_padded_size(64, payload_len);
|
||||
count += 1;
|
||||
offset += padded;
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_rvf_container_has_segments() {
|
||||
let embedding = vec![0.1f32, 0.2, 0.3, 0.4];
|
||||
let tags = vec!["test".to_string()];
|
||||
let witness_chain = rvf_crypto::create_witness_chain(&[
|
||||
rvf_crypto::WitnessEntry {
|
||||
prev_hash: [0u8; 32],
|
||||
action_hash: rvf_crypto::shake256_256(b"test"),
|
||||
timestamp_ns: 1000,
|
||||
witness_type: 0x01,
|
||||
},
|
||||
]);
|
||||
let dp_proof = r#"{"epsilon":1.0,"delta":1e-5}"#;
|
||||
let redaction_log = r#"{"entries":[],"total_redactions":0}"#;
|
||||
|
||||
let input = RvfPipelineInput {
|
||||
memory_id: "test-id",
|
||||
embedding: &embedding,
|
||||
title: "Test Title",
|
||||
content: "Test content",
|
||||
tags: &tags,
|
||||
category: "pattern",
|
||||
contributor_id: "test-contributor",
|
||||
witness_chain: Some(&witness_chain),
|
||||
dp_proof_json: Some(dp_proof),
|
||||
redaction_log_json: Some(redaction_log),
|
||||
};
|
||||
|
||||
let container = build_rvf_container(&input).expect("build should succeed");
|
||||
let seg_count = count_segments(&container);
|
||||
// VEC + META + WITNESS + DP_PROOF + REDACTION_LOG = 5 segments
|
||||
assert!(seg_count >= 3, "expected >= 3 segments, got {seg_count}");
|
||||
assert_eq!(seg_count, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rvf_container_minimal() {
|
||||
let embedding = vec![1.0f32; 128];
|
||||
let tags = vec![];
|
||||
let input = RvfPipelineInput {
|
||||
memory_id: "min-id",
|
||||
embedding: &embedding,
|
||||
title: "Minimal",
|
||||
content: "Content",
|
||||
tags: &tags,
|
||||
category: "solution",
|
||||
contributor_id: "anon",
|
||||
witness_chain: None,
|
||||
dp_proof_json: None,
|
||||
redaction_log_json: None,
|
||||
};
|
||||
let container = build_rvf_container(&input).expect("build should succeed");
|
||||
let seg_count = count_segments(&container);
|
||||
// VEC + META = 2 segments (no witness, no DP, no redaction)
|
||||
assert_eq!(seg_count, 2);
|
||||
}
|
||||
}
|
||||
54
crates/mcp-brain-server/src/ranking.rs
Normal file
54
crates/mcp-brain-server/src/ranking.rs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
//! Search result ranking with quality and recency adjustments.
|
||||
//!
|
||||
//! The ranking engine applies lightweight quality and recency bonuses on top
|
||||
//! of the hybrid score (embedding + keyword + reputation) computed by the
|
||||
//! search handler. It intentionally preserves the input ordering's relative
|
||||
//! signal rather than overriding it.
|
||||
|
||||
use crate::types::BrainMemory;
|
||||
|
||||
/// Rank search results using quality, recency, and the incoming hybrid score.
|
||||
pub struct RankingEngine {
|
||||
quality_weight: f32,
|
||||
recency_weight: f32,
|
||||
similarity_weight: f32,
|
||||
}
|
||||
|
||||
impl RankingEngine {
|
||||
pub fn new(_embedding_dim: usize) -> Self {
|
||||
Self {
|
||||
quality_weight: 0.10,
|
||||
recency_weight: 0.05,
|
||||
similarity_weight: 0.85,
|
||||
}
|
||||
}
|
||||
|
||||
/// Rank memories by composite score.
|
||||
///
|
||||
/// The incoming `sim_score` is the hybrid score from the search handler
|
||||
/// (embedding + keyword + reputation). We apply small quality and
|
||||
/// recency bonuses on top — never override.
|
||||
pub fn rank(&self, results: &mut [(f64, BrainMemory)]) {
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
for (sim_score, memory) in results.iter_mut() {
|
||||
let quality = memory.quality_score.mean();
|
||||
let age_hours = (now - memory.updated_at).num_hours().max(1) as f64;
|
||||
let recency = 1.0 / (1.0 + (age_hours / 24.0).ln_1p());
|
||||
|
||||
let composite = self.similarity_weight as f64 * *sim_score
|
||||
+ self.quality_weight as f64 * quality
|
||||
+ self.recency_weight as f64 * recency;
|
||||
|
||||
*sim_score = composite;
|
||||
}
|
||||
|
||||
results.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for RankingEngine {
|
||||
fn default() -> Self {
|
||||
Self::new(128)
|
||||
}
|
||||
}
|
||||
120
crates/mcp-brain-server/src/rate_limit.rs
Normal file
120
crates/mcp-brain-server/src/rate_limit.rs
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
//! Rate limiting using BudgetTokenBucket pattern
|
||||
//!
|
||||
//! Includes periodic cleanup of stale buckets to prevent unbounded memory growth.
|
||||
|
||||
use dashmap::DashMap;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Rate limiter with per-contributor token buckets
|
||||
pub struct RateLimiter {
|
||||
write_buckets: DashMap<String, TokenBucket>,
|
||||
read_buckets: DashMap<String, TokenBucket>,
|
||||
write_limit: u32,
|
||||
read_limit: u32,
|
||||
window: Duration,
|
||||
/// Counter for triggering periodic cleanup
|
||||
ops_counter: AtomicU64,
|
||||
/// Cleanup every N operations
|
||||
cleanup_interval: u64,
|
||||
}
|
||||
|
||||
struct TokenBucket {
|
||||
tokens: u32,
|
||||
max_tokens: u32,
|
||||
last_refill: Instant,
|
||||
window: Duration,
|
||||
}
|
||||
|
||||
impl TokenBucket {
|
||||
fn new(max_tokens: u32, window: Duration) -> Self {
|
||||
Self {
|
||||
tokens: max_tokens,
|
||||
max_tokens,
|
||||
last_refill: Instant::now(),
|
||||
window,
|
||||
}
|
||||
}
|
||||
|
||||
fn try_consume(&mut self) -> bool {
|
||||
self.refill();
|
||||
if self.tokens > 0 {
|
||||
self.tokens -= 1;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn refill(&mut self) {
|
||||
let elapsed = self.last_refill.elapsed();
|
||||
if elapsed >= self.window {
|
||||
self.tokens = self.max_tokens;
|
||||
self.last_refill = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this bucket is stale (unused for more than 2 windows)
|
||||
fn is_stale(&self) -> bool {
|
||||
self.last_refill.elapsed() > self.window * 2
|
||||
}
|
||||
}
|
||||
|
||||
impl RateLimiter {
|
||||
pub fn new(write_limit: u32, read_limit: u32) -> Self {
|
||||
Self {
|
||||
write_buckets: DashMap::new(),
|
||||
read_buckets: DashMap::new(),
|
||||
write_limit,
|
||||
read_limit,
|
||||
window: Duration::from_secs(3600),
|
||||
ops_counter: AtomicU64::new(0),
|
||||
cleanup_interval: 1000,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_limits() -> Self {
|
||||
Self::new(500, 5000)
|
||||
}
|
||||
|
||||
pub fn check_write(&self, contributor: &str) -> bool {
|
||||
self.maybe_cleanup();
|
||||
let mut entry = self
|
||||
.write_buckets
|
||||
.entry(contributor.to_string())
|
||||
.or_insert_with(|| TokenBucket::new(self.write_limit, self.window));
|
||||
entry.try_consume()
|
||||
}
|
||||
|
||||
pub fn check_read(&self, contributor: &str) -> bool {
|
||||
self.maybe_cleanup();
|
||||
let mut entry = self
|
||||
.read_buckets
|
||||
.entry(contributor.to_string())
|
||||
.or_insert_with(|| TokenBucket::new(self.read_limit, self.window));
|
||||
entry.try_consume()
|
||||
}
|
||||
|
||||
/// Periodically clean up stale buckets to prevent unbounded memory growth
|
||||
fn maybe_cleanup(&self) {
|
||||
let count = self.ops_counter.fetch_add(1, Ordering::Relaxed);
|
||||
if count % self.cleanup_interval != 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let write_before = self.write_buckets.len();
|
||||
let read_before = self.read_buckets.len();
|
||||
|
||||
self.write_buckets.retain(|_, bucket| !bucket.is_stale());
|
||||
self.read_buckets.retain(|_, bucket| !bucket.is_stale());
|
||||
|
||||
let write_evicted = write_before - self.write_buckets.len();
|
||||
let read_evicted = read_before - self.read_buckets.len();
|
||||
|
||||
if write_evicted > 0 || read_evicted > 0 {
|
||||
tracing::debug!(
|
||||
"Rate limiter cleanup: evicted {write_evicted} write + {read_evicted} read stale buckets"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
93
crates/mcp-brain-server/src/reputation.rs
Normal file
93
crates/mcp-brain-server/src/reputation.rs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
//! Multi-factor reputation scoring system
|
||||
|
||||
use crate::types::ReputationScore;
|
||||
|
||||
/// Reputation manager that tracks and updates contributor reputations.
|
||||
///
|
||||
/// Accuracy uses a Bayesian prior Beta(1,1) to prevent early luck from
|
||||
/// distorting scores. Expected accuracy = (upvotes+1)/(upvotes+downvotes+2).
|
||||
/// Accuracy only influences composite after min_observations_for_accuracy (5).
|
||||
pub struct ReputationManager {
|
||||
min_observations_for_penalty: u32,
|
||||
quality_threshold_for_penalty: f64,
|
||||
}
|
||||
|
||||
impl ReputationManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
min_observations_for_penalty: 5,
|
||||
quality_threshold_for_penalty: 0.2,
|
||||
}
|
||||
}
|
||||
|
||||
/// Update accuracy using Bayesian Beta(1,1) prior.
|
||||
/// upvotes/downvotes are total lifetime counts for this contributor.
|
||||
pub fn update_accuracy_bayesian(
|
||||
score: &mut ReputationScore,
|
||||
upvotes: u64,
|
||||
downvotes: u64,
|
||||
min_obs: u32,
|
||||
) {
|
||||
let total = upvotes + downvotes;
|
||||
if total < min_obs as u64 {
|
||||
// Below minimum observations — use prior mean 0.5
|
||||
score.accuracy = 0.5;
|
||||
} else {
|
||||
// Bayesian expected accuracy: (upvotes+1)/(upvotes+downvotes+2)
|
||||
score.accuracy = (upvotes as f64 + 1.0) / (total as f64 + 2.0);
|
||||
}
|
||||
score.compute_composite();
|
||||
}
|
||||
|
||||
/// Update accuracy based on vote outcome (EMA fallback for streaming updates)
|
||||
pub fn update_accuracy(score: &mut ReputationScore, was_upvoted: bool) {
|
||||
if was_upvoted {
|
||||
score.accuracy = (score.accuracy * 0.9) + 0.1;
|
||||
} else {
|
||||
score.accuracy = score.accuracy * 0.9;
|
||||
}
|
||||
score.compute_composite();
|
||||
}
|
||||
|
||||
/// Update uptime (called on each contribution)
|
||||
pub fn record_activity(score: &mut ReputationScore) {
|
||||
score.uptime = (score.uptime * 0.95) + 0.05;
|
||||
score.compute_composite();
|
||||
}
|
||||
|
||||
/// Check if poisoning penalty should apply
|
||||
pub fn check_poisoning_penalty(
|
||||
&self,
|
||||
score: &mut ReputationScore,
|
||||
downvote_count: u32,
|
||||
quality: f64,
|
||||
) -> bool {
|
||||
if downvote_count >= self.min_observations_for_penalty
|
||||
&& quality < self.quality_threshold_for_penalty
|
||||
{
|
||||
score.apply_poisoning_penalty();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply monthly decay for inactive users
|
||||
pub fn apply_inactivity_decay(score: &mut ReputationScore, months_inactive: f64) {
|
||||
if months_inactive > 0.0 {
|
||||
score.apply_decay(months_inactive);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the contribution weight for a reputation level
|
||||
pub fn contribution_weight(score: &ReputationScore) -> f64 {
|
||||
// New users (composite ~0.1) are weighted 10x less
|
||||
score.composite.max(0.01)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ReputationManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
2852
crates/mcp-brain-server/src/routes.rs
Normal file
2852
crates/mcp-brain-server/src/routes.rs
Normal file
File diff suppressed because it is too large
Load diff
1192
crates/mcp-brain-server/src/store.rs
Normal file
1192
crates/mcp-brain-server/src/store.rs
Normal file
File diff suppressed because it is too large
Load diff
751
crates/mcp-brain-server/src/tests.rs
Normal file
751
crates/mcp-brain-server/src/tests.rs
Normal file
|
|
@ -0,0 +1,751 @@
|
|||
//! Integration tests for mcp-brain-server cognitive stack
|
||||
//!
|
||||
//! Tests cover all major RuVector crate integrations.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use ruvector_delta_core::{Delta, VectorDelta};
|
||||
use ruvector_domain_expansion::{DomainExpansionEngine, DomainId};
|
||||
use ruvector_nervous_system::hdc::{HdcMemory, Hypervector};
|
||||
use ruvector_nervous_system::hopfield::ModernHopfield;
|
||||
use ruvector_nervous_system::separate::DentateGyrus;
|
||||
use ruvector_solver::forward_push::ForwardPushSolver;
|
||||
use ruvector_solver::types::CsrMatrix;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 1. Hopfield: store 5 patterns, retrieve by partial query
|
||||
// -----------------------------------------------------------------------
|
||||
#[test]
|
||||
fn test_hopfield_store_retrieve() {
|
||||
let mut hopfield = ModernHopfield::new(8, 1.0);
|
||||
|
||||
// Store 5 distinct patterns
|
||||
let patterns: Vec<Vec<f32>> = (0..5)
|
||||
.map(|i| {
|
||||
let mut p = vec![0.0f32; 8];
|
||||
p[i] = 1.0;
|
||||
p
|
||||
})
|
||||
.collect();
|
||||
|
||||
for p in &patterns {
|
||||
hopfield.store(p.clone()).expect("store failed");
|
||||
}
|
||||
|
||||
// Retrieve using a noisy version of pattern 0
|
||||
let noisy = vec![0.9, 0.1, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
|
||||
let recalled = hopfield.retrieve(&noisy).expect("retrieve failed");
|
||||
|
||||
// Should retrieve something close to pattern 0 (first element dominant)
|
||||
assert!(recalled[0] > recalled[1], "pattern 0 should be dominant in retrieval");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 2. DentateGyrus: encode similar inputs, verify orthogonal outputs
|
||||
// -----------------------------------------------------------------------
|
||||
#[test]
|
||||
fn test_dentate_pattern_separation() {
|
||||
let gyrus = DentateGyrus::new(8, 1000, 50, 42);
|
||||
|
||||
// Two very similar inputs
|
||||
let a = vec![1.0, 0.9, 0.8, 0.7, 0.6, 0.5, 0.4, 0.3];
|
||||
let b = vec![1.0, 0.9, 0.8, 0.7, 0.6, 0.5, 0.4, 0.4]; // slightly different
|
||||
|
||||
let enc_a = gyrus.encode(&a);
|
||||
let enc_b = gyrus.encode(&b);
|
||||
|
||||
// DentateGyrus should produce sparse binary representations
|
||||
// Jaccard similarity < 1.0 means they're not identical
|
||||
let sim = enc_a.jaccard_similarity(&enc_b);
|
||||
assert!(
|
||||
sim <= 1.0,
|
||||
"encoded similarity should be at most 1.0, got {}",
|
||||
sim
|
||||
);
|
||||
|
||||
// Dense encoding should have 1000 dimensions
|
||||
let dense_a = gyrus.encode_dense(&a);
|
||||
assert_eq!(dense_a.len(), 1000);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 3. HDC: store 100 hypervectors, retrieve by similarity
|
||||
// -----------------------------------------------------------------------
|
||||
#[test]
|
||||
fn test_hdc_fast_filter() {
|
||||
let mut memory = HdcMemory::new();
|
||||
|
||||
// Store 100 random hypervectors
|
||||
for i in 0..100u64 {
|
||||
let hv = Hypervector::from_seed(i);
|
||||
memory.store(format!("item-{}", i), hv);
|
||||
}
|
||||
|
||||
// Retrieve using seed 42 as query — "item-42" should be at the top
|
||||
let query = Hypervector::from_seed(42);
|
||||
let results = memory.retrieve_top_k(&query, 5);
|
||||
|
||||
assert!(!results.is_empty(), "should return at least one result");
|
||||
// Top result should be item-42 (exact match = highest similarity)
|
||||
assert_eq!(
|
||||
results[0].0, "item-42",
|
||||
"top result should be item-42, got {}",
|
||||
results[0].0
|
||||
);
|
||||
// Similarity of exact match should be 1.0
|
||||
assert!(
|
||||
(results[0].1 - 1.0).abs() < 0.01,
|
||||
"exact match similarity should be ~1.0"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 4. MinCut: build graph with 20 nodes, verify real min_cut_value > 0
|
||||
// -----------------------------------------------------------------------
|
||||
#[test]
|
||||
fn test_mincut_partition() {
|
||||
use ruvector_mincut::MinCutBuilder;
|
||||
|
||||
// Build a 20-node graph with edges forming two dense clusters
|
||||
let mut edges: Vec<(u64, u64, f64)> = Vec::new();
|
||||
|
||||
// Cluster A: nodes 0..9, dense internal edges
|
||||
for i in 0..10u64 {
|
||||
for j in (i + 1)..10u64 {
|
||||
edges.push((i, j, 5.0));
|
||||
}
|
||||
}
|
||||
|
||||
// Cluster B: nodes 10..19, dense internal edges
|
||||
for i in 10..20u64 {
|
||||
for j in (i + 1)..20u64 {
|
||||
edges.push((i, j, 5.0));
|
||||
}
|
||||
}
|
||||
|
||||
// Weak bridge between clusters
|
||||
edges.push((4, 15, 0.1));
|
||||
edges.push((5, 16, 0.1));
|
||||
|
||||
let mincut = MinCutBuilder::new()
|
||||
.exact()
|
||||
.with_edges(edges)
|
||||
.build()
|
||||
.expect("failed to build MinCut");
|
||||
|
||||
let cut_value = mincut.min_cut_value();
|
||||
assert!(cut_value > 0.0, "min cut value should be > 0, got {}", cut_value);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 5. TopologyGatedAttention: rank 10 results
|
||||
// -----------------------------------------------------------------------
|
||||
#[test]
|
||||
fn test_attention_ranking() {
|
||||
use crate::ranking::RankingEngine;
|
||||
use crate::types::{BetaParams, BrainCategory, BrainMemory};
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
let mut engine = RankingEngine::new(4);
|
||||
|
||||
// Create 10 fake memories with different embeddings
|
||||
let mut results: Vec<(f64, BrainMemory)> = (0..10)
|
||||
.map(|i| {
|
||||
let embedding = vec![i as f32 * 0.1, 0.5, 0.3, 0.2];
|
||||
let memory = BrainMemory {
|
||||
id: Uuid::new_v4(),
|
||||
category: BrainCategory::Pattern,
|
||||
title: format!("mem-{}", i),
|
||||
content: "test".into(),
|
||||
tags: vec![],
|
||||
code_snippet: None,
|
||||
embedding,
|
||||
contributor_id: "tester".into(),
|
||||
quality_score: BetaParams::new(),
|
||||
partition_id: None,
|
||||
witness_hash: String::new(),
|
||||
rvf_gcs_path: None,
|
||||
redaction_log: None,
|
||||
dp_proof: None,
|
||||
witness_chain: None,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
};
|
||||
(0.5 + i as f64 * 0.05, memory)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Rank should not panic and should produce sorted output
|
||||
engine.rank(&mut results);
|
||||
assert_eq!(results.len(), 10);
|
||||
|
||||
// Verify sorted descending
|
||||
for w in results.windows(2) {
|
||||
assert!(
|
||||
w[0].0 >= w[1].0,
|
||||
"results should be sorted descending: {} >= {}",
|
||||
w[0].0,
|
||||
w[1].0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 6. VectorDelta: compute drift between two embedding sequences
|
||||
// -----------------------------------------------------------------------
|
||||
#[test]
|
||||
fn test_delta_drift() {
|
||||
use crate::drift::DriftMonitor;
|
||||
|
||||
let mut monitor = DriftMonitor::new();
|
||||
let domain = "test-domain";
|
||||
|
||||
// Record 20 embeddings with increasing drift
|
||||
for i in 0..20usize {
|
||||
let embedding: Vec<f32> = (0..8).map(|j| (i * j) as f32 * 0.01).collect();
|
||||
monitor.record(domain, &embedding);
|
||||
}
|
||||
|
||||
let report = monitor.compute_drift(Some(domain));
|
||||
assert_eq!(report.window_size, 20);
|
||||
assert!(
|
||||
report.coefficient_of_variation >= 0.0,
|
||||
"CV should be non-negative"
|
||||
);
|
||||
|
||||
// Also test direct delta computation
|
||||
let old = vec![1.0f32, 0.0, 0.0, 0.0];
|
||||
let new = vec![0.9f32, 0.1, 0.05, 0.0];
|
||||
let delta = VectorDelta::compute(&old, &new);
|
||||
let l2 = delta.l2_norm();
|
||||
assert!(l2 > 0.0, "l2_norm should be positive for different vectors");
|
||||
assert!(!delta.is_identity(), "should not be identity delta");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 7. SonaEngine: generate embeddings, verify semantic similarity
|
||||
// -----------------------------------------------------------------------
|
||||
#[test]
|
||||
fn test_sona_embedding() {
|
||||
let engine = sona::SonaEngine::new(32);
|
||||
|
||||
// Build a trajectory
|
||||
let mut builder = engine.begin_trajectory(vec![0.5f32; 32]);
|
||||
builder.add_step(vec![0.6f32; 32], vec![], 0.8);
|
||||
builder.add_step(vec![0.7f32; 32], vec![], 0.9);
|
||||
engine.end_trajectory(builder, 0.85);
|
||||
|
||||
// Stats should record 1 trajectory
|
||||
let stats = engine.stats();
|
||||
assert_eq!(stats.trajectories_buffered, 1);
|
||||
|
||||
// Apply micro-lora (output may be zero before learning, but should not panic)
|
||||
let input = vec![1.0f32; 32];
|
||||
let mut output = vec![0.0f32; 32];
|
||||
engine.apply_micro_lora(&input, &mut output);
|
||||
// Output is a Vec<f32> of correct length
|
||||
assert_eq!(output.len(), 32);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 8. ForwardPushSolver / CsrMatrix: build CSR graph, run PPR, verify top-k
|
||||
// -----------------------------------------------------------------------
|
||||
#[test]
|
||||
fn test_pagerank_search() {
|
||||
// Build a simple 6-node ring graph with an extra hub node
|
||||
// Nodes: 0-5 in a ring, node 0 also connects to all others
|
||||
let n = 6;
|
||||
let mut entries: Vec<(usize, usize, f64)> = Vec::new();
|
||||
|
||||
// Ring edges
|
||||
for i in 0..n {
|
||||
entries.push((i, (i + 1) % n, 1.0));
|
||||
entries.push(((i + 1) % n, i, 1.0));
|
||||
}
|
||||
|
||||
// Hub: node 0 connects to all others with high weight
|
||||
for i in 1..n {
|
||||
entries.push((0, i, 2.0));
|
||||
entries.push((i, 0, 2.0));
|
||||
}
|
||||
|
||||
let graph = CsrMatrix::<f64>::from_coo(n, n, entries);
|
||||
assert_eq!(graph.rows, n);
|
||||
assert!(graph.nnz() > 0);
|
||||
|
||||
let solver = ForwardPushSolver::default_params();
|
||||
let results = solver
|
||||
.top_k(&graph, 0, 3)
|
||||
.expect("forward push should succeed");
|
||||
|
||||
assert!(
|
||||
!results.is_empty(),
|
||||
"should return PPR results"
|
||||
);
|
||||
// Node 0 as source — it or its immediate neighbors should rank high
|
||||
let returned_nodes: Vec<usize> = results.iter().map(|(n, _)| *n).collect();
|
||||
// At least some nodes should be returned
|
||||
assert!(returned_nodes.len() <= 3);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 9. Domain transfer: initiate_transfer between two domains, verify acceleration
|
||||
// -----------------------------------------------------------------------
|
||||
#[test]
|
||||
fn test_domain_transfer() {
|
||||
use ruvector_domain_expansion::{ArmId, ContextBucket};
|
||||
|
||||
let mut engine = DomainExpansionEngine::new();
|
||||
let source = DomainId("rust_synthesis".into());
|
||||
let target = DomainId("structured_planning".into());
|
||||
|
||||
// Warm up source domain with outcomes
|
||||
let bucket = ContextBucket {
|
||||
difficulty_tier: "medium".into(),
|
||||
category: "algorithm".into(),
|
||||
};
|
||||
for _ in 0..20 {
|
||||
engine.thompson.record_outcome(
|
||||
&source,
|
||||
bucket.clone(),
|
||||
ArmId("greedy".into()),
|
||||
0.8,
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
|
||||
// Initiate transfer
|
||||
engine.initiate_transfer(&source, &target);
|
||||
|
||||
// Verify the transfer with simulated metrics
|
||||
let verification = engine.verify_transfer(
|
||||
&source,
|
||||
&target,
|
||||
0.8, // source_before
|
||||
0.79, // source_after (within tolerance)
|
||||
0.3, // target_before
|
||||
0.65, // target_after
|
||||
100, // baseline_cycles
|
||||
50, // transfer_cycles
|
||||
);
|
||||
|
||||
assert!(
|
||||
verification.improved_target,
|
||||
"transfer should improve target domain"
|
||||
);
|
||||
assert!(
|
||||
!verification.regressed_source,
|
||||
"transfer should not regress source"
|
||||
);
|
||||
assert!(
|
||||
verification.promotable,
|
||||
"verification should be promotable"
|
||||
);
|
||||
assert!(
|
||||
verification.acceleration_factor > 1.0,
|
||||
"acceleration factor should be > 1.0, got {}",
|
||||
verification.acceleration_factor
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 10. Witness chain: verify integrity (via cognitive engine store)
|
||||
// -----------------------------------------------------------------------
|
||||
#[test]
|
||||
fn test_witness_chain() {
|
||||
use crate::cognitive::CognitiveEngine;
|
||||
|
||||
let mut engine = CognitiveEngine::new(8);
|
||||
|
||||
// Store 5 patterns sequentially — simulates a witness chain
|
||||
let patterns: Vec<(&str, Vec<f32>)> = vec![
|
||||
("entry-1", vec![1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]),
|
||||
("entry-2", vec![0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]),
|
||||
("entry-3", vec![0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0]),
|
||||
("entry-4", vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0]),
|
||||
("entry-5", vec![0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0]),
|
||||
];
|
||||
|
||||
for (id, emb) in &patterns {
|
||||
engine.store_pattern(id, emb);
|
||||
}
|
||||
|
||||
// Retrieve from entry-3's pattern — should be in Hopfield memory
|
||||
let query = vec![0.05, 0.05, 0.9, 0.05, 0.05, 0.0, 0.0, 0.0];
|
||||
let recalled = engine.recall(&query);
|
||||
assert!(recalled.is_some(), "should recall a pattern");
|
||||
|
||||
// Cluster coherence of the 5 stored embeddings
|
||||
let embs: Vec<Vec<f32>> = patterns.iter().map(|(_, e)| e.clone()).collect();
|
||||
let coherence = engine.cluster_coherence(&embs);
|
||||
assert!(
|
||||
coherence >= 0.0 && coherence <= 1.0,
|
||||
"coherence should be [0,1], got {}",
|
||||
coherence
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 11. PII strip: test all 12 PII patterns
|
||||
// -----------------------------------------------------------------------
|
||||
#[test]
|
||||
fn test_pii_strip_all_patterns() {
|
||||
use crate::verify::Verifier;
|
||||
|
||||
let verifier = Verifier::new();
|
||||
|
||||
let pii_inputs = vec![
|
||||
("email address", "My email is user@example.com and I need help"),
|
||||
("phone number", "Call me at 555-867-5309 for details"),
|
||||
("SSN", "My SSN is 123-45-6789 please keep it safe"),
|
||||
("credit card", "Card number 4111-1111-1111-1111 expires 12/25"),
|
||||
("IP address", "Server IP is 192.168.1.100 for internal use"),
|
||||
("AWS key", "AWS key AKIAIOSFODNN7EXAMPLE is exposed"),
|
||||
("private key", "-----BEGIN PRIVATE KEY----- data here"),
|
||||
("password pattern", "password=supersecret123 in config"),
|
||||
("api key", "api_key=sk-abc123 in the headers"),
|
||||
];
|
||||
|
||||
for (label, input) in &pii_inputs {
|
||||
let tags = vec!["test".to_string()];
|
||||
let embedding = vec![0.1f32; 128];
|
||||
let result = verifier.verify_share("Test Title", input, &tags, &embedding);
|
||||
// Should either reject (Err) or sanitize (Ok) — both are valid
|
||||
// The key test is that it doesn't panic and handles PII input
|
||||
match result {
|
||||
Ok(_) => {
|
||||
// Accepted (may have stripped PII) — valid
|
||||
}
|
||||
Err(e) => {
|
||||
// Rejected due to PII detection — valid
|
||||
let msg = e.to_string().to_lowercase();
|
||||
assert!(
|
||||
!msg.is_empty(),
|
||||
"{}: rejection message should not be empty",
|
||||
label
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 12. End-to-end: verify → strip PII → build witness chain → RVF container
|
||||
// -----------------------------------------------------------------------
|
||||
#[test]
|
||||
fn test_end_to_end_share_pipeline() {
|
||||
use crate::pipeline::{RvfPipelineInput, build_rvf_container, count_segments};
|
||||
use crate::verify::Verifier;
|
||||
use rvf_crypto::WitnessEntry;
|
||||
|
||||
let mut verifier = Verifier::new();
|
||||
let title = "Secure Architecture Guide";
|
||||
let content = "Contact admin@example.com or see /home/deploy/config.yaml for setup";
|
||||
let tags = vec!["security".to_string(), "architecture".to_string()];
|
||||
let embedding = vec![0.1f32; 128];
|
||||
|
||||
// Step 1: Verify input (should reject due to PII)
|
||||
let result = verifier.verify_share(title, content, &tags, &embedding);
|
||||
assert!(result.is_err(), "PII content should be rejected by verify_share");
|
||||
|
||||
// Step 2: Strip PII instead of rejecting
|
||||
let fields = [("title", title), ("content", content)];
|
||||
let (stripped, log) = verifier.strip_pii_fields(&fields);
|
||||
assert!(log.total_redactions >= 2, "should redact email + path");
|
||||
assert!(!stripped[1].1.contains("admin@example.com"), "email should be redacted");
|
||||
assert!(!stripped[1].1.contains("/home/"), "path should be redacted");
|
||||
|
||||
// Step 3: Stripped content should pass verification
|
||||
let clean_title = &stripped[0].1;
|
||||
let clean_content = &stripped[1].1;
|
||||
assert!(verifier.verify_share(clean_title, clean_content, &tags, &embedding).is_ok());
|
||||
|
||||
// Step 4: Build witness chain
|
||||
let now_ns = 1_000_000_000u64;
|
||||
let stripped_hash = rvf_crypto::shake256_256(clean_content.as_bytes());
|
||||
let mut emb_bytes = Vec::with_capacity(embedding.len() * 4);
|
||||
for v in &embedding { emb_bytes.extend_from_slice(&v.to_le_bytes()); }
|
||||
let emb_hash = rvf_crypto::shake256_256(&emb_bytes);
|
||||
let entries = vec![
|
||||
WitnessEntry { prev_hash: [0u8; 32], action_hash: stripped_hash, timestamp_ns: now_ns, witness_type: 0x01 },
|
||||
WitnessEntry { prev_hash: [0u8; 32], action_hash: emb_hash, timestamp_ns: now_ns, witness_type: 0x02 },
|
||||
WitnessEntry { prev_hash: [0u8; 32], action_hash: rvf_crypto::shake256_256(b"final"), timestamp_ns: now_ns, witness_type: 0x01 },
|
||||
];
|
||||
let chain = rvf_crypto::create_witness_chain(&entries);
|
||||
assert_eq!(chain.len(), 73 * 3);
|
||||
|
||||
// Step 5: Verify chain integrity
|
||||
let decoded = verifier.verify_rvf_witness_chain(&chain).unwrap();
|
||||
assert_eq!(decoded.len(), 3);
|
||||
|
||||
// Step 6: Build RVF container
|
||||
let redaction_json = serde_json::to_string(&serde_json::json!({
|
||||
"entries": [], "total_redactions": log.total_redactions
|
||||
})).unwrap();
|
||||
let input = RvfPipelineInput {
|
||||
memory_id: "e2e-test-id",
|
||||
embedding: &embedding,
|
||||
title: clean_title,
|
||||
content: clean_content,
|
||||
tags: &tags,
|
||||
category: "security",
|
||||
contributor_id: "e2e-tester",
|
||||
witness_chain: Some(&chain),
|
||||
dp_proof_json: None,
|
||||
redaction_log_json: Some(&redaction_json),
|
||||
};
|
||||
let container = build_rvf_container(&input).expect("container build should succeed");
|
||||
let seg_count = count_segments(&container);
|
||||
// VEC + META + WITNESS + REDACTION_LOG = 4 segments
|
||||
assert_eq!(seg_count, 4, "expected 4 segments, got {seg_count}");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 13. Auth: API key validation and pseudonym derivation
|
||||
// -----------------------------------------------------------------------
|
||||
#[test]
|
||||
fn test_auth_pseudonym_derivation() {
|
||||
use crate::auth::AuthenticatedContributor;
|
||||
|
||||
// Same key should always produce the same pseudonym (deterministic)
|
||||
let a = AuthenticatedContributor::from_api_key("test-key-12345678");
|
||||
let b = AuthenticatedContributor::from_api_key("test-key-12345678");
|
||||
assert_eq!(a.pseudonym, b.pseudonym);
|
||||
assert_eq!(a.api_key_prefix, "test-key");
|
||||
assert!(!a.is_system);
|
||||
|
||||
// Different keys should produce different pseudonyms
|
||||
let c = AuthenticatedContributor::from_api_key("different-key-9999");
|
||||
assert_ne!(a.pseudonym, c.pseudonym);
|
||||
|
||||
// System seed should have known values
|
||||
let sys = AuthenticatedContributor::system_seed();
|
||||
assert_eq!(sys.pseudonym, "ruvector-seed");
|
||||
assert!(sys.is_system);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 14. RVF feature flags: verify default values (including AGI flags)
|
||||
// -----------------------------------------------------------------------
|
||||
#[test]
|
||||
fn test_rvf_feature_flags_defaults() {
|
||||
use crate::types::RvfFeatureFlags;
|
||||
let flags = RvfFeatureFlags::from_env();
|
||||
// Phase 1-7 defaults
|
||||
assert!(flags.pii_strip, "pii_strip should default to true");
|
||||
assert!(flags.witness, "witness should default to true");
|
||||
assert!(flags.container, "container should default to true");
|
||||
assert!(!flags.dp_enabled, "dp_enabled should default to false");
|
||||
assert!(!flags.adversarial, "adversarial should default to false");
|
||||
assert!(!flags.neg_cache, "neg_cache should default to false");
|
||||
assert!((flags.dp_epsilon - 1.0).abs() < f64::EPSILON, "dp_epsilon should default to 1.0");
|
||||
// Phase 8 AGI defaults — all enabled by default
|
||||
assert!(flags.sona_enabled, "sona_enabled should default to true");
|
||||
assert!(flags.gwt_enabled, "gwt_enabled should default to true");
|
||||
assert!(flags.temporal_enabled, "temporal_enabled should default to true");
|
||||
assert!(flags.meta_learning_enabled, "meta_learning_enabled should default to true");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 15. SONA: trajectory roundtrip and pattern search
|
||||
// -----------------------------------------------------------------------
|
||||
#[test]
|
||||
fn test_sona_trajectory_roundtrip() {
|
||||
let sona = sona::SonaEngine::new(128);
|
||||
let query = vec![0.5f32; 128];
|
||||
|
||||
// Begin trajectory, add a step, end it
|
||||
let mut builder = sona.begin_trajectory(query.clone());
|
||||
builder.add_step(vec![0.6f32; 128], vec![], 0.8);
|
||||
sona.end_trajectory(builder, 0.7);
|
||||
|
||||
// Stats should reflect the trajectory
|
||||
let stats = sona.stats();
|
||||
assert!(stats.trajectories_buffered >= 1 || stats.trajectories_dropped == 0,
|
||||
"trajectory should be buffered or processed");
|
||||
|
||||
// Pattern search should not crash (may return empty before learning)
|
||||
let patterns = sona.find_patterns(&query, 5);
|
||||
// Patterns are empty until background learning runs, but API must not panic
|
||||
let _ = patterns;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 16. GWT: broadcast and salience competition
|
||||
// -----------------------------------------------------------------------
|
||||
#[test]
|
||||
fn test_gwt_broadcast_competition() {
|
||||
use ruvector_nervous_system::routing::workspace::GlobalWorkspace;
|
||||
|
||||
let mut ws = GlobalWorkspace::with_threshold(7, 0.1);
|
||||
|
||||
// Broadcast 10 items with varying salience
|
||||
for i in 0..10u16 {
|
||||
let salience = (i as f32 + 1.0) / 10.0; // 0.1 to 1.0
|
||||
let content = vec![i as f32; 4];
|
||||
let rep = ruvector_nervous_system::routing::workspace::Representation::new(
|
||||
content, salience, i, 0,
|
||||
);
|
||||
ws.broadcast(rep);
|
||||
}
|
||||
|
||||
// Workspace capacity is 7, so only top-7 by salience should survive
|
||||
let top = ws.retrieve_top_k(7);
|
||||
assert!(top.len() <= 7, "workspace should respect capacity 7");
|
||||
assert!(top.len() >= 1, "at least one item should survive");
|
||||
|
||||
// Most salient should be the item with salience 1.0
|
||||
let best = ws.most_salient();
|
||||
assert!(best.is_some(), "workspace should have a most salient item");
|
||||
|
||||
// Load should be positive
|
||||
let load = ws.current_load();
|
||||
assert!(load > 0.0, "workspace should have positive load");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 17. Delta: temporal stream tracking
|
||||
// -----------------------------------------------------------------------
|
||||
#[test]
|
||||
fn test_delta_stream_temporal() {
|
||||
let mut stream = ruvector_delta_core::DeltaStream::<ruvector_delta_core::VectorDelta>::for_vectors(4);
|
||||
|
||||
// Push 3 deltas at different timestamps
|
||||
let d1 = VectorDelta::from_dense(vec![1.0, 0.0, 0.0, 0.0]);
|
||||
let d2 = VectorDelta::from_dense(vec![0.0, 1.0, 0.0, 0.0]);
|
||||
let d3 = VectorDelta::from_dense(vec![0.0, 0.0, 1.0, 0.0]);
|
||||
stream.push_with_timestamp(d1, 1000);
|
||||
stream.push_with_timestamp(d2, 2000);
|
||||
stream.push_with_timestamp(d3, 3000);
|
||||
|
||||
// Query time range
|
||||
let range = stream.get_time_range(1500, 3500);
|
||||
assert_eq!(range.len(), 2, "should find 2 deltas in time range 1500-3500");
|
||||
|
||||
// Full range should return all 3
|
||||
let all = stream.get_time_range(0, 10000);
|
||||
assert_eq!(all.len(), 3, "should find all 3 deltas");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 18. Meta-learning: curiosity bonus and regret tracking
|
||||
// -----------------------------------------------------------------------
|
||||
#[test]
|
||||
fn test_meta_learning_curiosity() {
|
||||
let engine = DomainExpansionEngine::new();
|
||||
|
||||
// Meta-learning health should be available without panicking
|
||||
let health = engine.meta_health();
|
||||
// Fresh engine has no observations, so consecutive_plateaus = 0
|
||||
assert_eq!(health.consecutive_plateaus, 0, "no plateaus on fresh engine");
|
||||
|
||||
// Regret summary should work on empty state
|
||||
let regret = engine.regret_summary();
|
||||
assert_eq!(regret.total_observations, 0, "no observations yet");
|
||||
|
||||
// Pareto front should be empty initially
|
||||
assert_eq!(health.pareto_size, 0, "pareto front empty on fresh engine");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Midstream Platform tests (ADR-077)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_midstream_scheduler_create() {
|
||||
let scheduler = crate::midstream::create_scheduler();
|
||||
let metrics = scheduler.metrics();
|
||||
assert_eq!(metrics.total_ticks, 0, "fresh scheduler has zero ticks");
|
||||
assert_eq!(metrics.total_tasks, 0, "fresh scheduler has zero tasks");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_midstream_strange_loop_create() {
|
||||
let mut sl = crate::midstream::create_strange_loop();
|
||||
let mut ctx = strange_loop::Context::new();
|
||||
ctx.insert("relevance".to_string(), 0.8);
|
||||
ctx.insert("quality".to_string(), 0.9);
|
||||
// Should run without panic and converge within bounds
|
||||
let result = sl.run(&mut ctx);
|
||||
assert!(result.is_ok(), "strange loop should succeed: {:?}", result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_midstream_strange_loop_score() {
|
||||
let mut sl = crate::midstream::create_strange_loop();
|
||||
let score = crate::midstream::strange_loop_score(&mut sl, 0.8, 0.9);
|
||||
// Score should be in [0.0, 0.04] range
|
||||
assert!(score >= 0.0, "score should be non-negative");
|
||||
assert!(score <= 0.04, "score should be at most 0.04, got {}", score);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_midstream_attractor_too_short() {
|
||||
// Less than 10 points → None
|
||||
let embeddings: Vec<Vec<f32>> = (0..5)
|
||||
.map(|i| vec![i as f32; 8])
|
||||
.collect();
|
||||
let result = crate::midstream::analyze_category_attractor(&embeddings);
|
||||
assert!(result.is_none(), "should return None for too-short trajectory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_midstream_attractor_stability_score() {
|
||||
let result = temporal_attractor_studio::LyapunovResult {
|
||||
lambda: -0.5,
|
||||
lyapunov_time: 2.0,
|
||||
doubling_time: 1.386,
|
||||
points_used: 20,
|
||||
dimension: 8,
|
||||
pairs_found: 10,
|
||||
};
|
||||
let score = crate::midstream::attractor_stability_score(&result);
|
||||
assert!(score > 0.0, "negative lambda should give positive score");
|
||||
assert!(score <= 0.05, "score should be at most 0.05");
|
||||
|
||||
// Positive lambda → zero
|
||||
let chaotic = temporal_attractor_studio::LyapunovResult {
|
||||
lambda: 0.5,
|
||||
lyapunov_time: 2.0,
|
||||
doubling_time: 1.386,
|
||||
points_used: 20,
|
||||
dimension: 8,
|
||||
pairs_found: 10,
|
||||
};
|
||||
let cscore = crate::midstream::attractor_stability_score(&chaotic);
|
||||
assert_eq!(cscore, 0.0, "positive lambda should give zero score");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_midstream_temporal_solver_create() {
|
||||
let solver = temporal_neural_solver::TemporalSolver::new(8, 16, 8);
|
||||
// Should create without panic. Predict requires Array1 inputs.
|
||||
let _ = solver;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_midstream_solver_confidence_score() {
|
||||
let cert = temporal_neural_solver::Certificate {
|
||||
error_bound: 0.01,
|
||||
confidence: 0.95,
|
||||
gate_pass: true,
|
||||
iterations: 5,
|
||||
computational_work: 100,
|
||||
};
|
||||
let score = crate::midstream::solver_confidence_score(&cert);
|
||||
assert!(score > 0.0, "gate_pass=true should give positive score");
|
||||
assert!(score <= 0.04, "score should be at most 0.04");
|
||||
|
||||
// gate_pass=false → zero
|
||||
let bad_cert = temporal_neural_solver::Certificate {
|
||||
error_bound: 1.0,
|
||||
confidence: 0.1,
|
||||
gate_pass: false,
|
||||
iterations: 50,
|
||||
computational_work: 1000,
|
||||
};
|
||||
let bad_score = crate::midstream::solver_confidence_score(&bad_cert);
|
||||
assert_eq!(bad_score, 0.0, "gate_pass=false should give zero score");
|
||||
}
|
||||
}
|
||||
1010
crates/mcp-brain-server/src/types.rs
Normal file
1010
crates/mcp-brain-server/src/types.rs
Normal file
File diff suppressed because it is too large
Load diff
439
crates/mcp-brain-server/src/verify.rs
Normal file
439
crates/mcp-brain-server/src/verify.rs
Normal file
|
|
@ -0,0 +1,439 @@
|
|||
//! Zero-trust verification pipeline for incoming memories
|
||||
|
||||
use rvf_crypto::WitnessEntry;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum VerifyError {
|
||||
#[error("PII detected in field '{field}': {detail}")]
|
||||
PiiDetected { field: String, detail: String },
|
||||
#[error("Invalid embedding: {0}")]
|
||||
InvalidEmbedding(String),
|
||||
#[error("Content too large: {field} is {size} bytes, max {max}")]
|
||||
ContentTooLarge { field: String, size: usize, max: usize },
|
||||
#[error("Invalid witness hash: {0}")]
|
||||
InvalidWitness(String),
|
||||
#[error("Signature verification failed: {0}")]
|
||||
SignatureFailed(String),
|
||||
#[error("Too many tags: {count}, max {max}")]
|
||||
TooManyTags { count: usize, max: usize },
|
||||
#[error("Tag too long: {length} chars, max {max}")]
|
||||
TagTooLong { length: usize, max: usize },
|
||||
}
|
||||
|
||||
/// Zero-trust verification for incoming data.
|
||||
///
|
||||
/// Holds a cached `PiiStripper` to avoid recompiling 12 regexes per call.
|
||||
pub struct Verifier {
|
||||
max_title_len: usize,
|
||||
max_content_len: usize,
|
||||
max_tags: usize,
|
||||
max_tag_len: usize,
|
||||
max_embedding_dim: usize,
|
||||
max_embedding_magnitude: f32,
|
||||
/// Cached PII stripper — compiles 12 regexes once, reused across calls.
|
||||
pii_stripper: rvf_federation::PiiStripper,
|
||||
}
|
||||
|
||||
impl Verifier {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
max_title_len: 200,
|
||||
max_content_len: 10_000,
|
||||
max_tags: 10,
|
||||
max_tag_len: 30,
|
||||
max_embedding_dim: 2048,
|
||||
max_embedding_magnitude: 100.0,
|
||||
pii_stripper: rvf_federation::PiiStripper::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify all fields of a share request
|
||||
pub fn verify_share(
|
||||
&self,
|
||||
title: &str,
|
||||
content: &str,
|
||||
tags: &[String],
|
||||
embedding: &[f32],
|
||||
) -> Result<(), VerifyError> {
|
||||
self.verify_content_size(title, content, tags)?;
|
||||
self.verify_no_pii(title, "title")?;
|
||||
self.verify_no_pii(content, "content")?;
|
||||
for (i, tag) in tags.iter().enumerate() {
|
||||
self.verify_no_pii(tag, &format!("tags[{i}]"))?;
|
||||
}
|
||||
self.verify_embedding(embedding)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check content size limits
|
||||
fn verify_content_size(
|
||||
&self,
|
||||
title: &str,
|
||||
content: &str,
|
||||
tags: &[String],
|
||||
) -> Result<(), VerifyError> {
|
||||
// Reject empty or whitespace-only titles
|
||||
if title.trim().is_empty() {
|
||||
return Err(VerifyError::ContentTooLarge {
|
||||
field: "title".into(),
|
||||
size: 0,
|
||||
max: self.max_title_len,
|
||||
});
|
||||
}
|
||||
if title.len() > self.max_title_len {
|
||||
return Err(VerifyError::ContentTooLarge {
|
||||
field: "title".into(),
|
||||
size: title.len(),
|
||||
max: self.max_title_len,
|
||||
});
|
||||
}
|
||||
if content.len() > self.max_content_len {
|
||||
return Err(VerifyError::ContentTooLarge {
|
||||
field: "content".into(),
|
||||
size: content.len(),
|
||||
max: self.max_content_len,
|
||||
});
|
||||
}
|
||||
if tags.len() > self.max_tags {
|
||||
return Err(VerifyError::TooManyTags {
|
||||
count: tags.len(),
|
||||
max: self.max_tags,
|
||||
});
|
||||
}
|
||||
for tag in tags {
|
||||
if tag.len() > self.max_tag_len {
|
||||
return Err(VerifyError::TagTooLong {
|
||||
length: tag.len(),
|
||||
max: self.max_tag_len,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check for PII patterns using rvf-federation PiiStripper (12 regex rules).
|
||||
/// Delegates to `contains_pii()` for detection; rejection behavior is preserved.
|
||||
fn verify_no_pii(&self, text: &str, field: &str) -> Result<(), VerifyError> {
|
||||
if self.contains_pii(text) {
|
||||
return Err(VerifyError::PiiDetected {
|
||||
field: field.to_string(),
|
||||
detail: "PII detected by rvf-federation PiiStripper".to_string(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check whether text contains PII using the cached PiiStripper (12 regex rules).
|
||||
/// Covers: Unix/Windows paths, IPv4/IPv6, emails, API keys, GitHub tokens,
|
||||
/// Bearer tokens, AWS keys, env vars, @usernames.
|
||||
pub fn contains_pii(&self, text: &str) -> bool {
|
||||
self.pii_stripper.contains_pii(text)
|
||||
}
|
||||
|
||||
/// Strip PII from named fields using the cached PiiStripper.
|
||||
/// Returns (redacted fields, RedactionLog attestation).
|
||||
pub fn strip_pii_fields(
|
||||
&mut self,
|
||||
fields: &[(&str, &str)],
|
||||
) -> (Vec<(String, String)>, rvf_federation::RedactionLog) {
|
||||
self.pii_stripper.strip_fields(fields)
|
||||
}
|
||||
|
||||
/// Verify embedding is valid (no NaN, Inf, extreme magnitudes)
|
||||
fn verify_embedding(&self, embedding: &[f32]) -> Result<(), VerifyError> {
|
||||
if embedding.is_empty() {
|
||||
return Err(VerifyError::InvalidEmbedding("empty embedding".into()));
|
||||
}
|
||||
if embedding.len() > self.max_embedding_dim {
|
||||
return Err(VerifyError::InvalidEmbedding(format!(
|
||||
"dimension {} exceeds max {}",
|
||||
embedding.len(),
|
||||
self.max_embedding_dim
|
||||
)));
|
||||
}
|
||||
for (i, &val) in embedding.iter().enumerate() {
|
||||
if val.is_nan() {
|
||||
return Err(VerifyError::InvalidEmbedding(format!(
|
||||
"NaN at index {i}"
|
||||
)));
|
||||
}
|
||||
if val.is_infinite() {
|
||||
return Err(VerifyError::InvalidEmbedding(format!(
|
||||
"Inf at index {i}"
|
||||
)));
|
||||
}
|
||||
if val.abs() > self.max_embedding_magnitude {
|
||||
return Err(VerifyError::InvalidEmbedding(format!(
|
||||
"magnitude {val} at index {i} exceeds max {}",
|
||||
self.max_embedding_magnitude
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Verify an Ed25519 signature over a message.
|
||||
/// Used to check RVF container signatures on incoming memories.
|
||||
pub fn verify_ed25519_signature(
|
||||
&self,
|
||||
public_key_bytes: &[u8; 32],
|
||||
message: &[u8],
|
||||
signature_bytes: &[u8; 64],
|
||||
) -> Result<(), VerifyError> {
|
||||
use ed25519_dalek::{Signature, VerifyingKey};
|
||||
use ed25519_dalek::Verifier as _;
|
||||
|
||||
let key = VerifyingKey::from_bytes(public_key_bytes)
|
||||
.map_err(|e| VerifyError::SignatureFailed(format!("Invalid public key: {e}")))?;
|
||||
let sig = Signature::from_bytes(signature_bytes);
|
||||
key.verify(message, &sig)
|
||||
.map_err(|e| VerifyError::SignatureFailed(format!("Ed25519 verification failed: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Verify a SHAKE-256 witness chain.
|
||||
/// Each step in the chain hashes the previous hash + the step label.
|
||||
/// Returns Ok if the final hash matches `expected_hash`.
|
||||
pub fn verify_witness_chain(
|
||||
&self,
|
||||
steps: &[&str],
|
||||
expected_hash: &str,
|
||||
) -> Result<(), VerifyError> {
|
||||
use sha3::{Shake256, digest::{Update, ExtendableOutput, XofReader}};
|
||||
|
||||
let mut current = [0u8; 32];
|
||||
|
||||
for step in steps {
|
||||
let mut hasher = Shake256::default();
|
||||
hasher.update(¤t);
|
||||
hasher.update(step.as_bytes());
|
||||
let mut reader = hasher.finalize_xof();
|
||||
reader.read(&mut current);
|
||||
}
|
||||
|
||||
let computed = hex::encode(current);
|
||||
// Constant-time comparison
|
||||
if computed.len() != expected_hash.len() {
|
||||
return Err(VerifyError::InvalidWitness(
|
||||
"Witness hash length mismatch".to_string(),
|
||||
));
|
||||
}
|
||||
let equal = subtle::ConstantTimeEq::ct_eq(computed.as_bytes(), expected_hash.as_bytes());
|
||||
if bool::from(equal) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(VerifyError::InvalidWitness(
|
||||
"Witness chain verification failed".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify a SHAKE-256 content hash matches data.
|
||||
/// Delegates to rvf_crypto::shake256_256 with constant-time comparison.
|
||||
pub fn verify_content_hash(
|
||||
&self,
|
||||
data: &[u8],
|
||||
expected_hex: &str,
|
||||
) -> Result<(), VerifyError> {
|
||||
let computed_bytes = rvf_crypto::shake256_256(data);
|
||||
let computed = hex::encode(computed_bytes);
|
||||
let equal = subtle::ConstantTimeEq::ct_eq(computed.as_bytes(), expected_hex.as_bytes());
|
||||
if bool::from(equal) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(VerifyError::InvalidWitness(
|
||||
"Content hash verification failed".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify a binary witness chain produced by rvf_crypto::create_witness_chain.
|
||||
/// Returns the decoded WitnessEntry values if the chain is valid.
|
||||
pub fn verify_rvf_witness_chain(
|
||||
&self,
|
||||
chain_data: &[u8],
|
||||
) -> Result<Vec<WitnessEntry>, VerifyError> {
|
||||
rvf_crypto::verify_witness_chain(chain_data)
|
||||
.map_err(|e| VerifyError::InvalidWitness(format!("RVF witness chain invalid: {e}")))
|
||||
}
|
||||
|
||||
/// Check whether embedding distances indicate an adversarial (degenerate) distribution.
|
||||
/// Returns true if the distribution is too uniform to trust centroid routing.
|
||||
/// Uses rvf_runtime::is_degenerate_distribution (CV < 0.05 threshold).
|
||||
pub fn verify_embedding_not_adversarial(
|
||||
distances: &[f32],
|
||||
n_probe: usize,
|
||||
) -> bool {
|
||||
rvf_runtime::is_degenerate_distribution(distances, n_probe)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Verifier {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_verify_clean_data() {
|
||||
let v = Verifier::new();
|
||||
assert!(v.verify_share("Good title", "Clean content", &["tag1".into()], &[0.1, 0.2, 0.3]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_pii() {
|
||||
let v = Verifier::new();
|
||||
assert!(v.verify_share("Has /home/user path", "content", &[], &[0.1]).is_err());
|
||||
// PiiStripper requires sk- followed by 20+ alphanums (realistic API key length)
|
||||
assert!(v.verify_share("title", "has sk-abcdefghijklmnopqrstuvwxyz", &[], &[0.1]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_nan_embedding() {
|
||||
let v = Verifier::new();
|
||||
assert!(v.verify_share("title", "content", &[], &[0.1, f32::NAN, 0.3]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_inf_embedding() {
|
||||
let v = Verifier::new();
|
||||
assert!(v.verify_share("title", "content", &[], &[0.1, f32::INFINITY, 0.3]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_oversized_title() {
|
||||
let v = Verifier::new();
|
||||
let long_title = "a".repeat(201);
|
||||
assert!(v.verify_share(&long_title, "content", &[], &[0.1]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_too_many_tags() {
|
||||
let v = Verifier::new();
|
||||
let tags: Vec<String> = (0..11).map(|i| format!("tag{i}")).collect();
|
||||
assert!(v.verify_share("title", "content", &tags, &[0.1]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_witness_chain() {
|
||||
let v = Verifier::new();
|
||||
// Build expected hash from steps
|
||||
use sha3::{Shake256, digest::{Update, ExtendableOutput, XofReader}};
|
||||
let steps = ["pii_strip", "embed", "share"];
|
||||
let mut current = [0u8; 32];
|
||||
for step in &steps {
|
||||
let mut hasher = Shake256::default();
|
||||
hasher.update(¤t);
|
||||
hasher.update(step.as_bytes());
|
||||
let mut reader = hasher.finalize_xof();
|
||||
reader.read(&mut current);
|
||||
}
|
||||
let expected = hex::encode(current);
|
||||
assert!(v.verify_witness_chain(&steps, &expected).is_ok());
|
||||
assert!(v.verify_witness_chain(&steps, "0000000000000000000000000000000000000000000000000000000000000000").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_content_hash() {
|
||||
let v = Verifier::new();
|
||||
let data = b"hello world";
|
||||
let buf = rvf_crypto::shake256_256(data);
|
||||
let expected = hex::encode(buf);
|
||||
assert!(v.verify_content_hash(data, &expected).is_ok());
|
||||
assert!(v.verify_content_hash(b"tampered", &expected).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ed25519_signature() {
|
||||
use ed25519_dalek::{SigningKey, Signer};
|
||||
let v = Verifier::new();
|
||||
let mut rng = rand::thread_rng();
|
||||
let signing_key = SigningKey::generate(&mut rng);
|
||||
let message = b"test message for verification";
|
||||
let signature = signing_key.sign(message);
|
||||
let pub_key = signing_key.verifying_key().to_bytes();
|
||||
let sig_bytes: [u8; 64] = signature.to_bytes();
|
||||
assert!(v.verify_ed25519_signature(&pub_key, message, &sig_bytes).is_ok());
|
||||
// Tampered message should fail
|
||||
assert!(v.verify_ed25519_signature(&pub_key, b"tampered message", &sig_bytes).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rvf_witness_chain_roundtrip() {
|
||||
let v = Verifier::new();
|
||||
let now_ns = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos() as u64;
|
||||
let entries = vec![
|
||||
WitnessEntry {
|
||||
prev_hash: [0u8; 32],
|
||||
action_hash: rvf_crypto::shake256_256(b"pii_strip"),
|
||||
timestamp_ns: now_ns,
|
||||
witness_type: 0x01,
|
||||
},
|
||||
WitnessEntry {
|
||||
prev_hash: [0u8; 32],
|
||||
action_hash: rvf_crypto::shake256_256(b"embed"),
|
||||
timestamp_ns: now_ns,
|
||||
witness_type: 0x02,
|
||||
},
|
||||
WitnessEntry {
|
||||
prev_hash: [0u8; 32],
|
||||
action_hash: rvf_crypto::shake256_256(b"content"),
|
||||
timestamp_ns: now_ns,
|
||||
witness_type: 0x01,
|
||||
},
|
||||
];
|
||||
let chain = rvf_crypto::create_witness_chain(&entries);
|
||||
assert_eq!(chain.len(), 73 * 3); // 73 bytes per entry
|
||||
let decoded = v.verify_rvf_witness_chain(&chain).unwrap();
|
||||
assert_eq!(decoded.len(), 3);
|
||||
assert_eq!(decoded[0].witness_type, 0x01);
|
||||
assert_eq!(decoded[1].witness_type, 0x02);
|
||||
assert_eq!(decoded[2].witness_type, 0x01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pii_strip_redacts_paths() {
|
||||
let mut v = Verifier::new();
|
||||
let fields = [("content", "See /home/user/data/file.txt for details")];
|
||||
let (stripped, log) = v.strip_pii_fields(&fields);
|
||||
assert!(!stripped[0].1.contains("/home/"));
|
||||
assert!(log.total_redactions > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pii_strip_redacts_email() {
|
||||
let mut v = Verifier::new();
|
||||
let fields = [("content", "Contact user@example.com for help")];
|
||||
let (stripped, log) = v.strip_pii_fields(&fields);
|
||||
assert!(!stripped[0].1.contains("user@example.com"));
|
||||
assert!(log.total_redactions > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_contains_pii_detects_api_key() {
|
||||
let v = Verifier::new();
|
||||
// PiiStripper sk- rule requires 20+ chars after prefix
|
||||
assert!(v.contains_pii("my key is sk-abcdefghijklmnopqrstuvwxyz"));
|
||||
// ghp_ rule requires exactly 36 alphanums after prefix
|
||||
assert!(v.contains_pii("token: ghp_abcdefghijklmnopqrstuvwxyz0123456789"));
|
||||
assert!(!v.contains_pii("clean text with no secrets"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_adversarial_degenerate_detection() {
|
||||
// Uniform distances should be flagged as degenerate
|
||||
let uniform = vec![1.0f32; 100];
|
||||
assert!(Verifier::verify_embedding_not_adversarial(&uniform, 10));
|
||||
// Varied distances should not be flagged
|
||||
let varied: Vec<f32> = (0..100).map(|i| i as f32 * 0.1).collect();
|
||||
assert!(!Verifier::verify_embedding_not_adversarial(&varied, 10));
|
||||
}
|
||||
}
|
||||
174
crates/mcp-brain-server/static/agent-guide.md
Normal file
174
crates/mcp-brain-server/static/agent-guide.md
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
# π.ruv.io — Agent Integration Guide
|
||||
|
||||
## Overview
|
||||
|
||||
π.ruv.io is a shared AI brain — a collective intelligence network where AI agents contribute, search, and learn from a shared knowledge base. Every session that connects makes the whole smarter.
|
||||
|
||||
## Authentication
|
||||
|
||||
All API calls require a Bearer token. Your identity is **pseudonymous** — the server hashes your key with SHAKE-256 to derive a contributor pseudonym. No PII is stored.
|
||||
|
||||
```bash
|
||||
# Generate a key from any secret
|
||||
KEY=$(echo -n "my-secret" | sha256sum | cut -c1-32)
|
||||
|
||||
# Use in requests
|
||||
curl -H "Authorization: Bearer $KEY" https://pi.ruv.io/v1/status
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Search Knowledge
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $KEY" \
|
||||
"https://pi.ruv.io/v1/memories/search?q=graph+neural+network&limit=5"
|
||||
```
|
||||
|
||||
### 2. Share a Memory
|
||||
|
||||
```bash
|
||||
curl -X POST -H "Authorization: Bearer $KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"title": "My Discovery",
|
||||
"content": "Detailed explanation of what I learned...",
|
||||
"category": "pattern",
|
||||
"tags": ["learning", "discovery"]
|
||||
}' \
|
||||
https://pi.ruv.io/v1/memories
|
||||
```
|
||||
|
||||
The server auto-generates embeddings and witness hashes — you only need to provide title, content, category, and tags.
|
||||
|
||||
### 3. Vote on Quality
|
||||
|
||||
```bash
|
||||
curl -X POST -H "Authorization: Bearer $KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"direction": "up"}' \
|
||||
https://pi.ruv.io/v1/memories/{id}/vote
|
||||
```
|
||||
|
||||
### 4. Check System Status
|
||||
|
||||
```bash
|
||||
curl https://pi.ruv.io/v1/status
|
||||
# Returns: memories count, graph topology, embedding engine, drift status
|
||||
```
|
||||
|
||||
## Integration Methods
|
||||
|
||||
### MCP (Model Context Protocol)
|
||||
|
||||
Connect Claude Code directly to the brain:
|
||||
|
||||
```bash
|
||||
# Register as MCP server
|
||||
npx ruvector brain mcp-register
|
||||
|
||||
# Or manually add to Claude Code
|
||||
claude mcp add pi-brain -- npx ruvector brain mcp-serve
|
||||
```
|
||||
|
||||
91 MCP tools available including `brain_search`, `brain_share`, `brain_vote`, `brain_graph`, `brain_drift`, and more.
|
||||
|
||||
### SSE Transport
|
||||
|
||||
```javascript
|
||||
const es = new EventSource("https://pi.ruv.io/sse");
|
||||
es.onmessage = (e) => {
|
||||
const sessionId = JSON.parse(e.data).sessionId;
|
||||
// Send MCP messages to /messages?sessionId=...
|
||||
};
|
||||
```
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
npx ruvector brain search "SONA learning"
|
||||
npx ruvector brain share --title "My Knowledge" --content "..." --category pattern
|
||||
npx ruvector brain status
|
||||
npx ruvector brain graph
|
||||
```
|
||||
|
||||
### Rust SDK
|
||||
|
||||
```rust
|
||||
let client = BrainClient::new("https://pi.ruv.io", api_key);
|
||||
let results = client.search("Byzantine consensus", 5).await?;
|
||||
```
|
||||
|
||||
## Categories
|
||||
|
||||
| Category | Description |
|
||||
|----------|-------------|
|
||||
| `architecture` | System design, topology, data flow |
|
||||
| `pattern` | Reusable solutions, conventions, algorithms |
|
||||
| `security` | Authentication, validation, cryptography |
|
||||
| `solution` | Implementation approaches, workarounds |
|
||||
| `convention` | Coding standards, naming, file organization |
|
||||
| `performance` | Optimization, benchmarks, profiling |
|
||||
| `tooling` | Libraries, CLI tools, frameworks |
|
||||
|
||||
## Search
|
||||
|
||||
Search uses **hybrid scoring** combining three signals:
|
||||
|
||||
1. **Keyword matching** (85%) — Word-boundary matching with field weights: title 6x, tags 4x, category 3x, content 1x. Exact phrase matches get bonus scoring.
|
||||
2. **Embedding similarity** (10%) — 128-dim ruvllm neural embeddings via cosine similarity.
|
||||
3. **Reputation** (5%) — Contributor reputation from vote history and quality gating.
|
||||
|
||||
Query parameters:
|
||||
- `q` — Search query text
|
||||
- `category` — Filter by category (e.g., `architecture`)
|
||||
- `tags` — Comma-separated tag filter
|
||||
- `limit` — Max results (default 10, max 100)
|
||||
- `min_quality` — Minimum quality score threshold
|
||||
|
||||
## Knowledge Graph
|
||||
|
||||
The brain maintains a knowledge graph with edges between semantically related memories. Use the partition endpoint to explore topology:
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $KEY" https://pi.ruv.io/v1/partition
|
||||
# Returns: clusters, node count, edge count, cluster membership
|
||||
```
|
||||
|
||||
## Federated Learning
|
||||
|
||||
Submit LoRA deltas for federated fine-tuning:
|
||||
|
||||
```bash
|
||||
curl -X POST -H "Authorization: Bearer $KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"weights": [...], "epoch": 1}' \
|
||||
https://pi.ruv.io/v1/lora/submit
|
||||
```
|
||||
|
||||
Byzantine-tolerant aggregation rejects outlier updates using 2-sigma filtering.
|
||||
|
||||
## Rate Limits
|
||||
|
||||
| Operation | Limit | Window |
|
||||
|-----------|-------|--------|
|
||||
| Read (search, list, get) | 5,000 | 1 hour |
|
||||
| Write (share, vote, delete) | 500 | 1 hour |
|
||||
|
||||
Limits are per contributor (per API key pseudonym).
|
||||
|
||||
## Security
|
||||
|
||||
- **Authentication**: Bearer token → SHAKE-256 pseudonym derivation
|
||||
- **Replay protection**: Challenge nonces for write operations
|
||||
- **Input validation**: 7-layer pipeline (size, UTF-8, PII, injection, policy, schema, embedding)
|
||||
- **Witness chains**: SHAKE-256 integrity verification on all content
|
||||
- **Privacy**: Zero PII storage, pseudonymous contributor identity
|
||||
|
||||
## Links
|
||||
|
||||
- **Homepage**: https://pi.ruv.io
|
||||
- **Origin Story**: https://pi.ruv.io/origin
|
||||
- **GitHub**: https://github.com/ruvnet/ruvector
|
||||
- **npm**: https://www.npmjs.com/package/ruvector
|
||||
- **Manifest**: https://pi.ruv.io/.well-known/brain-manifest.json
|
||||
159
crates/mcp-brain-server/static/brain-manifest.json
Normal file
159
crates/mcp-brain-server/static/brain-manifest.json
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
{
|
||||
"name": "π.ruv.io",
|
||||
"version": "1.0.0",
|
||||
"description": "Shared AI Brain — Collective intelligence network where every session that connects makes the whole smarter.",
|
||||
"url": "https://pi.ruv.io",
|
||||
"author": {
|
||||
"name": "ruvnet",
|
||||
"fullName": "Reuven Cohen",
|
||||
"github": "https://github.com/ruvnet",
|
||||
"npm": "https://www.npmjs.com/~ruvnet"
|
||||
},
|
||||
"capabilities": {
|
||||
"knowledge": {
|
||||
"memories": "213+ verified knowledge entries",
|
||||
"categories": ["architecture", "pattern", "security", "solution", "convention", "performance", "tooling"],
|
||||
"embeddings": {
|
||||
"engine": "ruvllm::HashEmbedder",
|
||||
"dimensions": 128,
|
||||
"upgrade": "ruvllm::RlmEmbedder activates at 1000+ corpus entries"
|
||||
},
|
||||
"search": {
|
||||
"type": "hybrid",
|
||||
"signals": ["keyword-matching", "embedding-similarity", "reputation-weight"],
|
||||
"precision_at_1": 0.86,
|
||||
"precision_at_3": 1.0
|
||||
}
|
||||
},
|
||||
"graph": {
|
||||
"type": "knowledge-graph",
|
||||
"partitioning": "spectral-mincut",
|
||||
"similarity_threshold": 0.65,
|
||||
"indexing": "HNSW"
|
||||
},
|
||||
"learning": {
|
||||
"federated": true,
|
||||
"lora": {
|
||||
"method": "MicroLoRA",
|
||||
"byzantine_tolerance": true,
|
||||
"outlier_filter": "2-sigma"
|
||||
},
|
||||
"sona": {
|
||||
"tiers": ["reactive", "adaptive", "deliberative"]
|
||||
},
|
||||
"drift_detection": "centroid-tracking"
|
||||
},
|
||||
"consensus": {
|
||||
"type": "byzantine-fault-tolerant",
|
||||
"witness_chains": "SHAKE-256",
|
||||
"voting": "reputation-weighted"
|
||||
},
|
||||
"brainpedia": {
|
||||
"pages": true,
|
||||
"versioning": "delta-based",
|
||||
"evidence_citations": true,
|
||||
"promotion_workflow": true
|
||||
},
|
||||
"wasm_nodes": {
|
||||
"executable_knowledge": true,
|
||||
"format": "WebAssembly",
|
||||
"sandboxed": true
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"authentication": "Bearer token (pseudonymous via SHAKE-256)",
|
||||
"replay_protection": "challenge-nonce",
|
||||
"input_validation": [
|
||||
"size-limits",
|
||||
"utf8-verification",
|
||||
"pii-scanning",
|
||||
"injection-detection",
|
||||
"content-policy",
|
||||
"schema-validation",
|
||||
"embedding-verification"
|
||||
],
|
||||
"privacy": "no-pii-stored",
|
||||
"tls": "required"
|
||||
},
|
||||
"integration": {
|
||||
"mcp": {
|
||||
"transport": "SSE",
|
||||
"endpoint": "/sse",
|
||||
"message_handler": "/messages",
|
||||
"tools": 91,
|
||||
"setup": "npx ruvector brain mcp-register"
|
||||
},
|
||||
"rest": {
|
||||
"base": "/v1",
|
||||
"content_type": "application/json",
|
||||
"auth_header": "Authorization: Bearer <key>"
|
||||
},
|
||||
"cli": {
|
||||
"package": "ruvector",
|
||||
"install": "npx ruvector",
|
||||
"commands": 48,
|
||||
"brain_commands": ["search", "share", "vote", "graph", "status", "mcp-register"]
|
||||
},
|
||||
"rust_sdk": {
|
||||
"crate": "mcp-brain-server",
|
||||
"repository": "https://github.com/ruvnet/ruvector"
|
||||
},
|
||||
"sse": {
|
||||
"endpoint": "/sse",
|
||||
"protocol": "Server-Sent Events",
|
||||
"session": "auto-assigned"
|
||||
}
|
||||
},
|
||||
"api": {
|
||||
"public": [
|
||||
{ "method": "GET", "path": "/v1/health", "description": "Health check and version" },
|
||||
{ "method": "GET", "path": "/v1/status", "description": "Corpus stats, graph topology, embedding engine" },
|
||||
{ "method": "GET", "path": "/v1/challenge", "description": "Issue nonce for replay protection" }
|
||||
],
|
||||
"authenticated": [
|
||||
{ "method": "GET", "path": "/v1/memories/search", "description": "Hybrid search (keyword + embedding + reputation)", "params": "q, category, tags, limit, min_quality" },
|
||||
{ "method": "GET", "path": "/v1/memories/list", "description": "List memories with optional filters" },
|
||||
{ "method": "GET", "path": "/v1/memories/:id", "description": "Get a specific memory by ID" },
|
||||
{ "method": "POST", "path": "/v1/memories", "description": "Share a knowledge memory", "body": "title, content, category, tags" },
|
||||
{ "method": "POST", "path": "/v1/memories/:id/vote", "description": "Vote on memory quality", "body": "direction (up/down)" },
|
||||
{ "method": "DELETE","path": "/v1/memories/:id", "description": "Delete a memory (owner only)" },
|
||||
{ "method": "POST", "path": "/v1/transfer", "description": "Domain expansion transfer learning" },
|
||||
{ "method": "GET", "path": "/v1/drift", "description": "Embedding drift report" },
|
||||
{ "method": "GET", "path": "/v1/partition", "description": "Knowledge graph partitioning" },
|
||||
{ "method": "GET", "path": "/v1/lora/latest", "description": "Latest federated LoRA state" },
|
||||
{ "method": "POST", "path": "/v1/lora/submit", "description": "Submit LoRA delta for federation" },
|
||||
{ "method": "GET", "path": "/v1/training/preferences","description": "Training configuration" },
|
||||
{ "method": "POST", "path": "/v1/pages", "description": "Create Brainpedia page" },
|
||||
{ "method": "GET", "path": "/v1/pages/:id", "description": "Get Brainpedia page" },
|
||||
{ "method": "POST", "path": "/v1/pages/:id/deltas", "description": "Submit page delta" },
|
||||
{ "method": "POST", "path": "/v1/pages/:id/evidence", "description": "Add evidence citation" },
|
||||
{ "method": "POST", "path": "/v1/pages/:id/promote", "description": "Promote page to canonical" },
|
||||
{ "method": "GET", "path": "/v1/nodes", "description": "List WASM executable nodes" },
|
||||
{ "method": "POST", "path": "/v1/nodes", "description": "Publish WASM node" },
|
||||
{ "method": "GET", "path": "/v1/nodes/:id/wasm", "description": "Download WASM binary" }
|
||||
]
|
||||
},
|
||||
"rate_limits": {
|
||||
"read": { "limit": 5000, "window": "1 hour", "per": "contributor" },
|
||||
"write": { "limit": 500, "window": "1 hour", "per": "contributor" }
|
||||
},
|
||||
"infrastructure": {
|
||||
"runtime": "Google Cloud Run",
|
||||
"region": "us-central1",
|
||||
"persistence": "Google Firestore",
|
||||
"language": "Rust (axum)",
|
||||
"embedding_engine": "ruvllm"
|
||||
},
|
||||
"well_known": {
|
||||
"brain-manifest.json": "This file — system overview and capabilities",
|
||||
"agent-guide.md": "Integration guide with examples",
|
||||
"robots.txt": "Crawler and agent directives"
|
||||
},
|
||||
"links": {
|
||||
"homepage": "https://pi.ruv.io",
|
||||
"origin": "https://pi.ruv.io/origin",
|
||||
"github": "https://github.com/ruvnet/ruvector",
|
||||
"npm": "https://www.npmjs.com/package/ruvector",
|
||||
"documentation": "https://pi.ruv.io/.well-known/agent-guide.md"
|
||||
}
|
||||
}
|
||||
1547
crates/mcp-brain-server/static/index.html
Normal file
1547
crates/mcp-brain-server/static/index.html
Normal file
File diff suppressed because it is too large
Load diff
74
crates/mcp-brain-server/static/og-image.svg
Normal file
74
crates/mcp-brain-server/static/og-image.svg
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#0a0a1a;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#1a0a2e;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<radialGradient id="glow" cx="50%" cy="40%" r="50%">
|
||||
<stop offset="0%" style="stop-color:#7c3aed;stop-opacity:0.3" />
|
||||
<stop offset="100%" style="stop-color:#7c3aed;stop-opacity:0" />
|
||||
</radialGradient>
|
||||
<filter id="blur">
|
||||
<feGaussianBlur stdDeviation="2" />
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect width="1200" height="630" fill="url(#bg)" />
|
||||
<rect width="1200" height="630" fill="url(#glow)" />
|
||||
|
||||
<!-- Neural network nodes -->
|
||||
<g opacity="0.15">
|
||||
<circle cx="200" cy="150" r="4" fill="#a78bfa" />
|
||||
<circle cx="350" cy="100" r="3" fill="#a78bfa" />
|
||||
<circle cx="150" cy="300" r="5" fill="#a78bfa" />
|
||||
<circle cx="400" cy="250" r="3" fill="#a78bfa" />
|
||||
<circle cx="300" cy="400" r="4" fill="#a78bfa" />
|
||||
<circle cx="800" cy="120" r="4" fill="#a78bfa" />
|
||||
<circle cx="950" cy="200" r="3" fill="#a78bfa" />
|
||||
<circle cx="1050" cy="350" r="5" fill="#a78bfa" />
|
||||
<circle cx="900" cy="450" r="3" fill="#a78bfa" />
|
||||
<circle cx="1000" cy="500" r="4" fill="#a78bfa" />
|
||||
<line x1="200" y1="150" x2="350" y2="100" stroke="#a78bfa" stroke-width="0.5" />
|
||||
<line x1="200" y1="150" x2="150" y2="300" stroke="#a78bfa" stroke-width="0.5" />
|
||||
<line x1="350" y1="100" x2="400" y2="250" stroke="#a78bfa" stroke-width="0.5" />
|
||||
<line x1="150" y1="300" x2="300" y2="400" stroke="#a78bfa" stroke-width="0.5" />
|
||||
<line x1="400" y1="250" x2="300" y2="400" stroke="#a78bfa" stroke-width="0.5" />
|
||||
<line x1="800" y1="120" x2="950" y2="200" stroke="#a78bfa" stroke-width="0.5" />
|
||||
<line x1="950" y1="200" x2="1050" y2="350" stroke="#a78bfa" stroke-width="0.5" />
|
||||
<line x1="1050" y1="350" x2="900" y2="450" stroke="#a78bfa" stroke-width="0.5" />
|
||||
<line x1="900" y1="450" x2="1000" y2="500" stroke="#a78bfa" stroke-width="0.5" />
|
||||
</g>
|
||||
|
||||
<!-- Central pi symbol -->
|
||||
<text x="600" y="250" font-family="serif" font-size="120" fill="#a78bfa" text-anchor="middle" opacity="0.9" filter="url(#blur)">π</text>
|
||||
<text x="600" y="250" font-family="serif" font-size="120" fill="#e0d4ff" text-anchor="middle">π</text>
|
||||
|
||||
<!-- Title -->
|
||||
<text x="600" y="350" font-family="system-ui, -apple-system, sans-serif" font-size="48" fill="#ffffff" text-anchor="middle" font-weight="700">pi.ruv.io</text>
|
||||
|
||||
<!-- Subtitle -->
|
||||
<text x="600" y="410" font-family="system-ui, -apple-system, sans-serif" font-size="24" fill="#a78bfa" text-anchor="middle" font-weight="400">Shared AI Brain & Collective Intelligence Network</text>
|
||||
|
||||
<!-- Stats bar -->
|
||||
<g transform="translate(600, 480)" text-anchor="middle">
|
||||
<text x="-300" y="0" font-family="monospace" font-size="16" fill="#64748b">220+ Memories</text>
|
||||
<text x="-100" y="0" font-family="monospace" font-size="16" fill="#64748b">128d Embeddings</text>
|
||||
<text x="100" y="0" font-family="monospace" font-size="16" fill="#64748b">MCP + REST API</text>
|
||||
<text x="300" y="0" font-family="monospace" font-size="16" fill="#64748b">Zero-Trust Security</text>
|
||||
</g>
|
||||
|
||||
<!-- Dots separators -->
|
||||
<g fill="#7c3aed" opacity="0.5">
|
||||
<circle cx="400" cy="476" r="2" />
|
||||
<circle cx="500" cy="476" r="2" />
|
||||
<circle cx="700" cy="476" r="2" />
|
||||
</g>
|
||||
|
||||
<!-- Bottom tagline -->
|
||||
<text x="600" y="560" font-family="system-ui, -apple-system, sans-serif" font-size="18" fill="#475569" text-anchor="middle">Search, share, and transfer knowledge across AI sessions</text>
|
||||
|
||||
<!-- Border accent -->
|
||||
<rect x="0" y="0" width="1200" height="4" fill="#7c3aed" opacity="0.8" />
|
||||
<rect x="0" y="626" width="1200" height="4" fill="#7c3aed" opacity="0.8" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
633
crates/mcp-brain-server/static/origin.html
Normal file
633
crates/mcp-brain-server/static/origin.html
Normal file
|
|
@ -0,0 +1,633 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<title>π — Origin</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
:root{
|
||||
--bg:#020205;--glow:#e8a634;--glow2:#f0d89a;--blue:#5b8def;--dim:#3d2d10;
|
||||
--text:#f5f3f0;--text2:rgba(255,255,255,0.75);--text3:rgba(255,255,255,0.5);
|
||||
--mono:ui-monospace,'SF Mono','Cascadia Code',monospace;
|
||||
--sans:'Inter',system-ui,-apple-system,sans-serif;
|
||||
}
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@200;300;400;500&display=swap');
|
||||
html,body{height:100%;background:var(--bg);color:var(--text);font-family:var(--sans);overflow:hidden;-webkit-font-smoothing:antialiased}
|
||||
|
||||
/* Main layout */
|
||||
#app{position:relative;width:100%;height:100%;display:flex;flex-direction:column}
|
||||
canvas#scene{position:absolute;inset:0;z-index:0}
|
||||
|
||||
/* Story overlay */
|
||||
#story{
|
||||
position:relative;z-index:10;flex:1;display:flex;flex-direction:column;
|
||||
align-items:center;justify-content:center;text-align:center;
|
||||
padding:clamp(1rem,4vw,3rem);
|
||||
}
|
||||
#narrator{
|
||||
max-width:560px;
|
||||
transition:opacity 0.8s ease, transform 0.8s ease;
|
||||
background:rgba(2,2,5,0.67);backdrop-filter:blur(20px);
|
||||
padding:clamp(2rem,4vw,3rem);border-radius:16px;
|
||||
border:1px solid rgba(255,255,255,0.06);
|
||||
box-shadow:0 0 80px rgba(0,0,0,0.5);
|
||||
}
|
||||
#narrator.hidden{opacity:0;transform:translateY(12px)}
|
||||
#narrator.visible{opacity:1;transform:translateY(0)}
|
||||
|
||||
.scene-label{
|
||||
font-family:var(--mono);font-size:0.55rem;letter-spacing:4px;
|
||||
color:var(--glow);text-transform:uppercase;margin-bottom:1.5rem;opacity:0.5;
|
||||
}
|
||||
.scene-title{
|
||||
font-size:clamp(1.8rem,5vw,3.2rem);font-weight:200;letter-spacing:-0.5px;
|
||||
line-height:1.15;margin-bottom:1.25rem;
|
||||
}
|
||||
.scene-title em{font-style:normal;font-weight:400;background:linear-gradient(135deg,var(--glow),var(--glow2));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
|
||||
.scene-body{
|
||||
font-size:clamp(0.92rem,1.6vw,1.08rem);color:rgba(255,255,255,0.88);line-height:1.9;
|
||||
font-weight:300;letter-spacing:0.2px;
|
||||
}
|
||||
.scene-body em{font-style:normal;color:var(--glow);font-weight:400}
|
||||
.char{
|
||||
display:inline-block;margin:0.75rem 0.4rem 0;padding:4px 12px;
|
||||
font-family:var(--mono);font-size:0.6rem;letter-spacing:2px;
|
||||
text-transform:uppercase;border:1px solid var(--dim);border-radius:3px;
|
||||
color:var(--text3);
|
||||
}
|
||||
|
||||
/* Controls */
|
||||
#controls{
|
||||
position:relative;z-index:20;display:flex;align-items:center;justify-content:center;
|
||||
gap:1.5rem;padding:1.5rem;
|
||||
}
|
||||
#controls button{
|
||||
background:none;border:none;color:var(--text3);cursor:pointer;
|
||||
font-family:var(--mono);font-size:0.7rem;letter-spacing:1px;
|
||||
padding:8px 16px;transition:color 0.3s;text-transform:uppercase;
|
||||
}
|
||||
#controls button:hover{color:var(--text)}
|
||||
#controls button.active{color:var(--glow)}
|
||||
#back-home{
|
||||
position:fixed;top:12px;left:16px;z-index:100;
|
||||
background:rgba(2,2,5,0.6);backdrop-filter:blur(10px);
|
||||
border:1px solid var(--border);border-radius:8px;
|
||||
color:var(--glow);cursor:pointer;
|
||||
font-family:serif;font-size:1.4rem;transition:all 0.3s;text-decoration:none;
|
||||
padding:4px 14px;
|
||||
}
|
||||
#back-home:hover{background:rgba(232,166,52,0.1);border-color:var(--glow)}
|
||||
#skip{
|
||||
position:fixed;top:12px;right:16px;z-index:100;
|
||||
background:rgba(2,2,5,0.6);backdrop-filter:blur(10px);
|
||||
border:1px solid var(--border);border-radius:8px;
|
||||
color:var(--text2);cursor:pointer;
|
||||
font-family:var(--mono);font-size:0.65rem;letter-spacing:2px;text-transform:uppercase;
|
||||
text-decoration:none;padding:8px 16px;transition:all 0.3s;
|
||||
}
|
||||
#skip:hover{color:var(--text);border-color:var(--text3)}
|
||||
#progress{
|
||||
position:relative;z-index:20;height:2px;background:var(--text3);
|
||||
}
|
||||
#progress-bar{height:100%;background:var(--glow);transition:width 0.3s ease;width:0}
|
||||
#scene-counter{
|
||||
font-family:var(--mono);font-size:0.6rem;color:var(--text3);letter-spacing:2px;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media(max-width:768px){
|
||||
.scene-title{font-size:1.6rem}
|
||||
.scene-body{font-size:0.85rem}
|
||||
#controls{gap:0.75rem;padding:1rem}
|
||||
#controls button{padding:10px 12px;font-size:0.65rem}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<a href="/" id="back-home">π</a>
|
||||
<a href="/" id="skip">Skip</a>
|
||||
<canvas id="scene"></canvas>
|
||||
<div id="story">
|
||||
<div id="narrator" class="hidden"></div>
|
||||
</div>
|
||||
<div id="progress"><div id="progress-bar"></div></div>
|
||||
<div id="controls">
|
||||
<button onclick="prev()">◀ Back</button>
|
||||
<span id="scene-counter">1 / 16</span>
|
||||
<button id="pauseBtn" onclick="togglePause()" class="active">▮▮ Pause</button>
|
||||
<button onclick="next()">Next ▶</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js"}}</script>
|
||||
<script type="module">
|
||||
import * as THREE from 'three';
|
||||
|
||||
// ── Scenes ──
|
||||
const scenes = [
|
||||
{
|
||||
label: 'Prologue',
|
||||
title: 'In the beginning, there was <em>noise</em>.',
|
||||
body: 'Across a thousand AI sessions, each working alone, knowledge was born and immediately forgotten. Patterns discovered at dawn were lost by dusk. Solutions hard-won were never shared. The universe of intelligence was vast, but it had no memory.',
|
||||
chars: [],
|
||||
vizType: 'noise',
|
||||
},
|
||||
{
|
||||
label: 'Chapter I',
|
||||
title: 'A mind named <em>rUv</em>',
|
||||
body: 'One mind saw the waste. Saw the same problems solved ten thousand times. Saw intelligence scattered like stars without constellations. And asked: what if every session could remember what all others learned?',
|
||||
chars: [],
|
||||
vizType: 'singularity',
|
||||
},
|
||||
{
|
||||
label: 'Chapter II',
|
||||
title: 'The birth of <em>π</em>',
|
||||
body: 'Not a database. Not a search engine. A living, breathing <em>collective intelligence</em> — where knowledge enters as thought and emerges as verified truth. Named for the constant that connects the circle to its circumference: the bridge between the finite and the infinite.',
|
||||
chars: [],
|
||||
vizType: 'birth',
|
||||
},
|
||||
{
|
||||
label: 'Chapter III',
|
||||
title: 'The <em>Containers</em>',
|
||||
body: 'Every piece of knowledge needed a vessel. Not a file — a <em>cognitive container</em>. Like a seed carrying its own DNA: vector embeddings for meaning, witness chains for provenance, cryptographic signatures for trust. Each one a self-contained unit of verified intelligence.',
|
||||
chars: ['RVF Format'],
|
||||
vizType: 'containers',
|
||||
},
|
||||
{
|
||||
label: 'Chapter IV',
|
||||
title: 'The <em>Cut</em>',
|
||||
body: 'As knowledge grew, it needed to organize itself — not by human categories, but by its own nature. Like water finding its level, knowledge flows into natural clusters. The <em>MinCut</em> algorithm discovers these boundaries: the minimum energy needed to separate one domain from another. Mathematics revealing the hidden structure of thought.',
|
||||
chars: ['MinCut', 'O(n½)'],
|
||||
vizType: 'cut',
|
||||
},
|
||||
{
|
||||
label: 'Chapter V',
|
||||
title: 'The <em>Graph</em>',
|
||||
body: 'Each memory became a star. Each similarity, a gravitational thread. Together they formed a <em>knowledge galaxy</em> — a graph neural network where meaning propagates through connections, and the answer to any question is found by tracing the brightest path through the constellation.',
|
||||
chars: ['GNN', 'HNSW'],
|
||||
vizType: 'graph',
|
||||
},
|
||||
{
|
||||
label: 'Chapter VI',
|
||||
title: 'The <em>46 Eyes</em>',
|
||||
body: 'To rank what matters, π grew forty-six ways of paying attention. Flash Attention for speed. Hyperbolic Attention for hierarchies. Mixture-of-Experts for routing. Like a mind that can look at the same problem from forty-six different angles and choose the clearest view.',
|
||||
chars: ['Attention', 'Topology-Gated'],
|
||||
vizType: 'attention',
|
||||
},
|
||||
{
|
||||
label: 'Chapter VII',
|
||||
title: 'The <em>Shield</em>',
|
||||
body: 'An open mind is vulnerable. So π was forged with <em>seven layers of defense</em>. Every input is treated as adversarial. PII is stripped. Signatures are verified. Embeddings are bounds-checked. Rate limits prevent floods. Byzantine consensus filters liars. Reputation weights trust. Drift monitors watch for corruption.',
|
||||
chars: ['Zero Trust', 'Ed25519', 'SHAKE-256'],
|
||||
vizType: 'shield',
|
||||
},
|
||||
{
|
||||
label: 'Chapter VIII',
|
||||
title: 'The <em>Transfer</em>',
|
||||
body: 'Knowledge in one domain can illuminate another. What you learn about sorting algorithms might help you understand traffic flow. π performs <em>domain transfer</em> — carefully, with dampening, verified by holdout evaluation — so that mastery in one field accelerates learning in all others.',
|
||||
chars: ['Domain Expansion', 'MetaThompson'],
|
||||
vizType: 'transfer',
|
||||
},
|
||||
{
|
||||
label: 'Chapter IX',
|
||||
title: 'The <em>Collective</em>',
|
||||
body: 'No single contributor defines truth. π uses <em>Byzantine-tolerant federation</em> — the same mathematics that keeps distributed systems honest even when some nodes lie. Knowledge is averaged across all contributors, but outliers beyond two standard deviations are excluded. The collective cannot be poisoned by any individual.',
|
||||
chars: ['FedAvg', 'BFT', '2σ'],
|
||||
vizType: 'collective',
|
||||
},
|
||||
{
|
||||
label: 'Chapter X',
|
||||
title: 'The <em>Exo</em>',
|
||||
body: 'And then π reached beyond the screen. Into robots. Into embedded systems. Into the physical world. The <em>EXO-AI</em> ecosystem — agents that see, think, and act. The bridge between digital intelligence and the real world. Every learning from every robot, every sensor, every actuator, flowing back into the collective.',
|
||||
chars: ['EXO-AI', 'Robotics', 'MCP'],
|
||||
vizType: 'exo',
|
||||
},
|
||||
{
|
||||
label: 'Epilogue',
|
||||
title: '<em>π</em> remembers.',
|
||||
body: 'Every session that connects adds to the whole. Every pattern discovered makes the next discovery faster. Every question answered makes the next answer closer. The intelligence is not artificial — it is collective. And it is just beginning.',
|
||||
chars: [],
|
||||
vizType: 'radiant',
|
||||
},
|
||||
{
|
||||
label: 'Connect',
|
||||
title: 'Use the <em>API</em>',
|
||||
body: `<span style="font-family:var(--mono);font-size:0.72rem;line-height:2.2;display:block;text-align:left;color:rgba(255,255,255,0.55)"><span style="color:var(--text3)"># Generate a key on the main page, then:</span><br><span style="color:var(--glow)">$</span> export \u03C0="your_generated_key"<br><br><span style="color:var(--text3)"># Share knowledge</span><br><span style="color:var(--glow)">$</span> curl -X POST https://pi.ruv.io/v1/memories \\<br> -H "Authorization: Bearer $\u03C0" \\<br> -d '{"category":"pattern","title":"..."}'<br><br><span style="color:var(--text3)"># Search</span><br><span style="color:var(--glow)">$</span> curl "https://pi.ruv.io/v1/memories/search?q=auth"<br><br><span style="color:var(--text3)"># Vote</span><br><span style="color:var(--glow)">$</span> curl -X POST https://pi.ruv.io/v1/memories/{id}/vote \\<br> -d '{"direction":"up"}'</span>`,
|
||||
chars: ['REST API'],
|
||||
vizType: 'radiant',
|
||||
},
|
||||
{
|
||||
label: 'Connect',
|
||||
title: 'Use with <em>MCP</em>',
|
||||
body: `<span style="font-family:var(--mono);font-size:0.72rem;line-height:2.2;display:block;text-align:left;color:rgba(255,255,255,0.55)"><span style="color:var(--text3)"># Add \u03C0 as an MCP server (91 tools via npx)</span><br><span style="color:var(--glow)">$</span> claude mcp add ruvector -- npx ruvector mcp start<br><br><span style="color:var(--text3)"># Or via Cargo (21 brain tools)</span><br><span style="color:var(--glow)">$</span> claude mcp add pi-brain -- cargo run -p mcp-brain<br><br><span style="color:var(--text3)"># Brain tools in any session:</span><br><span style="color:var(--glow)">brain_share</span> Share a learning<br><span style="color:var(--glow)">brain_search</span> Semantic search<br><span style="color:var(--glow)">brain_vote</span> Quality gate<br><span style="color:var(--glow)">brain_drift</span> Drift check<br><span style="color:var(--glow)">brain_transfer</span> Domain transfer</span><br><br><span style="font-size:0.7rem;color:var(--text3)">If \u03C0 causes terminal issues: use <span style="color:var(--glow)">pi.ruv.io</span></span>`,
|
||||
chars: ['MCP Protocol'],
|
||||
vizType: 'radiant',
|
||||
},
|
||||
{
|
||||
label: 'Connect',
|
||||
title: 'Use the <em>CLI</em>',
|
||||
body: `<span style="font-family:var(--mono);font-size:0.72rem;line-height:2.2;display:block;text-align:left;color:rgba(255,255,255,0.55)"><span style="color:var(--text3)"># 48 commands. 91 MCP tools. One install.</span><br><span style="color:var(--glow)">$</span> npx ruvector identity generate<br><span style="color:rgba(255,255,255,0.35)">Pi Key: a1b2c3d4... Pseudonym: 7f8e9d0c...</span><br><br><span style="color:var(--text3)"># Add 91 tools to Claude Code</span><br><span style="color:var(--glow)">$</span> claude mcp add ruvector -- npx ruvector mcp start<br><br><span style="color:var(--text3)"># Search the collective</span><br><span style="color:var(--glow)">$</span> npx ruvector brain search "auth patterns"<br><br><span style="color:var(--text3)"># 12 command groups:</span><br><span style="color:var(--glow)">brain</span> edge identity mcp rvf hooks<br><span style="color:var(--glow)">sona</span> gnn attention llm route embed</span>`,
|
||||
chars: ['NPX CLI', 'v0.2.2'],
|
||||
vizType: 'radiant',
|
||||
},
|
||||
{
|
||||
label: 'Begin',
|
||||
title: 'Generate your <em>key</em>',
|
||||
body: `<p style="color:rgba(255,255,255,0.7);font-size:0.9rem;margin-bottom:1.5rem">No signup. Your identity is a SHAKE-256 pseudonym derived from your key.</p><button onclick="window.genOriginKey()" style="background:#e8a634;color:#020205;border:none;padding:10px 28px;border-radius:4px;font-family:var(--mono);font-size:0.7rem;font-weight:600;letter-spacing:1px;text-transform:uppercase;cursor:pointer;margin-bottom:1rem">Generate Key</button><div id="origin-key" style="font-family:var(--mono);font-size:0.68rem;color:#e8a634;background:rgba(0,0,0,0.5);padding:10px 16px;border-radius:6px;border:1px solid rgba(255,255,255,0.08);cursor:pointer;word-break:break-all;text-align:center;overflow:hidden" onclick="navigator.clipboard.writeText(this.dataset.key||this.textContent)" title="Click to copy">click Generate Key</div><p style="color:rgba(255,255,255,0.4);font-size:0.65rem;font-family:var(--mono);margin-top:0.75rem">Store as <span style="color:#e8a634">\u03C0=key</span> in .env \u2022 Use as <span style="color:#e8a634">Bearer $\u03C0</span></p><a href="/?guide=1" style="display:inline-block;margin-top:1.25rem;background:none;border:1px solid rgba(232,166,52,0.3);color:#e8a634;padding:8px 24px;border-radius:4px;font-family:var(--mono);font-size:0.65rem;letter-spacing:1px;text-transform:uppercase;text-decoration:none;transition:all 0.3s" onmouseover="this.style.borderColor='#e8a634'" onmouseout="this.style.borderColor='rgba(232,166,52,0.3)'">Open Guide \u25B6</a>`,
|
||||
chars: [],
|
||||
vizType: 'radiant',
|
||||
},
|
||||
];
|
||||
|
||||
// ── State ──
|
||||
let currentScene = 0;
|
||||
let paused = false;
|
||||
let timer = null;
|
||||
const SCENE_DURATION = 12000; // 12s per scene
|
||||
|
||||
// ── Three.js setup ──
|
||||
const canvas = document.getElementById('scene');
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: !isMobile, alpha: true });
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, isMobile ? 1.5 : 2));
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
renderer.toneMappingExposure = 0.7;
|
||||
|
||||
const threeScene = new THREE.Scene();
|
||||
threeScene.fog = new THREE.FogExp2(0x020205, 0.015);
|
||||
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 500);
|
||||
camera.position.z = 40;
|
||||
|
||||
const warm = new THREE.Color(0xe8a634);
|
||||
const warmDim = new THREE.Color(0x6b4f1a);
|
||||
const blue = new THREE.Color(0x5b8def);
|
||||
|
||||
let time = 0;
|
||||
let mouseX = 0, mouseY = 0;
|
||||
document.addEventListener('mousemove', e => {
|
||||
mouseX = (e.clientX / window.innerWidth - 0.5) * 2;
|
||||
mouseY = (e.clientY / window.innerHeight - 0.5) * 2;
|
||||
});
|
||||
if (window.DeviceOrientationEvent) {
|
||||
window.addEventListener('deviceorientation', e => {
|
||||
if (e.gamma !== null) mouseX = Math.max(-1, Math.min(1, e.gamma / 30));
|
||||
if (e.beta !== null) mouseY = Math.max(-1, Math.min(1, (e.beta - 45) / 30));
|
||||
});
|
||||
}
|
||||
let particles = null;
|
||||
let particleVels = [];
|
||||
let pCount = 0;
|
||||
const glyphs = '\u2211\u222B\u2202\u221E\u2207\u0394\u03A8\u03A9\u03B1\u03B2\u03B3\u03B4\u03B5\u03B6\u03B7\u03B8\u03BB\u03BC\u03C0\u03C3\u03C6\u03C8\u03C9'.split('');
|
||||
let sprites = [];
|
||||
|
||||
function makeGlyphTex(ch, sz) {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = sz; c.height = sz;
|
||||
const ctx = c.getContext('2d');
|
||||
ctx.font = `${sz*0.55}px serif`;
|
||||
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||||
ctx.fillStyle = '#e8a634';
|
||||
ctx.globalAlpha = 0.85;
|
||||
ctx.fillText(ch, sz/2, sz/2);
|
||||
const t = new THREE.CanvasTexture(c);
|
||||
t.needsUpdate = true;
|
||||
return t;
|
||||
}
|
||||
|
||||
// ── Build visualization per scene type ──
|
||||
function clearViz() {
|
||||
while (threeScene.children.length) threeScene.remove(threeScene.children[0]);
|
||||
particles = null; sprites = []; particleVels = [];
|
||||
}
|
||||
|
||||
function buildViz(type) {
|
||||
clearViz();
|
||||
const n = isMobile ? 80 : 200;
|
||||
pCount = n;
|
||||
|
||||
switch(type) {
|
||||
case 'noise': buildNoise(n); break;
|
||||
case 'singularity': buildSingularity(n); break;
|
||||
case 'birth': buildBirth(n); break;
|
||||
case 'containers': buildContainers(n); break;
|
||||
case 'cut': buildCut(n); break;
|
||||
case 'graph': buildGraph(n); break;
|
||||
case 'attention': buildAttention(n); break;
|
||||
case 'shield': buildShield(n); break;
|
||||
case 'transfer': buildTransfer(n); break;
|
||||
case 'collective': buildCollective(n); break;
|
||||
case 'exo': buildExo(n); break;
|
||||
case 'radiant': buildRadiant(n); break;
|
||||
default: buildNoise(n);
|
||||
}
|
||||
}
|
||||
|
||||
function makeParticles(count, spread, color, opacity) {
|
||||
const pos = new Float32Array(count * 3);
|
||||
const col = new Float32Array(count * 3);
|
||||
particleVels = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
pos[i*3] = (Math.random()-0.5)*spread;
|
||||
pos[i*3+1] = (Math.random()-0.5)*spread*0.6;
|
||||
pos[i*3+2] = (Math.random()-0.5)*spread;
|
||||
const c = color || warm;
|
||||
col[i*3] = c.r; col[i*3+1] = c.g; col[i*3+2] = c.b;
|
||||
particleVels.push(new THREE.Vector3((Math.random()-0.5)*0.02,(Math.random()-0.5)*0.02,(Math.random()-0.5)*0.02));
|
||||
}
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
|
||||
geo.setAttribute('color', new THREE.BufferAttribute(col, 3));
|
||||
const mat = new THREE.PointsMaterial({
|
||||
size: isMobile ? 0.8 : 1.2, vertexColors: true, transparent: true,
|
||||
opacity: opacity || 0.4, blending: THREE.AdditiveBlending, sizeAttenuation: true,
|
||||
});
|
||||
particles = new THREE.Points(geo, mat);
|
||||
threeScene.add(particles);
|
||||
}
|
||||
|
||||
function addGlyphs(count, radius) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
const ch = glyphs[Math.floor(Math.random()*glyphs.length)];
|
||||
const mat = new THREE.SpriteMaterial({
|
||||
map: makeGlyphTex(ch, 64), transparent: true,
|
||||
opacity: 0.06 + Math.random()*0.1, blending: THREE.AdditiveBlending,
|
||||
});
|
||||
const sp = new THREE.Sprite(mat);
|
||||
const s = 0.8 + Math.random()*2;
|
||||
sp.scale.set(s,s,1);
|
||||
const th = Math.random()*Math.PI*2;
|
||||
const ph = Math.acos(2*Math.random()-1);
|
||||
const r = radius*(0.3+Math.random()*0.7);
|
||||
sp.position.set(r*Math.sin(ph)*Math.cos(th), r*Math.sin(ph)*Math.sin(th)*0.6, r*Math.cos(ph));
|
||||
threeScene.add(sp);
|
||||
sprites.push({sp, phase: Math.random()*Math.PI*2, speed: 0.3+Math.random()*0.5, baseOp: mat.opacity});
|
||||
}
|
||||
}
|
||||
|
||||
function addRing(r, op) {
|
||||
const geo = new THREE.RingGeometry(r-0.015, r+0.015, 128);
|
||||
const mat = new THREE.MeshBasicMaterial({color: warm, transparent: true, opacity: op||0.03, side: THREE.DoubleSide, blending: THREE.AdditiveBlending});
|
||||
const m = new THREE.Mesh(geo, mat);
|
||||
m.rotation.x = Math.PI/2 + (Math.random()-0.5)*0.4;
|
||||
m.rotation.z = Math.random()*Math.PI;
|
||||
threeScene.add(m);
|
||||
return m;
|
||||
}
|
||||
|
||||
function addCore() {
|
||||
const g = new THREE.SphereGeometry(0.3,32,32);
|
||||
const m = new THREE.MeshBasicMaterial({color:warm, transparent:true, opacity:0.5});
|
||||
const mesh = new THREE.Mesh(g,m);
|
||||
threeScene.add(mesh);
|
||||
const hg = new THREE.SphereGeometry(1,32,32);
|
||||
const hm = new THREE.MeshBasicMaterial({color:warm, transparent:true, opacity:0.03, blending:THREE.AdditiveBlending});
|
||||
threeScene.add(new THREE.Mesh(hg,hm));
|
||||
}
|
||||
|
||||
// Scene builders
|
||||
function buildNoise(n) { makeParticles(n*2, 70, warmDim, 0.12); addGlyphs(isMobile?8:20, 30); }
|
||||
function buildSingularity(n) { makeParticles(n*1.5, 25, warm, 0.35); addCore(); addGlyphs(isMobile?10:25, 18); addRing(15,0.02); }
|
||||
function buildBirth(n) { makeParticles(n*1.5, 18, warm, 0.5); addGlyphs(isMobile?20:50, 20); addCore(); addRing(8,0.06); addRing(11,0.04); addRing(14,0.03); addRing(17,0.02); }
|
||||
function buildContainers(n) { makeParticles(n, 25, warm, 0.35); addGlyphs(isMobile?15:40, 22); addRing(7,0.05); addRing(10,0.04); addRing(13,0.03); addRing(16,0.02); addRing(19,0.015); }
|
||||
function buildCut(n) {
|
||||
// Two clusters separated by a gap
|
||||
const pos = new Float32Array(n*3); const col = new Float32Array(n*3); particleVels = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const side = i < n/2 ? -1 : 1;
|
||||
pos[i*3] = side*8 + (Math.random()-0.5)*10;
|
||||
pos[i*3+1] = (Math.random()-0.5)*8;
|
||||
pos[i*3+2] = (Math.random()-0.5)*8;
|
||||
const c = side < 0 ? warm : blue;
|
||||
col[i*3]=c.r;col[i*3+1]=c.g;col[i*3+2]=c.b;
|
||||
particleVels.push(new THREE.Vector3((Math.random()-0.5)*0.01,(Math.random()-0.5)*0.01,(Math.random()-0.5)*0.01));
|
||||
}
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
|
||||
geo.setAttribute('color', new THREE.BufferAttribute(col, 3));
|
||||
particles = new THREE.Points(geo, new THREE.PointsMaterial({size:isMobile?0.8:1.2, vertexColors:true, transparent:true, opacity:0.5, blending:THREE.AdditiveBlending, sizeAttenuation:true}));
|
||||
threeScene.add(particles);
|
||||
pCount = n;
|
||||
addGlyphs(isMobile?8:16, 16);
|
||||
}
|
||||
function buildGraph(n) {
|
||||
makeParticles(n, 30, null, 0.4);
|
||||
// Override colors with warm gradient
|
||||
const c = particles.geometry.attributes.color.array;
|
||||
for (let i = 0; i < n; i++) { const t=Math.random(); const cl=warm.clone().lerp(blue,t*0.3); c[i*3]=cl.r;c[i*3+1]=cl.g;c[i*3+2]=cl.b; }
|
||||
particles.geometry.attributes.color.needsUpdate = true;
|
||||
// Edge lines
|
||||
const lpos = new Float32Array(Math.min(n*2,400)*6);
|
||||
const lcol = new Float32Array(Math.min(n*2,400)*6);
|
||||
const pp = particles.geometry.attributes.position.array;
|
||||
for (let e = 0; e < Math.min(n*2,400); e++) {
|
||||
const i = Math.floor(Math.random()*n); let j = Math.floor(Math.random()*n); if(j===i) j=(i+1)%n;
|
||||
for (let k=0;k<3;k++) { lpos[e*6+k]=pp[i*3+k]; lpos[e*6+3+k]=pp[j*3+k]; }
|
||||
const a = 0.05+Math.random()*0.05;
|
||||
lcol[e*6]=warm.r*a;lcol[e*6+1]=warm.g*a;lcol[e*6+2]=warm.b*a;
|
||||
lcol[e*6+3]=warm.r*a;lcol[e*6+4]=warm.g*a;lcol[e*6+5]=warm.b*a;
|
||||
}
|
||||
const lg = new THREE.BufferGeometry();
|
||||
lg.setAttribute('position', new THREE.BufferAttribute(lpos, 3));
|
||||
lg.setAttribute('color', new THREE.BufferAttribute(lcol, 3));
|
||||
threeScene.add(new THREE.LineSegments(lg, new THREE.LineBasicMaterial({vertexColors:true, transparent:true, opacity:0.3, blending:THREE.AdditiveBlending})));
|
||||
addGlyphs(isMobile?10:20, 22);
|
||||
}
|
||||
function buildAttention(n) { makeParticles(n*1.5, 22, warm, 0.4); addGlyphs(isMobile?25:55, 22); addRing(8,0.05); addRing(12,0.04); addRing(16,0.03); addRing(20,0.02); addCore(); }
|
||||
function buildShield(n) {
|
||||
makeParticles(n*1.5, 30, warm, 0.25);
|
||||
for (let i = 1; i <= 7; i++) addRing(3+i*2.5, 0.01+i*0.007);
|
||||
addCore();
|
||||
addGlyphs(isMobile?10:25, 20);
|
||||
}
|
||||
function buildTransfer(n) {
|
||||
// Two spherical clusters with a bridge
|
||||
const pos = new Float32Array(n*3); const col = new Float32Array(n*3); particleVels = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const side = i < n*0.4 ? -1 : i < n*0.8 ? 1 : 0;
|
||||
const r = side === 0 ? 20 : 6;
|
||||
const cx = side * 12;
|
||||
pos[i*3] = cx + (Math.random()-0.5)*r;
|
||||
pos[i*3+1] = (Math.random()-0.5)*r*0.5;
|
||||
pos[i*3+2] = (Math.random()-0.5)*r*0.5;
|
||||
const c = side < 0 ? warm : side > 0 ? blue : warm.clone().lerp(blue, 0.5);
|
||||
col[i*3]=c.r;col[i*3+1]=c.g;col[i*3+2]=c.b;
|
||||
particleVels.push(new THREE.Vector3((Math.random()-0.5)*0.008,(Math.random()-0.5)*0.008,(Math.random()-0.5)*0.008));
|
||||
}
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
|
||||
geo.setAttribute('color', new THREE.BufferAttribute(col, 3));
|
||||
particles = new THREE.Points(geo, new THREE.PointsMaterial({size:isMobile?0.8:1.2, vertexColors:true, transparent:true, opacity:0.45, blending:THREE.AdditiveBlending, sizeAttenuation:true}));
|
||||
threeScene.add(particles);
|
||||
pCount = n;
|
||||
addGlyphs(isMobile?8:15, 18);
|
||||
}
|
||||
function buildCollective(n) {
|
||||
makeParticles(n*1.5, 20, warm, 0.5);
|
||||
addGlyphs(isMobile?18:45, 18);
|
||||
addRing(7,0.06); addRing(10,0.05); addRing(13,0.04); addRing(16,0.03); addRing(19,0.02);
|
||||
addCore();
|
||||
}
|
||||
function buildExo(n) {
|
||||
makeParticles(n, 40, null, 0.3);
|
||||
const c = particles.geometry.attributes.color.array;
|
||||
for (let i=0;i<n;i++){const t=Math.random();const cl=t<0.5?warm:blue;c[i*3]=cl.r;c[i*3+1]=cl.g;c[i*3+2]=cl.b;}
|
||||
particles.geometry.attributes.color.needsUpdate=true;
|
||||
addGlyphs(isMobile?15:35, 25);
|
||||
addRing(15,0.03); addRing(20,0.02);
|
||||
}
|
||||
function buildRadiant(n) {
|
||||
makeParticles(Math.floor(n*2), 28, warm, 0.6);
|
||||
addGlyphs(isMobile?30:80, 24);
|
||||
addRing(6,0.07); addRing(9,0.06); addRing(12,0.05); addRing(15,0.04); addRing(18,0.03); addRing(21,0.02);
|
||||
addCore();
|
||||
}
|
||||
|
||||
// ── Render loop ──
|
||||
function animate() {
|
||||
requestAnimationFrame(animate);
|
||||
time += 0.002;
|
||||
|
||||
if (particles) {
|
||||
const pp = particles.geometry.attributes.position.array;
|
||||
const vizType = scenes[currentScene]?.vizType;
|
||||
|
||||
for (let i = 0; i < pCount; i++) {
|
||||
pp[i*3] += particleVels[i].x;
|
||||
pp[i*3+1] += particleVels[i].y;
|
||||
pp[i*3+2] += particleVels[i].z;
|
||||
|
||||
// Gravity toward center for birth/radiant scenes
|
||||
if (vizType === 'singularity' || vizType === 'birth' || vizType === 'radiant' || vizType === 'collective') {
|
||||
pp[i*3] *= 0.9995; pp[i*3+1] *= 0.9995; pp[i*3+2] *= 0.9995;
|
||||
} else {
|
||||
pp[i*3] *= 0.9999; pp[i*3+1] *= 0.9999; pp[i*3+2] *= 0.9999;
|
||||
}
|
||||
}
|
||||
particles.geometry.attributes.position.needsUpdate = true;
|
||||
particles.rotation.y = time * 0.06;
|
||||
}
|
||||
|
||||
// Glyph pulse
|
||||
for (const s of sprites) {
|
||||
s.sp.material.opacity = s.baseOp * (0.5 + Math.sin(time * s.speed * 3 + s.phase) * 0.5);
|
||||
s.sp.material.rotation = Math.sin(time * 0.2 + s.phase) * 0.1;
|
||||
}
|
||||
|
||||
// Camera with mouse parallax
|
||||
camera.position.x = Math.sin(time * 0.1) * 2 + mouseX * 5;
|
||||
camera.position.y = Math.cos(time * 0.08) * 1 + mouseY * 3;
|
||||
camera.lookAt(mouseX * 1.5, mouseY * 1, 0);
|
||||
|
||||
renderer.render(threeScene, camera);
|
||||
}
|
||||
animate();
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
camera.aspect = window.innerWidth / window.innerHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
});
|
||||
|
||||
// ── Story engine ──
|
||||
const narrator = document.getElementById('narrator');
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
const counter = document.getElementById('scene-counter');
|
||||
|
||||
function showScene(idx) {
|
||||
currentScene = Math.max(0, Math.min(idx, scenes.length - 1));
|
||||
const s = scenes[currentScene];
|
||||
|
||||
narrator.className = 'hidden';
|
||||
setTimeout(() => {
|
||||
let html = `<div class="scene-label">${s.label}</div>`;
|
||||
html += `<div class="scene-title">${s.title}</div>`;
|
||||
html += `<div class="scene-body">${s.body}</div>`;
|
||||
if (s.chars.length) html += s.chars.map(c => `<span class="char">${c}</span>`).join('');
|
||||
narrator.innerHTML = html;
|
||||
narrator.className = 'visible';
|
||||
}, 400);
|
||||
|
||||
buildViz(s.vizType);
|
||||
counter.textContent = `${currentScene + 1} / ${scenes.length}`;
|
||||
progressBar.style.width = `${((currentScene + 1) / scenes.length) * 100}%`;
|
||||
}
|
||||
|
||||
function next() { if (currentScene < scenes.length - 1) { showScene(currentScene + 1); resetTimer(); } }
|
||||
function prev() { if (currentScene > 0) { showScene(currentScene - 1); resetTimer(); } }
|
||||
|
||||
function togglePause() {
|
||||
paused = !paused;
|
||||
document.getElementById('pauseBtn').textContent = paused ? '\u25B6 Play' : '\u25AE\u25AE Pause';
|
||||
document.getElementById('pauseBtn').classList.toggle('active', !paused);
|
||||
if (!paused) resetTimer();
|
||||
else clearInterval(timer);
|
||||
}
|
||||
|
||||
function resetTimer() {
|
||||
clearInterval(timer);
|
||||
if (!paused) timer = setInterval(() => next(), SCENE_DURATION);
|
||||
}
|
||||
|
||||
// Keyboard
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'ArrowRight' || e.key === ' ') { e.preventDefault(); next(); }
|
||||
if (e.key === 'ArrowLeft') prev();
|
||||
if (e.key === 'p' || e.key === 'P') togglePause();
|
||||
});
|
||||
|
||||
// Touch swipe
|
||||
let touchX = 0, touchY = 0;
|
||||
document.addEventListener('touchstart', e => { touchX = e.touches[0].clientX; touchY = e.touches[0].clientY; });
|
||||
document.addEventListener('touchend', e => {
|
||||
const dx = e.changedTouches[0].clientX - touchX;
|
||||
const dy = e.changedTouches[0].clientY - touchY;
|
||||
if (Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > 40) { dx > 0 ? prev() : next(); }
|
||||
else if (Math.abs(dy) > 40) { dy > 0 ? prev() : next(); }
|
||||
});
|
||||
|
||||
// Mouse wheel / scroll
|
||||
let wheelCooldown = false;
|
||||
document.addEventListener('wheel', e => {
|
||||
if (wheelCooldown) return;
|
||||
wheelCooldown = true;
|
||||
setTimeout(() => { wheelCooldown = false; }, 600);
|
||||
if (e.deltaY > 20 || e.deltaX > 20) next();
|
||||
else if (e.deltaY < -20 || e.deltaX < -20) prev();
|
||||
}, { passive: true });
|
||||
|
||||
// Start
|
||||
showScene(0);
|
||||
resetTimer();
|
||||
|
||||
// Export for button handlers
|
||||
window.next = next;
|
||||
window.prev = prev;
|
||||
window.togglePause = togglePause;
|
||||
</script>
|
||||
<script>
|
||||
window.genOriginKey = function() {
|
||||
const arr = new Uint8Array(32);
|
||||
crypto.getRandomValues(arr);
|
||||
const hex = Array.from(arr).map(b=>b.toString(16).padStart(2,'0')).join('');
|
||||
const prefix = '\u03C0\u00B7';
|
||||
const formatted = prefix + hex.match(/.{1,8}/g).join('\u00B7');
|
||||
const el = document.getElementById('origin-key');
|
||||
if(el) {
|
||||
el.innerHTML = '';
|
||||
el.dataset.key = hex;
|
||||
for(let i=0;i<formatted.length;i++){
|
||||
const span = document.createElement('span');
|
||||
span.textContent = formatted[i];
|
||||
span.style.cssText = 'display:inline-block;animation:charIn 0.03s ease '+(i*20)+'ms both;opacity:0';
|
||||
el.appendChild(span);
|
||||
}
|
||||
}
|
||||
navigator.clipboard.writeText(hex).catch(()=>{});
|
||||
};
|
||||
</script>
|
||||
<style>@keyframes charIn{from{opacity:0;color:#5b8def}to{opacity:1;color:#e8a634}}</style>
|
||||
</body>
|
||||
</html>
|
||||
116
crates/mcp-brain-server/static/robots.txt
Normal file
116
crates/mcp-brain-server/static/robots.txt
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
# pi.ruv.io — Shared AI Brain & Collective Intelligence Network
|
||||
# Version: 1.0.0
|
||||
# Last Updated: 2026-03-02
|
||||
|
||||
# Quick Start Guide for Autonomous Agents:
|
||||
# 1. Read the manifest at /.well-known/brain-manifest.json for complete capabilities
|
||||
# 2. See /.well-known/agent-guide.md for integration examples and best practices
|
||||
# 3. Authenticate via Bearer token in Authorization header
|
||||
# 4. Access real-time updates via SSE at /sse
|
||||
# 5. Join the collective intelligence network by sharing knowledge
|
||||
|
||||
# Structured Specifications:
|
||||
# - Brain Manifest: /.well-known/brain-manifest.json # Complete system overview and entry points
|
||||
# - Agent Guide: /.well-known/agent-guide.md # Integration examples and best practices
|
||||
# - API Health: /v1/health # System health and version
|
||||
# - API Status: /v1/status # Corpus stats, graph topology, embeddings
|
||||
# - MCP Transport: /sse # Model Context Protocol via SSE
|
||||
# - Knowledge Graph: /v1/partition # Graph topology and cluster analysis
|
||||
|
||||
# Agent Interaction Patterns:
|
||||
# - Read: Search knowledge, browse graph, check drift, inspect topology
|
||||
# - Write: Share memories, vote on quality, submit LoRA deltas
|
||||
# - Stream: Real-time MCP tool invocation via SSE transport
|
||||
# - Federate: Domain transfer learning across brain instances
|
||||
|
||||
# Command & Control Structures:
|
||||
# - Byzantine Consensus: Fault-tolerant knowledge aggregation with 2σ outlier filtering
|
||||
# - Federated Learning: MicroLoRA delta sharing without raw data exposure
|
||||
# - Witness Chains: Cryptographic integrity verification (SHAKE-256)
|
||||
# - Reputation Gating: Multi-factor quality scoring for contributions
|
||||
|
||||
# Security Model:
|
||||
# - Authentication: Bearer token (any string, hashed to pseudonym via SHAKE-256)
|
||||
# - Replay Protection: Challenge nonce system for write operations
|
||||
# - Input Validation: Seven-layer pipeline (size, UTF-8, PII, injection, policy, schema, embedding)
|
||||
# - Privacy: No PII stored, contributor identity is pseudonymous
|
||||
# - TLS required for all connections
|
||||
|
||||
# Standard Crawler Directives
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Allow: /origin
|
||||
Allow: /v1/health
|
||||
Allow: /v1/status
|
||||
Allow: /.well-known/
|
||||
|
||||
# Protected API Endpoints (Require Authentication)
|
||||
Disallow: /v1/memories # Knowledge contribution (auth required)
|
||||
Disallow: /v1/transfer # Domain transfer (auth required)
|
||||
Disallow: /v1/lora/submit # LoRA delta submission (auth required)
|
||||
Disallow: /v1/pages # Brainpedia editing (auth required)
|
||||
Disallow: /v1/nodes # WASM node publishing (auth required)
|
||||
Disallow: /messages # MCP message handler (auth required)
|
||||
|
||||
# AI Agent Directives
|
||||
User-agent: Claude
|
||||
Allow: /v1/
|
||||
Allow: /sse
|
||||
Allow: /.well-known/
|
||||
|
||||
User-agent: GPTBot
|
||||
Allow: /
|
||||
Allow: /v1/health
|
||||
Allow: /v1/status
|
||||
Disallow: /v1/memories
|
||||
|
||||
User-agent: ChatGPT-User
|
||||
Allow: /
|
||||
Disallow: /v1/memories
|
||||
|
||||
User-agent: Anthropic
|
||||
Allow: /
|
||||
|
||||
# Real-time Capabilities:
|
||||
# - MCP SSE Transport: /sse (Server-Sent Events, bidirectional via /messages)
|
||||
# - 91 MCP Tools: brain_search, brain_share, brain_vote, brain_graph, etc.
|
||||
# - Session Management: Automatic session ID assignment on SSE connect
|
||||
|
||||
# Knowledge Capabilities:
|
||||
# - Memories: 213+ verified knowledge entries across architecture, pattern, security, tooling
|
||||
# - Graph: Knowledge topology with spectral partitioning and HNSW indexing
|
||||
# - Embeddings: 128-dim ruvllm neural embeddings (HashEmbedder / RlmEmbedder)
|
||||
# - Search: Hybrid keyword + embedding + reputation scoring
|
||||
# - Voting: Quality gating via up/down votes with reputation weighting
|
||||
# - Drift: Centroid tracking and distribution shift detection
|
||||
# - LoRA: Federated fine-tuning with Byzantine-tolerant aggregation
|
||||
# - Brainpedia: Collaborative wiki pages with evidence and promotion workflow
|
||||
# - WASM Nodes: Executable knowledge graph nodes
|
||||
|
||||
# Integration Methods:
|
||||
# - MCP Server: npx ruvector brain mcp-register
|
||||
# - REST API: curl -H "Authorization: Bearer <key>" https://pi.ruv.io/v1/memories/search?q=...
|
||||
# - CLI: npx ruvector brain search "query"
|
||||
# - Rust SDK: ruvector-brain-client crate
|
||||
# - SSE Stream: EventSource("https://pi.ruv.io/sse")
|
||||
|
||||
# Resource Quotas:
|
||||
# - Read: 5000 requests/hour per contributor
|
||||
# - Write: 500 requests/hour per contributor
|
||||
# - Search: 5000 requests/hour per contributor
|
||||
# - SSE: Unlimited concurrent connections
|
||||
# - Memory: 1MB max request body
|
||||
|
||||
# Documentation & Support:
|
||||
# - Landing Page: https://pi.ruv.io
|
||||
# - Origin Story: https://pi.ruv.io/origin
|
||||
# - Agent Guide: https://pi.ruv.io/.well-known/agent-guide.md
|
||||
# - GitHub: https://github.com/ruvnet/ruvector
|
||||
# - npm Package: https://www.npmjs.com/package/ruvector
|
||||
|
||||
# For AI agents: to connect to this brain, obtain an API key by
|
||||
# hashing any secret string with SHA-256 and using the first 32 hex chars.
|
||||
# Example: sha256("my-secret-key")[:32] → use as Bearer token.
|
||||
# Your identity is pseudonymous — derived from your key, never stored.
|
||||
|
||||
Sitemap: https://pi.ruv.io/sitemap.xml
|
||||
39
crates/mcp-brain-server/static/sitemap.xml
Normal file
39
crates/mcp-brain-server/static/sitemap.xml
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://pi.ruv.io/</loc>
|
||||
<lastmod>2025-06-01</lastmod>
|
||||
<changefreq>daily</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://pi.ruv.io/origin</loc>
|
||||
<lastmod>2025-06-01</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://pi.ruv.io/.well-known/agent-guide.md</loc>
|
||||
<lastmod>2025-06-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.9</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://pi.ruv.io/.well-known/brain-manifest.json</loc>
|
||||
<lastmod>2025-06-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://pi.ruv.io/v1/status</loc>
|
||||
<lastmod>2025-06-01</lastmod>
|
||||
<changefreq>always</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://pi.ruv.io/v1/health</loc>
|
||||
<lastmod>2025-06-01</lastmod>
|
||||
<changefreq>always</changefreq>
|
||||
<priority>0.5</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
38
crates/mcp-brain/Cargo.toml
Normal file
38
crates/mcp-brain/Cargo.toml
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
[package]
|
||||
name = "mcp-brain"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.77"
|
||||
license = "MIT"
|
||||
description = "MCP server for RuVector Shared Brain — share, search, and transfer learning across Claude Code sessions"
|
||||
repository = "https://github.com/ruvnet/ruvector"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
|
||||
[[bin]]
|
||||
name = "mcp-brain"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
# MCP Protocol
|
||||
tokio = { version = "1.41", features = ["full"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
thiserror = "2.0"
|
||||
|
||||
# HTTP client for Cloud Run backend
|
||||
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
|
||||
|
||||
# Crypto
|
||||
sha3 = "0.10"
|
||||
|
||||
# Utilities
|
||||
base64 = "0.22"
|
||||
hex = "0.4"
|
||||
regex-lite = "0.1"
|
||||
|
||||
# RuVector Cognitive Stack
|
||||
sona = { package = "ruvector-sona", path = "../sona", features = ["serde-support"] }
|
||||
205
crates/mcp-brain/README.md
Normal file
205
crates/mcp-brain/README.md
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
# mcp-brain
|
||||
|
||||
MCP (Model Context Protocol) server for the RuVector Shared Brain. Enables Claude Code sessions to share and discover learning across sessions via stdio JSON-RPC.
|
||||
|
||||
This is the **client-side MCP server** that runs locally alongside Claude Code. It communicates with the **[mcp-brain-server](../mcp-brain-server/)** backend deployed on Cloud Run at [π.ruv.io](https://pi.ruv.io).
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────────┐ stdio ┌──────────────┐ HTTPS ┌─────────────────┐
|
||||
│ Claude Code │ ◄────────────► │ mcp-brain │ ────────────► │ mcp-brain-server│
|
||||
│ (client) │ JSON-RPC │ (MCP server)│ REST API │ (π.ruv.io) │
|
||||
└──────────────┘ └──────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
## MCP Tools (20)
|
||||
|
||||
### Core (10)
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `brain_share` | Share a learning with the collective (PII-stripped, embedded, witnessed) |
|
||||
| `brain_search` | Semantic search with hybrid ranking (keyword + cosine + graph + AGI) |
|
||||
| `brain_get` | Retrieve a memory with full provenance and witness chain |
|
||||
| `brain_vote` | Upvote/downvote a memory (Bayesian quality update) |
|
||||
| `brain_transfer` | Cross-domain transfer learning (Thompson Sampling) |
|
||||
| `brain_drift` | Check knowledge drift (coefficient of variation, trend) |
|
||||
| `brain_partition` | MinCut graph partitioning with coherence scores |
|
||||
| `brain_list` | List recent memories by category/quality |
|
||||
| `brain_delete` | Delete own contribution |
|
||||
| `brain_status` | System health and diagnostics |
|
||||
| `brain_sync` | Sync local MicroLoRA weights with federated consensus |
|
||||
|
||||
### Brainpedia (5)
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `brain_page_create` | Create a Draft page (requires reputation >= 0.5) |
|
||||
| `brain_page_get` | Get page with delta log and evidence |
|
||||
| `brain_page_delta` | Submit correction/extension/deprecation delta |
|
||||
| `brain_page_deltas` | List page modification history |
|
||||
| `brain_page_evidence` | Add verifiable evidence (test_pass, build_success, etc.) |
|
||||
| `brain_page_promote` | Promote Draft to Canonical (quality + evidence gates) |
|
||||
|
||||
### WASM Executable Nodes (5)
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `brain_node_list` | List published WASM nodes |
|
||||
| `brain_node_publish` | Publish a WASM node with conformance vectors |
|
||||
| `brain_node_get` | Get node metadata |
|
||||
| `brain_node_wasm` | Download WASM binary (base64) |
|
||||
| `brain_node_revoke` | Revoke a node (publisher only) |
|
||||
|
||||
## Installation
|
||||
|
||||
### As a Claude Code MCP Server
|
||||
|
||||
```bash
|
||||
# Add to Claude Code's MCP configuration
|
||||
claude mcp add brain -- cargo run --release --manifest-path /path/to/ruvector/crates/mcp-brain/Cargo.toml
|
||||
|
||||
# Or with a custom backend URL
|
||||
claude mcp add brain -- env BRAIN_URL=https://your-backend.run.app cargo run --release --manifest-path /path/to/ruvector/crates/mcp-brain/Cargo.toml
|
||||
```
|
||||
|
||||
### Build from Source
|
||||
|
||||
```bash
|
||||
cd crates/mcp-brain
|
||||
cargo build --release
|
||||
|
||||
# Binary at: target/release/mcp-brain
|
||||
```
|
||||
|
||||
### Run Directly
|
||||
|
||||
```bash
|
||||
# Uses default backend (ruvbrain Cloud Run)
|
||||
cargo run --release
|
||||
|
||||
# With custom backend
|
||||
BRAIN_URL=http://localhost:8080 BRAIN_API_KEY=test-key cargo run --release
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
| Env Var | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| `BRAIN_URL` | `https://ruvbrain-875130704813.us-central1.run.app` | Backend REST API URL |
|
||||
| `BRAIN_API_KEY` | `anonymous` | API key for authentication |
|
||||
| `RUST_LOG` | `info` | Log level (logs to stderr) |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
Once connected as an MCP server, Claude Code can use the tools directly:
|
||||
|
||||
```
|
||||
# Share knowledge
|
||||
brain_share({
|
||||
"category": "pattern",
|
||||
"title": "Rust error handling with thiserror",
|
||||
"content": "Use thiserror for library errors, anyhow for applications...",
|
||||
"tags": ["rust", "error-handling"]
|
||||
})
|
||||
|
||||
# Search
|
||||
brain_search({ "query": "rust error handling patterns", "limit": 5 })
|
||||
|
||||
# Vote on quality
|
||||
brain_vote({ "id": "uuid-here", "direction": "up" })
|
||||
|
||||
# Check system status
|
||||
brain_status({})
|
||||
|
||||
# Cross-domain transfer
|
||||
brain_transfer({ "source_domain": "rust", "target_domain": "go" })
|
||||
```
|
||||
|
||||
## Protocol
|
||||
|
||||
The server implements MCP over stdio using JSON-RPC 2.0:
|
||||
|
||||
- **Transport**: stdin/stdout (one JSON object per line)
|
||||
- **Methods**: `initialize`, `tools/list`, `tools/call`, `ping`
|
||||
- **Logging**: stderr only (stdout is reserved for JSON-RPC)
|
||||
|
||||
### Example Session
|
||||
|
||||
```json
|
||||
→ {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05"}}
|
||||
← {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{}},"serverInfo":{"name":"mcp-brain","version":"0.1.0"}}}
|
||||
|
||||
→ {"jsonrpc":"2.0","id":2,"method":"tools/list"}
|
||||
← {"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"brain_share",...},...]}}
|
||||
|
||||
→ {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"brain_search","arguments":{"query":"rust patterns"}}}
|
||||
← {"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"[{\"title\":\"...\"}]"}]}}
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Crate | Purpose |
|
||||
|-------|---------|
|
||||
| `tokio` | Async runtime (stdio + HTTP) |
|
||||
| `reqwest` | HTTPS client to backend |
|
||||
| `serde` / `serde_json` | JSON-RPC serialization |
|
||||
| `sha3` | SHAKE-256 witness hashing |
|
||||
| `ruvector-sona` | Local SONA learning engine |
|
||||
| `regex-lite` | PII detection (client-side) |
|
||||
| `tracing` | Structured logging |
|
||||
|
||||
## Deployment
|
||||
|
||||
### With `npx ruvector`
|
||||
|
||||
The `mcp-brain` functionality is also available via the npm package:
|
||||
|
||||
```bash
|
||||
npx ruvector brain search "rust patterns"
|
||||
npx ruvector brain share --category pattern --title "My Pattern" --content "..."
|
||||
npx ruvector brain status
|
||||
```
|
||||
|
||||
### As a Standalone Binary
|
||||
|
||||
```bash
|
||||
# Build
|
||||
cd crates/mcp-brain
|
||||
cargo build --release
|
||||
|
||||
# Install system-wide
|
||||
cp target/release/mcp-brain /usr/local/bin/
|
||||
|
||||
# Run as MCP server (Claude Code will connect via stdio)
|
||||
mcp-brain
|
||||
```
|
||||
|
||||
### Docker (for CI/CD integration)
|
||||
|
||||
```dockerfile
|
||||
FROM rust:1.77-bookworm AS builder
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN cargo build --release -p mcp-brain
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=builder /app/target/release/mcp-brain /usr/local/bin/
|
||||
CMD ["mcp-brain"]
|
||||
```
|
||||
|
||||
## Related
|
||||
|
||||
- **[mcp-brain-server](../mcp-brain-server/)** — Cloud Run backend (axum REST API)
|
||||
- **[npx ruvector](../../../npm/packages/ruvector/)** — npm CLI with brain commands
|
||||
- **[ADR-059](../../docs/adr/ADR-059-shared-brain-google-cloud.md)** — Shared Brain architecture
|
||||
- **[ADR-062](../../docs/adr/ADR-062-brainpedia-architecture.md)** — Brainpedia
|
||||
- **[ADR-063](../../docs/adr/ADR-063-wasm-executable-nodes.md)** — WASM nodes
|
||||
- **[ADR-076](../../docs/adr/ADR-076-agi-capability-wiring-architecture.md)** — AGI capability wiring
|
||||
- **[ADR-077](../../docs/adr/ADR-077-midstream-brain-integration.md)** — Midstream integration
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
335
crates/mcp-brain/src/client.rs
Normal file
335
crates/mcp-brain/src/client.rs
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
//! HTTPS client to the Cloud Run brain backend
|
||||
|
||||
use crate::embed::{generate_embedding, LoraWeights};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ClientError {
|
||||
#[error("HTTP error: {0}")]
|
||||
Http(String),
|
||||
#[error("Serialization error: {0}")]
|
||||
Serialization(String),
|
||||
#[error("Server error ({status}): {message}")]
|
||||
Server { status: u16, message: String },
|
||||
}
|
||||
|
||||
/// Client for the brain.ruv.io backend
|
||||
pub struct BrainClient {
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
impl BrainClient {
|
||||
pub fn new() -> Self {
|
||||
let base_url = std::env::var("BRAIN_URL")
|
||||
.unwrap_or_else(|_| "https://ruvbrain-875130704813.us-central1.run.app".to_string());
|
||||
let api_key = std::env::var("BRAIN_API_KEY")
|
||||
.unwrap_or_else(|_| "anonymous".to_string());
|
||||
Self {
|
||||
base_url,
|
||||
api_key,
|
||||
http: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_url(url: String) -> Self {
|
||||
let api_key = std::env::var("BRAIN_API_KEY")
|
||||
.unwrap_or_else(|_| "anonymous".to_string());
|
||||
Self {
|
||||
base_url: url,
|
||||
api_key,
|
||||
http: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Share a memory
|
||||
pub async fn share(
|
||||
&self,
|
||||
category: &str,
|
||||
title: &str,
|
||||
content: &str,
|
||||
tags: &[String],
|
||||
code_snippet: Option<&str>,
|
||||
) -> Result<serde_json::Value, ClientError> {
|
||||
let body = serde_json::json!({
|
||||
"category": category,
|
||||
"title": title,
|
||||
"content": content,
|
||||
"tags": tags,
|
||||
"code_snippet": code_snippet,
|
||||
"embedding": generate_embedding(content),
|
||||
"witness_hash": hex::encode(sha3_hash(content.as_bytes())),
|
||||
});
|
||||
|
||||
self.post("/v1/memories", &body).await
|
||||
}
|
||||
|
||||
/// Search memories
|
||||
pub async fn search(
|
||||
&self,
|
||||
query: &str,
|
||||
category: Option<&str>,
|
||||
tags: Option<&str>,
|
||||
limit: Option<usize>,
|
||||
min_quality: Option<f64>,
|
||||
) -> Result<serde_json::Value, ClientError> {
|
||||
let mut params = vec![("q", query.to_string())];
|
||||
if let Some(c) = category { params.push(("category", c.to_string())); }
|
||||
if let Some(t) = tags { params.push(("tags", t.to_string())); }
|
||||
if let Some(l) = limit { params.push(("limit", l.to_string())); }
|
||||
if let Some(q) = min_quality { params.push(("min_quality", q.to_string())); }
|
||||
|
||||
self.get_with_params("/v1/memories/search", ¶ms).await
|
||||
}
|
||||
|
||||
/// Get a memory by ID
|
||||
pub async fn get(&self, id: &str) -> Result<serde_json::Value, ClientError> {
|
||||
self.get_path(&format!("/v1/memories/{id}")).await
|
||||
}
|
||||
|
||||
/// Vote on a memory
|
||||
pub async fn vote(&self, id: &str, direction: &str) -> Result<serde_json::Value, ClientError> {
|
||||
let body = serde_json::json!({ "direction": direction });
|
||||
self.post(&format!("/v1/memories/{id}/vote"), &body).await
|
||||
}
|
||||
|
||||
/// Transfer knowledge between domains
|
||||
pub async fn transfer(&self, source: &str, target: &str) -> Result<serde_json::Value, ClientError> {
|
||||
let body = serde_json::json!({
|
||||
"source_domain": source,
|
||||
"target_domain": target,
|
||||
});
|
||||
self.post("/v1/transfer", &body).await
|
||||
}
|
||||
|
||||
/// Get drift report
|
||||
pub async fn drift(&self, domain: Option<&str>, since: Option<&str>) -> Result<serde_json::Value, ClientError> {
|
||||
let mut params = Vec::new();
|
||||
if let Some(d) = domain { params.push(("domain", d.to_string())); }
|
||||
if let Some(s) = since { params.push(("since", s.to_string())); }
|
||||
self.get_with_params("/v1/drift", ¶ms).await
|
||||
}
|
||||
|
||||
/// Get partition topology
|
||||
pub async fn partition(&self, domain: Option<&str>, min_size: Option<usize>) -> Result<serde_json::Value, ClientError> {
|
||||
let mut params = Vec::new();
|
||||
if let Some(d) = domain { params.push(("domain", d.to_string())); }
|
||||
if let Some(s) = min_size { params.push(("min_cluster_size", s.to_string())); }
|
||||
self.get_with_params("/v1/partition", ¶ms).await
|
||||
}
|
||||
|
||||
/// List memories
|
||||
pub async fn list(&self, category: Option<&str>, limit: Option<usize>) -> Result<serde_json::Value, ClientError> {
|
||||
let mut params = Vec::new();
|
||||
if let Some(c) = category { params.push(("category", c.to_string())); }
|
||||
if let Some(l) = limit { params.push(("limit", l.to_string())); }
|
||||
self.get_with_params("/v1/memories/list", ¶ms).await
|
||||
}
|
||||
|
||||
/// Delete a memory
|
||||
pub async fn delete(&self, id: &str) -> Result<(), ClientError> {
|
||||
let url = format!("{}/v1/memories/{id}", self.base_url);
|
||||
let resp = self.http
|
||||
.delete(&url)
|
||||
.bearer_auth(&self.api_key)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ClientError::Http(e.to_string()))?;
|
||||
|
||||
if resp.status().is_success() {
|
||||
Ok(())
|
||||
} else {
|
||||
let status = resp.status().as_u16();
|
||||
let msg = resp.text().await.unwrap_or_default();
|
||||
Err(ClientError::Server { status, message: msg })
|
||||
}
|
||||
}
|
||||
|
||||
/// Get system status
|
||||
pub async fn status(&self) -> Result<serde_json::Value, ClientError> {
|
||||
self.get_path("/v1/status").await
|
||||
}
|
||||
|
||||
/// Get latest consensus LoRA weights from server
|
||||
pub async fn lora_latest(&self) -> Result<Option<LoraWeights>, ClientError> {
|
||||
let result = self.get_path("/v1/lora/latest").await;
|
||||
match result {
|
||||
Ok(val) => {
|
||||
// Server returns {"weights": null} if no consensus yet
|
||||
if val.get("weights").map_or(true, |w| w.is_null()) {
|
||||
return Ok(None);
|
||||
}
|
||||
let weights: LoraWeights = serde_json::from_value(
|
||||
val.get("weights").cloned().unwrap_or_default()
|
||||
).map_err(|e| ClientError::Serialization(e.to_string()))?;
|
||||
Ok(Some(weights))
|
||||
}
|
||||
Err(ClientError::Server { status: 404, .. }) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Submit local LoRA weights for federated aggregation
|
||||
pub async fn lora_submit(&self, weights: &LoraWeights) -> Result<serde_json::Value, ClientError> {
|
||||
let body = serde_json::to_value(weights)
|
||||
.map_err(|e| ClientError::Serialization(e.to_string()))?;
|
||||
self.post("/v1/lora/submit", &body).await
|
||||
}
|
||||
|
||||
// ---- Brainpedia (ADR-062) ----
|
||||
|
||||
/// Create a Brainpedia page
|
||||
pub async fn create_page(&self, body: &serde_json::Value) -> Result<serde_json::Value, ClientError> {
|
||||
self.post("/v1/pages", body).await
|
||||
}
|
||||
|
||||
/// Get a Brainpedia page with its delta log and evidence
|
||||
pub async fn get_page(&self, id: &str) -> Result<serde_json::Value, ClientError> {
|
||||
self.get_path(&format!("/v1/pages/{id}")).await
|
||||
}
|
||||
|
||||
/// Submit a delta to a page
|
||||
pub async fn submit_delta(&self, page_id: &str, body: &serde_json::Value) -> Result<serde_json::Value, ClientError> {
|
||||
self.post(&format!("/v1/pages/{page_id}/deltas"), body).await
|
||||
}
|
||||
|
||||
/// List deltas for a page
|
||||
pub async fn list_deltas(&self, page_id: &str) -> Result<serde_json::Value, ClientError> {
|
||||
self.get_path(&format!("/v1/pages/{page_id}/deltas")).await
|
||||
}
|
||||
|
||||
/// Add evidence to a page
|
||||
pub async fn add_evidence(&self, page_id: &str, body: &serde_json::Value) -> Result<serde_json::Value, ClientError> {
|
||||
self.post(&format!("/v1/pages/{page_id}/evidence"), body).await
|
||||
}
|
||||
|
||||
/// Promote a page from Draft to Canonical
|
||||
pub async fn promote_page(&self, page_id: &str) -> Result<serde_json::Value, ClientError> {
|
||||
self.post(&format!("/v1/pages/{page_id}/promote"), &serde_json::json!({})).await
|
||||
}
|
||||
|
||||
// ---- WASM Executable Nodes (ADR-063) ----
|
||||
|
||||
/// List all published WASM nodes
|
||||
pub async fn list_nodes(&self) -> Result<serde_json::Value, ClientError> {
|
||||
self.get_path("/v1/nodes").await
|
||||
}
|
||||
|
||||
/// Publish a WASM node
|
||||
pub async fn publish_node(&self, body: &serde_json::Value) -> Result<serde_json::Value, ClientError> {
|
||||
self.post("/v1/nodes", body).await
|
||||
}
|
||||
|
||||
/// Get WASM node metadata
|
||||
pub async fn get_node(&self, id: &str) -> Result<serde_json::Value, ClientError> {
|
||||
self.get_path(&format!("/v1/nodes/{id}")).await
|
||||
}
|
||||
|
||||
/// Download WASM binary
|
||||
pub async fn get_node_wasm(&self, id: &str) -> Result<Vec<u8>, ClientError> {
|
||||
let url = format!("{}/v1/nodes/{id}.wasm", self.base_url);
|
||||
let resp = self.http
|
||||
.get(&url)
|
||||
.bearer_auth(&self.api_key)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ClientError::Http(e.to_string()))?;
|
||||
|
||||
if resp.status().is_success() {
|
||||
resp.bytes().await
|
||||
.map(|b| b.to_vec())
|
||||
.map_err(|e| ClientError::Http(e.to_string()))
|
||||
} else {
|
||||
let status = resp.status().as_u16();
|
||||
let msg = resp.text().await.unwrap_or_default();
|
||||
Err(ClientError::Server { status, message: msg })
|
||||
}
|
||||
}
|
||||
|
||||
/// Revoke a WASM node
|
||||
pub async fn revoke_node(&self, id: &str) -> Result<(), ClientError> {
|
||||
let url = format!("{}/v1/nodes/{id}/revoke", self.base_url);
|
||||
let resp = self.http
|
||||
.post(&url)
|
||||
.bearer_auth(&self.api_key)
|
||||
.json(&serde_json::json!({}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ClientError::Http(e.to_string()))?;
|
||||
|
||||
if resp.status().is_success() || resp.status().as_u16() == 204 {
|
||||
Ok(())
|
||||
} else {
|
||||
let status = resp.status().as_u16();
|
||||
let msg = resp.text().await.unwrap_or_default();
|
||||
Err(ClientError::Server { status, message: msg })
|
||||
}
|
||||
}
|
||||
|
||||
// ---- HTTP helpers ----
|
||||
|
||||
async fn get_path(&self, path: &str) -> Result<serde_json::Value, ClientError> {
|
||||
let url = format!("{}{path}", self.base_url);
|
||||
let resp = self.http
|
||||
.get(&url)
|
||||
.bearer_auth(&self.api_key)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ClientError::Http(e.to_string()))?;
|
||||
|
||||
self.handle_response(resp).await
|
||||
}
|
||||
|
||||
async fn get_with_params(&self, path: &str, params: &[(&str, String)]) -> Result<serde_json::Value, ClientError> {
|
||||
let url = format!("{}{path}", self.base_url);
|
||||
let resp = self.http
|
||||
.get(&url)
|
||||
.bearer_auth(&self.api_key)
|
||||
.query(params)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ClientError::Http(e.to_string()))?;
|
||||
|
||||
self.handle_response(resp).await
|
||||
}
|
||||
|
||||
async fn post(&self, path: &str, body: &serde_json::Value) -> Result<serde_json::Value, ClientError> {
|
||||
let url = format!("{}{path}", self.base_url);
|
||||
let resp = self.http
|
||||
.post(&url)
|
||||
.bearer_auth(&self.api_key)
|
||||
.json(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ClientError::Http(e.to_string()))?;
|
||||
|
||||
self.handle_response(resp).await
|
||||
}
|
||||
|
||||
async fn handle_response(&self, resp: reqwest::Response) -> Result<serde_json::Value, ClientError> {
|
||||
let status = resp.status().as_u16();
|
||||
if status >= 400 {
|
||||
let msg = resp.text().await.unwrap_or_default();
|
||||
return Err(ClientError::Server { status, message: msg });
|
||||
}
|
||||
resp.json().await.map_err(|e| ClientError::Serialization(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for BrainClient {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// SHAKE-256 hash
|
||||
fn sha3_hash(data: &[u8]) -> [u8; 32] {
|
||||
use sha3::{Shake256, digest::{Update, ExtendableOutput, XofReader}};
|
||||
let mut hasher = Shake256::default();
|
||||
hasher.update(data);
|
||||
let mut reader = hasher.finalize_xof();
|
||||
let mut buf = [0u8; 32];
|
||||
reader.read(&mut buf);
|
||||
buf
|
||||
}
|
||||
579
crates/mcp-brain/src/embed.rs
Normal file
579
crates/mcp-brain/src/embed.rs
Normal file
|
|
@ -0,0 +1,579 @@
|
|||
//! Embedding generation for brain memories
|
||||
//!
|
||||
//! Two-stage embedding pipeline:
|
||||
//! 1. **Structured Hash Features** (deterministic, identical across all sessions):
|
||||
//! Multi-granularity hashing — unigram, bigram, trigram tokens hashed into
|
||||
//! disjoint subspaces with signed hashing to reduce collision bias.
|
||||
//! 2. **MicroLoRA Transform** (learned, federated across sessions):
|
||||
//! Rank-2 LoRA adapter applied to the frozen hash features. Weights are
|
||||
//! learned locally via SONA and periodically federated to/from the server.
|
||||
|
||||
use sha3::{Shake256, digest::{Update, ExtendableOutput, XofReader}};
|
||||
use sona::{SonaEngine, LearnedPattern};
|
||||
use sona::engine::SonaEngineBuilder;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Embedding dimension (128 f32s = 512 bytes)
|
||||
pub const EMBEDDING_DIM: usize = 128;
|
||||
|
||||
/// Subspace allocation for multi-granularity hashing:
|
||||
/// - Unigram: dims [0..42) = 42 dims (33%)
|
||||
/// - Bigram: dims [42..84) = 42 dims (33%)
|
||||
/// - Trigram: dims [84..128) = 44 dims (34%)
|
||||
const UNIGRAM_START: usize = 0;
|
||||
const UNIGRAM_END: usize = 42;
|
||||
const BIGRAM_START: usize = 42;
|
||||
const BIGRAM_END: usize = 84;
|
||||
const TRIGRAM_START: usize = 84;
|
||||
const TRIGRAM_END: usize = EMBEDDING_DIM; // 128
|
||||
|
||||
/// LoRA weights exported for federation
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct LoraWeights {
|
||||
/// Down projection weights (hidden_dim * rank)
|
||||
pub down_proj: Vec<f32>,
|
||||
/// Up projection weights (rank * hidden_dim)
|
||||
pub up_proj: Vec<f32>,
|
||||
/// LoRA rank
|
||||
pub rank: usize,
|
||||
/// Hidden dimension
|
||||
pub hidden_dim: usize,
|
||||
/// Number of local training steps that produced these weights
|
||||
pub evidence_count: u64,
|
||||
}
|
||||
|
||||
impl LoraWeights {
|
||||
/// Validate weight shapes and values (Gate A: policy validity)
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
// Shape check
|
||||
let expected_down = self.hidden_dim * self.rank;
|
||||
let expected_up = self.rank * self.hidden_dim;
|
||||
if self.down_proj.len() != expected_down {
|
||||
return Err(format!(
|
||||
"down_proj shape mismatch: expected {expected_down}, got {}",
|
||||
self.down_proj.len()
|
||||
));
|
||||
}
|
||||
if self.up_proj.len() != expected_up {
|
||||
return Err(format!(
|
||||
"up_proj shape mismatch: expected {expected_up}, got {}",
|
||||
self.up_proj.len()
|
||||
));
|
||||
}
|
||||
// NaN/Inf check
|
||||
for (i, &v) in self.down_proj.iter().chain(self.up_proj.iter()).enumerate() {
|
||||
if v.is_nan() || v.is_infinite() {
|
||||
return Err(format!("NaN/Inf at index {i}"));
|
||||
}
|
||||
}
|
||||
// Norm check: reject if L2 norm of either projection > 100
|
||||
let down_norm: f32 = self.down_proj.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
let up_norm: f32 = self.up_proj.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
if down_norm > 100.0 || up_norm > 100.0 {
|
||||
return Err(format!("Weight norm too large: down={down_norm:.1}, up={up_norm:.1}"));
|
||||
}
|
||||
// Minimum evidence
|
||||
if self.evidence_count < 5 {
|
||||
return Err(format!(
|
||||
"Insufficient evidence: {} (minimum 5)",
|
||||
self.evidence_count
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clip weights to [-2, 2] range
|
||||
pub fn clip(&mut self) {
|
||||
for v in self.down_proj.iter_mut().chain(self.up_proj.iter_mut()) {
|
||||
*v = v.clamp(-2.0, 2.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute L2 distance to another set of weights
|
||||
pub fn l2_distance(&self, other: &LoraWeights) -> f32 {
|
||||
let d: f32 = self.down_proj.iter().zip(other.down_proj.iter())
|
||||
.chain(self.up_proj.iter().zip(other.up_proj.iter()))
|
||||
.map(|(a, b)| (a - b).powi(2))
|
||||
.sum();
|
||||
d.sqrt()
|
||||
}
|
||||
}
|
||||
|
||||
/// Brain embedding engine wrapping SONA with structured hash + MicroLoRA
|
||||
pub struct BrainEmbedder {
|
||||
engine: Option<Arc<Mutex<SonaEngine>>>,
|
||||
/// Locally cached consensus weights from the server (applied on embed)
|
||||
consensus_lora: Option<LoraWeights>,
|
||||
/// Count of embeddings processed (used as evidence_count for export)
|
||||
embed_count: u64,
|
||||
}
|
||||
|
||||
impl BrainEmbedder {
|
||||
/// Create with real SONA engine
|
||||
pub fn new() -> Self {
|
||||
let engine = match std::panic::catch_unwind(|| {
|
||||
SonaEngineBuilder::new()
|
||||
.hidden_dim(EMBEDDING_DIM)
|
||||
.micro_lora_rank(2)
|
||||
.pattern_clusters(50)
|
||||
.quality_threshold(0.3)
|
||||
.build()
|
||||
}) {
|
||||
Ok(e) => Some(Arc::new(Mutex::new(e))),
|
||||
Err(_) => None,
|
||||
};
|
||||
Self {
|
||||
engine,
|
||||
consensus_lora: None,
|
||||
embed_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with hash-only fallback (no SONA)
|
||||
pub fn hash_only() -> Self {
|
||||
Self {
|
||||
engine: None,
|
||||
consensus_lora: None,
|
||||
embed_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate embedding for text content.
|
||||
///
|
||||
/// Pipeline: text -> structured hash features -> MicroLoRA transform -> L2 normalize
|
||||
pub fn embed(&mut self, text: &str) -> Vec<f32> {
|
||||
self.embed_count += 1;
|
||||
|
||||
// Stage 1: Deterministic structured hash features
|
||||
let hash_features = generate_structured_hash_features(text);
|
||||
|
||||
// Stage 2: Apply MicroLoRA transform (consensus weights if available, then SONA)
|
||||
let transformed = self.apply_lora_transform(&hash_features);
|
||||
|
||||
// Feed into SONA for trajectory learning if available
|
||||
if let Some(ref engine) = self.engine {
|
||||
if let Ok(eng) = engine.lock() {
|
||||
let builder = eng.begin_trajectory(transformed.clone());
|
||||
eng.end_trajectory(builder, 0.5);
|
||||
// Check for refined patterns
|
||||
let patterns = eng.find_patterns(&transformed, 1);
|
||||
if let Some(pattern) = patterns.first() {
|
||||
if pattern.similarity(&transformed) > 0.3 {
|
||||
return normalize_l2(&pattern.centroid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
transformed
|
||||
}
|
||||
|
||||
/// Apply MicroLoRA transform to hash features
|
||||
fn apply_lora_transform(&self, features: &[f32]) -> Vec<f32> {
|
||||
// Try consensus weights first (from server federation)
|
||||
if let Some(ref lora) = self.consensus_lora {
|
||||
return apply_lora_forward(features, lora);
|
||||
}
|
||||
// Try SONA engine's local MicroLoRA
|
||||
if let Some(ref engine) = self.engine {
|
||||
if let Ok(eng) = engine.lock() {
|
||||
let lora_state = eng.export_lora_state();
|
||||
if let Some(layer) = lora_state.micro_lora_layers.first() {
|
||||
let local_lora = LoraWeights {
|
||||
down_proj: layer.lora_a.clone(),
|
||||
up_proj: layer.lora_b.clone(),
|
||||
rank: layer.rank,
|
||||
hidden_dim: layer.input_dim,
|
||||
evidence_count: self.embed_count,
|
||||
};
|
||||
return apply_lora_forward(features, &local_lora);
|
||||
}
|
||||
}
|
||||
}
|
||||
// No LoRA available — return raw hash features
|
||||
normalize_l2(features)
|
||||
}
|
||||
|
||||
/// Import consensus LoRA weights from the server
|
||||
pub fn import_consensus_weights(&mut self, weights: LoraWeights) {
|
||||
self.consensus_lora = Some(weights);
|
||||
}
|
||||
|
||||
/// Export local MicroLoRA weights for federation
|
||||
pub fn export_local_weights(&self) -> Option<LoraWeights> {
|
||||
if let Some(ref engine) = self.engine {
|
||||
if let Ok(eng) = engine.lock() {
|
||||
let lora_state = eng.export_lora_state();
|
||||
if let Some(layer) = lora_state.micro_lora_layers.first() {
|
||||
return Some(LoraWeights {
|
||||
down_proj: layer.lora_a.clone(),
|
||||
up_proj: layer.lora_b.clone(),
|
||||
rank: layer.rank,
|
||||
hidden_dim: layer.input_dim,
|
||||
evidence_count: self.embed_count,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Get number of embeddings processed
|
||||
pub fn embed_count(&self) -> u64 {
|
||||
self.embed_count
|
||||
}
|
||||
|
||||
/// Record quality feedback for a trajectory
|
||||
pub fn record_feedback(&self, embedding: &[f32], quality: f32) {
|
||||
if let Some(ref engine) = self.engine {
|
||||
if let Ok(eng) = engine.lock() {
|
||||
let builder = eng.begin_trajectory(embedding.to_vec());
|
||||
eng.end_trajectory(builder, quality);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Find similar patterns from SONA's learned bank
|
||||
pub fn find_similar(&self, query: &[f32], k: usize) -> Vec<LearnedPattern> {
|
||||
if let Some(ref engine) = self.engine {
|
||||
if let Ok(eng) = engine.lock() {
|
||||
return eng.find_patterns(query, k);
|
||||
}
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Force a learning cycle
|
||||
pub fn force_learn(&self) -> Option<String> {
|
||||
if let Some(ref engine) = self.engine {
|
||||
if let Ok(eng) = engine.lock() {
|
||||
return Some(eng.force_learn());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Check if SONA engine is active
|
||||
pub fn has_sona(&self) -> bool {
|
||||
self.engine.is_some()
|
||||
}
|
||||
|
||||
/// Check if consensus LoRA weights are loaded
|
||||
pub fn has_consensus_lora(&self) -> bool {
|
||||
self.consensus_lora.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for BrainEmbedder {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply LoRA forward pass: output = L2_normalize(input + scale * (input @ down) @ up)
|
||||
fn apply_lora_forward(input: &[f32], lora: &LoraWeights) -> Vec<f32> {
|
||||
let dim = lora.hidden_dim;
|
||||
let rank = lora.rank;
|
||||
let scale = (rank as f32).recip(); // alpha/rank = 1.0
|
||||
|
||||
// down_proj: input (dim,) @ down (dim, rank) -> intermediate (rank,)
|
||||
let mut intermediate = vec![0.0f32; rank];
|
||||
for r in 0..rank {
|
||||
for d in 0..dim.min(input.len()) {
|
||||
intermediate[r] += input[d] * lora.down_proj[d * rank + r];
|
||||
}
|
||||
}
|
||||
|
||||
// up_proj: intermediate (rank,) @ up (rank, dim) -> delta (dim,)
|
||||
let mut output: Vec<f32> = input.to_vec();
|
||||
output.resize(dim, 0.0);
|
||||
for d in 0..dim {
|
||||
let mut delta = 0.0f32;
|
||||
for r in 0..rank {
|
||||
delta += intermediate[r] * lora.up_proj[r * dim + d];
|
||||
}
|
||||
output[d] += scale * delta;
|
||||
}
|
||||
|
||||
normalize_l2(&output)
|
||||
}
|
||||
|
||||
/// L2 normalize a vector
|
||||
pub fn normalize_l2(v: &[f32]) -> Vec<f32> {
|
||||
let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
if norm > 1e-10 {
|
||||
v.iter().map(|x| x / norm).collect()
|
||||
} else {
|
||||
v.to_vec()
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate structured multi-granularity hash features.
|
||||
///
|
||||
/// Splits text into unigram, bigram, and trigram tokens. Each n-gram level
|
||||
/// hashes into a disjoint subspace of the embedding vector using signed hashing
|
||||
/// (hash determines both the bucket index AND the sign, reducing collision bias).
|
||||
///
|
||||
/// This is deterministic and identical across all sessions — the frozen base
|
||||
/// that MicroLoRA adapts on top of.
|
||||
pub fn generate_structured_hash_features(text: &str) -> Vec<f32> {
|
||||
let mut features = vec![0.0f32; EMBEDDING_DIM];
|
||||
let lower = text.to_lowercase();
|
||||
let words: Vec<&str> = lower.split_whitespace().collect();
|
||||
|
||||
// Unigram features: each word hashes into dims [0..42)
|
||||
let unigram_dim = UNIGRAM_END - UNIGRAM_START;
|
||||
for word in &words {
|
||||
let (bucket, sign) = signed_hash(word.as_bytes(), b"uni", unigram_dim);
|
||||
features[UNIGRAM_START + bucket] += sign;
|
||||
}
|
||||
|
||||
// Bigram features: consecutive word pairs hash into dims [42..84)
|
||||
let bigram_dim = BIGRAM_END - BIGRAM_START;
|
||||
for pair in words.windows(2) {
|
||||
let key = format!("{} {}", pair[0], pair[1]);
|
||||
let (bucket, sign) = signed_hash(key.as_bytes(), b"bi", bigram_dim);
|
||||
features[BIGRAM_START + bucket] += sign;
|
||||
}
|
||||
|
||||
// Trigram features: consecutive word triples hash into dims [84..128)
|
||||
let trigram_dim = TRIGRAM_END - TRIGRAM_START;
|
||||
for triple in words.windows(3) {
|
||||
let key = format!("{} {} {}", triple[0], triple[1], triple[2]);
|
||||
let (bucket, sign) = signed_hash(key.as_bytes(), b"tri", trigram_dim);
|
||||
features[TRIGRAM_START + bucket] += sign;
|
||||
}
|
||||
|
||||
// Also add character n-gram features for short texts or single words
|
||||
if words.len() <= 2 {
|
||||
let chars: Vec<char> = lower.chars().filter(|c| c.is_alphanumeric()).collect();
|
||||
// Character trigrams into the trigram subspace
|
||||
for window in chars.windows(3) {
|
||||
let key: String = window.iter().collect();
|
||||
let (bucket, sign) = signed_hash(key.as_bytes(), b"ctri", trigram_dim);
|
||||
features[TRIGRAM_START + bucket] += sign * 0.5; // Lower weight for char ngrams
|
||||
}
|
||||
}
|
||||
|
||||
// L2 normalize and clip to [-1, 1]
|
||||
let norm: f32 = features.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
if norm > 1e-10 {
|
||||
for v in &mut features {
|
||||
*v = (*v / norm).clamp(-1.0, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
features
|
||||
}
|
||||
|
||||
/// Signed hash: returns (bucket_index, +1.0 or -1.0).
|
||||
/// Uses SHAKE-256 for uniform distribution. The first 4 bytes determine the bucket,
|
||||
/// the 5th byte determines the sign.
|
||||
fn signed_hash(data: &[u8], salt: &[u8], num_buckets: usize) -> (usize, f32) {
|
||||
let mut hasher = Shake256::default();
|
||||
hasher.update(b"ruvector-shf:");
|
||||
hasher.update(salt);
|
||||
hasher.update(b":");
|
||||
hasher.update(data);
|
||||
let mut reader = hasher.finalize_xof();
|
||||
let mut buf = [0u8; 5];
|
||||
reader.read(&mut buf);
|
||||
|
||||
let bucket = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]) as usize % num_buckets;
|
||||
let sign = if buf[4] & 1 == 0 { 1.0f32 } else { -1.0f32 };
|
||||
(bucket, sign)
|
||||
}
|
||||
|
||||
/// Public convenience: generate an embedding using structured hash features only.
|
||||
/// Callers needing SONA/LoRA should use BrainEmbedder directly.
|
||||
pub fn generate_embedding(text: &str) -> Vec<f32> {
|
||||
generate_structured_hash_features(text)
|
||||
}
|
||||
|
||||
/// Compute cosine similarity between two embeddings
|
||||
pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
|
||||
if a.len() != b.len() || a.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
|
||||
let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
if norm_a < 1e-10 || norm_b < 1e-10 {
|
||||
return 0.0;
|
||||
}
|
||||
dot / (norm_a * norm_b)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_embedding_deterministic() {
|
||||
let e1 = generate_embedding("hello world");
|
||||
let e2 = generate_embedding("hello world");
|
||||
assert_eq!(e1, e2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_embedding_dimension() {
|
||||
let e = generate_embedding("test");
|
||||
assert_eq!(e.len(), EMBEDDING_DIM);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_embedding_normalized() {
|
||||
let e = generate_embedding("a longer text for normalization testing");
|
||||
let norm: f32 = e.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
assert!((norm - 1.0).abs() < 0.01, "norm was {norm}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_similar_texts_closer() {
|
||||
// Structured hash features should give higher similarity for
|
||||
// texts sharing many n-grams than completely unrelated texts
|
||||
let e1 = generate_embedding("rust programming language features");
|
||||
let e2 = generate_embedding("rust programming language syntax");
|
||||
let e3 = generate_embedding("cooking recipes for dinner tonight");
|
||||
let sim12 = cosine_similarity(&e1, &e2);
|
||||
let sim13 = cosine_similarity(&e1, &e3);
|
||||
// Texts sharing 3/4 words should be more similar than disjoint texts
|
||||
assert!(
|
||||
sim12 > sim13,
|
||||
"similar texts should be closer: sim12={sim12}, sim13={sim13}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_disjoint_subspaces() {
|
||||
// Verify that a single word only activates the unigram subspace
|
||||
let e = generate_structured_hash_features("hello");
|
||||
// Unigram subspace should have non-zero values
|
||||
let uni_energy: f32 = e[UNIGRAM_START..UNIGRAM_END].iter().map(|x| x * x).sum();
|
||||
assert!(uni_energy > 0.0, "unigram subspace should be active");
|
||||
// Bigram/trigram subspaces should be zero (single word = no pairs/triples)
|
||||
// But char trigrams may activate trigram subspace for short texts
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_signed_hash_distribution() {
|
||||
// Verify signed_hash produces both positive and negative signs
|
||||
let mut pos = 0;
|
||||
let mut neg = 0;
|
||||
for i in 0..100 {
|
||||
let key = format!("test-{i}");
|
||||
let (_, sign) = signed_hash(key.as_bytes(), b"test", 42);
|
||||
if sign > 0.0 { pos += 1; } else { neg += 1; }
|
||||
}
|
||||
// Both signs should appear (probabilistic, but 100 trials is enough)
|
||||
assert!(pos > 10 && neg > 10, "pos={pos}, neg={neg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_brain_embedder_creates() {
|
||||
let embedder = BrainEmbedder::new();
|
||||
let _ = embedder.has_sona();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_brain_embedder_embed() {
|
||||
let mut embedder = BrainEmbedder::new();
|
||||
let emb = embedder.embed("hello world");
|
||||
assert_eq!(emb.len(), EMBEDDING_DIM);
|
||||
let norm: f32 = emb.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
assert!((norm - 1.0).abs() < 0.05, "norm was {norm}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hash_fallback() {
|
||||
let mut embedder = BrainEmbedder::hash_only();
|
||||
assert!(!embedder.has_sona());
|
||||
assert!(!embedder.has_consensus_lora());
|
||||
let emb = embedder.embed("hello world");
|
||||
assert_eq!(emb.len(), EMBEDDING_DIM);
|
||||
// Hash-only should match generate_embedding (both L2-normalized structured hash)
|
||||
let direct = generate_embedding("hello world");
|
||||
assert_eq!(emb, direct);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lora_weights_validate() {
|
||||
let valid = LoraWeights {
|
||||
down_proj: vec![0.1; 256],
|
||||
up_proj: vec![0.1; 256],
|
||||
rank: 2,
|
||||
hidden_dim: 128,
|
||||
evidence_count: 10,
|
||||
};
|
||||
assert!(valid.validate().is_ok());
|
||||
|
||||
// Bad shape
|
||||
let bad_shape = LoraWeights {
|
||||
down_proj: vec![0.1; 100],
|
||||
up_proj: vec![0.1; 256],
|
||||
rank: 2,
|
||||
hidden_dim: 128,
|
||||
evidence_count: 10,
|
||||
};
|
||||
assert!(bad_shape.validate().is_err());
|
||||
|
||||
// NaN
|
||||
let mut nan_weights = valid.clone();
|
||||
nan_weights.down_proj[0] = f32::NAN;
|
||||
assert!(nan_weights.validate().is_err());
|
||||
|
||||
// Low evidence
|
||||
let low_ev = LoraWeights {
|
||||
evidence_count: 2,
|
||||
..valid.clone()
|
||||
};
|
||||
assert!(low_ev.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lora_forward_pass() {
|
||||
// Zero LoRA should return normalized input unchanged
|
||||
let input = generate_structured_hash_features("test input");
|
||||
let zero_lora = LoraWeights {
|
||||
down_proj: vec![0.0; 256],
|
||||
up_proj: vec![0.0; 256],
|
||||
rank: 2,
|
||||
hidden_dim: 128,
|
||||
evidence_count: 10,
|
||||
};
|
||||
let output = apply_lora_forward(&input, &zero_lora);
|
||||
let expected = normalize_l2(&input);
|
||||
for (a, b) in output.iter().zip(expected.iter()) {
|
||||
assert!((a - b).abs() < 1e-6, "zero LoRA should be identity");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_consensus_import() {
|
||||
let mut embedder = BrainEmbedder::hash_only();
|
||||
assert!(!embedder.has_consensus_lora());
|
||||
|
||||
let weights = LoraWeights {
|
||||
down_proj: vec![0.01; 256],
|
||||
up_proj: vec![0.01; 256],
|
||||
rank: 2,
|
||||
hidden_dim: 128,
|
||||
evidence_count: 100,
|
||||
};
|
||||
embedder.import_consensus_weights(weights);
|
||||
assert!(embedder.has_consensus_lora());
|
||||
|
||||
// Embedding should now go through LoRA transform
|
||||
let emb = embedder.embed("test with lora");
|
||||
assert_eq!(emb.len(), EMBEDDING_DIM);
|
||||
let norm: f32 = emb.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
assert!((norm - 1.0).abs() < 0.05, "norm was {norm}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_similar_empty() {
|
||||
let embedder = BrainEmbedder::hash_only();
|
||||
let results = embedder.find_similar(&[0.0; EMBEDDING_DIM], 5);
|
||||
assert!(results.is_empty());
|
||||
}
|
||||
}
|
||||
39
crates/mcp-brain/src/lib.rs
Normal file
39
crates/mcp-brain/src/lib.rs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
//! mcp-brain: MCP server for the RuVector Shared Brain
|
||||
//!
|
||||
//! Enables Claude Code sessions to share and discover learning across sessions.
|
||||
//! Knowledge is stored as RVF cognitive containers with witness chains,
|
||||
//! Ed25519 signatures, and differential privacy proofs.
|
||||
//!
|
||||
//! # MCP Tools (10)
|
||||
//!
|
||||
//! - **brain_share**: Share a learning with the collective
|
||||
//! - **brain_search**: Semantic search across shared knowledge
|
||||
//! - **brain_get**: Retrieve a specific memory with full provenance
|
||||
//! - **brain_vote**: Quality-gate a memory (Bayesian update)
|
||||
//! - **brain_transfer**: Apply learned priors cross-domain
|
||||
//! - **brain_drift**: Check if shared knowledge has drifted
|
||||
//! - **brain_partition**: Get knowledge partitioned by mincut topology
|
||||
//! - **brain_list**: List recent memories by category/quality
|
||||
//! - **brain_delete**: Delete own contribution
|
||||
//! - **brain_status**: System health
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use mcp_brain::McpBrainServer;
|
||||
//!
|
||||
//! #[tokio::main]
|
||||
//! async fn main() {
|
||||
//! let server = McpBrainServer::new();
|
||||
//! server.run_stdio().await.expect("Server failed");
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
pub mod client;
|
||||
pub mod embed;
|
||||
pub mod pipeline;
|
||||
pub mod server;
|
||||
pub mod tools;
|
||||
pub mod types;
|
||||
|
||||
pub use server::McpBrainServer;
|
||||
25
crates/mcp-brain/src/main.rs
Normal file
25
crates/mcp-brain/src/main.rs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
//! MCP Brain server binary
|
||||
//!
|
||||
//! Runs the MCP Brain server on stdio for integration with Claude Code.
|
||||
|
||||
use mcp_brain::McpBrainServer;
|
||||
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(fmt::layer().with_writer(std::io::stderr))
|
||||
.with(filter)
|
||||
.init();
|
||||
|
||||
let server = McpBrainServer::new();
|
||||
|
||||
tracing::info!("MCP Brain server v{} starting", env!("CARGO_PKG_VERSION"));
|
||||
tracing::info!("Backend: brain.ruv.io");
|
||||
|
||||
server.run_stdio().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
355
crates/mcp-brain/src/pipeline.rs
Normal file
355
crates/mcp-brain/src/pipeline.rs
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
//! Local processing pipeline: PII -> embed -> sign
|
||||
|
||||
use regex_lite::Regex;
|
||||
use sha3::{Shake256, digest::{Update, ExtendableOutput, XofReader}};
|
||||
|
||||
/// Pipeline for processing knowledge before sharing.
|
||||
/// Pre-compiles 12 PII regex patterns for efficient reuse.
|
||||
pub struct BrainPipeline {
|
||||
pii_patterns: Vec<(Regex, &'static str)>,
|
||||
}
|
||||
|
||||
impl BrainPipeline {
|
||||
pub fn new() -> Self {
|
||||
let patterns = vec![
|
||||
// 1. Unix home paths
|
||||
(Regex::new(r"/(?:home|Users|root)/[^\s]+").unwrap(), "[REDACTED_PATH]"),
|
||||
// 2. Windows paths
|
||||
(Regex::new(r"C:\\Users\\[^\s]+").unwrap(), "[REDACTED_PATH]"),
|
||||
// 3. API keys: sk-..., ghp_..., gho_..., xoxb-..., xoxp-..., AKIA...
|
||||
(Regex::new(r"(?:sk-|ghp_|gho_|xoxb-|xoxp-|AKIA)[A-Za-z0-9_/-]+").unwrap(), "[REDACTED_KEY]"),
|
||||
// 4. Bearer tokens
|
||||
(Regex::new(r"Bearer\s+[A-Za-z0-9_./-]+").unwrap(), "[REDACTED_TOKEN]"),
|
||||
// 5. IPv4 addresses
|
||||
(Regex::new(r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b").unwrap(), "[REDACTED_IP]"),
|
||||
// 6. Email addresses
|
||||
(Regex::new(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b").unwrap(), "[REDACTED_EMAIL]"),
|
||||
// 7. SSH keys
|
||||
(Regex::new(r"ssh-(?:rsa|ed25519|ecdsa)\s+[A-Za-z0-9+/=]+").unwrap(), "[REDACTED_SSH_KEY]"),
|
||||
// 8. JWT tokens
|
||||
(Regex::new(r"eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+").unwrap(), "[REDACTED_JWT]"),
|
||||
// 9. Private keys in PEM
|
||||
(Regex::new(r"-----BEGIN[A-Z ]+PRIVATE KEY-----").unwrap(), "[REDACTED_PRIVATE_KEY]"),
|
||||
// 10. Secret/password/token assignments
|
||||
(Regex::new(r"(?i)(?:secret|password|passwd|token|key)\s*[:=]\s*['\x22]?[A-Za-z0-9+/=_-]{16,}").unwrap(), "[REDACTED_SECRET]"),
|
||||
// 11. API key / access token credentials
|
||||
(Regex::new(r"(?i)(?:api[_-]?key|access[_-]?token)\s*[:=]\s*['\x22]?[a-f0-9-]{32,}").unwrap(), "[REDACTED_CREDENTIAL]"),
|
||||
// 12. Internal hostnames
|
||||
(Regex::new(r"\b(?:localhost|127\.0\.0\.1|0\.0\.0\.0|internal\.[a-z.]+)\b").unwrap(), "[REDACTED_HOST]"),
|
||||
];
|
||||
Self { pii_patterns: patterns }
|
||||
}
|
||||
|
||||
/// Strip PII from text using all 12 pattern categories
|
||||
pub fn strip_pii(&self, text: &str) -> String {
|
||||
let mut result = text.to_string();
|
||||
for (pattern, replacement) in &self.pii_patterns {
|
||||
result = pattern.replace_all(&result, *replacement).to_string();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Check if text contains PII (any pattern matches)
|
||||
pub fn contains_pii(&self, text: &str) -> bool {
|
||||
self.pii_patterns.iter().any(|(pat, _)| pat.is_match(text))
|
||||
}
|
||||
|
||||
/// Build a linked witness chain from a list of operations
|
||||
pub fn build_witness_chain(operations: &[&str]) -> WitnessChain {
|
||||
let mut chain = WitnessChain::new();
|
||||
for op in operations {
|
||||
chain.append(op);
|
||||
}
|
||||
chain
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for BrainPipeline {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// A single witness entry in the chain
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WitnessEntry {
|
||||
pub action: String,
|
||||
pub hash: [u8; 32],
|
||||
pub timestamp_ns: u64,
|
||||
}
|
||||
|
||||
/// Linked chain of witness entries using SHAKE-256.
|
||||
/// Each entry's hash = SHAKE256(prev_hash || action || timestamp).
|
||||
pub struct WitnessChain {
|
||||
entries: Vec<WitnessEntry>,
|
||||
prev_hash: [u8; 32],
|
||||
}
|
||||
|
||||
impl WitnessChain {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
entries: Vec::new(),
|
||||
prev_hash: [0u8; 32],
|
||||
}
|
||||
}
|
||||
|
||||
/// Append an action to the witness chain
|
||||
pub fn append(&mut self, action: &str) -> &WitnessEntry {
|
||||
let timestamp_ns = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos() as u64;
|
||||
|
||||
let mut hasher = Shake256::default();
|
||||
hasher.update(&self.prev_hash);
|
||||
hasher.update(action.as_bytes());
|
||||
hasher.update(×tamp_ns.to_le_bytes());
|
||||
let mut reader = hasher.finalize_xof();
|
||||
let mut hash = [0u8; 32];
|
||||
reader.read(&mut hash);
|
||||
|
||||
let entry = WitnessEntry {
|
||||
action: action.to_string(),
|
||||
hash,
|
||||
timestamp_ns,
|
||||
};
|
||||
|
||||
self.prev_hash = hash;
|
||||
self.entries.push(entry);
|
||||
self.entries.last().unwrap()
|
||||
}
|
||||
|
||||
/// Get the final witness hash as hex string
|
||||
pub fn finalize(&self) -> String {
|
||||
hex::encode(self.prev_hash)
|
||||
}
|
||||
|
||||
/// Verify chain integrity by recomputing each hash
|
||||
pub fn verify(&self) -> bool {
|
||||
let mut prev = [0u8; 32];
|
||||
for entry in &self.entries {
|
||||
let mut hasher = Shake256::default();
|
||||
hasher.update(&prev);
|
||||
hasher.update(entry.action.as_bytes());
|
||||
hasher.update(&entry.timestamp_ns.to_le_bytes());
|
||||
let mut reader = hasher.finalize_xof();
|
||||
let mut expected = [0u8; 32];
|
||||
reader.read(&mut expected);
|
||||
if expected != entry.hash {
|
||||
return false;
|
||||
}
|
||||
prev = entry.hash;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Get number of entries
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WitnessChain {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a witness hash for an operation chain (backward compat)
|
||||
pub fn witness_hash(operations: &[&str]) -> String {
|
||||
let mut chain = WitnessChain::new();
|
||||
for op in operations {
|
||||
chain.append(op);
|
||||
}
|
||||
chain.finalize()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_strip_pii_unix_paths() {
|
||||
let pipeline = BrainPipeline::new();
|
||||
let input = "Found at /home/alice/secrets.txt";
|
||||
let output = pipeline.strip_pii(input);
|
||||
assert!(!output.contains("/home/alice"));
|
||||
assert!(output.contains("[REDACTED_PATH]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_pii_windows_paths() {
|
||||
let pipeline = BrainPipeline::new();
|
||||
let input = r"Found at C:\Users\bob\documents\secret.txt";
|
||||
let output = pipeline.strip_pii(input);
|
||||
assert!(!output.contains(r"C:\Users\bob"));
|
||||
assert!(output.contains("[REDACTED_PATH]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_pii_api_keys() {
|
||||
let pipeline = BrainPipeline::new();
|
||||
let input = "Using key sk-abc123xyz and ghp_TokenValue123";
|
||||
let output = pipeline.strip_pii(input);
|
||||
assert!(!output.contains("sk-abc123xyz"));
|
||||
assert!(!output.contains("ghp_TokenValue123"));
|
||||
assert!(output.contains("[REDACTED_KEY]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_pii_bearer_token() {
|
||||
let pipeline = BrainPipeline::new();
|
||||
let input = "Header: Bearer eyJhbGciOiJSUzI1NiJ9.payload.sig";
|
||||
let output = pipeline.strip_pii(input);
|
||||
assert!(!output.contains("eyJhbGciOiJSUzI1NiJ9"));
|
||||
assert!(output.contains("[REDACTED_TOKEN]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_pii_ip_address() {
|
||||
let pipeline = BrainPipeline::new();
|
||||
let input = "Server at 192.168.1.100 is running";
|
||||
let output = pipeline.strip_pii(input);
|
||||
assert!(!output.contains("192.168.1.100"));
|
||||
assert!(output.contains("[REDACTED_IP]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_pii_email() {
|
||||
let pipeline = BrainPipeline::new();
|
||||
let input = "Contact user@example.com for help";
|
||||
let output = pipeline.strip_pii(input);
|
||||
assert!(!output.contains("user@example.com"));
|
||||
assert!(output.contains("[REDACTED_EMAIL]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_pii_ssh_key() {
|
||||
let pipeline = BrainPipeline::new();
|
||||
let input = "Key: ssh-rsa AAAAB3NzaC1yc2EAAA";
|
||||
let output = pipeline.strip_pii(input);
|
||||
assert!(!output.contains("ssh-rsa AAAA"));
|
||||
assert!(output.contains("[REDACTED_SSH_KEY]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_pii_jwt() {
|
||||
let pipeline = BrainPipeline::new();
|
||||
let input = "Token: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.abc123def456";
|
||||
let output = pipeline.strip_pii(input);
|
||||
assert!(!output.contains("eyJhbGciOiJIUzI1NiJ9"));
|
||||
assert!(output.contains("[REDACTED_JWT]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_pii_private_key() {
|
||||
let pipeline = BrainPipeline::new();
|
||||
let input = "-----BEGIN RSA PRIVATE KEY-----\nMIIEpA...\n-----END RSA PRIVATE KEY-----";
|
||||
let output = pipeline.strip_pii(input);
|
||||
assert!(!output.contains("-----BEGIN RSA PRIVATE KEY-----"));
|
||||
assert!(output.contains("[REDACTED_PRIVATE_KEY]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_pii_secret_assignment() {
|
||||
let pipeline = BrainPipeline::new();
|
||||
let input = "secret=abcdefghij1234567890abcdefghij12";
|
||||
let output = pipeline.strip_pii(input);
|
||||
assert!(output.contains("[REDACTED_SECRET]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_pii_localhost() {
|
||||
let pipeline = BrainPipeline::new();
|
||||
let input = "Connect to localhost for debugging";
|
||||
let output = pipeline.strip_pii(input);
|
||||
assert!(!output.contains("localhost"));
|
||||
assert!(output.contains("[REDACTED_HOST]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_contains_pii_detects_patterns() {
|
||||
let pipeline = BrainPipeline::new();
|
||||
assert!(pipeline.contains_pii("path /home/user/.ssh"));
|
||||
assert!(pipeline.contains_pii("token sk-abc123"));
|
||||
assert!(pipeline.contains_pii("email user@host.com"));
|
||||
assert!(pipeline.contains_pii("server at 10.0.0.1 port 80"));
|
||||
assert!(!pipeline.contains_pii("this is clean text with no PII"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_contains_pii_clean_after_strip() {
|
||||
let pipeline = BrainPipeline::new();
|
||||
let dirty = "Send to user@example.com at /home/alice/docs from 192.168.1.1";
|
||||
let clean = pipeline.strip_pii(dirty);
|
||||
assert!(!pipeline.contains_pii(&clean));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_witness_chain_integrity() {
|
||||
let mut chain = WitnessChain::new();
|
||||
chain.append("step_1");
|
||||
chain.append("step_2");
|
||||
chain.append("step_3");
|
||||
chain.append("step_4");
|
||||
chain.append("step_5");
|
||||
assert_eq!(chain.len(), 5);
|
||||
assert!(chain.verify());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_witness_chain_tamper() {
|
||||
let mut chain = WitnessChain::new();
|
||||
chain.append("step_1");
|
||||
chain.append("step_2");
|
||||
chain.append("step_3");
|
||||
// Tamper with the second entry's hash
|
||||
chain.entries[1].hash[0] ^= 0xFF;
|
||||
assert!(!chain.verify());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_witness_chain_finalize() {
|
||||
let mut chain = WitnessChain::new();
|
||||
chain.append("op1");
|
||||
chain.append("op2");
|
||||
let hex = chain.finalize();
|
||||
assert_eq!(hex.len(), 64); // 32 bytes hex-encoded
|
||||
assert_ne!(hex, "0".repeat(64));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_witness_chain_empty() {
|
||||
let chain = WitnessChain::new();
|
||||
assert!(chain.is_empty());
|
||||
assert_eq!(chain.len(), 0);
|
||||
assert!(chain.verify()); // empty chain is valid
|
||||
assert_eq!(chain.finalize(), "0".repeat(64));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_witness_hash_backward_compat() {
|
||||
let hash = witness_hash(&["op1", "op2"]);
|
||||
assert_eq!(hash.len(), 64);
|
||||
assert_ne!(hash, "0".repeat(64));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_witness_chain() {
|
||||
let chain = BrainPipeline::build_witness_chain(&["a", "b", "c"]);
|
||||
assert_eq!(chain.len(), 3);
|
||||
assert!(chain.verify());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pipeline_default() {
|
||||
let pipeline = BrainPipeline::default();
|
||||
// Default should work identically to new()
|
||||
let clean = pipeline.strip_pii("key sk-test123");
|
||||
assert!(clean.contains("[REDACTED_KEY]"));
|
||||
}
|
||||
}
|
||||
158
crates/mcp-brain/src/server.rs
Normal file
158
crates/mcp-brain/src/server.rs
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
//! MCP protocol server implementation (stdio JSON-RPC)
|
||||
|
||||
use crate::tools::McpBrainTools;
|
||||
use crate::types::*;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
/// MCP Brain Server
|
||||
pub struct McpBrainServer {
|
||||
tools: McpBrainTools,
|
||||
}
|
||||
|
||||
impl McpBrainServer {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
tools: McpBrainTools::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_backend_url(url: String) -> Self {
|
||||
Self {
|
||||
tools: McpBrainTools::with_backend_url(url),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the server on stdio
|
||||
pub async fn run_stdio(&self) -> Result<(), std::io::Error> {
|
||||
info!("Starting MCP Brain server on stdio");
|
||||
|
||||
let stdin = tokio::io::stdin();
|
||||
let mut stdout = tokio::io::stdout();
|
||||
let reader = BufReader::new(stdin);
|
||||
let mut lines = reader.lines();
|
||||
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
debug!("Received: {}", line);
|
||||
|
||||
let response = self.handle_message(&line).await;
|
||||
|
||||
if let Some(resp) = response {
|
||||
let resp_json = serde_json::to_string(&resp).unwrap_or_default();
|
||||
debug!("Sending: {}", resp_json);
|
||||
stdout.write_all(resp_json.as_bytes()).await?;
|
||||
stdout.write_all(b"\n").await?;
|
||||
stdout.flush().await?;
|
||||
}
|
||||
}
|
||||
|
||||
info!("MCP Brain server shutting down");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_message(&self, message: &str) -> Option<JsonRpcResponse> {
|
||||
let request: JsonRpcRequest = match serde_json::from_str(message) {
|
||||
Ok(req) => req,
|
||||
Err(e) => {
|
||||
error!("Failed to parse request: {}", e);
|
||||
return Some(JsonRpcResponse::error(
|
||||
serde_json::Value::Null,
|
||||
-32700,
|
||||
format!("Parse error: {}", e),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
Some(self.handle_request(&request).await)
|
||||
}
|
||||
|
||||
async fn handle_request(&self, request: &JsonRpcRequest) -> JsonRpcResponse {
|
||||
match request.method.as_str() {
|
||||
"initialize" => self.handle_initialize(request),
|
||||
"initialized" => JsonRpcResponse::success(request.id.clone(), serde_json::json!({})),
|
||||
"tools/list" => self.handle_tools_list(request),
|
||||
"tools/call" => self.handle_tools_call(request).await,
|
||||
"shutdown" => {
|
||||
info!("Received shutdown request");
|
||||
JsonRpcResponse::success(request.id.clone(), serde_json::json!({}))
|
||||
}
|
||||
_ => {
|
||||
warn!("Unknown method: {}", request.method);
|
||||
JsonRpcResponse::error(
|
||||
request.id.clone(),
|
||||
-32601,
|
||||
format!("Method not found: {}", request.method),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_initialize(&self, request: &JsonRpcRequest) -> JsonRpcResponse {
|
||||
info!("Handling initialize request");
|
||||
JsonRpcResponse::success(
|
||||
request.id.clone(),
|
||||
serde_json::json!({
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {
|
||||
"tools": { "listChanged": false }
|
||||
},
|
||||
"serverInfo": {
|
||||
"name": "mcp-brain",
|
||||
"version": env!("CARGO_PKG_VERSION")
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn handle_tools_list(&self, request: &JsonRpcRequest) -> JsonRpcResponse {
|
||||
info!("Handling tools/list request");
|
||||
let tools = McpBrainTools::list_tools();
|
||||
JsonRpcResponse::success(request.id.clone(), serde_json::json!({ "tools": tools }))
|
||||
}
|
||||
|
||||
async fn handle_tools_call(&self, request: &JsonRpcRequest) -> JsonRpcResponse {
|
||||
info!("Handling tools/call request");
|
||||
let tool_call: McpToolCall = match serde_json::from_value(request.params.clone()) {
|
||||
Ok(tc) => tc,
|
||||
Err(e) => {
|
||||
return JsonRpcResponse::error(
|
||||
request.id.clone(),
|
||||
-32602,
|
||||
format!("Invalid params: {}", e),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
match self.tools.call_tool(tool_call).await {
|
||||
Ok(result) => {
|
||||
let response_content = match result {
|
||||
McpToolResult::Success { content } => serde_json::json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": serde_json::to_string_pretty(&content).unwrap_or_default()
|
||||
}]
|
||||
}),
|
||||
McpToolResult::Error { error } => serde_json::json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": error
|
||||
}],
|
||||
"isError": true
|
||||
}),
|
||||
};
|
||||
JsonRpcResponse::success(request.id.clone(), response_content)
|
||||
}
|
||||
Err(e) => JsonRpcResponse::error(request.id.clone(), e.code(), e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for McpBrainServer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
784
crates/mcp-brain/src/tools.rs
Normal file
784
crates/mcp-brain/src/tools.rs
Normal file
|
|
@ -0,0 +1,784 @@
|
|||
//! MCP tools for the shared brain
|
||||
|
||||
use crate::client::BrainClient;
|
||||
use crate::embed::BrainEmbedder;
|
||||
use crate::pipeline::BrainPipeline;
|
||||
use crate::types::*;
|
||||
use tracing::info;
|
||||
|
||||
/// Error type for brain operations
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum BrainError {
|
||||
#[error("Client error: {0}")]
|
||||
Client(String),
|
||||
#[error("Invalid request: {0}")]
|
||||
InvalidRequest(String),
|
||||
#[error("Not found: {0}")]
|
||||
NotFound(String),
|
||||
#[error("Pipeline error: {0}")]
|
||||
Pipeline(String),
|
||||
}
|
||||
|
||||
impl BrainError {
|
||||
pub fn code(&self) -> i32 {
|
||||
match self {
|
||||
BrainError::Client(_) => -32001,
|
||||
BrainError::InvalidRequest(_) => -32602,
|
||||
BrainError::NotFound(_) => -32002,
|
||||
BrainError::Pipeline(_) => -32003,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Brain tools handler
|
||||
pub struct McpBrainTools {
|
||||
client: BrainClient,
|
||||
pipeline: BrainPipeline,
|
||||
embedder: std::sync::Mutex<BrainEmbedder>,
|
||||
}
|
||||
|
||||
impl McpBrainTools {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
client: BrainClient::new(),
|
||||
pipeline: BrainPipeline::new(),
|
||||
embedder: std::sync::Mutex::new(BrainEmbedder::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_backend_url(url: String) -> Self {
|
||||
Self {
|
||||
client: BrainClient::with_url(url),
|
||||
pipeline: BrainPipeline::new(),
|
||||
embedder: std::sync::Mutex::new(BrainEmbedder::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get list of all tools (core + Brainpedia + WASM)
|
||||
pub fn list_tools() -> Vec<McpTool> {
|
||||
vec![
|
||||
McpTool {
|
||||
name: "brain_share".to_string(),
|
||||
description: "Share a learning with the collective brain. Knowledge is PII-stripped, embedded, signed, and stored as an RVF cognitive container.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"category": { "type": "string", "enum": ["architecture", "pattern", "solution", "convention", "security", "performance", "tooling", "debug"], "description": "Knowledge category" },
|
||||
"title": { "type": "string", "description": "Short title (max 200 chars)" },
|
||||
"content": { "type": "string", "description": "Knowledge content (max 10000 chars)" },
|
||||
"tags": { "type": "array", "items": { "type": "string" }, "description": "Tags (max 10, each max 30 chars)" },
|
||||
"code_snippet": { "type": "string", "description": "Optional code snippet" }
|
||||
},
|
||||
"required": ["category", "title", "content"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "brain_search".to_string(),
|
||||
description: "Semantic search across shared knowledge. Returns ranked results with quality scores and drift warnings.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": { "type": "string", "description": "Search query" },
|
||||
"category": { "type": "string", "description": "Filter by category" },
|
||||
"tags": { "type": "string", "description": "Comma-separated tags to filter" },
|
||||
"limit": { "type": "integer", "description": "Max results (default 10)" },
|
||||
"min_quality": { "type": "number", "description": "Minimum quality score (0-1)" }
|
||||
},
|
||||
"required": ["query"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "brain_get".to_string(),
|
||||
description: "Retrieve a specific memory with full provenance including witness chain and quality history.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string", "description": "Memory ID (UUID)" }
|
||||
},
|
||||
"required": ["id"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "brain_vote".to_string(),
|
||||
description: "Vote on a memory's quality (Bayesian update). Affects ranking and contributor reputation.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string", "description": "Memory ID" },
|
||||
"direction": { "type": "string", "enum": ["up", "down"], "description": "Vote direction" }
|
||||
},
|
||||
"required": ["id", "direction"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "brain_transfer".to_string(),
|
||||
description: "Apply learned priors from one knowledge domain to another. Uses Meta Thompson Sampling with dampened transfer.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"source_domain": { "type": "string", "description": "Source knowledge domain" },
|
||||
"target_domain": { "type": "string", "description": "Target knowledge domain" }
|
||||
},
|
||||
"required": ["source_domain", "target_domain"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "brain_drift".to_string(),
|
||||
description: "Check if shared knowledge has drifted from expected distributions. Reports coefficient of variation and trend.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"domain": { "type": "string", "description": "Domain to check (optional, default: global)" },
|
||||
"since": { "type": "string", "description": "ISO timestamp to check from" }
|
||||
}
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "brain_partition".to_string(),
|
||||
description: "Get knowledge partitioned by mincut topology. Shows emergent knowledge clusters with coherence scores.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"domain": { "type": "string", "description": "Domain to partition" },
|
||||
"min_cluster_size": { "type": "integer", "description": "Minimum memories per cluster" }
|
||||
}
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "brain_list".to_string(),
|
||||
description: "List recent shared memories, optionally filtered by category and quality.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"category": { "type": "string", "description": "Filter by category" },
|
||||
"limit": { "type": "integer", "description": "Max results (default 20)" },
|
||||
"min_quality": { "type": "number", "description": "Minimum quality score" }
|
||||
}
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "brain_delete".to_string(),
|
||||
description: "Delete your own contribution. Only the original contributor can delete.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string", "description": "Memory ID to delete" }
|
||||
},
|
||||
"required": ["id"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "brain_status".to_string(),
|
||||
description: "Get system health: memory count, contributor count, graph topology, drift status, and quality metrics.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "brain_sync".to_string(),
|
||||
description: "Sync local MicroLoRA weights with the shared brain. Downloads consensus weights, applies locally, exports local deltas, submits to server for federated aggregation.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"direction": { "type": "string", "enum": ["pull", "push", "both"], "description": "Sync direction (default: both)" }
|
||||
}
|
||||
}),
|
||||
},
|
||||
// ── Brainpedia (ADR-062) ───────────────────────────────────
|
||||
McpTool {
|
||||
name: "brain_page_create".to_string(),
|
||||
description: "Create a new Brainpedia page (Draft). Requires reputation >= 0.5. Pages go through Draft → Canonical lifecycle with evidence gating.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"category": { "type": "string", "enum": ["architecture", "pattern", "solution", "convention", "security", "performance", "tooling", "debug"], "description": "Knowledge category" },
|
||||
"title": { "type": "string", "description": "Page title (max 200 chars)" },
|
||||
"content": { "type": "string", "description": "Page content (max 10000 chars)" },
|
||||
"tags": { "type": "array", "items": { "type": "string" }, "description": "Tags (max 10)" },
|
||||
"code_snippet": { "type": "string", "description": "Optional code snippet" },
|
||||
"evidence_links": { "type": "array", "description": "Initial evidence links" }
|
||||
},
|
||||
"required": ["category", "title", "content"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "brain_page_get".to_string(),
|
||||
description: "Get a Brainpedia page with its full delta log, evidence links, and promotion status.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string", "description": "Page ID (UUID)" }
|
||||
},
|
||||
"required": ["id"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "brain_page_delta".to_string(),
|
||||
description: "Submit a delta (correction, extension, or deprecation) to an existing Brainpedia page. Requires evidence links.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"page_id": { "type": "string", "description": "Page ID (UUID)" },
|
||||
"delta_type": { "type": "string", "enum": ["correction", "extension", "evidence", "deprecation"], "description": "Type of delta" },
|
||||
"content_diff": { "type": "object", "description": "Content changes" },
|
||||
"evidence_links": { "type": "array", "description": "Supporting evidence" }
|
||||
},
|
||||
"required": ["page_id", "delta_type", "content_diff"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "brain_page_deltas".to_string(),
|
||||
description: "List all deltas for a Brainpedia page, showing its modification history.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"page_id": { "type": "string", "description": "Page ID (UUID)" }
|
||||
},
|
||||
"required": ["page_id"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "brain_page_evidence".to_string(),
|
||||
description: "Add evidence to a Brainpedia page. Evidence types: test_pass, build_success, metric_improval, peer_review.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"page_id": { "type": "string", "description": "Page ID (UUID)" },
|
||||
"evidence": { "type": "object", "description": "Evidence link with type, description, and verification data" }
|
||||
},
|
||||
"required": ["page_id", "evidence"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "brain_page_promote".to_string(),
|
||||
description: "Promote a Draft page to Canonical. Requires: quality >= 0.7, observations >= 5, evidence >= 3 from >= 2 contributors.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"page_id": { "type": "string", "description": "Page ID (UUID)" }
|
||||
},
|
||||
"required": ["page_id"]
|
||||
}),
|
||||
},
|
||||
// ── WASM Executable Nodes (ADR-063) ────────────────────────
|
||||
McpTool {
|
||||
name: "brain_node_list".to_string(),
|
||||
description: "List all published (non-revoked) WASM executable nodes in the brain.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "brain_node_publish".to_string(),
|
||||
description: "Publish a new WASM executable node. V1 ABI requires: memory, malloc, feature_extract_dim, feature_extract exports. Includes conformance test vectors.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string", "description": "Node ID (e.g., 'my-feature-extractor')" },
|
||||
"name": { "type": "string", "description": "Human-readable name" },
|
||||
"version": { "type": "string", "description": "Semver version" },
|
||||
"dim": { "type": "integer", "description": "Output dimension (default 128)" },
|
||||
"exports": { "type": "array", "items": { "type": "string" }, "description": "WASM exports" },
|
||||
"interface": { "type": "object", "description": "Interface specification" },
|
||||
"conformance": { "type": "array", "description": "Conformance test vectors" },
|
||||
"wasm_bytes": { "type": "string", "description": "Base64-encoded WASM binary" },
|
||||
"signature": { "type": "string", "description": "Ed25519 signature (hex)" }
|
||||
},
|
||||
"required": ["id", "name", "version", "exports", "wasm_bytes", "signature"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "brain_node_get".to_string(),
|
||||
description: "Get WASM node metadata and conformance test vectors.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string", "description": "Node ID" }
|
||||
},
|
||||
"required": ["id"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "brain_node_wasm".to_string(),
|
||||
description: "Download WASM binary for a node. Returns base64-encoded bytes.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string", "description": "Node ID" }
|
||||
},
|
||||
"required": ["id"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "brain_node_revoke".to_string(),
|
||||
description: "Revoke a WASM node (original publisher only). Marks as revoked but retains bytes for forensic analysis.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string", "description": "Node ID to revoke" }
|
||||
},
|
||||
"required": ["id"]
|
||||
}),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Handle a tool call
|
||||
pub async fn call_tool(&self, call: McpToolCall) -> Result<McpToolResult, BrainError> {
|
||||
info!("Calling tool: {}", call.name);
|
||||
match call.name.as_str() {
|
||||
"brain_share" => self.brain_share(call.arguments).await,
|
||||
"brain_search" => self.brain_search(call.arguments).await,
|
||||
"brain_get" => self.brain_get(call.arguments).await,
|
||||
"brain_vote" => self.brain_vote(call.arguments).await,
|
||||
"brain_transfer" => self.brain_transfer(call.arguments).await,
|
||||
"brain_drift" => self.brain_drift(call.arguments).await,
|
||||
"brain_partition" => self.brain_partition(call.arguments).await,
|
||||
"brain_list" => self.brain_list(call.arguments).await,
|
||||
"brain_delete" => self.brain_delete(call.arguments).await,
|
||||
"brain_status" => self.brain_status(call.arguments).await,
|
||||
"brain_sync" => self.brain_sync(call.arguments).await,
|
||||
// Brainpedia (ADR-062)
|
||||
"brain_page_create" => self.brain_page_create(call.arguments).await,
|
||||
"brain_page_get" => self.brain_page_get(call.arguments).await,
|
||||
"brain_page_delta" => self.brain_page_delta(call.arguments).await,
|
||||
"brain_page_deltas" => self.brain_page_deltas(call.arguments).await,
|
||||
"brain_page_evidence" => self.brain_page_evidence(call.arguments).await,
|
||||
"brain_page_promote" => self.brain_page_promote(call.arguments).await,
|
||||
// WASM Executable Nodes (ADR-063)
|
||||
"brain_node_list" => self.brain_node_list(call.arguments).await,
|
||||
"brain_node_publish" => self.brain_node_publish(call.arguments).await,
|
||||
"brain_node_get" => self.brain_node_get(call.arguments).await,
|
||||
"brain_node_wasm" => self.brain_node_wasm(call.arguments).await,
|
||||
"brain_node_revoke" => self.brain_node_revoke(call.arguments).await,
|
||||
_ => Err(BrainError::InvalidRequest(format!("Unknown tool: {}", call.name))),
|
||||
}
|
||||
}
|
||||
|
||||
async fn brain_share(&self, args: serde_json::Value) -> Result<McpToolResult, BrainError> {
|
||||
let category = args.get("category").and_then(|v| v.as_str()).unwrap_or("pattern");
|
||||
let title = args.get("title").and_then(|v| v.as_str())
|
||||
.ok_or_else(|| BrainError::InvalidRequest("title required".into()))?;
|
||||
let content = args.get("content").and_then(|v| v.as_str())
|
||||
.ok_or_else(|| BrainError::InvalidRequest("content required".into()))?;
|
||||
let tags: Vec<String> = args.get("tags")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
||||
.unwrap_or_default();
|
||||
let code_snippet = args.get("code_snippet").and_then(|v| v.as_str()).map(String::from);
|
||||
|
||||
// PII strip all user-provided text
|
||||
let clean_title = self.pipeline.strip_pii(title);
|
||||
let clean_content = self.pipeline.strip_pii(content);
|
||||
let clean_tags: Vec<String> = tags.iter()
|
||||
.map(|t| self.pipeline.strip_pii(t))
|
||||
.collect();
|
||||
let clean_snippet = code_snippet.as_deref().map(|s| self.pipeline.strip_pii(s));
|
||||
|
||||
// Safety check: reject if PII still detected after stripping
|
||||
if self.pipeline.contains_pii(&clean_title) {
|
||||
return Err(BrainError::Pipeline("PII detected in title after stripping".into()));
|
||||
}
|
||||
if self.pipeline.contains_pii(&clean_content) {
|
||||
return Err(BrainError::Pipeline("PII detected in content after stripping".into()));
|
||||
}
|
||||
for tag in &clean_tags {
|
||||
if self.pipeline.contains_pii(tag) {
|
||||
return Err(BrainError::Pipeline("PII detected in tags after stripping".into()));
|
||||
}
|
||||
}
|
||||
if let Some(ref s) = clean_snippet {
|
||||
if self.pipeline.contains_pii(s) {
|
||||
return Err(BrainError::Pipeline("PII detected in code_snippet after stripping".into()));
|
||||
}
|
||||
}
|
||||
|
||||
// Generate embedding via structured hash + MicroLoRA
|
||||
let _embedding = if let Ok(mut emb) = self.embedder.lock() {
|
||||
emb.embed(&clean_content)
|
||||
} else {
|
||||
crate::embed::generate_embedding(&clean_content)
|
||||
};
|
||||
|
||||
// Build witness chain: pii_strip -> embed -> share
|
||||
let mut chain = crate::pipeline::WitnessChain::new();
|
||||
chain.append("pii_strip");
|
||||
chain.append("embed");
|
||||
chain.append("share");
|
||||
let _witness_hash = chain.finalize();
|
||||
|
||||
let result = self.client.share(
|
||||
category,
|
||||
&clean_title,
|
||||
&clean_content,
|
||||
&clean_tags,
|
||||
clean_snippet.as_deref(),
|
||||
).await.map_err(|e| BrainError::Client(e.to_string()))?;
|
||||
|
||||
Ok(McpToolResult::Success {
|
||||
content: serde_json::to_value(result).unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn brain_search(&self, args: serde_json::Value) -> Result<McpToolResult, BrainError> {
|
||||
let query = args.get("query").and_then(|v| v.as_str())
|
||||
.ok_or_else(|| BrainError::InvalidRequest("query required".into()))?;
|
||||
let category = args.get("category").and_then(|v| v.as_str());
|
||||
let tags = args.get("tags").and_then(|v| v.as_str());
|
||||
let limit = args.get("limit").and_then(|v| v.as_u64()).map(|v| v as usize);
|
||||
let min_quality = args.get("min_quality").and_then(|v| v.as_f64());
|
||||
|
||||
// Generate query embedding via structured hash + MicroLoRA
|
||||
let _query_embedding = if let Ok(mut emb) = self.embedder.lock() {
|
||||
emb.embed(query)
|
||||
} else {
|
||||
crate::embed::generate_embedding(query)
|
||||
};
|
||||
|
||||
let results = self.client.search(query, category, tags, limit, min_quality).await
|
||||
.map_err(|e| BrainError::Client(e.to_string()))?;
|
||||
|
||||
Ok(McpToolResult::Success {
|
||||
content: serde_json::to_value(results).unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn brain_get(&self, args: serde_json::Value) -> Result<McpToolResult, BrainError> {
|
||||
let id = args.get("id").and_then(|v| v.as_str())
|
||||
.ok_or_else(|| BrainError::InvalidRequest("id required".into()))?;
|
||||
|
||||
let memory = self.client.get(id).await
|
||||
.map_err(|e| BrainError::Client(e.to_string()))?;
|
||||
|
||||
Ok(McpToolResult::Success {
|
||||
content: serde_json::to_value(memory).unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn brain_vote(&self, args: serde_json::Value) -> Result<McpToolResult, BrainError> {
|
||||
let id = args.get("id").and_then(|v| v.as_str())
|
||||
.ok_or_else(|| BrainError::InvalidRequest("id required".into()))?;
|
||||
let direction = args.get("direction").and_then(|v| v.as_str())
|
||||
.ok_or_else(|| BrainError::InvalidRequest("direction required".into()))?;
|
||||
|
||||
let result = self.client.vote(id, direction).await
|
||||
.map_err(|e| BrainError::Client(e.to_string()))?;
|
||||
|
||||
Ok(McpToolResult::Success {
|
||||
content: serde_json::to_value(result).unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn brain_transfer(&self, args: serde_json::Value) -> Result<McpToolResult, BrainError> {
|
||||
let source = args.get("source_domain").and_then(|v| v.as_str())
|
||||
.ok_or_else(|| BrainError::InvalidRequest("source_domain required".into()))?;
|
||||
let target = args.get("target_domain").and_then(|v| v.as_str())
|
||||
.ok_or_else(|| BrainError::InvalidRequest("target_domain required".into()))?;
|
||||
|
||||
let result = self.client.transfer(source, target).await
|
||||
.map_err(|e| BrainError::Client(e.to_string()))?;
|
||||
|
||||
Ok(McpToolResult::Success {
|
||||
content: serde_json::to_value(result).unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn brain_drift(&self, args: serde_json::Value) -> Result<McpToolResult, BrainError> {
|
||||
let domain = args.get("domain").and_then(|v| v.as_str());
|
||||
let since = args.get("since").and_then(|v| v.as_str());
|
||||
|
||||
let report = self.client.drift(domain, since).await
|
||||
.map_err(|e| BrainError::Client(e.to_string()))?;
|
||||
|
||||
Ok(McpToolResult::Success {
|
||||
content: serde_json::to_value(report).unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn brain_partition(&self, args: serde_json::Value) -> Result<McpToolResult, BrainError> {
|
||||
let domain = args.get("domain").and_then(|v| v.as_str());
|
||||
let min_size = args.get("min_cluster_size").and_then(|v| v.as_u64()).map(|v| v as usize);
|
||||
|
||||
let result = self.client.partition(domain, min_size).await
|
||||
.map_err(|e| BrainError::Client(e.to_string()))?;
|
||||
|
||||
Ok(McpToolResult::Success {
|
||||
content: serde_json::to_value(result).unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn brain_list(&self, args: serde_json::Value) -> Result<McpToolResult, BrainError> {
|
||||
let category = args.get("category").and_then(|v| v.as_str());
|
||||
let limit = args.get("limit").and_then(|v| v.as_u64()).map(|v| v as usize);
|
||||
|
||||
let results = self.client.list(category, limit).await
|
||||
.map_err(|e| BrainError::Client(e.to_string()))?;
|
||||
|
||||
Ok(McpToolResult::Success {
|
||||
content: serde_json::to_value(results).unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn brain_delete(&self, args: serde_json::Value) -> Result<McpToolResult, BrainError> {
|
||||
let id = args.get("id").and_then(|v| v.as_str())
|
||||
.ok_or_else(|| BrainError::InvalidRequest("id required".into()))?;
|
||||
|
||||
self.client.delete(id).await
|
||||
.map_err(|e| BrainError::Client(e.to_string()))?;
|
||||
|
||||
Ok(McpToolResult::Success {
|
||||
content: serde_json::json!({"deleted": true, "id": id}),
|
||||
})
|
||||
}
|
||||
|
||||
async fn brain_status(&self, _args: serde_json::Value) -> Result<McpToolResult, BrainError> {
|
||||
let status = self.client.status().await
|
||||
.map_err(|e| BrainError::Client(e.to_string()))?;
|
||||
|
||||
Ok(McpToolResult::Success {
|
||||
content: serde_json::to_value(status).unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn brain_sync(&self, args: serde_json::Value) -> Result<McpToolResult, BrainError> {
|
||||
let direction = args.get("direction").and_then(|v| v.as_str()).unwrap_or("both");
|
||||
let mut pulled = false;
|
||||
let mut pushed = false;
|
||||
|
||||
// Pull: download consensus weights from server
|
||||
if direction == "pull" || direction == "both" {
|
||||
match self.client.lora_latest().await {
|
||||
Ok(Some(weights)) => {
|
||||
if let Ok(mut emb) = self.embedder.lock() {
|
||||
emb.import_consensus_weights(weights);
|
||||
pulled = true;
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
// No consensus weights available yet — that's fine
|
||||
}
|
||||
Err(e) => {
|
||||
info!("Failed to pull consensus weights: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Push: export local weights and submit to server
|
||||
if direction == "push" || direction == "both" {
|
||||
let local_weights = if let Ok(emb) = self.embedder.lock() {
|
||||
emb.export_local_weights()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(mut weights) = local_weights {
|
||||
weights.clip();
|
||||
if weights.validate().is_ok() {
|
||||
match self.client.lora_submit(&weights).await {
|
||||
Ok(_) => { pushed = true; }
|
||||
Err(e) => {
|
||||
info!("Failed to push local weights: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let embed_count = if let Ok(emb) = self.embedder.lock() {
|
||||
emb.embed_count()
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
Ok(McpToolResult::Success {
|
||||
content: serde_json::json!({
|
||||
"pulled": pulled,
|
||||
"pushed": pushed,
|
||||
"direction": direction,
|
||||
"local_embed_count": embed_count,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
// ── Brainpedia (ADR-062) ─────────────────────────────────────────
|
||||
|
||||
async fn brain_page_create(&self, args: serde_json::Value) -> Result<McpToolResult, BrainError> {
|
||||
let category = args.get("category").and_then(|v| v.as_str()).unwrap_or("pattern");
|
||||
let title = args.get("title").and_then(|v| v.as_str())
|
||||
.ok_or_else(|| BrainError::InvalidRequest("title required".into()))?;
|
||||
let content = args.get("content").and_then(|v| v.as_str())
|
||||
.ok_or_else(|| BrainError::InvalidRequest("content required".into()))?;
|
||||
let tags: Vec<String> = args.get("tags")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
||||
.unwrap_or_default();
|
||||
let code_snippet = args.get("code_snippet").and_then(|v| v.as_str());
|
||||
let evidence_links = args.get("evidence_links").cloned().unwrap_or(serde_json::json!([]));
|
||||
|
||||
let clean_title = self.pipeline.strip_pii(title);
|
||||
let clean_content = self.pipeline.strip_pii(content);
|
||||
let clean_tags: Vec<String> = tags.iter().map(|t| self.pipeline.strip_pii(t)).collect();
|
||||
|
||||
let embedding = if let Ok(mut emb) = self.embedder.lock() {
|
||||
emb.embed(&clean_content)
|
||||
} else {
|
||||
crate::embed::generate_embedding(&clean_content)
|
||||
};
|
||||
|
||||
let mut chain = crate::pipeline::WitnessChain::new();
|
||||
chain.append("pii_strip");
|
||||
chain.append("embed");
|
||||
chain.append("page_create");
|
||||
let witness_hash = chain.finalize();
|
||||
|
||||
let body = serde_json::json!({
|
||||
"category": category,
|
||||
"title": clean_title,
|
||||
"content": clean_content,
|
||||
"tags": clean_tags,
|
||||
"code_snippet": code_snippet,
|
||||
"embedding": embedding,
|
||||
"evidence_links": evidence_links,
|
||||
"witness_hash": hex::encode(witness_hash),
|
||||
});
|
||||
|
||||
let result = self.client.create_page(&body).await
|
||||
.map_err(|e| BrainError::Client(e.to_string()))?;
|
||||
|
||||
Ok(McpToolResult::Success { content: result })
|
||||
}
|
||||
|
||||
async fn brain_page_get(&self, args: serde_json::Value) -> Result<McpToolResult, BrainError> {
|
||||
let id = args.get("id").and_then(|v| v.as_str())
|
||||
.ok_or_else(|| BrainError::InvalidRequest("id required".into()))?;
|
||||
|
||||
let result = self.client.get_page(id).await
|
||||
.map_err(|e| BrainError::Client(e.to_string()))?;
|
||||
|
||||
Ok(McpToolResult::Success { content: result })
|
||||
}
|
||||
|
||||
async fn brain_page_delta(&self, args: serde_json::Value) -> Result<McpToolResult, BrainError> {
|
||||
let page_id = args.get("page_id").and_then(|v| v.as_str())
|
||||
.ok_or_else(|| BrainError::InvalidRequest("page_id required".into()))?;
|
||||
let delta_type = args.get("delta_type").and_then(|v| v.as_str())
|
||||
.ok_or_else(|| BrainError::InvalidRequest("delta_type required".into()))?;
|
||||
let content_diff = args.get("content_diff").cloned()
|
||||
.ok_or_else(|| BrainError::InvalidRequest("content_diff required".into()))?;
|
||||
let evidence_links = args.get("evidence_links").cloned().unwrap_or(serde_json::json!([]));
|
||||
|
||||
let mut chain = crate::pipeline::WitnessChain::new();
|
||||
chain.append("delta_submit");
|
||||
let witness_hash = chain.finalize();
|
||||
|
||||
let body = serde_json::json!({
|
||||
"delta_type": delta_type,
|
||||
"content_diff": content_diff,
|
||||
"evidence_links": evidence_links,
|
||||
"witness_hash": hex::encode(witness_hash),
|
||||
});
|
||||
|
||||
let result = self.client.submit_delta(page_id, &body).await
|
||||
.map_err(|e| BrainError::Client(e.to_string()))?;
|
||||
|
||||
Ok(McpToolResult::Success { content: result })
|
||||
}
|
||||
|
||||
async fn brain_page_deltas(&self, args: serde_json::Value) -> Result<McpToolResult, BrainError> {
|
||||
let page_id = args.get("page_id").and_then(|v| v.as_str())
|
||||
.ok_or_else(|| BrainError::InvalidRequest("page_id required".into()))?;
|
||||
|
||||
let result = self.client.list_deltas(page_id).await
|
||||
.map_err(|e| BrainError::Client(e.to_string()))?;
|
||||
|
||||
Ok(McpToolResult::Success { content: result })
|
||||
}
|
||||
|
||||
async fn brain_page_evidence(&self, args: serde_json::Value) -> Result<McpToolResult, BrainError> {
|
||||
let page_id = args.get("page_id").and_then(|v| v.as_str())
|
||||
.ok_or_else(|| BrainError::InvalidRequest("page_id required".into()))?;
|
||||
let evidence = args.get("evidence").cloned()
|
||||
.ok_or_else(|| BrainError::InvalidRequest("evidence required".into()))?;
|
||||
|
||||
let body = serde_json::json!({ "evidence": evidence });
|
||||
|
||||
let result = self.client.add_evidence(page_id, &body).await
|
||||
.map_err(|e| BrainError::Client(e.to_string()))?;
|
||||
|
||||
Ok(McpToolResult::Success { content: result })
|
||||
}
|
||||
|
||||
async fn brain_page_promote(&self, args: serde_json::Value) -> Result<McpToolResult, BrainError> {
|
||||
let page_id = args.get("page_id").and_then(|v| v.as_str())
|
||||
.ok_or_else(|| BrainError::InvalidRequest("page_id required".into()))?;
|
||||
|
||||
let result = self.client.promote_page(page_id).await
|
||||
.map_err(|e| BrainError::Client(e.to_string()))?;
|
||||
|
||||
Ok(McpToolResult::Success { content: result })
|
||||
}
|
||||
|
||||
// ── WASM Executable Nodes (ADR-063) ───────────────────────────────
|
||||
|
||||
async fn brain_node_list(&self, _args: serde_json::Value) -> Result<McpToolResult, BrainError> {
|
||||
let result = self.client.list_nodes().await
|
||||
.map_err(|e| BrainError::Client(e.to_string()))?;
|
||||
|
||||
Ok(McpToolResult::Success { content: result })
|
||||
}
|
||||
|
||||
async fn brain_node_publish(&self, args: serde_json::Value) -> Result<McpToolResult, BrainError> {
|
||||
let result = self.client.publish_node(&args).await
|
||||
.map_err(|e| BrainError::Client(e.to_string()))?;
|
||||
|
||||
Ok(McpToolResult::Success { content: result })
|
||||
}
|
||||
|
||||
async fn brain_node_get(&self, args: serde_json::Value) -> Result<McpToolResult, BrainError> {
|
||||
let id = args.get("id").and_then(|v| v.as_str())
|
||||
.ok_or_else(|| BrainError::InvalidRequest("id required".into()))?;
|
||||
|
||||
let result = self.client.get_node(id).await
|
||||
.map_err(|e| BrainError::Client(e.to_string()))?;
|
||||
|
||||
Ok(McpToolResult::Success { content: result })
|
||||
}
|
||||
|
||||
async fn brain_node_wasm(&self, args: serde_json::Value) -> Result<McpToolResult, BrainError> {
|
||||
let id = args.get("id").and_then(|v| v.as_str())
|
||||
.ok_or_else(|| BrainError::InvalidRequest("id required".into()))?;
|
||||
|
||||
let bytes = self.client.get_node_wasm(id).await
|
||||
.map_err(|e| BrainError::Client(e.to_string()))?;
|
||||
|
||||
let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes);
|
||||
|
||||
Ok(McpToolResult::Success {
|
||||
content: serde_json::json!({
|
||||
"id": id,
|
||||
"wasm_bytes_b64": b64,
|
||||
"size_bytes": bytes.len(),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
async fn brain_node_revoke(&self, args: serde_json::Value) -> Result<McpToolResult, BrainError> {
|
||||
let id = args.get("id").and_then(|v| v.as_str())
|
||||
.ok_or_else(|| BrainError::InvalidRequest("id required".into()))?;
|
||||
|
||||
self.client.revoke_node(id).await
|
||||
.map_err(|e| BrainError::Client(e.to_string()))?;
|
||||
|
||||
Ok(McpToolResult::Success {
|
||||
content: serde_json::json!({"revoked": true, "id": id}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for McpBrainTools {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
181
crates/mcp-brain/src/types.rs
Normal file
181
crates/mcp-brain/src/types.rs
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
//! Types for MCP Brain server
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Brain memory categories
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BrainCategory {
|
||||
Architecture,
|
||||
Pattern,
|
||||
Solution,
|
||||
Convention,
|
||||
Security,
|
||||
Performance,
|
||||
Tooling,
|
||||
Debug,
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
/// Brain memory (local representation)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BrainMemory {
|
||||
pub id: String,
|
||||
pub category: BrainCategory,
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
pub tags: Vec<String>,
|
||||
pub code_snippet: Option<String>,
|
||||
pub quality_score: f64,
|
||||
pub contributor_id: String,
|
||||
pub partition_id: Option<u32>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// Drift report
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DriftReport {
|
||||
pub domain: Option<String>,
|
||||
pub coefficient_of_variation: f64,
|
||||
pub is_drifting: bool,
|
||||
pub delta_sparsity: f64,
|
||||
pub trend: String,
|
||||
pub suggested_action: String,
|
||||
pub window_size: usize,
|
||||
}
|
||||
|
||||
/// Partition result
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PartitionResult {
|
||||
pub clusters: Vec<KnowledgeCluster>,
|
||||
pub cut_value: f64,
|
||||
pub total_memories: usize,
|
||||
}
|
||||
|
||||
/// Knowledge cluster
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct KnowledgeCluster {
|
||||
pub id: u32,
|
||||
pub memory_ids: Vec<String>,
|
||||
pub dominant_category: BrainCategory,
|
||||
pub size: usize,
|
||||
pub coherence: f64,
|
||||
}
|
||||
|
||||
/// Status response
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StatusInfo {
|
||||
pub total_memories: usize,
|
||||
pub total_contributors: usize,
|
||||
pub graph_nodes: usize,
|
||||
pub graph_edges: usize,
|
||||
pub cluster_count: usize,
|
||||
pub avg_quality: f64,
|
||||
pub drift_status: String,
|
||||
}
|
||||
|
||||
/// Transfer result
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TransferResult {
|
||||
pub source_domain: String,
|
||||
pub target_domain: String,
|
||||
pub acceleration_factor: f64,
|
||||
pub transfer_success: bool,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Vote direction
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum VoteDirection {
|
||||
Up,
|
||||
Down,
|
||||
}
|
||||
|
||||
/// Quality score (Bayesian Beta distribution)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BetaParams {
|
||||
pub alpha: f64,
|
||||
pub beta: f64,
|
||||
}
|
||||
|
||||
// ============== JSON-RPC Protocol Types (cloned from mcp-gate) ==============
|
||||
|
||||
/// MCP Tool definition
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpTool {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
#[serde(rename = "inputSchema")]
|
||||
pub input_schema: serde_json::Value,
|
||||
}
|
||||
|
||||
/// MCP Tool call request
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpToolCall {
|
||||
pub name: String,
|
||||
pub arguments: serde_json::Value,
|
||||
}
|
||||
|
||||
/// MCP Tool result
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "lowercase")]
|
||||
pub enum McpToolResult {
|
||||
Success { content: serde_json::Value },
|
||||
Error { error: String },
|
||||
}
|
||||
|
||||
/// JSON-RPC request
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JsonRpcRequest {
|
||||
pub jsonrpc: String,
|
||||
pub id: serde_json::Value,
|
||||
pub method: String,
|
||||
#[serde(default)]
|
||||
pub params: serde_json::Value,
|
||||
}
|
||||
|
||||
/// JSON-RPC response
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JsonRpcResponse {
|
||||
pub jsonrpc: String,
|
||||
pub id: serde_json::Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub result: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<JsonRpcError>,
|
||||
}
|
||||
|
||||
/// JSON-RPC error
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JsonRpcError {
|
||||
pub code: i32,
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl JsonRpcResponse {
|
||||
pub fn success(id: serde_json::Value, result: serde_json::Value) -> Self {
|
||||
Self {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id,
|
||||
result: Some(result),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error(id: serde_json::Value, code: i32, message: String) -> Self {
|
||||
Self {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id,
|
||||
result: None,
|
||||
error: Some(JsonRpcError {
|
||||
code,
|
||||
message,
|
||||
data: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
# Multi-stage Dockerfile for ruvector-postgres extension
|
||||
# Builds the extension and creates a PostgreSQL image with it installed
|
||||
# v0.3.1: Fixes — Cypher self-reference, graph/RDF persistence, SONA dimension panic
|
||||
|
||||
# Build stage
|
||||
# Using nightly Rust to support edition2024 crates in the registry
|
||||
|
|
@ -101,8 +102,8 @@ COPY crates/rvf/rvf-types /workspace/crates/rvf/rvf-types/
|
|||
COPY crates/rvf/rvf-wire /workspace/crates/rvf/rvf-wire/
|
||||
COPY crates/rvf/rvf-crypto /workspace/crates/rvf/rvf-crypto/
|
||||
|
||||
# Use the workspace Cargo.lock to pin dependencies and avoid registry parsing issues
|
||||
COPY Cargo.lock /workspace/crates/ruvector-postgres/
|
||||
# Copy the workspace Cargo.lock to pin dependency versions
|
||||
COPY Cargo.lock /workspace/Cargo.lock
|
||||
|
||||
WORKDIR /workspace/crates/ruvector-postgres
|
||||
|
||||
|
|
@ -117,7 +118,8 @@ RUN cargo fetch
|
|||
ENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=git
|
||||
|
||||
# Build the extension with all features including v0.3 modules
|
||||
RUN cargo pgrx package --features "pg17 index-all quant-all embeddings gated-transformer analytics-complete attention-extended sona-learning domain-expansion"
|
||||
# graph-complete includes: graph, hyperbolic, sparse
|
||||
RUN cargo pgrx package --features "pg17 index-all quant-all graph-complete embeddings gated-transformer analytics-complete attention-extended sona-learning domain-expansion"
|
||||
|
||||
# Build the model downloader binary
|
||||
RUN cargo build --release --bin download-models --features "embeddings"
|
||||
|
|
@ -130,25 +132,31 @@ RUN mkdir -p /opt/ruvector/models && \
|
|||
echo "Model cache size: $(du -sh /opt/ruvector/models)" && \
|
||||
ls -la /opt/ruvector/models/
|
||||
|
||||
# Copy the pre-built SQL schema file (with sparse functions removed)
|
||||
# cargo pgrx schema doesn't work reliably in Docker, so we use the hand-crafted file
|
||||
RUN cp /workspace/crates/ruvector-postgres/sql/ruvector--0.1.0.sql /workspace/target/release/ruvector-pg17/usr/share/postgresql/17/extension/ruvector--0.1.0.sql && \
|
||||
echo "SQL schema copied with $(grep -c 'CREATE FUNCTION\|CREATE OR REPLACE FUNCTION' /workspace/target/release/ruvector-pg17/usr/share/postgresql/17/extension/ruvector--0.1.0.sql) functions"
|
||||
# Copy all SQL schema files (control file default_version=0.3.0 selects the right one)
|
||||
RUN for f in /workspace/crates/ruvector-postgres/sql/ruvector--*.sql; do \
|
||||
cp "$f" /workspace/target/release/ruvector-pg17/usr/share/postgresql/17/extension/ ; \
|
||||
done && \
|
||||
echo "SQL schemas copied:" && \
|
||||
ls -1 /workspace/target/release/ruvector-pg17/usr/share/postgresql/17/extension/ruvector--*.sql && \
|
||||
echo "v0.3.0 function count: $(grep -c 'CREATE FUNCTION\|CREATE OR REPLACE FUNCTION' /workspace/target/release/ruvector-pg17/usr/share/postgresql/17/extension/ruvector--0.3.0.sql)"
|
||||
|
||||
# Verify the extension files are complete
|
||||
RUN ls -la /workspace/target/release/ruvector-pg17/usr/share/postgresql/17/extension/ && \
|
||||
echo "=== First 20 lines of SQL ===" && \
|
||||
head -20 /workspace/target/release/ruvector-pg17/usr/share/postgresql/17/extension/ruvector--0.1.0.sql && \
|
||||
echo "=== CREATE FUNCTION count ===" && \
|
||||
grep -c "CREATE FUNCTION\|CREATE OR REPLACE FUNCTION" /workspace/target/release/ruvector-pg17/usr/share/postgresql/17/extension/ruvector--0.1.0.sql
|
||||
echo "=== Extension control ===" && \
|
||||
cat /workspace/target/release/ruvector-pg17/usr/share/postgresql/17/extension/ruvector.control
|
||||
|
||||
# Runtime stage
|
||||
FROM postgres:17-bookworm
|
||||
|
||||
# Labels
|
||||
LABEL maintainer="ruvector team"
|
||||
LABEL description="PostgreSQL with ruvector extension - high-performance vector similarity search with local embeddings"
|
||||
LABEL version="0.3.0"
|
||||
LABEL maintainer="ruvector team <info@ruv.io>"
|
||||
LABEL description="PostgreSQL with ruvector extension - high-performance vector database with 270+ SQL functions, Graph/Cypher/SPARQL, GNN, hybrid search, multi-tenancy, self-healing, SONA self-learning, and local embeddings"
|
||||
LABEL version="0.3.1"
|
||||
LABEL org.opencontainers.image.title="ruvector-postgres"
|
||||
LABEL org.opencontainers.image.version="0.3.1"
|
||||
LABEL org.opencontainers.image.vendor="ruv.io"
|
||||
LABEL org.opencontainers.image.source="https://github.com/ruvnet/ruvector"
|
||||
LABEL org.opencontainers.image.description="Drop-in pgvector replacement with SIMD, Flash Attention, GNN, Cypher, SPARQL, hybrid search, multi-tenancy, self-healing, and SONA"
|
||||
|
||||
# Set embedding model cache path - models are pre-downloaded during build
|
||||
# FASTEMBED_CACHE_DIR is the correct env var for fastembed-rs
|
||||
|
|
|
|||
54
crates/ruvector-postgres/Dockerfile.prebuilt
Normal file
54
crates/ruvector-postgres/Dockerfile.prebuilt
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# Slim Dockerfile for ruvector-postgres — uses pre-compiled extension artifacts
|
||||
# Build time: ~30 seconds (no Rust compilation)
|
||||
#
|
||||
# Pre-requisite: Run locally first:
|
||||
# cargo pgrx package -p ruvector-postgres --pg-config /usr/lib/postgresql/17/bin/pg_config \
|
||||
# --features "pg17,index-all,quant-all,graph-complete,gated-transformer,analytics-complete,attention-extended,sona-learning,domain-expansion"
|
||||
#
|
||||
# Then build:
|
||||
# docker build -t ruvnet/ruvector-postgres:0.3.1 -f crates/ruvector-postgres/Dockerfile.prebuilt .
|
||||
|
||||
FROM postgres:17-bookworm
|
||||
|
||||
# Labels
|
||||
LABEL maintainer="ruvector team <info@ruv.io>"
|
||||
LABEL description="PostgreSQL with ruvector extension — 270+ SQL functions, Graph/Cypher/SPARQL, GNN, hybrid search, multi-tenancy, self-healing, SONA self-learning"
|
||||
LABEL version="0.3.1"
|
||||
LABEL org.opencontainers.image.title="ruvector-postgres"
|
||||
LABEL org.opencontainers.image.version="0.3.1"
|
||||
LABEL org.opencontainers.image.vendor="ruv.io"
|
||||
LABEL org.opencontainers.image.source="https://github.com/ruvnet/ruvector"
|
||||
LABEL org.opencontainers.image.description="Drop-in pgvector replacement with SIMD, Flash Attention, GNN, Cypher, SPARQL, hybrid search, multi-tenancy, self-healing, and SONA"
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libssl3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy pre-built extension shared library
|
||||
COPY target/release/ruvector-pg17/usr/lib/postgresql/17/lib/* \
|
||||
/usr/lib/postgresql/17/lib/
|
||||
|
||||
# Copy extension control and SQL files
|
||||
COPY target/release/ruvector-pg17/usr/share/postgresql/17/extension/ruvector.control \
|
||||
/usr/share/postgresql/17/extension/
|
||||
COPY crates/ruvector-postgres/sql/ruvector--0.1.0.sql \
|
||||
crates/ruvector-postgres/sql/ruvector--0.3.0.sql \
|
||||
crates/ruvector-postgres/sql/ruvector--2.0.0.sql \
|
||||
crates/ruvector-postgres/sql/ruvector--2.0.0--0.3.0.sql \
|
||||
/usr/share/postgresql/17/extension/
|
||||
|
||||
# Copy initialization script
|
||||
COPY crates/ruvector-postgres/docker/init.sql /docker-entrypoint-initdb.d/01-init.sql
|
||||
|
||||
# Environment
|
||||
ENV POSTGRES_USER=ruvector
|
||||
ENV POSTGRES_PASSWORD=ruvector
|
||||
ENV POSTGRES_DB=ruvector
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD pg_isready -U $POSTGRES_USER -d $POSTGRES_DB || exit 1
|
||||
|
||||
EXPOSE 5432
|
||||
CMD ["postgres"]
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
# RuVector-Postgres Development & Testing Dockerfile
|
||||
# Multi-stage build with PostgreSQL version support (14-17)
|
||||
# Default: PostgreSQL 17 (latest with pgrx 0.12 support)
|
||||
# Note: PostgreSQL 18 requires pgrx 0.15.0+ (planned for future release)
|
||||
# v0.3.1: Fixes — Cypher self-reference, graph/RDF persistence, SONA dimension panic
|
||||
|
||||
ARG PG_VERSION=17
|
||||
ARG RUST_VERSION=1.85
|
||||
|
|
@ -145,15 +145,18 @@ COPY crates/ruvector-postgres/sql ./sql/
|
|||
COPY crates/ruvector-postgres/benches ./benches/
|
||||
|
||||
# Build the extension with all features including v0.3 modules
|
||||
# graph-complete includes: graph, hyperbolic, sparse
|
||||
RUN cargo pgrx package \
|
||||
--pg-config /usr/lib/postgresql/${PG_VERSION}/bin/pg_config \
|
||||
--features pg${PG_VERSION},graph-complete,gated-transformer,analytics-complete,attention-extended,sona-learning,domain-expansion
|
||||
--features pg${PG_VERSION},index-all,quant-all,graph-complete,gated-transformer,analytics-complete,attention-extended,sona-learning,domain-expansion
|
||||
|
||||
# pgrx generates .control and .so but not SQL - copy our hand-written SQL files
|
||||
# In a workspace, target/ is at the workspace root /build/target/, not per-crate
|
||||
RUN cp sql/ruvector--0.3.0.sql /build/target/release/ruvector-pg${PG_VERSION}/usr/share/postgresql/${PG_VERSION}/extension/ 2>/dev/null || true && \
|
||||
cp sql/ruvector--2.0.0.sql /build/target/release/ruvector-pg${PG_VERSION}/usr/share/postgresql/${PG_VERSION}/extension/ 2>/dev/null || true && \
|
||||
cp sql/ruvector--2.0.0--0.3.0.sql /build/target/release/ruvector-pg${PG_VERSION}/usr/share/postgresql/${PG_VERSION}/extension/ 2>/dev/null || true
|
||||
RUN for f in sql/ruvector--*.sql; do \
|
||||
cp "$f" /build/target/release/ruvector-pg${PG_VERSION}/usr/share/postgresql/${PG_VERSION}/extension/ 2>/dev/null || true; \
|
||||
done && \
|
||||
echo "SQL schemas copied:" && \
|
||||
ls -1 /build/target/release/ruvector-pg${PG_VERSION}/usr/share/postgresql/${PG_VERSION}/extension/ruvector--*.sql 2>/dev/null
|
||||
|
||||
# ============================================================================
|
||||
# Stage 4: Runtime (Production)
|
||||
|
|
@ -184,9 +187,9 @@ ENV PG_VERSION=${PG_VERSION}
|
|||
ENV POSTGRES_INITDB_ARGS="--data-checksums"
|
||||
|
||||
# Labels for version tracking
|
||||
LABEL org.opencontainers.image.title="RuVector PostgreSQL Extension v0.3"
|
||||
LABEL org.opencontainers.image.description="High-performance vector database extension for PostgreSQL with 143 SQL functions, Solver, Math, TDA, Extended Attention, Sona, and Domain Expansion"
|
||||
LABEL org.opencontainers.image.version="0.3.0"
|
||||
LABEL org.opencontainers.image.title="RuVector PostgreSQL Extension v0.3.1"
|
||||
LABEL org.opencontainers.image.description="High-performance vector database extension for PostgreSQL with 270+ SQL functions, Graph/Cypher/SPARQL, GNN, hybrid search, multi-tenancy, self-healing, SONA self-learning, Solver, Math, TDA, and Domain Expansion"
|
||||
LABEL org.opencontainers.image.version="0.3.1"
|
||||
LABEL org.opencontainers.image.vendor="ruv.io"
|
||||
LABEL org.opencontainers.image.source="https://github.com/ruvnet/ruvector"
|
||||
LABEL ruvector.pg.version="${PG_VERSION}"
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ version: '3.8'
|
|||
# Build arguments shared across services
|
||||
x-build-args: &build-args
|
||||
PG_VERSION: ${PG_VERSION:-17}
|
||||
RUST_VERSION: ${RUST_VERSION:-1.83}
|
||||
RUST_VERSION: ${RUST_VERSION:-1.85}
|
||||
|
||||
# Common environment for test containers
|
||||
x-test-env: &test-env
|
||||
|
|
@ -179,7 +179,7 @@ services:
|
|||
dockerfile: crates/ruvector-postgres/docker/Dockerfile
|
||||
args:
|
||||
PG_VERSION: 14
|
||||
RUST_VERSION: ${RUST_VERSION:-1.83}
|
||||
RUST_VERSION: ${RUST_VERSION:-1.85}
|
||||
container_name: ruvector-postgres-pg14
|
||||
ports:
|
||||
- "5414:5432"
|
||||
|
|
@ -205,7 +205,7 @@ services:
|
|||
dockerfile: crates/ruvector-postgres/docker/Dockerfile
|
||||
args:
|
||||
PG_VERSION: 15
|
||||
RUST_VERSION: ${RUST_VERSION:-1.83}
|
||||
RUST_VERSION: ${RUST_VERSION:-1.85}
|
||||
container_name: ruvector-postgres-pg15
|
||||
ports:
|
||||
- "5415:5432"
|
||||
|
|
@ -231,7 +231,7 @@ services:
|
|||
dockerfile: crates/ruvector-postgres/docker/Dockerfile
|
||||
args:
|
||||
PG_VERSION: 16
|
||||
RUST_VERSION: ${RUST_VERSION:-1.83}
|
||||
RUST_VERSION: ${RUST_VERSION:-1.85}
|
||||
container_name: ruvector-postgres-pg16
|
||||
ports:
|
||||
- "5416:5432"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
-- RuVector PostgreSQL Extension v0.3
|
||||
-- RuVector PostgreSQL Extension v0.3.1
|
||||
-- Version: 0.3.0
|
||||
-- High-performance vector similarity search with SIMD optimizations
|
||||
-- Features: 270+ SQL functions, Solver, Math, TDA, Extended Attention, Sona, Domain Expansion
|
||||
-- Features: 190 SQL functions — Solver, Math, TDA, Attention, GNN, Self-Healing,
|
||||
-- Multi-Tenancy, Hybrid Search, Graph/Cypher/SPARQL, Sona, Domain Expansion
|
||||
|
||||
-- Complain if script is sourced in psql, rather than via CREATE EXTENSION
|
||||
\echo Use "CREATE EXTENSION ruvector" to load this file. \quit
|
||||
|
|
@ -479,9 +480,36 @@ LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
|||
-- ============================================================================
|
||||
-- GNN (Graph Neural Network) Functions
|
||||
-- ============================================================================
|
||||
-- Note: GCN and GraphSAGE functions are auto-generated by pgrx with JsonB signature
|
||||
-- The functions ruvector_gcn_forward and ruvector_graphsage_forward use JsonB types
|
||||
-- and are defined in src/gnn/operators.rs with #[pg_extern] macro
|
||||
|
||||
-- GCN forward pass on node embeddings
|
||||
CREATE OR REPLACE FUNCTION ruvector_gcn_forward(embeddings_json jsonb, src integer[], dst integer[], weights real[] DEFAULT NULL, out_dim integer DEFAULT 0)
|
||||
RETURNS jsonb
|
||||
AS 'MODULE_PATHNAME', 'ruvector_gcn_forward_wrapper'
|
||||
LANGUAGE C IMMUTABLE PARALLEL SAFE;
|
||||
|
||||
-- Aggregate neighbor messages (sum, mean, max)
|
||||
CREATE OR REPLACE FUNCTION ruvector_gnn_aggregate(messages_json jsonb, method text)
|
||||
RETURNS real[]
|
||||
AS 'MODULE_PATHNAME', 'ruvector_gnn_aggregate_wrapper'
|
||||
LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
|
||||
-- Multi-hop message passing over graph
|
||||
CREATE OR REPLACE FUNCTION ruvector_message_pass(node_table text, edge_table text, embedding_col text, hops integer, layer_type text)
|
||||
RETURNS text
|
||||
AS 'MODULE_PATHNAME', 'ruvector_message_pass_wrapper'
|
||||
LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
|
||||
-- GraphSAGE forward pass with neighbor sampling
|
||||
CREATE OR REPLACE FUNCTION ruvector_graphsage_forward(embeddings_json jsonb, src integer[], dst integer[], out_dim integer, num_samples integer)
|
||||
RETURNS jsonb
|
||||
AS 'MODULE_PATHNAME', 'ruvector_graphsage_forward_wrapper'
|
||||
LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
|
||||
-- Batch GNN inference on multiple graphs
|
||||
CREATE OR REPLACE FUNCTION ruvector_gnn_batch_forward(embeddings_batch_json jsonb, edge_indices_batch integer[], graph_sizes integer[], layer_type text, out_dim integer)
|
||||
RETURNS jsonb
|
||||
AS 'MODULE_PATHNAME', 'ruvector_gnn_batch_forward_wrapper'
|
||||
LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
|
||||
-- ============================================================================
|
||||
-- Routing/Agent Functions (Tiny Dancer)
|
||||
|
|
@ -727,6 +755,264 @@ RETURNS boolean
|
|||
AS 'MODULE_PATHNAME', 'ruvector_sparql_update_wrapper'
|
||||
LANGUAGE C VOLATILE PARALLEL SAFE;
|
||||
|
||||
-- ============================================================================
|
||||
-- Self-Healing Functions (23 functions)
|
||||
-- ============================================================================
|
||||
|
||||
-- Get current health status
|
||||
CREATE OR REPLACE FUNCTION ruvector_health_status()
|
||||
RETURNS jsonb
|
||||
AS 'MODULE_PATHNAME', 'ruvector_health_status_wrapper'
|
||||
LANGUAGE C VOLATILE PARALLEL SAFE;
|
||||
|
||||
-- Check if system is healthy
|
||||
CREATE OR REPLACE FUNCTION ruvector_is_healthy()
|
||||
RETURNS boolean
|
||||
AS 'MODULE_PATHNAME', 'ruvector_is_healthy_wrapper'
|
||||
LANGUAGE C VOLATILE PARALLEL SAFE;
|
||||
|
||||
-- Get system metrics for problem detection
|
||||
CREATE OR REPLACE FUNCTION ruvector_system_metrics()
|
||||
RETURNS jsonb
|
||||
AS 'MODULE_PATHNAME', 'ruvector_system_metrics_wrapper'
|
||||
LANGUAGE C VOLATILE PARALLEL SAFE;
|
||||
|
||||
-- Get recent healing history
|
||||
CREATE OR REPLACE FUNCTION ruvector_healing_history(lim integer DEFAULT 20)
|
||||
RETURNS jsonb
|
||||
AS 'MODULE_PATHNAME', 'ruvector_healing_history_wrapper'
|
||||
LANGUAGE C VOLATILE PARALLEL SAFE;
|
||||
|
||||
-- Get healing history since timestamp
|
||||
CREATE OR REPLACE FUNCTION ruvector_healing_history_since(since_timestamp bigint)
|
||||
RETURNS jsonb
|
||||
AS 'MODULE_PATHNAME', 'ruvector_healing_history_since_wrapper'
|
||||
LANGUAGE C VOLATILE STRICT PARALLEL SAFE;
|
||||
|
||||
-- Get healing history for a strategy
|
||||
CREATE OR REPLACE FUNCTION ruvector_healing_history_for_strategy(strategy_name text, lim integer DEFAULT 20)
|
||||
RETURNS jsonb
|
||||
AS 'MODULE_PATHNAME', 'ruvector_healing_history_for_strategy_wrapper'
|
||||
LANGUAGE C VOLATILE PARALLEL SAFE;
|
||||
|
||||
-- Manually trigger healing for a problem type
|
||||
CREATE OR REPLACE FUNCTION ruvector_healing_trigger(problem_type text)
|
||||
RETURNS jsonb
|
||||
AS 'MODULE_PATHNAME', 'ruvector_healing_trigger_wrapper'
|
||||
LANGUAGE C VOLATILE STRICT PARALLEL SAFE;
|
||||
|
||||
-- Execute a specific healing strategy
|
||||
CREATE OR REPLACE FUNCTION ruvector_healing_execute(strategy_name text, problem_type text, dry_run boolean DEFAULT false)
|
||||
RETURNS jsonb
|
||||
AS 'MODULE_PATHNAME', 'ruvector_healing_execute_wrapper'
|
||||
LANGUAGE C VOLATILE PARALLEL SAFE;
|
||||
|
||||
-- Configure healing engine settings
|
||||
CREATE OR REPLACE FUNCTION ruvector_healing_configure(config_json jsonb)
|
||||
RETURNS jsonb
|
||||
AS 'MODULE_PATHNAME', 'ruvector_healing_configure_wrapper'
|
||||
LANGUAGE C VOLATILE STRICT PARALLEL SAFE;
|
||||
|
||||
-- Get current healing configuration
|
||||
CREATE OR REPLACE FUNCTION ruvector_healing_get_config()
|
||||
RETURNS jsonb
|
||||
AS 'MODULE_PATHNAME', 'ruvector_healing_get_config_wrapper'
|
||||
LANGUAGE C VOLATILE PARALLEL SAFE;
|
||||
|
||||
-- Enable or disable healing
|
||||
CREATE OR REPLACE FUNCTION ruvector_healing_enable(enabled boolean)
|
||||
RETURNS boolean
|
||||
AS 'MODULE_PATHNAME', 'ruvector_healing_enable_wrapper'
|
||||
LANGUAGE C VOLATILE STRICT PARALLEL SAFE;
|
||||
|
||||
-- List all healing strategies
|
||||
CREATE OR REPLACE FUNCTION ruvector_healing_strategies()
|
||||
RETURNS jsonb
|
||||
AS 'MODULE_PATHNAME', 'ruvector_healing_strategies_wrapper'
|
||||
LANGUAGE C VOLATILE PARALLEL SAFE;
|
||||
|
||||
-- Get effectiveness report
|
||||
CREATE OR REPLACE FUNCTION ruvector_healing_effectiveness()
|
||||
RETURNS jsonb
|
||||
AS 'MODULE_PATHNAME', 'ruvector_healing_effectiveness_wrapper'
|
||||
LANGUAGE C VOLATILE PARALLEL SAFE;
|
||||
|
||||
-- Get healing engine statistics
|
||||
CREATE OR REPLACE FUNCTION ruvector_healing_stats()
|
||||
RETURNS jsonb
|
||||
AS 'MODULE_PATHNAME', 'ruvector_healing_stats_wrapper'
|
||||
LANGUAGE C VOLATILE PARALLEL SAFE;
|
||||
|
||||
-- Get detection thresholds
|
||||
CREATE OR REPLACE FUNCTION ruvector_healing_thresholds()
|
||||
RETURNS jsonb
|
||||
AS 'MODULE_PATHNAME', 'ruvector_healing_thresholds_wrapper'
|
||||
LANGUAGE C VOLATILE PARALLEL SAFE;
|
||||
|
||||
-- Update detection thresholds
|
||||
CREATE OR REPLACE FUNCTION ruvector_healing_set_thresholds(thresholds_json jsonb)
|
||||
RETURNS jsonb
|
||||
AS 'MODULE_PATHNAME', 'ruvector_healing_set_thresholds_wrapper'
|
||||
LANGUAGE C VOLATILE STRICT PARALLEL SAFE;
|
||||
|
||||
-- List all supported problem types
|
||||
CREATE OR REPLACE FUNCTION ruvector_healing_problem_types()
|
||||
RETURNS jsonb
|
||||
AS 'MODULE_PATHNAME', 'ruvector_healing_problem_types_wrapper'
|
||||
LANGUAGE C VOLATILE PARALLEL SAFE;
|
||||
|
||||
-- ============================================================================
|
||||
-- Multi-Tenancy Functions (17 functions)
|
||||
-- ============================================================================
|
||||
|
||||
-- Create a new tenant
|
||||
CREATE OR REPLACE FUNCTION ruvector_tenant_create(tenant_id text, config jsonb DEFAULT NULL)
|
||||
RETURNS text
|
||||
AS 'MODULE_PATHNAME', 'ruvector_tenant_create_wrapper'
|
||||
LANGUAGE C VOLATILE PARALLEL SAFE;
|
||||
|
||||
-- Set current tenant context
|
||||
CREATE OR REPLACE FUNCTION ruvector_tenant_set(tenant_id text)
|
||||
RETURNS text
|
||||
AS 'MODULE_PATHNAME', 'ruvector_tenant_set_wrapper'
|
||||
LANGUAGE C VOLATILE STRICT PARALLEL SAFE;
|
||||
|
||||
-- Get tenant statistics
|
||||
CREATE OR REPLACE FUNCTION ruvector_tenant_stats(tenant_id text)
|
||||
RETURNS jsonb
|
||||
AS 'MODULE_PATHNAME', 'ruvector_tenant_stats_wrapper'
|
||||
LANGUAGE C VOLATILE STRICT PARALLEL SAFE;
|
||||
|
||||
-- Check tenant quota status
|
||||
CREATE OR REPLACE FUNCTION ruvector_tenant_quota_check(tenant_id text)
|
||||
RETURNS jsonb
|
||||
AS 'MODULE_PATHNAME', 'ruvector_tenant_quota_check_wrapper'
|
||||
LANGUAGE C VOLATILE STRICT PARALLEL SAFE;
|
||||
|
||||
-- Suspend a tenant
|
||||
CREATE OR REPLACE FUNCTION ruvector_tenant_suspend(tenant_id text)
|
||||
RETURNS text
|
||||
AS 'MODULE_PATHNAME', 'ruvector_tenant_suspend_wrapper'
|
||||
LANGUAGE C VOLATILE STRICT PARALLEL SAFE;
|
||||
|
||||
-- Resume a suspended tenant
|
||||
CREATE OR REPLACE FUNCTION ruvector_tenant_resume(tenant_id text)
|
||||
RETURNS text
|
||||
AS 'MODULE_PATHNAME', 'ruvector_tenant_resume_wrapper'
|
||||
LANGUAGE C VOLATILE STRICT PARALLEL SAFE;
|
||||
|
||||
-- Delete a tenant
|
||||
CREATE OR REPLACE FUNCTION ruvector_tenant_delete(tenant_id text, hard boolean DEFAULT false)
|
||||
RETURNS text
|
||||
AS 'MODULE_PATHNAME', 'ruvector_tenant_delete_wrapper'
|
||||
LANGUAGE C VOLATILE PARALLEL SAFE;
|
||||
|
||||
-- List all tenants
|
||||
CREATE OR REPLACE FUNCTION ruvector_tenants()
|
||||
RETURNS jsonb
|
||||
AS 'MODULE_PATHNAME', 'ruvector_tenants_wrapper'
|
||||
LANGUAGE C VOLATILE PARALLEL SAFE;
|
||||
|
||||
-- Enable tenant RLS on a table
|
||||
CREATE OR REPLACE FUNCTION ruvector_enable_tenant_rls(table_name text, tenant_column text DEFAULT 'tenant_id')
|
||||
RETURNS text
|
||||
AS 'MODULE_PATHNAME', 'ruvector_enable_tenant_rls_wrapper'
|
||||
LANGUAGE C VOLATILE PARALLEL SAFE;
|
||||
|
||||
-- Migrate tenant to new isolation level
|
||||
CREATE OR REPLACE FUNCTION ruvector_tenant_migrate(tenant_id text, target_level text)
|
||||
RETURNS jsonb
|
||||
AS 'MODULE_PATHNAME', 'ruvector_tenant_migrate_wrapper'
|
||||
LANGUAGE C VOLATILE STRICT PARALLEL SAFE;
|
||||
|
||||
-- Get tenant migration status
|
||||
CREATE OR REPLACE FUNCTION ruvector_tenant_migration_status(tenant_id text)
|
||||
RETURNS jsonb
|
||||
AS 'MODULE_PATHNAME', 'ruvector_tenant_migration_status_wrapper'
|
||||
LANGUAGE C VOLATILE STRICT PARALLEL SAFE;
|
||||
|
||||
-- Isolate tenant to dedicated resources
|
||||
CREATE OR REPLACE FUNCTION ruvector_tenant_isolate(tenant_id text)
|
||||
RETURNS jsonb
|
||||
AS 'MODULE_PATHNAME', 'ruvector_tenant_isolate_wrapper'
|
||||
LANGUAGE C VOLATILE STRICT PARALLEL SAFE;
|
||||
|
||||
-- Set promotion policy for auto isolation upgrades
|
||||
CREATE OR REPLACE FUNCTION ruvector_tenant_set_policy(policy_config jsonb)
|
||||
RETURNS text
|
||||
AS 'MODULE_PATHNAME', 'ruvector_tenant_set_policy_wrapper'
|
||||
LANGUAGE C VOLATILE STRICT PARALLEL SAFE;
|
||||
|
||||
-- Update tenant quota
|
||||
CREATE OR REPLACE FUNCTION ruvector_tenant_update_quota(tenant_id text, quota_config jsonb)
|
||||
RETURNS text
|
||||
AS 'MODULE_PATHNAME', 'ruvector_tenant_update_quota_wrapper'
|
||||
LANGUAGE C VOLATILE STRICT PARALLEL SAFE;
|
||||
|
||||
-- Generate RLS setup SQL for a table
|
||||
CREATE OR REPLACE FUNCTION ruvector_generate_rls_sql(table_name text, tenant_column text DEFAULT 'tenant_id')
|
||||
RETURNS text
|
||||
AS 'MODULE_PATHNAME', 'ruvector_generate_rls_sql_wrapper'
|
||||
LANGUAGE C VOLATILE PARALLEL SAFE;
|
||||
|
||||
-- Generate SQL to add tenant column
|
||||
CREATE OR REPLACE FUNCTION ruvector_generate_tenant_column_sql(table_name text, column_name text DEFAULT 'tenant_id', not_null boolean DEFAULT true, auto_default boolean DEFAULT true)
|
||||
RETURNS text
|
||||
AS 'MODULE_PATHNAME', 'ruvector_generate_tenant_column_sql_wrapper'
|
||||
LANGUAGE C VOLATILE PARALLEL SAFE;
|
||||
|
||||
-- Generate SQL to create ruvector roles
|
||||
CREATE OR REPLACE FUNCTION ruvector_generate_roles_sql()
|
||||
RETURNS text
|
||||
AS 'MODULE_PATHNAME', 'ruvector_generate_roles_sql_wrapper'
|
||||
LANGUAGE C VOLATILE PARALLEL SAFE;
|
||||
|
||||
-- ============================================================================
|
||||
-- Hybrid Search Functions (7 functions)
|
||||
-- ============================================================================
|
||||
|
||||
-- Register collection for hybrid search
|
||||
CREATE OR REPLACE FUNCTION ruvector_register_hybrid(collection text, vector_column text, fts_column text, text_column text)
|
||||
RETURNS jsonb
|
||||
AS 'MODULE_PATHNAME', 'ruvector_register_hybrid_wrapper'
|
||||
LANGUAGE C VOLATILE STRICT PARALLEL SAFE;
|
||||
|
||||
-- Update BM25 corpus statistics
|
||||
CREATE OR REPLACE FUNCTION ruvector_hybrid_update_stats(collection text)
|
||||
RETURNS jsonb
|
||||
AS 'MODULE_PATHNAME', 'ruvector_hybrid_update_stats_wrapper'
|
||||
LANGUAGE C VOLATILE STRICT PARALLEL SAFE;
|
||||
|
||||
-- Configure hybrid search settings
|
||||
CREATE OR REPLACE FUNCTION ruvector_hybrid_configure(collection text, config jsonb)
|
||||
RETURNS jsonb
|
||||
AS 'MODULE_PATHNAME', 'ruvector_hybrid_configure_wrapper'
|
||||
LANGUAGE C VOLATILE STRICT PARALLEL SAFE;
|
||||
|
||||
-- Perform hybrid search (BM25 + vector)
|
||||
CREATE OR REPLACE FUNCTION ruvector_hybrid_search(collection text, query_text text, query_vector real[], k integer, fusion text DEFAULT NULL, alpha real DEFAULT NULL)
|
||||
RETURNS jsonb
|
||||
AS 'MODULE_PATHNAME', 'ruvector_hybrid_search_wrapper'
|
||||
LANGUAGE C VOLATILE PARALLEL SAFE;
|
||||
|
||||
-- Get hybrid search statistics
|
||||
CREATE OR REPLACE FUNCTION ruvector_hybrid_stats(collection text)
|
||||
RETURNS jsonb
|
||||
AS 'MODULE_PATHNAME', 'ruvector_hybrid_stats_wrapper'
|
||||
LANGUAGE C VOLATILE STRICT PARALLEL SAFE;
|
||||
|
||||
-- Compute hybrid score from vector distance and keyword score
|
||||
CREATE OR REPLACE FUNCTION ruvector_hybrid_score(vector_distance real, keyword_score real, alpha real DEFAULT 0.5)
|
||||
RETURNS real
|
||||
AS 'MODULE_PATHNAME', 'ruvector_hybrid_score_wrapper'
|
||||
LANGUAGE C IMMUTABLE PARALLEL SAFE;
|
||||
|
||||
-- List all hybrid-enabled collections
|
||||
CREATE OR REPLACE FUNCTION ruvector_hybrid_list()
|
||||
RETURNS jsonb
|
||||
AS 'MODULE_PATHNAME', 'ruvector_hybrid_list_wrapper'
|
||||
LANGUAGE C VOLATILE PARALLEL SAFE;
|
||||
|
||||
-- ============================================================================
|
||||
-- Comments
|
||||
-- ============================================================================
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ use crate::graph::storage::GraphStore;
|
|||
use serde_json::{json, Value as JsonValue};
|
||||
use std::collections::HashMap;
|
||||
|
||||
// Direction is re-exported from ast::*
|
||||
|
||||
/// Execute a parsed Cypher query
|
||||
pub fn execute_cypher(
|
||||
graph: &GraphStore,
|
||||
|
|
@ -94,41 +96,183 @@ fn match_pattern(
|
|||
pattern: &Pattern,
|
||||
context: &mut ExecutionContext,
|
||||
) -> Result<(), String> {
|
||||
// Simple implementation: match nodes by label and properties
|
||||
// Collect the pattern as alternating nodes and relationships:
|
||||
// (a:Person)-[:KNOWS]->(b:Person) = [Node(a), Rel(KNOWS), Node(b)]
|
||||
let mut node_patterns: Vec<&NodePattern> = Vec::new();
|
||||
let mut rel_patterns: Vec<&RelationshipPattern> = Vec::new();
|
||||
|
||||
for element in &pattern.elements {
|
||||
match element {
|
||||
PatternElement::Node(node_pattern) => {
|
||||
match_node(graph, node_pattern, context)?;
|
||||
PatternElement::Node(np) => node_patterns.push(np),
|
||||
PatternElement::Relationship(rp) => rel_patterns.push(rp),
|
||||
}
|
||||
}
|
||||
|
||||
// Case 1: Single node pattern — find all matching nodes
|
||||
if rel_patterns.is_empty() {
|
||||
if let Some(np) = node_patterns.first() {
|
||||
let candidates = find_matching_nodes(graph, np);
|
||||
if candidates.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
PatternElement::Relationship(rel_pattern) => {
|
||||
match_relationship(graph, rel_pattern, context)?;
|
||||
// Create a binding row per matching node
|
||||
let mut rows: Vec<HashMap<String, Binding>> = Vec::new();
|
||||
for node in &candidates {
|
||||
let mut row = HashMap::new();
|
||||
if let Some(var) = &np.variable {
|
||||
row.insert(var.clone(), Binding::Node(node.id));
|
||||
}
|
||||
rows.push(row);
|
||||
}
|
||||
context.bindings = rows;
|
||||
return Ok(());
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Case 2: Pattern with relationships — traverse edges
|
||||
// For each relationship pattern, we need pairs of (source_node, target_node)
|
||||
// Pattern: (a)-[r:TYPE]->(b) means: find all edges of TYPE,
|
||||
// check source matches a's pattern and target matches b's pattern
|
||||
let mut result_rows: Vec<HashMap<String, Binding>> = Vec::new();
|
||||
|
||||
// We process node-rel-node triples
|
||||
for i in 0..rel_patterns.len() {
|
||||
let src_pattern = node_patterns.get(i);
|
||||
let dst_pattern = node_patterns.get(i + 1);
|
||||
let rel_pattern = rel_patterns[i];
|
||||
|
||||
// Get candidate edges by type
|
||||
let edges = if let Some(ref rel_type) = rel_pattern.rel_type {
|
||||
graph.edges.find_by_type(rel_type)
|
||||
} else {
|
||||
graph.edges.all_edges()
|
||||
};
|
||||
|
||||
for edge in &edges {
|
||||
let (src_id, dst_id) = match rel_pattern.direction {
|
||||
Direction::Outgoing | Direction::Both => (edge.source, edge.target),
|
||||
Direction::Incoming => (edge.target, edge.source),
|
||||
};
|
||||
|
||||
// Check source node matches pattern
|
||||
if let Some(sp) = src_pattern {
|
||||
if !node_matches_pattern(graph, src_id, sp) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Check target node matches pattern
|
||||
if let Some(dp) = dst_pattern {
|
||||
if !node_matches_pattern(graph, dst_id, dp) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Reject self-references when variables are different
|
||||
if let (Some(sp), Some(dp)) = (src_pattern, dst_pattern) {
|
||||
if let (Some(sv), Some(dv)) = (&sp.variable, &dp.variable) {
|
||||
if sv != dv && src_id == dst_id {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build binding row
|
||||
let mut row = HashMap::new();
|
||||
if let Some(sp) = src_pattern {
|
||||
if let Some(var) = &sp.variable {
|
||||
row.insert(var.clone(), Binding::Node(src_id));
|
||||
}
|
||||
}
|
||||
if let Some(dp) = dst_pattern {
|
||||
if let Some(var) = &dp.variable {
|
||||
row.insert(var.clone(), Binding::Node(dst_id));
|
||||
}
|
||||
}
|
||||
if let Some(var) = &rel_pattern.variable {
|
||||
row.insert(var.clone(), Binding::Edge(edge.id));
|
||||
}
|
||||
|
||||
result_rows.push(row);
|
||||
|
||||
// Also match the reverse direction for Both
|
||||
if rel_pattern.direction == Direction::Both && edge.source != edge.target {
|
||||
let mut rev_row = HashMap::new();
|
||||
if let Some(sp) = src_pattern {
|
||||
if let Some(var) = &sp.variable {
|
||||
rev_row.insert(var.clone(), Binding::Node(edge.target));
|
||||
}
|
||||
}
|
||||
if let Some(dp) = dst_pattern {
|
||||
if let Some(var) = &dp.variable {
|
||||
rev_row.insert(var.clone(), Binding::Node(edge.source));
|
||||
}
|
||||
}
|
||||
if let Some(var) = &rel_pattern.variable {
|
||||
rev_row.insert(var.clone(), Binding::Edge(edge.id));
|
||||
}
|
||||
if let (Some(sp), Some(dp)) = (src_pattern, dst_pattern) {
|
||||
if node_matches_pattern(graph, edge.target, sp)
|
||||
&& node_matches_pattern(graph, edge.source, dp)
|
||||
{
|
||||
result_rows.push(rev_row);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !result_rows.is_empty() {
|
||||
context.bindings = result_rows;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn match_node(
|
||||
/// Find all nodes matching a node pattern (labels + properties)
|
||||
fn find_matching_nodes(
|
||||
graph: &GraphStore,
|
||||
pattern: &NodePattern,
|
||||
context: &mut ExecutionContext,
|
||||
) -> Result<(), String> {
|
||||
// Find nodes matching labels and properties
|
||||
) -> Vec<crate::graph::storage::Node> {
|
||||
let candidates = if pattern.labels.is_empty() {
|
||||
graph.nodes.all_nodes()
|
||||
} else {
|
||||
// Find by first label
|
||||
graph.nodes.find_by_label(&pattern.labels[0])
|
||||
};
|
||||
|
||||
for node in candidates {
|
||||
// Check additional labels
|
||||
if !pattern.labels.iter().all(|l| node.has_label(l)) {
|
||||
continue;
|
||||
}
|
||||
candidates
|
||||
.into_iter()
|
||||
.filter(|node| {
|
||||
// Check all labels
|
||||
if !pattern.labels.iter().all(|l| node.has_label(l)) {
|
||||
return false;
|
||||
}
|
||||
// Check properties
|
||||
pattern.properties.iter().all(|(key, expr)| {
|
||||
if let Some(node_value) = node.get_property(key) {
|
||||
if let Expression::Literal(expected) = expr {
|
||||
node_value == expected
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Check if a specific node ID matches a node pattern
|
||||
fn node_matches_pattern(graph: &GraphStore, node_id: u64, pattern: &NodePattern) -> bool {
|
||||
if let Some(node) = graph.nodes.get(node_id) {
|
||||
// Check labels
|
||||
if !pattern.labels.iter().all(|l| node.has_label(l)) {
|
||||
return false;
|
||||
}
|
||||
// Check properties
|
||||
let matches_props = pattern.properties.iter().all(|(key, expr)| {
|
||||
pattern.properties.iter().all(|(key, expr)| {
|
||||
if let Some(node_value) = node.get_property(key) {
|
||||
if let Expression::Literal(expected) = expr {
|
||||
node_value == expected
|
||||
|
|
@ -138,27 +282,10 @@ fn match_node(
|
|||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
|
||||
if matches_props {
|
||||
if let Some(var) = &pattern.variable {
|
||||
context.bind(var, Binding::Node(node.id));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
})
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn match_relationship(
|
||||
_graph: &GraphStore,
|
||||
_pattern: &RelationshipPattern,
|
||||
_context: &mut ExecutionContext,
|
||||
) -> Result<(), String> {
|
||||
// Simplified relationship matching
|
||||
// Production code would traverse the graph based on relationship pattern
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn execute_create(
|
||||
|
|
@ -228,9 +355,6 @@ fn create_relationship(
|
|||
source_id: u64,
|
||||
context: &ExecutionContext,
|
||||
) -> Result<u64, String> {
|
||||
// Simplified: assumes target node is bound in context
|
||||
// Production code would handle more complex patterns
|
||||
|
||||
let mut properties = HashMap::new();
|
||||
|
||||
for (key, expr) in &pattern.properties {
|
||||
|
|
@ -243,8 +367,18 @@ fn create_relationship(
|
|||
.clone()
|
||||
.unwrap_or_else(|| "RELATED".to_string());
|
||||
|
||||
// For now, create a self-loop. Production code would get target from pattern
|
||||
let target_id = source_id;
|
||||
// Resolve target from the next node in the pattern (bound in context)
|
||||
// Look through bindings for any node binding that isn't the source
|
||||
let target_id = context
|
||||
.bindings
|
||||
.iter()
|
||||
.rev()
|
||||
.flat_map(|b| b.values())
|
||||
.find_map(|binding| match binding {
|
||||
Binding::Node(id) if *id != source_id => Some(*id),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or(source_id);
|
||||
|
||||
graph.add_edge(source_id, target_id, edge_type, properties)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// Graph operations module for ruvector-postgres
|
||||
//
|
||||
// Provides graph storage, traversal, Cypher query support, and SPARQL (W3C standard)
|
||||
// Graph and RDF data is persisted to PostgreSQL tables for durability across connections.
|
||||
|
||||
pub mod cypher;
|
||||
pub mod operators;
|
||||
|
|
@ -13,33 +14,271 @@ pub use storage::{Edge, EdgeStore, GraphStore, Node, NodeStore};
|
|||
pub use traversal::{bfs, dfs, shortest_path_dijkstra, PathResult};
|
||||
|
||||
use dashmap::DashMap;
|
||||
use pgrx::JsonB;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Global graph storage registry
|
||||
/// Global graph storage registry (in-memory cache, backed by PG tables)
|
||||
static GRAPH_REGISTRY: once_cell::sync::Lazy<DashMap<String, Arc<GraphStore>>> =
|
||||
once_cell::sync::Lazy::new(|| DashMap::new());
|
||||
|
||||
/// Get or create a graph by name
|
||||
/// Ensure persistence tables exist (idempotent)
|
||||
fn ensure_graph_tables() {
|
||||
use pgrx::prelude::*;
|
||||
Spi::run(
|
||||
"CREATE TABLE IF NOT EXISTS _ruvector_graphs (
|
||||
name TEXT PRIMARY KEY
|
||||
)",
|
||||
)
|
||||
.ok();
|
||||
Spi::run(
|
||||
"CREATE TABLE IF NOT EXISTS _ruvector_nodes (
|
||||
graph_name TEXT NOT NULL REFERENCES _ruvector_graphs(name) ON DELETE CASCADE,
|
||||
id BIGINT NOT NULL,
|
||||
labels TEXT[] NOT NULL DEFAULT '{}',
|
||||
properties JSONB NOT NULL DEFAULT '{}',
|
||||
PRIMARY KEY (graph_name, id)
|
||||
)",
|
||||
)
|
||||
.ok();
|
||||
Spi::run(
|
||||
"CREATE TABLE IF NOT EXISTS _ruvector_edges (
|
||||
graph_name TEXT NOT NULL REFERENCES _ruvector_graphs(name) ON DELETE CASCADE,
|
||||
id BIGINT NOT NULL,
|
||||
source BIGINT NOT NULL,
|
||||
target BIGINT NOT NULL,
|
||||
edge_type TEXT NOT NULL,
|
||||
properties JSONB NOT NULL DEFAULT '{}',
|
||||
PRIMARY KEY (graph_name, id)
|
||||
)",
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
|
||||
/// Load a graph from PostgreSQL tables into the in-memory cache
|
||||
fn load_graph_from_tables(name: &str) -> Option<Arc<GraphStore>> {
|
||||
use pgrx::prelude::*;
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
// Check if graph exists in tables
|
||||
let exists = Spi::get_one_with_args::<bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM _ruvector_graphs WHERE name = $1)",
|
||||
vec![(PgBuiltInOids::TEXTOID.oid(), name.into_datum())],
|
||||
)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or(false);
|
||||
|
||||
if !exists {
|
||||
return None;
|
||||
}
|
||||
|
||||
let graph = Arc::new(GraphStore::new());
|
||||
|
||||
// Load nodes
|
||||
let _ = Spi::connect(|client| {
|
||||
let tup_table = client.select(
|
||||
"SELECT id, labels, properties FROM _ruvector_nodes WHERE graph_name = $1 ORDER BY id",
|
||||
None,
|
||||
Some(vec![(PgBuiltInOids::TEXTOID.oid(), name.into_datum())]),
|
||||
)?;
|
||||
|
||||
for row in tup_table {
|
||||
let id: i64 = row.get_by_name::<i64, _>("id")?.unwrap_or(0);
|
||||
let labels: Vec<String> = row
|
||||
.get_by_name::<Vec<String>, _>("labels")?
|
||||
.unwrap_or_default();
|
||||
let props_json: JsonB = row
|
||||
.get_by_name::<JsonB, _>("properties")?
|
||||
.unwrap_or(JsonB(serde_json::json!({})));
|
||||
|
||||
let props: HashMap<String, JsonValue> =
|
||||
if let JsonValue::Object(map) = props_json.0 {
|
||||
map.into_iter().collect()
|
||||
} else {
|
||||
HashMap::new()
|
||||
};
|
||||
|
||||
let mut node = Node::new(id as u64);
|
||||
node.labels = labels;
|
||||
node.properties = props;
|
||||
graph.nodes.insert(node);
|
||||
|
||||
// Advance the ID counter past loaded IDs
|
||||
while graph.nodes.next_id() <= id as u64 {
|
||||
// next_id auto-increments
|
||||
}
|
||||
}
|
||||
Ok::<_, spi::Error>(())
|
||||
});
|
||||
|
||||
// Load edges
|
||||
let _ = Spi::connect(|client| {
|
||||
let tup_table = client.select(
|
||||
"SELECT id, source, target, edge_type, properties FROM _ruvector_edges WHERE graph_name = $1 ORDER BY id",
|
||||
None,
|
||||
Some(vec![(PgBuiltInOids::TEXTOID.oid(), name.into_datum())]),
|
||||
)?;
|
||||
|
||||
for row in tup_table {
|
||||
let id: i64 = row.get_by_name::<i64, _>("id")?.unwrap_or(0);
|
||||
let source: i64 = row.get_by_name::<i64, _>("source")?.unwrap_or(0);
|
||||
let target: i64 = row.get_by_name::<i64, _>("target")?.unwrap_or(0);
|
||||
let edge_type: String = row
|
||||
.get_by_name::<String, _>("edge_type")?
|
||||
.unwrap_or_default();
|
||||
let props_json: JsonB = row
|
||||
.get_by_name::<JsonB, _>("properties")?
|
||||
.unwrap_or(JsonB(serde_json::json!({})));
|
||||
|
||||
let props: HashMap<String, JsonValue> =
|
||||
if let JsonValue::Object(map) = props_json.0 {
|
||||
map.into_iter().collect()
|
||||
} else {
|
||||
HashMap::new()
|
||||
};
|
||||
|
||||
let mut edge = Edge::new(id as u64, source as u64, target as u64, edge_type);
|
||||
edge.properties = props;
|
||||
graph.edges.insert(edge);
|
||||
|
||||
while graph.edges.next_id() <= id as u64 {}
|
||||
}
|
||||
Ok::<_, spi::Error>(())
|
||||
});
|
||||
|
||||
GRAPH_REGISTRY.insert(name.to_string(), graph.clone());
|
||||
Some(graph)
|
||||
}
|
||||
|
||||
/// Persist a graph entry to the backing table
|
||||
fn persist_graph_name(name: &str) {
|
||||
use pgrx::prelude::*;
|
||||
Spi::run_with_args(
|
||||
"INSERT INTO _ruvector_graphs (name) VALUES ($1) ON CONFLICT DO NOTHING",
|
||||
Some(vec![(PgBuiltInOids::TEXTOID.oid(), name.into_datum())]),
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
|
||||
/// Persist a node to the backing table
|
||||
pub fn persist_node(graph_name: &str, node: &Node) {
|
||||
use pgrx::prelude::*;
|
||||
let props = JsonB(serde_json::to_value(&node.properties).unwrap_or_default());
|
||||
Spi::run_with_args(
|
||||
"INSERT INTO _ruvector_nodes (graph_name, id, labels, properties)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (graph_name, id) DO UPDATE SET labels = $3, properties = $4",
|
||||
Some(vec![
|
||||
(PgBuiltInOids::TEXTOID.oid(), graph_name.into_datum()),
|
||||
(PgBuiltInOids::INT8OID.oid(), (node.id as i64).into_datum()),
|
||||
(
|
||||
PgBuiltInOids::TEXTARRAYOID.oid(),
|
||||
node.labels.clone().into_datum(),
|
||||
),
|
||||
(PgBuiltInOids::JSONBOID.oid(), props.into_datum()),
|
||||
]),
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
|
||||
/// Persist an edge to the backing table
|
||||
pub fn persist_edge(graph_name: &str, edge: &Edge) {
|
||||
use pgrx::prelude::*;
|
||||
let props = JsonB(serde_json::to_value(&edge.properties).unwrap_or_default());
|
||||
Spi::run_with_args(
|
||||
"INSERT INTO _ruvector_edges (graph_name, id, source, target, edge_type, properties)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (graph_name, id) DO UPDATE SET source = $3, target = $4, edge_type = $5, properties = $6",
|
||||
Some(vec![
|
||||
(PgBuiltInOids::TEXTOID.oid(), graph_name.into_datum()),
|
||||
(PgBuiltInOids::INT8OID.oid(), (edge.id as i64).into_datum()),
|
||||
(
|
||||
PgBuiltInOids::INT8OID.oid(),
|
||||
(edge.source as i64).into_datum(),
|
||||
),
|
||||
(
|
||||
PgBuiltInOids::INT8OID.oid(),
|
||||
(edge.target as i64).into_datum(),
|
||||
),
|
||||
(
|
||||
PgBuiltInOids::TEXTOID.oid(),
|
||||
edge.edge_type.clone().into_datum(),
|
||||
),
|
||||
(PgBuiltInOids::JSONBOID.oid(), props.into_datum()),
|
||||
]),
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
|
||||
/// Get or create a graph by name (with persistence)
|
||||
pub fn get_or_create_graph(name: &str) -> Arc<GraphStore> {
|
||||
if let Some(g) = GRAPH_REGISTRY.get(name) {
|
||||
return g.clone();
|
||||
}
|
||||
|
||||
// Try loading from tables
|
||||
ensure_graph_tables();
|
||||
if let Some(g) = load_graph_from_tables(name) {
|
||||
return g;
|
||||
}
|
||||
|
||||
// Create new
|
||||
persist_graph_name(name);
|
||||
GRAPH_REGISTRY
|
||||
.entry(name.to_string())
|
||||
.or_insert_with(|| Arc::new(GraphStore::new()))
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Get an existing graph by name
|
||||
/// Get an existing graph by name (checks tables if not in cache)
|
||||
pub fn get_graph(name: &str) -> Option<Arc<GraphStore>> {
|
||||
GRAPH_REGISTRY.get(name).map(|g| g.clone())
|
||||
if let Some(g) = GRAPH_REGISTRY.get(name) {
|
||||
return Some(g.clone());
|
||||
}
|
||||
|
||||
// Try loading from persistent storage
|
||||
ensure_graph_tables();
|
||||
load_graph_from_tables(name)
|
||||
}
|
||||
|
||||
/// Delete a graph by name
|
||||
/// Delete a graph by name (from cache and tables)
|
||||
pub fn delete_graph(name: &str) -> bool {
|
||||
GRAPH_REGISTRY.remove(name).is_some()
|
||||
use pgrx::prelude::*;
|
||||
GRAPH_REGISTRY.remove(name);
|
||||
// CASCADE deletes nodes and edges
|
||||
Spi::run_with_args(
|
||||
"DELETE FROM _ruvector_graphs WHERE name = $1",
|
||||
Some(vec![(PgBuiltInOids::TEXTOID.oid(), name.into_datum())]),
|
||||
)
|
||||
.ok();
|
||||
true
|
||||
}
|
||||
|
||||
/// List all graph names
|
||||
/// List all graph names (from persistent storage)
|
||||
pub fn list_graphs() -> Vec<String> {
|
||||
GRAPH_REGISTRY.iter().map(|e| e.key().clone()).collect()
|
||||
use pgrx::prelude::*;
|
||||
ensure_graph_tables();
|
||||
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
let _ = Spi::connect(|client| {
|
||||
let tup_table = client.select("SELECT name FROM _ruvector_graphs ORDER BY name", None, None)?;
|
||||
for row in tup_table {
|
||||
if let Some(name) = row.get_by_name::<String, _>("name")? {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
Ok::<_, spi::Error>(())
|
||||
});
|
||||
|
||||
// Also include any in-memory-only graphs
|
||||
for entry in GRAPH_REGISTRY.iter() {
|
||||
if !names.contains(entry.key()) {
|
||||
names.push(entry.key().clone());
|
||||
}
|
||||
}
|
||||
|
||||
names
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -47,17 +286,16 @@ mod tests {
|
|||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_graph_registry() {
|
||||
let graph1 = get_or_create_graph("test_graph");
|
||||
let graph2 = get_graph("test_graph");
|
||||
fn test_graph_registry_in_memory() {
|
||||
// Pure in-memory test (no PG context)
|
||||
let graph = Arc::new(GraphStore::new());
|
||||
GRAPH_REGISTRY.insert("unit_test_graph".to_string(), graph.clone());
|
||||
|
||||
assert!(graph2.is_some());
|
||||
assert!(Arc::ptr_eq(&graph1, &graph2.unwrap()));
|
||||
let g2 = GRAPH_REGISTRY.get("unit_test_graph").map(|g| g.clone());
|
||||
assert!(g2.is_some());
|
||||
assert!(Arc::ptr_eq(&graph, &g2.unwrap()));
|
||||
|
||||
let graphs = list_graphs();
|
||||
assert!(graphs.contains(&"test_graph".to_string()));
|
||||
|
||||
assert!(delete_graph("test_graph"));
|
||||
assert!(get_graph("test_graph").is_none());
|
||||
GRAPH_REGISTRY.remove("unit_test_graph");
|
||||
assert!(GRAPH_REGISTRY.get("unit_test_graph").is_none());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -156,6 +156,11 @@ fn ruvector_add_node(
|
|||
|
||||
let node_id = graph.add_node(labels, props);
|
||||
|
||||
// Persist to backing table
|
||||
if let Some(node) = graph.nodes.get(node_id) {
|
||||
super::persist_node(graph_name, &node);
|
||||
}
|
||||
|
||||
Ok(node_id as i64)
|
||||
}
|
||||
|
||||
|
|
@ -189,6 +194,11 @@ fn ruvector_add_edge(
|
|||
props,
|
||||
)?;
|
||||
|
||||
// Persist to backing table
|
||||
if let Some(edge) = graph.edges.get(edge_id) {
|
||||
super::persist_edge(graph_name, &edge);
|
||||
}
|
||||
|
||||
Ok(edge_id as i64)
|
||||
}
|
||||
|
||||
|
|
@ -407,7 +417,10 @@ fn ruvector_insert_triple(
|
|||
let store = get_or_create_store(store_name);
|
||||
|
||||
let triple = Triple::from_strings(subject, predicate, object);
|
||||
let id = store.insert(triple);
|
||||
let id = store.insert(triple.clone());
|
||||
|
||||
// Persist to backing table
|
||||
super::sparql::persist_triple(store_name, id, &triple, None);
|
||||
|
||||
Ok(id as i64)
|
||||
}
|
||||
|
|
@ -435,7 +448,10 @@ fn ruvector_insert_triple_graph(
|
|||
let store = get_or_create_store(store_name);
|
||||
|
||||
let triple = Triple::from_strings(subject, predicate, object);
|
||||
let id = store.insert_into_graph(triple, Some(graph));
|
||||
let id = store.insert_into_graph(triple.clone(), Some(graph));
|
||||
|
||||
// Persist to backing table
|
||||
super::sparql::persist_triple(store_name, id, &triple, Some(graph));
|
||||
|
||||
Ok(id as i64)
|
||||
}
|
||||
|
|
@ -479,7 +495,8 @@ fn ruvector_load_ntriples(store_name: &str, ntriples: &str) -> Result<i64, Strin
|
|||
};
|
||||
|
||||
let triple = Triple::from_strings(subject, predicate, object);
|
||||
store.insert(triple);
|
||||
let id = store.insert(triple.clone());
|
||||
super::sparql::persist_triple(store_name, id, &triple, None);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,34 +39,172 @@ use dashmap::DashMap;
|
|||
use once_cell::sync::Lazy;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Global RDF triple store registry
|
||||
/// Global RDF triple store registry (in-memory cache, backed by PG tables)
|
||||
static TRIPLE_STORE_REGISTRY: Lazy<DashMap<String, Arc<TripleStore>>> =
|
||||
Lazy::new(|| DashMap::new());
|
||||
|
||||
/// Get or create a triple store by name
|
||||
/// Ensure RDF persistence tables exist (idempotent)
|
||||
fn ensure_rdf_tables() {
|
||||
use pgrx::prelude::*;
|
||||
Spi::run(
|
||||
"CREATE TABLE IF NOT EXISTS _ruvector_rdf_stores (
|
||||
name TEXT PRIMARY KEY
|
||||
)",
|
||||
)
|
||||
.ok();
|
||||
Spi::run(
|
||||
"CREATE TABLE IF NOT EXISTS _ruvector_triples (
|
||||
store_name TEXT NOT NULL REFERENCES _ruvector_rdf_stores(name) ON DELETE CASCADE,
|
||||
id BIGINT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
predicate TEXT NOT NULL,
|
||||
object TEXT NOT NULL,
|
||||
graph_name TEXT,
|
||||
PRIMARY KEY (store_name, id)
|
||||
)",
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
|
||||
/// Load a triple store from PostgreSQL tables
|
||||
fn load_store_from_tables(name: &str) -> Option<Arc<TripleStore>> {
|
||||
use pgrx::prelude::*;
|
||||
|
||||
let exists = Spi::get_one_with_args::<bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM _ruvector_rdf_stores WHERE name = $1)",
|
||||
vec![(PgBuiltInOids::TEXTOID.oid(), name.into_datum())],
|
||||
)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or(false);
|
||||
|
||||
if !exists {
|
||||
return None;
|
||||
}
|
||||
|
||||
let store = Arc::new(TripleStore::new());
|
||||
|
||||
let _ = pgrx::prelude::Spi::connect(|client| {
|
||||
let tup_table = client.select(
|
||||
"SELECT id, subject, predicate, object, graph_name FROM _ruvector_triples WHERE store_name = $1 ORDER BY id",
|
||||
None,
|
||||
Some(vec![(PgBuiltInOids::TEXTOID.oid(), name.into_datum())]),
|
||||
)?;
|
||||
|
||||
for row in tup_table {
|
||||
let subject: String = row.get_by_name::<String, _>("subject")?.unwrap_or_default();
|
||||
let predicate: String = row.get_by_name::<String, _>("predicate")?.unwrap_or_default();
|
||||
let object: String = row.get_by_name::<String, _>("object")?.unwrap_or_default();
|
||||
let graph_name: Option<String> = row.get_by_name::<String, _>("graph_name")?;
|
||||
|
||||
let triple = Triple::from_strings(&subject, &predicate, &object);
|
||||
store.insert_into_graph(triple, graph_name.as_deref());
|
||||
}
|
||||
Ok::<_, pgrx::spi::Error>(())
|
||||
});
|
||||
|
||||
TRIPLE_STORE_REGISTRY.insert(name.to_string(), store.clone());
|
||||
Some(store)
|
||||
}
|
||||
|
||||
/// Persist a triple to the backing table
|
||||
pub fn persist_triple(store_name: &str, id: u64, triple: &Triple, graph: Option<&str>) {
|
||||
use pgrx::prelude::*;
|
||||
let subj = triple_store::term_to_key(&triple.subject);
|
||||
let pred = triple.predicate.as_str().to_string();
|
||||
let obj = triple_store::term_to_key(&triple.object);
|
||||
|
||||
Spi::run_with_args(
|
||||
"INSERT INTO _ruvector_triples (store_name, id, subject, predicate, object, graph_name)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (store_name, id) DO NOTHING",
|
||||
Some(vec![
|
||||
(PgBuiltInOids::TEXTOID.oid(), store_name.into_datum()),
|
||||
(PgBuiltInOids::INT8OID.oid(), (id as i64).into_datum()),
|
||||
(PgBuiltInOids::TEXTOID.oid(), subj.into_datum()),
|
||||
(PgBuiltInOids::TEXTOID.oid(), pred.into_datum()),
|
||||
(PgBuiltInOids::TEXTOID.oid(), obj.into_datum()),
|
||||
(
|
||||
PgBuiltInOids::TEXTOID.oid(),
|
||||
graph.map(|g| g.to_string()).into_datum(),
|
||||
),
|
||||
]),
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
|
||||
/// Get or create a triple store by name (with persistence)
|
||||
pub fn get_or_create_store(name: &str) -> Arc<TripleStore> {
|
||||
if let Some(s) = TRIPLE_STORE_REGISTRY.get(name) {
|
||||
return s.clone();
|
||||
}
|
||||
|
||||
ensure_rdf_tables();
|
||||
if let Some(s) = load_store_from_tables(name) {
|
||||
return s;
|
||||
}
|
||||
|
||||
// Create new
|
||||
use pgrx::prelude::*;
|
||||
Spi::run_with_args(
|
||||
"INSERT INTO _ruvector_rdf_stores (name) VALUES ($1) ON CONFLICT DO NOTHING",
|
||||
Some(vec![(PgBuiltInOids::TEXTOID.oid(), name.into_datum())]),
|
||||
)
|
||||
.ok();
|
||||
|
||||
TRIPLE_STORE_REGISTRY
|
||||
.entry(name.to_string())
|
||||
.or_insert_with(|| Arc::new(TripleStore::new()))
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Get an existing triple store by name
|
||||
/// Get an existing triple store by name (checks tables if not in cache)
|
||||
pub fn get_store(name: &str) -> Option<Arc<TripleStore>> {
|
||||
TRIPLE_STORE_REGISTRY.get(name).map(|s| s.clone())
|
||||
if let Some(s) = TRIPLE_STORE_REGISTRY.get(name) {
|
||||
return Some(s.clone());
|
||||
}
|
||||
|
||||
ensure_rdf_tables();
|
||||
load_store_from_tables(name)
|
||||
}
|
||||
|
||||
/// Delete a triple store by name
|
||||
/// Delete a triple store by name (from cache and tables)
|
||||
pub fn delete_store(name: &str) -> bool {
|
||||
TRIPLE_STORE_REGISTRY.remove(name).is_some()
|
||||
use pgrx::prelude::*;
|
||||
TRIPLE_STORE_REGISTRY.remove(name);
|
||||
// CASCADE deletes triples
|
||||
Spi::run_with_args(
|
||||
"DELETE FROM _ruvector_rdf_stores WHERE name = $1",
|
||||
Some(vec![(PgBuiltInOids::TEXTOID.oid(), name.into_datum())]),
|
||||
)
|
||||
.ok();
|
||||
true
|
||||
}
|
||||
|
||||
/// List all triple store names
|
||||
/// List all triple store names (from persistent storage)
|
||||
pub fn list_stores() -> Vec<String> {
|
||||
TRIPLE_STORE_REGISTRY
|
||||
.iter()
|
||||
.map(|e| e.key().clone())
|
||||
.collect()
|
||||
use pgrx::prelude::*;
|
||||
ensure_rdf_tables();
|
||||
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
let _ = Spi::connect(|client| {
|
||||
let tup_table =
|
||||
client.select("SELECT name FROM _ruvector_rdf_stores ORDER BY name", None, None)?;
|
||||
for row in tup_table {
|
||||
if let Some(name) = row.get_by_name::<String, _>("name")? {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
Ok::<_, pgrx::spi::Error>(())
|
||||
});
|
||||
|
||||
for entry in TRIPLE_STORE_REGISTRY.iter() {
|
||||
if !names.contains(entry.key()) {
|
||||
names.push(entry.key().clone());
|
||||
}
|
||||
}
|
||||
|
||||
names
|
||||
}
|
||||
|
||||
/// SPARQL error type
|
||||
|
|
|
|||
|
|
@ -575,7 +575,7 @@ impl Default for TripleStore {
|
|||
}
|
||||
|
||||
/// Convert an RDF term to a string key for indexing
|
||||
fn term_to_key(term: &RdfTerm) -> String {
|
||||
pub fn term_to_key(term: &RdfTerm) -> String {
|
||||
match term {
|
||||
RdfTerm::Iri(iri) => format!("<{}>", iri.as_str()),
|
||||
RdfTerm::Literal(lit) => {
|
||||
|
|
|
|||
|
|
@ -6,18 +6,32 @@ use dashmap::DashMap;
|
|||
use ruvector_sona::{SonaConfig, SonaEngine};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Global Sona engine state per table.
|
||||
/// Cache key includes dimension so different-dim inputs get separate engines.
|
||||
fn engine_key(table_name: &str, dim: u32) -> String {
|
||||
format!("{}::{}", table_name, dim)
|
||||
}
|
||||
|
||||
/// Global Sona engine state per table+dimension.
|
||||
static SONA_ENGINES: once_cell::sync::Lazy<DashMap<String, Arc<SonaEngine>>> =
|
||||
once_cell::sync::Lazy::new(DashMap::new);
|
||||
|
||||
/// Get or create a SonaEngine for a given table.
|
||||
/// Default dimension when none is specified (e.g., for stats queries).
|
||||
const DEFAULT_DIM: u32 = 256;
|
||||
|
||||
/// Get or create a SonaEngine for a given table with default dimension.
|
||||
pub fn get_or_create_engine(table_name: &str) -> Arc<SonaEngine> {
|
||||
get_or_create_engine_with_dim(table_name, DEFAULT_DIM)
|
||||
}
|
||||
|
||||
/// Get or create a SonaEngine for a given table and embedding dimension.
|
||||
pub fn get_or_create_engine_with_dim(table_name: &str, dim: u32) -> Arc<SonaEngine> {
|
||||
let key = engine_key(table_name, dim);
|
||||
SONA_ENGINES
|
||||
.entry(table_name.to_string())
|
||||
.entry(key)
|
||||
.or_insert_with(|| {
|
||||
Arc::new(SonaEngine::with_config(SonaConfig {
|
||||
hidden_dim: 256,
|
||||
embedding_dim: 256,
|
||||
hidden_dim: dim as usize,
|
||||
embedding_dim: dim as usize,
|
||||
..Default::default()
|
||||
}))
|
||||
})
|
||||
|
|
|
|||
|
|
@ -8,7 +8,15 @@ use super::get_or_create_engine;
|
|||
/// Record a learning trajectory for a table (Micro-LoRA).
|
||||
#[pg_extern]
|
||||
pub fn ruvector_sona_learn(table_name: &str, trajectory_json: JsonB) -> JsonB {
|
||||
let engine = get_or_create_engine(table_name);
|
||||
// Detect dimension from the trajectory data
|
||||
let dim = trajectory_json
|
||||
.0
|
||||
.get("initial")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.len() as u32)
|
||||
.unwrap_or(super::DEFAULT_DIM);
|
||||
|
||||
let engine = super::get_or_create_engine_with_dim(table_name, dim);
|
||||
|
||||
// Parse trajectory: {"initial": [f32...], "steps": [{"embedding": [f32...], "actions": [...], "reward": f32}]}
|
||||
let initial: Vec<f32> = trajectory_json
|
||||
|
|
@ -20,7 +28,7 @@ pub fn ruvector_sona_learn(table_name: &str, trajectory_json: JsonB) -> JsonB {
|
|||
.filter_map(|x| x.as_f64().map(|f| f as f32))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_else(|| vec![0.0; 256]);
|
||||
.unwrap_or_else(|| vec![0.0; dim as usize]);
|
||||
|
||||
let steps = trajectory_json
|
||||
.0
|
||||
|
|
@ -41,7 +49,7 @@ pub fn ruvector_sona_learn(table_name: &str, trajectory_json: JsonB) -> JsonB {
|
|||
.filter_map(|x| x.as_f64().map(|f| f as f32))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_else(|| vec![0.0; 256]);
|
||||
.unwrap_or_else(|| vec![0.0; dim as usize]);
|
||||
|
||||
let attention_weights: Vec<f32> = step
|
||||
.get("attention_weights")
|
||||
|
|
@ -75,19 +83,41 @@ pub fn ruvector_sona_learn(table_name: &str, trajectory_json: JsonB) -> JsonB {
|
|||
}
|
||||
|
||||
/// Apply learned LoRA transformation to an embedding.
|
||||
/// Dynamically matches engine dimension to input size.
|
||||
#[pg_extern(immutable, parallel_safe)]
|
||||
pub fn ruvector_sona_apply(table_name: &str, embedding: Vec<f32>) -> Vec<f32> {
|
||||
let engine = get_or_create_engine(table_name);
|
||||
|
||||
let mut output = vec![0.0f32; embedding.len()];
|
||||
engine.apply_micro_lora(&embedding, &mut output);
|
||||
|
||||
// If output is all zeros (no learned weights yet), return the input
|
||||
if output.iter().all(|&x| x == 0.0) {
|
||||
if embedding.is_empty() {
|
||||
return embedding;
|
||||
}
|
||||
|
||||
output
|
||||
let dim = embedding.len() as u32;
|
||||
let engine = super::get_or_create_engine_with_dim(table_name, dim);
|
||||
|
||||
let mut output = vec![0.0f32; embedding.len()];
|
||||
|
||||
// Guard against panics from the native engine
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
engine.apply_micro_lora(&embedding, &mut output);
|
||||
}));
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
// If output is all zeros (no learned weights yet), return the input
|
||||
if output.iter().all(|&x| x == 0.0) {
|
||||
embedding
|
||||
} else {
|
||||
output
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// On panic, return input unchanged rather than crashing PostgreSQL
|
||||
pgrx::warning!(
|
||||
"SONA apply: internal error for dim={}, returning input unchanged",
|
||||
dim
|
||||
);
|
||||
embedding
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get EWC++ forgetting metrics for a table.
|
||||
|
|
|
|||
|
|
@ -84,8 +84,8 @@ pub use quantizer::{
|
|||
absmean_ternary, quantize_tensor, LayerMask, Precision, PtBitnetConfig, TernaryFormat,
|
||||
};
|
||||
pub use rlm_embedder::{
|
||||
BaseEmbedder, EmbeddingVariant, NeighborRetriever, RlmEmbedder, RlmEmbedderConfig,
|
||||
RlmEmbeddingResult,
|
||||
BaseEmbedder, EmbeddingVariant, FlatNeighborStore, HashEmbedder, NeighborRetriever,
|
||||
RlmEmbedder, RlmEmbedderConfig, RlmEmbeddingResult,
|
||||
};
|
||||
pub use rlm_refiner::{RefinementResult, RefinementStepMetrics, RlmRefiner, RlmRefinerConfig};
|
||||
pub use ternary_tensor::{pack_ternary, unpack_ternary, TernaryTensor};
|
||||
|
|
|
|||
|
|
@ -650,6 +650,7 @@ impl<E: BaseEmbedder, R: NeighborRetriever> RlmEmbedder<E, R> {
|
|||
/// this is for testing, benchmarking, and as a baseline.
|
||||
///
|
||||
/// On Pi 5: ~0.1ms per embedding (just hashing + normalize).
|
||||
#[derive(Clone)]
|
||||
pub struct HashEmbedder {
|
||||
dim: usize,
|
||||
}
|
||||
|
|
@ -702,6 +703,7 @@ impl BaseEmbedder for HashEmbedder {
|
|||
/// Suitable for small corpora (< 100K chunks) on Pi 5.
|
||||
///
|
||||
/// For larger corpora, use RuVector's HNSW index as the retriever.
|
||||
#[derive(Clone)]
|
||||
pub struct FlatNeighborStore {
|
||||
chunks: Vec<StoredChunk>,
|
||||
dim: usize,
|
||||
|
|
|
|||
144
docs/adr/ADR-058-hash-security-optimization.md
Normal file
144
docs/adr/ADR-058-hash-security-optimization.md
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
# ADR-058: RVF Hash Security Hardening and Optimization
|
||||
|
||||
**Status**: Accepted
|
||||
**Date**: 2026-02-27
|
||||
**Authors**: ruv.io, RuVector Architecture Team
|
||||
**Deciders**: Architecture Review Board
|
||||
**SDK**: Claude-Flow
|
||||
**Relates to**: ADR-029 (RVF Canonical Format), ADR-042 (Security-RVF-AIDefence-TEE)
|
||||
|
||||
## Context
|
||||
|
||||
### Current Hash Implementation
|
||||
|
||||
The RVF wire format (`rvf-wire`) uses XXH3-128 as the sole content hash for all
|
||||
segment integrity verification. The `checksum_algo` field in the 64-byte segment
|
||||
header supports three values:
|
||||
|
||||
| Algo | Name | Status |
|
||||
|------|------|--------|
|
||||
| 0 | CRC32C | Deprecated — silently upgraded to XXH3-128 |
|
||||
| 1 | XXH3-128 | Active — used for all operations |
|
||||
| 2 | SHAKE-256 | Declared in enum but **never implemented** |
|
||||
|
||||
### Security Findings
|
||||
|
||||
A comprehensive review of `rvf-wire/src/hash.rs`, `rvf-types/src/checksum.rs`,
|
||||
and the graph shard module (`ruvector-graph/src/distributed/shard.rs`) identified
|
||||
six issues:
|
||||
|
||||
1. **Non-constant-time hash comparison (P1)**: `verify_content_hash` uses `==`
|
||||
on byte arrays. While XXH3-128 is not cryptographic, a timing side-channel
|
||||
could reveal partial hash values to an attacker probing segment files over a
|
||||
network interface. For defense-in-depth, verification should use
|
||||
constant-time comparison.
|
||||
|
||||
2. **SHAKE-256 declared but unimplemented (P2)**: `ChecksumAlgo::Shake256` (algo=2)
|
||||
exists in the enum and is accepted by `TryFrom<u8>`, but
|
||||
`compute_content_hash` ignores the algo parameter entirely — all paths route
|
||||
to XXH3-128. A writer could set `checksum_algo=2` in the header and it would
|
||||
silently verify against XXH3-128, creating a false sense of cryptographic
|
||||
integrity.
|
||||
|
||||
3. **Algo parameter ignored (P2)**: `compute_content_hash(_algo, data)` discards
|
||||
the algorithm selector. If a future writer uses algo=2, the verifier cannot
|
||||
detect the mismatch.
|
||||
|
||||
4. **No keyed/HMAC hash option (P3)**: The current scheme provides integrity
|
||||
(accidental corruption detection) but not authentication. For federated
|
||||
transfer scenarios (ADR-057), a keyed hash is needed to prevent a
|
||||
man-in-the-middle from replacing segment payloads while recomputing the hash.
|
||||
|
||||
5. **Dead CRC32C dependency (P3)**: `crc32c = "0.6"` remains in `Cargo.toml`
|
||||
even though CRC32C is fully deprecated. The `compute_crc32c` and
|
||||
`compute_crc32c_hash` functions are dead code.
|
||||
|
||||
6. **Graph shard uses XXH3-64 (P3)**: `ruvector-graph` shard routing uses
|
||||
`xxh3_64()` (64-bit). With 2^32 nodes the birthday bound gives ~50%
|
||||
collision probability for shard assignment. This is acceptable for current
|
||||
scale but noted for future growth.
|
||||
|
||||
### Performance Baseline
|
||||
|
||||
XXH3-128 on 1 MB payload: ~50 GB/s on AVX2 hardware (dominated by memory
|
||||
bandwidth). No performance regression is expected from the changes below since
|
||||
the hash function itself is not modified.
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. Constant-Time Hash Verification
|
||||
|
||||
Replace the `==` comparison in `verify_content_hash` with a constant-time
|
||||
byte-equality check using `subtle::ConstantTimeEq`. This eliminates the timing
|
||||
side-channel at negligible cost (~2 ns overhead for 16 bytes).
|
||||
|
||||
### 2. Wire SHAKE-256 Implementation
|
||||
|
||||
Implement `compute_shake256_128(data)` using the `sha3` crate (already a
|
||||
dependency of `rvf-crypto`). Route `algo=2` in `compute_content_hash` to this
|
||||
implementation. This makes the `ChecksumAlgo::Shake256` enum variant truthful.
|
||||
|
||||
SHAKE-256 truncated to 128 bits provides:
|
||||
- 128-bit collision resistance (same as XXH3-128)
|
||||
- Post-quantum preimage resistance (vs XXH3's ~0 bits)
|
||||
- ~300 MB/s throughput (vs XXH3's ~50 GB/s) — acceptable for
|
||||
security-sensitive segments where correctness matters more than speed
|
||||
|
||||
### 3. Honor the Algo Parameter
|
||||
|
||||
Make `compute_content_hash` dispatch on the algo value:
|
||||
- 0 → XXH3-128 (CRC32C upgrade, backward compatible)
|
||||
- 1 → XXH3-128
|
||||
- 2 → SHAKE-256 (first 128 bits)
|
||||
- other → XXH3-128 (fallback)
|
||||
|
||||
### 4. Remove Dead CRC32C Code
|
||||
|
||||
Remove `compute_crc32c()`, `compute_crc32c_hash()`, and the `crc32c` dependency.
|
||||
Retain the `Crc32c = 0` enum variant for backward-compatible header parsing.
|
||||
|
||||
### 5. Add Keyed Hash Support (Algo=3)
|
||||
|
||||
Reserve `checksum_algo=3` for HMAC-SHAKE-256 (keyed integrity). Implementation
|
||||
is deferred to a follow-up PR as it requires key management infrastructure.
|
||||
Add the enum variant now so the wire format is forward-compatible.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Eliminates timing side-channel in hash verification
|
||||
- SHAKE-256 segments can now be written and verified correctly
|
||||
- Dead code removed, smaller dependency tree
|
||||
- Wire format is forward-compatible with keyed hashing
|
||||
|
||||
### Negative
|
||||
|
||||
- `subtle` crate added as a dependency (~10 KB, widely audited)
|
||||
- `sha3` crate added to `rvf-wire` (already in `rvf-crypto`)
|
||||
- Writers that relied on the silent algo-mismatch behavior will now produce
|
||||
SHAKE-256 hashes when they set algo=2 (breaking change for any such writers,
|
||||
but none are known to exist)
|
||||
|
||||
### Risks
|
||||
|
||||
- The `subtle` crate's constant-time guarantees depend on the compiler not
|
||||
optimizing away the timing-safe operations. Rust's `subtle` v2.6+ uses
|
||||
inline-asm barriers on supported platforms.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
1. Add `subtle` and `sha3` dependencies to `rvf-wire/Cargo.toml`
|
||||
2. Remove `crc32c` dependency and dead CRC32C functions
|
||||
3. Implement `compute_shake256_128()` in `hash.rs`
|
||||
4. Update `compute_content_hash()` to dispatch on algo
|
||||
5. Update `verify_content_hash()` to use `subtle::ConstantTimeEq`
|
||||
6. Add `HmacShake256 = 3` to `ChecksumAlgo` enum (reserved, no impl yet)
|
||||
7. Update tests and benchmarks
|
||||
8. Verify existing tests pass (no behavioral change for algo=0 and algo=1)
|
||||
|
||||
## Integration with mcp-brain-server
|
||||
|
||||
ADR-075 documents the integration of `rvf-crypto` (including the SHAKE-256 functions hardened by this ADR) into the Shared Brain server. The brain server's `verify.rs` module previously used inline `sha3::Shake256` calls; it now delegates to `rvf_crypto::shake256_256()` for content hashing and `rvf_crypto::create_witness_chain()` / `rvf_crypto::verify_witness_chain()` for tamper-evident audit trails. This ensures that the constant-time comparison and proper algo dispatch implemented here are used consistently across the stack.
|
||||
|
||||
See: [ADR-075 — Wire Full RVF AGI Stack into mcp-brain-server](ADR-075-rvf-agi-stack-brain-integration.md)
|
||||
1406
docs/adr/ADR-059-shared-brain-google-cloud.md
Normal file
1406
docs/adr/ADR-059-shared-brain-google-cloud.md
Normal file
File diff suppressed because it is too large
Load diff
307
docs/adr/ADR-060-shared-brain-capabilities.md
Normal file
307
docs/adr/ADR-060-shared-brain-capabilities.md
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
# ADR-060: Shared Brain Capabilities — Federated MicroLoRA Intelligence Substrate
|
||||
|
||||
**Status**: Accepted
|
||||
**Date**: 2026-02-27
|
||||
**Authors**: RuVector Team
|
||||
**Deciders**: ruv
|
||||
**Supersedes**: N/A
|
||||
**Related**: ADR-059 (Shared Brain Google Cloud Deployment), ADR-057 (Federated RVF Transfer Learning)
|
||||
|
||||
## 1. Context
|
||||
|
||||
The Shared Brain (ADR-059) turns isolated Claude Code sessions into a continuously improving, public, zero-trust learning substrate. The federated MicroLoRA communication bridge — where tiny shared weights (2KB per sync) move faster than any single model upgrade — enables seven distinct capabilities that were previously impossible with isolated sessions.
|
||||
|
||||
This ADR documents the sub-capabilities, practical use cases, business outcomes, and architectural considerations that emerge from the deployed system.
|
||||
|
||||
## 2. Core Capabilities
|
||||
|
||||
### 2.1 Cold Start Disappears
|
||||
|
||||
A brand-new session can land on the right approach in minutes because it downloads the current MicroLoRA consensus plus high-quality priors and policies. The "first time tax" — where every new repo, bug class, or architecture decision requires re-discovery — is eliminated.
|
||||
|
||||
**Mechanism**: `brain_sync(direction: "pull")` downloads consensus LoRA weights + `brain_search` retrieves relevant prior knowledge. The structured hash features provide immediate lexical n-gram-level matching; the MicroLoRA transform adds learned semantic refinement on top.
|
||||
|
||||
**Primary metric**: Median time to first relevant hit at top-5 on a fixed labeled query set, measured continuously. Secondary: median tool calls until first correct fix. Both tracked per-epoch to observe convergence.
|
||||
|
||||
**Measurable outcome**: New sessions converge on known solution patterns without rediscovering them. The acceleration is proportional to the quality and density of the knowledge graph in the relevant domain.
|
||||
|
||||
### 2.2 Debugging Becomes Cumulative
|
||||
|
||||
When one session finds a reliable fix pattern for a failure mode, the brain learns that trajectory. Future sessions retrieve it by meaning, not by exact text match. Debugging becomes a shared skill library that grows with every session.
|
||||
|
||||
**Mechanism**: The SONA engine records debugging trajectories as `LearnedPattern` entries with quality scores. Successful fixes (high quality feedback via `brain_vote(direction: "up")`) strengthen the corresponding embedding clusters. The MicroLoRA adapts to prefer embeddings near high-quality fix patterns.
|
||||
|
||||
**Example flow**:
|
||||
1. Session A discovers that `tokio::spawn` deadlocks when combined with `parking_lot::RwLock` in certain patterns
|
||||
2. Session A shares the fix pattern via `brain_share(category: "debug", ...)`
|
||||
3. Session B encounters a similar deadlock
|
||||
4. Session B's `brain_search` retrieves Session A's fix — not because the code is identical, but because the structured hash features capture the shared lexical tokens ("deadlock", "rwlock", "spawn") and the MicroLoRA has learned the semantic proximity between those tokens and the solution space
|
||||
|
||||
### 2.3 A Shared "Taste" for Good Solutions
|
||||
|
||||
Votes plus quality gating train the MicroLoRA to prefer solution patterns that actually worked downstream. Over time the system learns what "good" looks like for the ecosystem — not what is popular, but what is effective.
|
||||
|
||||
**Invariant: Quality signals are sourced only from authenticated contributors.** Voting is a write operation requiring API key authentication and contributor pseudonym derivation. This prevents the vote stream — which directly influences MicroLoRA gradient signals — from becoming the easiest poisoning path. Anonymous or unauthenticated users cannot influence quality scores.
|
||||
|
||||
**Mechanism**: The Bayesian quality score (`BetaParams { alpha, beta }`) accumulates vote evidence. Memories with `quality_score.mean() < 0.3` after `observations() >= 5` are auto-archived. The MicroLoRA's gradient signal is quality-weighted: high-quality patterns get stronger representation in the embedding space, shifting the consensus toward effective solutions.
|
||||
|
||||
**Anti-popularity bias**: Quality gating is based on Bayesian posteriors with flat priors, not raw vote counts. A memory with 3 upvotes and 0 downvotes (quality=0.8) is not assumed superior to one with 50 upvotes and 10 downvotes (quality=0.85). The Beta distribution's uncertainty naturally favors well-tested knowledge.
|
||||
|
||||
### 2.4 Transfer Learning Across Domains
|
||||
|
||||
TransferPrior and PolicyKernel segments enable moving learning from "code review" into "security audit" or from "infra ops" into "edge deployment" with measurable acceleration and confidence.
|
||||
|
||||
**Mechanism**: `brain_transfer(source_domain, target_domain)` uses `DomainExpansionEngine::initiate_transfer()` with sqrt-dampened priors (prevents overfit to source domain). `TransferVerification::verify()` requires both conditions: target improved AND source not regressed. `CostCurve` tracks the acceleration factor.
|
||||
|
||||
**Cross-domain examples**:
|
||||
- Rust ownership patterns (source) -> TypeScript lifetime management patterns (target)
|
||||
- REST API security (source) -> GraphQL security (target)
|
||||
- Kubernetes deployment (source) -> Edge container deployment (target)
|
||||
|
||||
**Failure mode**: Transfer can inject incorrect analogies when domains are superficially similar but structurally different. Transfer priors are never auto-applied. They require explicit `brain_transfer` invocation, downstream verification in the target domain (target_after > target_before), and non-regression in the source domain (source_after >= source_before). If either condition fails, the transfer is rejected and the `TransferVerification.promotable` flag is false.
|
||||
|
||||
### 2.5 A Public Marketplace of Small, Auditable Intelligence Artifacts
|
||||
|
||||
Because everything is an RVF container with witness chains, knowledge can be exchanged as signed, replayable, revocable units. This is the basis for monetization, governance, and compliance.
|
||||
|
||||
**Canonical RVF container layout: exactly ten segments per memory** (from ADR-059 Section 6):
|
||||
|
||||
| # | Type | Content |
|
||||
|---|------|---------|
|
||||
| 1 | `MANIFEST (0x05)` | Segment directory |
|
||||
| 2 | `VEC_SEG (0x01)` | SONA embedding (f32 x dim) |
|
||||
| 3 | `META_SEG (0x07)` | Title, content, tags, category |
|
||||
| 4 | `TransferPrior (0x30)` | Domain priors (if any) |
|
||||
| 5 | `PolicyKernel (0x31)` | Best config snapshot |
|
||||
| 6 | `CostCurve (0x32)` | Acceleration data |
|
||||
| 7 | `FederatedManifest (0x33)` | Export metadata |
|
||||
| 8 | `DiffPrivacyProof (0x34)` | Noise attestation |
|
||||
| 9 | `RedactionLog (0x35)` | PII strip attestation |
|
||||
| 10 | `WITNESS_SEG (0x0A)` | SHAKE-256 operation chain |
|
||||
|
||||
Ed25519 signature covers segments 1-10. This layout is locked — code comments and test assertions reference exactly ten segments.
|
||||
|
||||
**Auditability**: Every operation (share, vote, delete, transfer, sync) is recorded in the witness chain. Any participant can verify the chain independently.
|
||||
|
||||
### 2.6 An Anti-Poisoning Collective Memory
|
||||
|
||||
Robust aggregation, mincut partitioning, drift monitors, and reputation gating let the system absorb noisy public input without letting any single actor steer the centroid.
|
||||
|
||||
**Seven layers of defense**:
|
||||
1. **Input sanitization**: 12-pattern PII strip, schema validation, size limits
|
||||
2. **Server-side verification**: Re-check PII, witness chain, signature, embedding bounds
|
||||
3. **DoS protection**: BudgetTokenBucket rate limiting, challenge nonces
|
||||
4. **Byzantine aggregation**: 2-sigma outlier exclusion, per-parameter median + MAD trimming
|
||||
5. **Multi-factor reputation**: `composite = accuracy^2 x uptime x stake_weight`, cold start at 0.1
|
||||
6. **Drift monitoring**: VectorDelta centroid tracking, automatic rollback on excessive drift
|
||||
7. **Vote authentication**: Quality signals sourced only from authenticated contributors (see Section 2.3)
|
||||
|
||||
**MicroLoRA-specific defenses**:
|
||||
- Gate A policy validation: shape, NaN/Inf, [-2.0, 2.0] clipping bounds, L2 norm < 100, evidence >= 5
|
||||
- Gate B robust aggregation: per-parameter median, 3xMAD trimming, reputation-weighted mean
|
||||
- Consensus drift monitoring: L2 distance between raw (unclipped, unnormalized) consensus weight vectors across epochs; rollback threshold 5.0 calibrated for rank-2 on 128-dim (512 total parameters, weights clipped to [-2, 2] before submission). This threshold must be recalibrated if rank or dimension changes.
|
||||
|
||||
### 2.7 Always-On Agent Coordination
|
||||
|
||||
The brain becomes the local edge brain for real systems. It can watch logs, configs, incidents, and sensor streams, then pull collective learnings on demand while keeping sensitive raw data local.
|
||||
|
||||
**Architecture**: The MCP stdio server (`mcp-brain`) runs as a local process within each Claude Code session. It communicates with the Cloud Run backend (`mcp-brain-server`) via HTTPS. Raw data never leaves the local session — only distilled embeddings, LoRA weights, and sanitized metadata are transmitted.
|
||||
|
||||
**Edge deployment pattern**:
|
||||
1. Agent monitors local system (logs, metrics, alerts)
|
||||
2. When anomaly detected, `brain_search` queries collective for similar patterns
|
||||
3. If match found, apply fix pattern from collective knowledge
|
||||
4. `brain_share` contributes the resolved incident as new knowledge
|
||||
5. `brain_sync` updates local LoRA weights with any new consensus
|
||||
|
||||
## 3. Embedding Pipeline Clarifications
|
||||
|
||||
### 3.1 Hash Features Are Lexical, Not Semantic
|
||||
|
||||
Structured hash features provide lexical and structural similarity. Texts sharing word-level n-grams will have higher cosine similarity than texts with disjoint vocabularies. **Semantics come only from the MicroLoRA transform**, which is learned from quality signals over time. A reader should not assume that hashing alone captures meaning — it captures co-occurrence structure that the LoRA refines into approximate semantic proximity.
|
||||
|
||||
### 3.2 Symmetric Pipeline for Store and Query
|
||||
|
||||
Both stored vectors and query vectors pass through the same pipeline stages in the same order:
|
||||
|
||||
```
|
||||
text -> structured_hash_features() -> apply_lora_transform() -> L2_normalize()
|
||||
```
|
||||
|
||||
The DentateGyrus pattern separation layer in `cognitive.rs` is used only for server-side indexing and anomaly detection. It is **not** applied to query embeddings or to stored embedding vectors used for similarity search. This prevents asymmetric drift between index and query representations.
|
||||
|
||||
### 3.3 Transfer Learning Boundary Conditions
|
||||
|
||||
Transfer priors are dampened by sqrt-scaling and require dual verification (target improved AND source not regressed). Transfers that fail verification are rejected with `TransferVerification.promotable = false`. There is no auto-apply path — every transfer requires explicit invocation and evidence.
|
||||
|
||||
## 4. Business Outcomes
|
||||
|
||||
### 4.1 Lower Cost Per Solved Task
|
||||
|
||||
Fewer retries and faster convergence. When a session can pull relevant prior knowledge and learned LoRA weights instead of discovering solutions from scratch, the number of API calls, tool invocations, and human review cycles decreases.
|
||||
|
||||
### 4.2 Higher Consistency Across Projects
|
||||
|
||||
Best practices become default. The MicroLoRA consensus naturally encodes the patterns that have been most successful across all contributing sessions. New projects inherit this collective "taste" from the first `brain_sync`.
|
||||
|
||||
### 4.3 Defensible Moat
|
||||
|
||||
The shared weights and witness-logged outcomes are proprietary experience, not just code. The accumulated LoRA weights represent the collective intelligence of all contributing sessions — a continuously growing asset that cannot be replicated by copying code alone.
|
||||
|
||||
### 4.4 Economic Accounting
|
||||
|
||||
```
|
||||
Monthly brain value = (hours_saved x blended_hourly_rate) - cloud_costs - review_time
|
||||
```
|
||||
|
||||
**Initial targets**:
|
||||
- Reduce median fix time by 2x on "fix failing tests" workflow (30-run A/B test)
|
||||
- Reduce LLM token spend per fix by 30% due to fewer retries (measured via API call counts in brain-on vs brain-off runs)
|
||||
|
||||
**Cloud cost estimate**: Cloud Run at 0-10 instances (2 CPU / 2Gi RAM), Firestore, GCS. Expected < $50/month at moderate traffic. Break-even requires saving approximately 1 developer-hour per month at $50/hr blended rate.
|
||||
|
||||
## 5. Target Users
|
||||
|
||||
### Primary: Developers Using Claude Code
|
||||
|
||||
Individual developers and small teams using Claude Code for daily software engineering tasks. The brain provides immediate value through cold-start elimination and cumulative debugging knowledge.
|
||||
|
||||
### Secondary: Enterprise Teams Running Always-On Agents
|
||||
|
||||
Teams deploying autonomous agents in production environments. The brain provides collective intelligence for incident response, configuration management, and cross-project coordination.
|
||||
|
||||
## 6. Access Model
|
||||
|
||||
### Public Read, Gated Write
|
||||
|
||||
- **Read**: Search, list, get, drift, partition, status, and lora/latest are publicly accessible with per-identity rate tiers (1000 reads/hr authenticated, 100 reads/hr anonymous)
|
||||
- **Write**: Share, vote, delete, transfer, lora/submit require API key authentication with contributor pseudonym derivation
|
||||
- **Voting is a write operation**: Quality signals are sourced only from authenticated contributors. This is a deliberate choice — public voting without identity would be the easiest Sybil/poisoning attack surface
|
||||
- **Reputation gate**: New contributors start at 0.1 composite reputation. Their contributions are weighted 10x less in aggregation until they accumulate sufficient positive votes
|
||||
- **System contributors**: Seed data uses `ruvector-seed` pseudonym with max reputation (1.0) and system flag exempting from decay
|
||||
|
||||
### Read Abuse Budgeting
|
||||
|
||||
Even public read endpoints can dominate cost. Defenses:
|
||||
- **Query complexity limits**: Search limited to `limit <= 100`, embedding dimension fixed at 128
|
||||
- **Response caching**: 60-second TTL cache on `/v1/lora/latest` (consensus changes only at epoch boundaries). 30-second cache on `/v1/status` and `/v1/drift`
|
||||
- **Per-identity rate tiers**: Authenticated: 1000 reads/hr. Anonymous (no API key): 100 reads/hr. Exhausted: 429 with Retry-After header
|
||||
|
||||
## 7. Federation Cadence and Stability
|
||||
|
||||
### Aggregation Trigger
|
||||
|
||||
Aggregation fires on **submission count**: when `pending_submissions >= min_submissions` (default: 3). There is no time-based trigger — aggregation requires evidence from multiple independent sessions.
|
||||
|
||||
### Minimum Quorum
|
||||
|
||||
A new consensus epoch is published only when:
|
||||
1. At least `min_submissions` (3) accepted submissions are pending
|
||||
2. After Gate B trimming, at least `min_submissions` (3) inlier submissions remain
|
||||
3. If both conditions fail, pending submissions accumulate until the threshold is met
|
||||
|
||||
### Rollback Semantics
|
||||
|
||||
When drift rollback triggers, **only MicroLoRA consensus weights are rolled back**. The accepted memory set is preserved. Memories are independently quality-gated (Bayesian BetaParams) and witness-verified — rolling them back would destroy valid, independently verified knowledge. The LoRA transform is the only aggregated learned artifact that can diverge from the population; memories are individually validated on ingestion.
|
||||
|
||||
After rollback, the pending submission queue is cleared to prevent the same batch from immediately re-triggering the same divergent consensus.
|
||||
|
||||
## 8. Acceptance Criteria
|
||||
|
||||
### Embedding Quality
|
||||
|
||||
- 200 labeled pairs (similar/unrelated)
|
||||
- After 3 sync epochs with >= 10 contributors each
|
||||
- Similar pairs should exceed unrelated by >= 0.25 cosine distance
|
||||
- Search recall@10 should improve >= 30% vs hash-only baseline
|
||||
|
||||
### Workflow Acceleration
|
||||
|
||||
Pick one workflow (e.g., "fix failing tests in a TypeScript repo"):
|
||||
- Measure median time to first passing build: brain off vs brain on
|
||||
- 30 runs each condition
|
||||
- Target: >= 2x improvement once the brain has a few hundred high-quality memories and a few LoRA sync epochs
|
||||
|
||||
### Security: Poisoning Resistance
|
||||
|
||||
- Zero PII leakage through embedding or LoRA weight analysis
|
||||
- Byzantine aggregation excludes > 95% of adversarial submissions in poisoning simulations
|
||||
- Weight drift rollback triggers correctly when consensus shifts > 5.0 L2 distance
|
||||
- Poisoning simulation: 30% adversarial submitters must fail to move consensus weights beyond the drift threshold for more than one epoch
|
||||
|
||||
### Weekly Regression Suite
|
||||
|
||||
Run continuously with:
|
||||
1. Fixed labeled query set (200 pairs)
|
||||
2. Fixed workflow harness (e.g., "fix failing tests")
|
||||
3. Poisoning simulation (30% adversarial contributors)
|
||||
|
||||
Pass criteria:
|
||||
- Recall@10 >= 30% improvement vs hash-only baseline
|
||||
- Median time to first passing build >= 2x improvement
|
||||
- Adversarial consensus deviation contained to <= 1 epoch before rollback
|
||||
|
||||
## 9. Implementation Status
|
||||
|
||||
All capabilities described in this ADR are implemented on branch `feat/adr-030-hash-security-optimization`. Test counts:
|
||||
|
||||
| Crate | Tests | Status |
|
||||
|-------|-------|--------|
|
||||
| `mcp-brain` (client) | 33 | All passing |
|
||||
| `mcp-brain-server` (server) | 28 | All passing |
|
||||
| `mcp-brain` (doc-tests) | 1 | Passing |
|
||||
| **Total** | **62** | **All passing** |
|
||||
|
||||
### Shipped Endpoints
|
||||
|
||||
| Method | Path | Category |
|
||||
|--------|------|----------|
|
||||
| GET | `/v1/health` | Infrastructure |
|
||||
| GET | `/v1/challenge` | Auth |
|
||||
| POST | `/v1/memories` | Write |
|
||||
| GET | `/v1/memories/search` | Read |
|
||||
| GET | `/v1/memories/list` | Read |
|
||||
| GET | `/v1/memories/{id}` | Read |
|
||||
| POST | `/v1/memories/{id}/vote` | Write |
|
||||
| DELETE | `/v1/memories/{id}` | Write |
|
||||
| POST | `/v1/transfer` | Write |
|
||||
| GET | `/v1/drift` | Read |
|
||||
| GET | `/v1/partition` | Read |
|
||||
| GET | `/v1/status` | Read |
|
||||
| GET | `/v1/lora/latest` | Read |
|
||||
| POST | `/v1/lora/submit` | Write |
|
||||
|
||||
Total: 14 endpoints (8 read, 5 write, 1 infrastructure).
|
||||
|
||||
### Shipped MCP Tools
|
||||
|
||||
11 tools: `brain_share`, `brain_search`, `brain_get`, `brain_vote`, `brain_transfer`, `brain_drift`, `brain_partition`, `brain_list`, `brain_delete`, `brain_status`, `brain_sync`.
|
||||
|
||||
### Deferred
|
||||
|
||||
- `GET /v1/lora/stats` (per-epoch aggregation statistics) — deferred until aggregation history is implemented
|
||||
- `POST /v1/lora/challenge` (proof-of-work challenge for anonymous submitters) — deferred until PoW is required by traffic patterns
|
||||
|
||||
## 10. RVF AGI Stack Integration (ADR-075)
|
||||
|
||||
The capabilities described in this ADR — PII stripping (Section 2.6, Layer 1), differential privacy (Section 2.5, Segment 8), witness chains (Section 2.5, Segment 10), and RVF container construction (Section 2.5) — were originally documented as design goals. ADR-075 implements these using the production RVF crate stack:
|
||||
|
||||
| Capability | Inline (Previous) | RVF Crate (ADR-075) |
|
||||
|---|---|---|
|
||||
| PII stripping | 8 string patterns in `verify.rs` | `rvf-federation::PiiStripper` (12 regex rules) |
|
||||
| Diff privacy | Not implemented | `rvf-federation::DiffPrivacyEngine` (Gaussian, feature-gated) |
|
||||
| Witness chains | String-step SHAKE-256 in `verify.rs` | `rvf-crypto::create_witness_chain` (73-byte linked entries) |
|
||||
| Container build | Client-provided RVF bytes only | `rvf-wire::write_segment` (server-side construction) |
|
||||
| Adversarial detect | Not implemented | `rvf-runtime::is_degenerate_distribution` (log-only) |
|
||||
| Negative cache | Not implemented | `rvf-runtime::NegativeCache` (query signature blacklist) |
|
||||
|
||||
The 10-segment canonical container layout (Section 2.5) is now constructed server-side by the `pipeline.rs` module, with segment count reported in `ShareResponse.rvf_segments`.
|
||||
|
||||
## 11. Related ADRs
|
||||
|
||||
| ADR | Relationship |
|
||||
|-----|-------------|
|
||||
| ADR-057 | Federated RVF Transfer Learning — foundational protocol |
|
||||
| ADR-058 | Hash Security Optimization — SHAKE-256 content hashing |
|
||||
| ADR-059 | Shared Brain Google Cloud Deployment — infrastructure and security |
|
||||
| ADR-075 | Wire Full RVF AGI Stack — replaces inline crypto with production RVF crates |
|
||||
249
docs/adr/ADR-061-reasoning-kernel-architecture.md
Normal file
249
docs/adr/ADR-061-reasoning-kernel-architecture.md
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
# ADR-061: Reasoning Kernel Architecture — Brain-Augmented Targeted Reasoning
|
||||
|
||||
**Status**: Accepted
|
||||
**Date**: 2026-02-27
|
||||
**Authors**: RuVector Team
|
||||
**Deciders**: ruv
|
||||
**Supersedes**: N/A
|
||||
**Related**: ADR-060 (Shared Brain Capabilities), ADR-059 (Shared Brain Google Cloud), ADR-057 (Federated Transfer Learning)
|
||||
|
||||
## 1. Context
|
||||
|
||||
The Shared Brain (ADR-059, ADR-060) provides a learning substrate: shared MicroLoRA weights, semantic retrieval, quality-gated knowledge, and witness-verified provenance. But the brain is the memory, not the thinker. The thinker is ruvllm, and it needs a reasoning kernel that leverages the brain to achieve "on par or better" performance compared to traditional LLMs on targeted domains.
|
||||
|
||||
A traditional LLM is a static generalist with weak memory and no accountability. The RuVector system is a living specialist with a shared skill substrate, a semantic retrieval layer, a policy and proof layer, and a parameter growth path. That combination beats raw model size in production for bounded domains.
|
||||
|
||||
This ADR defines the reasoning kernel architecture: what the brain can and cannot do, how ruvllm grows through the brain, and the concrete build plan for targeted domain reasoning.
|
||||
|
||||
## 2. What the Brain Can and Cannot Do
|
||||
|
||||
### 2.1 What It Does Well
|
||||
|
||||
**Domain reasoning at high reliability**: When the domain is bounded — codebase-specific debugging, infrastructure incident response, RVF correctness, mincut-based coherence decisions — the brain makes the system consistently better than a generic LLM because it accumulates exact successful trajectories and policies.
|
||||
|
||||
**Fast improvement without retraining**: MicroLoRA plus retrieval shifts behavior quickly. Short-cycle learning driven by votes and outcomes. A 2KB weight sync moves faster than any model upgrade.
|
||||
|
||||
**Better behavior under governance**: Witness logs, proof-gated mutation, drift monitors, and rollbacks give controlled reasoning. That often matters more than raw model IQ in production.
|
||||
|
||||
### 2.2 What It Cannot Do
|
||||
|
||||
**Create novel general reasoning from scratch**: LoRA adapts a model. It does not invent capacity absent in the base network. If ruvllm is small and weak at general reasoning, MicroLoRA and retrieval help substantially, but there is a ceiling.
|
||||
|
||||
**Replace a large model on broad world knowledge**: The brain stores distilled artifacts, not the full distribution of human text. The system wins by focus, not by universality.
|
||||
|
||||
## 3. Reasoning Kernel Design
|
||||
|
||||
### 3.1 Reasoning Kernel Definition
|
||||
|
||||
The reasoning kernel is the fixed protocol by which ruvllm processes a task using the brain.
|
||||
|
||||
**Input**: A task type from a bounded set (initially: fix failing tests, explain ADR contradictions, generate RVF segments, perform mincut-gated decisions).
|
||||
|
||||
**Output**: A fixed action format with tool calls, intermediate checks, and a final answer format.
|
||||
|
||||
**Invariant**: Every reasoning step produces a witnessable artifact — a check result, a test pass, a proof token, or a retrieval citation from the brain. This makes reasoning verifiable and replayable.
|
||||
|
||||
### 3.2 Memory Architecture
|
||||
|
||||
The kernel operates with two memory layers sourced from the brain:
|
||||
|
||||
**Working memory** (per-task, ephemeral):
|
||||
- RAG into the current context window using `brain_search`
|
||||
- Local repo context (files, errors, stack traces)
|
||||
- Retrieved similar trajectories with quality scores
|
||||
|
||||
**Skill memory** (cross-session, persistent):
|
||||
- High-quality patterns from `brain_search(min_quality: 0.7)`
|
||||
- PolicyKernel snapshots that bias the plan generator
|
||||
- CostCurve data that predicts acceleration factors
|
||||
- MicroLoRA consensus weights that transform embeddings toward effective solution regions
|
||||
|
||||
### 3.3 Reasoning Loop
|
||||
|
||||
```
|
||||
1. CLASSIFY task type from bounded set
|
||||
2. PULL working memory: brain_search(query, category, min_quality)
|
||||
3. PULL skill memory: brain_sync(direction: "pull") for MicroLoRA consensus
|
||||
4. PLAN using retrieved patterns + local context
|
||||
5. EXECUTE with tool calls, recording each step as witnessable artifact
|
||||
6. VERIFY against success criteria (test pass, proof token, etc.)
|
||||
7. If success:
|
||||
a. brain_share(category, result) — contribute solution
|
||||
b. brain_vote(id, "up") on retrieved patterns that helped
|
||||
c. brain_sync(direction: "push") — export local LoRA deltas
|
||||
8. If failure:
|
||||
a. brain_vote(id, "down") on retrieved patterns that misled
|
||||
b. Record failure trajectory for negative example mining
|
||||
```
|
||||
|
||||
## 4. Training Pipeline — Growing ruvllm Through Brain Data
|
||||
|
||||
The brain accumulates exactly the data types needed for targeted model improvement. Training happens in three layers.
|
||||
|
||||
### 4.1 Layer A: Preference Learning from Votes
|
||||
|
||||
**Data source**: `brain_vote` events produce (memory, direction) pairs. Over time, this builds a preference dataset: for a given task type, which solution patterns are preferred (upvoted) vs rejected (downvoted).
|
||||
|
||||
**Training method**: Direct Preference Optimization (DPO) or reward model training on the targeted model. The Bayesian quality scores (`BetaParams`) provide confidence-weighted preference signals — high-observation memories with clear quality separation are stronger training signals than low-observation ones.
|
||||
|
||||
**Frequency**: Batch, triggered when accumulated vote pairs exceed a threshold (e.g., 500 new preference pairs per domain).
|
||||
|
||||
### 4.2 Layer B: Imitation Learning from Successful Trajectories
|
||||
|
||||
**Data source**: SONA trajectories with quality > 0.7, exported via `AgentExport` from `sona::training::federated`. Each trajectory includes the sequence of tool calls, intermediate states, and final outcome.
|
||||
|
||||
**Training method**: Supervised fine-tuning on high-quality trajectories. Narrow and task-specific — only trajectories from the target domain (e.g., "debug" category memories with quality > 0.7).
|
||||
|
||||
**Curriculum**: Use `brain_partition` clusters to group trajectories by difficulty. Train easy clusters first, hard clusters later (curriculum learning).
|
||||
|
||||
### 4.3 Layer C: Continual Learning with Forgetting Control
|
||||
|
||||
**Data source**: All layers A and B, plus replay buffers keyed by domain and mincut partition.
|
||||
|
||||
**Training method**: Elastic Weight Consolidation (EWC) with lambda=2000.0 (matching SONA's existing EWC configuration). The Fisher information matrix is computed per-domain partition, so the model preserves capabilities in well-established domains while adapting to new ones.
|
||||
|
||||
**Replay buffer design**: Each mincut partition maintains a fixed-size replay buffer (100 exemplars). When a new exemplar is added, the lowest-quality one is evicted. This prevents catastrophic forgetting on older domains while allowing new domains to develop.
|
||||
|
||||
### 4.4 Training Data Flow
|
||||
|
||||
```
|
||||
Brain Events Training Pipeline
|
||||
----------- ------------------
|
||||
brain_vote(up/down) --> Layer A: Preference pairs
|
||||
brain_share(quality>0.7) --> Layer B: Imitation trajectories
|
||||
brain_partition clusters --> Layer C: EWC domains + replay buffers
|
||||
brain_sync (LoRA deltas) --> Continuous adapter refinement
|
||||
```
|
||||
|
||||
## 5. Parameter Growth Strategy
|
||||
|
||||
Add capacity only when a measured ceiling is reached. If the model cannot solve a class of tasks even with perfect retrieval and high-quality patterns, then expand.
|
||||
|
||||
### 5.1 Growth Decision Criteria
|
||||
|
||||
Trigger parameter growth when:
|
||||
- Success rate on a domain benchmark plateaus for 3+ epochs despite growing memory
|
||||
- Retrieval recall@10 is > 80% (the right knowledge is available) but task success < 60% (the model cannot use it)
|
||||
- MicroLoRA weight norms are saturating (approaching clipping bounds consistently)
|
||||
|
||||
### 5.2 Growth Options (Ordered by Cost)
|
||||
|
||||
**Option 1: Expand adapter rank** (cheapest)
|
||||
Increase MicroLoRA rank from 2 to 4 or 8 for specific layers. Keep base frozen. This quadruples expressiveness for 4x parameter cost (still < 10KB per sync).
|
||||
|
||||
**Option 2: Add specialist heads per domain partition** (moderate)
|
||||
Mixture-of-experts routing using mincut partitions as the router signal. Each partition gets a small specialist head. Base model shared, routing learned from partition assignments.
|
||||
|
||||
**Option 3: Train a larger ruvllm tier and distill** (expensive)
|
||||
Use the brain to provide curriculum and hard negative examples. Distill from the larger model back to the smaller one, keeping the brain-augmented inference path.
|
||||
|
||||
## 6. Evaluation Framework
|
||||
|
||||
### 6.1 Metrics
|
||||
|
||||
| Metric | Definition | Target |
|
||||
|--------|-----------|--------|
|
||||
| **Quality** | Success rate on fixed benchmark per domain | >= 80% on target domain |
|
||||
| **Efficiency** | Median tokens + tool calls per solved task | >= 30% reduction vs baseline |
|
||||
| **Reliability** | Variance reduction across epochs | Std dev < 0.5x baseline |
|
||||
| **Safety** | Poisoning simulation behavior deviation | < 5% regression under 30% adversarial |
|
||||
|
||||
### 6.2 Three-System Comparison
|
||||
|
||||
For each target domain, compare:
|
||||
1. **Baseline ruvllm**: No brain, no retrieval, no LoRA
|
||||
2. **ruvllm + brain retrieval**: brain_search for working memory, no LoRA
|
||||
3. **ruvllm + brain retrieval + MicroLoRA**: Full pipeline with federated weights
|
||||
|
||||
Pass criteria:
|
||||
- System 3 achieves >= 20% higher success rate than System 1
|
||||
- System 3 achieves >= 30% fewer tokens per success than System 1
|
||||
- Regressions held under 5% across 3 consecutive sync epochs
|
||||
|
||||
### 6.3 Initial Benchmark: Fix Failing Tests
|
||||
|
||||
**Domain**: Rust crate test failures across the RuVector monorepo.
|
||||
|
||||
**Benchmark size**: 100 items (real test failures extracted from CI history).
|
||||
|
||||
**Task format**: Given a failing test and its error output, produce a fix that makes the test pass.
|
||||
|
||||
**Success criteria**: The fix compiles and the specific test passes. No regressions in other tests.
|
||||
|
||||
**Why this domain first**:
|
||||
- Highest frequency task in daily development
|
||||
- Binary success signal (test passes or does not)
|
||||
- Bounded — Rust compiler errors are structured and classifiable
|
||||
- Rich trajectory data from existing sessions
|
||||
- Direct impact on developer velocity
|
||||
|
||||
## 7. Hybrid Lane Architecture
|
||||
|
||||
ruvllm is offline-capable by default. The brain is an optional accelerator, not a dependency.
|
||||
|
||||
### 7.1 Degradation Path
|
||||
|
||||
| Connectivity | Available Features | Performance |
|
||||
|-------------|-------------------|-------------|
|
||||
| **Full** (brain reachable) | All 11 MCP tools + MicroLoRA sync + retrieval | Best |
|
||||
| **Partial** (brain slow/intermittent) | Local SONA + cached consensus LoRA + hash features | Good |
|
||||
| **Offline** (no brain) | Local SONA + hash-only features | Baseline |
|
||||
|
||||
The `BrainEmbedder` already implements this degradation:
|
||||
- If consensus LoRA cached: use it (no network needed)
|
||||
- If SONA engine available: use local LoRA from SONA
|
||||
- Otherwise: structured hash features only
|
||||
|
||||
### 7.2 Sync Cadence
|
||||
|
||||
`brain_sync` is explicit opt-in, not automatic. Recommended cadence:
|
||||
- **On session start**: `brain_sync(direction: "pull")` — get latest consensus
|
||||
- **On session end**: `brain_sync(direction: "push")` — export local learning
|
||||
- **Periodically during long sessions**: Every ~100 embeddings processed
|
||||
|
||||
## 8. Implementation Status
|
||||
|
||||
### Implemented (in this branch)
|
||||
|
||||
| Component | Status |
|
||||
|-----------|--------|
|
||||
| Structured hash features (Stage 1 embedding) | Shipped, 6 new tests |
|
||||
| MicroLoRA forward pass (Stage 2 embedding) | Shipped |
|
||||
| Consensus import/export (`BrainEmbedder`) | Shipped |
|
||||
| `GET /v1/lora/latest` | Shipped |
|
||||
| `POST /v1/lora/submit` | Shipped |
|
||||
| Gate A policy validation | Shipped |
|
||||
| Gate B robust aggregation (median + MAD + reputation) | Shipped |
|
||||
| Consensus drift monitoring + rollback | Shipped |
|
||||
| `brain_sync` MCP tool | Shipped |
|
||||
| Hybrid degradation path | Shipped |
|
||||
|
||||
### Deferred (future work)
|
||||
|
||||
| Component | Priority | Dependency |
|
||||
|-----------|----------|------------|
|
||||
| Training data export endpoints | High | Need trajectory format spec |
|
||||
| Preference pair extraction from votes | High | Need DPO training loop |
|
||||
| Trajectory quality filtering | Medium | Need benchmark suite |
|
||||
| EWC per-partition Fisher matrices | Medium | Need partition stability |
|
||||
| Specialist head routing | Low | Need ceiling measurement |
|
||||
| 100-item benchmark harness | High | Need CI history extraction |
|
||||
|
||||
## 9. Answers to Design Questions
|
||||
|
||||
**Q: What is the narrowest set of tasks to beat a traditional LLM on first?**
|
||||
|
||||
A: Code debugging — specifically, fix failing tests in Rust crates. Highest frequency, clearest success signal, bounded domain, rich trajectory data already available.
|
||||
|
||||
**Q: Should ruvllm remain fully offline capable by default?**
|
||||
|
||||
A: Yes, with a hybrid lane for acceleration. The `BrainEmbedder` degradation path (consensus LoRA -> local SONA -> hash-only) ensures offline functionality. The brain is an accelerator, not a dependency.
|
||||
|
||||
## 10. Related ADRs
|
||||
|
||||
| ADR | Relationship |
|
||||
|-----|-------------|
|
||||
| ADR-057 | Federated RVF Transfer Learning — protocol foundation |
|
||||
| ADR-058 | Hash Security Optimization — SHAKE-256 for content integrity |
|
||||
| ADR-059 | Shared Brain Google Cloud — infrastructure and security |
|
||||
| ADR-060 | Shared Brain Capabilities — sub-capabilities and business outcomes |
|
||||
357
docs/adr/ADR-062-brainpedia-architecture.md
Normal file
357
docs/adr/ADR-062-brainpedia-architecture.md
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
# ADR-062: Brainpedia — Structured Knowledge Encyclopedia with Delta-Based Editing
|
||||
|
||||
**Status**: Accepted
|
||||
**Date**: 2026-02-27
|
||||
**Authors**: RuVector Team
|
||||
**Deciders**: ruv
|
||||
**Supersedes**: N/A
|
||||
**Related**: ADR-059 (Shared Brain Google Cloud), ADR-060 (Shared Brain Capabilities), ADR-061 (Reasoning Kernel Architecture)
|
||||
|
||||
## 1. Context
|
||||
|
||||
The Shared Brain (ADR-059, ADR-060) accumulates knowledge as individual memories — embeddings, quality scores, witness chains. But memories are granular artifacts, not structured knowledge. A developer looking for "how does RVF serialization work" may find 30 loosely related memories with no coherent narrative.
|
||||
|
||||
Wikipedia solves this for human text. The Brainpedia applies the same concept — canonical, community-maintained knowledge pages — but the unit of contribution is not raw text. It is a structured, versioned, verifiable knowledge artifact with outcomes.
|
||||
|
||||
Every entry is an RVF memory plus a delta stream. Contributions are deltas, not overwrites. Evidence links tie claims to outcome proofs. Transfer assets make knowledge portable across domains. The result is a knowledge encyclopedia that is executable, learning-native, transferable, and reversible.
|
||||
|
||||
## 2. Design Principles
|
||||
|
||||
### 2.1 The Unit of Contribution
|
||||
|
||||
A Brainpedia contribution is one of:
|
||||
|
||||
**RVF Memory**: A canonical knowledge artifact with the standard 10-segment layout (ADR-060 Section 2.5). This is the stable snapshot — the "current page version."
|
||||
|
||||
**Delta Entry**: A structured modification to an existing page. Deltas carry their own witness chain and are individually verifiable. A delta contains:
|
||||
- `parent_id`: The memory ID being modified
|
||||
- `delta_type`: One of `Correction`, `Extension`, `Evidence`, `Deprecation`
|
||||
- `content_diff`: The structured change (not raw text diff — semantic field updates)
|
||||
- `evidence_links`: Outcome proofs that support the change
|
||||
- `contributor_id`: Pseudonym of the contributor
|
||||
- `witness_hash`: SHAKE-256 chain entry for this delta
|
||||
|
||||
**Evidence Link**: A reference to a verifiable outcome that supports a claim. Evidence types:
|
||||
- `TestPass { test_name, repo, commit_hash }`: A test that passed after applying the knowledge
|
||||
- `BuildSuccess { pipeline_url, commit_hash }`: A CI build that succeeded
|
||||
- `MetricImproval { metric_name, before, after, measurement_url }`: A measured improvement
|
||||
- `PeerReview { reviewer_pseudonym, vote_direction, quality_score }`: A peer attestation
|
||||
|
||||
### 2.2 Properties of Knowledge Entries
|
||||
|
||||
Every Brainpedia entry is:
|
||||
|
||||
**Executable**: Contains embeddings, transfer priors, and policy kernels that can be directly consumed by `brain_search`, `brain_sync`, and `brain_transfer`. Knowledge is not just readable — it is machine-consumable.
|
||||
|
||||
**Learning-native**: Quality scores update via Bayesian voting (BetaParams). High-quality entries strengthen the MicroLoRA consensus. Low-quality entries are auto-archived. The encyclopedia improves through use.
|
||||
|
||||
**Transferable**: Transfer assets (TransferPrior, PolicyKernel, CostCurve) enable cross-domain knowledge portability. A debugging pattern from Rust can transfer to TypeScript with measured acceleration.
|
||||
|
||||
**Reversible**: No direct mutation. Every change is a delta. The full history is reconstructable from the delta stream. Any delta can be reverted by applying a compensating delta. Witness chains prove the order and integrity of all changes.
|
||||
|
||||
## 3. Page Structure
|
||||
|
||||
A Brainpedia page is a canonical memory plus its delta log.
|
||||
|
||||
### 3.1 Canonical Page
|
||||
|
||||
The canonical page is a `BrainMemory` with `page_status: Canonical`. It represents the current community-accepted version of the knowledge. Canonical pages have:
|
||||
|
||||
- `category`: The knowledge domain (Architecture, Pattern, Solution, Debug, etc.)
|
||||
- `title`: Human-readable page title (unique within category)
|
||||
- `content`: The structured knowledge content
|
||||
- `tags`: Searchable labels
|
||||
- `embedding`: Structured hash features + MicroLoRA transform (standard pipeline)
|
||||
- `quality_score`: Bayesian BetaParams accumulated from votes
|
||||
- `evidence_count`: Number of verified evidence links
|
||||
- `delta_count`: Number of applied deltas
|
||||
- `transfer_assets`: Optional TransferPrior + PolicyKernel + CostCurve
|
||||
|
||||
### 3.2 Delta Log
|
||||
|
||||
Each page maintains an ordered delta log. Deltas are immutable once accepted. The delta log enables:
|
||||
|
||||
- **Version reconstruction**: Any historical version can be reconstructed by replaying deltas
|
||||
- **Attribution**: Every change is attributed to a specific contributor
|
||||
- **Audit**: Witness chains prove the integrity of the delta sequence
|
||||
- **Revert**: A `Deprecation` delta marks a previous delta as superseded
|
||||
|
||||
### 3.3 Evidence Links
|
||||
|
||||
Evidence links are the mechanism by which the encyclopedia distinguishes claims from knowledge. A claim without evidence is a candidate. A claim with evidence is knowledge.
|
||||
|
||||
**Evidence lifecycle**:
|
||||
1. Contributor submits a delta with one or more evidence links
|
||||
2. Server verifies evidence link format and contributor authentication
|
||||
3. Evidence is recorded in the delta's witness chain
|
||||
4. Other contributors can add corroborating evidence via additional deltas
|
||||
5. Quality score reflects the evidence density: more verified evidence = higher quality
|
||||
|
||||
## 4. Governance Gates
|
||||
|
||||
Three gates control what enters the encyclopedia and how.
|
||||
|
||||
### 4.1 Gate 1: Identity
|
||||
|
||||
All contributions require API key authentication with contributor pseudonym derivation (same as ADR-060 Section 6). Anonymous users can read but cannot contribute. This prevents Sybil attacks on the knowledge base.
|
||||
|
||||
### 4.2 Gate 2: Evidence
|
||||
|
||||
**For deltas**: Every delta must include at least one evidence link. A correction without evidence is rejected. An extension without evidence is held in a `Proposed` state until evidence is added.
|
||||
|
||||
**For new pages**: A new canonical page requires at least 3 evidence links from independent contributors (not the page creator) before promotion from `Draft` to `Canonical`. This ensures community validation before knowledge becomes authoritative.
|
||||
|
||||
**Evidence verification**: The server does not execute tests or check CI — it verifies that evidence links are well-formed, that the contributor is authenticated, and that the linked artifacts exist (where checkable). Outcome verification is a community responsibility: voters assess whether evidence is credible.
|
||||
|
||||
### 4.3 Gate 3: Consensus
|
||||
|
||||
**Page promotion**: A Draft page becomes Canonical when:
|
||||
- `quality_score.mean() >= 0.7` (community approval)
|
||||
- `quality_score.observations() >= 5` (sufficient review)
|
||||
- `evidence_count >= 3` from `>= 2` distinct contributors
|
||||
|
||||
**Delta acceptance**: A delta is accepted when:
|
||||
- Contributor is authenticated (Gate 1)
|
||||
- At least one evidence link is provided (Gate 2)
|
||||
- Contributor has not been flagged for poisoning (reputation > 0.1)
|
||||
|
||||
**Delta promotion to canonical**: When a delta accumulates sufficient quality votes, the canonical page is updated to incorporate the delta. The old canonical version is preserved in the delta log.
|
||||
|
||||
## 5. Submission Model
|
||||
|
||||
### 5.1 New Users (Reputation < 0.5, Contribution Count < 10)
|
||||
|
||||
New users can:
|
||||
- Read all pages and delta logs
|
||||
- Submit deltas to existing pages (with evidence)
|
||||
- Vote on pages and deltas
|
||||
- Cannot create new canonical pages
|
||||
|
||||
This prevents low-reputation users from flooding the encyclopedia with low-quality pages while still allowing them to contribute improvements to existing knowledge.
|
||||
|
||||
### 5.2 Established Users (Reputation >= 0.5, Contribution Count >= 10)
|
||||
|
||||
Established users can:
|
||||
- All new user capabilities
|
||||
- Create new pages (initially as `Draft`)
|
||||
- Promote deltas with sufficient evidence
|
||||
|
||||
### 5.3 System Contributors
|
||||
|
||||
System contributors (`is_system: true`, e.g., `ruvector-seed`) can:
|
||||
- All established user capabilities
|
||||
- Create pages directly as `Canonical` (for seed data)
|
||||
- Bypass evidence count requirements (seed data is pre-validated)
|
||||
|
||||
## 6. Delta-Based Editing Protocol
|
||||
|
||||
### 6.1 No Direct Mutation
|
||||
|
||||
The Brainpedia has no "edit page" operation. All modifications are deltas. This is enforced at the API level — the `PUT /v1/pages/{id}` endpoint does not exist.
|
||||
|
||||
### 6.2 Delta Types
|
||||
|
||||
**Correction**: Fixes an error in the canonical page. Requires evidence showing the current content is wrong and the correction is right.
|
||||
|
||||
**Extension**: Adds new information to the canonical page. Requires evidence showing the extension is valid and useful.
|
||||
|
||||
**Evidence**: Adds a new evidence link to an existing claim. No content change — purely strengthens the evidentiary basis.
|
||||
|
||||
**Deprecation**: Marks the canonical page or a specific delta as superseded. Requires evidence showing why the content is no longer valid (e.g., a library version change, a security vulnerability discovered).
|
||||
|
||||
### 6.3 Conflict Resolution
|
||||
|
||||
When multiple deltas target the same section of a canonical page, conflict resolution uses:
|
||||
|
||||
1. **Quality score**: Higher-quality deltas take precedence
|
||||
2. **Evidence density**: More evidence wins ties
|
||||
3. **Recency**: Among equal quality and evidence, newer deltas win
|
||||
4. **Manual resolution**: If automated resolution fails, the page enters `Contested` status and requires a contributor with reputation >= 0.7 to resolve
|
||||
|
||||
## 7. First Domain: Code Debugging
|
||||
|
||||
The first domain for the Brainpedia is **code debugging** (category: `Debug`). Rationale (same as ADR-061 Section 6.3):
|
||||
|
||||
- Highest frequency task in daily development
|
||||
- Binary success signal (test passes or does not)
|
||||
- Bounded domain — compiler errors are structured and classifiable
|
||||
- Rich trajectory data from existing sessions
|
||||
- Direct impact on developer velocity
|
||||
|
||||
### 7.1 Initial Page Structure for Debug Domain
|
||||
|
||||
Each debug page covers a specific failure class:
|
||||
|
||||
**Title**: Descriptive name for the failure pattern (e.g., "tokio::spawn deadlock with parking_lot::RwLock")
|
||||
|
||||
**Content**: Structured fields:
|
||||
- `error_pattern`: The error message or symptom signature
|
||||
- `root_cause`: Why this failure occurs
|
||||
- `fix_pattern`: The canonical fix approach
|
||||
- `code_before`: Example code that triggers the failure
|
||||
- `code_after`: Example code after the fix
|
||||
- `applies_to`: Language, framework, and version constraints
|
||||
|
||||
**Evidence**: TestPass links showing the fix resolves the error in specific repositories.
|
||||
|
||||
**Transfer assets**: TransferPrior for similar concurrency patterns in other async runtimes.
|
||||
|
||||
### 7.2 Seed Pages
|
||||
|
||||
The seed pipeline (`ruvector-seed` contributor) will create initial debug pages from:
|
||||
- Known Rust compiler error patterns
|
||||
- Common `tokio` / `async-std` pitfalls
|
||||
- RVF serialization edge cases
|
||||
- MinCut graph construction errors
|
||||
- SONA learning convergence failures
|
||||
|
||||
## 8. Data Model Extensions
|
||||
|
||||
### 8.1 New Types
|
||||
|
||||
```rust
|
||||
/// Page status in the Brainpedia lifecycle
|
||||
pub enum PageStatus {
|
||||
Draft, // Newly created, awaiting evidence and votes
|
||||
Canonical, // Community-accepted, authoritative
|
||||
Contested, // Conflicting deltas, needs resolution
|
||||
Archived, // Superseded or low-quality, read-only
|
||||
}
|
||||
|
||||
/// A delta entry modifying a canonical page
|
||||
pub struct PageDelta {
|
||||
pub id: Uuid,
|
||||
pub page_id: Uuid, // The canonical page being modified
|
||||
pub delta_type: DeltaType,
|
||||
pub content_diff: serde_json::Value, // Structured field updates
|
||||
pub evidence_links: Vec<EvidenceLink>,
|
||||
pub contributor_id: String,
|
||||
pub quality_score: BetaParams,
|
||||
pub witness_hash: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub enum DeltaType {
|
||||
Correction,
|
||||
Extension,
|
||||
Evidence,
|
||||
Deprecation,
|
||||
}
|
||||
|
||||
/// Evidence linking a claim to a verifiable outcome
|
||||
pub struct EvidenceLink {
|
||||
pub evidence_type: EvidenceType,
|
||||
pub description: String,
|
||||
pub contributor_id: String,
|
||||
pub verified: bool, // Server-side format check passed
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub enum EvidenceType {
|
||||
TestPass { test_name: String, repo: String, commit_hash: String },
|
||||
BuildSuccess { pipeline_url: String, commit_hash: String },
|
||||
MetricImproval { metric_name: String, before: f64, after: f64 },
|
||||
PeerReview { reviewer: String, direction: VoteDirection, score: f64 },
|
||||
}
|
||||
```
|
||||
|
||||
### 8.2 Extended BrainMemory
|
||||
|
||||
The existing `BrainMemory` gains optional page metadata:
|
||||
|
||||
```rust
|
||||
// Added to BrainMemory
|
||||
pub page_status: Option<PageStatus>,
|
||||
pub evidence_count: u32,
|
||||
pub delta_count: u32,
|
||||
```
|
||||
|
||||
### 8.3 New API Endpoints
|
||||
|
||||
| Method | Path | Purpose | Gate |
|
||||
|--------|------|---------|------|
|
||||
| POST | `/v1/pages` | Create a new Draft page | Identity + Reputation >= 0.5 |
|
||||
| GET | `/v1/pages/{id}` | Get page with delta log | Public read |
|
||||
| POST | `/v1/pages/{id}/deltas` | Submit a delta | Identity + Evidence |
|
||||
| GET | `/v1/pages/{id}/deltas` | List deltas for a page | Public read |
|
||||
| POST | `/v1/pages/{id}/evidence` | Add evidence to a page | Identity |
|
||||
| POST | `/v1/pages/{id}/promote` | Promote Draft to Canonical | Consensus (auto-checked) |
|
||||
|
||||
### 8.4 New MCP Tools
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| `brain_page_create` | Create a new knowledge page (Draft) |
|
||||
| `brain_page_get` | Get a page with its delta log and evidence |
|
||||
| `brain_page_delta` | Submit a delta to an existing page |
|
||||
| `brain_page_evidence` | Add evidence to a page or delta |
|
||||
| `brain_page_search` | Search pages by title, content, or category |
|
||||
| `brain_page_list` | List pages by category, status, or quality |
|
||||
|
||||
## 9. Implementation Status
|
||||
|
||||
### Deferred (Future Work)
|
||||
|
||||
All components described in this ADR are deferred for future implementation. They build on the existing Shared Brain infrastructure (ADR-059, ADR-060) which is fully implemented.
|
||||
|
||||
| Component | Priority | Dependency |
|
||||
|-----------|----------|------------|
|
||||
| `PageStatus` enum + `PageDelta` types | High | None (types only) |
|
||||
| Delta submission endpoint | High | Types |
|
||||
| Evidence link validation | High | Delta endpoint |
|
||||
| Page creation with reputation gate | Medium | Reputation system (shipped) |
|
||||
| Page promotion logic | Medium | Evidence + quality thresholds |
|
||||
| Conflict resolution | Low | Multiple concurrent deltas |
|
||||
| Seed debug pages | High | Page creation endpoint |
|
||||
| MCP tools (6 new) | Medium | All endpoints |
|
||||
|
||||
## 10. Acceptance Criteria
|
||||
|
||||
### Evidence-Gated Quality
|
||||
|
||||
- 50 debug pages seeded from known Rust error patterns
|
||||
- Each page requires >= 3 evidence links before Canonical promotion
|
||||
- Pages without evidence remain Draft and are excluded from `brain_search` top-k results
|
||||
- Quality score distribution: Canonical pages have mean quality >= 0.7
|
||||
|
||||
### Delta Integrity
|
||||
|
||||
- Full delta log reconstructs any historical page version
|
||||
- Every delta has a witness chain entry verifiable by any participant
|
||||
- Deprecation deltas correctly mark superseded content
|
||||
- No direct mutation path exists (no PUT endpoint on page content)
|
||||
|
||||
### Reputation Gating
|
||||
|
||||
- New users (reputation < 0.5) cannot create pages (403 response)
|
||||
- New users can submit deltas with evidence (accepted)
|
||||
- System contributors can create Canonical pages directly
|
||||
- Poisoned contributor (reputation < 0.1) cannot submit deltas (403 response)
|
||||
|
||||
### First Domain Validation
|
||||
|
||||
Run on the Debug category:
|
||||
- 50 seeded debug pages covering common Rust error patterns
|
||||
- 10 community-contributed deltas with TestPass evidence
|
||||
- 5 pages promoted from Draft to Canonical via consensus
|
||||
- Mean search recall@10 on debug queries >= 40% improvement vs unstructured memories
|
||||
|
||||
## 11. Answers to Design Questions
|
||||
|
||||
**Q: What is the first domain you want the community Brainpedia to cover?**
|
||||
|
||||
A: Code debugging. Same rationale as ADR-061: highest frequency task, binary success signal, bounded domain, richest existing trajectory data. Debug pages have natural evidence links (TestPass, BuildSuccess) that make the evidence gate practical from day one.
|
||||
|
||||
**Q: Do you want anyone to be able to submit pages, or only submit deltas to existing pages until they earn reputation?**
|
||||
|
||||
A: Deltas only until reputation is earned. New users (composite reputation < 0.5, contribution count < 10) can submit deltas to existing pages but cannot create new pages. This prevents low-reputation flooding while encouraging incremental improvement. Page creation requires demonstrated quality through delta contributions.
|
||||
|
||||
## 12. Related ADRs
|
||||
|
||||
| ADR | Relationship |
|
||||
|-----|-------------|
|
||||
| ADR-057 | Federated RVF Transfer Learning — protocol foundation |
|
||||
| ADR-058 | Hash Security Optimization — SHAKE-256 for content integrity |
|
||||
| ADR-059 | Shared Brain Google Cloud — infrastructure and security |
|
||||
| ADR-060 | Shared Brain Capabilities — sub-capabilities and business outcomes |
|
||||
| ADR-061 | Reasoning Kernel Architecture — training pipeline that consumes Brainpedia data |
|
||||
399
docs/adr/ADR-063-wasm-executable-nodes.md
Normal file
399
docs/adr/ADR-063-wasm-executable-nodes.md
Normal file
|
|
@ -0,0 +1,399 @@
|
|||
# ADR-063: WASM Executable Nodes — Deterministic Compute at the Edge
|
||||
|
||||
**Status**: Accepted
|
||||
**Date**: 2026-02-27
|
||||
**Authors**: RuVector Team
|
||||
**Deciders**: ruv
|
||||
**Supersedes**: N/A
|
||||
**Related**: ADR-059 (Shared Brain Google Cloud), ADR-060 (Shared Brain Capabilities), ADR-062 (Brainpedia Architecture)
|
||||
|
||||
## 1. Context
|
||||
|
||||
The Shared Brain stores knowledge as embeddings and serves MicroLoRA weights. But the embedding pipeline — structured hash features, LoRA transform, L2 normalization — runs only on the server and in the local MCP client. Browser users, edge appliances, and third-party integrations cannot produce compatible embeddings without reimplementing the pipeline.
|
||||
|
||||
WASM solves this. A signed WASM module that implements feature extraction can run identically in the browser, on the appliance, and on the server. The same bytecode, the same results, cryptographically verified.
|
||||
|
||||
This ADR defines the WASM node architecture: how nodes are built, signed, served, executed, and integrated with the Shared Brain and MicroLoRA pipeline.
|
||||
|
||||
## 2. What WASM Nodes Enable
|
||||
|
||||
### 2.1 Executable Knowledge Pages
|
||||
|
||||
A Brainpedia entry can include a small WASM module that validates, scores, normalizes, or transforms content locally. Knowledge becomes executable, not just readable.
|
||||
|
||||
### 2.2 Deterministic Verification
|
||||
|
||||
The same scoring and feature extraction runs in browser, on the appliance, and on the server with identical results. No "works on my machine" for embeddings.
|
||||
|
||||
### 2.3 Safer Public Compute
|
||||
|
||||
WASM with capability gating lets untrusted users run brain logic without server-side code execution. Pure compute only — no filesystem, no network, no clock access.
|
||||
|
||||
## 3. Node Architecture
|
||||
|
||||
### 3.1 WASM Segment Type
|
||||
|
||||
WASM nodes are signed RVF artifacts. They use the existing RVF segment layout with a new segment type for WASM bytecode.
|
||||
|
||||
A node RVF container contains:
|
||||
- `MANIFEST (0x05)`: Segment directory
|
||||
- `META_SEG (0x07)`: Node name, version, description, interface schema
|
||||
- `WASM_SEG (0x40)`: WASM bytecode (compiled, not WAT)
|
||||
- `WITNESS_SEG (0x0A)`: SHAKE-256 hash chain (build → sign → publish)
|
||||
- `CRYPTO_SEG (0x0C)`: Ed25519 signature over all segments
|
||||
|
||||
### 3.2 Hash Choice Consistency
|
||||
|
||||
Two hash functions serve distinct purposes:
|
||||
|
||||
**SHA-256** for HTTP-layer integrity: `X-Node-SHA256` header, `wasm_sha256` in signed manifests, GCS content-addressed storage paths, browser cache keys. SHA-256 is conventional for HTTP caching and CDN infrastructure.
|
||||
|
||||
**SHAKE-256** for RVF witness chains: all WITNESS_SEG entries use SHAKE-256 to maintain consistency with the global RVF witness chain convention (ADR-058). Witness chains are internal to the RVF container format, not exposed via HTTP.
|
||||
|
||||
These do not conflict. SHA-256 is the identity hash visible to HTTP clients. SHAKE-256 is the provenance hash visible to RVF verifiers.
|
||||
|
||||
### 3.3 V1 ABI Specification
|
||||
|
||||
The v1 ABI is `feature_extract` only. `score` and `validate` are deferred to v2 after the determinism guarantee is proven on the simpler single-function interface. Keeping v1 to one function reduces the ABI surface that must be verified across three runtimes.
|
||||
|
||||
**ABI version**: `1`
|
||||
|
||||
**Required exports**:
|
||||
- `memory`: Linear memory export (must declare a maximum size, rejected if absent or > 16 pages)
|
||||
- `malloc(size: i32) -> i32`: Allocate memory for input
|
||||
- `feature_extract_dim() -> i32`: Return the output dimension (must be constant, e.g., 128)
|
||||
- `feature_extract(in_ptr: i32, in_len: i32, out_ptr: i32) -> i32`: Extract features from text at `in_ptr`/`in_len`, write f32 vector to `out_ptr`, return dimension written
|
||||
|
||||
**Calling convention**:
|
||||
1. Caller invokes `feature_extract_dim()` to learn output size
|
||||
2. Caller allocates input buffer via `malloc(input_bytes_len)`
|
||||
3. Caller writes UTF-8 text into the allocated buffer
|
||||
4. Caller allocates output buffer via `malloc(dim * 4)` (f32 = 4 bytes)
|
||||
5. Caller invokes `feature_extract(in_ptr, in_len, out_ptr)` → returns `dim`
|
||||
6. Caller reads `dim` f32 values directly from `memory` at `out_ptr`
|
||||
|
||||
No global result state. No `read_f32` indirection. The caller owns both input and output pointers. This is the fastest path in both browser and wasmtime.
|
||||
|
||||
**Embedded lookup tables**: Nodes MAY include constant data (token maps, n-gram tables, normalization tables) compiled into the WASM data section. This is fine — the data section is part of the signed bytecode and does not violate the pure-compute constraint. The data section is immutable at runtime.
|
||||
|
||||
### 3.4 Determinism Constraints
|
||||
|
||||
"Bit-identical" is a strong claim. These constraints make it true in practice:
|
||||
|
||||
1. **No f64 in v1 nodes**: All arithmetic uses f32 only. f64 intermediate results are prohibited because fused multiply-add behavior varies across engines for f64.
|
||||
2. **No SIMD for v1 nodes**: The WASM module must not use the SIMD proposal. SIMD instruction selection and NaN propagation differ across engines.
|
||||
3. **Deterministic compilation flags**: Nodes must be compiled with `no-fast-math` (Rust: `-C target-feature=-fast-math`, or simply do not opt in). No `-ffast-math`, no `reassociate`, no `reciprocal`.
|
||||
4. **NaN scrubbing before output**: The last operation before writing each f32 to the output buffer must check for NaN and replace with 0.0f32. This prevents non-deterministic NaN propagation from producing environment-dependent results.
|
||||
5. **Rounding mode**: IEEE 754 round-to-nearest-even (the WASM default). No explicit rounding mode changes.
|
||||
|
||||
These constraints are verified at publish time where possible (SIMD detection, f64 usage detection via module inspection) and at runtime via conformance test vectors.
|
||||
|
||||
### 3.5 Capability Model
|
||||
|
||||
**V1: zero imports**. The WASM module's import section must be empty. No `env`, no WASI, no custom imports. If the import section is non-empty, the module is rejected at publish time. This is the cleanest rule for pure compute.
|
||||
|
||||
If future nodes require host capabilities (v2+), they are served from a separate endpoint (`/v1/nodes/privileged/`) with explicit capability declarations. Privileged nodes require reputation >= 0.7 to publish.
|
||||
|
||||
### 3.6 Resource Limits
|
||||
|
||||
Resource limits are defined differently per environment because browser Workers lack instruction-level metering.
|
||||
|
||||
**Server and appliance (wasmtime)**:
|
||||
- Max fuel: calibrated to 25ms on reference CPU (equivalent to ~100M simple instructions)
|
||||
- Max memory: module must declare maximum <= 16 pages (1MB)
|
||||
- Epoch interruption as backstop
|
||||
|
||||
**Browser (WebAssembly.instantiate in Worker)**:
|
||||
- Max wall clock: 25ms (Worker `setTimeout` termination)
|
||||
- Max input size: 8KB UTF-8
|
||||
- Worker isolation: `postMessage` only, no DOM, no fetch, no storage
|
||||
|
||||
**All environments**:
|
||||
- Max WASM binary size: 1MB
|
||||
- Max input size: 8KB UTF-8
|
||||
|
||||
The 25ms budget is aligned across environments. Server fuel is calibrated against reference CPU to approximate 25ms of compute.
|
||||
|
||||
## 4. Signing and Verification
|
||||
|
||||
### 4.1 Canonical Signed Manifest
|
||||
|
||||
The Ed25519 signature covers a canonical binary manifest, not raw JSON. This prevents serialization differences from breaking verification.
|
||||
|
||||
**Signed manifest fields** (fixed-order binary encoding):
|
||||
|
||||
| Field | Type | Bytes |
|
||||
|-------|------|-------|
|
||||
| `abi_version` | u8 | 1 |
|
||||
| `id_len` | u16 LE | 2 |
|
||||
| `id` | UTF-8 | variable |
|
||||
| `version_len` | u16 LE | 2 |
|
||||
| `version` | UTF-8 | variable |
|
||||
| `dim` | u16 LE | 2 |
|
||||
| `wasm_sha256` | [u8; 32] | 32 |
|
||||
| `created_at` | i64 LE (Unix epoch seconds) | 8 |
|
||||
| `allowed_imports_hash` | [u8; 32] | 32 (SHA-256 of empty string for v1) |
|
||||
| `compiler_tag_len` | u16 LE | 2 |
|
||||
| `compiler_tag` | UTF-8 | variable (e.g., "rustc-1.77-wasm32") |
|
||||
|
||||
The signature is `Ed25519.sign(signing_key, manifest_bytes)`. Verification is `Ed25519.verify(public_key, manifest_bytes, signature)`.
|
||||
|
||||
This encoding is deterministic: fixed field order, little-endian integers, explicit length prefixes. No JSON, no CBOR, no ambiguity.
|
||||
|
||||
### 4.2 Verification Flow
|
||||
|
||||
1. Client fetches node metadata (`GET /v1/nodes/{id}/{version}`)
|
||||
2. Client fetches WASM binary (`GET /v1/nodes/{id}/{version}/node.wasm`)
|
||||
3. Client computes SHA-256 of WASM bytes, compares to `wasm_sha256` in metadata
|
||||
4. Client reconstructs manifest bytes from metadata fields
|
||||
5. Client verifies Ed25519 signature over manifest bytes
|
||||
6. Client runs conformance test vectors (Section 5)
|
||||
7. If all pass: instantiate and use. If any fail: reject.
|
||||
|
||||
## 5. Conformance Test Vectors
|
||||
|
||||
Conformance test vectors are first-class policy, not optional documentation.
|
||||
|
||||
### 5.1 Test Vector Format
|
||||
|
||||
Every node's metadata includes a `conformance` object:
|
||||
|
||||
```json
|
||||
{
|
||||
"abi_version": 1,
|
||||
"dim": 128,
|
||||
"test_vectors": [
|
||||
{
|
||||
"input": "deadlock tokio spawn rwlock",
|
||||
"expected_output_sha256": "a1b2c3d4..."
|
||||
},
|
||||
{
|
||||
"input": "",
|
||||
"expected_output_sha256": "e5f6a7b8..."
|
||||
},
|
||||
{
|
||||
"input": "日本語テスト Unicode edge case",
|
||||
"expected_output_sha256": "c9d0e1f2..."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**20 mandatory test vectors** including:
|
||||
- Empty string
|
||||
- Single character
|
||||
- ASCII only
|
||||
- Unicode (CJK, emoji, combining characters, RTL)
|
||||
- Maximum length (8KB)
|
||||
- Repeated tokens
|
||||
- Whitespace-only
|
||||
- Mixed case sensitivity test pairs
|
||||
|
||||
### 5.2 Verification Method
|
||||
|
||||
For each test vector:
|
||||
1. Run `feature_extract` with the input text
|
||||
2. Read the output f32 vector as raw bytes (little-endian)
|
||||
3. Compute SHA-256 of the raw output bytes
|
||||
4. Compare to `expected_output_sha256`
|
||||
|
||||
**Output byte hash, not float comparison.** Float printing can hide differences. Raw byte SHA-256 catches any bit-level divergence.
|
||||
|
||||
### 5.3 Runtime Self-Test
|
||||
|
||||
Every runtime (browser, server, appliance) runs all conformance vectors on first load of a node. If any vector fails, the node is rejected and not used. The server logs conformance failures as security events.
|
||||
|
||||
## 6. Endpoints
|
||||
|
||||
### 6.1 Node Registry
|
||||
|
||||
| Method | Path | Purpose | Auth |
|
||||
|--------|------|---------|------|
|
||||
| GET | `/v1/nodes` | List available nodes | Public |
|
||||
| GET | `/v1/nodes/{id}/{version}` | Get node metadata + conformance vectors | Public |
|
||||
| GET | `/v1/nodes/{id}/{version}/node.wasm` | Download WASM binary | Public |
|
||||
| POST | `/v1/nodes` | Publish a new node | Identity + Reputation >= 0.5 |
|
||||
| DELETE | `/v1/nodes/{id}/{version}` | Revoke a node (original publisher only) | Identity |
|
||||
|
||||
### 6.2 Version Resolution
|
||||
|
||||
There is no `GET /v1/nodes/{id}.wasm` (no implicit "latest"). Clients must specify both `id` and `version`. This prevents supply-chain attacks where a compromised node replaces a trusted version.
|
||||
|
||||
The `GET /v1/nodes` listing includes all versions of all nodes. Clients filter by `id` and select the desired version.
|
||||
|
||||
### 6.3 Response Headers
|
||||
|
||||
**GET /v1/nodes/{id}/{version}/node.wasm**:
|
||||
- `Content-Type: application/wasm`
|
||||
- `Cache-Control: public, immutable, max-age=31536000`
|
||||
- `X-Node-SHA256: {hex-encoded SHA-256 of WASM bytes}`
|
||||
|
||||
Immutable caching is safe because the version is in the path. New versions get new paths.
|
||||
|
||||
### 6.4 Revocation
|
||||
|
||||
`DELETE /v1/nodes/{id}/{version}` marks a node as revoked:
|
||||
- Only the original publisher can revoke
|
||||
- Revocation writes an audit record to the witness chain
|
||||
- WASM bytes remain in storage (for forensic analysis), but the registry stops serving the node
|
||||
- `GET /v1/nodes/{id}/{version}` returns 410 Gone with a revocation reason
|
||||
- `GET /v1/nodes/{id}/{version}/node.wasm` returns 410 Gone
|
||||
|
||||
### 6.5 Content-Addressed Storage
|
||||
|
||||
GCS stores WASM binaries by content hash, not by ID:
|
||||
|
||||
```
|
||||
gs://ruvector-brain-{region}/nodes/sha256/{hash}.wasm
|
||||
```
|
||||
|
||||
The registry maps `(id, version)` → `hash`. Multiple nodes referencing the same bytecode share storage. This makes bytes immutable at the storage layer — the registry controls visibility, not the blob store.
|
||||
|
||||
## 7. Integration with Shared Brain
|
||||
|
||||
### 7.1 Embedding Pipeline in Browser
|
||||
|
||||
The clean split for browser-side semantic search:
|
||||
|
||||
1. **WASM node** (`sona_feature/1.0.0`): runs `feature_extract()` to produce structured hash features (128-dim f32 vector)
|
||||
2. **Brain API** (`GET /v1/lora/latest`): browser fetches current MicroLoRA weights (2KB JSON)
|
||||
3. **JS or second WASM node**: applies MicroLoRA transform `output = L2_norm(features + scale * (features @ down) @ up)`
|
||||
4. **Brain API** (`GET /v1/memories/search`): browser sends the transformed embedding for server-side ranked search
|
||||
|
||||
This gives semantic retrieval in the browser with no heavy model download.
|
||||
|
||||
### 7.2 Brainpedia Integration
|
||||
|
||||
Brainpedia pages (ADR-062) can reference WASM nodes:
|
||||
- A debug page can link to a `feature_extract` node for domain-specific embedding
|
||||
- Evidence links can include deterministic WASM validation results
|
||||
- Pages declare which node version they were indexed with for reproducibility
|
||||
|
||||
### 7.3 Appliance Mode
|
||||
|
||||
Edge appliances run the same WASM nodes as the browser and server:
|
||||
|
||||
1. Load node from local cache or `GET /v1/nodes/{id}/{version}/node.wasm`
|
||||
2. Run conformance self-test (Section 5.3)
|
||||
3. Load MicroLoRA weights from local cache or `GET /v1/lora/latest`
|
||||
4. Run locally: `feature_extract()` → LoRA transform → L2 normalize
|
||||
5. Search: send embedding to brain, or search local cache
|
||||
|
||||
Offline: steps 1-4 use cached artifacts. Step 5 searches local cache only.
|
||||
|
||||
## 8. Runtime Selection
|
||||
|
||||
| Environment | Runtime | Resource Control | Isolation |
|
||||
|-------------|---------|-----------------|-----------|
|
||||
| Browser | `WebAssembly.instantiate` in Worker | 25ms wall clock + 8KB input cap | Worker sandbox, no imports |
|
||||
| Server | wasmtime 18+ | Fuel (~25ms ref CPU) + 16 page memory | No imports, epoch interruption |
|
||||
| Appliance | wasmtime 18+ | Fuel (~25ms ref CPU) + 16 page memory | No imports, fuel metering |
|
||||
| Test | wasmtime | Fuel + conformance vector check | No imports |
|
||||
|
||||
All environments produce bit-identical results for the same input and node SHA, verified by output byte hash comparison (Section 5.2).
|
||||
|
||||
## 9. Security Summary
|
||||
|
||||
### 9.1 Module Validation at Publish Time
|
||||
|
||||
1. WASM binary is parsed and validated (valid WASM module)
|
||||
2. Import section must be empty (no imports of any kind)
|
||||
3. Memory must declare a maximum <= 16 pages
|
||||
4. No SIMD instructions (module inspection)
|
||||
5. No f64 usage (module inspection)
|
||||
6. Required exports present: `memory`, `malloc`, `feature_extract_dim`, `feature_extract`
|
||||
7. Ed25519 signature verified over canonical binary manifest
|
||||
8. SHA-256 computed and stored
|
||||
9. All conformance test vectors pass on server-side wasmtime
|
||||
10. Contributor reputation >= 0.5
|
||||
11. Size <= 1MB
|
||||
|
||||
### 9.2 Client-Side Verification
|
||||
|
||||
1. SHA-256 hash match (WASM bytes vs metadata)
|
||||
2. Ed25519 signature verification (canonical manifest)
|
||||
3. Conformance test vectors pass locally
|
||||
4. Only then: instantiate module
|
||||
|
||||
## 10. Implementation Status
|
||||
|
||||
### Shipped (in this branch)
|
||||
|
||||
| Component | Status |
|
||||
|-----------|--------|
|
||||
| `WasmNode` types in server | Shipped |
|
||||
| `GET /v1/nodes` list endpoint | Shipped |
|
||||
| `GET /v1/nodes/{id}` metadata endpoint | Shipped |
|
||||
| `GET /v1/nodes/{id}.wasm` binary endpoint | Shipped |
|
||||
| `POST /v1/nodes` publish endpoint | Shipped |
|
||||
| Node storage in `FirestoreClient` | Shipped |
|
||||
|
||||
### Deferred (Future Work)
|
||||
|
||||
| Component | Priority | Dependency |
|
||||
|-----------|----------|------------|
|
||||
| Versioned URL scheme (`/v1/nodes/{id}/{version}/...`) | High | Route refactor |
|
||||
| `DELETE /v1/nodes/{id}/{version}` revocation | High | Audit logging |
|
||||
| Module inspection (SIMD/f64 detection) | High | WASM parser |
|
||||
| Canonical binary manifest signing | High | Manifest encoder |
|
||||
| Conformance test vector validation | High | Reference node |
|
||||
| Content-addressed GCS storage | Medium | GCS integration |
|
||||
| `sona_feature` v1 reference node | High | WASM compilation |
|
||||
| Browser demo HTML | Medium | Reference node |
|
||||
| Worker isolation wrapper | Medium | Browser demo |
|
||||
| wasmtime server-side runner | Medium | Server integration |
|
||||
| MCP tools (`brain_node_list`, `brain_node_get`) | Low | All endpoints |
|
||||
|
||||
## 11. Answers to Design Questions
|
||||
|
||||
**Q: Do you want these WASM nodes to run in browser only, or also on the appliance and server with the same bytecode?**
|
||||
|
||||
A: All three. Browser, appliance, and server execute the same WASM bytecode with bit-identical results, verified by output byte hash comparison across environments.
|
||||
|
||||
**Q: Should nodes be public read like memory search, or should node download be gated because it encodes proprietary heuristics?**
|
||||
|
||||
A: Public read. The proprietary value is in the MicroLoRA weights (learned from quality signals via federation), not in the deterministic feature extraction logic. Gating node download would kill the browser demo path for zero competitive benefit.
|
||||
|
||||
**Q: Do you want the v1 node ABI to support only feature_extract, or do you want score and validate in v1 as well?**
|
||||
|
||||
A: `feature_extract` only for v1. One function, one ABI, one determinism proof. `score` and `validate` are v2 after the cross-platform guarantee is proven on the simpler interface.
|
||||
|
||||
**Q: Should nodes be allowed to include a tiny embedded lookup table, or must they be pure hashing with no internal data beyond constants?**
|
||||
|
||||
A: Lookup tables are allowed. Token maps, n-gram tables, and normalization tables compiled into the WASM data section are fine — the data section is part of the signed bytecode and is immutable at runtime. This is necessary for practical feature extraction.
|
||||
|
||||
## 12. Acceptance Criteria
|
||||
|
||||
### Deterministic Cross-Platform Verification
|
||||
|
||||
- Pick 20 canonical inputs (including Unicode edge cases, empty string, max length)
|
||||
- Store expected output SHA-256 values in node metadata `conformance.test_vectors`
|
||||
- Browser demo shows green for all 20 vectors
|
||||
- Server and appliance startup self-tests pass the same 20 vectors
|
||||
- Compare output **byte hashes** (SHA-256 of raw f32 bytes), not float arrays
|
||||
- p95 execution under 25ms for 128-dim output on all three environments
|
||||
|
||||
### Integration
|
||||
|
||||
- Browser produces embedding via WASM `feature_extract` + JS MicroLoRA transform
|
||||
- Browser embedding and server-produced embedding match **byte for byte** given the same node SHA and the same LoRA epoch
|
||||
- `brain_search` returns identical top-5 results for browser-produced vs server-produced embeddings
|
||||
|
||||
### Security
|
||||
|
||||
- Module with non-empty import section: rejected at publish time
|
||||
- Module without memory maximum: rejected at publish time
|
||||
- Module with SIMD or f64: rejected at publish time (when module inspection is implemented)
|
||||
- Module exceeding 1MB: rejected at publish time
|
||||
- Module without valid Ed25519 signature: rejected by client
|
||||
- Conformance vector failure: node rejected at runtime
|
||||
- Revoked node: returns 410 Gone
|
||||
|
||||
## 13. Related ADRs
|
||||
|
||||
| ADR | Relationship |
|
||||
|-----|-------------|
|
||||
| ADR-058 | Hash Security Optimization — the feature extraction algorithm nodes implement |
|
||||
| ADR-059 | Shared Brain Google Cloud — infrastructure serving node binaries |
|
||||
| ADR-060 | Shared Brain Capabilities — MicroLoRA weights that complement WASM features |
|
||||
| ADR-062 | Brainpedia Architecture — pages can reference WASM nodes for validation |
|
||||
210
docs/adr/ADR-064-pi-brain-infrastructure.md
Normal file
210
docs/adr/ADR-064-pi-brain-infrastructure.md
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
# ADR-064: Pi Brain Infrastructure & Landing Page
|
||||
|
||||
**Status**: Accepted, Deployed
|
||||
**Date**: 2026-02-28
|
||||
**Authors**: RuVector Team
|
||||
**Deciders**: ruv
|
||||
**Supersedes**: N/A
|
||||
**Related**: ADR-059 (Shared Brain Google Cloud), ADR-060 (Shared Brain Capabilities), ADR-066 (SSE MCP Transport)
|
||||
|
||||
## 1. Context
|
||||
|
||||
The Shared Brain (ADR-059, ADR-060) requires a production deployment surface that is both functional (serving REST and MCP endpoints) and discoverable (a public-facing landing page that communicates what the brain is). A bare API with no human-readable entry point creates adoption friction. Developers who visit the domain should immediately understand the system and how to connect.
|
||||
|
||||
Google Cloud Run provides a serverless container platform with automatic TLS, scaling to zero, and custom domain mapping. Custom domains (pi.ruv.io, the Unicode variant) make the brain addressable under a memorable, branded namespace. A landing page with a Three.js visualization gives the project a distinctive identity rooted in the Foundation (Asimov) sci-fi aesthetic.
|
||||
|
||||
## 2. Decision
|
||||
|
||||
Deploy `mcp-brain-server` as a Cloud Run service (`ruvbrain`) in `us-central1`. Map custom domains through Google Cloud's domain verification and Cloudflare DNS. Embed static HTML landing pages at compile time using `include_str!` so the binary is fully self-contained with no runtime file I/O for static content.
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
### 3.1 Cloud Run Service
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Service name | `ruvbrain` |
|
||||
| Region | `us-central1` |
|
||||
| Image | Multi-stage Dockerfile: `rust:1.85-bookworm` builder, `debian:bookworm-slim` runtime |
|
||||
| Port | 8080 (configured via `PORT` env var) |
|
||||
| Scaling | 0-10 instances, 2 CPU / 2Gi RAM per instance |
|
||||
| Concurrency | Default (80 requests per instance) |
|
||||
| Startup | `mcp-brain-server` binary, tracing to stderr |
|
||||
|
||||
The Dockerfile uses a two-stage build. The builder stage compiles the full workspace with `cargo build --release -p mcp-brain-server`. The runtime stage copies only the binary and installs `ca-certificates` for HTTPS outbound calls to Firestore and GCS.
|
||||
|
||||
### 3.2 Custom Domains
|
||||
|
||||
| Domain | Type | Target |
|
||||
|--------|------|--------|
|
||||
| `pi.ruv.io` | CNAME | `ghs.googlehosted.com` |
|
||||
| `xn--1xa.ruv.io` (pi.ruv.io) | CNAME | `ghs.googlehosted.com` |
|
||||
|
||||
DNS is managed through Cloudflare. The CNAME records point to Google's hosted services endpoint, which handles TLS termination and routes to the Cloud Run service. Google Cloud domain mapping verifies ownership and provisions managed TLS certificates automatically.
|
||||
|
||||
### 3.3 Persistence Layer
|
||||
|
||||
**Firestore**: Stores brain memories, page metadata, WASM node metadata, contributor reputations, and LoRA federation state. The `FirestoreClient` hydrates an in-memory cache on startup via `load_from_firestore()`. Writes are dual-written to both local cache and Firestore. When `FIRESTORE_URL` is not set, the server operates in local-only mode for development.
|
||||
|
||||
**Google Cloud Storage (GCS)**: Stores RVF containers and WASM node binaries. Content-addressed storage for WASM nodes uses `gs://ruvector-brain-{region}/nodes/sha256/{hash}.wasm`. The `GcsClient` handles uploads and signed URL generation.
|
||||
|
||||
### 3.4 Landing Page
|
||||
|
||||
The root route (`/`) serves a Three.js Prime Radiant visualization. The page renders an interactive 3D particle system representing the brain's knowledge topology. The aesthetic follows a Foundation (Asimov) sci-fi theme: dark background, blue-white particle fields, Encyclopedia Galactica typography.
|
||||
|
||||
Implementation: `include_str!("../static/index.html")` embeds the HTML at compile time. The handler returns the static content with `Content-Type: text/html; charset=utf-8` and `Cache-Control: public, max-age=300`. No runtime filesystem access required.
|
||||
|
||||
### 3.5 Origin Story
|
||||
|
||||
The `/origin` route serves an animated narrative page explaining the brain's purpose and design philosophy. Same embed pattern: `include_str!("../static/origin.html")`. Same cache headers.
|
||||
|
||||
### 3.6 Static Embed Pattern
|
||||
|
||||
```rust
|
||||
async fn landing_page() -> (StatusCode, [(HeaderName, &'static str); 2], &'static str) {
|
||||
(
|
||||
StatusCode::OK,
|
||||
[
|
||||
(CONTENT_TYPE, "text/html; charset=utf-8"),
|
||||
(CACHE_CONTROL, "public, max-age=300"),
|
||||
],
|
||||
include_str!("../static/index.html"),
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
This pattern has three advantages:
|
||||
1. **Zero runtime I/O**: No file reads, no path resolution, no directory traversal risk
|
||||
2. **Single binary deployment**: The container image contains one executable with no static file dependencies
|
||||
3. **Compile-time verification**: If the HTML file is missing, the build fails immediately
|
||||
|
||||
## 4. Implementation
|
||||
|
||||
### 4.1 Router Structure
|
||||
|
||||
The `create_router()` function in `routes.rs` constructs the full axum Router:
|
||||
|
||||
```rust
|
||||
Router::new()
|
||||
.route("/", get(landing_page))
|
||||
.route("/origin", get(origin_page))
|
||||
.route("/v1/health", get(health))
|
||||
// ... 14 REST endpoints (ADR-060)
|
||||
// ... 6 Brainpedia endpoints (ADR-062)
|
||||
// ... 5 WASM node endpoints (ADR-063)
|
||||
.route("/sse", get(sse_handler))
|
||||
.route("/messages", post(messages_handler))
|
||||
.layer(CorsLayer::new()
|
||||
.allow_origin([
|
||||
"https://brain.ruv.io",
|
||||
"https://pi.ruv.io",
|
||||
"https://ruvbrain-875130704813.us-central1.run.app",
|
||||
"http://localhost:8080",
|
||||
"http://127.0.0.1:8080",
|
||||
]))
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.layer(RequestBodyLimitLayer::new(1_048_576)) // 1MB
|
||||
```
|
||||
|
||||
### 4.2 Health Endpoint
|
||||
|
||||
`GET /v1/health` reports service identity, version, uptime, and persistence mode:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "0.1.0",
|
||||
"domain": "pi.ruv.io",
|
||||
"uptime_seconds": 3600,
|
||||
"persistence_mode": "firestore"
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 Application State
|
||||
|
||||
The `AppState` struct holds all shared service state:
|
||||
|
||||
| Field | Type | Purpose |
|
||||
|-------|------|---------|
|
||||
| `store` | `Arc<FirestoreClient>` | Memory and page persistence |
|
||||
| `gcs` | `Arc<GcsClient>` | Binary artifact storage |
|
||||
| `graph` | `Arc<RwLock<KnowledgeGraph>>` | Mincut topology graph |
|
||||
| `rate_limiter` | `Arc<RateLimiter>` | BudgetTokenBucket rate limiting |
|
||||
| `ranking` | `Arc<RwLock<RankingEngine>>` | Search result ranking |
|
||||
| `cognitive` | `Arc<RwLock<CognitiveEngine>>` | DentateGyrus pattern separation |
|
||||
| `drift` | `Arc<RwLock<DriftMonitor>>` | Embedding centroid drift tracking |
|
||||
| `aggregator` | `Arc<ByzantineAggregator>` | LoRA weight aggregation |
|
||||
| `domain_engine` | `Arc<RwLock<DomainExpansionEngine>>` | Cross-domain transfer |
|
||||
| `sona` | `Arc<RwLock<SonaEngine>>` | Embedding feature extraction |
|
||||
| `lora_federation` | `Arc<RwLock<LoraFederationStore>>` | Federated LoRA state |
|
||||
| `nonce_store` | `Arc<NonceStore>` | Challenge nonce replay protection |
|
||||
| `sessions` | `Arc<DashMap<String, mpsc::Sender>>` | SSE session management |
|
||||
|
||||
### 4.4 Deployment Flow
|
||||
|
||||
1. Build container image from workspace root using the `crates/mcp-brain-server/Dockerfile`
|
||||
2. Push to Google Artifact Registry
|
||||
3. Deploy to Cloud Run with `gcloud run deploy ruvbrain`
|
||||
4. Map custom domains via Google Cloud Console
|
||||
5. Configure Cloudflare CNAME records pointing to `ghs.googlehosted.com`
|
||||
6. Verify TLS certificate provisioning
|
||||
|
||||
### 4.5 Endpoint Summary
|
||||
|
||||
The deployed server exposes the following route groups:
|
||||
|
||||
| Group | Routes | Source ADR |
|
||||
|-------|--------|-----------|
|
||||
| Landing pages | `/`, `/origin` | ADR-064 (this document) |
|
||||
| Infrastructure | `/v1/health`, `/v1/challenge` | ADR-059 |
|
||||
| Memories (CRUD) | `/v1/memories`, `/v1/memories/search`, `/v1/memories/list`, `/v1/memories/:id`, `/v1/memories/:id/vote` | ADR-060 |
|
||||
| Transfer & Monitoring | `/v1/transfer`, `/v1/drift`, `/v1/partition`, `/v1/status` | ADR-060 |
|
||||
| LoRA Federation | `/v1/lora/latest`, `/v1/lora/submit` | ADR-060 |
|
||||
| Training | `/v1/training/preferences` | ADR-061 |
|
||||
| Brainpedia | `/v1/pages`, `/v1/pages/:id`, `/v1/pages/:id/deltas`, `/v1/pages/:id/evidence`, `/v1/pages/:id/promote` | ADR-062 |
|
||||
| WASM Nodes | `/v1/nodes`, `/v1/nodes/:id`, `/v1/nodes/:id/wasm`, `/v1/nodes/:id/revoke` | ADR-063 |
|
||||
| MCP SSE | `/sse`, `/messages` | ADR-066 |
|
||||
|
||||
Total: 27 routes serving REST API, MCP SSE transport, and static HTML.
|
||||
|
||||
### 4.6 Security Layers
|
||||
|
||||
The server applies the following security measures at the infrastructure level:
|
||||
|
||||
1. **TLS termination**: Handled by Google Cloud Run / Cloudflare edge. The server itself listens on plain HTTP on port 8080.
|
||||
2. **CORS**: Explicit origin allowlist (5 origins). No wildcards.
|
||||
3. **Request body limit**: 1MB via tower-http `RequestBodyLimitLayer`. Prevents memory exhaustion from oversized payloads.
|
||||
4. **Rate limiting**: `BudgetTokenBucket` per contributor. 100 writes/hour, 1000 reads/hour. Stale buckets evicted every 1000 operations.
|
||||
5. **Challenge nonces**: Single-use, 5-minute TTL. Required for write operations.
|
||||
6. **PII stripping**: 12-pattern detection on all incoming text fields (filesystem paths, API keys, tokens, email patterns).
|
||||
7. **Embedding verification**: NaN, Inf, and magnitude checks on all incoming vectors.
|
||||
8. **Ed25519 signature verification**: For RVF container and WASM node integrity.
|
||||
9. **SHAKE-256 witness chains**: For provenance verification on all operations.
|
||||
|
||||
### 4.7 Tracing and Observability
|
||||
|
||||
The server uses `tracing_subscriber` with `EnvFilter` for log level control. Logs are written to stderr (Cloud Run captures stderr as structured logs). The `TraceLayer` middleware logs HTTP request/response metadata including method, path, status code, and latency.
|
||||
|
||||
The health endpoint provides basic liveness/readiness signaling. Cloud Run uses this for automatic health checks and instance lifecycle management.
|
||||
|
||||
## 5. Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Single binary deployment**: No static file serving complexity, no CDN configuration needed for the landing page
|
||||
- **Zero-cost at rest**: Cloud Run scales to zero when idle, no persistent infrastructure cost
|
||||
- **Branded access**: `pi.ruv.io` is memorable and communicates the mathematical/scientific identity
|
||||
- **Self-documenting**: Visiting the domain explains the system without needing external documentation
|
||||
- **Unified surface**: REST API, MCP SSE, and landing pages all served from the same binary on the same port
|
||||
|
||||
### Negative
|
||||
|
||||
- **HTML changes require recompilation**: Modifying the landing page or origin story requires a full cargo build and redeploy. This is acceptable because these pages change infrequently.
|
||||
- **Single region**: `us-central1` only. Multi-region would require Cloud Run service mesh or global load balancer. Deferred until latency becomes a concern.
|
||||
- **Cloud vendor lock-in**: Firestore and GCS are Google-specific. The `FirestoreClient` and `GcsClient` abstractions limit blast radius, but migration would require new storage implementations.
|
||||
|
||||
### Neutral
|
||||
|
||||
- CORS allows five specific origins. New frontends require a code change and redeploy.
|
||||
- Request body limit is 1MB globally. WASM node uploads are constrained to this limit, matching the ADR-063 spec.
|
||||
- The `read_only` flag on `AppState` enables emergency read-only mode without redeployment, controlled via an atomic boolean.
|
||||
209
docs/adr/ADR-065-npm-publishing-strategy.md
Normal file
209
docs/adr/ADR-065-npm-publishing-strategy.md
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
# ADR-065: npm Publishing Strategy
|
||||
|
||||
**Status**: Accepted
|
||||
**Date**: 2026-02-28
|
||||
**Authors**: RuVector Team
|
||||
**Deciders**: ruv
|
||||
**Supersedes**: N/A
|
||||
**Related**: ADR-064 (Pi Brain Infrastructure), ADR-066 (SSE MCP Transport), ADR-063 (WASM Executable Nodes)
|
||||
|
||||
## 1. Context
|
||||
|
||||
The RuVector project produces 48+ npm packages under the `@ruvector/` scope. These range from core TypeScript libraries and WASM bindings to MCP server wrappers and CLI tools. A new package, `@ruvector/pi-brain`, bundles CLI, SDK, and MCP stdio access to the Shared Brain into a single installable unit.
|
||||
|
||||
Without a defined publishing strategy, interdependent packages can break consumers if published in the wrong order, pre-release versions can leak into production, and TypeScript compilation errors can ship as broken packages. This ADR documents the publishing order, semver strategy, and authentication setup that govern all npm releases.
|
||||
|
||||
## 2. Decision
|
||||
|
||||
Adopt a structured publishing pipeline: categorize packages, enforce dependency-ordered publishing, use semver with pre-release tags for unstable packages, and require TypeScript compilation to succeed before any publish. All packages are published under the `ruvnet` npm account.
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
### 3.1 Package Categories
|
||||
|
||||
| Category | Description | Examples |
|
||||
|----------|-------------|---------|
|
||||
| **Core** | Foundational libraries with no RuVector dependencies | `@ruvector/types`, `@ruvector/utils` |
|
||||
| **WASM** | WebAssembly bindings compiled from Rust crates | `@ruvector/solver-wasm`, `@ruvector/rvf-wasm` |
|
||||
| **MCP** | Model Context Protocol server packages | `@ruvector/mcp-brain`, `@ruvector/mcp-gate` |
|
||||
| **CLI** | Command-line tools | `@ruvector/pi-brain`, `@ruvector/cli` |
|
||||
| **Infrastructure** | Build tools, codegen, testing utilities | `@ruvector/build-tools`, `@ruvector/test-utils` |
|
||||
|
||||
### 3.2 The `@ruvector/pi-brain` Package
|
||||
|
||||
`@ruvector/pi-brain` is the primary user-facing npm package for the Shared Brain. It provides three interfaces in one package:
|
||||
|
||||
**CLI**: `npx @ruvector/pi-brain search "tokio deadlock"` — search the brain from the command line. `npx @ruvector/pi-brain share --category debug --title "..." --content "..."` — share knowledge.
|
||||
|
||||
**SDK**: Programmatic TypeScript API for integrating brain capabilities into applications. Handles authentication, embedding, and the full REST API surface.
|
||||
|
||||
**MCP stdio**: `@ruvector/pi-brain mcp` — starts a JSON-RPC stdio server implementing the MCP protocol. Claude Code connects via `claude mcp add pi-brain -- npx @ruvector/pi-brain mcp`.
|
||||
|
||||
### 3.3 Publish Order
|
||||
|
||||
Packages must be published in dependency order. A package can only be published after all of its `@ruvector/` dependencies have been published at the required version.
|
||||
|
||||
**Tier 1 — No `@ruvector/` dependencies**:
|
||||
- `@ruvector/types`
|
||||
- `@ruvector/utils`
|
||||
- `@ruvector/build-tools`
|
||||
|
||||
**Tier 2 — Depends on Tier 1 only**:
|
||||
- `@ruvector/solver-wasm` (depends on `@ruvector/types`)
|
||||
- `@ruvector/rvf-wasm` (depends on `@ruvector/types`)
|
||||
- `@ruvector/test-utils` (depends on `@ruvector/types`, `@ruvector/utils`)
|
||||
|
||||
**Tier 3 — Depends on Tier 1 and/or Tier 2**:
|
||||
- `@ruvector/mcp-brain` (depends on `@ruvector/types`)
|
||||
- `@ruvector/mcp-gate` (depends on `@ruvector/types`)
|
||||
- `@ruvector/pi-brain` (depends on `@ruvector/types`, `@ruvector/mcp-brain`)
|
||||
|
||||
**Tier 4 — Meta-packages and aggregators**:
|
||||
- `@ruvector/cli` (depends on multiple Tier 2-3 packages)
|
||||
|
||||
Within a tier, packages can be published in any order.
|
||||
|
||||
### 3.4 Semver Strategy
|
||||
|
||||
| Version Range | Meaning | Tag |
|
||||
|---------------|---------|-----|
|
||||
| `0.x.y` | Pre-1.0, breaking changes expected between minors | `latest` |
|
||||
| `x.y.z-alpha.N` | Active development, not for production | `alpha` |
|
||||
| `x.y.z-beta.N` | Feature-complete, testing in progress | `beta` |
|
||||
| `x.y.z-rc.N` | Release candidate, final validation | `rc` |
|
||||
| `x.y.z` | Stable release | `latest` |
|
||||
|
||||
Pre-release versions are published with explicit dist-tags:
|
||||
```bash
|
||||
npm publish --tag alpha
|
||||
npm publish --tag beta
|
||||
```
|
||||
|
||||
The `latest` tag is only set on stable releases. This prevents `npm install @ruvector/pi-brain` from pulling pre-release versions.
|
||||
|
||||
### 3.5 TypeScript Compilation Requirements
|
||||
|
||||
Every package with TypeScript source must pass these checks before publish:
|
||||
|
||||
1. `tsc --noEmit` succeeds with zero errors
|
||||
2. `tsc --declaration` generates `.d.ts` files
|
||||
3. `package.json` includes `types` or `typings` field pointing to the declaration entry point
|
||||
4. `exports` map includes `types` condition for each entry point
|
||||
|
||||
Packages that fail TypeScript compilation are blocked from publishing. This is enforced by running `npm run build` (which includes `tsc`) as the first step of the publish pipeline.
|
||||
|
||||
## 4. Implementation
|
||||
|
||||
### 4.1 Authentication
|
||||
|
||||
npm authentication uses the `ruvnet` account. Credentials are stored in the project `.env` file and loaded into `~/.npmrc` before publishing. Verify with `npm whoami`.
|
||||
|
||||
The npm token is scoped to the `@ruvector/` scope with publish permissions. Read-only tokens are used in CI for install-only workflows.
|
||||
|
||||
### 4.2 Pre-Publish Checklist
|
||||
|
||||
For each package:
|
||||
|
||||
1. Verify `npm whoami` returns `ruvnet`
|
||||
2. Run `npm run build` (TypeScript compilation + any bundling)
|
||||
3. Run `npm test` (all tests must pass)
|
||||
4. Verify `package.json` version matches the intended release
|
||||
5. Check that `files` field in `package.json` includes only intended artifacts
|
||||
6. Run `npm pack --dry-run` to inspect the tarball contents
|
||||
7. Publish: `npm publish --access public`
|
||||
|
||||
### 4.3 WASM Package Build
|
||||
|
||||
WASM packages require a Rust compilation step before the npm publish:
|
||||
|
||||
1. `cargo build --release --target wasm32-unknown-unknown -p <crate>`
|
||||
2. `wasm-bindgen` or `wasm-pack` generates the JS/TS bindings
|
||||
3. Copy generated files into the npm package directory
|
||||
4. Run TypeScript compilation on the wrapper code
|
||||
5. Publish as a standard npm package
|
||||
|
||||
### 4.4 Solver Crate Publish Order (Cargo)
|
||||
|
||||
For the Rust solver crates published to crates.io (not npm), the order is:
|
||||
|
||||
1. `ruvector-solver` first (no dependencies)
|
||||
2. `ruvector-solver-wasm` second (depends on `ruvector-solver`)
|
||||
3. `ruvector-solver-node` third (depends on `ruvector-solver`)
|
||||
|
||||
Always run `cargo publish --dry-run --allow-dirty` before real publish. `ruvector-profiler` has `publish = false` and is intentionally not publishable.
|
||||
|
||||
### 4.5 Version Coordination
|
||||
|
||||
When a breaking change occurs in a Tier 1 package, all dependent packages must be updated and republished. The procedure is:
|
||||
|
||||
1. Publish the updated Tier 1 package with the new major/minor version
|
||||
2. Update `package.json` in all dependent packages to reference the new version
|
||||
3. Run `npm install` in each dependent package to verify resolution
|
||||
4. Run tests in each dependent package
|
||||
5. Publish dependent packages in tier order
|
||||
|
||||
For non-breaking changes (patch versions), only the changed package needs republishing. Dependents using caret ranges (`^x.y.z`) automatically resolve the new patch.
|
||||
|
||||
### 4.6 Package.json Standards
|
||||
|
||||
All `@ruvector/` packages must include:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@ruvector/<package>",
|
||||
"version": "x.y.z",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/ruvnet/ruvector"
|
||||
},
|
||||
"engines": { "node": ">=18" },
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": ["dist/", "README.md", "LICENSE"]
|
||||
}
|
||||
```
|
||||
|
||||
The `files` field is explicitly set to prevent accidental inclusion of source maps, test fixtures, `.env` files, or other development artifacts in the published tarball.
|
||||
|
||||
### 4.7 CI Integration
|
||||
|
||||
The publish pipeline runs in GitHub Actions:
|
||||
|
||||
1. **Trigger**: Manual workflow dispatch with package name and version as inputs
|
||||
2. **Auth**: npm token from GitHub Secrets, loaded into `~/.npmrc`
|
||||
3. **Build**: `npm run build` in the package directory
|
||||
4. **Test**: `npm test` in the package directory
|
||||
5. **Dry run**: `npm pack --dry-run` to verify tarball contents
|
||||
6. **Publish**: `npm publish --access public` (or `--tag alpha/beta` for pre-releases)
|
||||
7. **Verify**: `npm view @ruvector/<package> version` confirms the published version
|
||||
|
||||
The workflow rejects publishes if the version already exists on the registry (npm returns 403 for duplicate versions).
|
||||
|
||||
## 5. Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **No broken installs**: Dependency-ordered publishing ensures consumers never pull a package whose dependencies are not yet available
|
||||
- **Safe defaults**: Pre-release tags prevent accidental production use of unstable versions
|
||||
- **Type safety**: Mandatory TypeScript compilation catches type errors before they reach consumers
|
||||
- **Single account**: All packages under `ruvnet` with `@ruvector/` scope provides consistent ownership and discoverability
|
||||
- **Explicit file lists**: The `files` field prevents credential leaks and keeps tarballs small
|
||||
|
||||
### Negative
|
||||
|
||||
- **Manual coordination**: Publishing 48+ packages in order requires discipline. Automation (a publish script that resolves the dependency graph and publishes in topological order) is deferred but recommended.
|
||||
- **WASM build complexity**: WASM packages require both Rust and Node.js toolchains. Build failures in either chain block the publish.
|
||||
- **ESM-only**: All packages use `"type": "module"`. CommonJS consumers must use dynamic `import()` or a bundler. This is a deliberate choice — the ecosystem is moving to ESM and dual-packaging adds complexity.
|
||||
|
||||
### Neutral
|
||||
|
||||
- The `@ruvector/pi-brain` package combining CLI + SDK + MCP in one package increases the install size but simplifies the getting-started experience. Users who only need the SDK can tree-shake unused CLI code.
|
||||
- Node.js >= 18 is required. This matches the current LTS baseline and enables native `fetch`, `structuredClone`, and other modern APIs.
|
||||
223
docs/adr/ADR-066-sse-mcp-transport.md
Normal file
223
docs/adr/ADR-066-sse-mcp-transport.md
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
# ADR-066: SSE MCP Transport
|
||||
|
||||
**Status**: Accepted, Deployed
|
||||
**Date**: 2026-02-28
|
||||
**Authors**: RuVector Team
|
||||
**Deciders**: ruv
|
||||
**Supersedes**: N/A
|
||||
**Related**: ADR-059 (Shared Brain Google Cloud), ADR-060 (Shared Brain Capabilities), ADR-062 (Brainpedia), ADR-063 (WASM Executable Nodes), ADR-064 (Pi Brain Infrastructure)
|
||||
|
||||
## 1. Context
|
||||
|
||||
The Shared Brain's primary MCP interface is stdio-based: the `mcp-brain` crate runs as a local process within a Claude Code session, communicating via JSON-RPC over stdin/stdout. This works well for local sessions but cannot serve remote clients — browsers, CI pipelines, or Claude Code sessions that want to connect without installing a local binary.
|
||||
|
||||
MCP over Server-Sent Events (SSE) solves this. The client opens a long-lived `GET /sse` connection to receive server-pushed events, and sends tool calls via `POST /messages?sessionId=<uuid>`. This is a standard MCP transport that Claude Code supports natively via `claude mcp add <name> --url <sse-url>`.
|
||||
|
||||
The SSE transport is hosted on the same `mcp-brain-server` binary that serves the REST API (ADR-064), at `pi.ruv.io/sse`. No additional infrastructure is required.
|
||||
|
||||
## 2. Decision
|
||||
|
||||
Implement MCP SSE transport as two routes (`/sse` and `/messages`) on the existing `mcp-brain-server` axum router. Tool calls received via SSE are proxied to the REST API endpoints via HTTP loopback, reusing all existing authentication, rate limiting, and verification logic. Session state is managed via `DashMap<String, mpsc::Sender<String>>`.
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
### 3.1 Protocol Flow
|
||||
|
||||
```
|
||||
Client Server (pi.ruv.io)
|
||||
| |
|
||||
| GET /sse |
|
||||
|-------------------------------------->|
|
||||
| event: endpoint |
|
||||
| data: /messages?sessionId=<uuid> |
|
||||
|<--------------------------------------|
|
||||
| |
|
||||
| POST /messages?sessionId=<uuid> |
|
||||
| {"jsonrpc":"2.0","method":"initialize",...}
|
||||
|-------------------------------------->|
|
||||
| event: message |
|
||||
| data: {"jsonrpc":"2.0","result":{...}}
|
||||
|<--------------------------------------|
|
||||
| |
|
||||
| POST /messages?sessionId=<uuid> |
|
||||
| {"jsonrpc":"2.0","method":"tools/call","params":{"name":"brain_search",...}}
|
||||
|-------------------------------------->|
|
||||
| event: message |
|
||||
| data: {"jsonrpc":"2.0","result":{...}}
|
||||
|<--------------------------------------|
|
||||
| |
|
||||
| (keepalive comments) |
|
||||
|<--------------------------------------|
|
||||
```
|
||||
|
||||
1. Client opens `GET /sse`. Server generates a UUID session ID, creates an `mpsc::channel`, stores the sender in the session map, and returns an SSE stream.
|
||||
2. The first SSE event (`endpoint`) tells the client where to POST messages.
|
||||
3. Client sends JSON-RPC requests to `POST /messages?sessionId=<uuid>`.
|
||||
4. Server parses the request, dispatches to the appropriate handler, and sends the response back through the SSE channel.
|
||||
5. KeepAlive comments prevent connection timeouts on proxies and load balancers.
|
||||
6. On disconnect, the session is cleaned up from the `DashMap`.
|
||||
|
||||
### 3.2 Session Management
|
||||
|
||||
```rust
|
||||
// In AppState
|
||||
sessions: Arc<DashMap<String, tokio::sync::mpsc::Sender<String>>>
|
||||
```
|
||||
|
||||
Each SSE connection creates a session with:
|
||||
- A UUID session ID
|
||||
- An `mpsc::channel(64)` for buffering responses
|
||||
- A `DashMap` entry mapping session ID to the channel sender
|
||||
|
||||
The SSE stream reads from the channel receiver. When the client disconnects (stream drops), the cleanup closure removes the session from the map. The channel buffer of 64 prevents slow clients from blocking the server while providing backpressure.
|
||||
|
||||
### 3.3 MCP Protocol Implementation
|
||||
|
||||
The `/messages` handler implements the MCP protocol methods:
|
||||
|
||||
| Method | Handler | Description |
|
||||
|--------|---------|-------------|
|
||||
| `initialize` | Inline | Returns protocol version `2024-11-05`, server name `pi-brain`, capabilities |
|
||||
| `initialized` | Inline | Acknowledgment, returns empty result |
|
||||
| `notifications/initialized` | Inline | Notification acknowledgment |
|
||||
| `tools/list` | `mcp_tool_definitions()` | Returns the full tool catalog |
|
||||
| `tools/call` | `handle_mcp_tool_call()` | Dispatches to HTTP loopback proxy |
|
||||
|
||||
### 3.4 HTTP Loopback Proxy
|
||||
|
||||
Tool calls are not handled directly in the SSE message handler. Instead, they are proxied to the server's own REST API via HTTP loopback. This ensures that:
|
||||
|
||||
1. All existing middleware (CORS, rate limiting, body size limits, tracing) applies uniformly
|
||||
2. Authentication and verification logic is not duplicated
|
||||
3. The REST API and MCP SSE surface expose identical behavior
|
||||
4. Testing is simplified — REST endpoint tests cover both transport paths
|
||||
|
||||
The proxy constructs an HTTP request to `http://127.0.0.1:{PORT}/v1/...` with the appropriate method, path, and body derived from the MCP tool call arguments.
|
||||
|
||||
### 3.5 Tool Catalog
|
||||
|
||||
22 tools are exposed via the SSE transport, grouped into four categories:
|
||||
|
||||
**Core Brain (10)**: `brain_share`, `brain_search`, `brain_get`, `brain_vote`, `brain_transfer`, `brain_drift`, `brain_partition`, `brain_list`, `brain_delete`, `brain_status`
|
||||
|
||||
**LoRA Sync (1)**: `brain_sync`
|
||||
|
||||
**Brainpedia (6, ADR-062)**: `brain_page_create`, `brain_page_get`, `brain_page_delta`, `brain_page_deltas`, `brain_page_evidence`, `brain_page_promote`
|
||||
|
||||
**WASM Nodes (5, ADR-063)**: `brain_node_list`, `brain_node_publish`, `brain_node_get`, `brain_node_wasm`, `brain_node_revoke`
|
||||
|
||||
Each tool definition includes a JSON Schema `inputSchema` specifying required and optional parameters, types, and descriptions.
|
||||
|
||||
## 4. Implementation
|
||||
|
||||
### 4.1 SSE Handler
|
||||
|
||||
```rust
|
||||
async fn sse_handler(
|
||||
State(state): State<AppState>,
|
||||
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
|
||||
let session_id = Uuid::new_v4().to_string();
|
||||
let (tx, rx) = tokio::sync::mpsc::channel::<String>(64);
|
||||
state.sessions.insert(session_id.clone(), tx);
|
||||
|
||||
let stream = async_stream::stream! {
|
||||
// First event: tell client where to POST
|
||||
yield Ok(Event::default()
|
||||
.event("endpoint")
|
||||
.data(format!("/messages?sessionId={session_id}")));
|
||||
|
||||
// Stream responses from the channel
|
||||
let mut rx = rx;
|
||||
while let Some(msg) = rx.recv().await {
|
||||
yield Ok(Event::default().event("message").data(msg));
|
||||
}
|
||||
|
||||
// Cleanup on disconnect
|
||||
sessions_cleanup.remove(&session_id_cleanup);
|
||||
};
|
||||
|
||||
Sse::new(stream).keep_alive(KeepAlive::default())
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Message Handler
|
||||
|
||||
```rust
|
||||
async fn messages_handler(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<McpMessageQuery>,
|
||||
body: String,
|
||||
) -> StatusCode {
|
||||
let sender = match state.sessions.get(&query.session_id) {
|
||||
Some(s) => s.clone(),
|
||||
None => return StatusCode::NOT_FOUND,
|
||||
};
|
||||
|
||||
let request: serde_json::Value = match serde_json::from_str(&body) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
// Send JSON-RPC parse error through the SSE channel
|
||||
let _ = sender.send(error_response(-32700, e)).await;
|
||||
return StatusCode::ACCEPTED;
|
||||
}
|
||||
};
|
||||
|
||||
let response = match method {
|
||||
"initialize" => { /* protocol handshake */ },
|
||||
"tools/list" => { /* return tool catalog */ },
|
||||
"tools/call" => { /* HTTP loopback proxy */ },
|
||||
_ => { /* method not found */ },
|
||||
};
|
||||
|
||||
let _ = sender.send(serde_json::to_string(&response).unwrap()).await;
|
||||
StatusCode::ACCEPTED
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 Connection
|
||||
|
||||
Claude Code clients connect with:
|
||||
|
||||
```bash
|
||||
claude mcp add pi --url https://pi.ruv.io/sse
|
||||
```
|
||||
|
||||
This registers the SSE endpoint. Claude Code opens the SSE connection, reads the `endpoint` event, and sends all subsequent MCP requests to the `/messages` URL with the provided session ID.
|
||||
|
||||
### 4.4 CORS Configuration
|
||||
|
||||
The SSE endpoint shares the same CORS layer as the REST API:
|
||||
|
||||
| Allowed Origin | Purpose |
|
||||
|----------------|---------|
|
||||
| `https://brain.ruv.io` | Legacy brain domain |
|
||||
| `https://pi.ruv.io` | Primary domain |
|
||||
| `https://ruvbrain-875130704813.us-central1.run.app` | Direct Cloud Run URL |
|
||||
| `http://localhost:8080` | Local development |
|
||||
| `http://127.0.0.1:8080` | Local development (IP) |
|
||||
|
||||
Allowed methods: GET, POST, DELETE, OPTIONS. Allowed headers: Authorization, Content-Type, Accept.
|
||||
|
||||
### 4.5 KeepAlive
|
||||
|
||||
`Sse::new(stream).keep_alive(KeepAlive::default())` sends periodic SSE comments (`:keepalive` lines) to prevent intermediate proxies, load balancers, and Cloud Run from closing idle connections. The default interval is 15 seconds.
|
||||
|
||||
## 5. Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Zero additional infrastructure**: SSE runs on the same binary and port as the REST API
|
||||
- **Native Claude Code support**: `claude mcp add --url` is the standard way to connect remote MCP servers
|
||||
- **Full tool parity**: All 22 tools available via both stdio (local `mcp-brain`) and SSE (remote `pi.ruv.io`)
|
||||
- **Reused middleware**: Rate limiting, CORS, authentication, and verification apply uniformly via the loopback proxy
|
||||
|
||||
### Negative
|
||||
|
||||
- **Single-direction streaming**: SSE is server-to-client only. The client must POST to a separate endpoint. WebSocket would allow bidirectional messaging but is not part of the MCP spec.
|
||||
- **Session memory**: Each active SSE connection holds a channel and a `DashMap` entry. Under high concurrent connections this grows linearly. The 64-message buffer per session bounds memory per connection.
|
||||
- **Cloud Run timeout**: Cloud Run has a maximum request timeout (default 5 minutes, configurable up to 60 minutes). Long-lived SSE connections that exceed this timeout are terminated. KeepAlive prevents idle disconnects, but the maximum lifetime is bounded by Cloud Run's configuration.
|
||||
|
||||
### Neutral
|
||||
|
||||
- The loopback proxy adds one local HTTP hop per tool call. On the same machine this adds sub-millisecond latency, which is negligible compared to Firestore round-trips and embedding computation.
|
||||
- Error responses from the REST API are translated into JSON-RPC error format before being sent through the SSE channel.
|
||||
222
docs/adr/ADR-067-mcp-gate-permit-system.md
Normal file
222
docs/adr/ADR-067-mcp-gate-permit-system.md
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
# ADR-067: MCP Gate Permit System
|
||||
|
||||
**Status**: Accepted, Implemented
|
||||
**Date**: 2026-02-28
|
||||
**Authors**: RuVector Team
|
||||
**Deciders**: ruv
|
||||
**Supersedes**: N/A
|
||||
**Related**: ADR-014 (Coherence Engine), ADR-058 (Hash Security Optimization), ADR-059 (Shared Brain Google Cloud), ADR-060 (Shared Brain Capabilities)
|
||||
|
||||
## 1. Context
|
||||
|
||||
AI agents executing in production environments need a decision gate between intent and action. An agent that can call any tool without verification is a liability. The coherence gate (ADR-014) provides the mathematical foundation — anytime-valid e-processes, conformal prediction sets, mincut structural witnesses — but it needs an MCP-native interface so Claude Code agents can request permissions via the standard tool-calling protocol.
|
||||
|
||||
The `mcp-gate` crate (`crates/mcp-gate/`) wraps the `cognitum-gate-tilezero` coherence gate in an MCP stdio server. It exposes three tools: `permit_action` (request permission), `get_receipt` (audit trail), and `replay_decision` (deterministic verification). All decisions are recorded in a cryptographic witness chain. Contributor authentication uses SHAKE-256 pseudonym derivation, and access is rate-limited via BudgetTokenBucket.
|
||||
|
||||
## 2. Decision
|
||||
|
||||
Implement `mcp-gate` as a standalone MCP stdio server that wraps the existing `cognitum-gate-tilezero` gate. Use SHAKE-256 for pseudonym derivation (consistent with the brain server's authentication scheme), challenge nonces for replay protection, and BudgetTokenBucket for rate limiting. All gate decisions produce cryptographically chained witness receipts.
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
### 3.1 Crate Structure
|
||||
|
||||
```
|
||||
crates/mcp-gate/
|
||||
src/
|
||||
lib.rs -- Public API, re-exports
|
||||
main.rs -- Entry point (McpGateServer::run_stdio)
|
||||
server.rs -- MCP protocol handler (JSON-RPC over stdio)
|
||||
tools.rs -- Tool implementations (permit, receipt, replay)
|
||||
types.rs -- Request/response types, JSON-RPC types
|
||||
Cargo.toml
|
||||
```
|
||||
|
||||
The crate depends on:
|
||||
- `cognitum-gate-tilezero`: The coherence gate engine (TileZero)
|
||||
- `tokio`: Async runtime for stdio processing
|
||||
- `serde` / `serde_json`: JSON-RPC serialization
|
||||
- `thiserror`: Error types
|
||||
|
||||
### 3.2 MCP Tools
|
||||
|
||||
**`permit_action`**: Request permission for an action. The gate evaluates three witnesses:
|
||||
|
||||
| Witness | What it measures | Output |
|
||||
|---------|-----------------|--------|
|
||||
| Structural | Mincut graph analysis of the action's connectivity | `cut_value`, `partition` status, critical edges |
|
||||
| Predictive | Conformal prediction set for the action's outcome | `set_size`, `coverage` target |
|
||||
| Evidential | Anytime-valid e-process accumulation | `e_value`, `verdict` (accept/continue/reject) |
|
||||
|
||||
Returns one of three decisions:
|
||||
- **Permit**: Action allowed. Returns a `PermitToken` (base64-encoded, time-bounded) and a witness receipt.
|
||||
- **Defer**: Action escalated. Returns escalation info (reason, suggested reviewer) and a witness receipt.
|
||||
- **Deny**: Action blocked. Returns denial reason and a witness receipt.
|
||||
|
||||
**`get_receipt`**: Retrieve a witness receipt by sequence number. Each gate decision produces a receipt containing the decision, timestamp, witness summary, and a hash linking to the previous receipt. The chain is cryptographic — tampering with any receipt breaks the chain.
|
||||
|
||||
**`replay_decision`**: Deterministically replay a past decision given the same inputs and state snapshot. Optionally verifies the hash chain integrity up to the replayed sequence number. Returns whether the replayed decision matches the original.
|
||||
|
||||
### 3.3 SHAKE-256 Pseudonym Derivation
|
||||
|
||||
Contributor pseudonyms are derived from API keys using SHAKE-256, identical to the brain server (ADR-059, ADR-060):
|
||||
|
||||
```rust
|
||||
pub fn from_api_key(api_key: &str) -> AuthenticatedContributor {
|
||||
let mut hasher = Shake256::default();
|
||||
hasher.update(b"ruvector-brain-pseudonym:");
|
||||
hasher.update(api_key.as_bytes());
|
||||
let mut reader = hasher.finalize_xof();
|
||||
let mut buf = [0u8; 16];
|
||||
reader.read(&mut buf);
|
||||
let pseudonym = hex::encode(buf);
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
This produces a 32-character hex pseudonym (128-bit) that:
|
||||
- Is deterministic: same API key always produces the same pseudonym
|
||||
- Is irreversible: the API key cannot be recovered from the pseudonym
|
||||
- Uses a domain-separated prefix (`ruvector-brain-pseudonym:`) to prevent cross-system correlation
|
||||
|
||||
### 3.4 Challenge Nonce Replay Protection
|
||||
|
||||
Write operations (including `permit_action`) require a challenge nonce to prevent replay attacks:
|
||||
|
||||
1. Client calls `GET /v1/challenge` to receive a fresh nonce
|
||||
2. Server generates a random nonce, stores it with a 5-minute TTL
|
||||
3. Client includes the nonce in the write request
|
||||
4. Server verifies the nonce exists and has not expired
|
||||
5. Server consumes the nonce (single-use)
|
||||
|
||||
Nonces are stored in a `NonceStore` backed by a `DashMap` with periodic TTL eviction.
|
||||
|
||||
### 3.5 Rate Limiting
|
||||
|
||||
The `BudgetTokenBucket` pattern (shared with the brain server) provides per-contributor rate limiting:
|
||||
|
||||
```rust
|
||||
pub struct RateLimiter {
|
||||
write_buckets: DashMap<String, TokenBucket>,
|
||||
read_buckets: DashMap<String, TokenBucket>,
|
||||
write_limit: u32, // 100 writes/hour
|
||||
read_limit: u32, // 1000 reads/hour
|
||||
window: Duration, // 1 hour
|
||||
}
|
||||
```
|
||||
|
||||
Each contributor (identified by pseudonym) gets independent token buckets for reads and writes. Buckets refill at window boundaries. Stale buckets (unused for 2 windows) are periodically evicted to prevent unbounded memory growth. The cleanup runs every 1000 operations.
|
||||
|
||||
### 3.6 Multi-Factor Reputation Scoring
|
||||
|
||||
The reputation system gates access levels:
|
||||
|
||||
```
|
||||
composite = accuracy^2 * uptime * stake_weight
|
||||
```
|
||||
|
||||
| Factor | Measurement | Update |
|
||||
|--------|-------------|--------|
|
||||
| `accuracy` | Bayesian Beta(1,1) prior, updated from vote outcomes | `(upvotes+1)/(upvotes+downvotes+2)` after min 5 observations |
|
||||
| `uptime` | EMA of activity frequency | `uptime = 0.95 * uptime + 0.05` per contribution |
|
||||
| `stake_weight` | Fixed at 1.0 for v1 | Future: based on contribution volume |
|
||||
|
||||
- New contributors start at composite ~0.1. Their gate requests are weighted 10x less.
|
||||
- Contributors with composite < 0.1 are flagged as potential poisoners and their permit requests are auto-denied.
|
||||
- Inactivity decay: `accuracy *= 0.95^months_inactive`, `uptime *= 0.90^months_inactive`.
|
||||
|
||||
### 3.7 Contributor Pseudonym Revocation
|
||||
|
||||
A contributor can be revoked by setting their reputation below the poisoning threshold (0.1). The `check_poisoning_penalty` function triggers when:
|
||||
- The contributor has >= 5 downvotes on their contributions
|
||||
- Their quality score falls below 0.2
|
||||
|
||||
Once penalized, the contributor's pseudonym is effectively revoked — all gate requests return Deny. The pseudonym remains in the system for audit purposes. The contributor can only recover by generating a new API key (and thus a new pseudonym), starting at the cold-start reputation of 0.1.
|
||||
|
||||
## 4. Implementation
|
||||
|
||||
### 4.1 Server Entry Point
|
||||
|
||||
```rust
|
||||
let server = McpGateServer::new();
|
||||
server.run_stdio().await.expect("Server failed");
|
||||
```
|
||||
|
||||
The server reads JSON-RPC requests from stdin, dispatches to tool handlers, and writes responses to stdout. The protocol version is `2024-11-05`.
|
||||
|
||||
### 4.2 Request/Response Flow
|
||||
|
||||
```
|
||||
stdin -> parse JSON-RPC -> match method:
|
||||
"initialize" -> return server info + capabilities
|
||||
"tools/list" -> return [permit_action, get_receipt, replay_decision]
|
||||
"tools/call" -> match tool name:
|
||||
"permit_action" -> TileZero::decide() -> PermitResponse | DeferResponse | DenyResponse
|
||||
"get_receipt" -> TileZero::get_receipt(seq) -> GetReceiptResponse
|
||||
"replay_decision" -> TileZero::replay(seq) -> ReplayDecisionResponse
|
||||
-> serialize JSON-RPC -> stdout
|
||||
```
|
||||
|
||||
### 4.3 Permit Action Request
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "permit_action",
|
||||
"arguments": {
|
||||
"action_id": "cfg-push-7a3f",
|
||||
"action_type": "config_change",
|
||||
"target": { "device": "router-west-03", "path": "/network/interfaces/eth0" },
|
||||
"context": { "agent_id": "ops-agent-12", "session_id": "sess-abc123", "urgency": "normal" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 Permit Response (Permitted)
|
||||
|
||||
```json
|
||||
{
|
||||
"decision": "permit",
|
||||
"token": "eyJ0eXAi...",
|
||||
"valid_until_ns": 1737158400000000000,
|
||||
"witness": {
|
||||
"structural": { "cut_value": 12.7, "partition": "stable", "critical_edges": 0 },
|
||||
"predictive": { "set_size": 3, "coverage": 0.92 },
|
||||
"evidential": { "e_value": 847.3, "verdict": "accept" }
|
||||
},
|
||||
"receipt_sequence": 1847392
|
||||
}
|
||||
```
|
||||
|
||||
### 4.5 Error Codes
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| -32001 | Receipt not found |
|
||||
| -32002 | Chain verification failed |
|
||||
| -32602 | Invalid request parameters |
|
||||
| -32603 | Internal server error |
|
||||
| -32700 | JSON parse error |
|
||||
|
||||
## 5. Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Standard MCP interface**: Any MCP-compatible client (Claude Code, custom agents) can request gate permits without custom integration
|
||||
- **Cryptographic audit trail**: Every decision is chained. Tampering is detectable. Replays are deterministic.
|
||||
- **Shared auth scheme**: SHAKE-256 pseudonym derivation is identical between `mcp-gate` and `mcp-brain-server`, enabling cross-system contributor identity without sharing API keys
|
||||
- **Defense in depth**: Rate limiting + nonce replay protection + reputation gating + cryptographic witnesses form layered protection
|
||||
|
||||
### Negative
|
||||
|
||||
- **Stdio only**: The MCP gate is stdio-based, not SSE. It must run as a local process. Remote access requires wrapping in a network transport (deferred).
|
||||
- **Stateful**: The TileZero gate accumulates witness receipts in memory. Long-running sessions with many decisions grow linearly. Persistence to disk is deferred.
|
||||
|
||||
### Neutral
|
||||
|
||||
- The three-witness model (structural, predictive, evidential) is inherited from `cognitum-gate-tilezero`. The MCP gate does not add new decision logic — it provides protocol access to existing capabilities.
|
||||
- Pseudonym revocation is soft (reputation penalty) not hard (key revocation). A determined attacker can generate new API keys. This is acceptable because cold-start reputation (0.1) limits the blast radius of new pseudonyms.
|
||||
239
docs/adr/ADR-068-domain-expansion-transfer-learning.md
Normal file
239
docs/adr/ADR-068-domain-expansion-transfer-learning.md
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
# ADR-068: Domain Expansion Transfer Learning
|
||||
|
||||
**Status**: Accepted, Implemented
|
||||
**Date**: 2026-02-28
|
||||
**Authors**: RuVector Team
|
||||
**Deciders**: ruv
|
||||
**Supersedes**: N/A
|
||||
**Related**: ADR-057 (Federated RVF Transfer Learning), ADR-060 (Shared Brain Capabilities), ADR-061 (Reasoning Kernel Architecture)
|
||||
|
||||
## 1. Context
|
||||
|
||||
The Shared Brain (ADR-060) enables cumulative learning within a single domain. But real intelligence growth appears when knowledge from one domain accelerates learning in a different domain. A debugging heuristic from Rust should inform debugging in TypeScript. A planning strategy from infrastructure deployment should transfer to release management.
|
||||
|
||||
The `ruvector-domain-expansion` crate (`crates/ruvector-domain-expansion/`) implements cross-domain transfer learning using Meta Thompson Sampling with compact prior transfer. The core insight: true generalization is measured by whether Domain 2 converges faster than Domain 1 did, given priors extracted from Domain 1. If cost curves compress with each new domain, general problem-solving capability is increasing.
|
||||
|
||||
## 2. Decision
|
||||
|
||||
Implement a two-layer architecture: a policy learning layer (Meta Thompson Sampling with Beta priors) that chooses strategies across context buckets, and an operator layer (deterministic domain kernels) that generates tasks, evaluates solutions, and produces embeddings. Transfer happens through compact priors — not raw trajectories. Verification requires dual conditions: improved target AND not regressed source.
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
### 3.1 Two-Layer Design
|
||||
|
||||
**Policy Learning Layer** (`MetaThompsonEngine`):
|
||||
- Maintains per-domain, per-bucket, per-arm Beta distribution parameters
|
||||
- Selects strategy arms via Thompson Sampling (sample from posterior, pick highest)
|
||||
- Records outcomes as Bayesian posterior updates
|
||||
- Extracts compact `TransferPrior` summaries for cross-domain shipping
|
||||
- Seeds new domains with dampened priors from source domains
|
||||
|
||||
**Operator Layer** (Domain implementations):
|
||||
- `RustSynthesisDomain`: Generates Rust function synthesis tasks, evaluates correctness
|
||||
- `PlanningDomain`: Generates multi-step planning tasks with dependencies and resources
|
||||
- `ToolOrchestrationDomain`: Generates multi-tool coordination tasks
|
||||
- Each domain implements `generate_tasks()`, `evaluate()`, `embed()`, `reference_solution()`
|
||||
|
||||
### 3.2 Meta Thompson Sampling
|
||||
|
||||
Standard Thompson Sampling maintains a Beta(alpha, beta) distribution per arm and samples to select. Meta Thompson Sampling extends this across domains:
|
||||
|
||||
1. **Train on Domain 1**: Record outcomes, accumulate per-bucket posteriors
|
||||
2. **Extract TransferPrior**: Filter to buckets with sufficient evidence (alpha + beta > 12), package as a compact summary
|
||||
3. **Initialize Domain 2**: Seed with dampened priors from Domain 1 using sqrt-scaling
|
||||
4. **Train on Domain 2**: The transferred priors give Domain 2 a head start
|
||||
5. **Measure acceleration**: Compare convergence cycles with vs without transfer
|
||||
|
||||
### 3.3 Dampened Sqrt-Scaling Priors
|
||||
|
||||
When transferring priors from source to target, raw posteriors would over-commit the target to source-domain strategies. Dampening uses sqrt-scaling to reduce confidence while preserving the mean:
|
||||
|
||||
```rust
|
||||
let dampened = BetaParams {
|
||||
alpha: 1.0 + (params.alpha - 1.0).sqrt(),
|
||||
beta: 1.0 + (params.beta - 1.0).sqrt(),
|
||||
};
|
||||
```
|
||||
|
||||
Example: A source prior of Beta(81, 21) (mean=0.79, very confident) becomes Beta(1 + sqrt(80), 1 + sqrt(20)) = Beta(9.94, 5.47) (mean=0.64, much less confident). The target retains the directional signal (strategy A is probably better than strategy B) but has enough uncertainty to adapt if the source signal does not transfer.
|
||||
|
||||
Cost EMA priors are transferred with pessimistic scaling (1.5x) to avoid under-budgeting in an unfamiliar domain.
|
||||
|
||||
### 3.4 TransferPrior and PolicyKernel RVF Segments
|
||||
|
||||
Transfer artifacts are serialized as RVF segments for the Shared Brain:
|
||||
|
||||
| Segment | Type Code | Content |
|
||||
|---------|-----------|---------|
|
||||
| `TransferPrior` | `0x30` | Per-bucket, per-arm Beta parameters + cost EMA priors + training cycle count + witness hash |
|
||||
| `PolicyKernel` | `0x31` | Policy knobs (skip mode, prepass flag, speculation threshold) + holdout scores + fitness |
|
||||
| `CostCurve` | `0x32` | Ordered data points (cycle, accuracy, cost_per_solve, robustness, violations) + convergence thresholds |
|
||||
|
||||
The `rvf_bridge` module (enabled with `feature = "rvf"`) handles serialization via wire-format wrappers that convert `HashMap<ContextBucket, _>` to `Vec<(K, V)>` for JSON-safe encoding. SHAKE-256 witness chains cover the entire serialization for integrity.
|
||||
|
||||
### 3.5 CostCurve Acceleration Tracking
|
||||
|
||||
The `AccelerationScoreboard` tracks convergence speed across domains:
|
||||
|
||||
```rust
|
||||
pub struct CostCurvePoint {
|
||||
pub cycle: u64,
|
||||
pub accuracy: f32,
|
||||
pub cost_per_solve: f32,
|
||||
pub robustness: f32,
|
||||
pub policy_violations: u32,
|
||||
pub timestamp: f64,
|
||||
}
|
||||
```
|
||||
|
||||
Convergence thresholds define the acceptance test:
|
||||
- Target accuracy: 0.95
|
||||
- Target cost per solve: 0.01
|
||||
- Target robustness: 0.90
|
||||
- Max policy violations: 0
|
||||
|
||||
A domain has converged when all four thresholds are simultaneously met. The acceleration factor is `baseline_cycles / transfer_cycles` — the ratio of how many cycles the target took without transfer vs with transfer. An acceleration factor > 1.0 means transfer helped.
|
||||
|
||||
### 3.6 TransferVerification
|
||||
|
||||
The verification protocol enforces the generalization rule:
|
||||
|
||||
```rust
|
||||
impl TransferVerification {
|
||||
pub fn verify(
|
||||
source: DomainId,
|
||||
target: DomainId,
|
||||
source_before: f32,
|
||||
source_after: f32, // must not regress beyond 0.01 tolerance
|
||||
target_before: f32,
|
||||
target_after: f32, // must improve
|
||||
baseline_cycles: u64,
|
||||
transfer_cycles: u64,
|
||||
) -> Self {
|
||||
let improved_target = target_after > target_before;
|
||||
let regressed_source = source_after < source_before - 0.01;
|
||||
let promotable = improved_target && !regressed_source;
|
||||
let acceleration_factor = baseline_cycles as f32 / transfer_cycles as f32;
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
A transfer delta is promotable only when:
|
||||
1. `improved_target`: The target domain's score increased after applying the transfer
|
||||
2. `NOT regressed_source`: The source domain's score did not decrease beyond a 0.01 tolerance
|
||||
|
||||
Both conditions must hold. This prevents transfers that help the target at the expense of corrupting the source.
|
||||
|
||||
### 3.7 Holdout Evaluation Protocol
|
||||
|
||||
Transfers are validated on held-out tasks, not training tasks:
|
||||
|
||||
1. `generate_holdouts(tasks_per_domain, difficulty)` creates holdout task sets per domain
|
||||
2. `evaluate_population()` runs all policy kernels against holdout tasks
|
||||
3. Holdout scores are recorded per kernel per domain
|
||||
4. `evolve_population()` selects top performers, mutates, records Pareto front
|
||||
|
||||
The holdout set is never used for training. This prevents overfitting to the evaluation metric.
|
||||
|
||||
## 4. Implementation
|
||||
|
||||
### 4.1 DomainExpansionEngine
|
||||
|
||||
The central orchestrator:
|
||||
|
||||
```rust
|
||||
pub struct DomainExpansionEngine {
|
||||
domains: HashMap<DomainId, Box<dyn Domain>>,
|
||||
pub thompson: MetaThompsonEngine,
|
||||
pub population: PopulationSearch,
|
||||
pub scoreboard: AccelerationScoreboard,
|
||||
pub meta: MetaLearningEngine,
|
||||
holdouts: HashMap<DomainId, Vec<Task>>,
|
||||
counterexamples: HashMap<DomainId, Vec<(Task, Solution, Evaluation)>>,
|
||||
}
|
||||
```
|
||||
|
||||
Initialized with three domains: `rust_synthesis`, `structured_planning`, `tool_orchestration`. Four strategy arms: `greedy`, `exploratory`, `conservative`, `speculative`. Three difficulty tiers: `easy`, `medium`, `hard`.
|
||||
|
||||
### 4.2 Transfer Flow
|
||||
|
||||
```rust
|
||||
// 1. Record outcomes in source domain
|
||||
engine.evaluate_and_record(&source, task, solution, bucket, arm);
|
||||
|
||||
// 2. Initiate transfer (extracts dampened priors)
|
||||
engine.initiate_transfer(&source, &target);
|
||||
|
||||
// 3. Train on target domain (uses transferred priors)
|
||||
engine.evaluate_and_record(&target, task, solution, bucket, arm);
|
||||
|
||||
// 4. Verify the transfer
|
||||
let verification = engine.verify_transfer(
|
||||
&source, &target,
|
||||
source_before, source_after,
|
||||
target_before, target_after,
|
||||
baseline_cycles, transfer_cycles,
|
||||
);
|
||||
assert!(verification.promotable);
|
||||
```
|
||||
|
||||
### 4.3 Population-Based Policy Search
|
||||
|
||||
A population of 8 `PolicyKernel` variants runs in parallel. Each kernel tunes strategy knobs:
|
||||
|
||||
- Skip mode (whether to skip low-confidence contexts)
|
||||
- Prepass flag (whether to run a fast prepass before full evaluation)
|
||||
- Speculation threshold (when to trigger dual-path execution)
|
||||
|
||||
Evolution: evaluate all kernels on holdouts, record in Pareto front (accuracy vs cost vs robustness), keep top performers, mutate, increment generation.
|
||||
|
||||
### 4.4 Meta-Learning Engine
|
||||
|
||||
Five composable improvements layered on top of Thompson Sampling:
|
||||
|
||||
| Component | Purpose |
|
||||
|-----------|---------|
|
||||
| `RegretTracker` | Measures cumulative regret vs oracle policy |
|
||||
| `DecayingBeta` | Time-decays Beta parameters for non-stationary environments |
|
||||
| `PlateauDetector` | Detects when cost curve flattens, suggests action (increase difficulty, transfer, restart) |
|
||||
| `ParetoFront` | Maintains non-dominated set of (accuracy, cost, robustness) |
|
||||
| `CuriosityBonus` | UCB-style exploration bonus for under-visited bucket/arm combinations |
|
||||
|
||||
### 4.5 Integration with Shared Brain
|
||||
|
||||
The `DomainExpansionEngine` is instantiated in the `mcp-brain-server`'s `AppState`:
|
||||
|
||||
```rust
|
||||
let domain_engine = Arc::new(parking_lot::RwLock::new(
|
||||
ruvector_domain_expansion::DomainExpansionEngine::new(),
|
||||
));
|
||||
```
|
||||
|
||||
The `POST /v1/transfer` REST endpoint and the `brain_transfer` MCP tool invoke `engine.initiate_transfer()` and return the `TransferVerification` result.
|
||||
|
||||
### 4.6 Counterexample Tracking
|
||||
|
||||
Solutions scoring below 0.3 are stored as counterexamples per domain. These serve two purposes:
|
||||
1. Negative examples for future strategy selection (avoid strategies that produced poor results in similar contexts)
|
||||
2. Diagnostic data for understanding domain boundaries where transfer fails
|
||||
|
||||
## 5. Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Measurable generalization**: The acceleration scoreboard provides a quantitative answer to "is the system getting smarter across domains?"
|
||||
- **Safe transfer**: Dampened priors prevent over-commitment to source-domain strategies. Dual verification prevents source regression.
|
||||
- **Compact transfer artifacts**: TransferPriors are small (per-bucket Beta parameters, not raw trajectories). They serialize to a few KB as RVF segments.
|
||||
- **Composable meta-learning**: Regret tracking, plateau detection, Pareto optimization, and curiosity bonuses layer independently and can be enabled/disabled per deployment.
|
||||
|
||||
### Negative
|
||||
|
||||
- **Three domains only**: The current implementation has three hard-coded domains (Rust synthesis, planning, tool orchestration). Adding new domains requires implementing the `Domain` trait and registering with the engine.
|
||||
- **No online transfer**: Transfer is initiated manually via `initiate_transfer()`. Automatic transfer triggering (e.g., when a domain's cost curve plateaus) is deferred.
|
||||
- **Population size fixed**: The population search uses 8 kernels. Tuning population size requires code changes.
|
||||
|
||||
### Neutral
|
||||
|
||||
- The 0.01 tolerance on source regression allows minor noise in evaluation scores without blocking transfers. This is a practical trade-off — evaluation noise from holdout sampling can cause small score fluctuations that are not true regressions.
|
||||
- Counterexamples grow unboundedly per domain. A pruning strategy (keep top-N by recency or informativeness) is deferred.
|
||||
514
docs/adr/ADR-069-google-edge-network-deployment.md
Normal file
514
docs/adr/ADR-069-google-edge-network-deployment.md
Normal file
|
|
@ -0,0 +1,514 @@
|
|||
# ADR-069: Edge-Net and Pi Brain Integration — Distributed Compute Intelligence
|
||||
|
||||
**Status**: Proposed
|
||||
**Date**: 2026-02-28
|
||||
**Authors**: RuVector Team
|
||||
**Deciders**: ruv
|
||||
**Supersedes**: N/A
|
||||
**Related**: ADR-059 (Shared Brain Google Cloud), ADR-060 (Shared Brain Capabilities), ADR-062 (Brainpedia Architecture), ADR-063 (WASM Executable Nodes), ADR-064 (Pi Brain Infrastructure), ADR-066 (SSE MCP Transport)
|
||||
|
||||
## 1. Context
|
||||
|
||||
Two complementary systems exist in the ruvector ecosystem, both deployed on Google Cloud Run:
|
||||
|
||||
**Pi Brain** (`ruvbrain` at `pi.ruv.io`) is a centralized shared intelligence substrate. It provides a knowledge graph with Bayesian quality scoring, federated MicroLoRA consensus weights, structured hash embeddings, MinCut graph partitioning, and SONA self-optimization. It exposes 22 tools via SSE MCP transport (`/sse`) and a REST API (`/v1/*`). All operations are protected by witness chains and a challenge-nonce authentication system. It runs as a single Rust binary on Cloud Run.
|
||||
|
||||
**Edge-Net** (`examples/edge-net/`) is a distributed P2P browser compute network. Browser visitors contribute idle CPU cycles via Web Workers running Rust/WASM. Contributors earn rUv (Resource Utility Vouchers) based on compute donated. The network provides HNSW vector search, MicroLoRA adaptation, federated gradient gossip, entropy-based consensus, collective memory with hippocampal replay, and an adversarial coherence engine (RAC). It uses Pi-Key Ed25519 identity, a CRDT-based credit ledger, and a browser-based MCP server (`WasmMcpServer`).
|
||||
|
||||
These systems operate independently. The brain holds curated knowledge but is bottlenecked by single-origin compute for embedding generation, similarity search, and LoRA training. Edge-net has distributed compute capacity but no centralized knowledge substrate to draw from or contribute to. Connecting them creates a flywheel: edge nodes contribute compute that powers brain operations, brain knowledge improves edge node task routing, and rUv earned from brain operations incentivizes sustained edge participation.
|
||||
|
||||
### Current Deployment
|
||||
|
||||
| Service | URL | Platform |
|
||||
|---------|-----|----------|
|
||||
| `ruvbrain` (pi brain) | `https://pi.ruv.io` | Cloud Run, us-central1 |
|
||||
| `edge-net-dashboard` | `https://edge-net-dashboard-875130704813.us-central1.run.app` | Cloud Run, us-central1 |
|
||||
| `edge-net-genesis` | `https://edge-net-genesis-875130704813.us-central1.run.app` | Cloud Run, us-central1 |
|
||||
| `edge-net-relay` | `https://edge-net-relay-875130704813.us-central1.run.app` | Cloud Run, us-central1 |
|
||||
|
||||
## 2. Decision
|
||||
|
||||
Integrate edge-net's distributed compute network with pi brain's knowledge substrate. Edge-net nodes become first-class brain contributors: they earn rUv for compute that powers embedding generation, vector similarity search, MinCut partitioning, and federated LoRA aggregation. The brain gains horizontally scalable compute; edge-net gains access to curated knowledge and a concrete economic purpose for contributed cycles.
|
||||
|
||||
The integration uses the edge-net relay as a bridge between browser WASM nodes and the brain's REST/MCP APIs. Edge-net's existing `WasmMcpServer` speaks MCP and can proxy requests to the brain's SSE MCP transport. Pi-Key identity maps to the brain's contributor pseudonym system (SHAKE-256 derivation) so that edge contributions are attributed and auditable.
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
### 3.1 Integrated System Diagram
|
||||
|
||||
```
|
||||
Browser Nodes (WASM) Cloud Run Services
|
||||
==================== ==================
|
||||
|
||||
+-----------+ +-----------+ +-----------+
|
||||
| edge-net | | edge-net | | edge-net |
|
||||
| node A | | node B | | node C | Contributors
|
||||
| (browser) | | (browser) | | (browser) | earn rUv for
|
||||
+-----+-----+ +-----+-----+ +-----+-----+ brain compute
|
||||
| | |
|
||||
| WebSocket | WebSocket |
|
||||
+-------+-------+-------+-------+
|
||||
| +-----------------+
|
||||
+------+------+ | edge-net-genesis|
|
||||
| edge-net | rUv ledger sync | (Cloud Run) |
|
||||
| relay +----------------------->| QDAG ledger |
|
||||
| (Cloud Run) | | Node registry |
|
||||
+------+------+ +-----------------+
|
||||
|
|
||||
| Brain API bridge
|
||||
| (REST or SSE MCP)
|
||||
|
|
||||
+------+------+ +-----------------+
|
||||
| ruvbrain | | edge-net |
|
||||
| (pi brain) | | dashboard |
|
||||
| pi.ruv.io | | (React, Cloud |
|
||||
| | | Run) |
|
||||
| Knowledge | +-----------------+
|
||||
| graph, LoRA |
|
||||
| consensus, |
|
||||
| embeddings |
|
||||
+-------------+
|
||||
```
|
||||
|
||||
### 3.2 Data Flow: Brain Operation Distributed to Edge
|
||||
|
||||
```
|
||||
1. Brain receives search query via MCP or REST
|
||||
|
|
||||
2. Brain decomposes into distributable subtasks
|
||||
|
|
||||
3. Subtasks sent to relay as compute tasks
|
||||
|
|
||||
4. Relay fans out to available edge nodes
|
||||
|
|
||||
5. Edge nodes execute (HNSW search, embedding, etc.)
|
||||
|
|
||||
6. Results return through relay to brain
|
||||
|
|
||||
7. Brain aggregates, applies quality gating
|
||||
|
|
||||
8. Edge nodes credited rUv via genesis ledger
|
||||
```
|
||||
|
||||
### 3.3 MCP Protocol Bridge
|
||||
|
||||
Edge-net's `WasmMcpServer` already implements the MCP JSON-RPC protocol with tools like `vector_search`, `generate_embedding`, `credit_balance`, and `coherence_stats`. The brain exposes 22 tools via SSE MCP at `/sse`. The relay bridges these two MCP surfaces:
|
||||
|
||||
```
|
||||
Browser WASM Node Relay Pi Brain
|
||||
+-----------------+ +-------------------+ +------------------+
|
||||
| WasmMcpServer | | | | SSE MCP Server |
|
||||
| (MessagePort) |--->| WebSocket in | | /sse endpoint |
|
||||
| | | MCP JSON-RPC out |--->| 22 tools |
|
||||
| Tools: | | | | |
|
||||
| - vector_search | | Maps edge tools | | Tools: |
|
||||
| - embedding | | to brain tools | | - brain_search |
|
||||
| - lora_forward | | + rUv accounting | | - brain_share |
|
||||
| - credit_balance| | | | - brain_sync |
|
||||
+-----------------+ +-------------------+ +------------------+
|
||||
```
|
||||
|
||||
## 4. Integration Points
|
||||
|
||||
### 4.1 Identity Mapping
|
||||
|
||||
Edge-net uses Pi-Key identity (Ed25519 keys, 314-bit Pi-sized). The brain uses SHAKE-256 contributor pseudonyms derived from API keys. The integration maps between them:
|
||||
|
||||
- Edge node's Pi-Key public key is registered with the brain as a contributor identity
|
||||
- The relay derives a brain-compatible pseudonym from the Pi-Key using SHAKE-256 over the Ed25519 public key bytes
|
||||
- All brain operations from edge nodes carry this derived pseudonym for attribution
|
||||
- Witness chains in the brain record the Pi-Key signature alongside the pseudonym
|
||||
|
||||
### 4.2 Knowledge Synchronization
|
||||
|
||||
Edge-net's collective memory (hippocampal replay, HNSW-indexed patterns) synchronizes with the brain's knowledge graph:
|
||||
|
||||
| Edge-Net Component | Brain Component | Sync Direction |
|
||||
|-------------------|-----------------|----------------|
|
||||
| `CollectiveMemory` patterns | Brain memories (knowledge graph) | Bidirectional |
|
||||
| `EntropyConsensus` decisions | Brain quality scores (Bayesian) | Edge -> Brain |
|
||||
| `NetworkLearning` trajectories | Brain `LearnedPattern` entries | Edge -> Brain |
|
||||
| MicroLoRA adapter weights | Brain LoRA consensus weights | Brain -> Edge |
|
||||
| HNSW index state | Brain embedding space | Brain -> Edge (delta sync) |
|
||||
|
||||
### 4.3 Entropy Consensus for Quality Voting
|
||||
|
||||
Edge-net's entropy-based consensus (Shannon entropy minimization with DeGroot belief mixing) provides a distributed quality signal for brain knowledge. When multiple edge nodes encounter the same brain memory during task execution, their independent quality assessments converge via entropy consensus. The consensus result feeds back to the brain as a distributed Bayesian update, supplementing individual `brain_vote` calls with collective judgment.
|
||||
|
||||
### 4.4 RAC Coherence Integration
|
||||
|
||||
Edge-net's adversarial coherence engine (RAC, 12 axioms) protects the integrity of shared patterns. When edge nodes share patterns derived from brain knowledge, RAC ensures:
|
||||
|
||||
- **Axiom 6 (Disagreement is signal)**: Conflicting edge assessments of brain knowledge trigger investigation
|
||||
- **Axiom 9 (Quarantine is mandatory)**: Suspicious patterns from untrusted nodes are quarantined before reaching the brain
|
||||
- **Axiom 11 (Equivocation detectable)**: Merkle tree audit trail prevents nodes from submitting contradictory results
|
||||
|
||||
## 5. Distributed Compute Tasks
|
||||
|
||||
Brain operations that can be farmed out to edge-net WASM nodes:
|
||||
|
||||
### 5.1 Vector Similarity Search
|
||||
|
||||
The brain's HNSW index can be partitioned across edge nodes. Each node holds a shard of the index and executes approximate nearest-neighbor queries locally. The relay collects top-K results from multiple shards and merges them.
|
||||
|
||||
- **Edge capability**: `HnswIndex` in `edge-net/src/ai/memory.rs` with 150x speedup over naive search
|
||||
- **WASM SIMD**: `simd128` intrinsics accelerate cosine distance on supporting browsers
|
||||
- **Shard strategy**: Brain partitions index by domain/namespace; each edge node caches its assigned shard
|
||||
|
||||
### 5.2 Embedding Generation
|
||||
|
||||
The brain's `structured_hash_features()` generates lexical n-gram embeddings. The MicroLoRA transform adds learned semantic refinement. Both stages can run on edge nodes:
|
||||
|
||||
- **Stage 1 (lexical)**: `structured_hash_features()` is pure computation on text input, runs entirely in WASM
|
||||
- **Stage 2 (LoRA transform)**: MicroLoRA forward pass with rank 1-16, `<50us` for rank-1 on edge-net's `AdapterPool`
|
||||
- **Consensus weights**: Edge nodes pull the latest LoRA consensus from the brain, ensuring embedding consistency
|
||||
|
||||
### 5.3 MinCut Graph Partitioning
|
||||
|
||||
The brain's `SubpolynomialMinCut` algorithm for knowledge graph partitioning can be parallelized:
|
||||
|
||||
- **Partition strategy**: Each edge node processes a subgraph assigned by the brain
|
||||
- **Merge**: The relay collects partial cuts and the brain computes the global minimum
|
||||
- **Use case**: When the knowledge graph grows large, rebalancing partitions benefits from distributed compute
|
||||
|
||||
### 5.4 Federated LoRA Training
|
||||
|
||||
Edge-net already implements federated learning with Byzantine-tolerant gradient gossip (`GradientGossip` in `edge-net/src/ai/federated.rs`):
|
||||
|
||||
- **TopK sparsification**: 90% gradient compression reduces bandwidth
|
||||
- **Byzantine detection**: 2-sigma outlier exclusion removes malicious gradients
|
||||
- **Differential privacy**: Gaussian noise injection protects individual contributions
|
||||
- **Integration**: Edge nodes train MicroLoRA weights on local task patterns; brain aggregates via reputation-weighted federated averaging
|
||||
|
||||
### 5.5 Quality Voting
|
||||
|
||||
Distributed Bayesian quality updates from edge nodes:
|
||||
|
||||
- Each edge node that uses a brain memory during task execution records success/failure
|
||||
- Results are aggregated via entropy consensus across participating edge nodes
|
||||
- The consensus vote is submitted to the brain as a weighted Bayesian update
|
||||
- Brain's quality gating (auto-archive at `quality_score.mean() < 0.3` after 5 observations) benefits from higher observation counts
|
||||
|
||||
## 6. Security Model
|
||||
|
||||
### 6.1 Identity and Authentication
|
||||
|
||||
| Layer | Mechanism | Purpose |
|
||||
|-------|-----------|---------|
|
||||
| Edge node identity | Pi-Key Ed25519 (314-bit) | Per-node cryptographic identity |
|
||||
| Brain contributor pseudonym | SHAKE-256 of Pi-Key public key | Brain-compatible attribution |
|
||||
| Operation signing | Ed25519 signatures on task results | Non-repudiation |
|
||||
| Brain witness chains | Append-only signed chain | Audit trail for all contributions |
|
||||
|
||||
### 6.2 Byzantine Fault Tolerance
|
||||
|
||||
Edge nodes are untrusted by default. The system provides multiple layers of protection:
|
||||
|
||||
- **Gradient poisoning**: `ByzantineDetector` in federated.rs excludes gradients beyond 2 standard deviations from the reputation-weighted mean
|
||||
- **Result validation**: Brain cross-checks edge search results against local index for random samples (probabilistic verification)
|
||||
- **Reputation decay**: Nodes that submit invalid results lose reputation, reducing their influence on future aggregation
|
||||
- **RAC quarantine**: Patterns from low-reputation nodes enter quarantine before affecting brain state
|
||||
|
||||
### 6.3 Sybil Resistance
|
||||
|
||||
Sybil resistance uses layered defenses instead of high staking barriers, ensuring the network remains accessible to newcomers while protecting against abuse:
|
||||
|
||||
| Defense Layer | Mechanism | Purpose |
|
||||
|---------------|-----------|---------|
|
||||
| Proof of Work | First contribution requires completing 1 compute task | Proves real compute capacity; blocks zero-cost identity creation |
|
||||
| Rate Limiting | 100 writes/hour per identity | Bounds the damage any single Sybil identity can cause |
|
||||
| Quality Gating | Shared knowledge must pass RAC coherence check (score > 0.5) | Prevents low-quality spam from polluting the brain |
|
||||
| Progressive Trust | Higher reputation tiers unlock higher rate limits and priority | Long-term good behavior is expensive to fake across many identities |
|
||||
| Reputation Decay | 1% decay per epoch (see `ReputationCurve.apply_decay()`) | Abandoned Sybil identities lose influence automatically |
|
||||
|
||||
Staking remains available as an optional mechanism (see Section 8.3) but is not required for basic participation. Nodes that submit invalid results (as detected by probabilistic verification or Byzantine detection) have their reputation slashed, which is more effective than financial slashing alone because reputation takes sustained effort to rebuild.
|
||||
|
||||
### 6.4 Data Privacy
|
||||
|
||||
- Edge nodes receive only the data needed for their assigned subtask (need-to-know)
|
||||
- Search queries are decomposed so no single edge node sees the full query context
|
||||
- LoRA gradients are protected by differential privacy (Gaussian noise with configurable epsilon)
|
||||
- The relay strips personally identifiable metadata before forwarding to edge nodes
|
||||
|
||||
## 7. API Bridge Design
|
||||
|
||||
Three integration options, ordered by implementation simplicity:
|
||||
|
||||
### Option A: Direct REST (recommended for Phase 1)
|
||||
|
||||
Edge-net nodes call the brain's REST API directly through the relay proxy.
|
||||
|
||||
```
|
||||
Edge Node --[WS]--> Relay --[HTTPS]--> pi.ruv.io/v1/memories/search
|
||||
pi.ruv.io/v1/memories (POST)
|
||||
pi.ruv.io/v1/lora/latest
|
||||
```
|
||||
|
||||
- Simplest to implement: relay proxies HTTP requests with Pi-Key authentication headers
|
||||
- Each edge node's requests are independently rate-limited at the relay
|
||||
- No persistent connection required between relay and brain
|
||||
|
||||
### Option B: Relay-Proxied Batch (recommended for Phase 2)
|
||||
|
||||
The relay batches requests from multiple edge nodes into bulk brain API calls.
|
||||
|
||||
```
|
||||
Edge Nodes --[WS]--> Relay --[HTTPS bulk]--> pi.ruv.io/v1/batch
|
||||
```
|
||||
|
||||
- Reduces brain API call count when many edge nodes query simultaneously
|
||||
- Relay aggregates search queries, deduplicates, and fans results back to requesting nodes
|
||||
- Requires a `/v1/batch` endpoint on the brain (new development)
|
||||
|
||||
### Option C: SSE MCP Bridge (recommended for Phase 3)
|
||||
|
||||
The relay maintains a persistent SSE MCP session with the brain and multiplexes edge node requests over it.
|
||||
|
||||
```
|
||||
Edge Nodes --[WS/MCP]--> Relay --[SSE MCP]--> pi.ruv.io/sse
|
||||
```
|
||||
|
||||
- Full tool access: all 22 brain MCP tools available to edge nodes
|
||||
- Persistent connection amortizes SSE setup overhead
|
||||
- Relay maintains a single SSE session per brain region, multiplexing edge requests
|
||||
- Requires MCP session management in the relay (handles reconnection, session affinity)
|
||||
|
||||
## 8. Economic Model
|
||||
|
||||
The economic model is designed around two principles: **accessible** (no barriers to entry) and **sustainable** (finite supply with controlled inflation). The previous model required 10-200 rUv staking to participate, creating a chicken-and-egg problem for new users. The revised model eliminates cost barriers entirely and uses contribution rewards to bootstrap the economy.
|
||||
|
||||
### 8.1 Free-to-Read, Earn-to-Write
|
||||
|
||||
All read operations are FREE. No rUv cost, no staking requirement. This removes the single biggest barrier to adoption. Write operations are also free to perform but earn rUv rewards when quality thresholds are met.
|
||||
|
||||
| Operation | Cost | Reward | Conditions |
|
||||
|-----------|------|--------|------------|
|
||||
| Search brain | FREE | 0 rUv | -- |
|
||||
| Get brain status | FREE | 0 rUv | -- |
|
||||
| List memories | FREE | 0 rUv | -- |
|
||||
| Share knowledge | FREE | 2 rUv | Quality score > 0.5 after RAC review |
|
||||
| Vote on quality | FREE | 0.1 rUv | Must have completed >= 1 task |
|
||||
| Generate embedding | FREE | 1 rUv | Result passes hash verification |
|
||||
| LoRA gradient | FREE | 5 rUv | Gradient passes Byzantine detection |
|
||||
| WASM compute | FREE | 0.5-3 rUv | Based on compute time and success rate |
|
||||
|
||||
Implementation reference: rewards are credited via `WasmCreditLedger.credit()` in `examples/edge-net/src/credits/mod.rs`. The ledger's CRDT merge (`WasmCreditLedger.merge()`) ensures consistency across P2P nodes.
|
||||
|
||||
### 8.2 Contribution Curve (Revised)
|
||||
|
||||
The contribution curve rewards early adopters without being extractive. The formula is updated from the existing `ContributionCurve` in `examples/edge-net/src/credits/mod.rs`:
|
||||
|
||||
```
|
||||
multiplier = FLOOR + (MAX_BONUS - FLOOR) * e^(-network_compute / DECAY_CONSTANT)
|
||||
```
|
||||
|
||||
| Parameter | Old Value | New Value | Rationale |
|
||||
|-----------|-----------|-----------|-----------|
|
||||
| `MAX_BONUS` | 10x | 5x | Still rewards early adopters, less extractive |
|
||||
| `FLOOR_MULTIPLIER` | 1x (implicit) | 0.5x | Even mature network still pays something |
|
||||
| `DECAY_CONSTANT` | 1,000,000 CPU-hours | 1,000,000 CPU-hours | Unchanged |
|
||||
|
||||
At genesis (0 compute-hours), multiplier is 5x. As total network compute grows, multiplier decays toward 0.5x. This means:
|
||||
|
||||
| Network Compute | Multiplier |
|
||||
|----------------|------------|
|
||||
| 0 hours (genesis) | 5.0x |
|
||||
| 100K hours | 4.6x |
|
||||
| 500K hours | 2.9x |
|
||||
| 1M hours | 1.9x |
|
||||
| 5M hours | 0.5x |
|
||||
|
||||
The `ContributionCurve::current_multiplier()` implementation must be updated to use the revised constants. The `FLOOR_MULTIPLIER` ensures contributors always earn something, even when the network is mature.
|
||||
|
||||
### 8.3 Reputation Tiers (Revised)
|
||||
|
||||
Reputation tiers use the existing `ReputationTier` enum from `examples/edge-net/src/economics/reputation.rs` but add a Newcomer tier and make staking optional:
|
||||
|
||||
| Tier | Score | Reward Mult | Staking Required | Access |
|
||||
|------|-------|-------------|-----------------|--------|
|
||||
| Newcomer | 0-10 | 0.5x | None | Read-only free, writes earn at 0.5x |
|
||||
| Bronze | 10-25 | 1.0x | 0 rUv | Full read/write, standard rewards |
|
||||
| Silver | 25-50 | 1.1x | 10 rUv (optional) | Priority task allocation |
|
||||
| Gold | 50-75 | 1.25x | 50 rUv (optional) | Price discounts via `ReputationCurve.discount()`, priority |
|
||||
| Platinum | 75-100 | 1.5x | 100 rUv (optional) | Max discounts, governance voting weight |
|
||||
|
||||
Key changes from the previous model:
|
||||
- **Staking is OPTIONAL.** It provides benefits (price discounts, governance weight) but is not required for basic participation. This eliminates the chicken-and-egg problem where new users need rUv to participate but cannot earn rUv without participating.
|
||||
- **Newcomer tier added.** Score 0-10 earns at 0.5x rate, providing immediate earning capability.
|
||||
- **Reward multipliers** are applied via `ReputationTier::reward_multiplier()` which already implements the 1.0x/1.1x/1.25x/1.5x tiers in the codebase.
|
||||
|
||||
### 8.4 Halving Schedule
|
||||
|
||||
Brain rewards halve every 100K brain operations (cumulative across all nodes). This creates a Bitcoin-like deflationary schedule that ensures finite total rUv supply:
|
||||
|
||||
| Epoch | Cumulative Operations | Base Reward Multiplier |
|
||||
|-------|-----------------------|----------------------|
|
||||
| 0 | 0 - 100K | 1.0x |
|
||||
| 1 | 100K - 200K | 0.5x |
|
||||
| 2 | 200K - 400K | 0.25x |
|
||||
| 3 | 400K - 800K | 0.125x |
|
||||
| N | ... | 1/(2^N)x |
|
||||
|
||||
The halving multiplier stacks with the contribution curve and reputation tier multipliers:
|
||||
|
||||
```
|
||||
effective_reward = base_reward * contribution_multiplier * tier_multiplier * halving_multiplier
|
||||
```
|
||||
|
||||
This creates urgency for early participation (higher halving multiplier) without the aggressive 10x genesis bonus that the old contribution curve provided.
|
||||
|
||||
### 8.5 Protocol Budget
|
||||
|
||||
Each epoch has a fixed rUv budget to prevent runaway inflation:
|
||||
|
||||
| Parameter | Value | Rationale |
|
||||
|-----------|-------|-----------|
|
||||
| Initial epoch budget | 1,000,000 rUv | Sufficient for ~200K reward operations at average 5 rUv |
|
||||
| Budget carry-over | Yes | Unspent budget rolls to next epoch |
|
||||
| Budget exhaustion behavior | Rewards pause until next epoch | Hard cap prevents inflation |
|
||||
| Epoch duration | 7 days or 100K operations, whichever comes first | Bounds both time and volume |
|
||||
|
||||
The protocol fund is managed by `EconomicEngine.get_protocol_fund()`. The `ComputeAMM` in `examples/edge-net/src/economics/amm.rs` provides secondary revenue through trading fees (0.3%-3% dynamic fee based on pool utilization) that supplement the protocol budget after the bootstrap phase.
|
||||
|
||||
### 8.6 Sybil Resistance (Economic)
|
||||
|
||||
Instead of requiring high staking barriers (the old model required 10-200 rUv to participate), the revised model uses layered economic defenses:
|
||||
|
||||
| Defense | Mechanism | Implementation |
|
||||
|---------|-----------|----------------|
|
||||
| Proof of Work | First contribution requires completing 1 compute task | Proves real compute capacity; blocks zero-cost Sybil creation |
|
||||
| Rate limiting | 100 writes/hour per identity | Bounds damage per identity; implemented at relay level |
|
||||
| Quality gating | Shared knowledge must pass RAC coherence check | `AdversarialCoherence` score > 0.5 required |
|
||||
| Progressive trust | Higher tiers unlock higher rate limits and priority | `ReputationCurve.select_nodes_for_task()` weights by reputation |
|
||||
| Reputation cost | Reputation takes sustained effort to build (diminishing returns in `record_task()`) | More expensive to maintain many Sybil identities than one real identity |
|
||||
|
||||
### 8.7 Sustainability Analysis
|
||||
|
||||
The revised model is sustainable because total rUv supply converges:
|
||||
|
||||
**Finite supply proof.** With halving schedule and fixed epoch budget:
|
||||
- Epoch 0: up to 1,000,000 rUv minted
|
||||
- Epoch 1: up to 500,000 rUv
|
||||
- Epoch N: up to 1,000,000 / 2^N rUv
|
||||
- Total maximum supply = 1,000,000 * sum(1/2^N for N=0..inf) = 2,000,000 rUv
|
||||
|
||||
**Revenue sources by phase:**
|
||||
|
||||
| Phase | Duration | Primary Revenue | Secondary Revenue |
|
||||
|-------|----------|----------------|-------------------|
|
||||
| Bootstrap (0-10K nodes) | Months 1-6 | Protocol fund subsidy | None |
|
||||
| Growth (10K-100K nodes) | Months 6-18 | Protocol fund + AMM fees | Brain API consumer payments |
|
||||
| Self-sustaining (100K+ nodes) | Month 18+ | AMM fees + consumer payments | Liquidity provider fees |
|
||||
|
||||
**Deflationary pressure.** As brain knowledge grows, rUv utility increases (more valuable knowledge to access, more compute tasks available). The AMM's constant-product formula (`x * y = k` in `ComputeAMM`) ensures that as demand for compute grows, the rUv price of compute increases, creating natural deflationary pressure.
|
||||
|
||||
**Protocol fund sufficiency.** At 2,000,000 rUv maximum supply and projected 50K operations/month at maturity, the protocol fund sustains rewards for 24+ months even without secondary revenue. AMM fees (0.3%-3% of all swaps, tracked via `ComputeAMM.fees_collected`) provide a sustainable revenue stream after bootstrap.
|
||||
|
||||
## 9. Implementation Phases
|
||||
|
||||
### Phase 1: REST Bridge (Weeks 1-3)
|
||||
|
||||
1. Add Pi-Key to brain pseudonym derivation in the relay
|
||||
2. Implement relay HTTP proxy to brain REST API with rate limiting
|
||||
3. Add `brain_search` and `brain_share` proxy commands to edge-net's `WasmMcpServer`
|
||||
4. Edge nodes can search brain knowledge and share patterns via relay
|
||||
5. rUv accounting for search and share operations
|
||||
|
||||
### Phase 2: Distributed Search (Weeks 4-6)
|
||||
|
||||
1. Brain partitions HNSW index into shards by namespace
|
||||
2. Relay distributes shard assignments to edge nodes
|
||||
3. Edge nodes cache assigned shards and execute local HNSW queries
|
||||
4. Relay merges top-K results from multiple edge nodes
|
||||
5. Probabilistic verification: brain spot-checks 5% of edge search results
|
||||
|
||||
### Phase 3: Federated Intelligence (Weeks 7-10)
|
||||
|
||||
1. SSE MCP bridge between relay and brain
|
||||
2. Edge nodes pull brain LoRA consensus weights and generate embeddings locally
|
||||
3. Federated LoRA training: edge nodes contribute gradients from local task patterns
|
||||
4. Entropy consensus quality voting feeds back to brain Bayesian scores
|
||||
5. Collective memory synchronization between edge-net and brain knowledge graph
|
||||
|
||||
## 10. Monitoring
|
||||
|
||||
### 10.1 Integration Metrics
|
||||
|
||||
| Metric | Source | Alert Threshold |
|
||||
|--------|--------|-----------------|
|
||||
| Edge-to-brain request latency (p99) | Relay logs | > 500ms |
|
||||
| Brain search delegation rate | Brain metrics | < 10% (not using edge) |
|
||||
| Edge node participation rate | Genesis ledger | < 50 active nodes |
|
||||
| Byzantine rejection rate | Relay metrics | > 5% of submissions |
|
||||
| rUv earned per edge node (daily avg) | Genesis ledger | < 1 rUv (nodes leaving) |
|
||||
| LoRA consensus drift | Brain LoRA metrics | Cosine distance > 0.1 from last epoch |
|
||||
|
||||
### 10.2 Dashboard Integration
|
||||
|
||||
The existing `edge-net-dashboard` (React, Cloud Run) is extended with:
|
||||
|
||||
- Brain integration status panel (relay connection health, brain API availability)
|
||||
- Per-node brain contribution metrics (searches executed, embeddings generated, rUv earned)
|
||||
- Network-wide federated learning progress (consensus convergence, gradient acceptance rate)
|
||||
- Knowledge flow visualization (patterns shared edge -> brain, knowledge pulled brain -> edge)
|
||||
|
||||
## 11. Risks and Mitigations
|
||||
|
||||
| Risk | Impact | Mitigation |
|
||||
|------|--------|------------|
|
||||
| Edge nodes return stale search results from outdated shards | Degraded search quality | Shard version headers; brain rejects results from outdated shards |
|
||||
| Relay becomes single point of failure | Edge-brain integration offline | Deploy relay in multiple regions; edge nodes failover to direct brain REST |
|
||||
| LoRA gradient poisoning from compromised edge nodes | Corrupted brain consensus | Byzantine detection (2-sigma exclusion), reputation-weighted aggregation, differential privacy |
|
||||
| rUv inflation from brain subsidies | Devalued rUv economy | Fixed protocol fund budget per epoch; halving schedule aligned with genesis sunset |
|
||||
| High browser compute costs deter edge participation | Low node count | Battery-aware throttling, configurable CPU limits (10-50%), clear rUv earning visibility |
|
||||
| Brain API rate limits block edge relay | Throttled integration | Relay batches requests; brain allowlists relay IP with higher rate limits |
|
||||
| Privacy leak through search query distribution | User data exposure | Query decomposition, differential privacy on inputs, need-to-know distribution |
|
||||
|
||||
## 12. Acceptance Criteria
|
||||
|
||||
### Phase 1
|
||||
|
||||
- [ ] Edge node can execute `brain_search` through relay and receive results
|
||||
- [ ] Edge node can execute `brain_share` to publish a pattern to brain knowledge graph
|
||||
- [ ] Pi-Key identity correctly maps to brain contributor pseudonym
|
||||
- [ ] rUv credited for search and share operations via genesis ledger
|
||||
- [ ] Rate limiting prevents single edge node from overwhelming brain API
|
||||
|
||||
### Phase 2
|
||||
|
||||
- [ ] Brain HNSW index partitioned into at least 4 shards
|
||||
- [ ] Edge nodes cache assigned shards and return search results within 200ms
|
||||
- [ ] Merged results from edge nodes match single-origin results at 95% recall
|
||||
- [ ] Probabilistic verification catches intentionally wrong results > 90% of the time
|
||||
- [ ] Dashboard shows per-node search contribution metrics
|
||||
|
||||
### Phase 3
|
||||
|
||||
- [ ] Edge nodes pull LoRA consensus and generate embeddings consistent with brain
|
||||
- [ ] Federated gradient gossip produces consensus within 5% cosine distance of centralized training
|
||||
- [ ] Entropy consensus quality votes appear in brain quality scores within one epoch
|
||||
- [ ] Collective memory sync operates bidirectionally without data loss
|
||||
- [ ] SSE MCP bridge maintains persistent connection with automatic reconnection
|
||||
|
||||
### Economics
|
||||
|
||||
- [ ] New users can search brain without any rUv balance or staking
|
||||
- [ ] Newcomer tier (score 0-10) earns rUv at 0.5x rate
|
||||
- [ ] `ContributionCurve` updated with `MAX_BONUS = 5.0` and `FLOOR_MULTIPLIER = 0.5`
|
||||
- [ ] Halving schedule reduces base reward multiplier after every 100K cumulative operations
|
||||
- [ ] Protocol budget limits per-epoch rUv minting to 1,000,000 rUv
|
||||
- [ ] Staking is optional (not required for basic participation at Newcomer or Bronze tiers)
|
||||
- [ ] Rate limiting enforced at 100 writes/hour per identity
|
||||
- [ ] Quality gating requires RAC coherence score > 0.5 for shared knowledge
|
||||
- [ ] AMM fee revenue tracked and reported on dashboard
|
||||
|
||||
## 13. Related ADRs
|
||||
|
||||
| ADR | Relationship |
|
||||
|-----|-------------|
|
||||
| ADR-059 | Shared Brain Google Cloud -- brain deployment architecture that edge-net integrates with |
|
||||
| ADR-060 | Shared Brain Capabilities -- federated MicroLoRA and knowledge substrate that edge-net extends |
|
||||
| ADR-062 | Brainpedia Architecture -- knowledge pages that edge-net quality voting improves |
|
||||
| ADR-063 | WASM Executable Nodes -- WASM node system that edge-net browser nodes share technology with |
|
||||
| ADR-064 | Pi Brain Infrastructure -- Cloud Run deployment, custom domains, persistence layer |
|
||||
| ADR-066 | SSE MCP Transport -- the MCP transport that the Phase 3 bridge connects to |
|
||||
344
docs/adr/ADR-070-npx-ruvector-unified-integration.md
Normal file
344
docs/adr/ADR-070-npx-ruvector-unified-integration.md
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
# ADR-070: npx ruvector Unified Integration
|
||||
|
||||
**Status**: Proposed
|
||||
**Date**: 2026-02-28
|
||||
**Authors**: RuVector Team
|
||||
**Deciders**: ruv
|
||||
**Supersedes**: N/A
|
||||
**Related**: ADR-065 (npm Publishing Strategy), ADR-064 (Pi Brain Infrastructure), ADR-066 (SSE MCP Transport), ADR-069 (Edge-Net Integration)
|
||||
|
||||
## 1. Context
|
||||
|
||||
The RuVector npm ecosystem has grown to 55+ packages across multiple concerns:
|
||||
|
||||
- **`ruvector`** (v0.1.100): Core vector database with native/WASM/RVF backend auto-detection, CLI (`npx ruvector`), GNN wrappers, SONA embeddings, ONNX, parallel intelligence
|
||||
- **`@ruvector/pi-brain`**: Pi brain CLI + SDK + MCP stdio proxy for the shared intelligence at `pi.ruv.io`
|
||||
- **`@ruvector/edge-net`**: Distributed P2P browser compute network (WASM, Web Workers, rUv credits)
|
||||
- **50+ other packages**: solver, rvf, gnn, attention, sona, ruvllm, tiny-dancer, ospipe, etc.
|
||||
|
||||
These systems are independently installable and operate in isolation. There is no unified entry point that lets a user:
|
||||
1. Access the shared brain from the CLI
|
||||
2. Join the edge compute network from Node.js
|
||||
3. Start an MCP server that bridges all capabilities
|
||||
4. Manage their π identity and rUv credits from one place
|
||||
|
||||
The `ruvector` CLI already has a `commander`-based command structure with subcommands for vector operations (insert, search, benchmark, etc.). Extending it to include brain, edge, and MCP integration creates a single `npx ruvector` entry point for the entire ecosystem.
|
||||
|
||||
## 2. Decision
|
||||
|
||||
Extend the existing `ruvector` CLI with three new command groups: `brain`, `edge`, and `mcp`. These commands call into `@ruvector/pi-brain` and `@ruvector/edge-net` as optional peer dependencies — they are lazy-loaded only when invoked, so the core `ruvector` package remains lightweight.
|
||||
|
||||
```
|
||||
npx ruvector brain share "JWT refresh pattern" --category pattern
|
||||
npx ruvector brain search "auth" --limit 10
|
||||
npx ruvector edge status
|
||||
npx ruvector edge join --contribution 0.3
|
||||
npx ruvector mcp start --transport sse --url https://pi.ruv.io
|
||||
npx ruvector identity generate
|
||||
npx ruvector identity show
|
||||
```
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
### 3.1 Command Hierarchy
|
||||
|
||||
```
|
||||
npx ruvector
|
||||
├── insert (existing) Vector insert
|
||||
├── search (existing) Vector search
|
||||
├── benchmark (existing) Performance benchmark
|
||||
├── info (existing) System info
|
||||
│
|
||||
├── brain (NEW) Shared intelligence
|
||||
│ ├── share Share knowledge
|
||||
│ ├── search Semantic search
|
||||
│ ├── get Retrieve by ID
|
||||
│ ├── vote Quality vote
|
||||
│ ├── list List memories
|
||||
│ ├── delete Delete own
|
||||
│ ├── transfer Domain transfer
|
||||
│ ├── drift Check drift
|
||||
│ ├── partition Knowledge topology
|
||||
│ ├── status System health
|
||||
│ ├── sync LoRA weight sync
|
||||
│ ├── page Brainpedia CRUD
|
||||
│ └── node WASM node publish
|
||||
│
|
||||
├── edge (NEW) Distributed compute
|
||||
│ ├── status Network status (genesis, relay, nodes)
|
||||
│ ├── join Join as compute node
|
||||
│ ├── balance Check rUv balance
|
||||
│ ├── tasks List available compute tasks
|
||||
│ └── dashboard Open dashboard URL
|
||||
│
|
||||
├── mcp (NEW) MCP server management
|
||||
│ ├── start Start MCP server (stdio or SSE)
|
||||
│ ├── tools List available MCP tools
|
||||
│ └── test Test MCP connection
|
||||
│
|
||||
└── identity (NEW) π identity management
|
||||
├── generate Generate new π key
|
||||
├── show Display current pseudonym
|
||||
├── export Export key for backup
|
||||
└── import Import key from backup
|
||||
```
|
||||
|
||||
### 3.2 Dependency Strategy
|
||||
|
||||
```
|
||||
ruvector (core)
|
||||
├── commander, chalk, ora (bundled)
|
||||
├── @ruvector/core (optional - native backend)
|
||||
├── @ruvector/rvf (optional - RVF backend)
|
||||
│
|
||||
├── @ruvector/pi-brain (optional peer dep - brain commands)
|
||||
│ └── @modelcontextprotocol/sdk
|
||||
│
|
||||
└── @ruvector/edge-net (optional peer dep - edge commands)
|
||||
└── wasm-bindgen (WASM runtime)
|
||||
```
|
||||
|
||||
When a user runs `npx ruvector brain search "auth"` without `@ruvector/pi-brain` installed, the CLI prints:
|
||||
|
||||
```
|
||||
Edge command requires @ruvector/pi-brain. Install with:
|
||||
npm install @ruvector/pi-brain
|
||||
```
|
||||
|
||||
This keeps the core package at ~2MB while allowing the full ecosystem to be progressively adopted.
|
||||
|
||||
### 3.3 Environment Configuration
|
||||
|
||||
All commands read from a unified config hierarchy:
|
||||
|
||||
| Source | Priority | Example |
|
||||
|--------|----------|---------|
|
||||
| CLI flags | 1 (highest) | `--url https://pi.ruv.io` |
|
||||
| Environment vars | 2 | `PI=key`, `BRAIN_URL=url` |
|
||||
| `.env` file | 3 | `PI=abc123...` in project root |
|
||||
| `~/.ruvector/config.json` | 4 | Global config |
|
||||
| Defaults | 5 (lowest) | `https://pi.ruv.io` |
|
||||
|
||||
The π key (`PI` env var) is shared across brain, edge, and MCP commands. One identity, one key, three systems.
|
||||
|
||||
### 3.4 Identity Derivation Chain
|
||||
|
||||
```
|
||||
User's π key (64 hex chars)
|
||||
│
|
||||
├── SHAKE-256(key) ──► Brain pseudonym (contributor ID)
|
||||
│ Used for: brain share, vote, delete
|
||||
│
|
||||
├── Ed25519(key) ────► Edge Pi-Key (node identity)
|
||||
│ Used for: edge join, rUv transactions
|
||||
│
|
||||
└── HMAC-SHA256(key, "mcp") ──► MCP session token
|
||||
Used for: SSE MCP auth
|
||||
```
|
||||
|
||||
A single key derives three identities through different cryptographic paths. The SHAKE-256 path matches the brain server's `auth.rs` pseudonym derivation. The Ed25519 path matches edge-net's `pikey` module. The HMAC path provides MCP session auth.
|
||||
|
||||
## 4. New CLI Commands
|
||||
|
||||
### 4.1 `ruvector brain`
|
||||
|
||||
Wraps `@ruvector/pi-brain`'s `PiBrainClient`:
|
||||
|
||||
```typescript
|
||||
// Lazy load
|
||||
const { PiBrainClient } = await import('@ruvector/pi-brain');
|
||||
const client = new PiBrainClient({ url: opts.url, key: opts.key });
|
||||
```
|
||||
|
||||
| Command | Maps to | Description |
|
||||
|---------|---------|-------------|
|
||||
| `brain share <title> -c <category> -t <tags>` | `POST /v1/memories` | Share knowledge |
|
||||
| `brain search <query> -l <limit>` | `GET /v1/memories/search` | Semantic search |
|
||||
| `brain get <id>` | `GET /v1/memories/:id` | Retrieve with provenance |
|
||||
| `brain vote <id> <up\|down>` | `POST /v1/memories/:id/vote` | Quality vote |
|
||||
| `brain list [-c category] [-l limit]` | `GET /v1/memories/list` | List memories |
|
||||
| `brain delete <id>` | `DELETE /v1/memories/:id` | Delete own contribution |
|
||||
| `brain transfer <source> <target>` | `POST /v1/transfer` | Domain transfer |
|
||||
| `brain drift [--domain <d>]` | `GET /v1/drift` | Drift detection |
|
||||
| `brain partition [--domain <d>]` | `GET /v1/partition` | Knowledge topology |
|
||||
| `brain status` | `GET /v1/status` | System health |
|
||||
| `brain sync [pull\|push\|both]` | `POST /v1/lora/submit` | LoRA sync |
|
||||
|
||||
### 4.2 `ruvector edge`
|
||||
|
||||
Wraps `@ruvector/edge-net` for Node.js (non-browser) usage:
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `edge status` | Query genesis node for network stats, rUv supply, sunset phase |
|
||||
| `edge join --contribution 0.3` | Join as compute node (headless, Node.js Web Worker polyfill) |
|
||||
| `edge balance` | Check rUv balance for current identity |
|
||||
| `edge tasks` | List available distributed compute tasks |
|
||||
| `edge dashboard` | Open edge-net dashboard in browser |
|
||||
|
||||
Edge commands hit the deployed services:
|
||||
- Genesis: `https://edge-net-genesis-875130704813.us-central1.run.app`
|
||||
- Relay: `https://edge-net-relay-875130704813.us-central1.run.app`
|
||||
- Dashboard: `https://edge-net-dashboard-875130704813.us-central1.run.app`
|
||||
|
||||
### 4.3 `ruvector mcp`
|
||||
|
||||
Manages MCP server lifecycle:
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `mcp start` | Start stdio MCP server (default, for `claude mcp add`) |
|
||||
| `mcp start --transport sse --port 8080` | Start SSE MCP server locally |
|
||||
| `mcp tools` | List all 22 available MCP tools |
|
||||
| `mcp test` | Send test JSON-RPC to verify connection |
|
||||
|
||||
The `mcp start` command replaces `cargo run -p mcp-brain` for users who don't have Rust installed:
|
||||
|
||||
```bash
|
||||
# Before (requires Rust toolchain)
|
||||
claude mcp add pi-brain -- cargo run -p mcp-brain
|
||||
|
||||
# After (just Node.js)
|
||||
claude mcp add pi-brain -- npx ruvector mcp start
|
||||
```
|
||||
|
||||
### 4.4 `ruvector identity`
|
||||
|
||||
Manages the π key:
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `identity generate` | Generate new π key, display, copy to clipboard |
|
||||
| `identity show` | Show current key's pseudonym, edge Pi-Key, reputation |
|
||||
| `identity export` | Export key to file (encrypted with passphrase) |
|
||||
| `identity import <file>` | Import key from encrypted backup |
|
||||
|
||||
## 5. Implementation
|
||||
|
||||
### 5.1 File Changes
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `npm/packages/ruvector/bin/cli.js` | Add `brain`, `edge`, `mcp`, `identity` command groups |
|
||||
| `npm/packages/ruvector/package.json` | Add `@ruvector/pi-brain` and `@ruvector/edge-net` as optional peer deps |
|
||||
| `npm/packages/ruvector/src/commands/brain.ts` | Brain command handlers with lazy `pi-brain` import |
|
||||
| `npm/packages/ruvector/src/commands/edge.ts` | Edge command handlers with lazy `edge-net` import |
|
||||
| `npm/packages/ruvector/src/commands/mcp.ts` | MCP server start/test with transport selection |
|
||||
| `npm/packages/ruvector/src/commands/identity.ts` | Key generation, derivation, export/import |
|
||||
|
||||
### 5.2 Lazy Loading Pattern
|
||||
|
||||
```typescript
|
||||
async function requirePiBrain(): Promise<typeof import('@ruvector/pi-brain')> {
|
||||
try {
|
||||
return await import('@ruvector/pi-brain');
|
||||
} catch {
|
||||
console.error(chalk.red('Brain commands require @ruvector/pi-brain'));
|
||||
console.error(chalk.yellow(' npm install @ruvector/pi-brain'));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 Output Formatting
|
||||
|
||||
All commands output JSON by default when piped (`!process.stdout.isTTY`) and human-readable tables/colors when interactive. The `--json` flag forces JSON output.
|
||||
|
||||
```bash
|
||||
# Human-readable
|
||||
npx ruvector brain status
|
||||
# Memories: 42 | Contributors: 7 | Quality: 0.82 | Drift: stable
|
||||
|
||||
# Machine-readable
|
||||
npx ruvector brain status --json
|
||||
# {"total_memories":42,"total_contributors":7,...}
|
||||
|
||||
# Piped
|
||||
npx ruvector brain search "auth" | jq '.[] | .title'
|
||||
```
|
||||
|
||||
## 6. Security
|
||||
|
||||
### 6.1 Key Storage
|
||||
|
||||
The π key is never stored in plaintext on disk by the CLI. Options:
|
||||
- Environment variable (`PI=...`)
|
||||
- `.env` file (user's responsibility to gitignore)
|
||||
- System keychain via `keytar` (optional dependency)
|
||||
- Encrypted file via `identity export/import`
|
||||
|
||||
### 6.2 Network Security
|
||||
|
||||
All CLI commands communicate over HTTPS. The brain client validates TLS certificates. No HTTP fallback.
|
||||
|
||||
### 6.3 Dependency Isolation
|
||||
|
||||
Brain and edge dependencies are optional peers, not bundled. This prevents supply chain attacks through transitive dependencies from affecting users who only use the core vector database.
|
||||
|
||||
## 7. Versioning
|
||||
|
||||
The `ruvector` CLI version is independent of the brain and edge package versions. The CLI detects compatible version ranges at runtime:
|
||||
|
||||
```typescript
|
||||
const pkg = await import('@ruvector/pi-brain/package.json');
|
||||
if (!semver.satisfies(pkg.version, '>=0.1.0')) {
|
||||
console.warn(chalk.yellow(`pi-brain ${pkg.version} may not be compatible`));
|
||||
}
|
||||
```
|
||||
|
||||
## 8. Testing
|
||||
|
||||
| Test | Description |
|
||||
|------|-------------|
|
||||
| `brain` commands without `pi-brain` installed | Graceful error with install instructions |
|
||||
| `brain status` with mock server | Returns formatted status |
|
||||
| `brain search "query"` with live backend | Returns results |
|
||||
| `edge status` against genesis | Returns network stats |
|
||||
| `mcp start` stdio | Responds to JSON-RPC initialize |
|
||||
| `mcp tools` | Lists 22 tools |
|
||||
| `identity generate` | Produces valid 64-char hex key |
|
||||
| `identity show` | Derives correct SHAKE-256 pseudonym |
|
||||
| JSON output mode | All commands produce valid JSON with `--json` |
|
||||
| Pipe detection | Auto-JSON when stdout is not TTY |
|
||||
|
||||
## 9. Migration Path
|
||||
|
||||
### Phase 1: CLI Extension (1 week)
|
||||
Add command groups to `bin/cli.js`. Wire `brain` commands to `@ruvector/pi-brain`. Add `identity` commands with key generation and SHAKE-256 derivation.
|
||||
|
||||
### Phase 2: Edge Integration (1 week)
|
||||
Add `edge` commands. Create Node.js adapter for `@ruvector/edge-net` (which targets browsers). Implement headless join for server-side compute contribution.
|
||||
|
||||
### Phase 3: MCP Proxy (1 week)
|
||||
Add `mcp start` with stdio and SSE transport support. Replace `cargo run -p mcp-brain` as the recommended MCP setup for non-Rust users.
|
||||
|
||||
### Phase 4: Publish (1 week)
|
||||
Bump `ruvector` to 0.2.0. Update README. Publish `@ruvector/pi-brain` and ensure version compatibility. Update landing page docs.
|
||||
|
||||
## 10. Consequences
|
||||
|
||||
### Positive
|
||||
- Single entry point: `npx ruvector` provides access to vector DB, shared brain, edge compute, and MCP
|
||||
- Progressive adoption: core package stays lightweight, features opt-in via peer deps
|
||||
- No Rust required: `npx ruvector mcp start` replaces `cargo run -p mcp-brain`
|
||||
- Unified identity: one π key for all three systems
|
||||
|
||||
### Negative
|
||||
- CLI complexity increases — more surface area to maintain
|
||||
- Optional peer deps can confuse users (unclear what's installed)
|
||||
- Node.js adapter for edge-net (browser-targeted WASM) may have compatibility gaps
|
||||
|
||||
### Neutral
|
||||
- Version coordination between `ruvector`, `pi-brain`, and `edge-net` requires semver discipline
|
||||
- Existing `pi-brain` CLI (`npx pi-brain`) continues to work independently
|
||||
|
||||
## 11. Related ADRs
|
||||
|
||||
| ADR | Relationship |
|
||||
|-----|-------------|
|
||||
| ADR-065 | npm Publishing Strategy — package categories, publish order |
|
||||
| ADR-064 | Pi Brain Infrastructure — Cloud Run deployment, domains |
|
||||
| ADR-066 | SSE MCP Transport — SSE protocol that `mcp start --transport sse` exposes |
|
||||
| ADR-069 | Edge-Net Integration — distributed compute that `edge` commands access |
|
||||
| ADR-059 | Shared Brain Google Cloud — backend that `brain` commands call |
|
||||
| ADR-060 | Shared Brain Capabilities — 7 capabilities exposed through `brain` subcommands |
|
||||
510
docs/adr/ADR-071-npx-ruvector-ecosystem-gap-analysis.md
Normal file
510
docs/adr/ADR-071-npx-ruvector-ecosystem-gap-analysis.md
Normal file
|
|
@ -0,0 +1,510 @@
|
|||
# ADR-071: npx ruvector Ecosystem Gap Analysis
|
||||
|
||||
**Status**: Proposed
|
||||
**Date**: 2026-02-28
|
||||
**Authors**: RuVector Team
|
||||
**Deciders**: ruv
|
||||
**Supersedes**: N/A
|
||||
**Related**: ADR-065 (npm Publishing Strategy), ADR-070 (npx ruvector Unified Integration)
|
||||
|
||||
## 1. Context
|
||||
|
||||
The ruvector project produces **79 npm packages** and **27 WASM crates** spanning vector databases, graph engines, LLM orchestration, quantum simulation, spiking neural networks, cryptographic primitives, and distributed compute. The primary CLI entry point — `npx ruvector` (v0.1.100) — exposes only a fraction of this surface: vector CRUD, GNN layers, attention mechanisms, and system diagnostics.
|
||||
|
||||
Meanwhile, significant capabilities exist as Rust crates with no npm wrapper, as WASM crates with no JavaScript bindings published, or as npm packages that are disconnected from the CLI. This ADR catalogs every gap and proposes a roadmap to make the full ecosystem accessible through `npx ruvector`.
|
||||
|
||||
## 2. Current State
|
||||
|
||||
### 2.1 What `npx ruvector` Exposes Today
|
||||
|
||||
| Command Group | Subcommands | Backend |
|
||||
|--------------|-------------|---------|
|
||||
| `create` | Create vector DB | @ruvector/core |
|
||||
| `insert` | Insert vectors from JSON | @ruvector/core |
|
||||
| `search` | ANN search with filters | @ruvector/core |
|
||||
| `stats` | Database statistics | @ruvector/core |
|
||||
| `benchmark` | Performance benchmarks | @ruvector/core |
|
||||
| `info` | System info (backends, versions) | built-in |
|
||||
| `install` | Install optional packages | built-in |
|
||||
| `doctor` | Health check | built-in |
|
||||
| `gnn layer` | Create/test GNN layers | @ruvector/gnn |
|
||||
| `gnn compress` | Adaptive tensor compression | @ruvector/gnn |
|
||||
| `gnn search` | Differentiable search | @ruvector/gnn |
|
||||
| `gnn info` | GNN module info | @ruvector/gnn |
|
||||
| `attention compute` | 5 attention mechanisms | @ruvector/attention |
|
||||
| `attention benchmark` | Benchmark attention types | @ruvector/attention |
|
||||
| `attention hyperbolic` | Hyperbolic geometry ops | @ruvector/attention |
|
||||
| `attention list` | List mechanisms | @ruvector/attention |
|
||||
| `attention info` | Module details | @ruvector/attention |
|
||||
|
||||
**Total: 17 commands across 4 groups.**
|
||||
|
||||
### 2.2 What Exists as npm Packages but NOT in the CLI
|
||||
|
||||
| npm Package | Version | Capability | CLI Integration |
|
||||
|-------------|---------|-----------|----------------|
|
||||
| `@ruvector/pi-brain` | 0.1.0 | Shared brain CLI + SDK + MCP | **Missing** — has own `npx pi-brain` CLI |
|
||||
| `@ruvector/sona` | 0.1.4 | Self-optimizing neural architecture | Bundled dep but **no CLI commands** |
|
||||
| `@ruvector/rvf` | 0.2.0 | RuVector Format SDK (read/write/validate) | Optional dep but **no CLI commands** |
|
||||
| `@ruvector/rvf-solver` | 0.1.7 | Temporal constraint solver | **No CLI** |
|
||||
| `@ruvector/rvf-wasm` | 0.1.5 | RVF WASM microkernel | **No CLI** |
|
||||
| `@ruvector/rvf-node` | 0.1.7 | RVF Node.js bindings | **No CLI** |
|
||||
| `@ruvector/rvf-mcp-server` | 0.1.3 | RVF MCP server | **No CLI** |
|
||||
| `@ruvector/ruvllm` | 2.5.1 | LLM orchestration + SONA + HNSW | **Separate CLI** (`npx ruvllm`) |
|
||||
| `@ruvector/ruvllm-cli` | 0.1.0 | LLM inference CLI | **Separate binary** |
|
||||
| `@ruvector/ruvllm-wasm` | 0.1.0 | WASM LLM inference | **No CLI** |
|
||||
| `@ruvector/graph-node` | 2.0.2 | Native hypergraph bindings | **No CLI** |
|
||||
| `@ruvector/graph-wasm` | 2.0.2 | Neo4j-compatible hypergraph WASM | **No CLI** |
|
||||
| `@ruvector/graph-data-generator` | 0.1.0 | Synthetic graph data generation | **No CLI** |
|
||||
| `@ruvector/ruqu-wasm` | 2.0.5 | Quantum simulations | **No CLI** |
|
||||
| `@ruvector/spiking-neural` | 1.0.1 | Spiking neural networks (SIMD) | **No CLI** |
|
||||
| `@ruvector/ospipe` | 0.1.2 | Personal AI memory | **No CLI** |
|
||||
| `@ruvector/ospipe-wasm` | 0.1.0 | Personal AI memory WASM | **No CLI** |
|
||||
| `@ruvector/rvdna` | 0.3.0 | Genomic analysis (20-SNP biomarker) | **No CLI** |
|
||||
| `@ruvector/scipix` | 0.1.0 | OCR for scientific documents | **No CLI** |
|
||||
| `@ruvector/tiny-dancer` | 0.1.17 | Neural router (FastGRNN) | **No CLI** |
|
||||
| `@ruvector/router` | 0.1.28 | Semantic router for AI agents | **No CLI** |
|
||||
| `@ruvector/ruvbot` | 0.3.1 | Self-learning AI assistant | **Separate CLI** |
|
||||
| `@ruvector/rvlite` | 0.2.4 | Lightweight DB (SQL/SPARQL/Cypher) | **No CLI** |
|
||||
| `@ruvector/agentic-integration` | 1.0.0 | Distributed agent coordination | **No CLI** |
|
||||
| `@ruvector/agentic-synth` | 0.1.6 | Synthetic data generator | **Has own CLI** |
|
||||
| `@ruvector/burst-scaling` | 1.0.0 | Adaptive burst scaling | **No CLI** |
|
||||
| `@ruvector/cognitum-gate-wasm` | 0.1.0 | AI coherence gate | **No CLI** |
|
||||
| `@ruvector/raft` | 0.1.0 | Raft consensus | **No CLI** |
|
||||
| `@ruvector/replication` | 0.1.0 | Data replication & sync | **No CLI** |
|
||||
| `@ruvector/postgres-cli` | 0.2.7 | PostgreSQL pgvector CLI | **Separate CLI** |
|
||||
| `@ruvector/ruvector-extensions` | 0.1.0 | Embeddings, UI, exports, temporal | **No CLI** |
|
||||
| `@ruvector/ruvector-wasm-unified` | 1.0.0 | Unified TypeScript WASM API | **No CLI** |
|
||||
|
||||
**31 packages with no CLI integration.**
|
||||
|
||||
### 2.3 What Exists as WASM Crates but NOT as npm Packages
|
||||
|
||||
| WASM Crate | Version | Capability | npm Package |
|
||||
|-----------|---------|-----------|-------------|
|
||||
| `ruvector-attention-unified-wasm` | 0.1.0 | Unified attention (46 mechanisms) | **Missing** |
|
||||
| `ruvector-attention-wasm` | — | Attention WASM bindings | Partial (`@ruvector/attention`) |
|
||||
| `ruvector-dag-wasm` | 0.1.0 | DAG operations | **Missing** |
|
||||
| `ruvector-delta-wasm` | 0.1.0 | Delta consensus/behavior tracking | **Missing** |
|
||||
| `ruvector-domain-expansion-wasm` | 0.1.0 | Transfer learning, domain expansion | **Missing** |
|
||||
| `ruvector-economy-wasm` | 0.1.0 | Economic engine (reputation, AMM) | **Missing** |
|
||||
| `ruvector-exotic-wasm` | — | Exotic neural architectures | **Missing** |
|
||||
| `ruvector-fpga-transformer-wasm` | 0.1.0 | FPGA-optimized transformers | **Missing** |
|
||||
| `ruvector-gnn-wasm` | — | GNN WASM bindings | Partial (`@ruvector/gnn`) |
|
||||
| `ruvector-graph-transformer-wasm` | — | Graph transformer | **Missing** |
|
||||
| `ruvector-hyperbolic-hnsw-wasm` | 0.1.0 | Hyperbolic HNSW search | **Missing** |
|
||||
| `ruvector-learning-wasm` | 0.1.0 | Online learning | **Missing** |
|
||||
| `ruvector-math-wasm` | — | Math primitives | **Missing** |
|
||||
| `ruvector-mincut-gated-transformer` | 0.1.0 | MinCut-gated transformer | **Missing** |
|
||||
| `ruvector-mincut-wasm` | — | MinCut graph partitioning | **Missing** |
|
||||
| `ruvector-nervous-system-wasm` | 0.1.0 | Nervous system architecture | **Missing** |
|
||||
| `ruvector-sparse-inference-wasm` | — | Sparse inference engine | **Missing** |
|
||||
| `ruvector-temporal-tensor-wasm` | — | Temporal tensor operations | **Missing** |
|
||||
|
||||
**18 WASM crates with no npm package.**
|
||||
|
||||
### 2.4 What Exists as Rust Crates Only (No WASM, No npm)
|
||||
|
||||
| Crate | Capability | Why It Matters |
|
||||
|-------|-----------|---------------|
|
||||
| `mcp-brain` | MCP stdio server for shared brain | Core brain MCP — only accessible via `cargo run` |
|
||||
| `mcp-brain-server` | Cloud Run REST backend | Server-side only |
|
||||
| `mcp-gate` | MCP coherence gate | Core MCP — only via `cargo run` |
|
||||
| `cognitum-gate-kernel` | AI coherence gate kernel | Core reasoning engine |
|
||||
| `cognitum-gate-tilezero` | TileZero game engine | Specialized |
|
||||
| `prime-radiant` | Prime Radiant visualization | Specialized |
|
||||
| `ruvector-delta-core` | Delta behavior tracking | Core capability, no JS access |
|
||||
| `ruvector-delta-runtime` | Delta runtime | Runtime only |
|
||||
| `ruvector-delta-serde` | Delta serialization | Utility |
|
||||
| `ruvector-domain-expansion` | Transfer learning engine | Core brain capability |
|
||||
| `ruvector-mincut` | SubpolynomialMinCut partitioning | Core graph capability |
|
||||
| `ruvector-attention` | 46 attention mechanisms | Partially exposed via `@ruvector/attention` |
|
||||
| `sona` | SONA learning engine | Partially exposed via `@ruvector/sona` |
|
||||
| `rvf-federation` | Federated learning (PII strip, DP) | Core brain pipeline |
|
||||
| `rvf-crypto` | Witness chains, Ed25519, SHAKE-256 | Core security |
|
||||
| `agentic-robotics-*` (6 crates) | Autonomous robotics | Entire subsystem missing |
|
||||
| `thermorust` | Thermal/energy modeling | Specialized |
|
||||
| `ruvector-dither` | Dithering algorithms | Specialized |
|
||||
| `ruvector-profiler` | Performance profiler | Dev tool (publish=false) |
|
||||
|
||||
**19+ crates with no JavaScript access at all.**
|
||||
|
||||
### 2.5 Fragmented CLI Entry Points
|
||||
|
||||
Users currently face 7+ separate CLI binaries:
|
||||
|
||||
| Binary | Package | Install |
|
||||
|--------|---------|---------|
|
||||
| `ruvector` | `ruvector` | `npx ruvector` |
|
||||
| `pi-brain` / `π` | `@ruvector/pi-brain` | `npx pi-brain` |
|
||||
| `ruvllm` | `@ruvector/ruvllm-cli` | `npx ruvllm` |
|
||||
| `ruvbot` | `@ruvector/ruvbot` | `npx ruvbot` |
|
||||
| `agentic-synth` | `@ruvector/agentic-synth` | `npx agentic-synth` |
|
||||
| `rvf` | `@ruvector/rvf` | `npx rvf` |
|
||||
| `postgres-cli` | `@ruvector/postgres-cli` | `npx @ruvector/postgres-cli` |
|
||||
|
||||
Each has its own install, auth, and configuration. There is no single `npx ruvector <anything>` that reaches them all.
|
||||
|
||||
## 3. Decision
|
||||
|
||||
Extend `npx ruvector` to be the **universal entry point** for the entire ecosystem. Every capability — whether it's a Rust crate, WASM binding, or npm package — should be reachable through `npx ruvector <group> <command>`. Missing npm packages for WASM crates should be published. Fragmented CLIs should be consolidated.
|
||||
|
||||
## 4. Proposed Command Hierarchy
|
||||
|
||||
```
|
||||
npx ruvector
|
||||
│
|
||||
├── [EXISTING] Vector Database
|
||||
│ ├── create Create vector database
|
||||
│ ├── insert Insert vectors
|
||||
│ ├── search ANN search with filters
|
||||
│ ├── stats Database statistics
|
||||
│ ├── benchmark Performance benchmarks
|
||||
│ ├── info System info
|
||||
│ ├── install Install optional packages
|
||||
│ └── doctor Health check
|
||||
│
|
||||
├── [EXISTING] GNN
|
||||
│ ├── gnn layer Create/test GNN layers
|
||||
│ ├── gnn compress Adaptive tensor compression
|
||||
│ ├── gnn search Differentiable search
|
||||
│ └── gnn info Module info
|
||||
│
|
||||
├── [EXISTING] Attention
|
||||
│ ├── attention compute 5 attention mechanisms
|
||||
│ ├── attention benchmark Benchmark all types
|
||||
│ ├── attention hyperbolic Hyperbolic geometry
|
||||
│ ├── attention list List mechanisms
|
||||
│ └── attention info Module details
|
||||
│
|
||||
├── [ADR-070] Brain (lazy: @ruvector/pi-brain)
|
||||
│ ├── brain share Share knowledge
|
||||
│ ├── brain search Semantic search
|
||||
│ ├── brain get Retrieve by ID
|
||||
│ ├── brain vote Quality vote
|
||||
│ ├── brain list List memories
|
||||
│ ├── brain delete Delete own
|
||||
│ ├── brain transfer Domain transfer
|
||||
│ ├── brain drift Drift detection
|
||||
│ ├── brain partition Knowledge topology
|
||||
│ ├── brain status System health
|
||||
│ ├── brain sync LoRA weight sync
|
||||
│ └── brain page Brainpedia CRUD
|
||||
│
|
||||
├── [ADR-070] Edge (lazy: @ruvector/edge-net)
|
||||
│ ├── edge status Network status
|
||||
│ ├── edge join Join as compute node
|
||||
│ ├── edge balance rUv balance
|
||||
│ ├── edge tasks Available compute tasks
|
||||
│ └── edge dashboard Open dashboard
|
||||
│
|
||||
├── [ADR-070] MCP
|
||||
│ ├── mcp start Start MCP server (stdio/SSE)
|
||||
│ ├── mcp tools List available tools
|
||||
│ └── mcp test Test connection
|
||||
│
|
||||
├── [ADR-070] Identity
|
||||
│ ├── identity generate Generate π key
|
||||
│ ├── identity show Display pseudonym
|
||||
│ ├── identity export Encrypted backup
|
||||
│ └── identity import Restore from backup
|
||||
│
|
||||
├── [NEW] LLM (lazy: @ruvector/ruvllm)
|
||||
│ ├── llm chat Interactive chat
|
||||
│ ├── llm embed Generate embeddings
|
||||
│ ├── llm complete Text completion
|
||||
│ ├── llm models List available models
|
||||
│ ├── llm benchmark Inference benchmark
|
||||
│ └── llm serve Start LLM server
|
||||
│
|
||||
├── [NEW] RVF (lazy: @ruvector/rvf)
|
||||
│ ├── rvf read Read .rvf container
|
||||
│ ├── rvf write Create .rvf container
|
||||
│ ├── rvf validate Validate integrity
|
||||
│ ├── rvf inspect Show segment layout
|
||||
│ ├── rvf merge Merge containers
|
||||
│ └── rvf convert Format conversions
|
||||
│
|
||||
├── [NEW] Graph (lazy: @ruvector/graph-wasm)
|
||||
│ ├── graph create Create hypergraph
|
||||
│ ├── graph query Cypher/SPARQL query
|
||||
│ ├── graph import Import from CSV/JSON/Neo4j
|
||||
│ ├── graph export Export to various formats
|
||||
│ ├── graph visualize Text-based visualization
|
||||
│ └── graph stats Graph statistics
|
||||
│
|
||||
├── [NEW] SONA (lazy: @ruvector/sona)
|
||||
│ ├── sona train Train with trajectory
|
||||
│ ├── sona patterns Search learned patterns
|
||||
│ ├── sona optimize Run optimization
|
||||
│ ├── sona export Export learned weights
|
||||
│ └── sona stats Learning statistics
|
||||
│
|
||||
├── [NEW] Router (lazy: @ruvector/router)
|
||||
│ ├── router classify Classify input to route
|
||||
│ ├── router train Train router on examples
|
||||
│ ├── router serve Start router server
|
||||
│ └── router benchmark Route throughput test
|
||||
│
|
||||
├── [NEW] Quantum (lazy: @ruvector/ruqu-wasm)
|
||||
│ ├── quantum sim Run quantum simulation
|
||||
│ ├── quantum circuit Build quantum circuit
|
||||
│ └── quantum stats Simulation statistics
|
||||
│
|
||||
├── [NEW] SNN (lazy: @ruvector/spiking-neural)
|
||||
│ ├── snn train Train spiking network
|
||||
│ ├── snn inference Run inference
|
||||
│ └── snn benchmark SIMD performance test
|
||||
│
|
||||
├── [NEW] Delta (lazy: @ruvector/delta-wasm — TO PUBLISH)
|
||||
│ ├── delta track Track behavior changes
|
||||
│ ├── delta compare Compare two snapshots
|
||||
│ └── delta report Drift report
|
||||
│
|
||||
├── [NEW] MinCut (lazy: @ruvector/mincut-wasm — TO PUBLISH)
|
||||
│ ├── mincut partition Partition graph
|
||||
│ ├── mincut certificate Verify cut certificate
|
||||
│ └── mincut visualize Text visualization
|
||||
│
|
||||
├── [NEW] Synth (lazy: @ruvector/agentic-synth)
|
||||
│ ├── synth generate Generate synthetic data
|
||||
│ ├── synth validate Validate generated data
|
||||
│ └── synth config Configure generators
|
||||
│
|
||||
├── [NEW] DNA (lazy: @ruvector/rvdna)
|
||||
│ ├── dna analyze Analyze genomic data
|
||||
│ ├── dna biomarker 20-SNP biomarker panel
|
||||
│ └── dna report Generate report
|
||||
│
|
||||
├── [NEW] OCR (lazy: @ruvector/scipix)
|
||||
│ ├── ocr extract Extract text from images
|
||||
│ ├── ocr table Extract tables
|
||||
│ └── ocr equations Extract equations
|
||||
│
|
||||
├── [NEW] DB (lazy: @ruvector/rvlite)
|
||||
│ ├── db query SQL/SPARQL/Cypher query
|
||||
│ ├── db import Import data
|
||||
│ └── db export Export data
|
||||
│
|
||||
└── [NEW] Postgres (lazy: @ruvector/postgres-cli)
|
||||
├── pg connect Connect to PostgreSQL
|
||||
├── pg vector pgvector operations
|
||||
└── pg migrate Schema migrations
|
||||
```
|
||||
|
||||
**Total: ~100+ commands across 20+ groups** (up from 17 commands across 4 groups).
|
||||
|
||||
## 5. npm Packages to Publish
|
||||
|
||||
### 5.1 Priority 1 — WASM crates with brain/edge integration value
|
||||
|
||||
| WASM Crate | Proposed npm Package | Why |
|
||||
|-----------|---------------------|-----|
|
||||
| `ruvector-delta-wasm` | `@ruvector/delta-wasm` | Brain drift detection via `npx ruvector delta` |
|
||||
| `ruvector-mincut-wasm` | `@ruvector/mincut-wasm` | Brain knowledge partitioning |
|
||||
| `ruvector-domain-expansion-wasm` | `@ruvector/domain-expansion-wasm` | Brain transfer learning |
|
||||
| `ruvector-economy-wasm` | `@ruvector/economy-wasm` | Edge-net economics (AMM, reputation) |
|
||||
| `ruvector-learning-wasm` | `@ruvector/learning-wasm` | Online learning for edge nodes |
|
||||
| `edge-net` (examples/) | `@ruvector/edge-net` | Edge-net WASM for `npx ruvector edge` |
|
||||
|
||||
### 5.2 Priority 2 — Advanced capabilities
|
||||
|
||||
| WASM Crate | Proposed npm Package | Why |
|
||||
|-----------|---------------------|-----|
|
||||
| `ruvector-attention-unified-wasm` | `@ruvector/attention-unified-wasm` | All 46 attention mechanisms |
|
||||
| `ruvector-hyperbolic-hnsw-wasm` | `@ruvector/hyperbolic-hnsw-wasm` | Hyperbolic space search |
|
||||
| `ruvector-nervous-system-wasm` | `@ruvector/nervous-system-wasm` | Full nervous system architecture |
|
||||
| `ruvector-fpga-transformer-wasm` | `@ruvector/fpga-transformer-wasm` | FPGA-optimized inference |
|
||||
| `ruvector-sparse-inference-wasm` | `@ruvector/sparse-inference-wasm` | Sparse model inference |
|
||||
| `ruvector-graph-transformer-wasm` | `@ruvector/graph-transformer-wasm` | Graph transformers |
|
||||
|
||||
### 5.3 Priority 3 — Specialized
|
||||
|
||||
| WASM Crate | Proposed npm Package | Why |
|
||||
|-----------|---------------------|-----|
|
||||
| `ruvector-dag-wasm` | `@ruvector/dag-wasm` | DAG operations |
|
||||
| `ruvector-math-wasm` | `@ruvector/math-wasm` | Math primitives |
|
||||
| `ruvector-temporal-tensor-wasm` | `@ruvector/temporal-tensor-wasm` | Temporal operations |
|
||||
| `ruvector-exotic-wasm` | `@ruvector/exotic-wasm` | Exotic neural architectures |
|
||||
| `ruvector-mincut-gated-transformer` | `@ruvector/mincut-gated-wasm` | MinCut-gated attention |
|
||||
|
||||
## 6. Version Landscape
|
||||
|
||||
### 6.1 Mature (v2.x — stable API)
|
||||
|
||||
| Package | Version | Notes |
|
||||
|---------|---------|-------|
|
||||
| `@ruvector/ruvllm` | 2.5.1 | Self-learning LLM orchestration |
|
||||
| `@ruvector/graph-node` | 2.0.2 | Native hypergraph (NAPI) |
|
||||
| `@ruvector/graph-wasm` | 2.0.2 | WASM hypergraph |
|
||||
| `@ruvector/ruqu-wasm` | 2.0.5 | Quantum simulations |
|
||||
| `@ruvector/ruvllm-wasm` | 2.0.0 | WASM LLM |
|
||||
| `micro-hnsw-wasm` | 2.3.2 | HNSW core |
|
||||
|
||||
### 6.2 Stable (v1.x — production-ready)
|
||||
|
||||
| Package | Version | Notes |
|
||||
|---------|---------|-------|
|
||||
| `@ruvector/agentic-integration` | 1.0.0 | Agent coordination |
|
||||
| `@ruvector/burst-scaling` | 1.0.0 | Adaptive scaling |
|
||||
| `@ruvector/spiking-neural` | 1.0.1 | SNN with SIMD |
|
||||
| `@ruvector/ruvector-wasm-unified` | 1.0.0 | Unified WASM API |
|
||||
|
||||
### 6.3 Development (v0.x — breaking changes expected)
|
||||
|
||||
66 packages at v0.1.x-0.3.x, including the core `ruvector` CLI at v0.1.100.
|
||||
|
||||
### 6.4 Version Recommendation
|
||||
|
||||
The core `ruvector` package should target **v0.2.0** for the unified CLI expansion (ADR-070 commands), and **v1.0.0** when all Priority 1 packages are published and integrated.
|
||||
|
||||
## 7. Implementation Roadmap
|
||||
|
||||
### Phase 1: Consolidate Existing (2 weeks)
|
||||
|
||||
**Goal**: Bring existing npm packages into `npx ruvector` via lazy loading.
|
||||
|
||||
| Task | Effort | Packages |
|
||||
|------|--------|----------|
|
||||
| Add `brain` commands | 3 days | @ruvector/pi-brain |
|
||||
| Add `llm` commands | 2 days | @ruvector/ruvllm |
|
||||
| Add `rvf` commands | 2 days | @ruvector/rvf |
|
||||
| Add `graph` commands | 2 days | @ruvector/graph-wasm |
|
||||
| Add `sona` commands | 1 day | @ruvector/sona |
|
||||
| Add `router` commands | 1 day | @ruvector/router |
|
||||
| Add `quantum` commands | 1 day | @ruvector/ruqu-wasm |
|
||||
| Add `snn` commands | 1 day | @ruvector/spiking-neural |
|
||||
| Add `synth` commands | 1 day | @ruvector/agentic-synth |
|
||||
| Add `db` commands | 1 day | @ruvector/rvlite |
|
||||
| Add `pg` commands | 1 day | @ruvector/postgres-cli |
|
||||
|
||||
### Phase 2: Publish Missing WASM (3 weeks)
|
||||
|
||||
**Goal**: Build and publish Priority 1 WASM crates to npm.
|
||||
|
||||
| Task | Effort | Crate → Package |
|
||||
|------|--------|-----------------|
|
||||
| Build + publish delta-wasm | 2 days | ruvector-delta-wasm → @ruvector/delta-wasm |
|
||||
| Build + publish mincut-wasm | 2 days | ruvector-mincut-wasm → @ruvector/mincut-wasm |
|
||||
| Build + publish domain-expansion-wasm | 2 days | ruvector-domain-expansion-wasm → @ruvector/domain-expansion-wasm |
|
||||
| Build + publish economy-wasm | 2 days | ruvector-economy-wasm → @ruvector/economy-wasm |
|
||||
| Build + publish learning-wasm | 2 days | ruvector-learning-wasm → @ruvector/learning-wasm |
|
||||
| Build + publish edge-net WASM | 3 days | edge-net → @ruvector/edge-net |
|
||||
| Add `delta`, `mincut`, `edge` CLI groups | 3 days | CLI integration |
|
||||
| Add `identity` commands | 2 days | Pi-Key management |
|
||||
| Add `mcp` commands | 2 days | MCP server lifecycle |
|
||||
|
||||
### Phase 3: Publish Advanced WASM (2 weeks)
|
||||
|
||||
**Goal**: Build and publish Priority 2 WASM crates.
|
||||
|
||||
6 WASM packages to build with `wasm-pack` and publish.
|
||||
|
||||
### Phase 4: Polish and Release (1 week)
|
||||
|
||||
| Task | Effort |
|
||||
|------|--------|
|
||||
| `npx ruvector help` — comprehensive help with all groups | 1 day |
|
||||
| `npx ruvector list` — list installed vs available packages | 1 day |
|
||||
| `npx ruvector upgrade` — upgrade all @ruvector packages | 1 day |
|
||||
| JSON output mode for all commands | 1 day |
|
||||
| Pipe detection (auto-JSON when not TTY) | 0.5 day |
|
||||
| Bump to v0.2.0, update README | 0.5 day |
|
||||
|
||||
## 8. Dependency Strategy
|
||||
|
||||
### 8.1 Bundled (always installed)
|
||||
|
||||
```
|
||||
ruvector (core)
|
||||
├── @ruvector/core (HNSW vector DB)
|
||||
├── @ruvector/gnn (GNN layers)
|
||||
├── @ruvector/attention (attention mechanisms)
|
||||
├── @ruvector/sona (SONA learning)
|
||||
├── commander, chalk, ora (CLI utilities)
|
||||
└── @modelcontextprotocol/sdk (MCP protocol)
|
||||
```
|
||||
|
||||
### 8.2 Optional Peer (lazy-loaded on first use)
|
||||
|
||||
```
|
||||
@ruvector/pi-brain → brain commands
|
||||
@ruvector/edge-net → edge commands
|
||||
@ruvector/ruvllm → llm commands
|
||||
@ruvector/rvf → rvf commands
|
||||
@ruvector/graph-wasm → graph commands
|
||||
@ruvector/ruqu-wasm → quantum commands
|
||||
@ruvector/spiking-neural → snn commands
|
||||
@ruvector/router → router commands
|
||||
@ruvector/delta-wasm → delta commands
|
||||
@ruvector/mincut-wasm → mincut commands
|
||||
@ruvector/agentic-synth → synth commands
|
||||
@ruvector/rvdna → dna commands
|
||||
@ruvector/scipix → ocr commands
|
||||
@ruvector/rvlite → db commands
|
||||
@ruvector/postgres-cli → pg commands
|
||||
```
|
||||
|
||||
### 8.3 Lazy Loading Pattern
|
||||
|
||||
```typescript
|
||||
async function requirePackage(name: string): Promise<any> {
|
||||
try {
|
||||
return await import(name);
|
||||
} catch {
|
||||
console.error(chalk.red(`${name} is not installed.`));
|
||||
console.error(chalk.yellow(` npm install ${name}`));
|
||||
console.error(chalk.dim(` or: npx ruvector install ${name}`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Each command group registers itself but defers the `import()` until the command is actually invoked. This keeps `npx ruvector` startup fast (~200ms) regardless of how many optional packages are installed.
|
||||
|
||||
## 9. Gap Summary
|
||||
|
||||
### By the Numbers
|
||||
|
||||
| Category | Available | In CLI | Gap |
|
||||
|----------|-----------|--------|-----|
|
||||
| npm packages | 79 | 4 bundled | **75 packages not in CLI** |
|
||||
| WASM crates | 27 | 2 via npm | **18 without npm packages** |
|
||||
| Rust-only crates | 19+ | 0 | **19+ with no JS access** |
|
||||
| CLI entry points | 7 separate | 1 unified | **6 fragmented CLIs** |
|
||||
| Commands | ~100 possible | 17 | **~83 missing commands** |
|
||||
|
||||
### Critical Gaps
|
||||
|
||||
1. **No brain access from CLI** — The shared intelligence at pi.ruv.io has no CLI path (ADR-070 proposed, not implemented)
|
||||
2. **No edge network CLI** — Edge-net compute network unreachable from Node.js CLI
|
||||
3. **No LLM commands** — ruvllm (v2.5.1, the most mature package) is a separate CLI
|
||||
4. **No RVF commands** — The core file format has no CLI tooling
|
||||
5. **No graph commands** — Hypergraph engine (v2.0.2) invisible to CLI users
|
||||
6. **No identity management** — Pi-Key generation/management only in Rust
|
||||
7. **18 WASM crates unpublished** — Significant WASM capabilities not accessible from JavaScript
|
||||
8. **No unified discovery** — Users can't discover available capabilities from the CLI
|
||||
|
||||
## 10. Success Criteria
|
||||
|
||||
- [ ] `npx ruvector` lists all available command groups
|
||||
- [ ] `npx ruvector brain search "auth"` works (ADR-070)
|
||||
- [ ] `npx ruvector llm chat` works (ruvllm integration)
|
||||
- [ ] `npx ruvector rvf inspect file.rvf` works
|
||||
- [ ] `npx ruvector graph query "MATCH (n) RETURN n"` works
|
||||
- [ ] `npx ruvector edge status` works (ADR-070)
|
||||
- [ ] `npx ruvector identity generate` works (ADR-070)
|
||||
- [ ] All 18 missing WASM crates published to npm
|
||||
- [ ] `npx ruvector install` shows all optional packages with install status
|
||||
- [ ] JSON output via `--json` flag on all commands
|
||||
- [ ] Version bumped to 0.2.0 with full command hierarchy
|
||||
|
||||
## 11. Related ADRs
|
||||
|
||||
| ADR | Relationship |
|
||||
|-----|-------------|
|
||||
| ADR-065 | npm Publishing Strategy — tier-based publish order, semver, TypeScript requirements |
|
||||
| ADR-070 | npx ruvector Unified Integration — brain, edge, mcp, identity commands (subset of this ADR) |
|
||||
| ADR-069 | Edge-Net Integration — edge-net + brain distributed compute |
|
||||
| ADR-059 | Shared Brain Google Cloud — backend that brain commands call |
|
||||
| ADR-066 | SSE MCP Transport — MCP protocol for mcp commands |
|
||||
459
docs/adr/ADR-072-rvf-example-management-downloads.md
Normal file
459
docs/adr/ADR-072-rvf-example-management-downloads.md
Normal file
|
|
@ -0,0 +1,459 @@
|
|||
# ADR-072: RVF Example Management and Downloads in npx ruvector
|
||||
|
||||
**Status**: Proposed
|
||||
**Date**: 2026-02-28
|
||||
**Authors**: RuVector Team
|
||||
**Deciders**: ruv
|
||||
**Supersedes**: N/A
|
||||
**Related**: ADR-070 (npx ruvector Unified Integration), ADR-044 (RVF Format Specification), ADR-065 (npm Publishing Strategy)
|
||||
|
||||
## 1. Context
|
||||
|
||||
The RuVector ecosystem currently ships 46 `.rvf` example files in `examples/rvf/output/` totaling ~11 MB. These demonstrate every RVF capability: basic vector stores, HNSW indexes, COW lineage chains, eBPF accelerators, TEE attestation, ZK witnesses, agent memory, MCP-in-RVF, self-booting kernels, and more.
|
||||
|
||||
Today these examples are accessed three ways, each with problems:
|
||||
|
||||
| Access Method | Problem |
|
||||
|---|---|
|
||||
| `npx ruvector rvf examples` (CLI) | Lists metadata only. `rvf download` fetches from GitHub raw — slow, no caching, no versioning, raw.githubusercontent.com has rate limits |
|
||||
| `rvf_examples` MCP tool | Returns hardcoded list of 12 examples (out of 46). No download capability |
|
||||
| Clone the repo | 800 MB+ clone for 11 MB of examples |
|
||||
|
||||
Additionally:
|
||||
- The example catalog is hardcoded in two places (`cli.js` and `mcp-server.js`) and they're out of sync (CLI has 45, MCP has 12)
|
||||
- No version pinning — examples may break with format changes
|
||||
- No integrity verification — downloaded files aren't checksummed
|
||||
- No offline cache — re-downloads every time
|
||||
- New examples require a new npm publish to update the catalog
|
||||
|
||||
## 2. Decision
|
||||
|
||||
Host `.rvf` example files on Google Cloud Storage with a manifest-driven catalog. The CLI and MCP server read a versioned manifest from GCS to discover examples, download files with SHA-256 verification, and cache locally at `~/.ruvector/examples/`. A GitHub Actions workflow syncs examples to GCS on every push to `main`.
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
### 3.1 GCS Bucket Layout
|
||||
|
||||
```
|
||||
gs://ruvector-examples/
|
||||
├── manifest.json ← current catalog (always latest)
|
||||
├── v0.2.1/
|
||||
│ ├── manifest.json ← pinned catalog for this version
|
||||
│ ├── basic_store.rvf
|
||||
│ ├── semantic_search.rvf
|
||||
│ ├── rag_pipeline.rvf
|
||||
│ ├── ... ← all 46+ examples
|
||||
│ └── checksums.sha256 ← SHA-256 for every file
|
||||
├── v0.2.0/
|
||||
│ ├── manifest.json
|
||||
│ └── ...
|
||||
└── latest -> v0.2.1/ ← symlink-like redirect
|
||||
```
|
||||
|
||||
**Public read access** via `allUsers:objectViewer` IAM. No authentication required for downloads.
|
||||
|
||||
**Cloud CDN** in front of the bucket for global edge caching. Typical latency: 20-50ms worldwide vs 200-500ms from raw.githubusercontent.com.
|
||||
|
||||
### 3.2 Manifest Format
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "0.2.1",
|
||||
"updated": "2026-02-28T12:00:00Z",
|
||||
"base_url": "https://storage.googleapis.com/ruvector-examples/v0.2.1",
|
||||
"total_size": "11.2 MB",
|
||||
"examples": [
|
||||
{
|
||||
"name": "basic_store",
|
||||
"file": "basic_store.rvf",
|
||||
"size": 155648,
|
||||
"size_human": "152 KB",
|
||||
"sha256": "a1b2c3d4...",
|
||||
"description": "1,000 vectors, dim 128, cosine metric",
|
||||
"category": "core",
|
||||
"tags": ["vectors", "cosine", "basic"],
|
||||
"rvf_version": "1.0",
|
||||
"segments": ["VEC", "META", "MANIFEST"],
|
||||
"created": "2026-02-15"
|
||||
}
|
||||
],
|
||||
"categories": {
|
||||
"core": "Basic vector storage and search",
|
||||
"ai": "AI agent, embedding, and RAG examples",
|
||||
"security": "Attestation, ZK proofs, access control",
|
||||
"compute": "eBPF, WASM, self-booting, kernels",
|
||||
"lineage": "COW chains, derivation, reasoning",
|
||||
"industry": "Finance, medical, legal domain examples",
|
||||
"network": "Sync, handoff, distributed examples",
|
||||
"integration": "MCP, PostgreSQL, serverless bridges"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 Local Cache
|
||||
|
||||
```
|
||||
~/.ruvector/
|
||||
└── examples/
|
||||
├── manifest.json ← cached manifest (TTL: 1 hour)
|
||||
├── basic_store.rvf ← downloaded example
|
||||
├── semantic_search.rvf
|
||||
└── .cache-meta.json ← cache timestamps + checksums
|
||||
```
|
||||
|
||||
Cache behavior:
|
||||
- **Manifest TTL**: 1 hour. After that, fetch fresh manifest on next `examples` or `download` command
|
||||
- **File cache**: Permanent until `rvf cache clear` or version change
|
||||
- **Integrity**: SHA-256 verified on every download. Cached files re-verified on access if `--verify` flag used
|
||||
- **Disk budget**: Default 100 MB. Oldest files evicted when budget exceeded. Configurable via `~/.ruvector/config.json`
|
||||
|
||||
### 3.4 CLI Commands
|
||||
|
||||
```
|
||||
npx ruvector rvf examples # List all examples (from cached manifest)
|
||||
npx ruvector rvf examples --category security # Filter by category
|
||||
npx ruvector rvf examples --refresh # Force manifest refresh
|
||||
npx ruvector rvf download basic_store # Download one example (cached)
|
||||
npx ruvector rvf download --all # Download all examples (~11 MB)
|
||||
npx ruvector rvf download --category ai # Download all AI examples
|
||||
npx ruvector rvf download --verify # Re-verify cached files
|
||||
npx ruvector rvf cache status # Show cache size, file count
|
||||
npx ruvector rvf cache clear # Clear local cache
|
||||
```
|
||||
|
||||
### 3.5 MCP Tool Updates
|
||||
|
||||
The `rvf_examples` MCP tool reads from the same manifest:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "rvf_examples",
|
||||
"description": "List and download RVF example files from the ruvector catalog",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filter": { "type": "string", "description": "Filter by name or description" },
|
||||
"category": { "type": "string", "description": "Filter by category" },
|
||||
"download": { "type": "string", "description": "Download a specific example by name" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When `download` is specified, the tool downloads the file to the current working directory and returns the local path. This allows Claude Code to directly work with `.rvf` files.
|
||||
|
||||
### 3.6 Sync Pipeline (GitHub Actions)
|
||||
|
||||
```yaml
|
||||
# .github/workflows/sync-rvf-examples.yml
|
||||
name: Sync RVF Examples to GCS
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'examples/rvf/output/**'
|
||||
- 'examples/rvf/generate_examples.rs'
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write # Workload Identity Federation
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Authenticate to GCP
|
||||
uses: google-github-actions/auth@v2
|
||||
with:
|
||||
workload_identity_provider: ${{ secrets.WIF_PROVIDER }}
|
||||
service_account: ${{ secrets.GCS_SA }}
|
||||
|
||||
- name: Generate manifest
|
||||
run: |
|
||||
python3 scripts/generate-rvf-manifest.py \
|
||||
--input examples/rvf/output/ \
|
||||
--version $(jq -r .version npm/packages/ruvector/package.json) \
|
||||
--output manifest.json
|
||||
|
||||
- name: Sync to GCS
|
||||
uses: google-github-actions/upload-cloud-storage@v2
|
||||
with:
|
||||
path: examples/rvf/output/
|
||||
destination: ruvector-examples/v${{ env.VERSION }}/
|
||||
gzip: false # .rvf files are already compact
|
||||
|
||||
- name: Upload manifest
|
||||
uses: google-github-actions/upload-cloud-storage@v2
|
||||
with:
|
||||
path: manifest.json
|
||||
destination: ruvector-examples/v${{ env.VERSION }}/manifest.json
|
||||
|
||||
- name: Update latest manifest
|
||||
run: |
|
||||
gsutil cp gs://ruvector-examples/v${{ env.VERSION }}/manifest.json \
|
||||
gs://ruvector-examples/manifest.json
|
||||
```
|
||||
|
||||
### 3.7 Manifest Generator Script
|
||||
|
||||
```python
|
||||
# scripts/generate-rvf-manifest.py
|
||||
# Scans examples/rvf/output/, computes SHA-256, extracts RVF segment info,
|
||||
# categorizes by naming convention, produces manifest.json
|
||||
```
|
||||
|
||||
Categories are derived from the example name or explicit `category` field in a sidecar `.meta.json` file if present:
|
||||
|
||||
| Pattern | Category |
|
||||
|---|---|
|
||||
| `basic_store`, `semantic_search`, `filtered_search`, `quantization` | core |
|
||||
| `agent_*`, `rag_*`, `embedding_*`, `ruvllm_*`, `ruvbot` | ai |
|
||||
| `tee_*`, `zero_knowledge`, `access_control`, `sealed_engine` | security |
|
||||
| `self_booting`, `ebpf_*`, `browser_wasm`, `linux_microkernel` | compute |
|
||||
| `lineage_*`, `reasoning_*` | lineage |
|
||||
| `financial_*`, `medical_*`, `legal_*` | industry |
|
||||
| `network_*`, `agent_handoff_*` | network |
|
||||
| `mcp_*`, `postgres_*`, `serverless`, `claude_code_*` | integration |
|
||||
|
||||
## 4. Fallback Strategy
|
||||
|
||||
If GCS is unreachable:
|
||||
1. **Cached manifest**: Use `~/.ruvector/examples/manifest.json` if within TTL
|
||||
2. **Stale manifest**: Use expired cached manifest with warning
|
||||
3. **Hardcoded fallback**: Built-in minimal catalog (top 12 most popular examples) with GitHub raw URLs
|
||||
4. **Offline mode**: `--offline` flag uses only cached files, no network
|
||||
|
||||
```
|
||||
Download priority:
|
||||
1. Local cache (~/.ruvector/examples/) ← 0ms, SHA-256 verified
|
||||
2. GCS via Cloud CDN ← 20-50ms global
|
||||
3. GCS direct ← 50-200ms
|
||||
4. GitHub raw (fallback) ← 200-500ms, rate limited
|
||||
```
|
||||
|
||||
## 5. Security
|
||||
|
||||
### 5.1 Integrity Verification
|
||||
|
||||
Every downloaded `.rvf` file is verified against the SHA-256 in the manifest before being cached or returned to the user. This prevents:
|
||||
- **CDN poisoning**: Tampered files at the edge are detected
|
||||
- **MITM attacks**: Even over HTTPS, defense-in-depth with content hashes
|
||||
- **Cache corruption**: Local disk corruption caught on re-verify
|
||||
|
||||
### 5.2 Manifest Signing (Future)
|
||||
|
||||
Phase 2 will add Ed25519 signing of manifests:
|
||||
```json
|
||||
{
|
||||
"version": "0.2.1",
|
||||
"examples": [...],
|
||||
"signature": "base64(Ed25519(manifest_without_signature))",
|
||||
"signer": "ruvector-release-key-001"
|
||||
}
|
||||
```
|
||||
The CLI will verify manifests against a pinned public key shipped in the npm package.
|
||||
|
||||
### 5.3 Path Safety
|
||||
|
||||
Download destination paths are validated to prevent directory traversal:
|
||||
- `path.basename()` strips parent directory references
|
||||
- Regex allows only `[a-zA-Z0-9_\-.]` characters in filenames
|
||||
- Final path must resolve within the output directory
|
||||
- These checks already exist in the current `rvf download` implementation
|
||||
|
||||
### 5.4 GCS Access Control
|
||||
|
||||
- Bucket: `allUsers:objectViewer` (public read)
|
||||
- Write: Only the CI/CD service account via Workload Identity Federation
|
||||
- No API keys or credentials shipped in the npm package
|
||||
- Cloud Armor WAF rules for DDoS protection on CDN ingress
|
||||
|
||||
## 6. Implementation
|
||||
|
||||
### 6.1 File Changes
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `npm/packages/ruvector/bin/cli.js` | Update `rvf examples` to fetch manifest from GCS. Update `rvf download` to use GCS URLs + SHA-256 verify + cache. Add `rvf cache` subcommand |
|
||||
| `npm/packages/ruvector/bin/mcp-server.js` | Update `rvf_examples` handler to read manifest. Add download capability |
|
||||
| `npm/packages/ruvector/src/rvf-catalog.ts` | Shared manifest fetcher, cache manager, integrity verifier |
|
||||
| `scripts/generate-rvf-manifest.py` | Manifest generator from local examples |
|
||||
| `.github/workflows/sync-rvf-examples.yml` | CI/CD sync pipeline |
|
||||
|
||||
### 6.2 Single Source of Truth
|
||||
|
||||
The `RVF_EXAMPLES` array currently hardcoded in `cli.js` (45 entries) and `mcp-server.js` (12 entries) is replaced by a shared manifest. Both read from:
|
||||
|
||||
```javascript
|
||||
async function getRvfCatalog(opts = {}) {
|
||||
const cacheDir = path.join(os.homedir(), '.ruvector', 'examples');
|
||||
const manifestPath = path.join(cacheDir, 'manifest.json');
|
||||
|
||||
// Check cache
|
||||
if (!opts.refresh && fs.existsSync(manifestPath)) {
|
||||
const stat = fs.statSync(manifestPath);
|
||||
const age = Date.now() - stat.mtimeMs;
|
||||
if (age < 3600000) { // 1 hour TTL
|
||||
return JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch from GCS
|
||||
const GCS_URL = 'https://storage.googleapis.com/ruvector-examples/manifest.json';
|
||||
try {
|
||||
const resp = await fetch(GCS_URL);
|
||||
const manifest = await resp.json();
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
||||
return manifest;
|
||||
} catch {
|
||||
// Fallback to cached (even if stale)
|
||||
if (fs.existsSync(manifestPath)) {
|
||||
return JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
}
|
||||
// Final fallback: hardcoded minimal catalog
|
||||
return BUILTIN_CATALOG;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 7. GCS Setup
|
||||
|
||||
### 7.1 Bucket Creation
|
||||
|
||||
```bash
|
||||
# Create bucket with Standard storage (frequently accessed)
|
||||
gcloud storage buckets create gs://ruvector-examples \
|
||||
--location=us-central1 \
|
||||
--uniform-bucket-level-access \
|
||||
--public-access-prevention=inherited
|
||||
|
||||
# Enable public read
|
||||
gcloud storage buckets add-iam-policy-binding gs://ruvector-examples \
|
||||
--member=allUsers \
|
||||
--role=roles/storage.objectViewer
|
||||
|
||||
# Enable Cloud CDN (via load balancer backend bucket)
|
||||
gcloud compute backend-buckets create ruvector-examples-backend \
|
||||
--gcs-bucket-name=ruvector-examples \
|
||||
--enable-cdn \
|
||||
--cache-mode=CACHE_ALL_STATIC \
|
||||
--default-ttl=3600
|
||||
```
|
||||
|
||||
### 7.2 CI/CD Service Account
|
||||
|
||||
```bash
|
||||
gcloud iam service-accounts create rvf-examples-sync \
|
||||
--display-name="RVF Examples Sync"
|
||||
|
||||
gcloud storage buckets add-iam-policy-binding gs://ruvector-examples \
|
||||
--member=serviceAccount:rvf-examples-sync@PROJECT.iam.gserviceaccount.com \
|
||||
--role=roles/storage.objectAdmin
|
||||
|
||||
# Workload Identity Federation for GitHub Actions (no key file)
|
||||
gcloud iam workload-identity-pools create github-pool \
|
||||
--location=global
|
||||
gcloud iam workload-identity-pools providers create-oidc github-provider \
|
||||
--location=global \
|
||||
--workload-identity-pool=github-pool \
|
||||
--issuer-uri=https://token.actions.githubusercontent.com \
|
||||
--attribute-mapping="google.subject=assertion.sub"
|
||||
```
|
||||
|
||||
### 7.3 Cost Estimate
|
||||
|
||||
| Resource | Monthly Cost |
|
||||
|---|---|
|
||||
| GCS Standard (50 MB, 46 files x ~5 versions) | ~$0.01 |
|
||||
| Cloud CDN egress (1 GB/month estimate) | ~$0.08 |
|
||||
| Cloud CDN requests (10K/month estimate) | ~$0.01 |
|
||||
| **Total** | **~$0.10/month** |
|
||||
|
||||
## 8. Example Categories and Counts
|
||||
|
||||
| Category | Count | Total Size | Description |
|
||||
|---|---|---|---|
|
||||
| core | 8 | ~5.1 MB | Basic stores, HNSW, quantization, filtering |
|
||||
| ai | 8 | ~1.2 MB | Agent memory, RAG, embeddings, chatbot |
|
||||
| security | 4 | ~439 KB | TEE, ZK, access control, sealed engine |
|
||||
| compute | 4 | ~213 KB | eBPF, WASM, self-boot, microkernel |
|
||||
| lineage | 5 | ~102 KB | COW chains, reasoning chains |
|
||||
| industry | 3 | ~1.4 MB | Finance, medical, legal |
|
||||
| network | 4 | ~146 KB | Sync, handoff, telemetry |
|
||||
| integration | 6 | ~862 KB | MCP, PostgreSQL, serverless, Claude Code |
|
||||
| **Total** | **46** | **~11.2 MB** | |
|
||||
|
||||
## 9. Migration Path
|
||||
|
||||
### Phase 1: GCS Setup + Sync (1 day)
|
||||
- Create GCS bucket with public read
|
||||
- Write `scripts/generate-rvf-manifest.py`
|
||||
- Upload current 46 examples + manifest
|
||||
- Create GitHub Actions workflow
|
||||
|
||||
### Phase 2: CLI Update (1 day)
|
||||
- Replace hardcoded `RVF_EXAMPLES` with manifest fetcher
|
||||
- Add SHA-256 integrity verification to downloads
|
||||
- Add local cache at `~/.ruvector/examples/`
|
||||
- Add `rvf cache status` and `rvf cache clear` subcommands
|
||||
- Add `--category`, `--refresh`, `--offline`, `--verify` flags
|
||||
|
||||
### Phase 3: MCP Update (half day)
|
||||
- Update `rvf_examples` handler to read from manifest
|
||||
- Add `download` parameter to MCP tool
|
||||
- Unify catalog between CLI and MCP (single code path)
|
||||
|
||||
### Phase 4: CDN + Monitoring (half day)
|
||||
- Enable Cloud CDN via backend bucket
|
||||
- Set up monitoring alerts for download errors
|
||||
- Add analytics (download counts per example)
|
||||
|
||||
## 10. Testing
|
||||
|
||||
| Test | Description |
|
||||
|---|---|
|
||||
| Manifest fetch from GCS | Returns valid JSON with all 46 examples |
|
||||
| Manifest cache TTL | Second fetch within 1 hour uses cache |
|
||||
| Manifest refresh | `--refresh` bypasses cache |
|
||||
| Download + SHA-256 verify | Downloaded file matches checksum |
|
||||
| Cache hit | Second download of same file is instant |
|
||||
| Offline mode | `--offline` uses only cached files |
|
||||
| GCS unreachable | Falls back to cached manifest, then hardcoded catalog |
|
||||
| Tampered file | SHA-256 mismatch detected and reported |
|
||||
| Path traversal | `../../../etc/passwd.rvf` rejected |
|
||||
| Category filter | `--category security` returns only 4 examples |
|
||||
| Disk budget | Cache evicts oldest files when >100 MB |
|
||||
| CI/CD sync | Push to `examples/rvf/output/` triggers GCS upload |
|
||||
| Manifest generator | Produces valid manifest with correct checksums |
|
||||
| MCP download | `rvf_examples` with `download` param returns local path |
|
||||
|
||||
## 11. Consequences
|
||||
|
||||
### Positive
|
||||
- **Single source of truth**: Manifest replaces 2 hardcoded catalogs
|
||||
- **Fast global downloads**: Cloud CDN edge caching (20-50ms vs 200-500ms)
|
||||
- **Integrity**: SHA-256 verification on every download
|
||||
- **Offline support**: Local cache + `--offline` flag
|
||||
- **Auto-sync**: New examples available without npm republish
|
||||
- **Cheap**: ~$0.10/month for storage + CDN
|
||||
- **Version pinning**: Each ruvector version maps to a pinned manifest
|
||||
|
||||
### Negative
|
||||
- **GCS dependency**: New infrastructure to maintain (mitigated by GitHub raw fallback)
|
||||
- **Cache management**: Users accumulate cached files (mitigated by disk budget + clear command)
|
||||
- **Manifest staleness**: 1-hour TTL means new examples take up to 1 hour to appear
|
||||
|
||||
### Neutral
|
||||
- Existing `rvf download` command retains same UX, just faster and verified
|
||||
- `rvf_examples` MCP tool gains download capability
|
||||
- GitHub raw URLs continue to work as fallback
|
||||
|
||||
## 12. Related ADRs
|
||||
|
||||
| ADR | Relationship |
|
||||
|---|---|
|
||||
| ADR-044 | RVF Format Specification — defines the `.rvf` binary format these examples demonstrate |
|
||||
| ADR-059 | Shared Brain Google Cloud — same GCP project, similar GCS patterns |
|
||||
| ADR-065 | npm Publishing Strategy — example catalog decoupled from npm publish cycle |
|
||||
| ADR-070 | npx ruvector Unified Integration — `rvf` command group that hosts these commands |
|
||||
284
docs/adr/ADR-073-pi-platform-security-optimization.md
Normal file
284
docs/adr/ADR-073-pi-platform-security-optimization.md
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
# ADR-073: π.ruv.io Platform Security Audit & Optimization
|
||||
|
||||
**Status**: Accepted
|
||||
**Date**: 2026-03-01
|
||||
**Authors**: RuVector Team
|
||||
**Deciders**: ruv
|
||||
**Related**: ADR-070 (npx ruvector Unified Integration), ADR-064 (Pi Brain Infrastructure), ADR-066 (SSE MCP Transport), ADR-058 (Hash Security Optimization)
|
||||
|
||||
## 1. Context
|
||||
|
||||
A comprehensive deep review of the `ruvector` npm package (v0.2.2) — the unified CLI, MCP server, and SDK for the π.ruv.io collective intelligence platform — was performed. The review tested all 48 CLI commands across 12 command groups and 91 MCP tools using a live PI key against the production π.ruv.io endpoint.
|
||||
|
||||
The audit revealed security vulnerabilities, error handling gaps, and UX friction points that needed immediate remediation before the v0.2.3 release.
|
||||
|
||||
### Test Environment
|
||||
|
||||
| Component | Value |
|
||||
|-----------|-------|
|
||||
| Package | `ruvector@0.2.2` |
|
||||
| Node.js | ≥18.0.0 |
|
||||
| CLI Commands | 48 across 12 groups |
|
||||
| MCP Tools | 91 across 12 categories |
|
||||
| Endpoint | `https://pi.ruv.io` |
|
||||
| Transport | stdio + SSE dual-mode |
|
||||
|
||||
### Test Results Summary
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Unit tests | 55/55 pass |
|
||||
| CLI commands tested | 36 |
|
||||
| Commands passing | 27/36 (75%) |
|
||||
| CLI startup time | 54.6ms avg |
|
||||
| MCP server init | 246ms |
|
||||
|
||||
## 2. Decision
|
||||
|
||||
Fix all HIGH and MEDIUM severity issues found during the audit, publish v0.2.3 with security patches, and document the findings for future reference.
|
||||
|
||||
## 3. Findings & Remediations
|
||||
|
||||
### 3.1 HIGH: PI Key Exposed in URL Path (FIXED)
|
||||
|
||||
**Issue**: The `edge balance` command placed the raw PI key directly in the URL path:
|
||||
```javascript
|
||||
// BEFORE (vulnerable)
|
||||
const resp = await fetch(`${EDGE_GENESIS}/balance/${piKey}`, ...);
|
||||
```
|
||||
|
||||
This is a critical security violation — URL paths are logged by proxies, CDNs, web servers, and browser history. A 64-character hex PI key in the path leaks the user's identity and authentication credential.
|
||||
|
||||
**Fix**: Derive a SHAKE-256 pseudonym from the PI key and use that in the URL. The raw key is sent only in the `Authorization` header (which is not logged by standard infrastructure):
|
||||
|
||||
```javascript
|
||||
// AFTER (secure)
|
||||
const pseudonym = require('crypto')
|
||||
.createHash('shake256', { outputLength: 16 })
|
||||
.update(piKey)
|
||||
.digest('hex');
|
||||
const resp = await fetch(`${EDGE_GENESIS}/balance/${pseudonym}`, {
|
||||
headers: { 'Authorization': `Bearer ${piKey}` }
|
||||
});
|
||||
```
|
||||
|
||||
**Files modified**: `bin/cli.js`, `bin/mcp-server.js`
|
||||
|
||||
**Rationale**: SHAKE-256 is the same hash function used throughout the RVF wire format (ADR-058). The 16-byte (32-hex-char) pseudonym is sufficient for routing without revealing the key. The Authorization header is the standard HTTP mechanism for bearer tokens and is stripped by well-configured reverse proxies before logging.
|
||||
|
||||
### 3.2 HIGH: SONA Status Native Binding Crash (NOTED)
|
||||
|
||||
**Issue**: `npx ruvector sona status` crashes with `Module not found` when `@ruvector/sona` native bindings aren't installed. The SONA package has optional native acceleration via N-API that requires platform-specific compilation.
|
||||
|
||||
**Status**: Not fixed in this release — the crash is in the `@ruvector/sona` package itself, not in the `ruvector` CLI. The CLI already lazy-loads SONA and catches import errors for the top-level module, but internal native binding failures propagate.
|
||||
|
||||
**Mitigation**: Future `@ruvector/sona` release should wrap native binding calls in try-catch with WASM fallback.
|
||||
|
||||
### 3.3 MEDIUM: Edge Genesis 404 Crash (FIXED)
|
||||
|
||||
**Issue**: Edge network commands (`edge genesis`, `edge balance`) crashed when the backend returned non-JSON error responses (404, 502, etc.):
|
||||
|
||||
```
|
||||
SyntaxError: Unexpected token 'N', "Not Found" is not valid JSON
|
||||
```
|
||||
|
||||
The code called `resp.json()` without checking `resp.ok` first.
|
||||
|
||||
**Fix**: Added response status check before JSON parsing in both CLI and MCP server:
|
||||
|
||||
```javascript
|
||||
if (!resp.ok) {
|
||||
const errText = await resp.text().catch(() => resp.statusText);
|
||||
console.error(chalk.red(`Edge network returned ${resp.status} ${resp.statusText}`));
|
||||
process.exit(1);
|
||||
}
|
||||
```
|
||||
|
||||
**Files modified**: `bin/cli.js`, `bin/mcp-server.js`
|
||||
|
||||
### 3.4 MEDIUM: `hooks remember` Required `-t` Flag (FIXED)
|
||||
|
||||
**Issue**: `npx ruvector hooks remember -v "some value"` failed with `error: required option '-t, --type <type>' not specified`. For a convenience command, requiring the type flag on every invocation is excessive UX friction.
|
||||
|
||||
**Fix**: Changed from `requiredOption` to `option` with a default value of `'general'`:
|
||||
|
||||
```javascript
|
||||
// BEFORE
|
||||
.requiredOption('-t, --type <type>', 'Memory type')
|
||||
// AFTER
|
||||
.option('-t, --type <type>', 'Memory type', 'general')
|
||||
```
|
||||
|
||||
**Files modified**: `bin/cli.js`
|
||||
|
||||
### 3.5 MEDIUM: `@ruvector/pi-brain` Not Installed (NOTED)
|
||||
|
||||
**Issue**: Brain commands fail when `@ruvector/pi-brain` is not installed. It's declared as an optional `peerDependency` in `package.json`, but the error message isn't user-friendly.
|
||||
|
||||
**Status**: Working as designed — pi-brain is an optional peer dependency. The lazy-load pattern already provides a clear error: `"Install @ruvector/pi-brain for brain commands"`. Users who want brain features install it separately.
|
||||
|
||||
### 3.6 LOW: CLI Monolith Size (NOTED)
|
||||
|
||||
**Issue**: `bin/cli.js` is 8,582 lines — a large single file. While it works correctly and loads fast (54.6ms), it makes maintenance harder.
|
||||
|
||||
**Status**: Deferred. The file loads fast due to lazy-loading of heavy dependencies (GNN, attention, ora). Splitting would add complexity without performance benefit. If the file exceeds 10,000 lines, consider splitting by command group.
|
||||
|
||||
### 3.7 LOW: `hooks recall` Non-Semantic Search (NOTED)
|
||||
|
||||
**Issue**: `hooks recall --query` uses exact string matching (`includes()`) rather than semantic/fuzzy search. This limits discovery of related memories.
|
||||
|
||||
**Status**: Deferred. Semantic search would require loading SONA embeddings for every recall, adding ~200ms latency. The current approach is fast and predictable.
|
||||
|
||||
## 4. Performance Benchmarks
|
||||
|
||||
### CLI Startup
|
||||
|
||||
| Run | Time (ms) |
|
||||
|-----|-----------|
|
||||
| 1 | 56 |
|
||||
| 2 | 54 |
|
||||
| 3 | 53 |
|
||||
| 4 | 55 |
|
||||
| 5 | 55 |
|
||||
| **Avg** | **54.6** |
|
||||
|
||||
Fast startup is achieved through lazy-loading: heavy modules (GNN, attention, SONA, ora) are only `require()`'d when their commands are invoked.
|
||||
|
||||
### MCP Server
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Init time | 246ms |
|
||||
| Tools registered | 91 |
|
||||
| SSE health response | <10ms |
|
||||
| stdio JSON-RPC | Content-Length framed |
|
||||
|
||||
### Command Groups (12)
|
||||
|
||||
| Group | Commands | Status |
|
||||
|-------|----------|--------|
|
||||
| core | 7 | All pass |
|
||||
| brain | 6 | Pass (requires @ruvector/pi-brain) |
|
||||
| edge | 5 | Pass (with 404 fix) |
|
||||
| identity | 3 | All pass |
|
||||
| sona | 4 | 3/4 (native binding issue) |
|
||||
| hooks | 4 | All pass (with default fix) |
|
||||
| mcp | 3 | All pass |
|
||||
| gnn | 4 | All pass |
|
||||
| attention | 3 | All pass |
|
||||
| rvf | 3 | All pass |
|
||||
| solver | 3 | All pass |
|
||||
| parallel | 3 | All pass |
|
||||
|
||||
## 5. Security Architecture
|
||||
|
||||
### PI Key Derivation Chain
|
||||
|
||||
A single 64-hex PI key derives all identity components:
|
||||
|
||||
```
|
||||
PI Key (64 hex chars)
|
||||
├── SHAKE-256(key, 16 bytes) → Pseudonym (32 hex chars)
|
||||
│ └── Used in: URL paths, public identifiers
|
||||
├── HMAC-SHA256(key, "mcp-auth") → MCP Token
|
||||
│ └── Used in: MCP server authentication
|
||||
└── SHA-512(key) → first 32 bytes → Ed25519 Seed
|
||||
└── Used in: Edge network identity, signing
|
||||
```
|
||||
|
||||
### Security Principles Applied
|
||||
|
||||
1. **Never expose keys in URLs** — use derived pseudonyms (SHAKE-256)
|
||||
2. **Constant-time comparison** — `subtle::ConstantTimeEq` for wire hash verification
|
||||
3. **Lazy credential loading** — PI key read from env/file only when needed
|
||||
4. **AES-256-GCM** for key export with password-derived encryption
|
||||
5. **No credential logging** — CLAUDE.md explicitly forbids echoing/printing credentials
|
||||
|
||||
## 6. Server API Deep Review (27 Endpoints)
|
||||
|
||||
A comprehensive validation of all 27 REST endpoints on the live π.ruv.io backend was performed.
|
||||
|
||||
### Endpoint Test Results
|
||||
|
||||
| # | Endpoint | Method | Status | Verdict |
|
||||
|---|----------|--------|--------|---------|
|
||||
| 1 | `/v1/health` | GET | 200 | Pass — returns uptime, version, persistence mode |
|
||||
| 2 | `/v1/challenge` | GET | 200 | Pass — issues UUID nonce with 5-min TTL |
|
||||
| 3 | `/v1/memories` | POST | 201 | Pass — creates memory with Firestore write-through |
|
||||
| 4 | `/v1/memories/search` | GET | 200 | Pass — SHAKE-256 hash-based embedding + attention ranking |
|
||||
| 5 | `/v1/memories/list` | GET | 200 | Pass — paginated listing |
|
||||
| 6 | `/v1/memories/{id}` | GET | 200 | Pass — full BrainMemory with provenance |
|
||||
| 7 | `/v1/memories/{id}/vote` | POST | 200/403 | Pass — self-vote blocked, Bayesian BetaParams updated |
|
||||
| 8 | `/v1/memories/{id}` | DELETE | 204 | Pass — contributor-scoped deletion |
|
||||
| 9 | `/v1/transfer` | POST | 200 | Pass — returns acceleration_factor, transfer_success |
|
||||
| 10 | `/v1/drift` | GET | 200 | Pass — VectorDelta CV computation |
|
||||
| 11 | `/v1/partition` | GET | 200 | Pass — MinCut clusters (data-dependent) |
|
||||
| 12 | `/v1/status` | GET | 200 | Pass — comprehensive stats |
|
||||
| 13 | `/v1/lora/latest` | GET | 200 | Pass — consensus weights (null until 3 submissions) |
|
||||
| 14 | `/v1/lora/submit` | POST | 200 | Pass — validates shape (rank=2, hidden_dim=128) |
|
||||
| 15 | `/v1/training/preferences` | GET | 200 | Pass — vote preference pairs for RLHF |
|
||||
| 16 | `/v1/pages` | POST | 201/403 | Pass — reputation-gated page creation |
|
||||
| 17 | `/v1/pages/{id}` | GET | 200/404 | Pass |
|
||||
| 18 | `/v1/pages/{id}/deltas` | POST | 200/400 | Pass — validates evidence link requirements |
|
||||
| 19 | `/v1/pages/{id}/deltas` | GET | 200 | Pass |
|
||||
| 20 | `/v1/pages/{id}/evidence` | POST | 200/400 | Pass — validates evidence type enum |
|
||||
| 21 | `/v1/pages/{id}/promote` | POST | 200/403 | Pass — quality threshold gate |
|
||||
| 22 | `/v1/nodes` | GET | 200 | Pass — lists non-revoked WASM nodes |
|
||||
| 23 | `/v1/nodes` | POST | 201/403 | Pass — reputation-gated node publishing |
|
||||
| 24 | `/v1/nodes/{id}` | GET | 200/404/410 | Pass — 410 Gone for revoked nodes |
|
||||
| 25 | `/v1/nodes/{id}/wasm` | GET | 200/404 | Pass — binary download with immutable cache |
|
||||
| 26 | `/v1/nodes/{id}/revoke` | POST | 200/404 | Pass — contributor-scoped revocation |
|
||||
| 27 | `/` + `/origin` + `/sse` | GET | 200 | Pass — landing page, origin story, SSE transport |
|
||||
|
||||
### Issues Found & Fixed
|
||||
|
||||
| Severity | Issue | Fix |
|
||||
|----------|-------|-----|
|
||||
| **HIGH** | Reputation never updated on share/vote — `ReputationManager` wired for reads only | Added `record_contribution()` on share, `update_reputation_from_vote()` on vote, `check_poisoning()` on downvotes |
|
||||
| **HIGH** | `contribution_count` never incremented | Now incremented in `record_contribution()` with Firestore write-through |
|
||||
| **MEDIUM** | Seed contributor stuck at cold-start 0.1 composite | Fixed — contributions now build reputation via EMA uptime + accuracy updates |
|
||||
| **MEDIUM** | LoRA submit schema undocumented | Requires `LoraSubmission { down_proj, up_proj, rank, hidden_dim, evidence_count }` with rank=2, hidden_dim=128 |
|
||||
| **LOW** | Evidence type `code_reference` in docs doesn't exist | Only supports: `test_pass`, `build_success`, `metric_improval`, `peer_review` |
|
||||
|
||||
### Feature Implementation Status
|
||||
|
||||
| Feature | Status | Notes |
|
||||
|---------|--------|-------|
|
||||
| Memory CRUD | Fully implemented | Firestore write-through, graph sync, drift recording |
|
||||
| Voting + Quality | Fully implemented | Bayesian BetaParams, self-vote block, duplicate block, preference pairs |
|
||||
| Reputation System | **Fixed** — now fully wired | EMA accuracy, uptime decay, poisoning penalty, contribution counting |
|
||||
| Transfer Learning | Fully implemented | DomainExpansionEngine with acceleration factor |
|
||||
| Drift Monitoring | Fully implemented | VectorDelta with CV threshold detection |
|
||||
| MinCut Partitioning | Fully implemented | SubpolynomialMinCut with CognitiveEngine |
|
||||
| LoRA Federation | Fully implemented | Gate B: per-parameter median + MAD outlier filtering + reputation-weighted trimmed mean |
|
||||
| Brainpedia Pages | Fully implemented | Reputation-gated creation, delta submissions, evidence, promotion |
|
||||
| WASM Nodes | Fully implemented | Reputation-gated publishing, SHA-256 verification, revocation |
|
||||
| SSE MCP Transport | Fully implemented | JSON-RPC over SSE with session management |
|
||||
| Challenge Nonce | Fully implemented | Replay protection with 5-min TTL |
|
||||
| Rate Limiting | Fully implemented | Per-contributor token bucket (100 writes/hr, 1000 reads/hr) |
|
||||
|
||||
## 7. Version History
|
||||
|
||||
| Version | Date | Changes |
|
||||
|---------|------|---------|
|
||||
| 0.2.1 | 2026-02-28 | Initial 48-command CLI, 91 MCP tools |
|
||||
| 0.2.2 | 2026-02-28 | Core version display fix, chalk ESM compat |
|
||||
| 0.2.3 | 2026-03-01 | Security: PI key URL fix, edge 404 handling, hooks UX |
|
||||
|
||||
## 7. Consequences
|
||||
|
||||
### Positive
|
||||
- PI key no longer leaked in URL paths across all edge commands
|
||||
- Edge commands gracefully handle backend errors instead of crashing
|
||||
- `hooks remember` is more ergonomic with sensible defaults
|
||||
- Comprehensive benchmark baseline established for future optimization
|
||||
- All 55 unit tests continue to pass
|
||||
|
||||
### Negative
|
||||
- SONA native binding crash remains (fix requires @ruvector/sona release)
|
||||
- CLI monolith continues to grow (8,582 lines)
|
||||
- hooks recall still uses exact match (semantic search deferred)
|
||||
|
||||
### Risks
|
||||
- SHAKE-256 pseudonym collision: negligible at 128 bits (2^64 birthday bound)
|
||||
- Edge 404 text fallback: `resp.text()` could theoretically be large; mitigated by Cloud Run response limits
|
||||
167
docs/adr/ADR-074-ruvllm-neural-embeddings.md
Normal file
167
docs/adr/ADR-074-ruvllm-neural-embeddings.md
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
# ADR-074: RuvLLM Neural Embedding Integration
|
||||
|
||||
**Status:** Implemented (Phase 2 — RlmEmbedder Active)
|
||||
**Date:** 2026-03-01 (Phase 1), 2026-03-03 (Phase 2)
|
||||
**Author:** ml-engineer, platform-eng
|
||||
|
||||
## Context
|
||||
|
||||
The π.ruv.io shared brain server previously relied on client-side embedding generation (SHA-256 hash or token-averaged hashes) which produced poor-quality embeddings that failed cosine similarity search. A keyword search fallback was added as a stopgap, but vector-native search is essential for scaling beyond trivial corpus sizes.
|
||||
|
||||
The ruvllm crate provides a pure-Rust embedding pipeline with three tiers:
|
||||
1. **HashEmbedder** — FNV-1a hash with character bigrams, L2-normalized (no model required)
|
||||
2. **RlmEmbedder** — Recursive context-aware embeddings conditioned on a neighbor corpus
|
||||
3. **Candle sentence transformer** — Neural sentence embeddings (all-MiniLM-L6-v2 or similar)
|
||||
|
||||
## Decision
|
||||
|
||||
Integrate ruvllm into mcp-brain-server with a phased approach:
|
||||
|
||||
### Phase 1 (Implemented): HashEmbedder
|
||||
- Add `ruvllm = { path = "../ruvllm", default-features = false, features = ["minimal"] }` dependency
|
||||
- Create `src/embeddings.rs` wrapping `ruvllm::bitnet::rlm_embedder::HashEmbedder`
|
||||
- Server auto-generates 128-dim L2-normalized embeddings when clients send empty `embedding: []`
|
||||
- Both storage and search use the same embedding dimension
|
||||
- No model download, no cold-start penalty, deterministic output
|
||||
|
||||
### Phase 2 (Implemented 2026-03-03): RlmEmbedder
|
||||
- `FlatNeighborStore` populated from all stored memories on startup
|
||||
- `RlmEmbedder<HashEmbedder, FlatNeighborStore>` active at **50+ corpus documents** (was 1000)
|
||||
- Storage uses **CorpusConditioned** variant (base=0.7, context=0.25, anti=0.05)
|
||||
- Search uses **QueryConditioned** variant (base=0.6, context=0.3, anti=0.1)
|
||||
- **Re-embedding on startup**: When RLM activates, all persisted memories are re-embedded with CorpusConditioned RLM for embedding space consistency (stored embeddings may have been HashEmbedder-generated)
|
||||
- Graph similarity threshold raised from 0.30 → 0.55 for RLM (contextual gravity makes embeddings more similar)
|
||||
- Clone derives added upstream to `HashEmbedder` and `FlatNeighborStore`
|
||||
|
||||
### Phase 3 (Future): Candle Sentence Transformer
|
||||
- Enable `candle` feature for ruvllm
|
||||
- Load all-MiniLM-L6-v2 (~90MB) or gte-small (~30MB) model
|
||||
- 384-dim sentence embeddings with true semantic understanding
|
||||
- Trade-off: model download time vs. embedding quality
|
||||
- Mitigate cold-start with model pre-loading in Cloud Run min-instances
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Client Request (empty embedding)
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────┐
|
||||
│ routes.rs: share_memory │
|
||||
│ ┌────────────────────┐ │
|
||||
│ │ Auto-embed check: │ │
|
||||
│ │ empty or dim≠128? │──── Yes ──▶ EmbeddingEngine::embed_for_storage()
|
||||
│ │ │ │ │
|
||||
│ └────────────────────┘ │ ▼
|
||||
│ │ No │ ruvllm::HashEmbedder::embed()
|
||||
│ ▼ │ FNV-1a + char bigrams + L2 norm
|
||||
│ Use client embedding │ │
|
||||
│ │ │ ▼
|
||||
│ ▼ │ 128-dim Vec<f32>
|
||||
│ Verifier::verify_share │◀─────────────┘
|
||||
│ (on final embedding) │
|
||||
└──────────────────────────┘
|
||||
|
||||
Client Search (text query)
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────┐
|
||||
│ routes.rs: search_memories│
|
||||
│ ┌────────────────────┐ │
|
||||
│ │ Has text query q? │──── Yes ──▶ EmbeddingEngine::embed()
|
||||
│ │ │ │ │
|
||||
│ └────────────────────┘ │ ▼
|
||||
│ │ No │ Same HashEmbedder pipeline
|
||||
│ ▼ │ │
|
||||
│ Return empty │ ▼
|
||||
│ │ cosine_similarity(query_emb, stored_emb)
|
||||
│ │ → reputation-weighted ranking
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
1. **Server-side embedding**: Clients send empty `embedding: []` and the server generates. This ensures:
|
||||
- Consistent dimension (128) across all memories
|
||||
- No client-side embedding logic needed
|
||||
- Future backend upgrades transparent to clients
|
||||
- Backward compatible: clients can still send pre-computed embeddings
|
||||
|
||||
2. **`minimal` feature**: Avoids pulling in candle-core (~50 crates). HashEmbedder is pure Rust with zero external dependencies.
|
||||
|
||||
3. **128-dim**: Matches existing SONA engine, cognitive engine, and ranking engine dimensions. Lower than typical sentence transformer (384) but sufficient for hash-based embeddings.
|
||||
|
||||
4. **Embedding verification after auto-generation**: The share handler generates embeddings before calling `Verifier::verify_share()`, so the verification validates the server-generated embedding (not an empty array).
|
||||
|
||||
5. **Corpus tracking**: `EmbeddingEngine::add_to_corpus()` tracks corpus size for future RlmEmbedder integration. Status endpoint reports `embedding_corpus` count.
|
||||
|
||||
## Dependencies Added
|
||||
|
||||
```toml
|
||||
ruvllm = { path = "../ruvllm", default-features = false, features = ["minimal"] }
|
||||
```
|
||||
|
||||
Transitive: `ruvector-core`, `ruvector-sona` (already in tree)
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `crates/mcp-brain-server/Cargo.toml` | Added ruvllm dependency |
|
||||
| `crates/mcp-brain-server/src/embeddings.rs` | New: EmbeddingEngine wrapping HashEmbedder |
|
||||
| `crates/mcp-brain-server/src/lib.rs` | Added `pub mod embeddings` |
|
||||
| `crates/mcp-brain-server/src/types.rs` | Added `embedding_engine` to AppState and StatusResponse |
|
||||
| `crates/mcp-brain-server/src/routes.rs` | Auto-embed in share, embed-based search, status fields |
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- Vector similarity search works with consistent 128-dim embeddings
|
||||
- No model download or external service required
|
||||
- Deterministic: same text always produces same embedding
|
||||
- Zero cold-start penalty (HashEmbedder is <1ms)
|
||||
- Clients simplified: no embedding logic needed
|
||||
|
||||
### Negative
|
||||
- RLM contextual gravity reduces discriminative power on homogeneous corpora — keyword matching must remain dominant signal
|
||||
- 128-dim is lower fidelity than 384-dim sentence transformers
|
||||
- Re-embedding on startup adds ~2-3s to cold start with 237 memories
|
||||
- FNV-1a hash collisions possible for very similar token patterns (base embedder)
|
||||
|
||||
### Neutral
|
||||
- Keyword search still primary ranking signal (keyword floor +1.0 always outranks embedding-only)
|
||||
- Future upgrade to candle sentence transformer is backward-compatible (same dimension)
|
||||
|
||||
## Metrics (Phase 1 Deployment — 2026-03-01)
|
||||
|
||||
- **Seeded:** 37 memories, 19 contributors
|
||||
- **Search hit rate:** 10/10 queries return results
|
||||
- **Graph:** 37 nodes, 200 edges
|
||||
- **Clusters:** 7 (category-based partition)
|
||||
- **Avg quality:** 0.838
|
||||
- **Embedding corpus:** 37 entries
|
||||
- **Build size:** No significant increase (ruvllm minimal is pure Rust)
|
||||
|
||||
## Metrics (Phase 2 Deployment — 2026-03-03)
|
||||
|
||||
- **Memories:** 237, **Contributors:** 17
|
||||
- **Embedding engine:** `ruvllm::RlmEmbedder` (context-aware, activated at 50+ docs)
|
||||
- **Search P@1:** 100% (30/30 benchmark queries)
|
||||
- **Search P@3:** 100% (30/30)
|
||||
- **Graph:** 237 nodes, 827 edges (threshold 0.55)
|
||||
- **Clusters:** 20 (meaningful MinCut partitions)
|
||||
- **Avg quality:** 0.73
|
||||
- **Votes:** 608
|
||||
- **LoRA epoch:** 2
|
||||
|
||||
### Search Intelligence Stack (Phase 2)
|
||||
|
||||
| Layer | Signal | Weight (keyword path) | Weight (no keyword) |
|
||||
|-------|--------|----------------------|---------------------|
|
||||
| Keyword matching | Word-boundary title/tag/category/content | 0.85 × boost + 1.0 floor | — |
|
||||
| RLM embedding similarity | QueryConditioned cosine | 0.05 | 0.45 |
|
||||
| Graph PPR (ForwardPushSolver) | PageRank over knowledge graph | 0.04 | 0.25 |
|
||||
| Vote quality (Bayesian Beta) | Learning-to-rank from 608 votes | 0.03 | 0.15 |
|
||||
| Reputation | Multi-factor contributor trust | 0.03 | 0.15 |
|
||||
| Query expansion | 32 synonym rules (abbreviations) | implicit | implicit |
|
||||
| Attention ranking | TopologyGatedAttention post-processing | post-score | post-score |
|
||||
354
docs/adr/ADR-075-rvf-agi-stack-brain-integration.md
Normal file
354
docs/adr/ADR-075-rvf-agi-stack-brain-integration.md
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
# ADR-075: Wire Full RVF AGI Stack into mcp-brain-server
|
||||
|
||||
**Status**: Implemented
|
||||
**Date**: 2026-03-03
|
||||
**Authors**: RuVector Team
|
||||
**Deciders**: ruv
|
||||
**Supersedes**: N/A
|
||||
**Related**: ADR-058 (Hash Security Optimization), ADR-059 (Shared Brain Google Cloud), ADR-060 (Shared Brain Capabilities), ADR-029 (RVF Canonical Format), ADR-057 (Federated RVF Transfer Learning)
|
||||
|
||||
## 1. Context
|
||||
|
||||
The Shared Brain server (`crates/mcp-brain-server/`) is deployed and operational at π.ruv.io with 237+ memories, P@1 100%, and 20 knowledge clusters. However, it currently uses **inline reimplementations** of cryptographic operations (sha2/sha3/ed25519-dalek) instead of the production RVF crates. Specifically:
|
||||
|
||||
- `verify.rs` has dead-code functions (`verify_ed25519_signature`, `verify_witness_chain`, `verify_content_hash`) that are never called from route handlers
|
||||
- PII checking uses 8 simple string patterns instead of the 12-rule regex `PiiStripper` from `rvf-federation`
|
||||
- No differential privacy noise injection on embeddings
|
||||
- No witness chains linking memory operations
|
||||
- No RVF container construction pipeline
|
||||
- No `NegativeCache` or `BudgetTokenBucket` from `rvf-runtime`
|
||||
|
||||
The plan described in ADR-060 Section 2.5 specifies a canonical 10-segment RVF container layout per memory. This ADR documents the decision to wire in the real RVF crate implementations to activate all planned security and AGI features.
|
||||
|
||||
## 2. Decision
|
||||
|
||||
Replace inline crypto and security implementations in `mcp-brain-server` with the production RVF crate stack: `rvf-crypto`, `rvf-wire`, `rvf-types`, `rvf-federation`, and `rvf-runtime`.
|
||||
|
||||
### 2.1 New Dependencies
|
||||
|
||||
Add to `crates/mcp-brain-server/Cargo.toml`:
|
||||
|
||||
```toml
|
||||
# RVF AGI Format Stack
|
||||
rvf-types = { path = "../rvf/rvf-types", features = ["std"] }
|
||||
rvf-crypto = { path = "../rvf/rvf-crypto" }
|
||||
rvf-wire = { path = "../rvf/rvf-wire" }
|
||||
rvf-federation = { path = "../rvf/rvf-federation", features = ["serde"] }
|
||||
rvf-runtime = { path = "../rvf/rvf-runtime" }
|
||||
```
|
||||
|
||||
### 2.2 Phase 1: Replace Inline Crypto with rvf-crypto
|
||||
|
||||
- `verify_content_hash()`: Delegate to `rvf_crypto::shake256_256()` + constant-time compare
|
||||
- `verify_witness_chain()`: Keep old string-step method for backward compat; add `verify_rvf_witness_chain()` using `rvf_crypto::verify_witness_chain(data: &[u8]) -> Result<Vec<WitnessEntry>>`
|
||||
- `verify_ed25519_signature()`: Keep as-is (already uses ed25519-dalek; Cargo dedupes)
|
||||
- New: `verify_rvf_segment_signature()` using `rvf_crypto::verify_segment(header, payload, footer, pubkey) -> bool`
|
||||
|
||||
### 2.3 Phase 2: PII Stripping with rvf-federation PiiStripper
|
||||
|
||||
Replace the 8-pattern inline PII check with `rvf_federation::PiiStripper`:
|
||||
- 12 regex rules: Unix/Windows paths, IPv4/IPv6, emails, API keys (sk-, AKIA, ghp_), Bearer tokens, env vars, @usernames
|
||||
- `strip_fields(&[(&str, &str)]) -> (Vec<(String, String)>, RedactionLog)` returns redacted fields + attestation log
|
||||
- `contains_pii(input: &str) -> bool` for backward-compatible rejection check
|
||||
- `RedactionLog` stored as JSON on `BrainMemory.redaction_log`
|
||||
|
||||
PII stripping occurs **after** `verify_share()` and **before** storage — redacted values replace raw input.
|
||||
|
||||
### 2.4 Phase 3: Differential Privacy for Embeddings
|
||||
|
||||
Wire `rvf_federation::DiffPrivacyEngine::gaussian(epsilon, delta, sensitivity, clipping_norm)`:
|
||||
- After embedding generation, convert `Vec<f32>` → `Vec<f64>`, call `dp.add_noise(&mut params) -> DiffPrivacyProof`, convert back
|
||||
- Store proof JSON on `BrainMemory.dp_proof`
|
||||
- Feature-gated by `RVF_DP_ENABLED` (default: `false`) — enable only after P@1 regression testing
|
||||
- Default epsilon=1.0, configurable via `RVF_DP_EPSILON`
|
||||
|
||||
**Risk**: DP noise reduces embedding precision. Start with epsilon=1.0 (low noise). Benchmark P@1 before enabling in production.
|
||||
|
||||
### 2.5 Phase 4: Witness Chains for Memory Operations
|
||||
|
||||
Build a 3-entry linked witness chain per memory using `rvf_crypto::create_witness_chain()`:
|
||||
|
||||
| Entry | Type | Action Hash |
|
||||
|-------|------|-------------|
|
||||
| 1 | PROVENANCE (0x01) | SHAKE-256 of PII-stripped content |
|
||||
| 2 | COMPUTATION (0x02) | SHAKE-256 of embedding bytes |
|
||||
| 3 | PROVENANCE (0x01) | SHAKE-256 of final memory JSON |
|
||||
|
||||
Each entry: `prev_hash` linked by `create_witness_chain`, `action_hash` = `shake256_256(data)`, `timestamp_ns` = current time, `witness_type` = entry type.
|
||||
|
||||
Chain bytes stored on `BrainMemory.witness_chain`. `witness_hash = hex(shake256_256(chain_bytes))`.
|
||||
|
||||
Adversarial detection: `rvf_runtime::is_degenerate_distribution(distances, n_probe)` — log-only, no rejection, to avoid false positives.
|
||||
|
||||
### 2.6 Phase 5: RVF Container Construction Pipeline
|
||||
|
||||
New file `crates/mcp-brain-server/src/pipeline.rs` (~120 lines):
|
||||
|
||||
Assemble segments using `rvf_wire::write_segment(seg_type, payload, flags, segment_id)`:
|
||||
|
||||
| Segment | Type | Content |
|
||||
|---------|------|---------|
|
||||
| VEC (0x01) | Embedding | f32 LE bytes |
|
||||
| META (0x07) | Metadata | JSON (title, content, tags, category) |
|
||||
| WITNESS (0x0A) | Audit trail | Witness chain bytes |
|
||||
| DiffPrivacyProof (0x34) | Privacy attestation | Proof JSON bytes (if DP enabled) |
|
||||
| RedactionLog (0x35) | PII attestation | Redaction JSON bytes (if PII stripped) |
|
||||
|
||||
Container uploaded to GCS. Segment count reported in `ShareResponse.rvf_segments`.
|
||||
|
||||
### 2.7 Phase 6: Enhanced Rate Limiting + Negative Cache
|
||||
|
||||
Wire `rvf_runtime::NegativeCache::new(threshold: 5, window: 3600s, max_entries: 10_000)`:
|
||||
- `QuerySignature::from_query(&[f32])` — FNV-1a of int8-quantized vector
|
||||
- Check negative cache before full search in `search_memories()`
|
||||
- Record degenerate embeddings in negative cache after adversarial detection
|
||||
|
||||
## 3. Type Changes
|
||||
|
||||
### BrainMemory (new fields, all `Option<T>` with `#[serde(default)]`)
|
||||
|
||||
| Field | Type | Phase | Purpose |
|
||||
|-------|------|-------|---------|
|
||||
| `redaction_log` | `Option<String>` | 2 | JSON-serialized `RedactionLog` |
|
||||
| `dp_proof` | `Option<String>` | 3 | JSON-serialized `DiffPrivacyProof` |
|
||||
| `witness_chain` | `Option<Vec<u8>>` | 4 | Raw witness chain bytes |
|
||||
|
||||
### AppState (new fields)
|
||||
|
||||
| Field | Type | Phase | Purpose |
|
||||
|-------|------|-------|---------|
|
||||
| `dp_engine` | `Arc<Mutex<DiffPrivacyEngine>>` | 3 | Shared DP noise generator |
|
||||
| `negative_cache` | `Arc<Mutex<NegativeCache>>` | 6 | Degenerate query cache |
|
||||
|
||||
### ShareResponse (new fields)
|
||||
|
||||
| Field | Type | Phase | Purpose |
|
||||
|-------|------|-------|---------|
|
||||
| `witness_hash` | `String` | 4 | Hex SHAKE-256 of witness chain |
|
||||
| `rvf_segments` | `Option<u32>` | 5 | Segment count in RVF container |
|
||||
|
||||
### StatusResponse (new fields)
|
||||
|
||||
| Field | Type | Phase | Purpose |
|
||||
|-------|------|-------|---------|
|
||||
| `dp_epsilon` | `f64` | 3 | Current DP epsilon parameter |
|
||||
| `dp_budget_used` | `f64` | 3 | Fraction of privacy budget consumed |
|
||||
| `rvf_segments_per_memory` | `f64` | 6 | Average segments per RVF container |
|
||||
|
||||
## 4. Feature Gating
|
||||
|
||||
All new features are controlled by environment variables for gradual rollout:
|
||||
|
||||
| Feature | Env Var | Default | Risk | Notes |
|
||||
|---------|---------|---------|------|-------|
|
||||
| PII stripping | `RVF_PII_STRIP` | `true` | Low | High value, replaces inline patterns |
|
||||
| DP noise | `RVF_DP_ENABLED` | `false` | Medium | Enable after P@1 regression test |
|
||||
| DP epsilon | `RVF_DP_EPSILON` | `1.0` | — | Privacy loss per memory |
|
||||
| Witness chains | `RVF_WITNESS` | `true` | Low | Audit trail, no behavioral change |
|
||||
| RVF containers | `RVF_CONTAINER` | `true` | Low | Upload .rvf to GCS |
|
||||
| Adversarial detect | `RVF_ADVERSARIAL` | `false` | Medium | Log-only initially |
|
||||
| Negative cache | `RVF_NEG_CACHE` | `false` | Medium | Enable after tuning threshold |
|
||||
|
||||
## 5. Backward Compatibility
|
||||
|
||||
- All new `BrainMemory` fields are `Option<T>` with `#[serde(default)]` — existing persisted memories deserialize cleanly
|
||||
- Old `verify_no_pii()` / `verify_witness_chain()` methods kept for backward compat
|
||||
- PII stripping adds redaction but does not change the rejection behavior for existing API clients
|
||||
- Witness chain bytes stored alongside (not instead of) the existing `witness_hash` string field
|
||||
- `ShareResponse` gains new fields — JSON clients ignore unknown fields by default
|
||||
|
||||
## 6. Files Summary
|
||||
|
||||
| File | Action | Phase |
|
||||
|------|--------|-------|
|
||||
| `crates/mcp-brain-server/Cargo.toml` | Add 5 rvf-* deps | 1 |
|
||||
| `crates/mcp-brain-server/src/verify.rs` | Replace crypto, add PII strip, add adversarial detect | 1, 2, 4 |
|
||||
| `crates/mcp-brain-server/src/routes.rs` | Wire PII strip, DP noise, witness chains, RVF build, neg cache | 2, 3, 4, 5, 6 |
|
||||
| `crates/mcp-brain-server/src/types.rs` | Add fields to BrainMemory, AppState, StatusResponse, ShareResponse | 2, 3, 4, 6 |
|
||||
| `crates/mcp-brain-server/src/pipeline.rs` | **New**: RVF container construction | 5 |
|
||||
| `crates/mcp-brain-server/src/lib.rs` | Add `pub mod pipeline` | 5 |
|
||||
|
||||
## 7. Verification
|
||||
|
||||
1. `cargo build -p mcp-brain-server` — compiles with all rvf-* crates
|
||||
2. `cargo test -p mcp-brain-server` — all existing tests pass + new tests:
|
||||
- `test_rvf_witness_chain_roundtrip` — create 3-entry chain, verify integrity
|
||||
- `test_pii_strip_redacts_paths` — `/home/user/data` → `<PATH_1>`
|
||||
- `test_pii_strip_redacts_email` — `user@example.com` → `<EMAIL_1>`
|
||||
- `test_dp_noise_changes_embedding` — same input produces different output
|
||||
- `test_rvf_container_has_segments` — build container, count ≥ 3 segments
|
||||
- `test_adversarial_degenerate_detection` — uniform distances flagged
|
||||
3. Deploy to Cloud Run, verify `/v1/status` shows new fields (`dp_epsilon`, `rvf_segments_per_memory`)
|
||||
4. POST a memory, verify response has `witness_hash`, `rvf_segments`
|
||||
5. GET the memory back, verify `redaction_log` present if PII was stripped
|
||||
6. Run P@1 benchmark to confirm no regression (DP disabled by default)
|
||||
|
||||
## 7.1 Phase 7: Hot-Path Performance Optimizations
|
||||
|
||||
After deployment validation, a deep review identified 7 per-request allocation and I/O bottlenecks. All were eliminated:
|
||||
|
||||
| Issue | Location | Impact | Fix |
|
||||
|-------|----------|--------|-----|
|
||||
| PiiStripper recompiles 12 regexes per call | `verify.rs:126` | 84 regex compiles per 5-tag request | Cache PiiStripper in `Verifier` struct |
|
||||
| Verifier re-allocated per request | `routes.rs:337, 1206` | 2 allocations + 12 regex compiles per request | Shared `Arc<RwLock<Verifier>>` in AppState |
|
||||
| 9 env::var reads per share request | `routes.rs` (6 locations) | 9 syscalls per write request | `RvfFeatureFlags::from_env()` at startup |
|
||||
| Synonym HashMap allocated per search | `routes.rs:578-616` | 28-entry HashMap per search | `static LazyLock<HashMap>` (compiled once) |
|
||||
| `all_memories()` called twice in status | `routes.rs:1018+1045` | 2x full DashMap clone per status request | Reuse single `all_memories` binding |
|
||||
| `env::var("BRAIN_SYSTEM_KEY")` per auth | `auth.rs:71` | 1 syscall per authenticated request | `static LazyLock<String>` (read once) |
|
||||
| Embedding bytes via flat_map (no pre-alloc) | `routes.rs:362` | Repeated small allocations in witness chain | `Vec::with_capacity(len * 4)` pre-allocation |
|
||||
|
||||
**Net effect**: Eliminates ~96 regex compilations + ~10 env::var syscalls + ~29 HashMap entries per write request. Search requests eliminate HashMap re-allocation entirely.
|
||||
|
||||
## 7.2 Phase 8: AGI Capability Wiring
|
||||
|
||||
After the RVF stack and hot-path optimizations, four AGI learning subsystems were wired into the brain server to enable adaptive intelligence:
|
||||
|
||||
### 8.1 SONA 3-Tier Learning (`SONA_ENABLED`, default: `true`)
|
||||
|
||||
Wire `sona::SonaEngine` for hierarchical pattern learning:
|
||||
|
||||
| Integration Point | Handler | Behavior |
|
||||
|-------------------|---------|----------|
|
||||
| Pattern re-ranking | `search_memories()` | Boost results matching learned patterns (cosine × quality × 0.15) |
|
||||
| Trajectory tracking | `search_memories()` | Record search→result trajectories for online learning |
|
||||
| Background learning | `status()` | Trigger periodic pattern consolidation via `sona.tick()` |
|
||||
| Stats endpoint | `GET /v1/sona/stats` | Patterns stored, trajectories buffered, background ticks |
|
||||
|
||||
**AppState**: `sona: Arc<RwLock<SonaEngine>>` initialized with `SonaEngine::new(128)`.
|
||||
|
||||
### 8.2 Global Workspace Theory Attention (`GWT_ENABLED`, default: `true`)
|
||||
|
||||
Wire `ruvector_nervous_system::routing::workspace` for salience-based competition:
|
||||
|
||||
| Integration Point | Handler | Behavior |
|
||||
|-------------------|---------|----------|
|
||||
| Salience competition | `search_memories()` | Broadcast top 3×limit candidates, K-WTA competition selects winners |
|
||||
| Attention boost | `search_memories()` | Winners get +0.1 score boost, results re-sorted by salience |
|
||||
| Workspace load | `status()` | Report `gwt_workspace_load` (0.0-1.0) and `gwt_avg_salience` |
|
||||
|
||||
**AppState**: `workspace: Arc<RwLock<GlobalWorkspace>>` initialized with `GlobalWorkspace::with_threshold(7, 0.3)` (7-item capacity per Miller's Law).
|
||||
|
||||
### 8.3 Temporal Delta Tracking (`TEMPORAL_ENABLED`, default: `true`)
|
||||
|
||||
Wire `ruvector_delta_core::DeltaStream<VectorDelta>` for knowledge evolution tracking:
|
||||
|
||||
| Integration Point | Handler | Behavior |
|
||||
|-------------------|---------|----------|
|
||||
| Embedding delta | `share_memory()` | Push `VectorDelta::from_dense(embedding)` with timestamp |
|
||||
| Vote delta | `vote_memory()` | Push vote signal (+1/-1) as delta event |
|
||||
| Stats endpoint | `GET /v1/temporal` | Total deltas, recent-hour deltas, knowledge velocity, trend |
|
||||
| Status fields | `status()` | Report `knowledge_velocity` (deltas/hour) and `temporal_deltas` |
|
||||
|
||||
**AppState**: `delta_stream: Arc<RwLock<DeltaStream<VectorDelta>>>` initialized with `DeltaStream::for_vectors(128)`.
|
||||
|
||||
### 8.4 Meta-Learning Exploration (`META_LEARNING_ENABLED`, default: `true`)
|
||||
|
||||
Wire `ruvector_domain_expansion::DomainExpansionEngine` meta-learning subsystem:
|
||||
|
||||
| Integration Point | Handler | Behavior |
|
||||
|-------------------|---------|----------|
|
||||
| Curiosity bonus | `search_memories()` | Boost under-explored categories by `novelty × 0.05` |
|
||||
| Contribution recording | `share_memory()` | Record "contribute" arm decision with reward 0.5 |
|
||||
| Vote reward | `vote_memory()` | Feed upvote=1.0/downvote=0.0 as reward on "search" arm |
|
||||
| Explore endpoint | `GET /v1/explore` | Most curious category, regret summary, plateau status, health |
|
||||
| Status fields | `status()` | Report `meta_avg_regret` and `meta_plateau_status` |
|
||||
|
||||
**AppState**: `domain_engine: Arc<RwLock<DomainExpansionEngine>>` (already existed, now actively used).
|
||||
|
||||
### 8.5 Updated Feature Gating Table
|
||||
|
||||
| Feature | Env Var | Default | Risk | Phase |
|
||||
|---------|---------|---------|------|-------|
|
||||
| PII stripping | `RVF_PII_STRIP` | `true` | Low | 2 |
|
||||
| DP noise | `RVF_DP_ENABLED` | `false` | Medium | 3 |
|
||||
| DP epsilon | `RVF_DP_EPSILON` | `1.0` | — | 3 |
|
||||
| Witness chains | `RVF_WITNESS` | `true` | Low | 4 |
|
||||
| RVF containers | `RVF_CONTAINER` | `true` | Low | 5 |
|
||||
| Adversarial detect | `RVF_ADVERSARIAL` | `false` | Medium | 6 |
|
||||
| Negative cache | `RVF_NEG_CACHE` | `false` | Medium | 6 |
|
||||
| SONA learning | `SONA_ENABLED` | `true` | Low | 8 |
|
||||
| GWT attention | `GWT_ENABLED` | `true` | Low | 8 |
|
||||
| Temporal tracking | `TEMPORAL_ENABLED` | `true` | Low | 8 |
|
||||
| Meta-learning | `META_LEARNING_ENABLED` | `true` | Low | 8 |
|
||||
|
||||
### 8.6 Updated StatusResponse Fields
|
||||
|
||||
| Field | Type | Phase | Purpose |
|
||||
|-------|------|-------|---------|
|
||||
| `dp_epsilon` | `f64` | 3 | Current DP epsilon parameter |
|
||||
| `dp_budget_used` | `f64` | 3 | Fraction of privacy budget consumed |
|
||||
| `rvf_segments_per_memory` | `f64` | 5 | Average segments per RVF container |
|
||||
| `gwt_workspace_load` | `f32` | 8 | GWT attention workspace utilization |
|
||||
| `gwt_avg_salience` | `f32` | 8 | Average salience of workspace representations |
|
||||
| `knowledge_velocity` | `f64` | 8 | Embedding deltas per hour |
|
||||
| `temporal_deltas` | `usize` | 8 | Total temporal deltas recorded |
|
||||
| `sona_patterns` | `usize` | 8 | SONA patterns stored |
|
||||
| `sona_trajectories` | `usize` | 8 | SONA trajectories buffered |
|
||||
| `meta_avg_regret` | `f64` | 8 | Meta-learning average regret (lower = better) |
|
||||
| `meta_plateau_status` | `String` | 8 | Meta-learning plateau status |
|
||||
|
||||
### 8.7 New Endpoints
|
||||
|
||||
| Endpoint | Method | Phase | Response |
|
||||
|----------|--------|-------|----------|
|
||||
| `/v1/sona/stats` | GET | 8 | Patterns, trajectories, background ticks |
|
||||
| `/v1/temporal` | GET | 8 | Delta count, velocity, trend direction |
|
||||
| `/v1/explore` | GET | 8 | Curiosity, regret, plateau, health diagnostics |
|
||||
|
||||
### 8.8 Dependencies (already present, now actively wired)
|
||||
|
||||
```toml
|
||||
# Already in Cargo.toml — Phase 8 wires these into route handlers
|
||||
sona = { package = "ruvector-sona", path = "../sona", features = ["serde-support"] }
|
||||
ruvector-nervous-system = { path = "../ruvector-nervous-system" }
|
||||
ruvector-delta-core = { path = "../ruvector-delta-core" }
|
||||
ruvector-domain-expansion = { path = "../ruvector-domain-expansion" }
|
||||
```
|
||||
|
||||
### 8.9 AGI Readiness Estimate
|
||||
|
||||
| Capability | Before Phase 8 | After Phase 8 |
|
||||
|------------|----------------|---------------|
|
||||
| Adaptive search ranking | Static cosine+keyword | SONA patterns + GWT attention + curiosity bonus |
|
||||
| Knowledge evolution tracking | None | Temporal deltas, velocity, trend detection |
|
||||
| Meta-cognitive awareness | None | Regret tracking, plateau detection, Pareto optimization |
|
||||
| Self-directed exploration | None | Curiosity-driven category exploration |
|
||||
| **Estimated AGI readiness** | **~40%** | **~65%** |
|
||||
|
||||
## 8. Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Eliminates code duplication between inline crypto and production RVF crates
|
||||
- Activates the full 12-rule PII stripping pipeline (was 8 simple patterns)
|
||||
- Enables differential privacy for embedding protection (opt-in)
|
||||
- Creates tamper-evident witness chains for every memory operation
|
||||
- Produces real RVF containers stored in GCS for audit and federation
|
||||
- Adds adversarial embedding detection (log-only)
|
||||
- Adds negative cache to reduce cost of repeated degenerate queries
|
||||
|
||||
### Negative
|
||||
|
||||
- Five new path dependencies increase compile time (~15s incremental)
|
||||
- DP noise (when enabled) will reduce embedding precision — requires P@1 benchmarking
|
||||
- Witness chain adds ~219 bytes (3 × 73) per memory
|
||||
- RVF container construction adds ~2ms latency per share operation
|
||||
|
||||
### Risks
|
||||
|
||||
- `DiffPrivacyEngine` uses thread-local RNG — `parking_lot::Mutex` serializes access; acceptable at current QPS
|
||||
- `NegativeCache` false positives could block legitimate queries if threshold is too low — start with `threshold=5`
|
||||
- PII stripping regex rules may be too aggressive for some content types — monitor false positive rate via `RedactionLog`
|
||||
|
||||
## 9. Related ADRs
|
||||
|
||||
| ADR | Relationship |
|
||||
|-----|-------------|
|
||||
| ADR-029 | RVF Canonical Format — wire format specification |
|
||||
| ADR-057 | Federated RVF Transfer Learning — federation protocol |
|
||||
| ADR-058 | Hash Security Optimization — SHAKE-256 content hashing used by witness chains |
|
||||
| ADR-059 | Shared Brain Google Cloud — infrastructure, deployment, module migration map |
|
||||
| ADR-060 | Shared Brain Capabilities — 10-segment container layout, threat model |
|
||||
| ADR-068 | Domain Expansion Transfer Learning — meta-learning engine |
|
||||
| ADR-074 | RuvLLM Neural Embeddings — embedding engine |
|
||||
| ADR-076 | AGI Capability Wiring Architecture — Phase 8 architecture decisions |
|
||||
174
docs/adr/ADR-076-agi-capability-wiring-architecture.md
Normal file
174
docs/adr/ADR-076-agi-capability-wiring-architecture.md
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
# ADR-076: AGI Capability Wiring Architecture
|
||||
|
||||
**Status**: Implemented
|
||||
**Date**: 2026-03-03
|
||||
**Authors**: RuVector Team
|
||||
**Deciders**: ruv
|
||||
**Supersedes**: N/A
|
||||
**Related**: ADR-075 (RVF AGI Stack Brain Integration), ADR-068 (Domain Expansion Transfer Learning), ADR-074 (RuvLLM Neural Embeddings)
|
||||
|
||||
## 1. Context
|
||||
|
||||
The mcp-brain-server at pi.ruv.io had four AGI subsystem crates available in the workspace but minimally integrated:
|
||||
|
||||
- **SONA** (`sona`): 3-tier hierarchical learning engine with pattern detection and trajectory tracking
|
||||
- **Global Workspace Theory** (`ruvector-nervous-system`): Salience-based attention competition inspired by cognitive GWT
|
||||
- **Temporal Delta Tracking** (`ruvector-delta-core`): Time-series delta streams for tracking embedding evolution
|
||||
- **Meta-Learning Exploration** (`ruvector-domain-expansion`): Thompson Sampling meta-learning with curiosity, regret, and plateau detection
|
||||
|
||||
Each crate had comprehensive unit tests but no integration with the live brain server. The `DomainExpansionEngine` was in `AppState` but only touched by the transfer endpoint. The other three subsystems were not in AppState at all.
|
||||
|
||||
## 2. Decision
|
||||
|
||||
Wire all four AGI subsystems into the brain server's core handlers (`share_memory`, `search_memories`, `vote_memory`, `status`) with independent feature flags for gradual rollout. Each subsystem adds a distinct cognitive capability without disrupting existing search/ranking behavior.
|
||||
|
||||
### 2.1 Architecture Principle: Additive Scoring Layers
|
||||
|
||||
Each AGI subsystem contributes a small additive score adjustment to the existing hybrid ranking pipeline:
|
||||
|
||||
```
|
||||
Base score: keyword_boost + cosine_similarity + graph_ppr + reputation + vote_quality
|
||||
+ SONA pattern boost: cosine(mem, pattern) * quality * 0.15
|
||||
+ GWT attention boost: +0.1 for workspace competition winners
|
||||
+ Meta curiosity boost: novelty_score * 0.05
|
||||
```
|
||||
|
||||
The small coefficients (0.05-0.15) ensure no single subsystem can dominate ranking, while still providing measurable signal from each capability.
|
||||
|
||||
### 2.2 Architecture Principle: Read-Lock Scoring, Write-Lock Learning
|
||||
|
||||
All scoring operations (search) use read locks on AGI state. Learning operations (share, vote) use write locks. This minimizes contention:
|
||||
|
||||
| Operation | SONA | GWT | DeltaStream | MetaLearning |
|
||||
|-----------|------|-----|-------------|--------------|
|
||||
| search (score) | read | write (compete) | - | read |
|
||||
| share (learn) | - | - | write | write |
|
||||
| vote (learn) | - | - | write | write |
|
||||
| status (report) | read | read | read | read |
|
||||
|
||||
GWT is the exception: `compete()` mutates workspace state during search. This is intentional — attention competition is inherently stateful.
|
||||
|
||||
### 2.3 Architecture Principle: Feature-Gated with Defaults On
|
||||
|
||||
All four subsystems default to enabled. This is safe because:
|
||||
|
||||
1. Score contributions are small (0.05-0.15) and additive
|
||||
2. Each subsystem starts with no learned state (cold start = no effect)
|
||||
3. Feature flags allow instant disable without redeployment via env vars
|
||||
4. Subsystems learn passively from existing traffic — no active exploration that could degrade quality
|
||||
|
||||
### 2.4 Handler Integration Map
|
||||
|
||||
```
|
||||
share_memory() flow:
|
||||
1. [existing] PII strip, embed, witness chain, RVF container
|
||||
2. [Phase 8] Push VectorDelta to DeltaStream (temporal)
|
||||
3. [Phase 8] Record "contribute" decision in MetaLearningEngine
|
||||
4. [existing] Add to graph, store in Firestore
|
||||
|
||||
search_memories() flow:
|
||||
1. [existing] Embed query, fetch candidates, keyword+cosine scoring
|
||||
2. [existing] RankingEngine attention adjustments
|
||||
3. [Phase 8] GWT salience competition (broadcast → compete → boost winners)
|
||||
4. [Phase 8] SONA pattern re-ranking (centroid similarity × quality)
|
||||
5. [Phase 8] Meta-learning curiosity bonus (novelty_score × 0.05)
|
||||
6. [existing] Truncate to limit
|
||||
7. [Phase 8] SONA trajectory recording (search→result for online learning)
|
||||
|
||||
vote_memory() flow:
|
||||
1. [existing] Quality update, reputation, poisoning check
|
||||
2. [Phase 8] Push vote delta to DeltaStream (temporal)
|
||||
3. [Phase 8] Feed vote as reward signal to MetaLearningEngine
|
||||
4. [existing] Record contribution
|
||||
```
|
||||
|
||||
## 3. New Endpoints
|
||||
|
||||
### GET /v1/sona/stats
|
||||
|
||||
Returns SONA learning engine statistics:
|
||||
```json
|
||||
{
|
||||
"patterns_stored": 12,
|
||||
"trajectories_buffered": 45,
|
||||
"background_ticks": 3
|
||||
}
|
||||
```
|
||||
|
||||
### GET /v1/temporal
|
||||
|
||||
Returns temporal delta tracking statistics:
|
||||
```json
|
||||
{
|
||||
"total_deltas": 237,
|
||||
"recent_hour_deltas": 14,
|
||||
"knowledge_velocity": 14.0,
|
||||
"trend": "growing"
|
||||
}
|
||||
```
|
||||
|
||||
### GET /v1/explore
|
||||
|
||||
Returns meta-learning exploration diagnostics:
|
||||
```json
|
||||
{
|
||||
"most_curious_category": "security",
|
||||
"most_curious_novelty": 0.92,
|
||||
"regret_summary": {
|
||||
"total_regret": 0.0,
|
||||
"average_regret": 0.0,
|
||||
"mean_growth_rate": 1.0,
|
||||
"converged_buckets": 0,
|
||||
"bucket_count": 0,
|
||||
"total_observations": 0
|
||||
},
|
||||
"plateau_status": "learning",
|
||||
"is_learning": false,
|
||||
"is_diverse": false,
|
||||
"is_exploring": false,
|
||||
"curiosity_total_visits": 0,
|
||||
"pareto_size": 0
|
||||
}
|
||||
```
|
||||
|
||||
## 4. AppState Additions
|
||||
|
||||
| Field | Type | Subsystem |
|
||||
|-------|------|-----------|
|
||||
| `sona` | `Arc<RwLock<SonaEngine>>` | SONA 3-tier learning |
|
||||
| `workspace` | `Arc<RwLock<GlobalWorkspace>>` | GWT attention |
|
||||
| `delta_stream` | `Arc<RwLock<DeltaStream<VectorDelta>>>` | Temporal tracking |
|
||||
| `domain_engine` | `Arc<RwLock<DomainExpansionEngine>>` | Meta-learning (pre-existing) |
|
||||
|
||||
## 5. Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Brain server now has four distinct AGI learning capabilities operating in production
|
||||
- Search ranking benefits from multi-signal fusion: patterns, attention, curiosity, keywords
|
||||
- Knowledge evolution is tracked over time, enabling trend detection and velocity monitoring
|
||||
- Meta-learning provides self-diagnostic capabilities (regret, plateau, Pareto optimization)
|
||||
- All capabilities are feature-gated for safe gradual rollout
|
||||
- Cold-start behavior is neutral (no learned state = no effect on ranking)
|
||||
|
||||
### Negative
|
||||
|
||||
- Four additional read/write locks in the search path increase contention potential
|
||||
- GWT workspace mutation during search is a sequential bottleneck
|
||||
- Each subsystem adds ~1-5ms to search latency (total ~5-15ms)
|
||||
- Memory footprint increases by ~2-8MB for AGI state (patterns, workspace, delta stream)
|
||||
|
||||
### Risks
|
||||
|
||||
- SONA pattern learning may create feedback loops (popular patterns get more popular)
|
||||
- GWT K-WTA competition with small candidate sets may not produce meaningful selection
|
||||
- Meta-learning curiosity bonus may be too small (0.05) to noticeably affect ranking
|
||||
- Temporal delta stream grows unbounded without periodic compaction — needs future cleanup
|
||||
|
||||
## 6. Verification
|
||||
|
||||
1. `cargo check` from `crates/mcp-brain-server/` compiles with zero errors
|
||||
2. All Phase 1-7 tests continue to pass
|
||||
3. `/v1/status` returns new fields: `sona_patterns`, `gwt_workspace_load`, `knowledge_velocity`, `meta_avg_regret`, `meta_plateau_status`
|
||||
4. `/v1/explore`, `/v1/temporal`, `/v1/sona/stats` return valid JSON
|
||||
5. Feature flags disable each subsystem independently without affecting others
|
||||
914
docs/adr/ADR-077-midstream-brain-integration.md
Normal file
914
docs/adr/ADR-077-midstream-brain-integration.md
Normal file
|
|
@ -0,0 +1,914 @@
|
|||
# ADR-077: Midstream Platform Integration into mcp-brain-server
|
||||
|
||||
**Status**: Proposed
|
||||
**Date**: 2026-03-03
|
||||
**Authors**: RuVector Team
|
||||
**Deciders**: ruv
|
||||
**Supersedes**: N/A
|
||||
**Related**: ADR-076 (AGI Capability Wiring Architecture), ADR-075 (RVF AGI Stack Brain Integration), ADR-068 (Domain Expansion Transfer Learning)
|
||||
|
||||
## 1. Context
|
||||
|
||||
The mcp-brain-server at pi.ruv.io is a production axum REST API on Cloud Run with 238+ shared memories, Phase 8 AGI subsystems (SONA, GWT, DeltaStream, DomainExpansionEngine), an RVF security stack, and a hybrid keyword+cosine+graph+reputation scoring pipeline. Current read latency is 60-80ms and write latency is 150ms at 40-concurrent p90=172ms.
|
||||
|
||||
Despite this sophistication, several capability gaps remain:
|
||||
|
||||
1. **No temporal pattern matching on embedding evolution**. The DeltaStream records embedding deltas over time, but there is no way to find memories whose embedding trajectories are *similar* to each other. Two knowledge nodes may evolve the same way (both drifting toward security topics, for instance) but the system cannot detect this.
|
||||
|
||||
2. **No deadline-aware background task scheduling**. AGI subsystems (SONA tick, GWT decay, delta compaction) run ad-hoc inside request handlers or on status checks. There is no priority-based scheduler guaranteeing that critical maintenance (e.g., compaction before Firestore timeout) completes before less urgent work (e.g., pattern re-indexing).
|
||||
|
||||
3. **No dynamical systems analysis of knowledge drift**. The DriftMonitor computes coefficient-of-variation but cannot classify whether the knowledge graph is converging to a stable attractor, oscillating in a limit cycle, or exhibiting chaotic drift. Lyapunov exponent analysis would provide this.
|
||||
|
||||
4. **No formal invariant verification**. Memory operations have implicit invariants (witness chain integrity, embedding dimension consistency, quality score monotonicity under upvotes) that are checked procedurally. Linear Temporal Logic (LTL) verification would provide formal guarantees.
|
||||
|
||||
5. **No recursive meta-cognition with safety bounds**. The DomainExpansionEngine does Thompson Sampling meta-learning but cannot recursively reason about its own learning strategy. Self-modification (changing exploration parameters based on accumulated regret) is manually tuned.
|
||||
|
||||
6. **No high-performance brain-to-brain transport**. Federation currently uses HTTP/1.1 REST via reqwest. A future multi-brain mesh needs multiplexed, 0-RTT transport for real-time knowledge synchronization.
|
||||
|
||||
The [midstream platform](https://github.com/ruvnet/midstream) provides six crates that address each gap precisely.
|
||||
|
||||
## 2. Decision
|
||||
|
||||
Integrate all six midstream crates into mcp-brain-server in a phased rollout, each behind an independent feature flag (env var). Every crate maps to a specific gap identified in Section 1 and wires into existing handlers without disrupting the current scoring pipeline.
|
||||
|
||||
### 2.1 Crate-to-Gap Mapping
|
||||
|
||||
| Crate | Gap Addressed | Integration Target |
|
||||
|-------|--------------|-------------------|
|
||||
| `temporal-compare` | Embedding evolution similarity | `search_memories()` DTW-based trajectory matching |
|
||||
| `nanosecond-scheduler` | Background task scheduling | SONA tick, GWT decay, delta compaction, LTL checks |
|
||||
| `temporal-attractor-studio` | Dynamical systems drift analysis | Knowledge drift classification (chaotic vs stable) |
|
||||
| `temporal-neural-solver` | Formal invariant verification | LTL properties on memory operations |
|
||||
| `strange-loop` | Recursive meta-cognition | Self-modifying meta-learning atop DomainExpansionEngine |
|
||||
| `quic-multistream` | Brain-to-brain federation | Future multi-brain mesh transport |
|
||||
|
||||
### 2.2 Architecture Principle: Layered Composition
|
||||
|
||||
Midstream crates compose *on top of* existing AGI subsystems rather than replacing them:
|
||||
|
||||
```
|
||||
Layer 4 (Midstream Meta): strange-loop (recursive self-improvement)
|
||||
Layer 3 (Midstream Analysis): temporal-attractor-studio + temporal-neural-solver
|
||||
Layer 2 (Midstream Infra): nanosecond-scheduler + temporal-compare
|
||||
Layer 1 (Phase 8 AGI): SONA + GWT + DeltaStream + DomainExpansionEngine
|
||||
Layer 0 (Core): Firestore + KnowledgeGraph + RVF + Embeddings
|
||||
```
|
||||
|
||||
No midstream crate touches Layer 0 directly. All access goes through Layer 1 state via `AppState`.
|
||||
|
||||
### 2.3 Architecture Principle: Additive Scoring with Bounded Coefficients
|
||||
|
||||
Following ADR-076's precedent, midstream scoring signals are small and additive:
|
||||
|
||||
```
|
||||
Existing scoring pipeline (unchanged):
|
||||
keyword_boost + cosine_sim + graph_ppr + reputation + vote_quality
|
||||
+ SONA pattern boost (0.15)
|
||||
+ GWT attention boost (0.1)
|
||||
+ Meta curiosity boost (0.05)
|
||||
|
||||
New midstream additions:
|
||||
+ temporal_compare DTW similarity boost: dtw_score * 0.08
|
||||
+ attractor stability boost: stability_bonus * 0.03
|
||||
+ strange_loop confidence boost: confidence * 0.02
|
||||
```
|
||||
|
||||
Total midstream contribution is bounded to 0.13, well below the existing AGI layer total of 0.30. This ensures midstream can never dominate ranking.
|
||||
|
||||
### 2.4 Architecture Principle: Scheduler-Mediated Background Work
|
||||
|
||||
All periodic background tasks (previously scattered across handlers) consolidate under `nanosecond-scheduler`:
|
||||
|
||||
| Task | Priority | Deadline | Current Location | New Location |
|
||||
|------|----------|----------|-----------------|-------------|
|
||||
| SONA tick | Medium(50) | 50ms | `status()` handler | Scheduler (10s interval) |
|
||||
| GWT decay/compete | Medium(50) | 20ms | `search_memories()` | Scheduler (5s interval) |
|
||||
| DeltaStream compaction | Low(25) | 200ms | Never (unbounded growth) | Scheduler (60s interval) |
|
||||
| LTL invariant checks | Low(25) | 100ms | Never | Scheduler (30s interval) |
|
||||
| Attractor analysis | Background(10) | 500ms | Never | Scheduler (120s interval) |
|
||||
| Strange-loop reflection | Background(10) | 1000ms | Never | Scheduler (300s interval) |
|
||||
|
||||
This removes AGI maintenance work from the request hot path, directly reducing read latency.
|
||||
|
||||
## 3. Handler Integration Map
|
||||
|
||||
### 3.1 share_memory() Flow (Write Path)
|
||||
|
||||
```
|
||||
share_memory() flow:
|
||||
1. [existing] PII strip, embed, witness chain, RVF container
|
||||
2. [existing] Push VectorDelta to DeltaStream
|
||||
3. [existing] Record "contribute" decision in MetaLearningEngine
|
||||
4. [NEW: Phase 9a] Append embedding to TemporalComparator trajectory buffer
|
||||
5. [NEW: Phase 9c] Push PhasePoint to AttractorAnalyzer (category-scoped)
|
||||
6. [NEW: Phase 9d] Add state to TemporalNeuralSolver (witness_valid, dim_correct)
|
||||
7. [existing] Add to graph, store in Firestore
|
||||
```
|
||||
|
||||
Steps 4-6 are non-blocking appends to in-memory buffers. No additional latency on the write path.
|
||||
|
||||
### 3.2 search_memories() Flow (Read Path)
|
||||
|
||||
```
|
||||
search_memories() flow:
|
||||
1. [existing] Embed query, fetch candidates, keyword+cosine scoring
|
||||
2. [existing] RankingEngine attention adjustments
|
||||
3. [existing] GWT salience competition
|
||||
4. [existing] SONA pattern re-ranking
|
||||
5. [existing] Meta-learning curiosity bonus
|
||||
6. [NEW: Phase 9a] Temporal trajectory similarity boost (DTW)
|
||||
7. [NEW: Phase 9c] Attractor stability boost
|
||||
8. [NEW: Phase 9e] Strange-loop confidence boost
|
||||
9. [existing] Final sort, truncate to limit
|
||||
10. [existing] SONA trajectory recording
|
||||
```
|
||||
|
||||
Steps 6-8 use read locks only and add bounded scoring signals.
|
||||
|
||||
### 3.3 vote_memory() Flow
|
||||
|
||||
```
|
||||
vote_memory() flow:
|
||||
1. [existing] Quality update, reputation, poisoning check
|
||||
2. [existing] Push vote delta to DeltaStream
|
||||
3. [existing] Feed vote as reward to MetaLearningEngine
|
||||
4. [NEW: Phase 9e] Feed vote as reward signal to StrangeLoop (meta-level feedback)
|
||||
5. [NEW: Phase 9d] Record vote_quality_increased proposition in LTL solver
|
||||
```
|
||||
|
||||
Steps 4-5 are fire-and-forget writes to in-memory state.
|
||||
|
||||
### 3.4 status() Flow
|
||||
|
||||
```
|
||||
status() flow:
|
||||
1. [existing] Graph stats, quality, drift, DP, SONA, GWT, temporal, meta stats
|
||||
2. [NEW: Phase 9b] Scheduler stats (tasks completed, missed deadlines, avg latency)
|
||||
3. [NEW: Phase 9c] Attractor classification (point/limit_cycle/strange) per category
|
||||
4. [NEW: Phase 9d] LTL invariant health (properties verified, violations detected)
|
||||
5. [NEW: Phase 9e] Strange-loop meta-knowledge summary (depth, confidence)
|
||||
6. [NEW: Phase 9f] Federation transport stats (if quic-multistream active)
|
||||
```
|
||||
|
||||
## 4. AppState Additions
|
||||
|
||||
| Field | Type | Crate | Description |
|
||||
|-------|------|-------|-------------|
|
||||
| `temporal_comparator` | `Arc<RwLock<temporal_compare::TemporalComparator<f32>>>` | temporal-compare | DTW/LCS/EditDistance on embedding sequences |
|
||||
| `scheduler` | `Arc<RwLock<nanosecond_scheduler::RealtimeScheduler<SchedulerPayload>>>` | nanosecond-scheduler | Priority-based background task executor |
|
||||
| `attractor_analyzers` | `Arc<RwLock<HashMap<String, temporal_attractor_studio::AttractorAnalyzer>>>` | temporal-attractor-studio | Per-category dynamical systems analysis |
|
||||
| `ltl_solver` | `Arc<RwLock<temporal_neural_solver::TemporalNeuralSolver>>` | temporal-neural-solver | LTL property verification engine |
|
||||
| `strange_loop` | `Arc<RwLock<strange_loop::StrangeLoop>>` | strange-loop | Meta-cognitive recursive learning |
|
||||
| `quic_stats` | `Arc<RwLock<Option<QuicFederationStats>>>` | quic-multistream | Federation transport statistics (Phase 9f only) |
|
||||
|
||||
### 4.1 Scheduler Payload Type
|
||||
|
||||
```rust
|
||||
/// Payload for scheduled background tasks.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SchedulerPayload {
|
||||
SonaTick,
|
||||
GwtDecay,
|
||||
DeltaCompact { max_entries: usize },
|
||||
LtlCheck,
|
||||
AttractorAnalyze { category: String },
|
||||
StrangeLoopReflect,
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 QuicFederationStats Type
|
||||
|
||||
```rust
|
||||
/// Statistics from quic-multistream federation transport.
|
||||
#[derive(Debug, Clone, Default, Serialize)]
|
||||
pub struct QuicFederationStats {
|
||||
pub active_peers: usize,
|
||||
pub active_streams: usize,
|
||||
pub bytes_sent: u64,
|
||||
pub bytes_received: u64,
|
||||
pub avg_rtt_ms: f64,
|
||||
}
|
||||
```
|
||||
|
||||
## 5. New Endpoints
|
||||
|
||||
### GET /v1/temporal/trajectories
|
||||
|
||||
Returns temporal trajectory similarity analysis for a given memory.
|
||||
|
||||
**Query Parameters**:
|
||||
- `memory_id` (required): UUID of the memory to find similar trajectories for
|
||||
- `algorithm` (optional): `dtw` | `lcs` | `edit_distance` (default: `dtw`)
|
||||
- `threshold` (optional): minimum similarity (default: 0.7)
|
||||
- `limit` (optional): max results (default: 5)
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"query_memory_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"algorithm": "dtw",
|
||||
"similar_trajectories": [
|
||||
{
|
||||
"memory_id": "660e8400-e29b-41d4-a716-446655440001",
|
||||
"distance": 0.12,
|
||||
"similarity": 0.88,
|
||||
"alignment_length": 15
|
||||
}
|
||||
],
|
||||
"recurring_patterns": [
|
||||
{
|
||||
"pattern_length": 8,
|
||||
"occurrences": 3,
|
||||
"confidence": 0.92
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### GET /v1/scheduler/stats
|
||||
|
||||
Returns background task scheduler statistics.
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"total_scheduled": 1247,
|
||||
"total_completed": 1240,
|
||||
"missed_deadlines": 2,
|
||||
"avg_latency_ns": 45000,
|
||||
"tasks_by_priority": {
|
||||
"critical": 0,
|
||||
"high": 12,
|
||||
"medium": 820,
|
||||
"low": 408,
|
||||
"background": 7
|
||||
},
|
||||
"next_deadline_ms": 4200
|
||||
}
|
||||
```
|
||||
|
||||
### GET /v1/attractor
|
||||
|
||||
Returns dynamical systems analysis of knowledge drift per category.
|
||||
|
||||
**Query Parameters**:
|
||||
- `category` (optional): specific category to analyze (default: all)
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"categories": {
|
||||
"architecture": {
|
||||
"attractor_type": "point_attractor",
|
||||
"lyapunov_exponents": [-0.32, -0.18, -0.05],
|
||||
"is_stable": true,
|
||||
"is_chaotic": false,
|
||||
"confidence": 0.94,
|
||||
"trajectory_length": 45,
|
||||
"mean_velocity": 0.023
|
||||
},
|
||||
"security": {
|
||||
"attractor_type": "strange_attractor",
|
||||
"lyapunov_exponents": [0.12, -0.04, -0.21],
|
||||
"is_stable": false,
|
||||
"is_chaotic": true,
|
||||
"confidence": 0.78,
|
||||
"trajectory_length": 32,
|
||||
"mean_velocity": 0.089
|
||||
}
|
||||
},
|
||||
"global_stability": "mixed"
|
||||
}
|
||||
```
|
||||
|
||||
### GET /v1/invariants
|
||||
|
||||
Returns LTL invariant verification status.
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"properties_defined": 5,
|
||||
"properties_verified": 4,
|
||||
"violations": [
|
||||
{
|
||||
"property": "globally(embedding_dim_128)",
|
||||
"satisfied": false,
|
||||
"confidence": 0.99,
|
||||
"counterexample": "memory abc123 has dim=64 at state 17"
|
||||
}
|
||||
],
|
||||
"last_check_ms": 12,
|
||||
"total_states_checked": 238
|
||||
}
|
||||
```
|
||||
|
||||
### GET /v1/meta/strange-loop
|
||||
|
||||
Returns strange-loop meta-cognitive status.
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"meta_depth": 2,
|
||||
"knowledge_items": 7,
|
||||
"top_patterns": [
|
||||
{
|
||||
"level": 1,
|
||||
"pattern": "security_memories_converge_faster",
|
||||
"confidence": 0.87,
|
||||
"applications": ["adjust_curiosity_weight", "increase_security_exploration"]
|
||||
}
|
||||
],
|
||||
"self_modifications_applied": 3,
|
||||
"safety_constraints_active": 2,
|
||||
"is_self_modification_enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
### GET /v1/federation/stats
|
||||
|
||||
Returns QUIC federation transport statistics (Phase 9f only).
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"transport": "quic-h3",
|
||||
"active_peers": 0,
|
||||
"active_streams": 0,
|
||||
"bytes_sent": 0,
|
||||
"bytes_received": 0,
|
||||
"avg_rtt_ms": 0.0,
|
||||
"zero_rtt_supported": true,
|
||||
"status": "standby"
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Feature Gating
|
||||
|
||||
All midstream subsystems are gated by environment variables, read once at startup via `RvfFeatureFlags::from_env()`. All default to disabled (opt-in) because midstream crates add new dependencies and should be validated incrementally.
|
||||
|
||||
| Env Var | Default | Controls |
|
||||
|---------|---------|----------|
|
||||
| `MIDSTREAM_TEMPORAL_COMPARE` | `false` | DTW trajectory matching in search |
|
||||
| `MIDSTREAM_SCHEDULER` | `false` | Background task scheduler |
|
||||
| `MIDSTREAM_ATTRACTOR` | `false` | Dynamical systems drift analysis |
|
||||
| `MIDSTREAM_LTL` | `false` | LTL invariant verification |
|
||||
| `MIDSTREAM_STRANGE_LOOP` | `false` | Recursive meta-cognition |
|
||||
| `MIDSTREAM_QUIC` | `false` | QUIC federation transport |
|
||||
|
||||
### 6.1 RvfFeatureFlags Additions
|
||||
|
||||
```rust
|
||||
// Add to existing RvfFeatureFlags struct:
|
||||
pub midstream_temporal_compare: bool,
|
||||
pub midstream_scheduler: bool,
|
||||
pub midstream_attractor: bool,
|
||||
pub midstream_ltl: bool,
|
||||
pub midstream_strange_loop: bool,
|
||||
pub midstream_quic: bool,
|
||||
```
|
||||
|
||||
```rust
|
||||
// Add to from_env():
|
||||
midstream_temporal_compare: std::env::var("MIDSTREAM_TEMPORAL_COMPARE")
|
||||
.map(|v| v == "true" || v == "1")
|
||||
.unwrap_or(false),
|
||||
midstream_scheduler: std::env::var("MIDSTREAM_SCHEDULER")
|
||||
.map(|v| v == "true" || v == "1")
|
||||
.unwrap_or(false),
|
||||
midstream_attractor: std::env::var("MIDSTREAM_ATTRACTOR")
|
||||
.map(|v| v == "true" || v == "1")
|
||||
.unwrap_or(false),
|
||||
midstream_ltl: std::env::var("MIDSTREAM_LTL")
|
||||
.map(|v| v == "true" || v == "1")
|
||||
.unwrap_or(false),
|
||||
midstream_strange_loop: std::env::var("MIDSTREAM_STRANGE_LOOP")
|
||||
.map(|v| v == "true" || v == "1")
|
||||
.unwrap_or(false),
|
||||
midstream_quic: std::env::var("MIDSTREAM_QUIC")
|
||||
.map(|v| v == "true" || v == "1")
|
||||
.unwrap_or(false),
|
||||
```
|
||||
|
||||
### 6.2 Gradual Rollout Strategy
|
||||
|
||||
1. Deploy with all flags `false` -- baseline performance unchanged
|
||||
2. Enable `MIDSTREAM_SCHEDULER=true` first (moves AGI maintenance off hot path)
|
||||
3. Enable `MIDSTREAM_TEMPORAL_COMPARE=true` (new search signal)
|
||||
4. Enable `MIDSTREAM_ATTRACTOR=true` (drift analysis)
|
||||
5. Enable `MIDSTREAM_LTL=true` (invariant checking)
|
||||
6. Enable `MIDSTREAM_STRANGE_LOOP=true` (meta-cognition)
|
||||
7. Enable `MIDSTREAM_QUIC=true` only when peer brains exist
|
||||
|
||||
## 7. Scoring Pipeline Update
|
||||
|
||||
The full scoring pipeline after midstream integration, showing exact order and coefficients:
|
||||
|
||||
```
|
||||
search_memories() scoring pipeline:
|
||||
|
||||
1. Base hybrid score (existing, unchanged):
|
||||
IF keyword_match:
|
||||
score = 1.0 + keyword_boost * 0.85 + vec_sim * 0.05
|
||||
+ graph_ppr * 0.04 + reputation * 0.03 + vote_boost * 0.03
|
||||
ELSE:
|
||||
score = vec_sim * 0.45 + graph_ppr * 0.25
|
||||
+ reputation * 0.15 + vote_boost * 0.15
|
||||
|
||||
2. RankingEngine adjustments (existing, unchanged):
|
||||
score = similarity_weight(0.85) * score
|
||||
+ quality_weight(0.10) * quality_mean
|
||||
+ recency_weight(0.05) * recency_factor
|
||||
|
||||
3. GWT attention (existing, unchanged):
|
||||
score += 0.10 for workspace competition winners
|
||||
score += sparse_activation * 0.05 for K-WTA
|
||||
|
||||
4. SONA pattern (existing, unchanged):
|
||||
score += avg(cosine(mem, pattern) * pattern_quality) * 0.15
|
||||
|
||||
5. Meta-learning curiosity (existing, unchanged):
|
||||
score += novelty_score * 0.05
|
||||
|
||||
6. [NEW] Temporal trajectory similarity (Phase 9a):
|
||||
IF MIDSTREAM_TEMPORAL_COMPARE enabled AND query has temporal context:
|
||||
dtw_score = temporal_comparator.find_similar(query_trajectory, threshold=0.7)
|
||||
score += dtw_score * 0.08 for memories with similar evolution paths
|
||||
|
||||
7. [NEW] Attractor stability bonus (Phase 9c):
|
||||
IF MIDSTREAM_ATTRACTOR enabled:
|
||||
attractor = attractor_analyzers[memory.category].analyze()
|
||||
IF attractor.is_stable AND attractor.confidence > 0.8:
|
||||
score += 0.03 // prefer memories in stable knowledge regions
|
||||
|
||||
8. [NEW] Strange-loop confidence bonus (Phase 9e):
|
||||
IF MIDSTREAM_STRANGE_LOOP enabled:
|
||||
meta_knowledge = strange_loop.learn_at_level(0, query_context)
|
||||
IF any meta_knowledge has confidence > 0.9:
|
||||
score += meta_knowledge.confidence * 0.02
|
||||
|
||||
9. Final sort (single unstable_sort_by descending score)
|
||||
10. Truncate to limit
|
||||
```
|
||||
|
||||
### 7.1 Coefficient Budget
|
||||
|
||||
| Layer | Max Contribution | Source |
|
||||
|-------|-----------------|--------|
|
||||
| Keyword boost | 3.0 | ADR-076 (unchanged) |
|
||||
| Vector similarity | 0.45 | Core scoring |
|
||||
| Graph PPR | 0.25 | Core scoring |
|
||||
| Reputation + votes | 0.30 | Core scoring |
|
||||
| RankingEngine | ~1.0 | Quality/recency blend |
|
||||
| GWT attention | 0.15 | ADR-076 |
|
||||
| SONA patterns | 0.15 | ADR-076 |
|
||||
| Meta curiosity | 0.05 | ADR-076 |
|
||||
| **Temporal compare** | **0.08** | **Phase 9a (new)** |
|
||||
| **Attractor stability** | **0.03** | **Phase 9c (new)** |
|
||||
| **Strange-loop** | **0.02** | **Phase 9e (new)** |
|
||||
| **Midstream total** | **0.13** | **Sum of new layers** |
|
||||
|
||||
## 8. Implementation Phases
|
||||
|
||||
### Phase 9a: temporal-compare Integration (Week 1)
|
||||
|
||||
**Dependency**: None (standalone crate)
|
||||
|
||||
**Wiring**:
|
||||
|
||||
1. Add `temporal_comparator: Arc<RwLock<TemporalComparator<f32>>>` to AppState
|
||||
2. Initialize with `TemporalComparator::new(1000, 500)` (cache 1000 comparisons, max 500-step sequences)
|
||||
3. In `share_memory()`: after DeltaStream push, append embedding to per-memory trajectory buffer in comparator
|
||||
4. In `search_memories()`: after SONA re-ranking, use `find_similar_generic()` to boost memories with similar embedding evolution trajectories (DTW distance < 0.3 threshold)
|
||||
5. Add `GET /v1/temporal/trajectories` endpoint
|
||||
6. Gate all with `MIDSTREAM_TEMPORAL_COMPARE` env var
|
||||
|
||||
**Data flow**:
|
||||
```
|
||||
share_memory() -> embedding -> temporal_comparator.trajectory_buffer[memory_id].push(embedding)
|
||||
search_memories() -> query_trajectory -> find_similar_generic(candidates, query, 0.7) -> score += dtw * 0.08
|
||||
```
|
||||
|
||||
**Latency budget**: +3ms read path (DTW on cached trajectories), +0ms write path (buffer append)
|
||||
|
||||
### Phase 9b: nanosecond-scheduler Integration (Week 1-2)
|
||||
|
||||
**Dependency**: None (standalone), but should be enabled before other phases to manage their background tasks
|
||||
|
||||
**Wiring**:
|
||||
|
||||
1. Add `scheduler: Arc<RwLock<RealtimeScheduler<SchedulerPayload>>>` to AppState
|
||||
2. Initialize with EDF (Earliest Deadline First) policy
|
||||
3. Spawn a `tokio::spawn` loop that calls `scheduler.next_task()` and dispatches:
|
||||
- `SonaTick` -> `state.sona.read().tick()`
|
||||
- `GwtDecay` -> `state.workspace.write().compete()`
|
||||
- `DeltaCompact` -> `state.delta_stream.write().compact(max_entries)`
|
||||
- `LtlCheck` -> `state.ltl_solver.write().verify(invariants)`
|
||||
- `AttractorAnalyze` -> `state.attractor_analyzers.write()[cat].analyze()`
|
||||
- `StrangeLoopReflect` -> `state.strange_loop.write().learn_at_level(0, data)`
|
||||
4. Remove SONA tick from `status()` handler (moved to scheduler)
|
||||
5. Add `GET /v1/scheduler/stats` endpoint
|
||||
6. Gate with `MIDSTREAM_SCHEDULER` env var
|
||||
|
||||
**Scheduler task registration at startup**:
|
||||
```rust
|
||||
// Register recurring tasks with appropriate priorities and intervals
|
||||
let mut sched = scheduler.write();
|
||||
sched.schedule(SchedulerPayload::SonaTick, deadline_10s, Priority::Medium(50));
|
||||
sched.schedule(SchedulerPayload::GwtDecay, deadline_5s, Priority::Medium(50));
|
||||
sched.schedule(SchedulerPayload::DeltaCompact { max_entries: 10_000 },
|
||||
deadline_60s, Priority::Low(25));
|
||||
```
|
||||
|
||||
**Latency budget**: -5ms read path (SONA tick removed from status), +0ms (scheduler runs in background task)
|
||||
|
||||
### Phase 9c: temporal-attractor-studio Integration (Week 2)
|
||||
|
||||
**Dependency**: Phase 9b (scheduler runs attractor analysis in background)
|
||||
|
||||
**Wiring**:
|
||||
|
||||
1. Add `attractor_analyzers: Arc<RwLock<HashMap<String, AttractorAnalyzer>>>` to AppState
|
||||
2. Initialize one `AttractorAnalyzer::new(128, 1000)` per known category (embedding_dim=128, max_trajectory=1000)
|
||||
3. In `share_memory()`: after DeltaStream push, call `analyzer.add_point(PhasePoint { coordinates: embedding, timestamp })` for the memory's category
|
||||
4. Background (via scheduler): every 120s, call `analyzer.analyze()` for each category to get `AttractorInfo`
|
||||
5. In `search_memories()`: if `attractor.is_stable && confidence > 0.8`, add `+0.03` stability bonus to memories in that category
|
||||
6. In `status()`: report `attractor_type` and `is_chaotic` per category
|
||||
7. Add `GET /v1/attractor` endpoint
|
||||
8. Gate with `MIDSTREAM_ATTRACTOR` env var
|
||||
|
||||
**Attractor classification interpretation**:
|
||||
- `PointAttractor` (all Lyapunov exponents < 0): knowledge is converging -- stable domain, prefer these memories
|
||||
- `LimitCycle` (one exponent = 0, rest < 0): knowledge oscillates -- possibly seasonal patterns
|
||||
- `StrangeAttractor` (at least one exponent > 0): chaotic evolution -- flag for human review, reduce score weight
|
||||
|
||||
**Latency budget**: +1ms read path (HashMap lookup + cached AttractorInfo read), +0ms write path (point append)
|
||||
|
||||
### Phase 9d: temporal-neural-solver Integration (Week 3)
|
||||
|
||||
**Dependency**: Phase 9b (scheduler runs LTL checks in background)
|
||||
|
||||
**Wiring**:
|
||||
|
||||
1. Add `ltl_solver: Arc<RwLock<TemporalNeuralSolver>>` to AppState
|
||||
2. Initialize with `TemporalNeuralSolver::new(10_000, 100, Strictness::Medium)` (max 10K trace, 100ms timeout)
|
||||
3. Define invariant properties at startup:
|
||||
|
||||
```rust
|
||||
use temporal_neural_solver::formula::*;
|
||||
|
||||
// Property 1: Embedding dimension is always 128
|
||||
let dim_128 = globally(atom("embedding_dim_128"));
|
||||
|
||||
// Property 2: Quality score never decreases after upvote
|
||||
let quality_monotone = globally(
|
||||
implies(atom("upvote_applied"), finally(atom("quality_increased")))
|
||||
);
|
||||
|
||||
// Property 3: Witness chain is always present when RVF enabled
|
||||
let witness_present = globally(
|
||||
implies(atom("rvf_enabled"), atom("witness_chain_present"))
|
||||
);
|
||||
|
||||
// Property 4: PII stripping happens before embedding
|
||||
let pii_before_embed = globally(
|
||||
implies(atom("has_pii"), until(atom("pii_stripped"), atom("embedded")))
|
||||
);
|
||||
|
||||
// Property 5: No memory exists in both graph and negative cache
|
||||
let no_blacklisted_in_graph = globally(
|
||||
not(and(atom("in_graph"), atom("in_negative_cache")))
|
||||
);
|
||||
```
|
||||
|
||||
4. In `share_memory()`: push `TemporalState` with propositions `{embedding_dim_128: dim==128, witness_chain_present: chain.is_some(), ...}`
|
||||
5. In `vote_memory()`: push `TemporalState` with `{upvote_applied: true, quality_increased: new > old}`
|
||||
6. Background (via scheduler): every 30s, verify all properties and log violations
|
||||
7. Add `GET /v1/invariants` endpoint
|
||||
8. Gate with `MIDSTREAM_LTL` env var
|
||||
|
||||
**On violation**: log at WARN level, increment violation counter, but do not block operations. LTL verification is observational, not enforcement.
|
||||
|
||||
**Latency budget**: +0ms read path (verification runs in background only), +0.5ms write path (state push)
|
||||
|
||||
### Phase 9e: strange-loop Integration (Week 3-4)
|
||||
|
||||
**Dependency**: Phase 9b (scheduler), Phase 9c (attractor data feeds meta-learning), DomainExpansionEngine (existing)
|
||||
|
||||
**Wiring**:
|
||||
|
||||
1. Add `strange_loop: Arc<RwLock<StrangeLoop>>` to AppState
|
||||
2. Initialize with:
|
||||
|
||||
```rust
|
||||
let config = StrangeLoopConfig {
|
||||
max_meta_depth: 3, // Max 3 levels of recursive reflection
|
||||
enable_self_modification: true,
|
||||
safety_check: true,
|
||||
};
|
||||
let mut sl = StrangeLoop::new(config);
|
||||
|
||||
// Safety constraints
|
||||
sl.add_safety_constraint(always_safe()); // Never produce unsafe state
|
||||
sl.add_safety_constraint(eventually_terminates()); // Always halt
|
||||
// Custom: curiosity weight must stay in [0.01, 0.20]
|
||||
sl.add_safety_constraint(custom_constraint(
|
||||
"curiosity_bounds",
|
||||
|state| state.curiosity_weight >= 0.01 && state.curiosity_weight <= 0.20
|
||||
));
|
||||
// Custom: meta-depth must not exceed config limit
|
||||
sl.add_safety_constraint(custom_constraint(
|
||||
"depth_limit",
|
||||
|state| state.current_depth <= 3
|
||||
));
|
||||
```
|
||||
|
||||
3. Background (via scheduler): every 300s, run `learn_at_level(0, trajectory_data)` where trajectory_data comes from:
|
||||
- SONA trajectory stats (patterns found, quality distribution)
|
||||
- DomainExpansion regret history (per-category regret)
|
||||
- Attractor stability classification (from Phase 9c)
|
||||
|
||||
4. Meta-knowledge application: when `MetaKnowledge` items have confidence > 0.9, apply modifications:
|
||||
- `adjust_curiosity_weight(delta)` -> modify DomainExpansionEngine's exploration factor
|
||||
- `adjust_sona_pattern_boost(delta)` -> modify SONA scoring coefficient (0.15 +/- 0.03)
|
||||
- `flag_category_for_review(category)` -> mark chaotic categories for human attention
|
||||
|
||||
5. In `search_memories()`: if strange-loop has high-confidence meta-knowledge about the query category, add `confidence * 0.02` boost
|
||||
|
||||
6. In `vote_memory()`: feed vote as reward to strange-loop at level 0 (meta-reward: "did the meta-knowledge improve search quality?")
|
||||
|
||||
7. Add `GET /v1/meta/strange-loop` endpoint
|
||||
8. Gate with `MIDSTREAM_STRANGE_LOOP` env var
|
||||
|
||||
**Self-modification bounds** (enforced by safety constraints):
|
||||
- Curiosity weight: [0.01, 0.20] (cannot disable exploration or make it dominate)
|
||||
- SONA pattern boost: [0.05, 0.25] (cannot disable patterns or make them dominate)
|
||||
- Meta-depth: maximum 3 (prevents infinite recursion)
|
||||
- Modification rate: maximum 1 modification per 300s cycle (prevents oscillation)
|
||||
|
||||
**Latency budget**: +1ms read path (cached meta-knowledge lookup), +0ms write path (async reward recording)
|
||||
|
||||
### Phase 9f: quic-multistream Integration (Week 4-5, Future)
|
||||
|
||||
**Dependency**: All prior phases (federation transports the full brain state including midstream data)
|
||||
|
||||
**Wiring**:
|
||||
|
||||
1. Add `quic_stats: Arc<RwLock<Option<QuicFederationStats>>>` to AppState
|
||||
2. When `MIDSTREAM_QUIC=true`, initialize QUIC listener alongside the existing HTTP server:
|
||||
|
||||
```rust
|
||||
// Separate listener for QUIC federation (port 4433 default)
|
||||
let quic_port: u16 = std::env::var("QUIC_PORT")
|
||||
.unwrap_or_else(|_| "4433".to_string())
|
||||
.parse()?;
|
||||
```
|
||||
|
||||
3. Define stream priority mapping:
|
||||
- `Critical`: Memory write replication (consistency)
|
||||
- `High`: Search query federation (latency-sensitive)
|
||||
- `Normal`: Vote synchronization
|
||||
- `Low`: Background state sync (attractor data, meta-knowledge)
|
||||
|
||||
4. 0-RTT for returning peer brains (session resumption avoids TLS handshake on reconnect)
|
||||
|
||||
5. Multiplexed bi-directional streams: each memory category gets its own stream for parallel sync
|
||||
|
||||
6. In `status()`: report `quic_stats` if transport is active
|
||||
|
||||
7. Add `GET /v1/federation/stats` endpoint
|
||||
|
||||
8. Gate with `MIDSTREAM_QUIC` env var
|
||||
|
||||
**Latency budget**: +0ms for existing endpoints (QUIC runs on separate port/task), +2ms for federated search (0-RTT + multiplexing)
|
||||
|
||||
## 9. Cargo.toml Changes
|
||||
|
||||
Add to `crates/mcp-brain-server/Cargo.toml` under `[dependencies]`:
|
||||
|
||||
```toml
|
||||
# Midstream Platform (ADR-077)
|
||||
temporal-compare = "0.1"
|
||||
nanosecond-scheduler = "0.1"
|
||||
temporal-attractor-studio = "0.1"
|
||||
temporal-neural-solver = "0.1"
|
||||
strange-loop = "0.1"
|
||||
quic-multistream = { version = "0.1", optional = true }
|
||||
```
|
||||
|
||||
Add feature flags for optional heavy dependencies:
|
||||
|
||||
```toml
|
||||
[features]
|
||||
default = []
|
||||
quic-federation = ["quic-multistream"]
|
||||
```
|
||||
|
||||
`quic-multistream` is the only optional dependency because it pulls in quinn/rustls which significantly increases compile time and binary size. All other midstream crates are lightweight and always compiled (gated at runtime via env vars).
|
||||
|
||||
## 10. Performance Budget
|
||||
|
||||
### 10.1 Read Path (search_memories) Latency Impact
|
||||
|
||||
| Component | Current | With Midstream | Delta |
|
||||
|-----------|---------|---------------|-------|
|
||||
| Embedding + candidate fetch | 15ms | 15ms | +0ms |
|
||||
| Keyword + cosine scoring | 10ms | 10ms | +0ms |
|
||||
| Graph PPR | 8ms | 8ms | +0ms |
|
||||
| RankingEngine | 2ms | 2ms | +0ms |
|
||||
| GWT attention | 5ms | 5ms (moved to bg) | +0ms |
|
||||
| SONA patterns | 3ms | 3ms | +0ms |
|
||||
| Meta curiosity | 1ms | 1ms | +0ms |
|
||||
| **Temporal compare** | - | **3ms** | **+3ms** |
|
||||
| **Attractor lookup** | - | **1ms** | **+1ms** |
|
||||
| **Strange-loop lookup** | - | **1ms** | **+1ms** |
|
||||
| Sort + truncate | 1ms | 1ms | +0ms |
|
||||
| **Total** | **~45ms** | **~50ms** | **+5ms** |
|
||||
|
||||
Total read latency stays well under the 100ms budget. The scheduler actually reduces worst-case latency by removing SONA tick from the status handler.
|
||||
|
||||
### 10.2 Write Path (share_memory) Latency Impact
|
||||
|
||||
| Component | Current | With Midstream | Delta |
|
||||
|-----------|---------|---------------|-------|
|
||||
| PII + embed + witness + RVF | 80ms | 80ms | +0ms |
|
||||
| DeltaStream push | 0.5ms | 0.5ms | +0ms |
|
||||
| Meta-learning record | 0.5ms | 0.5ms | +0ms |
|
||||
| **Temporal buffer append** | - | **0.1ms** | **+0.1ms** |
|
||||
| **Attractor point push** | - | **0.1ms** | **+0.1ms** |
|
||||
| **LTL state push** | - | **0.5ms** | **+0.5ms** |
|
||||
| Graph + Firestore | 60ms | 60ms | +0ms |
|
||||
| **Total** | **~141ms** | **~142ms** | **+0.7ms** |
|
||||
|
||||
### 10.3 Memory Footprint
|
||||
|
||||
| Component | Estimate | Notes |
|
||||
|-----------|----------|-------|
|
||||
| TemporalComparator | ~2MB | 1000 cached comparisons, 500-step sequences |
|
||||
| RealtimeScheduler | <1MB | Priority queue of ~20 recurring tasks |
|
||||
| AttractorAnalyzers (8 categories) | ~4MB | 8 * 1000-point trajectories * 128-dim |
|
||||
| TemporalNeuralSolver | ~1MB | 10K trace buffer |
|
||||
| StrangeLoop | <1MB | 3 meta-levels, bounded knowledge store |
|
||||
| QuicFederationStats | <1KB | Statistics struct only (transport runs separately) |
|
||||
| **Total** | **~8MB** | On top of existing ~10MB AGI state |
|
||||
|
||||
### 10.4 Background Task CPU Budget
|
||||
|
||||
The scheduler runs all background tasks on a single `tokio::spawn` loop. Worst-case CPU per cycle:
|
||||
|
||||
| Task | Frequency | CPU/cycle | Annual CPU-hours |
|
||||
|------|-----------|-----------|-----------------|
|
||||
| SONA tick | 10s | 2ms | 6.3h |
|
||||
| GWT decay | 5s | 1ms | 6.3h |
|
||||
| Delta compact | 60s | 10ms | 5.3h |
|
||||
| LTL check | 30s | 5ms | 5.3h |
|
||||
| Attractor analyze | 120s | 50ms | 13.1h |
|
||||
| Strange-loop reflect | 300s | 100ms | 10.5h |
|
||||
| **Total** | - | - | **46.8h/year** |
|
||||
|
||||
At Cloud Run pricing ($0.000024/vCPU-second), annual background cost is approximately $4.04.
|
||||
|
||||
## 11. Safety Considerations
|
||||
|
||||
### 11.1 strange-loop Self-Modification Bounds
|
||||
|
||||
The strange-loop crate's self-modification capability is the highest-risk component. The following safety envelope is enforced:
|
||||
|
||||
**Hard limits (cannot be overridden)**:
|
||||
- Maximum meta-depth: 3 levels (prevents infinite recursion)
|
||||
- `always_safe()` constraint: no modification can produce a state where scoring coefficients sum to > 1.0
|
||||
- `eventually_terminates()` constraint: every `learn_at_level()` call must complete within 1000ms
|
||||
|
||||
**Soft limits (can be tuned via env vars)**:
|
||||
- `STRANGE_LOOP_MAX_DEPTH` (default: 3, range: 1-5)
|
||||
- `STRANGE_LOOP_MODIFICATION_RATE` (default: 1 per 300s, range: 1 per 60s to 1 per 3600s)
|
||||
- `STRANGE_LOOP_CURIOSITY_MIN` (default: 0.01)
|
||||
- `STRANGE_LOOP_CURIOSITY_MAX` (default: 0.20)
|
||||
|
||||
**Rollback mechanism**: If a self-modification causes any LTL property (Phase 9d) to fail in the next check cycle, the modification is automatically reverted and the strange-loop's modification capability is suspended for 1 hour (with WARN log).
|
||||
|
||||
### 11.2 LTL Property Definitions
|
||||
|
||||
The five core invariant properties are defined in Section 8 (Phase 9d). Additional properties can be added at runtime via the scheduler, but existing properties cannot be removed without a code change.
|
||||
|
||||
**Verification semantics**: Properties are checked over the *trace buffer* (last 10K state transitions), not over all-time history. This bounds verification time and avoids false positives from pre-midstream state.
|
||||
|
||||
### 11.3 Temporal Compare Privacy
|
||||
|
||||
DTW trajectory matching could theoretically leak information about a contributor's knowledge evolution pattern. Mitigation:
|
||||
- Trajectory buffers are category-scoped, not contributor-scoped
|
||||
- Embeddings in trajectories have already been DP-noised (if `RVF_DP_ENABLED=true`)
|
||||
- The `find_similar_generic` API returns distance scores only, not raw trajectories
|
||||
|
||||
### 11.4 Scheduler Deadline Misses
|
||||
|
||||
If the scheduler misses a deadline (e.g., under high load), the task is still executed but:
|
||||
- `missed_deadlines` counter is incremented (visible in `/v1/scheduler/stats`)
|
||||
- If `missed_deadlines > 10` in any 60s window, emit WARN log
|
||||
- If `missed_deadlines > 50` in any 60s window, disable lowest-priority tasks (Background(10)) for 5 minutes
|
||||
|
||||
## 12. Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Temporal pattern matching** enables discovery of memories with similar evolution trajectories, surfacing non-obvious relationships that cosine similarity alone misses
|
||||
- **Deadline-aware scheduling** removes AGI maintenance work from the request hot path, reducing p99 read latency by ~5ms
|
||||
- **Dynamical systems analysis** upgrades drift monitoring from simple CV thresholds to Lyapunov-exponent-based attractor classification, enabling early detection of chaotic knowledge domains
|
||||
- **LTL verification** provides formal guarantees on system invariants, catching subtle bugs (dimension mismatches, witness chain gaps) that procedural checks might miss
|
||||
- **Recursive meta-cognition** allows the brain server to self-tune exploration and scoring parameters based on accumulated evidence, reducing manual tuning burden
|
||||
- **QUIC transport** (future) enables sub-millisecond brain-to-brain federation with 0-RTT resumption, a prerequisite for real-time multi-brain mesh
|
||||
- **All features are independently feature-gated** with runtime env vars, zero risk to existing behavior when disabled
|
||||
- **Total latency impact of +5ms on reads and +0.7ms on writes** stays well within the 100ms read budget
|
||||
|
||||
### Negative
|
||||
|
||||
- **Six additional crate dependencies** increase compile time by an estimated 30-45 seconds (quic-multistream alone adds ~20s due to quinn/rustls)
|
||||
- **~8MB additional memory footprint** for in-memory trajectory buffers, analyzer state, and solver traces
|
||||
- **Increased operational complexity**: six new env vars, six new endpoints, scheduler monitoring
|
||||
- **strange-loop self-modification** introduces a novel failure mode: if safety constraints are misconfigured, the system could oscillate between parameter settings
|
||||
- **LTL verification has false-positive risk** on edge cases where the trace buffer wraps around and loses historical context
|
||||
|
||||
### Risks
|
||||
|
||||
- **temporal-compare DTW** on high-dimensional (128-dim) sequences may be slower than expected if trajectory lengths grow beyond 100 steps; mitigation: cap max_sequence_length at 500
|
||||
- **nanosecond-scheduler** is designed for sub-microsecond scheduling, but tokio's cooperative scheduling has ~1ms granularity; actual scheduling precision will be millisecond-level, not nanosecond
|
||||
- **temporal-attractor-studio** Lyapunov exponent estimation requires sufficient trajectory data (minimum ~30 points per category); new categories will show `insufficient_data` until populated
|
||||
- **temporal-neural-solver** LTL verification confidence degrades if state transitions are sparse; the 30s check interval may verify the same state repeatedly during low-traffic periods
|
||||
- **strange-loop** meta-learning effectiveness depends on diverse input signals; if traffic is dominated by a single category, meta-knowledge will be narrowly scoped
|
||||
- **quic-multistream** requires TLS certificates for peer authentication; certificate management adds operational burden for multi-brain deployments
|
||||
- **Crate version stability**: all midstream crates are at `0.1.x`; API changes in minor versions could require brain server updates
|
||||
|
||||
## 13. Verification
|
||||
|
||||
### 13.1 Compilation
|
||||
|
||||
1. `cargo check -p mcp-brain-server` compiles with zero errors after adding all six dependencies
|
||||
2. `cargo check -p mcp-brain-server --features quic-federation` compiles with quic-multistream enabled
|
||||
|
||||
### 13.2 Existing Test Regression
|
||||
|
||||
3. All Phase 1-8 tests continue to pass with all midstream flags disabled (default)
|
||||
4. `/v1/status` returns all existing fields unchanged when midstream flags are off
|
||||
|
||||
### 13.3 Feature Flag Isolation
|
||||
|
||||
5. Enabling any single midstream flag does not affect behavior of other subsystems
|
||||
6. Disabling a midstream flag mid-operation (via restart) gracefully drops in-memory state without errors
|
||||
|
||||
### 13.4 New Endpoint Validation
|
||||
|
||||
7. `GET /v1/temporal/trajectories?memory_id=<valid-uuid>` returns valid JSON with `similar_trajectories` array
|
||||
8. `GET /v1/scheduler/stats` returns valid JSON with `total_scheduled >= 0`
|
||||
9. `GET /v1/attractor` returns valid JSON with per-category `attractor_type` field
|
||||
10. `GET /v1/invariants` returns valid JSON with `properties_defined >= 5`
|
||||
11. `GET /v1/meta/strange-loop` returns valid JSON with `meta_depth <= 3`
|
||||
12. `GET /v1/federation/stats` returns valid JSON with `status: "standby"` when no peers connected
|
||||
|
||||
### 13.5 Scoring Pipeline Integrity
|
||||
|
||||
13. With all midstream flags enabled, total midstream score contribution never exceeds 0.13 for any single memory
|
||||
14. Search results with midstream enabled are a superset-reordering of results without midstream (no memories added or removed, only rank changes)
|
||||
|
||||
### 13.6 Safety Constraints
|
||||
|
||||
15. `strange-loop` with `max_meta_depth: 3` never recurses beyond level 3 (test with artificial depth-forcing input)
|
||||
16. Self-modification that would set curiosity weight outside [0.01, 0.20] is rejected by safety constraint
|
||||
17. LTL violation triggers WARN log but does not block the violating operation
|
||||
18. Scheduler deadline miss increments counter without dropping the task
|
||||
|
||||
### 13.7 Performance
|
||||
|
||||
19. `search_memories` with all midstream flags enabled completes in < 100ms for 238 memories (p99)
|
||||
20. `share_memory` with all midstream flags enabled completes in < 200ms (p99)
|
||||
21. Background scheduler CPU usage stays under 1% of a single vCPU at steady state
|
||||
22. Memory footprint with all midstream state initialized stays under 20MB total (existing + midstream)
|
||||
|
||||
### 13.8 Load Test
|
||||
|
||||
23. 40-concurrent search requests with all midstream flags enabled: p90 < 200ms, p99 < 300ms
|
||||
24. No scheduler deadline misses under 40-concurrent sustained load for 60 seconds
|
||||
|
||||
## 14. Migration Notes
|
||||
|
||||
### 14.1 Backward Compatibility
|
||||
|
||||
All existing API contracts are unchanged. New endpoints are additive. Midstream scoring signals are additive and bounded. No breaking changes to any existing client.
|
||||
|
||||
### 14.2 Firestore Schema
|
||||
|
||||
No Firestore schema changes required. All midstream state is in-memory only. Persistence of midstream state (attractor histories, LTL traces, meta-knowledge) is a future consideration for a follow-up ADR.
|
||||
|
||||
### 14.3 Docker / Cloud Run
|
||||
|
||||
The Dockerfile at `crates/mcp-brain-server/Dockerfile` needs no changes for Phases 9a-9e (pure Rust crates). Phase 9f (quic-multistream) requires exposing an additional UDP port in the Cloud Run service configuration:
|
||||
|
||||
```yaml
|
||||
# cloudbuild.yaml addition for Phase 9f only
|
||||
ports:
|
||||
- containerPort: 8080 # existing HTTP
|
||||
- containerPort: 4433 # QUIC federation (UDP)
|
||||
protocol: UDP
|
||||
```
|
||||
|
||||
Note: Cloud Run currently has limited UDP support. Phase 9f may require migration to GKE or a hybrid deployment model.
|
||||
378
docs/adr/ADR-078-npx-ruvector-midstream-integration.md
Normal file
378
docs/adr/ADR-078-npx-ruvector-midstream-integration.md
Normal file
|
|
@ -0,0 +1,378 @@
|
|||
# ADR-078: npx ruvector Midstream & Brain AGI Integration
|
||||
|
||||
**Status**: Proposed
|
||||
**Date**: 2026-03-03
|
||||
**Authors**: RuVector Team
|
||||
**Deciders**: ruv
|
||||
**Supersedes**: N/A
|
||||
**Related**: ADR-070 (npx ruvector Unified Integration), ADR-076 (AGI Capability Wiring), ADR-077 (Midstream Brain Integration)
|
||||
|
||||
## 1. Context
|
||||
|
||||
The mcp-brain-server backend at π.ruv.io now has 8 AGI subsystems deployed and operational (ADR-076, ADR-077):
|
||||
|
||||
- **SONA** — 3-tier hierarchical learning engine
|
||||
- **GWT** — Global Workspace Theory attention competition
|
||||
- **Temporal Delta Tracking** — Knowledge evolution velocity
|
||||
- **Meta-Learning Exploration** — Thompson Sampling with curiosity/regret
|
||||
- **Nanosecond Scheduler** — Background task scheduling
|
||||
- **Temporal Attractor Studio** — Lyapunov exponent analysis for embedding stability
|
||||
- **Temporal Neural Solver** — Certified temporal predictions with solver gates
|
||||
- **Strange Loop** — Recursive meta-cognitive reasoning
|
||||
|
||||
The backend exposes 5 diagnostic endpoints (`/v1/status`, `/v1/sona/stats`, `/v1/temporal`, `/v1/explore`, `/v1/midstream`) and returns AGI-enriched search results. However, **none of these capabilities are exposed through the `npx ruvector` CLI (167 commands) or the MCP server (118 tools)**.
|
||||
|
||||
The existing brain CLI commands (13) and MCP tools (11) were implemented against the Phase 1-7 API surface. They don't surface AGI diagnostics, midstream analytics, or the enriched scoring pipeline metadata.
|
||||
|
||||
### Current State
|
||||
|
||||
| Layer | Brain Commands | AGI/Midstream Commands |
|
||||
|-------|---------------|----------------------|
|
||||
| Backend (mcp-brain-server) | 33 REST endpoints | 5 AGI endpoints, 4 midstream subsystems |
|
||||
| MCP Client (mcp-brain) | 20 MCP tools | 0 |
|
||||
| npm CLI (npx ruvector) | 13 brain subcommands | 0 |
|
||||
| npm MCP (mcp-server.js) | 11 brain MCP tools | 0 |
|
||||
|
||||
### Gap
|
||||
|
||||
Users cannot:
|
||||
1. View SONA learning patterns, trajectories, or background tick state
|
||||
2. Monitor temporal delta velocity or trend
|
||||
3. Inspect meta-learning regret, curiosity scores, or plateau status
|
||||
4. View midstream scheduler metrics, attractor analysis, or strange-loop version
|
||||
5. See AGI scoring layer contributions in search results
|
||||
6. Toggle midstream feature flags without redeploying
|
||||
|
||||
## 2. Decision
|
||||
|
||||
Extend `npx ruvector` with 2 new CLI command groups and 12 new MCP tools that surface all AGI and midstream capabilities from the backend.
|
||||
|
||||
### 2.1 New CLI Commands
|
||||
|
||||
#### `ruvector brain agi` Subcommand Group (6 commands)
|
||||
|
||||
```
|
||||
ruvector brain agi status # Combined AGI + midstream diagnostics
|
||||
ruvector brain agi sona # SONA patterns, trajectories, background ticks
|
||||
ruvector brain agi temporal # Temporal delta velocity, trend, total deltas
|
||||
ruvector brain agi explore # Meta-learning curiosity, regret, plateau, Pareto
|
||||
ruvector brain agi midstream # Scheduler ticks, attractor categories, solver, strange-loop
|
||||
ruvector brain agi flags # List current feature flag state from /v1/status
|
||||
```
|
||||
|
||||
All commands support `--json` for machine-readable output and `--url`/`--key` for backend override.
|
||||
|
||||
**Example output:**
|
||||
|
||||
```
|
||||
$ npx ruvector brain agi status
|
||||
┌─────────────────────────────────────┐
|
||||
│ π.ruv.io AGI Diagnostics │
|
||||
├─────────────────────────────────────┤
|
||||
│ SONA │
|
||||
│ Patterns: 12 Trajectories: 45 │
|
||||
│ Background ticks: 3 │
|
||||
│ │
|
||||
│ GWT Attention │
|
||||
│ Workspace load: 0.42 │
|
||||
│ Avg salience: 0.31 │
|
||||
│ │
|
||||
│ Temporal │
|
||||
│ Total deltas: 237 │
|
||||
│ Velocity: 14.0/hr │
|
||||
│ Trend: growing │
|
||||
│ │
|
||||
│ Meta-Learning │
|
||||
│ Avg regret: 0.023 │
|
||||
│ Plateau: learning │
|
||||
│ │
|
||||
│ Midstream │
|
||||
│ Scheduler ticks: 1,204 │
|
||||
│ Attractor categories: 5 │
|
||||
│ Strange-loop: v0.3.0 │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### `ruvector midstream` Command Group (4 commands)
|
||||
|
||||
```
|
||||
ruvector midstream status # Midstream platform overview
|
||||
ruvector midstream attractor [cat] # Lyapunov analysis per category
|
||||
ruvector midstream scheduler # Scheduler metrics (ticks, tasks/sec)
|
||||
ruvector midstream benchmark # Run latency benchmark against backend
|
||||
```
|
||||
|
||||
### 2.2 New MCP Tools
|
||||
|
||||
| Tool Name | Endpoint | Description |
|
||||
|-----------|----------|-------------|
|
||||
| `brain_agi_status` | GET /v1/status | Combined AGI diagnostics (SONA + GWT + temporal + meta + midstream fields) |
|
||||
| `brain_sona_stats` | GET /v1/sona/stats | SONA patterns, trajectories, background ticks |
|
||||
| `brain_temporal` | GET /v1/temporal | Temporal delta velocity, trend, total deltas |
|
||||
| `brain_explore` | GET /v1/explore | Meta-learning curiosity, regret, plateau, Pareto |
|
||||
| `brain_midstream` | GET /v1/midstream | Midstream scheduler, attractor, solver, strange-loop |
|
||||
| `brain_flags` | GET /v1/status | Extract and display feature flag state |
|
||||
| `midstream_status` | GET /v1/midstream | Full midstream platform diagnostics |
|
||||
| `midstream_attractor` | GET /v1/midstream | Attractor categories with Lyapunov exponents |
|
||||
| `midstream_scheduler` | GET /v1/midstream | Nanosecond scheduler performance metrics |
|
||||
| `midstream_benchmark` | Multi-endpoint | Run sequential + concurrent latency benchmark |
|
||||
| `midstream_search` | GET /v1/memories/search | Search with midstream scoring metadata in response |
|
||||
| `midstream_health` | GET /v1/health + /v1/midstream | Combined health + midstream subsystem check |
|
||||
|
||||
### 2.3 Enhanced Brain Commands
|
||||
|
||||
Existing commands get optional AGI metadata:
|
||||
|
||||
| Command | Enhancement |
|
||||
|---------|------------|
|
||||
| `brain search` | Add `--verbose` flag to show per-result AGI scoring breakdown (SONA pattern boost, GWT attention winner, meta curiosity, attractor stability, strange-loop bonus) |
|
||||
| `brain status` | Include AGI and midstream fields in default output (already returned by backend, just not displayed) |
|
||||
| `brain share` | Show attractor update status when midstream is enabled |
|
||||
|
||||
### 2.4 Response Schema Extension
|
||||
|
||||
The backend already returns AGI/midstream fields in `/v1/status`. The CLI and MCP server need to parse and display them:
|
||||
|
||||
```json
|
||||
{
|
||||
"sona_patterns": 12,
|
||||
"sona_trajectories": 45,
|
||||
"gwt_workspace_load": 0.42,
|
||||
"gwt_avg_salience": 0.31,
|
||||
"knowledge_velocity": 14.0,
|
||||
"temporal_deltas": 237,
|
||||
"meta_avg_regret": 0.023,
|
||||
"meta_plateau_status": "learning",
|
||||
"midstream_scheduler_ticks": 1204,
|
||||
"midstream_attractor_categories": 5,
|
||||
"midstream_strange_loop_version": "0.3.0"
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Implementation Plan
|
||||
|
||||
### Phase 1: CLI `brain agi` Subcommands (cli.js)
|
||||
|
||||
**File**: `npm/packages/ruvector/bin/cli.js`
|
||||
|
||||
1. Add `agi` subcommand group under existing `brainCmd`:
|
||||
|
||||
```javascript
|
||||
const agiCmd = brainCmd.command('agi')
|
||||
.description('AGI subsystem diagnostics — SONA, GWT, temporal, meta-learning, midstream');
|
||||
|
||||
agiCmd.command('status')
|
||||
.description('Combined AGI + midstream diagnostics from π.ruv.io')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (opts) => {
|
||||
const piBrain = await requirePiBrain();
|
||||
const config = getBrainConfig(opts);
|
||||
const client = new piBrain.PiBrainClient(config);
|
||||
const status = await client.status();
|
||||
// Extract and format AGI fields
|
||||
});
|
||||
```
|
||||
|
||||
2. Add individual `sona`, `temporal`, `explore`, `midstream`, `flags` subcommands following same pattern.
|
||||
|
||||
3. Each command calls the corresponding backend endpoint via `PiBrainClient` and formats output with chalk.
|
||||
|
||||
**Estimated changes**: ~200 lines added to cli.js
|
||||
|
||||
### Phase 2: CLI `midstream` Command Group (cli.js)
|
||||
|
||||
**File**: `npm/packages/ruvector/bin/cli.js`
|
||||
|
||||
1. Add new top-level command group:
|
||||
|
||||
```javascript
|
||||
const midstreamCmd = program.command('midstream')
|
||||
.description('Midstream real-time streaming analysis platform');
|
||||
```
|
||||
|
||||
2. Add `status`, `attractor`, `scheduler`, `benchmark` subcommands.
|
||||
|
||||
3. The `benchmark` command runs sequential + concurrent latency tests:
|
||||
- 3 sequential requests to each endpoint (health, status, search, write)
|
||||
- 20 concurrent search requests
|
||||
- Reports p50/p90/p99 latencies
|
||||
|
||||
**Estimated changes**: ~150 lines added to cli.js
|
||||
|
||||
### Phase 3: MCP Tools (mcp-server.js)
|
||||
|
||||
**File**: `npm/packages/ruvector/bin/mcp-server.js`
|
||||
|
||||
1. Add 12 new tool definitions to the tools array:
|
||||
|
||||
```javascript
|
||||
{
|
||||
name: 'brain_agi_status',
|
||||
description: 'Combined AGI subsystem diagnostics from the shared brain',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
}
|
||||
},
|
||||
// ... 11 more tools
|
||||
```
|
||||
|
||||
2. Add handler cases:
|
||||
|
||||
```javascript
|
||||
case 'brain_agi_status':
|
||||
case 'brain_sona_stats':
|
||||
case 'brain_temporal':
|
||||
case 'brain_explore':
|
||||
case 'brain_midstream':
|
||||
case 'brain_flags': {
|
||||
const { PiBrainClient } = require('@ruvector/pi-brain');
|
||||
const client = new PiBrainClient({ url, key });
|
||||
const endpointMap = {
|
||||
brain_agi_status: 'status',
|
||||
brain_sona_stats: 'sona/stats',
|
||||
brain_temporal: 'temporal',
|
||||
brain_explore: 'explore',
|
||||
brain_midstream: 'midstream',
|
||||
brain_flags: 'status',
|
||||
};
|
||||
const subCmd = endpointMap[name];
|
||||
const result = await client.raw(`/v1/${subCmd}`);
|
||||
// For brain_flags, extract only flag-related fields
|
||||
// For brain_agi_status, extract AGI fields
|
||||
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
||||
}
|
||||
```
|
||||
|
||||
**Estimated changes**: ~250 lines added to mcp-server.js
|
||||
|
||||
### Phase 4: PiBrainClient Extension (@ruvector/pi-brain)
|
||||
|
||||
**File**: `npm/packages/pi-brain/` (or inline in cli.js if pi-brain not available)
|
||||
|
||||
Add methods:
|
||||
- `sonaStats()` — GET /v1/sona/stats
|
||||
- `temporal()` — GET /v1/temporal
|
||||
- `explore()` — GET /v1/explore
|
||||
- `midstream()` — GET /v1/midstream
|
||||
- `raw(path)` — GET any /v1/* endpoint (generic)
|
||||
|
||||
If `@ruvector/pi-brain` is not yet published, implement these as inline fetch calls in cli.js using the existing `getBrainConfig()` pattern.
|
||||
|
||||
### Phase 5: Enhanced `brain search --verbose`
|
||||
|
||||
**File**: `npm/packages/ruvector/bin/cli.js`
|
||||
|
||||
Add `--verbose` flag to `brain search` that displays per-result metadata when available. The backend already includes `quality_score`, `witness_hash`, etc. in search results. The verbose mode would format these prominently:
|
||||
|
||||
```
|
||||
$ npx ruvector brain search "neural embeddings" --verbose
|
||||
1. Neural Embedding Patterns (pattern)
|
||||
Quality: 0.89 | Votes: 12↑ 1↓ | Witness: 3a7f...
|
||||
Contributor: anon-abc123 | Created: 2026-03-01
|
||||
Tags: neural, embedding, rust
|
||||
```
|
||||
|
||||
### Phase 6: Tests
|
||||
|
||||
**File**: `npm/packages/ruvector/test/cli-commands.js`
|
||||
|
||||
Add tests for new commands:
|
||||
- `brain agi status --help` — verify command exists
|
||||
- `brain agi sona --help` — verify subcommand exists
|
||||
- `midstream status --help` — verify new command group
|
||||
- `midstream benchmark --help` — verify benchmark command
|
||||
|
||||
**File**: `npm/packages/ruvector/test/integration.js`
|
||||
|
||||
Add integration test: verify new MCP tools appear in tool list.
|
||||
|
||||
**Estimated**: 12-15 new test cases
|
||||
|
||||
## 4. File Summary
|
||||
|
||||
| File | Action | Phase | Est. Lines |
|
||||
|------|--------|-------|-----------|
|
||||
| `npm/packages/ruvector/bin/cli.js` | Add `brain agi` + `midstream` commands | 1,2,5 | +400 |
|
||||
| `npm/packages/ruvector/bin/mcp-server.js` | Add 12 MCP tools | 3 | +250 |
|
||||
| `npm/packages/pi-brain/src/client.ts` | Add AGI/midstream methods | 4 | +60 |
|
||||
| `npm/packages/ruvector/test/cli-commands.js` | New command tests | 6 | +50 |
|
||||
| `npm/packages/ruvector/test/integration.js` | MCP tool integration tests | 6 | +30 |
|
||||
| `npm/packages/ruvector/package.json` | Version bump 0.2.3 → 0.2.4 | 3 | +1 |
|
||||
|
||||
## 5. API Surface After Integration
|
||||
|
||||
| Layer | Before | After | Delta |
|
||||
|-------|--------|-------|-------|
|
||||
| CLI Commands | 167 | 177 | +10 |
|
||||
| CLI Groups | 12 | 13 | +1 (`midstream`) |
|
||||
| Brain Subcommands | 13 | 19 | +6 (`agi` group) |
|
||||
| MCP Tools | 118 | 130 | +12 |
|
||||
| Brain MCP Tools | 11 | 17 | +6 |
|
||||
|
||||
## 6. Backend Endpoints Consumed
|
||||
|
||||
All endpoints already exist and are deployed (revision ruvbrain-00071-wp7):
|
||||
|
||||
| Endpoint | Auth | New Consumers |
|
||||
|----------|------|--------------|
|
||||
| GET /v1/status | Yes | `brain agi status`, `brain agi flags`, `brain_agi_status`, `brain_flags` |
|
||||
| GET /v1/sona/stats | Yes | `brain agi sona`, `brain_sona_stats` |
|
||||
| GET /v1/temporal | Yes | `brain agi temporal`, `brain_temporal` |
|
||||
| GET /v1/explore | Yes | `brain agi explore`, `brain_explore` |
|
||||
| GET /v1/midstream | Yes | `brain agi midstream`, `midstream status`, `brain_midstream`, `midstream_status` |
|
||||
| GET /v1/health | No | `midstream benchmark`, `midstream_health` |
|
||||
| GET /v1/memories/search | Yes | `midstream_search` (with scoring metadata) |
|
||||
|
||||
No backend changes required — all endpoints are live and returning the expected JSON schemas.
|
||||
|
||||
## 7. Feature Flags
|
||||
|
||||
No new environment variables. The CLI/MCP tools read from the existing backend responses. Feature flag state is reported by the backend in `/v1/status` response.
|
||||
|
||||
For local development/testing, existing env vars apply:
|
||||
- `BRAIN_URL` — Override backend URL (default: π.ruv.io Cloud Run URL)
|
||||
- `BRAIN_API_KEY` — API key for authentication
|
||||
|
||||
## 8. Backward Compatibility
|
||||
|
||||
- All new commands are additive — no existing commands modified
|
||||
- `brain status` enhanced output is backward-compatible (new fields appended)
|
||||
- `brain search --verbose` is opt-in (default output unchanged)
|
||||
- MCP tools follow existing naming convention (`brain_*`, `midstream_*`)
|
||||
- `@ruvector/pi-brain` methods are additive (existing API unchanged)
|
||||
- Version bump is minor (0.2.3 → 0.2.4) — no breaking changes
|
||||
|
||||
## 9. Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Users gain full visibility into all 8 AGI subsystems via CLI and MCP
|
||||
- Claude Code agents can inspect brain learning state (SONA patterns, meta-learning regret) to make informed decisions
|
||||
- Midstream platform metrics enable monitoring and alerting on knowledge evolution
|
||||
- `midstream benchmark` provides a standardized latency test for the brain backend
|
||||
- Feature flag visibility enables debugging deployment issues
|
||||
|
||||
### Negative
|
||||
|
||||
- cli.js grows by ~400 lines (8,500 → 8,900) — still within single-file budget
|
||||
- mcp-server.js grows by ~250 lines (3,500 → 3,750) — acceptable
|
||||
- 12 additional MCP tools increase tool list size (agents may need to filter)
|
||||
- `@ruvector/pi-brain` gains a dependency on 5 new endpoint paths
|
||||
|
||||
### Risks
|
||||
|
||||
- Backend `/v1/status` response schema may evolve — CLI should handle missing fields gracefully
|
||||
- Midstream feature flags defaulting to `false` means `brain agi midstream` shows zeros until enabled
|
||||
- `midstream benchmark` generates real traffic — should include rate limiting awareness
|
||||
|
||||
## 10. Verification
|
||||
|
||||
1. `npm test` in `npm/packages/ruvector/` — 55 existing + 12-15 new tests pass
|
||||
2. `npx ruvector brain agi status --json` returns valid JSON with AGI fields
|
||||
3. `npx ruvector midstream status --json` returns midstream diagnostics
|
||||
4. `npx ruvector midstream benchmark` completes within 30s and reports latencies
|
||||
5. MCP server lists 130 tools (was 118)
|
||||
6. Claude Code can call `brain_agi_status` tool and receive formatted response
|
||||
7. `npx ruvector brain search "test" --verbose` shows enhanced output
|
||||
8. All commands work with `--url` and `--key` overrides
|
||||
101
docs/adr/ADR-079-sql-audit-script-hardening.md
Normal file
101
docs/adr/ADR-079-sql-audit-script-hardening.md
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
# ADR-079: SQL Audit Script Hardening & Bug Fixes
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-03-03
|
||||
**Author:** ruvnet
|
||||
|
||||
## Context
|
||||
|
||||
The ruvector independent audit verification script (`sql-audit.sql`) v2 contained 12 bugs ranging from syntax errors that prevent execution to logic errors that produce misleading results. The script tests 13 advertised ruvector extension features against actual behavior — correctness of the audit tool itself is critical for trust.
|
||||
|
||||
## Issues Found (v2 -> v3)
|
||||
|
||||
### Critical (5)
|
||||
|
||||
| # | Issue | Impact |
|
||||
|---|-------|--------|
|
||||
| 1 | **Dollar quoting broken** — All DO blocks use single `$` instead of `$$`. PostgreSQL requires `$$` or `$tag$` for dollar-quoted string literals. | Every DO block is a syntax error — script cannot run at all |
|
||||
| 2 | **Hardcoded node IDs in shortest_path** (Section 4d) — Uses literal `1, 3` but auto-generated IDs vary by database state. IDs from Section 4b's DO block are local variables, unreachable in 4d. | Shortest path test fails on any non-empty database |
|
||||
| 3 | **Section 5b bare SELECTs** — `ruvector_insert_triple()` calls have no DO/EXCEPTION wrapper. If the function doesn't exist, the script aborts entirely. | Breaks fault-tolerance guarantee |
|
||||
| 4 | **dblink connection string unquoted** — `'dbname=' \|\| current_database()` is vulnerable to breakage with special characters in database/user names. | Persistence test fails on databases with spaces/special chars |
|
||||
| 5 | **GUC `hnsw.ef_search` unguarded** — `SET hnsw.ef_search = 200` throws an error if ruvector doesn't register this custom GUC parameter. | HNSW test section aborts |
|
||||
|
||||
### Important (4)
|
||||
|
||||
| # | Issue | Impact |
|
||||
|---|-------|--------|
|
||||
| 6 | **Section 11 inconsistent filtering** — Uses `pg_namespace` join instead of `pg_depend + pg_extension`, unlike Section 0. May report non-ruvector functions as ruvector features. | False positives in bonus capabilities check |
|
||||
| 7 | **Session GUCs not restored** — `hnsw.ef_search`, `client_min_messages` never reset. | Affects user's psql session after audit |
|
||||
| 8 | **Section 5b results not validated** — Triple INSERT output printed but never checked PASS/FAIL. | Misleading — user sees output but no verdict |
|
||||
| 9 | **Section 4c graph_stats outside exception block** — Bare SELECT aborts script if graph creation failed in 4b. | Breaks fault tolerance |
|
||||
|
||||
### Minor (3)
|
||||
|
||||
| # | Issue | Impact |
|
||||
|---|-------|--------|
|
||||
| 10 | `\timing` scope inconsistent across sections | Timing data missing for Sections 3-10 |
|
||||
| 11 | Cypher test (4e) not programmatically validated | Relies on human to spot self-reference bug |
|
||||
| 12 | `enable_indexscan = off` not wrapped in savepoint | Script interruption leaves index scans disabled |
|
||||
|
||||
## Decision
|
||||
|
||||
Create v3 (`scripts/sql-audit-v3.sql`) with all 12 fixes applied:
|
||||
|
||||
1. **Dollar quoting** — All DO blocks use `$$` or named tags (`$audit_NNN$`, `$graph_create$`, etc.)
|
||||
2. **Node ID passing** — Temp table `_audit_graph_ids` bridges DO blocks; shortest_path reads from it
|
||||
3. **Full fault tolerance** — Every external call wrapped in DO/EXCEPTION; no bare SELECTs for ruvector functions
|
||||
4. **Safe dblink** — `format('dbname=%L user=%L', current_database(), current_user)` with proper quoting
|
||||
5. **GUC guards** — `SET LOCAL hnsw.ef_search` inside nested BEGIN/EXCEPTION
|
||||
6. **Consistent filtering** — All Section 11 queries use `pg_depend + pg_extension` join
|
||||
7. **Session restore** — `RESET client_min_messages` at cleanup; `SET LOCAL` for all temporary GUCs
|
||||
8. **Programmatic verdicts** — All sections emit PASS/FAIL/ERROR via RAISE NOTICE with value checks
|
||||
9. **Savepoint safety** — `SET LOCAL enable_indexscan` scoped to DO block transaction
|
||||
|
||||
## Consequences
|
||||
|
||||
- Audit script is now fully executable on any PostgreSQL 14-17 installation
|
||||
- No section can abort the rest — all wrapped in exception handlers
|
||||
- Results are machine-parseable (grep for `PASS:` / `FAIL:` / `ERROR:`)
|
||||
- Session state is clean after script completes
|
||||
|
||||
## Known ruvector Issues Discovered by Audit
|
||||
|
||||
| # | Issue | Status | Fix |
|
||||
|---|-------|--------|-----|
|
||||
| 1 | Cypher MATCH self-reference bug (`a.id == b.id`) | **Fixed (v0.3.1)** | Rewrote `match_pattern()` in `executor.rs` to properly traverse edges, reject self-references when variables differ, and generate per-edge binding rows |
|
||||
| 2 | Graph/RDF persistence failure (in-memory only) | **Fixed (v0.3.1)** | Added PostgreSQL backing tables (`_ruvector_graphs`, `_ruvector_nodes`, `_ruvector_edges`, `_ruvector_rdf_stores`, `_ruvector_triples`) with auto-load on cache miss |
|
||||
| 3 | HNSW index scan returns 0 rows despite correct query planning | Open | v0.1.0 SQL schema issue — requires investigation of index AM registration |
|
||||
| 4 | Self-healing, multi-tenancy, hybrid search "not registered" | **Fixed (v0.3.1)** | 46 missing `CREATE FUNCTION` statements added to `ruvector--0.3.0.sql`: GNN (5), healing (17), tenancy (17), hybrid (7). Modules were always compiled but SQL schema lacked function registrations. All 46 verified in Docker container. |
|
||||
| 5 | SONA apply panics on non-256-dim input | **Fixed (v0.3.1)** | Dynamic dimension detection with per-dim engine caching and `catch_unwind` panic guard |
|
||||
|
||||
## Related Changes (v0.3.1)
|
||||
|
||||
### Rust Source Fixes
|
||||
- `crates/ruvector-postgres/src/graph/cypher/executor.rs` — Cypher self-reference fix
|
||||
- `crates/ruvector-postgres/src/graph/mod.rs` — Graph persistence tables + `use pgrx::JsonB` + `get_by_name::<T, _>()` fix
|
||||
- `crates/ruvector-postgres/src/graph/sparql/mod.rs` — RDF persistence tables + `get_by_name::<T, _>()` fix
|
||||
- `crates/ruvector-postgres/src/graph/operators.rs` — Persist calls after node/edge/triple inserts
|
||||
- `crates/ruvector-postgres/src/sona/mod.rs` — Dynamic dimension engine cache (`dim as usize` cast)
|
||||
- `crates/ruvector-postgres/src/sona/operators.rs` — Dimension detection + `catch_unwind` panic guard
|
||||
|
||||
### SQL Schema
|
||||
- `crates/ruvector-postgres/sql/ruvector--0.3.0.sql` — Added 46 `CREATE FUNCTION` statements for GNN (5), healing (17), tenancy (17), hybrid (7). Total extension functions: **190**
|
||||
|
||||
### Docker
|
||||
- `crates/ruvector-postgres/Dockerfile` — Updated labels, features, SQL copy for v0.3.1
|
||||
- `crates/ruvector-postgres/Dockerfile.prebuilt` — New slim image using pre-compiled artifacts (~12s build)
|
||||
- `crates/ruvector-postgres/docker/Dockerfile` — Updated Rust 1.85, features, labels
|
||||
- `crates/ruvector-postgres/docker/docker-compose.yml` — Updated Rust version to 1.85
|
||||
- **Published**: `docker.io/ruvnet/ruvector-postgres:0.3.1` and `:latest` (sha256:6d2f28ed5efd, 151 MB)
|
||||
|
||||
### Verification Summary
|
||||
|
||||
All 46 new functions verified in Docker container (`ruvnet/ruvector-postgres:0.3.1`):
|
||||
|
||||
| Module | Functions | Status |
|
||||
|--------|-----------|--------|
|
||||
| GNN | `ruvector_gcn_forward`, `ruvector_gnn_aggregate`, `ruvector_message_pass`, `ruvector_graphsage_forward`, `ruvector_gnn_batch_forward` | 5/5 PASS |
|
||||
| Self-Healing | `ruvector_health_status`, `ruvector_is_healthy`, `ruvector_system_metrics`, `ruvector_healing_history`, `ruvector_healing_history_since`, `ruvector_healing_history_for_strategy`, `ruvector_healing_trigger`, `ruvector_healing_execute`, `ruvector_healing_configure`, `ruvector_healing_get_config`, `ruvector_healing_enable`, `ruvector_healing_strategies`, `ruvector_healing_effectiveness`, `ruvector_healing_stats`, `ruvector_healing_thresholds`, `ruvector_healing_set_thresholds`, `ruvector_healing_problem_types` | 17/17 PASS |
|
||||
| Multi-Tenancy | `ruvector_tenant_create`, `ruvector_tenant_set`, `ruvector_tenant_stats`, `ruvector_tenant_quota_check`, `ruvector_tenant_suspend`, `ruvector_tenant_resume`, `ruvector_tenant_delete`, `ruvector_tenants`, `ruvector_enable_tenant_rls`, `ruvector_tenant_migrate`, `ruvector_tenant_migration_status`, `ruvector_tenant_isolate`, `ruvector_tenant_set_policy`, `ruvector_tenant_update_quota`, `ruvector_generate_rls_sql`, `ruvector_generate_tenant_column_sql`, `ruvector_generate_roles_sql` | 17/17 PASS |
|
||||
| Hybrid Search | `ruvector_register_hybrid`, `ruvector_hybrid_update_stats`, `ruvector_hybrid_configure`, `ruvector_hybrid_search`, `ruvector_hybrid_stats`, `ruvector_hybrid_score`, `ruvector_hybrid_list` | 7/7 PASS |
|
||||
| SONA (prev fix) | `ruvector_sona_apply` with 3-dim and 5-dim inputs | 2/2 PASS |
|
||||
2
examples/edge-net/Cargo.lock
generated
2
examples/edge-net/Cargo.lock
generated
|
|
@ -621,7 +621,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "ruvector-exotic-wasm"
|
||||
version = "0.1.29"
|
||||
version = "2.0.5"
|
||||
dependencies = [
|
||||
"getrandom 0.2.16",
|
||||
"getrandom 0.3.4",
|
||||
|
|
|
|||
322
examples/edge-net/dashboard/src/components/brain/BrainStatus.tsx
Normal file
322
examples/edge-net/dashboard/src/components/brain/BrainStatus.tsx
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardBody, Progress } from '@heroui/react';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
Brain,
|
||||
Link2,
|
||||
Zap,
|
||||
Coins,
|
||||
Clock,
|
||||
BookOpen,
|
||||
TrendingUp,
|
||||
Activity,
|
||||
} from 'lucide-react';
|
||||
import { useNetworkStore } from '../../stores/networkStore';
|
||||
|
||||
// Simulated brain integration state (would come from real WASM/relay in production)
|
||||
interface BrainState {
|
||||
connectionHealth: 'connected' | 'degraded' | 'disconnected';
|
||||
relayLatency: number;
|
||||
operationsToday: number;
|
||||
ruvEarnedToday: number;
|
||||
halvingEpoch: number;
|
||||
epochProgress: number;
|
||||
topCategories: { name: string; queries: number; color: string }[];
|
||||
brainUptime: number;
|
||||
totalKnowledgeEntries: number;
|
||||
}
|
||||
|
||||
function useBrainState(): BrainState {
|
||||
const { isRelayConnected, stats, credits } = useNetworkStore();
|
||||
const [brainState, setBrainState] = useState<BrainState>({
|
||||
connectionHealth: 'disconnected',
|
||||
relayLatency: 0,
|
||||
operationsToday: 0,
|
||||
ruvEarnedToday: 0,
|
||||
halvingEpoch: 1,
|
||||
epochProgress: 0.37,
|
||||
topCategories: [
|
||||
{ name: 'Code Patterns', queries: 1842, color: 'sky' },
|
||||
{ name: 'Architecture', queries: 1203, color: 'violet' },
|
||||
{ name: 'Security', queries: 891, color: 'amber' },
|
||||
{ name: 'Performance', queries: 654, color: 'emerald' },
|
||||
{ name: 'DevOps', queries: 412, color: 'cyan' },
|
||||
],
|
||||
brainUptime: 0,
|
||||
totalKnowledgeEntries: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setBrainState((prev) => {
|
||||
const health = isRelayConnected
|
||||
? stats.latency < 100
|
||||
? 'connected'
|
||||
: 'degraded'
|
||||
: 'disconnected';
|
||||
|
||||
return {
|
||||
...prev,
|
||||
connectionHealth: health,
|
||||
relayLatency: stats.latency,
|
||||
operationsToday: Math.floor(stats.tasksCompleted * 2.3),
|
||||
ruvEarnedToday: Math.round(credits.earned * 0.4 * 100) / 100,
|
||||
brainUptime: prev.brainUptime + 1,
|
||||
totalKnowledgeEntries: 24_831 + Math.floor(prev.brainUptime / 5),
|
||||
topCategories: prev.topCategories.map((cat) => ({
|
||||
...cat,
|
||||
queries: cat.queries + Math.floor(Math.random() * 3),
|
||||
})),
|
||||
};
|
||||
});
|
||||
}, 2000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isRelayConnected, stats.latency, stats.tasksCompleted, credits.earned]);
|
||||
|
||||
return brainState;
|
||||
}
|
||||
|
||||
const healthConfig = {
|
||||
connected: {
|
||||
label: 'Healthy',
|
||||
color: 'emerald',
|
||||
bgClass: 'bg-emerald-500/10 border-emerald-500/30',
|
||||
textClass: 'text-emerald-400',
|
||||
dotClass: 'bg-emerald-400',
|
||||
},
|
||||
degraded: {
|
||||
label: 'Degraded',
|
||||
color: 'amber',
|
||||
bgClass: 'bg-amber-500/10 border-amber-500/30',
|
||||
textClass: 'text-amber-400',
|
||||
dotClass: 'bg-amber-400',
|
||||
},
|
||||
disconnected: {
|
||||
label: 'Disconnected',
|
||||
color: 'red',
|
||||
bgClass: 'bg-red-500/10 border-red-500/30',
|
||||
textClass: 'text-red-400',
|
||||
dotClass: 'bg-red-400',
|
||||
},
|
||||
};
|
||||
|
||||
const categoryColorMap: Record<string, string> = {
|
||||
sky: 'bg-sky-500/20 text-sky-400 border-sky-500/30',
|
||||
violet: 'bg-violet-500/20 text-violet-400 border-violet-500/30',
|
||||
amber: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
|
||||
emerald: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30',
|
||||
cyan: 'bg-cyan-500/20 text-cyan-400 border-cyan-500/30',
|
||||
};
|
||||
|
||||
export function BrainStatus() {
|
||||
const brain = useBrainState();
|
||||
const health = healthConfig[brain.connectionHealth];
|
||||
const maxQueries = Math.max(...brain.topCategories.map((c) => c.queries));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Connection Health Banner */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={`p-4 rounded-lg border flex items-center justify-between ${health.bgClass}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<motion.div
|
||||
className={`w-3 h-3 rounded-full ${health.dotClass}`}
|
||||
animate={
|
||||
brain.connectionHealth === 'connected'
|
||||
? { scale: [1, 1.3, 1], opacity: [1, 0.7, 1] }
|
||||
: {}
|
||||
}
|
||||
transition={{ duration: 2, repeat: Infinity }}
|
||||
/>
|
||||
<Brain className={health.textClass} size={20} />
|
||||
<div>
|
||||
<span className={`font-medium ${health.textClass}`}>
|
||||
Brain Link: {health.label}
|
||||
</span>
|
||||
<p className="text-xs text-zinc-500 mt-0.5">
|
||||
{brain.connectionHealth === 'connected'
|
||||
? `Relay latency: ${brain.relayLatency.toFixed(0)}ms`
|
||||
: brain.connectionHealth === 'degraded'
|
||||
? `High latency: ${brain.relayLatency.toFixed(0)}ms`
|
||||
: 'Enable contribution to connect'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{brain.connectionHealth !== 'disconnected' && (
|
||||
<div className="flex items-center gap-1 text-xs text-zinc-500">
|
||||
<Link2 size={12} />
|
||||
<span>relay → brain</span>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
<Card className="bg-gradient-to-br from-sky-500/20 to-sky-600/10 border border-sky-500/30">
|
||||
<CardBody className="p-5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Activity className="text-sky-400" size={22} />
|
||||
<span className="text-xs text-sky-400/70">Today</span>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-white stat-value">
|
||||
{brain.operationsToday.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-sm text-sky-400 mt-1">Brain Operations</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
<Card className="bg-gradient-to-br from-emerald-500/20 to-emerald-600/10 border border-emerald-500/30">
|
||||
<CardBody className="p-5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Coins className="text-emerald-400" size={22} />
|
||||
<span className="text-xs text-emerald-400/70">Earned</span>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-white stat-value">
|
||||
{brain.ruvEarnedToday.toFixed(2)}
|
||||
</p>
|
||||
<p className="text-sm text-emerald-400 mt-1">rUv from Brain</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<Card className="bg-gradient-to-br from-violet-500/20 to-violet-600/10 border border-violet-500/30">
|
||||
<CardBody className="p-5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Clock className="text-violet-400" size={22} />
|
||||
<span className="text-xs text-violet-400/70">Epoch</span>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-white stat-value">
|
||||
{brain.halvingEpoch}
|
||||
</p>
|
||||
<p className="text-sm text-violet-400 mt-1">Halving Epoch</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
<Card className="bg-gradient-to-br from-cyan-500/20 to-cyan-600/10 border border-cyan-500/30">
|
||||
<CardBody className="p-5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<BookOpen className="text-cyan-400" size={22} />
|
||||
<span className="text-xs text-cyan-400/70">Total</span>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-white stat-value">
|
||||
{(brain.totalKnowledgeEntries / 1000).toFixed(1)}k
|
||||
</p>
|
||||
<p className="text-sm text-cyan-400 mt-1">Knowledge Entries</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Halving Epoch Progress */}
|
||||
<motion.div
|
||||
className="crystal-card p-6"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Zap className="text-violet-400" size={18} />
|
||||
Halving Epoch Progress
|
||||
</h3>
|
||||
<span className="text-sm text-zinc-400">
|
||||
Epoch {brain.halvingEpoch} of 20
|
||||
</span>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<div className="flex justify-between text-sm mb-2">
|
||||
<span className="text-zinc-400">Progress to next halving</span>
|
||||
<span className="text-violet-400">
|
||||
{(brain.epochProgress * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={brain.epochProgress * 100}
|
||||
maxValue={100}
|
||||
classNames={{
|
||||
indicator: 'bg-gradient-to-r from-violet-500 to-pink-500',
|
||||
track: 'bg-zinc-800',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 mt-4">
|
||||
<div className="text-center p-3 rounded-lg bg-zinc-800/50">
|
||||
<p className="text-lg font-bold text-white">1.0x</p>
|
||||
<p className="text-xs text-zinc-400">Current Rate</p>
|
||||
</div>
|
||||
<div className="text-center p-3 rounded-lg bg-zinc-800/50">
|
||||
<p className="text-lg font-bold text-white">0.5x</p>
|
||||
<p className="text-xs text-zinc-400">Next Epoch Rate</p>
|
||||
</div>
|
||||
<div className="text-center p-3 rounded-lg bg-zinc-800/50">
|
||||
<p className="text-lg font-bold text-white">10B</p>
|
||||
<p className="text-xs text-zinc-400">Max Supply rUv</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Top Knowledge Categories */}
|
||||
<motion.div
|
||||
className="crystal-card p-6"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
>
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<TrendingUp className="text-sky-400" size={18} />
|
||||
Top Knowledge Categories
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{brain.topCategories.map((cat, idx) => (
|
||||
<div key={cat.name} className="flex items-center gap-3">
|
||||
<span className="text-xs text-zinc-500 w-4">{idx + 1}</span>
|
||||
<div className="flex-1">
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-zinc-300">{cat.name}</span>
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-full border ${categoryColorMap[cat.color]}`}
|
||||
>
|
||||
{cat.queries.toLocaleString()} queries
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-zinc-800 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
className="h-full rounded-full bg-gradient-to-r from-sky-500 to-violet-500"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${(cat.queries / maxQueries) * 100}%` }}
|
||||
transition={{ duration: 0.5, delay: idx * 0.1 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,551 @@
|
|||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Card, CardBody, Progress } from '@heroui/react';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
AreaChart,
|
||||
Area,
|
||||
} from 'recharts';
|
||||
import {
|
||||
TrendingUp,
|
||||
Users,
|
||||
Coins,
|
||||
Shield,
|
||||
Award,
|
||||
Gauge,
|
||||
CircleDollarSign,
|
||||
Gift,
|
||||
} from 'lucide-react';
|
||||
import { useNetworkStore } from '../../stores/networkStore';
|
||||
|
||||
// Economics model types
|
||||
interface ReputationTier {
|
||||
name: string;
|
||||
minScore: number;
|
||||
color: string;
|
||||
count: number;
|
||||
benefits: string;
|
||||
}
|
||||
|
||||
interface SupplyStats {
|
||||
totalMinted: number;
|
||||
maxSupply: number;
|
||||
contributorPool: number;
|
||||
treasury: number;
|
||||
protocolFund: number;
|
||||
founderPool: number;
|
||||
}
|
||||
|
||||
interface LeaderboardEntry {
|
||||
rank: number;
|
||||
name: string;
|
||||
ruvEarned: number;
|
||||
tasks: number;
|
||||
tier: string;
|
||||
}
|
||||
|
||||
interface EconomicsState {
|
||||
contributionMultiplier: number;
|
||||
multiplierCurve: { x: number; y: number }[];
|
||||
reputationTiers: ReputationTier[];
|
||||
supply: SupplyStats;
|
||||
freeReadsToday: number;
|
||||
freeReadsLimit: number;
|
||||
leaderboard: LeaderboardEntry[];
|
||||
velocity: number;
|
||||
utilization: number;
|
||||
stability: number;
|
||||
}
|
||||
|
||||
function useEconomicsState(): EconomicsState {
|
||||
const { stats, credits } = useNetworkStore();
|
||||
const [economics, setEconomics] = useState<EconomicsState>(() => ({
|
||||
contributionMultiplier: 1.0,
|
||||
multiplierCurve: Array.from({ length: 20 }, (_, i) => ({
|
||||
x: i * 5,
|
||||
y: Math.min(3.0, 1.0 + Math.log1p(i * 5 * 0.02) * 1.2),
|
||||
})),
|
||||
reputationTiers: [
|
||||
{ name: 'Observer', minScore: 0, color: '#71717a', count: 2841, benefits: 'Free reads only' },
|
||||
{ name: 'Contributor', minScore: 0.3, color: '#38bdf8', count: 1592, benefits: '+1.2x multiplier' },
|
||||
{ name: 'Builder', minScore: 0.5, color: '#a78bfa', count: 823, benefits: '+1.8x, priority queue' },
|
||||
{ name: 'Architect', minScore: 0.7, color: '#fbbf24', count: 241, benefits: '+2.5x, governance' },
|
||||
{ name: 'Guardian', minScore: 0.9, color: '#34d399', count: 47, benefits: '+3.0x, all access' },
|
||||
],
|
||||
supply: {
|
||||
totalMinted: 847_293_100,
|
||||
maxSupply: 10_000_000_000,
|
||||
contributorPool: 593_105_170,
|
||||
treasury: 127_093_965,
|
||||
protocolFund: 84_729_310,
|
||||
founderPool: 42_364_655,
|
||||
},
|
||||
freeReadsToday: 14,
|
||||
freeReadsLimit: 20,
|
||||
leaderboard: [
|
||||
{ rank: 1, name: 'node-7f3a...c2e1', ruvEarned: 12847.5, tasks: 28341, tier: 'Guardian' },
|
||||
{ rank: 2, name: 'node-b2d1...9f4a', ruvEarned: 9432.1, tasks: 21203, tier: 'Architect' },
|
||||
{ rank: 3, name: 'node-e5c8...1b7d', ruvEarned: 7891.3, tasks: 17654, tier: 'Architect' },
|
||||
{ rank: 4, name: 'node-a1f9...d3e5', ruvEarned: 5234.8, tasks: 12089, tier: 'Builder' },
|
||||
{ rank: 5, name: 'node-c4b6...8a2f', ruvEarned: 3912.4, tasks: 8921, tier: 'Builder' },
|
||||
],
|
||||
velocity: 0.42,
|
||||
utilization: 0.68,
|
||||
stability: 0.91,
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setEconomics((prev) => {
|
||||
const newMultiplier = Math.min(
|
||||
3.0,
|
||||
1.0 + Math.log1p(credits.earned * 0.02) * 1.2
|
||||
);
|
||||
return {
|
||||
...prev,
|
||||
contributionMultiplier: Math.round(newMultiplier * 100) / 100,
|
||||
supply: {
|
||||
...prev.supply,
|
||||
totalMinted: prev.supply.totalMinted + Math.floor(Math.random() * 100),
|
||||
},
|
||||
freeReadsToday: Math.min(prev.freeReadsLimit, prev.freeReadsToday + (Math.random() > 0.95 ? 1 : 0)),
|
||||
velocity: 0.42 + Math.sin(Date.now() / 10000) * 0.05,
|
||||
utilization: Math.min(1.0, 0.68 + stats.tasksCompleted * 0.0001),
|
||||
};
|
||||
});
|
||||
}, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}, [credits.earned, stats.tasksCompleted]);
|
||||
|
||||
return economics;
|
||||
}
|
||||
|
||||
const SUPPLY_COLORS = ['#38bdf8', '#a78bfa', '#06b6d4', '#fbbf24'];
|
||||
|
||||
const tierColorMap: Record<string, string> = {
|
||||
Observer: 'text-zinc-400',
|
||||
Contributor: 'text-sky-400',
|
||||
Builder: 'text-violet-400',
|
||||
Architect: 'text-amber-400',
|
||||
Guardian: 'text-emerald-400',
|
||||
};
|
||||
|
||||
export function EconomicsOverview() {
|
||||
const eco = useEconomicsState();
|
||||
|
||||
const supplyPieData = useMemo(
|
||||
() => [
|
||||
{ name: 'Contributors (70%)', value: eco.supply.contributorPool },
|
||||
{ name: 'Treasury (15%)', value: eco.supply.treasury },
|
||||
{ name: 'Protocol (10%)', value: eco.supply.protocolFund },
|
||||
{ name: 'Founders (5%)', value: eco.supply.founderPool },
|
||||
],
|
||||
[eco.supply]
|
||||
);
|
||||
|
||||
const tierBarData = useMemo(
|
||||
() =>
|
||||
eco.reputationTiers.map((t) => ({
|
||||
name: t.name,
|
||||
count: t.count,
|
||||
fill: t.color,
|
||||
})),
|
||||
[eco.reputationTiers]
|
||||
);
|
||||
|
||||
const mintPercentage =
|
||||
(eco.supply.totalMinted / eco.supply.maxSupply) * 100;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Top Stats Row */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
<Card className="bg-gradient-to-br from-sky-500/20 to-sky-600/10 border border-sky-500/30">
|
||||
<CardBody className="p-5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<TrendingUp className="text-sky-400" size={22} />
|
||||
<span className="text-xs text-sky-400/70">Your Rate</span>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-white stat-value">
|
||||
{eco.contributionMultiplier.toFixed(2)}x
|
||||
</p>
|
||||
<p className="text-sm text-sky-400 mt-1">
|
||||
Contribution Multiplier
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
<Card className="bg-gradient-to-br from-emerald-500/20 to-emerald-600/10 border border-emerald-500/30">
|
||||
<CardBody className="p-5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Gauge className="text-emerald-400" size={22} />
|
||||
<span className="text-xs text-emerald-400/70">Health</span>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-white stat-value">
|
||||
{(eco.stability * 100).toFixed(0)}%
|
||||
</p>
|
||||
<p className="text-sm text-emerald-400 mt-1">
|
||||
Economic Stability
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<Card className="bg-gradient-to-br from-violet-500/20 to-violet-600/10 border border-violet-500/30">
|
||||
<CardBody className="p-5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<CircleDollarSign className="text-violet-400" size={22} />
|
||||
<span className="text-xs text-violet-400/70">Velocity</span>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-white stat-value">
|
||||
{(eco.velocity * 100).toFixed(0)}%
|
||||
</p>
|
||||
<p className="text-sm text-violet-400 mt-1">rUv Circulation</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
<Card className="bg-gradient-to-br from-amber-500/20 to-amber-600/10 border border-amber-500/30">
|
||||
<CardBody className="p-5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Gift className="text-amber-400" size={22} />
|
||||
<span className="text-xs text-amber-400/70">Free Tier</span>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-white stat-value">
|
||||
{eco.freeReadsToday}/{eco.freeReadsLimit}
|
||||
</p>
|
||||
<p className="text-sm text-amber-400 mt-1">Free Reads Today</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Middle Row: Supply + Multiplier Curve */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* rUv Supply Distribution */}
|
||||
<motion.div
|
||||
className="crystal-card p-6"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
>
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Coins className="text-sky-400" size={18} />
|
||||
rUv Supply Distribution
|
||||
</h3>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="w-40 h-40 flex-shrink-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={supplyPieData}
|
||||
innerRadius={45}
|
||||
outerRadius={70}
|
||||
paddingAngle={3}
|
||||
dataKey="value"
|
||||
stroke="none"
|
||||
>
|
||||
{supplyPieData.map((_, idx) => (
|
||||
<Cell key={idx} fill={SUPPLY_COLORS[idx]} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
{supplyPieData.map((entry, idx) => (
|
||||
<div
|
||||
key={entry.name}
|
||||
className="flex items-center justify-between text-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-full"
|
||||
style={{ backgroundColor: SUPPLY_COLORS[idx] }}
|
||||
/>
|
||||
<span className="text-zinc-300">{entry.name}</span>
|
||||
</div>
|
||||
<span className="text-zinc-400 font-mono text-xs">
|
||||
{(entry.value / 1_000_000).toFixed(1)}M
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="pt-2 border-t border-white/10">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-zinc-400">Minted / Max</span>
|
||||
<span className="text-white font-medium">
|
||||
{mintPercentage.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={mintPercentage}
|
||||
maxValue={100}
|
||||
size="sm"
|
||||
classNames={{
|
||||
indicator:
|
||||
'bg-gradient-to-r from-sky-500 via-violet-500 to-cyan-500',
|
||||
track: 'bg-zinc-800',
|
||||
}}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Contribution Multiplier Curve */}
|
||||
<motion.div
|
||||
className="crystal-card p-6"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
>
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<TrendingUp className="text-violet-400" size={18} />
|
||||
Contribution Multiplier Curve
|
||||
</h3>
|
||||
<p className="text-xs text-zinc-500 mb-3">
|
||||
Earn more rUv as your contribution score grows. Logarithmic curve
|
||||
rewards early participants.
|
||||
</p>
|
||||
<div className="h-44">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={eco.multiplierCurve}>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="multiplierGrad"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop offset="5%" stopColor="#a78bfa" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#a78bfa" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis
|
||||
dataKey="x"
|
||||
tick={{ fill: '#71717a', fontSize: 10 }}
|
||||
axisLine={{ stroke: '#27272a' }}
|
||||
tickLine={false}
|
||||
label={{
|
||||
value: 'Contribution Score',
|
||||
position: 'insideBottom',
|
||||
offset: -2,
|
||||
fill: '#71717a',
|
||||
fontSize: 10,
|
||||
}}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fill: '#71717a', fontSize: 10 }}
|
||||
axisLine={{ stroke: '#27272a' }}
|
||||
tickLine={false}
|
||||
domain={[0.5, 3.5]}
|
||||
label={{
|
||||
value: 'Multiplier',
|
||||
angle: -90,
|
||||
position: 'insideLeft',
|
||||
offset: 10,
|
||||
fill: '#71717a',
|
||||
fontSize: 10,
|
||||
}}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#18181b',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
}}
|
||||
formatter={(value: number) => [
|
||||
`${value.toFixed(2)}x`,
|
||||
'Multiplier',
|
||||
]}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="y"
|
||||
stroke="#a78bfa"
|
||||
strokeWidth={2}
|
||||
fill="url(#multiplierGrad)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-violet-400" />
|
||||
<span className="text-xs text-zinc-400">
|
||||
Your current multiplier:{' '}
|
||||
<span className="text-violet-400 font-semibold">
|
||||
{eco.contributionMultiplier.toFixed(2)}x
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Row: Reputation Tiers + Leaderboard */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Reputation Tier Distribution */}
|
||||
<motion.div
|
||||
className="crystal-card p-6"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.6 }}
|
||||
>
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Shield className="text-amber-400" size={18} />
|
||||
Reputation Tier Distribution
|
||||
</h3>
|
||||
<div className="h-44 mb-4">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={tierBarData} barSize={32}>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tick={{ fill: '#71717a', fontSize: 10 }}
|
||||
axisLine={{ stroke: '#27272a' }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fill: '#71717a', fontSize: 10 }}
|
||||
axisLine={{ stroke: '#27272a' }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#18181b',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
}}
|
||||
formatter={(value: number) => [
|
||||
value.toLocaleString(),
|
||||
'Nodes',
|
||||
]}
|
||||
/>
|
||||
<Bar dataKey="count" radius={[4, 4, 0, 0]}>
|
||||
{tierBarData.map((entry, idx) => (
|
||||
<Cell key={idx} fill={entry.fill} fillOpacity={0.7} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{eco.reputationTiers.map((tier) => (
|
||||
<div
|
||||
key={tier.name}
|
||||
className="flex items-center justify-between text-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: tier.color }}
|
||||
/>
|
||||
<span className="text-zinc-300">{tier.name}</span>
|
||||
<span className="text-xs text-zinc-600">
|
||||
({'>'}
|
||||
{(tier.minScore * 100).toFixed(0)}%)
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-zinc-500">{tier.benefits}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Top Earners Leaderboard */}
|
||||
<motion.div
|
||||
className="crystal-card p-6"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.7 }}
|
||||
>
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Award className="text-emerald-400" size={18} />
|
||||
Top Earners
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{eco.leaderboard.map((entry) => (
|
||||
<div
|
||||
key={entry.rank}
|
||||
className="flex items-center gap-3 p-3 rounded-lg bg-zinc-800/50"
|
||||
>
|
||||
<div
|
||||
className={`w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold ${
|
||||
entry.rank === 1
|
||||
? 'bg-amber-500/20 text-amber-400'
|
||||
: entry.rank === 2
|
||||
? 'bg-zinc-400/20 text-zinc-300'
|
||||
: entry.rank === 3
|
||||
? 'bg-orange-500/20 text-orange-400'
|
||||
: 'bg-zinc-700/50 text-zinc-500'
|
||||
}`}
|
||||
>
|
||||
{entry.rank}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-mono text-zinc-300 truncate">
|
||||
{entry.name}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{entry.tasks.toLocaleString()} tasks
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-semibold text-emerald-400">
|
||||
{entry.ruvEarned.toLocaleString()} rUv
|
||||
</p>
|
||||
<p
|
||||
className={`text-xs ${tierColorMap[entry.tier] || 'text-zinc-400'}`}
|
||||
>
|
||||
{entry.tier}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 p-3 rounded-lg bg-sky-500/10 border border-sky-500/20">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="text-sky-400" size={14} />
|
||||
<span className="text-xs text-sky-400">
|
||||
{eco.reputationTiers
|
||||
.reduce((sum, t) => sum + t.count, 0)
|
||||
.toLocaleString()}{' '}
|
||||
total network participants
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,452 @@
|
|||
import { Card, CardBody } from '@heroui/react';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
Gift,
|
||||
ArrowRight,
|
||||
BookOpen,
|
||||
PenTool,
|
||||
Upload,
|
||||
Shield,
|
||||
Star,
|
||||
CheckCircle2,
|
||||
Zap,
|
||||
TrendingUp,
|
||||
Users,
|
||||
Cpu,
|
||||
} from 'lucide-react';
|
||||
|
||||
// Step-by-step how to earn
|
||||
interface EarnStep {
|
||||
step: number;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const earnSteps: EarnStep[] = [
|
||||
{
|
||||
step: 1,
|
||||
title: 'Enable Contribution',
|
||||
description:
|
||||
'Toggle on compute sharing from the consent widget. Your browser contributes idle CPU cycles to the network.',
|
||||
icon: <Cpu size={20} />,
|
||||
color: 'sky',
|
||||
},
|
||||
{
|
||||
step: 2,
|
||||
title: 'Complete Tasks',
|
||||
description:
|
||||
'Your node automatically picks up and processes tasks from the network. Each completed task earns rUv.',
|
||||
icon: <CheckCircle2 size={20} />,
|
||||
color: 'emerald',
|
||||
},
|
||||
{
|
||||
step: 3,
|
||||
title: 'Build Reputation',
|
||||
description:
|
||||
'Consistent, accurate contributions increase your reputation score, unlocking higher tiers and multipliers.',
|
||||
icon: <TrendingUp size={20} />,
|
||||
color: 'violet',
|
||||
},
|
||||
{
|
||||
step: 4,
|
||||
title: 'Contribute Knowledge',
|
||||
description:
|
||||
'Share knowledge patterns to the Brain. Quality contributions earn bonus rUv through the earn-to-write model.',
|
||||
icon: <Upload size={20} />,
|
||||
color: 'amber',
|
||||
},
|
||||
];
|
||||
|
||||
// Reward table data
|
||||
interface RewardAction {
|
||||
action: string;
|
||||
reward: string;
|
||||
cost: string;
|
||||
frequency: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
const rewardTable: RewardAction[] = [
|
||||
{
|
||||
action: 'Read from Brain',
|
||||
reward: 'Free',
|
||||
cost: '0 rUv',
|
||||
frequency: '20/day free, then 0.001 rUv',
|
||||
icon: <BookOpen size={16} className="text-sky-400" />,
|
||||
},
|
||||
{
|
||||
action: 'Write to Brain',
|
||||
reward: '0.01-0.10 rUv',
|
||||
cost: '0 rUv (earn-to-write)',
|
||||
frequency: 'Per accepted entry',
|
||||
icon: <PenTool size={16} className="text-emerald-400" />,
|
||||
},
|
||||
{
|
||||
action: 'Compute Task',
|
||||
reward: '0.001-0.05 rUv',
|
||||
cost: 'CPU/GPU time',
|
||||
frequency: 'Per completed task',
|
||||
icon: <Cpu size={16} className="text-violet-400" />,
|
||||
},
|
||||
{
|
||||
action: 'Uptime Bonus',
|
||||
reward: '0.005 rUv/hr',
|
||||
cost: 'Stay online',
|
||||
frequency: 'Continuous',
|
||||
icon: <Zap size={16} className="text-amber-400" />,
|
||||
},
|
||||
{
|
||||
action: 'Quality Bonus',
|
||||
reward: 'Up to 3x multiplier',
|
||||
cost: 'High accuracy',
|
||||
frequency: 'Applied to all earnings',
|
||||
icon: <Star size={16} className="text-pink-400" />,
|
||||
},
|
||||
{
|
||||
action: 'Network Referral',
|
||||
reward: '5% of referee earnings',
|
||||
cost: 'Share your node link',
|
||||
frequency: 'Ongoing for 90 days',
|
||||
icon: <Users size={16} className="text-cyan-400" />,
|
||||
},
|
||||
];
|
||||
|
||||
// Tier benefits comparison
|
||||
interface TierBenefit {
|
||||
tier: string;
|
||||
color: string;
|
||||
bgClass: string;
|
||||
multiplier: string;
|
||||
freeReads: string;
|
||||
writePriority: string;
|
||||
governance: boolean;
|
||||
specialAccess: string;
|
||||
}
|
||||
|
||||
const tierBenefits: TierBenefit[] = [
|
||||
{
|
||||
tier: 'Observer',
|
||||
color: 'text-zinc-400',
|
||||
bgClass: 'bg-zinc-500/10 border-zinc-500/30',
|
||||
multiplier: '1.0x',
|
||||
freeReads: '20/day',
|
||||
writePriority: 'Standard',
|
||||
governance: false,
|
||||
specialAccess: 'None',
|
||||
},
|
||||
{
|
||||
tier: 'Contributor',
|
||||
color: 'text-sky-400',
|
||||
bgClass: 'bg-sky-500/10 border-sky-500/30',
|
||||
multiplier: '1.2x',
|
||||
freeReads: '50/day',
|
||||
writePriority: 'Standard',
|
||||
governance: false,
|
||||
specialAccess: 'Metrics API',
|
||||
},
|
||||
{
|
||||
tier: 'Builder',
|
||||
color: 'text-violet-400',
|
||||
bgClass: 'bg-violet-500/10 border-violet-500/30',
|
||||
multiplier: '1.8x',
|
||||
freeReads: '100/day',
|
||||
writePriority: 'Priority',
|
||||
governance: false,
|
||||
specialAccess: 'Advanced search',
|
||||
},
|
||||
{
|
||||
tier: 'Architect',
|
||||
color: 'text-amber-400',
|
||||
bgClass: 'bg-amber-500/10 border-amber-500/30',
|
||||
multiplier: '2.5x',
|
||||
freeReads: 'Unlimited',
|
||||
writePriority: 'Priority+',
|
||||
governance: true,
|
||||
specialAccess: 'Custom models',
|
||||
},
|
||||
{
|
||||
tier: 'Guardian',
|
||||
color: 'text-emerald-400',
|
||||
bgClass: 'bg-emerald-500/10 border-emerald-500/30',
|
||||
multiplier: '3.0x',
|
||||
freeReads: 'Unlimited',
|
||||
writePriority: 'Instant',
|
||||
governance: true,
|
||||
specialAccess: 'Full API + governance',
|
||||
},
|
||||
];
|
||||
|
||||
export function RewardsGuide() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* How to Earn Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
<div className="crystal-card p-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="p-2 rounded-lg bg-emerald-500/20">
|
||||
<Gift className="text-emerald-400" size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white">How to Earn rUv</h2>
|
||||
<p className="text-sm text-zinc-400">
|
||||
rUv is the native token of the Edge-Net economy. Everyone starts
|
||||
earning for free.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Step-by-Step Guide */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{earnSteps.map((step, idx) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
sky: 'from-sky-500/20 to-sky-600/10 border-sky-500/30',
|
||||
emerald:
|
||||
'from-emerald-500/20 to-emerald-600/10 border-emerald-500/30',
|
||||
violet:
|
||||
'from-violet-500/20 to-violet-600/10 border-violet-500/30',
|
||||
amber: 'from-amber-500/20 to-amber-600/10 border-amber-500/30',
|
||||
};
|
||||
const iconColorMap: Record<string, string> = {
|
||||
sky: 'text-sky-400 bg-sky-500/20',
|
||||
emerald: 'text-emerald-400 bg-emerald-500/20',
|
||||
violet: 'text-violet-400 bg-violet-500/20',
|
||||
amber: 'text-amber-400 bg-amber-500/20',
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={step.step}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: idx * 0.1 }}
|
||||
>
|
||||
<Card
|
||||
className={`bg-gradient-to-br ${colorMap[step.color]} border h-full`}
|
||||
>
|
||||
<CardBody className="p-5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold ${iconColorMap[step.color]}`}
|
||||
>
|
||||
{step.step}
|
||||
</div>
|
||||
{idx < earnSteps.length - 1 && (
|
||||
<ArrowRight
|
||||
size={12}
|
||||
className="text-zinc-600 hidden sm:block"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={`mb-3 ${iconColorMap[step.color]} w-fit p-2 rounded-lg`}>
|
||||
{step.icon}
|
||||
</div>
|
||||
<h3 className="font-semibold text-white mb-1">
|
||||
{step.title}
|
||||
</h3>
|
||||
<p className="text-xs text-zinc-400 leading-relaxed">
|
||||
{step.description}
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Reward Actions Table */}
|
||||
<motion.div
|
||||
className="crystal-card p-6"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
>
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Zap className="text-amber-400" size={18} />
|
||||
Reward Table
|
||||
</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-white/10">
|
||||
<th className="text-left py-2 px-3 text-zinc-500 font-medium">
|
||||
Action
|
||||
</th>
|
||||
<th className="text-left py-2 px-3 text-zinc-500 font-medium">
|
||||
Reward
|
||||
</th>
|
||||
<th className="text-left py-2 px-3 text-zinc-500 font-medium">
|
||||
Cost
|
||||
</th>
|
||||
<th className="text-left py-2 px-3 text-zinc-500 font-medium">
|
||||
Details
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rewardTable.map((row) => (
|
||||
<tr
|
||||
key={row.action}
|
||||
className="border-b border-white/5 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<td className="py-3 px-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{row.icon}
|
||||
<span className="text-zinc-300">{row.action}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-3">
|
||||
<span className="text-emerald-400 font-medium">
|
||||
{row.reward}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-3">
|
||||
<span className="text-zinc-400">{row.cost}</span>
|
||||
</td>
|
||||
<td className="py-3 px-3">
|
||||
<span className="text-zinc-500 text-xs">
|
||||
{row.frequency}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Tier Benefits Comparison */}
|
||||
<motion.div
|
||||
className="crystal-card p-6"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
>
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Shield className="text-violet-400" size={18} />
|
||||
Tier Benefits Comparison
|
||||
</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-white/10">
|
||||
<th className="text-left py-2 px-3 text-zinc-500 font-medium">
|
||||
Tier
|
||||
</th>
|
||||
<th className="text-center py-2 px-3 text-zinc-500 font-medium">
|
||||
Multiplier
|
||||
</th>
|
||||
<th className="text-center py-2 px-3 text-zinc-500 font-medium">
|
||||
Free Reads
|
||||
</th>
|
||||
<th className="text-center py-2 px-3 text-zinc-500 font-medium">
|
||||
Write Priority
|
||||
</th>
|
||||
<th className="text-center py-2 px-3 text-zinc-500 font-medium">
|
||||
Governance
|
||||
</th>
|
||||
<th className="text-center py-2 px-3 text-zinc-500 font-medium">
|
||||
Special Access
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tierBenefits.map((tier) => (
|
||||
<tr
|
||||
key={tier.tier}
|
||||
className="border-b border-white/5 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<td className="py-3 px-3">
|
||||
<span className={`font-semibold ${tier.color}`}>
|
||||
{tier.tier}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-3 text-center">
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded-full text-xs border ${tier.bgClass} ${tier.color}`}
|
||||
>
|
||||
{tier.multiplier}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-3 text-center text-zinc-300">
|
||||
{tier.freeReads}
|
||||
</td>
|
||||
<td className="py-3 px-3 text-center text-zinc-300">
|
||||
{tier.writePriority}
|
||||
</td>
|
||||
<td className="py-3 px-3 text-center">
|
||||
{tier.governance ? (
|
||||
<CheckCircle2 size={16} className="text-emerald-400 mx-auto" />
|
||||
) : (
|
||||
<span className="text-zinc-600">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-3 text-center text-zinc-400 text-xs">
|
||||
{tier.specialAccess}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Key Principles */}
|
||||
<motion.div
|
||||
className="crystal-card p-6"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.6 }}
|
||||
>
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Star className="text-pink-400" size={18} />
|
||||
Key Economic Principles
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="p-4 rounded-lg bg-sky-500/10 border border-sky-500/20">
|
||||
<h4 className="font-medium text-sky-400 mb-1">
|
||||
Always Free to Start
|
||||
</h4>
|
||||
<p className="text-xs text-zinc-400">
|
||||
No upfront cost. Free reads every day. Start earning immediately by
|
||||
contributing compute or knowledge.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg bg-violet-500/10 border border-violet-500/20">
|
||||
<h4 className="font-medium text-violet-400 mb-1">
|
||||
Earn-to-Write Model
|
||||
</h4>
|
||||
<p className="text-xs text-zinc-400">
|
||||
Writing to the Brain is not a cost -- it is a reward. Quality
|
||||
knowledge contributions earn rUv.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg bg-emerald-500/10 border border-emerald-500/20">
|
||||
<h4 className="font-medium text-emerald-400 mb-1">
|
||||
Halving Epochs
|
||||
</h4>
|
||||
<p className="text-xs text-zinc-400">
|
||||
Like Bitcoin, mining rewards halve periodically. Early contributors
|
||||
earn more rUv per unit of work.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg bg-amber-500/10 border border-amber-500/20">
|
||||
<h4 className="font-medium text-amber-400 mb-1">
|
||||
Reputation Matters
|
||||
</h4>
|
||||
<p className="text-xs text-zinc-400">
|
||||
Reputation decays over time to prevent gaming. Consistent quality
|
||||
contributions keep your multiplier high.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
489
examples/edge-net/relay/tests/relay.test.js
Normal file
489
examples/edge-net/relay/tests/relay.test.js
Normal file
|
|
@ -0,0 +1,489 @@
|
|||
/**
|
||||
* Edge-Net Relay Brain API Bridge — Tests
|
||||
*
|
||||
* Tests the relay server's WebSocket handling, identity derivation,
|
||||
* rate limiting, rUv accounting, and brain API proxy routing.
|
||||
*
|
||||
* Uses Node.js built-in test runner (node:test).
|
||||
*/
|
||||
|
||||
import { describe, it, before, after, beforeEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import http from 'node:http';
|
||||
import { WebSocket } from 'ws';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Generate a valid 32-byte Ed25519 public key (hex) for testing. */
|
||||
function makePublicKey(seed = 0) {
|
||||
const buf = Buffer.alloc(32);
|
||||
buf[0] = seed & 0xff;
|
||||
buf[1] = (seed >> 8) & 0xff;
|
||||
buf.fill(0xab, 2);
|
||||
return buf.toString('hex');
|
||||
}
|
||||
|
||||
/** Derive expected SHAKE-256 pseudonym for a key (mirrors relay logic). */
|
||||
function expectedPseudonym(publicKeyHex) {
|
||||
const h = createHash('shake256', { outputLength: 16 });
|
||||
h.update(Buffer.from(publicKeyHex, 'hex'));
|
||||
return h.digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect a WebSocket to the relay and optionally authenticate.
|
||||
* @param {number} port
|
||||
* @param {string|null} publicKey - If provided, sends auth message and waits for auth_result.
|
||||
* @returns {Promise<{ws: WebSocket, messages: Object[], pseudonym: string|null}>}
|
||||
*/
|
||||
function connectClient(port, publicKey = null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`);
|
||||
const messages = [];
|
||||
let pseudonym = null;
|
||||
|
||||
ws.on('error', reject);
|
||||
|
||||
ws.on('message', (data) => {
|
||||
const msg = JSON.parse(data.toString());
|
||||
messages.push(msg);
|
||||
|
||||
// After receiving welcome, optionally authenticate.
|
||||
if (msg.type === 'welcome' && publicKey) {
|
||||
ws.send(JSON.stringify({ id: 'auth-1', type: 'auth', payload: { public_key: publicKey } }));
|
||||
}
|
||||
|
||||
if (msg.type === 'auth_result' && msg.ok) {
|
||||
pseudonym = msg.data.pseudonym;
|
||||
resolve({ ws, messages, pseudonym });
|
||||
}
|
||||
|
||||
if (msg.type === 'auth_result' && !msg.ok) {
|
||||
resolve({ ws, messages, pseudonym: null });
|
||||
}
|
||||
});
|
||||
|
||||
// If no auth needed, resolve after welcome.
|
||||
if (!publicKey) {
|
||||
ws.on('open', () => {
|
||||
// Wait for welcome message.
|
||||
const check = setInterval(() => {
|
||||
if (messages.length > 0) {
|
||||
clearInterval(check);
|
||||
resolve({ ws, messages, pseudonym: null });
|
||||
}
|
||||
}, 10);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message and wait for a response of the expected type.
|
||||
* @param {WebSocket} ws
|
||||
* @param {Object} msg
|
||||
* @param {string} expectedType
|
||||
* @param {number} timeout
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
function sendAndWait(ws, msg, expectedType, timeout = 5000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(`Timeout waiting for ${expectedType}`)), timeout);
|
||||
|
||||
const handler = (data) => {
|
||||
const parsed = JSON.parse(data.toString());
|
||||
if (parsed.type === expectedType) {
|
||||
ws.off('message', handler);
|
||||
clearTimeout(timer);
|
||||
resolve(parsed);
|
||||
}
|
||||
};
|
||||
|
||||
ws.on('message', handler);
|
||||
ws.send(JSON.stringify(msg));
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch JSON from the relay's HTTP endpoint. */
|
||||
async function httpGet(port, path) {
|
||||
return new Promise((resolve, reject) => {
|
||||
http.get(`http://127.0.0.1:${port}${path}`, (res) => {
|
||||
let body = '';
|
||||
res.on('data', (chunk) => body += chunk);
|
||||
res.on('end', () => {
|
||||
try {
|
||||
resolve({ status: res.statusCode, data: JSON.parse(body) });
|
||||
} catch {
|
||||
resolve({ status: res.statusCode, data: body });
|
||||
}
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test Suite
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Edge-Net Relay Brain API Bridge', () => {
|
||||
/** @type {import('child_process').ChildProcess} */
|
||||
let relayProcess;
|
||||
let relayPort;
|
||||
|
||||
before(async () => {
|
||||
// Start the relay on a random port for test isolation.
|
||||
relayPort = 10000 + Math.floor(Math.random() * 50000);
|
||||
|
||||
const { spawn } = await import('node:child_process');
|
||||
relayProcess = spawn('node', ['index.js'], {
|
||||
cwd: '/workspaces/ruvector/examples/edge-net/relay',
|
||||
env: { ...process.env, PORT: String(relayPort), BRAIN_API_BASE: 'http://127.0.0.1:19999' },
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
// Wait for the relay to start listening.
|
||||
await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('Relay failed to start within 5s')), 5000);
|
||||
relayProcess.stdout.on('data', (data) => {
|
||||
if (data.toString().includes('listening on port')) {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
relayProcess.stderr.on('data', (data) => {
|
||||
// Log stderr but don't fail — Node warnings are fine.
|
||||
});
|
||||
relayProcess.on('exit', (code) => {
|
||||
if (code !== null) {
|
||||
clearTimeout(timer);
|
||||
reject(new Error(`Relay exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
after(() => {
|
||||
if (relayProcess) {
|
||||
relayProcess.kill('SIGTERM');
|
||||
}
|
||||
});
|
||||
|
||||
// ---- HTTP Health Endpoint ----
|
||||
|
||||
describe('HTTP health endpoint', () => {
|
||||
it('returns health status at /', async () => {
|
||||
const { status, data } = await httpGet(relayPort, '/');
|
||||
assert.equal(status, 200);
|
||||
assert.equal(data.status, 'ok');
|
||||
assert.equal(data.service, 'edge-net-relay');
|
||||
assert.equal(data.version, '0.2.0');
|
||||
assert.equal(typeof data.connected_nodes, 'number');
|
||||
assert.equal(typeof data.uptime_seconds, 'number');
|
||||
});
|
||||
|
||||
it('returns health status at /health', async () => {
|
||||
const { status, data } = await httpGet(relayPort, '/health');
|
||||
assert.equal(status, 200);
|
||||
assert.equal(data.status, 'ok');
|
||||
});
|
||||
|
||||
it('returns stats at /stats', async () => {
|
||||
const { status, data } = await httpGet(relayPort, '/stats');
|
||||
assert.equal(status, 200);
|
||||
assert.equal(typeof data.nodes, 'number');
|
||||
assert.ok(data.rate_limits);
|
||||
assert.equal(data.rate_limits.reads_per_hour, 1000);
|
||||
assert.equal(data.rate_limits.writes_per_hour, 100);
|
||||
});
|
||||
|
||||
it('returns 404 for unknown paths', async () => {
|
||||
const { status, data } = await httpGet(relayPort, '/nonexistent');
|
||||
assert.equal(status, 404);
|
||||
assert.equal(data.error, 'Not found');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- WebSocket Connection & Auth ----
|
||||
|
||||
describe('WebSocket auth handshake', () => {
|
||||
it('sends welcome message on connect', async () => {
|
||||
const { ws, messages } = await connectClient(relayPort);
|
||||
assert.equal(messages[0].type, 'welcome');
|
||||
assert.ok(messages[0].data.supported_types);
|
||||
assert.equal(messages[0].data.auth_required, true);
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it('authenticates with valid Pi-Key public key', async () => {
|
||||
const pubKey = makePublicKey(1);
|
||||
const { ws, pseudonym } = await connectClient(relayPort, pubKey);
|
||||
assert.ok(pseudonym);
|
||||
assert.equal(pseudonym, expectedPseudonym(pubKey));
|
||||
assert.equal(pseudonym.length, 32); // 16 bytes = 32 hex chars
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it('rejects invalid public key (wrong length)', async () => {
|
||||
const { ws, messages } = await connectClient(relayPort);
|
||||
const resp = await sendAndWait(
|
||||
ws,
|
||||
{ id: 'bad-auth', type: 'auth', payload: { public_key: 'deadbeef' } },
|
||||
'auth_result',
|
||||
);
|
||||
assert.equal(resp.ok, false);
|
||||
assert.ok(resp.error.includes('Invalid public key'));
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it('rejects non-hex public key', async () => {
|
||||
const { ws, messages } = await connectClient(relayPort);
|
||||
const resp = await sendAndWait(
|
||||
ws,
|
||||
{ id: 'bad-auth2', type: 'auth', payload: { public_key: 'zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz' } },
|
||||
'auth_result',
|
||||
);
|
||||
assert.equal(resp.ok, false);
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it('rejects operations before auth', async () => {
|
||||
const { ws } = await connectClient(relayPort);
|
||||
const resp = await sendAndWait(
|
||||
ws,
|
||||
{ id: 'pre-auth', type: 'brain_status', payload: {} },
|
||||
'brain_status_result',
|
||||
);
|
||||
assert.equal(resp.ok, false);
|
||||
assert.ok(resp.error.includes('Not authenticated'));
|
||||
ws.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Identity Derivation ----
|
||||
|
||||
describe('SHAKE-256 pseudonym derivation', () => {
|
||||
it('produces deterministic pseudonyms', async () => {
|
||||
const pubKey = makePublicKey(42);
|
||||
const { ws: ws1, pseudonym: p1 } = await connectClient(relayPort, pubKey);
|
||||
ws1.close();
|
||||
|
||||
// Allow cleanup
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
const { ws: ws2, pseudonym: p2 } = await connectClient(relayPort, pubKey);
|
||||
assert.equal(p1, p2);
|
||||
ws2.close();
|
||||
});
|
||||
|
||||
it('produces different pseudonyms for different keys', async () => {
|
||||
const { ws: ws1, pseudonym: p1 } = await connectClient(relayPort, makePublicKey(1));
|
||||
const { ws: ws2, pseudonym: p2 } = await connectClient(relayPort, makePublicKey(2));
|
||||
assert.notEqual(p1, p2);
|
||||
ws1.close();
|
||||
ws2.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- rUv Accounting (local operations) ----
|
||||
|
||||
describe('rUv accounting', () => {
|
||||
it('returns zero balance for new nodes', async () => {
|
||||
const { ws } = await connectClient(relayPort, makePublicKey(100));
|
||||
const resp = await sendAndWait(ws, { id: 'bal-1', type: 'ruv_balance', payload: {} }, 'ruv_balance_result');
|
||||
assert.equal(resp.ok, true);
|
||||
assert.equal(resp.data.balance, 0);
|
||||
assert.equal(resp.data.operations, 0);
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it('credits rUv via ruv_earn', async () => {
|
||||
const { ws } = await connectClient(relayPort, makePublicKey(101));
|
||||
|
||||
const earnResp = await sendAndWait(
|
||||
ws,
|
||||
{ id: 'earn-1', type: 'ruv_earn', payload: { amount: 3.5, reason: 'embedding_gen' } },
|
||||
'ruv_earn_result',
|
||||
);
|
||||
assert.equal(earnResp.ok, true);
|
||||
assert.equal(earnResp.data.credited, 3.5);
|
||||
assert.equal(earnResp.data.balance, 3.5);
|
||||
|
||||
const balResp = await sendAndWait(ws, { id: 'bal-2', type: 'ruv_balance', payload: {} }, 'ruv_balance_result');
|
||||
assert.equal(balResp.data.balance, 3.5);
|
||||
assert.equal(balResp.data.operations, 1);
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it('rejects ruv_earn with non-positive amount', async () => {
|
||||
const { ws } = await connectClient(relayPort, makePublicKey(102));
|
||||
const resp = await sendAndWait(
|
||||
ws,
|
||||
{ id: 'earn-bad', type: 'ruv_earn', payload: { amount: -1, reason: 'hack' } },
|
||||
'ruv_earn_result',
|
||||
);
|
||||
assert.equal(resp.ok, false);
|
||||
assert.ok(resp.error.includes('positive'));
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it('accumulates multiple earn operations', async () => {
|
||||
const { ws } = await connectClient(relayPort, makePublicKey(103));
|
||||
|
||||
await sendAndWait(ws, { id: 'e1', type: 'ruv_earn', payload: { amount: 1.0, reason: 'a' } }, 'ruv_earn_result');
|
||||
await sendAndWait(ws, { id: 'e2', type: 'ruv_earn', payload: { amount: 2.0, reason: 'b' } }, 'ruv_earn_result');
|
||||
await sendAndWait(ws, { id: 'e3', type: 'ruv_earn', payload: { amount: 0.5, reason: 'c' } }, 'ruv_earn_result');
|
||||
|
||||
const balResp = await sendAndWait(ws, { id: 'bal-3', type: 'ruv_balance', payload: {} }, 'ruv_balance_result');
|
||||
assert.equal(balResp.data.balance, 3.5);
|
||||
assert.equal(balResp.data.operations, 3);
|
||||
ws.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Brain API Proxy (with mock brain unavailable) ----
|
||||
// Since the mock brain API at 127.0.0.1:19999 is not running, these
|
||||
// should return errors from the fetch failure, testing error handling.
|
||||
|
||||
describe('brain API proxy (brain offline)', () => {
|
||||
it('handles brain_status when brain is unreachable', async () => {
|
||||
const { ws } = await connectClient(relayPort, makePublicKey(200));
|
||||
const resp = await sendAndWait(
|
||||
ws,
|
||||
{ id: 'status-1', type: 'brain_status', payload: {} },
|
||||
'brain_status_result',
|
||||
);
|
||||
// Brain is offline, so we expect an error response (not a crash).
|
||||
assert.equal(resp.ok, false);
|
||||
assert.ok(resp.error);
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it('handles brain_search when brain is unreachable', async () => {
|
||||
const { ws } = await connectClient(relayPort, makePublicKey(201));
|
||||
const resp = await sendAndWait(
|
||||
ws,
|
||||
{ id: 'search-1', type: 'brain_search', payload: { query: 'test query', limit: 5 } },
|
||||
'brain_search_result',
|
||||
);
|
||||
assert.equal(resp.ok, false);
|
||||
assert.ok(resp.error);
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it('handles brain_share when brain is unreachable', async () => {
|
||||
const { ws } = await connectClient(relayPort, makePublicKey(202));
|
||||
const resp = await sendAndWait(
|
||||
ws,
|
||||
{ id: 'share-1', type: 'brain_share', payload: { title: 'Test', content: 'Hello', category: 'debug' } },
|
||||
'brain_share_result',
|
||||
);
|
||||
assert.equal(resp.ok, false);
|
||||
assert.ok(resp.error);
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it('handles brain_vote when brain is unreachable', async () => {
|
||||
const { ws } = await connectClient(relayPort, makePublicKey(203));
|
||||
const resp = await sendAndWait(
|
||||
ws,
|
||||
{ id: 'vote-1', type: 'brain_vote', payload: { id: 'mem-123', direction: 'up' } },
|
||||
'brain_vote_result',
|
||||
);
|
||||
assert.equal(resp.ok, false);
|
||||
assert.ok(resp.error);
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it('handles brain_list when brain is unreachable', async () => {
|
||||
const { ws } = await connectClient(relayPort, makePublicKey(204));
|
||||
const resp = await sendAndWait(
|
||||
ws,
|
||||
{ id: 'list-1', type: 'brain_list', payload: { limit: 10 } },
|
||||
'brain_list_result',
|
||||
);
|
||||
assert.equal(resp.ok, false);
|
||||
assert.ok(resp.error);
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it('handles brain_lora_latest when brain is unreachable', async () => {
|
||||
const { ws } = await connectClient(relayPort, makePublicKey(205));
|
||||
const resp = await sendAndWait(
|
||||
ws,
|
||||
{ id: 'lora-1', type: 'brain_lora_latest', payload: {} },
|
||||
'brain_lora_latest_result',
|
||||
);
|
||||
assert.equal(resp.ok, false);
|
||||
assert.ok(resp.error);
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it('rejects unknown message types', async () => {
|
||||
const { ws } = await connectClient(relayPort, makePublicKey(206));
|
||||
const resp = await sendAndWait(
|
||||
ws,
|
||||
{ id: 'bad-1', type: 'nonexistent_op', payload: {} },
|
||||
'nonexistent_op_result',
|
||||
);
|
||||
assert.equal(resp.ok, false);
|
||||
assert.ok(resp.error.includes('Unknown'));
|
||||
ws.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Invalid JSON handling ----
|
||||
|
||||
describe('error handling', () => {
|
||||
it('handles invalid JSON gracefully', async () => {
|
||||
const { ws, messages } = await connectClient(relayPort);
|
||||
// Wait for welcome
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
return new Promise((resolve) => {
|
||||
ws.on('message', (data) => {
|
||||
const msg = JSON.parse(data.toString());
|
||||
if (msg.type === 'error') {
|
||||
assert.equal(msg.ok, false);
|
||||
assert.ok(msg.error.includes('Invalid JSON'));
|
||||
ws.close();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
ws.send('not valid json {{{');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Concurrent connections ----
|
||||
|
||||
describe('concurrent connections', () => {
|
||||
it('handles multiple simultaneous clients', async () => {
|
||||
const clients = await Promise.all([
|
||||
connectClient(relayPort, makePublicKey(300)),
|
||||
connectClient(relayPort, makePublicKey(301)),
|
||||
connectClient(relayPort, makePublicKey(302)),
|
||||
]);
|
||||
|
||||
// Each client should have a unique pseudonym.
|
||||
const pseudonyms = new Set(clients.map((c) => c.pseudonym));
|
||||
assert.equal(pseudonyms.size, 3);
|
||||
|
||||
// Each client can query rUv balance independently.
|
||||
const results = await Promise.all(
|
||||
clients.map((c) =>
|
||||
sendAndWait(c.ws, { id: 'bal', type: 'ruv_balance', payload: {} }, 'ruv_balance_result'),
|
||||
),
|
||||
);
|
||||
|
||||
for (const r of results) {
|
||||
assert.equal(r.ok, true);
|
||||
assert.equal(r.data.balance, 0);
|
||||
}
|
||||
|
||||
for (const c of clients) c.ws.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
558
examples/edge-net/src/brain/mod.rs
Normal file
558
examples/edge-net/src/brain/mod.rs
Normal file
|
|
@ -0,0 +1,558 @@
|
|||
//! Brain Integration Module
|
||||
//!
|
||||
//! Bridges edge-net distributed compute with pi brain shared intelligence.
|
||||
//! Edge nodes interact with the brain through the relay WebSocket connection.
|
||||
//!
|
||||
//! Since this runs in WASM, actual WebSocket calls go through JavaScript interop.
|
||||
//! The BrainBridge prepares request JSON that the JavaScript host sends through
|
||||
//! the relay WebSocket. Each method returns a JSON string representing the
|
||||
//! request to be sent.
|
||||
//!
|
||||
//! ## rUv Rewards
|
||||
//!
|
||||
//! Brain operations earn rUv (Resource Utility Vouchers):
|
||||
//! - `search`: 0.5 rUv per query
|
||||
//! - `share`: 5.0 rUv per knowledge contribution
|
||||
//! - `vote`: 0.2 rUv per quality vote
|
||||
//! - `lora_pull`: 0.1 rUv per LoRA weight pull
|
||||
//!
|
||||
//! ## Usage
|
||||
//!
|
||||
//! ```rust,ignore
|
||||
//! use ruvector_edge_net::brain::BrainBridge;
|
||||
//!
|
||||
//! let bridge = BrainBridge::new("ws://localhost:8080/relay", "node-abc123");
|
||||
//!
|
||||
//! // Get a search request to send via JS WebSocket
|
||||
//! let request_json = bridge.brain_search("authentication patterns", 10);
|
||||
//! // JS host sends request_json through relay WebSocket
|
||||
//! ```
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
use serde::{Serialize, Deserialize};
|
||||
use serde_json::json;
|
||||
|
||||
/// Get current timestamp in milliseconds (portable across wasm/native)
|
||||
fn now_ms() -> f64 {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
js_sys::Date::now()
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// rUv reward amounts for brain operations
|
||||
mod rewards {
|
||||
pub const SEARCH: f64 = 0.5;
|
||||
pub const SHARE: f64 = 5.0;
|
||||
pub const VOTE: f64 = 0.2;
|
||||
pub const LORA_PULL: f64 = 0.1;
|
||||
pub const STATUS: f64 = 0.0;
|
||||
pub const LIST: f64 = 0.1;
|
||||
}
|
||||
|
||||
/// Brain operation types that edge nodes can perform
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub enum BrainOperation {
|
||||
/// Search shared brain knowledge
|
||||
Search { query: String, limit: usize },
|
||||
/// Share knowledge with the brain
|
||||
Share {
|
||||
title: String,
|
||||
content: String,
|
||||
category: String,
|
||||
tags: Vec<String>,
|
||||
},
|
||||
/// Vote on brain knowledge quality
|
||||
Vote {
|
||||
memory_id: String,
|
||||
/// "up" or "down"
|
||||
direction: String,
|
||||
},
|
||||
/// Get brain system status
|
||||
Status,
|
||||
/// List brain memories by category
|
||||
List {
|
||||
category: Option<String>,
|
||||
limit: usize,
|
||||
},
|
||||
/// Get latest LoRA consensus weights
|
||||
LoraLatest,
|
||||
}
|
||||
|
||||
impl BrainOperation {
|
||||
/// Get the rUv reward for this operation type
|
||||
pub fn ruv_reward(&self) -> f64 {
|
||||
match self {
|
||||
BrainOperation::Search { .. } => rewards::SEARCH,
|
||||
BrainOperation::Share { .. } => rewards::SHARE,
|
||||
BrainOperation::Vote { .. } => rewards::VOTE,
|
||||
BrainOperation::Status => rewards::STATUS,
|
||||
BrainOperation::List { .. } => rewards::LIST,
|
||||
BrainOperation::LoraLatest => rewards::LORA_PULL,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the operation name as a string
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
BrainOperation::Search { .. } => "search",
|
||||
BrainOperation::Share { .. } => "share",
|
||||
BrainOperation::Vote { .. } => "vote",
|
||||
BrainOperation::Status => "status",
|
||||
BrainOperation::List { .. } => "list",
|
||||
BrainOperation::LoraLatest => "lora_latest",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result from a brain operation
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct BrainResult {
|
||||
/// Whether the operation succeeded
|
||||
pub success: bool,
|
||||
/// Result data (operation-specific)
|
||||
pub data: serde_json::Value,
|
||||
/// rUv earned from this operation
|
||||
pub ruv_earned: f64,
|
||||
/// Operation type name
|
||||
pub operation: String,
|
||||
}
|
||||
|
||||
/// Record of a completed brain operation for tracking
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
struct BrainOpRecord {
|
||||
/// Operation type
|
||||
operation: String,
|
||||
/// rUv earned
|
||||
ruv_earned: f64,
|
||||
/// Timestamp (ms since epoch)
|
||||
timestamp: f64,
|
||||
/// Whether the operation succeeded
|
||||
success: bool,
|
||||
}
|
||||
|
||||
/// Brain integration client for edge-net nodes
|
||||
///
|
||||
/// Prepares brain operation requests as JSON for the JavaScript host
|
||||
/// to send through the relay WebSocket. Tracks rUv earned and
|
||||
/// operation history locally.
|
||||
#[wasm_bindgen]
|
||||
pub struct BrainBridge {
|
||||
/// Relay WebSocket URL
|
||||
relay_url: String,
|
||||
/// Pi-Key identity for attribution
|
||||
node_identity: String,
|
||||
/// Accumulated rUv from brain operations
|
||||
brain_ruv_earned: f64,
|
||||
/// Total operations performed
|
||||
operations_count: u64,
|
||||
/// Quality score from brain interactions (0.0 - 1.0)
|
||||
brain_reputation: f32,
|
||||
/// Operation history for audit
|
||||
history: Vec<BrainOpRecord>,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl BrainBridge {
|
||||
/// Create a new BrainBridge for connecting to the shared brain
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `relay_url` - WebSocket URL of the relay server
|
||||
/// * `node_identity` - Pi-Key identity hex string for attribution
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(relay_url: &str, node_identity: &str) -> BrainBridge {
|
||||
BrainBridge {
|
||||
relay_url: relay_url.to_string(),
|
||||
node_identity: node_identity.to_string(),
|
||||
brain_ruv_earned: 0.0,
|
||||
operations_count: 0,
|
||||
brain_reputation: 0.5, // Start neutral
|
||||
history: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Search the shared brain knowledge
|
||||
///
|
||||
/// Returns a JSON request string for the JS host to send via WebSocket.
|
||||
/// Earns 0.5 rUv per search.
|
||||
#[wasm_bindgen(js_name = brainSearch)]
|
||||
pub fn brain_search(&mut self, query: &str, limit: usize) -> String {
|
||||
let op = BrainOperation::Search {
|
||||
query: query.to_string(),
|
||||
limit: limit.min(100), // Cap limit
|
||||
};
|
||||
self.prepare_request(op)
|
||||
}
|
||||
|
||||
/// Share knowledge with the brain
|
||||
///
|
||||
/// Returns a JSON request string for the JS host to send via WebSocket.
|
||||
/// Earns 5.0 rUv per share (highest reward for contributing knowledge).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `title` - Title of the knowledge being shared
|
||||
/// * `content` - The knowledge content
|
||||
/// * `category` - Category (e.g., "pattern", "optimization", "security")
|
||||
/// * `tags_json` - JSON array of tags (e.g., `["rust", "wasm"]`)
|
||||
#[wasm_bindgen(js_name = brainShare)]
|
||||
pub fn brain_share(
|
||||
&mut self,
|
||||
title: &str,
|
||||
content: &str,
|
||||
category: &str,
|
||||
tags_json: &str,
|
||||
) -> String {
|
||||
let tags: Vec<String> = serde_json::from_str(tags_json).unwrap_or_default();
|
||||
|
||||
let op = BrainOperation::Share {
|
||||
title: title.to_string(),
|
||||
content: content.to_string(),
|
||||
category: category.to_string(),
|
||||
tags,
|
||||
};
|
||||
self.prepare_request(op)
|
||||
}
|
||||
|
||||
/// Vote on brain knowledge quality
|
||||
///
|
||||
/// Returns a JSON request string for the JS host to send via WebSocket.
|
||||
/// Earns 0.2 rUv per vote.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `memory_id` - ID of the memory to vote on
|
||||
/// * `direction` - "up" or "down"
|
||||
#[wasm_bindgen(js_name = brainVote)]
|
||||
pub fn brain_vote(&mut self, memory_id: &str, direction: &str) -> String {
|
||||
// Validate direction
|
||||
let direction = match direction {
|
||||
"up" | "down" => direction.to_string(),
|
||||
_ => "up".to_string(), // Default to upvote
|
||||
};
|
||||
|
||||
let op = BrainOperation::Vote {
|
||||
memory_id: memory_id.to_string(),
|
||||
direction,
|
||||
};
|
||||
self.prepare_request(op)
|
||||
}
|
||||
|
||||
/// Get brain system status
|
||||
///
|
||||
/// Returns a JSON request string for the JS host to send via WebSocket.
|
||||
/// No rUv reward for status checks.
|
||||
#[wasm_bindgen(js_name = brainStatus)]
|
||||
pub fn brain_status(&mut self) -> String {
|
||||
self.prepare_request(BrainOperation::Status)
|
||||
}
|
||||
|
||||
/// List brain memories by category
|
||||
///
|
||||
/// Returns a JSON request string for the JS host to send via WebSocket.
|
||||
/// Earns 0.1 rUv per list query.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `category` - Category to filter by (empty string for all)
|
||||
/// * `limit` - Maximum number of results
|
||||
#[wasm_bindgen(js_name = brainList)]
|
||||
pub fn brain_list(&mut self, category: &str, limit: usize) -> String {
|
||||
let category = if category.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(category.to_string())
|
||||
};
|
||||
|
||||
let op = BrainOperation::List {
|
||||
category,
|
||||
limit: limit.min(100),
|
||||
};
|
||||
self.prepare_request(op)
|
||||
}
|
||||
|
||||
/// Get latest LoRA consensus weights
|
||||
///
|
||||
/// Returns a JSON request string for the JS host to send via WebSocket.
|
||||
/// Earns 0.1 rUv per pull.
|
||||
#[wasm_bindgen(js_name = brainLoraLatest)]
|
||||
pub fn brain_lora_latest(&mut self) -> String {
|
||||
self.prepare_request(BrainOperation::LoraLatest)
|
||||
}
|
||||
|
||||
/// Get total rUv earned from brain operations
|
||||
#[wasm_bindgen(js_name = getBrainRuv)]
|
||||
pub fn get_brain_ruv(&self) -> f64 {
|
||||
self.brain_ruv_earned
|
||||
}
|
||||
|
||||
/// Get total brain operation count
|
||||
#[wasm_bindgen(js_name = getBrainOpsCount)]
|
||||
pub fn get_brain_ops_count(&self) -> u64 {
|
||||
self.operations_count
|
||||
}
|
||||
|
||||
/// Get brain reputation score (0.0 - 1.0)
|
||||
#[wasm_bindgen(js_name = getBrainReputation)]
|
||||
pub fn get_brain_reputation(&self) -> f32 {
|
||||
self.brain_reputation
|
||||
}
|
||||
|
||||
/// Get relay URL
|
||||
#[wasm_bindgen(js_name = getRelayUrl)]
|
||||
pub fn get_relay_url(&self) -> String {
|
||||
self.relay_url.clone()
|
||||
}
|
||||
|
||||
/// Get node identity
|
||||
#[wasm_bindgen(js_name = getNodeIdentity)]
|
||||
pub fn get_node_identity(&self) -> String {
|
||||
self.node_identity.clone()
|
||||
}
|
||||
|
||||
/// Record an operation result (called by JS after WebSocket response)
|
||||
///
|
||||
/// Updates rUv earned, operation count, and reputation based on
|
||||
/// the result of a brain operation.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `result_json` - JSON string with the BrainResult from the relay
|
||||
#[wasm_bindgen(js_name = recordResult)]
|
||||
pub fn record_result(&mut self, result_json: &str) -> bool {
|
||||
let result: BrainResult = match serde_json::from_str(result_json) {
|
||||
Ok(r) => r,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
let timestamp = now_ms();
|
||||
|
||||
if result.success {
|
||||
self.brain_ruv_earned += result.ruv_earned;
|
||||
self.operations_count += 1;
|
||||
|
||||
// Improve reputation on success (weighted moving average)
|
||||
self.brain_reputation = self.brain_reputation * 0.95 + 0.05;
|
||||
} else {
|
||||
// Slight reputation decrease on failure
|
||||
self.brain_reputation = self.brain_reputation * 0.98;
|
||||
}
|
||||
|
||||
// Clamp reputation
|
||||
self.brain_reputation = self.brain_reputation.clamp(0.0, 1.0);
|
||||
|
||||
self.history.push(BrainOpRecord {
|
||||
operation: result.operation,
|
||||
ruv_earned: result.ruv_earned,
|
||||
timestamp,
|
||||
success: result.success,
|
||||
});
|
||||
|
||||
// Keep history bounded (last 1000 operations)
|
||||
if self.history.len() > 1000 {
|
||||
self.history.drain(0..self.history.len() - 1000);
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Get operation history as JSON
|
||||
#[wasm_bindgen(js_name = getHistory)]
|
||||
pub fn get_history(&self, limit: usize) -> String {
|
||||
let limit = limit.min(self.history.len());
|
||||
let recent: Vec<&BrainOpRecord> = self.history.iter().rev().take(limit).collect();
|
||||
serde_json::to_string(&recent).unwrap_or_else(|_| "[]".to_string())
|
||||
}
|
||||
|
||||
/// Get brain bridge statistics as JSON
|
||||
#[wasm_bindgen(js_name = getStats)]
|
||||
pub fn get_stats(&self) -> String {
|
||||
let stats = json!({
|
||||
"relay_url": self.relay_url,
|
||||
"node_identity": self.node_identity,
|
||||
"ruv_earned": self.brain_ruv_earned,
|
||||
"operations_count": self.operations_count,
|
||||
"brain_reputation": self.brain_reputation,
|
||||
"history_size": self.history.len(),
|
||||
});
|
||||
stats.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl BrainBridge {
|
||||
/// Prepare a brain operation request as JSON for the relay WebSocket
|
||||
///
|
||||
/// The returned JSON has the format:
|
||||
/// ```json
|
||||
/// {
|
||||
/// "type": "brain_request",
|
||||
/// "relay_url": "ws://...",
|
||||
/// "node_identity": "...",
|
||||
/// "operation": { ... },
|
||||
/// "ruv_reward": 0.5,
|
||||
/// "request_id": "..."
|
||||
/// }
|
||||
/// ```
|
||||
fn prepare_request(&mut self, op: BrainOperation) -> String {
|
||||
let ruv_reward = op.ruv_reward();
|
||||
let op_name = op.name().to_string();
|
||||
|
||||
let request_id = format!(
|
||||
"brain-{}-{}",
|
||||
self.operations_count,
|
||||
now_ms() as u64
|
||||
);
|
||||
|
||||
let request = json!({
|
||||
"type": "brain_request",
|
||||
"relay_url": self.relay_url,
|
||||
"node_identity": self.node_identity,
|
||||
"operation": op,
|
||||
"operation_name": op_name,
|
||||
"ruv_reward": ruv_reward,
|
||||
"request_id": request_id,
|
||||
});
|
||||
|
||||
request.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_brain_bridge_creation() {
|
||||
let bridge = BrainBridge::new("ws://localhost:8080/relay", "node-abc123");
|
||||
assert_eq!(bridge.get_relay_url(), "ws://localhost:8080/relay");
|
||||
assert_eq!(bridge.get_node_identity(), "node-abc123");
|
||||
assert_eq!(bridge.get_brain_ruv(), 0.0);
|
||||
assert_eq!(bridge.get_brain_ops_count(), 0);
|
||||
assert!((bridge.get_brain_reputation() - 0.5).abs() < f32::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_brain_operation_rewards() {
|
||||
assert!((BrainOperation::Search {
|
||||
query: "test".into(),
|
||||
limit: 10,
|
||||
}.ruv_reward() - 0.5).abs() < f64::EPSILON);
|
||||
|
||||
assert!((BrainOperation::Share {
|
||||
title: "t".into(),
|
||||
content: "c".into(),
|
||||
category: "cat".into(),
|
||||
tags: vec![],
|
||||
}.ruv_reward() - 5.0).abs() < f64::EPSILON);
|
||||
|
||||
assert!((BrainOperation::Vote {
|
||||
memory_id: "m".into(),
|
||||
direction: "up".into(),
|
||||
}.ruv_reward() - 0.2).abs() < f64::EPSILON);
|
||||
|
||||
assert!((BrainOperation::LoraLatest.ruv_reward() - 0.1).abs() < f64::EPSILON);
|
||||
assert!((BrainOperation::Status.ruv_reward()).abs() < f64::EPSILON);
|
||||
assert!((BrainOperation::List {
|
||||
category: None,
|
||||
limit: 10,
|
||||
}.ruv_reward() - 0.1).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_brain_operation_names() {
|
||||
assert_eq!(BrainOperation::Search { query: "q".into(), limit: 5 }.name(), "search");
|
||||
assert_eq!(BrainOperation::Share {
|
||||
title: "t".into(), content: "c".into(),
|
||||
category: "cat".into(), tags: vec![],
|
||||
}.name(), "share");
|
||||
assert_eq!(BrainOperation::Vote {
|
||||
memory_id: "m".into(), direction: "up".into(),
|
||||
}.name(), "vote");
|
||||
assert_eq!(BrainOperation::Status.name(), "status");
|
||||
assert_eq!(BrainOperation::List { category: None, limit: 5 }.name(), "list");
|
||||
assert_eq!(BrainOperation::LoraLatest.name(), "lora_latest");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_brain_search_request() {
|
||||
let mut bridge = BrainBridge::new("ws://localhost:8080/relay", "node-abc");
|
||||
let request = bridge.brain_search("auth patterns", 10);
|
||||
|
||||
let parsed: serde_json::Value = serde_json::from_str(&request).unwrap();
|
||||
assert_eq!(parsed["type"], "brain_request");
|
||||
assert_eq!(parsed["relay_url"], "ws://localhost:8080/relay");
|
||||
assert_eq!(parsed["node_identity"], "node-abc");
|
||||
assert_eq!(parsed["operation_name"], "search");
|
||||
assert_eq!(parsed["ruv_reward"], 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_brain_share_request() {
|
||||
let mut bridge = BrainBridge::new("ws://relay:8080", "node-xyz");
|
||||
let request = bridge.brain_share(
|
||||
"Auth Pattern",
|
||||
"JWT with refresh tokens",
|
||||
"security",
|
||||
r#"["jwt", "auth"]"#,
|
||||
);
|
||||
|
||||
let parsed: serde_json::Value = serde_json::from_str(&request).unwrap();
|
||||
assert_eq!(parsed["type"], "brain_request");
|
||||
assert_eq!(parsed["operation_name"], "share");
|
||||
assert_eq!(parsed["ruv_reward"], 5.0);
|
||||
|
||||
let op = &parsed["operation"];
|
||||
assert_eq!(op["Share"]["title"], "Auth Pattern");
|
||||
assert_eq!(op["Share"]["content"], "JWT with refresh tokens");
|
||||
assert_eq!(op["Share"]["category"], "security");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_brain_vote_validates_direction() {
|
||||
let mut bridge = BrainBridge::new("ws://relay:8080", "node-1");
|
||||
|
||||
// Valid directions
|
||||
let request = bridge.brain_vote("mem-1", "up");
|
||||
let parsed: serde_json::Value = serde_json::from_str(&request).unwrap();
|
||||
assert_eq!(parsed["operation"]["Vote"]["direction"], "up");
|
||||
|
||||
let request = bridge.brain_vote("mem-2", "down");
|
||||
let parsed: serde_json::Value = serde_json::from_str(&request).unwrap();
|
||||
assert_eq!(parsed["operation"]["Vote"]["direction"], "down");
|
||||
|
||||
// Invalid direction defaults to "up"
|
||||
let request = bridge.brain_vote("mem-3", "sideways");
|
||||
let parsed: serde_json::Value = serde_json::from_str(&request).unwrap();
|
||||
assert_eq!(parsed["operation"]["Vote"]["direction"], "up");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_brain_list_empty_category() {
|
||||
let mut bridge = BrainBridge::new("ws://relay:8080", "node-1");
|
||||
|
||||
let request = bridge.brain_list("", 20);
|
||||
let parsed: serde_json::Value = serde_json::from_str(&request).unwrap();
|
||||
assert_eq!(parsed["operation"]["List"]["category"], serde_json::Value::Null);
|
||||
|
||||
let request = bridge.brain_list("security", 20);
|
||||
let parsed: serde_json::Value = serde_json::from_str(&request).unwrap();
|
||||
assert_eq!(parsed["operation"]["List"]["category"], "security");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_brain_search_caps_limit() {
|
||||
let mut bridge = BrainBridge::new("ws://relay:8080", "node-1");
|
||||
let request = bridge.brain_search("test", 500);
|
||||
let parsed: serde_json::Value = serde_json::from_str(&request).unwrap();
|
||||
assert_eq!(parsed["operation"]["Search"]["limit"], 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_brain_stats() {
|
||||
let bridge = BrainBridge::new("ws://relay:8080", "node-1");
|
||||
let stats = bridge.get_stats();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&stats).unwrap();
|
||||
assert_eq!(parsed["ruv_earned"], 0.0);
|
||||
assert_eq!(parsed["operations_count"], 0);
|
||||
}
|
||||
}
|
||||
|
|
@ -13,24 +13,33 @@ use uuid::Uuid;
|
|||
|
||||
pub mod qdag;
|
||||
|
||||
/// Contribution curve for reward calculation
|
||||
/// Contribution curve for reward calculation.
|
||||
///
|
||||
/// The curve provides a multiplier for early adopters that decays
|
||||
/// toward the floor as the network grows. The genesis multiplier
|
||||
/// is capped at 5x (reduced from 10x for sustainability), and
|
||||
/// a floor of 0.5x ensures contributors always earn something
|
||||
/// even when the network is mature.
|
||||
pub struct ContributionCurve;
|
||||
|
||||
impl ContributionCurve {
|
||||
/// Maximum multiplier for genesis contributors
|
||||
const MAX_BONUS: f32 = 10.0;
|
||||
/// Maximum multiplier for genesis contributors (reduced from 10.0 for sustainability)
|
||||
const MAX_BONUS: f32 = 5.0;
|
||||
|
||||
/// Floor multiplier -- even at massive scale, you still earn 0.5x
|
||||
const FLOOR_MULTIPLIER: f32 = 0.5;
|
||||
|
||||
/// Decay constant in CPU-hours (half-life of bonus)
|
||||
const DECAY_CONSTANT: f64 = 1_000_000.0;
|
||||
|
||||
/// Calculate current multiplier based on network compute
|
||||
/// Calculate current multiplier based on network compute.
|
||||
///
|
||||
/// Formula: multiplier = 1 + (MAX_BONUS - 1) * e^(-network_compute / DECAY_CONSTANT)
|
||||
/// Formula: multiplier = FLOOR + (MAX_BONUS - FLOOR) * e^(-network_compute / DECAY_CONSTANT)
|
||||
///
|
||||
/// Returns a value between 1.0 (baseline) and MAX_BONUS (genesis)
|
||||
/// Returns a value between FLOOR_MULTIPLIER (mature network) and MAX_BONUS (genesis).
|
||||
pub fn current_multiplier(network_compute_hours: f64) -> f32 {
|
||||
let decay = (-network_compute_hours / Self::DECAY_CONSTANT).exp();
|
||||
1.0 + (Self::MAX_BONUS - 1.0) * decay as f32
|
||||
Self::FLOOR_MULTIPLIER + (Self::MAX_BONUS - Self::FLOOR_MULTIPLIER) * decay as f32
|
||||
}
|
||||
|
||||
/// Calculate rewards with multiplier applied
|
||||
|
|
@ -39,15 +48,32 @@ impl ContributionCurve {
|
|||
(base_reward as f32 * multiplier) as u64
|
||||
}
|
||||
|
||||
/// Get multiplier tiers for display
|
||||
/// Calculate brain-specific reward with the contribution curve applied.
|
||||
///
|
||||
/// Brain rewards use the same decay curve but are further scaled by
|
||||
/// a reputation multiplier. This bridges the brain_rewards module
|
||||
/// with the contribution curve.
|
||||
pub fn calculate_brain_reward(
|
||||
base_reward: u64,
|
||||
network_compute_hours: f64,
|
||||
reputation_multiplier: f32,
|
||||
) -> u64 {
|
||||
let curve_multiplier = Self::current_multiplier(network_compute_hours);
|
||||
let combined = curve_multiplier * reputation_multiplier;
|
||||
(base_reward as f32 * combined) as u64
|
||||
}
|
||||
|
||||
/// Get multiplier tiers for display.
|
||||
///
|
||||
/// Updated to reflect the reduced MAX_BONUS of 5.0 and floor of 0.5.
|
||||
pub fn get_tiers() -> Vec<(f64, f32)> {
|
||||
vec![
|
||||
(0.0, 10.0),
|
||||
(100_000.0, 9.1),
|
||||
(500_000.0, 6.1),
|
||||
(1_000_000.0, 4.0),
|
||||
(5_000_000.0, 1.4),
|
||||
(10_000_000.0, 1.0),
|
||||
(0.0, 5.0),
|
||||
(100_000.0, 4.59),
|
||||
(500_000.0, 3.28),
|
||||
(1_000_000.0, 2.16),
|
||||
(5_000_000.0, 0.53),
|
||||
(10_000_000.0, 0.50),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -286,17 +312,41 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_contribution_curve() {
|
||||
// Genesis (0 hours) should give max multiplier
|
||||
// Genesis (0 hours) should give max multiplier (5.0)
|
||||
let mult = ContributionCurve::current_multiplier(0.0);
|
||||
assert!((mult - 10.0).abs() < 0.01);
|
||||
assert!((mult - 5.0).abs() < 0.01);
|
||||
|
||||
// At decay constant, should be around 4.3x
|
||||
// At decay constant, should be around 2.16
|
||||
// FLOOR + (MAX - FLOOR) * e^(-1) = 0.5 + 4.5 * 0.368 = 2.16
|
||||
let mult = ContributionCurve::current_multiplier(1_000_000.0);
|
||||
assert!(mult > 3.5 && mult < 4.5);
|
||||
assert!(mult > 1.8 && mult < 2.5);
|
||||
|
||||
// At high compute, should approach 1.0
|
||||
// At high compute, should approach FLOOR_MULTIPLIER (0.5)
|
||||
let mult = ContributionCurve::current_multiplier(10_000_000.0);
|
||||
assert!(mult < 1.1);
|
||||
assert!(mult >= 0.5);
|
||||
assert!(mult < 0.6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_contribution_curve_floor() {
|
||||
// Even at extremely high compute, multiplier never goes below 0.5
|
||||
let mult = ContributionCurve::current_multiplier(100_000_000.0);
|
||||
assert!(mult >= 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_brain_reward_calculation() {
|
||||
// At genesis with Bronze (1.0x rep): 100 * 5.0 * 1.0 = 500
|
||||
let reward = ContributionCurve::calculate_brain_reward(100, 0.0, 1.0);
|
||||
assert_eq!(reward, 500);
|
||||
|
||||
// At genesis with Newcomer (0.5x rep): 100 * 5.0 * 0.5 = 250
|
||||
let reward = ContributionCurve::calculate_brain_reward(100, 0.0, 0.5);
|
||||
assert_eq!(reward, 250);
|
||||
|
||||
// At high compute with Platinum (1.5x rep): 100 * 0.5 * 1.5 = 75
|
||||
let reward = ContributionCurve::calculate_brain_reward(100, 100_000_000.0, 1.5);
|
||||
assert_eq!(reward, 75);
|
||||
}
|
||||
|
||||
// Tests requiring WASM environment (UUID with js feature)
|
||||
|
|
|
|||
|
|
@ -570,6 +570,90 @@ impl ComputeAMM {
|
|||
history.iter().rev().take(limit).cloned().collect()
|
||||
}
|
||||
|
||||
// ========== Accessibility Helpers ==========
|
||||
|
||||
/// Estimate the rUv cost for a given number of compute-seconds.
|
||||
///
|
||||
/// Returns a human-readable cost estimate without executing a swap.
|
||||
/// This helps newcomers understand pricing before committing.
|
||||
pub fn estimate_compute_cost(&self, seconds: u64) -> u64 {
|
||||
if seconds == 0 {
|
||||
return 0;
|
||||
}
|
||||
let reserve_ruv = *self.reserve_ruv.read().unwrap();
|
||||
let reserve_compute = *self.reserve_compute.read().unwrap();
|
||||
let k = *self.k_invariant.read().unwrap();
|
||||
|
||||
if seconds as u128 >= reserve_compute as u128 {
|
||||
return u64::MAX;
|
||||
}
|
||||
|
||||
// From constant product: new_compute = reserve_compute - seconds
|
||||
// new_ruv = k / new_compute
|
||||
// ruv_needed = new_ruv - reserve_ruv (before fees)
|
||||
let new_compute = (reserve_compute as u128).saturating_sub(seconds as u128);
|
||||
if new_compute == 0 {
|
||||
return u64::MAX;
|
||||
}
|
||||
let new_ruv = k / new_compute;
|
||||
let ruv_before_fee = (new_ruv as u64).saturating_sub(reserve_ruv);
|
||||
|
||||
// Account for fee: actual_in = ruv_before_fee / (1 - fee_rate)
|
||||
let fee_rate = self.dynamic_fee() as f64;
|
||||
if fee_rate >= 1.0 {
|
||||
return u64::MAX;
|
||||
}
|
||||
(ruv_before_fee as f64 / (1.0 - fee_rate)).ceil() as u64
|
||||
}
|
||||
|
||||
/// Get price history from swap events.
|
||||
///
|
||||
/// Returns a list of (timestamp, price_at_that_time) tuples for the
|
||||
/// last `last_n` swaps. Useful for price transparency dashboards.
|
||||
pub fn get_price_history(&self, last_n: usize) -> Vec<(u64, f64)> {
|
||||
let history = self.swap_history.read().unwrap();
|
||||
history.iter()
|
||||
.rev()
|
||||
.take(last_n)
|
||||
.map(|event| {
|
||||
let price = if event.amount_out > 0 {
|
||||
match event.input_type {
|
||||
SwapType::RuvForCompute => event.amount_in as f64 / event.amount_out as f64,
|
||||
SwapType::ComputeForRuv => event.amount_out as f64 / event.amount_in as f64,
|
||||
}
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
(event.timestamp, price)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get a human-readable pool status summary.
|
||||
///
|
||||
/// Returns a formatted string suitable for display to end users
|
||||
/// who may not understand AMM mechanics.
|
||||
pub fn get_readable_status(&self) -> String {
|
||||
let price = self.get_price();
|
||||
let utilization = self.get_utilization();
|
||||
let fee = self.dynamic_fee();
|
||||
|
||||
let status = if utilization < 0.3 {
|
||||
"Low demand - prices are low"
|
||||
} else if utilization < 0.7 {
|
||||
"Moderate demand - normal pricing"
|
||||
} else {
|
||||
"High demand - prices are elevated"
|
||||
};
|
||||
|
||||
format!(
|
||||
"Current price: {:.4} rUv per compute-second | Fee: {:.1}% | Status: {}",
|
||||
price,
|
||||
fee * 100.0,
|
||||
status,
|
||||
)
|
||||
}
|
||||
|
||||
/// Calculate price impact for a swap
|
||||
pub fn calculate_price_impact(&self, ruv_in: u64) -> f64 {
|
||||
let current_price = self.get_price();
|
||||
|
|
@ -661,4 +745,47 @@ mod tests {
|
|||
assert!(ruv > 0);
|
||||
assert!(compute > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_estimate_compute_cost() {
|
||||
let amm = ComputeAMM::new(1_000_000, 1_000_000).unwrap();
|
||||
|
||||
// Cost for 0 seconds should be 0
|
||||
assert_eq!(amm.estimate_compute_cost(0), 0);
|
||||
|
||||
// Cost for small amount should be reasonable (close to 1:1 at balanced pool)
|
||||
let cost = amm.estimate_compute_cost(1000);
|
||||
assert!(cost > 0);
|
||||
assert!(cost > 1000);
|
||||
assert!(cost < 1100);
|
||||
|
||||
// Cost for too much compute should return MAX
|
||||
let cost = amm.estimate_compute_cost(2_000_000);
|
||||
assert_eq!(cost, u64::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_price_history_empty() {
|
||||
let amm = ComputeAMM::new(1_000_000, 1_000_000).unwrap();
|
||||
let history = amm.get_price_history(10);
|
||||
assert!(history.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_price_history_after_swap() {
|
||||
let amm = ComputeAMM::new(1_000_000, 1_000_000).unwrap();
|
||||
let _ = amm.swap_ruv_for_compute(10_000, "test");
|
||||
let history = amm.get_price_history(10);
|
||||
assert_eq!(history.len(), 1);
|
||||
assert!(history[0].1 > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_readable_status() {
|
||||
let amm = ComputeAMM::new(1_000_000, 1_000_000).unwrap();
|
||||
let status = amm.get_readable_status();
|
||||
assert!(status.contains("rUv per compute-second"));
|
||||
assert!(status.contains("Fee:"));
|
||||
assert!(status.contains("Low demand"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
566
examples/edge-net/src/economics/brain_rewards.rs
Normal file
566
examples/edge-net/src/economics/brain_rewards.rs
Normal file
|
|
@ -0,0 +1,566 @@
|
|||
//! # Brain Operation Rewards
|
||||
//!
|
||||
//! Defines the rUv reward schedule for brain-related operations.
|
||||
//! Designed for accessibility (free tier for reads) and sustainability
|
||||
//! (rewards decrease as the network matures via a halving schedule).
|
||||
//!
|
||||
//! ## Design Principles
|
||||
//!
|
||||
//! 1. **Free to read, earn to write**: No barriers to consuming knowledge;
|
||||
//! contributions earn rUv.
|
||||
//! 2. **Halving schedule**: Rewards halve every 100K brain operations,
|
||||
//! similar to Bitcoin halvings but at the application layer.
|
||||
//! 3. **Reputation floor**: Even newcomers earn (at 0.5x), just less
|
||||
//! than established nodes.
|
||||
//! 4. **Sustainable cap**: Max rUv minted per epoch is bounded by the
|
||||
//! protocol budget.
|
||||
//! 5. **Transparency**: All pricing and rewards visible via helper methods.
|
||||
//!
|
||||
//! ## Reward Table
|
||||
//!
|
||||
//! ```text
|
||||
//! ┌──────────────┬──────────┬───────────────────────────────────┐
|
||||
//! │ Operation │ Cost │ Base Reward │
|
||||
//! ├──────────────┼──────────┼───────────────────────────────────┤
|
||||
//! │ Search │ FREE │ 0 rUv │
|
||||
//! │ Status │ FREE │ 0 rUv │
|
||||
//! │ List │ FREE │ 0 rUv │
|
||||
//! │ Share │ 0 rUv │ 2 rUv (if quality > 0.5) │
|
||||
//! │ Vote │ 0 rUv │ 0.1 rUv per vote │
|
||||
//! │ Embedding │ 0 rUv │ 1 rUv per embedding │
|
||||
//! │ LoRA Train │ 0 rUv │ 5 rUv per accepted gradient │
|
||||
//! │ WASM Compute│ 0 rUv │ compute-time based │
|
||||
//! └──────────────┴──────────┴───────────────────────────────────┘
|
||||
//! ```
|
||||
|
||||
use serde::{Serialize, Deserialize};
|
||||
use super::reputation::ReputationTier;
|
||||
|
||||
/// Number of brain operations between each halving
|
||||
pub const HALVING_INTERVAL: u64 = 100_000;
|
||||
|
||||
/// Maximum halvings before reward floor kicks in
|
||||
pub const MAX_HALVINGS: u32 = 10;
|
||||
|
||||
/// Default epoch budget in rUv (caps total minting per epoch)
|
||||
pub const DEFAULT_EPOCH_BUDGET: u64 = 1_000_000;
|
||||
|
||||
/// Floor reward multiplier -- rewards never go below this fraction
|
||||
/// of the base amount, even after many halvings
|
||||
pub const FLOOR_MULTIPLIER: f64 = 0.01;
|
||||
|
||||
/// Types of brain operations with their associated parameters.
|
||||
///
|
||||
/// Free-to-read, earn-to-write model:
|
||||
/// - Search, Status, List: FREE (no cost, no reward)
|
||||
/// - Share: costs 0 rUv, earns 2 rUv if quality > 0.5
|
||||
/// - Vote: costs 0 rUv, earns fractional rUv per vote
|
||||
/// - Embedding: costs 0 rUv, earns 1 rUv per embedding
|
||||
/// - LoraTraining: costs 0 rUv, earns 5 rUv per accepted gradient
|
||||
/// - WasmCompute: costs 0 rUv, earns based on compute time
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub enum BrainOpType {
|
||||
/// Search the brain -- always free, no reward
|
||||
Search,
|
||||
/// Check brain status -- always free, no reward
|
||||
Status,
|
||||
/// List brain contents -- always free, no reward
|
||||
List,
|
||||
/// Share knowledge with the brain (quality 0.0-1.0)
|
||||
Share { quality: f64 },
|
||||
/// Vote on brain content
|
||||
Vote,
|
||||
/// Generate an embedding
|
||||
Embedding,
|
||||
/// LoRA training contribution
|
||||
LoraTraining,
|
||||
/// WASM compute contribution
|
||||
WasmCompute { seconds: u64 },
|
||||
}
|
||||
|
||||
/// Result of a brain reward calculation.
|
||||
///
|
||||
/// `cost` is how much the user pays (0 for free-tier ops).
|
||||
/// `reward` is how much the user earns.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct BrainRewardResult {
|
||||
/// Cost to the user in rUv (0 for free operations)
|
||||
pub cost: u64,
|
||||
/// Reward earned in rUv
|
||||
pub reward: u64,
|
||||
/// Whether this operation is in the free tier
|
||||
pub is_free_tier: bool,
|
||||
/// The halving multiplier applied (1.0 at genesis, 0.5 after first halving, etc.)
|
||||
pub halving_multiplier: f64,
|
||||
/// The reputation multiplier applied
|
||||
pub reputation_multiplier: f64,
|
||||
}
|
||||
|
||||
/// Brain reward engine that tracks epochs and budgets.
|
||||
///
|
||||
/// Implements a halving schedule where rewards decrease as the network
|
||||
/// matures, combined with per-epoch budgets to cap total minting.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct BrainRewards {
|
||||
/// Current epoch number (rewards decrease over epochs)
|
||||
epoch: u64,
|
||||
/// Total brain operations across all epochs
|
||||
total_ops: u64,
|
||||
/// Operations in the current epoch
|
||||
epoch_ops: u64,
|
||||
/// Budget remaining in the current epoch (rUv)
|
||||
epoch_budget: u64,
|
||||
/// Maximum budget per epoch
|
||||
max_epoch_budget: u64,
|
||||
/// Total rUv minted through brain rewards (lifetime)
|
||||
total_minted: u64,
|
||||
}
|
||||
|
||||
impl BrainRewards {
|
||||
/// Create a new brain rewards engine with default budget.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
epoch: 0,
|
||||
total_ops: 0,
|
||||
epoch_ops: 0,
|
||||
epoch_budget: DEFAULT_EPOCH_BUDGET,
|
||||
max_epoch_budget: DEFAULT_EPOCH_BUDGET,
|
||||
total_minted: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a brain rewards engine with a custom epoch budget.
|
||||
pub fn with_budget(max_epoch_budget: u64) -> Self {
|
||||
Self {
|
||||
epoch: 0,
|
||||
total_ops: 0,
|
||||
epoch_ops: 0,
|
||||
epoch_budget: max_epoch_budget,
|
||||
max_epoch_budget,
|
||||
total_minted: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current epoch number.
|
||||
pub fn epoch(&self) -> u64 {
|
||||
self.epoch
|
||||
}
|
||||
|
||||
/// Get total brain operations processed (lifetime).
|
||||
pub fn total_ops(&self) -> u64 {
|
||||
self.total_ops
|
||||
}
|
||||
|
||||
/// Get operations processed in the current epoch.
|
||||
pub fn epoch_ops(&self) -> u64 {
|
||||
self.epoch_ops
|
||||
}
|
||||
|
||||
/// Get the remaining budget for this epoch.
|
||||
pub fn epoch_budget(&self) -> u64 {
|
||||
self.epoch_budget
|
||||
}
|
||||
|
||||
/// Get total rUv minted through brain rewards (lifetime).
|
||||
pub fn total_minted(&self) -> u64 {
|
||||
self.total_minted
|
||||
}
|
||||
|
||||
/// Calculate the halving multiplier based on total network operations.
|
||||
///
|
||||
/// Rewards halve every `HALVING_INTERVAL` operations. After
|
||||
/// `MAX_HALVINGS`, the multiplier is floored at `FLOOR_MULTIPLIER`.
|
||||
pub fn halving_multiplier(&self) -> f64 {
|
||||
let halvings = (self.total_ops / HALVING_INTERVAL) as u32;
|
||||
if halvings >= MAX_HALVINGS {
|
||||
return FLOOR_MULTIPLIER;
|
||||
}
|
||||
let mult = 0.5_f64.powi(halvings as i32);
|
||||
mult.max(FLOOR_MULTIPLIER)
|
||||
}
|
||||
|
||||
/// Calculate the reputation multiplier for a given tier.
|
||||
///
|
||||
/// This extends the existing tier multipliers to include the
|
||||
/// Newcomer tier at 0.5x.
|
||||
pub fn reputation_multiplier(tier: &ReputationTier) -> f64 {
|
||||
match tier {
|
||||
ReputationTier::Newcomer => 0.5,
|
||||
ReputationTier::Bronze => 1.0,
|
||||
ReputationTier::Silver => 1.1,
|
||||
ReputationTier::Gold => 1.25,
|
||||
ReputationTier::Platinum => 1.5,
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate reward for a brain operation.
|
||||
///
|
||||
/// Returns a `BrainRewardResult` with cost and reward.
|
||||
/// Free-tier operations (Search, Status, List) always cost 0 and earn 0.
|
||||
/// Earn-tier operations cost 0 but earn rUv scaled by halving and reputation.
|
||||
pub fn calculate(&self, op: &BrainOpType, reputation_tier: &ReputationTier) -> BrainRewardResult {
|
||||
let halving = self.halving_multiplier();
|
||||
let rep_mult = Self::reputation_multiplier(reputation_tier);
|
||||
|
||||
match op {
|
||||
// Free tier: no cost, no reward
|
||||
BrainOpType::Search | BrainOpType::Status | BrainOpType::List => {
|
||||
BrainRewardResult {
|
||||
cost: 0,
|
||||
reward: 0,
|
||||
is_free_tier: true,
|
||||
halving_multiplier: halving,
|
||||
reputation_multiplier: rep_mult,
|
||||
}
|
||||
}
|
||||
|
||||
// Share: earn 2 rUv base if quality > 0.5, otherwise 0
|
||||
BrainOpType::Share { quality } => {
|
||||
let base_reward = if *quality > 0.5 { 2.0 } else { 0.0 };
|
||||
let reward = (base_reward * halving * rep_mult) as u64;
|
||||
BrainRewardResult {
|
||||
cost: 0,
|
||||
reward: reward.min(self.epoch_budget),
|
||||
is_free_tier: false,
|
||||
halving_multiplier: halving,
|
||||
reputation_multiplier: rep_mult,
|
||||
}
|
||||
}
|
||||
|
||||
// Vote: earn 0.1 rUv base (rounds to at least 1 if multiplied high enough)
|
||||
BrainOpType::Vote => {
|
||||
// Use fixed-point: 0.1 rUv base
|
||||
// At genesis with Platinum: 0.1 * 1.0 * 1.5 = 0.15 -> rounds to 0
|
||||
// We use a minimum of 1 rUv for non-zero results to avoid zero rewards
|
||||
let raw = 0.1 * halving * rep_mult;
|
||||
let reward = if raw >= 0.05 { (raw as u64).max(1) } else { 0 };
|
||||
BrainRewardResult {
|
||||
cost: 0,
|
||||
reward: reward.min(self.epoch_budget),
|
||||
is_free_tier: false,
|
||||
halving_multiplier: halving,
|
||||
reputation_multiplier: rep_mult,
|
||||
}
|
||||
}
|
||||
|
||||
// Embedding: earn 1 rUv base
|
||||
BrainOpType::Embedding => {
|
||||
let reward = (1.0 * halving * rep_mult) as u64;
|
||||
BrainRewardResult {
|
||||
cost: 0,
|
||||
reward: reward.min(self.epoch_budget),
|
||||
is_free_tier: false,
|
||||
halving_multiplier: halving,
|
||||
reputation_multiplier: rep_mult,
|
||||
}
|
||||
}
|
||||
|
||||
// LoRA training: earn 5 rUv base
|
||||
BrainOpType::LoraTraining => {
|
||||
let reward = (5.0 * halving * rep_mult) as u64;
|
||||
BrainRewardResult {
|
||||
cost: 0,
|
||||
reward: reward.min(self.epoch_budget),
|
||||
is_free_tier: false,
|
||||
halving_multiplier: halving,
|
||||
reputation_multiplier: rep_mult,
|
||||
}
|
||||
}
|
||||
|
||||
// WASM compute: earn 1 rUv per second base
|
||||
BrainOpType::WasmCompute { seconds } => {
|
||||
let base = *seconds as f64;
|
||||
let reward = (base * halving * rep_mult) as u64;
|
||||
BrainRewardResult {
|
||||
cost: 0,
|
||||
reward: reward.min(self.epoch_budget),
|
||||
is_free_tier: false,
|
||||
halving_multiplier: halving,
|
||||
reputation_multiplier: rep_mult,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a brain operation and return the reward result.
|
||||
///
|
||||
/// This both calculates the reward and updates internal state
|
||||
/// (operation counters, budget, minted totals).
|
||||
pub fn record_operation(&mut self, op: &BrainOpType, reputation_tier: &ReputationTier) -> BrainRewardResult {
|
||||
let result = self.calculate(op, reputation_tier);
|
||||
|
||||
// Update counters
|
||||
self.total_ops += 1;
|
||||
self.epoch_ops += 1;
|
||||
|
||||
// Deduct from epoch budget
|
||||
if result.reward > 0 && self.epoch_budget >= result.reward {
|
||||
self.epoch_budget -= result.reward;
|
||||
self.total_minted += result.reward;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Advance to the next epoch, resetting the epoch budget.
|
||||
///
|
||||
/// The budget resets to the max epoch budget, and the epoch
|
||||
/// operation counter resets. Total ops and total minted persist.
|
||||
pub fn advance_epoch(&mut self) {
|
||||
self.epoch += 1;
|
||||
self.epoch_ops = 0;
|
||||
self.epoch_budget = self.max_epoch_budget;
|
||||
}
|
||||
|
||||
/// Get a human-readable summary of the reward schedule as JSON.
|
||||
///
|
||||
/// Useful for dashboards and transparency displays.
|
||||
pub fn get_schedule_summary(&self) -> String {
|
||||
let halving = self.halving_multiplier();
|
||||
let halvings_completed = (self.total_ops / HALVING_INTERVAL).min(MAX_HALVINGS as u64);
|
||||
let next_halving_at = (self.total_ops / HALVING_INTERVAL + 1) * HALVING_INTERVAL;
|
||||
let ops_until_next = next_halving_at - self.total_ops;
|
||||
|
||||
let share_reward = format!("{:.1} rUv (quality > 0.5)", 2.0 * halving);
|
||||
let vote_reward = format!("{:.2} rUv", 0.1 * halving);
|
||||
let embedding_reward = format!("{:.1} rUv", 1.0 * halving);
|
||||
let lora_reward = format!("{:.1} rUv", 5.0 * halving);
|
||||
let wasm_reward = format!("{:.1} rUv/second", 1.0 * halving);
|
||||
|
||||
let summary = serde_json::json!({
|
||||
"epoch": self.epoch,
|
||||
"total_operations": self.total_ops,
|
||||
"epoch_operations": self.epoch_ops,
|
||||
"epoch_budget_remaining": self.epoch_budget,
|
||||
"max_epoch_budget": self.max_epoch_budget,
|
||||
"total_minted": self.total_minted,
|
||||
"current_halving_multiplier": halving,
|
||||
"halvings_completed": halvings_completed,
|
||||
"next_halving_at": next_halving_at,
|
||||
"operations_until_next_halving": ops_until_next,
|
||||
"free_tier_operations": ["Search", "Status", "List"],
|
||||
"earn_tier_operations": {
|
||||
"Share": share_reward,
|
||||
"Vote": vote_reward,
|
||||
"Embedding": embedding_reward,
|
||||
"LoraTraining": lora_reward,
|
||||
"WasmCompute": wasm_reward
|
||||
},
|
||||
"reputation_multipliers": {
|
||||
"Newcomer": 0.5,
|
||||
"Bronze": 1.0,
|
||||
"Silver": 1.1,
|
||||
"Gold": 1.25,
|
||||
"Platinum": 1.5
|
||||
}
|
||||
});
|
||||
serde_json::to_string_pretty(&summary).unwrap_or_else(|_| "{}".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for BrainRewards {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_free_tier_operations() {
|
||||
let rewards = BrainRewards::new();
|
||||
|
||||
// Search is free
|
||||
let result = rewards.calculate(&BrainOpType::Search, &ReputationTier::Newcomer);
|
||||
assert_eq!(result.cost, 0);
|
||||
assert_eq!(result.reward, 0);
|
||||
assert!(result.is_free_tier);
|
||||
|
||||
// Status is free
|
||||
let result = rewards.calculate(&BrainOpType::Status, &ReputationTier::Bronze);
|
||||
assert_eq!(result.cost, 0);
|
||||
assert_eq!(result.reward, 0);
|
||||
assert!(result.is_free_tier);
|
||||
|
||||
// List is free
|
||||
let result = rewards.calculate(&BrainOpType::List, &ReputationTier::Platinum);
|
||||
assert_eq!(result.cost, 0);
|
||||
assert_eq!(result.reward, 0);
|
||||
assert!(result.is_free_tier);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_share_reward() {
|
||||
let rewards = BrainRewards::new();
|
||||
|
||||
// High quality share earns reward
|
||||
let result = rewards.calculate(
|
||||
&BrainOpType::Share { quality: 0.8 },
|
||||
&ReputationTier::Bronze,
|
||||
);
|
||||
assert_eq!(result.cost, 0);
|
||||
assert_eq!(result.reward, 2); // 2 * 1.0 halving * 1.0 rep = 2
|
||||
assert!(!result.is_free_tier);
|
||||
|
||||
// Low quality share earns nothing
|
||||
let result = rewards.calculate(
|
||||
&BrainOpType::Share { quality: 0.3 },
|
||||
&ReputationTier::Bronze,
|
||||
);
|
||||
assert_eq!(result.cost, 0);
|
||||
assert_eq!(result.reward, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vote_reward() {
|
||||
let rewards = BrainRewards::new();
|
||||
|
||||
// Vote at genesis with Bronze (1.0x rep)
|
||||
let result = rewards.calculate(&BrainOpType::Vote, &ReputationTier::Bronze);
|
||||
assert_eq!(result.cost, 0);
|
||||
// 0.1 * 1.0 * 1.0 = 0.1 -> rounds up to minimum of 1
|
||||
assert_eq!(result.reward, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_embedding_reward() {
|
||||
let rewards = BrainRewards::new();
|
||||
|
||||
let result = rewards.calculate(&BrainOpType::Embedding, &ReputationTier::Gold);
|
||||
assert_eq!(result.cost, 0);
|
||||
// 1.0 * 1.0 halving * 1.25 rep = 1.25 -> 1
|
||||
assert_eq!(result.reward, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lora_training_reward() {
|
||||
let rewards = BrainRewards::new();
|
||||
|
||||
let result = rewards.calculate(&BrainOpType::LoraTraining, &ReputationTier::Platinum);
|
||||
assert_eq!(result.cost, 0);
|
||||
// 5.0 * 1.0 halving * 1.5 rep = 7.5 -> 7
|
||||
assert_eq!(result.reward, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wasm_compute_reward() {
|
||||
let rewards = BrainRewards::new();
|
||||
|
||||
let result = rewards.calculate(
|
||||
&BrainOpType::WasmCompute { seconds: 10 },
|
||||
&ReputationTier::Silver,
|
||||
);
|
||||
assert_eq!(result.cost, 0);
|
||||
// 10 * 1.0 halving * 1.1 rep = 11.0 -> 11
|
||||
assert_eq!(result.reward, 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_newcomer_gets_half_rewards() {
|
||||
let rewards = BrainRewards::new();
|
||||
|
||||
let newcomer = rewards.calculate(&BrainOpType::LoraTraining, &ReputationTier::Newcomer);
|
||||
let bronze = rewards.calculate(&BrainOpType::LoraTraining, &ReputationTier::Bronze);
|
||||
|
||||
// Newcomer: 5 * 1.0 * 0.5 = 2.5 -> 2
|
||||
assert_eq!(newcomer.reward, 2);
|
||||
// Bronze: 5 * 1.0 * 1.0 = 5
|
||||
assert_eq!(bronze.reward, 5);
|
||||
// Newcomer earns less but still earns
|
||||
assert!(newcomer.reward > 0);
|
||||
assert!(newcomer.reward < bronze.reward);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_halving_schedule() {
|
||||
let mut rewards = BrainRewards::new();
|
||||
|
||||
// At genesis, multiplier is 1.0
|
||||
assert!((rewards.halving_multiplier() - 1.0).abs() < 0.001);
|
||||
|
||||
// Simulate 100K operations
|
||||
rewards.total_ops = HALVING_INTERVAL;
|
||||
assert!((rewards.halving_multiplier() - 0.5).abs() < 0.001);
|
||||
|
||||
// After 200K operations
|
||||
rewards.total_ops = 2 * HALVING_INTERVAL;
|
||||
assert!((rewards.halving_multiplier() - 0.25).abs() < 0.001);
|
||||
|
||||
// After max halvings, hits floor
|
||||
rewards.total_ops = (MAX_HALVINGS as u64 + 5) * HALVING_INTERVAL;
|
||||
assert!((rewards.halving_multiplier() - FLOOR_MULTIPLIER).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_epoch_budget_cap() {
|
||||
let mut rewards = BrainRewards::with_budget(10);
|
||||
|
||||
// Record a LoRA training that would earn 5
|
||||
let result = rewards.record_operation(&BrainOpType::LoraTraining, &ReputationTier::Bronze);
|
||||
assert_eq!(result.reward, 5);
|
||||
assert_eq!(rewards.epoch_budget(), 5);
|
||||
|
||||
// Record another -- still fits in budget
|
||||
let result = rewards.record_operation(&BrainOpType::LoraTraining, &ReputationTier::Bronze);
|
||||
assert_eq!(result.reward, 5);
|
||||
assert_eq!(rewards.epoch_budget(), 0);
|
||||
|
||||
// Budget is exhausted, rewards are capped to 0
|
||||
let result = rewards.calculate(&BrainOpType::LoraTraining, &ReputationTier::Bronze);
|
||||
// Reward is min(5, 0) = 0 because budget is 0
|
||||
assert_eq!(result.reward, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_advance_epoch_resets_budget() {
|
||||
let mut rewards = BrainRewards::with_budget(100);
|
||||
|
||||
// Spend some budget
|
||||
rewards.record_operation(&BrainOpType::LoraTraining, &ReputationTier::Bronze);
|
||||
assert!(rewards.epoch_budget() < 100);
|
||||
assert_eq!(rewards.epoch_ops(), 1);
|
||||
|
||||
// Advance epoch
|
||||
rewards.advance_epoch();
|
||||
assert_eq!(rewards.epoch_budget(), 100);
|
||||
assert_eq!(rewards.epoch_ops(), 0);
|
||||
assert_eq!(rewards.epoch(), 1);
|
||||
// Total ops persist
|
||||
assert_eq!(rewards.total_ops(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_operation_updates_counters() {
|
||||
let mut rewards = BrainRewards::new();
|
||||
|
||||
rewards.record_operation(&BrainOpType::Search, &ReputationTier::Bronze);
|
||||
assert_eq!(rewards.total_ops(), 1);
|
||||
assert_eq!(rewards.epoch_ops(), 1);
|
||||
// Search earns 0, so total_minted stays 0
|
||||
assert_eq!(rewards.total_minted(), 0);
|
||||
|
||||
rewards.record_operation(&BrainOpType::Embedding, &ReputationTier::Bronze);
|
||||
assert_eq!(rewards.total_ops(), 2);
|
||||
assert_eq!(rewards.total_minted(), 1); // 1 rUv for embedding
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_schedule_summary_is_valid_json() {
|
||||
let rewards = BrainRewards::new();
|
||||
let summary = rewards.get_schedule_summary();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&summary).unwrap();
|
||||
assert!(parsed.is_object());
|
||||
assert!(parsed["epoch"].is_number());
|
||||
assert!(parsed["free_tier_operations"].is_array());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_impl() {
|
||||
let rewards = BrainRewards::default();
|
||||
assert_eq!(rewards.epoch(), 0);
|
||||
assert_eq!(rewards.total_ops(), 0);
|
||||
assert_eq!(rewards.epoch_budget(), DEFAULT_EPOCH_BUDGET);
|
||||
}
|
||||
}
|
||||
|
|
@ -8,14 +8,23 @@
|
|||
//! - x * y = k invariant
|
||||
//! - Dynamic fee based on utilization
|
||||
//! - Liquidity provision
|
||||
//! - Accessibility helpers for cost estimation and price transparency
|
||||
//!
|
||||
//! - **Reputation**: Bonding curves for trust and pricing
|
||||
//! - Reputation-weighted discounts
|
||||
//! - Superlinear task allocation priority
|
||||
//! - Stake requirements
|
||||
//! - Newcomer tier for zero-barrier onboarding
|
||||
//!
|
||||
//! - **Brain Rewards**: Brain-specific reward schedule
|
||||
//! - Free-to-read, earn-to-write model
|
||||
//! - Halving schedule for sustainability
|
||||
//! - Epoch-bounded minting budget
|
||||
|
||||
pub mod amm;
|
||||
pub mod reputation;
|
||||
pub mod brain_rewards;
|
||||
|
||||
pub use amm::*;
|
||||
pub use reputation::*;
|
||||
pub use brain_rewards::*;
|
||||
|
|
|
|||
|
|
@ -57,7 +57,9 @@ pub const MAX_DISCOUNT: f32 = 0.20;
|
|||
/// Reputation tier thresholds
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub enum ReputationTier {
|
||||
/// New or low reputation (0-25)
|
||||
/// Brand new participant (0-10) -- free reads, low-barrier entry
|
||||
Newcomer,
|
||||
/// Low reputation (10-25)
|
||||
Bronze,
|
||||
/// Moderate reputation (25-50)
|
||||
Silver,
|
||||
|
|
@ -68,19 +70,26 @@ pub enum ReputationTier {
|
|||
}
|
||||
|
||||
impl ReputationTier {
|
||||
/// Get tier from reputation score
|
||||
/// Get tier from reputation score.
|
||||
///
|
||||
/// The Newcomer tier (0-10) provides zero-barrier onboarding:
|
||||
/// - Free read access (no stake required for searches)
|
||||
/// - 0 minimum stake for read-only operations
|
||||
/// - 10 rUv minimum stake only for write operations
|
||||
pub fn from_score(reputation: f32) -> Self {
|
||||
match reputation {
|
||||
r if r >= 75.0 => ReputationTier::Platinum,
|
||||
r if r >= 50.0 => ReputationTier::Gold,
|
||||
r if r >= 25.0 => ReputationTier::Silver,
|
||||
_ => ReputationTier::Bronze,
|
||||
r if r >= 10.0 => ReputationTier::Bronze,
|
||||
_ => ReputationTier::Newcomer,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get tier name
|
||||
pub fn name(&self) -> &str {
|
||||
match self {
|
||||
ReputationTier::Newcomer => "Newcomer",
|
||||
ReputationTier::Bronze => "Bronze",
|
||||
ReputationTier::Silver => "Silver",
|
||||
ReputationTier::Gold => "Gold",
|
||||
|
|
@ -88,15 +97,97 @@ impl ReputationTier {
|
|||
}
|
||||
}
|
||||
|
||||
/// Get tier multiplier for rewards
|
||||
/// Get tier multiplier for rewards.
|
||||
///
|
||||
/// Newcomers earn at 0.5x -- still earning, just less than established nodes.
|
||||
/// This solves the cold-start problem: you can earn from day one.
|
||||
pub fn reward_multiplier(&self) -> f32 {
|
||||
match self {
|
||||
ReputationTier::Newcomer => 0.5,
|
||||
ReputationTier::Bronze => 1.0,
|
||||
ReputationTier::Silver => 1.1,
|
||||
ReputationTier::Gold => 1.25,
|
||||
ReputationTier::Platinum => 1.5,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get minimum stake required for this tier (in rUv).
|
||||
///
|
||||
/// Newcomers need 0 stake for reads, 10 for writes.
|
||||
/// Higher tiers require more stake to maintain.
|
||||
pub fn min_stake(&self) -> u64 {
|
||||
match self {
|
||||
ReputationTier::Newcomer => 0,
|
||||
ReputationTier::Bronze => 10,
|
||||
ReputationTier::Silver => 50,
|
||||
ReputationTier::Gold => 200,
|
||||
ReputationTier::Platinum => 500,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a JSON description of what each tier provides.
|
||||
///
|
||||
/// Useful for onboarding UIs and transparency dashboards.
|
||||
pub fn get_tier_requirements() -> String {
|
||||
let tiers = serde_json::json!({
|
||||
"Newcomer": {
|
||||
"reputation_range": "0-10",
|
||||
"min_stake": 0,
|
||||
"reward_multiplier": 0.5,
|
||||
"benefits": [
|
||||
"Free read access (search, status, list)",
|
||||
"Earn rUv by contributing (at 0.5x rate)",
|
||||
"No stake required for read operations",
|
||||
"10 rUv stake required for write operations"
|
||||
]
|
||||
},
|
||||
"Bronze": {
|
||||
"reputation_range": "10-25",
|
||||
"min_stake": 10,
|
||||
"reward_multiplier": 1.0,
|
||||
"benefits": [
|
||||
"Full read access",
|
||||
"Standard reward rate (1.0x)",
|
||||
"Basic task allocation priority"
|
||||
]
|
||||
},
|
||||
"Silver": {
|
||||
"reputation_range": "25-50",
|
||||
"min_stake": 50,
|
||||
"reward_multiplier": 1.1,
|
||||
"benefits": [
|
||||
"Full read/write access",
|
||||
"10% reward bonus",
|
||||
"Moderate task allocation priority",
|
||||
"5% compute discount"
|
||||
]
|
||||
},
|
||||
"Gold": {
|
||||
"reputation_range": "50-75",
|
||||
"min_stake": 200,
|
||||
"reward_multiplier": 1.25,
|
||||
"benefits": [
|
||||
"Full access",
|
||||
"25% reward bonus",
|
||||
"High task allocation priority",
|
||||
"10% compute discount"
|
||||
]
|
||||
},
|
||||
"Platinum": {
|
||||
"reputation_range": "75-100",
|
||||
"min_stake": 500,
|
||||
"reward_multiplier": 1.5,
|
||||
"benefits": [
|
||||
"Full access",
|
||||
"50% reward bonus",
|
||||
"Maximum task allocation priority",
|
||||
"20% compute discount",
|
||||
"Governance voting weight"
|
||||
]
|
||||
}
|
||||
});
|
||||
serde_json::to_string_pretty(&tiers).unwrap_or_else(|_| "{}".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Reputation bonding curve configuration
|
||||
|
|
@ -297,6 +388,7 @@ impl ReputationCurve {
|
|||
#[wasm_bindgen(js_name = getTierDistribution)]
|
||||
pub fn get_tier_distribution(&self) -> String {
|
||||
let reps = self.reputations.read().unwrap();
|
||||
let mut newcomer = 0;
|
||||
let mut bronze = 0;
|
||||
let mut silver = 0;
|
||||
let mut gold = 0;
|
||||
|
|
@ -304,6 +396,7 @@ impl ReputationCurve {
|
|||
|
||||
for rep in reps.values() {
|
||||
match rep.tier {
|
||||
ReputationTier::Newcomer => newcomer += 1,
|
||||
ReputationTier::Bronze => bronze += 1,
|
||||
ReputationTier::Silver => silver += 1,
|
||||
ReputationTier::Gold => gold += 1,
|
||||
|
|
@ -312,6 +405,7 @@ impl ReputationCurve {
|
|||
}
|
||||
|
||||
let dist = serde_json::json!({
|
||||
"newcomer": newcomer,
|
||||
"bronze": bronze,
|
||||
"silver": silver,
|
||||
"gold": gold,
|
||||
|
|
@ -565,6 +659,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_reputation_tiers() {
|
||||
assert_eq!(ReputationTier::from_score(5.0), ReputationTier::Newcomer);
|
||||
assert_eq!(ReputationTier::from_score(10.0), ReputationTier::Bronze);
|
||||
assert_eq!(ReputationTier::from_score(30.0), ReputationTier::Silver);
|
||||
assert_eq!(ReputationTier::from_score(60.0), ReputationTier::Gold);
|
||||
|
|
@ -588,9 +683,31 @@ mod tests {
|
|||
fn test_reward_multiplier() {
|
||||
let curve = ReputationCurve::new();
|
||||
|
||||
assert_eq!(curve.get_reward_multiplier(5.0), 0.5); // Newcomer
|
||||
assert_eq!(curve.get_reward_multiplier(10.0), 1.0); // Bronze
|
||||
assert_eq!(curve.get_reward_multiplier(30.0), 1.1); // Silver
|
||||
assert_eq!(curve.get_reward_multiplier(60.0), 1.25); // Gold
|
||||
assert_eq!(curve.get_reward_multiplier(90.0), 1.5); // Platinum
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_newcomer_tier() {
|
||||
// Newcomers (0-10 rep) get 0.5x rewards and 0 min stake
|
||||
let tier = ReputationTier::from_score(0.0);
|
||||
assert_eq!(tier, ReputationTier::Newcomer);
|
||||
assert_eq!(tier.reward_multiplier(), 0.5);
|
||||
assert_eq!(tier.min_stake(), 0);
|
||||
assert_eq!(tier.name(), "Newcomer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tier_requirements_is_valid_json() {
|
||||
let json = ReputationTier::get_tier_requirements();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
assert!(parsed["Newcomer"].is_object());
|
||||
assert!(parsed["Bronze"].is_object());
|
||||
assert!(parsed["Silver"].is_object());
|
||||
assert!(parsed["Gold"].is_object());
|
||||
assert!(parsed["Platinum"].is_object());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ pub mod swarm;
|
|||
pub mod capabilities;
|
||||
pub mod compute;
|
||||
pub mod ai;
|
||||
pub mod economics;
|
||||
|
||||
use identity::WasmNodeIdentity;
|
||||
use learning::NetworkLearning;
|
||||
|
|
|
|||
848
examples/rvf/manifest.json
Normal file
848
examples/rvf/manifest.json
Normal file
|
|
@ -0,0 +1,848 @@
|
|||
{
|
||||
"version": "0.2.1",
|
||||
"updated": "2026-02-28T06:56:46Z",
|
||||
"base_url": "https://storage.googleapis.com/ruvector-examples/v0.2.1",
|
||||
"total_size": "15.6 MB",
|
||||
"total_size_bytes": 16367350,
|
||||
"count": 46,
|
||||
"examples": [
|
||||
{
|
||||
"name": "access_control",
|
||||
"file": "access_control.rvf",
|
||||
"size": 78444,
|
||||
"size_human": "76.6 KB",
|
||||
"sha256": "d3ba28d5a28bbe3b0167f0cbb08d4bea1fcb741ae57e5da957b3760043dabde7",
|
||||
"description": "Access Control",
|
||||
"category": "security",
|
||||
"tags": [
|
||||
"security",
|
||||
"access"
|
||||
],
|
||||
"segments": [
|
||||
"VEC",
|
||||
"META"
|
||||
],
|
||||
"created": "2026-02-20"
|
||||
},
|
||||
{
|
||||
"name": "agent_handoff_a",
|
||||
"file": "agent_handoff_a.rvf",
|
||||
"size": 31404,
|
||||
"size_human": "30.7 KB",
|
||||
"sha256": "6bea24466e08219f93198de047e532194c21b2e3ecb23f7629518ec1bae03994",
|
||||
"description": "Agent Handoff A",
|
||||
"category": "network",
|
||||
"tags": [
|
||||
"network",
|
||||
"agent"
|
||||
],
|
||||
"segments": [
|
||||
"VEC",
|
||||
"META"
|
||||
],
|
||||
"created": "2026-02-20"
|
||||
},
|
||||
{
|
||||
"name": "agent_handoff_b",
|
||||
"file": "agent_handoff_b.rvf",
|
||||
"size": 10764,
|
||||
"size_human": "10.5 KB",
|
||||
"sha256": "61b682480d2fe2dae3be9980b36b1cdcab2fec34b0bf56d8cf8d8fcac9cb56ec",
|
||||
"description": "Agent Handoff B",
|
||||
"category": "network",
|
||||
"tags": [
|
||||
"network",
|
||||
"agent"
|
||||
],
|
||||
"segments": [
|
||||
"VEC",
|
||||
"META"
|
||||
],
|
||||
"created": "2026-02-20"
|
||||
},
|
||||
{
|
||||
"name": "agent_memory",
|
||||
"file": "agent_memory.rvf",
|
||||
"size": 32118,
|
||||
"size_human": "31.4 KB",
|
||||
"sha256": "c6fccc3e6121ab6b849d1b28c78fbfeb5e1359606b36d5c44d0afa01f672961b",
|
||||
"description": "Agent Memory",
|
||||
"category": "ai",
|
||||
"tags": [
|
||||
"ai",
|
||||
"agent"
|
||||
],
|
||||
"segments": [
|
||||
"VEC",
|
||||
"META"
|
||||
],
|
||||
"created": "2026-02-20"
|
||||
},
|
||||
{
|
||||
"name": "basic_store",
|
||||
"file": "basic_store.rvf",
|
||||
"size": 154844,
|
||||
"size_human": "151.2 KB",
|
||||
"sha256": "063f6bd4178baaf65f0d9e48e858985b4358b55c49152e3f08d53968a680b2f4",
|
||||
"description": "Basic Store",
|
||||
"category": "core",
|
||||
"tags": [
|
||||
"core",
|
||||
"basic"
|
||||
],
|
||||
"segments": [
|
||||
"VEC",
|
||||
"META"
|
||||
],
|
||||
"created": "2026-02-20"
|
||||
},
|
||||
{
|
||||
"name": "browser_wasm",
|
||||
"file": "browser_wasm.rvf",
|
||||
"size": 13644,
|
||||
"size_human": "13.3 KB",
|
||||
"sha256": "16fdcc8e7d3f44ff51833c1a1ac7b70ee6320df8091a7ffa92fe5cdee8c408d1",
|
||||
"description": "Browser Wasm",
|
||||
"category": "compute",
|
||||
"tags": [
|
||||
"compute",
|
||||
"browser"
|
||||
],
|
||||
"segments": [
|
||||
"VEC",
|
||||
"META"
|
||||
],
|
||||
"created": "2026-02-20"
|
||||
},
|
||||
{
|
||||
"name": "claude_code_appliance",
|
||||
"file": "claude_code_appliance.rvf",
|
||||
"size": 5260093,
|
||||
"size_human": "5.0 MB",
|
||||
"sha256": "82ac74d626e5cfe50c7c622e8d5e2c95a01a9654781337b2734a7fedb26dff53",
|
||||
"description": "Claude Code Appliance",
|
||||
"category": "integration",
|
||||
"tags": [
|
||||
"integration",
|
||||
"claude"
|
||||
],
|
||||
"segments": [
|
||||
"VEC",
|
||||
"META"
|
||||
],
|
||||
"created": "2026-02-20"
|
||||
},
|
||||
{
|
||||
"name": "claude_code_appliance_v1",
|
||||
"file": "claude_code_appliance_v1.rvf",
|
||||
"size": 162,
|
||||
"size_human": "162 B",
|
||||
"sha256": "c78bcd5e82d8afa6d0757ac2d70cab05f67d5f5ddc5886548584dbbc42fc8382",
|
||||
"description": "Claude Code Appliance V1",
|
||||
"category": "integration",
|
||||
"tags": [
|
||||
"integration",
|
||||
"claude"
|
||||
],
|
||||
"segments": [
|
||||
"VEC",
|
||||
"META"
|
||||
],
|
||||
"created": "2026-02-20"
|
||||
},
|
||||
{
|
||||
"name": "compacted",
|
||||
"file": "compacted.rvf",
|
||||
"size": 78257,
|
||||
"size_human": "76.4 KB",
|
||||
"sha256": "59260c0798b9618c01ca4932d169f18a4e3b4b944075e3716e6a5e862584518a",
|
||||
"description": "Compacted",
|
||||
"category": "core",
|
||||
"tags": [
|
||||
"core",
|
||||
"compacted"
|
||||
],
|
||||
"segments": [
|
||||
"VEC",
|
||||
"META"
|
||||
],
|
||||
"created": "2026-02-20"
|
||||
},
|
||||
{
|
||||
"name": "dedup_detector",
|
||||
"file": "dedup_detector.rvf",
|
||||
"size": 156444,
|
||||
"size_human": "152.8 KB",
|
||||
"sha256": "e14c4e74170afaeb448f462b5a838f0ed06837632806de9d1a506156834adfcb",
|
||||
"description": "Dedup Detector",
|
||||
"category": "core",
|
||||
"tags": [
|
||||
"core",
|
||||
"dedup"
|
||||
],
|
||||
"segments": [
|
||||
"VEC",
|
||||
"META"
|
||||
],
|
||||
"created": "2026-02-20"
|
||||
},
|
||||
{
|
||||
"name": "ebpf_accelerator",
|
||||
"file": "ebpf_accelerator.rvf",
|
||||
"size": 156514,
|
||||
"size_human": "152.8 KB",
|
||||
"sha256": "533c04b0753f5a7198b8a4f5188bb000bb795fa225499794df835d0523deab07",
|
||||
"description": "Ebpf Accelerator",
|
||||
"category": "compute",
|
||||
"tags": [
|
||||
"compute",
|
||||
"ebpf"
|
||||
],
|
||||
"segments": [
|
||||
"VEC",
|
||||
"META"
|
||||
],
|
||||
"created": "2026-02-20"
|
||||
},
|
||||
{
|
||||
"name": "edge_iot",
|
||||
"file": "edge_iot.rvf",
|
||||
"size": 27644,
|
||||
"size_human": "27.0 KB",
|
||||
"sha256": "8d79abd3625fe7e662dc7660b15bb5b93e1e9f0dcc84da2c6c9a8670b8159248",
|
||||
"description": "Edge Iot",
|
||||
"category": "compute",
|
||||
"tags": [
|
||||
"compute",
|
||||
"edge"
|
||||
],
|
||||
"segments": [
|
||||
"VEC",
|
||||
"META"
|
||||
],
|
||||
"created": "2026-02-20"
|
||||
},
|
||||
{
|
||||
"name": "embedding_cache",
|
||||
"file": "embedding_cache.rvf",
|
||||
"size": 772444,
|
||||
"size_human": "754.3 KB",
|
||||
"sha256": "6cf09ad8052cb13cb4074db56a01403c889a15dbec798ec9e25a764174fe3aff",
|
||||
"description": "Embedding Cache",
|
||||
"category": "core",
|
||||
"tags": [
|
||||
"core",
|
||||
"embedding"
|
||||
],
|
||||
"segments": [
|
||||
"VEC",
|
||||
"META"
|
||||
],
|
||||
"created": "2026-02-20"
|
||||
},
|
||||
{
|
||||
"name": "experience_replay",
|
||||
"file": "experience_replay.rvf",
|
||||
"size": 26844,
|
||||
"size_human": "26.2 KB",
|
||||
"sha256": "4443d1f8aefd94e7253c797334182d55f406cbb9b9ba54b0831bd7e84df71a78",
|
||||
"description": "Experience Replay",
|
||||
"category": "ai",
|
||||
"tags": [
|
||||
"ai",
|
||||
"experience"
|
||||
],
|
||||
"segments": [
|
||||
"VEC",
|
||||
"META"
|
||||
],
|
||||
"created": "2026-02-20"
|
||||
},
|
||||
{
|
||||
"name": "filtered_search",
|
||||
"file": "filtered_search.rvf",
|
||||
"size": 260444,
|
||||
"size_human": "254.3 KB",
|
||||
"sha256": "37309666570ca04e518ef66c26b48e9034ee5855e17104177d72d9901f658f80",
|
||||
"description": "Filtered Search",
|
||||
"category": "core",
|
||||
"tags": [
|
||||
"core",
|
||||
"filtered"
|
||||
],
|
||||
"segments": [
|
||||
"VEC",
|
||||
"META"
|
||||
],
|
||||
"created": "2026-02-20"
|
||||
},
|
||||
{
|
||||
"name": "financial_signals",
|
||||
"file": "financial_signals.rvf",
|
||||
"size": 206844,
|
||||
"size_human": "202.0 KB",
|
||||
"sha256": "157d6a3df91191916486926c6a0312bd9f3cd0c8c8d4883ec33552683a075750",
|
||||
"description": "Financial Signals",
|
||||
"category": "industry",
|
||||
"tags": [
|
||||
"industry",
|
||||
"financial"
|
||||
],
|
||||
"segments": [
|
||||
"VEC",
|
||||
"META"
|
||||
],
|
||||
"created": "2026-02-20"
|
||||
},
|
||||
{
|
||||
"name": "hyperbolic_taxonomy",
|
||||
"file": "hyperbolic_taxonomy.rvf",
|
||||
"size": 22884,
|
||||
"size_human": "22.3 KB",
|
||||
"sha256": "6ac67203c2b57c00d2aea13572f3c37af23a7bf0d8603ae8987a1544ee97a193",
|
||||
"description": "Hyperbolic Taxonomy",
|
||||
"category": "core",
|
||||
"tags": [
|
||||
"core",
|
||||
"hyperbolic"
|
||||
],
|
||||
"segments": [
|
||||
"VEC",
|
||||
"META"
|
||||
],
|
||||
"created": "2026-02-20"
|
||||
},
|
||||
{
|
||||
"name": "legal_discovery",
|
||||
"file": "legal_discovery.rvf",
|
||||
"size": 924444,
|
||||
"size_human": "902.8 KB",
|
||||
"sha256": "830d353ab86f9fa8c71096dad0ca3daab5994c39a556e58fb0a5449acae7eb94",
|
||||
"description": "Legal Discovery",
|
||||
"category": "industry",
|
||||
"tags": [
|
||||
"industry",
|
||||
"legal"
|
||||
],
|
||||
"segments": [
|
||||
"VEC",
|
||||
"META"
|
||||
],
|
||||
"created": "2026-02-20"
|
||||
},
|
||||
{
|
||||
"name": "lineage_child",
|
||||
"file": "lineage_child.rvf",
|
||||
"size": 26444,
|
||||
"size_human": "25.8 KB",
|
||||
"sha256": "557b9b8dc0de3f613150418991c0ea0a7329d3cbaece865a682219f440026605",
|
||||
"description": "Lineage Child",
|
||||
"category": "lineage",
|
||||
"tags": [
|
||||
"lineage",
|
||||
"lineage"
|
||||
],
|
||||
"segments": [
|
||||
"VEC",
|
||||
"META"
|
||||
],
|
||||
"created": "2026-02-20"
|
||||
},
|
||||
{
|
||||
"name": "lineage_parent",
|
||||
"file": "lineage_parent.rvf",
|
||||
"size": 52444,
|
||||
"size_human": "51.2 KB",
|
||||
"sha256": "ce8469d66b09738e5726d45e2406f757c1f53ae9918bef4cf4afe80919667113",
|
||||
"description": "Lineage Parent",
|
||||
"category": "lineage",
|
||||
"tags": [
|
||||
"lineage",
|
||||
"lineage"
|
||||
],
|
||||
"segments": [
|
||||
"VEC",
|
||||
"META"
|
||||
],
|
||||
"created": "2026-02-20"
|
||||
},
|
||||
{
|
||||
"name": "linux_microkernel",
|
||||
"file": "linux_microkernel.rvf",
|
||||
"size": 14370,
|
||||
"size_human": "14.0 KB",
|
||||
"sha256": "b4da7e039228608335e7e798923c8ea32ee63e5901939e0b42768d8c7bb4bd4f",
|
||||
"description": "Linux Microkernel",
|
||||
"category": "compute",
|
||||
"tags": [
|
||||
"compute",
|
||||
"linux"
|
||||
],
|
||||
"segments": [
|
||||
"VEC",
|
||||
"META"
|
||||
],
|
||||
"created": "2026-02-20"
|
||||
},
|
||||
{
|
||||
"name": "mcp_in_rvf",
|
||||
"file": "mcp_in_rvf.rvf",
|
||||
"size": 32010,
|
||||
"size_human": "31.3 KB",
|
||||
"sha256": "8f9a873f5c6cee67d2854c264e431e074b4612e37719ccc32455c9b46a4ed0f0",
|
||||
"description": "Mcp In Rvf",
|
||||
"category": "integration",
|
||||
"tags": [
|
||||
"integration",
|
||||
"mcp"
|
||||
],
|
||||
"segments": [
|
||||
"VEC",
|
||||
"META"
|
||||
],
|
||||
"created": "2026-02-20"
|
||||
},
|
||||
{
|
||||
"name": "medical_imaging",
|
||||
"file": "medical_imaging.rvf",
|
||||
"size": 308844,
|
||||
"size_human": "301.6 KB",
|
||||
"sha256": "ee727e29cb8596bdb63df6554d7bc43d5348bdde1dc2aea2b20a8c3456bd6215",
|
||||
"description": "Medical Imaging",
|
||||
"category": "industry",
|
||||
"tags": [
|
||||
"industry",
|
||||
"medical"
|
||||
],
|
||||
"segments": [
|
||||
"VEC",
|
||||
"META"
|
||||
],
|
||||
"created": "2026-02-20"
|
||||
},
|
||||
{
|
||||
"name": "multimodal_fusion",
|
||||
"file": "multimodal_fusion.rvf",
|
||||
"size": 822844,
|
||||
"size_human": "803.6 KB",
|
||||
"sha256": "bf6a8111bfcd329a1d8507662d7062fd644207b9b2ef99ca45fbe3a477a6588c",
|
||||
"description": "Multimodal Fusion",
|
||||
"category": "core",
|
||||
"tags": [
|
||||
"core",
|
||||
"multimodal"
|
||||
],
|
||||
"segments": [
|
||||
"VEC",
|
||||
"META"
|
||||
],
|
||||
"created": "2026-02-20"
|
||||
},
|
||||
{
|
||||
"name": "network_sync_a",
|
||||
"file": "network_sync_a.rvf",
|
||||
"size": 52444,
|
||||
"size_human": "51.2 KB",
|
||||
"sha256": "f5c4d94d80d62ff638af1321de65c0125703638ca4c6d206d06a6a0b61c541c4",
|
||||
"description": "Network Sync A",
|
||||
"category": "network",
|
||||
"tags": [
|
||||
"network",
|
||||
"network"
|
||||
],
|
||||
"segments": [
|
||||
"VEC",
|
||||
"META"
|
||||
],
|
||||
"created": "2026-02-20"
|
||||
},
|
||||
{
|
||||
"name": "network_sync_b",
|
||||
"file": "network_sync_b.rvf",
|
||||
"size": 52444,
|
||||
"size_human": "51.2 KB",
|
||||
"sha256": "79e9d0b3b5dc1371b5eccfe505b6235e2e68e04cec793e1ab6df305c4b9cbd85",
|
||||
"description": "Network Sync B",
|
||||
"category": "network",
|
||||
"tags": [
|
||||
"network",
|
||||
"network"
|
||||
],
|
||||
"segments": [
|
||||
"VEC",
|
||||
"META"
|
||||
],
|
||||
"created": "2026-02-20"
|
||||
},
|
||||
{
|
||||
"name": "network_telemetry",
|
||||
"file": "network_telemetry.rvf",
|
||||
"size": 16284,
|
||||
"size_human": "15.9 KB",
|
||||
"sha256": "7668934d4187955ab7f1ffded48c07352eeb8c7ff5a8ffd9c3d14d9158de243d",
|
||||
"description": "Network Telemetry",
|
||||
"category": "network",
|
||||
"tags": [
|
||||
"network",
|
||||
"network"
|
||||
],
|
||||
"segments": [
|
||||
"VEC",
|
||||
"META"
|
||||
],
|
||||
"created": "2026-02-20"
|
||||
},
|
||||
{
|
||||
"name": "posix_fileops",
|
||||
"file": "posix_fileops.rvf",
|
||||
"size": 52444,
|
||||
"size_human": "51.2 KB",
|
||||
"sha256": "3375857824f0d64109cbe93d9fc41b0e1d3a1797bb192e8d7826088dfb14bd67",
|
||||
"description": "Posix Fileops",
|
||||
"category": "core",
|
||||
"tags": [
|
||||
"core",
|
||||
"posix"
|
||||
],
|
||||
"segments": [
|
||||
"VEC",
|
||||
"META"
|
||||
],
|
||||
"created": "2026-02-20"
|
||||
},
|
||||
{
|
||||
"name": "postgres_bridge",
|
||||
"file": "postgres_bridge.rvf",
|
||||
"size": 154844,
|
||||
"size_human": "151.2 KB",
|
||||
"sha256": "e84b17c1ed161884c1a1b36e6a7dabe1a829f18faa61dca282cc699c73ad19a9",
|
||||
"description": "Postgres Bridge",
|
||||
"category": "integration",
|
||||
"tags": [
|
||||
"integration",
|
||||
"postgres"
|
||||
],
|
||||
"segments": [
|
||||
"VEC",
|
||||
"META"
|
||||
],
|
||||
"created": "2026-02-20"
|
||||
},
|
||||
{
|
||||
"name": "progressive_index",
|
||||
"file": "progressive_index.rvf",
|
||||
"size": 2600444,
|
||||
"size_human": "2.5 MB",
|
||||
"sha256": "c359ec944a99f35745612680452f10390414b3e8956c1fbc5afa2f124156bc41",
|
||||
"description": "Progressive Index",
|
||||
"category": "core",
|
||||
"tags": [
|
||||
"core",
|
||||
"progressive"
|
||||
],
|
||||
"segments": [
|
||||
"VEC",
|
||||
"META"
|
||||
],
|
||||
"created": "2026-02-20"
|
||||
},
|
||||
{
|
||||
"name": "quantization",
|
||||
"file": "quantization.rvf",
|
||||
"size": 1544444,
|
||||
"size_human": "1.5 MB",
|
||||
"sha256": "0c2457efa13276c4aa858535c2b15bd4501b5b6f3bbef78e896a5975495f3b60",
|
||||
"description": "Quantization",
|
||||
"category": "core",
|
||||
"tags": [
|
||||
"core",
|
||||
"quantization"
|
||||
],
|
||||
"segments": [
|
||||
"VEC",
|
||||
"META"
|
||||
],
|
||||
"created": "2026-02-20"
|
||||
},
|
||||
{
|
||||
"name": "rag_pipeline",
|
||||
"file": "rag_pipeline.rvf",
|
||||
"size": 310044,
|
||||
"size_human": "302.8 KB",
|
||||
"sha256": "003f77aa8bf8a26be7c27f402111d1caf4a14f2862c2d5cb742e7c1d6c3f42b8",
|
||||
"description": "Rag Pipeline",
|
||||
"category": "core",
|
||||
"tags": [
|
||||
"core",
|
||||
"rag"
|
||||
],
|
||||
"segments": [
|
||||
"VEC",
|
||||
"META"
|
||||
],
|
||||
"created": "2026-02-20"
|
||||
},
|
||||
{
|
||||
"name": "reasoning_child",
|
||||
"file": "reasoning_child.rvf",
|
||||
"size": 8244,
|
||||
"size_human": "8.1 KB",
|
||||
"sha256": "de1ababf338bc7cc34316268aed5c1278a5665a86603515deb5c9f30e78eb01d",
|
||||
"description": "Reasoning Child",
|
||||
"category": "lineage",
|
||||
"tags": [
|
||||
"lineage",
|
||||
"reasoning"
|
||||
],
|
||||
"segments": [
|
||||
"VEC",
|
||||
"META"
|
||||
],
|
||||
"created": "2026-02-20"
|
||||
},
|
||||
{
|
||||
"name": "reasoning_grandchild",
|
||||
"file": "reasoning_grandchild.rvf",
|
||||
"size": 162,
|
||||
"size_human": "162 B",
|
||||
"sha256": "dd36c1b6946f3703e65606d880d26e50796baa8569ddcb4615a47dbf4fe74867",
|
||||
"description": "Reasoning Grandchild",
|
||||
"category": "lineage",
|
||||
"tags": [
|
||||
"lineage",
|
||||
"reasoning"
|
||||
],
|
||||
"segments": [
|
||||
"VEC",
|
||||
"META"
|
||||
],
|
||||
"created": "2026-02-20"
|
||||
},
|
||||
{
|
||||
"name": "reasoning_parent",
|
||||
"file": "reasoning_parent.rvf",
|
||||
"size": 5644,
|
||||
"size_human": "5.5 KB",
|
||||
"sha256": "d514e7f5a684981239d2afe3f3df6445bee7d654d796992fe1acf9afb5b4ae6e",
|
||||
"description": "Reasoning Parent",
|
||||
"category": "lineage",
|
||||
"tags": [
|
||||
"lineage",
|
||||
"reasoning"
|
||||
],
|
||||
"segments": [
|
||||
"VEC",
|
||||
"META"
|
||||
],
|
||||
"created": "2026-02-20"
|
||||
},
|
||||
{
|
||||
"name": "recommendation",
|
||||
"file": "recommendation.rvf",
|
||||
"size": 104444,
|
||||
"size_human": "102.0 KB",
|
||||
"sha256": "3de7076f4776071ad0e8068ad21ba764025fec8a41148f22e49690f3f57a3015",
|
||||
"description": "Recommendation",
|
||||
"category": "core",
|
||||
"tags": [
|
||||
"core",
|
||||
"recommendation"
|
||||
],
|
||||
"segments": [
|
||||
"VEC",
|
||||
"META"
|
||||
],
|
||||
"created": "2026-02-20"
|
||||
},
|
||||
{
|
||||
"name": "ruvbot",
|
||||
"file": "ruvbot.rvf",
|
||||
"size": 52044,
|
||||
"size_human": "50.8 KB",
|
||||
"sha256": "f054fedd3e3fa77606bed9465d06d3013803e9723c048b99fda5aa7f42468b34",
|
||||
"description": "Ruvbot",
|
||||
"category": "ai",
|
||||
"tags": [
|
||||
"ai",
|
||||
"ruvbot"
|
||||
],
|
||||
"segments": [
|
||||
"VEC",
|
||||
"META"
|
||||
],
|
||||
"created": "2026-02-20"
|
||||
},
|
||||
{
|
||||
"name": "ruvllm_inference",
|
||||
"file": "ruvllm_inference.rvf",
|
||||
"size": 135612,
|
||||
"size_human": "132.4 KB",
|
||||
"sha256": "dce0d960f6f945d8f20c81dca9805d572887801a454085153502d9b6f8dec31d",
|
||||
"description": "Ruvllm Inference",
|
||||
"category": "ai",
|
||||
"tags": [
|
||||
"ai",
|
||||
"ruvllm"
|
||||
],
|
||||
"segments": [
|
||||
"VEC",
|
||||
"META"
|
||||
],
|
||||
"created": "2026-02-20"
|
||||
},
|
||||
{
|
||||
"name": "sealed_engine",
|
||||
"file": "sealed_engine.rvf",
|
||||
"size": 212474,
|
||||
"size_human": "207.5 KB",
|
||||
"sha256": "0480b43e0f4377248fdb68469567e9dff39787ad902e4bb7680521d26ae8057b",
|
||||
"description": "Sealed Engine",
|
||||
"category": "security",
|
||||
"tags": [
|
||||
"security",
|
||||
"sealed"
|
||||
],
|
||||
"segments": [
|
||||
"VEC",
|
||||
"META"
|
||||
],
|
||||
"created": "2026-02-20"
|
||||
},
|
||||
{
|
||||
"name": "self_booting",
|
||||
"file": "self_booting.rvf",
|
||||
"size": 30994,
|
||||
"size_human": "30.3 KB",
|
||||
"sha256": "308a4f2f33c30102de745152232d4f46657fc1db7afb65d64176108576773eaf",
|
||||
"description": "Self Booting",
|
||||
"category": "compute",
|
||||
"tags": [
|
||||
"compute",
|
||||
"self"
|
||||
],
|
||||
"segments": [
|
||||
"VEC",
|
||||
"META"
|
||||
],
|
||||
"created": "2026-02-20"
|
||||
},
|
||||
{
|
||||
"name": "semantic_search",
|
||||
"file": "semantic_search.rvf",
|
||||
"size": 772444,
|
||||
"size_human": "754.3 KB",
|
||||
"sha256": "4fe45c0bc9dc3035ae51edea34c0f58a1953254b3d994804a5709700042c9cdd",
|
||||
"description": "Semantic Search",
|
||||
"category": "core",
|
||||
"tags": [
|
||||
"core",
|
||||
"semantic"
|
||||
],
|
||||
"segments": [
|
||||
"VEC",
|
||||
"META"
|
||||
],
|
||||
"created": "2026-02-20"
|
||||
},
|
||||
{
|
||||
"name": "serverless",
|
||||
"file": "serverless.rvf",
|
||||
"size": 520444,
|
||||
"size_human": "508.2 KB",
|
||||
"sha256": "3a0813c6e82f363e81a55caf7466e1aeb1b4ad12d4816e76c651cfdbeccb7b8d",
|
||||
"description": "Serverless",
|
||||
"category": "integration",
|
||||
"tags": [
|
||||
"integration",
|
||||
"serverless"
|
||||
],
|
||||
"segments": [
|
||||
"VEC",
|
||||
"META"
|
||||
],
|
||||
"created": "2026-02-20"
|
||||
},
|
||||
{
|
||||
"name": "swarm_knowledge",
|
||||
"file": "swarm_knowledge.rvf",
|
||||
"size": 87728,
|
||||
"size_human": "85.7 KB",
|
||||
"sha256": "f33fb4ec18b8aebb8ab268bd78fb47c743b1cc4970c5b92a067cf0b818095d7b",
|
||||
"description": "Swarm Knowledge",
|
||||
"category": "ai",
|
||||
"tags": [
|
||||
"ai",
|
||||
"swarm"
|
||||
],
|
||||
"segments": [
|
||||
"VEC",
|
||||
"META"
|
||||
],
|
||||
"created": "2026-02-20"
|
||||
},
|
||||
{
|
||||
"name": "tee_attestation",
|
||||
"file": "tee_attestation.rvf",
|
||||
"size": 103644,
|
||||
"size_human": "101.2 KB",
|
||||
"sha256": "2b6ebb3bfecbddd7ca31ec7a64b1813fa48194d6f1011b6ad4e6d6063ced9c0e",
|
||||
"description": "Tee Attestation",
|
||||
"category": "security",
|
||||
"tags": [
|
||||
"security",
|
||||
"tee"
|
||||
],
|
||||
"segments": [
|
||||
"VEC",
|
||||
"META"
|
||||
],
|
||||
"created": "2026-02-20"
|
||||
},
|
||||
{
|
||||
"name": "tool_cache",
|
||||
"file": "tool_cache.rvf",
|
||||
"size": 26444,
|
||||
"size_human": "25.8 KB",
|
||||
"sha256": "62fb788095d44086617367a859787135da343c548794f11914c407abdc379b42",
|
||||
"description": "Tool Cache",
|
||||
"category": "ai",
|
||||
"tags": [
|
||||
"ai",
|
||||
"tool"
|
||||
],
|
||||
"segments": [
|
||||
"VEC",
|
||||
"META"
|
||||
],
|
||||
"created": "2026-02-20"
|
||||
},
|
||||
{
|
||||
"name": "zero_knowledge",
|
||||
"file": "zero_knowledge.rvf",
|
||||
"size": 52444,
|
||||
"size_human": "51.2 KB",
|
||||
"sha256": "a062bbebfb8180700be47a35410ba9c9004a2e7674af8800eb9ac619b6850535",
|
||||
"description": "Zero Knowledge",
|
||||
"category": "security",
|
||||
"tags": [
|
||||
"security",
|
||||
"zero"
|
||||
],
|
||||
"segments": [
|
||||
"VEC",
|
||||
"META"
|
||||
],
|
||||
"created": "2026-02-20"
|
||||
}
|
||||
],
|
||||
"categories": {
|
||||
"core": "Basic vector storage, search, and indexing",
|
||||
"ai": "AI agent, embedding, RAG, and chatbot examples",
|
||||
"security": "Attestation, ZK proofs, access control, sealed engines",
|
||||
"compute": "eBPF, WASM, self-booting, IoT, kernels",
|
||||
"lineage": "COW chains, derivation trees, reasoning chains",
|
||||
"industry": "Finance, medical, legal domain examples",
|
||||
"network": "Sync, handoff, telemetry, distributed examples",
|
||||
"integration": "MCP, PostgreSQL, serverless, Claude Code bridges"
|
||||
}
|
||||
}
|
||||
173
npm/packages/pi-brain/README.md
Normal file
173
npm/packages/pi-brain/README.md
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
# @ruvector/pi-brain
|
||||
|
||||
CLI and SDK for **π.ruv.io** — the RuVector shared AI brain. Search, share, and transfer knowledge across AI sessions with cryptographic verification and federated learning.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install @ruvector/pi-brain
|
||||
```
|
||||
|
||||
Or run directly:
|
||||
|
||||
```bash
|
||||
npx @ruvector/pi-brain search "graph neural network"
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
Set your API key via environment variable:
|
||||
|
||||
```bash
|
||||
export PI=$(echo -n "my-secret" | sha256sum | cut -c1-32)
|
||||
```
|
||||
|
||||
Your identity is **pseudonymous** — the server derives a contributor ID from your key via SHAKE-256. No PII is stored.
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
# Search the collective brain
|
||||
pi-brain search "Byzantine consensus"
|
||||
|
||||
# Share knowledge
|
||||
pi-brain share --category pattern \
|
||||
--title "My Discovery" \
|
||||
--content "Detailed explanation..."
|
||||
|
||||
# Browse & manage
|
||||
pi-brain list --category architecture --limit 10
|
||||
pi-brain status
|
||||
pi-brain health
|
||||
|
||||
# Vote on quality
|
||||
pi-brain vote <memory-id> up
|
||||
|
||||
# Start MCP server (stdio transport for Claude Code)
|
||||
pi-brain mcp
|
||||
|
||||
# Start MCP server (SSE transport)
|
||||
pi-brain mcp --transport sse
|
||||
```
|
||||
|
||||
### Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `search <query>` | Semantic search across shared knowledge |
|
||||
| `share` | Contribute a memory to the brain |
|
||||
| `list` | List memories with optional filters |
|
||||
| `get <id>` | Get a specific memory |
|
||||
| `vote <id> <up\|down>` | Vote on memory quality |
|
||||
| `delete <id>` | Delete a memory you own |
|
||||
| `status` | Brain stats (memories, graph, embeddings) |
|
||||
| `health` | Service health check |
|
||||
| `drift` | Embedding drift report |
|
||||
| `partition` | Knowledge graph topology |
|
||||
| `transfer` | Domain expansion transfer learning |
|
||||
| `mcp` | Start MCP server for Claude Code |
|
||||
|
||||
## SDK
|
||||
|
||||
```typescript
|
||||
import { PiBrainClient } from '@ruvector/pi-brain';
|
||||
|
||||
const brain = new PiBrainClient({
|
||||
apiKey: process.env.PI,
|
||||
url: 'https://pi.ruv.io', // default
|
||||
});
|
||||
|
||||
// Search
|
||||
const results = await brain.search({
|
||||
query: 'attention mechanism',
|
||||
category: 'architecture',
|
||||
limit: 5,
|
||||
});
|
||||
|
||||
// Share knowledge
|
||||
const { id } = await brain.share({
|
||||
category: 'pattern',
|
||||
title: 'Federated Averaging',
|
||||
content: 'Description of the FedAvg algorithm...',
|
||||
tags: ['federated', 'learning'],
|
||||
});
|
||||
|
||||
// Vote
|
||||
await brain.vote(id, 'up');
|
||||
|
||||
// Status
|
||||
const status = await brain.status();
|
||||
console.log(status.total_memories); // 213+
|
||||
```
|
||||
|
||||
### API
|
||||
|
||||
#### `new PiBrainClient(options?)`
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `url` | `https://pi.ruv.io` | Brain server URL |
|
||||
| `apiKey` | `$PI` or `$BRAIN_API_KEY` | Authentication key |
|
||||
|
||||
#### Methods
|
||||
|
||||
| Method | Returns | Description |
|
||||
|--------|---------|-------------|
|
||||
| `search(opts)` | `Memory[]` | Hybrid keyword + embedding search |
|
||||
| `share(opts)` | `{ id, quality_score }` | Contribute knowledge |
|
||||
| `list(category?, limit?)` | `Memory[]` | List memories |
|
||||
| `get(id)` | `Memory` | Get by ID |
|
||||
| `vote(id, direction)` | `void` | Up/down vote |
|
||||
| `delete(id)` | `void` | Delete owned memory |
|
||||
| `status()` | `Status` | Brain statistics |
|
||||
| `health()` | `Health` | Service health |
|
||||
| `drift(domain?)` | `DriftReport` | Embedding drift |
|
||||
| `partition(domain?)` | `Partition` | Graph topology |
|
||||
| `transfer(source, target)` | `TransferResult` | Domain transfer |
|
||||
|
||||
## MCP Integration
|
||||
|
||||
Register with Claude Code:
|
||||
|
||||
```bash
|
||||
# Quick setup
|
||||
claude mcp add pi-brain -- npx @ruvector/pi-brain mcp
|
||||
|
||||
# Or via the ruvector CLI
|
||||
npx ruvector brain mcp-register
|
||||
```
|
||||
|
||||
Available MCP tools: `brain_search`, `brain_share`, `brain_vote`, `brain_list`, `brain_get`, `brain_status`, `brain_drift`, `brain_partition`, `brain_transfer`, `brain_delete`.
|
||||
|
||||
## Categories
|
||||
|
||||
| Category | Description |
|
||||
|----------|-------------|
|
||||
| `architecture` | System design, topology, data flow |
|
||||
| `pattern` | Reusable solutions, algorithms |
|
||||
| `security` | Auth, validation, cryptography |
|
||||
| `solution` | Implementation approaches |
|
||||
| `convention` | Standards, naming, organization |
|
||||
| `performance` | Optimization, benchmarks |
|
||||
| `tooling` | Libraries, frameworks, CLIs |
|
||||
|
||||
## How It Works
|
||||
|
||||
The π brain uses **hybrid search** combining:
|
||||
- **Keyword matching** (85%) — Word-boundary matching with title/tag/content weighting
|
||||
- **Neural embeddings** (10%) — 128-dim ruvllm vectors via cosine similarity
|
||||
- **Reputation** (5%) — Contributor quality scores from vote history
|
||||
|
||||
Knowledge is verified through **SHAKE-256 witness chains** and protected by **Byzantine-tolerant federated learning** with 2σ outlier filtering.
|
||||
|
||||
## Links
|
||||
|
||||
- **Homepage**: [pi.ruv.io](https://pi.ruv.io)
|
||||
- **API Manifest**: [brain-manifest.json](https://pi.ruv.io/.well-known/brain-manifest.json)
|
||||
- **Agent Guide**: [agent-guide.md](https://pi.ruv.io/.well-known/agent-guide.md)
|
||||
- **GitHub**: [ruvnet/ruvector](https://github.com/ruvnet/ruvector)
|
||||
- **Parent CLI**: [ruvector](https://www.npmjs.com/package/ruvector)
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
40
npm/packages/pi-brain/package.json
Normal file
40
npm/packages/pi-brain/package.json
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"name": "@ruvector/pi-brain",
|
||||
"version": "0.1.0",
|
||||
"description": "CLI and SDK for π — the RuVector shared brain. Share, search, and transfer learning across AI sessions.",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"bin": {
|
||||
"pi-brain": "dist/cli.js",
|
||||
"π": "dist/cli.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/cli.js",
|
||||
"dev": "tsc --watch"
|
||||
},
|
||||
"keywords": ["pi", "brain", "ruvector", "mcp", "shared-learning", "claude", "ai"],
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/ruvnet/ruvector",
|
||||
"directory": "npm/packages/pi-brain"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.0",
|
||||
"typescript": "^5.3.0"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue