Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions .github/workflows/sona-napi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ jobs:
- os: ubuntu-latest
target: x86_64-unknown-linux-musl
node-file: sona.linux-x64-musl.node
# Linux ARM64
- os: ubuntu-latest
# Linux ARM64 — 22.04 so the gcc cross-link needs glibc 2.35, not 24.04's 2.39
- os: ubuntu-22.04
target: aarch64-unknown-linux-gnu
node-file: sona.linux-arm64-gnu.node
# macOS x64 — cross-compiled on Apple Silicon (macos-13 Intel runners are retired;
Expand Down Expand Up @@ -81,14 +81,25 @@ jobs:
sudo apt-get update
sudo apt-get install -y musl-tools

- name: Install cross-compilation tools (Linux ARM64)
if: matrix.target == 'aarch64-unknown-linux-gnu'
run: |
sudo apt-get update
sudo apt-get install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu

- name: Install napi-rs CLI
run: npm install -g @napi-rs/cli
run: npm install -g @napi-rs/cli@3.7.3

# Plain gcc cross-linker, same as build-diskann.yml. `--use-napi-cross`
# now fails in CI ("Failed to set up the --use-napi-cross toolchain ...
# Cannot find native binding") with both @napi-rs/cli 3.10.x and 3.7.3.
- name: Build native module (cross-compile)
if: matrix.target == 'aarch64-unknown-linux-gnu'
working-directory: npm/packages/sona
env:
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
run: |
npx napi build --platform --release -p ruvector-sona --manifest-path ../../../crates/sona/Cargo.toml --output-dir . -F napi --target ${{ matrix.target }} --use-napi-cross
npx napi build --platform --release -p ruvector-sona --manifest-path ../../../crates/sona/Cargo.toml --output-dir . -F napi --target ${{ matrix.target }}

- name: Build native module (macOS)
if: startsWith(matrix.target, 'x86_64-apple-darwin') || startsWith(matrix.target, 'aarch64-apple-darwin')
Expand Down Expand Up @@ -171,7 +182,7 @@ jobs:
registry-url: 'https://registry.npmjs.org'

- name: Install napi-rs CLI
run: npm install -g @napi-rs/cli
run: npm install -g @napi-rs/cli@3.7.3 # pinned: 3.10.x breaks --use-napi-cross (aarch64 "Cannot find native binding"); 3.7.3 was the last green run, 2026-07-17

- name: Download all artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
Expand Down
14 changes: 7 additions & 7 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion crates/sona/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "ruvector-sona"
version = "0.2.1"
version = "0.2.2"
edition = "2021"
rust-version = "1.70"
authors = ["RuVector Team <team@ruvector.dev>"]
Expand Down
18 changes: 18 additions & 0 deletions crates/sona/src/loops/instant.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,13 @@ impl InstantLoop {

/// Process completed trajectory
pub fn on_trajectory(&self, trajectory: QueryTrajectory) {
// A single NaN/Inf would propagate into the EWC++ Fisher estimate and
// the pattern centroids for good (stopping micro-LoRA learning and
// making base-LoRA output NaN), so non-finite trajectories are dropped.
if !is_finite_trajectory(&trajectory) {
return;
}

// Record to buffer
self.trajectory_buffer.record(trajectory.clone());
self.metrics
Expand Down Expand Up @@ -241,6 +248,17 @@ impl InstantLoop {
}
}

/// True when every value that feeds learning (quality, query embedding,
/// step activations and rewards) is finite.
fn is_finite_trajectory(trajectory: &QueryTrajectory) -> bool {
trajectory.final_quality.is_finite()
&& trajectory.query_embedding.iter().all(|v| v.is_finite())
&& trajectory
.steps
.iter()
.all(|s| s.reward.is_finite() && s.activations.iter().all(|v| v.is_finite()))
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
9 changes: 8 additions & 1 deletion crates/sona/src/lora.rs
Original file line number Diff line number Diff line change
Expand Up @@ -477,8 +477,15 @@ impl BaseLoRA {
}

/// Forward pass for single layer
///
/// A no-op when `layer_idx` is out of range, `rank` is 0 (the scale would
/// be 0/0 = NaN), or `input`/`output` are not `hidden_dim` long.
pub fn forward_layer(&self, layer_idx: usize, input: &[f32], output: &mut [f32]) {
if layer_idx >= self.layers.len() {
if layer_idx >= self.layers.len()
|| self.rank == 0
|| input.len() != self.hidden_dim
|| output.len() != self.hidden_dim
{
return;
}

Expand Down
89 changes: 58 additions & 31 deletions crates/sona/src/napi_simple.rs
Original file line number Diff line number Diff line change
@@ -1,34 +1,55 @@
//! Simplified NAPI-RS bindings for Node.js
//! Enable with feature flag: `napi`
//!
//! This version uses a simpler API that doesn't expose TrajectoryBuilder to JS
//! This version uses a simpler API that doesn't expose TrajectoryBuilder to JS.
//!
//! A Rust panic cannot unwind into Node: it aborts the whole process. Inputs
//! that the core asserts on are validated here and thrown as JS errors.

#![cfg(feature = "napi")]

use napi::{Error, Result};
use napi_derive::napi;
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use std::sync::{Mutex, MutexGuard};

use crate::{
LearnedPattern, SonaConfig, SonaEngine as RustSonaEngine,
TrajectoryBuilder as RustTrajectoryBuilder,
};

// Global storage for trajectory builders
fn get_trajectory_builders() -> &'static Mutex<HashMap<u32, RustTrajectoryBuilder>> {
static BUILDERS: OnceLock<Mutex<HashMap<u32, RustTrajectoryBuilder>>> = OnceLock::new();
BUILDERS.get_or_init(|| Mutex::new(HashMap::new()))
}

fn get_next_builder_id() -> &'static Mutex<u32> {
static NEXT_ID: OnceLock<Mutex<u32>> = OnceLock::new();
NEXT_ID.get_or_init(|| Mutex::new(0))
fn check_len(what: &str, got: usize, expected: usize) -> Result<()> {
if got == expected {
Ok(())
} else {
Err(Error::from_reason(format!(
"{what} length {got} does not match hiddenDim {expected}"
)))
}
}

/// Node.js SONA Engine wrapper
#[napi]
pub struct SonaEngine {
inner: RustSonaEngine,
/// Open trajectories of this engine (ids are per engine, so one engine
/// cannot end another's trajectory, and they are freed with the engine)
builders: Mutex<HashMap<u32, RustTrajectoryBuilder>>,
next_builder_id: Mutex<u32>,
}

impl SonaEngine {
fn from_inner(inner: RustSonaEngine) -> Self {
Self {
inner,
builders: Mutex::new(HashMap::new()),
next_builder_id: Mutex::new(0),
}
}

fn builders(&self) -> MutexGuard<'_, HashMap<u32, RustTrajectoryBuilder>> {
self.builders.lock().unwrap_or_else(|e| e.into_inner())
}
}

#[napi]
Expand All @@ -37,19 +58,23 @@ impl SonaEngine {
/// @param hidden_dim - Hidden dimension size (e.g., 256, 512)
#[napi(constructor)]
pub fn new(hidden_dim: u32) -> Self {
Self {
inner: RustSonaEngine::new(hidden_dim as usize),
}
Self::from_inner(RustSonaEngine::new(hidden_dim as usize))
}

/// Create with custom configuration
/// @param config - Custom SONA configuration object
#[napi(factory)]
pub fn with_config(config: JsSonaConfig) -> Self {
pub fn with_config(config: JsSonaConfig) -> Result<Self> {
let micro_lora_rank = config.micro_lora_rank.unwrap_or(1);
if !(1..=2).contains(&micro_lora_rank) {
return Err(Error::from_reason(format!(
"microLoraRank must be 1 or 2, got {micro_lora_rank}"
)));
}
let rust_config = SonaConfig {
hidden_dim: config.hidden_dim as usize,
embedding_dim: config.embedding_dim.unwrap_or(config.hidden_dim) as usize,
micro_lora_rank: config.micro_lora_rank.unwrap_or(1) as usize,
micro_lora_rank: micro_lora_rank as usize,
base_lora_rank: config.base_lora_rank.unwrap_or(8) as usize,
micro_lora_lr: config.micro_lora_lr.unwrap_or(0.001) as f32,
base_lora_lr: config.base_lora_lr.unwrap_or(0.0001) as f32,
Expand All @@ -60,9 +85,7 @@ impl SonaEngine {
quality_threshold: config.quality_threshold.unwrap_or(0.5) as f32,
enable_simd: config.enable_simd.unwrap_or(true),
};
Self {
inner: RustSonaEngine::with_config(rust_config),
}
Ok(Self::from_inner(RustSonaEngine::with_config(rust_config)))
}

/// Start a new trajectory recording
Expand All @@ -73,11 +96,13 @@ impl SonaEngine {
let embedding: Vec<f32> = query_embedding.iter().map(|&x| x as f32).collect();
let builder = self.inner.begin_trajectory(embedding);

let mut builders = get_trajectory_builders().lock().unwrap();
let mut next_id = get_next_builder_id().lock().unwrap();
let mut next_id = self
.next_builder_id
.lock()
.unwrap_or_else(|e| e.into_inner());
let id = *next_id;
*next_id += 1;
builders.insert(id, builder);
*next_id = next_id.wrapping_add(1);
self.builders().insert(id, builder);
id
}

Expand All @@ -94,7 +119,7 @@ impl SonaEngine {
attention_weights: Vec<f64>,
reward: f64,
) {
let mut builders = get_trajectory_builders().lock().unwrap();
let mut builders = self.builders();
if let Some(builder) = builders.get_mut(&trajectory_id) {
let act: Vec<f32> = activations.iter().map(|&x| x as f32).collect();
let att: Vec<f32> = attention_weights.iter().map(|&x| x as f32).collect();
Expand All @@ -107,7 +132,7 @@ impl SonaEngine {
/// @param route - Model route identifier
#[napi]
pub fn set_trajectory_route(&self, trajectory_id: u32, route: String) {
let mut builders = get_trajectory_builders().lock().unwrap();
let mut builders = self.builders();
if let Some(builder) = builders.get_mut(&trajectory_id) {
builder.set_model_route(&route);
}
Expand All @@ -118,7 +143,7 @@ impl SonaEngine {
/// @param context_id - Context identifier
#[napi]
pub fn add_trajectory_context(&self, trajectory_id: u32, context_id: String) {
let mut builders = get_trajectory_builders().lock().unwrap();
let mut builders = self.builders();
if let Some(builder) = builders.get_mut(&trajectory_id) {
builder.add_context(&context_id);
}
Expand All @@ -129,7 +154,7 @@ impl SonaEngine {
/// @param quality - Final quality score [0.0, 1.0]
#[napi]
pub fn end_trajectory(&self, trajectory_id: u32, quality: f64) {
let mut builders = get_trajectory_builders().lock().unwrap();
let mut builders = self.builders();
if let Some(builder) = builders.remove(&trajectory_id) {
let trajectory = builder.build(quality as f32);
self.inner.submit_trajectory(trajectory);
Expand All @@ -140,28 +165,30 @@ impl SonaEngine {
/// @param input - Input vector (Float64Array)
/// @returns Transformed output vector
#[napi]
pub fn apply_micro_lora(&self, input: Vec<f64>) -> Vec<f64> {
pub fn apply_micro_lora(&self, input: Vec<f64>) -> Result<Vec<f64>> {
check_len("input", input.len(), self.inner.config().hidden_dim)?;
let input_f32: Vec<f32> = input.iter().map(|&x| x as f32).collect();
// The Rust LoRA forward pass has residual semantics (it adds the
// learned delta into `output`), so `output` must start as a clone
// of `input` — not zeros — or callers only ever see the delta.
let mut output = input_f32.clone();
self.inner.apply_micro_lora(&input_f32, &mut output);
output.iter().map(|&x| x as f64).collect()
Ok(output.iter().map(|&x| x as f64).collect())
}

/// Apply base-LoRA transformation to layer output
/// @param layer_idx - Layer index
/// @param input - Input vector (Float64Array)
/// @returns Transformed output vector
#[napi]
pub fn apply_base_lora(&self, layer_idx: u32, input: Vec<f64>) -> Vec<f64> {
pub fn apply_base_lora(&self, layer_idx: u32, input: Vec<f64>) -> Result<Vec<f64>> {
check_len("input", input.len(), self.inner.config().hidden_dim)?;
let input_f32: Vec<f32> = input.iter().map(|&x| x as f32).collect();
// Same residual semantics as apply_micro_lora — seed with input.
let mut output = input_f32.clone();
self.inner
.apply_base_lora(layer_idx as usize, &input_f32, &mut output);
output.iter().map(|&x| x as f64).collect()
Ok(output.iter().map(|&x| x as f64).collect())
}

/// Run background learning cycle if due
Expand Down
Loading
Loading