cactus_rs/lib.rs
1//! # cactus-rs
2//!
3//! Safe Rust bindings for [Cactus Compute](https://cactuscompute.com)'s Needle engine and the two
4//! models it runs: Needle 3 for tool calling, structured extraction and text embedding, and
5//! Whistle for speech-to-text. On the CPU, with no server.
6//!
7//! ## What is Needle?
8//!
9//! Needle 3 is a 29M to 121M parameter model that does one thing: read a sentence and decide which
10//! of your functions to call with which arguments. It ships as a closed-source prebuilt static
11//! library of nine C functions, with its weights in a separate 35 MB `needle3.cact` archive.
12//! [`cactus-sys`](https://docs.rs/cactus-sys) links that library; this crate is the safe API over
13//! it.
14//!
15//! The engine holds one process-global model per kind, text and speech, behind one
16//! non-thread-safe runtime. There are no handles, no streaming, and weights cannot be unloaded
17//! once they are in. Those are the constraints the API is shaped around, and [`needle::engine`]
18//! and [`whistle::engine`] spell out what each one means for a program.
19//!
20//! ## What is Whistle?
21//!
22//! Whistle is a 16.9 MB speech-to-text model, `whistle.cact`, that runs in the same engine as
23//! Needle rather than in a second one. It transcribes clips of up to 30 seconds of 16 kHz mono
24//! audio in seven languages (English, German, French, Spanish, Italian, Dutch and Polish), with
25//! word timestamps and keyword biasing on request, and turns speech into 512-float embeddings,
26//! one per 80 ms frame. Because the engine keeps one model of each kind, a
27//! [`Whistle`](whistle::Whistle) and a [`Needle`](needle::Needle) live in one process, and
28//! [`Needle::complete_audio`](needle::Needle::complete_audio) turns a spoken request straight
29//! into tool calls.
30//!
31//! ## Key Features
32//!
33//! - **Tool Calling**: declare [`Tool`](needle::Tool)s, get back typed
34//! [`Call`](needle::Call)s with their arguments parsed into your own structs
35//! - **Grounding**: the engine reports which argument values it could not trace back to the
36//! input, and [`Completion::grounded_calls`](needle::Completion::grounded_calls) acts on it
37//! - **Embeddings**: 3072-dimension vectors from the same loaded model
38//! - **Transcription**: [`Whistle::transcribe`](whistle::Whistle::transcribe) turns a clip into
39//! a [`Transcript`](whistle::Transcript), with the language detected or forced
40//! - **Keyword Biasing**: names and jargon passed in
41//! [`TranscribeOptions`](whistle::TranscribeOptions) are favoured when the model hears them
42//! - **Word Timestamps**: each [`Word`](whistle::Word) with its start, end and probability
43//! - **Speech Embeddings**: one 512-float row per 80 ms of audio, from
44//! [`Whistle::embed`](whistle::Whistle::embed)
45//! - **Voice to Tool Calls**: [`Needle::complete_audio`](needle::Needle::complete_audio)
46//! transcribes and answers in one engine call, with the transcript in
47//! [`Completion::audio`](needle::Completion::audio)
48//! - **One Model per Kind, Enforced**: each process-global model is a Rust value, so a second
49//! [`Needle`](needle::Needle) or [`Whistle`](whistle::Whistle) is an [`Error::EngineBusy`],
50//! not a data race
51//! - **Offline**: with `--no-default-features` and `CACTUS_NEEDLE_LIB_DIR` pointing at a local
52//! archive, nothing is downloaded at build time or at run time. `HF_ENDPOINT` points the
53//! weight downloads at a Hugging Face mirror, and `HF_HUB_OFFLINE=1` makes them fail instead
54//! of touching the network
55//!
56//! ## Quick Start
57//!
58//! ```toml
59//! [dependencies]
60//! cactus-rs = "0.2"
61//! serde_json = "1.0"
62//! ```
63//!
64//! ```no_run
65//! use cactus_rs::needle::{Needle, Tool, Weights};
66//! use serde_json::json;
67//!
68//! # #[cfg(feature = "download")]
69//! fn main() -> Result<(), Box<dyn std::error::Error>> {
70//! let weights = Weights::fetch()?; // cached under ~/.cache/cactus-rs
71//! let mut needle = Needle::builder(weights)
72//! .system("You control the lights.")
73//! .tool(Tool::new(
74//! "set_light",
75//! "Turn a room light on or off",
76//! json!({
77//! "type": "object",
78//! "properties": { "room": { "type": "string" }, "on": { "type": "boolean" } },
79//! "required": ["room", "on"]
80//! }),
81//! ))
82//! .build()?;
83//!
84//! let completion = needle.complete("turn the kitchen light on")?;
85//! for call in completion.calls() {
86//! println!("{} {}", call.name(), call.arguments_json());
87//! }
88//! Ok(())
89//! }
90//! # #[cfg(not(feature = "download"))]
91//! # fn main() {}
92//! ```
93//!
94//! That prints `set_light {"room":"kitchen","on":true}`.
95//!
96//! ### Typed Arguments
97//!
98//! A [`Call`](needle::Call) parses into whatever type its schema describes, so the tool's
99//! arguments arrive as a struct rather than a [`serde_json::Value`] to pick apart:
100//!
101//! ```no_run
102//! use cactus_rs::needle::{Needle, Weights};
103//! use serde::Deserialize;
104//!
105//! #[derive(Deserialize)]
106//! struct SetLight {
107//! room: String,
108//! on: bool,
109//! }
110//!
111//! let mut needle = Needle::builder(Weights::from_file("needle3.cact")?).build()?;
112//! let completion = needle.complete("turn the kitchen light on")?;
113//!
114//! for call in completion.calls() {
115//! let args: SetLight = call.parse()?;
116//! println!("{} -> {}", args.room, args.on);
117//! }
118//! # Ok::<(), cactus_rs::Error>(())
119//! ```
120//!
121//! ### Acting Only on Grounded Calls
122//!
123//! The engine checks its own work: it reports argument values it invented, and inputs that
124//! negated the action. When a wrong call would be expensive, act on
125//! [`grounded_calls`](needle::Completion::grounded_calls) instead of
126//! [`calls`](needle::Completion::calls):
127//!
128//! ```no_run
129//! # use cactus_rs::needle::{Needle, Weights};
130//! # let mut needle = Needle::builder(Weights::from_file("needle3.cact")?).build()?;
131//! let completion = needle.complete("do not turn the kitchen light on")?;
132//!
133//! for call in completion.grounded_calls() {
134//! println!("acting on {}", call.name());
135//! }
136//! if !completion.validation().is_grounded() {
137//! println!("held back: {:?}", completion.validation().ungrounded());
138//! }
139//! # Ok::<(), cactus_rs::Error>(())
140//! ```
141//!
142//! ### Embeddings
143//!
144//! ```no_run
145//! # use cactus_rs::needle::{Needle, Weights};
146//! let mut needle = Needle::builder(Weights::from_file("needle3.cact")?).build()?;
147//!
148//! let kitchen = needle.embed("kitchen")?;
149//! assert_eq!(kitchen.len(), needle.embedding_dimension()?);
150//! # Ok::<(), cactus_rs::Error>(())
151//! ```
152//!
153//! ### A Conversation
154//!
155//! Each turn is appended to the one before it, until [`reset`](needle::Needle::reset) rewinds to
156//! the system prompt and tool declarations:
157//!
158//! ```no_run
159//! # use cactus_rs::needle::{Needle, Weights};
160//! # let mut needle = Needle::builder(Weights::from_file("needle3.cact")?).build()?;
161//! needle.complete("turn the kitchen light on")?;
162//! needle.complete("now the hall")?; // the engine still remembers the kitchen
163//! needle.reset(); // it no longer does
164//! # Ok::<(), cactus_rs::Error>(())
165//! ```
166//!
167//! ### Transcribing a Clip
168//!
169//! Clips are plain `&[f32]` slices of 16 kHz mono samples in `[-1, 1]`, so the output of a WAV
170//! reader or a resampler goes in as it is:
171//!
172//! ```no_run
173//! use cactus_rs::whistle::{Language, TranscribeOptions, Weights, Whistle};
174//!
175//! let mut whistle = Whistle::builder(Weights::from_file("whistle.cact")?).build()?;
176//! let pcm = vec![0.0_f32; 16_000]; // one second; read yours from a WAV file
177//!
178//! let transcript = whistle.transcribe(&pcm)?;
179//! println!("{} ({})", transcript.text(), transcript.language_code());
180//!
181//! let options = TranscribeOptions::new()
182//! .with_language(Language::En)
183//! .with_keyword("Siobhan")
184//! .with_word_timestamps(true);
185//! for word in whistle.transcribe_with_options(&pcm, &options)?.words() {
186//! println!("{:>6.2}s {}", word.start(), word.word());
187//! }
188//! # Ok::<(), cactus_rs::Error>(())
189//! ```
190//!
191//! ### Talking to Needle
192//!
193//! Once a [`Whistle`](whistle::Whistle) has loaded the speech model, a
194//! [`Needle`](needle::Needle) can take a clip instead of a sentence. The engine transcribes it,
195//! answers the transcript, and reports what it heard beside the calls. The
196//! [`Whistle`](whistle::Whistle) may be dropped afterwards, because the speech model stays
197//! loaded:
198//!
199//! ```no_run
200//! use cactus_rs::needle::{Needle, Weights};
201//! use cactus_rs::whistle::{self, Whistle};
202//!
203//! let whistle = Whistle::builder(whistle::Weights::from_file("whistle.cact")?).build()?;
204//! drop(whistle); // the speech model stays loaded
205//!
206//! let mut needle = Needle::builder(Weights::from_file("needle3.cact")?).build()?;
207//! let pcm = vec![0.0_f32; 16_000]; // "turn off the kitchen lights", say
208//!
209//! let completion = needle.complete_audio(&pcm)?;
210//! if let Some(heard) = completion.audio() {
211//! println!("heard: {}", heard.text());
212//! }
213//! for call in completion.calls() {
214//! println!("{} {}", call.name(), call.arguments_json());
215//! }
216//! # Ok::<(), cactus_rs::Error>(())
217//! ```
218//!
219//! ## Performance Characteristics
220//!
221//! One measurement per machine, through this crate, of the README's one-tool example, on an
222//! otherwise quiet system (the engine's threads spin rather than sleep, so a busy machine costs
223//! it a lot):
224//!
225//! | Machine | Prefill | Decode | Peak RAM | `complete` | `embed` | `build` |
226//! | --- | --- | --- | --- | --- | --- | --- |
227//! | Apple M1 Pro, macOS | 1900 to 1970 tok/s | 790 to 810 tok/s | 91 MB | 54 ms | 1.9 ms | 6 ms |
228//! | Ryzen 7 3800X (AVX2, no VNNI), Ubuntu 24.04 | 1050 to 1130 tok/s | 230 to 285 tok/s | 103 MB | 176 ms | 5.9 ms | 27 ms |
229//!
230//! Whistle was measured on one machine so far, a shared 4 vCPU Xeon at 2.1 GHz (x86_64, Linux),
231//! so take the numbers as rough. The clip is the 11 second JFK recording in the test data:
232//!
233//! | Machine | `transcribe` (11 s clip) | Time to first token | Decode | `embed` (1 s) | `build` | Peak RAM |
234//! | --- | --- | --- | --- | --- | --- | --- |
235//! | Xeon 2.1 GHz, 4 vCPU, Linux | 1.1 to 1.3 s | 950 to 1200 ms | 200 to 245 tok/s | 30 to 40 ms | 28 ms | 42 MB alone, 90 to 118 MB with Needle |
236//!
237//! macOS has not been measured for Whistle yet.
238//!
239//! The engine runs on the CPU only, with no CUDA or Metal path, and picks its kernels from what
240//! the CPU offers: on x86 one of AVX2, AVX-512, AVX-512 VNNI or AMX, on ARM NEON. The Ryzen above
241//! has only AVX2. Because the kernels differ, a borderline request can come out differently on two
242//! architectures, as it does with upstream's own engine; clear requests give the same calls
243//! everywhere. Embeddings have 3072 dimensions. Every
244//! [`Completion`](needle::Completion) carries its own numbers in [`Stats`](needle::Stats).
245//!
246//! - **Prefix**: the system prompt and tool declarations are tokenised once, at
247//! [`build`](needle::NeedleBuilder::build); [`prefix_tokens`](needle::Needle::prefix_tokens)
248//! is the standing cost every turn pays
249//! - **Buffers**: the output buffer is sized from the token budget and reused across turns, so a
250//! conversation allocates it once
251//! - **Weights**: loaded once per process, and copied by the engine, so the 35 MB archive is
252//! dropped as soon as [`build`](needle::NeedleBuilder::build) returns
253//! - **Embedding**: the dimension is asked for once and cached, so
254//! [`embed`](needle::Needle::embed) is a single engine call afterwards
255//! - **Speech**: [`Whistle`](whistle::Whistle) reuses one 256 KiB output buffer across clips,
256//! and a speech embedding has [`frame_count`](whistle::frame_count) rows of 512 floats,
257//! `max(1, (samples + 1120) / 1280)`
258//!
259//! ## Public Dependencies
260//!
261//! [`serde_json::Value`] and [`serde_json::Error`] appear in this crate's signatures, as does
262//! `schemars::JsonSchema` behind the `schemars` feature, so a major release of either is a major
263//! release here. The crate also enables `serde_json`'s `preserve_order` feature, because the
264//! model follows tool schemas best in their declared key order. Cargo unifies features across a
265//! build, so every `serde_json::Map` in a program that depends on this crate keeps insertion
266//! order.
267//!
268//! ## Safety Guarantees
269//!
270//! - All `unsafe` lives in the crate's private FFI layer, and each block carries a `// SAFETY:`
271//! comment naming the lock it holds and the contract it satisfies
272//! - Every engine call, of either model, takes one process-wide lock, so a
273//! [`Needle`](needle::Needle) and a [`Whistle`](whistle::Whistle) on different threads take
274//! turns instead of overlapping. Both are [`Send`] and [`Sync`], and every method that reaches
275//! the engine takes `&mut self`, so one conversation's turns stay in order
276//! - One value holds each model: a second [`NeedleBuilder::build`](needle::NeedleBuilder::build)
277//! or [`WhistleBuilder::build`](whistle::WhistleBuilder::build) returns
278//! [`Error::EngineBusy`] rather than calling into a model someone else is using
279//! - Weights are validated before the engine sees them. Needle and Whistle archives share one
280//! container format, so the builder reads the tensor directory to tell them apart, and a
281//! Whistle archive handed to Needle (or the reverse) is [`Error::WrongModel`] without ever
282//! being loaded. That matters because the engine replaces a loaded model on any successful
283//! load. Loading the same bytes again is skipped, which keeps a text model's conversation
284//! intact, and a second, different archive of the same kind returns
285//! [`Error::WeightsAlreadyLoaded`] instead of replacing the one in use
286//! - Audio is checked before it crosses: a clip over 30 seconds is [`Error::AudioTooLong`], a
287//! NaN or an infinity is [`Error::NonFiniteSample`], and a keyword that would split the
288//! engine's newline-separated list is [`Error::InvalidKeyword`]. Transcription settings are
289//! passed on every call, so one call's language or keywords never leak into the next
290//! - The engine's error string is read and copied under the same lock as the call that set it,
291//! because the header makes it valid only until the next call
292//! - Strings crossing the FFI boundary are rejected for interior NUL bytes rather than being
293//! silently cut short
294//! - The engine truncates its output without saying so; this crate detects it and returns
295//! [`Error::Truncated`] rather than broken JSON
296//!
297//! ## Affiliation
298//!
299//! This crate is unofficial and is not affiliated with Cactus Compute. The engine it links is
300//! published by them under Apache-2.0.
301//!
302//! ## Examples
303//!
304//! - **`lights.rs`**: the Quick Start, with confidence and decode speed
305//! - **`extract.rs`**: a sentence parsed into a typed struct
306//! - **`embed.rs`**: embeddings and cosine similarity
307//! - **`custom_weights.rs`**: weights loaded from a path you give
308//! - **`desktop.rs`**: a twelve-tool simulated desktop agent that scores itself against expected calls
309//! - **`transcribe.rs`**: a WAV file transcribed, with language, keywords and word timestamps
310//! - **`voice_lights.rs`**: a spoken request turned into light-switch calls by Needle
311//!
312//! Run any example with `cargo run -p cactus-rs --example <name>`.
313
314#![cfg_attr(docsrs, feature(doc_cfg))]
315#![warn(missing_docs)]
316
317mod archive;
318#[cfg(feature = "download")]
319mod download;
320pub mod error;
321mod ffi;
322mod model;
323pub mod needle;
324pub mod whistle;
325
326pub use error::{Error, Result};
327pub use model::Model;