]> git.hungrycats.org Git - bees/commitdiff
bees-plan: make the V-budget greedy fallback a config key
authorZygo Blaxell <bees@furryterror.org>
Thu, 2 Jul 2026 21:53:10 +0000 (17:53 -0400)
committerZygo Blaxell <bees@furryterror.org>
Sat, 5 Sep 2026 04:04:13 +0000 (00:04 -0400)
Promote the prototype's hardcoded boundary-vertex budget to a rewrite
policy key, rewrite.plan-max-vertices, wired across bees.h,
bees-config.cc, bees-config-v2.cc, and docs/config-file.md.  The exact
coverage search is O(V^2) in the boundary count and holds the dst
extent's Exclusion throughout, so this is the operator's lever for how
much per-extent planning time to permit before the linear greedy cover
takes over.

The default is 16384 (an exact search stays under ~2 s at that V even
with the op limits raised to 1000, per test/bench-bees-plan.cc), and
`unlimited` disables the fallback.  A real default lives on the struct
rather than the loud UINT64_MAX sentinel used for the cost weights, so
directly-constructed policies in tests and the benchmark still take the
exact DP for small inputs; the authoritative default is in
bees-config-v2.cc as usual.

Two unit tests cover the fallback: one forces it with a tiny budget on a
fully-coverable extent and asserts the greedy result equals both the
exact DP and the brute-force oracle (the minimal-interval cover is
optimal when there is no dedupe-vs-copy tradeoff), exercises the copy-gap
path, and reconciles debt; the other asserts the intended nullopt
"abandon" when even the greedy cover cannot satisfy the op limits.

Assisted-by: Claude-Code:claude-opus-4-8
docs/config-file.md
src/bees-config-v2.cc
src/bees-config.cc
src/bees-plan.cc
src/bees.h
test/test-bees-plan.cc

index 8ae626ca9d8d6d1260df88207d3c33ffa860d0dc..3f75dab5e8f3c9f09fac83b7fda455411ae704b2 100644 (file)
@@ -263,6 +263,21 @@ as the new planner grows into them.
   * Currently used for observation only; it does not yet change plan
     selection.
 
+* **`plan-max-vertices`**
+  Boundary-vertex budget for the shortest-path coverage search.  The exact
+  search is O(V²) in the boundary count V (the distinct match endpoints of a
+  destination extent) and holds that extent's lock for its whole duration, so
+  a single large, heavily-duplicated extent can stall a worker — and every
+  task queued behind that extent — for seconds to minutes.  When V exceeds
+  this budget the planner abandons the exact search for a linear-time greedy
+  minimal-interval cover and stops gathering further candidates for that
+  extent.  The greedy minimizes fragment count (hence refs added) but does not
+  weigh dedupe-versus-copy tradeoffs, so above the budget plans may be slightly
+  less optimal in exchange for a hard time bound.
+  * Default: `16384`
+  * Accepts a non-negative integer or `unlimited` (which disables the fallback
+    and always runs the exact search).
+
 * **`keep-older`**
   Enable Plan A: dedupe operations that keep the older copy of
   duplicated content.  When `yes` (default), the planner builds a
index 3cb85e48e13bd35026f0a134b4736f086615e227..8438b20ae03e9d2aea1554719b8d63f8bca81bc1 100644 (file)
@@ -183,6 +183,11 @@ static const char bees_config_v2[] = R"--v2-config--(
         # Default 53 = surveyed btrfs extent-item size.
         extent-cost = 53
 
+        # Boundary-vertex budget for the coverage search.  Above this V the
+        # planner uses a linear greedy cover instead of the O(V^2) exact
+        # search.  Integer count, or unlimited to disable the fallback.
+        plan-max-vertices = 16384
+
         # Maximum hash-conflict restarts per extent.  After this many
         # restarts the extent is skipped for this scan cycle.
         # Integer count.  See docs/config-file.md for the rationale.
index 9d654fb479450f8befdc9736396cfc62698184df..7348c8e3167618f55448490eaa56f30b734864fd 100644 (file)
@@ -455,6 +455,7 @@ BeesConfig::load_rewrite_policy()
        }
        rv.ref_cost              = load("rewrite.ref-cost",            bees_parse_size);
        rv.extent_cost           = load("rewrite.extent-cost",         bees_parse_size);
+       rv.m_plan_max_vertices   = load("rewrite.plan-max-vertices",   bees_parse_count);
        rv.m_restart_max         = load("rewrite.restart-max",         bees_parse_count);
        rv.m_insert_sample_max   = load("rewrite.insert-sample-max",   bees_parse_count);
        rv.m_insert_conflict_check = load("rewrite.insert-conflict-check", bees_parse_bool);
index 794648437e167ab13febc31ad978dc9ee4f5db06..a86e9e6648ee5ed1e80c0953f5262ab5aad64eae 100644 (file)
@@ -103,22 +103,15 @@ scan_next_covering_debt(const PlanSearchInputs &in,
        return c.space_debt(policy);
 }
 
-// Above this many boundary vertices (V), the exact O(V^2) shortest-path DP is
-// abandoned for a linear-time greedy minimal-interval cover.  test/bench-bees-
-// plan.cc puts the DP's worst-case knee (collapse on) at V ~ 8192-16384;
-// beyond it a single dense extent can occupy a worker — and, because it holds
-// that dst's Exclusion the whole time, starve every task queued behind it —
-// for seconds to minutes (production has seen an 8-hour single-extent grind
-// with the collapse fast path disabled).  The greedy minimizes dedupe-op count
-// (hence fragment count and refs_added / debt) but ignores dedupe-vs-copy debt
-// tradeoffs, so it returns a valid, limit-satisfying, but possibly-suboptimal
-// cover in O(M log M + V).
-//
-// PROTOTYPE: the threshold is a file-scope constant here; productionizing it
-// means a rewrite-policy key (e.g. rewrite.plan-max-vertices) wired across
-// bees.h / bees-config-v2.cc / bees-context.cc / docs/config-file.md per the
-// "update all docs when adding a config option" rule.
-static constexpr size_t BEES_PLAN_V_BUDGET = 8192;
+// The exact O(V^2) shortest-path DP is abandoned above policy.m_plan_max_vertices
+// (rewrite.plan-max-vertices) boundary vertices in favor of a linear-time greedy
+// minimal-interval cover.  Rationale: the DP holds the dst extent's Exclusion for
+// its whole duration, so a single dense extent can occupy a worker — and starve
+// every task queued behind it — for seconds to minutes (production has seen an
+// 8-hour single-extent grind with the collapse fast path disabled).  The greedy
+// minimizes dedupe-op count (hence fragment count and refs_added / debt) but
+// ignores dedupe-vs-copy debt tradeoffs, so it returns a valid, limit-satisfying,
+// but possibly-suboptimal cover in O(M log M + V).
 
 // Total boundary-vertex count across all data regions — the DP's actual cost
 // driver V (region ends plus distinct in-region match endpoints).  Cheap
@@ -258,8 +251,10 @@ scan_next_plan_shortest_path(const PlanSearchInputs &in,
        // V-budget: the exact DP below is O(V^2) in the boundary count and holds
        // the dst Exclusion for its whole duration, so a high-V extent can wedge a
        // worker (and everything queued behind that extent).  Above the budget,
-       // abandon the exact search for the linear greedy cover.
-       if (scan_next_plan_boundary_count(in) > BEES_PLAN_V_BUDGET) {
+       // abandon the exact search for the linear greedy cover.  A budget of
+       // UINT64_MAX (rewrite.plan-max-vertices = unlimited) can never be exceeded
+       // and disables the fallback.
+       if (scan_next_plan_boundary_count(in) > policy.m_plan_max_vertices) {
                return scan_next_plan_greedy(in, policy);
        }
 
index 4e04858f3559bea0e690f4709200fb2bc313cd1e..5c7b988c972505484ef73b322731ec6975e22ac3 100644 (file)
@@ -1442,6 +1442,16 @@ struct BeesRewritePolicy {
        /// extent item (~53 bytes average).  Authoritative default lives in
        /// bees-config-v2.cc; the C++ initializer is an invalid sentinel.
        uint64_t extent_cost = UINT64_MAX;
+       /// Boundary-vertex budget for the shortest-path coverage search.
+       /// The exact search is O(V^2) in the boundary count V and holds the
+       /// dst extent's Exclusion for its whole duration, so a high-V extent
+       /// can stall a worker and every task queued behind that extent.  When
+       /// V exceeds this budget the planner abandons the exact search for a
+       /// linear-time greedy cover and stops gathering further candidates.
+       /// See docs/config-file.md; a real default is kept here (not a loud
+       /// sentinel) so directly-constructed policies in tests/benchmarks get
+       /// the exact DP for small inputs.  `unlimited` disables the fallback.
+       uint64_t m_plan_max_vertices = 16384;
        /// Buffer size for source-side reads in the materialize copy
        /// loop and the block_map_fetch hashing pass.  Larger values
        /// amortise pread syscall and ref-selection cost over more bytes;
index 5a23d0a8a57b14ca0d866f7b50163c15dcdf32fa..d0e1ba44bdac642c6e7e14576bfd664d5ddd4a4d 100644 (file)
@@ -743,6 +743,80 @@ test_search_large_input_smoke()
        assert(secs < 15.0);
 }
 
+static void
+test_search_v_budget_greedy_fallback()
+{
+       // Above policy.m_plan_max_vertices the search abandons the exact O(V^2) DP
+       // for the linear greedy minimal-interval cover.  On a fully-coverable
+       // extent (no dedupe-vs-copy tradeoff) the greedy IS the optimum, so forcing
+       // it with a tiny budget must reproduce both the exact DP's result and the
+       // brute-force oracle minimum — same debt, same coverage — proving the
+       // fallback is correct, not merely fast.
+       auto policy = default_policy();
+
+       // Three overlapping matches tile [0,20): minimal cover is 3 dedupes, no
+       // copy.  V here is 6 (region ends 0,20 plus interior 6,8,12,14).
+       const auto in = make_inputs(20, { {0, 8}, {6, 14}, {12, 20} }, /*ref=*/1);
+
+       policy.m_plan_max_vertices = 16384;   // above V -> exact DP
+       const auto dp = scan_next_plan_shortest_path(in, policy);
+       assert(dp.has_value());
+
+       policy.m_plan_max_vertices = 2;       // below V -> greedy fallback
+       const auto greedy = scan_next_plan_shortest_path(in, policy);
+       assert(greedy.has_value());
+
+       // Fully covered by dedupe: all 20 blocks matched, no copy slice, and the
+       // cover is within the op limits.
+       assert(greedy->m_matched_blocks == 20);
+       assert(greedy->m_copy_slices.empty());
+       assert(greedy->m_selected_matches.size() == 3);
+       assert(greedy->m_selected_matches.size() <= policy.m_dedupe_max);
+
+       // Debt reconciles by construction, and equals both the exact DP and the
+       // oracle minimum — greedy is optimal on a coverable extent.
+       assert(greedy->m_debt == result_recomputed_debt(in, policy, *greedy));
+       assert(greedy->m_debt == dp->m_debt);
+       const auto omin = oracle_min_debt(in, policy);
+       assert(omin.has_value());
+       assert(greedy->m_debt == *omin);
+
+       // Copy path: shifting the first match to [1,8) leaves block 0 uncovered.
+       // The greedy must emit exactly one leading copy slice [0,1) and still
+       // reconcile its debt.
+       const auto in_gap = make_inputs(20, { {1, 8}, {6, 14}, {12, 20} }, /*ref=*/1);
+       const auto greedy_gap = scan_next_plan_shortest_path(in_gap, policy);
+       assert(greedy_gap.has_value());
+       assert(greedy_gap->m_copy_slices.size() == 1);
+       assert(greedy_gap->m_copy_slices[0].m_begin == 0);
+       assert(greedy_gap->m_copy_slices[0].m_end == 1 * BLOCK);
+       assert(greedy_gap->m_matched_blocks == 19);
+       assert(greedy_gap->m_debt
+               == result_recomputed_debt(in_gap, policy, *greedy_gap));
+}
+
+static void
+test_search_v_budget_greedy_abandons_over_limit()
+{
+       // When even the greedy minimal cover cannot satisfy the op limits, the
+       // fallback returns nullopt — the intended "abandon this extent" outcome
+       // (it then loses to do-nothing in selection) rather than spending DP time.
+       // This is the documented quality tradeoff: the exact DP could still find an
+       // all-copy plan under the limit here, but a high-V extent is exactly the
+       // case we choose to sacrifice for a hard time bound.
+       auto policy = default_policy();
+       policy.m_dedupe_max = 2;
+       policy.m_total_max = 2;
+       policy.m_plan_max_vertices = 1;   // force greedy
+
+       // Five non-overlapping single-block matches tile [0,5): the minimal cover
+       // is 5 dedupe ops, over the limit of 2, so the greedy abandons.
+       const auto in = make_inputs(5,
+               { {0, 1}, {1, 2}, {2, 3}, {3, 4}, {4, 5} }, /*ref=*/1);
+       const auto greedy = scan_next_plan_shortest_path(in, policy);
+       assert(!greedy.has_value());
+}
+
 int
 main(int, char **)
 {
@@ -765,5 +839,7 @@ main(int, char **)
        RUN_A_TEST(test_search_brute_force_oracle());
        RUN_A_TEST(test_search_compressed_all_or_nothing());
        RUN_A_TEST(test_search_large_input_smoke());
+       RUN_A_TEST(test_search_v_budget_greedy_fallback());
+       RUN_A_TEST(test_search_v_budget_greedy_abandons_over_limit());
        return 0;
 }