mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
B6: rank learnings by matched query tokens and disclose truncation
A more specific query used to drop the exact match: the token-OR filter recalled every entry sharing one token, the sort used confidence alone, and the default --limit cut silently. Entries now rank by how many distinct query tokens they match, then confidence, then recency, and a query that matched more than fit says '-- N more matched, raise --limit to see them'. The hit count lives in a local map so a field stored on disk can never become a sort key, and the type grouping uses a null-prototype map so a row typed 'constructor' or '__proto__' cannot crash the run. Ranking code ported from #2796; regression vectors adapted from #2799 (its stemmer is not needed). Co-authored-by: loulanyue <260355617@qq.com> Co-authored-by: y$un_ <forrest.sun527@gmail.com>
This commit is contained in:
1 parent
85c9d4cebd
commit
aa90e52af8
2 files changed
+102
-8
No files matched your search
@@ -128,25 +128,37 @@ let results = Array.from(seen.values());
|
||||
// Filter by type
|
||||
if (type) results = results.filter(e => e.type === type);
|
||||
|
||||
// Filter by query (token-OR: match if ANY whitespace-split token appears in ANY haystack)
|
||||
// Filter by query (token-OR: match if ANY whitespace-split token appears in ANY
|
||||
// haystack) and count how many distinct tokens each entry matched (#2762). The
|
||||
// count lives in a local map, never on the entry: a stored field from disk
|
||||
// must not become a sort key.
|
||||
const tokenHits = new Map();
|
||||
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 haystacks = [String(e.key || '').toLowerCase(), String(e.insight || '').toLowerCase(), ...(Array.isArray(e.files) ? e.files : []).map(f => String(f).toLowerCase())];
|
||||
const hits = [...new Set(queryTokens)].filter(tok => haystacks.some(h => h.includes(tok))).length;
|
||||
tokenHits.set(e, hits);
|
||||
return hits > 0;
|
||||
});
|
||||
|
||||
// Sort by effective confidence desc, then recency
|
||||
// Sort by matched tokens desc, then effective confidence desc, then recency.
|
||||
// Ranking on confidence alone let high-confidence single-token matches push an
|
||||
// entry matching every token past the limit.
|
||||
results.sort((a, b) => {
|
||||
const ah = tokenHits.get(a) || 0, bh = tokenHits.get(b) || 0;
|
||||
if (bh !== ah) return bh - ah;
|
||||
if (b._effectiveConfidence !== a._effectiveConfidence) return b._effectiveConfidence - a._effectiveConfidence;
|
||||
return new Date(b.ts).getTime() - new Date(a.ts).getTime();
|
||||
});
|
||||
|
||||
// Limit
|
||||
// Limit, and say when a query matched more than fit.
|
||||
const totalMatched = results.length;
|
||||
results = results.slice(0, limit);
|
||||
|
||||
if (results.length === 0) process.exit(0);
|
||||
|
||||
// Format output
|
||||
const byType = {};
|
||||
// Format output. A null-prototype map: a row typed 'constructor' or
|
||||
// '__proto__' must not find an inherited property and blank the whole run.
|
||||
const byType = Object.create(null);
|
||||
for (const e of results) {
|
||||
const t = e.type || 'unknown';
|
||||
if (!byType[t]) byType[t] = [];
|
||||
@@ -155,7 +167,10 @@ for (const e of results) {
|
||||
|
||||
// Summary line
|
||||
const counts = Object.entries(byType).map(([t, arr]) => arr.length + ' ' + t + (arr.length > 1 ? 's' : ''));
|
||||
console.log('LEARNINGS: ' + results.length + ' loaded (' + counts.join(', ') + ')');
|
||||
const moreNotice = queryTokens.length > 0 && totalMatched > results.length
|
||||
? ' -- ' + (totalMatched - results.length) + ' more matched, raise --limit to see them'
|
||||
: '';
|
||||
console.log('LEARNINGS: ' + results.length + ' loaded (' + counts.join(', ') + ')' + moreNotice);
|
||||
console.log('');
|
||||
|
||||
for (const [t, arr] of Object.entries(byType)) {
|
||||
|
||||
@@ -141,3 +141,82 @@ describe('B5: query scripts never report a failed read as an empty result', () =
|
||||
expect(r.stdout).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// B6 (#2762): a more specific query dropped the exact match. The token-OR
|
||||
// filter recalled every entry sharing any token, the sort used confidence
|
||||
// alone, and the default --limit cut silently. Vectors adapted from #2799
|
||||
// (by y$un_); ranking code from #2796 (by loulanyue).
|
||||
describe('B6: rank by matched tokens and disclose truncation', () => {
|
||||
const rankCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-search-rank-cwd-'));
|
||||
const rankDir = path.join(tmpHome, 'projects', path.basename(rankCwd).replace(/[^a-zA-Z0-9._-]/g, ''));
|
||||
const badCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-search-bad-cwd-'));
|
||||
const badDir = path.join(tmpHome, 'projects', path.basename(badCwd).replace(/[^a-zA-Z0-9._-]/g, ''));
|
||||
const TARGET = 'verify-preflight-project-line-before-trusting-report';
|
||||
const DECOYS = ['guideline', 'pipeline', 'deadline', 'headline', 'baseline', 'timeline',
|
||||
'outline', 'airline', 'lifeline', 'sideline', 'streamline', 'underline'];
|
||||
// user-stated rows do not decay, so ranks never drift with the wall clock.
|
||||
const row = (over: Record<string, unknown>) => ({ ts: '2026-05-01T00:00:00Z', skill: 'test', type: 'pattern', confidence: 8, source: 'user-stated', trusted: false, files: [], ...over });
|
||||
const write = (dir: string, rows: object[]) => {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'learnings.jsonl'), rows.map(r => JSON.stringify(r)).join('\n') + '\n');
|
||||
};
|
||||
write(rankDir, [
|
||||
row({ key: TARGET, insight: 'Check the project line in the preflight report before trusting it' }),
|
||||
...DECOYS.map((w, i) => row({ ts: '2026-05-' + String(4 + i).padStart(2, '0') + 'T00:00:00Z', key: 'decoy-' + w + '-rule', insight: 'A ' + w + ' related insight', confidence: 10 })),
|
||||
row({ key: 'tiebreak-alpha-beta-high', insight: 'alpha beta both present', confidence: 9 }),
|
||||
row({ ts: '2026-06-01T00:00:00Z', key: 'tiebreak-alpha-beta-low', insight: 'alpha beta both present', confidence: 5 }),
|
||||
row({ key: 'tiebreak-alpha-solo', insight: 'alpha only here', confidence: 10 }),
|
||||
row({ key: 'recency-gamma-delta-older', insight: 'gamma delta pair', confidence: 7 }),
|
||||
row({ ts: '2026-06-01T00:00:00Z', key: 'recency-gamma-delta-newer', insight: 'gamma delta pair', confidence: 7 }),
|
||||
row({ ts: '2020-01-01T00:00:00Z', key: 'planted-token-hits', insight: 'isolated poison row', confidence: 1, _tokenHits: 9999 }),
|
||||
]);
|
||||
afterAll(() => {
|
||||
fs.rmSync(rankCwd, { recursive: true, force: true });
|
||||
fs.rmSync(badCwd, { recursive: true, force: true });
|
||||
});
|
||||
const runIn = (cwd: string, args: string[]) => spawnSync('bash', [BIN, ...args], {
|
||||
timeout: 30_000, env: { ...process.env, GSTACK_HOME: tmpHome }, cwd, encoding: 'utf-8',
|
||||
});
|
||||
const keys = (out: string) => out.split('\n').map(l => /^- \[([^\]]+)\]/.exec(l)).filter((m): m is RegExpExecArray => m !== null).map(m => m[1]);
|
||||
|
||||
test('an entry matching every query token survives the default limit, for every refinement', () => {
|
||||
for (const q of ['preflight', 'preflight project', 'preflight project line']) {
|
||||
expect(keys(runIn(rankCwd, ['--query', q]).stdout)).toContain(TARGET);
|
||||
}
|
||||
});
|
||||
|
||||
test('a 3-of-3 match outranks twelve higher-confidence 1-of-3 matches', () => {
|
||||
expect(keys(runIn(rankCwd, ['--query', 'preflight project line']).stdout)[0]).toBe(TARGET);
|
||||
});
|
||||
|
||||
test('token hits outrank confidence; confidence then recency break ties', () => {
|
||||
expect(keys(runIn(rankCwd, ['--query', 'alpha beta']).stdout)).toEqual(['tiebreak-alpha-beta-high', 'tiebreak-alpha-beta-low', 'tiebreak-alpha-solo']);
|
||||
expect(keys(runIn(rankCwd, ['--query', 'gamma delta']).stdout)).toEqual(['recency-gamma-delta-newer', 'recency-gamma-delta-older']);
|
||||
});
|
||||
|
||||
test('a truncated query says how many more matched, on stdout with exit 0', () => {
|
||||
const r = runIn(rankCwd, ['--query', 'preflight project line']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('LEARNINGS: 10 loaded');
|
||||
expect(r.stdout).toContain('3 more matched, raise --limit to see them');
|
||||
expect(runIn(rankCwd, ['--query', 'gamma delta']).stdout).not.toContain('more matched');
|
||||
expect(runIn(rankCwd, ['--limit', '3']).stdout).not.toContain('more matched');
|
||||
});
|
||||
|
||||
test('a stored internal field cannot hijack the no-query ranking', () => {
|
||||
const ranked = keys(runIn(rankCwd, ['--limit', '3']).stdout);
|
||||
expect(ranked).toHaveLength(3);
|
||||
expect(ranked).not.toContain('planted-token-hits');
|
||||
});
|
||||
|
||||
for (const poison of ['constructor', 'toString', 'valueOf', 'hasOwnProperty', '__proto__']) {
|
||||
test(`a row typed "${poison}" cannot blank the whole store`, () => {
|
||||
write(badDir, [
|
||||
...[0, 1, 2].map(i => row({ ts: '2026-05-0' + (i + 1) + 'T00:00:00Z', key: 'healthy-' + i, insight: 'alpha only insight ' + i, confidence: 10 })),
|
||||
row({ key: 'poison-row', type: poison, insight: 'alpha beta both here', confidence: 1 }),
|
||||
]);
|
||||
const got = keys(runIn(badCwd, ['--query', 'alpha beta']).stdout);
|
||||
for (const k of ['healthy-0', 'healthy-1', 'healthy-2']) expect(got).toContain(k);
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user