diff --git a/Cargo.lock b/Cargo.lock index 257fce169..30ce9644e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3946,7 +3946,6 @@ dependencies = [ "allocator-api2", "equivalent", "foldhash 0.1.5", - "serde", ] [[package]] @@ -5895,17 +5894,6 @@ dependencies = [ [[package]] name = "neo4j-rs" version = "0.1.0" -dependencies = [ - "async-trait", - "chrono", - "hashbrown 0.15.5", - "parking_lot", - "serde", - "serde_json", - "smallvec", - "thiserror 2.0.18", - "tracing", -] [[package]] name = "neo4rs" @@ -8640,9 +8628,6 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -dependencies = [ - "serde", -] [[package]] name = "smol_str" diff --git a/crates/cockpit-server/src/main.rs b/crates/cockpit-server/src/main.rs index 8a8ebb0cd..083e53f45 100644 --- a/crates/cockpit-server/src/main.rs +++ b/crates/cockpit-server/src/main.rs @@ -102,6 +102,15 @@ async fn main() { .route("/api/data/status", get(data_status_handler)) // Live strategy diagnostics — runs all 16 strategies against real queries .route("/api/debug/strategies", get(strategy_check_handler)) + // Live OSINT pipeline audit — AriGraph health, NARS stats, xAI status + .route("/api/debug/osint", get(osint_audit_handler)) + // Brain MRI — plasticity, activation, NARS reasoning chains + .route("/mri", get(mri_page_handler)) + .route("/api/mri/scan", get(mri_scan_handler)) + .route("/api/mri/scan/:mode", get(mri_scan_mode_handler)) + // Meta-orchestrator — NARS RL style tuning + transparent fallback + .route("/api/orchestrator/status", get(orchestrator_status_handler)) + .route("/api/orchestrator/step", post(orchestrator_step_handler)) // Political analyst — NARS causality chains through analytical buckets .route("/api/analyst/buckets", get(analyst_buckets_handler)) .route("/api/analyst/analyze/:bucket", get(analyst_analyze_handler)) @@ -123,6 +132,8 @@ async fn main() { tracing::info!(" / → Palantir cockpit (Vite build, 221 aiwar nodes)"); tracing::info!(" /demo → infrastructure demo (24 seed nodes)"); tracing::info!(" /debug → neural debugger (18,763 functions)"); + tracing::info!(" /api/debug/osint → live OSINT pipeline audit (AriGraph + NARS + xAI)"); + tracing::info!(" /mri → AGI Brain MRI (plasticity, activation, reasoning)"); tracing::info!(" /mcp/* → MCP endpoints (lance-graph)"); let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); @@ -418,6 +429,205 @@ async fn strategy_check_handler() -> Json { } } +// ── Live OSINT Pipeline Audit ──────────────────────────────────────────────── + +/// Real-time health check of the AriGraph OSINT pipeline. +/// Reports: pipeline stage call counts, graph truth distribution, +/// NARS inference stats, episodic memory saturation, xAI API status. +async fn osint_audit_handler() -> Json { + let result = tokio::task::spawn_blocking(|| { + // In production these come from the live graph state. + // For now, report pipeline registry stats + env status. + notebook_query::osint_audit::run_osint_audit( + 0, // graph_triplet_count — wire to live graph + 0, // graph_active_count + 0, // graph_entity_count + 0, // graph_spatial_edges + 0, // graph_contradictions + 0, // episodic_count + 100, // episodic_capacity + ) + }) + .await; + + match result { + Ok(audit) => Json(serde_json::to_value(audit).unwrap_or_default()), + Err(e) => Json(serde_json::json!({ + "error": format!("OSINT audit failed: {}", e), + })), + } +} + +// ── Brain MRI ─────────────────────────────────────────────────────────────── + +/// Serve the MRI page (SPA fallback handles rendering). +async fn mri_page_handler() -> axum::response::Html { + axum::response::Html(format!( + r#" +AGI Brain MRI + + +

🧠 AGI Brain MRI

+

Scan mode: + +

+
+

Plasticity Map

+

NARS Reasoning Chains

+

Findings

+

Raw JSON


+"#
+    ))
+}
+
+/// JSON API: full brain MRI scan (default mode = full).
+async fn mri_scan_handler() -> Json {
+    mri_scan_with_mode(notebook_query::mri::ScanMode::Full).await
+}
+
+/// JSON API: brain MRI scan with specific mode.
+async fn mri_scan_mode_handler(
+    axum::extract::Path(mode): axum::extract::Path,
+) -> Json {
+    let scan_mode = match mode.as_str() {
+        "structural" => notebook_query::mri::ScanMode::Structural,
+        "functional" => notebook_query::mri::ScanMode::Functional,
+        _ => notebook_query::mri::ScanMode::Full,
+    };
+    mri_scan_with_mode(scan_mode).await
+}
+
+async fn mri_scan_with_mode(scan_mode: notebook_query::mri::ScanMode) -> Json {
+    let result = tokio::task::spawn_blocking(move || {
+        // In production, edges + entity_stats come from the live AriGraph.
+        // For now, return the pipeline registry data + empty graph.
+        let edges = Vec::new();
+        let entity_stats = std::collections::HashMap::new();
+        let thinking_activations = Vec::new();
+        notebook_query::mri::run_brain_mri(&edges, &entity_stats, &thinking_activations, scan_mode)
+    })
+    .await;
+
+    match result {
+        Ok(mri) => Json(serde_json::to_value(mri).unwrap_or_default()),
+        Err(e) => Json(serde_json::json!({
+            "error": format!("Brain MRI failed: {}", e),
+            "health_score": 0.0,
+        })),
+    }
+}
+
+// ── Meta-Orchestrator — Thinking About Thinking ─────────────────────────────
+
+/// Global orchestrator instance. Persists across requests.
+static ORCHESTRATOR: std::sync::OnceLock> =
+    std::sync::OnceLock::new();
+
+fn get_orchestrator() -> &'static std::sync::Mutex {
+    ORCHESTRATOR.get_or_init(|| {
+        std::sync::Mutex::new(notebook_query::orchestrator::MetaOrchestrator::new())
+    })
+}
+
+/// GET /api/orchestrator/status — current mode, topology, efficiency, mode switches.
+async fn orchestrator_status_handler() -> Json {
+    let orch = get_orchestrator().lock().unwrap();
+    Json(serde_json::to_value(orch.snapshot()).unwrap_or_default())
+}
+
+/// POST /api/orchestrator/step — execute one orchestration step.
+///
+/// Request body (optional): `{ "quality": 0.8 }` to record the outcome of the previous step.
+/// Response: the next style to execute + why it was chosen.
+async fn orchestrator_step_handler(
+    body: Option>,
+) -> Json {
+    let mut orch = get_orchestrator().lock().unwrap();
+
+    // If quality is provided, record the outcome of the previous step.
+    if let Some(Json(body)) = body {
+        if let Some(quality) = body.get("quality").and_then(|v| v.as_f64()) {
+            if let Some(style_name) = body.get("style").and_then(|v| v.as_str()) {
+                let style = match style_name {
+                    "plan" => notebook_query::orchestrator::AgentStyle::Plan,
+                    "act" => notebook_query::orchestrator::AgentStyle::Act,
+                    "explore" => notebook_query::orchestrator::AgentStyle::Explore,
+                    "reflex" => notebook_query::orchestrator::AgentStyle::Reflex,
+                    _ => notebook_query::orchestrator::AgentStyle::Plan,
+                };
+                orch.record_outcome(style, quality as f32);
+            }
+        }
+    }
+
+    let result = orch.select_next();
+    Json(serde_json::to_value(result).unwrap_or_default())
+}
+
 // ── Political Analyst Savant ──────────────────────────────────────────────────
 
 /// List available analysis buckets.
diff --git a/crates/stubs/notebook-query/src/lib.rs b/crates/stubs/notebook-query/src/lib.rs
index 5d3658d4b..57cf93c5e 100644
--- a/crates/stubs/notebook-query/src/lib.rs
+++ b/crates/stubs/notebook-query/src/lib.rs
@@ -11,6 +11,9 @@
 pub mod analyst;
 pub mod diagnostics;
 pub mod hydration;
+pub mod mri;
+pub mod orchestrator;
+pub mod osint_audit;
 pub mod reasoning;
 #[cfg(feature = "orchestrator")]
 pub mod thinking;
diff --git a/crates/stubs/notebook-query/src/mri.rs b/crates/stubs/notebook-query/src/mri.rs
new file mode 100644
index 000000000..a718e4f88
--- /dev/null
+++ b/crates/stubs/notebook-query/src/mri.rs
@@ -0,0 +1,664 @@
+//! `/mri` — AGI Brain MRI: plasticity, activation, and NARS reasoning scan.
+//!
+//! A real-time "functional MRI" of the cognitive pipeline. Shows which thinking
+//! styles are active, which NARS inference chains fired, which graph regions
+//! have high/low plasticity, and which causal paths are hot vs frozen.
+//!
+//! Three scan modes:
+//! - **Structural**: graph topology, entity count, edge distribution
+//! - **Functional**: which pipeline stages activated, latency, throughput
+//! - **Diffusion**: NARS inference chains, evidence flow, truth propagation
+//!
+//! Exposed at `/mri` (full scan) and `/api/mri/scan` (JSON API).
+
+use serde::{Deserialize, Serialize};
+use std::collections::HashMap;
+
+use super::osint_audit::{osint_registry, OsintCounterSnapshot};
+use super::reasoning::{
+    nars_abduction, nars_deduction, nars_induction, InferenceType, TruthEdge, TruthValue,
+};
+
+// ============================================================================
+// Brain Region Model
+// ============================================================================
+
+/// A brain "region" — a functional area of the cognitive pipeline.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct BrainRegion {
+    /// Region name (e.g., "perception", "reasoning", "memory", "action").
+    pub name: String,
+    /// Current activation level [0.0, 1.0] — how busy this region is.
+    pub activation: f32,
+    /// Plasticity [0.0, 1.0] — how much this region is learning/changing.
+    pub plasticity: f32,
+    /// Temperature — how "hot" the region is (call frequency / time window).
+    pub temperature: f32,
+    /// Sub-regions with their own activation.
+    pub sub_regions: Vec,
+}
+
+/// A sub-region within a brain region.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct SubRegion {
+    pub name: String,
+    pub activation: f32,
+    pub calls: u64,
+    pub avg_latency_us: u64,
+    pub status: RegionStatus,
+}
+
+/// Status of a brain region.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum RegionStatus {
+    /// Actively processing.
+    Active,
+    /// Idle but responsive.
+    Idle,
+    /// Learning / adapting.
+    Plastic,
+    /// Frozen — no longer updating.
+    Frozen,
+    /// Dead — never activated.
+    Dead,
+}
+
+// ============================================================================
+// NARS Inference Trace
+// ============================================================================
+
+/// A single NARS inference step — one deduction/abduction/induction.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct InferenceStep {
+    /// Inference type.
+    pub rule: String,
+    /// Premise A.
+    pub premise_a: String,
+    /// Premise B.
+    pub premise_b: String,
+    /// Conclusion.
+    pub conclusion: String,
+    /// Truth value of the conclusion.
+    pub truth: TruthValue,
+    /// Confidence gain/loss from this inference.
+    pub confidence_delta: f64,
+}
+
+/// A complete NARS reasoning chain — multiple steps forming a logical argument.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ReasoningChain {
+    pub id: u32,
+    pub steps: Vec,
+    /// Final truth value at the end of the chain.
+    pub final_truth: TruthValue,
+    /// Total confidence accumulated.
+    pub total_confidence_gain: f64,
+    /// Chain depth (number of inference steps).
+    pub depth: usize,
+}
+
+// ============================================================================
+// Plasticity Map
+// ============================================================================
+
+/// Per-entity plasticity — how much an entity's truth values have changed recently.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct EntityPlasticity {
+    pub entity: String,
+    /// Number of triplets involving this entity.
+    pub triplet_count: usize,
+    /// Average truth confidence across triplets.
+    pub avg_confidence: f32,
+    /// Number of revisions applied to triplets involving this entity.
+    pub revisions: u64,
+    /// Number of contradictions detected involving this entity.
+    pub contradictions: u64,
+    /// Plasticity classification.
+    pub state: PlasticityState,
+}
+
+/// CausalEdge64 plasticity states (bits 49-51).
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum PlasticityState {
+    /// Actively learning — confidence changing rapidly.
+    Hot,
+    /// Stable — confidence settled, occasional updates.
+    Warm,
+    /// Frozen — high confidence, no recent changes.
+    Frozen,
+    /// Contradicted — conflicting evidence, needs resolution.
+    Conflicted,
+}
+
+// ============================================================================
+// Thinking Style Activation
+// ============================================================================
+
+/// Activation snapshot for one thinking style.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ThinkingStyleActivation {
+    pub style: String,
+    pub cluster: String,
+    /// How many times this style was selected in the current window.
+    pub activations: u64,
+    /// Average quality score when this style was used.
+    pub avg_quality: f32,
+    /// NARS truth value for "this style is effective".
+    pub effectiveness_truth: TruthValue,
+    /// Neighboring styles that co-activate (from topology edges).
+    pub co_activations: Vec<(String, f32)>,
+}
+
+// ============================================================================
+// Full MRI Scan Result
+// ============================================================================
+
+/// The complete Brain MRI — structural + functional + diffusion.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct BrainMri {
+    /// Scan mode that produced this result.
+    pub scan_mode: ScanMode,
+    /// Timestamp (Unix millis).
+    pub timestamp_ms: u64,
+
+    // ── Structural ──
+    /// Brain regions with activation levels.
+    pub regions: Vec,
+    /// Total entities in the knowledge graph.
+    pub total_entities: usize,
+    /// Total active triplets.
+    pub total_triplets: usize,
+
+    // ── Functional ──
+    /// Pipeline stage activation (from OSINT registry).
+    pub pipeline_activation: Vec<(String, OsintCounterSnapshot)>,
+    /// Thinking style activation (from topology).
+    pub thinking_styles: Vec,
+
+    // ── Diffusion ──
+    /// Active NARS reasoning chains.
+    pub reasoning_chains: Vec,
+    /// Entity plasticity map.
+    pub plasticity_map: Vec,
+
+    // ── Summary ──
+    /// Overall brain health score [0.0, 1.0].
+    pub health_score: f32,
+    /// Dominant thinking mode.
+    pub dominant_mode: String,
+    /// Key findings.
+    pub findings: Vec,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum ScanMode {
+    /// Quick structural scan.
+    Structural,
+    /// Functional activation scan.
+    Functional,
+    /// Full diffusion tensor scan (slowest, most detailed).
+    Full,
+}
+
+// ============================================================================
+// Scan Functions
+// ============================================================================
+
+/// Run a brain MRI scan.
+///
+/// Collects activation data from the OSINT registry, builds brain regions
+/// from pipeline stages, computes plasticity from graph state, and traces
+/// NARS reasoning chains from inferred edges.
+pub fn run_brain_mri(
+    edges: &[TruthEdge],
+    entity_stats: &HashMap,
+    thinking_activations: &[(String, String, u64, f32)], // (style, cluster, count, quality)
+    scan_mode: ScanMode,
+) -> BrainMri {
+    let registry = osint_registry();
+    let pipeline = registry.snapshot();
+
+    // ── Build brain regions from pipeline stages ──
+    let regions = build_brain_regions(&pipeline.stages);
+
+    // ── Thinking style activation ──
+    let thinking_styles: Vec = thinking_activations
+        .iter()
+        .map(|(style, cluster, count, quality)| {
+            let effectiveness = if *count > 0 {
+                TruthValue::new(*quality as f64, (*count as f64 / (*count as f64 + 1.0)))
+            } else {
+                TruthValue::new(0.5, 0.0)
+            };
+            ThinkingStyleActivation {
+                style: style.clone(),
+                cluster: cluster.clone(),
+                activations: *count,
+                avg_quality: *quality,
+                effectiveness_truth: effectiveness,
+                co_activations: Vec::new(),
+            }
+        })
+        .collect();
+
+    // ── NARS reasoning chains (diffusion scan only) ──
+    let reasoning_chains = if scan_mode == ScanMode::Full {
+        trace_reasoning_chains(edges)
+    } else {
+        Vec::new()
+    };
+
+    // ── Entity plasticity map ──
+    let plasticity_map: Vec = entity_stats
+        .iter()
+        .map(|(entity, stats)| {
+            let state = if stats.contradictions > 0 {
+                PlasticityState::Conflicted
+            } else if stats.revisions > 5 {
+                PlasticityState::Hot
+            } else if stats.avg_confidence > 0.8 {
+                PlasticityState::Frozen
+            } else {
+                PlasticityState::Warm
+            };
+            EntityPlasticity {
+                entity: entity.clone(),
+                triplet_count: stats.triplet_count,
+                avg_confidence: stats.avg_confidence,
+                revisions: stats.revisions,
+                contradictions: stats.contradictions,
+                state,
+            }
+        })
+        .collect();
+
+    // ── Compute health score ──
+    let active_regions = regions.iter().filter(|r| r.activation > 0.1).count();
+    let total_regions = regions.len().max(1);
+    let region_health = active_regions as f32 / total_regions as f32;
+
+    let conflict_count = plasticity_map
+        .iter()
+        .filter(|p| p.state == PlasticityState::Conflicted)
+        .count();
+    let conflict_penalty = (conflict_count as f32 * 0.1).min(0.5);
+
+    let health_score = (region_health - conflict_penalty).clamp(0.0, 1.0);
+
+    // ── Dominant mode ──
+    let dominant_mode = thinking_styles
+        .iter()
+        .max_by_key(|s| s.activations)
+        .map(|s| s.style.clone())
+        .unwrap_or_else(|| "idle".to_string());
+
+    // ── Findings ──
+    let mut findings = Vec::new();
+    if conflict_count > 0 {
+        findings.push(format!(
+            "{} entities have conflicting evidence — run contradiction resolution",
+            conflict_count
+        ));
+    }
+    let hot_count = plasticity_map
+        .iter()
+        .filter(|p| p.state == PlasticityState::Hot)
+        .count();
+    if hot_count > 0 {
+        findings.push(format!("{} entities are actively learning (hot plasticity)", hot_count));
+    }
+    let frozen_count = plasticity_map
+        .iter()
+        .filter(|p| p.state == PlasticityState::Frozen)
+        .count();
+    if frozen_count > entity_stats.len() / 2 {
+        findings.push(format!(
+            "{}% of entities are frozen — consider new evidence ingestion",
+            frozen_count * 100 / entity_stats.len().max(1)
+        ));
+    }
+    if !reasoning_chains.is_empty() {
+        let max_depth = reasoning_chains.iter().map(|c| c.depth).max().unwrap_or(0);
+        findings.push(format!(
+            "{} reasoning chains active, max depth {}",
+            reasoning_chains.len(),
+            max_depth
+        ));
+    }
+    if findings.is_empty() {
+        findings.push("Brain is healthy — all regions within normal parameters".to_string());
+    }
+
+    let timestamp_ms = std::time::SystemTime::now()
+        .duration_since(std::time::UNIX_EPOCH)
+        .map(|d| d.as_millis() as u64)
+        .unwrap_or(0);
+
+    BrainMri {
+        scan_mode,
+        timestamp_ms,
+        regions,
+        total_entities: entity_stats.len(),
+        total_triplets: edges.len(),
+        pipeline_activation: pipeline.stages,
+        thinking_styles,
+        reasoning_chains,
+        plasticity_map,
+        health_score,
+        dominant_mode,
+        findings,
+    }
+}
+
+/// Per-entity statistics for plasticity computation.
+#[derive(Debug, Clone)]
+pub struct EntityStats {
+    pub triplet_count: usize,
+    pub avg_confidence: f32,
+    pub revisions: u64,
+    pub contradictions: u64,
+}
+
+// ── Internal helpers ────────────────────────────────────────────────────────
+
+/// Map pipeline stages to brain regions.
+fn build_brain_regions(stages: &[(String, OsintCounterSnapshot)]) -> Vec {
+    // Group stages into 4 brain regions
+    let perception_stages = ["extraction", "xai_api_call"];
+    let reasoning_stages = ["deduction", "contradiction", "revision"];
+    let memory_stages = ["episodic_store", "episodic_retrieve", "graph_bfs", "spatial_path"];
+    let action_stages = ["refinement", "planning", "classification"];
+
+    let build_region = |name: &str, stage_names: &[&str]| {
+        let sub_regions: Vec = stages
+            .iter()
+            .filter(|(n, _)| stage_names.contains(&n.as_str()))
+            .map(|(name, snap)| {
+                let status = if snap.calls == 0 {
+                    RegionStatus::Dead
+                } else if snap.failures > snap.successes {
+                    RegionStatus::Frozen
+                } else if snap.triplets_produced > 0 {
+                    RegionStatus::Plastic
+                } else {
+                    RegionStatus::Active
+                };
+                SubRegion {
+                    name: name.clone(),
+                    activation: if snap.calls > 0 { 1.0 } else { 0.0 },
+                    calls: snap.calls,
+                    avg_latency_us: snap.avg_latency_us,
+                    status,
+                }
+            })
+            .collect();
+
+        let total_calls: u64 = sub_regions.iter().map(|s| s.calls).sum();
+        let active_sub = sub_regions.iter().filter(|s| s.calls > 0).count();
+        let activation = if sub_regions.is_empty() {
+            0.0
+        } else {
+            active_sub as f32 / sub_regions.len() as f32
+        };
+
+        // Plasticity = proportion of sub-regions that produced new knowledge
+        let plastic_sub = sub_regions
+            .iter()
+            .filter(|s| s.status == RegionStatus::Plastic)
+            .count();
+        let plasticity = if sub_regions.is_empty() {
+            0.0
+        } else {
+            plastic_sub as f32 / sub_regions.len() as f32
+        };
+
+        BrainRegion {
+            name: name.to_string(),
+            activation,
+            plasticity,
+            temperature: total_calls as f32 / 100.0, // normalize to ~[0,1] for 100 calls
+            sub_regions,
+        }
+    };
+
+    vec![
+        build_region("perception", &perception_stages),
+        build_region("reasoning", &reasoning_stages),
+        build_region("memory", &memory_stages),
+        build_region("action", &action_stages),
+    ]
+}
+
+/// Trace NARS reasoning chains from inferred edges.
+fn trace_reasoning_chains(edges: &[TruthEdge]) -> Vec {
+    let inferred: Vec<&TruthEdge> = edges.iter().filter(|e| e.inferred).collect();
+    let mut chains = Vec::new();
+
+    for (id, edge) in inferred.iter().enumerate() {
+        let rule = match edge.inference_type {
+            Some(InferenceType::Deduction) => "deduction",
+            Some(InferenceType::Abduction) => "abduction",
+            Some(InferenceType::Induction) => "induction",
+            None => "unknown",
+        };
+
+        let via_str = if edge.via.is_empty() {
+            "direct".to_string()
+        } else {
+            edge.via.join(" → ")
+        };
+
+        let step = InferenceStep {
+            rule: rule.to_string(),
+            premise_a: format!("{} → {}", edge.source, edge.via.first().unwrap_or(&edge.target)),
+            premise_b: format!(
+                "{} → {}",
+                edge.via.last().unwrap_or(&edge.source),
+                edge.target
+            ),
+            conclusion: format!("{} → {} (via {})", edge.source, edge.target, via_str),
+            truth: edge.truth,
+            confidence_delta: edge.truth.confidence,
+        };
+
+        chains.push(ReasoningChain {
+            id: id as u32,
+            steps: vec![step],
+            final_truth: edge.truth,
+            total_confidence_gain: edge.truth.confidence,
+            depth: 1 + edge.via.len(),
+        });
+    }
+
+    chains
+}
+
+// ============================================================================
+// Tests
+// ============================================================================
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn sample_edges() -> Vec {
+        vec![
+            TruthEdge {
+                source: "Palantir".into(),
+                target: "US_DoD".into(),
+                rel_type: "DEVELOPED_BY".into(),
+                truth: TruthValue::new(0.95, 0.87),
+                inferred: false,
+                via: vec![],
+                inference_type: None,
+            },
+            TruthEdge {
+                source: "Palantir".into(),
+                target: "Gotham".into(),
+                rel_type: "DEPLOYED_BY".into(),
+                truth: TruthValue::new(0.85, 0.72),
+                inferred: true,
+                via: vec!["US_DoD".into()],
+                inference_type: Some(InferenceType::Deduction),
+            },
+        ]
+    }
+
+    fn sample_entity_stats() -> HashMap {
+        let mut map = HashMap::new();
+        map.insert(
+            "Palantir".into(),
+            EntityStats {
+                triplet_count: 5,
+                avg_confidence: 0.9,
+                revisions: 2,
+                contradictions: 0,
+            },
+        );
+        map.insert(
+            "US_DoD".into(),
+            EntityStats {
+                triplet_count: 8,
+                avg_confidence: 0.4,
+                revisions: 10,
+                contradictions: 1,
+            },
+        );
+        map
+    }
+
+    #[test]
+    fn test_full_brain_mri() {
+        let edges = sample_edges();
+        let stats = sample_entity_stats();
+        let styles = vec![
+            ("Analytical".into(), "Convergent".into(), 5u64, 0.8f32),
+            ("Creative".into(), "Divergent".into(), 2u64, 0.6f32),
+        ];
+
+        let mri = run_brain_mri(&edges, &stats, &styles, ScanMode::Full);
+
+        assert_eq!(mri.regions.len(), 4);
+        assert_eq!(mri.total_entities, 2);
+        assert_eq!(mri.total_triplets, 2);
+        assert_eq!(mri.dominant_mode, "Analytical");
+        assert!(!mri.findings.is_empty());
+        // Should detect US_DoD as conflicted (1 contradiction)
+        assert!(mri
+            .plasticity_map
+            .iter()
+            .any(|p| p.entity == "US_DoD" && p.state == PlasticityState::Conflicted));
+        // Should have reasoning chains (1 inferred edge)
+        assert!(!mri.reasoning_chains.is_empty());
+    }
+
+    #[test]
+    fn test_structural_scan_no_chains() {
+        let edges = sample_edges();
+        let stats = sample_entity_stats();
+        let mri = run_brain_mri(&edges, &stats, &[], ScanMode::Structural);
+
+        // Structural scan should NOT trace reasoning chains (that's Full only)
+        assert!(mri.reasoning_chains.is_empty());
+    }
+
+    #[test]
+    fn test_plasticity_states() {
+        let mut stats = HashMap::new();
+        stats.insert("hot_entity".into(), EntityStats {
+            triplet_count: 10,
+            avg_confidence: 0.5,
+            revisions: 20,
+            contradictions: 0,
+        });
+        stats.insert("frozen_entity".into(), EntityStats {
+            triplet_count: 5,
+            avg_confidence: 0.95,
+            revisions: 1,
+            contradictions: 0,
+        });
+        stats.insert("conflicted_entity".into(), EntityStats {
+            triplet_count: 3,
+            avg_confidence: 0.6,
+            revisions: 2,
+            contradictions: 2,
+        });
+
+        let mri = run_brain_mri(&[], &stats, &[], ScanMode::Full);
+
+        let hot = mri.plasticity_map.iter().find(|p| p.entity == "hot_entity").unwrap();
+        assert_eq!(hot.state, PlasticityState::Hot);
+
+        let frozen = mri.plasticity_map.iter().find(|p| p.entity == "frozen_entity").unwrap();
+        assert_eq!(frozen.state, PlasticityState::Frozen);
+
+        let conflicted = mri.plasticity_map.iter().find(|p| p.entity == "conflicted_entity").unwrap();
+        assert_eq!(conflicted.state, PlasticityState::Conflicted);
+    }
+
+    #[test]
+    fn test_brain_regions() {
+        let stages = vec![
+            ("extraction".into(), OsintCounterSnapshot {
+                calls: 10, successes: 9, failures: 1, avg_latency_us: 500, triplets_produced: 45,
+            }),
+            ("deduction".into(), OsintCounterSnapshot {
+                calls: 5, successes: 5, failures: 0, avg_latency_us: 100, triplets_produced: 12,
+            }),
+            ("episodic_store".into(), OsintCounterSnapshot {
+                calls: 0, successes: 0, failures: 0, avg_latency_us: 0, triplets_produced: 0,
+            }),
+        ];
+
+        let regions = build_brain_regions(&stages);
+        assert_eq!(regions.len(), 4);
+
+        let perception = regions.iter().find(|r| r.name == "perception").unwrap();
+        assert!(perception.activation > 0.0);
+
+        let memory = regions.iter().find(|r| r.name == "memory").unwrap();
+        // episodic_store has 0 calls, so some sub-regions are dead
+        assert!(memory.sub_regions.iter().any(|s| s.status == RegionStatus::Dead));
+    }
+
+    #[test]
+    fn test_empty_brain() {
+        let mri = run_brain_mri(&[], &HashMap::new(), &[], ScanMode::Full);
+        assert_eq!(mri.total_entities, 0);
+        assert_eq!(mri.health_score, 0.0);
+        assert!(mri.findings.iter().any(|f| f.contains("healthy")));
+    }
+
+    #[test]
+    fn test_reasoning_chain_trace() {
+        let edges = vec![
+            TruthEdge {
+                source: "A".into(),
+                target: "C".into(),
+                rel_type: "DEDUCED".into(),
+                truth: TruthValue::new(0.8, 0.6),
+                inferred: true,
+                via: vec!["B".into()],
+                inference_type: Some(InferenceType::Deduction),
+            },
+            TruthEdge {
+                source: "X".into(),
+                target: "Y".into(),
+                rel_type: "ABDUCED".into(),
+                truth: TruthValue::new(0.5, 0.3),
+                inferred: true,
+                via: vec!["Z".into()],
+                inference_type: Some(InferenceType::Abduction),
+            },
+        ];
+
+        let chains = trace_reasoning_chains(&edges);
+        assert_eq!(chains.len(), 2);
+        assert_eq!(chains[0].depth, 2); // 1 + 1 via node
+        assert_eq!(chains[0].steps[0].rule, "deduction");
+        assert_eq!(chains[1].steps[0].rule, "abduction");
+    }
+}
diff --git a/crates/stubs/notebook-query/src/orchestrator.rs b/crates/stubs/notebook-query/src/orchestrator.rs
new file mode 100644
index 000000000..720089174
--- /dev/null
+++ b/crates/stubs/notebook-query/src/orchestrator.rs
@@ -0,0 +1,1563 @@
+//! Meta-aware agent orchestrator with NARS reinforcement learning + MUL.
+//!
+//! Three layers of self-awareness, transparent switching:
+//!
+//! 1. **MUL (Meta-Uncertainty Layer)**: Before every step, assesses epistemic state.
+//!    Dunning-Kruger position gates which styles are allowed. Trust texture modulates
+//!    exploration rate. Flow state adjusts patience thresholds. Compass can override
+//!    both adaptive and hardcoded modes.
+//!
+//! 2. **Adaptive** (default): NARS topology learns which thinking style sequences
+//!    produce good outcomes. Each style pair (A→B) gets a truth value. High-confidence
+//!    edges fire; low-confidence edges get explored. MUL's `free_will_modifier`
+//!    scales the topology's expected quality — low free will = distrust the learned weights.
+//!
+//! 3. **Hardcoded fallback**: When MUL-adjusted efficiency drops below threshold,
+//!    transparently switches to the classic plan→act→explore→reflex loop.
+//!    The MRI endpoint reports which mode is active, MUL assessment, and why.
+//!
+//! The meta-awareness monitors BOTH its own efficiency AND its epistemic position:
+//! - Mount Stupid detected? → Force sandbox, don't act on false confidence
+//! - Valley of Despair? → Increase exploration, the system is learning
+//! - Plateau of Mastery? → Full exploit, trust the topology
+//! - Compass says Explore? → Override topology, go to unexplored territory
+//!
+//! # Architecture
+//!
+//! ```text
+//! Observation
+//!   ↓
+//! MetaOrchestrator::step()
+//!   ├── Check mode (adaptive vs fallback)
+//!   ├── If adaptive:
+//!   │     ├── Select style via NARS topology weights
+//!   │     ├── Execute style
+//!   │     ├── Measure outcome quality
+//!   │     ├── NARS revision on (style_from → style_to) edge
+//!   │     ├── If rolling_efficiency < FALLBACK_THRESHOLD → switch to fallback
+//!   │     └── Return result
+//!   └── If fallback:
+//!         ├── Execute hardcoded: plan → act → explore → reflex
+//!         ├── Measure outcome quality
+//!         ├── If rolling_efficiency > RESTORE_THRESHOLD → re-enable adaptive
+//!         └── Return result
+//! ```
+
+use super::reasoning::TruthValue;
+use serde::{Deserialize, Serialize};
+use std::collections::HashMap;
+
+// ============================================================================
+// Constants
+// ============================================================================
+
+/// Below this rolling efficiency, switch from adaptive to hardcoded fallback.
+const FALLBACK_THRESHOLD: f32 = 0.35;
+
+/// Above this rolling efficiency in fallback mode, re-enable adaptive.
+const RESTORE_THRESHOLD: f32 = 0.55;
+
+/// Minimum observations before allowing mode switch.
+const MIN_OBSERVATIONS: usize = 5;
+
+/// Rolling window size for efficiency calculation.
+const WINDOW_SIZE: usize = 20;
+
+/// Base exploration probability when adaptive mode has low confidence.
+const BASE_EXPLORATION_RATE: f32 = 0.15;
+
+// ============================================================================
+// MUL Assessment — Meta-Uncertainty Layer
+// ============================================================================
+
+/// Dunning-Kruger position on the confidence curve.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum DkPosition {
+    /// HIGH confidence, LOW experience — DANGEROUS. Block autonomous action.
+    MountStupid,
+    /// Aware of gaps, cautious. Increase exploration.
+    ValleyOfDespair,
+    /// Building real competence. Normal operation.
+    SlopeOfEnlightenment,
+    /// Calibrated confidence. Full exploit.
+    PlateauOfMastery,
+}
+
+impl DkPosition {
+    /// Humility factor: discounts confidence based on DK position.
+    pub fn humility_factor(&self) -> f32 {
+        match self {
+            Self::MountStupid => 0.3,
+            Self::ValleyOfDespair => 0.7,
+            Self::SlopeOfEnlightenment => 0.85,
+            Self::PlateauOfMastery => 1.0,
+        }
+    }
+
+    /// Exploration rate modifier: how much to explore vs exploit.
+    pub fn exploration_modifier(&self) -> f32 {
+        match self {
+            Self::MountStupid => 0.0,         // Don't explore, you're overconfident
+            Self::ValleyOfDespair => 2.0,      // Explore heavily, you're learning
+            Self::SlopeOfEnlightenment => 1.0, // Normal
+            Self::PlateauOfMastery => 0.5,     // Exploit more, you've earned it
+        }
+    }
+
+    /// Whether this position is safe for autonomous adaptive action.
+    pub fn allows_adaptive(&self) -> bool {
+        !matches!(self, Self::MountStupid)
+    }
+}
+
+/// Trust texture — how much to trust the data sources and environment.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum TrustTexture {
+    /// High reliability, stable environment. Trust topology weights.
+    Crystalline,
+    /// Moderate reliability. Normal operation.
+    Fibrous,
+    /// Low reliability, unstable environment. Distrust learned weights.
+    Fuzzy,
+}
+
+impl TrustTexture {
+    /// Trust factor: scales topology confidence.
+    pub fn trust_factor(&self) -> f32 {
+        match self {
+            Self::Crystalline => 1.0,
+            Self::Fibrous => 0.7,
+            Self::Fuzzy => 0.4,
+        }
+    }
+}
+
+/// Flow state — current cognitive load and engagement.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum FlowState {
+    /// Optimal: challenge matches skill. Normal thresholds.
+    Flow,
+    /// Under-stimulated. Widen thresholds, increase exploration.
+    Boredom,
+    /// Over-stimulated. Tighten thresholds, prefer known-good styles.
+    Anxiety,
+    /// Disengaged. Minimal processing, fast fallback.
+    Apathy,
+}
+
+impl FlowState {
+    /// Patience modifier: adjusts how long before fallback triggers.
+    pub fn patience_modifier(&self) -> f32 {
+        match self {
+            Self::Flow => 1.0,
+            Self::Boredom => 1.5,   // More patient, try more things
+            Self::Anxiety => 0.5,   // Less patient, fallback sooner
+            Self::Apathy => 0.25,   // Very impatient
+        }
+    }
+}
+
+/// Compass override — when the map runs out, the compass decides.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum CompassDecision {
+    /// No override. Let topology/hardcoded decide.
+    None,
+    /// Force exploration regardless of topology weights.
+    ForceExplore,
+    /// Force sandbox — block all autonomous action.
+    ForceSandbox,
+}
+
+/// Complete MUL assessment for one orchestrator step.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct MulAssessment {
+    pub dk_position: DkPosition,
+    pub trust: TrustTexture,
+    pub flow: FlowState,
+    pub compass: CompassDecision,
+    /// Combined modifier: DK humility × trust × flow patience. Range [0.0, 1.5].
+    pub free_will_modifier: f32,
+    /// Effective exploration rate after MUL adjustment.
+    pub effective_exploration_rate: f32,
+    /// Effective fallback threshold after flow adjustment.
+    pub effective_fallback_threshold: f32,
+}
+
+impl MulAssessment {
+    /// Compute from raw signals.
+    pub fn assess(
+        felt_competence: f32,
+        demonstrated_competence: f32,
+        source_reliability: f32,
+        environment_stability: f32,
+        challenge_skill_ratio: f32,
+    ) -> Self {
+        // Dunning-Kruger detection
+        let gap = felt_competence - demonstrated_competence;
+        let dk_position = if gap > 0.3 && demonstrated_competence < 0.4 {
+            DkPosition::MountStupid
+        } else if felt_competence < 0.4 && demonstrated_competence < 0.5 {
+            DkPosition::ValleyOfDespair
+        } else if demonstrated_competence > 0.7 && gap.abs() < 0.15 {
+            DkPosition::PlateauOfMastery
+        } else {
+            DkPosition::SlopeOfEnlightenment
+        };
+
+        // Trust texture
+        let trust_score = source_reliability * 0.5 + environment_stability * 0.5;
+        let trust = if trust_score > 0.8 {
+            TrustTexture::Crystalline
+        } else if trust_score > 0.5 {
+            TrustTexture::Fibrous
+        } else {
+            TrustTexture::Fuzzy
+        };
+
+        // Flow state from challenge/skill ratio
+        let flow = if challenge_skill_ratio > 0.4 && challenge_skill_ratio < 0.7 {
+            FlowState::Flow
+        } else if challenge_skill_ratio < 0.2 {
+            FlowState::Boredom
+        } else if challenge_skill_ratio > 0.85 {
+            FlowState::Anxiety
+        } else if challenge_skill_ratio < 0.05 {
+            FlowState::Apathy
+        } else {
+            FlowState::Flow
+        };
+
+        // Compass override
+        let compass = if dk_position == DkPosition::MountStupid {
+            CompassDecision::ForceSandbox
+        } else if dk_position == DkPosition::ValleyOfDespair
+            && demonstrated_competence < 0.2
+        {
+            CompassDecision::ForceExplore
+        } else {
+            CompassDecision::None
+        };
+
+        let free_will = dk_position.humility_factor()
+            * trust.trust_factor()
+            * flow.patience_modifier();
+
+        let exploration_rate =
+            BASE_EXPLORATION_RATE * dk_position.exploration_modifier() * trust.trust_factor();
+
+        let fallback_threshold = FALLBACK_THRESHOLD / flow.patience_modifier();
+
+        Self {
+            dk_position,
+            trust,
+            flow,
+            compass,
+            free_will_modifier: free_will,
+            effective_exploration_rate: exploration_rate.clamp(0.0, 0.8),
+            effective_fallback_threshold: fallback_threshold.clamp(0.1, 0.8),
+        }
+    }
+
+    /// Quick assessment from rolling efficiency (when no external signals).
+    /// Uses efficiency as a proxy for demonstrated competence, and
+    /// topology confidence as felt competence.
+    pub fn from_efficiency(efficiency: f32, topology_confidence: f32) -> Self {
+        Self::assess(
+            topology_confidence,    // felt = how confident the topology is
+            efficiency,             // demonstrated = actual rolling efficiency
+            0.7,                    // default source reliability
+            0.8,                    // default environment stability
+            (efficiency - 0.3).abs().clamp(0.0, 1.0), // challenge ~ distance from mediocrity
+        )
+    }
+
+    /// Self-regulated assessment from live graph signals.
+    ///
+    /// The graph's own entropy, contradiction rate, revision velocity, and
+    /// plasticity distribution become sensory input to MUL. The system's
+    /// epistemic position is derived from the knowledge it actually has,
+    /// not just from outcome quality.
+    ///
+    /// This is the self-awareness loop: graph state → DK position → style selection
+    /// → graph mutations → graph state changes → DK position shifts → ...
+    pub fn from_graph_signals(signals: &GraphSensorium, topology_confidence: f32) -> Self {
+        // Demonstrated competence: high when graph is consistent + growing.
+        // Low entropy + few contradictions + steady revision = mastery.
+        // High entropy + many contradictions + stalled revision = valley.
+        let consistency = 1.0 - signals.contradiction_rate;
+        let growth = signals.revision_velocity.clamp(0.0, 1.0);
+        let demonstrated = consistency * 0.6 + growth * 0.4;
+
+        // Felt competence: topology confidence (how sure the RL thinks it is).
+        let felt = topology_confidence;
+
+        // Source reliability: inverse of entropy. Low entropy = reliable, consistent sources.
+        let source_reliability = 1.0 - signals.truth_entropy;
+
+        // Environment stability: inverse of plasticity flux.
+        // If many entities are Hot (rapidly changing), the environment is unstable.
+        let stability = 1.0 - signals.plasticity_flux;
+
+        // Challenge/skill ratio: contradictions are the challenge,
+        // revision velocity is the skill to resolve them.
+        let challenge = signals.contradiction_rate;
+        let skill = signals.revision_velocity;
+        let challenge_skill = if skill > 0.01 {
+            (challenge / skill).clamp(0.0, 1.0)
+        } else if challenge > 0.1 {
+            0.95 // High challenge, no skill → Anxiety
+        } else {
+            0.1 // No challenge, no skill → Boredom
+        };
+
+        Self::assess(felt, demonstrated, source_reliability, stability, challenge_skill)
+    }
+}
+
+// ============================================================================
+// Graph Sensorium — real-time signals from the knowledge graph
+// ============================================================================
+
+/// Real-time signals from the knowledge graph for MUL self-regulation.
+///
+/// The graph's own state is the primary sensory input to the meta-awareness layer.
+/// These signals drive automatic style balancing: high contradiction rate
+/// triggers more Explore/Reflex; low entropy triggers more Plan/Act.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct GraphSensorium {
+    /// Contradiction rate: contradictions / active_triplets. Range [0, 1].
+    /// High = lots of conflicting evidence → Valley of Despair, increase exploration.
+    /// Low = consistent knowledge → Slope or Plateau, increase exploitation.
+    pub contradiction_rate: f32,
+
+    /// Truth entropy: Shannon entropy of truth confidence distribution.
+    /// Normalized to [0, 1] where 0 = all triplets have same confidence,
+    /// 1 = uniform distribution across confidence bands.
+    /// High = uncertain about everything → increase exploration.
+    /// Low = confident knowledge → increase exploitation.
+    pub truth_entropy: f32,
+
+    /// Revision velocity: revisions_per_step over rolling window.
+    /// Range [0, 1] where 1 = every step produces a revision.
+    /// High = actively learning → keep current mode, the system is adapting.
+    /// Low = stagnant → either mastery (if consistent) or stuck (if inconsistent).
+    pub revision_velocity: f32,
+
+    /// Plasticity flux: fraction of entities in Hot state. Range [0, 1].
+    /// High = environment is changing rapidly → lower trust, increase exploration.
+    /// Low = stable environment → higher trust, increase exploitation.
+    pub plasticity_flux: f32,
+
+    /// Deduction yield: inferred_triplets / deduction_attempts. Range [0, 1].
+    /// High = graph structure supports rich inference → Plan/Act more.
+    /// Low = sparse graph, few chains → Explore more.
+    pub deduction_yield: f32,
+
+    /// Episodic saturation: episodes / capacity. Range [0, 1].
+    /// High = memory full → start forgetting or compressing.
+    pub episodic_saturation: f32,
+}
+
+impl GraphSensorium {
+    /// Compute from raw graph statistics.
+    pub fn compute(
+        active_triplets: usize,
+        contradictions: usize,
+        confidence_histogram: &[usize; 5], // [certain, strong, moderate, weak, unknown]
+        revisions_in_window: usize,
+        window_steps: usize,
+        hot_entities: usize,
+        total_entities: usize,
+        deduction_attempts: usize,
+        deductions_produced: usize,
+        episodic_count: usize,
+        episodic_capacity: usize,
+    ) -> Self {
+        let active = active_triplets.max(1) as f32;
+
+        let contradiction_rate = contradictions as f32 / active;
+
+        // Shannon entropy of confidence distribution
+        let total: f32 = confidence_histogram.iter().sum::() as f32;
+        let truth_entropy = if total > 0.0 {
+            let mut h = 0.0f32;
+            for &count in confidence_histogram {
+                if count > 0 {
+                    let p = count as f32 / total;
+                    h -= p * p.ln();
+                }
+            }
+            // Normalize by max entropy (ln(5) ≈ 1.609)
+            (h / 1.609).clamp(0.0, 1.0)
+        } else {
+            0.0
+        };
+
+        let revision_velocity = if window_steps > 0 {
+            (revisions_in_window as f32 / window_steps as f32).clamp(0.0, 1.0)
+        } else {
+            0.0
+        };
+
+        let plasticity_flux = if total_entities > 0 {
+            hot_entities as f32 / total_entities as f32
+        } else {
+            0.0
+        };
+
+        let deduction_yield = if deduction_attempts > 0 {
+            (deductions_produced as f32 / deduction_attempts as f32).clamp(0.0, 1.0)
+        } else {
+            0.0
+        };
+
+        let episodic_saturation = if episodic_capacity > 0 {
+            episodic_count as f32 / episodic_capacity as f32
+        } else {
+            0.0
+        };
+
+        Self {
+            contradiction_rate,
+            truth_entropy,
+            revision_velocity,
+            plasticity_flux,
+            deduction_yield,
+            episodic_saturation,
+        }
+    }
+
+    /// What the graph signals suggest: explore more, exploit more, or panic.
+    pub fn suggested_bias(&self) -> GraphBias {
+        if self.contradiction_rate > 0.3 {
+            GraphBias::Resolve // Too many contradictions — focus on reflex/resolution
+        } else if self.truth_entropy > 0.7 {
+            GraphBias::Explore // Uncertain about everything — gather more evidence
+        } else if self.deduction_yield > 0.5 && self.truth_entropy < 0.3 {
+            GraphBias::Exploit // Rich consistent graph — exploit the knowledge
+        } else if self.plasticity_flux > 0.5 {
+            GraphBias::Adapt // Environment changing — stay flexible
+        } else if self.revision_velocity < 0.05 && self.truth_entropy > 0.4 {
+            GraphBias::Stagnant // Not learning + still uncertain — shake things up
+        } else {
+            GraphBias::Balanced // Normal operation
+        }
+    }
+}
+
+/// Graph-suggested cognitive bias.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum GraphBias {
+    /// High contradictions — focus on resolution (Reflex/Metacognitive).
+    Resolve,
+    /// High entropy — gather evidence (Explore/Divergent).
+    Explore,
+    /// Rich consistent graph — use the knowledge (Plan/Act/Analytical).
+    Exploit,
+    /// High plasticity — stay flexible (Creative/Exploratory).
+    Adapt,
+    /// Low revision + high entropy — stuck, need perturbation.
+    Stagnant,
+    /// Normal — let topology decide.
+    Balanced,
+}
+
+// ============================================================================
+// NARS Auto-Heal Contingency
+// ============================================================================
+
+/// Actions the NARS auto-heal contingency can take to fix an unorganized graph.
+///
+/// When the graph has low truth scores, high entropy, or unresolved contradictions,
+/// the contingency fires automatically before the next style selection.
+/// This is the immune system: detect disease → apply remedy → measure recovery.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct HealingAction {
+    pub action: HealingType,
+    pub reason: String,
+    pub triplets_affected: usize,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum HealingType {
+    /// Run NARS revision on all triplets with very low confidence.
+    /// Sets confidence to `from_evidence(1, 0)` — weak but non-zero.
+    BootstrapTruth,
+    /// Run contradiction detection + resolution.
+    /// Contradicting triplets get their confidence reduced by revision with counter-evidence.
+    ResolveContradictions,
+    /// Run deduction to fill in missing links.
+    /// A→B and B→C produces A→C with deduced truth.
+    InferMissingLinks,
+    /// Compact soft-deleted triplets (garbage collection).
+    CompactDeleted,
+    /// Re-normalize truth values: scale all confidences so max = 0.95.
+    /// Prevents confidence inflation from repeated self-revision.
+    NormalizeTruth,
+    /// Reset topology: when the orchestrator's own learning is poisoned
+    /// by bad data, wipe the NARS edges and restart from uniform prior.
+    ResetTopology,
+}
+
+/// Determine what healing actions the graph needs.
+///
+/// Called automatically by the orchestrator when `update_sensorium()` detects
+/// graph health issues. Returns a prioritized list of healing actions.
+pub fn diagnose_healing(signals: &GraphSensorium) -> Vec {
+    let mut actions = Vec::new();
+
+    // High contradiction rate → resolve contradictions first.
+    if signals.contradiction_rate > 0.15 {
+        actions.push(HealingAction {
+            action: HealingType::ResolveContradictions,
+            reason: format!(
+                "Contradiction rate {:.1}% exceeds 15% threshold",
+                signals.contradiction_rate * 100.0
+            ),
+            triplets_affected: 0, // Caller fills this in.
+        });
+    }
+
+    // High entropy + low revision = unorganized, truth scores not set properly.
+    if signals.truth_entropy > 0.6 && signals.revision_velocity < 0.1 {
+        actions.push(HealingAction {
+            action: HealingType::BootstrapTruth,
+            reason: format!(
+                "High entropy ({:.2}) + low revision velocity ({:.2}): truth values likely uninitialized",
+                signals.truth_entropy, signals.revision_velocity
+            ),
+            triplets_affected: 0,
+        });
+    }
+
+    // Low deduction yield with enough data → graph has gaps NARS can fill.
+    if signals.deduction_yield < 0.1 && signals.truth_entropy < 0.5 {
+        actions.push(HealingAction {
+            action: HealingType::InferMissingLinks,
+            reason: "Low deduction yield but consistent data — inference can fill gaps".into(),
+            triplets_affected: 0,
+        });
+    }
+
+    // High episodic saturation → compact deleted triplets to free memory.
+    if signals.episodic_saturation > 0.85 {
+        actions.push(HealingAction {
+            action: HealingType::CompactDeleted,
+            reason: format!(
+                "Episodic saturation {:.0}% — compact to free space",
+                signals.episodic_saturation * 100.0
+            ),
+            triplets_affected: 0,
+        });
+    }
+
+    // Very high truth entropy suggests truth inflation (everything at max confidence).
+    // Normalize to prevent overconfidence.
+    if signals.truth_entropy < 0.1 && signals.contradiction_rate < 0.05 {
+        actions.push(HealingAction {
+            action: HealingType::NormalizeTruth,
+            reason: "Very low entropy with few contradictions — possible truth inflation".into(),
+            triplets_affected: 0,
+        });
+    }
+
+    actions
+}
+
+impl MetaOrchestrator {
+    /// Auto-heal contingency: when graph signals indicate disease,
+    /// apply healing actions before the next thinking step.
+    ///
+    /// Returns the list of healing actions that should be applied.
+    /// The caller is responsible for executing them against the actual graph
+    /// (since the orchestrator doesn't own the graph).
+    pub fn auto_heal(&mut self) -> Vec {
+        let Some(ref signals) = self.sensorium else {
+            return Vec::new();
+        };
+
+        let mut actions = diagnose_healing(signals);
+
+        // If the orchestrator's own topology is producing consistently bad results
+        // AND the graph is in bad shape, reset the topology too.
+        if self.rolling_efficiency() < 0.2
+            && self.step_count > 20
+            && signals.truth_entropy > 0.5
+        {
+            actions.push(HealingAction {
+                action: HealingType::ResetTopology,
+                reason: format!(
+                    "Orchestrator efficiency {:.2} with high entropy {:.2} after {} steps — topology likely poisoned",
+                    self.rolling_efficiency(), signals.truth_entropy, self.step_count
+                ),
+                triplets_affected: 0,
+            });
+            // Actually reset the topology.
+            self.topology = StyleTopology::new();
+            self.temperature = 0.5; // Warm restart with moderate noise.
+            self.quality_window.clear();
+            self.switch_mode(
+                OrchestratorMode::HardcodedFallback,
+                "Topology reset by auto-heal — starting from hardcoded baseline".into(),
+            );
+        }
+
+        actions
+    }
+}
+
+// ============================================================================
+// Thinking Styles (maps to lance-graph-planner ThinkingStyle)
+// ============================================================================
+
+/// The 4 agent roles mapped to thinking styles.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub enum AgentStyle {
+    /// Plan agent → Analytical / Convergent.
+    /// Deep sequential reasoning, high confidence chains.
+    Plan,
+    /// Action agent → Focused / Deductive.
+    /// Narrow, precise, single best action selection.
+    Act,
+    /// Exploration agent → Exploratory / Divergent.
+    /// Lateral connections, find surprises, expand knowledge.
+    Explore,
+    /// Reflex agent → Metacognitive / Revision.
+    /// Learn from mistakes, revise beliefs, detect inefficiency.
+    Reflex,
+}
+
+impl AgentStyle {
+    pub fn all() -> &'static [AgentStyle] {
+        &[
+            AgentStyle::Plan,
+            AgentStyle::Act,
+            AgentStyle::Explore,
+            AgentStyle::Reflex,
+        ]
+    }
+
+    pub fn name(&self) -> &'static str {
+        match self {
+            Self::Plan => "plan",
+            Self::Act => "act",
+            Self::Explore => "explore",
+            Self::Reflex => "reflex",
+        }
+    }
+
+    /// The hardcoded sequence: plan → act → explore → reflex.
+    pub fn hardcoded_sequence() -> &'static [AgentStyle] {
+        &[
+            AgentStyle::Plan,
+            AgentStyle::Act,
+            AgentStyle::Explore,
+            AgentStyle::Reflex,
+        ]
+    }
+}
+
+// ============================================================================
+// NARS Topology — learned style activation weights
+// ============================================================================
+
+/// A directed edge in the style topology: "after style A, style B works well."
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct TopologyEdge {
+    /// Source style.
+    pub from: AgentStyle,
+    /// Target style.
+    pub to: AgentStyle,
+    /// NARS truth value: frequency = success rate, confidence = evidence strength.
+    pub truth: TruthValue,
+    /// Number of times this transition was observed.
+    pub observations: u64,
+    /// Cumulative quality score for this transition.
+    pub total_quality: f64,
+}
+
+impl TopologyEdge {
+    fn new(from: AgentStyle, to: AgentStyle) -> Self {
+        Self {
+            from,
+            to,
+            // Start with weak prior: frequency 0.5 (no bias), low confidence.
+            truth: TruthValue::new(0.5, 0.1),
+            observations: 0,
+            total_quality: 0.0,
+        }
+    }
+
+    /// NARS revision with new evidence.
+    fn revise(&mut self, quality: f64) {
+        self.observations += 1;
+        self.total_quality += quality;
+
+        // New evidence truth: frequency = quality, confidence grows with observations.
+        let evidence_f = quality.clamp(0.0, 1.0);
+        let evidence_c = (self.observations as f64 / (self.observations as f64 + 5.0)).min(0.99);
+        let evidence = TruthValue::new(evidence_f, evidence_c);
+
+        self.truth = self.truth.revision(&evidence);
+    }
+
+    /// Expected quality = truth expectation.
+    fn expected_quality(&self) -> f64 {
+        self.truth.expectation()
+    }
+}
+
+/// The full topology: 4×4 = 16 edges between styles.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct StyleTopology {
+    /// Edges keyed by (from, to).
+    edges: HashMap<(AgentStyle, AgentStyle), TopologyEdge>,
+}
+
+impl StyleTopology {
+    pub fn new() -> Self {
+        let mut edges = HashMap::new();
+        for &from in AgentStyle::all() {
+            for &to in AgentStyle::all() {
+                edges.insert((from, to), TopologyEdge::new(from, to));
+            }
+        }
+        Self { edges }
+    }
+
+    /// Get the edge from→to.
+    pub fn edge(&self, from: AgentStyle, to: AgentStyle) -> &TopologyEdge {
+        &self.edges[&(from, to)]
+    }
+
+    /// Revise an edge with observed quality.
+    pub fn revise(&mut self, from: AgentStyle, to: AgentStyle, quality: f64) {
+        self.edges.get_mut(&(from, to)).unwrap().revise(quality);
+    }
+
+    /// Select the best next style given the current style.
+    ///
+    /// With probability `exploration_rate`, picks a random style (explore).
+    /// Otherwise picks the highest expected quality.
+    pub fn select_next(
+        &self,
+        current: AgentStyle,
+        step: u64,
+    ) -> AgentStyle {
+        // Deterministic "random" from step counter for reproducibility.
+        let pseudo_random = ((step.wrapping_mul(0x9E3779B97F4A7C15)) >> 56) as f32 / 256.0;
+
+        if pseudo_random < EXPLORATION_RATE {
+            // Explore: pick the LEAST observed edge (maximize information gain).
+            let mut best = AgentStyle::Plan;
+            let mut min_obs = u64::MAX;
+            for &to in AgentStyle::all() {
+                let edge = self.edge(current, to);
+                if edge.observations < min_obs {
+                    min_obs = edge.observations;
+                    best = to;
+                }
+            }
+            return best;
+        }
+
+        // Exploit: pick highest expected quality.
+        let mut best = AgentStyle::Plan;
+        let mut best_eq = f64::NEG_INFINITY;
+        for &to in AgentStyle::all() {
+            let eq = self.edge(current, to).expected_quality();
+            if eq > best_eq {
+                best_eq = eq;
+                best = to;
+            }
+        }
+        best
+    }
+
+    /// Total observations across all edges.
+    pub fn total_observations(&self) -> u64 {
+        self.edges.values().map(|e| e.observations).sum()
+    }
+
+    /// Snapshot for MRI reporting.
+    pub fn snapshot(&self) -> Vec {
+        self.edges
+            .values()
+            .map(|e| TopologyEdgeSnapshot {
+                from: e.from.name().to_string(),
+                to: e.to.name().to_string(),
+                frequency: e.truth.frequency,
+                confidence: e.truth.confidence,
+                expectation: e.truth.expectation(),
+                observations: e.observations,
+                avg_quality: if e.observations > 0 {
+                    e.total_quality / e.observations as f64
+                } else {
+                    0.0
+                },
+            })
+            .collect()
+    }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct TopologyEdgeSnapshot {
+    pub from: String,
+    pub to: String,
+    pub frequency: f64,
+    pub confidence: f64,
+    pub expectation: f64,
+    pub observations: u64,
+    pub avg_quality: f64,
+}
+
+// ============================================================================
+// Orchestrator Mode
+// ============================================================================
+
+/// Current orchestration mode.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum OrchestratorMode {
+    /// NARS topology drives style selection.
+    Adaptive,
+    /// Classic plan→act→explore→reflex loop.
+    HardcodedFallback,
+}
+
+/// Why the mode was switched.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ModeSwitchEvent {
+    pub from: OrchestratorMode,
+    pub to: OrchestratorMode,
+    pub reason: String,
+    pub efficiency_at_switch: f32,
+    pub step: u64,
+}
+
+// ============================================================================
+// Meta Orchestrator
+// ============================================================================
+
+/// The meta-aware orchestrator.
+///
+/// Monitors its own efficiency and transparently switches between
+/// adaptive (NARS RL) and hardcoded (plan→act→explore→reflex) modes.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct MetaOrchestrator {
+    /// Current mode.
+    pub mode: OrchestratorMode,
+    /// NARS topology for adaptive mode.
+    pub topology: StyleTopology,
+    /// Last style executed (for topology edge tracking).
+    pub last_style: Option,
+    /// Rolling window of outcome qualities.
+    pub quality_window: Vec,
+    /// Total steps executed.
+    pub step_count: u64,
+    /// History of mode switches.
+    pub mode_switches: Vec,
+    /// Hardcoded sequence position (for fallback mode).
+    pub fallback_position: usize,
+    /// Steps spent in current mode.
+    pub steps_in_current_mode: usize,
+    /// Latest MUL assessment.
+    pub last_mul: MulAssessment,
+    /// Latest graph sensorium (None until first graph signal arrives).
+    pub sensorium: Option,
+    /// Stagnation temperature: injected noise when thinking is stale.
+    /// 0.0 = deterministic (normal). 1.0 = maximum randomness (shake things up).
+    /// Auto-increases when GraphBias::Stagnant detected, decays otherwise.
+    pub temperature: f32,
+}
+
+/// The result of one orchestrator step.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct StepResult {
+    /// Which style was selected.
+    pub style: AgentStyle,
+    /// Current mode when this step ran.
+    pub mode: OrchestratorMode,
+    /// Why this style was selected.
+    pub reason: StepReason,
+    /// MUL assessment at time of selection.
+    pub mul: MulAssessment,
+    /// Rolling efficiency at time of selection.
+    pub efficiency: f32,
+    /// Step number.
+    pub step: u64,
+}
+
+/// Why a particular style was chosen.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub enum StepReason {
+    /// NARS topology selected this as highest expected quality (scaled by MUL free_will).
+    TopologyExploit {
+        expected_quality: f64,
+        confidence: f64,
+    },
+    /// Exploration: picked least-observed edge for information gain.
+    /// Exploration rate was modulated by DK position + trust texture.
+    TopologyExplore {
+        observations: u64,
+    },
+    /// Hardcoded sequence position (fallback mode).
+    HardcodedSequence {
+        position: usize,
+    },
+    /// MUL compass/DK override — forced a specific style regardless of topology.
+    MulOverride {
+        dk: DkPosition,
+        compass: CompassDecision,
+        explanation: String,
+    },
+}
+
+impl MetaOrchestrator {
+    /// Create a new orchestrator starting in adaptive mode.
+    pub fn new() -> Self {
+        Self {
+            mode: OrchestratorMode::Adaptive,
+            topology: StyleTopology::new(),
+            last_style: None,
+            quality_window: Vec::with_capacity(WINDOW_SIZE),
+            step_count: 0,
+            mode_switches: Vec::new(),
+            fallback_position: 0,
+            steps_in_current_mode: 0,
+            last_mul: MulAssessment::from_efficiency(0.5, 0.5),
+            sensorium: None,
+            temperature: 0.0,
+        }
+    }
+
+    /// Feed real-time graph signals into the orchestrator.
+    ///
+    /// This is the sensory input loop: graph → sensorium → MUL → style selection.
+    /// Call this before `select_next()` to enable self-regulated thinking.
+    pub fn update_sensorium(&mut self, signals: GraphSensorium) {
+        // Auto-adjust temperature based on graph bias.
+        match signals.suggested_bias() {
+            GraphBias::Stagnant => {
+                // Increase temperature: thinking is stuck, inject noise.
+                self.temperature = (self.temperature + 0.15).min(0.9);
+            }
+            GraphBias::Resolve => {
+                // Moderate temperature: contradictions need diverse approaches.
+                self.temperature = (self.temperature + 0.05).min(0.5);
+            }
+            GraphBias::Exploit => {
+                // Cool down: graph is consistent, don't perturb.
+                self.temperature = (self.temperature - 0.1).max(0.0);
+            }
+            _ => {
+                // Gentle decay toward 0.
+                self.temperature = (self.temperature - 0.02).max(0.0);
+            }
+        }
+        self.sensorium = Some(signals);
+    }
+
+    /// Rolling efficiency: mean of quality window.
+    pub fn rolling_efficiency(&self) -> f32 {
+        if self.quality_window.is_empty() {
+            return 0.5; // neutral prior
+        }
+        self.quality_window.iter().sum::() / self.quality_window.len() as f32
+    }
+
+    /// Select the next style to execute, modulated by MUL assessment.
+    ///
+    /// The MUL layer runs BEFORE style selection:
+    /// 1. Assess DK position from efficiency (demonstrated) vs topology confidence (felt)
+    /// 2. Mount Stupid? → Force sandbox (return Reflex only, block everything else)
+    /// 3. Compass says ForceExplore? → Override topology, return Explore
+    /// 4. Flow state adjusts fallback threshold and exploration rate
+    /// 5. Trust texture scales topology edge confidence
+    /// 6. Then: adaptive (NARS topology × MUL free_will) or hardcoded fallback
+    pub fn select_next(&mut self) -> StepResult {
+        self.step_count += 1;
+        self.steps_in_current_mode += 1;
+
+        // ── MUL Assessment (self-regulated from graph signals when available) ──
+        let avg_confidence = self.topology.edges.values()
+            .filter(|e| e.observations > 0)
+            .map(|e| e.truth.confidence)
+            .sum::()
+            / self.topology.edges.values().filter(|e| e.observations > 0).count().max(1) as f64;
+        let mul = if let Some(ref signals) = self.sensorium {
+            MulAssessment::from_graph_signals(signals, avg_confidence as f32)
+        } else {
+            MulAssessment::from_efficiency(self.rolling_efficiency(), avg_confidence as f32)
+        };
+        self.last_mul = mul.clone();
+
+        // ── Compass Override ──
+        if mul.compass == CompassDecision::ForceSandbox {
+            return StepResult {
+                style: AgentStyle::Reflex,
+                mode: self.mode,
+                reason: StepReason::MulOverride {
+                    dk: mul.dk_position,
+                    compass: mul.compass,
+                    explanation: "Mount Stupid detected — sandboxing to Reflex only".into(),
+                },
+                mul,
+                efficiency: self.rolling_efficiency(),
+                step: self.step_count,
+            };
+        }
+
+        if mul.compass == CompassDecision::ForceExplore {
+            return StepResult {
+                style: AgentStyle::Explore,
+                mode: self.mode,
+                reason: StepReason::MulOverride {
+                    dk: mul.dk_position,
+                    compass: mul.compass,
+                    explanation: "Valley of Despair + low competence — compass forces exploration".into(),
+                },
+                mul,
+                efficiency: self.rolling_efficiency(),
+                step: self.step_count,
+            };
+        }
+
+        // ── Mode-dependent selection with MUL modulation ──
+        let (style, reason) = match self.mode {
+            OrchestratorMode::Adaptive if mul.dk_position.allows_adaptive() => {
+                let current = self.last_style.unwrap_or(AgentStyle::Plan);
+
+                // Use MUL-adjusted exploration rate
+                let pseudo_random =
+                    ((self.step_count.wrapping_mul(0x9E3779B97F4A7C15)) >> 56) as f32 / 256.0;
+
+                if pseudo_random < mul.effective_exploration_rate {
+                    // Explore: pick least observed edge (maximize information gain)
+                    let mut best = AgentStyle::Plan;
+                    let mut min_obs = u64::MAX;
+                    for &to in AgentStyle::all() {
+                        let edge = self.topology.edge(current, to);
+                        if edge.observations < min_obs {
+                            min_obs = edge.observations;
+                            best = to;
+                        }
+                    }
+                    (
+                        best,
+                        StepReason::TopologyExplore {
+                            observations: min_obs,
+                        },
+                    )
+                } else {
+                    // Exploit: pick highest expected quality, SCALED by MUL free_will.
+                    // When temperature > 0, inject deterministic noise to break stagnation.
+                    // Temperature acts like LLM temperature: 0 = greedy, 1 = random.
+                    let mut best = AgentStyle::Plan;
+                    let mut best_eq = f64::NEG_INFINITY;
+                    for &to in AgentStyle::all() {
+                        let base_eq = self.topology.edge(current, to).expected_quality()
+                            * mul.free_will_modifier as f64;
+                        // Temperature noise: deterministic from step + style index
+                        let noise = if self.temperature > 0.01 {
+                            let hash = (self.step_count.wrapping_mul(0x517cc1b727220a95)
+                                ^ (to as u64).wrapping_mul(0x6c62272e07bb0142)) >> 48;
+                            let uniform = (hash as f64) / 65536.0; // [0, 1)
+                            (uniform - 0.5) * self.temperature as f64
+                        } else {
+                            0.0
+                        };
+                        let eq = base_eq + noise;
+                        if eq > best_eq {
+                            best_eq = eq;
+                            best = to;
+                        }
+                    }
+                    let edge = self.topology.edge(current, best);
+                    (
+                        best,
+                        StepReason::TopologyExploit {
+                            expected_quality: best_eq,
+                            confidence: edge.truth.confidence * mul.trust.trust_factor() as f64,
+                        },
+                    )
+                }
+            }
+            // Mount Stupid in adaptive mode → force fallback
+            OrchestratorMode::Adaptive => {
+                self.switch_mode(
+                    OrchestratorMode::HardcodedFallback,
+                    format!("DK position {:?} blocks adaptive mode", mul.dk_position),
+                );
+                let seq = AgentStyle::hardcoded_sequence();
+                let pos = self.fallback_position % seq.len();
+                self.fallback_position += 1;
+                (seq[pos], StepReason::HardcodedSequence { position: pos })
+            }
+            OrchestratorMode::HardcodedFallback => {
+                let seq = AgentStyle::hardcoded_sequence();
+                let pos = self.fallback_position % seq.len();
+                self.fallback_position += 1;
+                (seq[pos], StepReason::HardcodedSequence { position: pos })
+            }
+        };
+
+        StepResult {
+            style,
+            mode: self.mode,
+            reason,
+            mul,
+            efficiency: self.rolling_efficiency(),
+            step: self.step_count,
+        }
+    }
+
+    /// Record the outcome of the last step and update NARS topology.
+    ///
+    /// `quality` is in [0.0, 1.0] where 1.0 = perfect outcome.
+    /// This drives the reinforcement learning: good outcomes strengthen
+    /// the topology edge that produced them.
+    pub fn record_outcome(&mut self, style: AgentStyle, quality: f32) {
+        // Update rolling window.
+        if self.quality_window.len() >= WINDOW_SIZE {
+            self.quality_window.remove(0);
+        }
+        self.quality_window.push(quality);
+
+        // NARS revision on the topology edge.
+        if let Some(prev) = self.last_style {
+            self.topology.revise(prev, style, quality as f64);
+        }
+        self.last_style = Some(style);
+
+        // Meta-awareness: check if we should switch modes.
+        // Uses MUL-adjusted thresholds (flow state modifies patience).
+        if self.quality_window.len() >= MIN_OBSERVATIONS {
+            let eff = self.rolling_efficiency();
+            let adjusted_fallback = self.last_mul.effective_fallback_threshold;
+            let adjusted_restore = RESTORE_THRESHOLD * self.last_mul.flow.patience_modifier();
+            match self.mode {
+                OrchestratorMode::Adaptive if eff < adjusted_fallback => {
+                    self.switch_mode(
+                        OrchestratorMode::HardcodedFallback,
+                        format!(
+                            "Adaptive efficiency {:.2} < MUL-adjusted threshold {:.2} (DK={:?}, Flow={:?}) after {} steps",
+                            eff, adjusted_fallback, self.last_mul.dk_position, self.last_mul.flow, self.steps_in_current_mode
+                        ),
+                    );
+                }
+                OrchestratorMode::HardcodedFallback if eff > adjusted_restore => {
+                    self.switch_mode(
+                        OrchestratorMode::Adaptive,
+                        format!(
+                            "Fallback efficiency {:.2} > MUL-adjusted restore {:.2} (DK={:?}), re-enabling adaptive",
+                            eff, adjusted_restore, self.last_mul.dk_position
+                        ),
+                    );
+                }
+                _ => {}
+            }
+        }
+    }
+
+    fn switch_mode(&mut self, new_mode: OrchestratorMode, reason: String) {
+        let event = ModeSwitchEvent {
+            from: self.mode,
+            to: new_mode,
+            reason,
+            efficiency_at_switch: self.rolling_efficiency(),
+            step: self.step_count,
+        };
+        self.mode_switches.push(event);
+        self.mode = new_mode;
+        self.steps_in_current_mode = 0;
+        self.fallback_position = 0;
+    }
+
+    /// Full status snapshot for the /mri endpoint.
+    pub fn snapshot(&self) -> OrchestratorSnapshot {
+        OrchestratorSnapshot {
+            mode: self.mode,
+            step_count: self.step_count,
+            rolling_efficiency: self.rolling_efficiency(),
+            steps_in_current_mode: self.steps_in_current_mode,
+            topology: self.topology.snapshot(),
+            mode_switches: self.mode_switches.clone(),
+            last_style: self.last_style.map(|s| s.name().to_string()),
+            mul: self.last_mul.clone(),
+            fallback_threshold: self.last_mul.effective_fallback_threshold,
+            restore_threshold: RESTORE_THRESHOLD * self.last_mul.flow.patience_modifier(),
+        }
+    }
+}
+
+impl Default for MetaOrchestrator {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+/// Serializable snapshot for API endpoints.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct OrchestratorSnapshot {
+    pub mode: OrchestratorMode,
+    pub step_count: u64,
+    pub rolling_efficiency: f32,
+    pub steps_in_current_mode: usize,
+    pub topology: Vec,
+    pub mode_switches: Vec,
+    pub last_style: Option,
+    pub mul: MulAssessment,
+    pub fallback_threshold: f32,
+    pub restore_threshold: f32,
+}
+
+// ============================================================================
+// Tests
+// ============================================================================
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_new_orchestrator_starts_adaptive() {
+        let orch = MetaOrchestrator::new();
+        assert_eq!(orch.mode, OrchestratorMode::Adaptive);
+        assert_eq!(orch.step_count, 0);
+    }
+
+    #[test]
+    fn test_select_produces_step_result() {
+        let mut orch = MetaOrchestrator::new();
+        let result = orch.select_next();
+        assert_eq!(result.mode, OrchestratorMode::Adaptive);
+        assert_eq!(result.step, 1);
+    }
+
+    #[test]
+    fn test_record_outcome_revises_topology() {
+        let mut orch = MetaOrchestrator::new();
+        let r = orch.select_next();
+        orch.record_outcome(r.style, 0.8);
+
+        // After one observation, topology should have data.
+        assert_eq!(orch.quality_window.len(), 1);
+        assert!((orch.quality_window[0] - 0.8).abs() < f32::EPSILON);
+    }
+
+    #[test]
+    fn test_fallback_on_low_efficiency() {
+        let mut orch = MetaOrchestrator::new();
+
+        // Feed consistently bad outcomes.
+        for _ in 0..10 {
+            let r = orch.select_next();
+            orch.record_outcome(r.style, 0.1); // very bad
+        }
+
+        // Should have switched to fallback.
+        assert_eq!(orch.mode, OrchestratorMode::HardcodedFallback);
+        assert!(!orch.mode_switches.is_empty());
+        assert_eq!(
+            orch.mode_switches.last().unwrap().to,
+            OrchestratorMode::HardcodedFallback
+        );
+    }
+
+    #[test]
+    fn test_restore_on_high_efficiency() {
+        let mut orch = MetaOrchestrator::new();
+
+        // Force into fallback.
+        for _ in 0..10 {
+            let r = orch.select_next();
+            orch.record_outcome(r.style, 0.1);
+        }
+        assert_eq!(orch.mode, OrchestratorMode::HardcodedFallback);
+
+        // Now feed good outcomes in fallback mode.
+        for _ in 0..10 {
+            let r = orch.select_next();
+            orch.record_outcome(r.style, 0.9); // very good
+        }
+
+        // Should restore to adaptive.
+        assert_eq!(orch.mode, OrchestratorMode::Adaptive);
+        assert!(orch.mode_switches.len() >= 2);
+    }
+
+    #[test]
+    fn test_hardcoded_follows_sequence() {
+        let mut orch = MetaOrchestrator::new();
+        // Force fallback.
+        orch.mode = OrchestratorMode::HardcodedFallback;
+
+        let r1 = orch.select_next();
+        assert_eq!(r1.style, AgentStyle::Plan);
+        let r2 = orch.select_next();
+        assert_eq!(r2.style, AgentStyle::Act);
+        let r3 = orch.select_next();
+        assert_eq!(r3.style, AgentStyle::Explore);
+        let r4 = orch.select_next();
+        assert_eq!(r4.style, AgentStyle::Reflex);
+        // Wraps around.
+        let r5 = orch.select_next();
+        assert_eq!(r5.style, AgentStyle::Plan);
+    }
+
+    #[test]
+    fn test_topology_learns_preference() {
+        let mut orch = MetaOrchestrator::new();
+
+        // Train: Plan→Act with high quality, Plan→Explore with low quality.
+        orch.last_style = Some(AgentStyle::Plan);
+        for _ in 0..20 {
+            orch.topology.revise(AgentStyle::Plan, AgentStyle::Act, 0.9);
+            orch.topology
+                .revise(AgentStyle::Plan, AgentStyle::Explore, 0.2);
+        }
+
+        let act_eq = orch
+            .topology
+            .edge(AgentStyle::Plan, AgentStyle::Act)
+            .expected_quality();
+        let explore_eq = orch
+            .topology
+            .edge(AgentStyle::Plan, AgentStyle::Explore)
+            .expected_quality();
+
+        // Act should be strongly preferred after Plan.
+        assert!(
+            act_eq > explore_eq,
+            "act_eq={:.3} should be > explore_eq={:.3}",
+            act_eq,
+            explore_eq
+        );
+    }
+
+    #[test]
+    fn test_rolling_efficiency() {
+        let mut orch = MetaOrchestrator::new();
+        assert!((orch.rolling_efficiency() - 0.5).abs() < f32::EPSILON); // neutral prior
+
+        orch.quality_window = vec![0.8, 0.6, 0.9, 0.7];
+        assert!((orch.rolling_efficiency() - 0.75).abs() < 0.01);
+    }
+
+    #[test]
+    fn test_snapshot_includes_mul() {
+        let mut orch = MetaOrchestrator::new();
+        let r = orch.select_next();
+        orch.record_outcome(r.style, 0.7);
+
+        let snap = orch.snapshot();
+        assert_eq!(snap.mode, OrchestratorMode::Adaptive);
+        assert_eq!(snap.step_count, 1);
+        // 4×4 = 16 topology edges.
+        assert_eq!(snap.topology.len(), 16);
+        // MUL assessment should be present.
+        assert!(snap.mul.free_will_modifier > 0.0);
+    }
+
+    #[test]
+    fn test_mode_switch_event_recorded() {
+        let mut orch = MetaOrchestrator::new();
+        for _ in 0..10 {
+            let r = orch.select_next();
+            orch.record_outcome(r.style, 0.1);
+        }
+
+        let last_switch = orch.mode_switches.last().unwrap();
+        assert_eq!(last_switch.from, OrchestratorMode::Adaptive);
+        assert_eq!(last_switch.to, OrchestratorMode::HardcodedFallback);
+        assert!(last_switch.reason.contains("efficiency") || last_switch.reason.contains("DK"));
+    }
+
+    #[test]
+    fn test_mul_mount_stupid_forces_sandbox() {
+        let mul = MulAssessment::assess(0.95, 0.1, 0.5, 0.5, 0.5);
+        assert_eq!(mul.dk_position, DkPosition::MountStupid);
+        assert_eq!(mul.compass, CompassDecision::ForceSandbox);
+        assert!(!mul.dk_position.allows_adaptive());
+    }
+
+    #[test]
+    fn test_mul_valley_increases_exploration() {
+        let valley = MulAssessment::assess(0.2, 0.3, 0.7, 0.8, 0.5);
+        let plateau = MulAssessment::assess(0.8, 0.85, 0.9, 0.9, 0.5);
+        // Valley should have higher exploration rate than Plateau.
+        assert!(valley.effective_exploration_rate > plateau.effective_exploration_rate);
+    }
+
+    #[test]
+    fn test_mul_anxiety_tightens_fallback() {
+        let flow = MulAssessment::assess(0.6, 0.6, 0.7, 0.8, 0.5);
+        let anxious = MulAssessment::assess(0.6, 0.6, 0.7, 0.8, 0.95);
+        // Anxiety should have tighter (higher) fallback threshold.
+        assert!(anxious.effective_fallback_threshold > flow.effective_fallback_threshold);
+    }
+
+    #[test]
+    fn test_mul_crystalline_trusts_topology() {
+        let crystalline = MulAssessment::assess(0.7, 0.75, 0.95, 0.95, 0.5);
+        let fuzzy = MulAssessment::assess(0.7, 0.75, 0.3, 0.3, 0.5);
+        assert!(crystalline.free_will_modifier > fuzzy.free_will_modifier);
+    }
+
+    #[test]
+    fn test_step_result_has_mul() {
+        let mut orch = MetaOrchestrator::new();
+        let result = orch.select_next();
+        // MUL should always be present.
+        assert!(result.mul.free_will_modifier >= 0.0);
+    }
+
+    #[test]
+    fn test_window_size_cap() {
+        let mut orch = MetaOrchestrator::new();
+        for i in 0..50 {
+            orch.quality_window.push(i as f32 / 50.0);
+            if orch.quality_window.len() > WINDOW_SIZE {
+                orch.quality_window.remove(0);
+            }
+        }
+        assert_eq!(orch.quality_window.len(), WINDOW_SIZE);
+    }
+
+    // ── Graph Sensorium tests ──
+
+    #[test]
+    fn test_graph_sensorium_healthy() {
+        let signals = GraphSensorium::compute(
+            100, 2,                  // 100 active, 2 contradictions
+            &[80, 10, 5, 3, 2],     // mostly certain
+            5, 10,                   // 5 revisions in 10 steps
+            3, 50,                   // 3/50 entities hot
+            10, 15,                  // 10 deductions from 15 attempts
+            5, 20,                   // 5/20 episodic
+        );
+        assert!(signals.contradiction_rate < 0.1);
+        assert!(signals.truth_entropy < 0.5); // mostly certain
+        assert_eq!(signals.suggested_bias(), GraphBias::Balanced);
+    }
+
+    #[test]
+    fn test_graph_sensorium_contradicted() {
+        let signals = GraphSensorium::compute(
+            100, 40,                 // 40% contradictions!
+            &[10, 10, 30, 30, 20],  // spread across bands
+            1, 10,                   // low revision
+            20, 50,                  // 40% hot
+            2, 20,                   // low deduction
+            18, 20,                  // near-full episodic
+        );
+        assert!(signals.contradiction_rate > 0.3);
+        assert_eq!(signals.suggested_bias(), GraphBias::Resolve);
+    }
+
+    #[test]
+    fn test_graph_sensorium_stagnant() {
+        let signals = GraphSensorium::compute(
+            100, 5,
+            &[20, 20, 20, 20, 20],  // uniform = high entropy
+            0, 20,                   // zero revisions = stagnant
+            2, 100,                  // low plasticity
+            0, 10,                   // zero deductions
+            5, 20,
+        );
+        assert!(signals.revision_velocity < 0.05);
+        assert!(signals.truth_entropy > 0.5);
+        assert_eq!(signals.suggested_bias(), GraphBias::Stagnant);
+    }
+
+    #[test]
+    fn test_temperature_rises_on_stagnation() {
+        let mut orch = MetaOrchestrator::new();
+        assert!((orch.temperature - 0.0).abs() < f32::EPSILON);
+
+        let stagnant = GraphSensorium::compute(
+            100, 5, &[20, 20, 20, 20, 20], 0, 20, 2, 100, 0, 10, 5, 20,
+        );
+        orch.update_sensorium(stagnant.clone());
+        assert!(orch.temperature > 0.1);
+
+        // Multiple stagnant updates should keep increasing temperature.
+        orch.update_sensorium(stagnant);
+        assert!(orch.temperature > 0.2);
+    }
+
+    #[test]
+    fn test_temperature_cools_on_exploit() {
+        let mut orch = MetaOrchestrator::new();
+        orch.temperature = 0.5;
+
+        let healthy = GraphSensorium::compute(
+            100, 1, &[90, 5, 3, 1, 1], 8, 10, 1, 100, 12, 15, 5, 20,
+        );
+        orch.update_sensorium(healthy);
+        assert!(orch.temperature < 0.5);
+    }
+
+    #[test]
+    fn test_mul_from_graph_signals() {
+        let signals = GraphSensorium::compute(
+            100, 2, &[80, 10, 5, 3, 2], 5, 10, 3, 50, 10, 15, 5, 20,
+        );
+        let mul = MulAssessment::from_graph_signals(&signals, 0.7);
+        // Healthy graph → should be Slope or Plateau.
+        assert!(mul.dk_position != DkPosition::MountStupid);
+        assert!(mul.free_will_modifier > 0.3);
+    }
+
+    #[test]
+    fn test_auto_heal_contradictions() {
+        let mut orch = MetaOrchestrator::new();
+        let sick = GraphSensorium::compute(
+            100, 30, &[10, 10, 30, 30, 20], 1, 10, 20, 50, 2, 20, 18, 20,
+        );
+        orch.update_sensorium(sick);
+        let actions = orch.auto_heal();
+        assert!(actions.iter().any(|a| a.action == HealingType::ResolveContradictions));
+    }
+
+    #[test]
+    fn test_auto_heal_bootstrap_truth() {
+        let mut orch = MetaOrchestrator::new();
+        let unset = GraphSensorium::compute(
+            100, 5, &[20, 20, 20, 20, 20], 0, 20, 2, 100, 0, 10, 5, 20,
+        );
+        orch.update_sensorium(unset);
+        let actions = orch.auto_heal();
+        assert!(actions.iter().any(|a| a.action == HealingType::BootstrapTruth));
+    }
+
+    #[test]
+    fn test_auto_heal_topology_reset() {
+        let mut orch = MetaOrchestrator::new();
+        // Simulate 25 terrible steps.
+        for _ in 0..25 {
+            let r = orch.select_next();
+            orch.record_outcome(r.style, 0.05);
+        }
+        // Now feed sick graph signals.
+        let sick = GraphSensorium::compute(
+            100, 30, &[20, 20, 20, 20, 20], 0, 20, 20, 50, 0, 20, 18, 20,
+        );
+        orch.update_sensorium(sick);
+        let actions = orch.auto_heal();
+        // Should trigger topology reset.
+        assert!(actions.iter().any(|a| a.action == HealingType::ResetTopology));
+        // Should be back in fallback mode.
+        assert_eq!(orch.mode, OrchestratorMode::HardcodedFallback);
+        // Temperature should be warm (0.5 from reset).
+        assert!((orch.temperature - 0.5).abs() < f32::EPSILON);
+    }
+
+    #[test]
+    fn test_graph_bias_exploit() {
+        let signals = GraphSensorium::compute(
+            200, 2, &[180, 10, 5, 3, 2], 15, 20, 2, 100, 15, 20, 5, 50,
+        );
+        assert_eq!(signals.suggested_bias(), GraphBias::Exploit);
+    }
+}
diff --git a/crates/stubs/notebook-query/src/osint_audit.rs b/crates/stubs/notebook-query/src/osint_audit.rs
new file mode 100644
index 000000000..b9cd34c99
--- /dev/null
+++ b/crates/stubs/notebook-query/src/osint_audit.rs
@@ -0,0 +1,407 @@
+//! Live OSINT pipeline auditing — real-time health monitoring for AriGraph.
+//!
+//! Uses neural-debug's `RuntimeRegistry` pattern for atomic call counting,
+//! combined with AriGraph graph health metrics (triplet count, truth distribution,
+//! contradiction detection, episodic memory saturation).
+//!
+//! Exposed via q2 cockpit-server at `/api/debug/osint`.
+
+use serde::{Deserialize, Serialize};
+use std::collections::HashMap;
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::sync::OnceLock;
+use std::time::Instant;
+
+// ── Global OSINT Pipeline Registry ──────────────────────────────────────────
+
+/// Global singleton registry for OSINT pipeline call tracking.
+/// Same pattern as neural-debug's RuntimeRegistry but specialized for AriGraph ops.
+static OSINT_REGISTRY: OnceLock = OnceLock::new();
+
+/// Get or initialize the global OSINT registry.
+pub fn osint_registry() -> &'static OsintRegistry {
+    OSINT_REGISTRY.get_or_init(OsintRegistry::new)
+}
+
+/// Atomic counter for a single OSINT operation.
+pub struct OsintCounter {
+    pub calls: AtomicU64,
+    pub successes: AtomicU64,
+    pub failures: AtomicU64,
+    pub total_ns: AtomicU64,
+    pub triplets_produced: AtomicU64,
+}
+
+impl OsintCounter {
+    pub const fn new() -> Self {
+        Self {
+            calls: AtomicU64::new(0),
+            successes: AtomicU64::new(0),
+            failures: AtomicU64::new(0),
+            total_ns: AtomicU64::new(0),
+            triplets_produced: AtomicU64::new(0),
+        }
+    }
+
+    pub fn record_success(&self, elapsed_ns: u64, triplets: u64) {
+        self.calls.fetch_add(1, Ordering::Relaxed);
+        self.successes.fetch_add(1, Ordering::Relaxed);
+        self.total_ns.fetch_add(elapsed_ns, Ordering::Relaxed);
+        self.triplets_produced.fetch_add(triplets, Ordering::Relaxed);
+    }
+
+    pub fn record_failure(&self, elapsed_ns: u64) {
+        self.calls.fetch_add(1, Ordering::Relaxed);
+        self.failures.fetch_add(1, Ordering::Relaxed);
+        self.total_ns.fetch_add(elapsed_ns, Ordering::Relaxed);
+    }
+
+    pub fn snapshot(&self) -> OsintCounterSnapshot {
+        let calls = self.calls.load(Ordering::Relaxed);
+        let total_ns = self.total_ns.load(Ordering::Relaxed);
+        OsintCounterSnapshot {
+            calls,
+            successes: self.successes.load(Ordering::Relaxed),
+            failures: self.failures.load(Ordering::Relaxed),
+            avg_latency_us: if calls > 0 {
+                total_ns / calls / 1000
+            } else {
+                0
+            },
+            triplets_produced: self.triplets_produced.load(Ordering::Relaxed),
+        }
+    }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct OsintCounterSnapshot {
+    pub calls: u64,
+    pub successes: u64,
+    pub failures: u64,
+    pub avg_latency_us: u64,
+    pub triplets_produced: u64,
+}
+
+/// Registry tracking all OSINT pipeline stages.
+pub struct OsintRegistry {
+    pub extraction: OsintCounter,
+    pub refinement: OsintCounter,
+    pub planning: OsintCounter,
+    pub classification: OsintCounter,
+    pub deduction: OsintCounter,
+    pub contradiction: OsintCounter,
+    pub revision: OsintCounter,
+    pub episodic_store: OsintCounter,
+    pub episodic_retrieve: OsintCounter,
+    pub graph_bfs: OsintCounter,
+    pub spatial_path: OsintCounter,
+    pub xai_api_call: OsintCounter,
+}
+
+impl OsintRegistry {
+    pub fn new() -> Self {
+        Self {
+            extraction: OsintCounter::new(),
+            refinement: OsintCounter::new(),
+            planning: OsintCounter::new(),
+            classification: OsintCounter::new(),
+            deduction: OsintCounter::new(),
+            contradiction: OsintCounter::new(),
+            revision: OsintCounter::new(),
+            episodic_store: OsintCounter::new(),
+            episodic_retrieve: OsintCounter::new(),
+            graph_bfs: OsintCounter::new(),
+            spatial_path: OsintCounter::new(),
+            xai_api_call: OsintCounter::new(),
+        }
+    }
+
+    /// Full snapshot of all pipeline stages.
+    pub fn snapshot(&self) -> OsintPipelineHealth {
+        OsintPipelineHealth {
+            stages: vec![
+                ("extraction".into(), self.extraction.snapshot()),
+                ("refinement".into(), self.refinement.snapshot()),
+                ("planning".into(), self.planning.snapshot()),
+                ("classification".into(), self.classification.snapshot()),
+                ("deduction".into(), self.deduction.snapshot()),
+                ("contradiction".into(), self.contradiction.snapshot()),
+                ("revision".into(), self.revision.snapshot()),
+                ("episodic_store".into(), self.episodic_store.snapshot()),
+                ("episodic_retrieve".into(), self.episodic_retrieve.snapshot()),
+                ("graph_bfs".into(), self.graph_bfs.snapshot()),
+                ("spatial_path".into(), self.spatial_path.snapshot()),
+                ("xai_api_call".into(), self.xai_api_call.snapshot()),
+            ],
+        }
+    }
+}
+
+// ── Graph Health Report ─────────────────────────────────────────────────────
+
+/// Comprehensive health report for the OSINT knowledge graph.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct OsintGraphHealth {
+    /// Total triplets in the graph (including soft-deleted).
+    pub total_triplets: usize,
+    /// Active (non-deleted) triplets.
+    pub active_triplets: usize,
+    /// Soft-deleted triplets (truth = unknown).
+    pub deleted_triplets: usize,
+    /// Unique entities (subjects + objects).
+    pub unique_entities: usize,
+    /// Spatial edges count.
+    pub spatial_edges: usize,
+    /// Contradictions detected (same S+O, different relation, both confident).
+    pub contradictions: usize,
+    /// Truth value distribution: how many triplets in each confidence band.
+    pub truth_distribution: TruthDistribution,
+    /// Episodic memory stats.
+    pub episodic: EpisodicHealth,
+    /// NARS inference stats.
+    pub nars: NarsHealth,
+}
+
+/// Distribution of truth confidence across triplets.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct TruthDistribution {
+    /// Confidence >= 0.9 (certain).
+    pub certain: usize,
+    /// Confidence 0.7-0.9 (strong).
+    pub strong: usize,
+    /// Confidence 0.4-0.7 (moderate).
+    pub moderate: usize,
+    /// Confidence 0.1-0.4 (weak).
+    pub weak: usize,
+    /// Confidence < 0.1 (unknown/deleted).
+    pub unknown: usize,
+}
+
+/// Episodic memory health.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct EpisodicHealth {
+    pub episodes_stored: usize,
+    pub capacity: usize,
+    pub saturation_pct: f32,
+}
+
+/// NARS inference engine health.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct NarsHealth {
+    /// Total deductions inferred this session.
+    pub deductions_inferred: u64,
+    /// Contradictions auto-detected this session.
+    pub contradictions_detected: u64,
+    /// Revisions applied this session.
+    pub revisions_applied: u64,
+}
+
+// ── Pipeline Health (combines registry + graph health) ──────────────────────
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct OsintPipelineHealth {
+    pub stages: Vec<(String, OsintCounterSnapshot)>,
+}
+
+/// Full OSINT audit result — pipeline health + graph health + recommendations.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct OsintAuditResult {
+    /// Pipeline stage call counts and latencies.
+    pub pipeline: OsintPipelineHealth,
+    /// Graph structure and truth distribution.
+    pub graph: OsintGraphHealth,
+    /// xAI API status.
+    pub xai_status: XaiStatus,
+    /// Prioritized recommendations.
+    pub recommendations: Vec,
+    /// Audit timestamp (Unix millis).
+    pub timestamp_ms: u64,
+}
+
+/// xAI API connection status.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct XaiStatus {
+    /// Whether ADA_XAI env var is set.
+    pub api_key_present: bool,
+    /// Last API call latency (0 if never called).
+    pub last_latency_us: u64,
+    /// Total API calls this session.
+    pub total_calls: u64,
+    /// Total failures this session.
+    pub total_failures: u64,
+}
+
+/// Run a full OSINT pipeline audit.
+///
+/// This is the function that the cockpit-server calls at `/api/debug/osint`.
+pub fn run_osint_audit(
+    graph_triplet_count: usize,
+    graph_active_count: usize,
+    graph_entity_count: usize,
+    graph_spatial_edges: usize,
+    graph_contradictions: usize,
+    episodic_count: usize,
+    episodic_capacity: usize,
+) -> OsintAuditResult {
+    let registry = osint_registry();
+    let pipeline = registry.snapshot();
+
+    let xai_snap = registry.xai_api_call.snapshot();
+    let nars_deductions = registry.deduction.snapshot();
+    let nars_contradictions = registry.contradiction.snapshot();
+    let nars_revisions = registry.revision.snapshot();
+
+    let deleted = graph_triplet_count.saturating_sub(graph_active_count);
+
+    // Build truth distribution (placeholder — real impl would scan graph)
+    let truth_distribution = TruthDistribution {
+        certain: graph_active_count, // approximate
+        strong: 0,
+        moderate: 0,
+        weak: 0,
+        unknown: deleted,
+    };
+
+    let episodic_saturation = if episodic_capacity > 0 {
+        (episodic_count as f32 / episodic_capacity as f32) * 100.0
+    } else {
+        0.0
+    };
+
+    let graph = OsintGraphHealth {
+        total_triplets: graph_triplet_count,
+        active_triplets: graph_active_count,
+        deleted_triplets: deleted,
+        unique_entities: graph_entity_count,
+        spatial_edges: graph_spatial_edges,
+        contradictions: graph_contradictions,
+        truth_distribution,
+        episodic: EpisodicHealth {
+            episodes_stored: episodic_count,
+            capacity: episodic_capacity,
+            saturation_pct: episodic_saturation,
+        },
+        nars: NarsHealth {
+            deductions_inferred: nars_deductions.triplets_produced,
+            contradictions_detected: nars_contradictions.calls,
+            revisions_applied: nars_revisions.calls,
+        },
+    };
+
+    let xai_status = XaiStatus {
+        api_key_present: std::env::var("ADA_XAI").is_ok(),
+        last_latency_us: xai_snap.avg_latency_us,
+        total_calls: xai_snap.calls,
+        total_failures: xai_snap.failures,
+    };
+
+    // Generate recommendations
+    let mut recommendations = Vec::new();
+    if !xai_status.api_key_present {
+        recommendations.push("Set ADA_XAI environment variable for xAI/Grok extraction".into());
+    }
+    if graph_contradictions > 0 {
+        recommendations.push(format!(
+            "{} contradictions detected — run NARS contradiction resolution",
+            graph_contradictions
+        ));
+    }
+    if episodic_saturation > 90.0 {
+        recommendations.push("Episodic memory >90% full — consider increasing capacity or pruning".into());
+    }
+    if deleted as f32 / (graph_triplet_count.max(1) as f32) > 0.3 {
+        recommendations.push("30%+ triplets soft-deleted — consider compaction".into());
+    }
+    if xai_snap.failures > xai_snap.successes && xai_snap.calls > 0 {
+        recommendations.push("xAI API failure rate >50% — check API key and network".into());
+    }
+    if nars_deductions.calls == 0 && graph_active_count > 10 {
+        recommendations.push("No NARS deductions run yet — call infer_deductions() to expand knowledge".into());
+    }
+
+    let timestamp_ms = std::time::SystemTime::now()
+        .duration_since(std::time::UNIX_EPOCH)
+        .map(|d| d.as_millis() as u64)
+        .unwrap_or(0);
+
+    OsintAuditResult {
+        pipeline,
+        graph,
+        xai_status,
+        recommendations,
+        timestamp_ms,
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_counter_success() {
+        let c = OsintCounter::new();
+        c.record_success(1_000_000, 5);
+        c.record_success(2_000_000, 3);
+        let snap = c.snapshot();
+        assert_eq!(snap.calls, 2);
+        assert_eq!(snap.successes, 2);
+        assert_eq!(snap.failures, 0);
+        assert_eq!(snap.triplets_produced, 8);
+    }
+
+    #[test]
+    fn test_counter_failure() {
+        let c = OsintCounter::new();
+        c.record_failure(500_000);
+        let snap = c.snapshot();
+        assert_eq!(snap.calls, 1);
+        assert_eq!(snap.failures, 1);
+        assert_eq!(snap.successes, 0);
+    }
+
+    #[test]
+    fn test_registry_snapshot() {
+        let r = OsintRegistry::new();
+        r.extraction.record_success(100_000, 5);
+        r.xai_api_call.record_success(50_000_000, 0);
+        let health = r.snapshot();
+        assert_eq!(health.stages.len(), 12);
+        let extraction = health.stages.iter().find(|(n, _)| n == "extraction").unwrap();
+        assert_eq!(extraction.1.calls, 1);
+    }
+
+    #[test]
+    fn test_run_osint_audit() {
+        let result = run_osint_audit(100, 80, 50, 10, 2, 15, 20);
+        assert_eq!(result.graph.total_triplets, 100);
+        assert_eq!(result.graph.active_triplets, 80);
+        assert_eq!(result.graph.deleted_triplets, 20);
+        assert_eq!(result.graph.contradictions, 2);
+        assert!(!result.recommendations.is_empty());
+        assert!(result.recommendations.iter().any(|r| r.contains("contradiction")));
+    }
+
+    #[test]
+    fn test_xai_status_no_key() {
+        // ADA_XAI is not set in test env (usually)
+        let result = run_osint_audit(10, 10, 5, 0, 0, 0, 10);
+        // We can't assert api_key_present because it depends on env
+        assert_eq!(result.graph.total_triplets, 10);
+    }
+
+    #[test]
+    fn test_episodic_saturation_warning() {
+        let result = run_osint_audit(10, 10, 5, 0, 0, 19, 20);
+        assert!(result.graph.episodic.saturation_pct > 90.0);
+        assert!(result.recommendations.iter().any(|r| r.contains("90%")));
+    }
+
+    #[test]
+    fn test_global_registry() {
+        let r1 = osint_registry();
+        let r2 = osint_registry();
+        // Same singleton
+        r1.extraction.record_success(100, 1);
+        assert_eq!(r2.extraction.snapshot().calls, r1.extraction.snapshot().calls);
+    }
+}
diff --git a/crates/stubs/q2-ndarray/src/deepnsm.rs b/crates/stubs/q2-ndarray/src/deepnsm.rs
new file mode 100644
index 000000000..93730a8b2
--- /dev/null
+++ b/crates/stubs/q2-ndarray/src/deepnsm.rs
@@ -0,0 +1,577 @@
+//! DeepNSM semantic analysis for graph notebooks.
+//!
+//! Provides NSM (Natural Semantic Metalanguage) analysis for the cockpit
+//! notebook system. Words are decomposed into weighted semantic primes
+//! for cross-linguistic comparison and semantic reasoning.
+//!
+//! This module is lightweight and standalone — it does not depend on
+//! the `ndarray` feature or any external crate.
+
+/// Number of universal semantic primes in the NSM inventory.
+pub const NSM_PRIME_COUNT: usize = 74;
+
+/// The 74 universal semantic primes (NSM theory, after Wierzbicka & Goddard).
+///
+/// Grouped by semantic category:
+///   Substantives, Determiners, Quantifiers, Evaluators, Descriptors,
+///   Mental predicates, Speech, Actions/Events/Movement, Existence/Possession,
+///   Life/Death, Time, Space, Logical concepts, Intensifier/Augmentor,
+///   Similarity, Taxonomy/Partonomy.
+pub const NSM_PRIME_NAMES: &[&str] = &[
+    // Substantives (0–5)
+    "I",
+    "YOU",
+    "SOMEONE",
+    "SOMETHING",
+    "THING",
+    "BODY",
+    // Determiners (6–7)
+    "KIND",
+    "PART",
+    // Quantifiers / demonstratives (8–12)
+    "THIS",
+    "THE_SAME",
+    "OTHER",
+    "ELSE",
+    "ANOTHER",
+    // Evaluators (13–14)
+    "GOOD",
+    "BAD",
+    // Descriptors (15–16)
+    "BIG",
+    "SMALL",
+    // Mental predicates (17–21)
+    "THINK",
+    "KNOW",
+    "WANT",
+    "FEEL",
+    "SEE",
+    // Speech (22–23)
+    "SAY",
+    "WORDS",
+    // Actions, events, movement (24–27)
+    "DO",
+    "HAPPEN",
+    "MOVE",
+    "TOUCH",
+    // Existence, possession (28–30)
+    "THERE_IS",
+    "HAVE",
+    "BE",
+    // Life and death (31–32)
+    "LIVE",
+    "DIE",
+    // Time (33–40)
+    "WHEN",
+    "NOW",
+    "BEFORE",
+    "AFTER",
+    "A_LONG_TIME",
+    "A_SHORT_TIME",
+    "FOR_SOME_TIME",
+    "MOMENT",
+    // Space (41–47)
+    "WHERE",
+    "HERE",
+    "ABOVE",
+    "BELOW",
+    "FAR",
+    "NEAR",
+    "SIDE",
+    // Logical concepts (48–52)
+    "NOT",
+    "MAYBE",
+    "CAN",
+    "BECAUSE",
+    "IF",
+    // Intensifier / augmentor (53–54)
+    "VERY",
+    "MORE",
+    // Similarity (55–56)
+    "LIKE",
+    "AS",
+    // Taxonomy / partonomy (57–58)
+    "ABOVE_KIND",
+    "BELOW_KIND",
+    // Relational (59–63)
+    "ONE",
+    "TWO",
+    "MUCH",
+    "MANY",
+    "ALL",
+    // Imagination / possibility (64–66)
+    "TRUE",
+    "INSIDE",
+    "SOME",
+    // Additional primes (67–73)
+    "PEOPLE",
+    "SOMEWHERE",
+    "AT_THE_SAME_TIME",
+    "WITH",
+    "IN",
+    "WORD",
+    "WAY",
+];
+
+// Compile-time assertion that we have exactly 74 primes.
+const _: () = assert!(NSM_PRIME_NAMES.len() == NSM_PRIME_COUNT);
+
+// ── Vocabulary map ────────────────────────────────────────────────────────
+
+/// A vocabulary entry: (word, list of (prime_index, weight) pairs).
+///
+/// Weights are in [0.0, 1.0] and need not sum to 1.
+type VocabEntry = (&'static str, &'static [(usize, f32)]);
+
+/// Vocabulary of common words decomposed into NSM primes.
+///
+/// Each word maps to a sparse list of (prime_index, weight) pairs.
+/// The index refers to `NSM_PRIME_NAMES`.
+const VOCAB: &[VocabEntry] = &[
+    // --- Substantives / pronouns ---
+    ("i", &[(0, 1.0)]),
+    ("me", &[(0, 1.0)]),
+    ("you", &[(1, 1.0)]),
+    ("someone", &[(2, 1.0)]),
+    ("person", &[(2, 0.8), (67, 0.6)]),
+    ("people", &[(67, 1.0), (2, 0.5), (63, 0.4)]),
+    ("something", &[(3, 1.0)]),
+    ("thing", &[(4, 1.0)]),
+    ("body", &[(5, 1.0)]),
+    // --- Determiners ---
+    ("kind", &[(6, 1.0)]),
+    ("type", &[(6, 0.9)]),
+    ("part", &[(7, 1.0)]),
+    ("piece", &[(7, 0.8)]),
+    // --- Demonstratives ---
+    ("this", &[(8, 1.0)]),
+    ("same", &[(9, 1.0)]),
+    ("other", &[(10, 1.0)]),
+    ("else", &[(11, 1.0)]),
+    ("another", &[(12, 1.0)]),
+    // --- Evaluators ---
+    ("good", &[(13, 1.0)]),
+    ("great", &[(13, 0.8), (15, 0.5), (53, 0.6)]),
+    ("bad", &[(14, 1.0)]),
+    ("terrible", &[(14, 0.8), (53, 0.7)]),
+    ("evil", &[(14, 0.9), (20, 0.3)]),
+    // --- Descriptors ---
+    ("big", &[(15, 1.0)]),
+    ("large", &[(15, 0.9)]),
+    ("huge", &[(15, 0.9), (53, 0.7)]),
+    ("small", &[(16, 1.0)]),
+    ("tiny", &[(16, 0.9), (53, 0.6)]),
+    // --- Mental predicates ---
+    ("think", &[(17, 1.0)]),
+    ("believe", &[(17, 0.8), (18, 0.4), (64, 0.3)]),
+    ("know", &[(18, 1.0)]),
+    ("understand", &[(18, 0.8), (17, 0.5)]),
+    ("want", &[(19, 1.0)]),
+    ("desire", &[(19, 0.9), (20, 0.3)]),
+    ("need", &[(19, 0.8), (50, 0.4)]),
+    ("feel", &[(20, 1.0)]),
+    ("emotion", &[(20, 0.9)]),
+    ("see", &[(21, 1.0)]),
+    ("look", &[(21, 0.8), (24, 0.3)]),
+    ("watch", &[(21, 0.7), (37, 0.3)]),
+    // --- Speech ---
+    ("say", &[(22, 1.0)]),
+    ("tell", &[(22, 0.8), (1, 0.3)]),
+    ("speak", &[(22, 0.7), (23, 0.5)]),
+    ("words", &[(23, 1.0)]),
+    ("word", &[(72, 1.0)]),
+    ("language", &[(23, 0.8), (72, 0.5), (67, 0.3)]),
+    // --- Actions, events, movement ---
+    ("do", &[(24, 1.0)]),
+    ("make", &[(24, 0.8), (28, 0.3)]),
+    ("happen", &[(25, 1.0)]),
+    ("event", &[(25, 0.8), (33, 0.3)]),
+    ("move", &[(26, 1.0)]),
+    ("go", &[(26, 0.8)]),
+    ("walk", &[(26, 0.7), (5, 0.3)]),
+    ("run", &[(26, 0.8), (53, 0.4)]),
+    ("touch", &[(27, 1.0)]),
+    // --- Existence, possession ---
+    ("exist", &[(28, 1.0)]),
+    ("have", &[(29, 1.0)]),
+    ("own", &[(29, 0.8)]),
+    ("be", &[(30, 1.0)]),
+    ("is", &[(30, 0.9)]),
+    // --- Life and death ---
+    ("live", &[(31, 1.0)]),
+    ("alive", &[(31, 0.9)]),
+    ("die", &[(32, 1.0)]),
+    ("dead", &[(32, 0.9)]),
+    ("death", &[(32, 0.9)]),
+    ("kill", &[(32, 0.7), (24, 0.5), (14, 0.3)]),
+    // --- Time ---
+    ("when", &[(33, 1.0)]),
+    ("now", &[(34, 1.0)]),
+    ("before", &[(35, 1.0)]),
+    ("after", &[(36, 1.0)]),
+    ("long", &[(37, 0.8)]),
+    ("short", &[(38, 0.8)]),
+    ("time", &[(37, 0.5), (33, 0.5)]),
+    // --- Space ---
+    ("where", &[(41, 1.0)]),
+    ("here", &[(42, 1.0)]),
+    ("above", &[(43, 1.0)]),
+    ("below", &[(44, 1.0)]),
+    ("far", &[(45, 1.0)]),
+    ("near", &[(46, 1.0)]),
+    ("close", &[(46, 0.8)]),
+    ("side", &[(47, 1.0)]),
+    // --- Logical ---
+    ("not", &[(48, 1.0)]),
+    ("maybe", &[(49, 1.0)]),
+    ("perhaps", &[(49, 0.9)]),
+    ("can", &[(50, 1.0)]),
+    ("possible", &[(50, 0.8), (49, 0.4)]),
+    ("because", &[(51, 1.0)]),
+    ("if", &[(52, 1.0)]),
+    // --- Intensifier ---
+    ("very", &[(53, 1.0)]),
+    ("more", &[(54, 1.0)]),
+    // --- Similarity ---
+    ("like", &[(55, 1.0)]),
+    ("as", &[(56, 1.0)]),
+    // --- Quantifiers ---
+    ("one", &[(59, 1.0)]),
+    ("two", &[(60, 1.0)]),
+    ("much", &[(61, 1.0)]),
+    ("many", &[(62, 1.0)]),
+    ("all", &[(63, 1.0)]),
+    // --- Truth / misc ---
+    ("true", &[(64, 1.0)]),
+    ("inside", &[(65, 1.0)]),
+    ("some", &[(66, 1.0)]),
+    ("with", &[(69, 1.0)]),
+    ("in", &[(70, 1.0)]),
+    ("way", &[(73, 1.0)]),
+    // --- Higher-level words (decomposed into multiple primes) ---
+    ("happy", &[(20, 0.8), (13, 0.7)]),
+    ("sad", &[(20, 0.8), (14, 0.6)]),
+    ("angry", &[(20, 0.8), (14, 0.5), (19, 0.3)]),
+    ("afraid", &[(20, 0.8), (14, 0.5), (25, 0.3)]),
+    ("love", &[(20, 0.7), (13, 0.6), (19, 0.5)]),
+    ("hate", &[(20, 0.6), (14, 0.7), (19, 0.3)]),
+    ("help", &[(24, 0.7), (13, 0.5), (19, 0.3)]),
+    ("hurt", &[(20, 0.6), (14, 0.5), (27, 0.4)]),
+    ("learn", &[(18, 0.7), (17, 0.5), (34, 0.2)]),
+    ("teach", &[(22, 0.5), (18, 0.6), (1, 0.3)]),
+    ("give", &[(24, 0.5), (29, 0.4), (1, 0.3)]),
+    ("take", &[(24, 0.5), (29, 0.5)]),
+    ("eat", &[(24, 0.5), (5, 0.4), (65, 0.3)]),
+    ("drink", &[(24, 0.5), (5, 0.3), (65, 0.3)]),
+    ("sleep", &[(31, 0.4), (5, 0.5), (48, 0.3)]),
+    ("water", &[(4, 0.6), (26, 0.3)]),
+    ("fire", &[(4, 0.5), (15, 0.3), (14, 0.3)]),
+    ("earth", &[(4, 0.6), (44, 0.3)]),
+    ("sky", &[(4, 0.5), (43, 0.4)]),
+    ("home", &[(41, 0.5), (31, 0.4), (13, 0.3)]),
+    ("child", &[(2, 0.6), (16, 0.5), (31, 0.3)]),
+    ("mother", &[(2, 0.5), (67, 0.3), (31, 0.4), (13, 0.3)]),
+    ("father", &[(2, 0.5), (67, 0.3), (31, 0.4)]),
+];
+
+// ── Core functions ────────────────────────────────────────────────────────
+
+/// Decompose text into a weighted NSM prime vector.
+///
+/// The input text is lowercased and split on whitespace. Each token is
+/// looked up in the built-in vocabulary; unknown tokens are ignored.
+/// The returned array holds the accumulated (and L1-normalised) weight
+/// for each of the 74 semantic primes.
+///
+/// # Example
+/// ```
+/// let v = q2_ndarray::deepnsm::nsm_decompose("I want to know");
+/// assert!(v[0] > 0.0);  // I   → prime 0
+/// assert!(v[19] > 0.0); // WANT → prime 19
+/// assert!(v[18] > 0.0); // KNOW → prime 18
+/// ```
+pub fn nsm_decompose(text: &str) -> [f32; NSM_PRIME_COUNT] {
+    let mut vec = [0.0f32; NSM_PRIME_COUNT];
+
+    for token in text.split_whitespace() {
+        let lower = token.to_lowercase();
+        // Strip common punctuation from edges.
+        let word = lower.trim_matches(|c: char| !c.is_alphanumeric());
+        if word.is_empty() {
+            continue;
+        }
+        if let Some((_w, primes)) = VOCAB.iter().find(|(w, _)| *w == word) {
+            for &(idx, weight) in *primes {
+                vec[idx] += weight;
+            }
+        }
+    }
+
+    // L1-normalise so vectors are comparable regardless of text length.
+    let sum: f32 = vec.iter().sum();
+    if sum > 0.0 {
+        for v in &mut vec {
+            *v /= sum;
+        }
+    }
+
+    vec
+}
+
+/// Cosine similarity between two NSM decomposition vectors.
+///
+/// Returns a value in [-1.0, 1.0].  For non-negative NSM vectors
+/// produced by [`nsm_decompose`] the range is [0.0, 1.0].
+///
+/// Returns `0.0` if either vector has zero magnitude.
+pub fn nsm_cosine_similarity(a: &[f32; NSM_PRIME_COUNT], b: &[f32; NSM_PRIME_COUNT]) -> f32 {
+    let mut dot = 0.0f32;
+    let mut mag_a = 0.0f32;
+    let mut mag_b = 0.0f32;
+
+    for i in 0..NSM_PRIME_COUNT {
+        dot += a[i] * b[i];
+        mag_a += a[i] * a[i];
+        mag_b += b[i] * b[i];
+    }
+
+    let denom = mag_a.sqrt() * mag_b.sqrt();
+    if denom == 0.0 { 0.0 } else { dot / denom }
+}
+
+// ── Legality analysis ─────────────────────────────────────────────────────
+
+/// Result of analysing an NSM explication for legality.
+///
+/// In NSM theory an explication should ideally use ONLY semantic primes
+/// (and approved semantic molecules). This struct reports how closely a
+/// given explication conforms.
+#[derive(Debug, Clone, PartialEq)]
+pub struct NsmLegality {
+    /// Fraction of tokens that are semantic primes (0.0–1.0).
+    pub primes_ratio: f32,
+    /// Fraction of tokens that are approved semantic molecules (0.0–1.0).
+    pub molecules_ratio: f32,
+    /// `true` if the explication uses the target word it is defining
+    /// (a circularity violation).
+    pub uses_original_word: bool,
+    /// Total number of content tokens analysed.
+    pub total_tokens: usize,
+    /// Number of tokens recognised as semantic primes.
+    pub prime_tokens: usize,
+    /// Number of tokens recognised as semantic molecules.
+    pub molecule_tokens: usize,
+}
+
+/// Approved semantic molecules (frequently used complex concepts that are
+/// accepted in NSM explications even though they are not primes).
+const MOLECULES: &[&str] = &[
+    "hands", "eyes", "mouth", "head", "face", "ears", "nose", "legs", "arms", "heart", "mind",
+    "children", "men", "women", "animal", "dog", "cat", "bird", "fish", "tree", "ground", "sun",
+    "moon", "day", "night", "morning", "evening", "long_time", "short_time", "hot", "cold",
+    "hard", "soft", "round", "flat", "sharp", "heavy", "light", "wet", "dry", "colour", "white",
+    "black", "red", "green", "blue", "yellow",
+];
+
+/// Analyse an NSM explication for legality with respect to a target word.
+///
+/// Checks what fraction of the explication consists of semantic primes vs
+/// molecules, and whether the explication circularly references the
+/// target word.
+///
+/// # Example
+/// ```
+/// let r = q2_ndarray::deepnsm::analyze_legality(
+///     "someone feels something good because of this",
+///     "happy",
+/// );
+/// assert!(!r.uses_original_word);
+/// assert!(r.primes_ratio > 0.5);
+/// ```
+pub fn analyze_legality(explication: &str, target_word: &str) -> NsmLegality {
+    let target_lower = target_word.to_lowercase();
+
+    let mut total_tokens = 0usize;
+    let mut prime_tokens = 0usize;
+    let mut molecule_tokens = 0usize;
+    let mut uses_original_word = false;
+
+    // Build a quick set of prime names (lowercased, underscores → spaces removed).
+    // We compare normalised tokens against these.
+    let prime_set: Vec = NSM_PRIME_NAMES
+        .iter()
+        .map(|p| p.to_lowercase().replace('_', ""))
+        .collect();
+
+    for token in explication.split_whitespace() {
+        let lower = token.to_lowercase();
+        let word = lower.trim_matches(|c: char| !c.is_alphanumeric());
+        if word.is_empty() {
+            continue;
+        }
+
+        total_tokens += 1;
+
+        if word == target_lower {
+            uses_original_word = true;
+        }
+
+        let normalised = word.replace('_', "");
+        if prime_set.contains(&normalised) {
+            prime_tokens += 1;
+        } else if MOLECULES.contains(&word) {
+            molecule_tokens += 1;
+        }
+    }
+
+    let total_f = total_tokens.max(1) as f32;
+
+    NsmLegality {
+        primes_ratio: prime_tokens as f32 / total_f,
+        molecules_ratio: molecule_tokens as f32 / total_f,
+        uses_original_word,
+        total_tokens,
+        prime_tokens,
+        molecule_tokens,
+    }
+}
+
+// ── Tests ─────────────────────────────────────────────────────────────────
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_prime_count() {
+        assert_eq!(NSM_PRIME_NAMES.len(), NSM_PRIME_COUNT);
+    }
+
+    #[test]
+    fn test_decompose_basic() {
+        let v = nsm_decompose("I want to know something");
+        // "I" → prime 0, "want" → prime 19, "know" → prime 18, "something" → prime 3
+        assert!(v[0] > 0.0, "prime I should be activated");
+        assert!(v[19] > 0.0, "prime WANT should be activated");
+        assert!(v[18] > 0.0, "prime KNOW should be activated");
+        assert!(v[3] > 0.0, "prime SOMETHING should be activated");
+        // "to" is not in vocab → should not contribute
+        // All values should be non-negative and sum to ~1.0 (L1-normalised).
+        let sum: f32 = v.iter().sum();
+        assert!((sum - 1.0).abs() < 1e-5, "L1 norm should be ~1.0, got {sum}");
+    }
+
+    #[test]
+    fn test_decompose_empty() {
+        let v = nsm_decompose("");
+        assert!(v.iter().all(|&x| x == 0.0));
+    }
+
+    #[test]
+    fn test_decompose_unknown_tokens() {
+        let v = nsm_decompose("xylophone quasar");
+        // All unknown → zero vector
+        assert!(v.iter().all(|&x| x == 0.0));
+    }
+
+    #[test]
+    fn test_cosine_similarity_self() {
+        let v = nsm_decompose("I want to know");
+        let sim = nsm_cosine_similarity(&v, &v);
+        assert!(
+            (sim - 1.0).abs() < 1e-5,
+            "self-similarity should be 1.0, got {sim}"
+        );
+    }
+
+    #[test]
+    fn test_cosine_similarity_orthogonal() {
+        // Two vectors with no overlap should have similarity 0.
+        let mut a = [0.0f32; NSM_PRIME_COUNT];
+        let mut b = [0.0f32; NSM_PRIME_COUNT];
+        a[0] = 1.0; // I
+        b[32] = 1.0; // DIE
+        let sim = nsm_cosine_similarity(&a, &b);
+        assert!(sim.abs() < 1e-5, "orthogonal vectors should have sim ~0");
+    }
+
+    #[test]
+    fn test_cosine_similarity_zero_vector() {
+        let zero = [0.0f32; NSM_PRIME_COUNT];
+        let v = nsm_decompose("I know");
+        assert_eq!(nsm_cosine_similarity(&zero, &v), 0.0);
+        assert_eq!(nsm_cosine_similarity(&v, &zero), 0.0);
+        assert_eq!(nsm_cosine_similarity(&zero, &zero), 0.0);
+    }
+
+    #[test]
+    fn test_cosine_similarity_related_words() {
+        // "happy" and "sad" should be somewhat similar (both are feelings)
+        // but not identical.
+        let happy = nsm_decompose("happy");
+        let sad = nsm_decompose("sad");
+        let sim = nsm_cosine_similarity(&happy, &sad);
+        assert!(sim > 0.3, "happy/sad should share FEEL prime, got {sim}");
+        assert!(sim < 1.0, "happy/sad should not be identical, got {sim}");
+    }
+
+    #[test]
+    fn test_legality_analysis() {
+        // An explication using mostly primes.
+        let result = analyze_legality(
+            "someone feels something good because of this someone",
+            "happy",
+        );
+        assert!(!result.uses_original_word);
+        assert!(
+            result.primes_ratio > 0.5,
+            "primes_ratio should be > 0.5, got {}",
+            result.primes_ratio
+        );
+        assert!(result.total_tokens > 0);
+        assert!(result.prime_tokens > 0);
+    }
+
+    #[test]
+    fn test_legality_circular_reference() {
+        let result = analyze_legality("happy is when someone feels good", "happy");
+        assert!(
+            result.uses_original_word,
+            "should detect circular reference to target word"
+        );
+    }
+
+    #[test]
+    fn test_legality_with_molecules() {
+        let result = analyze_legality("someone feels something good in the head", "joy");
+        assert!(!result.uses_original_word);
+        assert!(result.molecule_tokens > 0, "should detect 'head' as molecule");
+    }
+
+    #[test]
+    fn test_vocab_size() {
+        // We promised at least 50 vocabulary entries.
+        assert!(
+            VOCAB.len() >= 50,
+            "vocabulary should have >= 50 entries, has {}",
+            VOCAB.len()
+        );
+    }
+
+    #[test]
+    fn test_vocab_indices_in_range() {
+        // Every prime index in the vocabulary must be < NSM_PRIME_COUNT.
+        for (word, primes) in VOCAB {
+            for &(idx, weight) in *primes {
+                assert!(
+                    idx < NSM_PRIME_COUNT,
+                    "word '{word}' has prime index {idx} >= {NSM_PRIME_COUNT}"
+                );
+                assert!(
+                    (0.0..=1.0).contains(&weight),
+                    "word '{word}' has weight {weight} outside [0,1]"
+                );
+            }
+        }
+    }
+}
diff --git a/crates/stubs/q2-ndarray/src/lib.rs b/crates/stubs/q2-ndarray/src/lib.rs
index 3e1a68392..dd116afe4 100644
--- a/crates/stubs/q2-ndarray/src/lib.rs
+++ b/crates/stubs/q2-ndarray/src/lib.rs
@@ -10,6 +10,10 @@
 //! This gives a minimal pure-Rust fallback with the same `Array2D` API
 //! but no SIMD acceleration or ndarray dependency.
 
+// ── DeepNSM semantic analysis (always available, no feature gate) ────────────
+
+pub mod deepnsm;
+
 // ── Feature: ndarray-simd (default) ─────────────────────────────────────────
 
 #[cfg(feature = "ndarray-simd")]