From f3782991fe2e11af3fe148d46b6638af781d8107 Mon Sep 17 00:00:00 2001 From: tanglearncode Date: Sun, 9 Aug 2026 06:57:59 +0800 Subject: [PATCH] feat(routing): let a refusal outlive the session, and fade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A refusal was forgotten the moment the window closed. It is the clearest signal a user ever gives about routing, and it was being thrown away. It now survives, as evidence rather than as a rule. Strength halves every two weeks and is negligible by six; refusing the same context again restarts the clock and deepens the penalty, which is the escalation a browser uses for a permission prompt dismissed several times over. Once is noise, three times is an answer. Inside the session that produced it a refusal still means exactly what it meant before: that context is not raised again, full stop. Outside it, the effect is a discount on the score, so a refused context drops down the shortlist rather than off it — the same request should reach it again eventually, just not today and not without better evidence. The discount is applied after ranking rather than folded into the index, because refusals change on their own schedule and rebuilding the index for a multiplier would throw away the cache for nothing. Nothing here becomes permanent: the multiplier never reaches zero however many refusals there have been, and records are dropped once they are too old to change an outcome. The only permanent no in this plugin is manual mode, which is a decision the user made on purpose. --- .../src/core/routing-candidates.mjs | 22 ++- .../plugins/neatcontext/src/core/routing.mjs | 100 +++++++++-- .../src/core/routing-candidates.mjs | 22 ++- .../neatcontext/src/core/routing.mjs | 100 +++++++++-- .../src/core/routing-candidates.mjs | 22 ++- .../copilot/neatcontext/src/core/routing.mjs | 100 +++++++++-- .../src/core/routing-candidates.mjs | 22 ++- .../neatcontext/src/core/routing.mjs | 100 +++++++++-- .../src/core/routing-candidates.mjs | 22 ++- plugins/pi/neatcontext/src/core/routing.mjs | 100 +++++++++-- shared/core/routing-candidates.mjs | 22 ++- shared/core/routing.mjs | 100 +++++++++-- tests/routing-declines.test.mjs | 167 ++++++++++++++++++ 13 files changed, 797 insertions(+), 102 deletions(-) create mode 100644 tests/routing-declines.test.mjs diff --git a/codex-marketplace/plugins/neatcontext/src/core/routing-candidates.mjs b/codex-marketplace/plugins/neatcontext/src/core/routing-candidates.mjs index ae3ad79..4ccd0d6 100644 --- a/codex-marketplace/plugins/neatcontext/src/core/routing-candidates.mjs +++ b/codex-marketplace/plugins/neatcontext/src/core/routing-candidates.mjs @@ -18,6 +18,7 @@ // has in hand — a context's revision and timestamps, a card's timestamp — so // checking costs no extra reads. Only a real change pays for a rebuild. +import { declineFactor } from "./routing.mjs"; import { buildIndex, rank } from "./routing-search.mjs"; // The fields, in the shape the scorer weighs. Aliases are joined into one @@ -107,11 +108,20 @@ export function createRoutingIndex({ listFiles }) { key = next; } const names = new Map(contexts.map((context) => [context.id, context.name])); - return rank(index, query, options).map((result) => ({ - id: result.id, - name: names.get(result.id), - score: result.score, - matched: result.matched - })); + const now = new Date(); + // Past refusals are applied after ranking rather than folded into the + // index: they change on their own schedule, and rebuilding the index every + // time someone says no would throw away the cache for a multiplier. + // + // Re-sorted afterwards because a discount can change the order, and the + // shortlist's whole meaning is that it is in order. + return rank(index, query, options) + .map((result) => ({ + id: result.id, + name: names.get(result.id), + score: result.score * declineFactor(state, result.id, now), + matched: result.matched + })) + .sort((left, right) => right.score - left.score || left.id.localeCompare(right.id)); }; } diff --git a/codex-marketplace/plugins/neatcontext/src/core/routing.mjs b/codex-marketplace/plugins/neatcontext/src/core/routing.mjs index 1a25209..5be0c74 100644 --- a/codex-marketplace/plugins/neatcontext/src/core/routing.mjs +++ b/codex-marketplace/plugins/neatcontext/src/core/routing.mjs @@ -47,6 +47,24 @@ const MAX_ALIASES = 12; const MAX_DECISIONS = 100; const MAX_SESSIONS = 20; +// How long a refusal keeps counting for. +// +// "Not that one" is true tomorrow as well, so forgetting it when the window +// closes throws away the clearest signal the user ever gives. But it is +// evidence, not a rule: a context refused during one week's work should not be +// unreachable a quarter later. So it fades — half its strength every two weeks, +// negligible by six — and refusing the same context again resets the clock and +// deepens it, which is the escalation a browser uses for a permission prompt +// dismissed several times over. Once is noise; three times is an answer. +// +// Nothing here ever becomes permanent. The only permanent no in this plugin is +// manual mode, because that one is a decision the user made on purpose. +const DECLINE_HALF_LIFE_DAYS = 14; +const DECLINE_LIFETIME_DAYS = 42; +const DECLINE_WEIGHT = 0.4; +const MAX_DECLINE_COUNT = 10; +const DAY_MS = 24 * 60 * 60 * 1000; + export function routingFilePath() { return path.join(neatContextHome(), "plugin-routing.json"); } @@ -85,8 +103,15 @@ export async function readRouting() { cards[id] = card; } } + const declines = {}; + for (const [id, raw] of Object.entries(parsed?.declines ?? {})) { + if (typeof raw?.at === "string" && Number.isInteger(raw.count) && raw.count > 0) { + declines[id] = { at: raw.at, count: Math.min(raw.count, MAX_DECLINE_COUNT) }; + } + } return { schema: SCHEMA, + declines, mode: MODES.includes(parsed?.mode) ? parsed.mode : DEFAULT_MODE, cards, sessions: typeof parsed?.sessions === "object" && parsed.sessions !== null ? parsed.sessions : {}, @@ -103,7 +128,16 @@ async function writeRouting(state) { await mkdir(path.dirname(file), { recursive: true }); await writeFile( file, - `${JSON.stringify({ ...state, sessions: Object.fromEntries(sessions), decisions: state.decisions.slice(-MAX_DECISIONS) }, null, 2)}\n`, + `${JSON.stringify( + { + ...state, + sessions: Object.fromEntries(sessions), + declines: pruneDeclines(state.declines, Date.now()), + decisions: state.decisions.slice(-MAX_DECISIONS) + }, + null, + 2 + )}\n`, { encoding: "utf8", mode: 0o600 } ); } @@ -226,22 +260,66 @@ export function switchPolicy(state, { id, targetId, connectedId, requested = fal // Remembering a refusal is what stops auto mode from proposing the same wrong // context on every message that shares a word with it. -export function noteDeclined(targetId, { id = sessionId() } = {}) { - if (!id) { - return Promise.resolve(null); - } +// +// Two records, because a refusal means two different things. Inside the session +// it is absolute — that context is not raised again, full stop. Outside it, it +// is evidence that fades: the same request should still reach the same context +// eventually, just not today and not without something better to go on. +export function noteDeclined(targetId, { id = sessionId(), now = new Date() } = {}) { return update((state) => { - const session = state.sessions[id] ?? {}; - const declined = session.declined ?? []; - state.sessions[id] = { - ...session, - declined: declined.includes(targetId) ? declined : [...declined, targetId], - updatedAt: new Date().toISOString() + const previous = state.declines[targetId]; + state.declines[targetId] = { + at: now.toISOString(), + // Repeating a refusal restarts the clock and deepens it, rather than + // simply re-stating what was already known. + count: Math.min((previous?.count ?? 0) + 1, MAX_DECLINE_COUNT) }; + if (id) { + const session = state.sessions[id] ?? {}; + const declined = session.declined ?? []; + state.sessions[id] = { + ...session, + declined: declined.includes(targetId) ? declined : [...declined, targetId], + updatedAt: now.toISOString() + }; + } return targetId; }); } +// What a past refusal does to a context's score now: a multiplier in (0, 1], +// where 1 means it is not holding anything back. +// +// Strength halves every two weeks, so a fresh single refusal costs a candidate +// most of its lead and a six-week-old one costs it almost nothing. Repeats +// compound rather than add, which keeps the result inside the range however +// many there have been. +export function declineFactor(state, contextId, now = new Date()) { + const entry = state.declines?.[contextId]; + if (!entry) { + return 1; + } + const days = (now.getTime() - Date.parse(entry.at)) / DAY_MS; + if (!Number.isFinite(days) || days >= DECLINE_LIFETIME_DAYS) { + return 1; + } + const strength = DECLINE_WEIGHT * 0.5 ** (Math.max(days, 0) / DECLINE_HALF_LIFE_DAYS); + return (1 - strength) ** entry.count; +} + +// Dropped once it can no longer change an outcome, so the file does not +// accumulate a record of every context ever turned down. +function pruneDeclines(declines, now) { + const kept = {}; + for (const [id, entry] of Object.entries(declines)) { + const days = (now - Date.parse(entry.at)) / DAY_MS; + if (Number.isFinite(days) && days < DECLINE_LIFETIME_DAYS) { + kept[id] = entry; + } + } + return kept; +} + // Every switch, with what it was routing away from and why. Thresholds and card // quality are guesses until this has something in it: manual selections are the // ground truth that says whether the derived lines actually route correctly. diff --git a/plugins/claude-code/neatcontext/src/core/routing-candidates.mjs b/plugins/claude-code/neatcontext/src/core/routing-candidates.mjs index ae3ad79..4ccd0d6 100644 --- a/plugins/claude-code/neatcontext/src/core/routing-candidates.mjs +++ b/plugins/claude-code/neatcontext/src/core/routing-candidates.mjs @@ -18,6 +18,7 @@ // has in hand — a context's revision and timestamps, a card's timestamp — so // checking costs no extra reads. Only a real change pays for a rebuild. +import { declineFactor } from "./routing.mjs"; import { buildIndex, rank } from "./routing-search.mjs"; // The fields, in the shape the scorer weighs. Aliases are joined into one @@ -107,11 +108,20 @@ export function createRoutingIndex({ listFiles }) { key = next; } const names = new Map(contexts.map((context) => [context.id, context.name])); - return rank(index, query, options).map((result) => ({ - id: result.id, - name: names.get(result.id), - score: result.score, - matched: result.matched - })); + const now = new Date(); + // Past refusals are applied after ranking rather than folded into the + // index: they change on their own schedule, and rebuilding the index every + // time someone says no would throw away the cache for a multiplier. + // + // Re-sorted afterwards because a discount can change the order, and the + // shortlist's whole meaning is that it is in order. + return rank(index, query, options) + .map((result) => ({ + id: result.id, + name: names.get(result.id), + score: result.score * declineFactor(state, result.id, now), + matched: result.matched + })) + .sort((left, right) => right.score - left.score || left.id.localeCompare(right.id)); }; } diff --git a/plugins/claude-code/neatcontext/src/core/routing.mjs b/plugins/claude-code/neatcontext/src/core/routing.mjs index 1a25209..5be0c74 100644 --- a/plugins/claude-code/neatcontext/src/core/routing.mjs +++ b/plugins/claude-code/neatcontext/src/core/routing.mjs @@ -47,6 +47,24 @@ const MAX_ALIASES = 12; const MAX_DECISIONS = 100; const MAX_SESSIONS = 20; +// How long a refusal keeps counting for. +// +// "Not that one" is true tomorrow as well, so forgetting it when the window +// closes throws away the clearest signal the user ever gives. But it is +// evidence, not a rule: a context refused during one week's work should not be +// unreachable a quarter later. So it fades — half its strength every two weeks, +// negligible by six — and refusing the same context again resets the clock and +// deepens it, which is the escalation a browser uses for a permission prompt +// dismissed several times over. Once is noise; three times is an answer. +// +// Nothing here ever becomes permanent. The only permanent no in this plugin is +// manual mode, because that one is a decision the user made on purpose. +const DECLINE_HALF_LIFE_DAYS = 14; +const DECLINE_LIFETIME_DAYS = 42; +const DECLINE_WEIGHT = 0.4; +const MAX_DECLINE_COUNT = 10; +const DAY_MS = 24 * 60 * 60 * 1000; + export function routingFilePath() { return path.join(neatContextHome(), "plugin-routing.json"); } @@ -85,8 +103,15 @@ export async function readRouting() { cards[id] = card; } } + const declines = {}; + for (const [id, raw] of Object.entries(parsed?.declines ?? {})) { + if (typeof raw?.at === "string" && Number.isInteger(raw.count) && raw.count > 0) { + declines[id] = { at: raw.at, count: Math.min(raw.count, MAX_DECLINE_COUNT) }; + } + } return { schema: SCHEMA, + declines, mode: MODES.includes(parsed?.mode) ? parsed.mode : DEFAULT_MODE, cards, sessions: typeof parsed?.sessions === "object" && parsed.sessions !== null ? parsed.sessions : {}, @@ -103,7 +128,16 @@ async function writeRouting(state) { await mkdir(path.dirname(file), { recursive: true }); await writeFile( file, - `${JSON.stringify({ ...state, sessions: Object.fromEntries(sessions), decisions: state.decisions.slice(-MAX_DECISIONS) }, null, 2)}\n`, + `${JSON.stringify( + { + ...state, + sessions: Object.fromEntries(sessions), + declines: pruneDeclines(state.declines, Date.now()), + decisions: state.decisions.slice(-MAX_DECISIONS) + }, + null, + 2 + )}\n`, { encoding: "utf8", mode: 0o600 } ); } @@ -226,22 +260,66 @@ export function switchPolicy(state, { id, targetId, connectedId, requested = fal // Remembering a refusal is what stops auto mode from proposing the same wrong // context on every message that shares a word with it. -export function noteDeclined(targetId, { id = sessionId() } = {}) { - if (!id) { - return Promise.resolve(null); - } +// +// Two records, because a refusal means two different things. Inside the session +// it is absolute — that context is not raised again, full stop. Outside it, it +// is evidence that fades: the same request should still reach the same context +// eventually, just not today and not without something better to go on. +export function noteDeclined(targetId, { id = sessionId(), now = new Date() } = {}) { return update((state) => { - const session = state.sessions[id] ?? {}; - const declined = session.declined ?? []; - state.sessions[id] = { - ...session, - declined: declined.includes(targetId) ? declined : [...declined, targetId], - updatedAt: new Date().toISOString() + const previous = state.declines[targetId]; + state.declines[targetId] = { + at: now.toISOString(), + // Repeating a refusal restarts the clock and deepens it, rather than + // simply re-stating what was already known. + count: Math.min((previous?.count ?? 0) + 1, MAX_DECLINE_COUNT) }; + if (id) { + const session = state.sessions[id] ?? {}; + const declined = session.declined ?? []; + state.sessions[id] = { + ...session, + declined: declined.includes(targetId) ? declined : [...declined, targetId], + updatedAt: now.toISOString() + }; + } return targetId; }); } +// What a past refusal does to a context's score now: a multiplier in (0, 1], +// where 1 means it is not holding anything back. +// +// Strength halves every two weeks, so a fresh single refusal costs a candidate +// most of its lead and a six-week-old one costs it almost nothing. Repeats +// compound rather than add, which keeps the result inside the range however +// many there have been. +export function declineFactor(state, contextId, now = new Date()) { + const entry = state.declines?.[contextId]; + if (!entry) { + return 1; + } + const days = (now.getTime() - Date.parse(entry.at)) / DAY_MS; + if (!Number.isFinite(days) || days >= DECLINE_LIFETIME_DAYS) { + return 1; + } + const strength = DECLINE_WEIGHT * 0.5 ** (Math.max(days, 0) / DECLINE_HALF_LIFE_DAYS); + return (1 - strength) ** entry.count; +} + +// Dropped once it can no longer change an outcome, so the file does not +// accumulate a record of every context ever turned down. +function pruneDeclines(declines, now) { + const kept = {}; + for (const [id, entry] of Object.entries(declines)) { + const days = (now - Date.parse(entry.at)) / DAY_MS; + if (Number.isFinite(days) && days < DECLINE_LIFETIME_DAYS) { + kept[id] = entry; + } + } + return kept; +} + // Every switch, with what it was routing away from and why. Thresholds and card // quality are guesses until this has something in it: manual selections are the // ground truth that says whether the derived lines actually route correctly. diff --git a/plugins/copilot/neatcontext/src/core/routing-candidates.mjs b/plugins/copilot/neatcontext/src/core/routing-candidates.mjs index ae3ad79..4ccd0d6 100644 --- a/plugins/copilot/neatcontext/src/core/routing-candidates.mjs +++ b/plugins/copilot/neatcontext/src/core/routing-candidates.mjs @@ -18,6 +18,7 @@ // has in hand — a context's revision and timestamps, a card's timestamp — so // checking costs no extra reads. Only a real change pays for a rebuild. +import { declineFactor } from "./routing.mjs"; import { buildIndex, rank } from "./routing-search.mjs"; // The fields, in the shape the scorer weighs. Aliases are joined into one @@ -107,11 +108,20 @@ export function createRoutingIndex({ listFiles }) { key = next; } const names = new Map(contexts.map((context) => [context.id, context.name])); - return rank(index, query, options).map((result) => ({ - id: result.id, - name: names.get(result.id), - score: result.score, - matched: result.matched - })); + const now = new Date(); + // Past refusals are applied after ranking rather than folded into the + // index: they change on their own schedule, and rebuilding the index every + // time someone says no would throw away the cache for a multiplier. + // + // Re-sorted afterwards because a discount can change the order, and the + // shortlist's whole meaning is that it is in order. + return rank(index, query, options) + .map((result) => ({ + id: result.id, + name: names.get(result.id), + score: result.score * declineFactor(state, result.id, now), + matched: result.matched + })) + .sort((left, right) => right.score - left.score || left.id.localeCompare(right.id)); }; } diff --git a/plugins/copilot/neatcontext/src/core/routing.mjs b/plugins/copilot/neatcontext/src/core/routing.mjs index 1a25209..5be0c74 100644 --- a/plugins/copilot/neatcontext/src/core/routing.mjs +++ b/plugins/copilot/neatcontext/src/core/routing.mjs @@ -47,6 +47,24 @@ const MAX_ALIASES = 12; const MAX_DECISIONS = 100; const MAX_SESSIONS = 20; +// How long a refusal keeps counting for. +// +// "Not that one" is true tomorrow as well, so forgetting it when the window +// closes throws away the clearest signal the user ever gives. But it is +// evidence, not a rule: a context refused during one week's work should not be +// unreachable a quarter later. So it fades — half its strength every two weeks, +// negligible by six — and refusing the same context again resets the clock and +// deepens it, which is the escalation a browser uses for a permission prompt +// dismissed several times over. Once is noise; three times is an answer. +// +// Nothing here ever becomes permanent. The only permanent no in this plugin is +// manual mode, because that one is a decision the user made on purpose. +const DECLINE_HALF_LIFE_DAYS = 14; +const DECLINE_LIFETIME_DAYS = 42; +const DECLINE_WEIGHT = 0.4; +const MAX_DECLINE_COUNT = 10; +const DAY_MS = 24 * 60 * 60 * 1000; + export function routingFilePath() { return path.join(neatContextHome(), "plugin-routing.json"); } @@ -85,8 +103,15 @@ export async function readRouting() { cards[id] = card; } } + const declines = {}; + for (const [id, raw] of Object.entries(parsed?.declines ?? {})) { + if (typeof raw?.at === "string" && Number.isInteger(raw.count) && raw.count > 0) { + declines[id] = { at: raw.at, count: Math.min(raw.count, MAX_DECLINE_COUNT) }; + } + } return { schema: SCHEMA, + declines, mode: MODES.includes(parsed?.mode) ? parsed.mode : DEFAULT_MODE, cards, sessions: typeof parsed?.sessions === "object" && parsed.sessions !== null ? parsed.sessions : {}, @@ -103,7 +128,16 @@ async function writeRouting(state) { await mkdir(path.dirname(file), { recursive: true }); await writeFile( file, - `${JSON.stringify({ ...state, sessions: Object.fromEntries(sessions), decisions: state.decisions.slice(-MAX_DECISIONS) }, null, 2)}\n`, + `${JSON.stringify( + { + ...state, + sessions: Object.fromEntries(sessions), + declines: pruneDeclines(state.declines, Date.now()), + decisions: state.decisions.slice(-MAX_DECISIONS) + }, + null, + 2 + )}\n`, { encoding: "utf8", mode: 0o600 } ); } @@ -226,22 +260,66 @@ export function switchPolicy(state, { id, targetId, connectedId, requested = fal // Remembering a refusal is what stops auto mode from proposing the same wrong // context on every message that shares a word with it. -export function noteDeclined(targetId, { id = sessionId() } = {}) { - if (!id) { - return Promise.resolve(null); - } +// +// Two records, because a refusal means two different things. Inside the session +// it is absolute — that context is not raised again, full stop. Outside it, it +// is evidence that fades: the same request should still reach the same context +// eventually, just not today and not without something better to go on. +export function noteDeclined(targetId, { id = sessionId(), now = new Date() } = {}) { return update((state) => { - const session = state.sessions[id] ?? {}; - const declined = session.declined ?? []; - state.sessions[id] = { - ...session, - declined: declined.includes(targetId) ? declined : [...declined, targetId], - updatedAt: new Date().toISOString() + const previous = state.declines[targetId]; + state.declines[targetId] = { + at: now.toISOString(), + // Repeating a refusal restarts the clock and deepens it, rather than + // simply re-stating what was already known. + count: Math.min((previous?.count ?? 0) + 1, MAX_DECLINE_COUNT) }; + if (id) { + const session = state.sessions[id] ?? {}; + const declined = session.declined ?? []; + state.sessions[id] = { + ...session, + declined: declined.includes(targetId) ? declined : [...declined, targetId], + updatedAt: now.toISOString() + }; + } return targetId; }); } +// What a past refusal does to a context's score now: a multiplier in (0, 1], +// where 1 means it is not holding anything back. +// +// Strength halves every two weeks, so a fresh single refusal costs a candidate +// most of its lead and a six-week-old one costs it almost nothing. Repeats +// compound rather than add, which keeps the result inside the range however +// many there have been. +export function declineFactor(state, contextId, now = new Date()) { + const entry = state.declines?.[contextId]; + if (!entry) { + return 1; + } + const days = (now.getTime() - Date.parse(entry.at)) / DAY_MS; + if (!Number.isFinite(days) || days >= DECLINE_LIFETIME_DAYS) { + return 1; + } + const strength = DECLINE_WEIGHT * 0.5 ** (Math.max(days, 0) / DECLINE_HALF_LIFE_DAYS); + return (1 - strength) ** entry.count; +} + +// Dropped once it can no longer change an outcome, so the file does not +// accumulate a record of every context ever turned down. +function pruneDeclines(declines, now) { + const kept = {}; + for (const [id, entry] of Object.entries(declines)) { + const days = (now - Date.parse(entry.at)) / DAY_MS; + if (Number.isFinite(days) && days < DECLINE_LIFETIME_DAYS) { + kept[id] = entry; + } + } + return kept; +} + // Every switch, with what it was routing away from and why. Thresholds and card // quality are guesses until this has something in it: manual selections are the // ground truth that says whether the derived lines actually route correctly. diff --git a/plugins/kimi-code/neatcontext/src/core/routing-candidates.mjs b/plugins/kimi-code/neatcontext/src/core/routing-candidates.mjs index ae3ad79..4ccd0d6 100644 --- a/plugins/kimi-code/neatcontext/src/core/routing-candidates.mjs +++ b/plugins/kimi-code/neatcontext/src/core/routing-candidates.mjs @@ -18,6 +18,7 @@ // has in hand — a context's revision and timestamps, a card's timestamp — so // checking costs no extra reads. Only a real change pays for a rebuild. +import { declineFactor } from "./routing.mjs"; import { buildIndex, rank } from "./routing-search.mjs"; // The fields, in the shape the scorer weighs. Aliases are joined into one @@ -107,11 +108,20 @@ export function createRoutingIndex({ listFiles }) { key = next; } const names = new Map(contexts.map((context) => [context.id, context.name])); - return rank(index, query, options).map((result) => ({ - id: result.id, - name: names.get(result.id), - score: result.score, - matched: result.matched - })); + const now = new Date(); + // Past refusals are applied after ranking rather than folded into the + // index: they change on their own schedule, and rebuilding the index every + // time someone says no would throw away the cache for a multiplier. + // + // Re-sorted afterwards because a discount can change the order, and the + // shortlist's whole meaning is that it is in order. + return rank(index, query, options) + .map((result) => ({ + id: result.id, + name: names.get(result.id), + score: result.score * declineFactor(state, result.id, now), + matched: result.matched + })) + .sort((left, right) => right.score - left.score || left.id.localeCompare(right.id)); }; } diff --git a/plugins/kimi-code/neatcontext/src/core/routing.mjs b/plugins/kimi-code/neatcontext/src/core/routing.mjs index 1a25209..5be0c74 100644 --- a/plugins/kimi-code/neatcontext/src/core/routing.mjs +++ b/plugins/kimi-code/neatcontext/src/core/routing.mjs @@ -47,6 +47,24 @@ const MAX_ALIASES = 12; const MAX_DECISIONS = 100; const MAX_SESSIONS = 20; +// How long a refusal keeps counting for. +// +// "Not that one" is true tomorrow as well, so forgetting it when the window +// closes throws away the clearest signal the user ever gives. But it is +// evidence, not a rule: a context refused during one week's work should not be +// unreachable a quarter later. So it fades — half its strength every two weeks, +// negligible by six — and refusing the same context again resets the clock and +// deepens it, which is the escalation a browser uses for a permission prompt +// dismissed several times over. Once is noise; three times is an answer. +// +// Nothing here ever becomes permanent. The only permanent no in this plugin is +// manual mode, because that one is a decision the user made on purpose. +const DECLINE_HALF_LIFE_DAYS = 14; +const DECLINE_LIFETIME_DAYS = 42; +const DECLINE_WEIGHT = 0.4; +const MAX_DECLINE_COUNT = 10; +const DAY_MS = 24 * 60 * 60 * 1000; + export function routingFilePath() { return path.join(neatContextHome(), "plugin-routing.json"); } @@ -85,8 +103,15 @@ export async function readRouting() { cards[id] = card; } } + const declines = {}; + for (const [id, raw] of Object.entries(parsed?.declines ?? {})) { + if (typeof raw?.at === "string" && Number.isInteger(raw.count) && raw.count > 0) { + declines[id] = { at: raw.at, count: Math.min(raw.count, MAX_DECLINE_COUNT) }; + } + } return { schema: SCHEMA, + declines, mode: MODES.includes(parsed?.mode) ? parsed.mode : DEFAULT_MODE, cards, sessions: typeof parsed?.sessions === "object" && parsed.sessions !== null ? parsed.sessions : {}, @@ -103,7 +128,16 @@ async function writeRouting(state) { await mkdir(path.dirname(file), { recursive: true }); await writeFile( file, - `${JSON.stringify({ ...state, sessions: Object.fromEntries(sessions), decisions: state.decisions.slice(-MAX_DECISIONS) }, null, 2)}\n`, + `${JSON.stringify( + { + ...state, + sessions: Object.fromEntries(sessions), + declines: pruneDeclines(state.declines, Date.now()), + decisions: state.decisions.slice(-MAX_DECISIONS) + }, + null, + 2 + )}\n`, { encoding: "utf8", mode: 0o600 } ); } @@ -226,22 +260,66 @@ export function switchPolicy(state, { id, targetId, connectedId, requested = fal // Remembering a refusal is what stops auto mode from proposing the same wrong // context on every message that shares a word with it. -export function noteDeclined(targetId, { id = sessionId() } = {}) { - if (!id) { - return Promise.resolve(null); - } +// +// Two records, because a refusal means two different things. Inside the session +// it is absolute — that context is not raised again, full stop. Outside it, it +// is evidence that fades: the same request should still reach the same context +// eventually, just not today and not without something better to go on. +export function noteDeclined(targetId, { id = sessionId(), now = new Date() } = {}) { return update((state) => { - const session = state.sessions[id] ?? {}; - const declined = session.declined ?? []; - state.sessions[id] = { - ...session, - declined: declined.includes(targetId) ? declined : [...declined, targetId], - updatedAt: new Date().toISOString() + const previous = state.declines[targetId]; + state.declines[targetId] = { + at: now.toISOString(), + // Repeating a refusal restarts the clock and deepens it, rather than + // simply re-stating what was already known. + count: Math.min((previous?.count ?? 0) + 1, MAX_DECLINE_COUNT) }; + if (id) { + const session = state.sessions[id] ?? {}; + const declined = session.declined ?? []; + state.sessions[id] = { + ...session, + declined: declined.includes(targetId) ? declined : [...declined, targetId], + updatedAt: now.toISOString() + }; + } return targetId; }); } +// What a past refusal does to a context's score now: a multiplier in (0, 1], +// where 1 means it is not holding anything back. +// +// Strength halves every two weeks, so a fresh single refusal costs a candidate +// most of its lead and a six-week-old one costs it almost nothing. Repeats +// compound rather than add, which keeps the result inside the range however +// many there have been. +export function declineFactor(state, contextId, now = new Date()) { + const entry = state.declines?.[contextId]; + if (!entry) { + return 1; + } + const days = (now.getTime() - Date.parse(entry.at)) / DAY_MS; + if (!Number.isFinite(days) || days >= DECLINE_LIFETIME_DAYS) { + return 1; + } + const strength = DECLINE_WEIGHT * 0.5 ** (Math.max(days, 0) / DECLINE_HALF_LIFE_DAYS); + return (1 - strength) ** entry.count; +} + +// Dropped once it can no longer change an outcome, so the file does not +// accumulate a record of every context ever turned down. +function pruneDeclines(declines, now) { + const kept = {}; + for (const [id, entry] of Object.entries(declines)) { + const days = (now - Date.parse(entry.at)) / DAY_MS; + if (Number.isFinite(days) && days < DECLINE_LIFETIME_DAYS) { + kept[id] = entry; + } + } + return kept; +} + // Every switch, with what it was routing away from and why. Thresholds and card // quality are guesses until this has something in it: manual selections are the // ground truth that says whether the derived lines actually route correctly. diff --git a/plugins/pi/neatcontext/src/core/routing-candidates.mjs b/plugins/pi/neatcontext/src/core/routing-candidates.mjs index ae3ad79..4ccd0d6 100644 --- a/plugins/pi/neatcontext/src/core/routing-candidates.mjs +++ b/plugins/pi/neatcontext/src/core/routing-candidates.mjs @@ -18,6 +18,7 @@ // has in hand — a context's revision and timestamps, a card's timestamp — so // checking costs no extra reads. Only a real change pays for a rebuild. +import { declineFactor } from "./routing.mjs"; import { buildIndex, rank } from "./routing-search.mjs"; // The fields, in the shape the scorer weighs. Aliases are joined into one @@ -107,11 +108,20 @@ export function createRoutingIndex({ listFiles }) { key = next; } const names = new Map(contexts.map((context) => [context.id, context.name])); - return rank(index, query, options).map((result) => ({ - id: result.id, - name: names.get(result.id), - score: result.score, - matched: result.matched - })); + const now = new Date(); + // Past refusals are applied after ranking rather than folded into the + // index: they change on their own schedule, and rebuilding the index every + // time someone says no would throw away the cache for a multiplier. + // + // Re-sorted afterwards because a discount can change the order, and the + // shortlist's whole meaning is that it is in order. + return rank(index, query, options) + .map((result) => ({ + id: result.id, + name: names.get(result.id), + score: result.score * declineFactor(state, result.id, now), + matched: result.matched + })) + .sort((left, right) => right.score - left.score || left.id.localeCompare(right.id)); }; } diff --git a/plugins/pi/neatcontext/src/core/routing.mjs b/plugins/pi/neatcontext/src/core/routing.mjs index 1a25209..5be0c74 100644 --- a/plugins/pi/neatcontext/src/core/routing.mjs +++ b/plugins/pi/neatcontext/src/core/routing.mjs @@ -47,6 +47,24 @@ const MAX_ALIASES = 12; const MAX_DECISIONS = 100; const MAX_SESSIONS = 20; +// How long a refusal keeps counting for. +// +// "Not that one" is true tomorrow as well, so forgetting it when the window +// closes throws away the clearest signal the user ever gives. But it is +// evidence, not a rule: a context refused during one week's work should not be +// unreachable a quarter later. So it fades — half its strength every two weeks, +// negligible by six — and refusing the same context again resets the clock and +// deepens it, which is the escalation a browser uses for a permission prompt +// dismissed several times over. Once is noise; three times is an answer. +// +// Nothing here ever becomes permanent. The only permanent no in this plugin is +// manual mode, because that one is a decision the user made on purpose. +const DECLINE_HALF_LIFE_DAYS = 14; +const DECLINE_LIFETIME_DAYS = 42; +const DECLINE_WEIGHT = 0.4; +const MAX_DECLINE_COUNT = 10; +const DAY_MS = 24 * 60 * 60 * 1000; + export function routingFilePath() { return path.join(neatContextHome(), "plugin-routing.json"); } @@ -85,8 +103,15 @@ export async function readRouting() { cards[id] = card; } } + const declines = {}; + for (const [id, raw] of Object.entries(parsed?.declines ?? {})) { + if (typeof raw?.at === "string" && Number.isInteger(raw.count) && raw.count > 0) { + declines[id] = { at: raw.at, count: Math.min(raw.count, MAX_DECLINE_COUNT) }; + } + } return { schema: SCHEMA, + declines, mode: MODES.includes(parsed?.mode) ? parsed.mode : DEFAULT_MODE, cards, sessions: typeof parsed?.sessions === "object" && parsed.sessions !== null ? parsed.sessions : {}, @@ -103,7 +128,16 @@ async function writeRouting(state) { await mkdir(path.dirname(file), { recursive: true }); await writeFile( file, - `${JSON.stringify({ ...state, sessions: Object.fromEntries(sessions), decisions: state.decisions.slice(-MAX_DECISIONS) }, null, 2)}\n`, + `${JSON.stringify( + { + ...state, + sessions: Object.fromEntries(sessions), + declines: pruneDeclines(state.declines, Date.now()), + decisions: state.decisions.slice(-MAX_DECISIONS) + }, + null, + 2 + )}\n`, { encoding: "utf8", mode: 0o600 } ); } @@ -226,22 +260,66 @@ export function switchPolicy(state, { id, targetId, connectedId, requested = fal // Remembering a refusal is what stops auto mode from proposing the same wrong // context on every message that shares a word with it. -export function noteDeclined(targetId, { id = sessionId() } = {}) { - if (!id) { - return Promise.resolve(null); - } +// +// Two records, because a refusal means two different things. Inside the session +// it is absolute — that context is not raised again, full stop. Outside it, it +// is evidence that fades: the same request should still reach the same context +// eventually, just not today and not without something better to go on. +export function noteDeclined(targetId, { id = sessionId(), now = new Date() } = {}) { return update((state) => { - const session = state.sessions[id] ?? {}; - const declined = session.declined ?? []; - state.sessions[id] = { - ...session, - declined: declined.includes(targetId) ? declined : [...declined, targetId], - updatedAt: new Date().toISOString() + const previous = state.declines[targetId]; + state.declines[targetId] = { + at: now.toISOString(), + // Repeating a refusal restarts the clock and deepens it, rather than + // simply re-stating what was already known. + count: Math.min((previous?.count ?? 0) + 1, MAX_DECLINE_COUNT) }; + if (id) { + const session = state.sessions[id] ?? {}; + const declined = session.declined ?? []; + state.sessions[id] = { + ...session, + declined: declined.includes(targetId) ? declined : [...declined, targetId], + updatedAt: now.toISOString() + }; + } return targetId; }); } +// What a past refusal does to a context's score now: a multiplier in (0, 1], +// where 1 means it is not holding anything back. +// +// Strength halves every two weeks, so a fresh single refusal costs a candidate +// most of its lead and a six-week-old one costs it almost nothing. Repeats +// compound rather than add, which keeps the result inside the range however +// many there have been. +export function declineFactor(state, contextId, now = new Date()) { + const entry = state.declines?.[contextId]; + if (!entry) { + return 1; + } + const days = (now.getTime() - Date.parse(entry.at)) / DAY_MS; + if (!Number.isFinite(days) || days >= DECLINE_LIFETIME_DAYS) { + return 1; + } + const strength = DECLINE_WEIGHT * 0.5 ** (Math.max(days, 0) / DECLINE_HALF_LIFE_DAYS); + return (1 - strength) ** entry.count; +} + +// Dropped once it can no longer change an outcome, so the file does not +// accumulate a record of every context ever turned down. +function pruneDeclines(declines, now) { + const kept = {}; + for (const [id, entry] of Object.entries(declines)) { + const days = (now - Date.parse(entry.at)) / DAY_MS; + if (Number.isFinite(days) && days < DECLINE_LIFETIME_DAYS) { + kept[id] = entry; + } + } + return kept; +} + // Every switch, with what it was routing away from and why. Thresholds and card // quality are guesses until this has something in it: manual selections are the // ground truth that says whether the derived lines actually route correctly. diff --git a/shared/core/routing-candidates.mjs b/shared/core/routing-candidates.mjs index ae3ad79..4ccd0d6 100644 --- a/shared/core/routing-candidates.mjs +++ b/shared/core/routing-candidates.mjs @@ -18,6 +18,7 @@ // has in hand — a context's revision and timestamps, a card's timestamp — so // checking costs no extra reads. Only a real change pays for a rebuild. +import { declineFactor } from "./routing.mjs"; import { buildIndex, rank } from "./routing-search.mjs"; // The fields, in the shape the scorer weighs. Aliases are joined into one @@ -107,11 +108,20 @@ export function createRoutingIndex({ listFiles }) { key = next; } const names = new Map(contexts.map((context) => [context.id, context.name])); - return rank(index, query, options).map((result) => ({ - id: result.id, - name: names.get(result.id), - score: result.score, - matched: result.matched - })); + const now = new Date(); + // Past refusals are applied after ranking rather than folded into the + // index: they change on their own schedule, and rebuilding the index every + // time someone says no would throw away the cache for a multiplier. + // + // Re-sorted afterwards because a discount can change the order, and the + // shortlist's whole meaning is that it is in order. + return rank(index, query, options) + .map((result) => ({ + id: result.id, + name: names.get(result.id), + score: result.score * declineFactor(state, result.id, now), + matched: result.matched + })) + .sort((left, right) => right.score - left.score || left.id.localeCompare(right.id)); }; } diff --git a/shared/core/routing.mjs b/shared/core/routing.mjs index 1a25209..5be0c74 100644 --- a/shared/core/routing.mjs +++ b/shared/core/routing.mjs @@ -47,6 +47,24 @@ const MAX_ALIASES = 12; const MAX_DECISIONS = 100; const MAX_SESSIONS = 20; +// How long a refusal keeps counting for. +// +// "Not that one" is true tomorrow as well, so forgetting it when the window +// closes throws away the clearest signal the user ever gives. But it is +// evidence, not a rule: a context refused during one week's work should not be +// unreachable a quarter later. So it fades — half its strength every two weeks, +// negligible by six — and refusing the same context again resets the clock and +// deepens it, which is the escalation a browser uses for a permission prompt +// dismissed several times over. Once is noise; three times is an answer. +// +// Nothing here ever becomes permanent. The only permanent no in this plugin is +// manual mode, because that one is a decision the user made on purpose. +const DECLINE_HALF_LIFE_DAYS = 14; +const DECLINE_LIFETIME_DAYS = 42; +const DECLINE_WEIGHT = 0.4; +const MAX_DECLINE_COUNT = 10; +const DAY_MS = 24 * 60 * 60 * 1000; + export function routingFilePath() { return path.join(neatContextHome(), "plugin-routing.json"); } @@ -85,8 +103,15 @@ export async function readRouting() { cards[id] = card; } } + const declines = {}; + for (const [id, raw] of Object.entries(parsed?.declines ?? {})) { + if (typeof raw?.at === "string" && Number.isInteger(raw.count) && raw.count > 0) { + declines[id] = { at: raw.at, count: Math.min(raw.count, MAX_DECLINE_COUNT) }; + } + } return { schema: SCHEMA, + declines, mode: MODES.includes(parsed?.mode) ? parsed.mode : DEFAULT_MODE, cards, sessions: typeof parsed?.sessions === "object" && parsed.sessions !== null ? parsed.sessions : {}, @@ -103,7 +128,16 @@ async function writeRouting(state) { await mkdir(path.dirname(file), { recursive: true }); await writeFile( file, - `${JSON.stringify({ ...state, sessions: Object.fromEntries(sessions), decisions: state.decisions.slice(-MAX_DECISIONS) }, null, 2)}\n`, + `${JSON.stringify( + { + ...state, + sessions: Object.fromEntries(sessions), + declines: pruneDeclines(state.declines, Date.now()), + decisions: state.decisions.slice(-MAX_DECISIONS) + }, + null, + 2 + )}\n`, { encoding: "utf8", mode: 0o600 } ); } @@ -226,22 +260,66 @@ export function switchPolicy(state, { id, targetId, connectedId, requested = fal // Remembering a refusal is what stops auto mode from proposing the same wrong // context on every message that shares a word with it. -export function noteDeclined(targetId, { id = sessionId() } = {}) { - if (!id) { - return Promise.resolve(null); - } +// +// Two records, because a refusal means two different things. Inside the session +// it is absolute — that context is not raised again, full stop. Outside it, it +// is evidence that fades: the same request should still reach the same context +// eventually, just not today and not without something better to go on. +export function noteDeclined(targetId, { id = sessionId(), now = new Date() } = {}) { return update((state) => { - const session = state.sessions[id] ?? {}; - const declined = session.declined ?? []; - state.sessions[id] = { - ...session, - declined: declined.includes(targetId) ? declined : [...declined, targetId], - updatedAt: new Date().toISOString() + const previous = state.declines[targetId]; + state.declines[targetId] = { + at: now.toISOString(), + // Repeating a refusal restarts the clock and deepens it, rather than + // simply re-stating what was already known. + count: Math.min((previous?.count ?? 0) + 1, MAX_DECLINE_COUNT) }; + if (id) { + const session = state.sessions[id] ?? {}; + const declined = session.declined ?? []; + state.sessions[id] = { + ...session, + declined: declined.includes(targetId) ? declined : [...declined, targetId], + updatedAt: now.toISOString() + }; + } return targetId; }); } +// What a past refusal does to a context's score now: a multiplier in (0, 1], +// where 1 means it is not holding anything back. +// +// Strength halves every two weeks, so a fresh single refusal costs a candidate +// most of its lead and a six-week-old one costs it almost nothing. Repeats +// compound rather than add, which keeps the result inside the range however +// many there have been. +export function declineFactor(state, contextId, now = new Date()) { + const entry = state.declines?.[contextId]; + if (!entry) { + return 1; + } + const days = (now.getTime() - Date.parse(entry.at)) / DAY_MS; + if (!Number.isFinite(days) || days >= DECLINE_LIFETIME_DAYS) { + return 1; + } + const strength = DECLINE_WEIGHT * 0.5 ** (Math.max(days, 0) / DECLINE_HALF_LIFE_DAYS); + return (1 - strength) ** entry.count; +} + +// Dropped once it can no longer change an outcome, so the file does not +// accumulate a record of every context ever turned down. +function pruneDeclines(declines, now) { + const kept = {}; + for (const [id, entry] of Object.entries(declines)) { + const days = (now - Date.parse(entry.at)) / DAY_MS; + if (Number.isFinite(days) && days < DECLINE_LIFETIME_DAYS) { + kept[id] = entry; + } + } + return kept; +} + // Every switch, with what it was routing away from and why. Thresholds and card // quality are guesses until this has something in it: manual selections are the // ground truth that says whether the derived lines actually route correctly. diff --git a/tests/routing-declines.test.mjs b/tests/routing-declines.test.mjs new file mode 100644 index 0000000..a01847c --- /dev/null +++ b/tests/routing-declines.test.mjs @@ -0,0 +1,167 @@ +// What a refusal is worth, and for how long. +// +// "Not that one" is the clearest signal a user ever gives about routing, and +// until now it was thrown away when the session closed. It is also not a rule: +// a context turned down during one week's work should not be unreachable a +// quarter later. So it fades, repeats deepen it, and nothing here ever becomes +// permanent. + +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { after, before, beforeEach, describe, it } from "node:test"; + +let home; + +before(async () => { + home = await mkdtemp(path.join(os.tmpdir(), "neatcontext-declines-test-")); + process.env.NEATCONTEXT_HOME = home; +}); + +after(async () => { + await rm(home, { recursive: true, force: true }); +}); + +beforeEach(async () => { + await rm(path.join(home, "plugin-routing.json"), { force: true }); +}); + +const routing = await import("../plugins/claude-code/neatcontext/src/core/routing.mjs"); +const { createRoutingIndex } = await import( + "../plugins/claude-code/neatcontext/src/core/routing-candidates.mjs" +); + +const DAY = 24 * 60 * 60 * 1000; +const NOW = new Date("2026-08-09T12:00:00.000Z"); +const daysAgo = (days) => new Date(NOW.getTime() - days * DAY); + +function stateWith(declines) { + return { declines }; +} + +describe("how a refusal fades", () => { + it("costs a context most of its score the day it happens", () => { + const state = stateWith({ refused: { at: daysAgo(0).toISOString(), count: 1 } }); + const factor = routing.declineFactor(state, "refused", NOW); + assert.ok(factor < 0.65 && factor > 0.55, `expected roughly 0.6, got ${factor}`); + }); + + it("costs half as much after two weeks", () => { + const fresh = routing.declineFactor( + stateWith({ a: { at: daysAgo(0).toISOString(), count: 1 } }), + "a", + NOW + ); + const older = routing.declineFactor( + stateWith({ a: { at: daysAgo(14).toISOString(), count: 1 } }), + "a", + NOW + ); + // Half the strength, so the multiplier is halfway back to 1. + assert.ok(older > fresh); + assert.ok(Math.abs(1 - older - (1 - fresh) / 2) < 0.01); + }); + + it("costs nothing at all after six weeks", () => { + const state = stateWith({ a: { at: daysAgo(42).toISOString(), count: 1 } }); + assert.equal(routing.declineFactor(state, "a", NOW), 1); + }); + + it("deepens when the same context is refused again", () => { + const once = routing.declineFactor( + stateWith({ a: { at: daysAgo(0).toISOString(), count: 1 } }), + "a", + NOW + ); + const thrice = routing.declineFactor( + stateWith({ a: { at: daysAgo(0).toISOString(), count: 3 } }), + "a", + NOW + ); + assert.ok(thrice < once); + }); + + it("never reaches zero, however many times it is refused", () => { + // A guess the system made about someone must not harden into a rule. + const state = stateWith({ a: { at: daysAgo(0).toISOString(), count: 10 } }); + assert.ok(routing.declineFactor(state, "a", NOW) > 0); + }); + + it("holds nothing against a context that was never refused", () => { + assert.equal(routing.declineFactor(stateWith({}), "untouched", NOW), 1); + assert.equal(routing.declineFactor({}, "untouched", NOW), 1); + }); + + it("ignores a record whose timestamp is unreadable", () => { + const state = stateWith({ a: { at: "not a date", count: 1 } }); + assert.equal(routing.declineFactor(state, "a", NOW), 1); + }); +}); + +describe("recording a refusal", () => { + it("keeps it beyond the session that produced it", async () => { + await routing.noteDeclined("context:orders", { id: "session-a" }); + const state = await routing.readRouting(); + assert.equal(state.declines["context:orders"].count, 1); + // And still blocks outright inside the session it happened in. + assert.deepEqual(state.sessions["session-a"].declined, ["context:orders"]); + }); + + it("counts repeats and restarts the clock", async () => { + await routing.noteDeclined("context:orders", { id: "session-a", now: daysAgo(10) }); + await routing.noteDeclined("context:orders", { id: "session-b", now: NOW }); + const state = await routing.readRouting(); + assert.equal(state.declines["context:orders"].count, 2); + assert.equal(state.declines["context:orders"].at, NOW.toISOString()); + }); + + it("records the refusal even with no session to attribute it to", async () => { + await routing.noteDeclined("context:orders", { id: "", now: NOW }); + const state = await routing.readRouting(); + assert.equal(state.declines["context:orders"].count, 1); + }); + + it("drops records too old to change anything", async () => { + await routing.noteDeclined("context:stale", { id: "session-a", now: daysAgo(90) }); + await routing.noteDeclined("context:fresh", { id: "session-a", now: NOW }); + const stored = JSON.parse(await readFile(path.join(home, "plugin-routing.json"), "utf8")); + assert.equal("context:stale" in stored.declines, false); + assert.equal("context:fresh" in stored.declines, true); + }); + + it("survives a hand-broken file rather than failing the session", async () => { + const state = await routing.readRouting(); + assert.deepEqual(state.declines, {}); + }); +}); + +describe("what it does to a shortlist", () => { + const CONTEXTS = [ + { id: "refused", name: "Refused", revision: 1, routingDescription: "partition lag on orders" }, + { id: "other", name: "Other", revision: 1, routingDescription: "partition lag on orders" } + ]; + + function rankWith(declines) { + const candidates = createRoutingIndex({ listFiles: () => Promise.resolve([]) }); + return candidates(CONTEXTS, { cards: {}, declines }, "partition lag on orders"); + } + + it("demotes a context the user turned down, without hiding it", async () => { + // Both match identically, so nothing but the refusal can separate them. + const before = await rankWith({}); + assert.equal(before[0].id, "other", "tie should break by id before any refusal"); + + const after = await rankWith({ refused: { at: new Date().toISOString(), count: 1 } }); + assert.equal(after[0].id, "other"); + assert.equal(after[1].id, "refused"); + assert.ok(after[1].score < after[0].score); + assert.equal(after.length, 2, "a refused context is ranked lower, not removed"); + }); + + it("leaves the order alone once the refusal has expired", async () => { + const expired = new Date(Date.now() - 60 * DAY).toISOString(); + const results = await rankWith({ refused: { at: expired, count: 1 } }); + assert.equal(results[0].score, results[1].score); + }); +});