Skip to main content

triplets_core/source/
mod.rs

1//! Data source interfaces and paging helpers.
2//!
3//! Ownership model:
4//! - `DataSource` is the sampler-facing interface that produces batches.
5//! - `IndexableSource` exposes stable, index-based access into a corpus.
6//! - `IndexablePager` owns the deterministic pseudo-random paging logic and
7//!   can page any indexable source without retaining per-record state.
8
9use chrono::{DateTime, Utc};
10use std::hash::Hash;
11use std::time::Instant;
12use tracing::info;
13
14use crate::config::{SamplerConfig, TripletRecipe};
15use crate::data::DataRecord;
16use crate::errors::SamplerError;
17use crate::hash::stable_hash_with;
18use crate::types::SourceId;
19
20/// Source implementation modules.
21pub mod backends;
22/// Utility helpers used by source implementations.
23pub mod indexing;
24pub use backends::csv_source::{CsvSource, CsvSourceConfig};
25pub use backends::file_source::{
26    FileSource, FileSourceConfig, SectionBuilder, TaxonomyBuilder, anchor_context_sections,
27    taxonomy_from_path,
28};
29
30pub use backends::in_memory_source::InMemorySource;
31
32/// Source-owned incremental refresh position.
33///
34/// The sampler stores and returns this value between refresh calls.
35/// `revision` is opaque to the sampler and interpreted only by the source.
36#[derive(Clone, Debug)]
37pub struct SourceCursor {
38    /// Most recent observation timestamp produced by the source.
39    pub last_seen: DateTime<Utc>,
40    /// Opaque paging position token used to continue incremental refresh.
41    pub revision: u64,
42}
43
44/// Result of a single source refresh call.
45///
46/// Pass the returned `cursor` back into the next refresh to continue paging.
47#[derive(Clone, Debug)]
48pub struct SourceSnapshot {
49    /// Records returned by the refresh operation.
50    pub records: Vec<DataRecord>,
51    /// Next cursor to pass into a future refresh call.
52    pub cursor: SourceCursor,
53}
54
55/// Sampler-facing data source interface.
56///
57/// Implementations may be streaming or index-backed. For a fixed dataset state
58/// and cursor, refresh output should be deterministic.
59pub trait DataSource: Send + Sync {
60    /// Stable source identifier used in records, metrics, and persistence state.
61    fn id(&self) -> &str;
62    /// Fetch up to `limit` records starting from `cursor` state.
63    ///
64    /// Return the next cursor position in `SourceSnapshot.cursor`.
65    fn refresh(
66        &self,
67        config: &SamplerConfig,
68        cursor: Option<&SourceCursor>,
69        limit: Option<usize>,
70    ) -> Result<SourceSnapshot, SamplerError>;
71
72    /// Exact metadata record count reported by the source.
73    ///
74    /// This is intended for estimators that must avoid iterating records.
75    /// Implementations should return `Ok(count)` only when the count is
76    /// exact for the source scope. Return `Err` when exact counting is not
77    /// possible or the source is unavailable.
78    ///
79    /// Keep this consistent with `refresh` by using the same backend scope,
80    /// filtering, and logical corpus definition.
81    fn reported_record_count(&self, config: &SamplerConfig) -> Result<u128, SamplerError>;
82
83    /// Optional source-provided default triplet recipes.
84    ///
85    /// Used when sampler config does not provide explicit recipes.
86    fn default_triplet_recipes(&self) -> Vec<TripletRecipe> {
87        Vec::new()
88    }
89}
90
91/// Index-addressable source interface used by deterministic pagers.
92///
93/// `len_hint` must be stable within an epoch, and `record_at` must return the
94/// record corresponding to the same index across runs.
95///
96/// Dense indexing is strongly recommended: implement indices as `0..len_hint`
97/// with minimal gaps. Sparse indexes (returning `None` for many positions)
98/// still work but waste paging capacity and reduce batch fill rates.
99pub trait IndexableSource: Send + Sync {
100    /// Stable source identifier.
101    fn id(&self) -> &str;
102    /// Current index domain size, typically `Some(total_records)`.
103    fn len_hint(&self) -> Option<usize>;
104    /// Return the record at index `idx`, or `None` for sparse/missing positions.
105    fn record_at(&self, idx: usize) -> Result<Option<DataRecord>, SamplerError>;
106}
107
108/// Deterministic pager for `IndexableSource`.
109///
110/// Encapsulates shuffle seed and cursor math so callers can reuse a stable
111/// paging algorithm without implementing permutation logic themselves.
112pub struct IndexablePager {
113    source_id: SourceId,
114}
115
116impl IndexablePager {
117    /// Create a new deterministic pager for `source_id`.
118    pub fn new(source_id: impl Into<SourceId>) -> Self {
119        Self {
120            source_id: source_id.into(),
121        }
122    }
123
124    /// Page records from an `IndexableSource` using the provided cursor.
125    pub fn refresh(
126        &self,
127        source: &dyn IndexableSource,
128        cursor: Option<&SourceCursor>,
129        limit: Option<usize>,
130    ) -> Result<SourceSnapshot, SamplerError> {
131        let total = source
132            .len_hint()
133            .ok_or_else(|| SamplerError::SourceInconsistent {
134                source_id: source.id().to_string(),
135                details: "indexable source did not provide len_hint".into(),
136            })?;
137        self.refresh_with(total, cursor, limit, |idx| source.record_at(idx))
138    }
139
140    /// Page records using a custom index fetcher.
141    ///
142    /// Useful when records are indexable but not exposed through `IndexableSource`
143    /// (for example, temporary index stores or precomputed path lists).
144    ///
145    /// The fetcher is called concurrently using rayon. It must be `Fn + Send + Sync`
146    /// (not merely `FnMut`). All callers that pass a closure over a shared
147    /// `&IndexableSource` satisfy this because `record_at` takes `&self`.
148    pub fn refresh_with<F>(
149        &self,
150        total: usize,
151        cursor: Option<&SourceCursor>,
152        limit: Option<usize>,
153        fetch: F,
154    ) -> Result<SourceSnapshot, SamplerError>
155    where
156        F: Fn(usize) -> Result<Option<DataRecord>, SamplerError> + Send + Sync,
157    {
158        if total == 0 {
159            return Ok(SourceSnapshot {
160                records: Vec::new(),
161                cursor: SourceCursor {
162                    last_seen: Utc::now(),
163                    revision: 0,
164                },
165            });
166        }
167        let mut start = cursor.map(|cursor| cursor.revision as usize).unwrap_or(0);
168        if start >= total {
169            start = 0;
170        }
171        let max = limit.unwrap_or(total);
172        let seed = Self::seed_for(&self.source_id, total);
173
174        // Pre-generate the full permuted index sequence with per-position cursor
175        // values. Pure integer arithmetic — negligible cost vs. record fetch.
176        let mut permutation = IndexPermutation::new(total, seed, start as u64);
177        let seq: Vec<(usize, usize)> = (0..total)
178            .map(|_| {
179                let idx = permutation.next();
180                (idx, permutation.cursor())
181            })
182            .collect();
183
184        let should_report = total >= 10_000 || max >= 1_024;
185        let refresh_start = Instant::now();
186        if should_report {
187            info!(
188                source = %self.source_id,
189                source_records = total,
190                ingestion_limit = max,
191                "Source refresh started"
192            );
193        }
194
195        use rayon::prelude::*;
196        // The permutation `seq` covers all `total` positions.  We try up to
197        // `max` positions in parallel first (rayon's global pool keeps
198        // in-flight requests at `num_cpus`).  For dense sources (every index
199        // returns a record) this single parallel batch fills the quota and
200        // the rest of the loop body below is a no-op.
201        //
202        // If the source is sparse (some positions return None), the parallel
203        // batch may come up short.  We then walk the remaining positions
204        // sequentially to find enough records.  In practice this fallback
205        // rarely runs and only for a handful of positions.
206        let par_end = max.min(total);
207        let results: Vec<Result<Option<DataRecord>, SamplerError>> = seq[..par_end]
208            .par_iter()
209            .map(|&(idx, _)| fetch(idx))
210            .collect();
211        let mut records = Vec::with_capacity(max.min(total));
212        let mut final_cursor = start;
213        for (result, &(_, cursor_after)) in results.into_iter().zip(seq[..par_end].iter()) {
214            if records.len() >= max {
215                break;
216            }
217            if let Some(r) = result? {
218                records.push(r)
219            }
220            final_cursor = cursor_after;
221        }
222        // Sparse-source fallback: sequential walk through remaining positions.
223        for &(idx, cursor_after) in &seq[par_end..] {
224            if records.len() >= max {
225                break;
226            }
227            if let Some(r) = fetch(idx)? {
228                records.push(r);
229            }
230            final_cursor = cursor_after;
231        }
232
233        if should_report {
234            info!(
235                source = %self.source_id,
236                source_records = total,
237                ingested = records.len(),
238                elapsed_secs = refresh_start.elapsed().as_secs_f64(),
239                "Source refresh completed"
240            );
241        }
242        let last_seen = records
243            .iter()
244            .map(|record| record.updated_at)
245            .max()
246            .unwrap_or_else(Utc::now);
247        Ok(SourceSnapshot {
248            records,
249            cursor: SourceCursor {
250                last_seen,
251                revision: final_cursor as u64,
252            },
253        })
254    }
255
256    /// Build a deterministic seed for a source and total size.
257    pub(crate) fn seed_for(source_id: &SourceId, total: usize) -> u64 {
258        Self::stable_index_shuffle_key(source_id, 0)
259            ^ Self::stable_index_shuffle_key(source_id, total)
260    }
261
262    /// Build a deterministic seed for a source/total pair with explicit sampler seed.
263    pub fn seed_for_sampler(source_id: &SourceId, total: usize, sampler_seed: u64) -> u64 {
264        Self::seed_for(source_id, total)
265            ^ stable_hash_with(|hasher| {
266                "triplets_sampler_seed".hash(hasher);
267                source_id.hash(hasher);
268                total.hash(hasher);
269                sampler_seed.hash(hasher);
270            })
271    }
272
273    fn stable_index_shuffle_key(source_id: &SourceId, idx: usize) -> u64 {
274        stable_hash_with(|hasher| {
275            source_id.hash(hasher);
276            idx.hash(hasher);
277        })
278    }
279}
280
281/// DataSource adapter that pages an `IndexableSource` via `IndexablePager`.
282pub struct IndexableAdapter<T: IndexableSource> {
283    inner: T,
284}
285
286impl<T: IndexableSource> IndexableAdapter<T> {
287    /// Wrap an `IndexableSource` so it can be registered as a `DataSource`.
288    pub fn new(inner: T) -> Self {
289        Self { inner }
290    }
291}
292
293impl<T: IndexableSource> DataSource for IndexableAdapter<T> {
294    fn id(&self) -> &str {
295        self.inner.id()
296    }
297
298    fn refresh(
299        &self,
300        _config: &SamplerConfig,
301        cursor: Option<&SourceCursor>,
302        limit: Option<usize>,
303    ) -> Result<SourceSnapshot, SamplerError> {
304        let pager = IndexablePager::new(self.inner.id());
305        pager.refresh(&self.inner, cursor, limit)
306    }
307
308    fn reported_record_count(&self, _config: &SamplerConfig) -> Result<u128, SamplerError> {
309        self.inner
310            .len_hint()
311            .map(|value| value as u128)
312            .ok_or_else(|| SamplerError::SourceInconsistent {
313                source_id: self.inner.id().to_string(),
314                details: "indexable source did not provide len_hint".into(),
315            })
316    }
317}
318
319/// Internal permutation used by `IndexablePager`.
320pub struct IndexPermutation {
321    total: u64,
322    domain_bits: u32,
323    domain_size: u64,
324    seed: u64,
325    counter: u64,
326}
327
328impl IndexPermutation {
329    /// Creates a new deterministic permutation over `[0, total)`.
330    pub fn new(total: usize, seed: u64, counter: u64) -> Self {
331        let total_u64 = total as u64;
332        let domain_bits = (64 - (total_u64 - 1).leading_zeros()).max(1);
333        let domain_size = 1u64 << domain_bits;
334        Self {
335            total: total_u64,
336            domain_bits,
337            domain_size,
338            seed,
339            counter,
340        }
341    }
342
343    /// Returns the next permuted index, staying within `[0, total)`.
344    ///
345    /// Each call advances the internal counter and returns a deterministic
346    /// pseudo-random index that is guaranteed to be less than `total`.
347    #[allow(clippy::should_implement_trait)]
348    pub fn next(&mut self) -> usize {
349        loop {
350            let v =
351                Self::permute_bits(self.counter % self.domain_size, self.domain_bits, self.seed);
352            self.counter = self.counter.wrapping_add(1);
353            if v < self.total {
354                return v as usize;
355            }
356        }
357    }
358
359    /// Current consumption cursor position.
360    pub fn cursor(&self) -> usize {
361        (self.counter as usize) % (self.total as usize)
362    }
363    fn permute_bits(value: u64, bits: u32, seed: u64) -> u64 {
364        if bits == 0 {
365            return 0;
366        }
367        let mask = if bits == 64 {
368            u64::MAX
369        } else {
370            (1u64 << bits) - 1
371        };
372        let mut a = (seed | 1) & mask;
373        if a == 0 {
374            a = 1;
375        }
376        let b = (seed >> 1) & mask;
377        a.wrapping_mul(value).wrapping_add(b) & mask
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384    use crate::data::{QualityScore, RecordSection, SectionRole};
385    use crate::types::RecordId;
386    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
387    use std::thread;
388    use std::time::Duration as StdDuration;
389
390    /// Minimal `IndexableSource` test fixture.
391    struct IndexableStub {
392        id: SourceId,
393        count: usize,
394    }
395
396    struct NoLenHintStub {
397        id: SourceId,
398    }
399
400    impl IndexableStub {
401        fn new(id: &str, count: usize) -> Self {
402            Self {
403                id: id.to_string(),
404                count,
405            }
406        }
407    }
408
409    impl NoLenHintStub {
410        fn new(id: &str) -> Self {
411            Self { id: id.to_string() }
412        }
413    }
414
415    impl IndexableSource for IndexableStub {
416        fn id(&self) -> &str {
417            &self.id
418        }
419
420        fn len_hint(&self) -> Option<usize> {
421            Some(self.count)
422        }
423
424        fn record_at(&self, idx: usize) -> Result<Option<DataRecord>, SamplerError> {
425            if idx >= self.count {
426                return Ok(None);
427            }
428            let now = Utc::now();
429            Ok(Some(DataRecord {
430                id: format!("record_{idx}"),
431                source: self.id.clone(),
432                created_at: now,
433                updated_at: now,
434                quality: QualityScore { trust: 1.0 },
435                taxonomy: Vec::new(),
436                sections: vec![RecordSection {
437                    role: SectionRole::Anchor,
438                    heading: None,
439                    text: "stub".into(),
440                    sentences: vec!["stub".into()],
441                    token_count: 0,
442                    embedding: None,
443                }],
444                meta_prefix: None,
445                label: None,
446            }))
447        }
448    }
449
450    impl IndexableSource for NoLenHintStub {
451        fn id(&self) -> &str {
452            &self.id
453        }
454
455        fn len_hint(&self) -> Option<usize> {
456            None
457        }
458
459        fn record_at(&self, _idx: usize) -> Result<Option<DataRecord>, SamplerError> {
460            Ok(None)
461        }
462    }
463
464    #[test]
465    fn indexable_adapter_pages_in_stable_order() {
466        let adapter = IndexableAdapter::new(IndexableStub::new("stub", 6));
467        let config = SamplerConfig::default();
468        let full = adapter.refresh(&config, None, None).unwrap();
469        let full_ids: Vec<RecordId> = full.records.into_iter().map(|r| r.id).collect();
470
471        let mut cursor = None;
472        let mut paged = Vec::new();
473        for _ in 0..3 {
474            let snapshot = adapter.refresh(&config, cursor.as_ref(), Some(3)).unwrap();
475            cursor = Some(snapshot.cursor);
476            paged.extend(snapshot.records.into_iter().map(|r| r.id));
477            if paged.len() >= full_ids.len() {
478                break;
479            }
480        }
481        assert_eq!(paged, full_ids);
482    }
483
484    #[test]
485    fn indexable_paging_spans_multiple_regimes() {
486        // Use a source id whose permutation step is not 1 or -1 mod 2^k,
487        // otherwise the sequence would be a simple rotation/reversal.
488        let total = 256usize;
489        let mask = (1u64 << (64 - (total as u64 - 1).leading_zeros())) - 1;
490        let source_id = (0..512)
491            .map(|idx| format!("regime_test_{idx}"))
492            .find(|id| {
493                let seed = IndexablePager::seed_for(id, total);
494                let a = (seed | 1) & mask;
495                a != 1 && a != mask
496            })
497            .unwrap();
498
499        // Pull a single page and ensure the indices are spread across the space,
500        // which indicates the permutation isn't stuck in a narrow regime.
501        let adapter = IndexableAdapter::new(IndexableStub::new(&source_id, total));
502        let snapshot = adapter
503            .refresh(&SamplerConfig::default(), None, Some(64))
504            .unwrap();
505        let indices: Vec<usize> = snapshot
506            .records
507            .into_iter()
508            .map(|r| {
509                r.id.strip_prefix("record_")
510                    .unwrap()
511                    .parse::<usize>()
512                    .unwrap()
513            })
514            .collect();
515        let min_idx = *indices.iter().min().unwrap();
516        let max_idx = *indices.iter().max().unwrap();
517        assert!(
518            max_idx - min_idx >= total / 2,
519            "expected spread across the index space, got min={min_idx} max={max_idx}"
520        );
521    }
522
523    #[test]
524    fn indexable_pager_errors_when_len_hint_missing() {
525        let pager = IndexablePager::new("no_len_hint");
526        let source = NoLenHintStub::new("no_len_hint");
527        let result = pager.refresh(&source, None, Some(3));
528        assert!(result.is_err());
529    }
530
531    #[test]
532    fn indexable_adapter_reported_count_errors_when_len_hint_missing() {
533        let adapter = IndexableAdapter::new(NoLenHintStub::new("no_len_hint"));
534        let result = adapter.reported_record_count(&SamplerConfig::default());
535        assert!(result.is_err());
536    }
537
538    #[test]
539    fn indexable_pager_refresh_with_zero_total_returns_empty_snapshot() {
540        let pager = IndexablePager::new("empty");
541        let snapshot = pager
542            .refresh_with(0, None, Some(4), |_idx| Ok(None))
543            .unwrap();
544        assert!(snapshot.records.is_empty());
545        assert_eq!(snapshot.cursor.revision, 0);
546    }
547
548    #[test]
549    fn index_permutation_permute_bits_handles_zero_bits_and_zero_seed_path() {
550        assert_eq!(IndexPermutation::permute_bits(123, 0, 99), 0);
551
552        let bits = 1;
553        let value = 1;
554        let out = IndexPermutation::permute_bits(value, bits, 0);
555        assert!(out <= 1);
556    }
557
558    #[test]
559    fn index_permutation_next_stays_within_total_and_cursor_advances() {
560        let mut perm = IndexPermutation::new(3, 7, 0);
561        let mut seen = Vec::new();
562        for _ in 0..8 {
563            seen.push(perm.next());
564        }
565        assert!(seen.iter().all(|idx| *idx < 3));
566        assert!(perm.cursor() < 3);
567    }
568
569    #[test]
570    fn indexable_pager_large_refresh_triggers_reporting_branch_and_wraps_cursor() {
571        let pager = IndexablePager::new("reporting");
572        let cursor = SourceCursor {
573            last_seen: Utc::now(),
574            revision: 20_000,
575        };
576        let snapshot = pager
577            .refresh_with(10_000, Some(&cursor), Some(4), |idx| {
578                Ok(Some(DataRecord {
579                    id: format!("record_{idx}"),
580                    source: "reporting".to_string(),
581                    created_at: Utc::now(),
582                    updated_at: Utc::now(),
583                    quality: QualityScore { trust: 1.0 },
584                    taxonomy: Vec::new(),
585                    sections: vec![RecordSection {
586                        role: SectionRole::Anchor,
587                        heading: None,
588                        text: "t".to_string(),
589                        sentences: vec!["t".to_string()],
590                        token_count: 0,
591                        embedding: None,
592                    }],
593                    meta_prefix: None,
594                    label: None,
595                }))
596            })
597            .unwrap();
598
599        assert_eq!(snapshot.records.len(), 4);
600        assert!(snapshot.cursor.revision < 10_000);
601    }
602
603    #[test]
604    fn indexable_pager_reporting_branch_emits_progress_when_refresh_is_slow() {
605        let pager = IndexablePager::new("slow_reporting");
606        let slept = AtomicBool::new(false);
607        let snapshot = pager
608            .refresh_with(2_000, None, Some(1_024), |_idx| {
609                if !slept.swap(true, Ordering::Relaxed) {
610                    thread::sleep(StdDuration::from_millis(800));
611                }
612                Ok(None)
613            })
614            .unwrap();
615
616        assert!(snapshot.records.is_empty());
617        assert!(snapshot.cursor.revision < 2_000);
618    }
619
620    #[test]
621    fn source_ids_and_reported_counts_are_exposed() {
622        let adapter = IndexableAdapter::new(IndexableStub::new("stub_id", 3));
623        assert_eq!(adapter.id(), "stub_id");
624        assert_eq!(
625            adapter
626                .reported_record_count(&SamplerConfig::default())
627                .unwrap(),
628            3
629        );
630    }
631
632    #[test]
633    fn indexable_pager_sequential_fallback_fills_quota_when_parallel_pass_yields_none() {
634        // Exercise the seq[par_end..] fallback loop: parallel pass entries all
635        // return None, so the sequential sweep has to supply the records.
636        // total=8, limit=4 -> par_end=4. First 4 calls (parallel) get None;
637        // next 4 calls (sequential fallback) get Some, filling the quota.
638        let pager = IndexablePager::new("fallback_fill");
639        let call_count = AtomicUsize::new(0);
640        let par_end = 4usize;
641        let snapshot = pager
642            .refresh_with(8, None, Some(par_end), |idx| {
643                let n = call_count.fetch_add(1, Ordering::Relaxed);
644                if n < par_end {
645                    Ok(None)
646                } else {
647                    Ok(Some(DataRecord {
648                        id: format!("r_{idx}"),
649                        source: "fallback_fill".to_string(),
650                        created_at: Utc::now(),
651                        updated_at: Utc::now(),
652                        quality: QualityScore { trust: 1.0 },
653                        taxonomy: Vec::new(),
654                        sections: vec![RecordSection {
655                            role: SectionRole::Anchor,
656                            heading: None,
657                            text: "t".to_string(),
658                            sentences: vec!["t".to_string()],
659                            token_count: 0,
660                            embedding: None,
661                        }],
662                        meta_prefix: None,
663                        label: None,
664                    }))
665                }
666            })
667            .unwrap();
668        assert_eq!(snapshot.records.len(), par_end);
669    }
670
671    #[test]
672    fn indexable_pager_refresh_with_propagates_fetch_error() {
673        let pager = IndexablePager::new("err");
674        let err = pager
675            .refresh_with(8, None, Some(2), |_idx| {
676                Err(SamplerError::SourceUnavailable {
677                    source_id: "err".to_string(),
678                    reason: "fetch failed".to_string(),
679                })
680            })
681            .unwrap_err();
682        assert!(matches!(
683            err,
684            SamplerError::SourceUnavailable { ref reason, .. } if reason.contains("fetch failed")
685        ));
686    }
687
688    #[test]
689    fn seed_for_sampler_depends_on_sampler_seed() {
690        let source_id = "seeded".to_string();
691        let base = IndexablePager::seed_for(&source_id, 17);
692        let with_a = IndexablePager::seed_for_sampler(&source_id, 17, 1);
693        let with_b = IndexablePager::seed_for_sampler(&source_id, 17, 2);
694        assert_ne!(with_a, with_b);
695        assert_ne!(with_a, base);
696    }
697
698    #[test]
699    fn stable_hash_is_deterministic_across_calls() {
700        let a = crate::hash::stable_hash_str(42, "hello");
701        let b = crate::hash::stable_hash_str(42, "hello");
702        assert_eq!(a, b, "same inputs must produce same hash");
703    }
704
705    #[test]
706    fn stable_hash_varies_with_seed() {
707        let a = crate::hash::stable_hash_str(1, "hello");
708        let b = crate::hash::stable_hash_str(2, "hello");
709        assert_ne!(a, b, "different seeds should produce different hashes");
710    }
711
712    #[test]
713    fn stable_hash_varies_with_value() {
714        let a = crate::hash::stable_hash_str(42, "hello");
715        let b = crate::hash::stable_hash_str(42, "world");
716        assert_ne!(a, b, "different values should produce different hashes");
717    }
718
719    #[test]
720    fn stable_hash_str_handles_empty_string() {
721        let result = crate::hash::stable_hash_str(0, "");
722        assert_ne!(result, 0, "empty string should produce non-zero hash");
723    }
724}