feat(dedup): per-kind duplicate-report floors — notes/tasks default 0.93
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 53s
CI & Build / Build & push image (push) Successful in 42s

At chunk grain (#280) a note-pair's similarity is its closest chunk pair,
so the shared 0.82 floor saturated the note/task reports with related
families (38 note / 155 task groups against the 200-pair cap, measured
2026-08-09). Split kb_duplicate_threshold into per-kind settings keys
with per-kind defaults: snippet 0.82 (single-chunk, scale unchanged),
note/task 0.93 (points the report at genuinely-alike records). Settings
UI grows the two new knobs; report entrypoints inherit the change via
get_duplicate_threshold(user_id, kind).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
This commit is contained in:
2026-08-09 10:33:11 -04:00
co-authored by Claude Fable 5
parent 23cb396e65
commit 272b7dbddf
3 changed files with 135 additions and 38 deletions
+72 -16
View File
@@ -27,10 +27,14 @@ const kbWritePathEnabled = ref(true);
// code embeddings sit on a much higher similarity floor than prose, so 0.55 let // code embeddings sit on a much higher similarity floor than prose, so 0.55 let
// unrelated code through (#2223). Shares top-k, not the threshold. // unrelated code through (#2223). Shares top-k, not the threshold.
const kbWritePathThreshold = ref("0.68"); const kbWritePathThreshold = ref("0.68");
// Near-duplicate report floor. Deliberately looser than the 0.90 write-time // Near-duplicate report floors, one per record kind (services/dedup.py).
// gate: that one BLOCKS a create and must be unforgiving of noise, this one only // Snippets are single-chunk, so their floor sits below the 0.90 write-time
// suggests a merge the operator reviews (services/dedup.py). // gate and catches what it lets through. Notes/tasks are scored at chunk
const kbDuplicateThreshold = ref("0.82"); // grain (#280) — related families clear 0.90 easily — so their floor sits
// above the gate to keep the report pointed at genuinely-alike records.
const kbDupThresholdSnippet = ref("0.82");
const kbDupThresholdNote = ref("0.93");
const kbDupThresholdTask = ref("0.93");
const savingKbInject = ref(false); const savingKbInject = ref(false);
const kbInjectSaved = ref(false); const kbInjectSaved = ref(false);
@@ -76,16 +80,20 @@ async function saveRetention() {
async function saveKbInject() { async function saveKbInject() {
const t = Math.min(1, Math.max(0, Number(kbInjectThreshold.value) || 0)); const t = Math.min(1, Math.max(0, Number(kbInjectThreshold.value) || 0));
const k = Math.min(10, Math.max(1, Math.floor(Number(kbInjectTopK.value) || 1))); const k = Math.min(10, Math.max(1, Math.floor(Number(kbInjectTopK.value) || 1)));
// `|| 0.82` not `|| 0`: an unparseable value here should fall back to the // `|| default` not `|| 0`: an unparseable value here should fall back to the
// default, not to 0 — a 0 floor would report every snippet as a duplicate of // per-kind default, not to 0 — a 0 floor would report every record as a
// every other one. // duplicate of every other one.
const dupT = Math.min(1, Math.max(0, Number(kbDuplicateThreshold.value) || 0.82)); const dupSnip = Math.min(1, Math.max(0, Number(kbDupThresholdSnippet.value) || 0.82));
// Same `|| default` reasoning as dupT: falling back to 0 would surface every const dupNote = Math.min(1, Math.max(0, Number(kbDupThresholdNote.value) || 0.93));
const dupTask = Math.min(1, Math.max(0, Number(kbDupThresholdTask.value) || 0.93));
// Same `|| default` reasoning: falling back to 0 would surface every
// snippet in the corpus on every edit, which is the failure this knob fixes. // snippet in the corpus on every edit, which is the failure this knob fixes.
const wpT = Math.min(1, Math.max(0, Number(kbWritePathThreshold.value) || 0.68)); const wpT = Math.min(1, Math.max(0, Number(kbWritePathThreshold.value) || 0.68));
kbInjectThreshold.value = String(t); kbInjectThreshold.value = String(t);
kbInjectTopK.value = String(k); kbInjectTopK.value = String(k);
kbDuplicateThreshold.value = String(dupT); kbDupThresholdSnippet.value = String(dupSnip);
kbDupThresholdNote.value = String(dupNote);
kbDupThresholdTask.value = String(dupTask);
kbWritePathThreshold.value = String(wpT); kbWritePathThreshold.value = String(wpT);
savingKbInject.value = true; savingKbInject.value = true;
kbInjectSaved.value = false; kbInjectSaved.value = false;
@@ -99,7 +107,9 @@ async function saveKbInject() {
// measurements that split them. // measurements that split them.
kb_writepath_enabled: kbWritePathEnabled.value ? 'true' : 'false', kb_writepath_enabled: kbWritePathEnabled.value ? 'true' : 'false',
kb_writepath_threshold: String(wpT), kb_writepath_threshold: String(wpT),
kb_duplicate_threshold: String(dupT), kb_duplicate_threshold_snippet: String(dupSnip),
kb_duplicate_threshold_note: String(dupNote),
kb_duplicate_threshold_task: String(dupTask),
}); });
kbInjectSaved.value = true; kbInjectSaved.value = true;
setTimeout(() => (kbInjectSaved.value = false), 2000); setTimeout(() => (kbInjectSaved.value = false), 2000);
@@ -487,8 +497,14 @@ onMounted(async () => {
if (allSettings.kb_writepath_threshold !== undefined) { if (allSettings.kb_writepath_threshold !== undefined) {
kbWritePathThreshold.value = allSettings.kb_writepath_threshold; kbWritePathThreshold.value = allSettings.kb_writepath_threshold;
} }
if (allSettings.kb_duplicate_threshold !== undefined) { if (allSettings.kb_duplicate_threshold_snippet !== undefined) {
kbDuplicateThreshold.value = allSettings.kb_duplicate_threshold; kbDupThresholdSnippet.value = allSettings.kb_duplicate_threshold_snippet;
}
if (allSettings.kb_duplicate_threshold_note !== undefined) {
kbDupThresholdNote.value = allSettings.kb_duplicate_threshold_note;
}
if (allSettings.kb_duplicate_threshold_task !== undefined) {
kbDupThresholdTask.value = allSettings.kb_duplicate_threshold_task;
} }
if (allSettings.notify_task_reminders !== undefined) { if (allSettings.notify_task_reminders !== undefined) {
notifyTaskReminders.value = allSettings.notify_task_reminders !== "false"; notifyTaskReminders.value = allSettings.notify_task_reminders !== "false";
@@ -1267,10 +1283,10 @@ function formatUserDate(iso: string): string {
is for (#274). --> is for (#274). -->
<div class="field"> <div class="field">
<label for="kb-duplicate-threshold">Near-duplicate report threshold</label> <label for="kb-duplicate-threshold-snippet">Near-duplicate report threshold snippets</label>
<input <input
id="kb-duplicate-threshold" id="kb-duplicate-threshold-snippet"
v-model="kbDuplicateThreshold" v-model="kbDupThresholdSnippet"
type="number" type="number"
min="0" min="0"
max="1" max="1"
@@ -1285,6 +1301,46 @@ function formatUserDate(iso: string): string {
you review it never acts on its own. you review it never acts on its own.
</p> </p>
</div> </div>
<div class="field">
<label for="kb-duplicate-threshold-note">Near-duplicate report threshold notes</label>
<input
id="kb-duplicate-threshold-note"
v-model="kbDupThresholdNote"
type="number"
min="0"
max="1"
step="0.01"
class="input"
style="max-width: 8rem"
/>
<p class="field-hint">
The floor for the Knowledge page's note report. Notes are compared
section by section, so related records — a run of dev-logs, notes on
one topic — score high without being duplicates. Stricter than the
snippet floor on purpose; lower it to browse related families rather
than hunt true duplicates.
</p>
</div>
<div class="field">
<label for="kb-duplicate-threshold-task">Near-duplicate report threshold — tasks</label>
<input
id="kb-duplicate-threshold-task"
v-model="kbDupThresholdTask"
type="number"
min="0"
max="1"
step="0.01"
class="input"
style="max-width: 8rem"
/>
<p class="field-hint">
Same as the note floor, for the task report. Step tasks from different
milestones ("Verify on CI") legitimately resemble each other, so this
stays strict to keep the report pointed at work opened twice.
</p>
</div>
<div class="actions"> <div class="actions">
<button class="btn-primary" @click="saveKbInject" :disabled="savingKbInject"> <button class="btn-primary" @click="saveKbInject" :disabled="savingKbInject">
{{ savingKbInject ? 'Saving' : 'Save' }} {{ savingKbInject ? 'Saving' : 'Save' }}
+29 -11
View File
@@ -318,30 +318,45 @@ async def find_duplicate_note(
# scope here is set by what the operator can actually act on, not by what they # scope here is set by what the operator can actually act on, not by what they
# can see. # can see.
# #
# WHY A LOWER THRESHOLD THAN THE GATE. The gate BLOCKS a write at 0.90 and has to # WHY THE FLOOR IS PER-KIND. Chunked embeddings (#280) changed what a pair
# be unforgiving of noise. This report only makes a suggestion the operator # score MEANS for multi-chunk records: a note-pair's similarity is its closest
# reviews, so it can afford to be looser and catch the pairs the gate lets # chunk pair, so any family of related long records — a dev-log run, a research
# through — which are precisely the ones that accumulated. It is a setting rather # fan-out — clears a floor that whole-document vectors used to dilute below it.
# Measured on the live corpus (2026-08-09): at 0.82 the note/task reports
# saturate the pair cap with related-but-distinct families, while 0.93+ returns
# the genuinely-alike records. Snippets are single-chunk (short by nature), so
# their similarity scale never shifted and they keep the old floor.
#
# Snippets sit BELOW the 0.90 write gate — the report catches what the gate
# lets through. Notes/tasks sit ABOVE it, and that is not a contradiction: the
# gate compares a new record against best-matching chunks too, but it blocks a
# WRITE and must stay forgiving, while the report proposes a REVIEW and at
# chunk grain 0.90 would still drown it in families. Each is a setting rather
# than a constant (rule #25) because the right value depends on how uniform a # than a constant (rule #25) because the right value depends on how uniform a
# corpus is, and nobody can guess that from here. # corpus is, and nobody can guess that from here.
DUPLICATE_THRESHOLD_KEY = "kb_duplicate_threshold" DUPLICATE_THRESHOLD_KEYS = {
DUPLICATE_DEFAULT_THRESHOLD = 0.82 "snippet": "kb_duplicate_threshold_snippet",
"note": "kb_duplicate_threshold_note",
"task": "kb_duplicate_threshold_task",
}
DUPLICATE_DEFAULT_THRESHOLDS = {"snippet": 0.82, "note": 0.93, "task": 0.93}
# Hard cap on returned pairs. A pathologically uniform corpus is O(n²) pairs, and # Hard cap on returned pairs. A pathologically uniform corpus is O(n²) pairs, and
# a report nobody can read is not a report. # a report nobody can read is not a report.
_MAX_DUPLICATE_PAIRS = 200 _MAX_DUPLICATE_PAIRS = 200
async def get_duplicate_threshold(user_id: int) -> float: async def get_duplicate_threshold(user_id: int, kind: str = "snippet") -> float:
"""The user's near-duplicate similarity floor, clamped to [0, 1].""" """The user's near-duplicate similarity floor for `kind`, clamped to [0, 1]."""
from scribe.services.settings import get_setting from scribe.services.settings import get_setting
default = DUPLICATE_DEFAULT_THRESHOLDS[kind]
try: try:
value = float(await get_setting( value = float(await get_setting(
user_id, DUPLICATE_THRESHOLD_KEY, str(DUPLICATE_DEFAULT_THRESHOLD) user_id, DUPLICATE_THRESHOLD_KEYS[kind], str(default)
)) ))
except (TypeError, ValueError): except (TypeError, ValueError):
value = DUPLICATE_DEFAULT_THRESHOLD value = default
return min(1.0, max(0.0, value)) return min(1.0, max(0.0, value))
@@ -503,7 +518,10 @@ async def find_duplicate_records(
""" """
if kind not in _REPORT_KINDS: if kind not in _REPORT_KINDS:
raise ValueError(f"kind must be one of {_REPORT_KINDS}, not {kind!r}") raise ValueError(f"kind must be one of {_REPORT_KINDS}, not {kind!r}")
floor = await get_duplicate_threshold(user_id) if threshold is None else threshold floor = (
await get_duplicate_threshold(user_id, kind)
if threshold is None else threshold
)
floor = min(1.0, max(0.0, floor)) floor = min(1.0, max(0.0, floor))
max_distance = min(2.0, max(0.0, 1.0 - floor)) max_distance = min(2.0, max(0.0, 1.0 - floor))
+34 -11
View File
@@ -10,7 +10,7 @@ import pytest
from scribe.services import dedup as dedup_svc from scribe.services import dedup as dedup_svc
from scribe.services.dedup import ( from scribe.services.dedup import (
DUPLICATE_DEFAULT_THRESHOLD, DUPLICATE_DEFAULT_THRESHOLDS,
get_duplicate_threshold, get_duplicate_threshold,
group_pairs, group_pairs,
) )
@@ -61,29 +61,52 @@ def test_a_node_never_forms_a_group_with_itself():
# --- threshold ------------------------------------------------------------ # --- threshold ------------------------------------------------------------
async def test_threshold_falls_back_to_the_default_when_unset(): @pytest.mark.parametrize("kind", ["snippet", "note", "task"])
async def test_threshold_falls_back_to_the_per_kind_default_when_unset(kind):
default = DUPLICATE_DEFAULT_THRESHOLDS[kind]
with patch("scribe.services.settings.get_setting", with patch("scribe.services.settings.get_setting",
AsyncMock(return_value=str(DUPLICATE_DEFAULT_THRESHOLD))): AsyncMock(return_value=str(default))):
assert await get_duplicate_threshold(1) == DUPLICATE_DEFAULT_THRESHOLD assert await get_duplicate_threshold(1, kind) == default
async def test_each_kind_reads_its_own_settings_key():
"""Tuning the note floor must not move the snippet report — the whole
point of splitting the setting is that the kinds calibrate independently."""
get = AsyncMock(return_value="0.5")
with patch("scribe.services.settings.get_setting", get):
await get_duplicate_threshold(1, "note")
key, default = get.await_args.args[1], get.await_args.args[2]
assert key == "kb_duplicate_threshold_note"
assert default == str(DUPLICATE_DEFAULT_THRESHOLDS["note"])
async def test_a_garbage_setting_falls_back_rather_than_raising(): async def test_a_garbage_setting_falls_back_rather_than_raising():
with patch("scribe.services.settings.get_setting", with patch("scribe.services.settings.get_setting",
AsyncMock(return_value="not-a-number")): AsyncMock(return_value="not-a-number")):
assert await get_duplicate_threshold(1) == DUPLICATE_DEFAULT_THRESHOLD assert (await get_duplicate_threshold(1, "snippet")
== DUPLICATE_DEFAULT_THRESHOLDS["snippet"])
@pytest.mark.parametrize("stored, expected", [("2.5", 1.0), ("-3", 0.0)]) @pytest.mark.parametrize("stored, expected", [("2.5", 1.0), ("-3", 0.0)])
async def test_threshold_is_clamped_to_the_valid_range(stored, expected): async def test_threshold_is_clamped_to_the_valid_range(stored, expected):
with patch("scribe.services.settings.get_setting", AsyncMock(return_value=stored)): with patch("scribe.services.settings.get_setting", AsyncMock(return_value=stored)):
assert await get_duplicate_threshold(1) == expected assert await get_duplicate_threshold(1, "snippet") == expected
def test_the_report_threshold_is_looser_than_the_write_gate(): def test_the_snippet_report_threshold_is_looser_than_the_write_gate():
"""The gate BLOCKS a create and must be unforgiving of noise; this only """The gate BLOCKS a create and must be unforgiving of noise; the snippet
suggests a merge the operator reviews, so it has to reach further or it report only suggests a merge the operator reviews, so it has to reach
would never surface the pairs the gate already let through.""" further or it would never surface the pairs the gate already let through.
assert DUPLICATE_DEFAULT_THRESHOLD < dedup_svc._SEMANTIC_THRESHOLD Snippet-only on purpose: note/task floors sit ABOVE the gate because chunk
grain (#280) lifts related families over it — see the module comment."""
assert DUPLICATE_DEFAULT_THRESHOLDS["snippet"] < dedup_svc._SEMANTIC_THRESHOLD
def test_the_note_and_task_floors_are_stricter_than_the_snippet_floor():
"""At chunk grain, 0.82 is a related-families view; the report's default
must point at genuinely-alike records (measured 2026-08-09, note #2551)."""
assert DUPLICATE_DEFAULT_THRESHOLDS["note"] == 0.93
assert DUPLICATE_DEFAULT_THRESHOLDS["task"] == 0.93
# --- fail-open ------------------------------------------------------------ # --- fail-open ------------------------------------------------------------