Skip to main content

llm_verify/
util.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Small self-contained helpers. Deliberately dependency-free: every one of
3//! these would otherwise pull in a crate (chrono, rand, tiktoken) that costs
4//! more binary size than the handful of lines it replaces.
5
6use std::time::{SystemTime, UNIX_EPOCH};
7
8// ── time ───────────────────────────────────────────────────────────────────
9
10/// Unix milliseconds. Used for durations and PRNG seeding.
11pub fn now_ms() -> u128 {
12    SystemTime::now()
13        .duration_since(UNIX_EPOCH)
14        .map(|d| d.as_millis())
15        .unwrap_or(0)
16}
17
18/// RFC 3339 UTC timestamp, e.g. `2026-08-13T09:41:07Z`.
19///
20/// Implements the civil-from-days algorithm rather than pulling in chrono,
21/// which would add ~300KB and a time-zone database we never consult.
22pub fn iso8601_utc() -> String {
23    let secs = (now_ms() / 1000) as i64;
24    let days = secs.div_euclid(86_400);
25    let tod = secs.rem_euclid(86_400);
26    let (y, m, d) = civil_from_days(days);
27    format!(
28        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
29        y,
30        m,
31        d,
32        tod / 3600,
33        (tod % 3600) / 60,
34        tod % 60
35    )
36}
37
38/// Howard Hinnant's `civil_from_days`, shifted to a March-based year so the
39/// leap day lands at the end and the month arithmetic stays branch-free.
40fn civil_from_days(z: i64) -> (i64, u32, u32) {
41    let z = z + 719_468;
42    let era = z.div_euclid(146_097);
43    let doe = z.rem_euclid(146_097);
44    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
45    let y = yoe + era * 400;
46    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
47    let mp = (5 * doy + 2) / 153;
48    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
49    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
50    (if m <= 2 { y + 1 } else { y }, m, d)
51}
52
53/// Compact local-ish stamp for filenames: `20260813-094107`.
54pub fn file_stamp() -> String {
55    iso8601_utc()
56        .replace(['-', ':'], "")
57        .replace('T', "-")
58        .replace('Z', "")
59}
60
61// ── PRNG ───────────────────────────────────────────────────────────────────
62
63/// xorshift64*. Seeded per run so probe payloads differ every time — a
64/// provider cannot pre-cache answers to canaries it has not seen.
65pub struct Rng(u64);
66
67impl Rng {
68    pub fn new() -> Self {
69        let seed = now_ms() as u64 ^ 0x9E37_79B9_7F4A_7C15;
70        Self(if seed == 0 { 0xDEAD_BEEF } else { seed })
71    }
72
73    /// Deterministic construction.
74    ///
75    /// A run must be unpredictable *to the endpoint being probed* — a provider
76    /// that can guess the payloads can pre-cache the answers — but that is a
77    /// property of who chooses the seed, not of whether one exists. The CLI
78    /// leaves it to [`Rng::new`]; an embedder that probes on someone else's
79    /// behalf picks the seed itself, keeps it, and can then replay the exact
80    /// run when a verdict is challenged. Tests use it for the obvious reason.
81    pub fn from_seed(seed: u64) -> Self {
82        Self(if seed == 0 { 0xDEAD_BEEF } else { seed })
83    }
84
85    pub fn next_u64(&mut self) -> u64 {
86        let mut x = self.0;
87        x ^= x >> 12;
88        x ^= x << 25;
89        x ^= x >> 27;
90        self.0 = x;
91        x.wrapping_mul(0x2545_F491_4F6C_DD1D)
92    }
93
94    /// Uniform in `[lo, hi]`. Returns `lo` when the range is empty or inverted.
95    pub fn range(&mut self, lo: i64, hi: i64) -> i64 {
96        if hi <= lo {
97            return lo;
98        }
99        let span = (hi - lo + 1) as u64;
100        lo + (self.next_u64() % span) as i64
101    }
102
103    /// Uppercase hex token, used for canary markers.
104    pub fn hex(&mut self, len: usize) -> String {
105        const HEX: &[u8] = b"0123456789ABCDEF";
106        (0..len)
107            .map(|_| HEX[(self.next_u64() % 16) as usize] as char)
108            .collect()
109    }
110}
111
112impl Default for Rng {
113    fn default() -> Self {
114        Self::new()
115    }
116}
117
118// ── token estimation ───────────────────────────────────────────────────────
119
120/// Heuristic token count, used only when the endpoint offers no authoritative
121/// `count_tokens` route.
122///
123/// This is intentionally *not* a real BPE: embedding tiktoken's rank tables
124/// would add several megabytes to the binary. The heuristic is calibrated for
125/// the signal we actually need — inflation detection, where a genuine hit is
126/// 10x to 1000x over baseline, not 10%. Anything derived from this value is
127/// reported as an estimate, and the audit never claims exact billing fraud on
128/// an estimate alone.
129pub fn estimate_tokens(text: &str) -> u32 {
130    let mut cjk = 0usize; // CJK ideographs & kana: roughly 1 token each
131    let mut other = 0usize; // latin/punctuation bytes: roughly 4 chars per token
132    for ch in text.chars() {
133        let c = ch as u32;
134        let is_cjk = (0x3040..=0x30FF).contains(&c)      // kana
135            || (0x3400..=0x4DBF).contains(&c)            // CJK ext A
136            || (0x4E00..=0x9FFF).contains(&c)            // CJK unified
137            || (0xAC00..=0xD7AF).contains(&c)            // hangul
138            || (0xF900..=0xFAFF).contains(&c); // compatibility
139        if is_cjk {
140            cjk += 1;
141        } else {
142            other += ch.len_utf8();
143        }
144    }
145    // The +2 approximates the per-message role/delimiter overhead that every
146    // chat format adds around the content.
147    (cjk + other.div_ceil(4) + 2) as u32
148}
149
150// ── formatting ─────────────────────────────────────────────────────────────
151
152/// Truncate on a char boundary, appending an ellipsis when cut.
153pub fn truncate(s: &str, max: usize) -> String {
154    if s.chars().count() <= max {
155        return s.to_string();
156    }
157    let mut out: String = s.chars().take(max).collect();
158    out.push('…');
159    out
160}
161
162/// Escape for embedding text inside an HTML element or attribute.
163pub fn html_escape(s: &str) -> String {
164    let mut out = String::with_capacity(s.len() + 16);
165    for ch in s.chars() {
166        match ch {
167            '&' => out.push_str("&amp;"),
168            '<' => out.push_str("&lt;"),
169            '>' => out.push_str("&gt;"),
170            '"' => out.push_str("&quot;"),
171            '\'' => out.push_str("&#39;"),
172            _ => out.push(ch),
173        }
174    }
175    out
176}
177
178/// Pad to a fixed *display* width, counting CJK as two columns so mixed-script
179/// table rows stay aligned in a terminal. Truncates rather than overflowing.
180pub fn pad_display(s: &str, width: usize) -> String {
181    let mut used = 0usize;
182    let mut out = String::new();
183    for ch in s.chars() {
184        let w = if (ch as u32) >= 0x1100 && !ch.is_ascii() {
185            2
186        } else {
187            1
188        };
189        if used + w > width {
190            break;
191        }
192        out.push(ch);
193        used += w;
194    }
195    out.push_str(&" ".repeat(width.saturating_sub(used)));
196    out
197}
198
199/// Percentile by nearest-rank over an already-sorted slice.
200pub fn percentile(sorted: &[f64], p: f64) -> f64 {
201    if sorted.is_empty() {
202        return 0.0;
203    }
204    let rank = (p / 100.0 * sorted.len() as f64).ceil() as usize;
205    sorted[rank.clamp(1, sorted.len()) - 1]
206}
207
208pub fn mean(xs: &[f64]) -> f64 {
209    if xs.is_empty() {
210        return 0.0;
211    }
212    xs.iter().sum::<f64>() / xs.len() as f64
213}
214
215pub fn stddev(xs: &[f64]) -> f64 {
216    if xs.len() < 2 {
217        return 0.0;
218    }
219    let m = mean(xs);
220    (xs.iter().map(|x| (x - m).powi(2)).sum::<f64>() / (xs.len() - 1) as f64).sqrt()
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    #[test]
228    fn civil_from_days_matches_known_dates() {
229        assert_eq!(civil_from_days(0), (1970, 1, 1));
230        assert_eq!(civil_from_days(19_723), (2024, 1, 1));
231        assert_eq!(civil_from_days(19_783), (2024, 3, 1)); // day after leap day
232    }
233
234    #[test]
235    fn iso8601_has_expected_shape() {
236        let s = iso8601_utc();
237        assert_eq!(s.len(), 20, "{s}");
238        assert!(s.ends_with('Z'));
239        assert_eq!(&s[4..5], "-");
240        assert_eq!(&s[10..11], "T");
241    }
242
243    #[test]
244    fn rng_is_deterministic_for_a_seed_and_covers_range() {
245        let mut a = Rng::from_seed(42);
246        let mut b = Rng::from_seed(42);
247        assert_eq!(a.next_u64(), b.next_u64());
248        let mut r = Rng::from_seed(7);
249        for _ in 0..500 {
250            let v = r.range(3, 9);
251            assert!((3..=9).contains(&v));
252        }
253        assert_eq!(r.range(5, 5), 5);
254        assert_eq!(r.range(9, 2), 9, "inverted range collapses to lo");
255    }
256
257    #[test]
258    fn estimate_tokens_separates_cjk_from_latin() {
259        // Tiny prompts must stay tiny — this is what inflation detection keys on.
260        assert!(estimate_tokens("Say OK") < 10);
261        // CJK costs about one token per character, so it must exceed a
262        // naive bytes/4 count of the same string.
263        let cjk = "你好世界你好世界";
264        assert!(estimate_tokens(cjk) >= 8, "{}", estimate_tokens(cjk));
265        assert_eq!(estimate_tokens(""), 2);
266    }
267
268    #[test]
269    fn truncate_respects_char_boundaries() {
270        assert_eq!(truncate("hello", 10), "hello");
271        assert_eq!(truncate("你好世界", 2), "你好…");
272    }
273
274    #[test]
275    fn html_escape_covers_all_five_entities() {
276        assert_eq!(
277            html_escape(r#"<a href="x">&'</a>"#),
278            "&lt;a href=&quot;x&quot;&gt;&amp;&#39;&lt;/a&gt;"
279        );
280    }
281
282    #[test]
283    fn pad_display_counts_cjk_as_two_columns() {
284        assert_eq!(pad_display("协议契约", 10), "协议契约  ");
285        assert_eq!(pad_display("abc", 5), "abc  ");
286        // Truncation must not split a character.
287        assert_eq!(pad_display("协议契约检测", 5).chars().count(), 3);
288    }
289
290    #[test]
291    fn percentile_uses_nearest_rank() {
292        let xs = vec![1.0, 2.0, 3.0, 4.0, 5.0];
293        assert_eq!(percentile(&xs, 50.0), 3.0);
294        assert_eq!(percentile(&xs, 100.0), 5.0);
295        assert_eq!(percentile(&xs, 0.0), 1.0);
296        assert_eq!(percentile(&[], 50.0), 0.0);
297    }
298
299    #[test]
300    fn stddev_needs_two_samples() {
301        assert_eq!(stddev(&[5.0]), 0.0);
302        assert!((stddev(&[2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0]) - 2.138).abs() < 0.01);
303    }
304}