Skip to main content

dreamwell_engine/
hash.rs

1// FNV-1a 64-bit hash for deterministic event digests and canon ID generation.
2// Pure Rust, no deps.
3
4const FNV1A_BASIS: u64 = 0xcbf29ce484222325;
5const FNV1A_PRIME: u64 = 0x00000100000001B3;
6
7/// Compute FNV-1a 64-bit hash over arbitrary bytes.
8pub 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
17// Re-export from canon.rs — the canonical implementation of event digest computation.
18// See `crate::canon::compute_event_digest` for documentation.
19pub use crate::canon::compute_event_digest;
20
21/// Compute FNV-1a 64-bit hash over meshlet geometry data for deduplication.
22/// Input: vertex positions as contiguous f32 triples + triangle indices.
23pub 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
44/// Canonical normalization: trim + lowercase.
45pub fn canon_normalize(s: &str) -> String {
46    s.trim().to_lowercase()
47}
48
49/// Build a deterministic scribe_id from scope + kind + topic + template_version.
50pub 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
75/// Build a deterministic codex_id from scope + query_key + params_json.
76pub 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
101/// Build a deterministic chronicle_id.
102pub 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
135/// Build a deterministic canon link_id.
136pub 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
167/// Build a deterministic citation_log log_id.
168pub 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
174/// Build a citation string: "event_id@tick".
175pub fn build_citation_str(event_id: &str, tick: u64) -> String {
176    format!("{}@{}", event_id, tick)
177}
178
179/// Build a citation dedupe key (FNV-1a hex of event_id + tick).
180pub 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
186/// Seed value for bounded citation rolling hash.
187pub const CITATION_ROLL_SEED: &str = "dreamwell.citations.v1";
188
189/// Compute the initial rolling hash seed.
190pub fn citation_roll_seed_hash() -> String {
191    let hash = fnv1a_64(CITATION_ROLL_SEED.as_bytes());
192    format!("{:016x}", hash)
193}
194
195/// Advance a rolling hash with a new citation.
196pub 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
202/// Hot window max sizes.
203pub const HOT_MAX_SCRIBE: usize = 16;
204pub const HOT_MAX_CODEX: usize = 32;
205pub const HOT_MAX_CHRONICLE: usize = 64;
206/// Recent dedupe keys max sizes.
207pub const RECENT_KEYS_SCRIBE: usize = 64;
208pub const RECENT_KEYS_CODEX: usize = 128;
209pub const RECENT_KEYS_CHRONICLE: usize = 128;
210
211/// Mutable citation state buffer, replacing 6 separate mutable params.
212///
213/// Tracks the rolling hash, hot window, min/max tick, total count, and
214/// recent dedupe keys for bounded citation application.
215#[derive(Debug, Clone)]
216pub struct CitationBuffer {
217    /// Most recent citation strings (bounded by `hot_max`).
218    pub hot: Vec<String>,
219    /// Rolling FNV-1a hash over all accepted citations.
220    pub roll_hash: String,
221    /// Total number of accepted citations.
222    pub total: u64,
223    /// Earliest tick seen. `None` until the first citation is accepted.
224    pub min_tick: Option<u64>,
225    /// Latest tick seen.
226    pub max_tick: u64,
227    /// Recent citation dedupe keys (bounded by `recent_keys_max`).
228    pub recent_keys: Vec<String>,
229}
230
231impl CitationBuffer {
232    /// Create a new buffer with the default rolling hash seed.
233    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    /// Apply a citation. Returns `true` if accepted, `false` if duplicate.
245    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
289/// Apply a citation to bounded citation fields. Returns true if accepted.
290///
291/// Thin backward-compatible wrapper around `CitationBuffer::apply`.
292pub 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
325/// Build a scribe_index index_id.
326pub 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
349/// Build a codex_query_stats stat_id.
350pub 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
367/// Compute a deterministic checksum over JSON content.
368///
369/// Returns `Err` if the input is not valid JSON. Callers should handle the
370/// error rather than silently falling back to raw bytes.
371pub 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
378/// Build a codex_aliases alias_id.
379pub 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// =============================================================================
390// Tests
391// =============================================================================
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    // -- fnv1a_64 determinism -------------------------------------------------
398
399    #[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    // -- fnv1a_64 known test vectors ------------------------------------------
412    // Reference: http://www.isthe.com/chongo/tech/comp/fnv/#FNV-param
413
414    #[test]
415    fn fnv1a_64_empty_string() {
416        // FNV-1a 64-bit offset basis is the hash of the empty byte sequence.
417        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        // FNV-1a of a single zero byte.
433        assert_eq!(fnv1a_64(&[0u8]), 0xaf63bd4c8601b7df);
434    }
435
436    // -- canon_normalize ------------------------------------------------------
437
438    #[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    // -- build_scribe_id ------------------------------------------------------
449
450    #[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    // -- build_codex_id -------------------------------------------------------
481
482    #[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        // "codex:" (6) + 16 hex + ":v1" (3) = 25 chars
488        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    // -- build_chronicle_id ---------------------------------------------------
506
507    #[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    // -- build_link_id --------------------------------------------------------
529
530    #[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        // "link:" (5) + 16 hex = 21 chars
535        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    // -- build_citation_log_id ------------------------------------------------
553
554    #[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        // "log:" (4) + 16 hex = 20 chars
559        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    // -- build_citation_str ---------------------------------------------------
570
571    #[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    // -- build_citation_key ---------------------------------------------------
577
578    #[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    // -- citation_roll_seed_hash ----------------------------------------------
593
594    #[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    // -- citation_roll_advance ------------------------------------------------
607
608    #[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    // -- apply_bounded_citation -----------------------------------------------
623
624    #[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); // total unchanged
688    }
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        // Oldest entries evicted — hot window contains the 3 most recent.
720        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    // -- build_scribe_index_id ------------------------------------------------
775
776    #[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    // -- build_stat_id --------------------------------------------------------
790
791    #[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    // -- checksum_content -----------------------------------------------------
805
806    #[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        // serde_json::Value re-serialization sorts keys, so different key orders
823        // produce the same canonical form and thus the same checksum.
824        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    // -- build_alias_id -------------------------------------------------------
844
845    #[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    // -- Constants ------------------------------------------------------------
859
860    #[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        // The hot window should always be smaller than the dedupe window.
877        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    // -- CitationBuffer -------------------------------------------------------
883
884    #[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        // Before any citation, min_tick is None — not the sentinel 0.
929        assert_eq!(buf.min_tick, None);
930    }
931}