Skip to main content

fugue/core/
address.rs

1#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/docs/core/address.md"))]
2use std::fmt::{Display, Formatter};
3use std::hash::{Hash, Hasher};
4use std::ops::Deref;
5use std::sync::Arc;
6
7/// A unique identifier for random variables and observation sites in probabilistic models.
8/// Addresses serve as stable names for probabilistic choices, enabling conditioning, inference, and replay.
9///
10/// # Representation (FG-05)
11///
12/// `Address` is backed by an `Arc<str>` together with a **precomputed** 64-bit
13/// hash of that string. This makes the two operations that dominate inference
14/// bookkeeping cheap:
15///
16/// - **Clone** is an atomic reference-count bump plus a `u64` copy — no heap
17///   allocation and no string copy. Concrete handlers (`PriorHandler`,
18///   `ScoreGivenTrace`, …) clone every address twice per sample site (once as the
19///   `BTreeMap` key, once inside the stored `Choice`), and single-site MH clones
20///   the whole trace several times per step, so cheap cloning removes what the
21///   audit measured as the per-iteration allocation hot spot.
22/// - **Hash** writes the cached `u64` directly instead of re-hashing the string
23///   on every `HashMap` probe. Equality still compares the underlying `str`
24///   (after a fast hash pre-check), so hash collisions remain correct.
25///
26/// Ordering (`Ord`/`PartialOrd`) compares the underlying `str` lexicographically,
27/// preserving the stable, human-meaningful `BTreeMap` iteration order that traces
28/// rely on. `Display` and `Deref<Target = str>` are preserved so downstream code
29/// that formatted or string-sliced an address keeps compiling.
30///
31/// # Index-separator encoding (collision-free)
32///
33/// Indexed addresses built with `addr!(name, index)` are stored as the string
34/// `"{name}#{index}"`, using `'#'` as the separator between the name and its
35/// index. To guarantee that two *syntactically distinct* `addr!` calls can never
36/// produce the same [`Address`], any literal `'#'` (and any literal `'\'`) that
37/// appears **inside** a `name` or `index` segment is escaped when the address is
38/// built: `'\' -> "\\"` and `'#' -> "\#"`. The separator itself is the only
39/// *unescaped* `'#'` in the stored string.
40///
41/// This makes the encoding injective, so for example:
42///
43/// - `addr!("a#1")` stores `"a\#1"` (the literal `'#'` is escaped) — a plain name,
44/// - `addr!("a", 1)` stores `"a#1"` (an unescaped separator) — a name with index,
45///
46/// and the two are therefore **distinct** addresses. Likewise
47/// `addr!("a", "b#3")` (`"a#b\#3"`) and `addr!("a#b", 3)` (`"a\#b#3"`) do not
48/// collide. Names that contain neither `'#'` nor `'\'` are stored verbatim, so
49/// the common case (e.g. `addr!("mu")` -> `"mu"`, `addr!("x", 3)` -> `"x#3"`)
50/// is unchanged and `Display` stays human-readable.
51///
52/// Example:
53/// ```rust
54/// use fugue::*;
55/// // Create addresses using the addr! macro
56/// let addr1 = addr!("parameter");
57/// let addr2 = addr!("data", 5);
58/// // A literal '#' in a name never aliases an indexed address:
59/// assert_ne!(addr!("a#1"), addr!("a", 1));
60/// // Addresses can be compared and used in collections
61/// use std::collections::HashMap;
62/// let mut map = HashMap::new();
63/// map.insert(addr1, 1.0);
64/// map.insert(addr2, 2.0);
65/// ```
66#[derive(Clone, Debug)]
67pub struct Address {
68    /// Reference-counted, immutable backing string. Cloning shares this buffer.
69    repr: Arc<str>,
70    /// Precomputed hash of `repr`, written directly by [`Hash`] so that hashing
71    /// an address never re-scans the string.
72    hash: u64,
73}
74
75/// Compute the cached hash for an address's backing string.
76///
77/// Uses [`std::collections::hash_map::DefaultHasher`], whose keys are fixed, so
78/// the value is deterministic for a given string within and across runs of the
79/// same build. The value is only ever compared for equality and fed to another
80/// hasher via [`Hasher::write_u64`], so its only requirements are determinism and
81/// good dispersion — both of which SipHash satisfies.
82#[inline]
83fn compute_address_hash(s: &str) -> u64 {
84    let mut hasher = std::collections::hash_map::DefaultHasher::new();
85    s.hash(&mut hasher);
86    hasher.finish()
87}
88
89impl Address {
90    /// Construct an address from any string-like value.
91    ///
92    /// The backing string is moved into an `Arc<str>` once, and its hash is
93    /// computed once, here at construction. All later clones are allocation-free.
94    #[inline]
95    pub fn new(name: impl Into<Arc<str>>) -> Self {
96        let repr: Arc<str> = name.into();
97        let hash = compute_address_hash(&repr);
98        Address { repr, hash }
99    }
100
101    /// Borrow the underlying string slice.
102    #[inline]
103    pub fn as_str(&self) -> &str {
104        &self.repr
105    }
106
107    /// True iff this address is `prefix` itself or a descendant of it under the
108    /// address path grammar — i.e. `prefix` followed by a segment separator
109    /// (`#` from [`addr!`](crate::addr), `::` from
110    /// [`scoped_addr!`](crate::scoped_addr), or a caller-chosen `/`). Unlike a
111    /// raw [`str::starts_with`], this does **not** treat `"gene"` as a prefix of
112    /// `"generation"`.
113    ///
114    /// A `prefix` that already ends in a separator character (`#`, `/`, or `:`)
115    /// is matched by plain `starts_with`; callers using a bespoke separator
116    /// should pass the prefix *including* the trailing separator (e.g.
117    /// `"first/"`).
118    ///
119    /// ```rust
120    /// use fugue::*;
121    ///
122    /// assert!(addr!("gene", 3).has_prefix("gene"));
123    /// assert!(Address::new("node/0/1").has_prefix("node/0"));
124    /// assert!(Address::new("scope::x").has_prefix("scope"));
125    /// assert!(!Address::new("generation").has_prefix("gene"));
126    /// ```
127    pub fn has_prefix(&self, prefix: &str) -> bool {
128        let s = self.as_str();
129        let Some(rest) = s.strip_prefix(prefix) else {
130            return false;
131        };
132        if rest.is_empty() {
133            return true;
134        }
135        if matches!(prefix.chars().last(), Some('#' | '/' | ':')) {
136            return true;
137        }
138        rest.starts_with('#') || rest.starts_with('/') || rest.starts_with("::")
139    }
140}
141
142impl Display for Address {
143    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
144        write!(f, "{}", self.repr)
145    }
146}
147
148impl Deref for Address {
149    type Target = str;
150    #[inline]
151    fn deref(&self) -> &str {
152        &self.repr
153    }
154}
155
156impl Hash for Address {
157    /// Write the precomputed hash rather than re-hashing the string on every
158    /// `HashMap` probe (FG-05).
159    #[inline]
160    fn hash<H: Hasher>(&self, state: &mut H) {
161        state.write_u64(self.hash);
162    }
163}
164
165impl PartialEq for Address {
166    /// Equality compares the underlying `str`; the cached hash is used only as a
167    /// fast reject so distinct strings that collide in the hash still compare
168    /// unequal.
169    #[inline]
170    fn eq(&self, other: &Self) -> bool {
171        self.hash == other.hash && self.repr == other.repr
172    }
173}
174
175impl Eq for Address {}
176
177impl PartialOrd for Address {
178    #[inline]
179    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
180        Some(self.cmp(other))
181    }
182}
183
184impl Ord for Address {
185    /// Lexicographic ordering on the backing string, preserving stable
186    /// `BTreeMap` iteration order.
187    #[inline]
188    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
189        self.as_str().cmp(other.as_str())
190    }
191}
192
193impl From<String> for Address {
194    #[inline]
195    fn from(s: String) -> Self {
196        Address::new(s)
197    }
198}
199
200impl From<&str> for Address {
201    #[inline]
202    fn from(s: &str) -> Self {
203        Address::new(s)
204    }
205}
206
207/// The reserved separator placed between a name and its index inside an
208/// [`Address`] string built by `addr!(name, index)`.
209pub const ADDR_INDEX_SEP: char = '#';
210
211/// Escape a single address segment (a name or an index) so that a literal
212/// occurrence of the reserved separator [`ADDR_INDEX_SEP`] (`'#'`) can never be
213/// confused with the real separator, and so the escape character `'\'` itself is
214/// unambiguous.
215///
216/// The escaping is the standard, injective backslash scheme (`'\' -> "\\"`,
217/// `'#' -> "\#"`). Segments that contain neither character are returned verbatim
218/// (the common case), so no allocation-visible change occurs for ordinary names.
219///
220/// This is an implementation detail used by the `addr!` and `scoped_addr!`
221/// macros; it is public only so those macros can expand to it.
222#[doc(hidden)]
223pub fn escape_addr_segment(segment: &str) -> String {
224    if segment.contains('\\') || segment.contains('#') {
225        let mut out = String::with_capacity(segment.len() + 4);
226        for ch in segment.chars() {
227            match ch {
228                '\\' => out.push_str("\\\\"),
229                '#' => out.push_str("\\#"),
230                other => out.push(other),
231            }
232        }
233        out
234    } else {
235        segment.to_string()
236    }
237}
238
239/// Build the backing string for a plain (unindexed) address, escaping the
240/// reserved separator inside the name. Used by `addr!(name)`.
241#[doc(hidden)]
242pub fn make_name(name: impl Display) -> String {
243    escape_addr_segment(&name.to_string())
244}
245
246/// Build the backing string for an indexed address `"{name}#{index}"`, escaping
247/// the reserved separator inside both segments so the encoding is injective.
248/// Used by `addr!(name, index)`.
249#[doc(hidden)]
250pub fn make_indexed(name: impl Display, index: impl Display) -> String {
251    format!(
252        "{}{}{}",
253        escape_addr_segment(&name.to_string()),
254        ADDR_INDEX_SEP,
255        escape_addr_segment(&index.to_string())
256    )
257}
258
259/// Create an address for naming random variables and observation sites.
260/// This macro provides a convenient way to create `Address` instances with human-readable names and optional indices.
261/// The macro supports two forms:
262///
263/// - `addr!("name")` - Simple named address
264/// - `addr!("name", index)` - Indexed address using "name#index" format
265///
266/// Example:
267/// ```rust
268/// use fugue::*;
269/// // Simple addresses
270/// let mu = addr!("mu");
271/// let sigma = addr!("sigma");
272/// // Indexed addresses for collections
273/// let data_0 = addr!("data", 0);
274/// let data_1 = addr!("data", 1);
275/// // Use in models
276/// let model = sample(addr!("x"), Normal::new(0.0, 1.0).unwrap())
277///     .bind(|x| {
278///         // Index can be dynamic
279///         let i = 42;
280///         sample(addr!("y", i), Normal::new(x, 0.1).unwrap())
281///     });
282/// ```
283#[macro_export]
284macro_rules! addr {
285    ($name:expr) => {
286        $crate::core::address::Address::new($crate::core::address::make_name($name))
287    };
288    ($name:expr, $i:expr) => {
289        $crate::core::address::Address::new($crate::core::address::make_indexed($name, $i))
290    };
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296    use std::collections::{BTreeSet, HashSet};
297
298    #[test]
299    fn display_formats_inner_string() {
300        let a = Address::new("alpha");
301        assert_eq!(a.to_string(), "alpha");
302    }
303
304    // Regression for FG-05: an Address caches a hash of its backing string, so
305    // the `Hash` impl must agree with `Eq` (equal addresses hash equally) and
306    // clones must remain equal and share the backing buffer.
307    #[test]
308    fn cached_hash_is_consistent_with_eq_and_clone() {
309        use std::collections::hash_map::DefaultHasher;
310        use std::hash::{Hash, Hasher};
311
312        fn h(a: &Address) -> u64 {
313            let mut hasher = DefaultHasher::new();
314            a.hash(&mut hasher);
315            hasher.finish()
316        }
317
318        let a = addr!("mu", 7);
319        let b = addr!("mu", 7);
320        assert_eq!(a, b);
321        assert_eq!(h(&a), h(&b), "equal addresses must hash equally");
322
323        // Clone is allocation-free (shares the Arc) and stays equal.
324        let c = a.clone();
325        assert_eq!(a, c);
326        assert!(Arc::ptr_eq(&a.repr, &c.repr));
327        assert_eq!(h(&a), h(&c));
328
329        // A different address hashes differently (with overwhelming probability)
330        // and, more importantly, compares unequal.
331        let d = addr!("mu", 8);
332        assert_ne!(a, d);
333    }
334
335    #[test]
336    fn addr_macro_basic_and_indexed() {
337        let a = addr!("x");
338        assert_eq!(a.as_str(), "x");
339
340        let b = addr!("x", 3);
341        assert_eq!(b.as_str(), "x#3");
342    }
343
344    // Regression for FG-26 / FG-52: the `addr!` index-separator scheme must be
345    // collision-free. A literal '#' inside a name is escaped ("\#"), while the
346    // separator between name and index is an unescaped '#', so distinct calls
347    // can never alias to the same backing string.
348    #[test]
349    fn addr_indexed_and_literal_hash_do_not_alias() {
350        // The historical footgun: both used to produce "x#3".
351        let indexed = addr!("x", 3);
352        let literal_hash = addr!("x#3");
353        assert_ne!(indexed, literal_hash);
354        assert_eq!(indexed.as_str(), "x#3");
355        assert_eq!(literal_hash.as_str(), "x\\#3");
356
357        // The auditor's second example: addr!("a", "b#3") vs addr!("a#b", 3).
358        let a = addr!("a", "b#3");
359        let b = addr!("a#b", 3);
360        assert_ne!(a, b);
361        assert_eq!(a.as_str(), "a#b\\#3");
362        assert_eq!(b.as_str(), "a\\#b#3");
363
364        // The backslash escape character is itself escaped so it cannot forge
365        // a separator boundary.
366        assert_ne!(addr!("a\\", 1), addr!("a\\#1"));
367    }
368
369    // Regression for FG-26 / FG-52: the encoding is injective, so a name/index
370    // pair that could previously collide via a shared '#' now stays distinct.
371    #[test]
372    fn addr_encoding_is_injective_across_hash_placements() {
373        // (name = "a#", index = "b") vs (name = "a", index = "#b").
374        // Under a naive doubling scheme these both collapse to "a###b"; the
375        // backslash scheme keeps them apart.
376        let left = addr!("a#", "b");
377        let right = addr!("a", "#b");
378        assert_ne!(left, right);
379        assert_eq!(left.as_str(), "a\\##b");
380        assert_eq!(right.as_str(), "a#\\#b");
381    }
382
383    #[test]
384    fn equality_hash_and_ordering() {
385        let a1 = Address::new("x");
386        let a2 = Address::new("x");
387        let b = Address::new("y");
388
389        // Eq/Hash
390        let mut set = HashSet::new();
391        set.insert(a1.clone());
392        set.insert(a2.clone());
393        set.insert(b.clone());
394        assert_eq!(set.len(), 2);
395
396        // Ord/PartialOrd via BTreeSet (lexicographic)
397        let mut bset = BTreeSet::new();
398        bset.insert(b);
399        bset.insert(a1);
400        // Expect alphabetical order: "x" comes after "y"? No, "x" < "y"
401        let ordered: Vec<String> = bset.into_iter().map(|a| a.as_str().to_string()).collect();
402        assert_eq!(ordered, vec!["x".to_string(), "y".to_string()]);
403    }
404}