Repository navigation
Description
A downstream consumer (ruflo, ruvnet/ruflo#2222) found that SONA's trained adapter never changes a routing/recall decision — empirically Δ=0 after ~200 adapts — and attributed it to "@ruvector/ruvllm MicroLoRA apply() being inert." I cloned ruvnet/ruvector (c2089c4, 2026-05-28) and verified against the actual source. The real picture is more specific, and worth correcting: inference seams exist and work (applyLora, MicroLoRA::forward, LoraAdapter.forward) — this is not "no forward path." The gap is that the learn→adapt loop is not wired through the JS/WASM entry points a consumer actually calls, and even the Rust path only adapts under conditions a typical consumer won't hit. Filing so the seam isn't lost.
Findings (source + reproduction)
1. WasmSonaEngine::learn_from_feedback is a no-op — crates/sona/src/wasm.rs:183-192. It computes a reward and console.logs it, then returns; it never builds a trajectory, accumulates a gradient, or touches a weight:
pub fn learn_from_feedback(&self, success: bool, latency_ms: f32, quality: f32) {
let reward = if success { quality } else { -quality };
web_sys::console::log_1(&format!("Feedback: ... reward={}", reward).into());
}This is the natural JS/WASM "I have an outcome, learn from it" entry point — calling it any number of times trains nothing.
2. The pure-JS SonaCoordinator.processInstantLearning is an empty stub — npm/packages/ruvllm/src/sona.js:449-452: // In full implementation, this updates LoRA weights with no body. So the JS-package SONA coordinator never adapts LoRA either. (Its LoraAdapter.forward/backward in lora.ts do work — but SonaCoordinator never instantiates or calls them.)
3. The Rust trajectory path adapts only on multi-step, varying-reward trajectories. LearningSignal::estimate_gradient (crates/sona/src/types.rs:69-101) is REINFORCE with a mean-reward baseline. A single-step trajectory — the common "one outcome per task" shape — has advantage = reward − mean(reward) = 0, giving a zero gradient. Reproduced: 200 single-step adapts → Δ = 0 exactly.
4. MicroLoRA::accumulate_gradient only writes grad_up — crates/sona/src/lora.rs:192-229. down_proj/grad_down is allocated, zeroed, reset, never updated, so adaptation is up-projection-only (rank-deficient). Reproduced: down_proj Δ=0, up_proj |w|=0.566.
5. (Bonus correctness smell) estimate_gradient L2-normalizes before returning, so a uniform-reward trajectory (true advantage ≈ 0) has its f32 baseline residue amplified into a unit-norm gradient — a full-magnitude update from a no-information signal. Reproduced: ||gradient_estimate|| = 1.0, spurious Δ = 0.0125.
Reproduction
cargo test -p ruvector-sona --test repro_delta_zero -- --nocapture (drives the real public API — MicroLoRA + LearningSignal::from_trajectory + TrajectoryBuilder, no mocks):
[single-step feedback] Δ after 200 adapts = 0e0 (inert)
[uniform-reward 3-step] ||grad||=1.0, spurious Δ = 1.26e-2 (FP residue amplified)
[varying-reward 2-step] Δ after 200 adapts = 1.71e-2 (works — control)
[freeze check] down_proj Δ = 0, up_proj |w| = 0.566
Full reproduction test (drop in crates/sona/tests/repro_delta_zero.rs)
use ruvector_sona::{LearningSignal, MicroLoRA, TrajectoryBuilder};
const DIM: usize = 16;
const RANK: usize = 2;
const LR: f32 = 0.01;
fn forward_probe(lora: &MicroLoRA, input: &[f32]) -> Vec<f32> {
let mut out = vec![0.0f32; input.len()];
lora.forward(input, &mut out);
out
}
fn l2(a: &[f32], b: &[f32]) -> f32 {
a.iter().zip(b).map(|(x, y)| (x - y) * (x - y)).sum::<f32>().sqrt()
}
/// The natural "task finished, here is its quality" pattern: one step, one final score.
#[test]
fn single_step_feedback_is_inert() {
let mut lora = MicroLoRA::new(DIM, RANK);
let probe: Vec<f32> = (0..DIM).map(|i| (i as f32 * 0.1).sin()).collect();
let baseline = forward_probe(&lora, &probe);
for n in 0..200u64 {
let mut b = TrajectoryBuilder::new(n, probe.clone());
b.add_step(probe.clone(), vec![1.0; DIM], 1.0);
let signal = LearningSignal::from_trajectory(&b.build(0.95));
lora.accumulate_gradient(&signal);
lora.apply_accumulated(LR);
}
let delta = l2(&baseline, &forward_probe(&lora, &probe));
println!("[single-step feedback] Δ after 200 adapts = {delta:e}");
assert_eq!(delta, 0.0);
}
/// Uniform per-step rewards: true advantage 0, but L2-normalized FP residue → unit-norm gradient.
#[test]
fn uniform_reward_amplifies_fp_residue() {
let mut lora = MicroLoRA::new(DIM, RANK);
let probe: Vec<f32> = (0..DIM).map(|i| (i as f32 * 0.2).cos()).collect();
let baseline = forward_probe(&lora, &probe);
let mut b0 = TrajectoryBuilder::new(0, probe.clone());
b0.add_step(probe.clone(), vec![1.0; DIM], 0.9);
b0.add_step(probe.clone(), vec![1.0; DIM], 0.9);
b0.add_step(probe.clone(), vec![1.0; DIM], 0.9);
let sig0 = LearningSignal::from_trajectory(&b0.build(0.9));
let gnorm: f32 = sig0.gradient_estimate.iter().map(|x| x * x).sum::<f32>().sqrt();
println!("[uniform-reward] true advantage=0, yet ||gradient_estimate|| = {gnorm:e}");
for n in 0..200u64 {
let mut b = TrajectoryBuilder::new(n, probe.clone());
b.add_step(probe.clone(), vec![1.0; DIM], 0.9);
b.add_step(probe.clone(), vec![1.0; DIM], 0.9);
b.add_step(probe.clone(), vec![1.0; DIM], 0.9);
let signal = LearningSignal::from_trajectory(&b.build(0.9));
lora.accumulate_gradient(&signal);
lora.apply_accumulated(LR);
}
let delta = l2(&baseline, &forward_probe(&lora, &probe));
println!("[uniform-reward 3-step] spurious Δ after 200 adapts = {delta:e}");
assert!(delta > 0.0);
}
/// Control: varying per-step rewards DO adapt — the mechanism works when fed a real gradient.
#[test]
fn varying_reward_multistep_adapts() {
let mut lora = MicroLoRA::new(DIM, RANK);
let probe: Vec<f32> = (0..DIM).map(|i| (i as f32 * 0.3).sin() + 0.2).collect();
let baseline = forward_probe(&lora, &probe);
for n in 0..200u64 {
let mut b = TrajectoryBuilder::new(n, probe.clone());
b.add_step(probe.clone(), vec![1.0; DIM], 0.1);
b.add_step(probe.clone(), vec![1.0; DIM], 0.9);
let signal = LearningSignal::from_trajectory(&b.build(0.8));
lora.accumulate_gradient(&signal);
lora.apply_accumulated(LR);
}
let delta = l2(&baseline, &forward_probe(&lora, &probe));
println!("[varying-reward 2-step] Δ after 200 adapts = {delta:e}");
assert!(delta > 0.0);
}
/// down_proj is never adapted regardless of signal — only up_proj moves.
#[test]
fn down_proj_is_frozen() {
let mut lora = MicroLoRA::new(DIM, RANK);
let (down_before, _) = { let (d, u) = lora.get_weights(); (d.clone(), u.clone()) };
for n in 0..50u64 {
let mut b = TrajectoryBuilder::new(n, vec![0.5; DIM]);
b.add_step(vec![0.5; DIM], vec![1.0; DIM], 0.1);
b.add_step(vec![0.5; DIM], vec![1.0; DIM], 0.9);
lora.accumulate_gradient(&LearningSignal::from_trajectory(&b.build(0.8)));
lora.apply_accumulated(LR);
}
let (down_after, up_after) = lora.get_weights();
let down_delta = l2(&down_before, down_after);
let up_delta: f32 = up_after.iter().map(|x| x * x).sum::<f32>().sqrt();
println!("[freeze check] down_proj Δ = {down_delta:e} up_proj |w| = {up_delta:e}");
assert_eq!(down_delta, 0.0);
assert!(up_delta > 0.0);
}Net effect for consumers
A JS/WASM consumer driving SONA via the documented learnFromFeedback API — or via single-outcome trajectories — observes zero adaptation; applyLora/applyMicroLora then return the untrained (up_proj = 0) transform. The forward/inference seam exists; the learn→inference loop is just not connected through the bindings consumers reach for.
Suggested directions
- Wire
learn_from_feedbackto build a single-step-safeLearningSignal(e.g. fall back to aquery_embedding-based gradient whensteps < 2, or skip baseline subtraction for a single step) and accumulate + flush. - Implement
processInstantLearningin@ruvector/ruvllm, or mark it explicitly unimplemented so consumers don't assume it adapts. - Adapt
down_projtoo, or document up-only adaptation as intentional. - Guard
estimate_gradientagainst amplifying a near-zero gradient (don't normalize when the pre-norm magnitude is ≈ 0).
Related but distinct: #516 (@ruvector/sona published without build output → MODULE_NOT_FOUND) is a packaging issue; this is about the no-op learn path when the module is loaded and running.
Environment: ruvnet/ruvector@c2089c4, Rust 1.95, crate ruvector-sona 0.2.0. Downstream context: ruflo 3.10.10 / Node 26.
Status update (2026-06-01) — still present at ruflo 3.10.31 / @ruvector/ruvllm 2.5.5
Re-verified against the currently published packages (≈25 ruflo releases later): the JS-side stub is unchanged — @ruvector/ruvllm@2.5.5 dist/esm/sona.js:442-445 still reads:
processInstantLearning(signal) {
// Immediate pattern reinforcement would happen here
// In full implementation, this updates LoRA weights
}So finding #2 persists verbatim, and the matrix-LoRA delta a downstream consumer can observe is still 0 (ruflo's .claude-flow/neural/lora-delta.json → deltaNorm: 0.000000). The scalar confidence path a consumer can close (e.g. ruflo's intelligence.js distillLearning) is a per-pattern confidence += lr*reward nudge — useful, but not the rank-2 matrix adapter this issue is about; it does not exercise learn_from_feedback / accumulate_gradient.
Note the F3 sibling (route Q-state encoder) was fixed in ruflo 3.10.6+/3.10.31 (ruvnet/ruflo#2239, FNV-1a encoder, verified) — so the route learner now self-improves; this issue (#519) is the remaining SONA/LoRA half of that original investigation.
Related (same SONA wiring family)
- hooks route: learned Q-patterns never match — routing state-key namespace mismatch (always "default mapping") #517 —
hooks routelearned Q-patterns never match (routing state-key namespace mismatch). Adjacent learn→inference plumbing gap. - @ruvector/sona@0.1.6 published without build output → SONA routing silently dead (MODULE_NOT_FOUND) #516 —
@ruvector/sona@0.1.6published without build output → SONA routing silently dead. A consumer can't even load the engine that would host this fix.
A consumer-facing acceptance signal: after the fix, ruflo's repro_delta_zero single-step case (and the downstream lora-delta.json deltaNorm) should become > 0 for a wired learn→adapt+flush path.
Activity
Statusline RL panel + ruflo 3.10.31 reconciliation (F3 fixed, F4 gate…
[-]SONA learn→inference loop unwired at the JS/WASM boundary: learn_from_feedback is a no-op; MicroLoRA only adapts on multi-step varying-reward trajectories[/-][+]Bug: SONA learn→inference loop unwired at the JS/WASM boundary — learn_from_feedback is a no-op; MicroLoRA adapts only on multi-step varying-reward trajectories[/+]pacphi commentedon Jun 1, 2026
Validation of the four suggested directions (source @ 712e22c + literature + repro)
Re-verified the suggested fixes against current HEAD (
712e22c, newer than thec2089c4snapshot above). Key context first: the RustInstantLoop::on_trajectory(crates/sona/src/loops/instant.rs:103-127) is fully wired — it builds aLearningSignal::from_trajectory, callsaccumulate_gradient, and auto-flushes;apply_micro_lorareads it at inference. So this is not "no adapt path." The gaps are two specific disconnects: the JS/WASM entry points a consumer calls don't reach that loop, and the gradient is degenerate for common trajectory shapes.1. Single-step-safe
LearningSignal— ✅ valid, highest valueestimate_gradient(types.rs:68-101) is REINFORCE with a mean-reward baseline. For a single-step trajectory,advantage = reward − mean(reward) = 0→ zero gradient. (Thesteps.is_empty()branch returnsquery_embedding, but the one-step case is unhandled — that's the gap.) A baseline is a zero-expectation control variate for variance reduction; it is not meant to null out the only sample. Fix: skip baseline subtraction forsteps < 2, or fall back to the query-embedding-direction gradient scaled by reward.Refs: Williams 1992 (REINFORCE); Sutton & Barto §13.4; D. Silver, RL Lecture 7 (policy-gradient baselines); RLHF Book ch.6 (RLOO leave-one-out baseline) — https://rlhfbook.com/c/06-policy-gradients
2. Implement (or honestly mark)
processInstantLearning/learn_from_feedback— ✅ validlearn_from_feedback(wasm.rs:183-192) onlyconsole.logs, though it holdsArc<RwLock<SonaEngine>>and could delegate. JSSonaCoordinator.processInstantLearning(npm/packages/ruvllm/src/sona.ts:541-544) is an empty stub. Consumers reasonably assume these adapt. Fix (two tiers): now — make them either delegate to a single-stepon_trajectoryor explicitly throw/returnunimplemented(a 1-line honesty fix that should land regardless); full — the delegate impl, which depends on #1 to be non-inert. "Instant" learning should route one outcome into the existing accumulate→threshold-flush path (the safe online-update pattern), not a raw per-call write.Refs: online-vs-batch stability (LS-DQN, arXiv:1705.07461; batch/online separation, arXiv:2012.08005).
3. Adapt⚠️ partially valid → document, don't "fix"
down_projtoo —down_projis random-initialized (lora.rs:62) and used inforward;up_projis zero-initialized (:70);accumulate_gradient/apply_accumulatedupdate onlyup_proj. This is not a bug — zero-init-B + frozen random-A + train-B-only is the published LoRA-FA method ("freeze the projection-down matrix A and train only the projection-up matrix B … without sacrificing expressivity"). Standard LoRA also inits B=0/A=random so ΔW=0 at start. Recommendation: document the up-only adaptation as intentional frozen-A, fix the misleading "rank-1" comment (micro_lora_rankclamps to 1–2), and drop/annotate the unusedgrad_down. (Tuning caveat: with rank ≤ 2 the frozen subspace is small — consider higher max rank or periodic A re-init for hard tasks; not a correctness fix.)Refs: Hu et al. 2021, LoRA, arXiv:2106.09685; Zhang et al. 2023, LoRA-FA, arXiv:2308.03303; Beyond Zero Initialization, arXiv:2505.23194.
4. Guard
estimate_gradientagainst amplifying a near-zero gradient — ✅ valid (existing guard insufficient)A
if norm > 1e-8guard already exists (types.rs:94-98), but the threshold sits below the f32 residue of baseline subtraction. Reproduced (uniform-reward 3-step, f32):advantage = −1.19e-8, pre-norm||grad|| = 1.13e-7→ passes the 1e-8 guard → normalized to unit norm 1.0. A zero-information signal becomes a full-magnitude gradient (matches finding #5). The fix isn't "add an epsilon" (one exists) — it's raise the floor above the baseline-subtraction residue (≈1e-5 for f32) and/or detect near-zero advantage before normalizing and return a zero gradient.Refs: TF#12071 (norm-grad NaN/inf near zero); LayerNorm/RMSNorm ε≈1e-5..1e-8; Gradient Autoscaled Normalization, arXiv:2509.03677 (fixed ε=1e-8).
Overall: do 1, 2, 4; document 3
on_trajectory(needs #1)grad_downSequencing: #4 and #2-honest-mark are tiny and independently safe → land first; #1 is the substantive enabler; #2-impl depends on it; #3 is docs/cleanup.
Acceptance signal: after 1+2+4, the
repro_delta_zerosingle-step case (and a downstream consumer's observable LoRA delta-norm) should become > 0 for a wired learn→adapt+flush path.pacphi commentedon Jun 1, 2026
Why this matters — a runnable before/after (the "so what")
The thread so far is all mechanism. Here is the impact, as a falsifiable measurement rather than an assertion: does one real-world feedback signal change what the model does at inference?
I drove the real public API (
MicroLoRA+TrajectoryBuilder+LearningSignal::from_trajectory) with 200 single-outcome feedbacks — the common "one task, one quality score" shape — and measured the L2 change in the adapter's inference output. Then I re-ran with only the Direction-1 single-step-safe gradient applied locally (crate source untouched):So what: every "this answer was good / bad" signal a consumer sends through the natural feedback path today moves the model by exactly zero. The learning subsystem records, logs, and accounts — but the adapter that
apply_micro_loraserves at inference never changes. The minimal fix turns those same 200 signals into a measurable behavior change.Why a maintainer should care (impact tiers)
learn_from_feedbackandprocessInstantLearningseams a consumer calls are inert (no-op stub + empty body), and even the trajectory path is inert for single-step outcomes. The capability is named but not delivered at the entry points people actually wire up.console.logfires, trajectories accumulate — so a downstream integrator reasonably believes adaptation is happening. The only way to discover it isn't is to measure inference Δ, which almost nobody does. (This is how the downstream ruflo consumer's "Δ LoRA" metric sat at 0 across ~25 releases without anyone noticing it was structurally pinned to 0.)on_trajectory) + Direction 4 (precision-aware near-zero guard) are small, local, independently-safe changes. Together they flip SONA from "records but never adapts" to "feedback measurably changes behavior" — i.e. they make the product's central claim true.Reproduce in ~15s
Drop this in
crates/sona/tests/impact_demo.rsand runcargo test -p ruvector-sona --test impact_demo -- --nocapture:(Verified against HEAD
712e22c,cargo 1.95.0. The0e0BEFORE is the same inertness as the earlierrepro_delta_zerosingle-step case, now expressed as inference impact rather than weight Δ.)This is the acceptance test, inverted: when the fixes land, BEFORE Δ should also become > 0 for the unmodified
from_trajectorypath.style(sona): rustfmt the #519 regression tests
fix: repair the self-learning intelligence/SONA pipeline (#552)
fix(ruvllm): wire processInstantLearning to real micro-LoRA weight up…
ci(sona): cross-implementation drift gate — parity harness, RVF finge…
ruvnet commentedon Jun 12, 2026
Your before/after measurement nailed the impact, and the merged fix (#552, commit a58858e) is essentially your Direction-1 gradient, scoped conservatively:
LearningSignal::estimate_gradientfalls back to baseline-free REINFORCE (advantage = raw reward, normalized) only when the baselined gradient is identically zero — i.e. exactly the single-step / constant-reward trajectories wherereward - mean(rewards) == 0froze all learning. Multi-step varying-reward trajectories are bit-for-bit unaffected (regression-tested both directions).The WASM seams you traced are wired through the real engine now:
start_trajectorykeeps the builder (returns a real handle),record_stepappends,end_trajectorysubmits for learning, andlearn_from_feedbacksynthesizes a one-step trajectory from the last query embedding and flushes immediately — one feedback call produces an actual MicroLoRA weight update, with failure negating the reward (unlearn). The acceptance criterion from this thread is a test now:test_single_feedback_changes_lora_outputassertsapply_micro_loraoutput changes after 5 single-step constant-reward feedbacks.Shipped:
@ruvector/sona@0.1.7on npm (the 0.1.6 empty-package problem from #516 is also fixed). Worth knowing: the same stub existed independently in the TypeScript SONA inside@ruvector/ruvllm— found via #553, fixed in@ruvector/ruvllm@2.5.6. To keep these implementations from drifting apart again, CI now runs a cross-implementation behavioral parity gate (#554) whose central contract is precisely this thread''s finding: a single feedback must produce a non-zero weight delta in every implementation.🤖 Generated with claude-flow