🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@qvac/vla-ggml

Package Overview
Dependencies
Maintainers
2
Versions
22
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@qvac/vla-ggml - npm Package Compare versions

Comparing version
0.15.0
to
0.16.1
+42
addon.d.ts
export declare const DEFAULT_IMAGE_SIZE = 512;
export interface PreprocessImageOptions {
size?: number;
layout?: "hwc" | "chw";
/**
* Pixel-range hint that skips the [0,255] vs [0,1] auto-detection
* heuristic. Pass `1` if pixels are already in [0,1] (no rescale),
* pass `1/255` if pixels are in [0,255] (rescale to [0,1]). Anything
* else (including the literal `'auto'`) falls back to the heuristic.
* Typed as `number | 'auto'` because TS literal types can't represent
* `1/255` directly.
*/
scale?: number | "auto";
}
/**
* Resize + letterbox-pad an HWC or CHW image to `size × size`, then normalize
* from [0, 1] (or [0, 255]) to [-1, 1]. Output is a contiguous CHW Float32Array
* of length 3 * size * size — the format smolvla.cpp's SigLIP encoder expects.
*
* Matches the reference implementation in smolvla_ggml.py#preprocess_image:
* ratio = max(w/size, h/size), resize with bilinear, left/top pad with 0,
* scale to [-1, 1]. The pad region is filled with -1 (= 0 shifted into [-1, 1]).
*
* Bilinear resize, letterbox-pad and the [0,1]→[-1,1] shift run as a single
* pass over the output buffer; no `src` / `resized` intermediates are allocated
* (each call needs only the final 3*size*size Float32Array). Per-output-pixel
* coordinates are computed once and shared across the three channels.
*
* @param pixels - source pixels
* @param width - source width
* @param height - source height
* @param opts
* `scale` skips the [0,255] vs [0,1] heuristic when the caller knows the
* range (`1` = pixels already in [0,1], `1/255` = pixels in [0,255]).
*/
export declare function preprocessImage(pixels: Float32Array | Uint8Array | number[], width: number, height: number, opts?: PreprocessImageOptions): Float32Array;
/**
* Zero-pad a state vector to `targetDim`. Extra entries are zero-initialised;
* input longer than `targetDim` raises. Mirrors how smolvla.cpp expects the
* state tensor (`max_state_dim` = 32 by default).
*/
export declare function padState(state: ArrayLike<number>, targetDim?: number): Float32Array;
import QvacError = require("@qvac/error");
declare const QvacErrorBase: typeof QvacError.QvacErrorBase;
export declare class QvacErrorAddonVla extends QvacErrorBase {
}
export declare const ERR_CODES: Readonly<{
FAILED_TO_LOAD_WEIGHTS: 30001;
FAILED_TO_DESTROY: 30002;
MODEL_NOT_FOUND: 30003;
INVALID_CONFIG: 30004;
MISSING_REQUIRED_PARAMETER: 30005;
INVALID_INPUT: 30006;
JOB_ALREADY_RUNNING: 30007;
INSTANCE_NOT_INITIALIZED: 30008;
MODEL_UNLOADED: 30009;
INFERENCE_FAILED: 30010;
}>;
export {};
+104
-116

@@ -1,9 +0,10 @@

'use strict'
"use strict";
// Pure-JS helpers for SmolVLA preprocessing. No dependency on the native
// `.bare` addon, so CI's `ts-checks` job (which runs `test:unit --if-present`
// without a native build) can exercise these without ADDON_NOT_FOUND failures.
const DEFAULT_IMAGE_SIZE = 512
Object.defineProperty(exports, "__esModule", { value: true });
exports.DEFAULT_IMAGE_SIZE = void 0;
exports.preprocessImage = preprocessImage;
exports.padState = padState;
exports.DEFAULT_IMAGE_SIZE = 512;
/**

@@ -23,95 +24,86 @@ * Resize + letterbox-pad an HWC or CHW image to `size × size`, then normalize

*
* @param {Float32Array|Uint8Array|number[]} pixels - source pixels
* @param {number} width - source width
* @param {number} height - source height
* @param {{ size?: number, layout?: 'hwc'|'chw', scale?: 1|(1/255)|'auto' }} [opts]
* @param pixels - source pixels
* @param width - source width
* @param height - source height
* @param opts
* `scale` skips the [0,255] vs [0,1] heuristic when the caller knows the
* range (`1` = pixels already in [0,1], `1/255` = pixels in [0,255]).
* @returns {Float32Array}
*/
function preprocessImage(pixels, width, height, opts = {}) {
const size = opts.size ?? DEFAULT_IMAGE_SIZE
const layout = opts.layout ?? 'hwc'
if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) {
throw new TypeError('preprocessImage: width/height must be positive integers')
}
const expected = width * height * 3
if (pixels.length !== expected) {
throw new RangeError(`preprocessImage: expected ${expected} pixel values, got ${pixels.length}`)
}
const normalize = opts.scale === 1 || opts.scale === 1 / 255 ? opts.scale : detectScale(pixels)
// Letterbox target size (aspect-ratio preserving).
const ratio = Math.max(width / size, height / size)
const newW = Math.max(1, Math.floor(width / ratio))
const newH = Math.max(1, Math.floor(height / ratio))
const padLeft = size - newW
const padTop = size - newH
const xScale = width / newW
const yScale = height / newH
// Output starts at -1 so the pad region is already in [-1, 1] and we only
// need to overwrite the (newH × newW) inner region with the resized content.
const out = new Float32Array(3 * size * size)
out.fill(-1)
const planeStride = size * size
const widthHeight = width * height
for (let yy = 0; yy < newH; yy++) {
const yIn = (yy + 0.5) * yScale - 0.5
const y0 = Math.max(0, Math.floor(yIn))
const y1 = Math.min(height - 1, y0 + 1)
const dy = Math.min(1, Math.max(0, yIn - y0))
const dyInv = 1 - dy
const outY = yy + padTop
for (let xx = 0; xx < newW; xx++) {
const xIn = (xx + 0.5) * xScale - 0.5
const x0 = Math.max(0, Math.floor(xIn))
const x1 = Math.min(width - 1, x0 + 1)
const dx = Math.min(1, Math.max(0, xIn - x0))
const dxInv = 1 - dx
const outX = xx + padLeft
const w00 = dxInv * dyInv
const w10 = dx * dyInv
const w01 = dxInv * dy
const w11 = dx * dy
const outIdx = outY * size + outX
if (layout === 'hwc') {
const i00 = (y0 * width + x0) * 3
const i10 = (y0 * width + x1) * 3
const i01 = (y1 * width + x0) * 3
const i11 = (y1 * width + x1) * 3
for (let c = 0; c < 3; c++) {
const v =
pixels[i00 + c] * w00 +
pixels[i10 + c] * w10 +
pixels[i01 + c] * w01 +
pixels[i11 + c] * w11
// Apply scale and shift into [-1, 1] in one fused multiply-add.
out[c * planeStride + outIdx] = v * normalize * 2 - 1
const size = opts.size ?? exports.DEFAULT_IMAGE_SIZE;
const layout = opts.layout ?? "hwc";
if (!Number.isInteger(width) ||
!Number.isInteger(height) ||
width <= 0 ||
height <= 0) {
throw new TypeError("preprocessImage: width/height must be positive integers");
}
const expected = width * height * 3;
if (pixels.length !== expected) {
throw new RangeError(`preprocessImage: expected ${expected} pixel values, got ${pixels.length}`);
}
const normalize = opts.scale === 1 || opts.scale === 1 / 255
? opts.scale
: detectScale(pixels);
// Letterbox target size (aspect-ratio preserving).
const ratio = Math.max(width / size, height / size);
const newW = Math.max(1, Math.floor(width / ratio));
const newH = Math.max(1, Math.floor(height / ratio));
const padLeft = size - newW;
const padTop = size - newH;
const xScale = width / newW;
const yScale = height / newH;
// Output starts at -1 so the pad region is already in [-1, 1] and we only
// need to overwrite the (newH × newW) inner region with the resized content.
const out = new Float32Array(3 * size * size);
out.fill(-1);
const planeStride = size * size;
const widthHeight = width * height;
for (let yy = 0; yy < newH; yy++) {
const yIn = (yy + 0.5) * yScale - 0.5;
const y0 = Math.max(0, Math.floor(yIn));
const y1 = Math.min(height - 1, y0 + 1);
const dy = Math.min(1, Math.max(0, yIn - y0));
const dyInv = 1 - dy;
const outY = yy + padTop;
for (let xx = 0; xx < newW; xx++) {
const xIn = (xx + 0.5) * xScale - 0.5;
const x0 = Math.max(0, Math.floor(xIn));
const x1 = Math.min(width - 1, x0 + 1);
const dx = Math.min(1, Math.max(0, xIn - x0));
const dxInv = 1 - dx;
const outX = xx + padLeft;
const w00 = dxInv * dyInv;
const w10 = dx * dyInv;
const w01 = dxInv * dy;
const w11 = dx * dy;
const outIdx = outY * size + outX;
if (layout === "hwc") {
const i00 = (y0 * width + x0) * 3;
const i10 = (y0 * width + x1) * 3;
const i01 = (y1 * width + x0) * 3;
const i11 = (y1 * width + x1) * 3;
for (let c = 0; c < 3; c++) {
const v = pixels[i00 + c] * w00 +
pixels[i10 + c] * w10 +
pixels[i01 + c] * w01 +
pixels[i11 + c] * w11;
// Apply scale and shift into [-1, 1] in one fused multiply-add.
out[c * planeStride + outIdx] = v * normalize * 2 - 1;
}
}
else {
for (let c = 0; c < 3; c++) {
const plane = c * widthHeight;
const v = pixels[plane + y0 * width + x0] * w00 +
pixels[plane + y0 * width + x1] * w10 +
pixels[plane + y1 * width + x0] * w01 +
pixels[plane + y1 * width + x1] * w11;
out[c * planeStride + outIdx] = v * normalize * 2 - 1;
}
}
}
} else {
for (let c = 0; c < 3; c++) {
const plane = c * widthHeight
const v =
pixels[plane + y0 * width + x0] * w00 +
pixels[plane + y0 * width + x1] * w10 +
pixels[plane + y1 * width + x0] * w01 +
pixels[plane + y1 * width + x1] * w11
out[c * planeStride + outIdx] = v * normalize * 2 - 1
}
}
}
}
return out
return out;
}
/**

@@ -121,31 +113,27 @@ * Zero-pad a state vector to `targetDim`. Extra entries are zero-initialised;

* state tensor (`max_state_dim` = 32 by default).
*
* @param {ArrayLike<number>} state
* @param {number} targetDim
* @returns {Float32Array}
*/
function padState(state, targetDim = 32) {
if (!Number.isInteger(targetDim) || targetDim <= 0) {
throw new TypeError('padState: targetDim must be a positive integer')
}
if (state.length > targetDim) {
throw new RangeError(`padState: input length ${state.length} exceeds targetDim ${targetDim}`)
}
const out = new Float32Array(targetDim)
for (let i = 0; i < state.length; i++) out[i] = state[i]
return out
if (!Number.isInteger(targetDim) || targetDim <= 0) {
throw new TypeError("padState: targetDim must be a positive integer");
}
if (state.length > targetDim) {
throw new RangeError(`padState: input length ${state.length} exceeds targetDim ${targetDim}`);
}
const out = new Float32Array(targetDim);
for (let i = 0; i < state.length; i++)
out[i] = state[i];
return out;
}
function detectScale(pixels) {
if (pixels instanceof Uint8Array) return 1 / 255
// Float/Number arrays: scan a small window to decide whether it's [0,255] or [0,1].
const limit = Math.min(pixels.length, 256)
let maxVal = 0
for (let i = 0; i < limit; i++) {
const v = pixels[i]
if (v > maxVal) maxVal = v
}
return maxVal > 1.001 ? 1 / 255 : 1
if (pixels instanceof Uint8Array)
return 1 / 255;
// Float/Number arrays: scan a small window to decide whether it's [0,255] or [0,1].
const limit = Math.min(pixels.length, 256);
let maxVal = 0;
for (let i = 0; i < limit; i++) {
const v = pixels[i];
if (v > maxVal)
maxVal = v;
}
return maxVal > 1.001 ? 1 / 255 : 1;
}
module.exports = { preprocessImage, padState, DEFAULT_IMAGE_SIZE }
+142
-124

@@ -1,128 +0,146 @@

export const DEFAULT_IMAGE_SIZE: number
export class QvacErrorAddonVla extends Error {
code: number
import QvacLogger = require("@qvac/logging");
import addonModule = require("./addon");
import errorModule = require("./lib/error");
interface VlaConfig {
verbosity?: number;
backendsDir?: string;
[key: string]: unknown;
}
export const ERR_CODES: Readonly<{
FAILED_TO_LOAD_WEIGHTS: 30001
FAILED_TO_DESTROY: 30002
MODEL_NOT_FOUND: 30003
INVALID_CONFIG: 30004
MISSING_REQUIRED_PARAMETER: 30005
INVALID_INPUT: 30006
JOB_ALREADY_RUNNING: 30007
INSTANCE_NOT_INITIALIZED: 30008
MODEL_UNLOADED: 30009
INFERENCE_FAILED: 30010
}>
export interface VlaHparams {
chunkSize: number
actionDim: number
maxActionDim: number
maxStateDim: number
tokenizerMaxLength: number
visionImageSize: number
/**
* Number of camera views the model accepts. 2 for SmolVLA, up to 3 for
* π₀.₅. Optional for back-compat — older addon builds may omit it.
*/
numCameras?: number
/**
* How the consumer passes the robot state. `'continuous'` (SmolVLA) means
* the `state` Float32Array is projected by an in-model linear layer;
* `'discrete'` (π₀.₅) means the state is already tokenized into the
* language prompt and the `state` buffer is ignored. Optional for
* back-compat.
*/
stateInputMode?: 'continuous' | 'discrete'
interface VlaModelState {
configLoaded: boolean;
weightsLoaded: boolean;
}
export interface VlaRunInput {
images: Float32Array[]
imgWidth?: number
imgHeight?: number
state: Float32Array
tokens: Int32Array
mask: Uint8Array
noise?: Float32Array | null
declare class VlaModel {
readonly logger: QvacLogger;
opts: {
stats?: boolean;
};
state: VlaModelState;
private _files;
private _config;
private _job;
private _run;
private _handle;
private _hparams;
private _backendName;
private _hasActiveResponse;
private _nativeLoggerActive;
private _packageName;
private _packageVersion;
private _pending;
constructor(options: VlaModel.VlaModelOptions);
private _connectNativeLogger;
private _onAddonEvent;
private _releaseNativeLogger;
load({ backend }?: {
backend?: "auto" | "cpu";
}): Promise<void>;
private _load;
get hparams(): VlaModel.VlaHparams | null;
get backendName(): string | null;
run(input: VlaModel.VlaRunInput): Promise<VlaModel.QvacResponse>;
private _runInternal;
pause(): Promise<void>;
cancel(): Promise<void>;
unload(): Promise<void>;
getState(): VlaModelState;
}
export interface VlaRunStats {
vision_ms: number
/**
* SmolVLA-specific legacy alias for `prefill_compute_ms`. Kept for
* back-compat with consumers written against v0.1.x; will be removed once
* π₀.₅ ships and consumers migrate to the architecture-neutral names.
*/
smollm2_compute_ms: number
/** Legacy alias for `prefill_total_ms`; see `smollm2_compute_ms`. */
smollm2_total_ms: number
/** Architecture-neutral prefill compute time (ms). */
prefill_compute_ms: number
/** Architecture-neutral prefill total time (ms). */
prefill_total_ms: number
ode_ms: number
total_ms: number
/** 0 = CPU backend, 1 = GPU backend (Vulkan / Metal / OpenCL). */
backendDevice: number
import VlaModelClass = VlaModel;
/**
* Declaration merging with the class above models this package's CommonJS
* export shape — `module.exports` IS the `VlaModel` constructor, carrying the
* named exports as own properties — directly in the type system. TypeScript
* emits the property attachments natively, so the generated `index.js` and
* `index.d.ts` can no longer drift from each other, and a CommonJS consumer
* (`import VlaModel = require('@qvac/vla-ggml')`) gets a real construct
* signature instead of TS2351.
*/
declare namespace VlaModel {
export import VlaModel = VlaModelClass;
export import preprocessImage = addonModule.preprocessImage;
export import padState = addonModule.padState;
export import DEFAULT_IMAGE_SIZE = addonModule.DEFAULT_IMAGE_SIZE;
export import QvacErrorAddonVla = errorModule.QvacErrorAddonVla;
export import ERR_CODES = errorModule.ERR_CODES;
interface VlaHparams {
chunkSize: number;
actionDim: number;
maxActionDim: number;
maxStateDim: number;
tokenizerMaxLength: number;
visionImageSize: number;
/**
* Number of camera views the model accepts. 2 for SmolVLA, up to 3 for
* π₀.₅. Optional for back-compat — older addon builds may omit it.
*/
numCameras?: number;
/**
* How the consumer passes the robot state. `'continuous'` (SmolVLA) means
* the `state` Float32Array is projected by an in-model linear layer;
* `'discrete'` (π₀.₅) means the state is already tokenized into the
* language prompt and the `state` buffer is ignored. Optional for
* back-compat.
*/
stateInputMode?: "continuous" | "discrete";
/**
* How the consumer passes camera images. `'pixels'` (SmolVLA, π₀.₅) means
* each image is a `3 · w · h` float pixel plane; `'patches'` (GR00T) means
* each image is already patchified by Gr00tPolicy into a
* `patches · patch_flat` buffer. Optional for back-compat.
*/
imageInputMode?: "pixels" | "patches";
/**
* Exact per-image buffer length (in floats) required when
* `imageInputMode === 'patches'`. Optional for back-compat.
*/
imagePatchElems?: number;
}
interface VlaRunInput {
images: Float32Array[];
imgWidth?: number;
imgHeight?: number;
state: Float32Array;
tokens: Int32Array;
mask: Uint8Array;
noise?: Float32Array | null;
}
interface VlaRunStats {
vision_ms: number;
/**
* SmolVLA-specific legacy alias for `prefill_compute_ms`. Kept for
* back-compat with consumers written against v0.1.x; will be removed once
* π₀.₅ ships and consumers migrate to the architecture-neutral names.
*/
smollm2_compute_ms: number;
/** Legacy alias for `prefill_total_ms`; see `smollm2_compute_ms`. */
smollm2_total_ms: number;
/** Architecture-neutral prefill compute time (ms). */
prefill_compute_ms: number;
/** Architecture-neutral prefill total time (ms). */
prefill_total_ms: number;
ode_ms: number;
total_ms: number;
/** 0 = CPU backend, 1 = GPU backend (Vulkan / Metal / OpenCL). */
backendDevice: number;
}
interface VlaRunResult {
actions: Float32Array;
stats: VlaRunStats;
}
interface VlaModelOptions {
files: {
model: string[];
};
config?: VlaConfig;
logger?: QvacLogger.LoggerInterface | null;
opts?: {
stats?: boolean;
};
}
interface QvacResponse {
await(): Promise<VlaRunResult>;
cancel(): Promise<void>;
on(event: string, listener: (...args: unknown[]) => void): this;
}
}
export interface VlaRunResult {
actions: Float32Array
stats: VlaRunStats
}
export interface VlaModelOptions {
files: { model: string[] }
config?: Record<string, unknown>
logger?: unknown
opts?: { stats?: boolean }
}
export interface QvacResponse {
await(): Promise<VlaRunResult>
cancel(): Promise<void>
on(event: string, listener: (...args: unknown[]) => void): this
}
export class VlaModel {
constructor (options: VlaModelOptions)
readonly hparams: VlaHparams | null
/**
* Name of the ggml backend the loaded model is running on
* ("CPU" / "Vulkan" / "OpenCL" / "Metal"). `null` before `load()`.
*/
readonly backendName: string | null
load (opts?: { backend?: 'auto' | 'cpu' }): Promise<void>
run (input: VlaRunInput): Promise<QvacResponse>
pause (): Promise<void>
cancel (): Promise<void>
unload (): Promise<void>
getState (): { configLoaded: boolean; weightsLoaded: boolean }
}
export function preprocessImage (
pixels: Float32Array | Uint8Array | number[],
width: number,
height: number,
opts?: {
size?: number,
layout?: 'hwc' | 'chw',
/**
* Pixel-range hint that skips the [0,255] vs [0,1] auto-detection
* heuristic. Pass `1` if pixels are already in [0,1] (no rescale),
* pass `1/255` if pixels are in [0,255] (rescale to [0,1]). Anything
* else (including the literal `'auto'`) falls back to the heuristic.
* Typed as `number | 'auto'` because TS literal types can't represent
* `1/255` directly.
*/
scale?: number | 'auto'
}
): Float32Array
export function padState (state: ArrayLike<number>, targetDim?: number): Float32Array
declare const _default: typeof VlaModel
export default _default
export = VlaModel;
+540
-493

@@ -1,534 +0,581 @@

'use strict'
const fs = require('bare-fs')
const path = require('bare-path')
const QvacLogger = require('@qvac/logging')
const { createJobHandler, exclusiveRunQueue } = require('@qvac/infer-base')
const binding = require('./binding')
const { preprocessImage, padState, DEFAULT_IMAGE_SIZE } = require('./addon.js')
const { QvacErrorAddonVla, ERR_CODES } = require('./lib/error')
"use strict";
/* eslint-disable @typescript-eslint/no-require-imports -- Bare modules and @qvac/logging expose CommonJS export shapes. */
const fs = require("bare-fs");
const path = require("bare-path");
const QvacLogger = require("@qvac/logging");
// `./addon` and `./lib/error` are imported as whole-module aliases rather than
// named imports so the `VlaModel` namespace at the bottom of this file can
// re-export their members with `export import`. That is what makes the
// CommonJS "class object with attached properties" export shape part of the
// emitted declarations instead of an untyped `module.exports` cast.
const addonModule = require("./addon");
const errorModule = require("./lib/error");
/* eslint-enable @typescript-eslint/no-require-imports */
const infer_base_1 = require("@qvac/infer-base");
const { DEFAULT_IMAGE_SIZE } = addonModule;
const { QvacErrorAddonVla, ERR_CODES } = errorModule;
// eslint-disable-next-line @typescript-eslint/no-require-imports -- native binding is resolved lazily from package prebuilds.
const binding = require("./binding");
// Maps the C++ Priority enum (0=ERROR, 1=WARNING, 2=INFO, 3=DEBUG) to the
// matching method on the JS QvacLogger instance. Mirrors diffusion-cpp.
const LOG_METHODS = ['error', 'warn', 'info', 'debug']
const LOG_METHODS = ["error", "warn", "info", "debug"];
// Default verbosity sent to the C++ side when a logger is connected. Matches
// the JS-side QvacLogger default — INFO and above are forwarded, DEBUG drops
// unless explicitly raised.
const DEFAULT_NATIVE_VERBOSITY = 2 // INFO
const DEFAULT_NATIVE_VERBOSITY = 2; // INFO
function pickPrimaryGgufPath(files) {
const FIRST_SHARD_REGEX = /-0*1-of-\d+\.gguf$/
return files.find((p) => FIRST_SHARD_REGEX.test(p)) || files[0]
const FIRST_SHARD_REGEX = /-0*1-of-\d+\.gguf$/;
return files.find((p) => FIRST_SHARD_REGEX.test(p)) || files[0];
}
function validateRunInput(input, hparams) {
if (!input || typeof input !== 'object') {
throw new QvacErrorAddonVla({ code: ERR_CODES.INVALID_INPUT, adds: 'input must be an object' })
}
if (!Array.isArray(input.images) || input.images.length === 0) {
throw new QvacErrorAddonVla({
code: ERR_CODES.INVALID_INPUT,
adds: 'input.images must be a non-empty array of Float32Array'
})
}
const imgWidth = input.imgWidth ?? DEFAULT_IMAGE_SIZE
const imgHeight = input.imgHeight ?? DEFAULT_IMAGE_SIZE
// The C++ inference path requires img_width == img_height == hparams.visionImageSize
// (SigLIP's conv2d output is sized from runtime args, but the downstream
// patch-embedding reshape uses hp.vision_image_size — a mismatch trips
// GGML_ASSERT in ggml.c, which is a hard abort that kills the worker).
// Throw a clean QvacError here so the failure surfaces as a rejected
// run() promise instead of a process crash.
if (hparams && Number.isInteger(hparams.visionImageSize)) {
const expected = hparams.visionImageSize
if (imgWidth !== expected || imgHeight !== expected) {
throw new QvacErrorAddonVla({
code: ERR_CODES.INVALID_INPUT,
adds: `imgWidth/imgHeight (${imgWidth}x${imgHeight}) must equal hparams.visionImageSize (${expected})`
})
if (!input || typeof input !== "object") {
throw new QvacErrorAddonVla({
code: ERR_CODES.INVALID_INPUT,
adds: "input must be an object",
});
}
}
// Pixel-plane models (smolvla, pi05) take `3 · w · h` floats per camera.
// GR00T takes images already patchified by Gr00tPolicy — a
// `patches · patch_flat` buffer whose exact length the native side copies
// blindly (no source-length check before the memcpy), so it MUST be validated
// here. The length is fixed per model (imgWidth is pinned to visionImageSize),
// surfaced as `hparams.imagePatchElems`. `imageInputMode` is the
// distinguishing axis (both groot and smolvla are `continuous` state).
const imagesArePatches = hparams && hparams.imageInputMode === 'patches'
const patchElems = imagesArePatches ? hparams.imagePatchElems : 0
const patchElemsKnown = Number.isInteger(patchElems) && patchElems > 0
const expectedPerImage = 3 * imgWidth * imgHeight
for (let i = 0; i < input.images.length; i++) {
const img = input.images[i]
if (!(img instanceof Float32Array)) {
throw new QvacErrorAddonVla({
code: ERR_CODES.INVALID_INPUT,
adds: `input.images[${i}] must be a Float32Array`
})
}
if (imagesArePatches) {
// Exact length guards the native memcpy against an OOB read. The native
// side copies patchElems floats per image blindly (no source-length
// check), so the expected length MUST be known here; if the addon didn't
// surface imagePatchElems (version skew), fail closed rather than pass an
// unvalidated buffer to that memcpy.
if (!patchElemsKnown) {
if (!Array.isArray(input.images) || input.images.length === 0) {
throw new QvacErrorAddonVla({
code: ERR_CODES.INVALID_INPUT,
adds: `input.images[${i}] (patches): addon did not surface hparams.imagePatchElems, cannot validate patch buffer length`
})
}
if (img.length !== patchElems) {
throw new QvacErrorAddonVla({
code: ERR_CODES.INVALID_INPUT,
adds: `input.images[${i}] (patches) length ${img.length} != ${patchElems}`
})
}
} else if (img.length !== expectedPerImage) {
throw new QvacErrorAddonVla({
code: ERR_CODES.INVALID_INPUT,
adds: `input.images[${i}] length ${img.length} != 3*${imgWidth}*${imgHeight}`
})
code: ERR_CODES.INVALID_INPUT,
adds: "input.images must be a non-empty array of Float32Array",
});
}
}
if (!(input.state instanceof Float32Array)) {
throw new QvacErrorAddonVla({
code: ERR_CODES.INVALID_INPUT,
adds: 'input.state must be a Float32Array'
})
}
if (!(input.tokens instanceof Int32Array)) {
throw new QvacErrorAddonVla({
code: ERR_CODES.INVALID_INPUT,
adds: 'input.tokens must be an Int32Array'
})
}
if (!(input.mask instanceof Uint8Array)) {
throw new QvacErrorAddonVla({
code: ERR_CODES.INVALID_INPUT,
adds: 'input.mask must be a Uint8Array'
})
}
if (input.mask.length !== input.tokens.length) {
throw new QvacErrorAddonVla({
code: ERR_CODES.INVALID_INPUT,
adds: 'input.mask and input.tokens must have the same length'
})
}
if (input.noise !== undefined && input.noise !== null && !(input.noise instanceof Float32Array)) {
throw new QvacErrorAddonVla({
code: ERR_CODES.INVALID_INPUT,
adds: 'input.noise must be a Float32Array when provided'
})
}
if (hparams && hparams.stateInputMode === 'continuous' && Number.isInteger(hparams.maxStateDim)) {
if (input.state.length === 0 || input.state.length > hparams.maxStateDim) {
throw new QvacErrorAddonVla({
code: ERR_CODES.INVALID_INPUT,
adds: `state.length (${input.state.length}) must be > 0 and <= hparams.maxStateDim (${hparams.maxStateDim})`
})
const imgWidth = input.imgWidth ?? DEFAULT_IMAGE_SIZE;
const imgHeight = input.imgHeight ?? DEFAULT_IMAGE_SIZE;
// The C++ inference path requires img_width == img_height == hparams.visionImageSize
// (SigLIP's conv2d output is sized from runtime args, but the downstream
// patch-embedding reshape uses hp.vision_image_size — a mismatch trips
// GGML_ASSERT in ggml.c, which is a hard abort that kills the worker).
// Throw a clean QvacError here so the failure surfaces as a rejected
// run() promise instead of a process crash.
if (hparams && Number.isInteger(hparams.visionImageSize)) {
const expected = hparams.visionImageSize;
if (imgWidth !== expected || imgHeight !== expected) {
throw new QvacErrorAddonVla({
code: ERR_CODES.INVALID_INPUT,
adds: `imgWidth/imgHeight (${imgWidth}x${imgHeight}) must equal hparams.visionImageSize (${expected})`,
});
}
}
}
// GR00T (imageInputMode 'patches') is a continuous-state flow-matching model
// that does NOT sample noise internally — GrootModel::infer hard-rejects a
// null noise. The discrete branch below already enforces this for pi05; do the
// same for the continuous/patches path so a missing prior surfaces as a clean
// INVALID_INPUT rather than an opaque INFERENCE_FAILED from the worker.
if (hparams && hparams.imageInputMode === 'patches') {
// Fail closed on GR00T's fixed-shape embodiment contract before the noise
// checks. GrootModel::infer derives the image-placeholder count from
// nImages and accepts nImages >= 1, so a one-camera input against a
// two-camera checkpoint would silently produce actions for the wrong camera
// layout instead of a clean INVALID_INPUT. The shared continuous-state
// check above allows state.length <= maxStateDim; GR00T needs it exact.
if (Number.isInteger(hparams.numCameras) && input.images.length !== hparams.numCameras) {
throw new QvacErrorAddonVla({
code: ERR_CODES.INVALID_INPUT,
adds: `groot requires exactly ${hparams.numCameras} patch image buffers (got ${input.images.length})`
})
// Pixel-plane models (smolvla, pi05) take `3 · w · h` floats per camera.
// GR00T takes images already patchified by Gr00tPolicy — a
// `patches · patch_flat` buffer whose exact length the native side copies
// blindly (no source-length check before the memcpy), so it MUST be validated
// here. The length is fixed per model (imgWidth is pinned to visionImageSize),
// surfaced as `hparams.imagePatchElems`. `imageInputMode` is the
// distinguishing axis (both groot and smolvla are `continuous` state).
const imagesArePatches = hparams !== null && hparams.imageInputMode === "patches";
const patchElems = imagesArePatches ? (hparams.imagePatchElems ?? 0) : 0;
const patchElemsKnown = Number.isInteger(patchElems) && patchElems > 0;
const expectedPerImage = 3 * imgWidth * imgHeight;
for (let i = 0; i < input.images.length; i++) {
const img = input.images[i];
if (!(img instanceof Float32Array)) {
throw new QvacErrorAddonVla({
code: ERR_CODES.INVALID_INPUT,
adds: `input.images[${i}] must be a Float32Array`,
});
}
if (imagesArePatches) {
// Exact length guards the native memcpy against an OOB read. The native
// side copies patchElems floats per image blindly (no source-length
// check), so the expected length MUST be known here; if the addon didn't
// surface imagePatchElems (version skew), fail closed rather than pass an
// unvalidated buffer to that memcpy.
if (!patchElemsKnown) {
throw new QvacErrorAddonVla({
code: ERR_CODES.INVALID_INPUT,
adds: `input.images[${i}] (patches): addon did not surface hparams.imagePatchElems, cannot validate patch buffer length`,
});
}
if (img.length !== patchElems) {
throw new QvacErrorAddonVla({
code: ERR_CODES.INVALID_INPUT,
adds: `input.images[${i}] (patches) length ${img.length} != ${patchElems}`,
});
}
}
else if (img.length !== expectedPerImage) {
throw new QvacErrorAddonVla({
code: ERR_CODES.INVALID_INPUT,
adds: `input.images[${i}] length ${img.length} != 3*${imgWidth}*${imgHeight}`,
});
}
}
if (Number.isInteger(hparams.maxStateDim) && input.state.length !== hparams.maxStateDim) {
throw new QvacErrorAddonVla({
code: ERR_CODES.INVALID_INPUT,
adds: `groot requires state.length === ${hparams.maxStateDim} (got ${input.state.length})`
})
if (!(input.state instanceof Float32Array)) {
throw new QvacErrorAddonVla({
code: ERR_CODES.INVALID_INPUT,
adds: "input.state must be a Float32Array",
});
}
if (!input.noise || !(input.noise instanceof Float32Array) || input.noise.length === 0) {
throw new QvacErrorAddonVla({
code: ERR_CODES.INVALID_INPUT,
adds: 'groot requires input.noise (Float32Array) — flow matching needs a noise prior at t=1'
})
if (!(input.tokens instanceof Int32Array)) {
throw new QvacErrorAddonVla({
code: ERR_CODES.INVALID_INPUT,
adds: "input.tokens must be an Int32Array",
});
}
// Exact length guards the native memcpy against an OOB read: GrootModel::infer
// copies chunkSize*maxActionDim floats blindly from this buffer (no source-length
// check), so a short array reads adjacent heap memory into the action prior.
if (Number.isInteger(hparams.chunkSize) && Number.isInteger(hparams.maxActionDim)) {
const expectedNoise = hparams.chunkSize * hparams.maxActionDim
if (input.noise.length !== expectedNoise) {
if (!(input.mask instanceof Uint8Array)) {
throw new QvacErrorAddonVla({
code: ERR_CODES.INVALID_INPUT,
adds: `input.noise length ${input.noise.length} != ${expectedNoise} (chunkSize*maxActionDim)`
})
}
code: ERR_CODES.INVALID_INPUT,
adds: "input.mask must be a Uint8Array",
});
}
}
if (hparams && hparams.stateInputMode === 'discrete') {
if (Number.isInteger(hparams.numCameras) && input.images.length !== hparams.numCameras) {
throw new QvacErrorAddonVla({
code: ERR_CODES.INVALID_INPUT,
adds: `pi05 requires exactly ${hparams.numCameras} camera images (got ${input.images.length})`
})
if (input.mask.length !== input.tokens.length) {
throw new QvacErrorAddonVla({
code: ERR_CODES.INVALID_INPUT,
adds: "input.mask and input.tokens must have the same length",
});
}
if (
Number.isInteger(hparams.tokenizerMaxLength) &&
input.tokens.length !== hparams.tokenizerMaxLength
) {
throw new QvacErrorAddonVla({
code: ERR_CODES.INVALID_INPUT,
adds: `pi05 requires tokens.length === ${hparams.tokenizerMaxLength} (got ${input.tokens.length})`
})
if (input.noise !== undefined &&
input.noise !== null &&
!(input.noise instanceof Float32Array)) {
throw new QvacErrorAddonVla({
code: ERR_CODES.INVALID_INPUT,
adds: "input.noise must be a Float32Array when provided",
});
}
if (!input.noise || !(input.noise instanceof Float32Array) || input.noise.length === 0) {
throw new QvacErrorAddonVla({
code: ERR_CODES.INVALID_INPUT,
adds: 'pi05 requires input.noise (Float32Array) — flow matching needs a noise prior at t=1'
})
if (hparams &&
hparams.stateInputMode === "continuous" &&
Number.isInteger(hparams.maxStateDim)) {
if (input.state.length === 0 || input.state.length > hparams.maxStateDim) {
throw new QvacErrorAddonVla({
code: ERR_CODES.INVALID_INPUT,
adds: `state.length (${input.state.length}) must be > 0 and <= hparams.maxStateDim (${hparams.maxStateDim})`,
});
}
}
// Same native-memcpy OOB guard as the groot branch: pi05 xT is action_horizon *
// action_dim floats, surfaced as chunkSize * maxActionDim (max_action_dim maps
// to action_dim for pi05), copied blindly from this buffer.
if (Number.isInteger(hparams.chunkSize) && Number.isInteger(hparams.maxActionDim)) {
const expectedNoise = hparams.chunkSize * hparams.maxActionDim
if (input.noise.length !== expectedNoise) {
throw new QvacErrorAddonVla({
code: ERR_CODES.INVALID_INPUT,
adds: `input.noise length ${input.noise.length} != ${expectedNoise} (chunkSize*maxActionDim)`
})
}
// GR00T (imageInputMode 'patches') is a continuous-state flow-matching model
// that does NOT sample noise internally — GrootModel::infer hard-rejects a
// null noise. The discrete branch below already enforces this for pi05; do the
// same for the continuous/patches path so a missing prior surfaces as a clean
// INVALID_INPUT rather than an opaque INFERENCE_FAILED from the worker.
if (hparams && hparams.imageInputMode === "patches") {
// Fail closed on GR00T's fixed-shape embodiment contract before the noise
// checks. GrootModel::infer derives the image-placeholder count from
// nImages and accepts nImages >= 1, so a one-camera input against a
// two-camera checkpoint would silently produce actions for the wrong camera
// layout instead of a clean INVALID_INPUT. The shared continuous-state
// check above allows state.length <= maxStateDim; GR00T needs it exact.
if (Number.isInteger(hparams.numCameras) &&
input.images.length !== hparams.numCameras) {
throw new QvacErrorAddonVla({
code: ERR_CODES.INVALID_INPUT,
adds: `groot requires exactly ${hparams.numCameras} patch image buffers (got ${input.images.length})`,
});
}
if (Number.isInteger(hparams.maxStateDim) &&
input.state.length !== hparams.maxStateDim) {
throw new QvacErrorAddonVla({
code: ERR_CODES.INVALID_INPUT,
adds: `groot requires state.length === ${hparams.maxStateDim} (got ${input.state.length})`,
});
}
if (!input.noise ||
!(input.noise instanceof Float32Array) ||
input.noise.length === 0) {
throw new QvacErrorAddonVla({
code: ERR_CODES.INVALID_INPUT,
adds: "groot requires input.noise (Float32Array) — flow matching needs a noise prior at t=1",
});
}
// Exact length guards the native memcpy against an OOB read: GrootModel::infer
// copies chunkSize*maxActionDim floats blindly from this buffer (no source-length
// check), so a short array reads adjacent heap memory into the action prior.
if (Number.isInteger(hparams.chunkSize) &&
Number.isInteger(hparams.maxActionDim)) {
const expectedNoise = hparams.chunkSize * hparams.maxActionDim;
if (input.noise.length !== expectedNoise) {
throw new QvacErrorAddonVla({
code: ERR_CODES.INVALID_INPUT,
adds: `input.noise length ${input.noise.length} != ${expectedNoise} (chunkSize*maxActionDim)`,
});
}
}
}
}
return { imgWidth, imgHeight }
if (hparams && hparams.stateInputMode === "discrete") {
if (Number.isInteger(hparams.numCameras) &&
input.images.length !== hparams.numCameras) {
throw new QvacErrorAddonVla({
code: ERR_CODES.INVALID_INPUT,
adds: `pi05 requires exactly ${hparams.numCameras} camera images (got ${input.images.length})`,
});
}
if (Number.isInteger(hparams.tokenizerMaxLength) &&
input.tokens.length !== hparams.tokenizerMaxLength) {
throw new QvacErrorAddonVla({
code: ERR_CODES.INVALID_INPUT,
adds: `pi05 requires tokens.length === ${hparams.tokenizerMaxLength} (got ${input.tokens.length})`,
});
}
if (!input.noise ||
!(input.noise instanceof Float32Array) ||
input.noise.length === 0) {
throw new QvacErrorAddonVla({
code: ERR_CODES.INVALID_INPUT,
adds: "pi05 requires input.noise (Float32Array) — flow matching needs a noise prior at t=1",
});
}
// Same native-memcpy OOB guard as the groot branch: pi05 xT is action_horizon *
// action_dim floats, surfaced as chunkSize * maxActionDim (max_action_dim maps
// to action_dim for pi05), copied blindly from this buffer.
if (Number.isInteger(hparams.chunkSize) &&
Number.isInteger(hparams.maxActionDim)) {
const expectedNoise = hparams.chunkSize * hparams.maxActionDim;
if (input.noise.length !== expectedNoise) {
throw new QvacErrorAddonVla({
code: ERR_CODES.INVALID_INPUT,
adds: `input.noise length ${input.noise.length} != ${expectedNoise} (chunkSize*maxActionDim)`,
});
}
}
}
return { imgWidth, imgHeight };
}
class VlaModel {
constructor({ files, config = {}, logger = null, opts = {} } = {}) {
if (!files || !Array.isArray(files.model) || files.model.length === 0) {
throw new QvacErrorAddonVla({
code: ERR_CODES.MISSING_REQUIRED_PARAMETER,
adds: 'files.model (non-empty array of absolute paths)'
})
logger;
opts;
state;
_files;
_config;
_job;
_run;
_handle;
_hparams;
_backendName;
_hasActiveResponse;
_nativeLoggerActive;
_packageName;
_packageVersion;
// Per-run accumulator filled by _onAddonEvent; null between runs.
_pending;
constructor(options) {
const { files, config = {}, logger = null, opts = {}, } = options ?? { files: { model: [] } };
if (!files || !Array.isArray(files.model) || files.model.length === 0) {
throw new QvacErrorAddonVla({
code: ERR_CODES.MISSING_REQUIRED_PARAMETER,
adds: "files.model (non-empty array of absolute paths)",
});
}
for (const [i, entry] of files.model.entries()) {
if (typeof entry !== "string" || entry.length === 0) {
throw new QvacErrorAddonVla({
code: ERR_CODES.INVALID_CONFIG,
adds: `files.model[${i}] must be an absolute path string`,
});
}
if (!path.isAbsolute(entry)) {
throw new QvacErrorAddonVla({
code: ERR_CODES.INVALID_CONFIG,
adds: `files.model[${i}] must be an absolute path (got: ${entry})`,
});
}
}
this._files = files.model;
this._config = config;
this.logger = new QvacLogger(logger);
this.opts = opts;
// The cancel hook is wired to the framework's binding.cancel(handle)
// through the public cancel() method; the createJobHandler tear-down
// flows through that path.
this._job = (0, infer_base_1.createJobHandler)({ cancel: () => this.cancel() });
this._run = (0, infer_base_1.exclusiveRunQueue)();
this._handle = null;
this._hparams = null;
this._backendName = null;
this._hasActiveResponse = false;
this._nativeLoggerActive = false;
this._packageName = "@qvac/vla-ggml";
// eslint-disable-next-line @typescript-eslint/no-require-imports -- package metadata is read from the package root at runtime.
this._packageVersion = require("./package.json")
.version;
// Per-run accumulator filled by _onAddonEvent; null between runs.
this._pending = null;
this.state = { configLoaded: false, weightsLoaded: false };
}
for (const [i, entry] of files.model.entries()) {
if (typeof entry !== 'string' || entry.length === 0) {
throw new QvacErrorAddonVla({
code: ERR_CODES.INVALID_CONFIG,
adds: `files.model[${i}] must be an absolute path string`
})
}
if (!path.isAbsolute(entry)) {
throw new QvacErrorAddonVla({
code: ERR_CODES.INVALID_CONFIG,
adds: `files.model[${i}] must be an absolute path (got: ${entry})`
})
}
_connectNativeLogger() {
if (this._nativeLoggerActive)
return;
try {
binding.setLogger((priority, message) => {
const method = LOG_METHODS[priority] || "info";
if (typeof this.logger[method] === "function") {
this.logger[method](`[C++] ${message}`);
}
});
const verbosity = Number.isInteger(this._config.verbosity)
? this._config.verbosity
: DEFAULT_NATIVE_VERBOSITY;
try {
binding.setVerbosity(verbosity);
}
catch { }
this._nativeLoggerActive = true;
}
catch (err) {
this.logger.warn("Failed to connect native logger:", err && err.message);
}
}
this._files = files.model
this._config = config
this.logger = new QvacLogger(logger)
this.opts = opts
// The cancel hook is wired to the framework's binding.cancel(handle)
// through the public cancel() method; the createJobHandler tear-down
// flows through that path.
this._job = createJobHandler({ cancel: () => this.cancel() })
this._run = exclusiveRunQueue()
this._handle = null
this._hparams = null
this._backendName = null
this._hasActiveResponse = false
this._nativeLoggerActive = false
this._packageName = '@qvac/vla-ggml'
this._packageVersion = require('./package.json').version
// Per-run accumulator filled by _onAddonEvent; null between runs.
this._pending = null
this.state = { configLoaded: false, weightsLoaded: false }
}
_connectNativeLogger() {
if (this._nativeLoggerActive) return
try {
binding.setLogger((priority, message) => {
const method = LOG_METHODS[priority] || 'info'
if (typeof this.logger[method] === 'function') {
this.logger[method](`[C++] ${message}`)
// Framework output callback: invoked from the JS event loop after each
// event the worker thread queues. The shape is:
// (jsHandle, eventTypeName, outputData, errorData)
// For VLA we receive at most three event types per job:
// - Output (Float32Array) — the action chunk.
// - JobEnded (RuntimeStats obj) — finishing event with timing/stats.
// - Error (string in errorData) — eventTypeName contains "Error".
// The pair is accumulated in `_pending` and surfaced through the active
// _job response (`_job.output` / `_job.end` / `_job.fail`) so the public
// `model.run(input)` Promise resolves with `{ actions, stats }` once both
// halves have arrived — preserving the previous external API even though
// the underlying dispatch is now asynchronous.
_onAddonEvent(_jsHandle, eventTypeName, outputData, errorData) {
// `_hasActiveResponse` is cleared by the response promise's .finally() in
// _runInternal, NOT here — see the rationale block there. Doing it from
// this callback would mean the flag stays set forever if the worker
// aborts before delivering JobEnded/Error.
if (typeof eventTypeName === "string" && eventTypeName.includes("Error")) {
const err = new QvacErrorAddonVla({
code: ERR_CODES.INFERENCE_FAILED,
adds: typeof errorData === "string" ? errorData : "native error",
});
if (this._pending)
this._pending.actions = null;
this._pending = null;
if (this._job.active)
this._job.fail(err);
return;
}
})
const verbosity =
this._config && Number.isInteger(this._config.verbosity)
? this._config.verbosity
: DEFAULT_NATIVE_VERBOSITY
try {
binding.setVerbosity(verbosity)
} catch (_) {}
this._nativeLoggerActive = true
} catch (err) {
this.logger.warn('Failed to connect native logger:', err && err.message)
if (outputData instanceof Float32Array) {
if (this._pending)
this._pending.actions = outputData;
this._job.output(outputData);
return;
}
if (outputData && typeof outputData === "object") {
const stats = outputData;
const actions = this._pending ? this._pending.actions : null;
this._pending = null;
this._job.end(this.opts.stats ? stats : null, { actions, stats });
}
}
}
// Framework output callback: invoked from the JS event loop after each
// event the worker thread queues. The shape is:
// (jsHandle, eventTypeName, outputData, errorData)
// For VLA we receive at most three event types per job:
// - Output (Float32Array) — the action chunk.
// - JobEnded (RuntimeStats obj) — finishing event with timing/stats.
// - Error (string in errorData) — eventTypeName contains "Error".
// The pair is accumulated in `_pending` and surfaced through the active
// _job response (`_job.output` / `_job.end` / `_job.fail`) so the public
// `model.run(input)` Promise resolves with `{ actions, stats }` once both
// halves have arrived — preserving the previous external API even though
// the underlying dispatch is now asynchronous.
_onAddonEvent(_jsHandle, eventTypeName, outputData, errorData) {
// `_hasActiveResponse` is cleared by the response promise's .finally() in
// _runInternal, NOT here — see the rationale block there. Doing it from
// this callback would mean the flag stays set forever if the worker
// aborts before delivering JobEnded/Error.
if (typeof eventTypeName === 'string' && eventTypeName.includes('Error')) {
const err = new QvacErrorAddonVla({
code: ERR_CODES.INFERENCE_FAILED,
adds: typeof errorData === 'string' ? errorData : 'native error'
})
if (this._pending) this._pending.actions = null
this._pending = null
if (this._job.active) this._job.fail(err)
return
_releaseNativeLogger() {
if (!this._nativeLoggerActive)
return;
try {
binding.releaseLogger();
}
catch { }
this._nativeLoggerActive = false;
}
if (outputData instanceof Float32Array) {
if (this._pending) this._pending.actions = outputData
this._job.output(outputData)
return
async load({ backend = "auto" } = {}) {
if (backend !== "auto" && backend !== "cpu") {
throw new QvacErrorAddonVla({
code: ERR_CODES.INVALID_CONFIG,
adds: `backend must be 'auto' or 'cpu' (got: ${String(backend)})`,
});
}
return this._run(async () => {
if (this.state.configLoaded)
return;
await this._load(backend);
this.state.configLoaded = true;
this.state.weightsLoaded = true;
});
}
if (outputData && typeof outputData === 'object') {
const stats = outputData
const actions = this._pending ? this._pending.actions : null
this._pending = null
this._job.end(this.opts.stats ? stats : null, { actions, stats })
// eslint-disable-next-line @typescript-eslint/require-await -- kept async to preserve the Promise-returning contract; the load steps are synchronous native binding calls.
async _load(backend) {
this.logger.info("Starting model load");
this._connectNativeLogger();
const ggufPath = pickPrimaryGgufPath(this._files);
if (!fs.existsSync(ggufPath)) {
// _connectNativeLogger has already registered a JS callback with
// the native side; without unregistering, that callback pins the
// Bare event loop and prevents the process from exiting. Release
// before throwing so a `new VlaModel(...).load()` against a
// non-existent file leaves no event-loop references behind.
this._releaseNativeLogger();
throw new QvacErrorAddonVla({
code: ERR_CODES.MODEL_NOT_FOUND,
adds: ggufPath,
});
}
try {
// Canonical instance lifecycle (mirrors LLM/embed/NMT):
// createInstance(jsHandle, params, outputCb) — the framework's
// JobRunner thread consumes runJob() and feeds the outputCb.
const backendsDir = this._config.backendsDir
? this._config.backendsDir
: path.join(__dirname, "prebuilds");
this._handle = binding.createInstance(this, { ggufPath, backend, backendsDir }, (jsHandle, eventTypeName, outputData, errorData) => {
this._onAddonEvent(jsHandle, eventTypeName, outputData, errorData);
});
// No-op for VLA (no IModelAsyncLoad weights stream) but kept for
// symmetry with sibling addons.
binding.activate(this._handle);
this._hparams = binding.getVlaHparams(this._handle);
this._backendName = binding.getVlaBackendName(this._handle);
}
catch (loadError) {
this.logger.error("Error during model load:", loadError);
if (this._handle) {
try {
binding.destroyInstance(this._handle);
}
catch { }
this._handle = null;
}
// Same logger-leak guard as the missing-file path above.
this._releaseNativeLogger();
throw new QvacErrorAddonVla({
code: ERR_CODES.FAILED_TO_LOAD_WEIGHTS,
adds: loadError.message,
cause: loadError,
});
}
this.logger.info("Model load completed successfully");
}
}
_releaseNativeLogger() {
if (!this._nativeLoggerActive) return
try {
binding.releaseLogger()
} catch (_) {}
this._nativeLoggerActive = false
}
async load({ backend = 'auto' } = {}) {
if (backend !== 'auto' && backend !== 'cpu') {
throw new QvacErrorAddonVla({
code: ERR_CODES.INVALID_CONFIG,
adds: `backend must be 'auto' or 'cpu' (got: ${backend})`
})
get hparams() {
return this._hparams;
}
return this._run(async () => {
if (this.state.configLoaded) return
await this._load(backend)
this.state.configLoaded = true
this.state.weightsLoaded = true
})
}
async _load(backend) {
this.logger.info('Starting model load')
this._connectNativeLogger()
const ggufPath = pickPrimaryGgufPath(this._files)
if (!fs.existsSync(ggufPath)) {
// _connectNativeLogger has already registered a JS callback with
// the native side; without unregistering, that callback pins the
// Bare event loop and prevents the process from exiting. Release
// before throwing so a `new VlaModel(...).load()` against a
// non-existent file leaves no event-loop references behind.
this._releaseNativeLogger()
throw new QvacErrorAddonVla({ code: ERR_CODES.MODEL_NOT_FOUND, adds: ggufPath })
get backendName() {
return this._backendName;
}
try {
// Canonical instance lifecycle (mirrors LLM/embed/NMT):
// createInstance(jsHandle, params, outputCb) — the framework's
// JobRunner thread consumes runJob() and feeds the outputCb.
const backendsDir =
this._config && this._config.backendsDir
? this._config.backendsDir
: path.join(__dirname, 'prebuilds')
this._handle = binding.createInstance(
this,
{ ggufPath, backend, backendsDir },
(jsHandle, eventTypeName, outputData, errorData) => {
this._onAddonEvent(jsHandle, eventTypeName, outputData, errorData)
async run(input) {
return this._run(() => this._runInternal(input));
}
// eslint-disable-next-line @typescript-eslint/require-await -- kept async so run() can serialize it through the exclusive run queue; dispatch is fire-and-forget.
async _runInternal(input) {
if (!this._handle) {
throw new QvacErrorAddonVla({
code: ERR_CODES.INSTANCE_NOT_INITIALIZED,
});
}
)
// No-op for VLA (no IModelAsyncLoad weights stream) but kept for
// symmetry with sibling addons.
binding.activate(this._handle)
this._hparams = binding.getVlaHparams(this._handle)
this._backendName = binding.getVlaBackendName(this._handle)
} catch (loadError) {
this.logger.error('Error during model load:', loadError)
if (this._handle) {
if (this._hasActiveResponse) {
throw new QvacErrorAddonVla({ code: ERR_CODES.JOB_ALREADY_RUNNING });
}
const { imgWidth, imgHeight } = validateRunInput(input, this._hparams);
this.logger.info("Starting inference");
const response = this._job.start();
// Per-job accumulator. Two events flow through _onAddonEvent: the
// Float32Array action chunk lands first, then the RuntimeStats object —
// we resolve the response only when both have arrived.
this._pending = { actions: null };
let accepted = false;
try {
binding.destroyInstance(this._handle)
} catch (_) {}
this._handle = null
}
// Same logger-leak guard as the missing-file path above.
this._releaseNativeLogger()
throw new QvacErrorAddonVla({
code: ERR_CODES.FAILED_TO_LOAD_WEIGHTS,
adds: loadError.message,
cause: loadError
})
accepted = binding.runJob(this._handle, {
type: "vla",
input: {
images: input.images,
imgWidth,
imgHeight,
state: input.state,
tokens: input.tokens,
mask: input.mask,
noise: input.noise ?? undefined,
},
});
}
catch (err) {
this._pending = null;
this._job.fail(err);
throw err;
}
if (!accepted) {
this._pending = null;
const err = new QvacErrorAddonVla({
code: ERR_CODES.JOB_ALREADY_RUNNING,
});
this._job.fail(err);
throw err;
}
// Only mark the model busy once the worker has actually accepted the job.
// Clear via `.finally()` on the response promise, not from inside the
// native event callback — if the worker thread aborts mid-inference
// (e.g. an unrecoverable GGML_ASSERT in smolvla.cpp) no JobEnded/Error
// event is delivered and the previous "clear from _onAddonEvent" pattern
// would leave the flag set forever, wedging every subsequent run() with
// JOB_ALREADY_RUNNING. Mirrors qvac-lib-infer-llamacpp-llm/index.js.
this._hasActiveResponse = true;
const finalized = response.await().finally(() => {
this._hasActiveResponse = false;
});
// Swallow rejections at the unobserved-promise level so an awaiter who
// catches still sees the rejection through their own await; without
// this the runtime logs an "unhandled promise rejection" warning.
finalized.catch((err) => {
this.logger?.warn?.("Inference response rejected:", err?.message || err);
});
// Make response.await() idempotent: subsequent calls return the same
// chained promise so .finally() fires exactly once.
response.await = () => finalized;
this.logger.info("Inference job dispatched");
return response;
}
this.logger.info('Model load completed successfully')
}
get hparams() {
return this._hparams
}
get backendName() {
return this._backendName
}
async run(input) {
return this._run(() => this._runInternal(input))
}
async _runInternal(input) {
if (!this._handle) {
throw new QvacErrorAddonVla({ code: ERR_CODES.INSTANCE_NOT_INITIALIZED })
async pause() {
/* no-op: SmolVLA inference has no per-step cancel point */
}
if (this._hasActiveResponse) {
throw new QvacErrorAddonVla({ code: ERR_CODES.JOB_ALREADY_RUNNING })
}
const { imgWidth, imgHeight } = validateRunInput(input, this._hparams)
this.logger.info('Starting inference')
const response = this._job.start()
// Per-job accumulator. Two events flow through _onAddonEvent: the
// Float32Array action chunk lands first, then the RuntimeStats object —
// we resolve the response only when both have arrived.
this._pending = { actions: null }
let accepted = false
try {
accepted = binding.runJob(this._handle, {
type: 'vla',
input: {
images: input.images,
imgWidth,
imgHeight,
state: input.state,
tokens: input.tokens,
mask: input.mask,
noise: input.noise ?? undefined
async cancel() {
if (this._handle) {
try {
await binding.cancel(this._handle);
}
catch { }
}
})
} catch (err) {
this._pending = null
this._job.fail(err)
throw err
}
if (!accepted) {
this._pending = null
const err = new QvacErrorAddonVla({ code: ERR_CODES.JOB_ALREADY_RUNNING })
this._job.fail(err)
throw err
async unload() {
return this._run(async () => {
await this.cancel();
if (this._job.active) {
this._job.fail(new QvacErrorAddonVla({ code: ERR_CODES.MODEL_UNLOADED }));
}
this._pending = null;
this._hasActiveResponse = false;
if (this._handle) {
try {
binding.destroyInstance(this._handle);
}
catch (destroyError) {
this._handle = null;
this._releaseNativeLogger();
throw new QvacErrorAddonVla({
code: ERR_CODES.FAILED_TO_DESTROY,
adds: destroyError.message,
cause: destroyError,
});
}
this._handle = null;
}
this._releaseNativeLogger();
this._hparams = null;
this._backendName = null;
this.state.configLoaded = false;
this.state.weightsLoaded = false;
});
}
// Only mark the model busy once the worker has actually accepted the job.
// Clear via `.finally()` on the response promise, not from inside the
// native event callback — if the worker thread aborts mid-inference
// (e.g. an unrecoverable GGML_ASSERT in smolvla.cpp) no JobEnded/Error
// event is delivered and the previous "clear from _onAddonEvent" pattern
// would leave the flag set forever, wedging every subsequent run() with
// JOB_ALREADY_RUNNING. Mirrors qvac-lib-infer-llamacpp-llm/index.js.
this._hasActiveResponse = true
const finalized = response.await().finally(() => {
this._hasActiveResponse = false
})
// Swallow rejections at the unobserved-promise level so an awaiter who
// catches still sees the rejection through their own await; without
// this the runtime logs an "unhandled promise rejection" warning.
finalized.catch((err) => {
this.logger?.warn?.('Inference response rejected:', err?.message || err)
})
// Make response.await() idempotent: subsequent calls return the same
// chained promise so .finally() fires exactly once.
response.await = () => finalized
this.logger.info('Inference job dispatched')
return response
}
async pause() {
/* no-op: SmolVLA inference has no per-step cancel point */
}
async cancel() {
if (this._handle) {
try {
await binding.cancel(this._handle)
} catch (_) {}
getState() {
return this.state;
}
}
async unload() {
return this._run(async () => {
await this.cancel()
if (this._job.active) {
this._job.fail(new QvacErrorAddonVla({ code: ERR_CODES.MODEL_UNLOADED }))
}
this._pending = null
this._hasActiveResponse = false
if (this._handle) {
try {
binding.destroyInstance(this._handle)
} catch (destroyError) {
this._handle = null
this._releaseNativeLogger()
throw new QvacErrorAddonVla({
code: ERR_CODES.FAILED_TO_DESTROY,
adds: destroyError.message,
cause: destroyError
})
}
this._handle = null
}
this._releaseNativeLogger()
this._hparams = null
this._backendName = null
this.state.configLoaded = false
this.state.weightsLoaded = false
})
}
getState() {
return this.state
}
}
module.exports = VlaModel
module.exports.VlaModel = VlaModel
module.exports.preprocessImage = preprocessImage
module.exports.padState = padState
module.exports.DEFAULT_IMAGE_SIZE = DEFAULT_IMAGE_SIZE
module.exports.QvacErrorAddonVla = QvacErrorAddonVla
module.exports.ERR_CODES = ERR_CODES
// Alias used by the namespace below to re-export the class as a property of
// itself (`VlaModel.VlaModel`). `export import VlaModel = VlaModel` inside the
// namespace would resolve to the alias being declared, so the indirection is
// required.
var VlaModelClass = VlaModel;
/**
* Declaration merging with the class above models this package's CommonJS
* export shape — `module.exports` IS the `VlaModel` constructor, carrying the
* named exports as own properties — directly in the type system. TypeScript
* emits the property attachments natively, so the generated `index.js` and
* `index.d.ts` can no longer drift from each other, and a CommonJS consumer
* (`import VlaModel = require('@qvac/vla-ggml')`) gets a real construct
* signature instead of TS2351.
*/
// eslint-disable-next-line @typescript-eslint/no-namespace -- class/namespace merging is the only way to type a constructor-first CommonJS export (`module.exports = VlaModel` plus attached members).
(function (VlaModel) {
VlaModel.VlaModel = VlaModelClass;
VlaModel.preprocessImage = addonModule.preprocessImage;
VlaModel.padState = addonModule.padState;
VlaModel.DEFAULT_IMAGE_SIZE = addonModule.DEFAULT_IMAGE_SIZE;
VlaModel.QvacErrorAddonVla = errorModule.QvacErrorAddonVla;
VlaModel.ERR_CODES = errorModule.ERR_CODES;
})(VlaModel || (VlaModel = {}));
module.exports = VlaModel;

@@ -1,74 +0,70 @@

'use strict'
const { QvacErrorBase, addCodes } = require('@qvac/error')
const { name, version } = require('../package.json')
class QvacErrorAddonVla extends QvacErrorBase {}
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ERR_CODES = exports.QvacErrorAddonVla = void 0;
/* eslint-disable @typescript-eslint/no-require-imports -- @qvac/error exposes a CommonJS export shape. */
const QvacError = require("@qvac/error");
/* eslint-enable @typescript-eslint/no-require-imports */
const { QvacErrorBase, addCodes } = QvacError;
class QvacErrorAddonVla extends QvacErrorBase {
}
exports.QvacErrorAddonVla = QvacErrorAddonVla;
// eslint-disable-next-line @typescript-eslint/no-require-imports -- package metadata is read from the package root at runtime.
const { name, version } = require("../package.json");
// This library has error code range from 30001 to 31000
const ERR_CODES = Object.freeze({
FAILED_TO_LOAD_WEIGHTS: 30001,
FAILED_TO_DESTROY: 30002,
MODEL_NOT_FOUND: 30003,
INVALID_CONFIG: 30004,
MISSING_REQUIRED_PARAMETER: 30005,
INVALID_INPUT: 30006,
JOB_ALREADY_RUNNING: 30007,
INSTANCE_NOT_INITIALIZED: 30008,
MODEL_UNLOADED: 30009,
INFERENCE_FAILED: 30010
})
addCodes(
{
[ERR_CODES.FAILED_TO_LOAD_WEIGHTS]: {
name: 'FAILED_TO_LOAD_WEIGHTS',
message: (message) => `Failed to load weights, error: ${message}`
exports.ERR_CODES = Object.freeze({
FAILED_TO_LOAD_WEIGHTS: 30001,
FAILED_TO_DESTROY: 30002,
MODEL_NOT_FOUND: 30003,
INVALID_CONFIG: 30004,
MISSING_REQUIRED_PARAMETER: 30005,
INVALID_INPUT: 30006,
JOB_ALREADY_RUNNING: 30007,
INSTANCE_NOT_INITIALIZED: 30008,
MODEL_UNLOADED: 30009,
INFERENCE_FAILED: 30010,
});
addCodes({
[exports.ERR_CODES.FAILED_TO_LOAD_WEIGHTS]: {
name: "FAILED_TO_LOAD_WEIGHTS",
message: (message) => `Failed to load weights, error: ${message}`,
},
[ERR_CODES.FAILED_TO_DESTROY]: {
name: 'FAILED_TO_DESTROY',
message: (message) => `Failed to destroy instance, error: ${message}`
[exports.ERR_CODES.FAILED_TO_DESTROY]: {
name: "FAILED_TO_DESTROY",
message: (message) => `Failed to destroy instance, error: ${message}`,
},
[ERR_CODES.MODEL_NOT_FOUND]: {
name: 'MODEL_NOT_FOUND',
message: (path) => `SmolVLA GGUF not found: ${path}`
[exports.ERR_CODES.MODEL_NOT_FOUND]: {
name: "MODEL_NOT_FOUND",
message: (path) => `SmolVLA GGUF not found: ${path}`,
},
[ERR_CODES.INVALID_CONFIG]: {
name: 'INVALID_CONFIG',
message: (message) => `Invalid configuration: ${message}`
[exports.ERR_CODES.INVALID_CONFIG]: {
name: "INVALID_CONFIG",
message: (message) => `Invalid configuration: ${message}`,
},
[ERR_CODES.MISSING_REQUIRED_PARAMETER]: {
name: 'MISSING_REQUIRED_PARAMETER',
message: (paramName) => `Missing required parameter: ${paramName}`
[exports.ERR_CODES.MISSING_REQUIRED_PARAMETER]: {
name: "MISSING_REQUIRED_PARAMETER",
message: (paramName) => `Missing required parameter: ${paramName}`,
},
[ERR_CODES.INVALID_INPUT]: {
name: 'INVALID_INPUT',
message: (message) => `Invalid input: ${message}`
[exports.ERR_CODES.INVALID_INPUT]: {
name: "INVALID_INPUT",
message: (message) => `Invalid input: ${message}`,
},
[ERR_CODES.JOB_ALREADY_RUNNING]: {
name: 'JOB_ALREADY_RUNNING',
message: () => 'Cannot set new job: a job is already set or being processed'
[exports.ERR_CODES.JOB_ALREADY_RUNNING]: {
name: "JOB_ALREADY_RUNNING",
message: () => "Cannot set new job: a job is already set or being processed",
},
[ERR_CODES.INSTANCE_NOT_INITIALIZED]: {
name: 'INSTANCE_NOT_INITIALIZED',
message: () => 'Addon not initialized. Call load() first.'
[exports.ERR_CODES.INSTANCE_NOT_INITIALIZED]: {
name: "INSTANCE_NOT_INITIALIZED",
message: () => "Addon not initialized. Call load() first.",
},
[ERR_CODES.MODEL_UNLOADED]: {
name: 'MODEL_UNLOADED',
message: () => 'Model was unloaded'
[exports.ERR_CODES.MODEL_UNLOADED]: {
name: "MODEL_UNLOADED",
message: () => "Model was unloaded",
},
[ERR_CODES.INFERENCE_FAILED]: {
name: 'INFERENCE_FAILED',
message: (message) => `Inference failed: ${message}`
}
},
{
[exports.ERR_CODES.INFERENCE_FAILED]: {
name: "INFERENCE_FAILED",
message: (message) => `Inference failed: ${message}`,
},
}, {
name,
version
}
)
module.exports = {
ERR_CODES,
QvacErrorAddonVla
}
version,
});
{
"name": "@qvac/vla-ggml",
"version": "0.15.0",
"version": "0.16.1",
"description": "VLA vision-language-action inference addon for QVAC (ggml backend)",

@@ -10,13 +10,20 @@ "addon": true,

"scripts": {
"build": "bare-make generate && bare-make build && bare-make install",
"build:pack": "mkdir -p dist && npm pack --pack-destination dist",
"build": "npm run build:ts && npm run build:native",
"build:native": "bare-make generate && bare-make build && bare-make install",
"build:ts": "tsc -p tsconfig.build.json",
"build:pack": "npm run build:ts && mkdir -p dist && npm pack --pack-destination dist",
"check:generated": "npm run build:ts && git diff --exit-code -- index.js addon.js index.d.ts addon.d.ts lib/error.js lib/error.d.ts",
"typecheck": "tsc --noEmit -p tsconfig.json",
"mobile:copy-prebuilds": "cp -r prebuilds/android-arm64 prebuilds/android-ia32 || echo 'Warning: Failed to copy vla prebuilds to android-ia32'; cp -r prebuilds/android-arm64 prebuilds/android-arm || echo 'Warning: Failed to copy vla prebuilds to android-arm'; cp -r prebuilds/android-arm64 prebuilds/android-x64 || echo 'Warning: Failed to copy vla prebuilds to android-x64'; cp -r prebuilds/ios-arm64 prebuilds/ios-arm64-simulator 2>/dev/null || echo 'iOS prebuilds already present'; cp -r prebuilds/ios-arm64 prebuilds/ios-x64-simulator 2>/dev/null || echo 'iOS prebuilds already present'",
"lint": "prettier --check \"**/*.{js,cjs,mjs}\" && lunte",
"lint": "npm run lint:js && npm run lint:ts",
"lint:js": "prettier --check \"**/*.{js,cjs,mjs}\" && lunte",
"lint:ts": "eslint \"src/**/*.ts\" --max-warnings=0",
"lint:ts:fix": "eslint \"src/**/*.ts\" --fix",
"format": "prettier --write \"**/*.{js,cjs,mjs}\"",
"lint:cpp": "clang-tidy -p build $(find addon -name '*.cpp')",
"test": "npm run test:unit && npm run test:integration",
"test:integration": "npm run test:integration:generate && bare test/integration/all.js --exit",
"test": "npm run lint && npm run test:types && npm run test:unit && npm run test:integration",
"test:integration": "npm run build:ts && npm run test:integration:generate && bare test/integration/all.js --exit",
"test:integration:generate": "brittle -r test/integration/all.js test/integration/*.test.js && npm run test:mobile:generate",
"test:unit:generate": "brittle -r test/unit/all.js test/unit/*.test.js",
"test:unit": "npm run test:unit:generate && bare test/unit/all.js --exit",
"test:unit": "npm run build:ts && npm run test:unit:generate && bare test/unit/all.js --exit",
"test:cpp:build": "bare-make generate -D BUILD_TESTING=ON && bare-make build --target addon-test",

@@ -33,6 +40,9 @@ "test:cpp:run": "cd build/test/unit/ && ./addon-test --gtest_output=xml:cpp-test-results.xml",

"test:mobile:validate": "node scripts/validate-mobile-tests.js",
"test:dts": "tsc -p tsconfig.dts.json"
"test:types": "npm run typecheck && npm run check:generated && npm run test:types:consumer",
"test:types:consumer": "tsc --noEmit -p test/types/tsconfig.cjs.json && tsc --noEmit -p test/types/tsconfig.esm.json",
"test:dts": "npm run test:types"
},
"files": [
"addon.js",
"addon.d.ts",
"binding.js",

@@ -67,6 +77,10 @@ "index.js",

"cmake-vcpkg": "^1.1.0",
"eslint": "^9.39.4",
"eslint-import-resolver-typescript": "^4.4.5",
"eslint-plugin-import": "^2.32.0",
"lunte": "^1.8.0",
"prettier": "^3.6.2",
"prettier-config-holepunch": "^2.0.0",
"typescript": "^5.9.2"
"typescript": "^5.9.2",
"typescript-eslint": "^8.63.0"
},

@@ -73,0 +87,0 @@ "exports": {

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet