Merge commit 'd803bfe2b1fe7f5e219e50ac20d6801a0a58ac75' as 'vendor/ruvector'
This commit is contained in:
586
vendor/ruvector/crates/ruvector-cognitive-container/src/container.rs
vendored
Normal file
586
vendor/ruvector/crates/ruvector-cognitive-container/src/container.rs
vendored
Normal file
@@ -0,0 +1,586 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::epoch::{ContainerEpochBudget, EpochController, Phase};
|
||||
use crate::error::{ContainerError, Result};
|
||||
use crate::memory::{MemoryConfig, MemorySlab};
|
||||
use crate::witness::{
|
||||
CoherenceDecision, ContainerWitnessReceipt, VerificationResult, WitnessChain,
|
||||
};
|
||||
|
||||
/// Top-level container configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ContainerConfig {
|
||||
/// Memory layout.
|
||||
pub memory: MemoryConfig,
|
||||
/// Per-epoch tick budgets.
|
||||
pub epoch_budget: ContainerEpochBudget,
|
||||
/// Unique identifier for this container instance.
|
||||
pub instance_id: u64,
|
||||
/// Maximum number of witness receipts retained.
|
||||
pub max_receipts: usize,
|
||||
}
|
||||
|
||||
impl Default for ContainerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
memory: MemoryConfig::default(),
|
||||
epoch_budget: ContainerEpochBudget::default(),
|
||||
instance_id: 0,
|
||||
max_receipts: 1024,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A graph-structure delta to apply during the ingest phase.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum Delta {
|
||||
EdgeAdd { u: usize, v: usize, weight: f64 },
|
||||
EdgeRemove { u: usize, v: usize },
|
||||
WeightUpdate { u: usize, v: usize, new_weight: f64 },
|
||||
Observation { node: usize, value: f64 },
|
||||
}
|
||||
|
||||
/// Bitmask tracking which pipeline components completed during a tick.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ComponentMask(pub u8);
|
||||
|
||||
impl ComponentMask {
|
||||
pub const INGEST: Self = Self(0b0000_0001);
|
||||
pub const MINCUT: Self = Self(0b0000_0010);
|
||||
pub const SPECTRAL: Self = Self(0b0000_0100);
|
||||
pub const EVIDENCE: Self = Self(0b0000_1000);
|
||||
pub const WITNESS: Self = Self(0b0001_0000);
|
||||
pub const ALL: Self = Self(0b0001_1111);
|
||||
|
||||
/// Returns `true` if all bits in `other` are set in `self`.
|
||||
pub fn contains(&self, other: Self) -> bool {
|
||||
self.0 & other.0 == other.0
|
||||
}
|
||||
|
||||
/// Set all bits present in `other`.
|
||||
pub fn insert(&mut self, other: Self) {
|
||||
self.0 |= other.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Output of a single `tick()` invocation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TickResult {
|
||||
/// The witness receipt generated for this epoch.
|
||||
pub receipt: ContainerWitnessReceipt,
|
||||
/// True if any pipeline phase was skipped due to budget exhaustion.
|
||||
pub partial: bool,
|
||||
/// Bitmask of completed components.
|
||||
pub components_completed: u8,
|
||||
/// Wall-clock duration in microseconds.
|
||||
pub tick_time_us: u64,
|
||||
}
|
||||
|
||||
/// Internal graph representation.
|
||||
struct GraphState {
|
||||
num_vertices: usize,
|
||||
num_edges: usize,
|
||||
edges: Vec<(usize, usize, f64)>,
|
||||
min_cut_value: f64,
|
||||
canonical_hash: [u8; 32],
|
||||
}
|
||||
|
||||
impl GraphState {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
num_vertices: 0,
|
||||
num_edges: 0,
|
||||
edges: Vec::new(),
|
||||
min_cut_value: 0.0,
|
||||
canonical_hash: [0u8; 32],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal spectral analysis state.
|
||||
struct SpectralState {
|
||||
scs: f64,
|
||||
fiedler: f64,
|
||||
gap: f64,
|
||||
}
|
||||
|
||||
impl SpectralState {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
scs: 0.0,
|
||||
fiedler: 0.0,
|
||||
gap: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal evidence accumulation state.
|
||||
struct EvidenceState {
|
||||
observations: Vec<f64>,
|
||||
accumulated_evidence: f64,
|
||||
threshold: f64,
|
||||
}
|
||||
|
||||
impl EvidenceState {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
observations: Vec::new(),
|
||||
accumulated_evidence: 0.0,
|
||||
threshold: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializable snapshot of the container state.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ContainerSnapshot {
|
||||
pub epoch: u64,
|
||||
pub config: ContainerConfig,
|
||||
pub graph_edges: Vec<(usize, usize, f64)>,
|
||||
pub spectral_scs: f64,
|
||||
pub evidence_accumulated: f64,
|
||||
}
|
||||
|
||||
/// A sealed cognitive container that orchestrates ingest, min-cut, spectral,
|
||||
/// evidence, and witness phases within a memory slab and epoch budget.
|
||||
pub struct CognitiveContainer {
|
||||
config: ContainerConfig,
|
||||
#[allow(dead_code)]
|
||||
slab: MemorySlab,
|
||||
epoch: EpochController,
|
||||
witness: WitnessChain,
|
||||
graph: GraphState,
|
||||
spectral: SpectralState,
|
||||
evidence: EvidenceState,
|
||||
initialized: bool,
|
||||
}
|
||||
|
||||
impl CognitiveContainer {
|
||||
/// Create and initialize a new container.
|
||||
pub fn new(config: ContainerConfig) -> Result<Self> {
|
||||
let slab = MemorySlab::new(config.memory.clone())?;
|
||||
let epoch = EpochController::new(config.epoch_budget.clone());
|
||||
let witness = WitnessChain::new(config.max_receipts);
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
slab,
|
||||
epoch,
|
||||
witness,
|
||||
graph: GraphState::new(),
|
||||
spectral: SpectralState::new(),
|
||||
evidence: EvidenceState::new(),
|
||||
initialized: true,
|
||||
})
|
||||
}
|
||||
|
||||
/// Execute one full epoch: ingest deltas, recompute min-cut, update spectral
|
||||
/// metrics, accumulate evidence, and produce a witness receipt.
|
||||
pub fn tick(&mut self, deltas: &[Delta]) -> Result<TickResult> {
|
||||
if !self.initialized {
|
||||
return Err(ContainerError::NotInitialized);
|
||||
}
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
self.epoch.reset();
|
||||
let mut completed = ComponentMask(0);
|
||||
|
||||
// Phase 1: Ingest
|
||||
if self.epoch.try_budget(Phase::Ingest) {
|
||||
for delta in deltas {
|
||||
self.apply_delta(delta);
|
||||
}
|
||||
self.epoch.consume(deltas.len().max(1) as u64);
|
||||
completed.insert(ComponentMask::INGEST);
|
||||
}
|
||||
|
||||
// Phase 2: Min-cut
|
||||
if self.epoch.try_budget(Phase::MinCut) {
|
||||
self.recompute_mincut();
|
||||
self.epoch.consume(self.graph.num_edges.max(1) as u64);
|
||||
completed.insert(ComponentMask::MINCUT);
|
||||
}
|
||||
|
||||
// Phase 3: Spectral
|
||||
if self.epoch.try_budget(Phase::Spectral) {
|
||||
self.update_spectral();
|
||||
self.epoch.consume(self.graph.num_vertices.max(1) as u64);
|
||||
completed.insert(ComponentMask::SPECTRAL);
|
||||
}
|
||||
|
||||
// Phase 4: Evidence
|
||||
if self.epoch.try_budget(Phase::Evidence) {
|
||||
self.accumulate_evidence();
|
||||
self.epoch
|
||||
.consume(self.evidence.observations.len().max(1) as u64);
|
||||
completed.insert(ComponentMask::EVIDENCE);
|
||||
}
|
||||
|
||||
// Phase 5: Witness
|
||||
let decision = self.make_decision();
|
||||
let input_bytes = self.serialize_deltas(deltas);
|
||||
let mincut_bytes = self.graph.min_cut_value.to_le_bytes();
|
||||
let evidence_bytes = self.evidence.accumulated_evidence.to_le_bytes();
|
||||
|
||||
let receipt = self.witness.generate_receipt(
|
||||
&input_bytes,
|
||||
&mincut_bytes,
|
||||
self.spectral.scs,
|
||||
&evidence_bytes,
|
||||
decision,
|
||||
);
|
||||
completed.insert(ComponentMask::WITNESS);
|
||||
|
||||
Ok(TickResult {
|
||||
receipt,
|
||||
partial: completed.0 != ComponentMask::ALL.0,
|
||||
components_completed: completed.0,
|
||||
tick_time_us: start.elapsed().as_micros() as u64,
|
||||
})
|
||||
}
|
||||
|
||||
/// Reference to the container configuration.
|
||||
pub fn config(&self) -> &ContainerConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
/// Current epoch counter (next epoch to be generated).
|
||||
pub fn current_epoch(&self) -> u64 {
|
||||
self.witness.current_epoch()
|
||||
}
|
||||
|
||||
/// Slice of all retained witness receipts.
|
||||
pub fn receipt_chain(&self) -> &[ContainerWitnessReceipt] {
|
||||
self.witness.receipt_chain()
|
||||
}
|
||||
|
||||
/// Verify the integrity of the internal witness chain.
|
||||
pub fn verify_chain(&self) -> VerificationResult {
|
||||
WitnessChain::verify_chain(self.witness.receipt_chain())
|
||||
}
|
||||
|
||||
/// Produce a serializable snapshot of the current container state.
|
||||
pub fn snapshot(&self) -> ContainerSnapshot {
|
||||
ContainerSnapshot {
|
||||
epoch: self.witness.current_epoch(),
|
||||
config: self.config.clone(),
|
||||
graph_edges: self.graph.edges.clone(),
|
||||
spectral_scs: self.spectral.scs,
|
||||
evidence_accumulated: self.evidence.accumulated_evidence,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Private helpers ----
|
||||
|
||||
fn apply_delta(&mut self, delta: &Delta) {
|
||||
match delta {
|
||||
Delta::EdgeAdd { u, v, weight } => {
|
||||
self.graph.edges.push((*u, *v, *weight));
|
||||
self.graph.num_edges += 1;
|
||||
let max_node = (*u).max(*v) + 1;
|
||||
if max_node > self.graph.num_vertices {
|
||||
self.graph.num_vertices = max_node;
|
||||
}
|
||||
}
|
||||
Delta::EdgeRemove { u, v } => {
|
||||
self.graph.edges.retain(|(a, b, _)| !(*a == *u && *b == *v));
|
||||
self.graph.num_edges = self.graph.edges.len();
|
||||
}
|
||||
Delta::WeightUpdate { u, v, new_weight } => {
|
||||
for edge in &mut self.graph.edges {
|
||||
if edge.0 == *u && edge.1 == *v {
|
||||
edge.2 = *new_weight;
|
||||
}
|
||||
}
|
||||
}
|
||||
Delta::Observation { value, .. } => {
|
||||
self.evidence.observations.push(*value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Simplified Stoer-Wagner-style min-cut: find the minimum total weight
|
||||
/// among all vertex partitions. For small graphs this uses the minimum
|
||||
/// weighted vertex degree as a fast approximation.
|
||||
fn recompute_mincut(&mut self) {
|
||||
if self.graph.edges.is_empty() {
|
||||
self.graph.min_cut_value = 0.0;
|
||||
self.graph.canonical_hash = [0u8; 32];
|
||||
return;
|
||||
}
|
||||
|
||||
// Approximate min-cut via minimum weighted degree.
|
||||
let n = self.graph.num_vertices;
|
||||
let mut degree = vec![0.0f64; n];
|
||||
for &(u, v, w) in &self.graph.edges {
|
||||
if u < n {
|
||||
degree[u] += w;
|
||||
}
|
||||
if v < n {
|
||||
degree[v] += w;
|
||||
}
|
||||
}
|
||||
|
||||
self.graph.min_cut_value = degree
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&d| d > 0.0)
|
||||
.fold(f64::MAX, f64::min);
|
||||
if self.graph.min_cut_value == f64::MAX {
|
||||
self.graph.min_cut_value = 0.0;
|
||||
}
|
||||
|
||||
// Canonical hash: hash sorted edges.
|
||||
let mut sorted = self.graph.edges.clone();
|
||||
sorted.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
|
||||
let bytes: Vec<u8> = sorted
|
||||
.iter()
|
||||
.flat_map(|(u, v, w)| {
|
||||
let mut b = Vec::with_capacity(24);
|
||||
b.extend_from_slice(&u.to_le_bytes());
|
||||
b.extend_from_slice(&v.to_le_bytes());
|
||||
b.extend_from_slice(&w.to_le_bytes());
|
||||
b
|
||||
})
|
||||
.collect();
|
||||
self.graph.canonical_hash = crate::witness::deterministic_hash_public(&bytes);
|
||||
}
|
||||
|
||||
/// Simplified spectral metrics: SCS is the ratio of min-cut to total weight.
|
||||
fn update_spectral(&mut self) {
|
||||
let total_weight: f64 = self.graph.edges.iter().map(|e| e.2).sum();
|
||||
if total_weight > 0.0 {
|
||||
self.spectral.scs = self.graph.min_cut_value / total_weight;
|
||||
self.spectral.fiedler = self.spectral.scs;
|
||||
self.spectral.gap = 1.0 - self.spectral.scs;
|
||||
} else {
|
||||
self.spectral.scs = 0.0;
|
||||
self.spectral.fiedler = 0.0;
|
||||
self.spectral.gap = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple sequential probability ratio test (SPRT) style accumulation.
|
||||
fn accumulate_evidence(&mut self) {
|
||||
if self.evidence.observations.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mean: f64 = self.evidence.observations.iter().sum::<f64>()
|
||||
/ self.evidence.observations.len() as f64;
|
||||
self.evidence.accumulated_evidence += mean.abs();
|
||||
}
|
||||
|
||||
/// Decision logic based on spectral coherence and accumulated evidence.
|
||||
fn make_decision(&self) -> CoherenceDecision {
|
||||
if self.graph.edges.is_empty() {
|
||||
return CoherenceDecision::Inconclusive;
|
||||
}
|
||||
if self.spectral.scs >= 0.5 && self.evidence.accumulated_evidence < self.evidence.threshold
|
||||
{
|
||||
return CoherenceDecision::Pass;
|
||||
}
|
||||
if self.spectral.scs < 0.2 {
|
||||
let severity = ((1.0 - self.spectral.scs) * 10.0).min(255.0) as u8;
|
||||
return CoherenceDecision::Fail { severity };
|
||||
}
|
||||
CoherenceDecision::Inconclusive
|
||||
}
|
||||
|
||||
fn serialize_deltas(&self, deltas: &[Delta]) -> Vec<u8> {
|
||||
serde_json::to_vec(deltas).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn default_container() -> CognitiveContainer {
|
||||
CognitiveContainer::new(ContainerConfig::default()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_container_lifecycle() {
|
||||
let mut container = default_container();
|
||||
assert_eq!(container.current_epoch(), 0);
|
||||
|
||||
let result = container.tick(&[]).unwrap();
|
||||
assert_eq!(result.receipt.epoch, 0);
|
||||
assert_eq!(container.current_epoch(), 1);
|
||||
|
||||
match container.verify_chain() {
|
||||
VerificationResult::Valid { chain_length, .. } => {
|
||||
assert_eq!(chain_length, 1);
|
||||
}
|
||||
other => panic!("Expected Valid, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_container_tick_with_deltas() {
|
||||
let mut container = default_container();
|
||||
|
||||
let deltas = vec![
|
||||
Delta::EdgeAdd {
|
||||
u: 0,
|
||||
v: 1,
|
||||
weight: 1.0,
|
||||
},
|
||||
Delta::EdgeAdd {
|
||||
u: 1,
|
||||
v: 2,
|
||||
weight: 2.0,
|
||||
},
|
||||
Delta::EdgeAdd {
|
||||
u: 2,
|
||||
v: 0,
|
||||
weight: 1.5,
|
||||
},
|
||||
Delta::Observation {
|
||||
node: 0,
|
||||
value: 0.8,
|
||||
},
|
||||
];
|
||||
|
||||
let result = container.tick(&deltas).unwrap();
|
||||
assert!(!result.partial);
|
||||
assert_eq!(result.components_completed, ComponentMask::ALL.0);
|
||||
|
||||
// Graph should reflect the edges.
|
||||
let snap = container.snapshot();
|
||||
assert_eq!(snap.graph_edges.len(), 3);
|
||||
assert!(snap.spectral_scs > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_container_snapshot_restore() {
|
||||
let mut container = default_container();
|
||||
container
|
||||
.tick(&[Delta::EdgeAdd {
|
||||
u: 0,
|
||||
v: 1,
|
||||
weight: 3.0,
|
||||
}])
|
||||
.unwrap();
|
||||
|
||||
let snap = container.snapshot();
|
||||
let json = serde_json::to_string(&snap).expect("serialize snapshot");
|
||||
let restored: ContainerSnapshot =
|
||||
serde_json::from_str(&json).expect("deserialize snapshot");
|
||||
|
||||
assert_eq!(restored.epoch, snap.epoch);
|
||||
assert_eq!(restored.graph_edges.len(), snap.graph_edges.len());
|
||||
assert!((restored.spectral_scs - snap.spectral_scs).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_container_decision_logic() {
|
||||
let mut container = default_container();
|
||||
|
||||
// Empty graph => Inconclusive
|
||||
let r = container.tick(&[]).unwrap();
|
||||
assert_eq!(r.receipt.decision, CoherenceDecision::Inconclusive);
|
||||
|
||||
// Single edge: min-cut/total = 1.0 (high scs), no evidence => Pass
|
||||
let r = container
|
||||
.tick(&[Delta::EdgeAdd {
|
||||
u: 0,
|
||||
v: 1,
|
||||
weight: 5.0,
|
||||
}])
|
||||
.unwrap();
|
||||
assert_eq!(r.receipt.decision, CoherenceDecision::Pass);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_container_multiple_epochs() {
|
||||
let mut container = default_container();
|
||||
for i in 0..10 {
|
||||
container
|
||||
.tick(&[Delta::EdgeAdd {
|
||||
u: i,
|
||||
v: i + 1,
|
||||
weight: 1.0,
|
||||
}])
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(container.current_epoch(), 10);
|
||||
|
||||
match container.verify_chain() {
|
||||
VerificationResult::Valid {
|
||||
chain_length,
|
||||
first_epoch,
|
||||
last_epoch,
|
||||
} => {
|
||||
assert_eq!(chain_length, 10);
|
||||
assert_eq!(first_epoch, 0);
|
||||
assert_eq!(last_epoch, 9);
|
||||
}
|
||||
other => panic!("Expected Valid, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_container_edge_remove() {
|
||||
let mut container = default_container();
|
||||
container
|
||||
.tick(&[
|
||||
Delta::EdgeAdd {
|
||||
u: 0,
|
||||
v: 1,
|
||||
weight: 1.0,
|
||||
},
|
||||
Delta::EdgeAdd {
|
||||
u: 1,
|
||||
v: 2,
|
||||
weight: 2.0,
|
||||
},
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
container.tick(&[Delta::EdgeRemove { u: 0, v: 1 }]).unwrap();
|
||||
|
||||
let snap = container.snapshot();
|
||||
assert_eq!(snap.graph_edges.len(), 1);
|
||||
assert_eq!(snap.graph_edges[0], (1, 2, 2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_container_weight_update() {
|
||||
let mut container = default_container();
|
||||
container
|
||||
.tick(&[Delta::EdgeAdd {
|
||||
u: 0,
|
||||
v: 1,
|
||||
weight: 1.0,
|
||||
}])
|
||||
.unwrap();
|
||||
|
||||
container
|
||||
.tick(&[Delta::WeightUpdate {
|
||||
u: 0,
|
||||
v: 1,
|
||||
new_weight: 5.0,
|
||||
}])
|
||||
.unwrap();
|
||||
|
||||
let snap = container.snapshot();
|
||||
assert_eq!(snap.graph_edges[0].2, 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_component_mask() {
|
||||
let mut mask = ComponentMask(0);
|
||||
assert!(!mask.contains(ComponentMask::INGEST));
|
||||
|
||||
mask.insert(ComponentMask::INGEST);
|
||||
assert!(mask.contains(ComponentMask::INGEST));
|
||||
assert!(!mask.contains(ComponentMask::MINCUT));
|
||||
|
||||
mask.insert(ComponentMask::MINCUT);
|
||||
assert!(mask.contains(ComponentMask::INGEST));
|
||||
assert!(mask.contains(ComponentMask::MINCUT));
|
||||
|
||||
assert!(!mask.contains(ComponentMask::ALL));
|
||||
}
|
||||
}
|
||||
187
vendor/ruvector/crates/ruvector-cognitive-container/src/epoch.rs
vendored
Normal file
187
vendor/ruvector/crates/ruvector-cognitive-container/src/epoch.rs
vendored
Normal file
@@ -0,0 +1,187 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Per-phase tick budgets for a single container epoch.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ContainerEpochBudget {
|
||||
/// Maximum total ticks for the entire epoch.
|
||||
pub total: u64,
|
||||
/// Ticks allocated to the ingest phase.
|
||||
pub ingest: u64,
|
||||
/// Ticks allocated to the min-cut phase.
|
||||
pub mincut: u64,
|
||||
/// Ticks allocated to the spectral analysis phase.
|
||||
pub spectral: u64,
|
||||
/// Ticks allocated to the evidence accumulation phase.
|
||||
pub evidence: u64,
|
||||
/// Ticks allocated to the witness receipt phase.
|
||||
pub witness: u64,
|
||||
}
|
||||
|
||||
impl Default for ContainerEpochBudget {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
total: 10_000,
|
||||
ingest: 2_000,
|
||||
mincut: 3_000,
|
||||
spectral: 2_000,
|
||||
evidence: 2_000,
|
||||
witness: 1_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Processing phases within a single epoch.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Phase {
|
||||
Ingest,
|
||||
MinCut,
|
||||
Spectral,
|
||||
Evidence,
|
||||
Witness,
|
||||
}
|
||||
|
||||
/// Controls compute-tick budgeting across phases within an epoch.
|
||||
pub struct EpochController {
|
||||
budget: ContainerEpochBudget,
|
||||
ticks_used: u64,
|
||||
phase_used: [u64; 5],
|
||||
current_phase: Phase,
|
||||
}
|
||||
|
||||
impl EpochController {
|
||||
/// Create a new controller with the given budget.
|
||||
pub fn new(budget: ContainerEpochBudget) -> Self {
|
||||
Self {
|
||||
budget,
|
||||
ticks_used: 0,
|
||||
phase_used: [0; 5],
|
||||
current_phase: Phase::Ingest,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether `phase` still has budget remaining.
|
||||
/// If yes, sets the current phase and returns `true`.
|
||||
pub fn try_budget(&mut self, phase: Phase) -> bool {
|
||||
let idx = Self::phase_index(phase);
|
||||
let limit = self.phase_budget(phase);
|
||||
if self.phase_used[idx] < limit && self.ticks_used < self.budget.total {
|
||||
self.current_phase = phase;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume `ticks` from both the total budget and the current phase budget.
|
||||
pub fn consume(&mut self, ticks: u64) {
|
||||
let idx = Self::phase_index(self.current_phase);
|
||||
self.ticks_used += ticks;
|
||||
self.phase_used[idx] += ticks;
|
||||
}
|
||||
|
||||
/// Ticks remaining in the total epoch budget.
|
||||
pub fn remaining(&self) -> u64 {
|
||||
self.budget.total.saturating_sub(self.ticks_used)
|
||||
}
|
||||
|
||||
/// Reset the controller for a new epoch.
|
||||
pub fn reset(&mut self) {
|
||||
self.ticks_used = 0;
|
||||
self.phase_used = [0; 5];
|
||||
self.current_phase = Phase::Ingest;
|
||||
}
|
||||
|
||||
/// Total tick budget allocated to `phase`.
|
||||
pub fn phase_budget(&self, phase: Phase) -> u64 {
|
||||
match phase {
|
||||
Phase::Ingest => self.budget.ingest,
|
||||
Phase::MinCut => self.budget.mincut,
|
||||
Phase::Spectral => self.budget.spectral,
|
||||
Phase::Evidence => self.budget.evidence,
|
||||
Phase::Witness => self.budget.witness,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ticks consumed so far by `phase`.
|
||||
pub fn phase_used(&self, phase: Phase) -> u64 {
|
||||
self.phase_used[Self::phase_index(phase)]
|
||||
}
|
||||
|
||||
fn phase_index(phase: Phase) -> usize {
|
||||
match phase {
|
||||
Phase::Ingest => 0,
|
||||
Phase::MinCut => 1,
|
||||
Phase::Spectral => 2,
|
||||
Phase::Evidence => 3,
|
||||
Phase::Witness => 4,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_epoch_budgeting() {
|
||||
let budget = ContainerEpochBudget {
|
||||
total: 100,
|
||||
ingest: 30,
|
||||
mincut: 25,
|
||||
spectral: 20,
|
||||
evidence: 15,
|
||||
witness: 10,
|
||||
};
|
||||
let mut ctl = EpochController::new(budget);
|
||||
|
||||
assert!(ctl.try_budget(Phase::Ingest));
|
||||
ctl.consume(30);
|
||||
assert_eq!(ctl.phase_used(Phase::Ingest), 30);
|
||||
// Phase is now exhausted.
|
||||
assert!(!ctl.try_budget(Phase::Ingest));
|
||||
assert_eq!(ctl.remaining(), 70);
|
||||
|
||||
assert!(ctl.try_budget(Phase::MinCut));
|
||||
ctl.consume(25);
|
||||
assert!(!ctl.try_budget(Phase::MinCut));
|
||||
assert_eq!(ctl.remaining(), 45);
|
||||
|
||||
assert!(ctl.try_budget(Phase::Spectral));
|
||||
ctl.consume(20);
|
||||
assert!(ctl.try_budget(Phase::Evidence));
|
||||
ctl.consume(15);
|
||||
assert!(ctl.try_budget(Phase::Witness));
|
||||
ctl.consume(10);
|
||||
|
||||
assert_eq!(ctl.remaining(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_epoch_reset() {
|
||||
let mut ctl = EpochController::new(ContainerEpochBudget::default());
|
||||
assert!(ctl.try_budget(Phase::Ingest));
|
||||
ctl.consume(500);
|
||||
assert_eq!(ctl.phase_used(Phase::Ingest), 500);
|
||||
|
||||
ctl.reset();
|
||||
assert_eq!(ctl.phase_used(Phase::Ingest), 0);
|
||||
assert_eq!(ctl.remaining(), 10_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_total_budget_caps_phase() {
|
||||
let budget = ContainerEpochBudget {
|
||||
total: 10,
|
||||
ingest: 100,
|
||||
mincut: 100,
|
||||
spectral: 100,
|
||||
evidence: 100,
|
||||
witness: 100,
|
||||
};
|
||||
let mut ctl = EpochController::new(budget);
|
||||
assert!(ctl.try_budget(Phase::Ingest));
|
||||
ctl.consume(10);
|
||||
// Total is exhausted even though phase still has room.
|
||||
assert!(!ctl.try_budget(Phase::MinCut));
|
||||
}
|
||||
}
|
||||
66
vendor/ruvector/crates/ruvector-cognitive-container/src/error.rs
vendored
Normal file
66
vendor/ruvector/crates/ruvector-cognitive-container/src/error.rs
vendored
Normal file
@@ -0,0 +1,66 @@
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors that can occur during cognitive container operations.
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ContainerError {
|
||||
#[error("Memory allocation failed: requested {requested} bytes, available {available}")]
|
||||
AllocationFailed { requested: usize, available: usize },
|
||||
|
||||
#[error("Epoch budget exhausted: used {used} of {budget} ticks")]
|
||||
EpochExhausted { used: u64, budget: u64 },
|
||||
|
||||
#[error("Witness chain broken at epoch {epoch}")]
|
||||
BrokenChain { epoch: u64 },
|
||||
|
||||
#[error("Invalid configuration: {reason}")]
|
||||
InvalidConfig { reason: String },
|
||||
|
||||
#[error("Container not initialized")]
|
||||
NotInitialized,
|
||||
|
||||
#[error("Slab overflow: component {component} exceeded budget")]
|
||||
SlabOverflow { component: String },
|
||||
}
|
||||
|
||||
/// Convenience alias for container results.
|
||||
pub type Result<T> = std::result::Result<T, ContainerError>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_error_display() {
|
||||
let err = ContainerError::AllocationFailed {
|
||||
requested: 1024,
|
||||
available: 512,
|
||||
};
|
||||
assert!(err.to_string().contains("1024"));
|
||||
assert!(err.to_string().contains("512"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_variants() {
|
||||
let err = ContainerError::EpochExhausted {
|
||||
used: 100,
|
||||
budget: 50,
|
||||
};
|
||||
assert!(err.to_string().contains("100"));
|
||||
|
||||
let err = ContainerError::BrokenChain { epoch: 7 };
|
||||
assert!(err.to_string().contains("7"));
|
||||
|
||||
let err = ContainerError::InvalidConfig {
|
||||
reason: "bad value".into(),
|
||||
};
|
||||
assert!(err.to_string().contains("bad value"));
|
||||
|
||||
let err = ContainerError::NotInitialized;
|
||||
assert!(err.to_string().contains("not initialized"));
|
||||
|
||||
let err = ContainerError::SlabOverflow {
|
||||
component: "graph".into(),
|
||||
};
|
||||
assert!(err.to_string().contains("graph"));
|
||||
}
|
||||
}
|
||||
19
vendor/ruvector/crates/ruvector-cognitive-container/src/lib.rs
vendored
Normal file
19
vendor/ruvector/crates/ruvector-cognitive-container/src/lib.rs
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
//! Verifiable WASM cognitive container with canonical witness chains.
|
||||
//!
|
||||
//! This crate composes cognitive primitives (graph ingest, min-cut, spectral
|
||||
//! analysis, evidence accumulation) into a sealed container that produces a
|
||||
//! tamper-evident witness chain linking every epoch to its predecessor.
|
||||
|
||||
pub mod container;
|
||||
pub mod epoch;
|
||||
pub mod error;
|
||||
pub mod memory;
|
||||
pub mod witness;
|
||||
|
||||
pub use container::{
|
||||
CognitiveContainer, ComponentMask, ContainerConfig, ContainerSnapshot, Delta, TickResult,
|
||||
};
|
||||
pub use epoch::{ContainerEpochBudget, EpochController, Phase};
|
||||
pub use error::{ContainerError, Result};
|
||||
pub use memory::{Arena, MemoryConfig, MemorySlab};
|
||||
pub use witness::{CoherenceDecision, ContainerWitnessReceipt, VerificationResult, WitnessChain};
|
||||
213
vendor/ruvector/crates/ruvector-cognitive-container/src/memory.rs
vendored
Normal file
213
vendor/ruvector/crates/ruvector-cognitive-container/src/memory.rs
vendored
Normal file
@@ -0,0 +1,213 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::{ContainerError, Result};
|
||||
|
||||
/// Configuration for memory slab layout.
|
||||
///
|
||||
/// Each budget defines the byte size of a sub-arena within the slab.
|
||||
/// The total slab size is the sum of all budgets.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MemoryConfig {
|
||||
/// Total slab size in bytes (must equal sum of budgets).
|
||||
pub slab_size: usize,
|
||||
/// Bytes reserved for graph adjacency data.
|
||||
pub graph_budget: usize,
|
||||
/// Bytes reserved for feature / embedding storage.
|
||||
pub feature_budget: usize,
|
||||
/// Bytes reserved for solver scratch space.
|
||||
pub solver_budget: usize,
|
||||
/// Bytes reserved for witness receipt storage.
|
||||
pub witness_budget: usize,
|
||||
/// Bytes reserved for evidence accumulation.
|
||||
pub evidence_budget: usize,
|
||||
}
|
||||
|
||||
impl Default for MemoryConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
slab_size: 4 * 1024 * 1024, // 4 MB total
|
||||
graph_budget: 1024 * 1024, // 1 MB
|
||||
feature_budget: 1024 * 1024, // 1 MB
|
||||
solver_budget: 512 * 1024, // 512 KB
|
||||
witness_budget: 512 * 1024, // 512 KB
|
||||
evidence_budget: 1024 * 1024, // 1 MB
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MemoryConfig {
|
||||
/// Validate that budget components sum to `slab_size`.
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
let sum = self.graph_budget
|
||||
+ self.feature_budget
|
||||
+ self.solver_budget
|
||||
+ self.witness_budget
|
||||
+ self.evidence_budget;
|
||||
if sum != self.slab_size {
|
||||
return Err(ContainerError::InvalidConfig {
|
||||
reason: format!(
|
||||
"budget sum ({sum}) does not equal slab_size ({})",
|
||||
self.slab_size
|
||||
),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A contiguous block of memory backing all container arenas.
|
||||
pub struct MemorySlab {
|
||||
data: Vec<u8>,
|
||||
config: MemoryConfig,
|
||||
}
|
||||
|
||||
impl MemorySlab {
|
||||
/// Allocate a new slab according to `config`.
|
||||
pub fn new(config: MemoryConfig) -> Result<Self> {
|
||||
config.validate()?;
|
||||
Ok(Self {
|
||||
data: vec![0u8; config.slab_size],
|
||||
config,
|
||||
})
|
||||
}
|
||||
|
||||
/// Total slab size in bytes.
|
||||
pub fn total_size(&self) -> usize {
|
||||
self.data.len()
|
||||
}
|
||||
|
||||
/// Immutable view of the raw slab bytes.
|
||||
pub fn as_bytes(&self) -> &[u8] {
|
||||
&self.data
|
||||
}
|
||||
|
||||
/// Reference to the underlying config.
|
||||
pub fn config(&self) -> &MemoryConfig {
|
||||
&self.config
|
||||
}
|
||||
}
|
||||
|
||||
/// A bump-allocator arena within a `MemorySlab`.
|
||||
///
|
||||
/// `base_offset` is the starting position inside the slab.
|
||||
/// Allocations grow upward; `reset()` reclaims all space.
|
||||
pub struct Arena {
|
||||
base_offset: usize,
|
||||
size: usize,
|
||||
offset: usize,
|
||||
}
|
||||
|
||||
impl Arena {
|
||||
/// Create a new arena starting at `base_offset` with the given `size`.
|
||||
pub fn new(base_offset: usize, size: usize) -> Self {
|
||||
Self {
|
||||
base_offset,
|
||||
size,
|
||||
offset: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bump-allocate `size` bytes with the given `align`ment.
|
||||
///
|
||||
/// Returns the absolute offset within the slab on success.
|
||||
pub fn alloc(&mut self, size: usize, align: usize) -> Result<usize> {
|
||||
let align = align.max(1);
|
||||
let current = self.base_offset + self.offset;
|
||||
let aligned = (current + align - 1) & !(align - 1);
|
||||
let padding = aligned - current;
|
||||
let total = padding + size;
|
||||
|
||||
if self.offset + total > self.size {
|
||||
return Err(ContainerError::AllocationFailed {
|
||||
requested: size,
|
||||
available: self.remaining(),
|
||||
});
|
||||
}
|
||||
|
||||
self.offset += total;
|
||||
Ok(aligned)
|
||||
}
|
||||
|
||||
/// Reset the arena, reclaiming all allocated space.
|
||||
pub fn reset(&mut self) {
|
||||
self.offset = 0;
|
||||
}
|
||||
|
||||
/// Number of bytes currently consumed (including alignment padding).
|
||||
pub fn used(&self) -> usize {
|
||||
self.offset
|
||||
}
|
||||
|
||||
/// Number of bytes still available.
|
||||
pub fn remaining(&self) -> usize {
|
||||
self.size.saturating_sub(self.offset)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_memory_slab_creation() {
|
||||
let config = MemoryConfig::default();
|
||||
let slab = MemorySlab::new(config).expect("slab should allocate");
|
||||
assert_eq!(slab.total_size(), 4 * 1024 * 1024);
|
||||
assert_eq!(slab.as_bytes().len(), slab.total_size());
|
||||
// Fresh slab is zero-filled.
|
||||
assert!(slab.as_bytes().iter().all(|&b| b == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_memory_config_validation_fails_on_mismatch() {
|
||||
let config = MemoryConfig {
|
||||
slab_size: 100,
|
||||
graph_budget: 10,
|
||||
feature_budget: 10,
|
||||
solver_budget: 10,
|
||||
witness_budget: 10,
|
||||
evidence_budget: 10,
|
||||
};
|
||||
assert!(MemorySlab::new(config).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_arena_allocation() {
|
||||
let mut arena = Arena::new(0, 256);
|
||||
assert_eq!(arena.remaining(), 256);
|
||||
assert_eq!(arena.used(), 0);
|
||||
|
||||
let off1 = arena.alloc(64, 8).expect("alloc 64");
|
||||
assert_eq!(off1, 0); // base 0, align 8 => 0
|
||||
assert_eq!(arena.used(), 64);
|
||||
assert_eq!(arena.remaining(), 192);
|
||||
|
||||
let off2 = arena.alloc(32, 16).expect("alloc 32");
|
||||
// 64 already used, align to 16 => 64 (already aligned)
|
||||
assert_eq!(off2, 64);
|
||||
assert_eq!(arena.used(), 96);
|
||||
|
||||
arena.reset();
|
||||
assert_eq!(arena.used(), 0);
|
||||
assert_eq!(arena.remaining(), 256);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_arena_allocation_overflow() {
|
||||
let mut arena = Arena::new(0, 64);
|
||||
assert!(arena.alloc(128, 1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_arena_alignment_padding() {
|
||||
let mut arena = Arena::new(0, 256);
|
||||
// Allocate 1 byte at alignment 1
|
||||
let _ = arena.alloc(1, 1).unwrap();
|
||||
assert_eq!(arena.used(), 1);
|
||||
// Next allocation with align 16: from offset 1, aligned to 16 => 16
|
||||
let off = arena.alloc(8, 16).unwrap();
|
||||
assert_eq!(off, 16);
|
||||
// used = 1 (first) + 15 (padding) + 8 = 24
|
||||
assert_eq!(arena.used(), 24);
|
||||
}
|
||||
}
|
||||
353
vendor/ruvector/crates/ruvector-cognitive-container/src/witness.rs
vendored
Normal file
353
vendor/ruvector/crates/ruvector-cognitive-container/src/witness.rs
vendored
Normal file
@@ -0,0 +1,353 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
/// Coherence decision emitted after each epoch.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum CoherenceDecision {
|
||||
Pass,
|
||||
Fail { severity: u8 },
|
||||
Inconclusive,
|
||||
}
|
||||
|
||||
/// A single witness receipt linking an epoch to its predecessor via hashes.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ContainerWitnessReceipt {
|
||||
/// Epoch number this receipt covers.
|
||||
pub epoch: u64,
|
||||
/// Hash of the previous receipt (zero for the genesis receipt).
|
||||
pub prev_hash: [u8; 32],
|
||||
/// Hash of the input deltas for this epoch.
|
||||
pub input_hash: [u8; 32],
|
||||
/// Hash of the min-cut result.
|
||||
pub mincut_hash: [u8; 32],
|
||||
/// Spectral coherence score in fixed-point 32.32 representation.
|
||||
pub spectral_scs: u64,
|
||||
/// Hash of the evidence accumulation state.
|
||||
pub evidence_hash: [u8; 32],
|
||||
/// Decision for this epoch.
|
||||
pub decision: CoherenceDecision,
|
||||
/// Hash of this receipt (covers all fields above).
|
||||
pub receipt_hash: [u8; 32],
|
||||
}
|
||||
|
||||
impl ContainerWitnessReceipt {
|
||||
/// Serialize all fields except `receipt_hash` into a byte vector for hashing.
|
||||
pub fn signable_bytes(&self) -> Vec<u8> {
|
||||
let mut buf = Vec::with_capacity(256);
|
||||
buf.extend_from_slice(&self.epoch.to_le_bytes());
|
||||
buf.extend_from_slice(&self.prev_hash);
|
||||
buf.extend_from_slice(&self.input_hash);
|
||||
buf.extend_from_slice(&self.mincut_hash);
|
||||
buf.extend_from_slice(&self.spectral_scs.to_le_bytes());
|
||||
buf.extend_from_slice(&self.evidence_hash);
|
||||
match self.decision {
|
||||
CoherenceDecision::Pass => buf.push(0),
|
||||
CoherenceDecision::Fail { severity } => {
|
||||
buf.push(1);
|
||||
buf.push(severity);
|
||||
}
|
||||
CoherenceDecision::Inconclusive => buf.push(2),
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
/// Compute and set `receipt_hash` from the signable portion of this receipt.
|
||||
pub fn compute_hash(&mut self) {
|
||||
self.receipt_hash = deterministic_hash(&self.signable_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of verifying a witness chain.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum VerificationResult {
|
||||
/// Chain is valid.
|
||||
Valid {
|
||||
chain_length: usize,
|
||||
first_epoch: u64,
|
||||
last_epoch: u64,
|
||||
},
|
||||
/// Chain is empty (no receipts).
|
||||
Empty,
|
||||
/// A receipt's `prev_hash` does not match the preceding receipt's `receipt_hash`.
|
||||
BrokenChain { epoch: u64 },
|
||||
/// Epoch numbers are not strictly monotonic.
|
||||
EpochGap { expected: u64, got: u64 },
|
||||
}
|
||||
|
||||
/// Append-only chain of witness receipts with hash linking.
|
||||
pub struct WitnessChain {
|
||||
current_epoch: u64,
|
||||
prev_hash: [u8; 32],
|
||||
receipts: Vec<ContainerWitnessReceipt>,
|
||||
max_receipts: usize,
|
||||
}
|
||||
|
||||
impl WitnessChain {
|
||||
/// Create a new empty chain that retains at most `max_receipts` entries.
|
||||
pub fn new(max_receipts: usize) -> Self {
|
||||
Self {
|
||||
current_epoch: 0,
|
||||
prev_hash: [0u8; 32],
|
||||
receipts: Vec::with_capacity(max_receipts.min(1024)),
|
||||
max_receipts,
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a new receipt, append it to the chain, and return a clone.
|
||||
pub fn generate_receipt(
|
||||
&mut self,
|
||||
input_deltas: &[u8],
|
||||
mincut_data: &[u8],
|
||||
spectral_scs: f64,
|
||||
evidence_data: &[u8],
|
||||
decision: CoherenceDecision,
|
||||
) -> ContainerWitnessReceipt {
|
||||
let scs_fixed = f64_to_fixed_32_32(spectral_scs);
|
||||
|
||||
let mut receipt = ContainerWitnessReceipt {
|
||||
epoch: self.current_epoch,
|
||||
prev_hash: self.prev_hash,
|
||||
input_hash: deterministic_hash(input_deltas),
|
||||
mincut_hash: deterministic_hash(mincut_data),
|
||||
spectral_scs: scs_fixed,
|
||||
evidence_hash: deterministic_hash(evidence_data),
|
||||
decision,
|
||||
receipt_hash: [0u8; 32],
|
||||
};
|
||||
receipt.compute_hash();
|
||||
|
||||
self.prev_hash = receipt.receipt_hash;
|
||||
self.current_epoch += 1;
|
||||
|
||||
// Ring-buffer behavior: drop oldest when full.
|
||||
if self.receipts.len() >= self.max_receipts {
|
||||
self.receipts.remove(0);
|
||||
}
|
||||
self.receipts.push(receipt.clone());
|
||||
|
||||
receipt
|
||||
}
|
||||
|
||||
/// Current epoch counter (next epoch to be generated).
|
||||
pub fn current_epoch(&self) -> u64 {
|
||||
self.current_epoch
|
||||
}
|
||||
|
||||
/// Most recent receipt, if any.
|
||||
pub fn latest_receipt(&self) -> Option<&ContainerWitnessReceipt> {
|
||||
self.receipts.last()
|
||||
}
|
||||
|
||||
/// Slice of all retained receipts.
|
||||
pub fn receipt_chain(&self) -> &[ContainerWitnessReceipt] {
|
||||
&self.receipts
|
||||
}
|
||||
|
||||
/// Verify hash-chain integrity and epoch monotonicity for a slice of receipts.
|
||||
pub fn verify_chain(receipts: &[ContainerWitnessReceipt]) -> VerificationResult {
|
||||
if receipts.is_empty() {
|
||||
return VerificationResult::Empty;
|
||||
}
|
||||
|
||||
// Verify each receipt's self-hash.
|
||||
for r in receipts {
|
||||
let expected = deterministic_hash(&r.signable_bytes());
|
||||
if expected != r.receipt_hash {
|
||||
return VerificationResult::BrokenChain { epoch: r.epoch };
|
||||
}
|
||||
}
|
||||
|
||||
// Verify prev_hash linkage and epoch ordering.
|
||||
for i in 1..receipts.len() {
|
||||
let prev = &receipts[i - 1];
|
||||
let curr = &receipts[i];
|
||||
|
||||
if curr.prev_hash != prev.receipt_hash {
|
||||
return VerificationResult::BrokenChain { epoch: curr.epoch };
|
||||
}
|
||||
|
||||
let expected_epoch = prev.epoch + 1;
|
||||
if curr.epoch != expected_epoch {
|
||||
return VerificationResult::EpochGap {
|
||||
expected: expected_epoch,
|
||||
got: curr.epoch,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
VerificationResult::Valid {
|
||||
chain_length: receipts.len(),
|
||||
first_epoch: receipts[0].epoch,
|
||||
last_epoch: receipts[receipts.len() - 1].epoch,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert an f64 to a 32.32 fixed-point representation.
|
||||
fn f64_to_fixed_32_32(value: f64) -> u64 {
|
||||
let clamped = value.clamp(0.0, (u32::MAX as f64) + 0.999_999_999);
|
||||
(clamped * (1u64 << 32) as f64) as u64
|
||||
}
|
||||
|
||||
/// Public wrapper for deterministic hashing, used by other modules.
|
||||
pub fn deterministic_hash_public(data: &[u8]) -> [u8; 32] {
|
||||
deterministic_hash(data)
|
||||
}
|
||||
|
||||
/// Deterministic hash producing 32 bytes.
|
||||
///
|
||||
/// Uses `std::hash::DefaultHasher` (SipHash-2-4) run with four different seeds
|
||||
/// to fill 32 bytes. This is NOT cryptographic but fully deterministic across
|
||||
/// runs on the same platform.
|
||||
fn deterministic_hash(data: &[u8]) -> [u8; 32] {
|
||||
let mut result = [0u8; 32];
|
||||
for i in 0u64..4 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
i.hash(&mut hasher);
|
||||
data.hash(&mut hasher);
|
||||
let h = hasher.finish();
|
||||
let offset = (i as usize) * 8;
|
||||
result[offset..offset + 8].copy_from_slice(&h.to_le_bytes());
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_deterministic_hash_consistency() {
|
||||
let a = deterministic_hash(b"hello world");
|
||||
let b = deterministic_hash(b"hello world");
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deterministic_hash_differs_for_different_inputs() {
|
||||
let a = deterministic_hash(b"alpha");
|
||||
let b = deterministic_hash(b"beta");
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_witness_chain_integrity() {
|
||||
let mut chain = WitnessChain::new(100);
|
||||
|
||||
for i in 0..5 {
|
||||
let data = format!("epoch-{i}");
|
||||
chain.generate_receipt(
|
||||
data.as_bytes(),
|
||||
b"mincut",
|
||||
0.95,
|
||||
b"evidence",
|
||||
CoherenceDecision::Pass,
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(chain.current_epoch(), 5);
|
||||
|
||||
match WitnessChain::verify_chain(chain.receipt_chain()) {
|
||||
VerificationResult::Valid {
|
||||
chain_length,
|
||||
first_epoch,
|
||||
last_epoch,
|
||||
} => {
|
||||
assert_eq!(chain_length, 5);
|
||||
assert_eq!(first_epoch, 0);
|
||||
assert_eq!(last_epoch, 4);
|
||||
}
|
||||
other => panic!("Expected Valid, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_witness_chain_epoch_monotonicity() {
|
||||
let mut chain = WitnessChain::new(100);
|
||||
for _ in 0..3 {
|
||||
chain.generate_receipt(
|
||||
b"input",
|
||||
b"mincut",
|
||||
1.0,
|
||||
b"evidence",
|
||||
CoherenceDecision::Pass,
|
||||
);
|
||||
}
|
||||
|
||||
let receipts = chain.receipt_chain();
|
||||
for i in 1..receipts.len() {
|
||||
assert_eq!(receipts[i].epoch, receipts[i - 1].epoch + 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verification_detects_tampering() {
|
||||
let mut chain = WitnessChain::new(100);
|
||||
for _ in 0..3 {
|
||||
chain.generate_receipt(
|
||||
b"input",
|
||||
b"mincut",
|
||||
0.5,
|
||||
b"evidence",
|
||||
CoherenceDecision::Inconclusive,
|
||||
);
|
||||
}
|
||||
|
||||
// Tamper with the second receipt's input_hash.
|
||||
let mut tampered: Vec<ContainerWitnessReceipt> = chain.receipt_chain().to_vec();
|
||||
tampered[1].input_hash[0] ^= 0xFF;
|
||||
|
||||
match WitnessChain::verify_chain(&tampered) {
|
||||
VerificationResult::BrokenChain { epoch } => {
|
||||
assert_eq!(epoch, 1);
|
||||
}
|
||||
other => panic!("Expected BrokenChain, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_chain_verification() {
|
||||
let receipts: Vec<ContainerWitnessReceipt> = vec![];
|
||||
match WitnessChain::verify_chain(&receipts) {
|
||||
VerificationResult::Empty => {}
|
||||
other => panic!("Expected Empty, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ring_buffer_eviction() {
|
||||
let mut chain = WitnessChain::new(3);
|
||||
for _ in 0..5 {
|
||||
chain.generate_receipt(b"data", b"mc", 0.1, b"ev", CoherenceDecision::Pass);
|
||||
}
|
||||
assert_eq!(chain.receipt_chain().len(), 3);
|
||||
assert_eq!(chain.receipt_chain()[0].epoch, 2);
|
||||
assert_eq!(chain.receipt_chain()[2].epoch, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_f64_to_fixed() {
|
||||
assert_eq!(f64_to_fixed_32_32(1.0), 1u64 << 32);
|
||||
assert_eq!(f64_to_fixed_32_32(0.0), 0);
|
||||
let half = f64_to_fixed_32_32(0.5);
|
||||
assert_eq!(half, 1u64 << 31);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_signable_bytes_determinism() {
|
||||
let receipt = ContainerWitnessReceipt {
|
||||
epoch: 42,
|
||||
prev_hash: [1u8; 32],
|
||||
input_hash: [2u8; 32],
|
||||
mincut_hash: [3u8; 32],
|
||||
spectral_scs: 100,
|
||||
evidence_hash: [4u8; 32],
|
||||
decision: CoherenceDecision::Fail { severity: 7 },
|
||||
receipt_hash: [0u8; 32],
|
||||
};
|
||||
let a = receipt.signable_bytes();
|
||||
let b = receipt.signable_bytes();
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user