]> git.hungrycats.org Git - bees/commitdiff
scan_next: prefer the higher-ref src on equal-reach ties
authorZygo Blaxell <bees@furryterror.org>
Tue, 7 Jul 2026 17:32:57 +0000 (13:32 -0400)
committerZygo Blaxell <bees@furryterror.org>
Sat, 5 Sep 2026 04:04:13 +0000 (00:04 -0400)
The coverage search charges only the dst extent's fragmentation, so among
matches that reach equally far it recorded whichever it met first — an
arbitrary src choice.  For a whole-extent dedupe (F=1) every candidate src
reaches the full extent, so they all tie on reach, and the arbitrary pick
lets dedupe chain through low-ref extents.  That feeds the rolling
re-canonicalization the low-total-max stress run exhibited: an extent absorbs
references as a src, is later eliminated as a dst, and drains them elsewhere,
never converging.

Carry each match's src reference count into PlanSearchMatch and break
equal-reach ties in the per-vertex reach precompute (and the greedy fallback)
toward the higher-ref src, consolidating dedupe onto the already-canonical
extent.  Reach stays the primary key — a farther-reaching low-ref src still
wins, because trading reach for src refs would raise the fragment count, a
real debt term rather than an equal-cost tie.  The src choice never changes
an edge's debt, so the tie-break is free in the O(M+V) sweep and leaves every
covering's debt (and all existing debt reconciliations) untouched.

This is Feature #1 of the reference-cluster cost model, following the
ref-op-cost soft cost (#2) and the additive ref ceiling (#3).  The A/B stress
runs showed #2 brakes the churn but leaves a bounded both-hot residual (one
extent absorbing ~5800 and draining ~8900 refs); this rule targets that
residual by orienting each dedupe toward the canonical src.

test-bees-plan gains test_search_prefers_high_ref_src, pinning the tie-break
on both the exact DP and greedy paths (order-independently) and the
reach-primacy guard that keeps a full-span low-ref src ahead of a short
high-ref one.

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 35a30e534bd1665185e0ad659e2e1e3a7036aa57..fe37bba3599d1fb25b5cf1f05892b6e154ee4dd1 100644 (file)
@@ -200,7 +200,15 @@ scan_next_plan_greedy(const PlanSearchInputs &in,
                                if (m.m_dst_end <= region.m_begin
                                    || m.m_dst_begin >= region.m_end) continue;
                                const uint64_t end = min(m.m_dst_end, region.m_end);
-                               if (end > best_end) {
+                               // Farther reach wins; equal reach breaks toward the
+                               // higher-ref src (see the DP reach precompute) so a
+                               // whole-extent cover consolidates onto the canonical
+                               // extent.  Src choice does not change the edge's debt.
+                               if (end > best_end
+                                   || (end == best_end
+                                       && best_idx != in.m_matches.size()
+                                       && m.m_src_ref_count
+                                          > in.m_matches[best_idx].m_src_ref_count)) {
                                        best_end = end;
                                        best_idx = idx;
                                }
@@ -509,6 +517,14 @@ scan_next_plan_shortest_path(const PlanSearchInputs &in,
                // O(M) covering-match rescan that previously sat in the innermost
                // loop and made the search O(V^2 * S * M); M=100 fell from ~1.5 s to
                // milliseconds (test/bench-bees-plan.cc).
+               //
+               // Reach is the primary key (a farther reach lets a later covering use
+               // fewer fragments — a real debt term); among equal-reach matches the
+               // argmax breaks ties toward the higher-ref src, so a whole-extent
+               // dedupe (every src reaches the full extent, all tied on reach)
+               // consolidates onto the canonical high-ref extent instead of an
+               // arbitrary one.  Since the src choice never changes this edge's cost,
+               // the tie-break is free and leaves the covering's debt untouched.
                vector<uint64_t> reach_end(nverts, 0);
                vector<size_t>   cover_at(nverts, in.m_matches.size());
                {
@@ -519,10 +535,15 @@ scan_next_plan_shortest_path(const PlanSearchInputs &in,
                                const uint64_t a = verts[vi];
                                while (mp < m_by_begin.size()
                                       && in.m_matches[m_by_begin[mp]].m_dst_begin <= a) {
-                                       const auto &m = in.m_matches[m_by_begin[mp]];
-                                       if (m.m_dst_end > best_end) {
+                                       const size_t  idx = m_by_begin[mp];
+                                       const auto   &m   = in.m_matches[idx];
+                                       if (m.m_dst_end > best_end
+                                           || (m.m_dst_end == best_end
+                                               && best_idx != in.m_matches.size()
+                                               && m.m_src_ref_count
+                                                  > in.m_matches[best_idx].m_src_ref_count)) {
                                                best_end = m.m_dst_end;
-                                               best_idx = m_by_begin[mp];
+                                               best_idx = idx;
                                        }
                                        ++mp;
                                }
index c556b87aff3de642761a606a468f3ad2e4050f1c..af6ad441760da9bedc1b7653ddcc1b43eaa9c0dc 100644 (file)
@@ -155,13 +155,21 @@ struct PlanSearchRegion {
 };
 
 /// One candidate dedupe edge: a match's dst span plus an opaque group id
-/// the caller uses to recover the CandidateGroup / source extent.  Only the
-/// dst geometry drives the search (the cost model charges the dst's
-/// fragmentation, not the src), so the src extent is not needed here.
+/// the caller uses to recover the CandidateGroup / source extent.  The dst
+/// geometry drives the search's *cost* (the model charges the dst's
+/// fragmentation, not the src), so the src extent's bytes are not needed
+/// here.  m_src_ref_count is carried only as a tie-break: among matches that
+/// reach equally far (equal dst cost) the search prefers the src with the
+/// most references, consolidating dedupe onto the already-canonical extent
+/// rather than an arbitrary one.  This is the "prefer more-ref src when other
+/// costs are equal" rule — it never overrides reach, only orders equal-reach
+/// ties, so it costs nothing in the O(M+V) reach sweep.  0 (the default, and
+/// what a single-src Plan B leaves unset) makes every tie resolve as before.
 struct PlanSearchMatch {
-       uint64_t m_dst_begin = 0;
-       uint64_t m_dst_end   = 0;
-       size_t   m_group_id  = 0;  ///< caller-defined match identity
+       uint64_t m_dst_begin      = 0;
+       uint64_t m_dst_end        = 0;
+       size_t   m_group_id       = 0;  ///< caller-defined match identity
+       uint64_t m_src_ref_count  = 0;  ///< src refs()->size(), tie-break only
 };
 
 /// Fixed per-dst inputs to the search, mirroring scan_next_plan_init's
index af5fbf66228524d1eee010b1c4347f4bb5b03d9d..0885d68faa9333b77ed25bfe318235ad6f823a48 100644 (file)
@@ -1074,8 +1074,13 @@ scan_next_choose_match_plan(const BeesExtent &dst,
        for (const auto &group : candidate_groups) {
                for (const auto &match : group.m_matches) {
                        if (match.size() == 0) continue;
+                       // Carry the src ref count as the search's equal-reach tie-break
+                       // (prefer consolidating onto the higher-ref, canonical src).  The
+                       // refs() lookup is layer-cached; the src extents here were already
+                       // resolved to produce these matches, so this is warm.
+                       const uint64_t src_refs = match.m_src.refs(layer)->size();
                        in.m_matches.push_back(PlanSearchMatch{
-                               match.m_dst_begin, match.m_dst_end, flat.size() });
+                               match.m_dst_begin, match.m_dst_end, flat.size(), src_refs });
                        flat.push_back(FlatMatch{ &group, &match });
                }
        }
index 88b9c8396aa88307f6d6984c4db5d79715b145f5..b22b798caf21f3a6369b9cda2457b1f746f4cad2 100644 (file)
@@ -902,6 +902,60 @@ test_ref_op_cost_prices_whole_extent_dedupe()
        assert(q1 - q0 == 3000.0 * 100.0);
 }
 
+// Feature #1: among matches that reach equally far (equal dst cost) the search
+// consolidates onto the higher-ref src — the "prefer more-ref src when other
+// costs are equal" rule that converges dedupe onto a canonical extent instead
+// of chaining through arbitrary ones.  Reach stays the primary key, so a
+// farther-reaching low-ref src still beats a short high-ref one.
+static void
+test_search_prefers_high_ref_src()
+{
+       auto policy = default_policy();
+
+       // Two srcs both cover the whole extent [0,10): equal reach, equal dst
+       // cost.  The search must pick the higher-ref src.  Try both input orders
+       // to prove the tie-break is order-independent (not just "keep first").
+       for (const bool high_first : { false, true }) {
+               auto in = make_inputs(10, { {0, 10}, {0, 10} }, /*ref=*/1);
+               const size_t hi = high_first ? 0 : 1;
+               const size_t lo = high_first ? 1 : 0;
+               in.m_matches[hi].m_src_ref_count = 5000;
+               in.m_matches[lo].m_src_ref_count = 5;
+
+               // Exact DP path.
+               policy.m_plan_max_vertices = 16384;
+               const auto dp = scan_next_plan_shortest_path(in, policy);
+               assert(dp.has_value());
+               assert(dp->m_selected_matches.size() == 1);
+               assert(dp->m_selected_matches[0] == hi);   // canonical src chosen
+               assert(dp->m_debt == result_recomputed_debt(in, policy, *dp));
+
+               // Greedy fallback (tiny V budget forces it): same decision, and the
+               // tie-break costs nothing on the debt (unchanged reconciliation).
+               policy.m_plan_max_vertices = 1;
+               const auto gr = scan_next_plan_shortest_path(in, policy);
+               assert(gr.has_value());
+               assert(gr->m_selected_matches.size() == 1);
+               assert(gr->m_selected_matches[0] == hi);
+               assert(gr->m_debt == result_recomputed_debt(in, policy, *gr));
+       }
+
+       // Reach stays primary: a full-span low-ref src beats a short high-ref src
+       // even though the tie-break alone would prefer the latter.  Choosing the
+       // short src would leave a gap needing an extra op — a real debt increase,
+       // not an equal-cost tie — so the tie-break must not fire here.
+       {
+               auto in = make_inputs(10, { {0, 10}, {0, 6} }, /*ref=*/1);
+               in.m_matches[0].m_src_ref_count = 5;      // full span, few refs
+               in.m_matches[1].m_src_ref_count = 5000;   // short span, many refs
+               policy.m_plan_max_vertices = 16384;
+               const auto dp = scan_next_plan_shortest_path(in, policy);
+               assert(dp.has_value());
+               assert(dp->m_selected_matches.size() == 1);
+               assert(dp->m_selected_matches[0] == 0);   // full-span src wins on reach
+       }
+}
+
 int
 main(int, char **)
 {
@@ -928,5 +982,6 @@ main(int, char **)
        RUN_A_TEST(test_search_v_budget_greedy_abandons_over_limit());
        RUN_A_TEST(test_refs_ceiling_gate());
        RUN_A_TEST(test_ref_op_cost_prices_whole_extent_dedupe());
+       RUN_A_TEST(test_search_prefers_high_ref_src());
        return 0;
 }