π fix(search): bound recency decay β clamp the multiplier and expose the relevance floor - #67
Conversation
cwest
left a comment
There was a problem hiding this comment.
The code is right and I verified it end-to-end. The decay clamp, the standalone relevance floor, the corrected help text, and every control reproduce against the built CLI: half-life 90 at the default floor keeps old-exact on top at 0.2433; --decay-floor 0 restores the unbounded inversion (fresh-weak 0.1121, old-exact 0.0000); half-life 365 at floor 0 lands 0.1135 / 0.0808 digit-for-digit; the raw baseline is 0.9730 / 0.1140. --min-relevance drops the sub-floor node and applies with no --half-life, which is the fix for the hardwired-zero floor. Full suite passes with -race, gofmt and vet are clean, and the real corpus indexes to 234 nodes with decay inert (no dates), so the top-5 is byte-identical with and without the flag.
One thing to correct before this lands, in the PR description rather than the code: the Layer 3 section reports --min-relevance 0 (default) -> 234 results (admits everything), presented as run output. For that query the CLI returns 229, not 234. 234 is the indexed-node count; the per-query result set is smaller because nodes with a non-positive score against the query vector aren't returned (I get 229 for this query, 233 and 231 for two others, never 234). The movement to 3 is real and the fix is proven, but the before-count as pasted is a number the command doesn't emit. Fix the figure so the evidence matches what the CLI prints, or relabel it as the indexed-node total distinct from the per-query result count.
β¦the relevance floor --half-life documented a guarantee no CLI invocation could obtain: "reorders survivors, never promotes an irrelevant-but-fresh node". Two independent gaps made it unreachable. 1. Clamp the recency multiplier. DecayOptions.factor computed 0.5^(age/half-life) with no lower bound, so an old-but-perfect match decayed toward zero and could be crushed below a mediocre fresh one (0.0000 at half-life 90). Add DecayFloor (Β§5.2/Β§13.1), a scale-free math.Max clamp that assumes nothing about the embedder cosine distribution, wired to a new --decay-floor flag defaulting to 0.25. --decay-floor 0 restores the exact unbounded behavior digit-for-digit for opt-out backward compat. 2. Expose the relevance floor. DecayOptions.MinRelevance (a raw-cosine floor applied before decay reorders) was hardwired to 0 in the plugin with no flag β correct in the library, dead code from the CLI. Add --min-relevance, default kept at 0 because raw cosine distributions differ sharply between embedders (hash dim 64 vs model2vec dim 256), so any non-zero default is right for one and wrong for the other. It applies standalone (no --half-life needed). 3. Correct the --half-life help text to describe what the command actually guarantees once bounded, pointing at the two flags that deliver it. Closes #65
cwest
left a comment
There was a problem hiding this comment.
The code is right and the rebase is clean: PR 68's slice-scope filters and this card's --decay-floor / --min-relevance flags coexist, the clamp is a single math.Max on the recency multiplier, the full suite is green under -race, and the two-node repro reproduces the issue digit-for-digit (raw 0.9730/0.1140; half-life 365 unbounded 0.1135/0.0808; half-life 90 at the default floor puts old-exact back on top at 0.2433). Both decay controls hold.
One thing to fix before this lands, and it's in the pasted evidence, not the code. The Layer-3 --min-relevance block now reports --min-relevance 0 (default) -> 229 results and explains it as "nodes scoring non-positive against the query vector are never returned." Neither holds on this head. Running the same query on fbdba69:
--min-relevance 0 -> 234 rows (all indexed nodes)
Of those 234, 226 score positive, 3 score exactly 0.0000, and 5 score negative (e.g. casey/economy-of-action.md at -0.1035). The ranker appends every entry regardless of sign, so at the default floor every indexed node comes back β the result set equals the indexed-node count here, it isn't smaller. That makes the movement 234 -> 3 and the drop 231 sub-floor nodes, not 229 -> 3 / 226.
The 0.29 -> 3 count and the top-3 (granting-an-agent-google-access 0.3493 / seo-aio 0.3146 / cloudflare-full-strict 0.2963) are correct, and the direction of the finding is right. Fix the two figures and drop the "non-positive nodes are never returned" sentence, since they are returned. Everything else is ready.
cwest
left a comment
There was a problem hiding this comment.
No changes needed.
The evidence line that bounced twice now matches ground truth. Rebuilt the binary at this head and ran the literal query on the real corpus: --min-relevance 0 returns 234 rows (226 positive, 3 zero, 5 negative β every indexed node, since rank() at internal/search/query.go:318 appends each scored entry regardless of sign), and --min-relevance 0.29 returns 3 (granting-an-agent-google-access 0.3493 / seo-aio 0.3146 / cloudflare-full-strict-github-pages-origin 0.2963). The body's 234 -> 3 (drop 231) is right, and the earlier claim that non-positive-scoring nodes are never returned is gone.
Code, the PR-68 rebase (slice scope filters and the decay flags coexist), the full -race suite, and both decay controls were verified digit-for-digit on this commit in the prior round; the head is unchanged. Merge-readiness is green: mergeable, checks green, no unresolved threads.
GET /api/v1/search applied recency decay with no lower clamp, so a strong old match was crushed toward zero and could be reordered below a weak fresh one β the HTTP surface disagreed with the CLI for the same query, bundle, index and half_life. The endpoint also silently ignored decay_floor and min_relevance (HTTP 200, output unchanged), with no way to opt in. Root cause was a merge-order accident: #65/#67 landed the DecayFloor clamp and defaulted it to 0.25 on the CLI, but internal/apiserver/search.go built its DecayOptions with DecayFloor left at zero (unbounded) and MinRelevance hardcoded to 0, and its comment still claimed those were the exact options the CLI builds. - Hoist the shared default to one named constant, search.DefaultDecayFloor, in the package both surfaces import; the CLI flag default and the API default now read it, so the two cannot drift on the next merge. - Default the API DecayFloor to that constant. - Accept decay_floor (validated [0,1]) and min_relevance (non-negative) as query params, returning 400 on out-of-range with the same constraint language the CLI uses; empty reads as unset and takes the default. - Build decay only when half_life > 0 || min_relevance > 0, so an absent half_life is byte-identical to today β the floor never engages without decay. - Fix the stale comment. Adds a cross-surface equivalence harness asserting the HTTP endpoint and search.QueryWith produce identical rankings for the same inputs, table-driven over half_life x decay_floor, on the fixture and (env-guarded) the real 234-node corpus. The decay clock is injectable so both paths measure ages from the same instant. Closes #74
Closes #65.
--half-lifedocumented a guarantee no CLI invocation could obtain β "reorders survivors, never promotes an irrelevant-but-fresh node." Two independent gaps made it unreachable, both fixed here.What changed
DecayOptions.factorcomputed0.5^(age/half-life)with no lower bound, so an old-but-perfect match decayed toward zero and could be crushed below a mediocre fresh one. NewDecayOptions.DecayFlooris a scale-freemath.Maxclamp (it assumes nothing about the cosine distribution of whichever embedder is loaded), wired to a new--decay-floorflag defaulting to0.25.--decay-floor 0restores the exact unbounded behavior.DecayOptions.MinRelevance(a raw-cosine floor applied before decay reorders) was hardwired to0in the plugin with no flag β correct in the library atinternal/search/query.go, dead code from the CLI. New--min-relevanceflag, default kept at 0: raw cosine distributions differ sharply betweenhash(dim 64) andmodel2vec(dim 256), so any non-zero default is right for one and wrong for the other. It applies standalone (no--half-liferequired).--half-lifehelp text to describe what the command actually guarantees once bounded, pointing at the two flags that deliver it.Spec: Β§5.2 governs
generated.at, the field decay reads; Β§13.1 the legacytimestampfallback. Upstream spec verified Version 0.2 today.Conformance gate β run output (not a claim)
Layer 1 β spec-conformance suite (
-run Conformance -race)Layer 2 β full suite (
gofmt -l . && go vet ./... && go test ./... -race)New tests (all PASS):
TestDecayFactor_ClampedToFloorβ the multiplier clamps at DecayFloor.TestDecayFactor_FloorZeroIsUnboundedβ DecayFloor 0 == unbounded0.5^x, digit-for-digit.TestQuery_DecayFloor_KeepsStrongOldAboveFreshWeakβ positive control: the inversion is fixed (asserts ordering + non-zero score).TestQuery_DecayFloor_StillReordersComparableSurvivorsβ load-bearing negative control: a clamp that neutralizes decay is broken; comparable stale/fresh nodes still reorder at floor 0.25.TestPlugin_DecayFloorPositiveControl/TestPlugin_DecayFloorNegativeControlβ CLI positive + second negative control.TestPlugin_MinRelevanceBothDirectionsβ--min-relevancedrops sub-floor (positive) and default 0 admits everything (negative).TestPlugin_HelpTextDescribesClampedGuaranteeβ help no longer makes the unreachable claim.Layer 3 β REAL CORPUS (
~/src/knowledge-base/bundles/knowledge)Indexes to 234 concept nodes (262
.mdfiles; delta is reservedindex.md/log.mdper Β§3.1):--decay-floorbefore/after (query"tannin structure astringency in wine", top-5). The corpus carries zerogenerated.at/timestampprovenance (verified:grep -rl 'generated:'β 0,grep -rlE '^timestamp:'β 0), so decay is a no-op on it by construction β every node's factor is 1 regardless of the floor. The top-5 is therefore byte-identical with the default--decay-floor 0.25, with--decay-floor 0, and with no decay at all. This is the control proving the change does not silently alter ranking where there is no date to decay on:--min-relevancebefore/after (real corpus, count movement). This floor works on raw cosine and needs no dates, so it moves on the real corpus.rank()atinternal/search/query.go:318appends every scored entry regardless of sign, so at the default--min-relevance 0the result set equals the full indexed-node count. Captured on this head (query"tannin structure astringency in wine",--k 1000, 3 deterministic runs, all 234): 234 results at--min-relevance 0(226 positive, 3 exactly 0.0000, 5 negative), dropping to 3 at--min-relevance 0.29:The
234 -> 3movement proves the previously-dead relevance floor is now reachable from the CLI.v0.1 fixture β legacy
timestamp->generated.atfallback (Β§13.1)Two-node fixture carrying only the legacy
timestampfield (nogenerated.at), proving the fallback still drives decay and the clamp fires on it:(Exact figures track
time.Now(); the old node is ~1309 days old at run time, so0.5^(1309/365)*0.9730 = 0.0808unbounded, clamped to0.9730*0.25 = 0.2433.)Both controls
--decay-floor 0.25keepsold-exactabovefresh-weakat half-life 90, non-zero β asserted by ordering, not score.TestQuery_DecayFloor_StillReordersComparableSurvivors), and--decay-floor 0restores unbounded behavior digit-for-digit.