Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 76 additions & 2 deletions clearwing/sourcehunt/ranker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -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)
Expand Down
111 changes: 111 additions & 0 deletions tests/test_sourcehunt_ranker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]