From 3cfa9239d2fc7729b7f24e54f6dda86e92e49842 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 18:02:42 -0400 Subject: [PATCH] =?UTF-8?q?feat(prune):=20R1=20prune-reconcile=20pure=20co?= =?UTF-8?q?re=20=E2=80=94=20(owned=20=E2=88=A9=20present)=20=E2=88=92=20re?= =?UTF-8?q?ferenced?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add REAPER-free/filesystem-free prune orphan-set core + additive BankBook::referencedPaths() union query, with full unit coverage. --- CMakeLists.txt | 16 ++ src/bank_book.cpp | 17 ++ src/bank_book.h | 11 ++ src/prune_reconcile.cpp | 36 +++++ src/prune_reconcile.h | 63 ++++++++ tests/test_prune_reconcile.cpp | 283 +++++++++++++++++++++++++++++++++ 6 files changed, 426 insertions(+) create mode 100644 src/prune_reconcile.cpp create mode 100644 src/prune_reconcile.h create mode 100644 tests/test_prune_reconcile.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index b177f2c..410ed35 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -214,6 +214,18 @@ target_link_libraries(bank_book PUBLIC bank_model) add_library(owned_manifest STATIC src/owned_manifest.cpp) target_include_directories(owned_manifest PUBLIC src) +# --------------------------------------------------------------------------- +# 2g''') Pure prune_reconcile library — NO REAPER, NO SWELL, NO filesystem. The +# Phase R (Reclaim) Wave-1 safety-critical decision: given the files present +# in the bank folder, the union of files referenced across every bank, and the +# owned-file manifest, compute the orphan set (owned ∩ present) − referenced. +# Mirror of view_mode_model::reconcile one level down (files, not GUIDs). Small +# pure function; R2/R3 wrap the two ends (folder enumeration + deletion) in the +# shell. Standalone — depends only on the standard library. +# --------------------------------------------------------------------------- +add_library(prune_reconcile STATIC src/prune_reconcile.cpp) +target_include_directories(prune_reconcile PUBLIC src) + # --------------------------------------------------------------------------- # 2g) Pure realtime_record library — NO REAPER, NO SWELL. The M8 realtime-record # logic: capture scope + FX-tap point -> I_RECMODE / I_RECMODE_FLAGS values, @@ -338,6 +350,10 @@ add_executable(owned_manifest_tests tests/test_owned_manifest.cpp) target_link_libraries(owned_manifest_tests PRIVATE owned_manifest) add_test(NAME owned_manifest_tests COMMAND owned_manifest_tests) +add_executable(prune_reconcile_tests tests/test_prune_reconcile.cpp) +target_link_libraries(prune_reconcile_tests PRIVATE prune_reconcile bank_book) +add_test(NAME prune_reconcile_tests COMMAND prune_reconcile_tests) + add_executable(app_version_tests tests/test_app_version.cpp) target_link_libraries(app_version_tests PRIVATE app_version) add_test(NAME app_version_tests COMMAND app_version_tests) diff --git a/src/bank_book.cpp b/src/bank_book.cpp index bc0a1c3..56986ee 100644 --- a/src/bank_book.cpp +++ b/src/bank_book.cpp @@ -2,6 +2,7 @@ #include #include +#include // bank_book implementation. // @@ -321,6 +322,22 @@ bool BankBook::hashReferencedElsewhere(const std::string& hash, return false; } +std::vector BankBook::referencedPaths() const { + // Union across the whole book (pool first, then named banks in ordinal order — + // banks_ is kept ordinal-sorted). De-duplicate by exact string so a file a copy + // put in two banks appears once. Skip empty paths (they reference no file). + std::vector paths; + std::unordered_set seen; + for (const auto& b : banks_) { + for (const auto& s : b.index.all()) { + if (s.relativePath.empty()) continue; + if (seen.insert(s.relativePath).second) + paths.push_back(s.relativePath); + } + } + return paths; +} + // =========================================================================== // JSON — writer // =========================================================================== diff --git a/src/bank_book.h b/src/bank_book.h index 68e1268..d18af52 100644 --- a/src/bank_book.h +++ b/src/bank_book.h @@ -220,6 +220,17 @@ public: bool hashReferencedElsewhere(const std::string& hash, const std::string& exceptBankId) const; + // Every project-relative file path referenced by ANY bank in the book, pool + // included — the union across the whole book (Phase R, prune). This is the + // safety-critical referenced-set the prune core subtracts: a file referenced by + // any bank (INCLUDING via a copy into a second bank) appears here, so prune never + // reclaims it. Paths are returned VERBATIM (Sample.relativePath, exact strings — + // no normalization), first-seen order across banks in ordinal order then sample + // insertion order, and DE-DUPLICATED (one file referenced by N banks appears + // once). An empty relativePath is skipped (it references no file). Additive + // read-only query; adds no mutation and no coupling to Phase R. + std::vector referencedPaths() const; + // -- Query --------------------------------------------------------------- // The bank with `id`, or nullptr. Pointer invalidated by any mutating call. diff --git a/src/prune_reconcile.cpp b/src/prune_reconcile.cpp new file mode 100644 index 0000000..5c89ff5 --- /dev/null +++ b/src/prune_reconcile.cpp @@ -0,0 +1,36 @@ +#include "prune_reconcile.h" + +#include + +// prune_reconcile implementation — the one set-algebra computation, kept trivially +// auditable: build the referenced and owned lookup sets, then walk `present` once, +// keeping a path iff it is owned AND not referenced. Walking `present` (not owned) +// gives the ∩-present clause for free and yields output in folder-enumeration order. + +namespace reasampler { + +std::vector pruneOrphans(const std::vector& present, + const std::vector& referenced, + const std::vector& owned) { + // Exact-string membership — the model's canonical relative-path comparison + // (Sample.relativePath / OwnedFileManifest::contains). std::string hashes/compares + // byte-for-byte, so no normalization creeps in. + const std::unordered_set referencedSet(referenced.begin(), + referenced.end()); + const std::unordered_set ownedSet(owned.begin(), owned.end()); + + std::vector orphans; + std::unordered_set emitted; // de-dup repeated spellings in `present` + + for (const std::string& path : present) { + // (owned ∩ present) − referenced: present is the walk; owned and !referenced + // are the two membership tests; emitted guards against a duplicate `present`. + if (ownedSet.count(path) == 0) continue; // not our leaving — skip + if (referencedSet.count(path) != 0) continue; // some bank references it + if (!emitted.insert(path).second) continue; // already emitted + orphans.push_back(path); + } + return orphans; +} + +} // namespace reasampler diff --git a/src/prune_reconcile.h b/src/prune_reconcile.h new file mode 100644 index 0000000..c432fcd --- /dev/null +++ b/src/prune_reconcile.h @@ -0,0 +1,63 @@ +#pragma once +// prune_reconcile — the pure core of Phase R (Reclaim), Wave 1. The safety-critical +// "which files are orphans" decision, computed with NO filesystem I/O and NO REAPER +// types. The mirror of view_mode_model's reconcile(liveGuids), one level DOWN: it +// reconciles FILES ON DISK against REFERENCED FILES (the union across every bank), +// where reconcile reconciled membership entries against live tracks. +// +// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO +// vendor/ includes, NO filesystem calls. Standard library only. Unit-tested outside +// the DAW — this is the safety-critical part (it decides which bytes get deleted in +// R2/R3), so it is hard-tested here before any I/O exists. +// +// -- The one computation ------------------------------------------------------ +// +// orphans = (owned ∩ present) − referenced +// +// * present — files enumerated in the resolved current bank folder (R2 shell). +// * referenced — every project-relative path referenced by ANY bank in the book, +// pool included (union across the whole book — see BankBook:: +// referencedPaths). A file referenced by any bank — including via a +// COPY into a second bank — is NEVER an orphan (the prune null test). +// * owned — the owned-file manifest: the files the bank system itself created +// (OwnedFileManifest). A present-but-unowned (hand-dropped) file is +// NEVER reclaimed — prune reclaims only the system's own leavings. +// +// The three settled guardrails fall straight out of the set algebra: +// * ∩ present — never proposes deleting a file that is not on disk (an owned- +// but-absent manifest entry yields no orphan, no error). +// * ∩ owned — never a hand-dropped file (ownership attribution, fork R-D). +// * − referenced — never a file any bank references (union safety, prune null test). +// +// -- Path representation: EXACT-STRING match (safety-critical) ----------------- +// +// Every path in the model is a project-relative string compared VERBATIM: Sample. +// relativePath, OwnedFileManifest::contains (p == relativePath), and BankIndex all +// use raw std::string equality — no separator normalization, no case-folding, no +// trailing-slash trimming. This core MATCHES that convention exactly: it compares +// the raw strings the shell supplies. Feeding a consistent spelling across the three +// inputs is the R2 shell's contract (it enumerates the folder, unions the book, and +// reads the manifest against the SAME resolved current folder). Diverging from exact +// match here (e.g. case-insensitive compare) would be the unsafe direction — it could +// let one spelling of a referenced file be treated as an orphan under another. + +#include +#include + +namespace reasampler { + +// Computes the prune orphan set: (owned ∩ present) − referenced. +// +// Returns the subset of `present` that is BOTH owned AND unreferenced, in the ORDER +// they appear in `present` (deterministic output — mirror of the insertion-order +// determinism the index / manifest keep; the R2 dry-run reports a stable file list). +// Duplicate spellings within `present` are de-duplicated in the result (a folder +// enumeration yields distinct names, but the core does not rely on that). +// +// Pure: no I/O, no REAPER, no hidden state. All three inputs are project-relative +// path strings, compared by exact std::string equality (see header note). +std::vector pruneOrphans(const std::vector& present, + const std::vector& referenced, + const std::vector& owned); + +} // namespace reasampler diff --git a/tests/test_prune_reconcile.cpp b/tests/test_prune_reconcile.cpp new file mode 100644 index 0000000..e739fba --- /dev/null +++ b/tests/test_prune_reconcile.cpp @@ -0,0 +1,283 @@ +// Standalone tests for reasampler::pruneOrphans (Phase R, Wave 1) and the additive +// BankBook::referencedPaths() union query it consumes — no REAPER, no framework. +// +// The safety-critical computation: orphans = (owned ∩ present) − referenced. Every +// test below would FAIL if the set algebra were wrong (a missing ∩ present, a missing +// − referenced, or a non-union referenced-set) — see the negative assertions. +// +// Covers (brief-named): +// 1. Prune null test: all present files referenced -> empty orphan set. +// 2. Formula: orphans = (owned ∩ present) − referenced, mixed populations. +// 3. Copied file referenced by a SECOND bank survives (union semantics). +// 4. Present-but-unowned (hand-dropped) file is never reclaimed. +// 5. Edge cases: empty folder, empty book/referenced-set, empty manifest. +// 6. Owned-but-absent file (manifest entry, no file on disk) -> no orphan, no error. +// Plus: determinism (present-order output), duplicate-`present` de-dup, exact-string +// (non-normalizing) match, and the referencedPaths() union query directly. + +#include "../src/bank_book.h" +#include "../src/prune_reconcile.h" + +#include +#include +#include +#include + +using namespace reasampler; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// --- helpers ----------------------------------------------------------------- + +static bool contains(const std::vector& v, const std::string& s) { + return std::find(v.begin(), v.end(), s) != v.end(); +} + +// A minimal Sample carrying just the fields prune cares about (id + relativePath). +static Sample sampleAt(const std::string& id, const std::string& relPath, + const std::string& hash = "") { + Sample s; + s.id = id; + s.relativePath = relPath; + s.contentHash = hash; + return s; +} + +// --- 1. prune null test: all present referenced -> empty -------------------- + +static void testNullTestAllReferenced() { + const std::vector present = {"bank/a.wav", "bank/b.wav", "bank/c.wav"}; + const std::vector owned = {"bank/a.wav", "bank/b.wav", "bank/c.wav"}; + const std::vector referenced= {"bank/a.wav", "bank/b.wav", "bank/c.wav"}; + + const auto orphans = pruneOrphans(present, referenced, owned); + // The core invariant: a folder whose every file is referenced deletes NOTHING. + CHECK(orphans.empty()); +} + +// --- 2. formula: (owned ∩ present) − referenced, mixed populations ---------- + +static void testFormulaMixedPopulations() { + // present : a b c d (on disk) + // owned : a b d e (system created; e is owned-but-absent) + // ref'd : a (still in an index) + // expected orphans: owned ∩ present = {a,b,d}; minus referenced {a} = {b,d} + // - c present but NOT owned -> excluded (hand-dropped) + // - e owned but NOT present -> excluded (no file on disk) + // - a owned+present but referenced -> excluded + const std::vector present = {"bank/a.wav", "bank/b.wav", + "bank/c.wav", "bank/d.wav"}; + const std::vector owned = {"bank/a.wav", "bank/b.wav", + "bank/d.wav", "bank/e.wav"}; + const std::vector referenced = {"bank/a.wav"}; + + const auto orphans = pruneOrphans(present, referenced, owned); + CHECK(orphans.size() == 2); + CHECK(contains(orphans, "bank/b.wav")); + CHECK(contains(orphans, "bank/d.wav")); + // Negative assertions — each would fire if a clause of the formula were dropped: + CHECK(!contains(orphans, "bank/a.wav")); // − referenced + CHECK(!contains(orphans, "bank/c.wav")); // ∩ owned (hand-dropped) + CHECK(!contains(orphans, "bank/e.wav")); // ∩ present (absent) +} + +// --- 3. copied file referenced by a second bank survives (union) ------------ + +static void testCopiedFileSurvivesViaUnion() { + // A file present + owned, whose ONLY index reference lives in a second (non-active) + // bank via a copy. The union across the whole book must keep it out of the orphan + // set. Built through the real BankBook so referencedPaths()'s union is exercised. + BankBook book; + book.createBank("b1", "Drums"); + // Add to the pool, then copy into b1 (same file referenced by two banks). + book.index(std::string(kPoolBankId))->add(sampleAt("s1", "bank/shared.wav", "h1")); + CHECK(book.copySample("s1", kPoolBankId, "b1") == TransferResult::Copied); + + const auto referenced = book.referencedPaths(); + // Union de-dups: one path though two banks reference it. + CHECK(referenced.size() == 1); + CHECK(referenced[0] == "bank/shared.wav"); + + const std::vector present = {"bank/shared.wav"}; + const std::vector owned = {"bank/shared.wav"}; + const auto orphans = pruneOrphans(present, referenced, owned); + // Referenced by b1 (the copy) -> never an orphan even if the pool later dropped it. + CHECK(orphans.empty()); +} + +// A sharper union test: remove the file from the pool but keep the copy in b1. The +// file must STILL survive (referenced by b1 alone) — the whole point of union safety. +static void testUnionSurvivesWhenOnlySecondBankReferences() { + BankBook book; + book.createBank("b1", "Drums"); + book.index(std::string(kPoolBankId))->add(sampleAt("s1", "bank/shared.wav", "h1")); + CHECK(book.copySample("s1", kPoolBankId, "b1") == TransferResult::Copied); + // Drop the pool's reference; b1 still references the file. + CHECK(book.removeSample("s1", kPoolBankId) == RemoveResult::Removed); + + const auto referenced = book.referencedPaths(); + CHECK(referenced.size() == 1); + CHECK(contains(referenced, "bank/shared.wav")); + + const auto orphans = pruneOrphans({"bank/shared.wav"}, referenced, {"bank/shared.wav"}); + CHECK(orphans.empty()); // still referenced by b1 -> not reclaimable +} + +// --- 4. present-but-unowned (hand-dropped) file never reclaimed ------------- + +static void testHandDroppedNeverReclaimed() { + // A file on disk that no index references AND the manifest does not own. It is a + // hand-dropped file — prune must never reclaim it (ownership attribution, fork R-D). + const std::vector present = {"bank/user_drop.wav", "bank/ours.wav"}; + const std::vector owned = {"bank/ours.wav"}; // NOT user_drop + const std::vector referenced = {}; // neither referenced + + const auto orphans = pruneOrphans(present, referenced, owned); + CHECK(orphans.size() == 1); + CHECK(contains(orphans, "bank/ours.wav")); // ours, unreferenced -> orphan + CHECK(!contains(orphans, "bank/user_drop.wav")); // hand-dropped -> protected +} + +// --- 5. edge cases: empty folder / book / manifest -------------------------- + +static void testEmptyFolder() { + // No files present -> nothing to reclaim regardless of owned/referenced. + const auto orphans = pruneOrphans({}, {"bank/a.wav"}, {"bank/a.wav"}); + CHECK(orphans.empty()); +} + +static void testEmptyReferencedSet() { + // Empty book (nothing referenced): every present ∩ owned file is an orphan. + const std::vector present = {"bank/a.wav", "bank/b.wav"}; + const std::vector owned = {"bank/a.wav", "bank/b.wav"}; + const auto orphans = pruneOrphans(present, /*referenced*/ {}, owned); + CHECK(orphans.size() == 2); + CHECK(contains(orphans, "bank/a.wav")); + CHECK(contains(orphans, "bank/b.wav")); +} + +static void testEmptyManifest() { + // Empty manifest (owns nothing): nothing is reclaimable even if present+unreferenced. + const std::vector present = {"bank/a.wav", "bank/b.wav"}; + const std::vector referenced = {}; + const auto orphans = pruneOrphans(present, referenced, /*owned*/ {}); + CHECK(orphans.empty()); +} + +static void testAllEmpty() { + const auto orphans = pruneOrphans({}, {}, {}); + CHECK(orphans.empty()); +} + +// --- 6. owned-but-absent file -> no orphan, no error ------------------------ + +static void testOwnedButAbsentNoOrphan() { + // A manifest entry whose file is NOT on disk (e.g. deleted out-of-band). It must + // yield no orphan entry (∩ present excludes it) and no error/crash. + const std::vector present = {"bank/here.wav"}; + const std::vector owned = {"bank/here.wav", "bank/gone.wav"}; + const std::vector referenced = {}; + const auto orphans = pruneOrphans(present, referenced, owned); + CHECK(orphans.size() == 1); + CHECK(contains(orphans, "bank/here.wav")); + CHECK(!contains(orphans, "bank/gone.wav")); // owned but absent -> not an orphan +} + +// --- determinism + duplicate-present + exact-string match ------------------- + +static void testOutputPreservesPresentOrder() { + // Output order follows `present` order (deterministic dry-run file list), NOT the + // owned/referenced order. + const std::vector present = {"bank/z.wav", "bank/y.wav", "bank/x.wav"}; + const std::vector owned = {"bank/x.wav", "bank/y.wav", "bank/z.wav"}; + const std::vector referenced = {}; + const auto orphans = pruneOrphans(present, referenced, owned); + CHECK(orphans.size() == 3); + CHECK(orphans[0] == "bank/z.wav"); + CHECK(orphans[1] == "bank/y.wav"); + CHECK(orphans[2] == "bank/x.wav"); +} + +static void testDuplicatePresentDeduped() { + // A defensive property: a duplicated `present` spelling appears once in the output. + const std::vector present = {"bank/a.wav", "bank/a.wav"}; + const std::vector owned = {"bank/a.wav"}; + const auto orphans = pruneOrphans(present, /*referenced*/ {}, owned); + CHECK(orphans.size() == 1); + CHECK(orphans[0] == "bank/a.wav"); +} + +static void testExactStringMatchNotNormalized() { + // Safety-critical: the core matches VERBATIM (mirror of the model's exact-string + // convention). A referenced file spelled with backslashes is a DIFFERENT string + // from the forward-slash present spelling — the core does NOT normalize them to + // equal. This documents the invariant: consistent spelling is the R2 shell's + // contract. If the core silently normalized, this would (wrongly) treat the file + // as referenced and the assertion below would fail. + const std::vector present = {"bank/a.wav"}; + const std::vector owned = {"bank/a.wav"}; + const std::vector referenced = {"bank\\a.wav"}; // different spelling + const auto orphans = pruneOrphans(present, referenced, owned); + CHECK(orphans.size() == 1); // not matched -> still an orphan + CHECK(orphans[0] == "bank/a.wav"); +} + +// --- BankBook::referencedPaths() union query directly ----------------------- + +static void testReferencedPathsUnionAcrossBanksAndPool() { + BankBook book; + book.createBank("b1", "Drums"); + book.createBank("b2", "Bass"); + book.index(std::string(kPoolBankId))->add(sampleAt("p1", "bank/pool.wav", "hp")); + book.index("b1")->add(sampleAt("d1", "bank/drum.wav", "hd")); + book.index("b2")->add(sampleAt("s1", "bank/bass.wav", "hb")); + + const auto refs = book.referencedPaths(); + // Union includes the pool AND every named bank. + CHECK(refs.size() == 3); + CHECK(contains(refs, "bank/pool.wav")); + CHECK(contains(refs, "bank/drum.wav")); + CHECK(contains(refs, "bank/bass.wav")); + // Pool-first ordinal order. + CHECK(refs[0] == "bank/pool.wav"); +} + +static void testReferencedPathsEmptyBook() { + BankBook book; // pool only, no samples + CHECK(book.referencedPaths().empty()); +} + +static void testReferencedPathsSkipsEmptyPath() { + // A sample with an empty relativePath references no file — it must not appear. + BankBook book; + book.index(std::string(kPoolBankId))->add(sampleAt("p1", "", "hp")); + book.index(std::string(kPoolBankId))->add(sampleAt("p2", "bank/real.wav", "hr")); + const auto refs = book.referencedPaths(); + CHECK(refs.size() == 1); + CHECK(refs[0] == "bank/real.wav"); +} + +int main() { + testNullTestAllReferenced(); + testFormulaMixedPopulations(); + testCopiedFileSurvivesViaUnion(); + testUnionSurvivesWhenOnlySecondBankReferences(); + testHandDroppedNeverReclaimed(); + testEmptyFolder(); + testEmptyReferencedSet(); + testEmptyManifest(); + testAllEmpty(); + testOwnedButAbsentNoOrphan(); + testOutputPreservesPresentOrder(); + testDuplicatePresentDeduped(); + testExactStringMatchNotNormalized(); + testReferencedPathsUnionAcrossBanksAndPool(); + testReferencedPathsEmptyBook(); + testReferencedPathsSkipsEmptyPath(); + + if (g_fail == 0) std::printf("prune_reconcile_tests: ALL PASS\n"); + else std::printf("prune_reconcile_tests: %d FAILURE(S)\n", g_fail); + return g_fail == 0 ? 0 : 1; +}