Skip to content

Commit 52dc61e

Browse files
authored
Merge pull request #277 from AdaWorldAPI/claude/medcare-rs-continue-ufsazd
feat(hpc): universal builder for CLAM — stateful distances, two arms, one core
2 parents ff140d8 + bd3c65d commit 52dc61e

3 files changed

Lines changed: 238 additions & 29 deletions

File tree

src/hpc/clam.rs

Lines changed: 225 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -158,13 +158,42 @@ impl Default for BuildConfig {
158158
/// Distance function type: takes two byte slices of equal length, returns u64.
159159
pub 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.
162191
pub 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

170199
impl 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
}

src/hpc/clam_compress.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -359,7 +359,7 @@ impl CompressedTree {
359359
/// Cost: O(num_diffs) per point instead of O(vec_len).
360360
pub fn hamming_to_compressed(
361361
&self, query: &[u8], point_idx: usize, data: &[u8], vec_len: usize, dist_cache: &mut DistanceCache,
362-
dist_fn: fn(&[u8], &[u8]) -> u64,
362+
dist_fn: impl Fn(&[u8], &[u8]) -> u64,
363363
) -> u64 {
364364
let center_idx = self.encoding_centers[point_idx];
365365

src/hpc/clam_v3.rs

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -155,10 +155,7 @@ impl RailSpec {
155155
/// The same spec with a continuation register stacked at `at`.
156156
#[must_use]
157157
pub const fn stacked(self, at: usize) -> Self {
158-
Self {
159-
cont: Some(at),
160-
..self
161-
}
158+
Self { cont: Some(at), ..self }
162159
}
163160

164161
/// Maximum representable depth under this spec.
@@ -273,6 +270,11 @@ impl Distance for V3ValueHamming {
273270
/// [`super::clam::DistanceFn`] so it plugs straight into
274271
/// `ClamTree::build_with_fn(rows, 512, …, v3_value_hamming)`.
275272
///
273+
/// The rail geodesic has no such bare-fn form BY DESIGN: it carries a
274+
/// [`RailSpec`], and state does not fit in a fn pointer. It rides
275+
/// [`super::clam::ClamTree::build_with_distance`] as [`V3RailGeodesic`]
276+
/// directly — no `const` workaround needed since the universal-builder pass.
277+
///
276278
/// A row shorter than the value offset contributes nothing — an honest 0
277279
/// beats a panic in a distance callback, and a truncated row is a loader
278280
/// bug this function cannot repair.
@@ -293,7 +295,12 @@ mod tests {
293295
fn row(levels: &[u8], axis: RailAxis, fill: u8) -> Vec<u8> {
294296
let mut r = vec![0u8; 512];
295297
for (i, &v) in levels.iter().enumerate().take(RAIL_PAIRS) {
296-
let at = 4 + 2 * i + match axis { RailAxis::Lo => 0, RailAxis::Hi => 1 };
298+
let at = 4
299+
+ 2 * i
300+
+ match axis {
301+
RailAxis::Lo => 0,
302+
RailAxis::Hi => 1,
303+
};
297304
r[at] = v;
298305
}
299306
for b in &mut r[V3_VALUE_OFF..] {

0 commit comments

Comments
 (0)