1const FNV1A_BASIS: u64 = 0xcbf29ce484222325;
5const FNV1A_PRIME: u64 = 0x00000100000001B3;
6
7pub fn fnv1a_64(data: &[u8]) -> u64 {
9 let mut hash = FNV1A_BASIS;
10 for &byte in data {
11 hash ^= byte as u64;
12 hash = hash.wrapping_mul(FNV1A_PRIME);
13 }
14 hash
15}
16
17pub use crate::canon::compute_event_digest;
20
21pub fn hash_meshlet(positions: &[[f32; 3]], indices: &[u32]) -> u64 {
24 let mut hash = FNV1A_BASIS;
25 for pos in positions {
26 for &f in pos {
27 let bytes = f.to_le_bytes();
28 for &b in &bytes {
29 hash ^= b as u64;
30 hash = hash.wrapping_mul(FNV1A_PRIME);
31 }
32 }
33 }
34 for &idx in indices {
35 let bytes = idx.to_le_bytes();
36 for &b in &bytes {
37 hash ^= b as u64;
38 hash = hash.wrapping_mul(FNV1A_PRIME);
39 }
40 }
41 hash
42}
43
44pub fn canon_normalize(s: &str) -> String {
46 s.trim().to_lowercase()
47}
48
49pub fn build_scribe_id(
51 universe_id: &str,
52 timeline_id: &str,
53 world_id: &str,
54 region_id: &str,
55 epoch_id: &str,
56 shard_id: &str,
57 kind: &str,
58 topic: &str,
59 template_version: &str,
60) -> String {
61 format!(
62 "scribe:{}:{}:{}:{}:{}:{}:{}:{}:{}",
63 canon_normalize(universe_id),
64 canon_normalize(timeline_id),
65 canon_normalize(world_id),
66 canon_normalize(region_id),
67 canon_normalize(epoch_id),
68 canon_normalize(shard_id),
69 canon_normalize(kind),
70 canon_normalize(topic),
71 canon_normalize(template_version),
72 )
73}
74
75pub fn build_codex_id(
77 universe_id: &str,
78 timeline_id: &str,
79 world_id: &str,
80 region_id: &str,
81 epoch_id: &str,
82 shard_id: &str,
83 query_key: &str,
84 params_json: &str,
85) -> String {
86 let input = format!(
87 "scope={}:{}:{}:{}:{}:{}|qk={}|params={}|qtv=v1|sv=dreamwell.codex.v1",
88 canon_normalize(universe_id),
89 canon_normalize(timeline_id),
90 canon_normalize(world_id),
91 canon_normalize(region_id),
92 canon_normalize(epoch_id),
93 canon_normalize(shard_id),
94 canon_normalize(query_key),
95 params_json.trim(),
96 );
97 let hash = fnv1a_64(input.as_bytes());
98 format!("codex:{:016x}:v1", hash)
99}
100
101pub fn build_chronicle_id(
103 universe_id: &str,
104 timeline_id: &str,
105 world_id: &str,
106 region_id: &str,
107 epoch_id: &str,
108 shard_id: &str,
109 query_key: &str,
110 params_json: &str,
111 tick_min: u64,
112 tick_max: u64,
113 depth: u32,
114 format: &str,
115) -> String {
116 let input = format!(
117 "scope={}:{}:{}:{}:{}:{}|qk={}|params={}|tmin={}|tmax={}|d={}|fmt={}|ctv=v1|sv=dreamwell.chronicle.v1",
118 canon_normalize(universe_id),
119 canon_normalize(timeline_id),
120 canon_normalize(world_id),
121 canon_normalize(region_id),
122 canon_normalize(epoch_id),
123 canon_normalize(shard_id),
124 canon_normalize(query_key),
125 params_json.trim(),
126 tick_min,
127 tick_max,
128 depth,
129 canon_normalize(format),
130 );
131 let hash = fnv1a_64(input.as_bytes());
132 format!("chronicle:{:016x}:v1", hash)
133}
134
135pub fn build_link_id(
137 universe_id: &str,
138 timeline_id: &str,
139 world_id: &str,
140 region_id: &str,
141 epoch_id: &str,
142 shard_id: &str,
143 from_kind: &str,
144 from_id: &str,
145 to_kind: &str,
146 to_id: &str,
147 reason: &str,
148) -> String {
149 let input = format!(
150 "scope={}:{}:{}:{}:{}:{}|fk={}|fi={}|tk={}|ti={}|r={}",
151 canon_normalize(universe_id),
152 canon_normalize(timeline_id),
153 canon_normalize(world_id),
154 canon_normalize(region_id),
155 canon_normalize(epoch_id),
156 canon_normalize(shard_id),
157 canon_normalize(from_kind),
158 canon_normalize(from_id),
159 canon_normalize(to_kind),
160 canon_normalize(to_id),
161 canon_normalize(reason),
162 );
163 let hash = fnv1a_64(input.as_bytes());
164 format!("link:{:016x}", hash)
165}
166
167pub fn build_citation_log_id(artifact_kind: &str, artifact_id: &str, event_id: &str, tick: u64) -> String {
169 let input = format!("ak={}|ai={}|ei={}|t={}", artifact_kind, artifact_id, event_id, tick);
170 let hash = fnv1a_64(input.as_bytes());
171 format!("log:{:016x}", hash)
172}
173
174pub fn build_citation_str(event_id: &str, tick: u64) -> String {
176 format!("{}@{}", event_id, tick)
177}
178
179pub fn build_citation_key(event_id: &str, tick: u64) -> String {
181 let input = format!("evt={}|tick={}", event_id, tick);
182 let hash = fnv1a_64(input.as_bytes());
183 format!("{:016x}", hash)
184}
185
186pub const CITATION_ROLL_SEED: &str = "dreamwell.citations.v1";
188
189pub fn citation_roll_seed_hash() -> String {
191 let hash = fnv1a_64(CITATION_ROLL_SEED.as_bytes());
192 format!("{:016x}", hash)
193}
194
195pub fn citation_roll_advance(prev_roll_hex: &str, citation_key_hex: &str, tick: u64) -> String {
197 let input = format!("{}|{}|{}", prev_roll_hex, citation_key_hex, tick);
198 let hash = fnv1a_64(input.as_bytes());
199 format!("{:016x}", hash)
200}
201
202pub const HOT_MAX_SCRIBE: usize = 16;
204pub const HOT_MAX_CODEX: usize = 32;
205pub const HOT_MAX_CHRONICLE: usize = 64;
206pub const RECENT_KEYS_SCRIBE: usize = 64;
208pub const RECENT_KEYS_CODEX: usize = 128;
209pub const RECENT_KEYS_CHRONICLE: usize = 128;
210
211#[derive(Debug, Clone)]
216pub struct CitationBuffer {
217 pub hot: Vec<String>,
219 pub roll_hash: String,
221 pub total: u64,
223 pub min_tick: Option<u64>,
225 pub max_tick: u64,
227 pub recent_keys: Vec<String>,
229}
230
231impl CitationBuffer {
232 pub fn new() -> Self {
234 Self {
235 hot: Vec::new(),
236 roll_hash: citation_roll_seed_hash(),
237 total: 0,
238 min_tick: None,
239 max_tick: 0,
240 recent_keys: Vec::new(),
241 }
242 }
243
244 pub fn apply(&mut self, event_id: &str, tick: u64, hot_max: usize, recent_keys_max: usize) -> bool {
246 let recent_keys_max = recent_keys_max.max(1);
247 let cit_key = build_citation_key(event_id, tick);
248
249 if self.recent_keys.iter().any(|k| k == &cit_key) {
250 return false;
251 }
252
253 let cit_str = build_citation_str(event_id, tick);
254
255 self.hot.push(cit_str);
256 if self.hot.len() > hot_max {
257 let excess = self.hot.len() - hot_max;
258 self.hot.drain(0..excess);
259 }
260
261 self.roll_hash = citation_roll_advance(&self.roll_hash, &cit_key, tick);
262 self.total += 1;
263
264 match self.min_tick {
265 None => self.min_tick = Some(tick),
266 Some(current_min) if tick < current_min => self.min_tick = Some(tick),
267 _ => {}
268 }
269 if tick > self.max_tick {
270 self.max_tick = tick;
271 }
272
273 self.recent_keys.push(cit_key);
274 if self.recent_keys.len() > recent_keys_max {
275 let excess = self.recent_keys.len() - recent_keys_max;
276 self.recent_keys.drain(0..excess);
277 }
278
279 true
280 }
281}
282
283impl Default for CitationBuffer {
284 fn default() -> Self {
285 Self::new()
286 }
287}
288
289pub fn apply_bounded_citation(
293 citations_hot: &mut Vec<String>,
294 citations_roll_hash: &mut String,
295 citations_total: &mut u64,
296 citations_min_tick: &mut Option<u64>,
297 citations_max_tick: &mut u64,
298 citations_recent_keys: &mut Vec<String>,
299 event_id: &str,
300 tick: u64,
301 hot_max: usize,
302 recent_keys_max: usize,
303) -> bool {
304 let mut buf = CitationBuffer {
305 hot: std::mem::take(citations_hot),
306 roll_hash: std::mem::take(citations_roll_hash),
307 total: *citations_total,
308 min_tick: *citations_min_tick,
309 max_tick: *citations_max_tick,
310 recent_keys: std::mem::take(citations_recent_keys),
311 };
312
313 let accepted = buf.apply(event_id, tick, hot_max, recent_keys_max);
314
315 *citations_hot = buf.hot;
316 *citations_roll_hash = buf.roll_hash;
317 *citations_total = buf.total;
318 *citations_min_tick = buf.min_tick;
319 *citations_max_tick = buf.max_tick;
320 *citations_recent_keys = buf.recent_keys;
321
322 accepted
323}
324
325pub fn build_scribe_index_id(
327 universe_id: &str,
328 timeline_id: &str,
329 world_id: &str,
330 region_id: &str,
331 epoch_id: &str,
332 shard_id: &str,
333 kind: &str,
334 topic: &str,
335) -> String {
336 format!(
337 "idx::{}::{}::{}::{}::{}::{}::{}::{}",
338 canon_normalize(universe_id),
339 canon_normalize(timeline_id),
340 canon_normalize(world_id),
341 canon_normalize(region_id),
342 canon_normalize(epoch_id),
343 canon_normalize(shard_id),
344 canon_normalize(kind),
345 canon_normalize(topic),
346 )
347}
348
349pub fn build_stat_id(
351 universe_id: &str,
352 timeline_id: &str,
353 world_id: &str,
354 stats_scope: &str,
355 query_key: &str,
356) -> String {
357 format!(
358 "stat::{}::{}::{}::{}::{}",
359 canon_normalize(universe_id),
360 canon_normalize(timeline_id),
361 canon_normalize(world_id),
362 canon_normalize(stats_scope),
363 canon_normalize(query_key),
364 )
365}
366
367pub fn checksum_content(json: &str) -> Result<String, String> {
372 let parsed: serde_json::Value =
373 serde_json::from_str(json).map_err(|e| format!("checksum_content_invalid_json: {}", e))?;
374 let canonical = serde_json::to_string(&parsed).map_err(|e| format!("checksum_content_serialize_failed: {}", e))?;
375 Ok(format!("{:016x}", fnv1a_64(canonical.as_bytes())))
376}
377
378pub fn build_alias_id(universe_id: &str, timeline_id: &str, world_id: &str, alias_key: &str) -> String {
380 format!(
381 "alias::{}::{}::{}::{}",
382 canon_normalize(universe_id),
383 canon_normalize(timeline_id),
384 canon_normalize(world_id),
385 canon_normalize(alias_key),
386 )
387}
388
389#[cfg(test)]
394mod tests {
395 use super::*;
396
397 #[test]
400 fn fnv1a_64_deterministic_same_input() {
401 let a = fnv1a_64(b"hello world");
402 let b = fnv1a_64(b"hello world");
403 assert_eq!(a, b);
404 }
405
406 #[test]
407 fn fnv1a_64_different_inputs_differ() {
408 assert_ne!(fnv1a_64(b"alpha"), fnv1a_64(b"bravo"));
409 }
410
411 #[test]
415 fn fnv1a_64_empty_string() {
416 assert_eq!(fnv1a_64(b""), 0xcbf29ce484222325);
418 }
419
420 #[test]
421 fn fnv1a_64_single_byte_a() {
422 assert_eq!(fnv1a_64(b"a"), 0xaf63dc4c8601ec8c);
423 }
424
425 #[test]
426 fn fnv1a_64_foobar() {
427 assert_eq!(fnv1a_64(b"foobar"), 0x85944171f73967e8);
428 }
429
430 #[test]
431 fn fnv1a_64_single_null_byte() {
432 assert_eq!(fnv1a_64(&[0u8]), 0xaf63bd4c8601b7df);
434 }
435
436 #[test]
439 fn canon_normalize_trims_and_lowercases() {
440 assert_eq!(canon_normalize(" Hello WORLD "), "hello world");
441 }
442
443 #[test]
444 fn canon_normalize_no_op_on_clean_input() {
445 assert_eq!(canon_normalize("already_clean"), "already_clean");
446 }
447
448 #[test]
451 fn build_scribe_id_format() {
452 let id = build_scribe_id("U1", "TL1", "W1", "R1", "E1", "S1", "narrative", "topic1", "v2");
453 assert_eq!(id, "scribe:u1:tl1:w1:r1:e1:s1:narrative:topic1:v2");
454 }
455
456 #[test]
457 fn build_scribe_id_normalizes_whitespace_and_case() {
458 let a = build_scribe_id("U1", "TL1", "W1", "R1", "E1", "S1", "Narrative", "Topic1", "V2");
459 let b = build_scribe_id(
460 " u1 ",
461 " tl1 ",
462 " w1 ",
463 " r1 ",
464 " e1 ",
465 " s1 ",
466 " narrative ",
467 " topic1 ",
468 " v2 ",
469 );
470 assert_eq!(a, b);
471 }
472
473 #[test]
474 fn build_scribe_id_deterministic() {
475 let a = build_scribe_id("u", "t", "w", "r", "e", "s", "k", "p", "v1");
476 let b = build_scribe_id("u", "t", "w", "r", "e", "s", "k", "p", "v1");
477 assert_eq!(a, b);
478 }
479
480 #[test]
483 fn build_codex_id_format() {
484 let id = build_codex_id("u1", "tl1", "w1", "r1", "e1", "s1", "qk1", "{}");
485 assert!(id.starts_with("codex:"));
486 assert!(id.ends_with(":v1"));
487 assert_eq!(id.len(), 25);
489 }
490
491 #[test]
492 fn build_codex_id_deterministic() {
493 let a = build_codex_id("u", "t", "w", "r", "e", "s", "q", "{}");
494 let b = build_codex_id("u", "t", "w", "r", "e", "s", "q", "{}");
495 assert_eq!(a, b);
496 }
497
498 #[test]
499 fn build_codex_id_differs_on_query_key() {
500 let a = build_codex_id("u", "t", "w", "r", "e", "s", "query_a", "{}");
501 let b = build_codex_id("u", "t", "w", "r", "e", "s", "query_b", "{}");
502 assert_ne!(a, b);
503 }
504
505 #[test]
508 fn build_chronicle_id_format() {
509 let id = build_chronicle_id("u1", "tl1", "w1", "r1", "e1", "s1", "qk1", "{}", 0, 100, 3, "json");
510 assert!(id.starts_with("chronicle:"));
511 assert!(id.ends_with(":v1"));
512 }
513
514 #[test]
515 fn build_chronicle_id_deterministic() {
516 let a = build_chronicle_id("u", "t", "w", "r", "e", "s", "q", "{}", 1, 10, 2, "json");
517 let b = build_chronicle_id("u", "t", "w", "r", "e", "s", "q", "{}", 1, 10, 2, "json");
518 assert_eq!(a, b);
519 }
520
521 #[test]
522 fn build_chronicle_id_differs_on_tick_range() {
523 let a = build_chronicle_id("u", "t", "w", "r", "e", "s", "q", "{}", 0, 100, 1, "json");
524 let b = build_chronicle_id("u", "t", "w", "r", "e", "s", "q", "{}", 0, 200, 1, "json");
525 assert_ne!(a, b);
526 }
527
528 #[test]
531 fn build_link_id_format() {
532 let id = build_link_id("u1", "tl1", "w1", "r1", "e1", "s1", "actor", "a1", "item", "i1", "owns");
533 assert!(id.starts_with("link:"));
534 assert_eq!(id.len(), 21);
536 }
537
538 #[test]
539 fn build_link_id_deterministic() {
540 let a = build_link_id("u", "t", "w", "r", "e", "s", "fk", "fi", "tk", "ti", "reason");
541 let b = build_link_id("u", "t", "w", "r", "e", "s", "fk", "fi", "tk", "ti", "reason");
542 assert_eq!(a, b);
543 }
544
545 #[test]
546 fn build_link_id_differs_on_direction() {
547 let ab = build_link_id("u", "t", "w", "r", "e", "s", "actor", "a1", "item", "i1", "r");
548 let ba = build_link_id("u", "t", "w", "r", "e", "s", "item", "i1", "actor", "a1", "r");
549 assert_ne!(ab, ba);
550 }
551
552 #[test]
555 fn build_citation_log_id_format() {
556 let id = build_citation_log_id("scribe", "scribe_001", "evt:main:1:000000", 1);
557 assert!(id.starts_with("log:"));
558 assert_eq!(id.len(), 20);
560 }
561
562 #[test]
563 fn build_citation_log_id_deterministic() {
564 let a = build_citation_log_id("codex", "c1", "evt:m:1:000000", 42);
565 let b = build_citation_log_id("codex", "c1", "evt:m:1:000000", 42);
566 assert_eq!(a, b);
567 }
568
569 #[test]
572 fn build_citation_str_format() {
573 assert_eq!(build_citation_str("evt:main:1:000000", 42), "evt:main:1:000000@42");
574 }
575
576 #[test]
579 fn build_citation_key_is_hex() {
580 let key = build_citation_key("evt:main:1:000000", 1);
581 assert_eq!(key.len(), 16);
582 assert!(key.chars().all(|c| c.is_ascii_hexdigit()));
583 }
584
585 #[test]
586 fn build_citation_key_deterministic() {
587 let a = build_citation_key("evt:m:1:000000", 5);
588 let b = build_citation_key("evt:m:1:000000", 5);
589 assert_eq!(a, b);
590 }
591
592 #[test]
595 fn citation_roll_seed_hash_is_hex() {
596 let h = citation_roll_seed_hash();
597 assert_eq!(h.len(), 16);
598 assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
599 }
600
601 #[test]
602 fn citation_roll_seed_hash_deterministic() {
603 assert_eq!(citation_roll_seed_hash(), citation_roll_seed_hash());
604 }
605
606 #[test]
609 fn citation_roll_advance_deterministic() {
610 let a = citation_roll_advance("abcdef0123456789", "1234567890abcdef", 10);
611 let b = citation_roll_advance("abcdef0123456789", "1234567890abcdef", 10);
612 assert_eq!(a, b);
613 }
614
615 #[test]
616 fn citation_roll_advance_changes_with_tick() {
617 let a = citation_roll_advance("abcdef0123456789", "1234567890abcdef", 10);
618 let b = citation_roll_advance("abcdef0123456789", "1234567890abcdef", 11);
619 assert_ne!(a, b);
620 }
621
622 #[test]
625 fn apply_bounded_citation_accepts_first() {
626 let mut hot = Vec::new();
627 let mut roll = citation_roll_seed_hash();
628 let mut total = 0u64;
629 let mut min_tick = None;
630 let mut max_tick = 0u64;
631 let mut recent = Vec::new();
632
633 let accepted = apply_bounded_citation(
634 &mut hot,
635 &mut roll,
636 &mut total,
637 &mut min_tick,
638 &mut max_tick,
639 &mut recent,
640 "evt:m:1:000000",
641 5,
642 HOT_MAX_SCRIBE,
643 RECENT_KEYS_SCRIBE,
644 );
645 assert!(accepted);
646 assert_eq!(total, 1);
647 assert_eq!(min_tick, Some(5));
648 assert_eq!(max_tick, 5);
649 assert_eq!(hot.len(), 1);
650 assert_eq!(recent.len(), 1);
651 }
652
653 #[test]
654 fn apply_bounded_citation_rejects_duplicate() {
655 let mut hot = Vec::new();
656 let mut roll = citation_roll_seed_hash();
657 let mut total = 0u64;
658 let mut min_tick = None;
659 let mut max_tick = 0u64;
660 let mut recent = Vec::new();
661
662 apply_bounded_citation(
663 &mut hot,
664 &mut roll,
665 &mut total,
666 &mut min_tick,
667 &mut max_tick,
668 &mut recent,
669 "evt:m:1:000000",
670 5,
671 HOT_MAX_SCRIBE,
672 RECENT_KEYS_SCRIBE,
673 );
674 let dup = apply_bounded_citation(
675 &mut hot,
676 &mut roll,
677 &mut total,
678 &mut min_tick,
679 &mut max_tick,
680 &mut recent,
681 "evt:m:1:000000",
682 5,
683 HOT_MAX_SCRIBE,
684 RECENT_KEYS_SCRIBE,
685 );
686 assert!(!dup);
687 assert_eq!(total, 1); }
689
690 #[test]
691 fn apply_bounded_citation_evicts_oldest_when_full() {
692 let mut hot = Vec::new();
693 let mut roll = citation_roll_seed_hash();
694 let mut total = 0u64;
695 let mut min_tick = None;
696 let mut max_tick = 0u64;
697 let mut recent = Vec::new();
698
699 let hot_max = 3;
700 let recent_max = 64;
701
702 for i in 0..5u64 {
703 let eid = format!("evt:m:{}:000000", i);
704 apply_bounded_citation(
705 &mut hot,
706 &mut roll,
707 &mut total,
708 &mut min_tick,
709 &mut max_tick,
710 &mut recent,
711 &eid,
712 i + 1,
713 hot_max,
714 recent_max,
715 );
716 }
717 assert_eq!(hot.len(), hot_max);
718 assert_eq!(total, 5);
719 assert!(hot[0].contains("evt:m:2:000000"));
721 assert!(hot[1].contains("evt:m:3:000000"));
722 assert!(hot[2].contains("evt:m:4:000000"));
723 }
724
725 #[test]
726 fn apply_bounded_citation_tracks_min_max_tick() {
727 let mut hot = Vec::new();
728 let mut roll = citation_roll_seed_hash();
729 let mut total = 0u64;
730 let mut min_tick = None;
731 let mut max_tick = 0u64;
732 let mut recent = Vec::new();
733
734 apply_bounded_citation(
735 &mut hot,
736 &mut roll,
737 &mut total,
738 &mut min_tick,
739 &mut max_tick,
740 &mut recent,
741 "evt:a",
742 10,
743 16,
744 64,
745 );
746 apply_bounded_citation(
747 &mut hot,
748 &mut roll,
749 &mut total,
750 &mut min_tick,
751 &mut max_tick,
752 &mut recent,
753 "evt:b",
754 3,
755 16,
756 64,
757 );
758 apply_bounded_citation(
759 &mut hot,
760 &mut roll,
761 &mut total,
762 &mut min_tick,
763 &mut max_tick,
764 &mut recent,
765 "evt:c",
766 20,
767 16,
768 64,
769 );
770 assert_eq!(min_tick, Some(3));
771 assert_eq!(max_tick, 20);
772 }
773
774 #[test]
777 fn build_scribe_index_id_format() {
778 let id = build_scribe_index_id("U1", "TL1", "W1", "R1", "E1", "S1", "narrative", "main_quest");
779 assert_eq!(id, "idx::u1::tl1::w1::r1::e1::s1::narrative::main_quest");
780 }
781
782 #[test]
783 fn build_scribe_index_id_deterministic() {
784 let a = build_scribe_index_id("u", "t", "w", "r", "e", "s", "k", "topic");
785 let b = build_scribe_index_id("u", "t", "w", "r", "e", "s", "k", "topic");
786 assert_eq!(a, b);
787 }
788
789 #[test]
792 fn build_stat_id_format() {
793 let id = build_stat_id("U1", "TL1", "W1", "region", "combat_queries");
794 assert_eq!(id, "stat::u1::tl1::w1::region::combat_queries");
795 }
796
797 #[test]
798 fn build_stat_id_deterministic() {
799 let a = build_stat_id("u", "t", "w", "scope", "qk");
800 let b = build_stat_id("u", "t", "w", "scope", "qk");
801 assert_eq!(a, b);
802 }
803
804 #[test]
807 fn checksum_content_deterministic() {
808 let a = checksum_content(r#"{"key":"value","num":42}"#).unwrap();
809 let b = checksum_content(r#"{"key":"value","num":42}"#).unwrap();
810 assert_eq!(a, b);
811 }
812
813 #[test]
814 fn checksum_content_is_16_hex() {
815 let c = checksum_content(r#"{"hello":"world"}"#).unwrap();
816 assert_eq!(c.len(), 16);
817 assert!(c.chars().all(|ch| ch.is_ascii_hexdigit()));
818 }
819
820 #[test]
821 fn checksum_content_normalizes_key_order() {
822 let a = checksum_content(r#"{"b":2,"a":1}"#).unwrap();
825 let b = checksum_content(r#"{"a":1,"b":2}"#).unwrap();
826 assert_eq!(a, b);
827 }
828
829 #[test]
830 fn checksum_content_differs_on_different_data() {
831 let a = checksum_content(r#"{"x":1}"#).unwrap();
832 let b = checksum_content(r#"{"x":2}"#).unwrap();
833 assert_ne!(a, b);
834 }
835
836 #[test]
837 fn checksum_content_invalid_json_returns_error() {
838 let result = checksum_content("not json {{{");
839 assert!(result.is_err());
840 assert!(result.unwrap_err().contains("checksum_content_invalid_json"));
841 }
842
843 #[test]
846 fn build_alias_id_format() {
847 let id = build_alias_id("U1", "TL1", "W1", "player.main_char");
848 assert_eq!(id, "alias::u1::tl1::w1::player.main_char");
849 }
850
851 #[test]
852 fn build_alias_id_deterministic() {
853 let a = build_alias_id("u", "t", "w", "key");
854 let b = build_alias_id("u", "t", "w", "key");
855 assert_eq!(a, b);
856 }
857
858 #[test]
861 fn hot_max_constants_are_nonzero() {
862 assert!(HOT_MAX_SCRIBE > 0);
863 assert!(HOT_MAX_CODEX > 0);
864 assert!(HOT_MAX_CHRONICLE > 0);
865 }
866
867 #[test]
868 fn recent_keys_constants_are_nonzero() {
869 assert!(RECENT_KEYS_SCRIBE > 0);
870 assert!(RECENT_KEYS_CODEX > 0);
871 assert!(RECENT_KEYS_CHRONICLE > 0);
872 }
873
874 #[test]
875 fn hot_max_less_than_or_equal_to_recent_keys() {
876 assert!(HOT_MAX_SCRIBE <= RECENT_KEYS_SCRIBE);
878 assert!(HOT_MAX_CODEX <= RECENT_KEYS_CODEX);
879 assert!(HOT_MAX_CHRONICLE <= RECENT_KEYS_CHRONICLE);
880 }
881
882 #[test]
885 fn citation_buffer_new_defaults() {
886 let buf = CitationBuffer::new();
887 assert!(buf.hot.is_empty());
888 assert_eq!(buf.total, 0);
889 assert_eq!(buf.min_tick, None);
890 assert_eq!(buf.max_tick, 0);
891 assert!(buf.recent_keys.is_empty());
892 }
893
894 #[test]
895 fn citation_buffer_accepts_first() {
896 let mut buf = CitationBuffer::new();
897 let accepted = buf.apply("evt:m:1:000000", 5, HOT_MAX_SCRIBE, RECENT_KEYS_SCRIBE);
898 assert!(accepted);
899 assert_eq!(buf.total, 1);
900 assert_eq!(buf.min_tick, Some(5));
901 assert_eq!(buf.max_tick, 5);
902 assert_eq!(buf.hot.len(), 1);
903 assert_eq!(buf.recent_keys.len(), 1);
904 }
905
906 #[test]
907 fn citation_buffer_rejects_duplicate() {
908 let mut buf = CitationBuffer::new();
909 buf.apply("evt:m:1:000000", 5, HOT_MAX_SCRIBE, RECENT_KEYS_SCRIBE);
910 let dup = buf.apply("evt:m:1:000000", 5, HOT_MAX_SCRIBE, RECENT_KEYS_SCRIBE);
911 assert!(!dup);
912 assert_eq!(buf.total, 1);
913 }
914
915 #[test]
916 fn citation_buffer_tracks_min_max_tick() {
917 let mut buf = CitationBuffer::new();
918 buf.apply("evt:a", 10, 16, 64);
919 buf.apply("evt:b", 3, 16, 64);
920 buf.apply("evt:c", 20, 16, 64);
921 assert_eq!(buf.min_tick, Some(3));
922 assert_eq!(buf.max_tick, 20);
923 }
924
925 #[test]
926 fn citation_buffer_min_tick_none_before_first() {
927 let buf = CitationBuffer::new();
928 assert_eq!(buf.min_tick, None);
930 }
931}