fix: repair the self-learning intelligence/SONA pipeline (#552)

* fix(sona): wire WASM learn-to-inference loop; single-step gradient fallback (#519)

start_trajectory/record_step/end_trajectory now drive real TrajectoryBuilders
through SonaEngine instead of console.log stubs; learn_from_feedback
synthesizes a one-step trajectory and flushes so a single feedback call
updates MicroLoRA weights. LearningSignal::estimate_gradient falls back to
baseline-free REINFORCE only when the baselined gradient is exactly zero
(single-step / constant-reward trajectories), leaving multi-step
varying-reward behavior unchanged. 3 regression tests added.

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(ruvector): force-learn crash and learned-route namespace mismatch (#529, #517)

force-learn: stop calling intel.tick() on the engine-less Intelligence
wrapper (TypeError); use the native engine forceLearn()/tick() like the MCP
handler does, degrade to success:false + exit 0 when the engine is
unavailable, never throw (#529).

route learning: Q-patterns were written as command/edit outcome episodes
under state keys route() never queries, so routing always returned default
mapping. Add recordRouteOutcome() writing agent outcomes under the exact
getState() key route() reads; trajectory-end now closes the loop (and
trajectory-begin gains --file); Intelligence.load() preserves
activeTrajectories so cross-process trajectories survive; sync route() uses
the canonical state key and includes learned agents in candidates (#517).
New test suite tests/hooks-route-learning.test.mjs.

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(sona-npm): guard publish against missing build output; bump to 0.1.7 (#516)

0.1.6 shipped with only README + package.json because index.js/index.d.ts
are napi build artifacts absent at publish time and npm silently skips
missing `files` entries. Add a prepublishOnly check that hard-fails without
build output; bump platform optionalDependencies 0.1.4 -> 0.1.5 (latest
published for all 7 targets). CI had the same latent gap: sona-napi.yml only
staged .node artifacts for publish — now uploads index.js/index.d.ts as a
js-bindings artifact and verifies presence before npm publish.

Co-Authored-By: claude-flow <ruv@ruv.net>

* style(sona): rustfmt the #519 regression tests

Co-Authored-By: claude-flow <ruv@ruv.net>

---------

Co-authored-by: ruv <ruvnet@users.noreply.github.com>
This commit is contained in:
rUv 2026-06-11 15:29:24 -04:00 committed by GitHub
parent a083bd77fa
commit a58858e5ec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 480 additions and 45 deletions

View file

@ -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

View file

@ -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);

View file

@ -95,6 +95,33 @@ impl LearningSignal {
let norm: f32 = gradient.iter().map(|x| x * x).sum::<f32>().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::<f32>().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::<f32>()
.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::<f32>()
.sqrt();
assert!((norm - 1.0).abs() < 1e-4);
}
#[test]
fn test_pattern_merge() {
let p1 = LearnedPattern {

View file

@ -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<RwLock<SonaEngine>>,
/// Active trajectory builders keyed by the ID handed to JS,
/// paired with the query embedding for step recording (fixes #519).
active_trajectories: RwLock<HashMap<u64, (TrajectoryBuilder, Vec<f32>)>>,
/// Last query embedding seen, used to synthesize feedback trajectories.
last_embedding: RwLock<Vec<f32>>,
/// 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<f32>) -> 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

View file

@ -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 <context>', 'Task or operation context')
.option('-a, --agent <agent>', 'Agent performing the task', 'unknown')
.option('-f, --file <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}` }));
}
});
// ============================================

View file

@ -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)
// =========================================================================

View file

@ -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);
});

View file

@ -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"
}
}