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}