From 2233f247d72e1f31d3f7212aa9e0b7c874283131 Mon Sep 17 00:00:00 2001 From: y$un_ Date: Fri, 4 Sep 2026 01:36:02 -0400 Subject: [PATCH 1/7] fix(learnings-search): rank by whole-word relevance, insight before naming (#2762) `--query` gets worse as the query gets more specific. The filter is token-OR over substrings, so every word admits its own set of entries; the union is then ranked by confidence alone and cut to --limit (10 by default). Nothing errors and nothing comes back empty, so a caller searching for something specific receives ten confident, well-formed, unrelated entries and reads them as a complete answer. That is a false absence, which is the dangerous direction to fail in. Recall and relevance are now answered separately. RECALL is unchanged: substring containment across key, insight and files. Every entry that matched before still matches -- verified set-identical to the base binary across 72 query-and-store combinations, with the no-query and --type paths byte-identical across 40 more. RELEVANCE is new, and is counted over DISTINCT query tokens found as WHOLE WORDS. It has two tiers, because the two obvious single-tier designs each fail: - Scoring key, insight and files together lets verbosity beat quality. Keys and file paths are long and descriptive by convention, so a thin entry whose name or path happens to carry the query words outranks an insight that answers it. - Scoring only the insight overcorrects. An entry named exactly after the query then scores zero and loses to incidental prose, which is the same truncation the fix exists to prevent. So what an entry SAYS decides first, and what it is ABOUT (key and file paths) only orders entries that already tie on substance. Confidence breaks a remaining tie, then recency. Whole-word matching is what makes a hit mean the concept is present: a recall net that accepts 'bug' inside 'debug' or 'cause' inside 'because' is right for finding candidates and useless for ordering them. Word characters are identified by case-folding rather than an ASCII range, so accented letters do not fake a word boundary while scripts without case keep an embedded Latin term findable. Internal ranking fields are stripped from every row at parse time. They live in an underscore namespace and gstack-learnings-log re-serializes whatever it is handed, so without the strip a stored _field would arrive as a live sort key on the gstack-skill-start --limit 3 call that runs in every session. Stripping the namespace once covers fields added later, rather than relying on each new one to remember to initialize itself. When a query matched more than the limit shows, the summary line states the fraction once ('LEARNINGS: 10 of 13 matched'), following the phrasing already used in gstack-retro-metrics. It is gated on a non-empty query because the preamble call must not grow a line, and written to stdout because this block redirects its own stderr to /dev/null. Every behavioural claim above is pinned by a test that fails when the corresponding line is reverted; all nine such mutants were verified to die. Fixes #2762 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GgbYViR1AYn2RqfZaTYd7o --- bin/gstack-learnings-search | 107 ++++++++++- test/gstack-learnings-search.test.ts | 260 ++++++++++++++++++++++++++- 2 files changed, 362 insertions(+), 5 deletions(-) diff --git a/bin/gstack-learnings-search b/bin/gstack-learnings-search index d7038e8218..9d0cb164cf 100755 --- a/bin/gstack-learnings-search +++ b/bin/gstack-learnings-search @@ -66,6 +66,37 @@ const now = Date.now(); const type = process.env.GSTACK_SEARCH_TYPE || ''; const queryRaw = (process.env.GSTACK_SEARCH_QUERY || '').toLowerCase(); const queryTokens = queryRaw.split(/\s+/).filter(Boolean); +// #2762: score over DISTINCT tokens. Repeating a word must not let an entry +// matching one concept outrank an entry matching several. Deduping cannot change +// the filter's result, so this is a ranking-only concern. +const scoreTokens = Array.from(new Set(queryTokens)); + +// #2762: word-boundary containment, used for RELEVANCE only (never for recall). +// Hand-rolled rather than a RegExp because building one from caller input needs +// the standard escape idiom, and that idiom requires a literal dollar sign -- +// which cannot appear in this block, since the whole thing is a double-quoted +// bash string. (Backslash escapes are fine here and this file already uses +// them; the dollar sign is the constraint.) Hyphens, dots and slashes are all +// separators, so hyphenated keys and file paths yield their individual words. +function isWordChar(c) { + // Case-folding identifies letters without a character-class escape: a letter + // has distinct cases, so accented Latin and Cyrillic count as word characters + // and 'caf' stops matching inside 'cafe' with an accent. Scripts without case + // (CJK) stay separators, which is what keeps an embedded Latin term findable + // in text that has no spaces around it. + return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c === '_' + || c.toLowerCase() !== c.toUpperCase(); +} +function hasWholeWord(hay, tok) { + let i = hay.indexOf(tok); + while (i !== -1) { + const before = i === 0 ? '' : hay.charAt(i - 1); + const after = i + tok.length >= hay.length ? '' : hay.charAt(i + tok.length); + if (!isWordChar(before) && !isWordChar(after)) return true; + i = hay.indexOf(tok, i + 1); + } + return false; +} const limit = parseInt(process.env.GSTACK_SEARCH_LIMIT || '10', 10); const entries = []; @@ -77,6 +108,14 @@ for (const taggedLine of lines) { const e = JSON.parse(line); if (!e.key || !e.type) continue; + // #2762: strip every underscore-prefixed key the moment a row is parsed. + // Internal ranking fields live in this namespace, and gstack-learnings-log + // re-serializes whatever it is handed, so a stored _field would otherwise + // arrive as a live sort key. Doing it here, once, immunizes every internal + // field including ones added later, instead of relying on each new one + // remembering to initialize itself. + for (const k of Object.keys(e)) { if (k.charAt(0) === '_') delete e[k]; } + // Apply confidence decay: observed/inferred lose 1pt per 30 days let conf = e.confidence || 5; if (e.source === 'observed' || e.source === 'inferred') { @@ -85,6 +124,14 @@ for (const taggedLine of lines) { } e._effectiveConfidence = conf; + // #2762: initialize the relevance score here, beside the other internal + // fields, NOT in the query filter. The filter runs only when a query was + // given, so a value assigned there would leave the no-query path reading + // whatever JSON.parse produced -- and gstack-learnings-log re-serializes + // unknown keys, so a stored _tokenHits would become a live sort key on the + // preamble call that runs in every session. + e._tokenHits = 0; + // Determine if this is from the current project or cross-project // Cross-project entries are tagged for display const isCrossProject = sourceTag === 'cross'; @@ -120,13 +167,53 @@ let results = Array.from(seen.values()); if (type) results = results.filter(e => e.type === type); // Filter by query (token-OR: match if ANY whitespace-split token appears in ANY haystack) +// #2762: recall and relevance are now two different questions, answered separately. +// +// RECALL (this predicate) is unchanged from before: substring containment across +// key, insight and files. Every entry that used to come back still comes back. +// +// RELEVANCE (_tokenHits) is a stricter, narrower measure, because reusing the +// recall net as a ranking signal ranks badly in two ways that were measured on +// gstack's own shipped queries: +// - Substring hits are not concept hits. Under /investigate's shipped query, +// 'bug' hits inside 'debug', 'cause' inside 'because', 'fix' inside +// 'fixture', so prose that merely says 'debug output interleaves because the +// fixture is parallel' outscores a real insight about root causes. Whole-word +// matching is what makes a hit mean the concept is present. +// - Naming is weaker evidence than substance, but it is not worthless. Keys and +// file paths are long and descriptive by convention, so scoring them equally +// with the insight lets verbosity beat quality: a thin entry whose key or file +// list happens to carry the query words would outrank a strong insight that +// answers it. Scoring them at zero overcorrects in the other direction, and an +// entry named exactly after the query then loses to incidental prose. +// +// So relevance is two tiers. What the entry SAYS (insight) decides first; what it +// is ABOUT (key and file paths) only orders entries that already tie on substance. +// Both are recall-neutral: the predicate below is unchanged. if (queryTokens.length > 0) results = results.filter(e => { - const haystacks = [(e.key || '').toLowerCase(), (e.insight || '').toLowerCase(), ...(e.files || []).map(f => f.toLowerCase())]; - return queryTokens.some(tok => haystacks.some(h => h.includes(tok))); + const key = (e.key || '').toLowerCase(); + const insight = (e.insight || '').toLowerCase(); + const files = (e.files || []).map(f => f.toLowerCase()); + e._insightHits = scoreTokens.filter(tok => hasWholeWord(insight, tok)).length; + e._contextHits = scoreTokens.filter(tok => hasWholeWord(key, tok) || files.some(f => hasWholeWord(f, tok))).length; + return queryTokens.some(tok => key.includes(tok) || insight.includes(tok) || files.some(f => f.includes(tok))); }); -// Sort by effective confidence desc, then recency +// How many entries survived the filters, before the limit truncates. Feeds the +// truncation notice on the summary line below. +const totalMatched = results.length; + +// Sort by insight relevance, then naming relevance, then effective confidence, +// then recency (#2762). No-query calls are unaffected: both hit counts are 0 for +// every entry, the first two comparisons always tie, and the order is exactly the +// previous confidence-then-recency one. Queried calls DO re-rank, single-token +// included -- that is the point of the change, and it is why relevance is measured +// on whole words rather than on the substrings the recall filter accepts. results.sort((a, b) => { + const aIns = a._insightHits || 0, bIns = b._insightHits || 0; + if (bIns !== aIns) return bIns - aIns; + const aCtx = a._contextHits || 0, bCtx = b._contextHits || 0; + if (bCtx !== aCtx) return bCtx - aCtx; if (b._effectiveConfidence !== a._effectiveConfidence) return b._effectiveConfidence - a._effectiveConfidence; return new Date(b.ts).getTime() - new Date(a.ts).getTime(); }); @@ -145,8 +232,20 @@ for (const e of results) { } // Summary line +// #2762: when a query matched more than the limit shows, say so. A caller that +// searched for something specific and got back a full page of confident but +// unrelated entries would otherwise read the list as a complete answer. Gated on a +// non-empty query because the no-query preamble call (gstack-skill-start, --limit 3) +// runs every session and must not grow a line. Emitted on stdout because this block +// ends with its own stderr redirected to /dev/null, so stdout is the only channel +// that reaches a caller. Phrased as a whole-and-part after gstack-retro-metrics +// ('showing 300 of %d'), so the reader never has to add two numbers together, and +// stated once rather than beside a second count of the same number. const counts = Object.entries(byType).map(([t, arr]) => arr.length + ' ' + t + (arr.length > 1 ? 's' : '')); -console.log('LEARNINGS: ' + results.length + ' loaded (' + counts.join(', ') + ')'); +const truncated = queryTokens.length > 0 && totalMatched > results.length; +const loaded = truncated ? results.length + ' of ' + totalMatched + ' matched' : results.length + ' loaded'; +const hint = truncated ? '; raise --limit for the rest' : ''; +console.log('LEARNINGS: ' + loaded + ' (' + counts.join(', ') + ')' + hint); console.log(''); for (const [t, arr] of Object.entries(byType)) { diff --git a/test/gstack-learnings-search.test.ts b/test/gstack-learnings-search.test.ts index ffa0227aed..c74f581144 100644 --- a/test/gstack-learnings-search.test.ts +++ b/test/gstack-learnings-search.test.ts @@ -2,7 +2,7 @@ import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { execFileSync } from 'child_process'; +import { execFileSync, spawnSync } from 'child_process'; const ROOT = path.resolve(import.meta.dir, '..'); const BIN = path.join(ROOT, 'bin', 'gstack-learnings-search'); @@ -45,6 +45,10 @@ beforeAll(() => { afterAll(() => { fs.rmSync(tmpHome, { recursive: true, force: true }); fs.rmSync(tmpCwd, { recursive: true, force: true }); + // #2762: rankCwd is created at module scope, so it must be removed at module + // scope too. A describe-scoped afterAll leaks it whenever a filtered run + // (bun test -t ...) skips that describe. + fs.rmSync(rankCwd, { recursive: true, force: true }); }); describe('gstack-learnings-search token-OR query semantics', () => { @@ -91,3 +95,257 @@ describe('gstack-learnings-search cross-project trust gating', () => { expect(out).not.toContain('foreign-legacy'); }); }); + +// #2762: relevance ranking. The query filter is token-OR over substrings, so a +// broad token can admit most of a store. Ranking on confidence alone then lets a +// high-confidence single-token match outrank an entry that matched every token, +// and the default --limit 10 truncates the exact answer off the end. The caller +// gets ten confident, well-formed, wrong entries and reads them as a complete +// answer -- a false absence, which is the dangerous failure direction. +// +// This fixture lives in its own project dir so the assertions above (which depend +// on a three-entry store) keep their meaning. Every entry is `user-stated` because +// that source is exempt from confidence decay -- an `observed` fixture would drift +// as wall-clock time passes and turn these into date-dependent flakes. Every entry +// is the same `type` so the formatter emits one group and printed order is rank order. +const rankCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-search-rank-cwd-')); +const rankSlug = path.basename(rankCwd).replace(/[^a-zA-Z0-9._-]/g, ''); +const rankProjDir = path.join(tmpHome, 'projects', rankSlug); + +const TARGET = 'verify-preflight-project-line-before-trusting-report'; +// Twelve decoys, each matching ONLY the token `line`, via substring hits inside +// guideline / pipeline / deadline / etc. All outrank the target on confidence. +const DECOY_WORDS = [ + 'guideline', 'pipeline', 'deadline', 'headline', 'baseline', 'timeline', + 'outline', 'airline', 'lifeline', 'sideline', 'streamline', 'underline', +]; + +function rankEntry(over: Record): Record { + return { ts: '2026-05-01T00:00:00Z', skill: 'test', type: 'pattern', confidence: 8, source: 'user-stated', trusted: false, files: [], ...over }; +} + +// #2762 / I11: assert on the printed order as an ARRAY, never with indexOf +// comparisons. indexOf returns -1 for an absent key, and -1 is less than every +// real index, so `indexOf(a) < indexOf(b)` reports green when `a` has vanished +// entirely -- the exact false-absence this suite exists to catch. +function rankedKeys(args: string[]): string[] { + return runRank(args) + .split('\n') + .map(line => /^- \[([^\]]+)\]/.exec(line)) + .filter((m): m is RegExpExecArray => m !== null) + .map(m => m[1]); +} + +function runRank(args: string[]): string { + return execFileSync(BIN, args, { + timeout: 30_000, + env: { ...process.env, GSTACK_HOME: tmpHome }, + cwd: rankCwd, + encoding: 'utf-8', + }); +} + +describe('gstack-learnings-search relevance ranking (#2762)', () => { + beforeAll(() => { + fs.mkdirSync(rankProjDir, { recursive: true }); + const rows = [ + // Matches all three tokens of "preflight project line", at LOWER confidence + // than every decoy. This is the entry the caller is looking for. + rankEntry({ key: TARGET, insight: 'Check the project line in the preflight report before trusting it', confidence: 8 }), + // 1-of-3 matches (`line` only) at max confidence: enough of them to fill the + // default limit on their own. + ...DECOY_WORDS.map((w, i) => rankEntry({ + ts: '2026-05-' + String(4 + i).padStart(2, '0') + 'T00:00:00Z', + key: 'decoy-' + w + '-rule', + insight: 'A ' + w + ' related insight', + confidence: 10, + })), + // Tie-break probes for the query "alpha beta": 2 hits at confidence 9, 2 hits + // at confidence 5, 1 hit at confidence 10. Correct order is 9, 5, 10 -- hits + // outrank confidence, and confidence still breaks a tie between equal hits. + // The lower-confidence row is deliberately the NEWER one, so recency alone + // would order these backwards. Only the confidence tier produces the + // expected order, which is what makes the assertion able to fail. + rankEntry({ ts: '2026-05-01T00:00:00Z', key: 'tiebreak-alpha-beta-high', insight: 'alpha beta both present', confidence: 9 }), + rankEntry({ ts: '2026-06-01T00:00:00Z', key: 'tiebreak-alpha-beta-low', insight: 'alpha beta both present', confidence: 5 }), + rankEntry({ key: 'tiebreak-alpha-solo', insight: 'alpha only here', confidence: 10 }), + // Recency probes for "gamma delta": identical hits AND identical confidence, + // so the third comparison (recency) has to decide. + rankEntry({ ts: '2026-05-01T00:00:00Z', key: 'recency-gamma-delta-older', insight: 'gamma delta pair', confidence: 7 }), + rankEntry({ ts: '2026-06-01T00:00:00Z', key: 'recency-gamma-delta-newer', insight: 'gamma delta pair', confidence: 7 }), + // A row carrying a stored _tokenHits, the shape gstack-learnings-log will + // happily persist because it re-serializes unknown keys. Worst entry in the + // store on every legacy signal: lowest confidence, oldest timestamp. + rankEntry({ ts: '2020-01-01T00:00:00Z', key: 'planted-token-hits', insight: 'isolated poison row', confidence: 1, + _insightHits: 9999, _contextHits: 9999, _tokenHits: 9999, _somethingAddedLater: 9999 }), + // Substring-vs-word probes. The decoy satisfies "cause", "bug" and "fix" only + // as substrings (be-CAUSE, de-BUG, FIX-ture); the real answer contains three + // of them as whole words. + rankEntry({ key: 'nested-substring-decoy', insight: 'debug output ran because the fixture was parallel', confidence: 10 }), + rankEntry({ key: 'nested-whole-word-match', insight: 'form a root cause hypothesis first', confidence: 2 }), + // Key-verbosity probes. The verbose key carries four query tokens; its + // content carries none. The plain key carries none; its content carries one. + rankEntry({ key: 'kappa-lambda-sigma-omega-verbose-key', insight: 'unrelated content', confidence: 4 }), + rankEntry({ key: 'plain-key', insight: 'kappa appears here', confidence: 10 }), + // Naming-tier probes: identical insight relevance (both score 1 on "sigma"), + // so the key/file tier has to break the tie -- and it must beat confidence. + rankEntry({ key: 'tau-rho-xi-named', insight: 'tau noted', confidence: 2 }), + rankEntry({ key: 'unnamed-probe', insight: 'tau noted', confidence: 8 }), + // files is a scored field; give it tokens that appear nowhere else, so a hit + // can only have come from the path. + rankEntry({ key: 'path-carrier', insight: 'nothing relevant here', confidence: 3, files: ['test/zulu/yankee.test.ts'] }), + rankEntry({ key: 'insight-carrier', insight: 'zulu and yankee explained properly', confidence: 3, files: [] }), + // Non-ASCII boundary probes. 'chi' is a real word in the CJK row and only an + // incidental substring in 'chile'. + rankEntry({ key: 'accented-neighbour', insight: 'psi\u00e9 deploy', confidence: 5 }), + rankEntry({ key: 'ascii-neighbour', insight: 'psi deploy', confidence: 5 }), + // Dedup probes. The repeated-token entry must NOT also match the other + // concepts, or repetition inflates both rows equally and the ordering cannot + // reveal whether tokens were deduped. + rankEntry({ key: 'repeated-token-only', insight: 'nu only here', confidence: 9 }), + rankEntry({ key: 'two-distinct-concepts', insight: 'omicron and kirin together', confidence: 2 }), + ]; + fs.writeFileSync(path.join(rankProjDir, 'learnings.jsonl'), rows.map(e => JSON.stringify(e)).join('\n') + '\n'); + }); + + // The reported defect: adding a discriminating token made the search WORSE. + // On the pre-fix binary the first two queries find the target and the third + // does not, even though its key contains all three tokens. + test('an entry matching every query token survives the default limit', () => { + expect(runRank(['--query', 'preflight'])).toContain(TARGET); + expect(runRank(['--query', 'preflight project'])).toContain(TARGET); + expect(runRank(['--query', 'preflight project line'])).toContain(TARGET); + }); + + test('a 3-of-3 match outranks twelve higher-confidence 1-of-3 matches', () => { + expect(rankedKeys(['--query', 'preflight project line'])[0]).toBe(TARGET); + }); + + test('token hits outrank confidence, and confidence still breaks a hit tie', () => { + expect(rankedKeys(['--query', 'alpha beta'])).toEqual([ + 'tiebreak-alpha-beta-high', // 2 hits, confidence 9 + 'tiebreak-alpha-beta-low', // 2 hits, confidence 5 -- hits tie, confidence decides + 'tiebreak-alpha-solo', // 1 hit, confidence 10 -- outranked despite the best confidence + ]); + }); + + test('equal hits and equal confidence still fall through to recency', () => { + expect(rankedKeys(['--query', 'gamma delta'])).toEqual([ + 'recency-gamma-delta-newer', + 'recency-gamma-delta-older', + ]); + }); + + // Relevance applies to single-token queries too, and this is the case that shows + // why it has to. `line` appears in all twelve decoys only inside guideline, + // pipeline, deadline and friends; the target contains it as an actual word. The + // pre-fix binary ranked on confidence alone and truncated the target away. + test('a single token still discriminates a real word from an incidental substring', () => { + const ranked = rankedKeys(['--query', 'line']); + expect(ranked[0]).toBe(TARGET); + // Recall is untouched: the substring-only decoys are all still returned. + expect(ranked).toContain('decoy-underline-rule'); + }); + + test('a truncated query reports the part and the whole, stated once', () => { + const out = runRank(['--query', 'preflight project line']); + expect(out).toContain('LEARNINGS: 10 of 13 matched'); + expect(out).toContain('raise --limit for the rest'); + // The count is stated as a fraction instead of alongside a second copy of itself. + expect(out).not.toContain('10 loaded'); + }); + + test('a query that fits under the limit says nothing about truncation', () => { + const out = runRank(['--query', 'gamma delta']); + expect(out).toContain('recency-gamma-delta-newer'); + expect(out).toContain('loaded'); + expect(out).not.toContain('matched'); + }); + + // The preamble calls this with --limit 3 and no query on every skill invocation + // in every session. It must not grow a line. + test('the no-query preamble path never emits a truncation notice', () => { + const out = runRank(['--limit', '3']); + expect(out).toContain('LEARNINGS: 3 loaded'); + expect(out).not.toContain('matched'); + }); + + // The script ends with the bun stage's own stderr redirected to /dev/null, so + // stdout is the only channel that can reach a caller at all. Assert the notice + // is on it and that the process still succeeds. + test('the truncation notice is delivered on stdout with a zero exit', () => { + const res = spawnSync(BIN, ['--query', 'preflight project line'], { + timeout: 30_000, + env: { ...process.env, GSTACK_HOME: tmpHome }, + cwd: rankCwd, + encoding: 'utf-8', + }); + expect(res.status).toBe(0); + expect(res.stdout).toContain('10 of 13 matched'); + }); + + // A stored internal field must never become a live sort key. The query filter is + // the only writer, so on the no-query path -- which gstack-skill-start runs at + // --limit 3 in every session -- an unstripped field would be read straight off + // disk. gstack-learnings-log persists unknown keys, so this row is a shape the + // supported writer can actually produce, not a hand-edit. The fixture plants the + // current sort fields AND a name that does not exist yet, because the defense is + // the underscore-namespace strip rather than a list of known fields. + test('a stored internal field cannot hijack the no-query preamble ranking', () => { + const ranked = rankedKeys(['--limit', '3']); + expect(ranked).not.toContain('planted-token-hits'); + expect(ranked[0]).not.toBe('planted-token-hits'); + }); + + // Relevance counts whole words, not substrings. Under /investigate's shipped + // query shape, substring scoring gave prose containing "because"/"debug"/ + // "fixture" three free hits and buried the real answer below it. + test('substring-only hits do not score, so incidental prose cannot outrank', () => { + const ranked = rankedKeys(['--query', 'root cause hypothesis bug fix']); + expect(ranked[0]).toBe('nested-whole-word-match'); + // Recall unchanged: the decoy still matches the substring filter and is returned. + expect(ranked).toContain('nested-substring-decoy'); + }); + + // Naming is weaker evidence than substance. A verbose key carrying four query + // tokens must not outrank an insight that actually says one of them. + test('a verbose key never outranks an insight that answers the query', () => { + const ranked = rankedKeys(['--query', 'kappa lambda sigma omega']); + expect(ranked[0]).toBe('plain-key'); + expect(ranked).toContain('kappa-lambda-sigma-omega-verbose-key'); + }); + + // But naming is not worthless: on an insight tie it breaks the tie, ahead of + // confidence. Without this tier an entry named exactly after the query loses to + // incidental prose and can be truncated away. + test('on an insight tie, key and file naming breaks it ahead of confidence', () => { + expect(rankedKeys(['--query', 'tau rho xi'])).toEqual([ + 'tau-rho-xi-named', // insight 1, naming 3, confidence 2 + 'unnamed-probe', // insight 1, naming 0, confidence 8 + ]); + }); + + test('a query token found only in files scores as naming, below a real insight', () => { + const ranked = rankedKeys(['--query', 'zulu yankee']); + expect(ranked[0]).toBe('insight-carrier'); + expect(ranked).toContain('path-carrier'); + }); + + test('a letter with an accent is a word character, so it does not fake a boundary', () => { + const ranked = rankedKeys(['--query', 'psi']); + expect(ranked[0]).toBe('ascii-neighbour'); + }); + + // Repeating a word must not promote an entry matching fewer concepts. Asserted + // as an OUTCOME, not by comparing two runs: a run-vs-run comparison cannot see + // uniform score inflation, so it stays green when the dedup is deleted. + test('a repeated query token cannot outrank an entry matching more concepts', () => { + // Deduped: repeated-token-only scores 1 (nu), two-distinct-concepts scores 2. + // Undeduped it would score 3 for the same one concept and take the lead, even + // though it answers less of the query and the other row is the better match. + expect(rankedKeys(['--query', 'nu nu nu omicron kirin'])).toEqual([ + 'two-distinct-concepts', // 2 distinct hits, confidence 2 + 'repeated-token-only', // 1 distinct hit, confidence 9 + ]); + }); +}); From 1c29eb73c5710a644c4ca18729b813dcba997df1 Mon Sep 17 00:00:00 2001 From: y$un_ Date: Sun, 6 Sep 2026 18:46:06 -0400 Subject: [PATCH 2/7] test(learnings-search): run the binary through bash so the suite executes on Windows The suite invoked the binary directly. gstack-learnings-search is a shebang bash script, and Windows has no shebang handling, so every test in this file died at `execFileSync` with "Executable not found in $PATH" before reaching an assertion -- all 22 of them, including the 16 this file gained for #2762. They did not fail visibly on a green machine; they failed on the platform nobody was reading the log for, so the ranking behaviour this file exists to pin was unverified there. The file is not in the windows-free-tests.yml curated lane either, so nothing else was covering it. Passing the script to `bash` explicitly is the pattern already used by browse/test/learnings-injection.test.ts for the same reason, and it is a no-op on Linux and macOS where the shebang would have been honoured anyway. All 22 pre-existing tests pass unchanged once they can run. Co-Authored-By: Claude Opus 5 (1M context) --- test/gstack-learnings-search.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/gstack-learnings-search.test.ts b/test/gstack-learnings-search.test.ts index c74f581144..9f12737937 100644 --- a/test/gstack-learnings-search.test.ts +++ b/test/gstack-learnings-search.test.ts @@ -15,7 +15,7 @@ const projDir = path.join(tmpHome, 'projects', slug); const otherProjDir = path.join(tmpHome, 'projects', 'other-project'); function run(args: string[]): string { - return execFileSync(BIN, args, { + return execFileSync('bash', [BIN, ...args], { timeout: 30_000, env: { ...process.env, GSTACK_HOME: tmpHome }, cwd: tmpCwd, @@ -137,7 +137,7 @@ function rankedKeys(args: string[]): string[] { } function runRank(args: string[]): string { - return execFileSync(BIN, args, { + return execFileSync('bash', [BIN, ...args], { timeout: 30_000, env: { ...process.env, GSTACK_HOME: tmpHome }, cwd: rankCwd, @@ -274,7 +274,7 @@ describe('gstack-learnings-search relevance ranking (#2762)', () => { // stdout is the only channel that can reach a caller at all. Assert the notice // is on it and that the process still succeeds. test('the truncation notice is delivered on stdout with a zero exit', () => { - const res = spawnSync(BIN, ['--query', 'preflight project line'], { + const res = spawnSync('bash', [BIN, '--query', 'preflight project line'], { timeout: 30_000, env: { ...process.env, GSTACK_HOME: tmpHome }, cwd: rankCwd, From 7dea1d8ef29aef54c7a94d48432fd4f1ad0a75d7 Mon Sep 17 00:00:00 2001 From: y$un_ Date: Sun, 6 Sep 2026 18:46:48 -0400 Subject: [PATCH 3/7] fix(learnings-search): treat an underscore as a word separator when scoring isWordChar counted `_` as a word character, so a snake_case key was one long word and matched none of its parts. The function's own comment says hyphens, dots and slashes are separators "so hyphenated keys and file paths yield their individual words" -- underscore belongs in that list on the same reasoning. It is not a rare shape. gstack-learnings-log's key regex is /^[a-zA-Z0-9_-]+$/, so snake_case keys are supported on the write side, and file paths carry underscores constantly. The result was the same entry ranked on its separator rather than its content: with a store of thirteen rows and the query "preflight project line", the key preflight-project-line ranks 1st and preflight_project_line ranks 13th -- off the page at the default --limit 10, which is the truncation #2762 exists to close. Recall is untouched: the filter matches substrings and never consulted isWordChar. Co-Authored-By: Claude Opus 5 (1M context) --- bin/gstack-learnings-search | 2 +- test/gstack-learnings-search.test.ts | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/bin/gstack-learnings-search b/bin/gstack-learnings-search index 9d0cb164cf..eec684b9e5 100755 --- a/bin/gstack-learnings-search +++ b/bin/gstack-learnings-search @@ -84,7 +84,7 @@ function isWordChar(c) { // and 'caf' stops matching inside 'cafe' with an accent. Scripts without case // (CJK) stay separators, which is what keeps an embedded Latin term findable // in text that has no spaces around it. - return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c === '_' + return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c.toLowerCase() !== c.toUpperCase(); } function hasWholeWord(hay, tok) { diff --git a/test/gstack-learnings-search.test.ts b/test/gstack-learnings-search.test.ts index 9f12737937..8702631064 100644 --- a/test/gstack-learnings-search.test.ts +++ b/test/gstack-learnings-search.test.ts @@ -204,6 +204,13 @@ describe('gstack-learnings-search relevance ranking (#2762)', () => { // reveal whether tokens were deduped. rankEntry({ key: 'repeated-token-only', insight: 'nu only here', confidence: 9 }), rankEntry({ key: 'two-distinct-concepts', insight: 'omicron and kirin together', confidence: 2 }), + // Underscore separator probes. gstack-learnings-log's key regex admits + // [a-zA-Z0-9_-], so snake_case keys are supported and file paths carry + // underscores constantly. If `_` counts as a word character the whole key is + // one word and scores nothing, while a kebab-case key holding the same words + // scores fully -- the same entry ranked on its separator, not its content. + rankEntry({ key: 'iota_upsilon_probe', insight: 'no query words in this text', confidence: 2 }), + rankEntry({ key: 'plain-row-iota', insight: 'no query words in this text', confidence: 10 }), ]; fs.writeFileSync(path.join(rankProjDir, 'learnings.jsonl'), rows.map(e => JSON.stringify(e)).join('\n') + '\n'); }); @@ -348,4 +355,13 @@ describe('gstack-learnings-search relevance ranking (#2762)', () => { 'repeated-token-only', // 1 distinct hit, confidence 9 ]); }); + + // Same entry, same words, different separator. Underscore must break words or a + // snake_case key scores nothing while its kebab-case twin scores fully. + test('an underscore separates words in a key, exactly as a hyphen does', () => { + expect(rankedKeys(['--query', 'iota upsilon'])).toEqual([ + 'iota_upsilon_probe', // 2 naming hits, confidence 2 + 'plain-row-iota', // 1 naming hit, confidence 10 + ]); + }); }); From 942f04ca6adb67aedc02f96382366165b84a1ecb Mon Sep 17 00:00:00 2001 From: y$un_ Date: Sun, 6 Sep 2026 18:47:43 -0400 Subject: [PATCH 4/7] fix(learnings-search): score a base-form query against its regular inflections Whole-word scoring matched the exact form only, so a token was blind to the form prose actually uses. English inflects and the shipped queries do not: "test" could not see "tests", "merge" could not see "merged", "fix" could not see "fixes". That reintroduced the very truncation this feature exists to close, one tier down. Under the shipped `--query "" --limit 5` shape used by /investigate, /qa and ship's adversarial section, an entry whose insight opens "Tests interleave when the runner is parallel" scores ZERO for the query "test", while six unrelated rows each score one naming hit for carrying a ui/panelN.test.ts path. The answer is pushed off a five-row page by six filenames -- an exact match truncated away, which is the failure this change was written to prevent. A token now also scores against its regular inflections. The list holds only suffixes that are true suffixes of the base form; 'flaky' is not flake+y and 'policies' is not policy+ies, so reaching those needs stem rewriting rather than a longer list, and adding them bare would widen the false-positive surface while matching neither. Over-reach is bounded by the same word-boundary check that already applied: 'orbital' still scores nothing for 'orbit'. Recall is unchanged -- the filter never consulted this function -- and the no-query path stays byte-identical, both re-verified against the base binary. Co-Authored-By: Claude Opus 5 (1M context) --- bin/gstack-learnings-search | 17 +++++++++++-- test/gstack-learnings-search.test.ts | 36 ++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/bin/gstack-learnings-search b/bin/gstack-learnings-search index eec684b9e5..c730603160 100755 --- a/bin/gstack-learnings-search +++ b/bin/gstack-learnings-search @@ -87,12 +87,25 @@ function isWordChar(c) { return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c.toLowerCase() !== c.toUpperCase(); } +// #2762: a query is written in the base form; prose is written in whatever form +// the sentence needs. Scoring only the exact form makes a real answer invisible to +// the query it answers, so a token also scores against its regular inflections. +// The list holds only suffixes that are true suffixes OF THE BASE FORM: 'flaky' is +// not flake+y and 'policies' is not policy+ies, so reaching those would take stem +// rewriting, and adding them as bare suffixes would widen the false-positive +// surface while matching neither. Irregulars are out of scope for the same reason. +const INFLECTIONS = ['', 's', 'es', 'd', 'ed', 'ing']; function hasWholeWord(hay, tok) { let i = hay.indexOf(tok); while (i !== -1) { const before = i === 0 ? '' : hay.charAt(i - 1); - const after = i + tok.length >= hay.length ? '' : hay.charAt(i + tok.length); - if (!isWordChar(before) && !isWordChar(after)) return true; + if (!isWordChar(before)) { + const j = i + tok.length; + for (const suf of INFLECTIONS) { + const k = j + suf.length; + if (hay.slice(j, k) === suf && !isWordChar(k >= hay.length ? '' : hay.charAt(k))) return true; + } + } i = hay.indexOf(tok, i + 1); } return false; diff --git a/test/gstack-learnings-search.test.ts b/test/gstack-learnings-search.test.ts index 8702631064..6b3dd0eaf6 100644 --- a/test/gstack-learnings-search.test.ts +++ b/test/gstack-learnings-search.test.ts @@ -204,6 +204,20 @@ describe('gstack-learnings-search relevance ranking (#2762)', () => { // reveal whether tokens were deduped. rankEntry({ key: 'repeated-token-only', insight: 'nu only here', confidence: 9 }), rankEntry({ key: 'two-distinct-concepts', insight: 'omicron and kirin together', confidence: 2 }), + // #2762: inflection probes. A query is written in the base form; prose is + // written in whatever form the sentence needs. The insight below carries one + // regular inflection of each query token and no token verbatim, so under + // exact-form scoring it takes ZERO insight hits and loses to a row that only + // has the words in its NAME -- the shipped `--query "" --limit 5` + // shape, where a real answer saying "tests interleave" lost to decoys whose + // only claim was a `.test.ts` filename. + rankEntry({ key: 'inflected-insight', insight: 'quarks and vortexes pulsed while orbited and drifting', confidence: 2 }), + rankEntry({ key: 'quark-vortex-pulse-orbit-drift-in-the-name', insight: 'nothing to say here', confidence: 10 }), + // Over-reach probe: 'orbital' merely STARTS with the query token. It is + // recalled by the substring filter, so it is present to be ranked, and it + // must score nothing -- suffix tolerance is a closed list of inflections, + // not a prefix match. + rankEntry({ key: 'orbital-overreach-decoy', insight: 'an orbital note about nothing', confidence: 10 }), // Underscore separator probes. gstack-learnings-log's key regex admits // [a-zA-Z0-9_-], so snake_case keys are supported and file paths carry // underscores constantly. If `_` counts as a word character the whole key is @@ -356,6 +370,28 @@ describe('gstack-learnings-search relevance ranking (#2762)', () => { ]); }); + // A query is written in the base form; prose is written in whatever form the + // sentence needs. Scoring only the exact form re-opens the truncation this fix + // exists to close: the answer is in the insight, the query words are only in + // someone else's name, and the name wins. + test('a base-form query scores against the inflected form in the insight', () => { + expect(rankedKeys(['--query', 'quark vortex pulse orbit drift'])).toEqual([ + 'inflected-insight', // 5 insight hits via -s -es -d -ed -ing, confidence 2 + 'quark-vortex-pulse-orbit-drift-in-the-name', // 0 insight, 5 naming, confidence 10 + 'orbital-overreach-decoy', // 0 insight, 0 naming, confidence 10 + ]); + }); + + test('inflection tolerance does not degrade into loose prefix matching', () => { + // 'orbited' is 'orbit' plus a listed inflection and scores; 'orbital' merely + // starts with it and must not, even though it holds the better confidence. + expect(rankedKeys(['--query', 'orbit'])).toEqual([ + 'inflected-insight', // 1 insight hit via -ed, confidence 2 + 'quark-vortex-pulse-orbit-drift-in-the-name', // 0 insight, 1 naming hit + 'orbital-overreach-decoy', // 0 hits, confidence 10 -- recalled, never scored + ]); + }); + // Same entry, same words, different separator. Underscore must break words or a // snake_case key scores nothing while its kebab-case twin scores fully. test('an underscore separates words in a key, exactly as a hyphen does', () => { From f838af4f0e49c21da1624d6f3ee0af47cce00760 Mon Sep 17 00:00:00 2001 From: y$un_ Date: Sun, 6 Sep 2026 18:48:40 -0400 Subject: [PATCH 5/7] fix(learnings-search): a malformed row type can no longer blank the whole store The formatter groups rows into a plain `{}` keyed by each row's own `type`. When a row's type names a property every object inherits -- constructor, toString, valueOf, hasOwnProperty, __proto__ -- the truthiness guard sees the inherited value, the array is never created, and .push throws on a function. The bun stage ends `2>/dev/null || exit 0`, so the throw becomes empty stdout at exit 0. The caller cannot distinguish it from "this project has no learnings": one malformed row silently blanks every other row in the store, on every query. The object is pre-existing, but relevance ranking is what makes it reachable. The malformed row used to sit wherever confidence put it, usually below the --limit cut that hid it; scoring floats it onto the page. On a store of ten confident rows plus one row typed "constructor", the query that matches them all returns ten rows before this change and zero after it -- so the containment belongs with the ranking that exposed it. Object.create(null) has no inherited properties, so a type only collides with a type. Verified against all five reachable poison types. gstack-learnings-log rejects these types on write, so a row like this arrives by hand-edit, an older writer, another tool, or a cross-project store -- not remotely exploitable, but a total-silence path either way. Co-Authored-By: Claude Opus 5 (1M context) --- bin/gstack-learnings-search | 2 +- test/gstack-learnings-search.test.ts | 61 ++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/bin/gstack-learnings-search b/bin/gstack-learnings-search index c730603160..0a37b39698 100755 --- a/bin/gstack-learnings-search +++ b/bin/gstack-learnings-search @@ -237,7 +237,7 @@ results = results.slice(0, limit); if (results.length === 0) process.exit(0); // Format output -const byType = {}; +const byType = Object.create(null); for (const e of results) { const t = e.type || 'unknown'; if (!byType[t]) byType[t] = []; diff --git a/test/gstack-learnings-search.test.ts b/test/gstack-learnings-search.test.ts index 6b3dd0eaf6..f39607e653 100644 --- a/test/gstack-learnings-search.test.ts +++ b/test/gstack-learnings-search.test.ts @@ -49,6 +49,7 @@ afterAll(() => { // scope too. A describe-scoped afterAll leaks it whenever a filtered run // (bun test -t ...) skips that describe. fs.rmSync(rankCwd, { recursive: true, force: true }); + fs.rmSync(badCwd, { recursive: true, force: true }); }); describe('gstack-learnings-search token-OR query semantics', () => { @@ -112,6 +113,12 @@ const rankCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-search-rank-cwd-') const rankSlug = path.basename(rankCwd).replace(/[^a-zA-Z0-9._-]/g, ''); const rankProjDir = path.join(tmpHome, 'projects', rankSlug); +// #2762: the malformed-row store lives apart from every other fixture because the +// defect it probes blanks the ENTIRE run, which would mask each of them in turn. +const badCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-search-bad-cwd-')); +const badSlug = path.basename(badCwd).replace(/[^a-zA-Z0-9._-]/g, ''); +const badProjDir = path.join(tmpHome, 'projects', badSlug); + const TARGET = 'verify-preflight-project-line-before-trusting-report'; // Twelve decoys, each matching ONLY the token `line`, via substring hits inside // guideline / pipeline / deadline / etc. All outrank the target on confidence. @@ -401,3 +408,57 @@ describe('gstack-learnings-search relevance ranking (#2762)', () => { ]); }); }); + +// The formatter groups rows into a plain object keyed by the row's own `type`. +// A row whose type names an inherited Object property finds that property already +// truthy, so the array is never created and .push throws. The bun stage ends with +// `2>/dev/null || exit 0`, which converts the throw into empty stdout at exit 0 -- +// indistinguishable from "this project has no learnings". Ranking is what makes it +// reachable: it promotes the malformed row past the --limit cut that used to hide it. +describe('gstack-learnings-search malformed row containment', () => { + const POISON_TYPES = ['constructor', 'toString', 'valueOf', 'hasOwnProperty', '__proto__']; + + beforeAll(() => { + fs.mkdirSync(badProjDir, { recursive: true }); + }); + + function writeBadStore(poisonType: string): void { + const rows = [ + ...Array.from({ length: 3 }, (_, i) => rankEntry({ + ts: '2026-05-0' + (i + 1) + 'T00:00:00Z', + key: 'healthy-' + i, + insight: 'alpha only insight ' + i, + confidence: 10, + })), + // Scores higher than every healthy row, so ranking floats it to the top. + rankEntry({ key: 'poison-row', type: poisonType, insight: 'alpha beta both here', confidence: 1 }), + ]; + fs.writeFileSync(path.join(badProjDir, 'learnings.jsonl'), rows.map(e => JSON.stringify(e)).join('\n') + '\n'); + } + + function runBad(args: string[]): ReturnType { + return spawnSync('bash', [BIN, ...args], { + timeout: 30_000, + env: { ...process.env, GSTACK_HOME: tmpHome }, + cwd: badCwd, + encoding: 'utf-8', + }); + } + + for (const poisonType of POISON_TYPES) { + test('a row typed "' + poisonType + '" cannot blank the whole store', () => { + writeBadStore(poisonType); + const res = runBad(['--query', 'alpha beta']); + const keys = String(res.stdout).split('\n') + .map(line => /^- \[([^\]]+)\]/.exec(line)) + .filter((m): m is RegExpExecArray => m !== null) + .map(m => m[1]); + // Every healthy row still reaches the caller. Asserted by presence, not by + // count: the failure mode is silence, and a count assertion on an empty + // result reads the same as a count assertion on a truncated one. + expect(keys).toContain('healthy-0'); + expect(keys).toContain('healthy-1'); + expect(keys).toContain('healthy-2'); + }); + } +}); From e634744deb239ba9417df13ab2350dff2992eab4 Mon Sep 17 00:00:00 2001 From: y$un_ Date: Sun, 6 Sep 2026 18:49:35 -0400 Subject: [PATCH 6/7] test(learnings-search): pin how confidence decay interacts with relevance ranking Every row in the #2762 fixture is `user-stated`, which is exempt from confidence decay, and the diff contained no `observed` or `inferred` row anywhere. That was deliberate -- a decaying fixture drifts with wall clock and turns into a date-dependent flake -- but it left the interaction between decay and the new sort order completely uncovered. The interaction is worth stating out loud, because relevance now sorts ahead of confidence and decay only feeds confidence: a row that has decayed to zero still outranks a current row when it matches more of the query. That follows from the ordering #2762 asked for, so these tests pin it as intended behaviour rather than assert against it -- and the second test shows decay is still live underneath, demoting the stale row as soon as the hit counts tie. Both fixtures are dated far enough back that Math.max pins them to the 0 floor, so they are stable forever instead of drifting. Co-Authored-By: Claude Opus 5 (1M context) --- test/gstack-learnings-search.test.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/test/gstack-learnings-search.test.ts b/test/gstack-learnings-search.test.ts index f39607e653..cd012449ae 100644 --- a/test/gstack-learnings-search.test.ts +++ b/test/gstack-learnings-search.test.ts @@ -232,6 +232,14 @@ describe('gstack-learnings-search relevance ranking (#2762)', () => { // scores fully -- the same entry ranked on its separator, not its content. rankEntry({ key: 'iota_upsilon_probe', insight: 'no query words in this text', confidence: 2 }), rankEntry({ key: 'plain-row-iota', insight: 'no query words in this text', confidence: 10 }), + // Decay-vs-relevance probes. Fixed at a date far enough back that the + // observed rows are pinned to the 0 floor by Math.max, so they are stable + // forever rather than drifting with wall clock -- which is why the rest of + // this fixture is user-stated. + rankEntry({ key: 'decayed-two-hits', insight: 'quasar and nebula both discussed', confidence: 9, source: 'observed', ts: '2019-01-01T00:00:00Z' }), + rankEntry({ key: 'current-one-hit', insight: 'quasar alone here', confidence: 10 }), + rankEntry({ key: 'decayed-tied-hit', insight: 'pulsar mentioned', confidence: 9, source: 'observed', ts: '2019-01-01T00:00:00Z' }), + rankEntry({ key: 'current-tied-hit', insight: 'pulsar mentioned too', confidence: 5 }), ]; fs.writeFileSync(path.join(rankProjDir, 'learnings.jsonl'), rows.map(e => JSON.stringify(e)).join('\n') + '\n'); }); @@ -407,6 +415,26 @@ describe('gstack-learnings-search relevance ranking (#2762)', () => { 'plain-row-iota', // 1 naming hit, confidence 10 ]); }); + + // #2762 ranks tokens matched ahead of confidence by design, and decay feeds + // confidence. This pins the consequence so it is deliberate and visible rather + // than discovered later: a fully decayed row that answers more of the query is + // still ranked above a current row that answers less of it. + test('relevance outranks confidence decay when the decayed row matches more', () => { + expect(rankedKeys(['--query', 'quasar nebula'])).toEqual([ + 'decayed-two-hits', // 2 hits, observed and decayed to the 0 floor + 'current-one-hit', // 1 hit, confidence 10, exempt from decay + ]); + }); + + // ...and decay is still live underneath it: once the hit counts tie, the decayed + // row loses on the confidence it has lost. + test('confidence decay still demotes a stale row once relevance ties', () => { + expect(rankedKeys(['--query', 'pulsar'])).toEqual([ + 'current-tied-hit', // 1 hit, confidence 5 + 'decayed-tied-hit', // 1 hit, decayed to 0 + ]); + }); }); // The formatter groups rows into a plain object keyed by the row's own `type`. From f0abb32266de47614a4bda7fa1f53307deac0c56 Mon Sep 17 00:00:00 2001 From: y$un_ Date: Sun, 6 Sep 2026 18:50:14 -0400 Subject: [PATCH 7/7] refactor(learnings-search): drop the dead _tokenHits initializer `e._tokenHits = 0` was never read. The sort reads _insightHits and _contextHits, which this line does not initialize, and no other reader exists. Its six-line comment is the reason to remove it rather than leave it: it credits the line with preventing a stored _field from becoming a live sort key on the no-query preamble call. That defense is real, but it is provided by the underscore-namespace strip at parse time, which runs on every row on every path. A maintainer trusting the comment could delete the strip and keep this line, believing the control was still in place -- the test planting _insightHits, _contextHits and a name that does not exist yet still passes with the line gone and fails with the strip gone. Removing it leaves output byte-identical on every path. Co-Authored-By: Claude Opus 5 (1M context) --- bin/gstack-learnings-search | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/bin/gstack-learnings-search b/bin/gstack-learnings-search index 0a37b39698..d890c5fd02 100755 --- a/bin/gstack-learnings-search +++ b/bin/gstack-learnings-search @@ -137,14 +137,6 @@ for (const taggedLine of lines) { } e._effectiveConfidence = conf; - // #2762: initialize the relevance score here, beside the other internal - // fields, NOT in the query filter. The filter runs only when a query was - // given, so a value assigned there would leave the no-query path reading - // whatever JSON.parse produced -- and gstack-learnings-log re-serializes - // unknown keys, so a stored _tokenHits would become a live sort key on the - // preamble call that runs in every session. - e._tokenHits = 0; - // Determine if this is from the current project or cross-project // Cross-project entries are tagged for display const isCrossProject = sourceTag === 'cross'; @@ -185,7 +177,7 @@ if (type) results = results.filter(e => e.type === type); // RECALL (this predicate) is unchanged from before: substring containment across // key, insight and files. Every entry that used to come back still comes back. // -// RELEVANCE (_tokenHits) is a stricter, narrower measure, because reusing the +// RELEVANCE (the hit counts below) is a stricter, narrower measure, because reusing the // recall net as a ranking signal ranks badly in two ways that were measured on // gstack's own shipped queries: // - Substring hits are not concept hits. Under /investigate's shipped query,