Skip to content

Commit e1af99d

Browse files
uqioclaude
andcommitted
feat: Rust ONNX inference for LiquidAI LFM2.5-VL (vision-language)
`lfm` is a Rust crate that runs the LiquidAI LFM2.5-VL ONNX vision- language model end-to-end: image preprocessing → vision encoding → text embedding → decoder prefill+decode loop → detokenize, with optional schema-constrained generation via llguidance. ## Public surface - `Engine::from_dir(model_dir, opts)` — strict constructor that byte-validates the supplied tokenizer.json, chat_template.jinja, preprocessor_config.json, and config.json's max_position_embeddings against the bundled assets before loading any ONNX session. Catches model-revision drift early. - `Engine::from_onnx_dir(onnx_dir, opts)` — load just the ONNX files; tokenizer + JSON configs come from the bundled `models/` assets via `include_bytes!`. - `Engine::from_paths(paths, opts)` — escape hatch for unusual layouts; opts out of the strict drift checks. - `Engine::generate(messages, images, req)` — free-form chat. - `Engine::run(task, images, req)` — schema-constrained generation driven by a `vlm_tasks::Task` (separate crate). - `Preprocessor`, `ImageBudget`, `RequestOptions`, `Options`, `ChatMessage`, `ImageInput`, `Error` — public configuration and request types. ## Algorithm fidelity - **Tile grid + smart_resize**: ports upstream `Lfm2VlImageProcessorFast.crop_image_to_patches` / `_is_image_too_large` / aspect-ratio search bit-for-bit. Phase 0 fixture `tests/fixtures/multi_image_ordering_proof.json` captures upstream `image_features` and we match. - **Per-tile marker order**: matches upstream's variable-naming inversion (`num_rows = grid_width`, `num_cols = grid_height`). Verified against `tests/fixtures/image_expansion_cases.json`. - **Patch layout**: HWC interleaved per `(dy, dx, ch)` — what upstream's `convert_image_to_patches` produces after the permute+reshape, despite preprocessor_config declaring CHW. - **Resize**: uses `fast_image_resize`'s `Convolution(FilterType::Bilinear)` to match torchvision `F.resize(..., interpolation=BILINEAR, antialias=True)` = PIL's `Image.resize(..., Image.BILINEAR)`. - **EXIF orientation**: applied during decode so header-only dimensions in the admission preflight match the eventual patchified grid. - **Per-image vision encoding**: never batches across images (Phase 0 G6 contract — batching corrupts multi-tile outputs). Per-image pixel buffers are decoded + freed inside the vision-encode loop, so peak memory is O(1 image) not O(N). ## Numeric safety - Sampler caps logits beyond tokenizer vocab size to -Inf so decoder-only padding IDs (64400-65535) can't win sampling. - Post-penalty range-restricted guard on `[0, vocab_size)`: any-NaN → SamplerNonFinite (catches model-emitted NaN); all-(-Inf) → SamplerNonFinite (catches penalty overflow). - `RequestOptions::validate()` rejects NaN/Inf/subnormal-positive temperature, NaN/Inf/out-of-range min_p, NaN/Inf/<1.0/>100.0 repetition_penalty, max_new_tokens > 32_768. - `ImageBudget::validate()` enforces tile counts, image-token bounds, and `max_tiles ≤ MAX_TOKENIZER_TILE_DIM` (= 10). ## Admission control Cheap-first, expensive-last admission gates in `generate()`: - Request-shape cap (max messages, total content parts) - Body-size cap (text bytes ≤ 16× MODEL_CONTEXT_TOKENS) - Special-token denylist (tokenizer added vocab + structural strings + named LFM control tokens, even across split parts) - Image-count match + lower-bound floor (rejects impossible batches before any image_dimensions header read) - Decoded-buffer alloc cap (worst-case W*H*4 bytes vs decode_limits().max_alloc) at header time - Per-grid token sum (including IMAGE_START/END wrappers and row/col markers) before render+tokenize - Authoritative context-length check after tokenize ## Features + CI - Cargo features: `inference`, `bundled` (= inference + decoders), `decoders`, `serde`, `cuda`, `tensorrt`, `directml`, `rocm`, `coreml`, `integration`. Default = bundled + inference + decoders. - Examples: `smoke`, `scene_analysis` (require bundled+inference+ decoders), `preprocess_only` (cfg-split for no-default builds). - CI runs clippy with `-D warnings` across no-default, decoders-only, and all-features configurations. - 136 lib tests cover sampler math, RNG state, tile-grid edge cases, smart_resize parity, chat-template rendering, image- block layout, admission gates, and drift-check failure modes. ## Bundled assets (`models/` via `include_bytes!`) - tokenizer.json (~4.5 MB), tokenizer_config.json, preprocessor_config.json, processor_config.json, config.json, generation_config.json, chat_template.jinja. - Total payload ~4.5 MB (under crates.io's 10 MB include limit). - ONNX models (vision_encoder ~86 MB, decoder ~350 MB) NOT bundled — users supply via `from_dir` / `from_onnx_dir`. ## Trust model Documented in `docs/codex-review-rejections.md`: both supplied model files and caller inputs are trusted (in-process library, no attacker). In-scope review concerns: algorithmic correctness vs upstream Python, model I/O contracts, bugs reachable on cooperative callers, concurrency hazards. Out of scope: defense-in-depth hardening against tampered model assets, DoS via large/malformed caller inputs, sampler-config exploits. ## Provenance Distilled from ~95 incremental commits on the `0.1.0` branch covering the design spec, plan, implementation phases, and 42 rounds of Codex adversarial review. Commit `53d8394` was the final fix (issue #2 C-001 single-NaN logit poisoning); commit `b9bgyuvsm` was the final review pass returning approve / no findings. See git history before squash for round-by-round context if needed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ee6eaa7 commit e1af99d

53 files changed

Lines changed: 342514 additions & 359 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 66 additions & 284 deletions
Large diffs are not rendered by default.

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,6 @@
1616

1717
/target
1818
Cargo.lock
19+
20+
# Project-local Claude Code state
21+
.claude/

CHANGELOG.md

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,65 @@
1-
# UNRELEASED
1+
# Changelog
22

3-
# 0.1.2 (January 6th, 2022)
3+
All notable changes follow the format from [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
4+
and this crate adheres to [Semantic Versioning](https://semver.org/).
45

5-
FEATURES
6+
## [0.1.0] — 2026-05-03
67

8+
### Added
79

10+
- Public `Engine` API for LiquidAI LFM2.5-VL-450M ONNX inference:
11+
- `Engine::from_dir(model_dir, opts)` — load from a directory containing
12+
the three ONNX graphs + `tokenizer.json`.
13+
- `Engine::from_paths(EnginePaths, opts)` — explicit per-graph path
14+
override.
15+
- `Engine::from_onnx_dir(onnx_dir, opts)` (`bundled` feature) — load from
16+
a directory containing **only the ONNX files**; the bundled tokenizer +
17+
JSON configs (~4.5 MB embedded via `include_bytes!`) are written to a
18+
per-process temp file and used in place of the missing on-disk files.
19+
ONNX model files are NOT bundled (vision_encoder ~86 MB, decoder ~350 MB).
20+
- `engine.generate(messages, images, req)` — free-form generation;
21+
returns the model's raw text output.
22+
- `engine.run(&task, messages, images, req)` — schema-constrained
23+
generation via any `vlm_tasks::Task` instance; returns `Task::Output`.
24+
- Bundled `SceneTask` (wrapping `vlm_tasks::SceneAnalysis`) for structured
25+
scene analysis without any extra configuration.
26+
- Public chat types: `ChatMessage`, `ChatContent`, `ContentPart`,
27+
`ImageInput`.
28+
- Public configuration: `Options`, `RequestOptions`, `ImageBudget`,
29+
`ThreadOptions`, `GraphOptimizationLevel`.
30+
- Wasm-friendly preprocessing subset under
31+
`--no-default-features --features decoders` (no `ort`, no `tokenizers`):
32+
`Preprocessor`, `TileGrid`, `PreprocessedImage`,
33+
`decode_bytes_with_orientation`.
34+
- EXIF-aware image decoding: `decode_with_orientation` (native) and
35+
`decode_bytes_with_orientation` (all targets including wasm).
36+
- Schema-constrained sampling via `llguidance` 1.7 token-mask filtering
37+
applied at each decode step.
38+
- Hybrid KV+conv-state cache management for the LFM2 hybrid LM
39+
(10 conv-state layers + 6 KV-attn layers, sparse layer indices).
40+
- Per-image vision-encoder dispatch (Phase 0 G6 contract: one image per
41+
encoder call; batched multi-image calls produce silently-wrong embeddings).
42+
- Chat template rendering with `minijinja` 2: `apply_chat_template`,
43+
`expand_image_placeholders`, bundled Jinja2 source via `include_str!`.
44+
- Examples:
45+
- `smoke` — free-form generation over one image.
46+
- `scene_analysis` — structured `SceneAnalysis` output.
47+
- `preprocess_only` — preprocessing-only (no inference, no-default-features).
48+
- `qwen_compare` — side-by-side LFM vs Qwen3-VL comparison
49+
(requires `--features comparison`).
50+
- Benches: `bench_preproc`, `bench_tile_grid`, `bench_chat_template`.
51+
- Integration test suite gated on `feature = "integration"` and the
52+
`LFM_MODEL_PATH` env var.
53+
- Execution-provider gates: `cuda`, `tensorrt`, `directml`, `rocm`,
54+
`coreml` (all off by default; each implies `inference`).
55+
- `serde` feature: `Serialize`/`Deserialize` on `Options`,
56+
`RequestOptions`, `ThreadOptions`, `ImageBudget`.
57+
58+
### Model weights
59+
60+
The crate wraps [LFM2.5-VL-450M-ONNX](https://huggingface.co/LiquidAI/LFM2.5-VL-450M-ONNX).
61+
The weights ship under the [LFM Open License v1.0](https://www.liquid.ai/lfm-license)
62+
— verify your use case complies with Liquid AI's terms separately from
63+
this crate's MIT OR Apache-2.0 license.
64+
65+
[0.1.0]: https://github.com/findit-ai/lfm/releases/tag/v0.1.0

Cargo.toml

Lines changed: 100 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,114 @@
11
[package]
2-
name = "template-rs"
3-
version = "0.0.0"
4-
edition = "2021"
5-
repository = "https://github.com/al8n/template-rs"
6-
homepage = "https://github.com/al8n/template-rs"
7-
documentation = "https://docs.rs/template-rs"
8-
description = "A template for creating Rust open-source repo on GitHub"
9-
license = "MIT OR Apache-2.0"
10-
rust-version = "1.73"
11-
12-
[[bench]]
13-
path = "benches/foo.rs"
14-
name = "foo"
15-
harness = false
16-
17-
[features]
18-
default = ["std"]
19-
alloc = []
20-
std = []
2+
name = "lfm"
3+
version = "0.1.0"
4+
edition = "2024"
5+
rust-version = "1.95"
6+
description = "Rust ONNX inference for LiquidAI LFM2.5-VL (vision-language) models"
7+
license = "MIT OR Apache-2.0"
8+
include = [
9+
"src/**/*.rs",
10+
"examples/**/*.rs",
11+
"benches/**/*.rs",
12+
"models/*.json",
13+
"models/*.jinja",
14+
"build.rs",
15+
"Cargo.toml",
16+
"README.md",
17+
"CHANGELOG.md",
18+
"LICENSE-*",
19+
]
2120

2221
[dependencies]
22+
# vlm-tasks: declares both `path` (for in-workspace dev) AND `version`
23+
# (so the published .crate can resolve the dependency from crates.io).
24+
# Codex round 27 finding 1: a path-only dep blocks `cargo publish`
25+
# because the packaged crate strips the path and consumers need a
26+
# version to resolve.
27+
vlm-tasks = { path = "../vlm-tasks", version = "0.1" }
28+
ort = { version = "2.0.0-rc.12", optional = true }
29+
tokenizers = { version = "0.23", optional = true }
30+
llguidance = { version = "1.7", optional = true }
31+
toktrie = { version = "1.7", optional = true }
32+
toktrie_hf_tokenizers = { version = "1.7", optional = true }
33+
minijinja = { version = "2", optional = true, default-features = false, features = ["builtins", "json", "macros", "serde"] }
34+
image = { version = "0.25", default-features = false }
35+
smol_str = "0.3"
36+
thiserror = "2"
37+
tracing = "0.1"
38+
serde = { version = "1", features = ["derive"] }
39+
serde_json = { version = "1" }
40+
fast_image_resize = "6.0.0"
2341

2442
[dev-dependencies]
43+
serde_json = "1"
44+
45+
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
2546
criterion = "0.8"
26-
tempfile = "3"
47+
48+
[features]
49+
default = ["inference", "bundled", "decoders"]
50+
# `inference` provides ORT, tokenizers, llguidance, minijinja. The Engine
51+
# additionally requires `decoders` for image preprocessing — see
52+
# src/lib.rs Engine gate. Use `inference` alone for the runtime types
53+
# (Sampler, Decoder, KvCache, etc.) without the public Engine.
54+
inference = ["dep:ort", "dep:tokenizers", "dep:llguidance", "dep:toktrie", "dep:toktrie_hf_tokenizers", "dep:minijinja"]
55+
# `bundled` ships the tokenizer + small JSON configs as include_bytes!.
56+
# It implies both `inference` (for Engine machinery) AND `decoders`
57+
# (for the `Engine::from_onnx_dir` constructor); without this, a
58+
# `--features bundled` build would fail to expose Engine.
59+
bundled = ["inference", "decoders"]
60+
decoders = ["image/jpeg", "image/png"]
61+
serde = ["smol_str/serde", "vlm-tasks/serde"]
62+
cuda = ["inference", "ort/cuda"]
63+
tensorrt = ["inference", "ort/tensorrt"]
64+
directml = ["inference", "ort/directml"]
65+
rocm = ["inference", "ort/rocm"]
66+
coreml = ["inference", "ort/coreml"]
67+
integration = ["inference"]
68+
69+
[[test]]
70+
name = "integration"
71+
path = "tests/integration.rs"
72+
required-features = ["integration"]
73+
74+
[[example]]
75+
name = "smoke"
76+
# Engine + from_dir require all three: bundled (for tokenizer assets +
77+
# from_dir gate), inference (for the runtime), decoders (for image
78+
# decode). Round-27 fix: was incorrectly listed as `inference`-only.
79+
required-features = ["bundled", "inference", "decoders"]
80+
[[example]]
81+
name = "scene_analysis"
82+
required-features = ["bundled", "inference", "decoders"]
83+
[[example]]
84+
name = "preprocess_only"
85+
86+
[[bench]]
87+
name = "bench_preproc"
88+
harness = false
89+
[[bench]]
90+
name = "bench_tile_grid"
91+
harness = false
92+
[[bench]]
93+
name = "bench_chat_template"
94+
harness = false
95+
required-features = ["inference"]
2796

2897
[profile.bench]
29-
opt-level = 3
30-
debug = false
31-
codegen-units = 1
32-
lto = 'thin'
33-
incremental = false
34-
debug-assertions = false
35-
overflow-checks = false
36-
rpath = false
98+
opt-level = 3
99+
debug = false
100+
codegen-units = 1
101+
lto = 'thin'
102+
incremental = false
103+
debug-assertions = false
104+
overflow-checks = false
105+
rpath = false
37106

38107
[package.metadata.docs.rs]
39-
all-features = true
108+
features = ["inference", "bundled", "decoders", "serde"]
40109
rustdoc-args = ["--cfg", "docsrs"]
41110

42111
[lints.rust]
43-
rust_2018_idioms = "warn"
112+
rust_2018_idioms = "warn"
44113
single_use_lifetimes = "warn"
45-
unexpected_cfgs = { level = "warn", check-cfg = [
46-
'cfg(all_tests)',
47-
'cfg(tarpaulin)',
48-
] }
114+
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(docsrs)', 'cfg(tarpaulin)'] }

0 commit comments

Comments
 (0)