🐛 fix(search): reject out-of-range --decay-floor and negative --half-life - #72
Conversation
…life --decay-floor landed with no upper bound. The recency multiplier is math.Max(0.5^(age/half-life), DecayFloor), so any floor above 1 wins the max for every node and becomes a flat GAIN on raw cosine — a flag whose help text reads "lower clamp on the recency multiplier" silently turns into a score amplifier, and scores leave the [-1, 1] cosine range the rest of the ranking assumes. A negative floor is worse than redundant: it silently re-enables the exact recency inversion f4c9824 was merged to fix (an old-but-relevant node crushed to 0.0000 below a mediocre fresh one). A negative --half-life was accepted silently too, byte-identical to no decay, while the HTTP surface already rejects half_life < 0 with 400. Validate both at parse time in okfctl-search before DecayOptions is built: reject --decay-floor outside [0, 1] (inclusive) and a negative --half-life, reusing the apiserver's "non-negative number of days" wording so the two surfaces agree. --decay-floor is validated first, so a both-bad invocation yields one deterministic error. No library-side re-clamp — a silent clamp would change behavior for in-range values, the very "quietly rewriting it" this rejects. The factor() doc comment now records that DecayFloor is required in [0, 1] and the CLI enforces it, so [DecayFloor, 1] is a real interval by construction; the stale "#65 not landed" note on the API search handler is corrected in passing. Closes #71
cwest
left a comment
There was a problem hiding this comment.
No changes needed.
The fix rejects --decay-floor outside [0, 1] and a negative --half-life at parse time, before DecayOptions is built, which is where the API surface already draws the line. The [0, 1] bound is inclusive on both ends: a floor of exactly 1 (no decay) is accepted, and 0 restores unbounded decay. When both flags are bad only the --decay-floor error surfaces, and that ordering is pinned by a test so a reorder can't silently change which message a user sees.
I ran the whole thing end to end against a freshly built binary, not just the unit suite. Bad input fires: --decay-floor 2 and -1 exit 1 naming the flag and the range, --half-life -5 exits 1 with the same non-negative wording the HTTP handler uses, and both-bad yields the single deterministic decay-floor error. Good input stays silent: the two boundaries, the 0.25 default, and the flags-omitted path all exit 0. The three existing controls pass unmodified, and the negative case carries a comment recording that a negative floor re-opens the #65 inversion, so the test is guarding a real regression vector rather than restating the zero case.
gofmt is clean, go vet is clean, the full -race suite passes across all packages, and the conformance gate is green. The apiserver change is the one stale-comment correction and touches no logic. Scope holds — no HTTP plumbing, no default change, no --min-relevance.
Closes #71.
What
--decay-floorlanded inf4c9824with no upper bound. The recency multiplier isso any floor above 1 wins the
math.Maxfor every node and becomes a flat GAIN onraw cosine — a flag whose help text reads "lower clamp on the recency multiplier"
silently amplifies scores out of the
[-1, 1]cosine range. A negative floorsilently re-enables the exact recency inversion
f4c9824fixed. A negative--half-lifewas accepted silently too, byte-identical to no decay, while the HTTPsurface (
internal/apiserver/search.go) already rejectshalf_life < 0with 400.This validates both at parse time in
cmd/okfctl-search/main.gobeforesearch.DecayOptionsis built:--decay-flooroutside[0, 1](inclusive) is rejected, error names the flag and the range.--half-lifeis rejected, reusing the apiserver'smust be a non-negative number of dayswording so the two surfaces agree.--decay-flooris validated first, so a both-bad invocation yields a deterministic single error (pinned in a test).factor()doc comment (internal/search/query.go) now records thatDecayFlooris required in[0, 1]and the CLI enforces it, so[DecayFloor, 1]is a real interval by construction.#65 not landedcomment on the API search handler is corrected.Tests — both directions proven
New table/tests in
cmd/okfctl-search/main_test.go. Positive controls assert themessage text, not just the exit code; the three named existing controls pass
unmodified.
Gates (all three layers)
Real-corpus controls —
~/src/knowledge-base/bundles/knowledge(262.mdfiles)Base =
main@e63969b; after = this branch. Decay is a no-op on this corpus(zero nodes carry
generated.at), so the good-input controls must be identicalbefore/after — proving the validation does not fire on good input:
validateOK: bundle conforms to the OKF spec floorOK: …(identical)lint --strictline count--semantic "docker container runtime" --k 20 --half-life 90diffclean)Top-5 (unchanged):
0.3749 infra/serverless-gpu-compute-modal-vs-gke.md,0.3426 research/ai-fluency.md,0.2981 research/agent-skill-app-substitution-evidence.md, …The fix fires on the same corpus with bad input (before: silent exit 0):