Skip to content
Merged
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions crates/ruvector-agent-memory/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ serde = { workspace = true }
# ed25519-dalek + rand_core; the always-compiled `sha256` module needs no
# feature.
rvf-types = { version = "0.2", path = "../rvf/rvf-types", features = ["ed25519"] }
# ADR-347: hold an `ed25519_dalek::SigningKey` directly in `SignedWitnessSink`
# (no per-signature secret copies) and use `verify_strict`. Same crate and
# spec rvf-types already pulls in (2.2.x) — no new crate in the tree.
ed25519-dalek = { version = "2", default-features = false, features = ["alloc"] }
# Existing ADR-194/047 proof-gated write machinery; adapts to the ledger's
# ProofGate trait behind the `proof-gate` feature (see src/ledger.rs).
ruvector-proof-gate = { path = "../ruvector-proof-gate", optional = true }
Expand Down
224 changes: 224 additions & 0 deletions crates/ruvector-agent-memory/examples/witness_signing_bench.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
//! Nightly research benchmark (2026-09-16, PIR follow-up to ADR-134 §9):
//! measures the real latency/throughput cost of closing the `WitnessSigner`
//! gap named in `ops.rs` and `ledger.rs`, comparing three variants:
//!
//! baseline — unsigned `MemoryWitnessLog` (today's shipped behavior)
//! candidate_a — `SignedWitnessSink` with `SigningStrategy::PerRecord`
//! candidate_b — `SignedWitnessSink` with `SigningStrategy::BatchTail`
//! at a few batch sizes
//!
//! Workload: N sequential `add` + `accept` pairs (2N witness records),
//! deterministic content, single-threaded, release build. Reports mean/p50/
//! p95/p99 per-`add`+`accept` wall latency, total run time, signatures
//! produced, and a correctness gate (every produced signature verifies,
//! every constructed diligent-forgery attempt is rejected).
//!
//! Run:
//! cargo run --release -p ruvector-agent-memory --example witness_signing_bench

use ruvector_agent_memory::{
verify_signed_chain, AlwaysAdmitGate, MemoryWitnessLog, SignedAnchor, SignedWitnessSink,
SigningStrategy, TransactionalLedger,
};
use rvf_types::ed25519::Ed25519Keypair;
use std::time::{Duration, Instant};

const N_ENTRIES: usize = 20_000;
const SECRET: [u8; 32] = [11u8; 32];

fn percentile(sorted_ns: &[u64], p: f64) -> f64 {
if sorted_ns.is_empty() {
return 0.0;
}
let idx = ((sorted_ns.len() - 1) as f64 * p).round() as usize;
sorted_ns[idx] as f64 / 1000.0 // microseconds
}

struct RunResult {
label: &'static str,
total: Duration,
per_op_us: Vec<u64>,
signatures_produced: usize,
correctness_ok: bool,
}

fn report(r: &RunResult) {
let mut sorted = r.per_op_us.clone();
sorted.sort_unstable();
let mean: f64 = sorted.iter().sum::<u64>() as f64 / sorted.len() as f64 / 1000.0;
let throughput = N_ENTRIES as f64 / r.total.as_secs_f64();
println!(
"{:<14} total={:>9.3}ms mean={:>8.3}us p50={:>8.3}us p95={:>8.3}us p99={:>8.3}us \
throughput={:>10.1} ops/s signatures={:>7} correctness={}",
r.label,
r.total.as_secs_f64() * 1000.0,
mean,
percentile(&sorted, 0.50),
percentile(&sorted, 0.95),
percentile(&sorted, 0.99),
throughput,
r.signatures_produced,
if r.correctness_ok { "PASS" } else { "FAIL" },
);
}

fn run_baseline() -> RunResult {
let mut ledger =
TransactionalLedger::new(MemoryWitnessLog::default(), AlwaysAdmitGate::default());
let mut per_op = Vec::with_capacity(N_ENTRIES);
let t0 = Instant::now();
for i in 0..N_ENTRIES {
let op0 = Instant::now();
let id = ledger
.add(format!("memory entry {i}"), &[], "bench", "synthetic")
.expect("add");
ledger
.accept(id, "bench", "synthetic verification")
.expect("accept");
per_op.push(op0.elapsed().as_nanos() as u64);
}
let total = t0.elapsed();
let log = ledger.into_witness_sink();
RunResult {
label: "baseline",
total,
per_op_us: per_op,
signatures_produced: 0,
correctness_ok: log.verify_chain(),
}
}

fn run_signed(label: &'static str, strategy: SigningStrategy) -> RunResult {
let sink = SignedWitnessSink::from_keypair(
MemoryWitnessLog::default(),
&Ed25519Keypair::from_secret(&SECRET),
strategy,
)
.expect("valid strategy");
let mut ledger = TransactionalLedger::new(sink, AlwaysAdmitGate::default());
let mut per_op = Vec::with_capacity(N_ENTRIES);
let t0 = Instant::now();
for i in 0..N_ENTRIES {
let op0 = Instant::now();
let id = ledger
.add(format!("memory entry {i}"), &[], "bench", "synthetic")
.expect("add");
ledger
.accept(id, "bench", "synthetic verification")
.expect("accept");
per_op.push(op0.elapsed().as_nanos() as u64);
}
let total = t0.elapsed();
let mut sink = ledger.into_witness_sink();
sink.seal();
let pk = sink.public_key();
let correctness_ok =
verify_signed_chain(sink.inner(), sink.spans(), &pk, &sink.anchor()).is_ok();
RunResult {
label,
total,
per_op_us: per_op,
signatures_produced: sink.spans().len(),
correctness_ok,
}
}

/// Construct a diligent, fully-recomputed forgery (see
/// `witness_signing::tests::diligent_forgery_defeats_chain_walk_alone_but_not_signatures`)
/// against a signed run and confirm it is rejected. Returns `true` iff the
/// forgery is correctly rejected (the desired, secure outcome).
fn diligent_forgery_is_rejected(strategy: SigningStrategy) -> bool {
let sink = SignedWitnessSink::from_keypair(
MemoryWitnessLog::default(),
&Ed25519Keypair::from_secret(&SECRET),
strategy,
)
.expect("valid strategy");
let mut ledger = TransactionalLedger::new(sink, AlwaysAdmitGate::default());
for i in 0..200 {
let id = ledger.add(format!("m{i}"), &[], "bench", "r").unwrap();
ledger.accept(id, "bench", "r").unwrap();
}
let mut sink = ledger.into_witness_sink();
sink.seal();
let pk = sink.public_key();

let mut forged = sink.inner().clone();
let tamper_at = forged.records.len() / 2;
forged.records[tamper_at].payload ^= 0xDEAD_BEEF_u64;
let mut prev_hash = if tamper_at == 0 {
0
} else {
forged.records[tamper_at - 1].chain_hash()
};
for r in forged.records.iter_mut().skip(tamper_at) {
r.prev_hash = prev_hash;
r.record_hash = r.compute_record_hash();
prev_hash = r.chain_hash();
}
forged.committed_head = prev_hash;
forged.committed_count = forged.records.len() as u64;

let chain_walk_alone_passes = forged.verify_chain();
let signed_check_passes =
verify_signed_chain(&forged, sink.spans(), &pk, &SignedAnchor::genesis()).is_ok();
// The whole point of signing: the unsigned chain walk is fooled, the
// signed check is not.
chain_walk_alone_passes && !signed_check_passes
}

fn main() {
println!("ruvector-agent-memory witness signing benchmark");
println!("N_ENTRIES={N_ENTRIES} (each = 1 add + 1 accept = 2 witness records)\n");

let baseline = run_baseline();
let per_record = run_signed("candidate_a", SigningStrategy::PerRecord);
let batch_16 = run_signed(
"candidate_b16",
SigningStrategy::BatchTail { batch_size: 16 },
);
let batch_64 = run_signed(
"candidate_b64",
SigningStrategy::BatchTail { batch_size: 64 },
);
let batch_256 = run_signed(
"candidate_b256",
SigningStrategy::BatchTail { batch_size: 256 },
);

report(&baseline);
report(&per_record);
report(&batch_16);
report(&batch_64);
report(&batch_256);

println!("\nDiligent-forgery rejection (chain-walk-alone fooled, signed check must reject):");
for (label, strategy) in [
("candidate_a", SigningStrategy::PerRecord),
(
"candidate_b64",
SigningStrategy::BatchTail { batch_size: 64 },
),
] {
let ok = diligent_forgery_is_rejected(strategy);
println!(
" {label:<16} forgery_rejected={}",
if ok { "PASS" } else { "FAIL" }
);
}

let per_record_mean: f64 =
per_record.per_op_us.iter().sum::<u64>() as f64 / per_record.per_op_us.len() as f64;
let batch64_mean: f64 =
batch_64.per_op_us.iter().sum::<u64>() as f64 / batch_64.per_op_us.len() as f64;
println!(
"\nAmortization: candidate_a mean/op = {:.1}ns, candidate_b64 mean/op = {:.1}ns, ratio = {:.2}x",
per_record_mean,
batch64_mean,
per_record_mean / batch64_mean.max(1.0)
);
println!(
"Signature count: candidate_a={} candidate_b64={} (expected ratio ~{}x)",
per_record.signatures_produced, batch_64.signatures_produced, 64
);
}
8 changes: 8 additions & 0 deletions crates/ruvector-agent-memory/src/ledger.rs
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,14 @@ impl<S: WitnessSink, G: ProofGate> TransactionalLedger<S, G> {
&self.sink
}

/// Consume the ledger and recover ownership of its witness sink, e.g.
/// to call `SignedWitnessSink::flush` and inspect signed spans once a
/// run is done. Ledger state (`entries`, `history`) is discarded; the
/// sink already holds everything durable.
pub fn into_witness_sink(self) -> S {
self.sink
}

/// Read access to the acceptance gate (e.g. for offline receipt or
/// chain-integrity verification).
pub fn proof_gate(&self) -> &G {
Expand Down
5 changes: 5 additions & 0 deletions crates/ruvector-agent-memory/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ pub mod memory;
pub mod observation;
pub mod ops;
pub mod scoring;
pub mod witness_signing;
pub mod witnessed_compaction;

pub use arbitration::{
Expand Down Expand Up @@ -87,6 +88,10 @@ pub use ops::{
MemoryWitnessLog, NoopWitnessSink, TransitionKind, TransitionRecord, WitnessSink,
};
pub use scoring::{coherence_score, cosine_sim, normalize};
pub use witness_signing::{
verify_signed_chain, SignPurpose, SignedAnchor, SignedChainError, SignedChainReport,
SignedSpan, SignedWitnessSink, SigningStrategy, WitnessSignerError,
};
pub use witnessed_compaction::{compact_witnessed, EvictionWitnessChain};

/// Compact `store` in-place using `policy`, retaining `target_size` entries.
Expand Down
Loading
Loading