From 714a2e3bf1b254aeba1a5c8b8039b52de12761c3 Mon Sep 17 00:00:00 2001 From: TSUNODA Kazuya Date: Wed, 2 Sep 2026 17:02:07 +0900 Subject: [PATCH] fix(sourcehunt): reserve a per-band minimum when cutting large-repo ranker candidates On repositories above large_repo_file_threshold, _select_llm_candidates kept the head of a single global ordering. A file whose heuristic priority sits in a lower band never reached the LLM reranker even when it was the only file in its band. Reserve large_repo_band_min files per priority band before filling the remaining slots by the existing sort key; the rerank budget is unchanged. The reservation is round-robin across bands and never exceeds the limit, so an oversized band_min still keeps low-band representation while favouring stronger bands on overflow. large_repo_band_min=0 reproduces the previous head cut. --- clearwing/sourcehunt/ranker.py | 78 +++++++++++++++++++++- tests/test_sourcehunt_ranker.py | 111 ++++++++++++++++++++++++++++++++ 2 files changed, 187 insertions(+), 2 deletions(-) diff --git a/clearwing/sourcehunt/ranker.py b/clearwing/sourcehunt/ranker.py index 40237d5b..73b5f6e2 100644 --- a/clearwing/sourcehunt/ranker.py +++ b/clearwing/sourcehunt/ranker.py @@ -94,6 +94,8 @@ class RankerConfig: llm_timeout_seconds: int | None = None large_repo_file_threshold: int = 2000 large_repo_llm_file_limit: int = 600 + large_repo_band_min: int = 0 + large_repo_bands: int = 4 include_static_hints: bool = True include_imports_by: bool = True static_hint_surface_floor: int = 3 # files with static_hint > 0 → min surface 3 @@ -268,14 +270,15 @@ def _select_llm_candidates(self, files: list[FileTarget]) -> list[FileTarget]: ) return [] - candidates = [ + ordered = [ ft for _, ft in sorted( enumerate(files), key=lambda item: self._candidate_sort_key(item[0], item[1]), reverse=True, - )[:limit] + ) ] + candidates = self._reserve_band_minimum(ordered, limit) logger.info( "Large repo detected for ranker; heuristics applied to %d files, " "LLM reranking top %d files", @@ -284,6 +287,77 @@ def _select_llm_candidates(self, files: list[FileTarget]) -> list[FileTarget]: ) return candidates + def _reserve_band_minimum( + self, ordered: list[FileTarget], limit: int + ) -> list[FileTarget]: + """Take ``limit`` files from ``ordered`` (already sorted best-first), + but reserve ``large_repo_band_min`` slots for each priority band first. + + A plain head cut keeps only the front of a single global ordering, so a + file whose heuristic ``priority`` sits in a lower band never reaches the + LLM reranker even when it is the only file in its band. Reserving a + per-band minimum keeps at least one representative of each populated + band; the remaining slots are filled globally, so the head of the + ordering is unchanged apart from the reserved lower-band files. The + reservation never exceeds ``limit``: when it cannot honour every band it + prefers the higher (stronger-priority) bands. + """ + if not ordered: + return [] + band_min = max(0, self.config.large_repo_band_min) + bands = max(1, self.config.large_repo_bands) + if band_min == 0 or bands == 1: + return ordered[:limit] + if band_min * bands > limit: + logger.warning( + "band-reservation exceeds candidate limit; falling back to head cut " + "(band_min=%d, bands=%d, limit=%d)", + band_min, + bands, + limit, + ) + return ordered[:limit] + + hi = ordered[0].get("priority", 0.0) + lo = ordered[-1].get("priority", 0.0) + span = hi - lo + if span <= 0: + # Every file shares one priority (e.g. --no-rank): no bands to + # protect, so the plain head cut is already correct. + return ordered[:limit] + + def band_of(ft: FileTarget) -> int: + # Higher priority → lower band index. Clamp the bottom edge: a + # file at exactly ``lo`` gives frac=1 → int(1*bands)=bands, which + # is one past the last valid index, so cap it at ``bands-1``. + frac = (hi - ft.get("priority", 0.0)) / span + return min(bands - 1, int(frac * bands)) + + by_band: dict[int, list[int]] = {} + for idx, ft in enumerate(ordered): + by_band.setdefault(band_of(ft), []).append(idx) + + reserved: set[int] = set() + # Round-robin the reservation: the r-th file of each band in ascending + # band order (strongest band first), so a tight ``limit`` still gives the + # low bands representation while favouring stronger bands on overflow. + for rank in range(band_min): + if len(reserved) >= limit: + break + for band in sorted(by_band): + if len(reserved) >= limit: + break + members = by_band[band] + if rank < len(members): + reserved.add(members[rank]) + # Fill any remaining slots globally in best-first order. + for idx in range(len(ordered)): + if len(reserved) >= limit: + break + reserved.add(idx) + # Preserve the global ordering of the final set. + return [ft for idx, ft in enumerate(ordered) if idx in reserved][:limit] + @staticmethod def _candidate_sort_key(index: int, ft: FileTarget) -> tuple: influence_signal = ft.get("transitive_callers", 0) or ft.get("imports_by", 0) diff --git a/tests/test_sourcehunt_ranker.py b/tests/test_sourcehunt_ranker.py index f6107314..8570fd43 100644 --- a/tests/test_sourcehunt_ranker.py +++ b/tests/test_sourcehunt_ranker.py @@ -721,3 +721,114 @@ def test_prompt_mentions_both_axes(self): assert "constants.h" in RANKER_SYSTEM_PROMPT or "propagat" in RANKER_SYSTEM_PROMPT.lower() # Must request JSON output assert "JSON" in RANKER_SYSTEM_PROMPT + + +class TestLargeRepoBandMinimum: + """`_select_llm_candidates` reserves a per-band minimum so a lower-band + ground-truth file is not dropped by a plain head cut.""" + + @staticmethod + def _files_with_priorities(priorities: list[float]) -> list[dict]: + files = [] + for i, prio in enumerate(priorities): + ft = _make_file(f"f{i}.c") + ft["priority"] = prio + files.append(ft) + return files + + def test_below_threshold_returns_all(self): + config = RankerConfig(large_repo_file_threshold=100) + ranker = Ranker(AsyncMock(), config) + files = self._files_with_priorities([1.0, 2.0, 3.0]) + assert ranker._select_llm_candidates(files) == files + + def test_band_minimum_keeps_a_low_band_file(self): + # 8 files over the threshold; limit 4. Files cluster in the high band, + # one sits alone in the lowest band. A plain head cut keeps only the + # top 4 (all high band) and drops the low-band file; band reservation + # keeps a representative of every band. + config = RankerConfig( + large_repo_file_threshold=5, + large_repo_llm_file_limit=4, + large_repo_band_min=1, + large_repo_bands=4, + ) + ranker = Ranker(AsyncMock(), config) + files = self._files_with_priorities( + [10.0, 9.9, 9.8, 9.7, 9.6, 9.5, 9.4, 0.0] + ) + out = ranker._select_llm_candidates(files) + paths = {ft["path"] for ft in out} + assert len(out) == 4 + assert "f7.c" in paths # the lone low-band file survives the cut + + def test_band_min_zero_is_plain_head_cut(self): + config = RankerConfig( + large_repo_file_threshold=5, + large_repo_llm_file_limit=3, + large_repo_band_min=0, + ) + ranker = Ranker(AsyncMock(), config) + files = self._files_with_priorities([10.0, 9.9, 9.8, 9.7, 9.6, 0.0]) + out = ranker._select_llm_candidates(files) + assert [ft["path"] for ft in out] == ["f0.c", "f1.c", "f2.c"] + + def test_default_is_plain_head_cut(self): + # The band-reservation behaviour is opt-in: the shipped defaults must + # preserve the historical head-cut selection so existing users see no + # change until they set ``large_repo_band_min`` themselves. + config = RankerConfig( + large_repo_file_threshold=5, + large_repo_llm_file_limit=3, + ) + assert config.large_repo_band_min == 0 + ranker = Ranker(AsyncMock(), config) + files = self._files_with_priorities([10.0, 9.9, 9.8, 9.7, 9.6, 0.0]) + out = ranker._select_llm_candidates(files) + assert [ft["path"] for ft in out] == ["f0.c", "f1.c", "f2.c"] + + def test_overflow_falls_back_to_head_cut(self, caplog): + # When ``band_min * bands`` exceeds the candidate limit the reservation + # cannot be honoured; the selector must fall back to the plain head cut + # and log a WARNING naming the overflow. + config = RankerConfig( + large_repo_file_threshold=5, + large_repo_llm_file_limit=10, + large_repo_band_min=100, + large_repo_bands=4, + ) + ranker = Ranker(AsyncMock(), config) + files = self._files_with_priorities( + [10.0, 9.9, 9.8, 9.7, 9.6, 9.5, 9.4, 9.3, 9.2, 9.1, 9.0, 0.0] + ) + with caplog.at_level("WARNING", logger="clearwing.sourcehunt.ranker"): + out = ranker._select_llm_candidates(files) + expected = [ft["path"] for ft in files[:10]] + assert [ft["path"] for ft in out] == expected + assert any( + "band_min=100" in r.message + and "bands=4" in r.message + and "limit=10" in r.message + for r in caplog.records + ) + + def test_empty_ordered_returns_empty(self): + config = RankerConfig( + large_repo_band_min=1, + large_repo_bands=4, + ) + ranker = Ranker(AsyncMock(), config) + assert ranker._reserve_band_minimum([], limit=10) == [] + + def test_uniform_priority_is_plain_head_cut(self): + # --no-rank leaves every priority equal: no bands to protect. + config = RankerConfig( + large_repo_file_threshold=3, + large_repo_llm_file_limit=2, + large_repo_band_min=1, + large_repo_bands=4, + ) + ranker = Ranker(AsyncMock(), config) + files = self._files_with_priorities([5.0, 5.0, 5.0, 5.0]) + out = ranker._select_llm_candidates(files) + assert [ft["path"] for ft in out] == ["f0.c", "f1.c"]