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
108impl Display for Address {
109    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
110        write!(f, "{}", self.repr)
111    }
112}
113
114impl Deref for Address {
115    type Target = str;
116    #[inline]
117    fn deref(&self) -> &str {
118        &self.repr
119    }
120}
121
122impl Hash for Address {
123    /// Write the precomputed hash rather than re-hashing the string on every
124    /// `HashMap` probe (FG-05).
125    #[inline]
126    fn hash<H: Hasher>(&self, state: &mut H) {
127        state.write_u64(self.hash);
128    }
129}
130
131impl PartialEq for Address {
132    /// Equality compares the underlying `str`; the cached hash is used only as a
133    /// fast reject so distinct strings that collide in the hash still compare
134    /// unequal.
135    #[inline]
136    fn eq(&self, other: &Self) -> bool {
137        self.hash == other.hash && self.repr == other.repr
138    }
139}
140
141impl Eq for Address {}
142
143impl PartialOrd for Address {
144    #[inline]
145    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
146        Some(self.cmp(other))
147    }
148}
149
150impl Ord for Address {
151    /// Lexicographic ordering on the backing string, preserving stable
152    /// `BTreeMap` iteration order.
153    #[inline]
154    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
155        self.as_str().cmp(other.as_str())
156    }
157}
158
159impl From<String> for Address {
160    #[inline]
161    fn from(s: String) -> Self {
162        Address::new(s)
163    }
164}
165
166impl From<&str> for Address {
167    #[inline]
168    fn from(s: &str) -> Self {
169        Address::new(s)
170    }
171}
172
173/// The reserved separator placed between a name and its index inside an
174/// [`Address`] string built by `addr!(name, index)`.
175pub const ADDR_INDEX_SEP: char = '#';
176
177/// Escape a single address segment (a name or an index) so that a literal
178/// occurrence of the reserved separator [`ADDR_INDEX_SEP`] (`'#'`) can never be
179/// confused with the real separator, and so the escape character `'\'` itself is
180/// unambiguous.
181///
182/// The escaping is the standard, injective backslash scheme (`'\' -> "\\"`,
183/// `'#' -> "\#"`). Segments that contain neither character are returned verbatim
184/// (the common case), so no allocation-visible change occurs for ordinary names.
185///
186/// This is an implementation detail used by the `addr!` and `scoped_addr!`
187/// macros; it is public only so those macros can expand to it.
188#[doc(hidden)]
189pub fn escape_addr_segment(segment: &str) -> String {
190    if segment.contains('\\') || segment.contains('#') {
191        let mut out = String::with_capacity(segment.len() + 4);
192        for ch in segment.chars() {
193            match ch {
194                '\\' => out.push_str("\\\\"),
195                '#' => out.push_str("\\#"),
196                other => out.push(other),
197            }
198        }
199        out
200    } else {
201        segment.to_string()
202    }
203}
204
205/// Build the backing string for a plain (unindexed) address, escaping the
206/// reserved separator inside the name. Used by `addr!(name)`.
207#[doc(hidden)]
208pub fn make_name(name: impl Display) -> String {
209    escape_addr_segment(&name.to_string())
210}
211
212/// Build the backing string for an indexed address `"{name}#{index}"`, escaping
213/// the reserved separator inside both segments so the encoding is injective.
214/// Used by `addr!(name, index)`.
215#[doc(hidden)]
216pub fn make_indexed(name: impl Display, index: impl Display) -> String {
217    format!(
218        "{}{}{}",
219        escape_addr_segment(&name.to_string()),
220        ADDR_INDEX_SEP,
221        escape_addr_segment(&index.to_string())
222    )
223}
224
225/// Create an address for naming random variables and observation sites.
226/// This macro provides a convenient way to create `Address` instances with human-readable names and optional indices.
227/// The macro supports two forms:
228///
229/// - `addr!("name")` - Simple named address
230/// - `addr!("name", index)` - Indexed address using "name#index" format
231///
232/// Example:
233/// ```rust
234/// use fugue::*;
235/// // Simple addresses
236/// let mu = addr!("mu");
237/// let sigma = addr!("sigma");
238/// // Indexed addresses for collections
239/// let data_0 = addr!("data", 0);
240/// let data_1 = addr!("data", 1);
241/// // Use in models
242/// let model = sample(addr!("x"), Normal::new(0.0, 1.0).unwrap())
243///     .bind(|x| {
244///         // Index can be dynamic
245///         let i = 42;
246///         sample(addr!("y", i), Normal::new(x, 0.1).unwrap())
247///     });
248/// ```
249#[macro_export]
250macro_rules! addr {
251    ($name:expr) => {
252        $crate::core::address::Address::new($crate::core::address::make_name($name))
253    };
254    ($name:expr, $i:expr) => {
255        $crate::core::address::Address::new($crate::core::address::make_indexed($name, $i))
256    };
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262    use std::collections::{BTreeSet, HashSet};
263
264    #[test]
265    fn display_formats_inner_string() {
266        let a = Address::new("alpha");
267        assert_eq!(a.to_string(), "alpha");
268    }
269
270    // Regression for FG-05: an Address caches a hash of its backing string, so
271    // the `Hash` impl must agree with `Eq` (equal addresses hash equally) and
272    // clones must remain equal and share the backing buffer.
273    #[test]
274    fn cached_hash_is_consistent_with_eq_and_clone() {
275        use std::collections::hash_map::DefaultHasher;
276        use std::hash::{Hash, Hasher};
277
278        fn h(a: &Address) -> u64 {
279            let mut hasher = DefaultHasher::new();
280            a.hash(&mut hasher);
281            hasher.finish()
282        }
283
284        let a = addr!("mu", 7);
285        let b = addr!("mu", 7);
286        assert_eq!(a, b);
287        assert_eq!(h(&a), h(&b), "equal addresses must hash equally");
288
289        // Clone is allocation-free (shares the Arc) and stays equal.
290        let c = a.clone();
291        assert_eq!(a, c);
292        assert!(Arc::ptr_eq(&a.repr, &c.repr));
293        assert_eq!(h(&a), h(&c));
294
295        // A different address hashes differently (with overwhelming probability)
296        // and, more importantly, compares unequal.
297        let d = addr!("mu", 8);
298        assert_ne!(a, d);
299    }
300
301    #[test]
302    fn addr_macro_basic_and_indexed() {
303        let a = addr!("x");
304        assert_eq!(a.as_str(), "x");
305
306        let b = addr!("x", 3);
307        assert_eq!(b.as_str(), "x#3");
308    }
309
310    // Regression for FG-26 / FG-52: the `addr!` index-separator scheme must be
311    // collision-free. A literal '#' inside a name is escaped ("\#"), while the
312    // separator between name and index is an unescaped '#', so distinct calls
313    // can never alias to the same backing string.
314    #[test]
315    fn addr_indexed_and_literal_hash_do_not_alias() {
316        // The historical footgun: both used to produce "x#3".
317        let indexed = addr!("x", 3);
318        let literal_hash = addr!("x#3");
319        assert_ne!(indexed, literal_hash);
320        assert_eq!(indexed.as_str(), "x#3");
321        assert_eq!(literal_hash.as_str(), "x\\#3");
322
323        // The auditor's second example: addr!("a", "b#3") vs addr!("a#b", 3).
324        let a = addr!("a", "b#3");
325        let b = addr!("a#b", 3);
326        assert_ne!(a, b);
327        assert_eq!(a.as_str(), "a#b\\#3");
328        assert_eq!(b.as_str(), "a\\#b#3");
329
330        // The backslash escape character is itself escaped so it cannot forge
331        // a separator boundary.
332        assert_ne!(addr!("a\\", 1), addr!("a\\#1"));
333    }
334
335    // Regression for FG-26 / FG-52: the encoding is injective, so a name/index
336    // pair that could previously collide via a shared '#' now stays distinct.
337    #[test]
338    fn addr_encoding_is_injective_across_hash_placements() {
339        // (name = "a#", index = "b") vs (name = "a", index = "#b").
340        // Under a naive doubling scheme these both collapse to "a###b"; the
341        // backslash scheme keeps them apart.
342        let left = addr!("a#", "b");
343        let right = addr!("a", "#b");
344        assert_ne!(left, right);
345        assert_eq!(left.as_str(), "a\\##b");
346        assert_eq!(right.as_str(), "a#\\#b");
347    }
348
349    #[test]
350    fn equality_hash_and_ordering() {
351        let a1 = Address::new("x");
352        let a2 = Address::new("x");
353        let b = Address::new("y");
354
355        // Eq/Hash
356        let mut set = HashSet::new();
357        set.insert(a1.clone());
358        set.insert(a2.clone());
359        set.insert(b.clone());
360        assert_eq!(set.len(), 2);
361
362        // Ord/PartialOrd via BTreeSet (lexicographic)
363        let mut bset = BTreeSet::new();
364        bset.insert(b);
365        bset.insert(a1);
366        // Expect alphabetical order: "x" comes after "y"? No, "x" < "y"
367        let ordered: Vec<String> = bset.into_iter().map(|a| a.as_str().to_string()).collect();
368        assert_eq!(ordered, vec!["x".to_string(), "y".to_string()]);
369    }
370}