Merge R1: prune-reconcile pure core

This commit is contained in:
2026-07-26 18:08:23 -04:00
6 changed files with 426 additions and 0 deletions
+16
View File
@@ -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)
+17
View File
@@ -2,6 +2,7 @@
#include <algorithm>
#include <climits>
#include <unordered_set>
// bank_book implementation.
//
@@ -321,6 +322,22 @@ bool BankBook::hashReferencedElsewhere(const std::string& hash,
return false;
}
std::vector<std::string> 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<std::string> paths;
std::unordered_set<std::string> 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
// ===========================================================================
+11
View File
@@ -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<std::string> referencedPaths() const;
// -- Query ---------------------------------------------------------------
// The bank with `id`, or nullptr. Pointer invalidated by any mutating call.
+36
View File
@@ -0,0 +1,36 @@
#include "prune_reconcile.h"
#include <unordered_set>
// 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<std::string> pruneOrphans(const std::vector<std::string>& present,
const std::vector<std::string>& referenced,
const std::vector<std::string>& 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<std::string> referencedSet(referenced.begin(),
referenced.end());
const std::unordered_set<std::string> ownedSet(owned.begin(), owned.end());
std::vector<std::string> orphans;
std::unordered_set<std::string> 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
+63
View File
@@ -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 <string>
#include <vector>
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<std::string> pruneOrphans(const std::vector<std::string>& present,
const std::vector<std::string>& referenced,
const std::vector<std::string>& owned);
} // namespace reasampler
+283
View File
@@ -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 <algorithm>
#include <cstdio>
#include <string>
#include <vector>
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<std::string>& 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<std::string> present = {"bank/a.wav", "bank/b.wav", "bank/c.wav"};
const std::vector<std::string> owned = {"bank/a.wav", "bank/b.wav", "bank/c.wav"};
const std::vector<std::string> 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<std::string> present = {"bank/a.wav", "bank/b.wav",
"bank/c.wav", "bank/d.wav"};
const std::vector<std::string> owned = {"bank/a.wav", "bank/b.wav",
"bank/d.wav", "bank/e.wav"};
const std::vector<std::string> 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<std::string> present = {"bank/shared.wav"};
const std::vector<std::string> 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<std::string> present = {"bank/user_drop.wav", "bank/ours.wav"};
const std::vector<std::string> owned = {"bank/ours.wav"}; // NOT user_drop
const std::vector<std::string> 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<std::string> present = {"bank/a.wav", "bank/b.wav"};
const std::vector<std::string> 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<std::string> present = {"bank/a.wav", "bank/b.wav"};
const std::vector<std::string> 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<std::string> present = {"bank/here.wav"};
const std::vector<std::string> owned = {"bank/here.wav", "bank/gone.wav"};
const std::vector<std::string> 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<std::string> present = {"bank/z.wav", "bank/y.wav", "bank/x.wav"};
const std::vector<std::string> owned = {"bank/x.wav", "bank/y.wav", "bank/z.wav"};
const std::vector<std::string> 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<std::string> present = {"bank/a.wav", "bank/a.wav"};
const std::vector<std::string> 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<std::string> present = {"bank/a.wav"};
const std::vector<std::string> owned = {"bank/a.wav"};
const std::vector<std::string> 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;
}