diff --git a/crates/synapse-bench/src/lib.rs b/crates/synapse-bench/src/lib.rs
index 793c80b..0755b39 100644
--- a/crates/synapse-bench/src/lib.rs
+++ b/crates/synapse-bench/src/lib.rs
@@ -38,6 +38,7 @@ pub mod synapse_arm;
pub mod tree_parity;
pub mod workload;
pub mod write_phase;
+pub mod x6;
use std::path::Path;
diff --git a/crates/synapse-bench/src/main.rs b/crates/synapse-bench/src/main.rs
index 4246fce..2801b4e 100644
--- a/crates/synapse-bench/src/main.rs
+++ b/crates/synapse-bench/src/main.rs
@@ -601,6 +601,65 @@ fn segments_report(args: &[String]) -> Result<(), String> {
.map_err(|e| format!("write stdout: {e}"))
}
+/// ADR 0003 X6 (G2): `x6 --corpus
--out [--dir ]
+/// [--reps 3] [--idle-window-s 10] [--only K1,C0]`. Progress goes to stderr.
+fn x6(args: &[String]) -> Result<(), String> {
+ let opts = parse_pairs(
+ args,
+ &[
+ "--corpus",
+ "--out",
+ "--dir",
+ "--reps",
+ "--idle-window-s",
+ "--only",
+ ],
+ )?;
+ let get = |k: &str| {
+ opts.iter()
+ .rev()
+ .find(|(key, _)| key == k)
+ .map(|(_, v)| v.clone())
+ };
+ let corpus = PathBuf::from(get("--corpus").ok_or("missing --corpus")?);
+ let out = PathBuf::from(get("--out").ok_or("missing --out")?);
+ let scratch = get("--dir")
+ .map(PathBuf::from)
+ .unwrap_or_else(synapse_bench::x6::default_scratch);
+ let reps: usize = get("--reps")
+ .map_or(Ok(3), |v| v.parse())
+ .map_err(|_| "invalid --reps")?;
+ let idle: u64 = get("--idle-window-s")
+ .map_or(Ok(10), |v| v.parse())
+ .map_err(|_| "invalid --idle-window-s")?;
+ let only: Option> = get("--only").map(|v| v.split(',').map(String::from).collect());
+ std::fs::create_dir_all(&scratch).map_err(|e| format!("create {}: {e}", scratch.display()))?;
+ let raw =
+ synapse_bench::x6::run_x6(&corpus, &scratch, reps, idle, only.as_deref(), &mut |m| {
+ eprintln!("{m}")
+ })?;
+ let bytes = serde_json::to_vec(&raw).map_err(|e| e.to_string())?;
+ std::fs::write(&out, bytes).map_err(|e| format!("write {}: {e}", out.display()))
+}
+
+/// `x6-report --raw `: the RULE.md section 6 decision as JSON on stdout.
+fn x6_report(args: &[String]) -> Result<(), String> {
+ use std::io::Write;
+ let opts = parse_pairs(args, &["--raw"])?;
+ let raw_path = opts
+ .iter()
+ .rev()
+ .find(|(k, _)| k == "--raw")
+ .map(|(_, v)| PathBuf::from(v))
+ .ok_or("missing --raw")?;
+ let raw: synapse_bench::x6::X6Raw = synapse_bench::read_json(&raw_path)?;
+ let report = synapse_bench::x6::decide(&raw);
+ let bytes = serde_json::to_vec_pretty(&report).map_err(|e| e.to_string())?;
+ std::io::stdout()
+ .write_all(&bytes)
+ .map_err(|e| format!("write stdout: {e}"))
+}
+
fn main() -> ExitCode {
let args: Vec = std::env::args().skip(1).collect();
let outcome = match args.first().map(String::as_str) {
@@ -617,6 +676,8 @@ fn main() -> ExitCode {
Some("crypto-vectors-verify") => crypto_vectors_verify(&args[1..]),
Some("segments") => segments(&args[1..]),
Some("segments-report") => segments_report(&args[1..]),
+ Some("x6") => x6(&args[1..]),
+ Some("x6-report") => x6_report(&args[1..]),
Some("store-repair") => store_repair(&args[1..]),
_ => Err("usage: synapse-bench run ... | synapse-bench child ... (see crate docs)".into()),
};
diff --git a/crates/synapse-bench/src/x6.rs b/crates/synapse-bench/src/x6.rs
new file mode 100644
index 0000000..52d7193
--- /dev/null
+++ b/crates/synapse-bench/src/x6.rs
@@ -0,0 +1,800 @@
+//! ADR 0003 experiment X6 (step G2, issue #45): content-defined chunking threshold and
+//! chunk sizes on real AIEN repository histories.
+//!
+//! Everything this module does is fixed in `evidence/g2-content-defined-chunking/x6/
+//! RULE.md`, committed before the runs: the eight configurations, the corpus (one
+//! accepted change per first-parent commit that adds or modifies a regular file, bytes
+//! provided the first time a blob appears), the measurements (ingest phase: per-change
+//! latency, wall time, one energy window, log bytes; read phase: every distinct blob once
+//! with a byte comparison, per-read time, one energy window), three repetitions with the
+//! configuration order rotated by one per repetition, and the decision criterion
+//! ([`decide`]). Joules come only from the `aien_spbm` package meter; a refused window
+//! leaves them `None` with the reason (`INSTRUMENT_UNAVAILABLE`), never a substitute.
+
+use crate::energy::{idle_window, EnergyReading, EnergyWindow};
+use crate::stats::{summarize_us, LatencySummary};
+use ed25519_dalek::SigningKey;
+use serde::{Deserialize, Serialize};
+use std::collections::{BTreeMap, HashMap};
+use std::io::{BufRead, BufReader, Read, Write};
+use std::path::{Path, PathBuf};
+use std::process::{Command, Stdio};
+use std::time::{Duration, Instant};
+use synapse_core::{
+ BlobDigest, ChangesetEnvelope, FileMode, GitOid, IdempotencyKey, MutationOp, PathMutation,
+ RepoId, SYNAPSE_ENVELOPE_VERSION,
+};
+use synapse_store::{
+ ChunkParams, FsyncPolicy, IngestOutcome, RecoveryPolicy, SharedStore, StoreLimits,
+};
+
+/// A2 flush bound, seconds: `evidence/a2-segment-store/measurement/floor.json`, cheapest
+/// `overwrite_*_fdatasync` p50 (`overwrite_64k_fdatasync`), as fixed in RULE.md section 4.
+pub const A2_FLUSH_BOUND_S: f64 = 1.006e-3;
+/// Memory bandwidth, bytes per second (NVIDIA DGX Spark specification, A0).
+pub const MEMORY_BW_BPS: f64 = 273e9;
+
+/// The corpus repositories and commits of RULE.md section 3, in order.
+pub const CORPUS: [(&str, &str); 4] = [
+ (
+ "aien-architecture",
+ "d2768e9b8730af5f13c0fbbdfbfa4ba788f7605a",
+ ),
+ ("aienos", "b7ebf7cde234e1ddcc91eb8fcaf378d060cfb3a2"),
+ ("omega", "43d8d20da2f64319bcf6196ac7204021c3f280eb"),
+ ("synapse", "b9f46b16aa74301f0b330cfb5fa2e06f07d60b43"),
+];
+
+/// The eight configurations of RULE.md section 2.
+pub fn configurations() -> Vec<(&'static str, ChunkParams)> {
+ const K: u64 = 1024;
+ vec![
+ ("C0", ChunkParams::OFF),
+ ("K1", ChunkParams::candidate(16 * K, 4 * 1024)),
+ ("K2", ChunkParams::candidate(16 * K, 8 * 1024)),
+ ("K3", ChunkParams::candidate(64 * K, 8 * 1024)),
+ ("K4", ChunkParams::candidate(64 * K, 16 * 1024)),
+ ("K5", ChunkParams::candidate(64 * K, 32 * 1024)),
+ ("K6", ChunkParams::candidate(256 * K, 16 * 1024)),
+ ("K7", ChunkParams::candidate(256 * K, 64 * 1024)),
+ ]
+}
+
+/// A signed changeset and the blob bytes it provides.
+pub type SignedChange = (ChangesetEnvelope, Vec<(BlobDigest, Vec)>);
+
+/// One file revision of a change: synthetic path, blob index, executable bit.
+#[derive(Debug, Clone)]
+pub struct FileRev {
+ pub path: String,
+ pub blob: usize,
+ pub exec: bool,
+}
+
+/// The corpus in memory: changes in order and the distinct blobs they name.
+#[derive(Debug)]
+pub struct Corpus {
+ pub changes: Vec<(String, String, Vec)>,
+ pub blobs: Vec>,
+ pub digests: Vec,
+ pub revisions: usize,
+ pub revision_bytes: u64,
+}
+
+fn git(dir: &Path, args: &[&str]) -> Result, String> {
+ let out = Command::new("git")
+ .arg("-C")
+ .arg(dir)
+ .args(args)
+ .output()
+ .map_err(|e| format!("git: {e}"))?;
+ if !out.status.success() {
+ return Err(format!(
+ "git {args:?} in {}: {}",
+ dir.display(),
+ String::from_utf8_lossy(&out.stderr)
+ ));
+ }
+ Ok(out.stdout)
+}
+
+/// Read the corpus from bare clones `/.git` (RULE.md section 3).
+pub fn load_corpus(root: &Path) -> Result {
+ let mut changes = Vec::new();
+ let mut oid_index: HashMap = HashMap::new();
+ let mut oids: Vec = Vec::new();
+ let mut paths: HashMap<(usize, String), usize> = HashMap::new();
+ let mut revisions = 0usize;
+ for (r, (name, commit)) in CORPUS.iter().enumerate() {
+ let dir = root.join(format!("{name}.git"));
+ let head = git(&dir, &["rev-parse", &format!("{commit}^{{commit}}")])?;
+ if String::from_utf8_lossy(&head).trim() != *commit {
+ return Err(format!("{name}: commit {commit} not found"));
+ }
+ let log = git(
+ &dir,
+ &[
+ "log",
+ "--reverse",
+ "--first-parent",
+ "--raw",
+ "--no-renames",
+ "--no-abbrev",
+ "--format=commit %H",
+ commit,
+ ],
+ )?;
+ let mut current: Option<(String, Vec)> = None;
+ let flush = |cur: Option<(String, Vec)>, changes: &mut Vec<_>| {
+ if let Some((h, files)) = cur {
+ if !files.is_empty() {
+ changes.push((name.to_string(), h, files));
+ }
+ }
+ };
+ for line in String::from_utf8_lossy(&log).lines() {
+ if let Some(h) = line.strip_prefix("commit ") {
+ flush(current.take(), &mut changes);
+ current = Some((h.to_string(), Vec::new()));
+ continue;
+ }
+ let Some(rest) = line.strip_prefix(':') else {
+ continue;
+ };
+ let (meta, path) = rest.split_once('\t').ok_or("raw line without path")?;
+ let f: Vec<&str> = meta.split(' ').collect();
+ if f.len() != 5 {
+ return Err(format!("unexpected raw line {line}"));
+ }
+ let (mode, oid, status) = (f[1], f[3], f[4]);
+ if !(status.starts_with('A') || status.starts_with('M')) {
+ continue;
+ }
+ if mode != "100644" && mode != "100755" {
+ continue;
+ }
+ let next = paths.len();
+ let pid = *paths.entry((r, path.to_string())).or_insert(next);
+ let next = oids.len();
+ let blob = *oid_index.entry(oid.to_string()).or_insert_with(|| {
+ oids.push(oid.to_string());
+ next
+ });
+ let files = &mut current.as_mut().ok_or("raw line before commit")?.1;
+ files.push(FileRev {
+ path: format!("p{pid}"),
+ blob,
+ exec: mode == "100755",
+ });
+ revisions += 1;
+ }
+ flush(current.take(), &mut changes);
+ }
+ // Blob contents through one `git cat-file --batch` per repository.
+ let mut blobs: Vec