Skip to content

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 #519

Closed

Description

@pacphi

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_feedback to build a single-step-safe LearningSignal (e.g. fall back to a query_embedding-based gradient when steps < 2, or skip baseline subtraction for a single step) and accumulate + flush.
  • Implement processInstantLearning in @ruvector/ruvllm, or mark it explicitly unimplemented so consumers don't assume it adapts.
  • Adapt down_proj too, or document up-only adaptation as intentional.
  • Guard estimate_gradient against 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)

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

  1. changed the title [-]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[/+] on Jun 1, 2026
  2. pacphi commented on Jun 1, 2026

    @pacphi
    Author

    Validation of the four suggested directions (source @ 712e22c + literature + repro)

    Re-verified the suggested fixes against current HEAD (712e22c, newer than the c2089c4 snapshot above). Key context first: the Rust InstantLoop::on_trajectory (crates/sona/src/loops/instant.rs:103-127) is fully wired — it builds a LearningSignal::from_trajectory, calls accumulate_gradient, and auto-flushes; apply_micro_lora reads 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 value

    estimate_gradient (types.rs:68-101) is REINFORCE with a mean-reward baseline. For a single-step trajectory, advantage = reward − mean(reward) = 0 → zero gradient. (The steps.is_empty() branch returns query_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 for steps < 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 — ✅ valid

    learn_from_feedback (wasm.rs:183-192) only console.logs, though it holds Arc<RwLock<SonaEngine>> and could delegate. JS SonaCoordinator.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-step on_trajectory or explicitly throw/return unimplemented (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 down_proj too — ⚠️ partially valid → document, don't "fix"

    down_proj is random-initialized (lora.rs:62) and used in forward; up_proj is zero-initialized (:70); accumulate_gradient/apply_accumulated update only up_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_rank clamps to 1–2), and drop/annotate the unused grad_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_gradient against amplifying a near-zero gradient — ✅ valid (existing guard insufficient)

    A if norm > 1e-8 guard 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

    Dir Verdict Action Benefit
    1 valid (highest) single-step-safe gradient the common one-outcome-per-task shape actually adapts
    2 valid honest-mark now (1 line); full impl = delegate to on_trajectory (needs #1) consumers stop silently assuming adaptation
    3 partial document as LoRA-FA; fix "rank-1" comment; retire grad_down correctness-neutral; removes the bug misread
    4 valid precision-aware floor (~1e-5 f32) + skip on no-variance kills spurious unit-norm updates from FP residue

    Sequencing: #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_zero single-step case (and a downstream consumer's observable LoRA delta-norm) should become > 0 for a wired learn→adapt+flush path.

  3. pacphi commented on Jun 1, 2026

    @pacphi
    Author

    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):

    ==== RuVector#519 impact (200 single-outcome feedbacks, DIM=64, rank=2) ====
    BEFORE (current from_trajectory): inference Δ = 0e0     → model UNCHANGED (learning is a no-op)
    AFTER  (Direction-1 single-step): inference Δ = 0.6547  → model ADAPTS (feedback now changes behavior)
    ====
    

    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_lora serves at inference never changes. The minimal fix turns those same 200 signals into a measurable behavior change.

    Why a maintainer should care (impact tiers)

    1. Truth-in-advertising. SONA's headline is "self-optimizing / instant learning." Today the JS/WASM learn_from_feedback and processInstantLearning seams 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.
    2. Silent failure is the worst kind. Nothing errors. Counters increment, console.log fires, 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.)
    3. The fix is cheap and the unlock is large. Direction 1 (single-step-safe gradient) + Direction 2 (route the JS/WASM seam into 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.rs and run cargo test -p ruvector-sona --test impact_demo -- --nocapture:

    use ruvector_sona::{LearningSignal, MicroLoRA, TrajectoryBuilder};
    const DIM: usize = 64; const RANK: usize = 2; const LR: f32 = 0.05;
    fn l2(a: &[f32], b: &[f32]) -> f32 { a.iter().zip(b).map(|(x,y)|(x-y)*(x-y)).sum::<f32>().sqrt() }
    fn infer(l: &MicroLoRA, x: &[f32]) -> Vec<f32> { let mut o = vec![0.0; x.len()]; l.forward(x,&mut o); o }
    // Direction-1 fix, applied locally: single step -> skip self-cancelling baseline, use raw reward*activation.
    fn single_step_safe(q: &[f32], act: &[f32], r: f32) -> LearningSignal {
        LearningSignal::with_gradient(q.to_vec(), act.iter().map(|&a| a*r).collect(), r)
    }
    #[test]
    fn impact_demo() {
        let q: Vec<f32> = (0..DIM).map(|i|(i as f32*0.07).sin()).collect();
        let act: Vec<f32> = (0..DIM).map(|i|(i as f32*0.05).cos()).collect();
        // BEFORE: today's path
        let mut a = MicroLoRA::new(DIM, RANK); let base_a = infer(&a, &q);
        for _ in 0..200 {
            let mut b = TrajectoryBuilder::new(0, q.clone());
            b.add_step(act.clone(), vec![], 1.0);
            let s = LearningSignal::from_trajectory(&b.build(0.95));
            a.accumulate_gradient(&s); a.apply_accumulated(LR);
        }
        let d_before = l2(&base_a, &infer(&a, &q));
        // AFTER: Direction-1 signal
        let mut c = MicroLoRA::new(DIM, RANK); let base_c = infer(&c, &q);
        for _ in 0..200 { let s = single_step_safe(&q, &act, 1.0); c.accumulate_gradient(&s); c.apply_accumulated(LR); }
        let d_after = l2(&base_c, &infer(&c, &q));
        println!("BEFORE Δ={d_before:e}  AFTER Δ={d_after:.4}");
        assert_eq!(d_before, 0.0); assert!(d_after > 0.0);
    }

    (Verified against HEAD 712e22c, cargo 1.95.0. The 0e0 BEFORE is the same inertness as the earlier repro_delta_zero single-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_trajectory path.

  4. ruvnet commented on Jun 12, 2026

    @ruvnet
    Owner

    Your before/after measurement nailed the impact, and the merged fix (#552, commit a58858e) is essentially your Direction-1 gradient, scoped conservatively: LearningSignal::estimate_gradient falls 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 where reward - mean(rewards) == 0 froze 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_trajectory keeps the builder (returns a real handle), record_step appends, end_trajectory submits for learning, and learn_from_feedback synthesizes 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_output asserts apply_micro_lora output changes after 5 single-step constant-reward feedbacks.

    Shipped: @ruvector/sona@0.1.7 on 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions