mirror of
https://github.com/ruvnet/RuVector.git
synced 2026-07-09 17:28:42 +00:00
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:
parent
a083bd77fa
commit
a58858e5ec
8 changed files with 480 additions and 45 deletions
17
.github/workflows/sona-napi.yml
vendored
17
.github/workflows/sona-napi.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}` }));
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
// =========================================================================
|
||||
|
|
|
|||
102
npm/packages/ruvector/tests/hooks-route-learning.test.mjs
Normal file
102
npm/packages/ruvector/tests/hooks-route-learning.test.mjs
Normal 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);
|
||||
});
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue