#pragma once // bank_book — the pure core of the multi-bank phase (Phase B), deliberately free // of any REAPER type so it compiles and unit-tests OUTSIDE the DAW. It is the // third instance of the same "pure registry + JSON round-trip, unit-tested outside // the DAW" pattern as bank_model and view_mode_model. // // PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO // vendor/ includes. Standard library only. // // -- What it is -------------------------------------------------------------- // // An ordered registry of banks. Each bank = { stable id, display name, ordinal, // BankIndex }. The book WRAPS N BankIndex instances — bank_model / BankIndex are // UNTOUCHED (additive: no bankId on Sample). Movement of samples between banks is // index-only (remove from source's BankIndex, add to destination's); files never // relocate — banks are logical groupings over one shared file pool. // // -- The pool (privileged, not special-cased) -------------------------------- // // Structurally the pool is bank-zero — one Bank among many, seeded on construction // with a fixed id (kPoolBankId) and fixed display name (kPoolBankName), ordinal 0. // Semantically it is privileged, and the privileges are enforced HERE in the pure // rules layer (CONTEXT.md §Multi-bank guardrail — not deferred to a shell): // * always exists (seeded on construction; the book never reaches zero banks) // * un-deletable (deleteBank rejects the pool) // * un-renamable (renameBank rejects the pool) // * un-evacuable (evacuate rejects the pool — the pool is evacuation's // destination, not a source) // // -- Id minting is the CALLER'S job (design decision) ------------------------ // // createBank takes a caller-supplied stable id, mirroring bank_model's "id // assigned by the caller" and view_mode_model's mode ids. The pure core has no // REAPER genGuid / RNG and deliberately introduces none: a fake in-model id source // would not be a real GUID anyway, and keeping ids caller-supplied lets the B2 // shell mint a genuine REAPER GUID while the model stays pure and deterministically // testable. The model still enforces the invariants: non-empty, unique, not the // reserved pool id. #include #include #include #include #include "bank_model.h" namespace reasampler { // The pool's fixed identity. The id is reserved: createBank rejects it, and the // pool is always bank-zero. The name is fixed: renameBank rejects the pool. inline constexpr const char* kPoolBankId = "pool"; inline constexpr const char* kPoolBankName = "Pool"; // SlotMap — the L7 gap-preserving display-position carrier for ONE bank (F2 settled: // plain interchangeable slots, NOT M9 fixed/addressable slots). A slot is just a // display position a sample id occupies; the map is sample id -> slot (>= 0). Gaps // are first-class: a bank may have a sample at slot 1 with slot 0 empty (an empty // first row above an occupied second row). At most one id per slot (a slot is never // double-occupied) and at most one slot per id (an id sits in exactly one place). // // Position lives HERE, not on Sample (CLAUDE.md wrapping discipline): a copy of one // sample into two banks may sit at different slots, so position is a per-bank display // concern owned by the bank's membership. bank_model / Sample stay untouched. // // PURE: standard library only. Hard-tested to the bar of BankIndex's round-trip. class SlotMap { public: // The slot an id occupies, or -1 if the id is not mapped. O(N). int slotOf(const std::string& id) const; // The id occupying `slot`, or "" if the slot is empty. O(N). std::string idAt(int slot) const; // The highest occupied slot, or -1 when the map is empty. Defines the append // frontier and (with trailing-empty trim) the content extent. int maxSlot() const; // Ids in ASCENDING slot order (the deterministic display order). Empty slots // produce no entry — the caller iterates occupants; sparse layout is a draw // concern that reads slotOf/idAt, not this list. std::vector orderedIds() const; // Places `id` at the next free slot after the last occupied one (append). If the // id is already mapped it is first removed (leaving its old slot empty), then // appended — an append never fills an earlier gap. No-op guard: empty id ignored. void append(const std::string& id); // Drops `id`'s mapping, LEAVING ITS SLOT EMPTY (no re-pack) so every other id // keeps its position. Returns true if the id was mapped. bool remove(const std::string& id); // Moves `id` to `targetSlot`, gap-preserving (F3 reorder semantics): // * target slot EMPTY -> `id` moves there; its old slot is left empty. // * target slot OCCUPIED -> insert-before-and-shift: `id` takes targetSlot and // every occupant at slot >= targetSlot (except `id` itself) shifts up by one, // preserving their relative order and never colliding. Matches file-manager // reorder. Interior gaps between shifted occupants are preserved as-is // (shift is +1 on each occupant, so the gap structure above the target is kept). // * negative targetSlot is clamped to 0. // Returns false (no mutation) if `id` is not mapped. Deterministic. bool reorder(const std::string& id, int targetSlot); // Rebuilds the map densely from `ids` in the given order (slot i = ids[i]), // dropping any prior state. The migration path: a pre-L7 bank with no persisted // slot data is seeded from its BankIndex insertion order, densely packed (no gaps), // so it is visually identical on first post-L7 load. Empty/duplicate ids skipped. void resetDense(const std::vector& ids); // Drops any mapping whose id is NOT in `liveIds` (a stale marker whose sample left // the index) and appends any live id that has NO mapping yet (a sample the index // gained out-of-band). Slots of surviving ids are untouched (gaps preserved). Keeps // the map consistent with the bank's membership without a re-pack. Deterministic: // orphan appends follow `liveIds` order. void reconcile(const std::vector& liveIds); bool empty() const { return entries_.empty(); } std::size_t size() const { return entries_.size(); } bool operator==(const SlotMap& o) const; // JSON fragment (an array of {id, slot} objects, ascending slot). Emitted as the // bank envelope's "slots" member by BankBook::serialize; parsed back by its parser. // Round-trips losslessly with the rest of the bank. std::string serialize() const; // Builds a map from explicit (id, slot) pairs parsed from persisted JSON. Enforces // the map invariants defensively against a hand-edited blob: a duplicate id keeps // its FIRST occurrence; a slot already taken by a kept id drops the later pair // (never double-occupies); an empty id or negative slot is dropped. The result is // sorted ascending by slot. reconcile() against live membership runs afterward, so // a lossy repair here degrades gracefully rather than corrupting lookup. static SlotMap fromEntries(const std::vector>& pairs); private: struct Entry { std::string id; int slot = 0; bool operator==(const Entry& o) const { return id == o.id && slot == o.slot; } }; std::vector entries_; // kept sorted ascending by slot (invariant) void sortBySlot(); }; // One bank: a stable id, a display name, an ordinal (tab/display order), and its // own BankIndex. The pool is the bank whose id == kPoolBankId. struct Bank { std::string id; // stable, persisted; the pool's is kPoolBankId std::string displayName; // mutable for named banks; fixed "Pool" for the pool int ordinal = 0; // display order; pool is 0, named banks 1..N BankIndex index; // this bank's samples SlotMap slots; // L7 display positions of this bank's samples (gap-preserving) bool isPool() const { return id == kPoolBankId; } bool operator==(const Bank& o) const { return id == o.id && displayName == o.displayName && ordinal == o.ordinal && index == o.index && slots == o.slots; } }; // Outcome of a cross-bank sample move/copy. Mirrors AddResult's honesty: the op // reports what happened rather than silently mutating on a bad request. // - Moved / Copied: the sample was transferred to the destination as a new entry. // - Collapsed: the destination already held the hash; it collapsed onto the // existing entry (a no-op add on the destination side). For a // MOVE the source entry is STILL removed; for a COPY the source // entry is (as always) retained. // - RejectedUnknownBank: a source or destination id named no bank. // - RejectedSampleAbsent: the sample id was not in the source bank. // - RejectedSameBank: source and destination were the same bank (no-op). enum class TransferResult { Moved, Copied, Collapsed, RejectedUnknownBank, RejectedSampleAbsent, RejectedSameBank, }; // Scope of a sample-remove (fork R-A, settled 2026-07-24). ThisBank is the default // and the ONLY behavior surfaced in the UI/action layer; AllBanks is a latent seam — // live and tested at the model level, promotable later behind this parameter without // a rewrite, but never wired to an affordance in B5. // - ThisBank: drop the entry from the one named source bank only. A same-hash entry // in another bank survives (no cross-bank cascade — dedup is per-bank). // - AllBanks: drop the sample's entry from EVERY bank that holds the source id // ("purge from the library"). Latent; unsurfaced. enum class RemoveScope { ThisBank, AllBanks, }; // Outcome of BankBook::removeSample. Mirrors TransferResult's honesty: the op reports // what happened rather than silently mutating on a bad request. // - Removed: at least one index entry was dropped. // - RejectedUnknownBank: the source bank id named no bank (ThisBank scope only). // - RejectedSampleAbsent: the sample id was in no bank in scope (nothing removed). enum class RemoveResult { Removed, RejectedUnknownBank, RejectedSampleAbsent, }; // An ordered registry of banks with the pool seeded as bank-zero, per-bank sample // indices, an active-bank pointer, and lossless JSON round-trip. The heart of the // multi-bank phase — mirror of bank_model / view_mode_model. class BankBook { public: BankBook(); // seeds the pool (id kPoolBankId, name kPoolBankName, ordinal 0); // active bank = pool; zero named banks. // -- Bank lifecycle ------------------------------------------------------ // Creates a named bank with the caller-supplied stable id and display name, // assigning the next ordinal. Rejects (returns false, no mutation) an empty id, // a duplicate id, the reserved pool id, or a display name that duplicates an // existing bank's name (including the pool's "Pool"). Display-name uniqueness is // trimmed + case-insensitive (ASCII): "Drums", "drums", and " Drums " collide. bool createBank(const std::string& id, const std::string& displayName); // Renames a named bank. Rejects (false, no mutation) an unknown id, the pool, or a // target name already used by a DIFFERENT bank (trimmed + case-insensitive, as // createBank). Renaming a bank to its own current name is a no-op success. bool renameBank(const std::string& id, const std::string& displayName); // Deletes a NAMED bank, removing it (and its member index entries) from the // registry. Files are a shell/prune concern and are NOT touched here. Rejects // (false, no mutation) an unknown id or the pool. Remaining banks' ordinals are // compacted so the pool stays 0 and named banks stay contiguous 1..N. If the // deleted bank was active, the active bank falls back to the pool. bool deleteBank(const std::string& id); // Reorders a NAMED bank to `newOrdinal` (clamped into the named-bank range), // shifting the others to keep ordinals contiguous. The pool is pinned at 0 and // cannot be reordered. Rejects (false, no mutation) an unknown id or the pool. bool reorderBank(const std::string& id, int newOrdinal); // Moves EVERY member of a named bank into the pool (index-only, observing the // same destination-collapse-by-hash as a move), leaving the bank empty. Rejects // (false, no mutation) an unknown id or the pool (the pool is the destination, // never a source). Returns true on success even if the bank was already empty. bool evacuate(const std::string& id); // -- Active bank --------------------------------------------------------- // The active bank's id (the capture target). Defaults to the pool. const std::string& activeBankId() const { return activeBankId_; } // Sets the active bank. Rejects (returns false, no change) an id that names no // bank — an invalid set never corrupts state. bool setActiveBank(const std::string& id); // The active bank's BankIndex — the index the capture layer adds to. Always // valid (the active id always names a live bank; it falls back to the pool). BankIndex& activeIndex(); const BankIndex& activeIndex() const; // -- Sample movement (index-only; files never relocate) ------------------ // Moves a sample by id from `fromBankId` to `toBankId`: removes it from the // source index and adds it to the destination (observing destination // collapse-by-hash). See TransferResult for the full outcome set. TransferResult moveSample(const std::string& sampleId, const std::string& fromBankId, const std::string& toBankId); // Copies a sample by id from `fromBankId` to `toBankId`: the source entry is // retained, the destination gains it (observing destination collapse-by-hash). // Same hash may then live in both banks — cross-bank dedup is NOT enforced. TransferResult copySample(const std::string& sampleId, const std::string& fromBankId, const std::string& toBankId); // -- Sample removal (index-only; the file is NEVER touched — orphaned until prune) -- // Drops a sample's index entry (the sample-level sibling of move/copy/evacuate). // Index-only and non-destructive to the file: a last-reference remove leaves the // file on disk, orphaned until Phase R prune — remove NEVER deletes bytes. // // Scope (fork R-A): ThisBank (default, the only surfaced verb) drops the entry from // `fromBankId` alone; AllBanks (latent seam) drops the sample id from every bank // that holds it. See RemoveResult for the outcome set. // * ThisBank: RejectedUnknownBank if `fromBankId` names no bank; RejectedSampleAbsent // if that bank does not hold the id; Removed on a drop. // * AllBanks: `fromBankId` is ignored (the id is purged book-wide); // RejectedSampleAbsent if NO bank held the id; Removed otherwise. // No mutation occurs on any Rejected outcome (no-op guardrail for the undo layer). RemoveResult removeSample(const std::string& sampleId, const std::string& fromBankId, RemoveScope scope = RemoveScope::ThisBank); // -- Sample display order (L7; index membership untouched) --------------- // The bank's sample ids in DISPLAY (slot) order — the deterministic order the grid // iterates, sourced from the bank's SlotMap. Reconciles the map against live index // membership first (drops stale markers, appends unmapped samples densely), so a // freshly-migrated or out-of-band-mutated bank always yields a complete order. An // unknown bank id yields an empty vector. Const-logical but reconciles lazily, so // it is a non-const member. std::vector orderedSampleIds(const std::string& bankId); // Ensures every bank's SlotMap is consistent with its index membership: seeds a // map that has NO overlap with its index from insertion order (the pre-L7 migration // default — dense, no gaps), and reconciles a partially-populated map (drop stale, // append unmapped). Idempotent. Called after deserialize and after any capture/ // transfer that added samples out-of-band of the L7 reorder path. void reconcileSlots(); // Reorders sample `id` within `bankId` to `targetSlot` (gap-preserving; see // SlotMap::reorder). INDEX-ONLY of positions — the sample's membership, file, and // metadata are untouched (capture != placement holds). Reconciles the bank's slots // first so the target space is complete. Returns false (no mutation) on an unknown // bank or an id the bank does not hold. bool reorderSample(const std::string& id, const std::string& bankId, int targetSlot); // Alt-replace (L7 F3): the dragged sample `newId` (already a member of `bankId`) // takes the slot of the occupant `oldId`, and `oldId` is REMOVED from `bankId`'s // index (index-only, same semantics as removeSample ThisBank — the file stays on // disk; owned-manifest/prune govern bytes; hashReferencedElsewhere handles the // last-reference case). Position of the slot is preserved; only its occupant changes. // // POOL GUARD (settled): the index-removal of `oldId` passes the SAME guard the // remove verb applies — removeSample(oldId, bankId, ThisBank) must return Removed. // For the pool this is permitted whenever the occupant exists (per-sample removal // is not a pool privilege violation — the pool's guards are un-delete/rename/evacuate, // never per-sample remove). If the removal would be rejected (occupant absent), the // whole replace is rejected: false, NO mutation (neither the index nor the slots // change), so the shell can fall back to the default insert-shift or a no-op. // Rejects (false, no mutation) an unknown bank, a `newId`/`oldId` the bank does not // hold, or `newId == oldId`. NEVER touches disk; introduces no new deletion authority. bool replaceSample(const std::string& newId, const std::string& oldId, const std::string& bankId); // Refreshes a sample IN PLACE wherever it lives in the book (M10 re-capture): // finds the bank holding `sampleId` and replaces its entry with `updated` // (order-preserving, no dedup — see BankIndex::updateInPlace). Scans banks in // ordinal order and updates the FIRST holder (a sample id is unique within a // bank; the same id living in two banks via copy would update the earliest, which // is acceptable — re-capture operates on the panel's focused single selection). // Returns false (no mutation) if no bank holds the id or the replacement's path // is absolute. Index-only and non-destructive to the timeline. bool updateSampleInPlace(const std::string& sampleId, const Sample& updated); // Reference-count query backing the confirm-on-last-reference guardrail: does any // bank OTHER than `exceptBankId` still hold an entry whose contentHash == `hash`? // // Identity is the CONTENT HASH, not the file path: hash is the canonical dedup key // the whole model already reasons in (findByHash / collapse-by-hash), and two // entries that share content share one file — so "some other bank still references // this hash" is exactly "removing here does not orphan the file." An EMPTY hash is // never matched (it does not participate in dedup, mirroring findByHash), so an // empty-hash sample reads as referenced-nowhere-else — the safe, confirm-eliciting // direction (we cannot prove another bank shares an unhashed file). 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. Bank* bank(const std::string& id); const Bank* bank(const std::string& id) const; // The bank's BankIndex by id, or nullptr. Convenience over bank()->index. BankIndex* index(const std::string& id); const BankIndex* index(const std::string& id) const; // The pool (always present). Never null. Bank& pool(); const Bank& pool() const; // All banks in ordinal order (pool first). The pool is always banks()[0]. const std::vector& banks() const { return banks_; } std::size_t size() const { return banks_.size(); } // >= 1 (the pool) bool operator==(const BankBook& o) const { return banks_ == o.banks_ && activeBankId_ == o.activeBankId_; } // -- Persistence --------------------------------------------------------- // Serializes the whole book to a JSON string (lossless round-trip): the pool // folded in as bank-zero + named banks + per-bank indices + ordinals + active // id. deserialize(serialize(x)) == x. std::string serialize() const; // Parses a book JSON produced by serialize(). std::nullopt on malformed input. // // LEGACY MIGRATION: a bare legacy bank_index JSON (the pre-multi-bank shape, an // object with a "samples" array and no "banks" key) is promoted into the pool's // index, yielding a book of { pool } with zero named banks — one-way, lossless. // After migration the book blob is authoritative (the caller persists the book // shape going forward; the legacy key is retired by the B2 shell). static std::optional deserialize(const std::string& json); // Resolve a BankBook from the two persisted ext-state values a project may carry: // the authoritative `banks` blob and the retired-but-possibly-present legacy // `bank_index` blob. The persist shell (B2) hands both raw strings straight here so // the load-source decision stays REAPER-free and unit-tested. Precedence: // 1. non-empty `banksJson` present -> deserialize it (authoritative). If it is // MALFORMED, do NOT silently fall back to the legacy blob — a corrupt `banks` // blob is an error, not an absence; return an empty book so a stale legacy key // can never resurrect a superseded single-bank state over a broken book. // 2. else non-empty `legacyJson` -> deserialize it (one-way pool migration). // 3. else (both absent/empty) -> a fresh empty book (pool only). // Never returns nullopt: an unloadable input degrades to the empty book (matching // the shell's existing "malformed -> ignore, start empty" behaviour), so the caller // has one branchless install path. static BankBook loadFromPersisted(const std::string& banksJson, const std::string& legacyJson); private: std::vector banks_; // ordinal order; banks_[0] is always the pool std::string activeBankId_; // always names a live bank; defaults to pool // True if a bank OTHER than `exceptId` already carries `name`'s uniqueness key // (trimmed + case-insensitive, ASCII). Backs the create/rename uniqueness check; // pass exceptId=id to let a bank keep (or re-case/-space) its own name. bool displayNameTaken(const std::string& name, const std::string& exceptId) const; // Re-sorts banks_ by ordinal (pool pinned first) and rewrites ordinals to a // contiguous 0..N-1 so the pool is 0 and named banks are 1..N. Called after any // structural change (create / delete / reorder). void normalizeOrdinals(); // Replaces the book's banks with a parsed set, normalizes ordinals, and resolves // the active bank (falling back to the pool if the id names no bank). Used only // by deserialize; kept private so the public surface stays create/rename/etc. void adoptBanks(std::vector&& banks, const std::string& activeBank); }; // The next bank id to activate when cycling the active bank forward, in ordinal // order (the ids arrive pool-first, named 1..N, matching banks()). Wraps: the id // after the last returns the first (pool → named → … → pool). This is the pure // decision behind the "cycle active bank" action — the shell reads the book's // ordered bank ids + current active id, asks for the next, and activates it. // * empty list -> "" (nothing to cycle to) // * single id (pool-only) -> that id (a one-bank book stays put) // * currentBankId not present -> the first id (a sane home to jump to) // Exposed as a free function (not a BankBook member) so it is unit-testable against // a bare id vector without a full book. Mirror of view_mode_model's nextModeId. std::string nextBankId(const std::vector& orderedBankIds, const std::string& currentBankId); } // namespace reasampler