diff --git a/.github/workflows/sona-napi.yml b/.github/workflows/sona-napi.yml index 44f9f91ee..b3ca3c73e 100644 --- a/.github/workflows/sona-napi.yml +++ b/.github/workflows/sona-napi.yml @@ -118,6 +118,16 @@ jobs: path: npm/packages/sona/${{ matrix.node-file }} if-no-files-found: error + - name: Upload JS bindings (generated by napi build) + if: matrix.target == 'x86_64-unknown-linux-gnu' + uses: actions/upload-artifact@v4 + with: + name: js-bindings + path: | + npm/packages/sona/index.js + npm/packages/sona/index.d.ts + if-no-files-found: error + # Build universal macOS binary universal-macos: runs-on: macos-14 @@ -195,6 +205,13 @@ jobs: cp ../../../artifacts/bindings-aarch64-pc-windows-msvc/*.node . 2>/dev/null || true cp ../../../artifacts/bindings-darwin-universal/*.node . 2>/dev/null || true + # JS loader + type defs generated by napi build (required by `files` and `main`) + cp ../../../artifacts/js-bindings/index.js ../../../artifacts/js-bindings/index.d.ts . + + # Hard-fail if the main package build output is incomplete + # (npm publish silently skips missing `files` entries — this shipped a broken 0.1.6) + test -f index.js -a -f index.d.ts || { echo "ERROR: index.js/index.d.ts missing"; exit 1; } + echo "=== .node files in package ===" ls -la *.node diff --git a/crates/sona/src/engine.rs b/crates/sona/src/engine.rs index 9589ef3d7..555fcc300 100644 --- a/crates/sona/src/engine.rs +++ b/crates/sona/src/engine.rs @@ -416,6 +416,43 @@ mod tests { ); } + #[test] + fn test_single_feedback_changes_lora_output() { + // Regression test for #519: a series of single-step, constant-reward + // feedback trajectories (what wasm learn_from_feedback synthesizes) + // must produce an actual micro-LoRA weight update, i.e. apply_micro_lora + // output must change. + let engine = SonaEngine::new(64); + + let input = vec![1.0f32; 64]; + let mut before = vec![0.0f32; 64]; + engine.apply_micro_lora(&input, &mut before); + + // Mirror WasmSonaEngine::learn_from_feedback + for _ in 0..5 { + let embedding = vec![1.0 / (64f32).sqrt(); 64]; + let mut builder = engine.begin_trajectory(embedding.clone()); + builder.add_step(embedding, vec![], 0.9); + let trajectory = builder.build_with_latency(0.9, 50_000); + engine.submit_trajectory(trajectory); + engine.flush(); + } + + let mut after = vec![0.0f32; 64]; + engine.apply_micro_lora(&input, &mut after); + + let delta: f32 = before + .iter() + .zip(after.iter()) + .map(|(a, b)| (a - b).abs()) + .sum(); + assert!( + delta > 0.0, + "apply_micro_lora output unchanged after feedback (delta={})", + delta + ); + } + #[test] fn test_disabled_engine() { let mut engine = SonaEngine::new(64); diff --git a/crates/sona/src/types.rs b/crates/sona/src/types.rs index 1d647f968..3ced0ee66 100644 --- a/crates/sona/src/types.rs +++ b/crates/sona/src/types.rs @@ -95,6 +95,33 @@ impl LearningSignal { let norm: f32 = gradient.iter().map(|x| x * x).sum::().sqrt(); if norm > 1e-8 { gradient.iter_mut().for_each(|x| *x /= norm); + return gradient; + } + + // Degenerate case (fixes #519): single-step trajectories, or trajectories + // where every step has the same reward, have zero advantage everywhere + // (reward - baseline == 0), which produced an exact-zero gradient and + // therefore no learning. Fall back to baseline-free REINFORCE + // (advantage = raw reward) so single-feedback trajectories still adapt. + // Tradeoff: without the baseline the estimate has higher variance, but + // it only applies when the baselined estimate is identically zero — + // multi-step varying-reward trajectories are unaffected. + let mut fallback = vec![0.0f32; dim]; + for step in &trajectory.steps { + let activation_len = step.activations.len().min(dim); + for (grad, &act) in fallback + .iter_mut() + .zip(step.activations.iter()) + .take(activation_len) + { + *grad += step.reward * act; + } + } + + let fallback_norm: f32 = fallback.iter().map(|x| x * x).sum::().sqrt(); + if fallback_norm > 1e-8 { + fallback.iter_mut().for_each(|x| *x /= fallback_norm); + return fallback; } gradient @@ -530,6 +557,78 @@ mod tests { assert_eq!(signal.metadata.trajectory_id, 1); } + #[test] + fn test_gradient_nonzero_for_single_step_trajectory() { + // Regression test for #519: single-step (or constant-reward) + // trajectories used to yield an exact-zero REINFORCE gradient + // (advantage = reward - baseline = 0), so feedback never learned. + let mut trajectory = QueryTrajectory::new(1, vec![0.1; 8]); + trajectory.add_step(TrajectoryStep::new(vec![0.5; 8], vec![], 0.9, 0)); + trajectory.finalize(0.9, 1000); + + let signal = LearningSignal::from_trajectory(&trajectory); + let norm: f32 = signal + .gradient_estimate + .iter() + .map(|x| x * x) + .sum::() + .sqrt(); + assert!( + norm > 1e-6, + "Expected non-zero gradient for single-step trajectory, norm={}", + norm + ); + + // Negative reward should flip the gradient direction. + let mut neg = QueryTrajectory::new(2, vec![0.1; 8]); + neg.add_step(TrajectoryStep::new(vec![0.5; 8], vec![], -0.9, 0)); + neg.finalize(0.9, 1000); + let neg_signal = LearningSignal::from_trajectory(&neg); + let dot: f32 = signal + .gradient_estimate + .iter() + .zip(neg_signal.gradient_estimate.iter()) + .map(|(a, b)| a * b) + .sum(); + assert!( + dot < 0.0, + "Negative reward should flip gradient, dot={}", + dot + ); + } + + #[test] + fn test_gradient_unchanged_for_varying_reward_trajectory() { + // The baselined REINFORCE path must remain in effect when step + // rewards vary (non-degenerate case). + let mut trajectory = QueryTrajectory::new(1, vec![0.1; 4]); + trajectory.add_step(TrajectoryStep::new( + vec![1.0, 0.0, 0.0, 0.0], + vec![], + 0.2, + 0, + )); + trajectory.add_step(TrajectoryStep::new( + vec![0.0, 1.0, 0.0, 0.0], + vec![], + 0.8, + 1, + )); + trajectory.finalize(0.8, 1000); + + let signal = LearningSignal::from_trajectory(&trajectory); + // advantages: -0.3 and +0.3 -> gradient ∝ (-0.3, 0.3, 0, 0), normalized + assert!(signal.gradient_estimate[0] < 0.0); + assert!(signal.gradient_estimate[1] > 0.0); + let norm: f32 = signal + .gradient_estimate + .iter() + .map(|x| x * x) + .sum::() + .sqrt(); + assert!((norm - 1.0).abs() < 1e-4); + } + #[test] fn test_pattern_merge() { let p1 = LearnedPattern { diff --git a/crates/sona/src/wasm.rs b/crates/sona/src/wasm.rs index 398561d48..60ed51e8c 100644 --- a/crates/sona/src/wasm.rs +++ b/crates/sona/src/wasm.rs @@ -32,8 +32,11 @@ #![cfg(feature = "wasm")] +use crate::trajectory::TrajectoryBuilder; use crate::{LearningSignal, SonaConfig, SonaEngine}; use parking_lot::RwLock; +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use wasm_bindgen::prelude::*; @@ -43,6 +46,13 @@ use wasm_bindgen::prelude::*; #[wasm_bindgen] pub struct WasmSonaEngine { inner: Arc>, + /// Active trajectory builders keyed by the ID handed to JS, + /// paired with the query embedding for step recording (fixes #519). + active_trajectories: RwLock)>>, + /// Last query embedding seen, used to synthesize feedback trajectories. + last_embedding: RwLock>, + /// Trajectory handle generator. + next_trajectory_id: AtomicU64, } #[wasm_bindgen] @@ -63,6 +73,9 @@ impl WasmSonaEngine { Ok(Self { inner: Arc::new(RwLock::new(SonaEngine::new(hidden_dim))), + active_trajectories: RwLock::new(HashMap::new()), + last_embedding: RwLock::new(Vec::new()), + next_trajectory_id: AtomicU64::new(1), }) } @@ -96,6 +109,9 @@ impl WasmSonaEngine { Ok(Self { inner: Arc::new(RwLock::new(SonaEngine::with_config(config))), + active_trajectories: RwLock::new(HashMap::new()), + last_embedding: RwLock::new(Vec::new()), + next_trajectory_id: AtomicU64::new(1), }) } @@ -114,12 +130,18 @@ impl WasmSonaEngine { /// ``` #[wasm_bindgen(js_name = startTrajectory)] pub fn start_trajectory(&self, query_embedding: Vec) -> u64 { - let engine = self.inner.read(); - let builder = engine.begin_trajectory(query_embedding); - // Return simple counter ID since builder.id is private - use std::sync::atomic::{AtomicU64, Ordering}; - static NEXT_ID: AtomicU64 = AtomicU64::new(1); - NEXT_ID.fetch_add(1, Ordering::Relaxed) + let builder = { + let engine = self.inner.read(); + engine.begin_trajectory(query_embedding.clone()) + }; + + *self.last_embedding.write() = query_embedding.clone(); + + let id = self.next_trajectory_id.fetch_add(1, Ordering::Relaxed); + self.active_trajectories + .write() + .insert(id, (builder, query_embedding)); + id } /// Record a step in the trajectory @@ -135,16 +157,18 @@ impl WasmSonaEngine { /// engine.record_step(trajectoryId, 42, 0.8, 1000); /// ``` #[wasm_bindgen(js_name = recordStep)] - pub fn record_step(&self, trajectory_id: u64, node_id: u32, score: f32, latency_us: u64) { - // Note: This is a simplified version. In production, you'd want to maintain - // a map of active trajectory builders - web_sys::console::log_1( - &format!( - "Recording step: traj={}, node={}, score={}, latency={}us", - trajectory_id, node_id, score, latency_us - ) - .into(), - ); + pub fn record_step(&self, trajectory_id: u64, node_id: u32, score: f32, _latency_us: u64) { + let mut active = self.active_trajectories.write(); + if let Some((builder, embedding)) = active.get_mut(&trajectory_id) { + // The query embedding is the only activation signal available at the + // JS boundary; node_id is preserved as the step's layer name. + builder.add_named_step( + &format!("node-{}", node_id), + embedding.clone(), + Vec::new(), + score, + ); + } } /// End the trajectory and submit for learning @@ -159,13 +183,15 @@ impl WasmSonaEngine { /// ``` #[wasm_bindgen(js_name = endTrajectory)] pub fn end_trajectory(&self, trajectory_id: u64, final_score: f32) { - web_sys::console::log_1( - &format!( - "Ending trajectory: traj={}, score={}", - trajectory_id, final_score - ) - .into(), - ); + let entry = self.active_trajectories.write().remove(&trajectory_id); + if let Some((mut builder, embedding)) = entry { + // Ensure at least one step so a learning signal can be derived. + if builder.step_count() == 0 { + builder.add_step(embedding, Vec::new(), final_score); + } + let engine = self.inner.read(); + engine.end_trajectory(builder, final_score); + } } /// Apply learning from user feedback @@ -181,14 +207,31 @@ impl WasmSonaEngine { /// ``` #[wasm_bindgen(js_name = learnFromFeedback)] pub fn learn_from_feedback(&self, success: bool, latency_ms: f32, quality: f32) { + let quality = quality.clamp(0.0, 1.0); + // Negative reward on failure flips the gradient direction (unlearn). let reward = if success { quality } else { -quality }; - web_sys::console::log_1( - &format!( - "Feedback: success={}, latency={}ms, quality={}, reward={}", - success, latency_ms, quality, reward - ) - .into(), - ); + + // Reuse the last query embedding so feedback is attributed to the most + // recent inference; fall back to a uniform unit vector otherwise. + let embedding = { + let last = self.last_embedding.read(); + if last.is_empty() { + let dim = self.inner.read().config().hidden_dim; + vec![1.0 / (dim as f32).sqrt(); dim] + } else { + last.clone() + } + }; + + let engine = self.inner.read(); + let mut builder = engine.begin_trajectory(embedding.clone()); + builder.add_step(embedding, Vec::new(), reward); + let latency_us = (latency_ms.max(0.0) * 1000.0) as u64; + let trajectory = builder.build_with_latency(quality, latency_us); + engine.submit_trajectory(trajectory); + // Apply the accumulated micro-LoRA gradient immediately so a single + // feedback call produces an actual weight update (fixes #519). + engine.flush(); } /// Apply LoRA transformation to input vector diff --git a/npm/packages/ruvector/bin/cli.js b/npm/packages/ruvector/bin/cli.js index 5f82efbfe..4a2057962 100755 --- a/npm/packages/ruvector/bin/cli.js +++ b/npm/packages/ruvector/bin/cli.js @@ -2937,7 +2937,12 @@ class Intelligence { agents: data.agents || defaults.agents, edges: data.edges || defaults.edges, stats: { ...defaults.stats, ...(data.stats || {}) }, - // Preserve learning data if present + // Preserve in-flight trajectories so trajectory-end (run in a later + // process) can find what trajectory-begin recorded (#517) + activeTrajectories: data.activeTrajectories || {}, + // Preserve auxiliary learned data if present + coEditPatterns: data.coEditPatterns || undefined, + sequences: data.sequences || undefined, learning: data.learning || undefined }; } @@ -3093,6 +3098,48 @@ class Intelligence { } } + // Canonical routing state key — MUST mirror IntelligenceEngine.getState()/ + // getExtension() so patterns written here are found by engine.route() (#517). + routeState(task, file) { + const t = task || ''; + const taskType = t.includes('fix') ? 'fix' : + t.includes('test') ? 'test' : + t.includes('refactor') ? 'refactor' : + t.includes('document') ? 'docs' : 'edit'; + let ext = ''; + if (file) { + const idx = file.lastIndexOf('.'); + ext = idx >= 0 ? file.slice(idx).toLowerCase() : ''; + } + return `${taskType}:${ext || 'unknown'}`; + } + + // Record an agent routing outcome under the state key route() reads. + // Uses the engine's Q-update semantics (0.5 baseline), so a single good + // outcome (reward > 0.5) is enough to beat the static default mapping. + recordRouteOutcome(task, file, agent, reward) { + if (!agent || agent === 'unknown') return null; + const state = this.routeState(task, file); + const key = `${state}|${agent}`; + if (!this.data.patterns) this.data.patterns = {}; + if (!this.data.stats) this.data.stats = { total_patterns: 0, total_memories: 0, total_trajectories: 0, total_errors: 0, session_count: 0, last_session: 0 }; + if (!this.data.patterns[key]) { + this.data.patterns[key] = { state, action: agent, q_value: 0.5, visits: 0, last_update: 0 }; + } + const p = this.data.patterns[key]; + p.q_value = p.q_value + this.alpha * (reward - p.q_value); + p.visits++; + p.last_update = this.now(); + this.data.stats.total_patterns = Object.keys(this.data.patterns).length; + + // Forward to engine if already initialized (don't trigger lazy load) + const eng = this.getEngineIfReady(); + if (eng && typeof eng.recordRouteOutcome === 'function') { + try { eng.recordRouteOutcome(task, file, agent, reward); } catch {} + } + return key; + } + learn(state, action, outcome, reward) { const id = `traj_${this.now()}`; this.updateQ(state, action, reward); @@ -3145,7 +3192,10 @@ class Intelligence { route(task, file, crateName, operation = 'edit') { const fileType = file ? path.extname(file).slice(1) : 'unknown'; - const state = `${operation}_${fileType}_in_${crateName ?? 'project'}`; + // Canonical state shared with the write side (recordRouteOutcome) and + // the engine's route() — previously this read `edit_ts_in_project`-style + // keys that no learning path ever wrote agent actions for (#517). + const state = this.routeState(task || operation, file); const agentMap = { rs: ['rust-developer', 'coder', 'reviewer', 'tester'], ts: ['typescript-developer', 'coder', 'frontend-dev'], @@ -3159,7 +3209,16 @@ class Intelligence { yml: ['devops-engineer', 'coder'], yaml: ['devops-engineer', 'coder'] }; - const agents = agentMap[fileType] ?? ['coder', 'reviewer']; + const agents = (agentMap[fileType] ?? ['coder', 'reviewer']).slice(); + // Include agents learned for this state (e.g. from trajectory outcomes) + // even if they are not in the static candidate list. + const prefix = `${state}|`; + for (const key of Object.keys(this.data.patterns || {})) { + if (key.startsWith(prefix)) { + const learned = key.slice(prefix.length); + if (learned && !agents.includes(learned)) agents.push(learned); + } + } const { action, confidence } = this.suggest(state, agents); const reason = confidence > 0.5 ? 'learned from past success' : confidence > 0 ? 'based on patterns' : `default for ${fileType} files`; @@ -4274,6 +4333,7 @@ hooksCmd.command('trajectory-begin') .description('Begin tracking a new execution trajectory') .requiredOption('-c, --context ', 'Task or operation context') .option('-a, --agent ', 'Agent performing the task', 'unknown') + .option('-f, --file ', 'Primary file being worked on') .action((opts) => { const intel = new Intelligence({ skipEngine: true }); // Fast mode - no engine needed const trajId = `traj_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; @@ -4282,6 +4342,7 @@ hooksCmd.command('trajectory-begin') id: trajId, context: opts.context, agent: opts.agent, + file: opts.file || null, steps: [], startTime: Date.now() }; @@ -4335,6 +4396,14 @@ hooksCmd.command('trajectory-end') if (!intel.data.trajectories) intel.data.trajectories = []; intel.data.trajectories.push(traj); delete trajectories[latestTrajId]; + + // Close the routing learning loop (#517): when the trajectory knows which + // agent did the work, record the outcome under the agent-routing state + // key that `hooks route` / engine.route() actually query. + let learnedRoute = null; + if (traj.agent && traj.agent !== 'unknown') { + learnedRoute = intel.recordRouteOutcome(traj.context, traj.file || undefined, traj.agent, quality); + } intel.save(); console.log(JSON.stringify({ @@ -4342,7 +4411,8 @@ hooksCmd.command('trajectory-end') trajectory_id: latestTrajId, steps: traj.steps.length, duration_ms: traj.endTime - traj.startTime, - quality + quality, + ...(learnedRoute ? { learned_route: learnedRoute } : {}) })); }); @@ -4416,9 +4486,30 @@ hooksCmd.command('error-suggest') hooksCmd.command('force-learn') .description('Force an immediate learning cycle') .action(() => { - const intel = new Intelligence({ skipEngine: true }); // Fast mode - intel.tick(); - console.log(JSON.stringify({ success: true, result: 'Learning cycle triggered', stats: intel.stats() })); + try { + // Engine enabled: tick()/forceLearn() only exist on the native IntelligenceEngine, + // not on this lightweight Intelligence wrapper (see issue #529). + const intel = new Intelligence(); + const eng = intel.getEngine(); + let success = false; + let result; + if (eng && typeof eng.forceLearn === 'function') { + try { + const learnResult = eng.forceLearn(); + if (typeof eng.tick === 'function') eng.tick(); + result = learnResult || 'Engine learning cycle complete'; + success = true; + } catch (e) { + result = `Engine learning failed: ${e.message}`; + } + } else { + result = 'Native intelligence engine unavailable; no learning cycle performed'; + } + try { intel.save(); } catch {} + console.log(JSON.stringify({ success, engineEnabled: !!eng, result, stats: intel.stats() })); + } catch (e) { + console.log(JSON.stringify({ success: false, engineEnabled: false, result: `force-learn failed: ${e.message}` })); + } }); // ============================================ diff --git a/npm/packages/ruvector/src/core/intelligence-engine.ts b/npm/packages/ruvector/src/core/intelligence-engine.ts index 2eaaf1ff3..e51772189 100644 --- a/npm/packages/ruvector/src/core/intelligence-engine.ts +++ b/npm/packages/ruvector/src/core/intelligence-engine.ts @@ -171,6 +171,9 @@ export class IntelligenceEngine { // Runtime state private currentTrajectoryId: number | null = null; + private currentTrajectoryContext: string | null = null; + private currentTrajectoryFile: string | undefined = undefined; + private currentTrajectoryAgent: string | null = null; private sessionStart: number = Date.now(); private learningEnabled: boolean = true; private episodeBatchQueue: BatchEpisode[] = []; @@ -638,6 +641,12 @@ export class IntelligenceEngine { beginTrajectory(context: string, file?: string): void { const embed = this.embed(context + ' ' + (file || '')); + // Remember the task context so endTrajectory() can write the routing + // outcome into the same state namespace route() reads (issue #517). + this.currentTrajectoryContext = context; + this.currentTrajectoryFile = file; + this.currentTrajectoryAgent = null; + if (this.sona) { try { this.currentTrajectoryId = this.sona.beginTrajectory(embed); @@ -678,13 +687,28 @@ export class IntelligenceEngine { } } + // Close the routing learning loop: if a route was chosen for this + // trajectory, record its outcome under the state key route() queries. + if (this.currentTrajectoryAgent && this.currentTrajectoryContext) { + this.recordRouteOutcome( + this.currentTrajectoryContext, + this.currentTrajectoryFile, + this.currentTrajectoryAgent, + q + ); + } + this.currentTrajectoryId = null; + this.currentTrajectoryContext = null; + this.currentTrajectoryFile = undefined; + this.currentTrajectoryAgent = null; } /** * Set the agent route for current trajectory */ setTrajectoryRoute(agent: string): void { + this.currentTrajectoryAgent = agent; if (this.sona && this.currentTrajectoryId !== null) { try { this.sona.setRoute(this.currentTrajectoryId, agent); @@ -694,6 +718,27 @@ export class IntelligenceEngine { } } + /** + * Record the outcome of an agent routing decision. + * + * This is the write-side counterpart of route(): it derives the state key + * with the exact same getState()/getExtension() logic route() uses for + * lookups, so learned agent outcomes actually influence future routing + * (fixes #517 — previously only command/edit outcome episodes were stored, + * under state keys route() never queries). + */ + recordRouteOutcome(task: string, file: string | undefined, agent: string, reward: number): void { + if (!agent || agent === 'unknown') return; + const ext = file ? this.getExtension(file) : ''; + const state = this.getState(task, ext); + if (!this.routingPatterns.has(state)) { + this.routingPatterns.set(state, new Map()); + } + const patterns = this.routingPatterns.get(state)!; + const oldValue = patterns.get(agent) ?? 0.5; + patterns.set(agent, oldValue + this.config.learningRate * (reward - oldValue)); + } + // ========================================================================= // Episode Learning (Q-learning compatible) // ========================================================================= diff --git a/npm/packages/ruvector/tests/hooks-route-learning.test.mjs b/npm/packages/ruvector/tests/hooks-route-learning.test.mjs new file mode 100644 index 000000000..b1eee1392 --- /dev/null +++ b/npm/packages/ruvector/tests/hooks-route-learning.test.mjs @@ -0,0 +1,102 @@ +/** + * Regression test for issue #517: `ruvector hooks route` never returned + * learned routing (always default mapping / confidence 0) because the + * Q-pattern state keys written by the learning hooks did not match the + * state keys route() reads, and no path ever wrote agent-name actions. + * + * Covers the full loop via real CLI invocations in an isolated temp project: + * 1. sane fallback when nothing has been learned, + * 2. learned routing from a seeded .ruvector/intelligence.json, + * 3. trajectory-begin/trajectory-end writing the agent-routing pattern + * that a subsequent `hooks route` picks up (cross-process). + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const CLI = path.join(__dirname, '..', 'bin', 'cli.js'); + +function makeProject(intelligence) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruvector-route-')); + fs.mkdirSync(path.join(dir, '.ruvector'), { recursive: true }); + fs.writeFileSync( + path.join(dir, '.ruvector', 'intelligence.json'), + JSON.stringify(intelligence ?? {}, null, 2) + ); + return dir; +} + +function cli(cwd, args) { + const out = execFileSync(process.execPath, [CLI, ...args], { + cwd, + encoding: 'utf8', + timeout: 30000, + env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1' }, + }); + return JSON.parse(out); +} + +test('hooks route falls back to default mapping when nothing learned', (t) => { + const dir = makeProject({}); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + + const res = cli(dir, ['hooks', 'route', 'fix a failing test', '--file', 'src/index.ts']); + assert.equal(res.recommended, 'typescript-developer'); + assert.equal(res.confidence, 0); + assert.match(res.reasoning, /default for ts files/); +}); + +test('hooks route returns learned agent from persisted Q-patterns', (t) => { + const dir = makeProject({ + patterns: { + 'fix:.ts|tester': { state: 'fix:.ts', action: 'tester', q_value: 0.85, visits: 12, last_update: 0 }, + }, + stats: { total_patterns: 1 }, + }); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + + const res = cli(dir, ['hooks', 'route', 'fix a failing test', '--file', 'src/index.ts']); + assert.equal(res.recommended, 'tester'); + assert.ok(res.confidence > 0.5, `confidence should reflect learned q-value, got ${res.confidence}`); + assert.match(res.reasoning, /learned/); + assert.doesNotMatch(res.reasoning, /default/); +}); + +test('trajectory-end writes the routing pattern route() reads (cross-process loop)', (t) => { + const dir = makeProject({}); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + + const begin = cli(dir, [ + 'hooks', 'trajectory-begin', + '-c', 'fix a failing test', + '-a', 'tester', + '-f', 'src/index.ts', + ]); + assert.equal(begin.success, true); + + const end = cli(dir, ['hooks', 'trajectory-end', '--success']); + assert.equal(end.success, true); + assert.equal(end.learned_route, 'fix:.ts|tester'); + + // The learned outcome must now influence routing. + const res = cli(dir, ['hooks', 'route', 'fix a failing test', '--file', 'src/index.ts']); + assert.equal(res.recommended, 'tester'); + assert.ok(res.confidence > 0.5, `expected non-zero learned confidence, got ${res.confidence}`); + assert.match(res.reasoning, /learned/); + + // A different task type is untouched and still falls back sanely. + const other = cli(dir, ['hooks', 'route', 'refactor the parser', '--file', 'src/index.ts']); + assert.equal(other.confidence, 0); + assert.match(other.reasoning, /default/); + + // The persisted pattern lives in the namespace engine.route() imports + // (state `taskType:ext`, action = agent name). + const saved = JSON.parse(fs.readFileSync(path.join(dir, '.ruvector', 'intelligence.json'), 'utf8')); + assert.ok(saved.patterns['fix:.ts|tester'], 'pattern persisted under canonical state key'); + assert.ok(saved.patterns['fix:.ts|tester'].q_value > 0.5); +}); diff --git a/npm/packages/sona/package.json b/npm/packages/sona/package.json index 0310efd62..9024d73f6 100644 --- a/npm/packages/sona/package.json +++ b/npm/packages/sona/package.json @@ -1,6 +1,6 @@ { "name": "@ruvector/sona", - "version": "0.1.6", + "version": "0.1.7", "description": "Self-Optimizing Neural Architecture (SONA) - Runtime-adaptive learning with LoRA, EWC++, and ReasoningBank for LLM routers and AI systems. Sub-millisecond learning overhead, WASM and Node.js support.", "main": "index.js", "types": "index.d.ts", @@ -20,6 +20,7 @@ "artifacts": "napi artifacts", "build": "napi build --platform --release -p ruvector-sona --cargo-cwd ../../../crates/sona --features napi", "build:debug": "napi build --platform -p ruvector-sona --cargo-cwd ../../../crates/sona --features napi", + "prepublishOnly": "node -e \"const fs=require('fs');const missing=['index.js','index.d.ts'].filter(f=>!fs.existsSync(f));if(missing.length){console.error('ERROR: cannot publish @ruvector/sona: missing build output: '+missing.join(', ')+'. Run npm run build first (napi build against crates/sona, requires cargo + @napi-rs/cli). npm silently skips missing files entries, which is how the broken 0.1.6 tarball shipped.');process.exit(1)}if(!fs.readdirSync('.').some(f=>f.endsWith('.node'))){console.warn('WARNING: no local .node binary found; installs will rely solely on @ruvector/sona-* optionalDependencies.')}\"", "test": "node --test", "universal": "napi universal", "version": "napi version" @@ -71,12 +72,12 @@ "*.node" ], "optionalDependencies": { - "@ruvector/sona-linux-x64-gnu": "0.1.4", - "@ruvector/sona-linux-x64-musl": "0.1.4", - "@ruvector/sona-linux-arm64-gnu": "0.1.4", - "@ruvector/sona-darwin-x64": "0.1.4", - "@ruvector/sona-darwin-arm64": "0.1.4", - "@ruvector/sona-win32-x64-msvc": "0.1.4", - "@ruvector/sona-win32-arm64-msvc": "0.1.4" + "@ruvector/sona-linux-x64-gnu": "0.1.5", + "@ruvector/sona-linux-x64-musl": "0.1.5", + "@ruvector/sona-linux-arm64-gnu": "0.1.5", + "@ruvector/sona-darwin-x64": "0.1.5", + "@ruvector/sona-darwin-arm64": "0.1.5", + "@ruvector/sona-win32-x64-msvc": "0.1.5", + "@ruvector/sona-win32-arm64-msvc": "0.1.5" } } \ No newline at end of file