v0.42.3.0 feat(search): autocut — score-discontinuity result-sizing (#1663 wave 1) (#1682)

* 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>
This commit is contained in:
Garry Tan
2026-06-01 20:16:37 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 7b0d99adb0
commit d9eadfec13
25 changed files with 1418 additions and 20 deletions
+1 -1
View File
@@ -142,7 +142,7 @@ describe('D2 — knobsHash differs across cross-modal knob values', () => {
// v0.39 T21 (master) bump to v=4 for graph_signals + schema-pack fields.
// v0.40.3.0 D8 bumps to v=5 (sequenced behind salem's v=4 graph-signals).
// v0.41.22.0 (type-unification): 5→6 for alias_resolved post-fusion boost.
expect(KNOBS_HASH_VERSION).toBe(7);
expect(KNOBS_HASH_VERSION).toBe(8);
});
test('flipping unified_multimodal changes the hash', () => {
+1 -1
View File
@@ -90,6 +90,6 @@ describe('alias_resolved boost stage', () => {
describe('KNOBS_HASH_VERSION', () => {
it('bumped to 6 to invalidate caches across v0.42 boost stage addition', () => {
expect(KNOBS_HASH_VERSION).toBe(7);
expect(KNOBS_HASH_VERSION).toBe(8);
});
});
+84 -6
View File
@@ -74,6 +74,9 @@ describe('SEARCH_MODES + MODE_BUNDLES canonical shape', () => {
graph_signals: false,
...CR_DISABLED_DEFAULT,
contextual_retrieval: 'none',
// v0.42.3.0 — autocut OFF for conservative (no reranker).
autocut: false,
autocut_jump: 0.2,
});
});
@@ -90,7 +93,8 @@ describe('SEARCH_MODES + MODE_BUNDLES canonical shape', () => {
searchLimit: 25,
reranker_enabled: true,
reranker_model: 'zeroentropyai:zerank-2',
reranker_top_n_in: 30,
// v0.42.3.0 D4: topNIn = searchLimit (25), was 30.
reranker_top_n_in: 25,
reranker_top_n_out: null,
reranker_timeout_ms: 5000,
floor_ratio: undefined,
@@ -99,6 +103,9 @@ describe('SEARCH_MODES + MODE_BUNDLES canonical shape', () => {
graph_signals: true,
...CR_DISABLED_DEFAULT,
contextual_retrieval: 'title',
// v0.42.3.0 — autocut ON.
autocut: true,
autocut_jump: 0.2,
});
});
@@ -113,7 +120,8 @@ describe('SEARCH_MODES + MODE_BUNDLES canonical shape', () => {
searchLimit: 50,
reranker_enabled: true,
reranker_model: 'zeroentropyai:zerank-2',
reranker_top_n_in: 30,
// v0.42.3.0 D4: topNIn = searchLimit (50), was 30.
reranker_top_n_in: 50,
reranker_top_n_out: null,
reranker_timeout_ms: 5000,
floor_ratio: undefined,
@@ -122,6 +130,9 @@ describe('SEARCH_MODES + MODE_BUNDLES canonical shape', () => {
graph_signals: true,
...CR_DISABLED_DEFAULT,
contextual_retrieval: 'per_chunk_synopsis',
// v0.42.3.0 — autocut ON.
autocut: true,
autocut_jump: 0.2,
});
});
@@ -376,10 +387,11 @@ describe('knobsHash determinism + cross-mode separation (CDX-4)', () => {
// written when the brain was on balanced (title-only) — different
// embedding spaces. Sequenced behind salem's v=4 graph-signals work.
// v0.41.22.0 (type-unification): bumped 5→6 for the new alias_resolved
// post-fusion boost stage. A query against a brain with slug_aliases
// populated must not be served from a cache row written before the
// boost stage existed.
expect(KNOBS_HASH_VERSION).toBe(7);
// post-fusion boost stage. T2: bumped 6→7 for title_boost. v0.42.3.0:
// bumped 7→8 for autocut (ac=/acj=). A query against a brain with
// slug_aliases populated must not be served from a cache row written
// before the boost stage existed.
expect(KNOBS_HASH_VERSION).toBe(8);
});
test('T1 (codex): floor_ratio set vs unset produces DIFFERENT hashes (cache contamination prevention)', () => {
@@ -542,3 +554,69 @@ describe('v0.40.4 — graph_signals knob', () => {
expect(attr.value).toBe(true);
});
});
describe('v0.42.3.0 — autocut knobs', () => {
test('KNOBS_HASH_VERSION bumped to 7', () => {
expect(KNOBS_HASH_VERSION).toBe(8);
});
test('bundle defaults: conservative off, balanced/tokenmax on @0.20', () => {
expect(MODE_BUNDLES.conservative.autocut).toBe(false);
expect(MODE_BUNDLES.balanced.autocut).toBe(true);
expect(MODE_BUNDLES.tokenmax.autocut).toBe(true);
for (const m of ['conservative', 'balanced', 'tokenmax'] as const) {
expect(MODE_BUNDLES[m].autocut_jump).toBe(0.2);
}
});
test('D4: reranked modes set top_n_in = searchLimit (no unscored tail)', () => {
expect(MODE_BUNDLES.balanced.reranker_top_n_in).toBe(MODE_BUNDLES.balanced.searchLimit);
expect(MODE_BUNDLES.tokenmax.reranker_top_n_in).toBe(MODE_BUNDLES.tokenmax.searchLimit);
expect(MODE_BUNDLES.balanced.reranker_top_n_in).toBe(25);
expect(MODE_BUNDLES.tokenmax.reranker_top_n_in).toBe(50);
});
test('resolveSearchMode threads autocut: per-call > config > bundle', () => {
// per-call wins
expect(resolveSearchMode({ mode: 'balanced', perCall: { autocut: false } }).autocut).toBe(false);
// config override wins over bundle
expect(resolveSearchMode({ mode: 'balanced', overrides: { autocut: false } }).autocut).toBe(false);
// per-call beats config
expect(
resolveSearchMode({ mode: 'balanced', overrides: { autocut: false }, perCall: { autocut: true } }).autocut,
).toBe(true);
// jump knob threads too
expect(resolveSearchMode({ mode: 'balanced', perCall: { autocut_jump: 0.5 } }).autocut_jump).toBe(0.5);
});
test('loadOverridesFromConfig reads search.autocut + search.autocut_jump', () => {
const ov = loadOverridesFromConfig({ 'search.autocut': 'false', 'search.autocut_jump': '0.35' });
expect(ov.autocut).toBe(false);
expect(ov.autocut_jump).toBe(0.35);
});
test('SEARCH_MODE_CONFIG_KEYS includes the autocut keys', () => {
expect(SEARCH_MODE_CONFIG_KEYS).toContain('search.autocut');
expect(SEARCH_MODE_CONFIG_KEYS).toContain('search.autocut_jump');
});
test('knobsHash includes ac= / acj= — autocut-on vs off differ', () => {
const on = knobsHash(resolveSearchMode({ mode: 'balanced' })); // autocut true
const off = knobsHash(resolveSearchMode({ mode: 'balanced', perCall: { autocut: false } }));
expect(on).not.toBe(off);
});
test('knobsHash differs on jump sensitivity', () => {
const a = knobsHash(resolveSearchMode({ mode: 'balanced' }));
const b = knobsHash(resolveSearchMode({ mode: 'balanced', perCall: { autocut_jump: 0.5 } }));
expect(a).not.toBe(b);
});
test('attributeKnob reports autocut source', () => {
const input = { mode: 'balanced', perCall: { autocut: false } };
const resolved = resolveSearchMode(input);
const attr = attributeKnob('autocut', input, resolved);
expect(attr.source).toBe('per-call');
expect(attr.value).toBe(false);
});
});
+197
View File
@@ -0,0 +1,197 @@
/**
* v0.42.3.0 — autocut precision/recall eval gate (in-repo, hermetic).
*
* This is the D2 precondition for default-ON, runnable in CI without the
* sibling gbrain-evals repo and without any API key. It measures the EXACT
* claim default-ON rests on: does cutting at the rerank-score cliff lift
* precision WITHOUT regressing recall — especially on enumeration queries?
*
* WHAT IT MODELS (and what it does NOT): autocut cuts on cross-encoder
* rerank scores. This gate feeds applyAutocut labeled qrels fixtures whose
* per-candidate `rerank_score` follows realistic cross-encoder distributions
* (clean cliffs for single-answer queries, a high cluster + cliff for
* enumeration, flat curves for ambiguous breadth, and an adversarial case
* where the reranker mis-scores a relevant doc below a cliff). It measures
* the precision/recall tradeoff of the CUT DECISION on those distributions.
* It does NOT claim that ZeroEntropy's live scores look like these fixtures
* on a specific brain — that empirical confirmation is the optional
* gbrain-evals PrecisionMemBench run. What this gate DOES guarantee, in CI:
* - autocut lifts mean precision well above the no-autocut baseline,
* - it does NOT regress recall below a floor,
* - it NEVER regresses recall on enumeration/flat queries (structural:
* a flat curve has no cliff, so autocut declines → identical recall).
*
* The gate fails (correctly) if someone over-tunes autocut (e.g. drops
* jumpRatio so low it cuts into clusters) or if the cut math regresses.
* Floors are env-overridable so an intentional ranking change edits the
* threshold with a documented reason.
*/
import { describe, expect, test } from 'bun:test';
import { applyAutocut, DEFAULT_AUTOCUT } from '../../src/core/search/autocut.ts';
type Candidate = { rerank_score: number; relevant: boolean };
type EvalQuery = {
id: string;
kind: 'single' | 'cluster' | 'enumeration' | 'adversarial';
/** Candidates in reranked (descending-score) order. */
candidates: Candidate[];
};
const scoreOf = (c: Candidate) => c.rerank_score;
const LIMIT = 10; // mirrors a typical returned-set cap; doesn't bind these lists
// Realistic cross-encoder distributions. Proportions reflect the empirical
// reality return-policy.ts cites: rank-1 is correct in ~94% of single-answer
// cases, so clean cliffs dominate and adversarial mis-scores are rare.
const FIXTURE: EvalQuery[] = [
// 5 single-answer queries with a clean cliff after the one right answer.
...Array.from({ length: 5 }, (_, i): EvalQuery => ({
id: `single-${i}`,
kind: 'single',
candidates: [
{ rerank_score: 0.95, relevant: true },
{ rerank_score: 0.30, relevant: false },
{ rerank_score: 0.25, relevant: false },
{ rerank_score: 0.20, relevant: false },
{ rerank_score: 0.15, relevant: false },
{ rerank_score: 0.10, relevant: false },
{ rerank_score: 0.08, relevant: false },
{ rerank_score: 0.05, relevant: false },
],
})),
// 2 cluster queries: a tight relevant cluster, then a cliff to noise.
...Array.from({ length: 2 }, (_, i): EvalQuery => ({
id: `cluster-${i}`,
kind: 'cluster',
candidates: [
{ rerank_score: 0.90, relevant: true },
{ rerank_score: 0.88, relevant: true },
{ rerank_score: 0.85, relevant: true },
{ rerank_score: 0.25, relevant: false },
{ rerank_score: 0.20, relevant: false },
{ rerank_score: 0.15, relevant: false },
{ rerank_score: 0.10, relevant: false },
],
})),
// 2 enumeration/broad queries: flat curve, many relevant, NO cliff.
// Autocut must DECLINE here — this is the recall-regression risk D2 gates.
...Array.from({ length: 2 }, (_, i): EvalQuery => ({
id: `enumeration-${i}`,
kind: 'enumeration',
candidates: [
{ rerank_score: 0.60, relevant: true },
{ rerank_score: 0.58, relevant: true },
{ rerank_score: 0.56, relevant: true },
{ rerank_score: 0.54, relevant: true },
{ rerank_score: 0.52, relevant: true },
{ rerank_score: 0.50, relevant: false },
{ rerank_score: 0.48, relevant: false },
],
})),
// 1 adversarial query: the reranker mis-scores a relevant doc BELOW an
// early cliff. Autocut will drop it — this models reranker error, the only
// way autocut can hurt recall. Kept rare to match real distributions.
{
id: 'adversarial-0',
kind: 'adversarial',
candidates: [
{ rerank_score: 0.90, relevant: true },
{ rerank_score: 0.50, relevant: true }, // relevant but mis-scored below the cliff
{ rerank_score: 0.48, relevant: false },
{ rerank_score: 0.46, relevant: false },
{ rerank_score: 0.20, relevant: false },
],
},
];
function precisionRecall(kept: Candidate[], totalRelevant: number): { p: number; r: number } {
const relevantKept = kept.filter((c) => c.relevant).length;
const p = kept.length === 0 ? 0 : relevantKept / kept.length;
const r = totalRelevant === 0 ? 1 : relevantKept / totalRelevant;
return { p, r };
}
function evalQuery(q: EvalQuery, autocutOn: boolean) {
const totalRelevant = q.candidates.filter((c) => c.relevant).length;
const pool = autocutOn
? applyAutocut(q.candidates, scoreOf, { ...DEFAULT_AUTOCUT }).kept
: q.candidates;
const kept = pool.slice(0, LIMIT);
return precisionRecall(kept, totalRelevant);
}
function mean(xs: number[]): number {
return xs.length === 0 ? 0 : xs.reduce((a, b) => a + b, 0) / xs.length;
}
function envFloor(name: string, fallback: number): number {
const v = process.env[name];
if (v === undefined) return fallback;
const n = parseFloat(v);
return Number.isFinite(n) ? n : fallback;
}
describe('autocut eval gate (D2 — precision lift without recall regression)', () => {
const off = FIXTURE.map((q) => evalQuery(q, false));
const on = FIXTURE.map((q) => evalQuery(q, true));
const precisionOff = mean(off.map((x) => x.p));
const precisionOn = mean(on.map((x) => x.p));
const recallOff = mean(off.map((x) => x.r));
const recallOn = mean(on.map((x) => x.r));
// Surface the numbers (the CHANGELOG/eval record reads these).
// eslint-disable-next-line no-console
console.error(
`[autocut-eval] precision ${precisionOff.toFixed(3)}${precisionOn.toFixed(3)} ` +
`(+${(precisionOn - precisionOff).toFixed(3)}) | recall ${recallOff.toFixed(3)}` +
`${recallOn.toFixed(3)} (${(recallOn - recallOff).toFixed(3)})`,
);
// Floors are env-overridable (document the reason in the commit when you move them).
const LIFT_FLOOR = envFloor('GBRAIN_AUTOCUT_EVAL_PRECISION_LIFT_FLOOR', 0.15);
const RECALL_FLOOR = envFloor('GBRAIN_AUTOCUT_EVAL_RECALL_FLOOR', 0.9);
const RECALL_REGRESSION_TOLERANCE = envFloor('GBRAIN_AUTOCUT_EVAL_RECALL_TOLERANCE', 0.1);
test('autocut lifts mean precision well above baseline', () => {
expect(precisionOn - precisionOff).toBeGreaterThanOrEqual(LIFT_FLOOR);
});
test('autocut keeps mean recall above the floor', () => {
expect(recallOn).toBeGreaterThanOrEqual(RECALL_FLOOR);
});
test('recall regression is bounded', () => {
expect(recallOff - recallOn).toBeLessThanOrEqual(RECALL_REGRESSION_TOLERANCE);
});
test('ZERO recall regression on enumeration/flat queries (the D2 core concern)', () => {
// Where recall matters most — broad enumeration with no cliff — autocut
// must decline and return the full set, so recall is identical on/off.
const enums = FIXTURE.filter((q) => q.kind === 'enumeration');
expect(enums.length).toBeGreaterThan(0);
for (const q of enums) {
const offR = evalQuery(q, false).r;
const onR = evalQuery(q, true).r;
expect(onR).toBe(offR);
}
});
test('single-answer queries: precision goes to 1.0 with recall preserved', () => {
const singles = FIXTURE.filter((q) => q.kind === 'single');
for (const q of singles) {
const onPR = evalQuery(q, true);
expect(onPR.p).toBe(1); // only the right answer returned
expect(onPR.r).toBe(1); // and it IS the right answer
}
});
test('cluster queries: the whole relevant cluster survives (no over-cut)', () => {
const clusters = FIXTURE.filter((q) => q.kind === 'cluster');
for (const q of clusters) {
// recall stays 1.0 — autocut cuts AFTER the cluster, not into it.
expect(evalQuery(q, true).r).toBe(1);
}
});
});
@@ -0,0 +1,169 @@
/**
* v0.42.3.0 — autocut end-to-end through hybridSearch (the IRON-RULE
* behavioral regression).
*
* Drives bare hybridSearch against PGLite with a stubbed rerankerFn so we
* pin the behavior the pure-fn tests can't:
* - A cliff-shaped rerank → autocut trims the result set at the cliff.
* - A flat rerank → autocut declines (full set returned).
* - Reranker disabled → autocut is a no-op (no rerank scores to cut on),
* which is the load-bearing gate (no trustworthy signal without a reranker).
* - Per-call autocut:false forces the full top-K even with a cliff (ceiling).
* - Composes with adaptive-return without violating the never-empty floor.
*
* Serial because it mutates gateway global state (configureGateway +
* __setEmbedTransportForTests). No API keys; embedding + reranker stubbed.
*/
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
import { hybridSearch } from '../../src/core/search/hybrid.ts';
import {
configureGateway,
resetGateway,
__setEmbedTransportForTests,
} from '../../src/core/ai/gateway.ts';
import type { PageInput, SearchOpts } from '../../src/core/types.ts';
import type { RerankInput, RerankResult } from '../../src/core/ai/gateway.ts';
let engine: PGLiteEngine;
const DIMS = 1536;
const FAKE_EMB = Array.from({ length: DIMS }, (_, j) => (j === 0 ? 1 : 0.01));
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
// Seed 5 pages sharing a keyword so the candidate pool is 5 deep.
const pages: Array<[string, PageInput, string]> = [
['notes/a', { type: 'note', title: 'A', compiled_truth: 'alpha keyword one' }, 'alpha keyword one chunk'],
['notes/b', { type: 'note', title: 'B', compiled_truth: 'alpha keyword two' }, 'alpha keyword two chunk'],
['notes/c', { type: 'note', title: 'C', compiled_truth: 'alpha keyword three' }, 'alpha keyword three chunk'],
['notes/d', { type: 'note', title: 'D', compiled_truth: 'alpha keyword four' }, 'alpha keyword four chunk'],
['notes/e', { type: 'note', title: 'E', compiled_truth: 'alpha keyword five' }, 'alpha keyword five chunk'],
];
for (const [slug, page, chunkText] of pages) {
await engine.putPage(slug, page);
await engine.upsertChunks(slug, [
{ chunk_index: 0, chunk_text: chunkText, chunk_source: 'compiled_truth' },
]);
}
configureGateway({
embedding_model: 'openai:text-embedding-3-large',
embedding_dimensions: DIMS,
env: { OPENAI_API_KEY: 'sk-test' },
});
__setEmbedTransportForTests(async (args: any) => ({
embeddings: args.values.map(() => FAKE_EMB),
}) as any);
});
afterAll(async () => {
__setEmbedTransportForTests(null);
resetGateway();
await engine.disconnect();
});
// A reranker that assigns descending scores from a fixed array (by index).
function rerankerWithScores(scores: number[]) {
return async (input: RerankInput): Promise<RerankResult[]> =>
input.documents.map((_, i) => ({ index: i, relevanceScore: scores[i] ?? 0.01 }));
}
// balanced mode (the default) has autocut ON. We pass opts.reranker to stub
// the cross-encoder; resolvedMode.autocut stays true (no search.mode config).
function rerankerOpts(scores: number[]): SearchOpts['reranker'] {
return {
enabled: true,
topNIn: 30,
topNOut: null,
rerankerFn: rerankerWithScores(scores),
};
}
describe('autocut — fires on a real cliff', () => {
test('cliff after rank 2 → result set trimmed to 2', async () => {
const out = await hybridSearch(engine, 'alpha keyword', {
limit: 10,
reranker: rerankerOpts([0.95, 0.9, 0.2, 0.15, 0.1]),
});
expect(out.length).toBe(2);
expect(out.map((r) => r.rerank_score)).toEqual([0.95, 0.9]);
});
test('cliff after rank 1 → single obvious answer', async () => {
const out = await hybridSearch(engine, 'alpha keyword', {
limit: 10,
reranker: rerankerOpts([0.98, 0.12, 0.1, 0.08, 0.05]),
});
expect(out.length).toBe(1);
});
});
describe('autocut — declines on a flat curve', () => {
test('flat rerank scores → full set returned (no trim)', async () => {
const baseline = await hybridSearch(engine, 'alpha keyword', { limit: 10 });
const out = await hybridSearch(engine, 'alpha keyword', {
limit: 10,
reranker: rerankerOpts([0.9, 0.88, 0.86, 0.84, 0.82]),
});
expect(out.length).toBe(baseline.length);
expect(out.length).toBeGreaterThanOrEqual(3); // meaningful pool to NOT trim
});
});
describe('autocut — no-op without a reranker (the load-bearing gate)', () => {
test('reranker disabled → no trim even though autocut is on for the mode', async () => {
const baseline = await hybridSearch(engine, 'alpha keyword', { limit: 10 });
const out = await hybridSearch(engine, 'alpha keyword', {
limit: 10,
reranker: { enabled: false, topNIn: 30, topNOut: null, rerankerFn: rerankerWithScores([0.95, 0.1]) },
});
// No rerank scores were stamped → autocut sees <2 finite scores → no-op.
expect(out.length).toBe(baseline.length);
});
test('reranker fails open (throws) → no trim (fail-open + autocut no-op)', async () => {
const baseline = await hybridSearch(engine, 'alpha keyword', { limit: 10 });
const out = await hybridSearch(engine, 'alpha keyword', {
limit: 10,
reranker: {
enabled: true,
topNIn: 30,
topNOut: null,
rerankerFn: async () => {
throw new Error('upstream down');
},
},
});
expect(out.map((r) => r.slug)).toEqual(baseline.map((r) => r.slug));
});
});
describe('autocut — ceiling override', () => {
test('per-call autocut:false forces full top-K even with a cliff', async () => {
const baseline = await hybridSearch(engine, 'alpha keyword', { limit: 10 });
const out = await hybridSearch(engine, 'alpha keyword', {
limit: 10,
autocut: false,
reranker: rerankerOpts([0.95, 0.9, 0.2, 0.15, 0.1]),
});
// Cliff present, but the override keeps the full reranked set.
expect(out.length).toBe(baseline.length);
});
});
describe('autocut — composes with adaptive-return (never-empty holds)', () => {
test('adaptive-return + autocut both on → non-empty, bounded', async () => {
const out = await hybridSearch(engine, 'alpha keyword', {
limit: 10,
adaptiveReturn: true,
reranker: rerankerOpts([0.95, 0.9, 0.2, 0.15, 0.1]),
});
expect(out.length).toBeGreaterThanOrEqual(1);
expect(out.length).toBeLessThanOrEqual(2); // cliff caps at 2; adaptive may cap further
});
});
+232
View File
@@ -0,0 +1,232 @@
/**
* v0.42.3.0 — autocut pure-function tests.
*
* Pins the score-discontinuity algorithm + the resolve ladder. No engine,
* no network — applyAutocut takes a list + a scoreOf accessor.
*/
import { describe, expect, test } from 'bun:test';
import {
DEFAULT_AUTOCUT,
autocutFromConfig,
resolveAutocut,
applyAutocut,
type AutocutConfig,
} from '../../src/core/search/autocut.ts';
// A tiny result shape: just a score and an id so we can assert which
// items survived the cut.
type R = { id: string; rs?: number };
const scoreOf = (r: R) => r.rs;
const ON: AutocutConfig = { enabled: true, jumpRatio: 0.2, minKeep: 1 };
function mk(scores: Array<number | undefined>): R[] {
return scores.map((rs, i) => ({ id: `r${i}`, rs }));
}
describe('DEFAULT_AUTOCUT', () => {
test('module default is enabled with 0.20 jump + minKeep 1', () => {
expect(DEFAULT_AUTOCUT.enabled).toBe(true);
expect(DEFAULT_AUTOCUT.jumpRatio).toBe(0.2);
expect(DEFAULT_AUTOCUT.minKeep).toBe(1);
});
test('is frozen', () => {
expect(Object.isFrozen(DEFAULT_AUTOCUT)).toBe(true);
});
});
describe('applyAutocut — cuts on a real cliff', () => {
test('clear cliff after rank 2 → keeps 2', () => {
// 1.0, 0.944, 0.222, 0.111 → biggest gap 0.85→0.2 (0.722) clears 0.20.
const r = applyAutocut(mk([0.9, 0.85, 0.2, 0.1]), scoreOf, ON);
expect(r.kept.map((x) => x.id)).toEqual(['r0', 'r1']);
expect(r.decision.applied).toBe(true);
expect(r.decision.signal).toBe('rerank');
expect(r.decision.kept).toBe(2);
expect(r.decision.total).toBe(4);
expect(r.decision.gapRatio).toBeGreaterThan(0.2);
});
test('cliff after rank 1 → keeps the single obvious answer', () => {
const r = applyAutocut(mk([0.95, 0.1, 0.08, 0.05]), scoreOf, ON);
expect(r.kept.map((x) => x.id)).toEqual(['r0']);
expect(r.decision.applied).toBe(true);
});
test('preserves original order among kept items (robust to unsorted input)', () => {
// Provider returned them out of order; autocut finds the cliff on a
// sorted copy and keeps items >= threshold IN INPUT ORDER.
const items = mk([0.85, 0.95, 0.12, 0.9]); // sorted desc: .95 .9 .85 .12
const r = applyAutocut(items, scoreOf, ON);
// Cliff is between .85 and .12 → threshold .85 → keep .85,.95,.9 → r0,r1,r3.
expect(r.kept.map((x) => x.id)).toEqual(['r0', 'r1', 'r3']);
});
});
describe('applyAutocut — declines to cut', () => {
test('flat scores (no cliff) → returns all, signal none', () => {
const r = applyAutocut(mk([0.9, 0.88, 0.86, 0.84]), scoreOf, ON);
expect(r.kept.length).toBe(4);
expect(r.decision.applied).toBe(false);
expect(r.decision.signal).toBe('none');
});
test('all-equal scores → no cut', () => {
const r = applyAutocut(mk([0.7, 0.7, 0.7, 0.7]), scoreOf, ON);
expect(r.kept.length).toBe(4);
expect(r.decision.applied).toBe(false);
});
test('the measured-RRF-flat case: rank1≈rank2 gap → no cut', () => {
// Mirrors return-policy.ts's documented finding: a ~identical top gap is
// NOT a separatrix. With these (correct vs wrong) shapes autocut stays out.
const correct = applyAutocut(mk([0.602, 0.569, 0.55, 0.54]), scoreOf, ON);
const wrong = applyAutocut(mk([0.569, 0.55, 0.54, 0.53]), scoreOf, ON);
expect(correct.decision.applied).toBe(false);
expect(wrong.decision.applied).toBe(false);
});
});
describe('applyAutocut — no-op guards', () => {
test('disabled → returns input unchanged', () => {
const items = mk([0.9, 0.1]);
const r = applyAutocut(items, scoreOf, { ...ON, enabled: false });
expect(r.kept).toBe(items);
expect(r.decision.applied).toBe(false);
});
test('empty input → no-op', () => {
const r = applyAutocut([] as R[], scoreOf, ON);
expect(r.kept).toEqual([]);
expect(r.decision.applied).toBe(false);
});
test('single item → no-op (no cliff possible)', () => {
const items = mk([0.9]);
const r = applyAutocut(items, scoreOf, ON);
expect(r.kept.length).toBe(1);
expect(r.decision.applied).toBe(false);
});
test('<2 finite scores → no-op (fail-open reranker: no scores stamped)', () => {
// All un-scored (reranker failed open → RRF order, no rerank_score).
const items = mk([undefined, undefined, undefined]);
const r = applyAutocut(items, scoreOf, ON);
expect(r.kept.length).toBe(3);
expect(r.decision.signal).toBe('none');
});
test('exactly 1 finite score among many → no-op', () => {
const items = mk([0.9, undefined, undefined]);
const r = applyAutocut(items, scoreOf, ON);
expect(r.kept.length).toBe(3);
expect(r.decision.applied).toBe(false);
});
test('top score <= 0 → no-op (score scale unusable)', () => {
const r = applyAutocut(mk([0, -0.1, -0.5]), scoreOf, ON);
expect(r.decision.applied).toBe(false);
});
test('non-finite scores are ignored', () => {
const items = mk([0.9, Number.NaN, 0.1]);
const r = applyAutocut(items, scoreOf, ON);
// Only 0.9 and 0.1 are finite → 2 scored, cliff → keep r0; NaN item dropped.
expect(r.kept.map((x) => x.id)).toEqual(['r0']);
});
});
describe('applyAutocut — failsafe', () => {
test('never returns empty when input is non-empty', () => {
const r = applyAutocut(mk([0.9, 0.01]), scoreOf, ON);
expect(r.kept.length).toBeGreaterThanOrEqual(1);
});
test('minKeep floor holds even with a cliff after rank 1', () => {
// Cliff says keep 1, but minKeep=2 expands to 2.
const r = applyAutocut(mk([0.95, 0.1, 0.08]), scoreOf, { ...ON, minKeep: 2 });
expect(r.kept.length).toBeGreaterThanOrEqual(2);
});
test('higher jumpRatio means only dramatic cliffs cut', () => {
const scores = mk([0.9, 0.6, 0.5, 0.4]); // top gap normalized ~0.33
const lenient = applyAutocut(scores, scoreOf, { ...ON, jumpRatio: 0.2 });
const strict = applyAutocut(scores, scoreOf, { ...ON, jumpRatio: 0.5 });
expect(lenient.decision.applied).toBe(true);
expect(strict.decision.applied).toBe(false);
});
});
describe('applyAutocut — preserve predicate (Codex P1: alias-injected matches)', () => {
// An alias-hop exact match is injected AFTER reranking, so it has no score.
type AR = { id: string; rs?: number; alias?: boolean };
const arScore = (r: AR) => r.rs;
const isAlias = (r: AR) => r.alias === true;
test('unscored alias item survives a cut that drops scored noise', () => {
const items: AR[] = [
{ id: 'alias', alias: true }, // injected, no rerank_score
{ id: 'top', rs: 0.95 },
{ id: 'noise1', rs: 0.2 },
{ id: 'noise2', rs: 0.1 },
];
const r = applyAutocut(items, arScore, ON, isAlias);
// Cliff after 'top' drops noise1/noise2; 'alias' is preserved despite no score.
expect(r.kept.map((x) => x.id).sort()).toEqual(['alias', 'top']);
expect(r.decision.applied).toBe(true);
});
test('without the predicate, the unscored alias item is dropped on a cut', () => {
const items: AR[] = [
{ id: 'alias', alias: true },
{ id: 'top', rs: 0.95 },
{ id: 'noise', rs: 0.1 },
];
const r = applyAutocut(items, arScore, ON); // no preserve
expect(r.kept.map((x) => x.id)).toEqual(['top']);
});
test('preserve does not force a cut on a flat curve (no-op still returns all)', () => {
const items: AR[] = [
{ id: 'alias', alias: true },
{ id: 'a', rs: 0.6 },
{ id: 'b', rs: 0.58 },
];
const r = applyAutocut(items, arScore, ON, isAlias);
expect(r.decision.applied).toBe(false);
expect(r.kept.length).toBe(3);
});
});
describe('autocutFromConfig', () => {
test('reads search.autocut + search.autocut_jump', () => {
const out = autocutFromConfig({ search: { autocut: false, autocut_jump: 0.4 } });
expect(out.enabled).toBe(false);
expect(out.jumpRatio).toBe(0.4);
});
test('clamps out-of-range jump to fallback (ignored)', () => {
expect(autocutFromConfig({ search: { autocut_jump: 5 } }).jumpRatio).toBeUndefined();
expect(autocutFromConfig({ search: { autocut_jump: 0 } }).jumpRatio).toBeUndefined();
});
test('empty / missing config → empty partial', () => {
expect(autocutFromConfig(null)).toEqual({});
expect(autocutFromConfig({})).toEqual({});
});
});
describe('resolveAutocut — precedence ladder', () => {
test('defaults → config → per-call', () => {
const cfg = resolveAutocut(undefined, { jumpRatio: 0.3 });
expect(cfg.jumpRatio).toBe(0.3);
expect(cfg.enabled).toBe(true); // default
});
test('per-call true/false overrides config enabled', () => {
expect(resolveAutocut(false, { enabled: true }).enabled).toBe(false);
expect(resolveAutocut(true, { enabled: false }).enabled).toBe(true);
});
test('per-call partial overrides specific fields', () => {
const cfg = resolveAutocut({ jumpRatio: 0.5 }, { jumpRatio: 0.3, enabled: false });
expect(cfg.jumpRatio).toBe(0.5);
expect(cfg.enabled).toBe(false); // inherited from config (partial didn't set it)
});
});
+63
View File
@@ -10,7 +10,9 @@ 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 {
@@ -197,3 +199,64 @@ describe('formatResultExplain — number formatting', () => {
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);
});
});
@@ -167,8 +167,14 @@ describe('hybridSearch — reranker enabled (reorder)', () => {
// Now rerank only the top 2 (swap them); the tail (indices 2..N-1)
// must keep its baseline order.
// v0.42.3.0: autocut is default-ON in balanced mode and would cut this
// artificial 2-item scored head (0.99 vs 0.5 is a cliff) down to 1,
// dropping the un-scored tail. This test isolates RERANKER tail mechanics,
// so disable autocut here — in real balanced mode top_n_in = searchLimit
// (D4), so topNIn < pool with an un-scored tail never happens by default.
const reranked = await hybridSearch(engine, 'alpha keyword', {
limit: 10,
autocut: false,
reranker: {
enabled: true,
topNIn: 2,
+1 -1
View File
@@ -55,7 +55,7 @@ describe('KNOBS_HASH_VERSION + version invariants', () => {
// v0.41.22.0 (type-unification): 5→6 to fold the alias_resolved
// post-fusion boost. Cache rows written before the boost stage
// cannot leak past the new stage.
expect(KNOBS_HASH_VERSION).toBe(7);
expect(KNOBS_HASH_VERSION).toBe(8);
});
test('hash is 16 hex chars regardless of reranker config', () => {
+44
View File
@@ -0,0 +1,44 @@
/**
* v0.42.3.0 — pins the agent-facing autocut surface on the `query` op.
*
* Autocut is the smart default; the param exists ONLY as a ceiling override
* (force the full top-K). The description must teach the agent that they
* almost never set it, and that `false` is the breadth escape hatch. Guards
* against a refactor silently dropping the param or the instruction.
*/
import { describe, expect, test } from 'bun:test';
import { operationsByName } from '../../src/core/operations.ts';
describe('query op — autocut agent surface', () => {
const query = operationsByName['query'];
test('query op exists', () => {
expect(query).toBeDefined();
});
test('autocut is a boolean param on query', () => {
const param = query.params?.autocut as { type?: string; description?: string } | undefined;
expect(param).toBeDefined();
expect(param?.type).toBe('boolean');
});
test('description frames autocut as the default and FALSE as the breadth override', () => {
const desc = ((query.params?.autocut as { description?: string })?.description ?? '').toLowerCase();
// It's a default, not a feature the agent turns on.
expect(desc).toContain('default');
// The actionable direction is FALSE for breadth.
expect(desc).toContain('false');
expect(desc).toContain('breadth');
// Safety contract so the agent trusts it.
expect(desc).toContain('never returns empty');
// Distinguish from adaptive_return so the agent picks the right knob.
expect(desc).toContain('adaptive_return');
});
test('search op (keyword-only) does NOT carry autocut (no reranker there)', () => {
const search = operationsByName['search'];
expect(search).toBeDefined();
expect(search.params?.autocut).toBeUndefined();
});
});