feat(search): discount the exact-name bonus by corpus frequency (#982) - #1462
feat(search): discount the exact-name bonus by corpus frequency (#982)#1462maxmilian wants to merge 3 commits into
Conversation
|
Pushed The agent A/B does not support this change. It shows parity.
A first attempt was worse than useless and I'd rather report it than drop it: run on an implementation task, both arms answered with 16–44 What does resolve the change is a deterministic probe pointed at the condition the lever keys on — a query containing a corpus-common symbol name. On django (62k nodes) the reordering is real and in the intended direction: So: fires on a large symbol-name-rich corpus, no-op elsewhere, nothing reordered for the worse. But it is a retrieval tie-break whose effect sits below the resolution of the A/B this repo gates on, so I'm leaving it as a draft. Your call whether deterministic evidence is the right bar for a change of this shape — I didn't want to flip it to ready on evidence that doesn't meet the bar you actually set. Two things worth your attention regardless:
Unrelated and not touched here: the pre-existing exact-match supplement in Tests are 14 now, including one pinning |
|
With #1463 and #1542 both on main now, here's what this needs to come out of draft — one real blocker, one small alignment, and two things you were tracking that are now moot. The blocker: the two discounts compose, and the composition breaks your own invariant. This PR's floor derivation and #1463's damping derivation each pin "an exact name the user typed never loses to a mere prefix match," and each clears it in isolation: The alignment: Moot: the "adjacent, not touched" On the merge decision itself: the direction is right, and the floor sweep was the strongest part of the draft — real corpora never driving the raw scale below ~0.36 (making the old 0.25 dead code), and the exact-name-loses-to-prefix failures at low floors, are findings that only come from running the thing. Since this changes default ranking (unlike #1463), the bar is: rebase, fix the composed bound, and re-run the deterministic probes on the rebased tree so the numbers describe what would merge. The parity agent A/B doesn't block it, for the same reason it didn't block #1463 — wrong instrument for effects this size. Do that and I'm happy to promote it. |
…ounts compose The corpus-frequency discount (colbymchenry#1462) and the de-prioritized-path damping (colbymchenry#1463) each pin the same invariant with the other lever off: an exact name the user typed never loses to a mere prefix match. Multiplied they break it — 80 * 0.6 * 0.75 - 15 = 21, under the prefix arm's supremum of 40. Floor the combined multiplier at 0.70, above the 55/80 = 0.6875 the bound requires. min() of the two is not enough: min(0.6, 0.75) = 0.6 is itself under the bound. Also aligns nameCorpusStats on lower(name) = lower(?) with the raw name, per colbymchenry#1542, so the file keeps one idiom.
31bdefc to
fbf36af
Compare
|
Rebased and pushed ( The composed boundFixed, but not the way you suggested, because the second option doesn't clear its own bound: So it's the first option — a floor on the combined multiplier. Working the rounding through: the check is Structurally this meant moving The composed test is in The What the probes foundFull suite on the rebased tree: 61 failures against 57 on a clean
I isolated the cause rather than assuming it was the new floor. Setting the floor to Here's what actually reaches the agent, same fixture, same query:
The discount puts a generated CRUD file back into the envelope and pushes both hand-written files out. That's the #1500 behaviour, arriving through the change meant to reduce it. The CG-10 gates still pass — the answer/generated share split holds — so what breaks is CG-12 specifically: The mechanism, as far as I can tell: the fixture deliberately gives half the generated tree names that collide with the hand-written layer. When a name collides, both copies get discounted, so their relative order is preserved — but the hand-written file's absolute score drops, and it loses to a generated file whose name has no twin and is therefore not discounted at all. The discount penalises having your name copied, and being copied is what the hand-written original looks like from the index's point of view. I tried the obvious narrowing — raising the Where that leaves itI don't think I should pick the direction here. Making the discount immune to this would mean changing its shape, and that invalidates the floor sweep the 0.60 came out of — five repos, measured against the undiscounted baseline. That's your call, not a patch I should land quietly:
Staying in draft until you say which. The rebase, the composed bound, the composed test and the alignment are all in |
nameMatchBonus handed a flat 80 (whole query === name) / 60 (a token of a multi-word query === name) regardless of how many symbols carry that name. A generic non-stopword token that happens to be a symbol name — usage, get, status — collected the full bonus and outranked the product code that answers the query but does not literally contain the token. This is the corpus-frequency discount colbymchenry#746 floated and left unbuilt. nameMatchIdfScale(df, total) = log(1 + total/df) / log(1 + total), so a unique name keeps its full bonus and the weight decays as the name spreads. Floored at 0.25: a query where the common name genuinely IS the discriminating term must still rank, so this discounts rather than erases. Only the two exact-name arms are scaled; prefix and substring bonuses are already small and length-scaled and never produced the crowd-out. Corpus stats come from the DB layer via an optional parameter, so nameMatchBonus stays pure and its existing callers are unaffected. Fixes colbymchenry#982 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SKXAJMrVrdHS5Uco6ABtky
Two follow-ups on the corpus-frequency discount, both from measuring rather than reasoning about it. `nameCorpusStats` counted names with `name = ? COLLATE NOCASE`, which matches neither `idx_nodes_name` (BINARY) nor `idx_nodes_lower_name` (expression), so every distinct candidate name cost a full index scan: 3.2ms on gin (2.5k nodes), 14ms on excalidraw (11k), 79ms on django (62k) added to every search, growing with the corpus. Written as `lower(name) = ?` it seeks `idx_nodes_lower_name` and is flat at ~0.08ms on all four. The `COUNT(*)` for the corpus total — the cost the PR flagged as the thing to watch — measures 0.002ms and needs no cache. The 0.25 floor was inert. Real corpora never drive the raw scale below ~0.36 (django's commonest name spans 1097 of 62080 nodes), so it never once bound. Sweeping 0→1 over the top-25 common names of five indexed repos, the binding failures are real but milder: `request` on Alamofire (scale 0.392) and `alamofire` itself (0.551) lost their own top slot to a mere prefix match (`requests`, `AlamofireExtended`). 0.60 is the lowest value clearing the worst case with margin — 80 * 0.6 = 48 sits above the prefix arm's supremum of 40, so a whole-query exact match can no longer lose to a prefix at any frequency. It restores exact-name recall@1 to the undiscounted baseline on all five repos while keeping ~95% of the crowd-out relief.
…ounts compose The corpus-frequency discount (colbymchenry#1462) and the de-prioritized-path damping (colbymchenry#1463) each pin the same invariant with the other lever off: an exact name the user typed never loses to a mere prefix match. Multiplied they break it — 80 * 0.6 * 0.75 - 15 = 21, under the prefix arm's supremum of 40. Floor the combined multiplier at 0.70, above the 55/80 = 0.6875 the bound requires. min() of the two is not enough: min(0.6, 0.75) = 0.6 is itself under the bound. Also aligns nameCorpusStats on lower(name) = lower(?) with the raw name, per colbymchenry#1542, so the file keeps one idiom.
fbf36af to
74cdf67
Compare
Implements the corpus-frequency discount from #982 — the general lever #746 floated and left unbuilt.
The change
nameMatchBonushanded a flat 80 (whole query===name) / 60 (a token of a multi-word query===name) no matter how many symbols carry that name.STOP_WORDSdeliberately doesn't filter common symbol names, so a bareusage/get/statuscollected the full bonus and outranked product code that answers the query but doesn't literally contain the token.1— the common case is bit-for-bit unchangednameMatchBonusstays pure and every existing caller behaves exactly as before when stats aren't suppliedBecause
findRelevantContextretrieves throughsearchNodes, the explore path inherits this.The two things I asked you to decide, now answered with measurements
I left this draft open on two questions rather than guess. Both are settled, and both answers turned out different from what I expected — the second one contradicts the concern I raised.
1. The floor: 0.25 was inert. It is now 0.60, derived from a sweep.
Swept the floor 0→1 over the top-25 corpus-common names of five indexed repos — gin (2,531 nodes), Alamofire (4,512), codegraph itself (9,230), excalidraw (11,161), django (62,080) — scoring two opposing constraints:
The first finding is that 0.25 never did anything. Real corpora don't drive the raw scale that low — the minimum reached is 0.36–0.47:
Floors of 0, 0.10, 0.15, 0.20, 0.25 and 0.30 produce byte-identical sweep results on all five repos. The number I flagged as "the one I picked rather than derived" was dead code.
The second finding is that the real failures sit higher, and they are indefensible ones. With the floor inert, searching Alamofire for
request(df 173 → scale 0.392) demoted the symbol actually namedrequestto rank 3, behindrequests— a prefix match. Searching foralamofire(scale 0.551) put it at rank 4, behindAlamofireExtended. Same shape on codegraph itself:vitest(scale 0.445) fell to rank 2. The user typed the name; burying it under a longer name that merely starts with it is never right.That gives a principled bound, not a taste call: the prefix arm pays
10 + 30 * ratiowithratio < 1, so it approaches but never reaches 40. A floored whole-query exact match pays80 * floor.80 * 0.6 = 48 > 40— above the prefix arm at any corpus frequency. The sweep agrees empirically: 0.60 is the lowest tested value restoring exact-name recall@1 to the undiscounted baseline on all five repos.Exact-name recall@1 (top-25 common names; floor 1.0 = discount off):
gin's 0.88 is identical at every floor including discount-off, so it is not this change — the three misses are framework route nodes (
get /,get /example) that the query tokenizer splits oddly. Pre-existing, untouched, and I'd rather flag it than let it read as fallout here.And the cost of moving 0.25 → 0.60 is small. Crowd-out in top-10 (lower = more relief):
~95% of the relief, and the exact-name regression is gone. Sweep harness and per-name failure dumps are reproducible from the numbers above; say the word and I'll land them under
scripts/agent-eval/.2. The hot-path cost: the
COUNT(*)I worried about is free. The per-name lookup was the real problem — now fixed.I flagged one
COUNT(*)persearchNodesand offered to cache it on the instance. Measured, that was the wrong thing to worry about: it costs 0.002 ms. No cache needed, and I'd rather not add invalidation machinery for it.The cost I did not flag is where the time went.
nameCorpusStatscounted names with:which matches neither index — not
idx_nodes_name(BINARY collation) noridx_nodes_lower_name(expression form).EXPLAIN QUERY PLANconfirmsSCAN nodes USING COVERING INDEX idx_nodes_name: a full scan per distinct candidate name, so ~50 scans on every search, growing with the corpus.Written as
lower(name) = ?it seeksidx_nodes_lower_name(SEARCH … (<expr>=?)). Per search, 50 distinct candidate names:COLLATE NOCASElower(name)The seek is flat in corpus size; the scan is linear in it.
getNodesByLowerNamein the same file already usedlower(name) = ?, so theCOLLATE NOCASEform I reached for was also out of step with the established idiom here. With this fixed the whole feature costs ~0.08 ms per search on a 62k-node repo, so the memoized-per-search design needs no further caching.Agent A/B
The honest summary: the agent-level A/B cannot resolve this change. Deterministic probes can, and do.
I ran both harnesses CLAUDE.md asks for,
--model sonnet --effort high, 2 runs/arm.First attempt was void and I'm reporting it rather than dropping it. I ran
ab-new-vs-baseline.shon an implementation task ("add a system check for Meta ordering…"). Both arms answered it with 16–44Bashgreps and zero codegraph calls — the agent never queried the index, so the arms differed by run-to-run noise alone (176s/$0.87 vs 418s/$2.70). That gap looks like a large win for this branch and is nothing of the sort. Discarded.Isolating A/B — new build vs
572d22b, both codegraph-on, flow questions (2 runs/arm):Parity. Zero file reads in all 8 arms, and the between-arm spread is smaller than the within-arm spread (the excalidraw new arm alone ran 1 and 3 explores on identical input). I can't claim an agent-level win from this and I'm not going to.
Pass bar — with vs without codegraph (2 runs/arm), unaffected by this change but confirming no regression:
django clears it on every axis. excalidraw's without-arm is faster while doing 10–11 reads — the small-repo floor effect the README already documents, not something this branch caused.
Why the A/B is the wrong instrument here, and what is the right one. A flow question is answered from
buildFlowFromNamedSymbolsover a precise symbol bag; the exact-name tie-break this PR touches barely participates. So I pointed a deterministic probe at the condition the lever actually keys on — a query containing a corpus-common symbol name — and diffed the top-10 with the discount on vs off:django (62,080 nodes) — real reordering, in the intended direction:
setup handling during request processingsetup,request,setup, then 7× testsetUprequest×5,Request,request@tests×3 — every testsetUpleaves the top-10where is setup configured at startupsetup×3,configured, 6×setUpconfiguredpromoted to rank 1,setUpdemotedwhere is meta configured at startupMeta×10 — the whole pageconfiguredpromoted to rank 1excalidraw (11,161 nodes) — inert. Every query's ordering is byte-identical; only absolute scores shift uniformly (75→51). Its corpus-common names are import module nodes (
react,clsx,vitest) that all share one name, so a uniform discount cannot reorder them among themselves.That is a coherent result rather than a flattering one: the lever fires on a large, symbol-name-rich corpus and is a no-op elsewhere. It never reordered anything for the worse in these probes. But it is a retrieval tie-break whose effect is below the resolution of the agent A/B this repo gates on, so I'd rather hand you that finding plainly than dress up a null result. Left as a draft on that basis — your call whether the deterministic evidence is the right bar for a change of this shape.
Tests
__tests__/name-match-idf.test.ts, 14 tests — all fail onmain, pass here:nameMatchIdfScale: unique name → exactly 1; monotone decay (strict until the floor binds); a very common name discounted to the floor but non-zero; degenerate inputs (df=0,total=1,NaN,df > total) safe80 * FLOORexceeds the prefix arm's supremum of 40, plus the concrete Alamofirerequestvsrequestsinversion that motivated itnameMatchIdfScale(1097, 62080)and(173, 4512)at the floor, so a future "simplification" back to 0.25 fails loudly instead of silently going inertnameMatchBonus: unchanged without corpus stats; unchanged for a rare name; discounted for a common one; prefix/substring arms untouchedusagestill surfacesusage()in the top 3Ranking-adjacent suites, all green at the new floor:
context-ranking,explore-corroboration-ranking,explore-nl-stopword-collision,explore-result-count,symbol-lookup,same-name-disambiguation,field-name-retrieval,search-query-parser— 61 passed.tsc --noEmitclean.Scope — what this does NOT fix
#982's 8-file minimal reproduction is not fixed by this change. In that repro only two symbols are named
usageout of ~25 nodes. The token is rare there, so the IDF scale is ≈0.8 — nearly inert, which is correct behaviour for a corpus-frequency lever. Measured: the helpers still land at 62.8 vs the top product symbol at 51.2.The repro demonstrates the mechanism (the exact-name bonus dominates) but not the condition this lever keys on (the name being common). What fixes that shape is the issue's complementary, deliberately-separate path lever — user-extensible de-prioritization via
codegraph.json— which is #1463. #982 explicitly asks for the two to stay distinct, and there's a test here pinning the rare-name case so nobody later "fixes" the inertness by removing the floor.The two PRs are independent, both off
main, and touch the same region ofscorePathRelevance's neighbours — whichever lands second needs a light rebase, in either order.