mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* feat(search): autocut — score-discontinuity result-sizing on the rerank separatrix Cut the ranked set at the cross-encoder rerank-score cliff instead of a fixed top-K. Default-ON in reranked modes (balanced/tokenmax), no-op without a reranker. New pure src/core/search/autocut.ts; mode.ts knobs + reranker_top_n_in = searchLimit (no unscored tail); query op autocut param; --explain + glossary. * test(search): autocut pure-fn, agent-surface, behavioral + precision/recall eval gate Adds autocut.test.ts, query-op-autocut.test.ts, autocut-integration.serial.test.ts (IRON-RULE behavioral via rerankerFn seam), autocut-eval.test.ts (in-repo precision-lift-without-recall-regression gate). Updates existing knobsHash/bundle pins to v=7 + reranker_top_n_in. * chore: version + changelog + docs for autocut (v0.41.34.0) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(search): autocut preserves alias-hop exact matches + cache-HIT meta (codex P1/P2) P1: applyAliasHop injects the canonical page after reranking (no rerank_score); autocut would drop it when cutting on the scored set. applyAutocut gains an optional preserve predicate; hybrid passes r => r.alias_hit === true. P2: cache-HIT cachedMeta now carries autocut/adaptive_return/mode/embedding_column. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: bump version to v0.42.3.0 (autocut wave) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: PR titles lead with the version (IRON RULE in CLAUDE.md) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
263 lines
7.7 KiB
TypeScript
263 lines
7.7 KiB
TypeScript
/**
|
||
* v0.40.4.0 — `gbrain search --explain` formatter.
|
||
*
|
||
* Pins output format for the per-stage attribution view. Stable shape so
|
||
* scripts that grep `--explain` output don't break under refactors.
|
||
*/
|
||
|
||
import { describe, test, expect } from 'bun:test';
|
||
import type { SearchResult } from '../../src/core/types.ts';
|
||
import {
|
||
formatResultExplain,
|
||
formatResultsExplain,
|
||
formatAutocutSummary,
|
||
} from '../../src/core/search/explain-formatter.ts';
|
||
import type { AutocutDecision } from '../../src/core/search/autocut.ts';
|
||
|
||
function r(slug: string, score: number, extras: Partial<SearchResult> = {}): SearchResult {
|
||
return {
|
||
slug,
|
||
page_id: 1,
|
||
title: slug,
|
||
type: 'note',
|
||
chunk_text: `body of ${slug}`,
|
||
chunk_source: 'compiled_truth',
|
||
chunk_id: 1000,
|
||
chunk_index: 0,
|
||
score,
|
||
stale: false,
|
||
source_id: 'default',
|
||
...extras,
|
||
};
|
||
}
|
||
|
||
describe('formatResultExplain — no boosts', () => {
|
||
test('result with no attribution → "no boosts applied"', () => {
|
||
const out = formatResultExplain(r('a/b', 1.5), 1);
|
||
expect(out).toContain('1. a/b (score=1.5)');
|
||
expect(out).toContain('base=1.5 (rrf+cosine)');
|
||
expect(out).toContain('no boosts applied');
|
||
expect(out).toContain('= final 1.5');
|
||
});
|
||
|
||
test('base_score equals score when no stage stamped base_score', () => {
|
||
const out = formatResultExplain(r('a/b', 3.14), 1);
|
||
expect(out).toContain('base=3.14');
|
||
});
|
||
});
|
||
|
||
describe('formatResultExplain — every boost type', () => {
|
||
test('backlink_boost renders', () => {
|
||
const out = formatResultExplain(
|
||
r('a/b', 1.5, { base_score: 1.0, backlink_boost: 1.5 }),
|
||
1,
|
||
);
|
||
expect(out).toContain('+ backlink ×1.5');
|
||
expect(out).not.toContain('no boosts applied');
|
||
});
|
||
|
||
test('salience_boost renders', () => {
|
||
const out = formatResultExplain(
|
||
r('a/b', 1.2, { base_score: 1.0, salience_boost: 1.2 }),
|
||
1,
|
||
);
|
||
expect(out).toContain('+ salience ×1.2');
|
||
});
|
||
|
||
test('recency_boost renders', () => {
|
||
const out = formatResultExplain(
|
||
r('a/b', 1.3, { base_score: 1.0, recency_boost: 1.3 }),
|
||
1,
|
||
);
|
||
expect(out).toContain('+ recency ×1.3');
|
||
});
|
||
|
||
test('exact_match_boost renders', () => {
|
||
const out = formatResultExplain(
|
||
r('a/b', 2.0, { base_score: 1.0, exact_match_boost: 2.0 }),
|
||
1,
|
||
);
|
||
expect(out).toContain('+ exact-match ×2');
|
||
});
|
||
|
||
test('graph_adjacency_boost + hits render', () => {
|
||
const out = formatResultExplain(
|
||
r('hub', 1.05, { base_score: 1.0, graph_adjacency_boost: 1.05, graph_adjacency_hits: 3 }),
|
||
1,
|
||
);
|
||
expect(out).toContain('+ adjacency ×1.05 (hits=3)');
|
||
});
|
||
|
||
test('graph_cross_source_boost + cross_source_hits render', () => {
|
||
const out = formatResultExplain(
|
||
r('hub', 1.10, { base_score: 1.0, graph_cross_source_boost: 1.10, graph_cross_source_hits: 2 }),
|
||
1,
|
||
);
|
||
expect(out).toContain('+ cross_source ×1.1 (other_sources=2)');
|
||
});
|
||
|
||
test('session_demote_factor renders as DEMOTE not boost', () => {
|
||
const out = formatResultExplain(
|
||
r('chat/b', 0.95, {
|
||
base_score: 1.0,
|
||
session_demote_factor: 0.95,
|
||
graph_session_prefix: 'chat',
|
||
graph_session_demoted: true,
|
||
}),
|
||
1,
|
||
);
|
||
expect(out).toContain('- session_demote ×0.95 (prefix=chat)');
|
||
});
|
||
|
||
test('reranker_delta positive renders as rank-up arrow', () => {
|
||
const out = formatResultExplain(
|
||
r('a/b', 1.0, { base_score: 1.0, reranker_delta: 2 }),
|
||
1,
|
||
);
|
||
expect(out).toContain('↑ reranker rank +2');
|
||
});
|
||
|
||
test('reranker_delta negative renders as rank-down arrow', () => {
|
||
const out = formatResultExplain(
|
||
r('a/b', 1.0, { base_score: 1.0, reranker_delta: -1 }),
|
||
1,
|
||
);
|
||
expect(out).toContain('↓ reranker rank -1');
|
||
});
|
||
|
||
test('reranker_delta = 0 → no rendering (no movement)', () => {
|
||
const out = formatResultExplain(
|
||
r('a/b', 1.0, { base_score: 1.0, reranker_delta: 0 }),
|
||
1,
|
||
);
|
||
expect(out).not.toContain('reranker rank');
|
||
expect(out).toContain('no boosts applied');
|
||
});
|
||
});
|
||
|
||
describe('formatResultExplain — multi-stage stacking', () => {
|
||
test('three boosts render as separate lines in order', () => {
|
||
const out = formatResultExplain(
|
||
r('hub', 1.5, {
|
||
base_score: 1.0,
|
||
backlink_boost: 1.1,
|
||
salience_boost: 1.05,
|
||
graph_adjacency_boost: 1.05,
|
||
graph_adjacency_hits: 3,
|
||
}),
|
||
1,
|
||
);
|
||
const lines = out.split('\n');
|
||
// base, +backlink, +salience, +adjacency, = final → 5 substantive lines + header.
|
||
expect(lines.length).toBeGreaterThanOrEqual(6);
|
||
expect(out).toMatch(/\+ backlink[\s\S]*\+ salience[\s\S]*\+ adjacency/);
|
||
});
|
||
});
|
||
|
||
describe('formatResultsExplain — list rendering', () => {
|
||
test('empty list', () => {
|
||
expect(formatResultsExplain([])).toBe('No results.\n');
|
||
});
|
||
|
||
test('multiple results separated by blank lines, trailing newline', () => {
|
||
const out = formatResultsExplain([
|
||
r('a', 1.0),
|
||
r('b', 0.9),
|
||
]);
|
||
expect(out).toContain('1. a');
|
||
expect(out).toContain('2. b');
|
||
expect(out).toMatch(/\n\n2\./); // blank line between entries
|
||
expect(out.endsWith('\n')).toBe(true);
|
||
});
|
||
|
||
test('rank numbering is 1-based', () => {
|
||
const out = formatResultsExplain([
|
||
r('first', 10),
|
||
r('second', 9),
|
||
r('third', 8),
|
||
]);
|
||
expect(out).toContain('1. first');
|
||
expect(out).toContain('2. second');
|
||
expect(out).toContain('3. third');
|
||
});
|
||
});
|
||
|
||
describe('formatResultExplain — number formatting', () => {
|
||
test('trailing zeros stripped', () => {
|
||
const out = formatResultExplain(r('a/b', 1.0), 1);
|
||
expect(out).toContain('score=1');
|
||
expect(out).not.toContain('score=1.0000');
|
||
});
|
||
|
||
test('non-zero fractional digits preserved up to 4 places', () => {
|
||
const out = formatResultExplain(r('a/b', 0.1234), 1);
|
||
expect(out).toContain('score=0.1234');
|
||
});
|
||
|
||
test('NaN preserved as "NaN"', () => {
|
||
const out = formatResultExplain(r('a/b', NaN), 1);
|
||
expect(out).toContain('score=NaN');
|
||
});
|
||
});
|
||
|
||
describe('v0.42.3.0 — autocut in --explain', () => {
|
||
test('rerank_score renders per result (the cliff signal)', () => {
|
||
const out = formatResultExplain(r('a/b', 1.2, { rerank_score: 0.87 }), 1);
|
||
expect(out).toContain('rerank score=0.87');
|
||
});
|
||
|
||
test('no rerank score line when absent', () => {
|
||
const out = formatResultExplain(r('a/b', 1.2), 1);
|
||
expect(out).not.toContain('rerank score');
|
||
});
|
||
|
||
const applied: AutocutDecision = {
|
||
applied: true,
|
||
signal: 'rerank',
|
||
cut: 2,
|
||
kept: 2,
|
||
total: 7,
|
||
gapRatio: 0.41,
|
||
};
|
||
const declined: AutocutDecision = {
|
||
applied: false,
|
||
signal: 'none',
|
||
cut: 7,
|
||
kept: 7,
|
||
total: 7,
|
||
gapRatio: 0.05,
|
||
};
|
||
|
||
test('formatAutocutSummary — applied cut', () => {
|
||
const s = formatAutocutSummary(applied);
|
||
expect(s).toContain('kept 2/7');
|
||
expect(s).toContain('0.41');
|
||
});
|
||
|
||
test('formatAutocutSummary — declined', () => {
|
||
const s = formatAutocutSummary(declined);
|
||
expect(s).toContain('no cut');
|
||
expect(s).toContain('full 7');
|
||
});
|
||
|
||
test('formatAutocutSummary — undefined → null (omit cleanly)', () => {
|
||
expect(formatAutocutSummary(undefined)).toBeNull();
|
||
});
|
||
|
||
test('formatResultsExplain prepends the autocut summary when meta has a decision', () => {
|
||
const out = formatResultsExplain([r('a/b', 1.2, { rerank_score: 0.9 })], {
|
||
vector_enabled: true,
|
||
detail_resolved: null,
|
||
expansion_applied: false,
|
||
autocut: applied,
|
||
});
|
||
expect(out.startsWith('autocut:')).toBe(true);
|
||
expect(out).toContain('kept 2/7');
|
||
});
|
||
|
||
test('formatResultsExplain omits the summary when meta has no autocut decision', () => {
|
||
const out = formatResultsExplain([r('a/b', 1.2)]);
|
||
expect(out.startsWith('autocut:')).toBe(false);
|
||
});
|
||
});
|