1use std::time::{SystemTime, UNIX_EPOCH};
7
8pub fn now_ms() -> u128 {
12 SystemTime::now()
13 .duration_since(UNIX_EPOCH)
14 .map(|d| d.as_millis())
15 .unwrap_or(0)
16}
17
18pub 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
38fn 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
53pub fn file_stamp() -> String {
55 iso8601_utc()
56 .replace(['-', ':'], "")
57 .replace('T', "-")
58 .replace('Z', "")
59}
60
61pub 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 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 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 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
118pub fn estimate_tokens(text: &str) -> u32 {
130 let mut cjk = 0usize; let mut other = 0usize; for ch in text.chars() {
133 let c = ch as u32;
134 let is_cjk = (0x3040..=0x30FF).contains(&c) || (0x3400..=0x4DBF).contains(&c) || (0x4E00..=0x9FFF).contains(&c) || (0xAC00..=0xD7AF).contains(&c) || (0xF900..=0xFAFF).contains(&c); if is_cjk {
140 cjk += 1;
141 } else {
142 other += ch.len_utf8();
143 }
144 }
145 (cjk + other.div_ceil(4) + 2) as u32
148}
149
150pub 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
162pub 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("&"),
168 '<' => out.push_str("<"),
169 '>' => out.push_str(">"),
170 '"' => out.push_str("""),
171 '\'' => out.push_str("'"),
172 _ => out.push(ch),
173 }
174 }
175 out
176}
177
178pub 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
199pub 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)); }
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 assert!(estimate_tokens("Say OK") < 10);
261 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 "<a href="x">&'</a>"
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 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}