pith_text/ffi.rs
1//! The C ABI surface of `pith-text`: the entry points the Python
2//! (ctypes), Node (koffi) and Go (cgo) SDKs bind through.
3//!
4//! The suite's FFI convention, defined by this module and mirrored by
5//! every `pith-*` cdylib:
6//!
7//! * one flat set of `#[unsafe(no_mangle)] pub unsafe extern "C"`
8//! functions — raw pointers plus lengths, no structs across the
9//! boundary;
10//! * every function returns a status code (see the constants below),
11//! never a `Result`, never a panic: a `panic = "abort"` cdylib must
12//! not be reachable from a foreign caller;
13//! * an operation either hands ownership to the caller (and ships a
14//! matching `_free` — [`pith_text_free`] here) or writes into
15//! caller-provided out-parameters — [`pith_text_jaccard`] allocates
16//! nothing, so it ships no free;
17//! * the `unsafe` allowance is confined to this module; every core
18//! module stays unsafe-free behind the crate-root `#![deny]`.
19//!
20//! # Wire formats (the cross-SDK contract)
21//!
22//! `pith_text_fingerprint` hands out the canonical fingerprint stream
23//! of [`crate::reference::canonical_stream`]:
24//!
25//! ```text
26//! [0..4) word_count u32 big-endian
27//! [4..8) shingle_count u32 big-endian
28//! [8..8+C) canonical the canonical UTF-8 bytes (C bytes)
29//! [8+C..) signature 128 u64 little-endian words (1024 bytes)
30//! ```
31//!
32//! so `canonical_sha256 == sha256(stream[8..8+C])` and the
33//! `signature_fnv1a64` / `signature_sha256` folds of `reference.json`
34//! are the standard folds over the 1024-byte little-endian tail. The
35//! input is UTF-8: `len` is a **byte** length, and bytes that are not
36//! valid UTF-8 are rejected ([`PITH_E_REJECTED`]), never lossily
37//! coerced. The empty input is valid and pins the sentinel stream
38//! (zero counts, 128 `u64::MAX` words) — it is *not* a refusal.
39//!
40//! `pith_text_jaccard` compares two `u64` word slices: the words are
41//! read one element at a time with [`core::ptr::read_unaligned`] (the
42//! SDK-side blob is a byte buffer, alignment is not guaranteed), and
43//! the estimate crosses the ABI as its **f64 bit pattern** through a
44//! `u64` out-parameter — no float crosses the boundary, so the SDKs
45//! compare `value_bits` hex-exactly. A null pointer with `len == 0`
46//! is the legal empty slice — the sentinel operand
47//! (`reference.json`'s `empty-signature` arm passes `Vec::new()`,
48//! *not* `signature("")`, whose all-`u64::MAX` words would score
49//! ≈ 0) — while a null pointer with `len > 0` is [`PITH_E_INVALID`].
50
51#![allow(unsafe_code)]
52
53use alloc::boxed::Box;
54use alloc::vec::Vec;
55
56use crate::jaccard_estimate;
57use crate::reference::canonical_stream;
58
59/// Status: success.
60pub const PITH_OK: i32 = 0;
61/// Status: a caller argument is invalid — a null pointer (or a null
62/// buffer carrying a nonzero length where a slice is read).
63pub const PITH_E_INVALID: i32 = -1;
64/// Status: the core pipeline refused the input (the bytes are not
65/// valid UTF-8).
66pub const PITH_E_REJECTED: i32 = -2;
67
68/// Computes the full text fingerprint of a UTF-8 string into the
69/// canonical byte stream the `reference.json` vectors are defined
70/// over.
71///
72/// `data` points at `len` **bytes** of UTF-8 input (a byte length, so
73/// multi-ordinal inputs are passed whole, never truncated to a
74/// character count); `len == 0` — with or without a null `data` — is
75/// the valid empty input. On success the function allocates a buffer,
76/// writes its address through `out`, its length through `out_len`,
77/// and returns [`PITH_OK`]; the caller owns the buffer and must
78/// release it with [`pith_text_free`], passing back the same pointer
79/// *and* length. The buffer layout is documented in the [module
80/// docs](self): big-endian counts header, canonical UTF-8 bytes,
81/// 128 little-endian signature words.
82///
83/// # Safety
84///
85/// `data` must point to `len` readable bytes; `out` to one writable
86/// pointer; `out_len` to one writable `usize`. All must stay valid
87/// for the duration of the call; the function retains nothing.
88#[unsafe(no_mangle)]
89pub unsafe extern "C" fn pith_text_fingerprint(
90 data: *const u8,
91 len: usize,
92 out: *mut *mut u8,
93 out_len: *mut usize,
94) -> i32 {
95 if out.is_null() || out_len.is_null() {
96 return PITH_E_INVALID;
97 }
98 let bytes: &[u8] = if len == 0 {
99 // The empty input is legal (the canon-empty / sentinel case);
100 // a null pointer carries no bytes to read.
101 &[]
102 } else {
103 if data.is_null() {
104 return PITH_E_INVALID;
105 }
106 unsafe { core::slice::from_raw_parts(data, len) }
107 };
108 match fingerprint_stream(bytes) {
109 Ok(stream) => {
110 let len = stream.len();
111 // Hand the exact-length buffer to the caller; `pith_text_free`
112 // reconstructs the boxed slice from the same length.
113 let ptr = Box::into_raw(stream.into_boxed_slice());
114 unsafe {
115 *out = ptr.cast::<u8>();
116 *out_len = len;
117 }
118 PITH_OK
119 }
120 Err(status) => status,
121 }
122}
123
124/// Estimates the Jaccard index of the two shingle sets that produced
125/// the `a_words` and `b_words` signature words.
126///
127/// `a` and `b` point at the callers' `u64` signature words (the
128/// little-endian tail of a [`pith_text_fingerprint`] stream); the
129/// words are read unaligned-safe, so the SDK-side buffers carry no
130/// alignment promise. Callers comparing signatures pass full
131/// 128-word signatures; the empty slice (`len == 0`, null allowed)
132/// is the sentinel operand of `reference.json`'s `empty-signature`
133/// vectors. On success the estimate is written through `out_bits` as
134/// its `f64` bit pattern and [`PITH_OK`] is returned.
135///
136/// # Safety
137///
138/// `a` must point to `a_words` readable `u64` values and `b` to
139/// `b_words` readable `u64` values (null is only legal with a zero
140/// length); `out_bits` must point to one writable `u64`. All must
141/// stay valid for the duration of the call; the function retains
142/// nothing.
143#[unsafe(no_mangle)]
144pub unsafe extern "C" fn pith_text_jaccard(
145 a: *const u64,
146 a_words: usize,
147 b: *const u64,
148 b_words: usize,
149 out_bits: *mut u64,
150) -> i32 {
151 if out_bits.is_null() {
152 return PITH_E_INVALID;
153 }
154 let a = match unsafe { read_words(a, a_words) } {
155 Ok(words) => words,
156 Err(status) => return status,
157 };
158 let b = match unsafe { read_words(b, b_words) } {
159 Ok(words) => words,
160 Err(status) => return status,
161 };
162 unsafe { *out_bits = jaccard_bits(&a, &b) };
163 PITH_OK
164}
165
166/// Releases a buffer handed out by [`pith_text_fingerprint`].
167///
168/// # Safety
169///
170/// `ptr` must be a pointer returned by [`pith_text_fingerprint`]
171/// with the `out_len` value that came back with it, and must not
172/// have been released (or otherwise freed) before. Null is accepted
173/// and ignored, so callers can free unconditionally on the error
174/// path.
175#[unsafe(no_mangle)]
176pub unsafe extern "C" fn pith_text_free(ptr: *mut u8, len: usize) {
177 if ptr.is_null() {
178 return;
179 }
180 let slice = unsafe { core::slice::from_raw_parts_mut(ptr, len) };
181 drop(unsafe { Box::from_raw(slice) });
182}
183
184/// The safe core of [`pith_text_fingerprint`]: validate UTF-8, then
185/// serialize the canonical stream. Non-UTF-8 bytes map to
186/// [`PITH_E_REJECTED`].
187fn fingerprint_stream(bytes: &[u8]) -> Result<Vec<u8>, i32> {
188 let text = core::str::from_utf8(bytes).map_err(|_| PITH_E_REJECTED)?;
189 Ok(canonical_stream(text))
190}
191
192/// The safe core of [`pith_text_jaccard`]: the estimate as its `f64`
193/// bit pattern.
194fn jaccard_bits(a: &[u64], b: &[u64]) -> u64 {
195 jaccard_estimate(a, b).to_bits()
196}
197
198/// Copies `words` `u64` values out of `ptr`, one
199/// [`core::ptr::read_unaligned`] per element — the SDK-side blob is a
200/// byte buffer, alignment is not guaranteed. A null pointer with a
201/// zero length is the legal empty slice; a null pointer with a
202/// nonzero length is [`PITH_E_INVALID`].
203///
204/// # Safety
205///
206/// `ptr` must point to `words` readable `u64` values (or be null
207/// with `words == 0`).
208unsafe fn read_words(ptr: *const u64, words: usize) -> Result<Vec<u64>, i32> {
209 if ptr.is_null() {
210 if words == 0 {
211 return Ok(Vec::new());
212 }
213 return Err(PITH_E_INVALID);
214 }
215 let mut out = Vec::new();
216 for i in 0..words {
217 out.push(unsafe { core::ptr::read_unaligned(ptr.add(i)) });
218 }
219 Ok(out)
220}
221
222#[cfg(test)]
223mod tests {
224 use alloc::vec::Vec;
225
226 use super::{
227 PITH_E_INVALID, PITH_E_REJECTED, PITH_OK, fingerprint_stream, jaccard_bits,
228 pith_text_fingerprint, pith_text_free, pith_text_jaccard, read_words,
229 };
230
231 /// The canonical stream through the public module path, from raw
232 /// input bytes — exactly the wire the FFI serves.
233 fn canonical_stream_core(input: &[u8]) -> Vec<u8> {
234 crate::reference::canonical_stream(core::str::from_utf8(input).expect("utf-8"))
235 }
236
237 /// Loads a committed fixture's bytes (UTF-8 by conformance).
238 fn fixture(name: &str) -> Vec<u8> {
239 let path = format!("{}/tests/fixtures/{name}.txt", env!("CARGO_MANIFEST_DIR"));
240 std::fs::read(&path).expect("fixture")
241 }
242
243 /// The signature words of a fixture (the little-endian tail of its
244 /// fingerprint stream), for jaccard pointer round-trips.
245 fn fixture_words(name: &str) -> Vec<u64> {
246 let stream = fingerprint_stream(&fixture(name)).expect("utf-8");
247 let tail = &stream[stream.len() - 1024..];
248 tail.chunks_exact(8)
249 .map(|c| u64::from_le_bytes(c.try_into().unwrap()))
250 .collect()
251 }
252
253 /// The FFI reproduces the safe core byte-for-byte and hands the
254 /// buffer back through `pith_text_free`.
255 #[test]
256 fn ffi_fingerprint_roundtrips_the_canonical_stream() {
257 let input = b"alpha beta gamma";
258 let expected = canonical_stream_core(input);
259 assert_eq!(expected.len(), 8 + 17 + 1024);
260
261 let mut out: *mut u8 = core::ptr::null_mut();
262 let mut out_len: usize = 0;
263 let status =
264 unsafe { pith_text_fingerprint(input.as_ptr(), input.len(), &mut out, &mut out_len) };
265 assert_eq!(status, PITH_OK);
266 assert_eq!(out_len, expected.len());
267 let handed_back = unsafe { core::slice::from_raw_parts(out, out_len) };
268 assert_eq!(handed_back, expected.as_slice());
269 // The header is the documented one: 3 words, 1 shingle.
270 assert_eq!(&handed_back[..8], &[0, 0, 0, 3, 0, 0, 0, 1]);
271 unsafe { pith_text_free(out, out_len) };
272 }
273
274 /// The empty input is valid and carries the sentinel stream (this
275 /// is the canon-empty / signature-empty-sentinel case, not a
276 /// refusal).
277 #[test]
278 fn ffi_fingerprint_accepts_the_empty_input() {
279 let expected = canonical_stream_core(b"");
280 assert_eq!(expected.len(), 8 + 1 + 1024);
281
282 let empty: Vec<u8> = Vec::new();
283 let mut out: *mut u8 = core::ptr::null_mut();
284 let mut out_len: usize = 0;
285 let status =
286 unsafe { pith_text_fingerprint(empty.as_ptr(), empty.len(), &mut out, &mut out_len) };
287 assert_eq!(status, PITH_OK);
288 let handed_back = unsafe { core::slice::from_raw_parts(out, out_len) };
289 assert_eq!(handed_back, expected.as_slice());
290 assert_eq!(handed_back[8], b'\n');
291 assert!(handed_back[9..].iter().all(|&b| b == 0xff));
292 unsafe { pith_text_free(out, out_len) };
293
294 // A null pointer with len == 0 is the same legal empty input
295 // (how the Go binding passes zero-length operands).
296 let status = unsafe { pith_text_fingerprint(core::ptr::null(), 0, &mut out, &mut out_len) };
297 assert_eq!(status, PITH_OK);
298 unsafe { pith_text_free(out, out_len) };
299 }
300
301 /// Null out-parameters and a null buffer with a nonzero length are
302 /// [`PITH_E_INVALID`]; non-UTF-8 bytes are [`PITH_E_REJECTED`]; a
303 /// null buffer is a legal free.
304 #[test]
305 fn ffi_fingerprint_refusals() {
306 let mut out: *mut u8 = core::ptr::null_mut();
307 let mut out_len: usize = 0;
308 let input = b"text";
309
310 let null_out = unsafe {
311 pith_text_fingerprint(
312 input.as_ptr(),
313 input.len(),
314 core::ptr::null_mut(),
315 &mut out_len,
316 )
317 };
318 assert_eq!(null_out, PITH_E_INVALID);
319
320 let null_out_len = unsafe {
321 pith_text_fingerprint(input.as_ptr(), input.len(), &mut out, core::ptr::null_mut())
322 };
323 assert_eq!(null_out_len, PITH_E_INVALID);
324
325 let null_data = unsafe {
326 pith_text_fingerprint(core::ptr::null(), input.len(), &mut out, &mut out_len)
327 };
328 assert_eq!(null_data, PITH_E_INVALID);
329
330 let bad_utf8: &[u8] = &[b'o', b'n', b'e', 0xFF];
331 let status = unsafe {
332 pith_text_fingerprint(bad_utf8.as_ptr(), bad_utf8.len(), &mut out, &mut out_len)
333 };
334 assert_eq!(status, PITH_E_REJECTED);
335
336 unsafe { pith_text_free(core::ptr::null_mut(), 0) };
337 }
338
339 /// The safe core rejects non-UTF-8 bytes with the documented
340 /// status instead of panicking.
341 #[test]
342 fn safe_core_rejects_non_utf8() {
343 assert_eq!(fingerprint_stream(&[0xFF]), Err(PITH_E_REJECTED));
344 assert_eq!(fingerprint_stream(b"ok"), Ok(canonical_stream_core(b"ok")));
345 }
346
347 /// The jaccard FFI reproduces the `reference.json` pins through
348 /// raw, deliberately misaligned-style pointers: near-duplicate,
349 /// unrelated, identical, and the empty-slice sentinel operand.
350 #[test]
351 fn ffi_jaccard_matches_reference_pins() {
352 let doc_a = fixture_words("doc_a");
353 let doc_a_edit = fixture_words("doc_a_edit");
354 let doc_b = fixture_words("doc_b");
355 let prose = fixture_words("prose");
356
357 let mut bits: u64 = 0;
358 // jaccard-near-duplicate: doc_a vs doc_a_edit == 0x3fef800000000000.
359 let status = unsafe {
360 pith_text_jaccard(
361 doc_a.as_ptr(),
362 doc_a.len(),
363 doc_a_edit.as_ptr(),
364 doc_a_edit.len(),
365 &mut bits,
366 )
367 };
368 assert_eq!(status, PITH_OK);
369 assert_eq!(bits, 0x3fef_8000_0000_0000);
370
371 // jaccard-unrelated: doc_a vs doc_b == 0.
372 let status = unsafe {
373 pith_text_jaccard(
374 doc_a.as_ptr(),
375 doc_a.len(),
376 doc_b.as_ptr(),
377 doc_b.len(),
378 &mut bits,
379 )
380 };
381 assert_eq!(status, PITH_OK);
382 assert_eq!(bits, 0x0000_0000_0000_0000);
383
384 // jaccard-identical: prose vs prose == 1.0.
385 let status = unsafe {
386 pith_text_jaccard(
387 prose.as_ptr(),
388 prose.len(),
389 prose.as_ptr(),
390 prose.len(),
391 &mut bits,
392 )
393 };
394 assert_eq!(status, PITH_OK);
395 assert_eq!(bits, 0x3ff0_0000_0000_0000);
396
397 // jaccard-against-empty: doc_a vs the EMPTY slice (null, 0
398 // words) == 1.0 — `Vec::new()`, not signature("").
399 let status = unsafe {
400 pith_text_jaccard(doc_a.as_ptr(), doc_a.len(), core::ptr::null(), 0, &mut bits)
401 };
402 assert_eq!(status, PITH_OK);
403 assert_eq!(bits, 0x3ff0_0000_0000_0000);
404
405 // empty vs empty is the degenerate 1.0.
406 let status =
407 unsafe { pith_text_jaccard(core::ptr::null(), 0, core::ptr::null(), 0, &mut bits) };
408 assert_eq!(status, PITH_OK);
409 assert_eq!(bits, 0x3ff0_0000_0000_0000);
410 }
411
412 /// A null word pointer with a nonzero length and a null
413 /// out-parameter are [`PITH_E_INVALID`].
414 #[test]
415 fn ffi_jaccard_refusals() {
416 let words = [1u64, 2, 3];
417 let mut bits: u64 = 0;
418
419 let null_bits = unsafe {
420 pith_text_jaccard(
421 words.as_ptr(),
422 words.len(),
423 words.as_ptr(),
424 words.len(),
425 core::ptr::null_mut(),
426 )
427 };
428 assert_eq!(null_bits, PITH_E_INVALID);
429
430 let null_a = unsafe {
431 pith_text_jaccard(core::ptr::null(), 1, words.as_ptr(), words.len(), &mut bits)
432 };
433 assert_eq!(null_a, PITH_E_INVALID);
434
435 let null_b = unsafe {
436 pith_text_jaccard(words.as_ptr(), words.len(), core::ptr::null(), 2, &mut bits)
437 };
438 assert_eq!(null_b, PITH_E_INVALID);
439 }
440
441 /// [`read_words`] and [`jaccard_bits`] mirror the FFI contract in
442 /// the safe world: null+0 is empty, null+len is invalid, and the
443 /// bits are `f64::to_bits` of the estimate.
444 #[test]
445 fn safe_cores_match_the_abi_contract() {
446 assert_eq!(unsafe { read_words(core::ptr::null(), 0) }, Ok(Vec::new()));
447 assert_eq!(
448 unsafe { read_words(core::ptr::null(), 1) },
449 Err(PITH_E_INVALID)
450 );
451 let words = [7u64, 9];
452 assert_eq!(
453 unsafe { read_words(words.as_ptr(), words.len()) },
454 Ok(words.to_vec())
455 );
456 assert_eq!(jaccard_bits(&[], &[]), 1.0f64.to_bits());
457 // Equal words → 1.0; differing words at the common prefix → 0.0.
458 assert_eq!(jaccard_bits(&[1], &[1]), 1.0f64.to_bits());
459 assert_eq!(jaccard_bits(&[1], &[2]), 0.0f64.to_bits());
460 }
461}