Skip to content

Commit 5fc9e4d

Browse files
authored
Merge pull request #303 from AdaWorldAPI/claude/pr-f6-dn-path-scent
feat(F6): FNV-1a scent with scent_u64 accessor + birthday collision tests
2 parents 830c266 + b5370a5 commit 5fc9e4d

2 files changed

Lines changed: 127 additions & 23 deletions

File tree

crates/lance-graph-callcenter/src/dn_path.rs

Lines changed: 124 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
//!
55
//! Each segment is a u64 FNV-1a hash of the path component. The 6-tuple
66
//! compresses via ZeckBF17→Base17→CAM-PQ→scent (1B, ρ=0.937).
7-
//! Phase C wires the full compression chain. Phase A carries the
8-
//! parsed address and a stub scent derived by XOR-folding the 6 hashes.
7+
//! Phase C wires the full compression chain. The scent is computed by
8+
//! FNV-1a hashing the canonical hex representation of the 6 segment
9+
//! hashes and XOR-folding the 64-bit digest to 1 byte.
910
//!
1011
//! Plan: `.claude/plans/callcenter-membrane-v1.md` § 10.12 – § 10.13
1112
@@ -45,21 +46,51 @@ impl DnPath {
4546
})
4647
}
4748

48-
/// Phase-A scent stub: XOR-fold of the 6 segment hashes into 1 byte.
49+
/// 64-bit FNV-1a digest over the canonical hex path.
4950
///
50-
/// Phase C replaces this with the full ZeckBF17→Base17→CAM-PQ chain
51-
/// (16Kbit → 48B → 34B → 6B → 1B, ρ=0.937). Until then, this gives
52-
/// a deterministic, stable placeholder that exercises the scent field.
51+
/// The canonical form is the 6 segment hashes rendered as hex and
52+
/// concatenated with `/` separators (deterministic, stable, zero-dep).
53+
/// CAM-PQ stages downstream (HHTL Phase C) keep the full bits;
54+
/// [`scent()`](Self::scent) folds this to u8 for HHTL Phase A bucket
55+
/// dispatch.
56+
pub fn scent_u64(&self) -> u64 {
57+
use core::fmt::Write;
58+
let mut buf = String::with_capacity(6 * 17);
59+
let segments = [self.ns, self.heel, self.hip, self.branch, self.twig, self.leaf];
60+
for (i, seg) in segments.iter().enumerate() {
61+
if i > 0 { buf.push('/'); }
62+
let _ = write!(buf, "{:016x}", seg);
63+
}
64+
fnv1a(&buf)
65+
}
66+
67+
/// Compute the scent of this DN path: FNV-1a hash of the canonical
68+
/// path string, folded to a single `u8`.
69+
///
70+
/// XOR-folds [`scent_u64()`](Self::scent_u64) (64 → 8 bits), preserving
71+
/// avalanche properties much better than the old XOR-fold of individual
72+
/// segment hashes.
73+
///
74+
/// Future phases may replace this with ZeckBF17→Base17→CAM-PQ
75+
/// (16Kbit → 48B → 34B → 6B → 1B, ρ=0.937) once bgz-tensor
76+
/// enters the callcenter dep tree.
77+
pub fn scent(&self) -> u8 {
78+
let h = self.scent_u64();
79+
let folded = h
80+
^ (h >> 8)
81+
^ (h >> 16)
82+
^ (h >> 24)
83+
^ (h >> 32)
84+
^ (h >> 40)
85+
^ (h >> 48)
86+
^ (h >> 56);
87+
folded as u8
88+
}
89+
90+
/// Deprecated alias — use [`scent()`](Self::scent) instead.
91+
#[deprecated(since = "0.1.1", note = "renamed to `scent()`; the XOR-fold stub has been replaced with FNV-1a")]
5392
pub fn scent_stub(&self) -> u8 {
54-
let fold = self.ns ^ self.heel ^ self.hip ^ self.branch ^ self.twig ^ self.leaf;
55-
(fold
56-
^ (fold >> 8)
57-
^ (fold >> 16)
58-
^ (fold >> 24)
59-
^ (fold >> 32)
60-
^ (fold >> 40)
61-
^ (fold >> 48)
62-
^ (fold >> 56)) as u8
93+
self.scent()
6394
}
6495
}
6596

@@ -102,7 +133,7 @@ mod tests {
102133
}
103134

104135
#[test]
105-
fn scent_stub_is_deterministic() {
136+
fn scent_is_deterministic() {
106137
let p1 = DnPath::parse(
107138
"/tree/ada/heel/callcenter/hip/v1/branch/agents/twig/card/leaf/abc",
108139
)
@@ -111,11 +142,11 @@ mod tests {
111142
"/tree/ada/heel/callcenter/hip/v1/branch/agents/twig/card/leaf/abc",
112143
)
113144
.unwrap();
114-
assert_eq!(p1.scent_stub(), p2.scent_stub());
145+
assert_eq!(p1.scent(), p2.scent());
115146
}
116147

117148
#[test]
118-
fn different_paths_typically_differ() {
149+
fn different_paths_different_scents() {
119150
let p1 = DnPath::parse(
120151
"/tree/ada/heel/callcenter/hip/v1/branch/agents/twig/card/leaf/abc",
121152
)
@@ -124,7 +155,80 @@ mod tests {
124155
"/tree/ada/heel/callcenter/hip/v1/branch/agents/twig/card/leaf/xyz",
125156
)
126157
.unwrap();
127-
// leaf differs → scent should differ (not guaranteed but very likely for FNV-1a)
128-
assert_ne!(p1.leaf, p2.leaf);
158+
assert_ne!(p1.scent(), p2.scent());
159+
}
160+
161+
#[test]
162+
fn empty_path_scent() {
163+
let p = DnPath::default();
164+
let s1 = p.scent();
165+
let s2 = p.scent();
166+
assert_eq!(s1, s2);
167+
}
168+
169+
#[allow(deprecated)]
170+
#[test]
171+
fn scent_stub_alias_matches_scent() {
172+
let p = DnPath::parse(
173+
"/tree/ada/heel/callcenter/hip/v1/branch/agents/twig/card/leaf/abc",
174+
)
175+
.unwrap();
176+
assert_eq!(p.scent_stub(), p.scent());
177+
}
178+
179+
#[test]
180+
fn scent_u64_fold_matches_scent() {
181+
let p = DnPath::parse(
182+
"/tree/ada/heel/callcenter/hip/v1/branch/agents/twig/card/leaf/abc",
183+
)
184+
.unwrap();
185+
let h = p.scent_u64();
186+
let folded = (h
187+
^ (h >> 8)
188+
^ (h >> 16)
189+
^ (h >> 24)
190+
^ (h >> 32)
191+
^ (h >> 40)
192+
^ (h >> 48)
193+
^ (h >> 56)) as u8;
194+
assert_eq!(folded, p.scent());
195+
}
196+
197+
#[test]
198+
fn scent_distribution_100_paths_low_collision() {
199+
let paths: Vec<DnPath> = (0..100)
200+
.map(|i| {
201+
DnPath::parse(&format!(
202+
"/tree/tenant/heel/agent_{i}/hip/session_{i}/branch/leaf_{i}/twig/t_{i}/leaf/l_{i}"
203+
))
204+
.unwrap()
205+
})
206+
.collect();
207+
let scents: Vec<u8> = paths.iter().map(|p| p.scent()).collect();
208+
let unique: std::collections::HashSet<_> = scents.iter().copied().collect();
209+
assert!(
210+
unique.len() >= 50,
211+
"FNV-1a XOR-fold should distribute >=50 unique buckets across 100 distinct paths, got {}",
212+
unique.len()
213+
);
214+
}
215+
216+
#[test]
217+
fn scent_u64_distribution_100_paths_all_unique() {
218+
let paths: Vec<DnPath> = (0..100)
219+
.map(|i| {
220+
DnPath::parse(&format!(
221+
"/tree/tenant/heel/agent_{i}/hip/session_{i}/branch/leaf_{i}/twig/t_{i}/leaf/l_{i}"
222+
))
223+
.unwrap()
224+
})
225+
.collect();
226+
let scents: std::collections::HashSet<u64> =
227+
paths.iter().map(|p| p.scent_u64()).collect();
228+
assert_eq!(
229+
scents.len(),
230+
100,
231+
"scent_u64 in 64-bit codomain should have zero collisions in 100 paths"
232+
);
129233
}
130234
}

crates/lance-graph-callcenter/src/lance_membrane.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -397,7 +397,7 @@ impl ExternalMembrane for LanceMembrane {
397397
/// Four-step BBB crossing:
398398
/// 1. Pass membrane — `ExternalIntent` is the safe crossing type.
399399
/// 2. Get a role — `intent.role` is stamped into `current_actor.role`.
400-
/// 3. Get a place — `intent.dn.scent_stub()` becomes `current_scent`.
400+
/// 3. Get a place — `intent.dn.scent()` becomes `current_scent`.
401401
/// 4. Translate — returns `UnifiedStep` for `OrchestrationBridge::route()`.
402402
fn ingest(&self, intent: ExternalIntent) -> UnifiedStep {
403403
// 2. Role — atomic write to the shared actor state (F-01 fix).
@@ -418,8 +418,8 @@ impl ExternalMembrane for LanceMembrane {
418418
actor.role = role;
419419
}
420420

421-
// 3. Place (Phase A: XOR-fold stub; Phase C: full cascade)
422-
let scent = intent.dn.scent_stub();
421+
// 3. Place (FNV-1a scent; Phase C may upgrade to full cascade)
422+
let scent = intent.dn.scent();
423423
self.current_scent.store(scent as u64, Ordering::Release);
424424

425425
// 4. Translate to step type for OrchestrationBridge

0 commit comments

Comments
 (0)