Merge commit 'd803bfe2b1fe7f5e219e50ac20d6801a0a58ac75' as 'vendor/ruvector'
This commit is contained in:
913
vendor/ruvector/crates/cognitum-gate-kernel/src/canonical_witness.rs
vendored
Normal file
913
vendor/ruvector/crates/cognitum-gate-kernel/src/canonical_witness.rs
vendored
Normal file
@@ -0,0 +1,913 @@
|
||||
//! Canonical witness fragments using pseudo-deterministic min-cut.
|
||||
//!
|
||||
//! Produces reproducible, hash-stable witness fragments by computing
|
||||
//! a canonical min-cut partition via lexicographic tie-breaking.
|
||||
//!
|
||||
//! All structures are `#[repr(C)]` aligned, use fixed-size arrays, and
|
||||
//! operate entirely on the stack (no heap allocation). This module is
|
||||
//! designed for no_std WASM tiles with a ~2.1KB temporary memory footprint.
|
||||
|
||||
#![allow(missing_docs)]
|
||||
|
||||
use crate::shard::{CompactGraph, MAX_SHARD_VERTICES};
|
||||
use core::mem::size_of;
|
||||
|
||||
// ============================================================================
|
||||
// Fixed-point weight for deterministic comparison
|
||||
// ============================================================================
|
||||
|
||||
/// Fixed-point weight for deterministic, total-order comparison.
|
||||
///
|
||||
/// Uses 16.16 fixed-point representation (upper 16 bits integer, lower 16
|
||||
/// bits fractional). This avoids floating-point non-determinism in
|
||||
/// partition comparisons.
|
||||
#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd)]
|
||||
#[repr(transparent)]
|
||||
pub struct FixedPointWeight(pub u32);
|
||||
|
||||
impl FixedPointWeight {
|
||||
/// Zero weight constant
|
||||
pub const ZERO: Self = Self(0);
|
||||
|
||||
/// One (1.0) in 16.16 fixed-point
|
||||
pub const ONE: Self = Self(65536);
|
||||
|
||||
/// Maximum representable weight
|
||||
pub const MAX: Self = Self(u32::MAX);
|
||||
|
||||
/// Convert from a `ShardEdge` weight (u16, 0.01 precision) to fixed-point.
|
||||
///
|
||||
/// The shard weight is scaled up by shifting left 8 bits, mapping
|
||||
/// the 0-65535 range into the 16.16 fixed-point space.
|
||||
#[inline(always)]
|
||||
pub const fn from_u16_weight(w: u16) -> Self {
|
||||
Self((w as u32) << 8)
|
||||
}
|
||||
|
||||
/// Saturating addition (clamps at `u32::MAX`)
|
||||
#[inline(always)]
|
||||
pub const fn saturating_add(self, other: Self) -> Self {
|
||||
Self(self.0.saturating_add(other.0))
|
||||
}
|
||||
|
||||
/// Saturating subtraction (clamps at 0)
|
||||
#[inline(always)]
|
||||
pub const fn saturating_sub(self, other: Self) -> Self {
|
||||
Self(self.0.saturating_sub(other.0))
|
||||
}
|
||||
|
||||
/// Truncate to u16 by shifting right 8 bits (inverse of `from_u16_weight`)
|
||||
#[inline(always)]
|
||||
pub const fn to_u16(self) -> u16 {
|
||||
(self.0 >> 8) as u16
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Cactus node and arena
|
||||
// ============================================================================
|
||||
|
||||
/// A single node in the arena-allocated cactus tree.
|
||||
///
|
||||
/// Represents a vertex (or contracted 2-edge-connected component) in the
|
||||
/// simplified cactus structure derived from the tile's compact graph.
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
#[repr(C)]
|
||||
pub struct CactusNode {
|
||||
/// Vertex ID in the original graph
|
||||
pub id: u16,
|
||||
/// Parent index in `ArenaCactus::nodes` (0xFFFF = root / no parent)
|
||||
pub parent: u16,
|
||||
/// Degree in the cactus tree
|
||||
pub degree: u8,
|
||||
/// Flags (reserved)
|
||||
pub flags: u8,
|
||||
/// Weight of the edge connecting this node to its parent
|
||||
pub weight_to_parent: FixedPointWeight,
|
||||
}
|
||||
|
||||
impl CactusNode {
|
||||
/// Sentinel value indicating no parent (root node)
|
||||
pub const NO_PARENT: u16 = 0xFFFF;
|
||||
|
||||
/// Create an empty / default node
|
||||
#[inline(always)]
|
||||
pub const fn empty() -> Self {
|
||||
Self {
|
||||
id: 0,
|
||||
parent: Self::NO_PARENT,
|
||||
degree: 0,
|
||||
flags: 0,
|
||||
weight_to_parent: FixedPointWeight::ZERO,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compile-time size check: repr(C) layout is 12 bytes
|
||||
// (u16 + u16 + u8 + u8 + 2-pad + u32 = 12, aligned to 4)
|
||||
// 256 nodes * 12 = 3072 bytes (~3KB), fits in 14.5KB headroom.
|
||||
const _: () = assert!(size_of::<CactusNode>() == 12, "CactusNode must be 12 bytes");
|
||||
|
||||
/// Arena-allocated cactus tree for a single tile (up to 256 vertices).
|
||||
///
|
||||
/// The cactus captures the 2-edge-connected component structure of the
|
||||
/// tile's local graph. It is built entirely on the stack (~2KB) and used
|
||||
/// to derive a canonical min-cut partition.
|
||||
#[repr(C)]
|
||||
pub struct ArenaCactus {
|
||||
/// Node storage (one per vertex in the original graph)
|
||||
pub nodes: [CactusNode; 256],
|
||||
/// Number of active nodes
|
||||
pub n_nodes: u16,
|
||||
/// Root node index
|
||||
pub root: u16,
|
||||
/// Value of the global minimum cut found
|
||||
pub min_cut_value: FixedPointWeight,
|
||||
}
|
||||
|
||||
impl ArenaCactus {
|
||||
/// Build a cactus from the tile's `CompactGraph`.
|
||||
///
|
||||
/// Algorithm (simplified):
|
||||
/// 1. BFS spanning tree from the lowest-ID active vertex.
|
||||
/// 2. Identify back edges and compute 2-edge-connected components
|
||||
/// via low-link (Tarjan-style on edges).
|
||||
/// 3. Contract each 2-edge-connected component into a single cactus
|
||||
/// node; the inter-component bridge edges become cactus edges.
|
||||
/// 4. Track the minimum-weight bridge as the global min-cut value.
|
||||
pub fn build_from_compact_graph(graph: &CompactGraph) -> Self {
|
||||
let mut cactus = ArenaCactus {
|
||||
nodes: [CactusNode::empty(); 256],
|
||||
n_nodes: 0,
|
||||
root: 0xFFFF,
|
||||
min_cut_value: FixedPointWeight::MAX,
|
||||
};
|
||||
|
||||
if graph.num_vertices == 0 {
|
||||
cactus.min_cut_value = FixedPointWeight::ZERO;
|
||||
return cactus;
|
||||
}
|
||||
|
||||
// ---- Phase 1: BFS spanning tree ----
|
||||
// BFS queue (fixed-size ring buffer)
|
||||
let mut queue = [0u16; 256];
|
||||
let mut q_head: usize = 0;
|
||||
let mut q_tail: usize = 0;
|
||||
|
||||
// Per-vertex BFS state
|
||||
let mut visited = [false; MAX_SHARD_VERTICES];
|
||||
let mut parent = [0xFFFFu16; MAX_SHARD_VERTICES];
|
||||
let mut depth = [0u16; MAX_SHARD_VERTICES];
|
||||
// Component ID for 2-edge-connected grouping
|
||||
let mut comp_id = [0xFFFFu16; MAX_SHARD_VERTICES];
|
||||
|
||||
// Find lowest-ID active vertex as root
|
||||
let mut root_v = 0xFFFFu16;
|
||||
for v in 0..MAX_SHARD_VERTICES {
|
||||
if graph.vertices[v].is_active() {
|
||||
root_v = v as u16;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if root_v == 0xFFFF {
|
||||
cactus.min_cut_value = FixedPointWeight::ZERO;
|
||||
return cactus;
|
||||
}
|
||||
|
||||
// BFS
|
||||
visited[root_v as usize] = true;
|
||||
parent[root_v as usize] = 0xFFFF;
|
||||
queue[q_tail] = root_v;
|
||||
q_tail += 1;
|
||||
|
||||
while q_head < q_tail {
|
||||
let u = queue[q_head] as usize;
|
||||
q_head += 1;
|
||||
|
||||
let neighbors = graph.neighbors(u as u16);
|
||||
for adj in neighbors {
|
||||
let w = adj.neighbor as usize;
|
||||
if !visited[w] {
|
||||
visited[w] = true;
|
||||
parent[w] = u as u16;
|
||||
depth[w] = depth[u] + 1;
|
||||
if q_tail < 256 {
|
||||
queue[q_tail] = w as u16;
|
||||
q_tail += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Phase 2: Identify 2-edge-connected components ----
|
||||
// For each back edge (u,w) where w is an ancestor of u in the BFS tree,
|
||||
// all vertices on the path from u to w belong to the same 2-edge-connected
|
||||
// component. We perform path marking for each back edge.
|
||||
let mut next_comp: u16 = 0;
|
||||
|
||||
// Mark tree edges as bridges initially; back edges will un-bridge them
|
||||
// We iterate edges and find back edges (both endpoints visited, not parent-child)
|
||||
for e_idx in 0..graph.edges.len() {
|
||||
let edge = &graph.edges[e_idx];
|
||||
if !edge.is_active() {
|
||||
continue;
|
||||
}
|
||||
let u = edge.source as usize;
|
||||
let w = edge.target as usize;
|
||||
|
||||
if !visited[u] || !visited[w] {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this is a back edge (non-tree edge)
|
||||
let is_tree = (parent[w] == u as u16 && depth[w] == depth[u] + 1)
|
||||
|| (parent[u] == w as u16 && depth[u] == depth[w] + 1);
|
||||
|
||||
if is_tree {
|
||||
continue; // Skip tree edges
|
||||
}
|
||||
|
||||
// Back edge found: mark the path from u to w as same component
|
||||
// Walk u and w up to their LCA, assigning a single component ID
|
||||
let c = if comp_id[u] != 0xFFFF {
|
||||
comp_id[u]
|
||||
} else if comp_id[w] != 0xFFFF {
|
||||
comp_id[w]
|
||||
} else {
|
||||
let c = next_comp;
|
||||
next_comp = next_comp.saturating_add(1);
|
||||
c
|
||||
};
|
||||
|
||||
// Walk from u towards root, marking component
|
||||
let mut a = u as u16;
|
||||
while a != 0xFFFF && comp_id[a as usize] != c {
|
||||
if comp_id[a as usize] == 0xFFFF {
|
||||
comp_id[a as usize] = c;
|
||||
}
|
||||
a = parent[a as usize];
|
||||
}
|
||||
|
||||
// Walk from w towards root, marking component
|
||||
let mut b = w as u16;
|
||||
while b != 0xFFFF && comp_id[b as usize] != c {
|
||||
if comp_id[b as usize] == 0xFFFF {
|
||||
comp_id[b as usize] = c;
|
||||
}
|
||||
b = parent[b as usize];
|
||||
}
|
||||
}
|
||||
|
||||
// Assign each unmarked visited vertex its own component
|
||||
for v in 0..MAX_SHARD_VERTICES {
|
||||
if visited[v] && comp_id[v] == 0xFFFF {
|
||||
comp_id[v] = next_comp;
|
||||
next_comp = next_comp.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Phase 3: Build cactus from component structure ----
|
||||
// Each unique comp_id becomes a cactus node.
|
||||
// The representative vertex is the lowest-ID vertex in the component.
|
||||
let mut comp_repr = [0xFFFFu16; 256]; // comp_id -> representative vertex
|
||||
let mut comp_to_node = [0xFFFFu16; 256]; // comp_id -> cactus node index
|
||||
|
||||
// Find representative (lowest vertex ID) for each component
|
||||
for v in 0..MAX_SHARD_VERTICES {
|
||||
if !visited[v] {
|
||||
continue;
|
||||
}
|
||||
let c = comp_id[v] as usize;
|
||||
if c < 256 && (comp_repr[c] == 0xFFFF || (v as u16) < comp_repr[c]) {
|
||||
comp_repr[c] = v as u16;
|
||||
}
|
||||
}
|
||||
|
||||
// Create cactus nodes for each component
|
||||
let mut n_cactus: u16 = 0;
|
||||
for c in 0..next_comp.min(256) as usize {
|
||||
if comp_repr[c] != 0xFFFF {
|
||||
let idx = n_cactus as usize;
|
||||
if idx < 256 {
|
||||
cactus.nodes[idx] = CactusNode {
|
||||
id: comp_repr[c],
|
||||
parent: CactusNode::NO_PARENT,
|
||||
degree: 0,
|
||||
flags: 0,
|
||||
weight_to_parent: FixedPointWeight::ZERO,
|
||||
};
|
||||
comp_to_node[c] = n_cactus;
|
||||
n_cactus += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cactus.n_nodes = n_cactus;
|
||||
|
||||
// Set root to the node containing root_v
|
||||
let root_comp = comp_id[root_v as usize] as usize;
|
||||
if root_comp < 256 {
|
||||
cactus.root = comp_to_node[root_comp];
|
||||
}
|
||||
|
||||
// ---- Phase 4: Connect cactus nodes via bridge edges ----
|
||||
// A tree edge (parent[v] -> v) where comp_id[parent[v]] != comp_id[v]
|
||||
// is a bridge. It becomes a cactus edge.
|
||||
for v in 0..MAX_SHARD_VERTICES {
|
||||
if !visited[v] || parent[v] == 0xFFFF {
|
||||
continue;
|
||||
}
|
||||
let p = parent[v] as usize;
|
||||
let cv = comp_id[v] as usize;
|
||||
let cp = comp_id[p] as usize;
|
||||
|
||||
if cv != cp && cv < 256 && cp < 256 {
|
||||
let node_v = comp_to_node[cv];
|
||||
let node_p = comp_to_node[cp];
|
||||
|
||||
if node_v < 256
|
||||
&& node_p < 256
|
||||
&& cactus.nodes[node_v as usize].parent == CactusNode::NO_PARENT
|
||||
&& node_v != cactus.root
|
||||
{
|
||||
// Compute bridge weight: sum of edge weights between the
|
||||
// two components along this boundary
|
||||
let bridge_weight = Self::compute_bridge_weight(graph, v as u16, parent[v]);
|
||||
|
||||
cactus.nodes[node_v as usize].parent = node_p;
|
||||
cactus.nodes[node_v as usize].weight_to_parent = bridge_weight;
|
||||
cactus.nodes[node_p as usize].degree += 1;
|
||||
cactus.nodes[node_v as usize].degree += 1;
|
||||
|
||||
// Track minimum cut
|
||||
if bridge_weight < cactus.min_cut_value {
|
||||
cactus.min_cut_value = bridge_weight;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no bridges found, min cut is sum of all edge weights (graph is
|
||||
// 2-edge-connected) or zero if there are no edges
|
||||
if cactus.min_cut_value == FixedPointWeight::MAX {
|
||||
if graph.num_edges == 0 {
|
||||
cactus.min_cut_value = FixedPointWeight::ZERO;
|
||||
} else {
|
||||
// 2-edge-connected: min cut is at least the minimum degree
|
||||
// weight sum. Compute as total weight / 2 as rough upper bound
|
||||
// or just report the minimum vertex weighted degree.
|
||||
cactus.min_cut_value = Self::min_vertex_weight_degree(graph);
|
||||
}
|
||||
}
|
||||
|
||||
cactus
|
||||
}
|
||||
|
||||
/// Compute bridge weight between two vertices that are in different
|
||||
/// 2-edge-connected components.
|
||||
fn compute_bridge_weight(graph: &CompactGraph, v: u16, p: u16) -> FixedPointWeight {
|
||||
// Find the edge between v and p and return its weight
|
||||
if let Some(eid) = graph.find_edge(v, p) {
|
||||
FixedPointWeight::from_u16_weight(graph.edges[eid as usize].weight)
|
||||
} else {
|
||||
FixedPointWeight::ONE
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute minimum vertex weighted degree in the graph.
|
||||
fn min_vertex_weight_degree(graph: &CompactGraph) -> FixedPointWeight {
|
||||
let mut min_weight = FixedPointWeight::MAX;
|
||||
|
||||
for v in 0..MAX_SHARD_VERTICES {
|
||||
if !graph.vertices[v].is_active() || graph.vertices[v].degree == 0 {
|
||||
continue;
|
||||
}
|
||||
let mut weight_sum = FixedPointWeight::ZERO;
|
||||
let neighbors = graph.neighbors(v as u16);
|
||||
for adj in neighbors {
|
||||
let eid = adj.edge_id as usize;
|
||||
if eid < graph.edges.len() && graph.edges[eid].is_active() {
|
||||
weight_sum = weight_sum
|
||||
.saturating_add(FixedPointWeight::from_u16_weight(graph.edges[eid].weight));
|
||||
}
|
||||
}
|
||||
if weight_sum < min_weight {
|
||||
min_weight = weight_sum;
|
||||
}
|
||||
}
|
||||
|
||||
if min_weight == FixedPointWeight::MAX {
|
||||
FixedPointWeight::ZERO
|
||||
} else {
|
||||
min_weight
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive the canonical (lex-smallest) partition from this cactus.
|
||||
///
|
||||
/// Finds the minimum-weight edge in the cactus, removes it to create
|
||||
/// two subtrees, and assigns the subtree with the lex-smallest vertex
|
||||
/// set to side A. Ties are broken by selecting the edge whose removal
|
||||
/// yields the lex-smallest side-A bitset.
|
||||
pub fn canonical_partition(&self) -> CanonicalPartition {
|
||||
let mut best = CanonicalPartition::empty();
|
||||
|
||||
if self.n_nodes <= 1 {
|
||||
// Trivial: all vertices on side A
|
||||
best.cardinality_a = self.n_nodes;
|
||||
best.cut_value = FixedPointWeight::ZERO;
|
||||
best.compute_hash();
|
||||
return best;
|
||||
}
|
||||
|
||||
// Find the minimum-weight cactus edge. For each non-root node whose
|
||||
// edge to its parent has weight == min_cut_value, compute the
|
||||
// resulting partition and keep the lex-smallest.
|
||||
let mut found = false;
|
||||
|
||||
for i in 0..self.n_nodes as usize {
|
||||
let node = &self.nodes[i];
|
||||
if node.parent == CactusNode::NO_PARENT {
|
||||
continue; // Root has no parent edge
|
||||
}
|
||||
if node.weight_to_parent != self.min_cut_value {
|
||||
continue; // Not a minimum edge
|
||||
}
|
||||
|
||||
// Removing this edge splits the cactus into:
|
||||
// subtree rooted at node i vs everything else
|
||||
let mut candidate = CanonicalPartition::empty();
|
||||
candidate.cut_value = self.min_cut_value;
|
||||
|
||||
// Mark the subtree rooted at node i as side B
|
||||
self.mark_subtree(i as u16, &mut candidate);
|
||||
|
||||
// Count cardinalities
|
||||
candidate.recount();
|
||||
|
||||
// Ensure canonical orientation: side A should have lex-smallest
|
||||
// vertex set. If side B is lex-smaller, flip.
|
||||
if !candidate.is_canonical() {
|
||||
candidate.flip();
|
||||
}
|
||||
|
||||
candidate.compute_hash();
|
||||
|
||||
if !found || candidate.side < best.side {
|
||||
best = candidate;
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
best.compute_hash();
|
||||
}
|
||||
|
||||
best
|
||||
}
|
||||
|
||||
/// Mark all nodes in the subtree rooted at `start` to side B.
|
||||
fn mark_subtree(&self, start: u16, partition: &mut CanonicalPartition) {
|
||||
// The cactus tree has parent pointers, so we find all nodes
|
||||
// whose ancestor chain leads to `start` (before reaching the root
|
||||
// or a node not descended from `start`).
|
||||
partition.set_side(self.nodes[start as usize].id, true);
|
||||
|
||||
for i in 0..self.n_nodes as usize {
|
||||
if i == start as usize {
|
||||
continue;
|
||||
}
|
||||
// Walk ancestor chain to see if this node is in start's subtree
|
||||
let mut cur = i as u16;
|
||||
let mut in_subtree = false;
|
||||
let mut steps = 0u16;
|
||||
while cur != CactusNode::NO_PARENT && steps < 256 {
|
||||
if cur == start {
|
||||
in_subtree = true;
|
||||
break;
|
||||
}
|
||||
cur = self.nodes[cur as usize].parent;
|
||||
steps += 1;
|
||||
}
|
||||
if in_subtree {
|
||||
partition.set_side(self.nodes[i].id, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute a 16-bit digest of the cactus structure for embedding
|
||||
/// in the witness fragment.
|
||||
pub fn digest(&self) -> u16 {
|
||||
let mut hash: u32 = 0x811c9dc5;
|
||||
for i in 0..self.n_nodes as usize {
|
||||
let node = &self.nodes[i];
|
||||
hash ^= node.id as u32;
|
||||
hash = hash.wrapping_mul(0x01000193);
|
||||
hash ^= node.parent as u32;
|
||||
hash = hash.wrapping_mul(0x01000193);
|
||||
hash ^= node.weight_to_parent.0;
|
||||
hash = hash.wrapping_mul(0x01000193);
|
||||
}
|
||||
(hash & 0xFFFF) as u16
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Canonical partition
|
||||
// ============================================================================
|
||||
|
||||
/// A canonical two-way partition of vertices into sides A and B.
|
||||
///
|
||||
/// The bitset encodes 256 vertices (1 bit each = 32 bytes). A cleared
|
||||
/// bit means side A, a set bit means side B. The canonical orientation
|
||||
/// guarantees that side A contains the lex-smallest vertex set.
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
#[repr(C)]
|
||||
pub struct CanonicalPartition {
|
||||
/// Bitset: 256 vertices, 1 bit each (0 = side A, 1 = side B)
|
||||
pub side: [u8; 32],
|
||||
/// Number of vertices on side A
|
||||
pub cardinality_a: u16,
|
||||
/// Number of vertices on side B
|
||||
pub cardinality_b: u16,
|
||||
/// Cut value (weight of edges crossing the partition)
|
||||
pub cut_value: FixedPointWeight,
|
||||
/// 32-bit FNV-1a hash of the `side` bitset
|
||||
pub canonical_hash: [u8; 4],
|
||||
}
|
||||
|
||||
impl CanonicalPartition {
|
||||
/// Create an empty partition (all vertices on side A)
|
||||
#[inline]
|
||||
pub const fn empty() -> Self {
|
||||
Self {
|
||||
side: [0u8; 32],
|
||||
cardinality_a: 0,
|
||||
cardinality_b: 0,
|
||||
cut_value: FixedPointWeight::ZERO,
|
||||
canonical_hash: [0u8; 4],
|
||||
}
|
||||
}
|
||||
|
||||
/// Set which side a vertex belongs to.
|
||||
///
|
||||
/// `side_b = false` means side A, `side_b = true` means side B.
|
||||
#[inline]
|
||||
pub fn set_side(&mut self, vertex: u16, side_b: bool) {
|
||||
if vertex >= 256 {
|
||||
return;
|
||||
}
|
||||
let byte_idx = (vertex / 8) as usize;
|
||||
let bit_idx = vertex % 8;
|
||||
if side_b {
|
||||
self.side[byte_idx] |= 1 << bit_idx;
|
||||
} else {
|
||||
self.side[byte_idx] &= !(1 << bit_idx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get which side a vertex belongs to (false = A, true = B).
|
||||
#[inline]
|
||||
pub fn get_side(&self, vertex: u16) -> bool {
|
||||
if vertex >= 256 {
|
||||
return false;
|
||||
}
|
||||
let byte_idx = (vertex / 8) as usize;
|
||||
let bit_idx = vertex % 8;
|
||||
(self.side[byte_idx] >> bit_idx) & 1 != 0
|
||||
}
|
||||
|
||||
/// Compute the FNV-1a hash of the side bitset.
|
||||
pub fn compute_hash(&mut self) {
|
||||
self.canonical_hash = fnv1a_hash(&self.side);
|
||||
}
|
||||
|
||||
/// Check if this partition is in canonical orientation.
|
||||
///
|
||||
/// Canonical means: side A (the cleared bits) represents the
|
||||
/// lex-smallest vertex set. Equivalently, the first set bit in
|
||||
/// the bitset must be 1 (vertex 0 is on side A) OR, if vertex 0
|
||||
/// is on side B, we should flip.
|
||||
///
|
||||
/// More precisely: the complement of `side` (i.e. the A-set bitset)
|
||||
/// must be lex-smaller-or-equal to `side` (the B-set bitset).
|
||||
pub fn is_canonical(&self) -> bool {
|
||||
// Compare side vs. its complement byte-by-byte.
|
||||
// The complement represents side-A. If complement < side, canonical.
|
||||
// If complement > side, not canonical (should flip).
|
||||
// If equal, canonical by convention.
|
||||
for i in 0..32 {
|
||||
let complement = !self.side[i];
|
||||
if complement < self.side[i] {
|
||||
return true;
|
||||
}
|
||||
if complement > self.side[i] {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true // Equal (symmetric partition)
|
||||
}
|
||||
|
||||
/// Flip the partition so that side A and side B swap.
|
||||
pub fn flip(&mut self) {
|
||||
for i in 0..32 {
|
||||
self.side[i] = !self.side[i];
|
||||
}
|
||||
let tmp = self.cardinality_a;
|
||||
self.cardinality_a = self.cardinality_b;
|
||||
self.cardinality_b = tmp;
|
||||
}
|
||||
|
||||
/// Recount cardinalities from the bitset.
|
||||
pub fn recount(&mut self) {
|
||||
let mut count_b: u16 = 0;
|
||||
for i in 0..32 {
|
||||
count_b += self.side[i].count_ones() as u16;
|
||||
}
|
||||
self.cardinality_b = count_b;
|
||||
// cardinality_a is total vertices minus B, but we only know
|
||||
// about the vertices that were explicitly placed. We approximate
|
||||
// with 256 - B here; the caller may adjust.
|
||||
self.cardinality_a = 256u16.saturating_sub(count_b);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Canonical witness fragment
|
||||
// ============================================================================
|
||||
|
||||
/// Canonical witness fragment (16 bytes, same as `WitnessFragment`).
|
||||
///
|
||||
/// Extends the original witness fragment with pseudo-deterministic
|
||||
/// partition information derived from the cactus tree.
|
||||
#[derive(Debug, Copy, Clone, Default)]
|
||||
#[repr(C, align(16))]
|
||||
pub struct CanonicalWitnessFragment {
|
||||
/// Tile ID (0-255)
|
||||
pub tile_id: u8,
|
||||
/// Truncated epoch (tick & 0xFF)
|
||||
pub epoch: u8,
|
||||
/// Vertices on side A of the canonical partition
|
||||
pub cardinality_a: u16,
|
||||
/// Vertices on side B of the canonical partition
|
||||
pub cardinality_b: u16,
|
||||
/// Cut value (original weight format, truncated)
|
||||
pub cut_value: u16,
|
||||
/// FNV-1a hash of the canonical partition bitset
|
||||
pub canonical_hash: [u8; 4],
|
||||
/// Number of boundary edges
|
||||
pub boundary_edges: u16,
|
||||
/// Truncated hash of the cactus structure
|
||||
pub cactus_digest: u16,
|
||||
}
|
||||
|
||||
// Compile-time size assertion
|
||||
const _: () = assert!(
|
||||
size_of::<CanonicalWitnessFragment>() == 16,
|
||||
"CanonicalWitnessFragment must be exactly 16 bytes"
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// FNV-1a hash (no_std, no allocation)
|
||||
// ============================================================================
|
||||
|
||||
/// Compute a 32-bit FNV-1a hash of the given byte slice.
|
||||
///
|
||||
/// FNV-1a is a simple, fast, non-cryptographic hash with good
|
||||
/// distribution properties. It is fully deterministic and portable.
|
||||
#[inline]
|
||||
pub fn fnv1a_hash(data: &[u8]) -> [u8; 4] {
|
||||
let mut hash: u32 = 0x811c9dc5; // FNV offset basis
|
||||
for &byte in data {
|
||||
hash ^= byte as u32;
|
||||
hash = hash.wrapping_mul(0x01000193); // FNV prime
|
||||
}
|
||||
hash.to_le_bytes()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::shard::CompactGraph;
|
||||
use crate::TileState;
|
||||
use core::mem::size_of;
|
||||
|
||||
#[test]
|
||||
fn test_fixed_point_weight_ordering() {
|
||||
let a = FixedPointWeight(100);
|
||||
let b = FixedPointWeight(200);
|
||||
let c = FixedPointWeight(100);
|
||||
|
||||
assert!(a < b);
|
||||
assert!(b > a);
|
||||
assert_eq!(a, c);
|
||||
assert!(a <= c);
|
||||
assert!(a >= c);
|
||||
|
||||
// Check from_u16_weight ordering
|
||||
let w1 = FixedPointWeight::from_u16_weight(50);
|
||||
let w2 = FixedPointWeight::from_u16_weight(100);
|
||||
assert!(w1 < w2);
|
||||
|
||||
// Saturating add
|
||||
let sum = w1.saturating_add(w2);
|
||||
assert_eq!(sum, FixedPointWeight((50u32 << 8) + (100u32 << 8)));
|
||||
|
||||
// Saturating add at max
|
||||
let max_sum = FixedPointWeight::MAX.saturating_add(FixedPointWeight::ONE);
|
||||
assert_eq!(max_sum, FixedPointWeight::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_canonical_partition_determinism() {
|
||||
// Build the same graph twice, verify same partition hash
|
||||
let build_graph = || {
|
||||
let mut g = CompactGraph::new();
|
||||
g.add_edge(0, 1, 100);
|
||||
g.add_edge(1, 2, 100);
|
||||
g.add_edge(2, 3, 100);
|
||||
g.add_edge(3, 0, 100);
|
||||
g.add_edge(0, 2, 50); // Diagonal, lighter weight
|
||||
g.recompute_components();
|
||||
g
|
||||
};
|
||||
|
||||
let g1 = build_graph();
|
||||
let g2 = build_graph();
|
||||
|
||||
let c1 = ArenaCactus::build_from_compact_graph(&g1);
|
||||
let c2 = ArenaCactus::build_from_compact_graph(&g2);
|
||||
|
||||
let p1 = c1.canonical_partition();
|
||||
let p2 = c2.canonical_partition();
|
||||
|
||||
assert_eq!(p1.canonical_hash, p2.canonical_hash);
|
||||
assert_eq!(p1.side, p2.side);
|
||||
assert_eq!(p1.cut_value, p2.cut_value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fnv1a_known_values() {
|
||||
// Empty input
|
||||
let h0 = fnv1a_hash(&[]);
|
||||
assert_eq!(
|
||||
u32::from_le_bytes(h0),
|
||||
0x811c9dc5,
|
||||
"FNV-1a of empty should be the offset basis"
|
||||
);
|
||||
|
||||
// Single zero byte
|
||||
let h1 = fnv1a_hash(&[0]);
|
||||
let expected = 0x811c9dc5u32 ^ 0;
|
||||
let expected = expected.wrapping_mul(0x01000193);
|
||||
assert_eq!(u32::from_le_bytes(h1), expected);
|
||||
|
||||
// Determinism: same input -> same output
|
||||
let data = [1, 2, 3, 4, 5, 6, 7, 8];
|
||||
let a = fnv1a_hash(&data);
|
||||
let b = fnv1a_hash(&data);
|
||||
assert_eq!(a, b);
|
||||
|
||||
// Different input -> (almost certainly) different output
|
||||
let c = fnv1a_hash(&[8, 7, 6, 5, 4, 3, 2, 1]);
|
||||
assert_ne!(a, c);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_arena_cactus_simple_triangle() {
|
||||
let mut g = CompactGraph::new();
|
||||
g.add_edge(0, 1, 100);
|
||||
g.add_edge(1, 2, 100);
|
||||
g.add_edge(2, 0, 100);
|
||||
g.recompute_components();
|
||||
|
||||
let cactus = ArenaCactus::build_from_compact_graph(&g);
|
||||
|
||||
// A triangle is 2-edge-connected, so the cactus should have
|
||||
// a single node (all 3 vertices collapsed into one component).
|
||||
assert!(
|
||||
cactus.n_nodes >= 1,
|
||||
"Triangle cactus should have at least 1 node"
|
||||
);
|
||||
|
||||
// The partition should be trivial since there is only one component
|
||||
let partition = cactus.canonical_partition();
|
||||
partition.canonical_hash; // Just ensure it doesn't panic
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_canonical_witness_fragment_size() {
|
||||
assert_eq!(
|
||||
size_of::<CanonicalWitnessFragment>(),
|
||||
16,
|
||||
"CanonicalWitnessFragment must be exactly 16 bytes"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_canonical_witness_reproducibility() {
|
||||
// Build two identical tile states and verify they produce the
|
||||
// same canonical witness fragment.
|
||||
let build_tile = || {
|
||||
let mut tile = TileState::new(42);
|
||||
tile.ingest_delta(&crate::delta::Delta::edge_add(0, 1, 100));
|
||||
tile.ingest_delta(&crate::delta::Delta::edge_add(1, 2, 100));
|
||||
tile.ingest_delta(&crate::delta::Delta::edge_add(2, 3, 200));
|
||||
tile.ingest_delta(&crate::delta::Delta::edge_add(3, 0, 200));
|
||||
tile.tick(10);
|
||||
tile
|
||||
};
|
||||
|
||||
let t1 = build_tile();
|
||||
let t2 = build_tile();
|
||||
|
||||
let w1 = t1.canonical_witness();
|
||||
let w2 = t2.canonical_witness();
|
||||
|
||||
assert_eq!(w1.tile_id, w2.tile_id);
|
||||
assert_eq!(w1.epoch, w2.epoch);
|
||||
assert_eq!(w1.cardinality_a, w2.cardinality_a);
|
||||
assert_eq!(w1.cardinality_b, w2.cardinality_b);
|
||||
assert_eq!(w1.cut_value, w2.cut_value);
|
||||
assert_eq!(w1.canonical_hash, w2.canonical_hash);
|
||||
assert_eq!(w1.boundary_edges, w2.boundary_edges);
|
||||
assert_eq!(w1.cactus_digest, w2.cactus_digest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_partition_set_get_side() {
|
||||
let mut p = CanonicalPartition::empty();
|
||||
|
||||
// All on side A initially
|
||||
for v in 0..256u16 {
|
||||
assert!(!p.get_side(v), "vertex {} should be on side A", v);
|
||||
}
|
||||
|
||||
// Set some to side B
|
||||
p.set_side(0, true);
|
||||
p.set_side(7, true);
|
||||
p.set_side(8, true);
|
||||
p.set_side(255, true);
|
||||
|
||||
assert!(p.get_side(0));
|
||||
assert!(p.get_side(7));
|
||||
assert!(p.get_side(8));
|
||||
assert!(p.get_side(255));
|
||||
assert!(!p.get_side(1));
|
||||
assert!(!p.get_side(254));
|
||||
|
||||
// Clear
|
||||
p.set_side(0, false);
|
||||
assert!(!p.get_side(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_partition_flip() {
|
||||
let mut p = CanonicalPartition::empty();
|
||||
p.set_side(0, true);
|
||||
p.set_side(1, true);
|
||||
p.cardinality_a = 254;
|
||||
p.cardinality_b = 2;
|
||||
|
||||
p.flip();
|
||||
|
||||
assert!(!p.get_side(0));
|
||||
assert!(!p.get_side(1));
|
||||
assert!(p.get_side(2));
|
||||
assert_eq!(p.cardinality_a, 2);
|
||||
assert_eq!(p.cardinality_b, 254);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_graph_cactus() {
|
||||
let g = CompactGraph::new();
|
||||
let cactus = ArenaCactus::build_from_compact_graph(&g);
|
||||
assert_eq!(cactus.n_nodes, 0);
|
||||
assert_eq!(cactus.min_cut_value, FixedPointWeight::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_edge_cactus() {
|
||||
let mut g = CompactGraph::new();
|
||||
g.add_edge(0, 1, 150);
|
||||
g.recompute_components();
|
||||
|
||||
let cactus = ArenaCactus::build_from_compact_graph(&g);
|
||||
assert!(
|
||||
cactus.n_nodes >= 2,
|
||||
"Single edge should have 2 cactus nodes"
|
||||
);
|
||||
|
||||
let partition = cactus.canonical_partition();
|
||||
// One vertex on each side
|
||||
assert!(
|
||||
partition.cardinality_b >= 1,
|
||||
"Should have at least 1 vertex on side B"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user