diff --git a/Cargo.lock b/Cargo.lock index 82891f3..e101420 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -979,6 +979,7 @@ dependencies = [ "blake3", "rusqlite", "tempfile", + "wit-logic", "zstd", ] diff --git a/crates/wit-cli/src/main.rs b/crates/wit-cli/src/main.rs index 1f7b1cf..699174d 100644 --- a/crates/wit-cli/src/main.rs +++ b/crates/wit-cli/src/main.rs @@ -6,8 +6,10 @@ //! (census + extracted names; see `wit-logic`'s module docs for why byte //! comparison is a diagnostic, never the verdict). M3 adds `scan` and //! `dupes` (`wit-index`) — the first commands that persist anything, via -//! the one crate in the workspace allowed to write. `wit log`/`diff`/ -//! `report` land later; see `docs/ROADMAP.md`. +//! the one crate in the workspace allowed to write. M2.5 adds +//! `logic-report` — the issue #15 reality-gate tool, running `logic-probe`'s +//! comparison across an entire library instead of one pair. `wit log`/ +//! `diff`/`report` land later; see `docs/ROADMAP.md`. use clap::{Parser, Subcommand}; use std::collections::BTreeSet; @@ -51,6 +53,11 @@ enum Command { /// Report byte-for-byte duplicate audio files under `path`. Read-only /// — Wit never deletes anything; this is just a map. Dupes { path: PathBuf }, + /// M2.5 (issue #15) reality-gate report: walk every Logic/GarageBand + /// alternative's backup chain under `path`, run `logic-probe`'s + /// comparison on every consecutive pair, and print the empty-verdict + /// rate across the whole library. Read-only. + LogicReport { path: PathBuf }, } fn main() -> ExitCode { @@ -60,6 +67,7 @@ fn main() -> ExitCode { Command::LogicProbe { old, new } => logic_probe(&old, &new), Command::Scan { path, data_dir } => scan(&path, data_dir), Command::Dupes { path } => dupes(&path), + Command::LogicReport { path } => logic_report(&path), } } @@ -345,3 +353,65 @@ fn human_bytes(bytes: u64) -> String { format!("{size:.1} {}", UNITS[unit]) } } + +// --------------------------------------------------------------------- // +// M2.5: logic-report (issue #15 reality gate) +// --------------------------------------------------------------------- // + +fn logic_report(path: &std::path::Path) -> ExitCode { + let report = wit_index::logic_report(path); + + if report.projects_scanned == 0 { + println!(" no Logic/GarageBand project found under this path — nothing to report"); + return ExitCode::SUCCESS; + } + + // Project/alternative *names* only, never a full path — same privacy + // discipline `wit scan`/`wit dupes` already follow (no home-directory + // path leaves this machine's report output). + let mut out = String::new(); + out.push_str(&format!( + " scanned {} project(s), {} alternative(s), {} consecutive save pair(s)\n", + report.projects_scanned, + report.alternatives_scanned, + report.total_pairs() + )); + + if report.total_pairs() == 0 { + out.push_str(" no consecutive save pairs found (every alternative has 0 or 1 version) — nothing to compare\n"); + } else { + out.push_str(&format!( + " {:.1}% of save pairs show a structural change Wit can see ({} of {})\n", + report.structural_change_percent(), + report.pairs_with_structural_change(), + report.total_pairs() + )); + out.push_str( + " distribution of change counts per save pair (0 = no visible structural change):\n", + ); + for (count, n) in report.change_count_distribution() { + out.push_str(&format!(" {count} change(s): {n} pair(s)\n")); + } + let byte_different_but_same = report.byte_different_structurally_identical(); + out.push_str(&format!( + " {byte_different_but_same} pair(s) ({:.1}%) are byte-different but structurally identical\n", + byte_different_but_same as f64 / report.total_pairs() as f64 * 100.0 + )); + } + if !report.read_errors.is_empty() { + out.push_str(&format!( + " ({} ProjectData file(s) could not be read or walked and were skipped)\n", + report.read_errors.len() + )); + } + + if let Err(msg) = wit_index::assert_no_home_paths(&out) { + // Must never happen — a bug in this function, not a recoverable + // runtime condition, so fail loudly rather than print a path that + // was supposed to be impossible to print (mirrors `dupes` above). + eprintln!("wit: internal error — {msg}"); + return ExitCode::FAILURE; + } + print!("{out}"); + ExitCode::SUCCESS +} diff --git a/crates/wit-index/Cargo.toml b/crates/wit-index/Cargo.toml index d60b0ee..d8e00e7 100644 --- a/crates/wit-index/Cargo.toml +++ b/crates/wit-index/Cargo.toml @@ -13,6 +13,7 @@ workspace = true blake3 = "1.5" zstd = "0.13" rusqlite = { version = "0.32", features = ["bundled"] } +wit-logic = { path = "../wit-logic" } [dev-dependencies] tempfile = "3" diff --git a/crates/wit-index/src/lib.rs b/crates/wit-index/src/lib.rs index 785dad6..8627388 100644 --- a/crates/wit-index/src/lib.rs +++ b/crates/wit-index/src/lib.rs @@ -10,6 +10,7 @@ pub mod discover; pub mod dupes; pub mod registry; +pub mod report; pub mod scan; pub mod store; @@ -19,5 +20,6 @@ pub use discover::{ }; pub use dupes::{assert_no_home_paths, duplicate_report, DuplicateGroup, DuplicateReport}; pub use registry::{ProjectRow, Registry, RegistryError}; +pub use report::{logic_report, LogicLibraryReport, SavePairResult}; pub use scan::{scan, ScanResult}; pub use store::{Hash, Store, StoreError}; diff --git a/crates/wit-index/src/report.rs b/crates/wit-index/src/report.rs new file mode 100644 index 0000000..7766713 --- /dev/null +++ b/crates/wit-index/src/report.rs @@ -0,0 +1,244 @@ +//! `wit logic-report` (M2.5, [issue #15](https://github.com/sep-lab/Wit/issues/15)): +//! the library-wide "reality gate". Walks every discovered Logic/GarageBand +//! alternative's chain — `Project File Backups/00`–`09` (oldest first) then +//! the current `ProjectData`, matching [`LogicAlternative`]'s own field +//! order — compares every consecutive pair at `wit-logic`'s Structure +//! honesty tier, and reports the three statistics the issue asks for: +//! +//! - % of saves with **any** structural change Wit can see +//! ([`LogicLibraryReport::structural_change_percent`]) +//! - distribution of change counts per save +//! ([`LogicLibraryReport::change_count_distribution`], via +//! [`wit_logic::change_count`]) +//! - how often two adjacent saves are byte-different but structurally +//! identical ([`LogicLibraryReport::byte_different_structurally_identical`]) +//! +//! Read-only — this only reads `ProjectData` bytes (via `wit_logic::walk_file` +//! and a plain `std::fs::read` for the byte-identity check) and never writes +//! or modifies anything under the library root, the same discipline +//! `discover.rs` and `dupes.rs` already follow. + +use crate::discover::discover_logic_projects; +use std::path::{Path, PathBuf}; + +/// One consecutive-pair comparison within a single alternative's chain. +#[derive(Debug, Clone, PartialEq)] +pub struct SavePairResult { + pub project_name: String, + pub alternative_name: String, + pub older: PathBuf, + pub newer: PathBuf, + pub structural_change: bool, + /// `wit_logic::change_count` — always `0` when `structural_change` is + /// `false` (see that function's doc comment for why). + pub change_count: usize, + pub bytes_identical: bool, +} + +#[derive(Debug, Clone, PartialEq, Default)] +pub struct LogicLibraryReport { + pub projects_scanned: usize, + pub alternatives_scanned: usize, + pub pairs: Vec, + /// `ProjectData` paths that failed to read or failed to walk. Excluded + /// from every pair touching them rather than silently dropped from + /// the count — one bad file must not blank the whole report (mirrors + /// `duplicate_report`'s and `wit-index::scan`'s read-error handling). + pub read_errors: Vec, +} + +impl LogicLibraryReport { + pub fn total_pairs(&self) -> usize { + self.pairs.len() + } + + pub fn pairs_with_structural_change(&self) -> usize { + self.pairs.iter().filter(|p| p.structural_change).count() + } + + /// Issue #15's first published statistic: % of saves with *any* + /// structural change Wit can see. `0.0` on an empty report rather than + /// `NaN` — mirrors `DuplicateReport::duplicate_percent`. + pub fn structural_change_percent(&self) -> f64 { + if self.pairs.is_empty() { + 0.0 + } else { + self.pairs_with_structural_change() as f64 / self.pairs.len() as f64 * 100.0 + } + } + + /// Issue #15's third statistic: adjacent saves that differ byte-for-byte + /// but walk to the same Structure-tier verdict — the case that makes + /// raw byte comparison useless as a change signal on this format (see + /// `wit-logic`'s module docs). + pub fn byte_different_structurally_identical(&self) -> usize { + self.pairs + .iter() + .filter(|p| !p.bytes_identical && !p.structural_change) + .count() + } + + /// Issue #15's second statistic: how many pairs land at each + /// `change_count`, ascending by count. Bucket `0` holds every pair + /// `semantic_equal` called `NoStructuralChange`. + pub fn change_count_distribution(&self) -> Vec<(usize, usize)> { + let mut buckets: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + for pair in &self.pairs { + *buckets.entry(pair.change_count).or_insert(0) += 1; + } + buckets.into_iter().collect() + } +} + +/// Scan `root` for Logic/GarageBand bundles and report M2.5's three +/// statistics across every alternative's chain. +pub fn logic_report(root: &Path) -> LogicLibraryReport { + let mut report = LogicLibraryReport::default(); + let projects = discover_logic_projects(root); + report.projects_scanned = projects.len(); + + for project in &projects { + for alt in &project.alternatives { + report.alternatives_scanned += 1; + + let mut chain: Vec<&PathBuf> = alt.backups.iter().collect(); + chain.push(&alt.current); + + let mut walks: Vec<(&PathBuf, wit_logic::Walked)> = Vec::new(); + for path in chain { + match wit_logic::walk_file(path) { + Ok(w) => walks.push((path, w)), + Err(_) => report.read_errors.push(path.clone()), + } + } + + for window in walks.windows(2) { + let (path_a, a) = &window[0]; + let (path_b, b) = &window[1]; + let structural_change = + wit_logic::semantic_equal(a, b) == wit_logic::Verdict::StructuralChange; + let change_count = wit_logic::change_count(a, b); + let bytes_identical = wit_logic::bytes_equal( + &std::fs::read(path_a).unwrap_or_default(), + &std::fs::read(path_b).unwrap_or_default(), + ); + report.pairs.push(SavePairResult { + project_name: project.name.clone(), + alternative_name: alt.name.clone(), + older: (*path_a).clone(), + newer: (*path_b).clone(), + structural_change, + change_count, + bytes_identical, + }); + } + } + } + + report +} + +#[cfg(test)] +mod tests { + use super::*; + + // Minimal ProjectData container builder — same layout as + // `wit-logic/src/frame.rs` documents (magic, version word, root + // LENGTH, then a flat record sequence), reimplemented here because + // `wit-logic`'s own builder is private to its crate's test module. + const MAGIC: [u8; 4] = [0x23, 0x47, 0xC0, 0xAB]; + const RECORD_HEADER_LEN: usize = 0x24; + + fn build_container(records: &[(&[u8; 4], Vec)]) -> Vec { + let mut payload = Vec::new(); + for (tag, rec_payload) in records { + payload.extend_from_slice(*tag); + payload.extend_from_slice(&[0u8; 0x1c - 4]); + payload.extend_from_slice(&(rec_payload.len() as u32).to_le_bytes()); + payload.extend_from_slice(&[0u8; RECORD_HEADER_LEN - 0x20]); + payload.extend_from_slice(rec_payload); + } + let mut out = Vec::new(); + out.extend_from_slice(&MAGIC); + out.extend_from_slice(&[0xd0, 0x09]); + out.extend_from_slice(&[0u8; 10]); + out.extend_from_slice(&(payload.len() as u32).to_le_bytes()); + out.extend_from_slice(&[0u8; 4]); + out.extend_from_slice(&payload); + out + } + + fn write(path: &Path, bytes: &[u8]) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, bytes).unwrap(); + } + + #[test] + fn computes_the_three_statistics_on_a_crafted_two_pair_chain() { + let dir = tempfile::tempdir().unwrap(); + let bundle = dir.path().join("Song.logicx"); + + // v1 and v2: same tag census (byte-different filler, same length) + // -> NoStructuralChange, change_count 0, bytes differ. + let v1 = build_container(&[(b"karT", vec![0xAA; 8])]); + let v2 = build_container(&[(b"karT", vec![0xBB; 8])]); + // v3: adds a new record tag -> StructuralChange, change_count >= 1. + let v3 = build_container(&[(b"karT", vec![0xBB; 8]), (b"gRuA", vec![0u8; 8])]); + assert_ne!(v1, v2); + + write( + &bundle.join("Alternatives/000/Project File Backups/00/ProjectData"), + &v1, + ); + write( + &bundle.join("Alternatives/000/Project File Backups/01/ProjectData"), + &v2, + ); + write(&bundle.join("Alternatives/000/ProjectData"), &v3); + + let report = logic_report(dir.path()); + + assert_eq!(report.projects_scanned, 1); + assert_eq!(report.alternatives_scanned, 1); + assert_eq!(report.total_pairs(), 2); + assert!(report.read_errors.is_empty()); + + // Pair 1 (00 -> 01): byte-different, structurally identical. + // Pair 2 (01 -> current): structural change. + assert_eq!(report.pairs_with_structural_change(), 1); + assert_eq!(report.structural_change_percent(), 50.0); + assert_eq!(report.byte_different_structurally_identical(), 1); + + let distribution = report.change_count_distribution(); + assert_eq!(distribution[0], (0, 1)); // pair 1: change_count 0 + assert!(distribution.iter().any(|&(count, n)| count >= 1 && n == 1)); // pair 2 + } + + #[test] + fn an_unreadable_projectdata_is_recorded_as_a_read_error_not_a_panic() { + let dir = tempfile::tempdir().unwrap(); + let bundle = dir.path().join("Song.logicx"); + // Not a valid ProjectData container -- walk_file must fail cleanly. + write( + &bundle.join("Alternatives/000/ProjectData"), + b"not a real ProjectData file", + ); + + let report = logic_report(dir.path()); + assert_eq!(report.projects_scanned, 1); + assert_eq!(report.total_pairs(), 0); + assert_eq!(report.read_errors.len(), 1); + } + + #[test] + fn an_empty_library_reports_zero_everything_not_nan() { + let dir = tempfile::tempdir().unwrap(); + let report = logic_report(dir.path()); + assert_eq!(report.projects_scanned, 0); + assert_eq!(report.total_pairs(), 0); + assert_eq!(report.structural_change_percent(), 0.0); + assert_eq!(report.byte_different_structurally_identical(), 0); + assert!(report.change_count_distribution().is_empty()); + } +} diff --git a/crates/wit-index/tests/real_fixtures.rs b/crates/wit-index/tests/real_fixtures.rs new file mode 100644 index 0000000..4cb7126 --- /dev/null +++ b/crates/wit-index/tests/real_fixtures.rs @@ -0,0 +1,93 @@ +//! Opt-in real-material check for `logic_report` — M2.5 (issue #15), the +//! reality gate. Mirrors the `WIT_FIXTURES`/`WIT_LOGIC_PROJECT` discipline +//! (`wit-diff/tests/real_fixtures.rs`, `wit-logic/tests/real_fixtures.rs`): +//! **loudly skipped** by default, never touches real material unless asked. +//! +//! `WIT_LOGIC_PROJECT` (in `wit-logic`) points at a single `.logicx`/`.band` +//! bundle. This test's `WIT_LOGIC_LIBRARY` points at a **library root** — +//! a directory that may contain many such bundles — because issue #15 +//! explicitly asks for the statistics across a whole library, not one +//! project. +//! +//! Run against a real Logic library: +//! +//! ```text +//! WIT_LOGIC_LIBRARY="/path/to/YourLibrary" cargo test -p wit-index --test real_fixtures -- --nocapture --ignored +//! ``` +//! +//! The published number in `docs/EXPERIMENTS.md` was produced by this exact +//! command against **one** real project (n=1), not the 30-project / 26 GB +//! library the issue asks for — that run still needs to happen on a machine +//! that has it. See the EXPERIMENTS.md entry for the honestly-labeled n=1 +//! result and what it does and does not answer. + +use std::path::PathBuf; + +fn library_root() -> Option { + std::env::var_os("WIT_LOGIC_LIBRARY").map(PathBuf::from) +} + +#[test] +#[ignore = "opt-in: set WIT_LOGIC_LIBRARY to a real Logic library root and pass --ignored"] +fn real_library_reports_the_three_m2_5_statistics() { + let Some(root) = library_root() else { + eprintln!( + "WIT_LOGIC_LIBRARY not set — skipped. To run: \ + WIT_LOGIC_LIBRARY=/path/to/YourLibrary cargo test -p wit-index --test real_fixtures -- --nocapture --ignored" + ); + return; + }; + + let report = wit_index::logic_report(&root); + + assert!( + report.projects_scanned > 0, + "no Logic/GarageBand project found under WIT_LOGIC_LIBRARY={root:?}" + ); + + eprintln!( + "scanned {} project(s), {} alternative(s), {} consecutive save pair(s)", + report.projects_scanned, + report.alternatives_scanned, + report.total_pairs() + ); + + if report.total_pairs() == 0 { + eprintln!("no consecutive save pairs found — nothing more to report"); + return; + } + + eprintln!( + "{:.1}% of save pairs show a structural change Wit can see ({} of {})", + report.structural_change_percent(), + report.pairs_with_structural_change(), + report.total_pairs() + ); + eprintln!("distribution of change counts per save pair:"); + for (count, n) in report.change_count_distribution() { + eprintln!(" {count} change(s): {n} pair(s)"); + } + let byte_different_but_same = report.byte_different_structurally_identical(); + eprintln!( + "{byte_different_but_same} pair(s) ({:.1}%) are byte-different but structurally identical", + byte_different_but_same as f64 / report.total_pairs() as f64 * 100.0 + ); + if !report.read_errors.is_empty() { + eprintln!( + "{} ProjectData file(s) could not be read or walked", + report.read_errors.len() + ); + } + + // Sanity, not a correctness assertion about the *content* of the + // library (issue #15's exit criterion is a judgment call for a human, + // not something this test decides): every percentage must be a real + // percentage, and the distribution must account for every pair. + assert!((0.0..=100.0).contains(&report.structural_change_percent())); + let distributed: usize = report + .change_count_distribution() + .iter() + .map(|(_, n)| n) + .sum(); + assert_eq!(distributed, report.total_pairs()); +} diff --git a/crates/wit-logic/src/lib.rs b/crates/wit-logic/src/lib.rs index 311c9ed..505a254 100644 --- a/crates/wit-logic/src/lib.rs +++ b/crates/wit-logic/src/lib.rs @@ -73,6 +73,43 @@ pub fn semantic_equal(a: &Walked, b: &Walked) -> Verdict { } } +/// Count of distinct signals that differ between two walks: one per +/// census tag whose count differs, one per name added to or removed from +/// each of the three [`Extracted`] name lists, and one if tempo differs. +/// **Diagnostic granularity only — not part of the [`Verdict`].** +/// `semantic_equal` stays a strict boolean at v1 (M2 tracking issue +/// guardrail); this exists to answer a different question — *how much* +/// changed on saves that already report `StructuralChange` — for M2.5's +/// "distribution of change counts per save" (issue #15). Guaranteed to be +/// `0` exactly when `semantic_equal` reports `NoStructuralChange`, since +/// both are derived from the same census/extracted equality checks. +pub fn change_count(a: &Walked, b: &Walked) -> usize { + let mut count = 0usize; + let tags: std::collections::BTreeSet<&String> = + a.census.keys().chain(b.census.keys()).collect(); + for tag in tags { + if a.census.get(tag).copied().unwrap_or(0) != b.census.get(tag).copied().unwrap_or(0) { + count += 1; + } + } + count += symmetric_diff_count( + &a.extracted.possible_track_names, + &b.extracted.possible_track_names, + ); + count += symmetric_diff_count(&a.extracted.region_names, &b.extracted.region_names); + count += symmetric_diff_count(&a.extracted.audio_file_names, &b.extracted.audio_file_names); + if a.extracted.tempo_bpm != b.extracted.tempo_bpm { + count += 1; + } + count +} + +fn symmetric_diff_count(a: &[String], b: &[String]) -> usize { + let sa: std::collections::BTreeSet<&String> = a.iter().collect(); + let sb: std::collections::BTreeSet<&String> = b.iter().collect(); + sa.symmetric_difference(&sb).count() +} + /// Raw byte comparison — a diagnostic only ("did the file change at all"), /// never fed into [`semantic_equal`]'s verdict. Exposed because `wit logic /// probe` reports it alongside the structural verdict, honestly labeled as @@ -151,4 +188,34 @@ mod tests { assert_ne!(a.root.version_word, b.root.version_word); assert_eq!(semantic_equal(&a, &b), Verdict::NoStructuralChange); } + + #[test] + fn change_count_is_zero_exactly_when_no_structural_change() { + let data_a = build_container(&[(b"karT", vec![0xAA; 8])]); + let data_b = build_container(&[(b"karT", vec![0xBB; 8])]); + let a = walk(&data_a).unwrap(); + let b = walk(&data_b).unwrap(); + assert_eq!(semantic_equal(&a, &b), Verdict::NoStructuralChange); + assert_eq!(change_count(&a, &b), 0); + } + + #[test] + fn change_count_counts_one_per_differing_census_tag() { + let data_a = build_container(&[(b"karT", vec![0u8; 8])]); + let data_b = build_container(&[(b"karT", vec![0u8; 8]), (b"gRuA", vec![0u8; 8])]); + let a = walk(&data_a).unwrap(); + let b = walk(&data_b).unwrap(); + assert_eq!(semantic_equal(&a, &b), Verdict::StructuralChange); + // Only "gRuA" changed count (0 -> 1); "karT" is unchanged (1 -> 1). + assert_eq!(change_count(&a, &b), 1); + } + + #[test] + fn change_count_is_symmetric() { + let data_a = build_container(&[(b"karT", vec![0u8; 8])]); + let data_b = build_container(&[(b"karT", vec![0u8; 8]), (b"gRuA", vec![0u8; 8])]); + let a = walk(&data_a).unwrap(); + let b = walk(&data_b).unwrap(); + assert_eq!(change_count(&a, &b), change_count(&b, &a)); + } } diff --git a/docs/EXPERIMENTS.md b/docs/EXPERIMENTS.md index a73be85..53d8dfb 100644 --- a/docs/EXPERIMENTS.md +++ b/docs/EXPERIMENTS.md @@ -551,6 +551,72 @@ Processed (DERIVED: Freeze/Consolidate/Crop) 850 MB 51.8% --- +## 11. M2.5 — the empty-verdict rate on a real Logic library + +**Tracking issue:** [#15](https://github.com/sep-lab/Wit/issues/15). On Ableton, 24% of +saves are semantically empty (§1). On Logic, where `wit-logic`'s Structure honesty tier +can't see knob/fader moves (§4's limit, restated for this format), the empty-verdict rate +was unknown before this experiment. + +**Method.** `wit logic-report ` (new in this change; `crates/wit-index/src/report.rs`, +`crates/wit-cli/src/main.rs`) discovers every Logic/GarageBand bundle under a library root +(reusing `wit-index`'s M3 discovery, `discover_logic_projects`), and for every alternative's +chain (`Project File Backups/00`–`09` oldest-first, then the current `ProjectData` — +matching M2's `backup_chain()` order in `wit-logic/tests/real_fixtures.rs`) walks every +consecutive pair with `wit_logic::walk` and reports, per pair: `semantic_equal`'s verdict, +`wit_logic::change_count` (a new diagnostic — one per differing census tag or added/removed +extracted name, or a tempo change; `0` exactly when the verdict is `NoStructuralChange`), +and raw byte identity. + +**Result — measured, n = 1 project.** + +``` +$ wit logic-report "/path/to/YourLibrary" + scanned 1 project(s), 1 alternative(s), 9 consecutive save pair(s) + 44.4% of save pairs show a structural change Wit can see (4 of 9) + distribution of change counts per save pair (0 = no visible structural change): + 0 change(s): 5 pair(s) + 1 change(s): 1 pair(s) + 2 change(s): 1 pair(s) + 14 change(s): 1 pair(s) + 17 change(s): 1 pair(s) + 5 pair(s) (55.6%) are byte-different but structurally identical +``` + +Run against `You make my crazy!` (§0's fixture table) — the same 10-save chain M2 used. +5 of 9 pairs show no structural change (**55.6%**), and every one of those 5 is also +byte-different from its neighbor (the M2 finding that raw byte comparison is useless on +this format, reconfirmed: 100% of the no-visible-change pairs would look "changed" to a +byte diff). Every pair with a nonzero `change_count` was correctly called +`StructuralChange`, and every zero-count pair was correctly called `NoStructuralChange` — +`change_count` and the verdict never disagreed, which is expected since both derive from +the same census/extracted equality (`wit-logic/src/lib.rs`), but is a useful sanity check +on the new counting logic itself. + +**Limits — this is a tool delivery, not the finding issue #15 asks for.** The issue's own +exit criterion is a decision at **30 projects, 26 GB** — whether >50% of real saves show +no visible structural change. **n = 1 project cannot answer that question either way**; +55.6% on one project's one alternative is a data point, not a verdict on a library. This +environment does not have the 30-project/26 GB library (`daw-vcs-adoption-evidence` +memory) the issue was scoped against — only the one `.logicx` bundle in the fixture table +above. The exit-criterion decision (M5 vs. the M2 stretch goal — mapping Logic's +volume-fader field) is **not resolved by this entry** and still requires running the +command below against the real library. + +**Reproduce (opt-in, real material, never committed):** + +```bash +WIT_LOGIC_LIBRARY="/path/to/YourLibrary" cargo test -p wit-index --test real_fixtures -- --nocapture --ignored +``` + +or, without the test harness, directly: + +```bash +cargo run -p wit-cli -- logic-report "/path/to/YourLibrary" +``` + +--- + ## Reproducing these ```bash diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 2a43352..5c34444 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -46,7 +46,7 @@ understand — without ever risking a project file.** | M0 ✅ | [PR #13](https://github.com/sep-lab/Wit/pull/13) | ADR-0006, Rust workspace scaffolding, CI (`rust` + `licenses` jobs) | Landed | | M1 ✅ | [PR #24](https://github.com/sep-lab/Wit/pull/24) | Ableton `.als` parity port (`wit-als`) — Rust port of the working Python differ | Landed — golden byte-for-byte (`crates/wit-diff/tests/golden.rs`); spot-checked against a real 29-save `Backup/` chain via `WIT_FIXTURES` (28 pairs parse clean, no panics; 2 pairs cross-checked line-for-line against Python's actual output, exact match). The formal corpus-agreement gate against the specific 7 zero-change / 3 knob-only pairs named in `wit-planning/PLAN.md` has not been re-run — flagged for a follow-up pass | | M2 ✅ | [PR #25](https://github.com/sep-lab/Wit/pull/25) | Logic/GarageBand `ProjectData` walker (`wit-logic`) | Landed — spot-checked against a real `.logicx` project + its 9 on-disk `Project File Backups` (10 files, clean EOF on all, tempo matches `MetaData.plist` exactly on all); 5 of 9 real consecutive pairs correctly report `NoStructuralChange` despite differing bytes. The 30-fixture `jonkubis/LogicProFormatWriter` corpus fetch (pinned SHA `1f77c5c37d49ccd9551cc8e9107750e8db2f1fed`) has not been run — network-gated, opt-in, flagged for a follow-up pass | -| M2.5 | [#15](https://github.com/sep-lab/Wit/issues/15) | **Reality gate** — measure the empty-verdict rate on a real Logic library | Published finding in EXPERIMENTS.md; if >50% of saves show no visible structural change, the next work is mapping Logic's volume-fader field, not the GUI | +| M2.5 | [#15](https://github.com/sep-lab/Wit/issues/15) | **Reality gate** — measure the empty-verdict rate on a real Logic library | **Tool landed, finding still open.** `wit logic-report ` (`wit-index::logic_report`) now walks every alternative's backup chain across a whole library and publishes the three statistics the issue asks for — see EXPERIMENTS.md §11. Run against the one real `.logicx` project available in this environment (n=1, 9 pairs): 55.6% show no visible structural change. That is a data point, not the issue's own >50%-of-30-projects decision — the real 26 GB/30-project run (`WIT_LOGIC_LIBRARY=/path/to/YourLibrary cargo test -p wit-index --test real_fixtures -- --nocapture --ignored`) still needs to happen before M5-vs-stretch-goal is decided | | M3 ✅ | [PR #29](https://github.com/sep-lab/Wit/pull/29) | Index, content-addressed store, CLI (`wit-index`, `wit-cli`) — covers Logic, GarageBand, and Ableton discovery | Landed — `wit scan` verified against real local Ableton (5 lineages / 30 versions, exact match) and Logic (10 versions) libraries, rescans idempotent through the actual CLI. `wit dupes` verified correct against a deliberate real duplicate; the README's ~5.4 GB figure needs the full 26 GB library this environment doesn't have, flagged for a follow-up pass. (Originally reviewed and merged as [PR #26](https://github.com/sep-lab/Wit/pull/26), which was opened against the wrong base branch and never reached `main` — PR #29 is the corrected landing.) | | M4 ✅ | [PR #27](https://github.com/sep-lab/Wit/pull/27) | Audio engine — decode, peaks, alignment, null-diff (`wit-audio`) | Landed — injected sample shifts (+1, −1, +4800, −12000) recovered exactly via FFT cross-correlation on seeded broadband noise (a pure tone can't exercise misalignment, per the issue's own reasoning); the confidence gate (refuse below 1.5) verified both ways — a real aligned pair scores tens-to-hundreds, two independently-seeded noise buffers score 0.9 and are correctly refused; real `afconvert`-generated PCM-CAF, ALAC-in-CAF, and ALAC-in-M4A fixtures decode sample-exact against the synthetic source (macOS, measured on this machine — loud-skips via `WIT_AUDIO_AFCONVERT`/`cfg!(target_os)` elsewhere, mirroring `wit-logic`'s `WIT_FIXTURES` doctrine); perf measured at 1.57 s for two 5-minute mono bounces end-to-end (decode+align+null-diff, release build, single-threaded, this machine) against the 10 s budget. Not yet exercised: the `afconvert` test running inside GitHub Actions' macOS runner itself, since it is intentionally opt-in (`#[ignore]`) rather than a default CI assertion — flagged for a follow-up pass | | M5 | [#18](https://github.com/sep-lab/Wit/issues/18) | Tauri app alpha — Shelf, Story, Compare, watcher | Installs on a second Mac, reads a diff on the demo library, a real Logic folder, **and one Ableton `.als` lineage**, clicks Reveal |