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