@@ -158,13 +158,42 @@ impl Default for BuildConfig {
158158/// Distance function type: takes two byte slices of equal length, returns u64.
159159pub type DistanceFn = fn ( & [ u8 ] , & [ u8 ] ) -> u64 ;
160160
161+ /// The tree's distance, carried on one of two arms.
162+ ///
163+ /// `Ptr` is every pre-existing construction path, byte-identical in behaviour.
164+ /// `Dyn` is the stateful arm ([`ClamTree::build_with_distance`]): a distance
165+ /// that carries configuration — e.g. a ClassView-derived `RailSpec` — which a
166+ /// bare fn pointer cannot hold. Both arms feed the SAME partition core and the
167+ /// SAME search paths through [`ClamTree::dist`], so one name can never mean
168+ /// two things (`I-LEGACY-API-FEATURE-GATED`).
169+ enum TreeDistance {
170+ Ptr ( DistanceFn ) ,
171+ Dyn {
172+ f : Box < dyn Fn ( & [ u8 ] , & [ u8 ] ) -> u64 + Send + Sync > ,
173+ /// Carried from [`Distance::is_metric`]. The `Ptr` arm is recorded as
174+ /// metric because every historical caller relied on triangle-inequality
175+ /// pruning with it — preserving, not blessing, that assumption.
176+ metric : bool ,
177+ } ,
178+ }
179+
180+ impl TreeDistance {
181+ #[ inline]
182+ fn eval ( & self , a : & [ u8 ] , b : & [ u8 ] ) -> u64 {
183+ match self {
184+ TreeDistance :: Ptr ( f) => f ( a, b) ,
185+ TreeDistance :: Dyn { f, .. } => f ( a, b) ,
186+ }
187+ }
188+ }
189+
161190/// Divisive hierarchical clustering tree.
162191pub struct ClamTree {
163192 pub nodes : Vec < Cluster > ,
164193 pub reordered : Vec < usize > ,
165194 pub num_leaves : usize ,
166195 pub mean_leaf_radius : f64 ,
167- distance_fn : DistanceFn ,
196+ distance : TreeDistance ,
168197}
169198
170199impl ClamTree {
@@ -185,24 +214,64 @@ impl ClamTree {
185214
186215 /// Build a CLAM tree with a custom distance function.
187216 pub fn build_with_fn ( data : & [ u8 ] , vec_len : usize , count : usize , config : & BuildConfig , dist_fn : DistanceFn ) -> Self {
188- assert_eq ! ( data. len( ) , vec_len * count) ;
217+ Self :: build_core ( data, vec_len, count, config, & dist_fn, TreeDistance :: Ptr ( dist_fn) )
218+ }
189219
220+ /// Build with a **stateful** distance — the universal arm.
221+ ///
222+ /// A [`Distance`] impl can carry configuration a bare fn pointer cannot:
223+ /// a byte-range spec, a codebook, a resolved ClassView reading. Its
224+ /// [`Distance::is_metric`] answer is carried into the tree (see
225+ /// [`ClamTree::is_metric`]) instead of being assumed. Identical inputs
226+ /// produce a tree identical to the fn-pointer arm — pinned by test, not
227+ /// claimed.
228+ pub fn build_with_distance < D > ( data : & [ u8 ] , vec_len : usize , count : usize , config : & BuildConfig , dist : D ) -> Self
229+ where
230+ D : Distance < Point = [ u8 ] > + Send + Sync + ' static ,
231+ {
232+ let metric = dist. is_metric ( ) ;
233+ let f: Box < dyn Fn ( & [ u8 ] , & [ u8 ] ) -> u64 + Send + Sync > = Box :: new ( move |a, b| dist. distance ( a, b) ) ;
234+ // Build against a borrow of the SAME closure that will be stored —
235+ // one callable, never a rebuilt twin that could drift.
236+ let nodes_input = & * f as & dyn Fn ( & [ u8 ] , & [ u8 ] ) -> u64 ;
237+ // SAFETY-free reborrow dance: build_core only borrows `dist` during
238+ // construction; the box is moved into the carrier afterwards.
239+ let tmp = Self :: build_core_nodes ( data, vec_len, count, config, nodes_input) ;
240+ Self :: assemble ( tmp, TreeDistance :: Dyn { f, metric } )
241+ }
242+
243+ /// The one construction core, parameterised over any callable. Both
244+ /// public arms land here.
245+ fn build_core (
246+ data : & [ u8 ] , vec_len : usize , count : usize , config : & BuildConfig , dist : & dyn Fn ( & [ u8 ] , & [ u8 ] ) -> u64 ,
247+ carrier : TreeDistance ,
248+ ) -> Self {
249+ let tmp = Self :: build_core_nodes ( data, vec_len, count, config, dist) ;
250+ Self :: assemble ( tmp, carrier)
251+ }
252+
253+ fn build_core_nodes (
254+ data : & [ u8 ] , vec_len : usize , count : usize , config : & BuildConfig , dist : & dyn Fn ( & [ u8 ] , & [ u8 ] ) -> u64 ,
255+ ) -> ( Vec < Cluster > , Vec < usize > ) {
256+ // Order and constants are the ORIGINAL build's, verbatim: assert
257+ // before the empty return, the 0xDEAD_BEEF_CAFE_BABE seed, the
258+ // 2*count capacity. A shared core that quietly changes any of them
259+ // would rebuild every existing caller's tree differently — the exact
260+ // silent break this refactor exists to make impossible.
261+ assert_eq ! ( data. len( ) , vec_len * count) ;
190262 if count == 0 {
191- return ClamTree {
192- nodes : Vec :: new ( ) ,
193- reordered : Vec :: new ( ) ,
194- num_leaves : 0 ,
195- mean_leaf_radius : 0.0 ,
196- distance_fn : dist_fn,
197- } ;
263+ return ( Vec :: new ( ) , Vec :: new ( ) ) ;
198264 }
199-
200265 let mut indices: Vec < usize > = ( 0 ..count) . collect ( ) ;
201266 let mut nodes = Vec :: with_capacity ( 2 * count) ;
202267 let mut rng = SplitMix64 :: new ( 0xDEAD_BEEF_CAFE_BABE ) ;
268+ Self :: partition ( data, vec_len, & mut indices, 0 , count, 0 , config, & mut nodes, & mut rng, dist) ;
269+ ( nodes, indices)
270+ }
203271
204- Self :: partition ( data, vec_len, & mut indices, 0 , count, 0 , config, & mut nodes, & mut rng, dist_fn) ;
205-
272+ fn assemble ( ( nodes, reordered) : ( Vec < Cluster > , Vec < usize > ) , carrier : TreeDistance ) -> Self {
273+ // Integer sum, one division — the original arithmetic, not an f64
274+ // re-summation with different rounding.
206275 let mut num_leaves = 0usize ;
207276 let mut leaf_radius_sum = 0u64 ;
208277 for node in & nodes {
@@ -216,21 +285,20 @@ impl ClamTree {
216285 } else {
217286 0.0
218287 } ;
219-
220288 ClamTree {
221289 nodes,
222- reordered : indices ,
290+ reordered,
223291 num_leaves,
224292 mean_leaf_radius,
225- distance_fn : dist_fn ,
293+ distance : carrier ,
226294 }
227295 }
228296
229297 /// Recursive partition (Algorithm 1 from CAKES).
230298 #[ allow( clippy:: too_many_arguments) ]
231299 fn partition (
232300 data : & [ u8 ] , vec_len : usize , indices : & mut [ usize ] , start : usize , end : usize , depth : usize ,
233- config : & BuildConfig , nodes : & mut Vec < Cluster > , rng : & mut SplitMix64 , dist_fn : DistanceFn ,
301+ config : & BuildConfig , nodes : & mut Vec < Cluster > , rng : & mut SplitMix64 , dist_fn : & dyn Fn ( & [ u8 ] , & [ u8 ] ) -> u64 ,
234302 ) -> usize {
235303 let n = end - start;
236304 let node_idx = nodes. len ( ) ;
@@ -370,12 +438,40 @@ impl ClamTree {
370438
371439 #[ inline]
372440 pub fn dist ( & self , a : & [ u8 ] , b : & [ u8 ] ) -> u64 {
373- ( self . distance_fn ) ( a, b)
441+ self . distance . eval ( a, b)
374442 }
375443
376444 #[ inline]
445+ /// The raw fn-pointer arm, for callers that thread it onward.
446+ ///
447+ /// # Panics
448+ /// On a tree built with [`ClamTree::build_with_distance`] — a stateful
449+ /// distance has no fn pointer to hand out, and returning a substitute
450+ /// would silently measure something else. New code should call
451+ /// [`ClamTree::dist`] instead; no pre-existing construction path can
452+ /// reach the panic.
377453 pub fn distance_fn ( & self ) -> DistanceFn {
378- self . distance_fn
454+ match & self . distance {
455+ TreeDistance :: Ptr ( f) => * f,
456+ TreeDistance :: Dyn { .. } => panic ! (
457+ "ClamTree was built with build_with_distance (stateful arm); \
458+ use ClamTree::dist instead of extracting a fn pointer"
459+ ) ,
460+ }
461+ }
462+
463+ /// Whether this tree's distance declared itself a metric.
464+ ///
465+ /// The fn-pointer arm reports `true` — every historical caller relied on
466+ /// triangle-inequality pruning with it, and this accessor preserves that
467+ /// assumption rather than blessing it. The stateful arm carries the
468+ /// answer from [`Distance::is_metric`]; `rho_nn`-style pruning over a
469+ /// distance that answers `false` is unsound (silent false negatives).
470+ pub fn is_metric ( & self ) -> bool {
471+ match & self . distance {
472+ TreeDistance :: Ptr ( _) => true ,
473+ TreeDistance :: Dyn { metric, .. } => * metric,
474+ }
379475 }
380476
381477 pub fn root ( & self ) -> & Cluster {
@@ -492,7 +588,7 @@ impl ClamTree {
492588 let center = self . center_data ( cluster, data, vec_len) ;
493589 let mut distances: Vec < u64 > = self
494590 . cluster_points ( cluster, data, vec_len)
495- . map ( |( _, point) | ( self . distance_fn ) ( center, point) )
591+ . map ( |( _, point) | self . distance . eval ( center, point) )
496592 . collect ( ) ;
497593
498594 if distances. is_empty ( ) {
@@ -1068,7 +1164,7 @@ impl CompressedTree {
10681164 /// Compute Hamming distance from query to compressed point WITHOUT decompression.
10691165 pub fn hamming_to_compressed (
10701166 & self , query : & [ u8 ] , point_idx : usize , data : & [ u8 ] , vec_len : usize , dist_cache : & mut DistanceCache ,
1071- dist_fn : DistanceFn ,
1167+ dist_fn : impl Fn ( & [ u8 ] , & [ u8 ] ) -> u64 ,
10721168 ) -> u64 {
10731169 let center_idx = self . encoding_centers [ point_idx] ;
10741170
@@ -1167,7 +1263,7 @@ impl ClamTree {
11671263 while let Some ( node_idx) = stack. pop ( ) {
11681264 let node = & self . nodes [ node_idx] ;
11691265 let center = & data[ self . reordered [ node. center_idx ] * vec_len..] [ ..vec_len] ;
1170- let dist_to_center = ( self . distance_fn ) ( query, center) ;
1266+ let dist_to_center = self . distance . eval ( query, center) ;
11711267
11721268 // Triangle inequality: closest possible point in cluster
11731269 if node. delta_minus ( dist_to_center) > rho {
@@ -1179,7 +1275,7 @@ impl ClamTree {
11791275 for i in node. offset ..node. offset + node. cardinality {
11801276 let idx = self . reordered [ i] ;
11811277 let point = & data[ idx * vec_len..] [ ..vec_len] ;
1182- let d = ( self . distance_fn ) ( query, point) ;
1278+ let d = self . distance . eval ( query, point) ;
11831279 if d <= rho {
11841280 candidates. push ( ( idx, d) ) ;
11851281 }
@@ -1430,7 +1526,12 @@ impl ClamTree {
14301526 let mut cache = DistanceCache :: new ( ) ;
14311527 let mut hits = Vec :: new ( ) ;
14321528 let mut distance_calls = 0usize ;
1433- let dist_fn = self . distance_fn ( ) ;
1529+ // A borrow of the carrier, not an extracted fn pointer — this path
1530+ // must work on BOTH arms (a Dyn-built tree has no pointer to hand out).
1531+ let dist_fn: & dyn Fn ( & [ u8 ] , & [ u8 ] ) -> u64 = match & self . distance {
1532+ TreeDistance :: Ptr ( f) => f,
1533+ TreeDistance :: Dyn { f, .. } => & * * f,
1534+ } ;
14341535
14351536 // Use CLAM tree structure: walk to find overlapping leaves,
14361537 // then do compressive distance on leaf members
@@ -3012,4 +3113,105 @@ mod tests {
30123113 let hits = clam_cascade_search ( & tree, & cascade, & data, vec_len, query, u64:: MAX , 5 ) ;
30133114 assert ! ( hits. len( ) <= 5 ) ;
30143115 }
3116+
3117+ // ── the universal-builder pass: two arms, one core ──
3118+
3119+ /// A stateful distance for the Dyn arm: Hamming over the tail from a
3120+ /// CONFIGURED offset — state a bare fn pointer cannot carry.
3121+ struct TailHamming {
3122+ from : usize ,
3123+ }
3124+ impl Distance for TailHamming {
3125+ type Point = [ u8 ] ;
3126+ fn distance ( & self , a : & [ u8 ] , b : & [ u8 ] ) -> u64 {
3127+ hamming_inline ( & a[ self . from ..] , & b[ self . from ..] )
3128+ }
3129+ fn is_metric ( & self ) -> bool {
3130+ true
3131+ }
3132+ }
3133+
3134+ /// The fn-pointer twin of `TailHamming { from: 16 }` — offset hardcoded,
3135+ /// because a pointer can hold nothing else. Exists so the two arms can be
3136+ /// compared on EQUAL inputs.
3137+ fn tail16_hamming ( a : & [ u8 ] , b : & [ u8 ] ) -> u64 {
3138+ hamming_inline ( & a[ 16 ..] , & b[ 16 ..] )
3139+ }
3140+
3141+ fn arm_test_data ( ) -> Vec < u8 > {
3142+ // Deterministic, structured enough to force real splits.
3143+ let mut rng = SplitMix64 :: new ( 0x51_7EED ) ;
3144+ ( 0 ..64 * 32 )
3145+ . map ( |_| ( rng. next_u64 ( ) & 0xFF ) as u8 )
3146+ . collect ( )
3147+ }
3148+
3149+ fn assert_same_tree ( a : & ClamTree , b : & ClamTree ) {
3150+ assert_eq ! ( a. reordered, b. reordered, "reordering diverged" ) ;
3151+ assert_eq ! ( a. nodes. len( ) , b. nodes. len( ) , "node count diverged" ) ;
3152+ for ( i, ( x, y) ) in a. nodes . iter ( ) . zip ( & b. nodes ) . enumerate ( ) {
3153+ assert_eq ! (
3154+ ( x. center_idx, x. radius, x. cardinality, x. offset, x. depth, x. left, x. right) ,
3155+ ( y. center_idx, y. radius, y. cardinality, y. offset, y. depth, y. left, y. right) ,
3156+ "cluster {i} diverged"
3157+ ) ;
3158+ }
3159+ assert_eq ! ( a. num_leaves, b. num_leaves) ;
3160+ }
3161+
3162+ /// THE claim of the refactor, pinned: both arms land in one core, so the
3163+ /// same distance produces the byte-identical tree regardless of arm.
3164+ #[ test]
3165+ fn the_two_arms_build_the_identical_tree ( ) {
3166+ let data = arm_test_data ( ) ;
3167+ let cfg = BuildConfig {
3168+ min_cardinality : 4 ,
3169+ ..Default :: default ( )
3170+ } ;
3171+ let ptr = ClamTree :: build_with_fn ( & data, 32 , 64 , & cfg, tail16_hamming) ;
3172+ let dy = ClamTree :: build_with_distance ( & data, 32 , 64 , & cfg, TailHamming { from : 16 } ) ;
3173+ assert_same_tree ( & ptr, & dy) ;
3174+ // and the search paths agree through the dispatch
3175+ let q = & data[ 0 ..32 ] ;
3176+ let r1 = rho_nn ( & ptr, & data, 32 , q, 40 ) ;
3177+ let r2 = rho_nn ( & dy, & data, 32 , q, 40 ) ;
3178+ assert_eq ! ( r1. hits, r2. hits, "rho_nn diverged between arms" ) ;
3179+ }
3180+
3181+ /// `is_metric` rides the carrier: the Ptr arm preserves the historical
3182+ /// assumption (true), the Dyn arm carries the distance's own answer.
3183+ #[ test]
3184+ fn is_metric_is_carried_not_assumed ( ) {
3185+ struct NotAMetric ;
3186+ impl Distance for NotAMetric {
3187+ type Point = [ u8 ] ;
3188+ fn distance ( & self , a : & [ u8 ] , b : & [ u8 ] ) -> u64 {
3189+ hamming_inline ( a, b)
3190+ }
3191+ fn is_metric ( & self ) -> bool {
3192+ false
3193+ }
3194+ }
3195+ let data = arm_test_data ( ) ;
3196+ let cfg = BuildConfig {
3197+ min_cardinality : 4 ,
3198+ ..Default :: default ( )
3199+ } ;
3200+ assert ! ( ClamTree :: build_with_fn( & data, 32 , 64 , & cfg, hamming_inline) . is_metric( ) ) ;
3201+ assert ! ( !ClamTree :: build_with_distance( & data, 32 , 64 , & cfg, NotAMetric ) . is_metric( ) ) ;
3202+ }
3203+
3204+ /// The fn-pointer accessor refuses the stateful arm LOUDLY. A silent
3205+ /// substitute would measure something else; a panic names the fix.
3206+ #[ test]
3207+ #[ should_panic( expected = "build_with_distance" ) ]
3208+ fn the_fn_pointer_accessor_refuses_the_stateful_arm ( ) {
3209+ let data = arm_test_data ( ) ;
3210+ let cfg = BuildConfig {
3211+ min_cardinality : 4 ,
3212+ ..Default :: default ( )
3213+ } ;
3214+ let t = ClamTree :: build_with_distance ( & data, 32 , 64 , & cfg, TailHamming { from : 0 } ) ;
3215+ let _ = t. distance_fn ( ) ;
3216+ }
30153217}
0 commit comments