914 lines
31 KiB
Rust
914 lines
31 KiB
Rust
//! 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"
|
|
);
|
|
}
|
|
}
|