OverviewFilesCommitsIssuesDocsReleasesPeers

knowledge-ratchet commits / e7ed82d

Content-addressed rule IDs + dedup pass — collapse 1048→271
The rule corpus was inflated ~4x by a latent bug: rule-extractor.js generated IDs as `rule-${project}-${type}-${rules.length}` where rules.length was the extraction-time index. Every pipeline run produced different indexes (loops get reordered, new ones appear), so the same underlying loop generated a new rule ID each run. The id-based merge in mergeRules treated each re-extraction as new rules, causing linear growth with pipeline invocations. Discovered while spot-checking 20 random needs-investigation rules to calibrate the Change 2 quality filter. The 20 turned out to be 4 distinct underlying loops duplicated 3-6 times each. Quantification: - needs-investigation: 143 rules → 35 distinct fingerprints (4.09x dup) - full corpus: 1048 rules → 270 distinct fingerprints (3.88x dup) - 7 loops had 8 copies, 7 had 9 copies, 1 had 11 copies Fix: - New ruleId(loop) helper derives IDs from md5(fingerprint).slice(0,8). Format: `rule-${project}-${type}-${hash8}`. Same loop → same ID → merge is idempotent. - Dedup pass in main() groups merged rules by source.loopFingerprint, keeps one representative per fingerprint (tiebreak: most recent source.date), normalizes all IDs to content-addressed form. Handles both one-shot migration of the pre-Change-4 corpus and defensive future-proofing. - Rules without a fingerprint are kept as-is (can't safely dedup). Verification: - Run 1: 1048 → 271 rules (988 duplicates dropped, 12.1s one-shot collapse) - Run 2: 271 → 271 rules (0 duplicates, 2.6s normal pipeline) - Quality ratios preserved: 47.2% specific / 39.9% partial / 12.9% ni (was 44.5/41.9/13.6 pre-collapse — essentially unchanged) - hypercore-kb briefing still reads "9 actionable, 6 need investigation" — Change 2's filter is unaffected. Side effects worth noting: - The README's "1048 learned rules" is now 271. Updated. - benchmark-history.json will show a synthetic discontinuity on the next snapshot (462 → 498 → 648 → 953 → 1048 → 271). This is not a regression — the corpus is truthful for the first time. - The 108 old 'unknown' rules collapsed to 21 via dedup. Remaining 21 are real unknown-bucket loops, held back by quality filter anyway. Not in this commit: - Wiring findSimilarLoops (Change 3 still deferred) - Cleanup of the 21 remaining unknown rules (separate migration) Updates docs/audit-2026-04-10.md with Change 4 details. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
e7ed82da124858cfc9d15006ec1939ce1c52e44b
by plankatron <fleeky@gmail.com> · 2026-04-11T12:04:30.000Z

Changed files (3) +163 -44

README.md +2 -2
……@@ -50,8 +50,8 @@ node benchmark-ratchet.js
5050| Novel correction rate | 86.9% | >90% | Old mistakes eliminated (higher = better) |
5151| Correction density | 21.2% | <15% | Corrections per turn (lower = better) |
5252| Loop resolution | 8.2 turns | <6 turns | How fast loops resolve (lower = better) |
53| Learned rules | 1048 | growing | Auto-extracted from resolved loops |
54| Rule quality | 44.5% specific / 41.9% partial / 13.6% needs-investigation | more specific | Quality-aware injection prioritizes actionable rules |
53| Learned rules | 271 | growing | Auto-extracted from resolved loops (content-addressed, idempotent across runs) |
54| Rule quality | 47.2% specific / 39.9% partial / 12.9% needs-investigation | more specific | Quality-aware injection prioritizes actionable rules |
5555
5656## Architecture
5757
docs/audit-2026-04-10.md +82 -32
……@@ -182,33 +182,83 @@ directory after backfill. The briefing header reads
182182rules all have at least one concrete signal (concrete fix files or a
183183concrete antipattern — not both missing).
184184
185## Changes deferred
186
187### Change 3 — Wire `findSimilarLoops` into `loop-briefing.js`
188
189Not done. The function exists (`loop-detector.js:220`) and works. The wire-up
190is ~5 lines (import and call after the project filter). Deferred because
191after Change 1, cross-project retrieval is less urgent — most loops now have
192specific project tags and the `hypercore-kb` bucket surfaces the work we're
193actually doing. Revisit once we see whether cross-project rule transfer is
194still a measurable gap.
195
196### Cleanup of 108 dead `unknown` rules
197
198Not done. The rules remain in `learned-rules.json` with `quality:
199needs-investigation` (most of them) and `project: unknown`, so they're held
200back from injection anyway. A cleanup migration would drop them entirely but
201loses the audit trail. Scoped separately; decide later whether to purge or
202keep as history.
203
204### Rule extractor id collision fix
205
206Not done. Pre-existing issue: `rule-${project}-${type}-${rules.length}` uses
207extraction-time index, which changes on re-run, causing id mismatches and
208duplicate accumulation. The rule count grew from ~462 to 1048 over 5 days
209largely from this churn. A content-addressed id
210(`rule-${hash(project+type+symptom+fingerprint)}`) would fix it. Separate
211commit when we need it.
185## Change 3 — Wire `findSimilarLoops` into `loop-briefing.js`
186
187Still deferred. The function exists (`loop-detector.js:220`) and works.
188The wire-up is ~5 lines (import and call after the project filter).
189After Change 1, cross-project retrieval is less urgent — most loops
190now have specific project tags. Revisit once we see whether cross-project
191rule transfer is still a measurable gap.
192
193## Change 4 — Content-addressed rule IDs + dedup pass
194
195Added after a spot-check sample of 20 `needs-investigation` rules
196revealed they were only 4 distinct underlying loops duplicated 3–6 times
197each. Deeper analysis showed the entire corpus was inflated: **1048 rule
198entries corresponded to only 270 distinct loop fingerprints** (avg 3.88x
199duplication, with some loops having up to 9 copies).
200
201### Root cause
202
203`rule-extractor.js` generated IDs as `rule-${project}-${type}-${rules.length}`
204where `rules.length` was the extraction-time index. Every pipeline run
205produced different indexes (loops get reordered, new ones appear), so the
206same underlying loop generated a new rule ID on every run. The id-based
207merge in `mergeRules` treated each as a new rule, so the corpus grew
208linearly with pipeline invocations.
209
210### Fix
211
212- New `ruleId(loop)` helper in `rule-extractor.js`: derives the ID from
213 `md5(loop.fingerprint).slice(0,8)`, giving a stable content-addressed
214 ID. Format: `rule-${project}-${type}-${8-hex-hash}`. Same loop → same
215 ID → merge is idempotent.
216- Dedup pass in `main()` groups the merged rule list by
217 `source.loopFingerprint`, keeps one representative per fingerprint
218 (tiebreak: most recent `source.date`), and normalizes all IDs to the
219 new content-addressed form. This handles both one-shot migration of
220 the pre-Change-4 corpus and defensive future-proofing.
221
222### Verification
223
224- **Run 1 (one-shot collapse):** 1048 rules → 271 rules (988 duplicates
225 dropped). Pipeline took 12.1s due to initial collapse workload.
226- **Run 2 (idempotence check):** 271 → 271 (0 duplicates dropped).
227 Pipeline back to 2.6s. Content-addressed IDs mean the same corpus
228 reproduces exactly across re-runs.
229- **Quality ratios preserved:** 128 specific (47.2%), 108 partial (39.9%),
230 35 needs-investigation (12.9%). Ratios are essentially identical to
231 pre-collapse because duplication was uniform across tiers.
232- **hypercore-kb briefing unchanged:** still shows "LEARNED RULES
233 (9 actionable, 6 need investigation)" — proof that Change 2's filter
234 isn't distorted by the dedup.
235
236### Side effects
237
238- The README's "1048 learned rules" (from before Change 4) is obsolete.
239 Actual count is 271, which is the real knowledge base size.
240- The dashboard's "Learned Rules" trend line will show a dramatic drop
241 on the next snapshot. This is not a regression — it's the corpus being
242 truthful for the first time.
243- The benchmark history still shows old inflated numbers (462 → 498 →
244 648 → 953 → 1048 → 271). Future entries will be stable.
245
246## Deferred
247
248### Cleanup of 21 remaining `unknown` rules
249
250The 108-rule unknown bucket has collapsed to 21 after Change 4's dedup
251(most of the 108 were duplicates of the same underlying unknown loops).
252They remain in the corpus as `needs-investigation` and are held back from
253injection by Change 2. A cleanup migration would drop them entirely but
254loses the audit trail. Scoped separately.
255
256### Shadow harness for real measurement
257
258Still not built. The dashboard's benchmark trend is only 5 days old and
259now has a synthetic discontinuity from Change 4's collapse. Real before/
260after measurement of the changes requires either waiting 1-2 weeks for a
261clean history or building the shadow harness.
212262
213263## Measurement caveats
214264
……@@ -278,13 +328,13 @@ conversation transcript if anyone wants the reasoning in detail.
278328## Files touched this session
279329
280330**Changed:**
281- `knowledge-ratchet/classify-project.js` (new, 147 LOC)
282- `knowledge-ratchet/loop-detector.js` (import shared classifier, -15 LOC local)
283- `knowledge-ratchet/loop-briefing.js` (import shared classifier, add quality-aware injection)
284- `knowledge-ratchet/rule-extractor.js` (add `computeQuality` + backfill + exports)
331- `knowledge-ratchet/classify-project.js` (new, 147 LOC) — Change 1
332- `knowledge-ratchet/loop-detector.js` — imports shared classifier
333- `knowledge-ratchet/loop-briefing.js` — imports shared classifier + quality-aware injection
334- `knowledge-ratchet/rule-extractor.js` — `computeQuality` + backfill + content-addressed IDs + dedup pass
285335
286336**Not changed (but modified in working tree by other work — not staged):**
287337- `realtime-score.js` — pre-existing uncommitted fix for correction-burst counting
288338- Various data files under `data/` — pipeline artifacts
289339
290**Total net: ~230 LOC across 4 files.**
340**Total net: ~280 LOC across 4 files across 3 code commits.**
rule-extractor.js +79 -10
……@@ -19,11 +19,34 @@
1919
2020const fs = require('fs')
2121const path = require('path')
22const crypto = require('crypto')
2223
2324const DATA_DIR = path.join(__dirname, 'data')
2425const LOOPS_FILE = path.join(DATA_DIR, 'loops.json')
2526const RULES_FILE = path.join(DATA_DIR, 'learned-rules.json')
2627
28// ─── Content-addressed rule IDs ───
29// Previously: `rule-${project}-${type}-${rules.length}` — index-based,
30// unstable across extraction runs (rules.length shifts when loops are
31// reordered or new ones appear). This caused merge-based dedup to
32// fail: the same underlying loop produced a new rule ID on every
33// pipeline run, and mergeRules treated each as a new rule. Result:
34// corpus inflation to ~4x the actual number of distinct loops.
35//
36// Fix: derive the ID from the loop's fingerprint (which is stable for
37// a given loop). Same loop → same ID → merge is idempotent.
38//
39// The fingerprint is already generated by loop-detector.js:buildFingerprint
40// and stored on every rule as source.loopFingerprint.
41
42function ruleId (ruleOrLoop) {
43 const fp = ruleOrLoop.fingerprint || ruleOrLoop.source?.loopFingerprint || ''
44 const project = ruleOrLoop.project || ruleOrLoop.trigger?.project || 'unknown'
45 const type = ruleOrLoop.type || ruleOrLoop.trigger?.type || 'other'
46 const hash = crypto.createHash('md5').update(fp).digest('hex').slice(0, 8)
47 return `rule-${project}-${type}-${hash}`
48}
49
2750function loadJSON (file) {
2851 if (!fs.existsSync(file)) return null
2952 try { return JSON.parse(fs.readFileSync(file, 'utf8')) }
……@@ -104,9 +127,10 @@ function extractRules (loops) {
104127 const fixFiles = (loop.resolution.files || []).map(f => path.basename(f))
105128 const approval = loop.resolution.approval || null
106129
107 // Build the rule
130 // Build the rule — id is content-addressed via ruleId(loop) so the
131 // same loop always produces the same id across extraction runs
108132 const rule = {
109 id: `rule-${loop.project}-${loop.type}-${rules.length}`,
133 id: ruleId(loop),
110134 trigger: {
111135 project: loop.project,
112136 type: loop.type,
……@@ -213,17 +237,60 @@ function main () {
213237 const newRules = extractRules(loopsData.loops || [])
214238 const { merged, added } = mergeRules(newRules, existingRules)
215239
216 // Backfill quality on every rule in the merged output. This catches
240 // ─── Content-addressed dedup pass ───
241 // Group by loop fingerprint, collapse duplicates, and normalize all
242 // IDs to the content-addressed form. This handles:
243 // - One-shot migration: pre-Change-4 corpora have index-based IDs
244 // like `rule-dc-terrain-other-3` which don't survive re-extraction,
245 // so the corpus accumulates ~4x duplicates. This pass collapses
246 // them to one representative per fingerprint.
247 // - Defensive: future pipeline runs stay idempotent. If anything
248 // ever produces a duplicate again, it gets collapsed here.
249 // Rules without a fingerprint are kept as-is (can't dedup them safely).
250 // When duplicates exist, the rule with the most recent source.date wins.
251 const byFingerprint = new Map()
252 const noFingerprint = []
253 let duplicatesDropped = 0
254
255 for (const rule of merged) {
256 const fp = rule.source?.loopFingerprint
257 if (!fp) {
258 noFingerprint.push(rule)
259 continue
260 }
261 // Normalize id to content-addressed form
262 rule.id = ruleId({
263 project: rule.trigger.project,
264 type: rule.trigger.type,
265 fingerprint: fp
266 })
267 const existing = byFingerprint.get(fp)
268 if (!existing) {
269 byFingerprint.set(fp, rule)
270 } else {
271 duplicatesDropped++
272 // Tiebreak: keep the one with the most recent source.date
273 const existingDate = existing.source?.date || ''
274 const newDate = rule.source?.date || ''
275 if (newDate > existingDate) {
276 byFingerprint.set(fp, rule)
277 }
278 }
279 }
280
281 const deduped = [...Array.from(byFingerprint.values()), ...noFingerprint]
282
283 // Backfill quality on every rule in the deduped output. This catches
217284 // pre-Change 2 rules that lack a .quality field, and also refreshes
218285 // the tag for any rule whose antipattern or fixFiles changed since
219286 // the last extraction.
220 for (const rule of merged) {
287 for (const rule of deduped) {
221288 rule.quality = computeQuality(rule)
222289 }
223290
224291 // Group by project
225292 const byProject = {}
226 for (const rule of merged) {
293 for (const rule of deduped) {
227294 const proj = rule.trigger.project
228295 if (!byProject[proj]) byProject[proj] = []
229296 byProject[proj].push(rule)
……@@ -235,7 +302,9 @@ function main () {
235302 console.log(` Resolved loops analyzed: ${loopsData.loops.filter(l => l.resolved).length}`)
236303 console.log(` Rules extracted: ${newRules.length}`)
237304 console.log(` New rules added: ${added}`)
238 console.log(` Total rules: ${merged.length}`)
305 console.log(` Pre-dedup total: ${merged.length}`)
306 console.log(` Duplicates dropped: ${duplicatesDropped}`)
307 console.log(` Total rules: ${deduped.length}`)
239308
240309 console.log('\n BY PROJECT')
241310 console.log(' ' + '-'.repeat(56))
……@@ -253,7 +322,7 @@ function main () {
253322 }
254323
255324 // Show top rules by severity (longest loops)
256 const bySeverity = [...merged].sort((a, b) => b.source.duration - a.source.duration)
325 const bySeverity = [...deduped].sort((a, b) => b.source.duration - a.source.duration)
257326
258327 console.log('\n HIGHEST SEVERITY RULES (longest loops)')
259328 console.log(' ' + '-'.repeat(56))
……@@ -265,14 +334,14 @@ function main () {
265334 // Save
266335 const report = {
267336 generated: new Date().toISOString(),
268 totalRules: merged.length,
269 rules: merged,
337 totalRules: deduped.length,
338 rules: deduped,
270339 }
271340 fs.writeFileSync(RULES_FILE, JSON.stringify(report, null, 2))
272341 console.log(`\nSaved: ${RULES_FILE}`)
273342 console.log('═'.repeat(60))
274343}
275344
276module.exports = { extractRules, mergeRules, computeQuality, QUALITY_RANK, GENERIC_ANTIPATTERN }
345module.exports = { extractRules, mergeRules, computeQuality, QUALITY_RANK, GENERIC_ANTIPATTERN, ruleId }
277346
278347if (require.main === module) main()