]> git.hungrycats.org Git - bees/commitdiff
scan_next: unify the two space-debt input builders into one
authorZygo Blaxell <bees@furryterror.org>
Wed, 8 Jul 2026 19:58:52 +0000 (15:58 -0400)
committerZygo Blaxell <bees@furryterror.org>
Sat, 5 Sep 2026 04:04:13 +0000 (00:04 -0400)
The four do-nothing space-debt input fields were computed twice: once in
scan_next_covering_debt (the search's covering selection) and once in
scan_next_dst_debt (the plan cost that drives orientation selection).  The two
had to mirror each other field-for-field, and when they drifted — m_refs_created
present in the search builder but missing from the plan builder — ref-op-cost
silently went inert in selection.  A comment claimed the two were
reconciliation-checked, but no such check existed.

Extract the arithmetic into one shared builder, scan_next_debt_fields(), that
takes raw scalars and returns a CostReport with the four fields set.  Both paths
now marshal their inputs into it:
- scan_next_covering_debt() becomes a thin wrapper that prices the result.
- scan_next_dst_debt() returns the builder's CostReport directly; the
  ScanNextDstDebt mirror struct is deleted (Plan A/B read the same field names,
  so their cost assembly is unchanged).

The extents_delta test collapses to copy_ops > 0 ? +1 : -1; for a complete
covering — the only input either caller passes — copy_ops > 0 iff unmatched data
remains, so this equals the plan path's former bad_blocks()==0 form.  With one
source of truth a term can no longer exist in one path but not the other.

test-bees-plan gains test_debt_fields_shared_builder, pinning the field
arithmetic directly (including refs_created at F=1) and asserting the builder
and scan_next_covering_debt agree for the same covering.

Assisted-by: Claude-Code:claude-opus-4-8
src/bees-plan.cc
src/bees-plan.h
src/bees-scan-next.cc
test/test-bees-plan.cc

index fe37bba3599d1fb25b5cf1f05892b6e154ee4dd1..cf2141a4d30ebab5c7fbb7ca047fce1d995c11be 100644 (file)
@@ -60,58 +60,49 @@ operator<<(ostream &os, const CostReport &c)
 // -----------------------------------------------------------------------------
 // Shortest-path Plan A coverage search (do-nothing-plan.md "Next Step 2").
 
-double
-scan_next_covering_debt(const PlanSearchInputs &in,
-       const BeesRewritePolicy &policy,
-       size_t dedupe_ops, size_t copy_ops, size_t matched_blocks)
+CostReport
+scan_next_debt_fields(uint64_t dst_ref_count, size_t frag_count, size_t copy_ops,
+       bool dst_compressed, uint64_t dst_phys_size,
+       uint64_t good_blocks, uint64_t clone_alignment)
 {
-       // Mirror scan_next_dst_debt + CostReport::space_debt exactly so the
-       // search's debt equals scan_next_dst_debt() of the ChosenPlan the
-       // caller builds from the returned covering.
-       //
-       // F (fragment count) = rewrite_ops() = dedupe + copy + hole ops.
-       const size_t frag_count = dedupe_ops + copy_ops + in.m_hole_ops;
+       CostReport c;
 
        // refs_added = dst_ref_count * (F - 1); guard F <= 1 so it can't wrap.
-       uint64_t refs_added = 0;
        if (frag_count > 1) {
-               refs_added = in.m_dst_ref_count
+               c.m_refs_added = dst_ref_count
                        * (static_cast<uint64_t>(frag_count) - 1);
        }
 
        // refs_created = dst_ref_count * F: every dst reference re-written once
-       // per fragment.  This is the FILE_EXTENT_SAME work the plan performs,
-       // including the F=1 whole-extent case where refs_added is zero.  Weighted
-       // by ref_op_cost (default 0) in space_debt().
-       const uint64_t refs_created = in.m_dst_ref_count
-               * static_cast<uint64_t>(frag_count);
+       // per fragment — the FILE_EXTENT_SAME work, charged even for the F=1
+       // whole-extent case where refs_added is zero.  Weighted by ref_op_cost.
+       c.m_refs_created = dst_ref_count * static_cast<uint64_t>(frag_count);
 
        // physical_freed: a compressed dst is removed wholesale (phys_size);
        // an uncompressed dst frees good_blocks * blocksize.
-       const size_t good_blocks =
-               in.m_zero_like_blocks + in.m_unreachable_blocks + matched_blocks;
-       uint64_t physical_freed;
-       if (in.m_dst_compressed) {
-               physical_freed = in.m_dst_phys_size;
-       } else {
-               physical_freed = static_cast<uint64_t>(good_blocks)
-                       * in.m_clone_alignment;
-       }
+       c.m_physical_freed = dst_compressed
+               ? dst_phys_size
+               : good_blocks * clone_alignment;
 
        // extents_delta: +1 if the plan copies anything (all copies share one
-       // tempfile extent), -1 if the dst is fully freed (no unmatched data
-       // left).  For a complete covering copy_ops > 0 iff some data block is
-       // unmatched, so bad_blocks == 0 iff copy_ops == 0.
-       int64_t extents_delta = (copy_ops > 0) ? 1 : 0;
-       if (copy_ops == 0) {
-               extents_delta -= 1;
-       }
+       // tempfile extent), -1 if the dst is fully freed (no unmatched data left).
+       c.m_extents_delta = (copy_ops > 0) ? 1 : -1;
 
-       CostReport c;
-       c.m_refs_added     = refs_added;
-       c.m_refs_created   = refs_created;
-       c.m_extents_delta  = extents_delta;
-       c.m_physical_freed = physical_freed;
+       return c;
+}
+
+double
+scan_next_covering_debt(const PlanSearchInputs &in,
+       const BeesRewritePolicy &policy,
+       size_t dedupe_ops, size_t copy_ops, size_t matched_blocks)
+{
+       // F (fragment count) = rewrite_ops() = dedupe + copy + hole ops.
+       const size_t frag_count = dedupe_ops + copy_ops + in.m_hole_ops;
+       const uint64_t good_blocks = static_cast<uint64_t>(in.m_zero_like_blocks)
+               + in.m_unreachable_blocks + matched_blocks;
+       const auto c = scan_next_debt_fields(in.m_dst_ref_count, frag_count,
+               copy_ops, in.m_dst_compressed, in.m_dst_phys_size,
+               good_blocks, in.m_clone_alignment);
        return c.space_debt(policy);
 }
 
index cf9509c127d0f591058749badf0e14dbd9f4d09d..62d4d21b504c3b1bf696368c389a7d3ecdc8f1c4 100644 (file)
@@ -221,10 +221,30 @@ std::optional<PlanSearchResult>
 scan_next_plan_shortest_path(const PlanSearchInputs &in,
        const BeesRewritePolicy &policy);
 
+/// THE single computation of the four do-nothing space-debt input fields
+/// (do-nothing-plan.md §4), from raw scalars.  Both debt paths marshal their
+/// inputs here — the search via scan_next_covering_debt, the plan-cost path via
+/// scan_next_dst_debt — so the two can never drift (a field added to one but not
+/// the other silently drops the term from selection; that is how ref-op-cost
+/// went inert once).  Returns a CostReport with only the four space_debt input
+/// fields set (space_debt() not yet applied); the caller either sums several of
+/// these before pricing (Plan B) or prices immediately (the search).
+///   refs_added   = dst_ref_count * (F - 1), 0 when F <= 1 (no fragmentation)
+///   refs_created = dst_ref_count * F        (FILE_EXTENT_SAME work; F=1 counts)
+///   physical_freed = compressed ? dst_phys_size : good_blocks * clone_alignment
+///   extents_delta  = +1 if any copy (one shared tempfile extent), else -1 (dst
+///                    fully freed).  copy_ops > 0 iff unmatched data remains, so
+///                    this single test matches the old bad_blocks==0 form.
+/// @p good_blocks is the already-summed matched + zero-like + unreachable count.
+CostReport
+scan_next_debt_fields(uint64_t dst_ref_count, size_t frag_count, size_t copy_ops,
+       bool dst_compressed, uint64_t dst_phys_size,
+       uint64_t good_blocks, uint64_t clone_alignment);
+
 /// Compute the signed space-debt (do-nothing-plan.md §4) of a covering with
-/// the given operation/block counts, using the same formula as
-/// CostReport::space_debt + scan_next_dst_debt.  Exposed for the search's
-/// terminal-state evaluation and for test reconciliation.
+/// the given operation/block counts.  Thin wrapper: marshals the search's
+/// inputs into scan_next_debt_fields() (the shared builder) and prices the
+/// result.  Exposed for the search's terminal-state evaluation and for tests.
 double
 scan_next_covering_debt(const PlanSearchInputs &in,
        const BeesRewritePolicy &policy,
index e4a77304f2297d8d88839432d78f800a539b7eef..f2abf99db3484b513f5b8b4db45b768f31252a31 100644 (file)
@@ -1871,23 +1871,13 @@ scan_next_compute_match_views(const BeesExtent &start,
        return rv;
 }
 
-/// Per-dst raw inputs for the do-nothing space-debt model
-/// (do-nothing-plan.md §4).  Aggregated across all dsts of a plan into
-/// CostReport's m_refs_added / m_refs_created / m_extents_delta /
-/// m_physical_freed, then combined by CostReport::space_debt() into the
-/// m_space_debt ordering key.  Must mirror scan_next_covering_debt
-/// (bees-plan.cc) field-for-field — the search and the plan-cost path share
-/// one space_debt() formula, so a term set in one but not the other (as
-/// m_refs_created once was) silently drops out of orientation selection.
-struct ScanNextDstDebt {
-       uint64_t m_refs_added     = 0;
-       uint64_t m_refs_created   = 0;
-       int64_t  m_extents_delta  = 0;
-       uint64_t m_physical_freed = 0;
-};
-
-/// Compute the do-nothing debt inputs contributed by deduping/rewriting
-/// one dst extent, given the ChosenPlan composed for it.
+/// Compute the do-nothing debt inputs (do-nothing-plan.md §4) contributed by
+/// deduping/rewriting one dst extent, given the ChosenPlan composed for it.
+/// Returns a CostReport with only the four space_debt input fields set: Plan A
+/// prices it directly, Plan B sums several across candidates before pricing.
+/// The field arithmetic lives in the shared scan_next_debt_fields() (bees-plan)
+/// so this plan-cost path and the search (scan_next_covering_debt) marshal into
+/// one computation and cannot drift.
 ///
 /// Fragment count F is sourced from the composed SuperExtent leaf count,
 /// which equals ChosenPlan::rewrite_ops() (scan_next_compose_plan_tree
@@ -1904,61 +1894,31 @@ struct ScanNextDstDebt {
 /// debt) than the unsplit edge, so the debt-minimizing selector never
 /// places it on a chosen plan.  rewrite_ops() therefore matches what
 /// btrfs materializes wherever the debt is consulted.
-static ScanNextDstDebt
+static CostReport
 scan_next_dst_debt(BeesExtentLayer &layer,
        const BeesExtent &dst, const ChosenPlan &cp)
 {
-       ScanNextDstDebt rv;
-
-       // Fragment count: SuperExtent leaf count for this dst.
-       const auto frag_count = cp.rewrite_ops();
-
-       // refs_added = dst_ref_count * (F - 1).  F >= 1 for any non-empty
-       // plan; guard the F == 0 edge (no ops) so the subtraction can't wrap.
-       const auto dst_ref_count = dst.refs(layer)->size();
-       if (frag_count > 1) {
-               rv.m_refs_added = static_cast<uint64_t>(dst_ref_count)
-                       * (static_cast<uint64_t>(frag_count) - 1);
-       }
-
-       // refs_created = dst_ref_count * F: every dst reference is re-written once
-       // per fragment, the actual FILE_EXTENT_SAME work.  Unlike refs_added (net
-       // metadata growth, F-1 per ref) this is charged even for a whole-extent
-       // (F == 1) dedupe, so it is NOT guarded by frag_count > 1 — mirrors
-       // scan_next_covering_debt (bees-plan.cc).  This is the term ref-op-cost
-       // weights; without it the plan-level cost that drives orientation
-       // selection never prices the references a dedupe moves, so draining a
-       // high-ref extent as dst looks free.
-       rv.m_refs_created = static_cast<uint64_t>(dst_ref_count)
-               * static_cast<uint64_t>(frag_count);
-
-       // physical_freed: a compressed dst is removed wholesale (the
-       // compressed-dst-with-copy reject forbids retaining any fragment),
-       // so the recovered space is exactly its phys_size.  An uncompressed
-       // dst is 1:1 logical:physical, so it is (blocks freed * blocksize),
-       // where good_blocks() counts matched + zero_like + unreachable.
+       // Marshal this dst's ChosenPlan into the shared builder:
+       //   frag_count F   = cp.rewrite_ops() (SuperExtent leaf count for this dst)
+       //   copy_ops       = cp.copy_ops(); for a complete covering copy_ops > 0
+       //                    iff unmatched data remains, so it stands in for the
+       //                    old bad_blocks() > 0 extents_delta test exactly.  All
+       //                    tempfile copies concatenate into one tempfile extent,
+       //                    so the +1 is per-plan, not per-copy.
+       //   good_blocks    = cp.good_blocks() = matched + zero_like + unreachable.
+       //   physical_freed = phys_size for a compressed dst (removed wholesale,
+       //                    the compressed-with-copy reject forbids fragments),
+       //                    else good_blocks * blocksize.
        const bool dst_compressed =
                dst.compress_type(layer) != BTRFS_COMPRESS_NONE;
-       if (dst_compressed) {
-               rv.m_physical_freed = dst.phys_size();
-       } else {
-               rv.m_physical_freed = static_cast<uint64_t>(cp.good_blocks())
-                       * static_cast<uint64_t>(BeesContext::s_clone_alignment);
-       }
-
-       // extents_delta: all tempfile copies for this dst are concatenated
-       // into a single tempfile hole by scan_next_materialize_plan_tree, so
-       // they collectively create exactly one new extent (+1) regardless of
-       // copy_ops count — not one extent per copy op.  A dst that is fully
-       // freed (no unmatched data left behind) removes one extent (-1).  The
-       // ref cost is unaffected: each copied fragment still adds its own ref,
-       // already accounted for via rewrite_ops() in refs_added above.
-       rv.m_extents_delta = (cp.copy_ops() > 0) ? 1 : 0;
-       if (cp.bad_blocks() == 0) {
-               rv.m_extents_delta -= 1;
-       }
-
-       return rv;
+       return scan_next_debt_fields(
+               dst.refs(layer)->size(),
+               cp.rewrite_ops(),
+               cp.copy_ops(),
+               dst_compressed,
+               dst.phys_size(),
+               cp.good_blocks(),
+               static_cast<uint64_t>(BeesContext::s_clone_alignment));
 }
 
 // -----------------------------------------------------------------------------
index 3b1329bc17f50d9033fe72044a298566bea9d9ef..49f25d8c55aa5bc3d0ecede4575afd59ab3b8b21 100644 (file)
@@ -933,6 +933,59 @@ test_search_prefers_high_ref_src()
        }
 }
 
+// scan_next_debt_fields is THE single builder for the four space_debt input
+// fields, shared by the search (scan_next_covering_debt) and the plan-cost path
+// (scan_next_dst_debt) so the two cannot drift.  Pin its arithmetic directly —
+// especially refs_created at F=1, whose absence from the plan path made
+// ref-op-cost inert in orientation selection.
+static void
+test_debt_fields_shared_builder()
+{
+       // Whole-extent (F=1) dedupe, compressed dst, no copy: no fragmentation,
+       // but refs_created MUST still be charged (dst_ref_count * 1) — this is the
+       // term that priced the re-canonicalization drain out of selection.
+       {
+               const auto c = scan_next_debt_fields(
+                       /*dst_ref_count=*/3000, /*frag_count=*/1, /*copy_ops=*/0,
+                       /*dst_compressed=*/true, /*dst_phys_size=*/131072,
+                       /*good_blocks=*/32, /*clone_alignment=*/4096);
+               assert(c.m_refs_added == 0);            // F=1 -> no metadata growth
+               assert(c.m_refs_created == 3000);       // work still priced
+               assert(c.m_physical_freed == 131072);   // compressed: whole phys_size
+               assert(c.m_extents_delta == -1);        // dst fully freed, no copy
+       }
+       // Fragmented (F=3) dedupe, uncompressed, with copy.
+       {
+               const auto c = scan_next_debt_fields(
+                       /*dst_ref_count=*/10, /*frag_count=*/3, /*copy_ops=*/1,
+                       /*dst_compressed=*/false, /*dst_phys_size=*/0,
+                       /*good_blocks=*/5, /*clone_alignment=*/4096);
+               assert(c.m_refs_added == 10 * (3 - 1)); // dst_ref_count*(F-1)
+               assert(c.m_refs_created == 10 * 3);     // dst_ref_count*F
+               assert(c.m_physical_freed == 5 * 4096); // uncompressed: good_blocks*bs
+               assert(c.m_extents_delta == 1);         // copy present -> +1 tempfile
+       }
+       // F=0 edge (no ops): refs_added guarded against wrap, refs_created 0.
+       {
+               const auto c = scan_next_debt_fields(999, 0, 0, false, 0, 0, 4096);
+               assert(c.m_refs_added == 0);
+               assert(c.m_refs_created == 0);
+       }
+       // Whole-extent dedupe priced through the shared builder equals what
+       // scan_next_covering_debt computes for the same covering — the two paths
+       // agree by construction now that covering_debt wraps the builder.
+       {
+               auto policy = default_policy();
+               policy.m_ref_op_cost = 100;
+               const auto in = make_inputs(1, { {0, 1} }, /*ref=*/3000);
+               const auto c = scan_next_debt_fields(3000, /*F=*/1, /*copy=*/0,
+                       /*compressed=*/false, /*phys=*/0, /*good_blocks=*/1,
+                       /*clone_alignment=*/BLOCK);
+               assert(c.space_debt(policy)
+                       == scan_next_covering_debt(in, policy, 1, 0, 1));
+       }
+}
+
 int
 main(int, char **)
 {
@@ -958,6 +1011,7 @@ main(int, char **)
        RUN_A_TEST(test_search_v_budget_greedy_fallback());
        RUN_A_TEST(test_search_v_budget_greedy_abandons_over_limit());
        RUN_A_TEST(test_ref_op_cost_prices_whole_extent_dedupe());
+       RUN_A_TEST(test_debt_fields_shared_builder());
        RUN_A_TEST(test_search_prefers_high_ref_src());
        return 0;
 }