Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions crates/ruvector-mincut/examples/determinism_probe.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
//! Nightly research probe (2026-09-07): reproduces and diagnoses the
//! non-determinism in `RuVectorGraphAnalyzer::partition()` first measured by
//! the 2026-09-05 nightly run (ADR-345, Open Question #2). Not part of the
//! shipped API surface -- this is raw measurement evidence for
//! `docs/research/nightly/2026-09-07-deterministic-mincut-witness/README.md`.
//!
//! Two fixed topologies, byte-identical across trials, each queried with a
//! *fresh* `RuVectorGraphAnalyzer` per trial (matching ADR-345's
//! methodology, which builds a new analyzer per trial rather than reusing
//! one with its result cache):
//!
//! - `two_clique_bridge_19`: the exact 19-vertex topology from
//! `mincut_determinism_probe.rs` (two 9-vertex near-duplicate clusters
//! joined by one bridge vector), which measured ~50% empty/degenerate
//! results in the prior run.
//! - `two_clique_bridge_84`: a larger 84-entry variant (6 clusters x 12 +
//! 12 bridges) matching the corpus size used in the rejected
//! `MincutGatedForgetting` benchmark, to check the finding holds at that
//! scale too.
//!
//! For each topology we report: how many of N trials returned an
//! empty/degenerate partition, how many *distinct* (U, V\U) partitions were
//! observed across trials (byte-identical graph => should be 1 if
//! deterministic), and wall-clock.

use std::collections::HashSet;
use std::time::Instant;

fn normalize(v: &[f32]) -> Vec<f32> {
let n: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
v.iter().map(|x| x / n).collect()
}

fn cosine_sim(a: &[f32], b: &[f32]) -> f32 {
let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
let na: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
if na < 1e-9 || nb < 1e-9 {
0.0
} else {
(dot / (na * nb)).clamp(-1.0, 1.0)
}
}

/// Two near-duplicate clusters of `per_cluster` vectors each, joined by one
/// bridge vector interpolated 50/50 between the two cluster axes.
fn two_clique_bridge(per_cluster: usize) -> (Vec<Vec<f32>>, usize) {
let mut entries: Vec<Vec<f32>> = Vec::new();
let plain_a = vec![1.0f32, 0.0, 0.0];
let plain_b = vec![0.0f32, 1.0, 0.0];
// One gateway per cluster, each interpolated 50/50 toward the shared
// bridge axis, matching the 2026-09-05 nightly's methodology (a single
// gateway would leave one cluster with no path to the bridge at all).
let gateway_a = normalize(&[1.0, 0.0, 0.5]);
let gateway_b = normalize(&[0.0, 1.0, 0.5]);
for _ in 0..per_cluster {
entries.push(plain_a.clone());
}
entries.push(gateway_a);
for _ in 0..per_cluster {
entries.push(plain_b.clone());
}
entries.push(gateway_b);
entries.push(vec![0.0, 0.0, 1.0]); // bridge
let bridge_idx = entries.len() - 1;
(entries, bridge_idx)
}

fn knn_edges(entries: &[Vec<f32>], k: usize, min_sim: f32) -> Vec<(usize, Vec<(usize, f64)>)> {
let n = entries.len();
(0..n)
.map(|i| {
let mut sims: Vec<(usize, f32)> = (0..n)
.filter(|&j| j != i)
.map(|j| (j, cosine_sim(&entries[i], &entries[j])))
.filter(|&(_, s)| s >= min_sim)
.collect();
sims.sort_unstable_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
sims.truncate(k);
let dists = sims
.into_iter()
.map(|(j, s)| (j, (1.0 - s).max(1e-4) as f64))
.collect();
(i, dists)
})
.collect()
}

struct TrialStats {
trials: usize,
empty_or_degenerate: usize,
distinct_partitions: usize,
elapsed_s: f64,
}

fn run_trials(neighbors: &[(usize, Vec<(usize, f64)>)], n: usize, trials: usize) -> TrialStats {
let mut empty = 0usize;
let mut seen: HashSet<Vec<u8>> = HashSet::new();
let t0 = Instant::now();
for _ in 0..trials {
let mut analyzer = ruvector_mincut::RuVectorGraphAnalyzer::from_knn(neighbors);
match analyzer.partition() {
None => empty += 1,
Some((a, b)) => {
if a.is_empty() || b.is_empty() || a.len() + b.len() != n {
empty += 1;
continue;
}
// Canonical signature: sorted membership of the smaller side.
let mut sig: Vec<u64> = if a.len() <= b.len() { a } else { b };
sig.sort_unstable();
let bytes: Vec<u8> = sig.iter().flat_map(|v| v.to_le_bytes()).collect();
seen.insert(bytes);
}
}
}
TrialStats {
trials,
empty_or_degenerate: empty,
distinct_partitions: seen.len(),
elapsed_s: t0.elapsed().as_secs_f64(),
}
}

fn main() {
let trials: usize = std::env::var("TRIALS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(100);

for &per_cluster in &[8usize, 9usize, 41usize] {
let (entries, _bridge_idx) = two_clique_bridge(per_cluster);
let n = entries.len();
let neighbors = knn_edges(&entries, 8, 0.05);
let stats = run_trials(&neighbors, n, trials);
println!(
"n={n} trials={} empty_or_degenerate={} ({:.0}%) distinct_partitions_seen={} elapsed={:.2}s avg_ms={:.1}",
stats.trials,
stats.empty_or_degenerate,
100.0 * stats.empty_or_degenerate as f64 / stats.trials as f64,
stats.distinct_partitions,
stats.elapsed_s,
stats.elapsed_s * 1000.0 / stats.trials as f64,
);
}
}
22 changes: 19 additions & 3 deletions crates/ruvector-mincut/src/instance/bounded.rs
Original file line number Diff line number Diff line change
Expand Up @@ -209,8 +209,13 @@ impl BoundedInstance {
// Build cluster hierarchy for strategic seed selection
self.ensure_hierarchy(&graph);

// Determine seed vertices to try
let seed_vertices: Vec<VertexId> = if let Some(ref hierarchy) = self.cluster_hierarchy {
// Determine seed vertices to try. `HashSet` iteration order is
// randomized per-instance (`RandomState`), so every collection here
// is explicitly sorted before use -- otherwise the *order* in which
// seeds are tried varies run-to-run, and since `search` below
// returns the first in-range cut it finds, that flows into which
// (equally valid) witness gets returned. See ADR-347.
let mut seed_vertices: Vec<VertexId> = if let Some(ref hierarchy) = self.cluster_hierarchy {
// Use cluster boundary vertices as strategic seeds
let mut boundary_vertices = HashSet::new();

Expand Down Expand Up @@ -239,6 +244,7 @@ impl BoundedInstance {
// No hierarchy - use all vertices
self.vertices.iter().copied().collect()
};
seed_vertices.sort_unstable();

// Try different budgets within our range
for budget in self.lambda_min..=self.lambda_max {
Expand Down Expand Up @@ -296,7 +302,17 @@ impl BoundedInstance {
return None;
}

let vertex_vec: Vec<_> = self.vertices.iter().copied().collect();
// Deterministic order: `self.vertices` is a `HashSet`, whose
// iteration order depends on `RandomState`'s per-instance random
// seed and therefore differs across otherwise-identical runs. Since
// ties in `boundary < min_cut` below are broken by "first subset
// seen wins", an unsorted `vertex_vec` makes the *specific* witness
// returned for a tied-optimal cut vary run-to-run even though the
// minimum cut *value* does not. Sorting fixes the bit-position ->
// vertex mapping, making mask iteration order (and thus the chosen
// witness) reproducible for a fixed graph. See ADR-347.
let mut vertex_vec: Vec<_> = self.vertices.iter().copied().collect();
vertex_vec.sort_unstable();
let n = vertex_vec.len();

if n <= 1 {
Expand Down
104 changes: 100 additions & 4 deletions crates/ruvector-mincut/src/instance/witness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,8 @@ impl WitnessHandle {
self.inner.hash
}

/// Materialize full partition (U, V \ U)
/// Materialize full partition (U, V \ U), assuming a dense `0..=max(U)`
/// vertex universe.
///
/// This is an expensive operation (O(|V|)) that converts the implicit
/// representation into explicit sets. Use sparingly, primarily for
Expand All @@ -218,10 +219,19 @@ impl WitnessHandle {
/// - `U` is the set of vertices in the cut
/// - `V_minus_U` is the complement set
///
/// # Note
/// # Correctness warning
///
/// This method assumes vertices are numbered 0..max_vertex. For sparse
/// graphs, V \ U may contain vertex IDs that don't exist in the graph.
/// This method derives the complement's vertex universe from
/// `membership.max()` (the highest vertex ID *inside* `U`), not from the
/// graph's actual vertex set. Whenever `U` does not happen to contain the
/// graph's highest-numbered vertex -- the common case, since `U` is
/// typically the *small* side of an unbalanced minimum cut -- every
/// vertex numbered above `membership.max()` is silently dropped from
/// both returned sets rather than appearing in `V_minus_U`. Callers that
/// know the graph's real vertex set (or an upper bound on it) MUST use
/// [`Self::materialize_partition_within`] instead; this method is kept
/// only for callers that truly have no other vertex-universe information
/// and can tolerate a possibly-incomplete complement.
///
/// # Examples
///
Expand Down Expand Up @@ -255,6 +265,48 @@ impl WitnessHandle {
(u, v_minus_u)
}

/// Materialize the full partition `(U, V \ U)` against a caller-supplied
/// vertex universe, instead of guessing the universe from `membership`.
///
/// This is the correct replacement for [`Self::materialize_partition`]
/// whenever the caller has access to the graph's actual vertex set (e.g.
/// via `DynamicGraph::vertices()`): every vertex in `universe` ends up in
/// exactly one of the two returned sets, regardless of whether it is the
/// membership set's own maximum element.
///
/// # Examples
///
/// ```
/// use ruvector_mincut::instance::witness::WitnessHandle;
/// use roaring::RoaringBitmap;
///
/// let mut membership = RoaringBitmap::new();
/// membership.insert(0); // the found cut set is just {0} ...
///
/// let witness = WitnessHandle::new(0, membership, 1);
/// // ... but the graph actually has 5 vertices, 3 of which (2,3,4) are
/// // numbered above membership.max() == 0.
/// let (u, v_minus_u) = witness.materialize_partition_within(&[0, 1, 2, 3, 4]);
///
/// assert_eq!(u.len(), 1);
/// assert_eq!(v_minus_u.len(), 4); // materialize_partition() would give 0 here
/// ```
pub fn materialize_partition_within(
&self,
universe: &[VertexId],
) -> (HashSet<VertexId>, HashSet<VertexId>) {
let mut u = HashSet::with_capacity(self.inner.membership.len() as usize);
let mut v_minus_u = HashSet::with_capacity(universe.len());
for &v in universe {
if self.contains(v) {
u.insert(v);
} else {
v_minus_u.insert(v);
}
}
(u, v_minus_u)
}

/// Get the cardinality of the cut set U
///
/// # Examples
Expand Down Expand Up @@ -562,6 +614,50 @@ impl LazyWitnessBatch {
}
}

#[cfg(test)]
mod witness_tests {
use super::*;

/// ADR-347 regression: `materialize_partition()` derives its complement
/// universe from `membership.max()`, so a small `U` that omits the
/// graph's true highest vertex silently drops trailing vertices from
/// `V \ U`. `materialize_partition_within` must not have this defect
/// when given the real vertex universe.
#[test]
fn materialize_partition_within_covers_full_universe() {
let mut membership = RoaringBitmap::new();
membership.insert(0);
let witness = WitnessHandle::new(0, membership, 1);

// Buggy behavior for comparison: 0..=0 minus membership = empty.
let (_buggy_u, buggy_v_minus_u) = witness.materialize_partition();
assert!(
buggy_v_minus_u.is_empty(),
"sanity check: materialize_partition() is documented to drop \
vertices above membership.max() in this scenario"
);

let universe = [0u64, 1, 2, 3, 4];
let (u, v_minus_u) = witness.materialize_partition_within(&universe);
assert_eq!(u, HashSet::from([0u64]));
assert_eq!(v_minus_u, HashSet::from([1u64, 2, 3, 4]));
assert_eq!(u.len() + v_minus_u.len(), universe.len());
}

#[test]
fn materialize_partition_within_handles_sparse_ids() {
let mut membership = RoaringBitmap::new();
membership.insert(100);
membership.insert(200);
let witness = WitnessHandle::new(100, membership, 2);

let universe = [5u64, 100, 200, 300];
let (u, v_minus_u) = witness.materialize_partition_within(&universe);
assert_eq!(u, HashSet::from([100u64, 200]));
assert_eq!(v_minus_u, HashSet::from([5u64, 300]));
}
}

#[cfg(test)]
mod lazy_tests {
use super::*;
Expand Down
Loading
Loading