datalog_structural_model/ir/symbols.rs
1//! Predicate, variable, and constant symbols used by the
2//! Datalog IR.
3//!
4//! All three carry their textual identity as `Arc<str>` so the
5//! evaluator hot-path binding clones and trail rollback
6//! becomes a refcount bump instead of a `String::clone` →
7//! memmove. The wire / debug surface is unchanged; constructors
8//! still accept `impl Into<String>` so callers don't see the
9//! switch.
10//!
11//! [`VariableSymbol`] additionally caches a precomputed `FxHash`
12//! of its name so `HashMap` lookups on the forward engine's
13//! `Binding` skip rehashing the name string every time. The
14//! cached value is `state.write_u64`-fed into whatever hasher
15//! the map uses (Fx today, deterministic per-name regardless).
16//! Trail/profile justification: samply showed
17//! `Binding::get` (`FxHash` + memcmp) costing ~15 % of total
18//! samples on a rule-materialization benchmark after replacing
19//! `SipHash` with `FxHashMap`.
20
21use std::hash::{Hash, Hasher};
22use std::sync::Arc;
23
24use rustc_hash::FxHasher;
25
26/// Predicate symbol in a Datalog atom.
27#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
28pub struct PredicateSymbol(Arc<str>);
29
30impl PredicateSymbol {
31 /// Construct a predicate symbol.
32 #[must_use]
33 pub fn new(value: impl Into<String>) -> Self {
34 Self(Arc::from(value.into()))
35 }
36
37 /// Access the raw predicate identifier.
38 #[must_use]
39 pub fn as_str(&self) -> &str {
40 &self.0
41 }
42}
43
44/// Variable symbol used in Datalog terms.
45///
46/// Carries a precomputed `FxHash` of its name alongside the
47/// `Arc<str>` so [`std::collections::HashMap`]-keyed lookups (notably the forward
48/// engine bindings) hash a single `u64` instead of walking
49/// the name bytes on every probe.
50#[derive(Clone, Debug)]
51pub struct VariableSymbol {
52 name: Arc<str>,
53 cached_hash: u64,
54}
55
56impl VariableSymbol {
57 /// Construct a variable symbol. Computes the cached hash of
58 /// `name` once; subsequent `Hash` calls feed that `u64` to
59 /// whatever hasher the map uses.
60 #[must_use]
61 pub fn new(value: impl Into<String>) -> Self {
62 let name: Arc<str> = Arc::from(value.into());
63 let cached_hash = {
64 let mut h = FxHasher::default();
65 h.write(name.as_bytes());
66 h.finish()
67 };
68 Self { name, cached_hash }
69 }
70
71 /// Access the raw variable identifier.
72 #[must_use]
73 pub fn as_str(&self) -> &str {
74 &self.name
75 }
76}
77
78impl PartialEq for VariableSymbol {
79 fn eq(&self, other: &Self) -> bool {
80 // Hash inequality is a fast 8-byte prefilter that lets
81 // hash hits short-circuit before walking the full name.
82 // True equality still requires identical names because
83 // the cached hash is derived from them; the inequality
84 // case is the common one on HashMap probes.
85 self.cached_hash == other.cached_hash && self.name == other.name
86 }
87}
88
89impl Eq for VariableSymbol {}
90
91impl Hash for VariableSymbol {
92 fn hash<H: Hasher>(&self, state: &mut H) {
93 state.write_u64(self.cached_hash);
94 }
95}
96
97impl PartialOrd for VariableSymbol {
98 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
99 Some(self.cmp(other))
100 }
101}
102
103impl Ord for VariableSymbol {
104 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
105 // Stable, content-addressed ordering — compare names, not
106 // hashes, so `BTreeSet` / `BTreeMap` order is independent
107 // of the hasher seed.
108 self.name.cmp(&other.name)
109 }
110}
111
112/// Constant symbol used in Datalog terms.
113///
114/// Like [`VariableSymbol`], carries a precomputed `FxHash` of its
115/// value alongside the `Arc<str>`. Constants often dominate
116/// evaluator hot paths — a hash-join frontier index
117/// (`FxHashMap<GroundTerm, _>`) hashes a constant on every probe,
118/// and `unify_one` compares constants on every fact cell — so a
119/// cached hash (fed as a single `u64`) plus a hash-prefiltered
120/// equality cut the per-fact term cost. Profile justification:
121/// profiling of a bulk rule-materialization benchmark showed
122/// `GroundTerm` `PartialEq` plus hash-join
123/// index's `GroundTerm` hashing at ~20 % of total samples, all
124/// content-based on the constant `Arc<str>`.
125///
126/// Ordering stays content-based so `BTreeSet` / `BTreeMap` order
127/// (a relation prefix index may rely on it for range queries) is
128/// independent of the hasher seed — the same split
129/// [`VariableSymbol`] makes.
130#[derive(Clone, Debug)]
131pub struct ConstantSymbol {
132 value: Arc<str>,
133 cached_hash: u64,
134}
135
136impl ConstantSymbol {
137 /// Construct a constant symbol. Computes the cached hash of
138 /// `value` once; subsequent `Hash` calls feed that `u64` to
139 /// whatever hasher the map uses.
140 #[must_use]
141 pub fn new(value: impl Into<String>) -> Self {
142 let value: Arc<str> = Arc::from(value.into());
143 let cached_hash = {
144 let mut h = FxHasher::default();
145 h.write(value.as_bytes());
146 h.finish()
147 };
148 Self { value, cached_hash }
149 }
150
151 /// Access the raw constant identifier.
152 #[must_use]
153 pub fn as_str(&self) -> &str {
154 &self.value
155 }
156}
157
158impl PartialEq for ConstantSymbol {
159 fn eq(&self, other: &Self) -> bool {
160 // Hash inequality is a fast 8-byte prefilter; true equality
161 // still requires identical values (the cached hash is
162 // derived from them). Inequality is the common case on the
163 // hash-join probe and on `unify_one`'s constant comparisons.
164 self.cached_hash == other.cached_hash && self.value == other.value
165 }
166}
167
168impl Eq for ConstantSymbol {}
169
170impl Hash for ConstantSymbol {
171 fn hash<H: Hasher>(&self, state: &mut H) {
172 state.write_u64(self.cached_hash);
173 }
174}
175
176impl PartialOrd for ConstantSymbol {
177 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
178 Some(self.cmp(other))
179 }
180}
181
182impl Ord for ConstantSymbol {
183 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
184 // Content-addressed ordering — compare values, not hashes,
185 // so `BTreeSet` / `BTreeMap` order is deterministic and
186 // hasher-independent.
187 self.value.cmp(&other.value)
188 }
189}
190
191#[cfg(test)]
192mod tests {
193 use std::collections::{BTreeSet, HashMap};
194
195 use super::*;
196
197 /// The hand-written `Eq`/`Hash` must keep the std invariant
198 /// `a == b ⟹ hash(a) == hash(b)`: equal values resolve to the
199 /// same `HashMap` slot regardless of which `Arc` allocation they
200 /// came from.
201 #[test]
202 fn equal_constants_hash_and_compare_equal() {
203 let a = ConstantSymbol::new("http://ex.org/x");
204 let b = ConstantSymbol::new(String::from("http://ex.org/x"));
205 assert_eq!(a, b);
206
207 let mut map: HashMap<ConstantSymbol, u32> = HashMap::new();
208 map.insert(a, 7);
209 assert_eq!(map.get(&b), Some(&7), "equal values must hash to one slot");
210 }
211
212 /// Distinct values stay distinct (the cached-hash prefilter is an
213 /// optimisation, not a substitute for value equality).
214 #[test]
215 fn distinct_constants_are_unequal() {
216 let a = ConstantSymbol::new("http://ex.org/x");
217 let b = ConstantSymbol::new("http://ex.org/y");
218 assert_ne!(a, b);
219 }
220
221 /// Ordering stays content-based (lexicographic on the value), so
222 /// `BTreeSet`/`BTreeMap` order is independent of the cached hash —
223 /// the property the `FactSet` prefix-range index relies on.
224 #[test]
225 fn ordering_is_content_based() {
226 let set: BTreeSet<ConstantSymbol> = ["c", "a", "b"]
227 .into_iter()
228 .map(ConstantSymbol::new)
229 .collect();
230 let ordered: Vec<&str> = set.iter().map(ConstantSymbol::as_str).collect();
231 assert_eq!(ordered, ["a", "b", "c"]);
232 }
233}