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}
0 commit comments