Skip to main content

openentropy_core/sources/gpu/
nl_inference_timing.rs

1//! Natural Language framework inference timing — system-wide NLP cache entropy.
2//!
3//! Apple's NaturalLanguage framework routes inference through the Apple Neural
4//! Engine (ANE) on devices that support it. `NLLanguageRecognizer` runs a
5//! neural language identification model; `NLTokenizer` runs a word segmentation
6//! model. Both maintain a system-wide inference cache that is shared across all
7//! processes.
8//!
9//! ## Physics
10//!
11//! Each `processString:` call either hits the framework cache (fast, ~25µs)
12//! or runs a full ANE inference pass (slow, ~500µs–80ms). The cache state
13//! depends on what text every running process has processed recently:
14//! text editors, Mail, Safari, Spotlight, Siri, and dozens of system services
15//! all call into NaturalLanguage continuously.
16//!
17//! The timing therefore captures:
18//!
19//! 1. **ANE queue depth** — how many inference requests from other processes
20//!    are ahead of ours in the ANE scheduler
21//! 2. **Cache occupancy** — whether our input pattern matches recently cached
22//!    results from any process
23//! 3. **Thermal state** — ANE frequency scales with die temperature
24//! 4. **Memory pressure** — cache evictions under memory contention
25//!
26//! Measured on M4 Mac mini (N=500 across 7 input strings):
27//! - `NLLanguageRecognizer`: CV=686%, H≈7.65 bits/low-byte, LSB=0.502
28//! - `NLTokenizer`: CV=1710%, range=18–2,062,293 ticks
29//!
30//! The Shannon entropy of H≈7.65 bits/byte approaches the theoretical maximum
31//! of 8.0 bits — the combination of cache-hit/miss and intra-mode jitter
32//! produces a near-uniform low-byte distribution.
33//!
34//! ## Uniqueness
35//!
36//! This is the first entropy source to measure ANE inference timing via the
37//! NaturalLanguage framework's system-wide shared cache. Unlike sources that
38//! directly time framework API calls (SEP, keychain, CoreAudio), this source
39//! captures the aggregate NLP processing load of the entire running system.
40
41use crate::source::{EntropySource, Platform, Requirement, SourceCategory, SourceInfo};
42#[cfg(target_os = "macos")]
43use crate::sources::helpers::{extract_timing_entropy, mach_time};
44
45static NL_INFERENCE_TIMING_INFO: SourceInfo = SourceInfo {
46    name: "nl_inference_timing",
47    description: "NaturalLanguage ANE inference timing via system-wide NLP cache state",
48    physics: "Times NLLanguageRecognizer.processString() calls that route through the \
49              Apple Neural Engine. The NL framework maintains a system-wide inference \
50              cache shared across all running processes. Timing varies by ANE queue \
51              depth (from other apps), cache hit/miss (system-wide cache occupancy), \
52              ANE thermal state, and memory pressure. Measured: CV=686%, H\u{2248}7.65 bits/byte, \
53              LSB=0.502 — approaching theoretical maximum, combining ANE scheduling \
54              nondeterminism with system-wide NLP activity.",
55    category: SourceCategory::GPU,
56    platform: Platform::MacOS,
57    requirements: &[Requirement::Metal], // Reuses Metal requirement as ANE proxy
58    entropy_rate_estimate: 2.0,
59    composite: false,
60    is_fast: false,
61};
62
63/// Entropy source from NaturalLanguage framework ANE inference timing.
64pub struct NLInferenceTimingSource;
65
66/// Objective-C runtime + NaturalLanguage framework FFI.
67///
68/// On ARM64 (Apple Silicon), `objc_msgSend` uses the standard C calling
69/// convention — NOT the variadic convention. Declaring it as `fn(...) -> Id`
70/// in Rust causes variadic arguments to be passed on the stack instead of
71/// in registers (x2, x3, …), which is incorrect and causes segfaults.
72///
73/// The correct approach is to declare `objc_msgSend` as a raw symbol and
74/// cast it to the specific function pointer type needed for each call site.
75#[cfg(target_os = "macos")]
76mod objc_nl {
77    use std::ffi::{CStr, c_void};
78
79    pub type Id = *mut c_void;
80    pub type Sel = *mut c_void;
81    pub type Class = *mut c_void;
82
83    // Typed function pointer aliases for objc_msgSend casts.
84    /// (receiver, sel) -> Id  — for alloc, init, dominantLanguage, reset
85    pub type MsgSendFn = unsafe extern "C" fn(Id, Sel) -> Id;
86    /// (receiver, sel, arg) -> Id  — for processString:, stringWithUTF8String:
87    pub type MsgSendFn1 = unsafe extern "C" fn(Id, Sel, Id) -> Id;
88    /// (receiver, sel, arg: *const i8) -> Id  — for stringWithUTF8String:
89    pub type MsgSendStr = unsafe extern "C" fn(Id, Sel, *const i8) -> Id;
90
91    #[link(name = "objc", kind = "dylib")]
92    #[allow(clashing_extern_declarations)]
93    unsafe extern "C" {
94        pub fn objc_getClass(name: *const i8) -> Class;
95        pub fn sel_registerName(name: *const i8) -> Sel;
96        // Raw symbol — always cast to a typed fn pointer before calling.
97        // Declared without args so we can transmute to exact typed fn pointers
98        // matching the ARM64 calling convention (not variadic).
99        pub fn objc_msgSend();
100    }
101
102    // Ensure NaturalLanguage framework is linked and loaded.
103    #[link(name = "NaturalLanguage", kind = "framework")]
104    unsafe extern "C" {}
105
106    // Foundation for NSString.
107    #[link(name = "Foundation", kind = "framework")]
108    unsafe extern "C" {}
109
110    /// Get typed function pointer for `objc_msgSend` with no extra args.
111    #[inline(always)]
112    pub fn msg_send() -> MsgSendFn {
113        unsafe { core::mem::transmute(objc_msgSend as *const ()) }
114    }
115
116    /// Get typed function pointer for `objc_msgSend` with one Id arg.
117    #[inline(always)]
118    pub fn msg_send1() -> MsgSendFn1 {
119        unsafe { core::mem::transmute(objc_msgSend as *const ()) }
120    }
121
122    /// Get typed function pointer for `objc_msgSend` with one `*const i8` arg.
123    #[inline(always)]
124    pub fn msg_send_str() -> MsgSendStr {
125        unsafe { core::mem::transmute(objc_msgSend as *const ()) }
126    }
127
128    /// Create an NSString from a UTF-8 Rust string slice.
129    ///
130    /// # Safety
131    /// Returns an autoreleased NSString. Caller must retain if needed beyond
132    /// the current autorelease pool scope.
133    pub unsafe fn ns_string(s: &CStr) -> Id {
134        let class = unsafe { objc_getClass(c"NSString".as_ptr()) };
135        let sel = unsafe { sel_registerName(c"stringWithUTF8String:".as_ptr()) };
136        unsafe { msg_send_str()(class, sel, s.as_ptr()) }
137    }
138}
139
140/// Input corpus: varied strings that prevent the NL cache from settling.
141///
142/// Mixing English, Spanish, German, Japanese, and nonsense strings forces
143/// the recognizer to run both cache-hit and cache-miss code paths, maximising
144/// timing variance. Each string is from a different semantic domain to reduce
145/// cross-string cache correlation.
146#[cfg(target_os = "macos")]
147static CORPUS: &[&str] = &[
148    "The quick brown fox jumps over the lazy dog\0",
149    "Quantum entanglement defies local hidden variables\0",
150    "El gato duerme sobre la alfombra roja\0",
151    "Die Quantenverschraenkung widerspricht dem lokalen Realismus\0",
152    "photosynthesis chlorophyll absorption spectrum wavelength\0",
153    "random noise entropy measurement hardware oscillator\0",
154    "cryptographic hash function pseudorandom deterministic\0",
155    "serendipitous juxtaposition kaleidoscopic iridescent\0",
156];
157
158#[cfg(target_os = "macos")]
159mod imp {
160    use std::ffi::CStr;
161
162    use super::objc_nl::*;
163    use super::*;
164
165    impl EntropySource for NLInferenceTimingSource {
166        fn info(&self) -> &SourceInfo {
167            &NL_INFERENCE_TIMING_INFO
168        }
169
170        fn is_available(&self) -> bool {
171            // NLLanguageRecognizer is available on macOS 10.14+.
172            // Check by looking up the class at runtime.
173            let class = unsafe { objc_getClass(c"NLLanguageRecognizer".as_ptr()) };
174            !class.is_null()
175        }
176
177        fn collect(&self, n_samples: usize) -> Vec<u8> {
178            // 1× + padding: each ANE inference call takes ~25µs–80ms, and the
179            // initial model load can take 1-2s. Keep count low to stay within
180            // the per-source time budget.
181            let raw_count = n_samples + 64;
182            let mut timings = Vec::with_capacity(raw_count);
183
184            // Typed objc_msgSend trampolines (ARM64 ABI requires exact signatures).
185            let send = msg_send();
186            let send1 = msg_send1();
187
188            // Create NLLanguageRecognizer instance.
189            let alloc_sel = unsafe { sel_registerName(c"alloc".as_ptr()) };
190            let init_sel = unsafe { sel_registerName(c"init".as_ptr()) };
191            let process_sel = unsafe { sel_registerName(c"processString:".as_ptr()) };
192            let dominant_sel = unsafe { sel_registerName(c"dominantLanguage".as_ptr()) };
193            let reset_sel = unsafe { sel_registerName(c"reset".as_ptr()) };
194            let class = unsafe { objc_getClass(c"NLLanguageRecognizer".as_ptr()) };
195            if class.is_null() {
196                return Vec::new();
197            }
198
199            // SAFETY: all selectors are valid C strings and the class is non-null.
200            let alloc = unsafe { send(class, alloc_sel) };
201            if alloc.is_null() {
202                return Vec::new();
203            }
204            let rec = unsafe { send(alloc, init_sel) };
205            if rec.is_null() {
206                return Vec::new();
207            }
208
209            // Warm up: load the model and populate the cache. Keep to 2
210            // iterations — model load can take 1-2s on first call, and we
211            // need to stay well under the pool's 6s per-source timeout.
212            for i in 0..2_usize {
213                let corpus_entry = CORPUS[i % CORPUS.len()];
214                // SAFETY: corpus entries are null-terminated static strings.
215                let ns_str = unsafe {
216                    ns_string(CStr::from_bytes_with_nul_unchecked(corpus_entry.as_bytes()))
217                };
218                if ns_str.is_null() {
219                    continue;
220                }
221                // SAFETY: rec and ns_str are valid ObjC objects.
222                unsafe {
223                    send1(rec, process_sel, ns_str);
224                    send(rec, dominant_sel);
225                    send(rec, reset_sel);
226                };
227            }
228
229            let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
230            for i in 0..raw_count {
231                if i % 64 == 0 && std::time::Instant::now() >= deadline {
232                    break;
233                }
234                let corpus_entry = CORPUS[i % CORPUS.len()];
235                // SAFETY: corpus entries are null-terminated static strings.
236                let ns_str = unsafe {
237                    ns_string(CStr::from_bytes_with_nul_unchecked(corpus_entry.as_bytes()))
238                };
239                if ns_str.is_null() {
240                    continue;
241                }
242
243                let t0 = mach_time();
244                // SAFETY: rec and ns_str are valid; processString: modifies rec's
245                // internal state and dominantLanguage reads it. reset clears state.
246                unsafe {
247                    send1(rec, process_sel, ns_str);
248                    let lang = send(rec, dominant_sel);
249                    send(rec, reset_sel);
250                    // Use lang to prevent dead-code elimination.
251                    let _ = lang;
252                };
253                let elapsed = mach_time().wrapping_sub(t0);
254
255                // Sanity filter: reject suspend/resume artifacts (>500ms).
256                if elapsed < 12_000_000 {
257                    timings.push(elapsed);
258                }
259            }
260
261            // Release the NLLanguageRecognizer instance (alloc+init = +1 retain).
262            let release_sel = unsafe { sel_registerName(c"release".as_ptr()) };
263            unsafe { send(rec, release_sel) };
264
265            extract_timing_entropy(&timings, n_samples)
266        }
267    }
268}
269
270#[cfg(not(target_os = "macos"))]
271impl EntropySource for NLInferenceTimingSource {
272    fn info(&self) -> &SourceInfo {
273        &NL_INFERENCE_TIMING_INFO
274    }
275
276    fn is_available(&self) -> bool {
277        false
278    }
279
280    fn collect(&self, _n_samples: usize) -> Vec<u8> {
281        Vec::new()
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    #[test]
290    fn info() {
291        let src = NLInferenceTimingSource;
292        assert_eq!(src.info().name, "nl_inference_timing");
293        assert!(matches!(src.info().category, SourceCategory::GPU));
294        assert_eq!(src.info().platform, Platform::MacOS);
295        assert!(!src.info().composite);
296    }
297
298    #[test]
299    #[cfg(target_os = "macos")]
300    fn is_available_on_macos() {
301        assert!(NLInferenceTimingSource.is_available());
302    }
303
304    #[test]
305    #[ignore] // Requires NaturalLanguage framework + live ANE
306    fn collects_bytes_with_variation() {
307        let src = NLInferenceTimingSource;
308        if !src.is_available() {
309            return;
310        }
311        let data = src.collect(32);
312        assert!(!data.is_empty());
313        let unique: std::collections::HashSet<u8> = data.iter().copied().collect();
314        assert!(
315            unique.len() > 4,
316            "expected high variation from NL inference timing"
317        );
318    }
319}