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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
7b0d99adb0
commit
d9eadfec13
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user