From 238510080ea89ee3c0172bfafcda980ad6c856fc Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 2 Sep 2026 18:05:00 -0400 Subject: [PATCH] feat(retrieval): the standing-rule arm gets its own bar, and asks for one rule not two (#3318) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Milestone 333 step 4 — the split #2223 made one surface down, now made for the third corpus. The arm inherited WRITEPATH_DEFAULT_THRESHOLD = 0.68, a number measured against code-vs-note-PROSE and never re-derived for code-vs-RULE-TEXT. THE DEFAULT IS ARGUED STRUCTURALLY, NOT READ OFF A HISTOGRAM (rule 115). Two facts hold on any install, including one with six rules and no telemetry: - The eligible corpus is tiny — conditional rules only, a handful to a few dozen against thousands of notes. A top-k over forty candidates always returns something, so "the best match cleared the bar" stops meaning "a good match exists". A bar calibrated for best-of-thousands is cleared by best-of-forty as arithmetic, not relevance. - Rules are short imperative technical English, far more homogeneous than note prose. #2223 put the code-vs-prose floor at 0.55-0.63 and set 0.68 above it; a more homogeneous corpus has a HIGHER floor, so 0.68 is not merely inherited, it sits below where this corpus's noise lives. 0.72 errs deliberately toward silence on an asymmetry that is also structural: this hint fires on EVERY write. A missed rule is recoverable — it is still in Scribe and the agent can search it. A hint that cries wolf is not: it teaches the reader to skip the whole block, and the true positives go with it. The arm's own comment already said "noise on a hint that fires on every write is how a hint gets ignored". Pinned as an INEQUALITY, not a value: test_the_rule_bar_defaults_above_the_code_bar asserts RULEHINT > WRITEPATH, so tuning the number stays free while inverting the relationship — which would silently reinstate #3311 — does not. RULEHINT_LIMIT = 1, and deliberately not a knob. With a corpus this small, k=2 means the second line is almost always the second-best noise wearing the same confident framing as the first; halving k halves that regardless of the bar. It stays a constant because it is a decision about how loud one hint may be, not a per-install tuning question — and a knob nobody turns only adds a way to misconfigure the surface. Reachable from Settings, no restart (rule 25), with copy that says which way to move it and points at retrieval_telemetry's rule pull-through — which step 3 made readable — to tell "arriving unread" from "never arrived". Every config stand-in in the suite gained the key, not just the one that noticed. The arm reads `rule_threshold` while BUILDING its search arguments, so a missing key raises inside its fail-open except and turns the arm into a silent no-op — indistinguishable from it running and finding nothing. That is the same vacuous-pass shape that bit step 2, one layer down (rule 33). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TcCs1CcQ1ormdnzSshKqvN --- frontend/src/views/SettingsView.vue | 36 +++++++++++++ src/scribe/services/plugin_context.py | 70 ++++++++++++++++++++++-- tests/test_note_usage.py | 3 +- tests/test_rule_usage_wiring.py | 48 +++++++++++++++-- tests/test_services_plugin_context.py | 6 ++- tests/test_write_path_trigger.py | 76 ++++++++++++++++++++++++++- 6 files changed, 227 insertions(+), 12 deletions(-) diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 715c222..60b94d4 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -86,6 +86,7 @@ const kbWritePathEnabled = ref(true); // 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. const kbWritePathThreshold = ref("0.68"); +const kbRuleHintThreshold = ref("0.72"); // Near-duplicate report floors, one per record kind (services/dedup.py). // Snippets are single-chunk, so their floor sits below the 0.90 write-time // gate and catches what it lets through. Notes/tasks are scored at chunk @@ -148,12 +149,17 @@ async function saveKbInject() { // Same `|| default` reasoning: falling back to 0 would surface every // 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)); + // Same `|| default` reasoning again, and it bites harder here: a rule hint + // fires on every write, so a fallback of 0 would attach a standing rule to + // every edit in the session. + const rhT = Math.min(1, Math.max(0, Number(kbRuleHintThreshold.value) || 0.72)); kbInjectThreshold.value = String(t); kbInjectTopK.value = String(k); kbDupThresholdSnippet.value = String(dupSnip); kbDupThresholdNote.value = String(dupNote); kbDupThresholdTask.value = String(dupTask); kbWritePathThreshold.value = String(wpT); + kbRuleHintThreshold.value = String(rhT); savingKbInject.value = true; kbInjectSaved.value = false; try { @@ -166,6 +172,10 @@ async function saveKbInject() { // measurements that split them. kb_writepath_enabled: kbWritePathEnabled.value ? 'true' : 'false', kb_writepath_threshold: String(wpT), + // A THIRD corpus with a third bar — see RULEHINT_DEFAULT_THRESHOLD + // in services/plugin_context.py for why rules cannot share the + // code threshold any more than code could share the prose one. + kb_rulehint_threshold: String(rhT), kb_duplicate_threshold_snippet: String(dupSnip), kb_duplicate_threshold_note: String(dupNote), kb_duplicate_threshold_task: String(dupTask), @@ -611,6 +621,9 @@ onMounted(async () => { kbInjectTopK.value = allSettings.kb_autoinject_top_k; } kbWritePathEnabled.value = allSettings.kb_writepath_enabled !== "false"; + if (allSettings.kb_rulehint_threshold !== undefined) { + kbRuleHintThreshold.value = allSettings.kb_rulehint_threshold; + } if (allSettings.kb_writepath_threshold !== undefined) { kbWritePathThreshold.value = allSettings.kb_writepath_threshold; } @@ -1456,6 +1469,29 @@ async function deleteUser(userId: number) { location, not by resemblance.

+
+ + +

+ The same hint can mention a standing rule whose trigger resembles what's + being written — only rules marked conditional, since always-on + ones are already loaded. Stricter again than the threshold above, because + there are far fewer rules than snippets: with a small set, something + always ranks first, so the bar has to carry more of the judgement. + Raise it if rules keep arriving unread; lower it if a rule you needed + never showed up. Settings → check the pull-through in + retrieval_telemetry to see which is happening. +

+