Merge PR #6280: perf(mnemopi): run recall vector kernels natively (@wolfiesch)

This commit is contained in:
can1357
2026-07-22 21:13:24 +02:00
13 changed files with 942 additions and 24 deletions
+1
View File
@@ -54,6 +54,7 @@ pub(crate) mod testing;
pub mod text;
pub mod tokens;
pub(crate) mod utils;
pub mod vectors;
pub mod workspace;
#[cfg(target_os = "windows")]
+342
View File
@@ -0,0 +1,342 @@
//! Batch numeric vector kernels for mnemopi recall paths.
//!
//! Every export processes an entire candidate batch per N-API crossing so the
//! crossing cost is amortized over the whole recall operation. Semantics
//! mirror the TypeScript reference implementations in
//! `packages/mnemopi/src/core` exactly — same accumulation order, same
//! non-finite handling, same tie-breaking — so float scores are
//! bit-identical to the TS versions and integer results are exactly equal.
use napi::{
Error, Result, Status,
bindgen_prelude::{Float32Array, Float64Array, Uint32Array},
};
use napi_derive::napi;
fn invalid<T>(message: &str) -> Result<T> {
Err(Error::new(Status::InvalidArg, message))
}
#[inline]
const fn finite_or_zero(value: f64) -> f64 {
if value.is_finite() { value } else { 0.0 }
}
/// Cosine similarity with the exact semantics of mnemopi's TS
/// `cosineSimilarity`: iterate `max(len_a, len_b)` elements, treat missing
/// and non-finite entries as `0`, return `0` when either norm is zero.
///
/// Splitting the shared prefix from the tails preserves bit-exactness: tail
/// terms of the shorter side only ever add `±0.0` to `dot` and `+0.0` to its
/// own norm, in the same index order as the TS loop.
#[inline]
#[allow(
clippy::suboptimal_flops,
reason = "mul_add rounds differently; bit-exact with the TS loops is the contract"
)]
fn cosine_one(a: &[f64], b: &[f64]) -> f64 {
if a.is_empty() && b.is_empty() {
return 0.0;
}
let shared = a.len().min(b.len());
let mut dot = 0.0f64;
let mut norm_a = 0.0f64;
let mut norm_b = 0.0f64;
for i in 0..shared {
let av = finite_or_zero(a[i]);
let bv = finite_or_zero(b[i]);
dot += av * bv;
norm_a += av * av;
norm_b += bv * bv;
}
for &raw in &a[shared..] {
let av = finite_or_zero(raw);
norm_a += av * av;
}
for &raw in &b[shared..] {
let bv = finite_or_zero(raw);
norm_b += bv * bv;
}
if norm_a == 0.0 || norm_b == 0.0 {
return 0.0;
}
dot / (norm_a.sqrt() * norm_b.sqrt())
}
/// All pairs `(i, j)` with `i < j` whose cosine similarity meets `threshold`.
///
/// `vectors` is `count` vectors flattened row-major at `dim` `f64` elements
/// per row (zero-padded, which matches the TS `?? 0` missing-element
/// semantics), so the similarity is bit-identical to the TS pairwise loop in
/// `clusterBySimilarity`. Returns pairs flattened as `[i0, j0, i1, j1, ...]`
/// in the same `(i, j)` visit order as the TS nested loop.
#[napi]
pub fn cosine_similarity_pairs(
vectors: Float64Array,
count: u32,
dim: u32,
threshold: f64,
) -> Result<Uint32Array> {
let count = count as usize;
let dim = dim as usize;
let data: &[f64] = &vectors;
if data.len() != count * dim {
return invalid("vectors length must equal count * dim");
}
let widened: &[f64] = data;
let mut pairs: Vec<u32> = Vec::new();
for i in 0..count {
let left = &widened[i * dim..(i + 1) * dim];
for j in (i + 1)..count {
let right = &widened[j * dim..(j + 1) * dim];
if cosine_one(left, right) >= threshold {
pairs.push(i as u32);
pairs.push(j as u32);
}
}
}
Ok(Uint32Array::new(pairs))
}
/// Top-k rows of a normalized vector matrix ranked by dot product with a
/// normalized query.
#[napi(object)]
pub struct VectorTopK {
/// Row indices of the selected hits, best score first.
pub indices: Uint32Array,
/// Scores aligned with `indices`.
pub scores: Float64Array,
}
/// Score every row of a normalized `f32` matrix against `query` and return
/// the top `limit` rows.
///
/// Mirrors the TS `searchExactVectorIndex` loop bit-exactly: the query is
/// normalized by the L2 norm of its *full* length, each row score sums
/// `matrix[row][col] * (query[col] / norm)` over
/// `min(query.len, dimensions)` columns in column order. Ranking matches the
/// TS stable sort: score descending, lower row index first on exact ties
/// (`-0.0` and `+0.0` compare equal). Callers are expected to enforce the TS
/// guards first (finite query with a positive norm, non-empty matrix).
#[napi]
#[allow(
clippy::suboptimal_flops,
reason = "mul_add rounds differently; bit-exact with the TS loops is the contract"
)]
pub fn vector_index_top_k(
matrix: Float32Array,
dimensions: u32,
query: Float64Array,
limit: u32,
) -> Result<VectorTopK> {
let dims = dimensions as usize;
let data: &[f32] = &matrix;
if dims == 0 || !data.len().is_multiple_of(dims) {
return invalid("matrix length must be a positive multiple of dimensions");
}
let count = data.len() / dims;
let q: &[f64] = &query;
let mut norm_sq = 0.0f64;
for &value in q {
norm_sq += value * value;
}
let norm = norm_sq.sqrt();
// Hoisting the per-column division out of the row loop is bitwise
// identical to the TS per-row `query[col] / queryNorm`.
let query_dims = q.len().min(dims);
let normalized: Vec<f64> = q[..query_dims].iter().map(|&v| v / norm).collect();
let mut order: Vec<(f64, u32)> = Vec::with_capacity(count);
for row in 0..count {
let base = row * dims;
let mut score = 0.0f64;
for (col, &qv) in normalized.iter().enumerate() {
score += f64::from(data[base + col]) * qv;
}
order.push((score, row as u32));
}
// JS comparator `(a, b) => b.score - a.score` under a stable sort: strict
// score ordering, otherwise (equal, including ±0.0) original row order.
order.sort_by(|a, b| {
let diff = b.0 - a.0;
if diff > 0.0 {
core::cmp::Ordering::Greater
} else if diff < 0.0 {
core::cmp::Ordering::Less
} else {
a.1.cmp(&b.1)
}
});
let take = (limit as usize).min(order.len());
order.truncate(take);
let indices: Vec<u32> = order.iter().map(|&(_, row)| row).collect();
let scores: Vec<f64> = order.iter().map(|&(score, _)| score).collect();
Ok(VectorTopK { indices: Uint32Array::new(indices), scores: Float64Array::new(scores) })
}
/// ECMA-262 `\s` (`WhiteSpace` ∪ `LineTerminator`), which differs from Rust's
/// `char::is_whitespace` (JS additionally includes U+FEFF).
#[inline]
const fn is_js_whitespace(c: char) -> bool {
matches!(
c,
'\u{0009}'
| '\u{000a}'
| '\u{000b}'
| '\u{000c}'
| '\u{000d}'
| '\u{0020}'
| '\u{00a0}'
| '\u{1680}'
| '\u{2000}'
..='\u{200a}'
| '\u{2028}'
| '\u{2029}'
| '\u{202f}'
| '\u{205f}'
| '\u{3000}'
| '\u{feff}'
)
}
/// Lowercased word set per the TS `jaccardSimilarity` tokenizer:
/// `text.toLowerCase().split(/\s+/).filter(Boolean)` into a `Set`.
/// Returned sorted and deduplicated for merge-based intersection counting.
fn word_set(text: &str) -> Vec<Box<str>> {
let lower = text.to_lowercase();
let mut words: Vec<Box<str>> = lower
.split(is_js_whitespace)
.filter(|w| !w.is_empty())
.map(Box::from)
.collect();
words.sort_unstable();
words.dedup();
words
}
/// Jaccard similarity of two sorted, deduplicated word sets. Matches the TS
/// `jaccardSimilarity`: `0` when either set is empty, otherwise
/// `|A ∩ B| / (|A| + |B| - |A ∩ B|)` with exact integer counts.
fn jaccard_sorted(a: &[Box<str>], b: &[Box<str>]) -> f64 {
if a.is_empty() || b.is_empty() {
return 0.0;
}
let mut intersection = 0usize;
let (mut i, mut j) = (0usize, 0usize);
while i < a.len() && j < b.len() {
match a[i].cmp(&b[j]) {
core::cmp::Ordering::Less => i += 1,
core::cmp::Ordering::Greater => j += 1,
core::cmp::Ordering::Equal => {
intersection += 1;
i += 1;
j += 1;
},
}
}
intersection as f64 / (a.len() + b.len() - intersection) as f64
}
/// MMR selection over pre-sorted candidates using Jaccard word similarity.
///
/// `contents[i]` and `scores[i]` describe candidate `i`, already sorted by
/// relevance exactly as the TS `mmrRerank` sorts them (the JS stable sort
/// stays on the TS side so its tie and NaN semantics are preserved).
/// Replicates the TS selection loop exactly: candidate `0` is always taken
/// first; each round picks the remaining candidate maximizing
/// `lambda * score - (1 - lambda) * maxSimilarity(selected)` with strict
/// `>` comparisons, so ties keep the earliest remaining candidate — and a
/// round where every score is `NaN` picks the first remaining candidate,
/// matching the TS `bestIdx = 0` initialisation. Returns the selected
/// indices into the input order.
///
/// Word tokenization matches `text.toLowerCase().split(/\s+/)` (ECMA `\s`,
/// Unicode default full case conversion). Known divergence: unpaired
/// surrogates arrive here as U+FFFD, while JS keeps the lone surrogate; both
/// tokenize to a single non-whitespace word so Jaccard counts still agree
/// unless a text mixes U+FFFD words with lone-surrogate words.
#[napi]
#[allow(
clippy::suboptimal_flops,
reason = "mul_add rounds differently; bit-exact with the TS loops is the contract"
)]
pub fn mmr_rerank_indices(
contents: Vec<String>,
scores: Float64Array,
lambda_param: f64,
top_k: u32,
) -> Result<Uint32Array> {
if scores.len() != contents.len() {
return invalid("scores length must equal contents length");
}
let limit = top_k as usize;
let count = contents.len();
if limit == 0 || count == 0 {
return Ok(Uint32Array::new(Vec::new()));
}
let sets: Vec<Vec<Box<str>>> = contents.iter().map(|text| word_set(text)).collect();
let mut selected: Vec<u32> = Vec::with_capacity(limit.min(count));
selected.push(0);
let mut remaining: Vec<u32> = (1..count as u32).collect();
while !remaining.is_empty() && selected.len() < limit {
let mut best_idx = 0usize;
let mut best_score = f64::NEG_INFINITY;
for (idx, &candidate) in remaining.iter().enumerate() {
let mut max_similarity = 0.0f64;
for &picked in &selected {
let similarity = jaccard_sorted(&sets[candidate as usize], &sets[picked as usize]);
if similarity > max_similarity {
max_similarity = similarity;
}
}
let relevance = scores[candidate as usize];
let mmr_score = lambda_param * relevance - (1.0 - lambda_param) * max_similarity;
if mmr_score > best_score {
best_score = mmr_score;
best_idx = idx;
}
}
selected.push(remaining.remove(best_idx));
}
if selected.len() < limit {
selected.extend(remaining);
selected.truncate(limit);
}
Ok(Uint32Array::new(selected))
}
#[cfg(test)]
mod tests {
use super::{cosine_one, is_js_whitespace, jaccard_sorted, word_set};
#[test]
fn cosine_matches_reference_semantics() {
assert_eq!(cosine_one(&[], &[]), 0.0);
assert_eq!(cosine_one(&[1.0, 0.0], &[0.0, 0.0]), 0.0);
let same = cosine_one(&[1.0, 2.0, 3.0], &[1.0, 2.0, 3.0]);
assert!((same - 1.0).abs() < 1e-12);
// Non-finite entries are zeroed, mismatched lengths pad with zero.
let sim = cosine_one(&[f64::NAN, 1.0], &[0.5, 1.0, 2.0]);
let expect = 1.0 / (1.0f64.sqrt() * (0.25f64 + 1.0 + 4.0).sqrt());
assert!((sim - expect).abs() < 1e-12);
}
#[test]
fn word_set_matches_js_tokenizer() {
let set = word_set("Hello\u{00a0}WORLD hello\u{feff}world");
assert_eq!(set, vec![Box::from("hello"), Box::from("world")]);
assert!(word_set("").is_empty());
assert!(word_set(" \t\n").is_empty());
assert!(!is_js_whitespace('\u{200b}')); // ZWSP is not JS \s
}
#[test]
fn jaccard_matches_reference() {
let a = word_set("the quick brown fox");
let b = word_set("the lazy brown dog");
let sim = jaccard_sorted(&a, &b);
assert!((sim - 2.0 / 6.0).abs() < 1e-12);
assert_eq!(jaccard_sorted(&a, &word_set("")), 0.0);
}
}
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Changed
- Recall hot loops (exact vector-index search, SHMR similarity clustering, and default-similarity MMR rerank) now run on native batch kernels with one N-API crossing per operation. Wrapper-inclusive speedups at dim=384 on Apple M1 (see `bench/native-vectors.bench.json`): 1.6-1.8x top-K search, 2.4x pairwise clustering scans at the measured 100- and 1000-candidate points (0.86x at the measured 10-candidate point, where the whole scan costs ~20µs either way), and 22-36x MMR rerank. Binary vector search stays on the TypeScript loops (per-call packing measured as a wash), as do custom `similarityFn` MMR reranks and incremental per-row scoring ([#6280](https://github.com/can1357/oh-my-pi/pull/6280) by [@wolfiesch](https://github.com/wolfiesch)).
## [17.0.4] - 2026-07-18
### Fixed
@@ -0,0 +1,100 @@
{
"sha": "8047bedaa46276710c9544d08f12bb13996da858",
"date": "2026-07-22T12:17:28.977Z",
"scenario": "dim=384, stride=48B, warmup=20, iterations=200 (adaptive for O(n²) rows, see per-row fields), crossing-inclusive",
"runtime": "bun 1.3.14",
"host": "Apple M1, darwin-arm64",
"rows": [
{
"kernel": "searchExactVectorIndex (topK wrapper)",
"count": 10,
"ts_us": 17.34,
"native_us": 9.49,
"speedup": 1.83,
"ts_iterations": 200,
"native_iterations": 200
},
{
"kernel": "searchExactVectorIndex (topK wrapper)",
"count": 100,
"ts_us": 56,
"native_us": 34.65,
"speedup": 1.62,
"ts_iterations": 200,
"native_iterations": 200
},
{
"kernel": "searchExactVectorIndex (topK wrapper)",
"count": 1000,
"ts_us": 508.91,
"native_us": 300.93,
"speedup": 1.69,
"ts_iterations": 200,
"native_iterations": 200
},
{
"kernel": "searchExactVectorIndex (topK wrapper)",
"count": 10000,
"ts_us": 5562.41,
"native_us": 3437.82,
"speedup": 1.62,
"ts_iterations": 200,
"native_iterations": 200
},
{
"kernel": "cosineSimilarityPairs (incl. flatten+adjacency)",
"count": 10,
"ts_us": 26.42,
"native_us": 30.67,
"speedup": 0.86,
"ts_iterations": 200,
"native_iterations": 200
},
{
"kernel": "cosineSimilarityPairs (incl. flatten+adjacency)",
"count": 100,
"ts_us": 4418.75,
"native_us": 1866.25,
"speedup": 2.37,
"ts_iterations": 200,
"native_iterations": 200
},
{
"kernel": "cosineSimilarityPairs (incl. flatten+adjacency)",
"count": 1000,
"ts_us": 445625.44,
"native_us": 179685.28,
"speedup": 2.48,
"ts_iterations": 10,
"native_iterations": 10
},
{
"kernel": "mmrRerankIndices (via mmrRerank)",
"count": 10,
"ts_us": 395.5,
"native_us": 17.62,
"speedup": 22.44,
"ts_iterations": 200,
"native_iterations": 200
},
{
"kernel": "mmrRerankIndices (via mmrRerank)",
"count": 100,
"ts_us": 10439.19,
"native_us": 287.99,
"speedup": 36.25,
"ts_iterations": 200,
"native_iterations": 200
},
{
"kernel": "mmrRerankIndices (via mmrRerank)",
"count": 1000,
"ts_us": 103521.56,
"native_us": 3430.07,
"speedup": 30.18,
"ts_iterations": 200,
"native_iterations": 200
}
],
"sink": 15782.734317403312
}
@@ -0,0 +1,195 @@
/**
* Crossing-inclusive benchmark of native batch vector kernels vs the TS
* reference loops, at realistic mnemopi recall shapes (fastembed
* bge-small-en-v1.5, dim=384; binarized stride=48 bytes).
*
* Run from the repo root: `bun packages/mnemopi/bench/native-vectors.bench.ts`
*/
import * as os from "node:os";
import {
cosineSimilarityPairs,
} from "@oh-my-pi/pi-natives";
import { jaccardSimilarity, mmrRerank } from "../src/core/mmr";
import { searchExactVectorIndex } from "../src/core/vector-index";
import { cosineSimilarity } from "../src/core/vector-math";
const DIM = 384;
const STRIDE = DIM / 8;
const COUNTS = [10, 100, 1000, 10000];
const WARMUP = 20;
const ITERATIONS = 200;
function makeRng(seed: number): () => number {
let state = seed >>> 0;
return () => {
state = (state * 1664525 + 1013904223) >>> 0;
return state / 4294967296;
};
}
let sink = 0;
function timeNs(fn: () => void, iterations = ITERATIONS, warmup = WARMUP): { ns: number; iterations: number } {
for (let i = 0; i < warmup; i += 1) fn();
const start = Bun.nanoseconds();
for (let i = 0; i < iterations; i += 1) fn();
return { ns: (Bun.nanoseconds() - start) / iterations, iterations };
}
interface Row {
kernel: string;
count: number;
tsNs: number;
nativeNs: number;
speedup: number;
tsIterations: number;
nativeIterations: number;
}
function pushRow(kernel: string, count: number, ts: { ns: number; iterations: number }, native: { ns: number; iterations: number }): void {
rows.push({
kernel,
count,
tsNs: ts.ns,
nativeNs: native.ns,
speedup: ts.ns / native.ns,
tsIterations: ts.iterations,
nativeIterations: native.iterations,
});
}
const rows: Row[] = [];
const rng = makeRng(0xbe4c4);
// vectorIndexTopK is measured through the public wrapper searchExactVectorIndex,
// so the native side pays the production per-call query conversion, guards, and
// hit-array construction. The TS side replicates the pre-native wrapper body.
for (const count of COUNTS) {
const matrix = new Float32Array(count * DIM);
for (let i = 0; i < matrix.length; i += 1) matrix[i] = rng() * 2 - 1;
const queryArr: number[] = Array.from({ length: DIM }, () => rng() * 2 - 1);
const ids: number[] = Array.from({ length: count }, (_v, i) => i);
const index = { ids, matrix, dimensions: DIM, count };
const limit = 10;
const ts = timeNs(() => {
let queryNormSq = 0;
for (const value of queryArr) queryNormSq += value * value;
const queryNorm = Math.sqrt(queryNormSq);
const hits: Array<{ id: number; score: number }> = [];
for (let row = 0; row < count; row += 1) {
const offset = row * DIM;
let score = 0;
for (let col = 0; col < DIM; col += 1) {
score += (matrix[offset + col] ?? 0) * ((queryArr[col] ?? 0) / queryNorm);
}
hits.push({ id: ids[row] ?? 0, score });
}
hits.sort((a, b) => b.score - a.score);
sink += hits.slice(0, limit)[0]?.score ?? 0;
});
const native = timeNs(() => {
sink += searchExactVectorIndex(index, queryArr, limit)[0]?.score ?? 0;
});
pushRow("searchExactVectorIndex (topK wrapper)", count, ts, native);
}
// cosineSimilarityPairs: O(n²) pair scan. The TS baseline is the pre-native
// clusterBySimilarity loop over per-item vectors; the native side pays the
// production flatten (dim scan + Float64Array fill) before crossing. Capped at
// 1k candidates with adaptive iterations (the TS side at n=1000 is ~500k pair
// cosines × 384 dims per run).
for (const count of COUNTS.filter(n => n <= 1000)) {
const vectors: number[][] = Array.from({ length: count }, () => Array.from({ length: DIM }, () => rng() * 2 - 1));
const threshold = 0.15;
const pairIterations = count >= 1000 ? 10 : ITERATIONS;
const pairWarmup = count >= 1000 ? 2 : WARMUP;
const ts = timeNs(
() => {
// Pre-native clusterBySimilarity: adjacency unions built inline
// during the pair scan.
const adjacency: number[][] = Array.from({ length: count }, () => []);
for (let i = 0; i < count; i += 1) {
const a = vectors[i] ?? [];
for (let j = i + 1; j < count; j += 1) {
if (cosineSimilarity(a, vectors[j] ?? []) >= threshold) {
adjacency[i]?.push(j);
adjacency[j]?.push(i);
}
}
}
sink += adjacency[0]?.length ?? 0;
},
pairIterations,
pairWarmup,
);
const native = timeNs(() => {
// Current clusterBySimilarity: flatten, one crossing, then adjacency
// built from the materialized pair list.
let dim = 0;
for (const vector of vectors) if (vector.length > dim) dim = vector.length;
const flat = new Float64Array(vectors.length * dim);
for (let i = 0; i < vectors.length; i += 1) {
const vector = vectors[i];
if (vector === undefined) continue;
for (let col = 0; col < vector.length; col += 1) flat[i * dim + col] = vector[col] ?? 0;
}
const pairs = cosineSimilarityPairs(flat, vectors.length, dim, threshold);
const adjacency: number[][] = Array.from({ length: count }, () => []);
for (let p = 0; p < pairs.length; p += 2) {
adjacency[pairs[p] ?? 0]?.push(pairs[p + 1] ?? 0);
adjacency[pairs[p + 1] ?? 0]?.push(pairs[p] ?? 0);
}
sink += adjacency[0]?.length ?? 0;
}, pairIterations, pairWarmup);
pushRow("cosineSimilarityPairs (incl. flatten+adjacency)", count, ts, native);
}
// mmrRerank production paths: the TS side wraps jaccardSimilarity in a lambda,
// defeating the identity check so the exact pre-native selection loop runs; the
// native side calls mmrRerank with the default similarity, exercising the real
// fast path including its sort and wrapper overhead.
const tsJaccard = (a: string, b: string): number => jaccardSimilarity(a, b);
for (const count of COUNTS.filter(n => n <= 1000)) {
const words = ["alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta", "theta", "iota", "kappa"];
const results: Array<{ content: string; score: number }> = [];
for (let i = 0; i < count; i += 1) {
const n = 5 + Math.floor(rng() * 20);
results.push({
content: Array.from({ length: n }, () => words[Math.floor(rng() * words.length)]).join(" "),
score: rng(),
});
}
const topK = 10;
const ts = timeNs(() => {
sink += mmrRerank(results, 0.7, topK, tsJaccard).length;
});
const native = timeNs(() => {
sink += mmrRerank(results, 0.7, topK).length;
});
pushRow("mmrRerankIndices (via mmrRerank)", count, ts, native);
}
const sha = Bun.env.BENCH_SHA ?? Bun.spawnSync(["git", "rev-parse", "HEAD"]).stdout.toString().trim();
const report = {
sha,
date: new Date().toISOString(),
scenario: `dim=${DIM}, stride=${STRIDE}B, warmup=${WARMUP}, iterations=${ITERATIONS} (adaptive for O(n²) rows, see per-row fields), crossing-inclusive`,
runtime: `bun ${Bun.version}`,
host: `${os.cpus()[0]?.model ?? "unknown"}, ${os.platform()}-${os.arch()}`,
rows: rows.map(r => ({
kernel: r.kernel,
count: r.count,
ts_us: +(r.tsNs / 1000).toFixed(2),
native_us: +(r.nativeNs / 1000).toFixed(2),
speedup: +r.speedup.toFixed(2),
ts_iterations: r.tsIterations,
native_iterations: r.nativeIterations,
})),
sink,
};
console.log(JSON.stringify(report, null, 2));
+1
View File
@@ -41,6 +41,7 @@
"dependencies": {
"@oh-my-pi/pi-ai": "catalog:",
"@oh-my-pi/pi-catalog": "catalog:",
"@oh-my-pi/pi-natives": "catalog:",
"@oh-my-pi/pi-utils": "catalog:",
"lru-cache": "catalog:"
},
+30
View File
@@ -1,3 +1,5 @@
import { mmrRerankIndices } from "@oh-my-pi/pi-natives";
export interface MmrResult {
readonly content?: string;
readonly score?: number;
@@ -33,6 +35,34 @@ export function mmrRerank<T extends MmrResult>(
const first = sortedResults[0];
if (first === undefined) return [];
// Native batch kernel: one N-API crossing selects all indices. Only valid
// for the default Jaccard similarity; custom similarity functions stay in
// TS. Lone-surrogate contents also stay in TS: N-API converts them to
// U+FFFD, which would merge distinct tokens. NaN topK stays in TS so the
// pre-native contract (loop guard is false, first result still returned)
// is preserved.
if (
similarityFn === jaccardSimilarity &&
!Number.isNaN(limit) &&
sortedResults.every(result => (result.content ?? "").isWellFormed())
) {
// Pre-lowercase with JS semantics so contextual mappings (Final_Sigma:
// "ΟΣ" -> "ος") are applied before the context-insensitive native
// lowercase, which is idempotent on already-lowercased text.
const contents = sortedResults.map(result => (result.content ?? "").toLowerCase());
const scores = Float64Array.from(sortedResults, result => result.score ?? 0);
// Clamp before the u32 N-API boundary: Infinity or >= 2**32 would
// otherwise wrap (ToUint32) and silently return nothing.
const nativeLimit = Math.min(limit, sortedResults.length);
const picked = mmrRerankIndices(contents, scores, lambdaParam, nativeLimit);
const out: T[] = [];
for (const index of picked) {
const item = sortedResults[index];
if (item !== undefined) out.push(item);
}
return out;
}
const selected: T[] = [first];
const remaining = sortedResults.slice(1);
+18 -11
View File
@@ -1,5 +1,6 @@
import type { Database } from "bun:sqlite";
import { createHash } from "node:crypto";
import { cosineSimilarityPairs } from "@oh-my-pi/pi-natives";
import { logger } from "@oh-my-pi/pi-utils";
import * as embeddings from "./embeddings";
import { cosineSimilarity } from "./vector-math";
@@ -166,17 +167,23 @@ export async function clusterBySimilarity(items: readonly ShmrItem[], threshold:
if (items.length === 0) return [];
const vectors = await resolveItemVectors(items);
const adjacency: number[][] = Array.from({ length: items.length }, () => []);
for (let i = 0; i < items.length; i++) {
const leftEmbedding = vectors[i];
if (leftEmbedding === undefined) continue;
for (let j = i + 1; j < items.length; j++) {
const rightEmbedding = vectors[j];
if (rightEmbedding === undefined) continue;
if (cosineSimilarity(leftEmbedding, rightEmbedding) >= threshold) {
adjacency[i]?.push(j);
adjacency[j]?.push(i);
}
}
// Native batch kernel: one N-API crossing evaluates all O(n^2) pairs.
// Vectors are zero-padded to a shared dim, matching the TS `?? 0`
// missing-element semantics of cosineSimilarity.
let dim = 0;
for (const vector of vectors) if (vector.length > dim) dim = vector.length;
const flat = new Float64Array(vectors.length * dim);
for (let i = 0; i < vectors.length; i++) {
const vector = vectors[i];
if (vector === undefined) continue;
for (let col = 0; col < vector.length; col++) flat[i * dim + col] = vector[col] ?? 0;
}
const pairs = cosineSimilarityPairs(flat, vectors.length, dim, threshold);
for (let p = 0; p < pairs.length; p += 2) {
const i = pairs[p] ?? 0;
const j = pairs[p + 1] ?? 0;
adjacency[i]?.push(j);
adjacency[j]?.push(i);
}
const visited = new Set<number>();
const clusters: ShmrItem[][] = [];
+12 -13
View File
@@ -1,3 +1,5 @@
import { vectorIndexTopK } from "@oh-my-pi/pi-natives";
export interface ExactVectorSearchHit<TId> {
id: TId;
score: number;
@@ -66,19 +68,16 @@ export function searchExactVectorIndex<TId>(
queryNormSq += value * value;
}
if (queryNormSq <= 0) return [];
const queryNorm = Math.sqrt(queryNormSq);
const queryDimensions = Math.min(query.length, index.dimensions);
// Native batch kernel: one N-API crossing scores every row and ranks the
// top k with the same stable ordering as the TS sort. TS guards above
// (finite query, positive norm, non-empty index) are preserved. Clamp k to
// the row count before the u32 boundary: Infinity or >= 2**32 would
// otherwise wrap (ToUint32) and return no hits.
const topK = vectorIndexTopK(index.matrix, index.dimensions, Float64Array.from(query), Math.min(k, index.count));
const hits: ExactVectorSearchHit<TId>[] = [];
for (let row = 0; row < index.count; row += 1) {
const offset = row * index.dimensions;
let score = 0;
for (let col = 0; col < queryDimensions; col += 1) {
score += index.matrix[offset + col] * ((query[col] ?? 0) / queryNorm);
}
hits.push({ id: index.ids[row] as TId, score });
for (let i = 0; i < topK.indices.length; i += 1) {
const row = topK.indices[i] ?? 0;
hits.push({ id: index.ids[row] as TId, score: topK.scores[i] ?? 0 });
}
hits.sort((a, b) => b.score - a.score);
return hits.slice(0, Math.min(k, hits.length));
return hits;
}
@@ -0,0 +1,175 @@
import { describe, expect, test } from "bun:test";
import { cosineSimilarityPairs, mmrRerankIndices, vectorIndexTopK } from "@oh-my-pi/pi-natives";
import { jaccardSimilarity, mmrRerank } from "../src/core/mmr";
import { buildExactVectorIndex, searchExactVectorIndex } from "../src/core/vector-index";
import { cosineSimilarity } from "../src/core/vector-math";
/** Deterministic LCG so parity failures reproduce exactly. */
function makeRng(seed: number): () => number {
let state = seed >>> 0;
return () => {
state = (state * 1664525 + 1013904223) >>> 0;
return state / 4294967296;
};
}
const REL_TOL = 1e-9;
function expectClose(actual: number, expected: number): void {
if (expected === 0) {
expect(actual).toBe(0);
return;
}
expect(Math.abs(actual - expected) / Math.abs(expected)).toBeLessThanOrEqual(REL_TOL);
}
describe("native vector kernel parity", () => {
test("cosineSimilarityPairs matches the TS pairwise threshold loop", () => {
const rng = makeRng(0x9a175);
const dim = 64;
const count = 40;
const flat = new Float64Array(count * dim);
for (let i = 0; i < flat.length; i += 1) flat[i] = rng() * 2 - 1;
const threshold = 0.02;
const expected: number[] = [];
for (let i = 0; i < count; i += 1) {
for (let j = i + 1; j < count; j += 1) {
if (
cosineSimilarity(flat.subarray(i * dim, (i + 1) * dim), flat.subarray(j * dim, (j + 1) * dim)) >=
threshold
) {
expected.push(i, j);
}
}
}
expect(Array.from(cosineSimilarityPairs(flat, count, dim, threshold))).toEqual(expected);
});
test("vectorIndexTopK matches the TS scoring loop and stable sort", () => {
const rng = makeRng(0x70b1);
const dims = 384;
const count = 300;
const matrix = new Float32Array(count * dims);
for (let i = 0; i < matrix.length; i += 1) matrix[i] = rng() * 2 - 1;
const query = Float64Array.from({ length: dims }, () => rng() * 2 - 1);
let normSq = 0;
for (const v of query) normSq += v * v;
const norm = Math.sqrt(normSq);
const hits: Array<{ row: number; score: number }> = [];
for (let row = 0; row < count; row += 1) {
let score = 0;
for (let col = 0; col < dims; col += 1) {
score += (matrix[row * dims + col] ?? 0) * ((query[col] ?? 0) / norm);
}
hits.push({ row, score });
}
hits.sort((a, b) => b.score - a.score);
const limit = 25;
const result = vectorIndexTopK(matrix, dims, query, limit);
expect(Array.from(result.indices)).toEqual(hits.slice(0, limit).map(h => h.row));
for (let i = 0; i < limit; i += 1) {
expectClose(result.scores[i] ?? Number.NaN, hits[i]?.score ?? Number.NaN);
}
});
test("mmrRerankIndices selects identical index sequences to the TS loop", () => {
const rng = makeRng(0x33a11);
const words = ["alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta", "theta", "iota", "kappa"];
const count = 60;
const contents: string[] = [];
const scores = new Float64Array(count);
for (let i = 0; i < count; i += 1) {
const n = 1 + Math.floor(rng() * 8);
contents.push(Array.from({ length: n }, () => words[Math.floor(rng() * words.length)]).join(" "));
scores[i] = rng();
}
scores[5] = scores[6] = 0.5; // exercise strict-> tie keeping the earlier candidate
for (const lambda of [0.0, 0.3, 0.7, 1.0]) {
for (const topK of [1, 10, count, count + 5]) {
// TS reference selection over pre-sorted candidates (mirrors mmrRerank
// after its sort step).
const order = contents.map((_, i) => i).sort((a, b) => (scores[b] ?? 0) - (scores[a] ?? 0));
const sortedContents = order.map(i => contents[i] ?? "");
const sortedScores = order.map(i => scores[i] ?? 0);
const selected: number[] = [0];
const remaining = sortedContents.map((_, i) => i).slice(1);
while (remaining.length > 0 && selected.length < topK) {
let bestIdx = 0;
let bestScore = Number.NEGATIVE_INFINITY;
for (let idx = 0; idx < remaining.length; idx += 1) {
const candidate = remaining[idx] ?? 0;
let maxSimilarity = 0;
for (const picked of selected) {
const sim = jaccardSimilarity(sortedContents[candidate] ?? "", sortedContents[picked] ?? "");
if (sim > maxSimilarity) maxSimilarity = sim;
}
const mmrScore = lambda * (sortedScores[candidate] ?? 0) - (1 - lambda) * maxSimilarity;
if (mmrScore > bestScore) {
bestScore = mmrScore;
bestIdx = idx;
}
}
selected.push(remaining.splice(bestIdx, 1)[0] ?? 0);
}
if (selected.length < topK) selected.push(...remaining.slice(0, topK - selected.length));
const native = mmrRerankIndices(sortedContents, Float64Array.from(sortedScores), lambda, topK);
expect(Array.from(native)).toEqual(selected.slice(0, topK));
}
}
});
test("mmrRerank wrapper preserves the pre-native limit contract at u32 boundaries", () => {
const results = Array.from({ length: 8 }, (_v, i) => ({
content: `item ${i} alpha beta`,
score: (8 - i) / 10,
}));
const all = mmrRerank(results, 0.7, results.length);
// Infinity and >= 2**32 previously walked every candidate; ToUint32
// would have collapsed them to 0/1 without the TS-side clamp.
expect(mmrRerank(results, 0.7, Number.POSITIVE_INFINITY)).toEqual(all);
expect(mmrRerank(results, 0.7, 2 ** 32)).toEqual(all);
expect(mmrRerank(results, 0.7, 2 ** 32 + 1)).toEqual(all);
// NaN: loop guard is false but the first result is already selected.
expect(mmrRerank(results, 0.7, Number.NaN)).toEqual([results[0] as (typeof results)[number]]);
expect(mmrRerank(results, 0.7, 0)).toEqual([]);
expect(mmrRerank(results, 0.7, -3)).toEqual([]);
});
test("mmrRerank matches the TS path on contextual-lowercase and lone-surrogate content", () => {
// Force the TS selection loop by defeating the identity check.
const tsJaccard = (a: string, b: string): number => jaccardSimilarity(a, b);
// Final_Sigma: JS lowercases "ΟΣ" to "ος"; a context-insensitive
// lowercase would produce "οσ" and score these words as distinct.
const sigma = [
{ content: "ΟΣ", score: 0.9 },
{ content: "ος", score: 0.8 },
{ content: "other words entirely", score: 0.7 },
];
for (const lambda of [0, 0.3, 0.7]) {
expect(mmrRerank(sigma, lambda, 2)).toEqual(mmrRerank(sigma, lambda, 2, tsJaccard));
}
// Lone surrogates route to the TS path: N-API would convert them to
// U+FFFD and merge the first two tokens.
const surrogate = [
{ content: "\ud800", score: 0.9 },
{ content: "\ufffd", score: 0.8 },
{ content: "other", score: 0.7 },
];
expect(mmrRerank(surrogate, 0, 2)).toEqual(mmrRerank(surrogate, 0, 2, tsJaccard));
});
test("searchExactVectorIndex preserves the pre-native limit contract at u32 boundaries", () => {
const rows = Array.from({ length: 6 }, (_v, i) => ({
id: i,
vector: Array.from({ length: 8 }, (_x, j) => Math.sin(i * 8 + j)),
}));
const index = buildExactVectorIndex(rows);
const query = Array.from({ length: 8 }, (_x, j) => Math.cos(j));
const all = searchExactVectorIndex(index, query, index.count);
expect(all.length).toBe(index.count);
expect(searchExactVectorIndex(index, query, Number.POSITIVE_INFINITY)).toEqual(all);
expect(searchExactVectorIndex(index, query, 2 ** 32)).toEqual(all);
expect(searchExactVectorIndex(index, query, Number.NaN)).toEqual([]);
expect(searchExactVectorIndex(index, query, 0)).toEqual([]);
});
});
+3
View File
@@ -8,6 +8,9 @@
### Added
- Added jsdiff-compatible native diff exports: `diffLines`, `diffWords`, `diffLineRuns`, and `structuredPatchHunks` ([#6279](https://github.com/can1357/oh-my-pi/pull/6279) by [@wolfiesch](https://github.com/wolfiesch)).
### Added
- Added batch vector kernels for mnemopi recall paths: `cosineSimilarityPairs`, `vectorIndexTopK`, and `mmrRerankIndices` ([#6280](https://github.com/can1357/oh-my-pi/pull/6280) by [@wolfiesch](https://github.com/wolfiesch)).
## [17.0.5] - 2026-07-18
+58
View File
@@ -470,6 +470,17 @@ export interface ContextLine {
*/
export declare function copyToClipboard(text: string): void
/**
* All pairs `(i, j)` with `i < j` whose cosine similarity meets `threshold`.
*
* `vectors` is `count` vectors flattened row-major at `dim` `f64` elements
* per row (zero-padded, which matches the TS `?? 0` missing-element
* semantics), so the similarity is bit-identical to the TS pairwise loop in
* `clusterBySimilarity`. Returns pairs flattened as `[i0, j0, i1, j1, ...]`
* in the same `(i, j)` visit order as the TS nested loop.
*/
export declare function cosineSimilarityPairs(vectors: Float64Array, count: number, dim: number, threshold: number): Uint32Array
/**
* Count tokens in `input`.
*
@@ -1250,6 +1261,28 @@ export interface MinimizerResult {
outputBytes: number
}
/**
* MMR selection over pre-sorted candidates using Jaccard word similarity.
*
* `contents[i]` and `scores[i]` describe candidate `i`, already sorted by
* relevance exactly as the TS `mmrRerank` sorts them (the JS stable sort
* stays on the TS side so its tie and NaN semantics are preserved).
* Replicates the TS selection loop exactly: candidate `0` is always taken
* first; each round picks the remaining candidate maximizing
* `lambda * score - (1 - lambda) * maxSimilarity(selected)` with strict
* `>` comparisons, so ties keep the earliest remaining candidate — and a
* round where every score is `NaN` picks the first remaining candidate,
* matching the TS `bestIdx = 0` initialisation. Returns the selected
* indices into the input order.
*
* Word tokenization matches `text.toLowerCase().split(/\s+/)` (ECMA `\s`,
* Unicode default full case conversion). Known divergence: unpaired
* surrogates arrive here as U+FFFD, while JS keeps the lone surrogate; both
* tokenize to a single non-whitespace word so Jaccard counts still agree
* unless a text mixes U+FFFD words with lone-surrogate words.
*/
export declare function mmrRerankIndices(contents: Array<string>, scores: Float64Array, lambdaParam: number, topK: number): Uint32Array
/** Parsed Kitty keyboard protocol sequence result for a Kitty input sequence. */
export interface ParsedKittyResult {
/** Primary codepoint associated with the key. */
@@ -1668,6 +1701,31 @@ export declare function supportsLanguage(lang: string): boolean
*/
export declare function truncateToWidth(text: string, maxWidth: number, ellipsisKind: Ellipsis | undefined | null, pad: boolean | undefined | null, tabWidth: number): string
/**
* Score every row of a normalized `f32` matrix against `query` and return
* the top `limit` rows.
*
* Mirrors the TS `searchExactVectorIndex` loop bit-exactly: the query is
* normalized by the L2 norm of its *full* length, each row score sums
* `matrix[row][col] * (query[col] / norm)` over
* `min(query.len, dimensions)` columns in column order. Ranking matches the
* TS stable sort: score descending, lower row index first on exact ties
* (`-0.0` and `+0.0` compare equal). Callers are expected to enforce the TS
* guards first (finite query with a positive norm, non-empty matrix).
*/
export declare function vectorIndexTopK(matrix: Float32Array, dimensions: number, query: Float64Array, limit: number): VectorTopK
/**
* Top-k rows of a normalized vector matrix ranked by dot product with a
* normalized query.
*/
export interface VectorTopK {
/** Row indices of the selected hits, best score first. */
indices: Uint32Array
/** Scores aligned with `indices`. */
scores: Float64Array
}
/**
* Calculate visible width of text, excluding ANSI escape sequences.
*
+3
View File
@@ -30,6 +30,7 @@ export const astGrep = nativeBindings.astGrep;
export const astMatch = nativeBindings.astMatch;
export const blockRangeAt = nativeBindings.blockRangeAt;
export const copyToClipboard = nativeBindings.copyToClipboard;
export const cosineSimilarityPairs = nativeBindings.cosineSimilarityPairs;
export const countTokens = nativeBindings.countTokens;
export const detectMacOSAppearance = nativeBindings.detectMacOSAppearance;
export const diffLineRuns = nativeBindings.diffLineRuns;
@@ -59,6 +60,7 @@ export const listWorkspace = nativeBindings.listWorkspace;
export const matchesKey = nativeBindings.matchesKey;
export const matchesKittySequence = nativeBindings.matchesKittySequence;
export const matchesLegacySequence = nativeBindings.matchesLegacySequence;
export const mmrRerankIndices = nativeBindings.mmrRerankIndices;
export const parseKey = nativeBindings.parseKey;
export const parseKittySequence = nativeBindings.parseKittySequence;
export const readImageFromClipboard = nativeBindings.readImageFromClipboard;
@@ -71,6 +73,7 @@ export const structuredPatchHunks = nativeBindings.structuredPatchHunks;
export const summarizeCode = nativeBindings.summarizeCode;
export const supportsLanguage = nativeBindings.supportsLanguage;
export const truncateToWidth = nativeBindings.truncateToWidth;
export const vectorIndexTopK = nativeBindings.vectorIndexTopK;
export const visibleWidth = nativeBindings.visibleWidth;
export const wrapTextWithAnsi = nativeBindings.wrapTextWithAnsi;