Skip to content

πŸ› fix(search): bound recency decay β€” clamp the multiplier and expose the relevance floor - #67

Merged
cwest merged 1 commit into
mainfrom
wt/t_a9312909
Aug 3, 2026
Merged

πŸ› fix(search): bound recency decay β€” clamp the multiplier and expose the relevance floor#67
cwest merged 1 commit into
mainfrom
wt/t_a9312909

Conversation

@cwest

@cwest cwest commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Closes #65.

--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, both fixed here.

What changed

  1. Clamp the decay 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. New DecayOptions.DecayFloor is a scale-free math.Max clamp (it assumes nothing about the cosine distribution of whichever embedder is loaded), wired to a new --decay-floor flag defaulting to 0.25. --decay-floor 0 restores the exact unbounded behavior.
  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 at internal/search/query.go, dead code from the CLI. New --min-relevance flag, default kept at 0: raw cosine distributions differ sharply between hash (dim 64) and model2vec (dim 256), so any non-zero default is right for one and wrong for the other. It applies standalone (no --half-life required).
  3. Correct the --half-life help 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 legacy timestamp fallback. Upstream spec verified Version 0.2 today.

Conformance gate β€” run output (not a claim)

Layer 1 β€” spec-conformance suite (-run Conformance -race)

ok  github.com/cwest/okfctl/internal/okf   1.444s   (8 conformance tests PASS)
ok  github.com/cwest/okfctl/cmd            1.734s   (3 conformance tests PASS)

Layer 2 β€” full suite (gofmt -l . && go vet ./... && go test ./... -race)

gofmt -l .   -> (empty, clean)
go vet ./... -> (clean)

?   github.com/cwest/okfctl                     [no test files]
ok  github.com/cwest/okfctl/cmd                 9.218s
ok  github.com/cwest/okfctl/cmd/okfctl-api      1.518s
ok  github.com/cwest/okfctl/cmd/okfctl-search   1.905s
ok  github.com/cwest/okfctl/internal/apiserver  1.712s
ok  github.com/cwest/okfctl/internal/okf        6.100s
ok  github.com/cwest/okfctl/internal/okfconfig  2.244s
ok  github.com/cwest/okfctl/internal/plugin     2.601s
ok  github.com/cwest/okfctl/internal/search     2.455s

New tests (all PASS):

  • TestDecayFactor_ClampedToFloor β€” the multiplier clamps at DecayFloor.
  • TestDecayFactor_FloorZeroIsUnbounded β€” DecayFloor 0 == unbounded 0.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-relevance drops 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 .md files; delta is reserved index.md/log.md per Β§3.1):

indexed 234 node(s) with hash-test-embedder (dim 64)

--decay-floor before/after (query "tannin structure astringency in wine", top-5). The corpus carries zero generated.at/timestamp provenance (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:

no decay / --decay-floor 0.25 / --decay-floor 0  (all three identical):
0.3493  research/granting-an-agent-google-access.md
0.3146  content/seo-aio.md
0.2963  domains/cloudflare-full-strict-github-pages-origin.md
0.2804  security/auth/kba-security-questions-anti-pattern.md
0.2795  security/auth/sso-and-identity-protocols.md

--min-relevance before/after (real corpus, count movement). This floor works on raw cosine and needs no dates, so it moves on the real corpus. rank() at internal/search/query.go:318 appends every scored entry regardless of sign, so at the default --min-relevance 0 the 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:

--min-relevance 0    (default) -> 234 results (admits everything the ranker returned)
--min-relevance 0.29           ->   3 results (drops 231 sub-floor nodes)
   0.3493  research/granting-an-agent-google-access.md
   0.3146  content/seo-aio.md
   0.2963  domains/cloudflare-full-strict-github-pages-origin.md

The 234 -> 3 movement proves the previously-dead relevance floor is now reachable from the CLI.

v0.1 fixture β€” legacy timestamp -> generated.at fallback (Β§13.1)

Two-node fixture carrying only the legacy timestamp field (no generated.at), proving the fallback still drives decay and the clamp fires on it:

--half-life 90, --decay-floor 0  (unbounded, the bug):
0.1121  docs/fresh-weak.md
0.0000  docs/old-exact.md          <- correct answer crushed to zero

--half-life 90, --decay-floor 0.25 (default, the fix):
0.2433  docs/old-exact.md          <- restored to top, no longer 0
0.1121  docs/fresh-weak.md

--half-life 365, --decay-floor 0  (digit-for-digit backward compat):
0.1135  docs/fresh-weak.md
0.0808  docs/old-exact.md          <- matches the issue's 0.1135 / 0.0808

--half-life 365, --decay-floor 0.25 (default):
0.2433  docs/old-exact.md
0.1135  docs/fresh-weak.md

(Exact figures track time.Now(); the old node is ~1309 days old at run time, so 0.5^(1309/365)*0.9730 = 0.0808 unbounded, clamped to 0.9730*0.25 = 0.2433.)

Both controls

  • Positive (the fix fires): default --decay-floor 0.25 keeps old-exact above fresh-weak at half-life 90, non-zero β€” asserted by ordering, not score.
  • Negative (the fix stays silent where correct): comparable stale/fresh nodes still reorder toward fresh at floor 0.25 (TestQuery_DecayFloor_StillReordersComparableSurvivors), and --decay-floor 0 restores unbounded behavior digit-for-digit.

@cwest cwest left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 cwest left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
cwest marked this pull request as ready for review August 3, 2026 04:05

@cwest cwest left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@cwest
cwest merged commit f4c9824 into main Aug 3, 2026
1 check passed
@cwest
cwest deleted the wt/t_a9312909 branch August 3, 2026 04:09
cwest added a commit that referenced this pull request Aug 3, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

πŸ› fix(search): bound recency decay β€” the documented relevance floor is unreachable

1 participant