knodin 0.10.8 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,281 @@
1
+ # knodin 0.12.0
2
+
3
+ Ten open defects worked as one batch, plus three more found while investigating
4
+ them. Nearly all are the same failure in different clothing: a result that is
5
+ *complete* and a result that is *empty* arriving in the same shape, so the reader
6
+ cannot tell which one they got. One is worse than that, and it goes first.
7
+
8
+ No schema change and no re-index. An earlier draft of this release carried both,
9
+ for an embedding change that measured well on the test embedder and turned out to
10
+ do nothing on the real model. That story is told below, because the way it was
11
+ nearly shipped is more instructive than the fix — and because the same mistake,
12
+ caught a second time, is what led to the retrieval benchmark this release adds.
13
+
14
+ ## `review` answered a different question and said nothing about it
15
+
16
+ The MCP gateway accepted `auditRange`, `auditBase` and `auditHead` on `review`,
17
+ discarded them, and reviewed `HEAD~1..HEAD` instead. The response reported
18
+ `base: HEAD~1` truthfully — but carried `staleness: fresh`, `availability:
19
+ healthy` and a full freshness envelope, so unless the caller happened to inspect
20
+ `base`, it read as authoritative for the range they had asked about.
21
+
22
+ Measured against a real branch, the difference was one changed file versus
23
+ twenty. That is not a near-miss; it is a review of the wrong thing, presented
24
+ with the same confidence as a review of the right thing. It was found while
25
+ running a product validation, where the conclusion would otherwise have been
26
+ "review does not catch this defect" — a false negative manufactured entirely out
27
+ of discarded input, and reported as a finding.
28
+
29
+ Those three parameters belong to `prs`, and only to `prs`; the schema had always
30
+ said so and nothing enforced it. They are now refused on every other operation,
31
+ and the refusal on `review` names the parameter that would have worked
32
+ (`diffScope: "compare"` with `from`/`toRevision`). An unused-parameter error is
33
+ always better than a confident answer to an unasked question.
34
+
35
+ ## `count: 0` could not tell a typo from dead code
36
+
37
+ On a healthy, fresh graph, `knodin query callers_of someMisspelling` returned
38
+ `count: 0`, `staleness: fresh`, exit 0 — byte-identical to the answer for a real
39
+ symbol that genuinely has no callers.
40
+
41
+ The uninitialized-graph case was fixed earlier and now fails loudly, which made
42
+ this one worse rather than better: before that fix an empty result at least felt
43
+ untrustworthy, and afterwards it arrived with a certificate. The freshness stamp
44
+ certifies the **graph**; it says nothing about whether the **target** resolved.
45
+ Those are separate axes and are now reported separately. A query for a symbol
46
+ that exists in no open repository answers `targetResolution: "not-found"`,
47
+ renders as `target-not-found` rather than `no-match`, and exits non-zero, since
48
+ a misspelling is operator error rather than a negative result. A real symbol
49
+ with no callers still answers `resolved` and exits 0 — that is the honest
50
+ negative, and it is now distinguishable from the typo.
51
+
52
+ This matters most for the symbol it is easiest to lose: a seam is *by
53
+ definition* a well-documented symbol with no callers yet, so `count: 0` was
54
+ destroying exactly the signal that would have said "use this one". One session
55
+ nearly hand-rolled a duplicate of a blessed seam for that reason.
56
+
57
+ ## `callers_of` counted callers and read as call sites
58
+
59
+ `callers_of` answers "which symbols call X", and its output — `count: N` above
60
+ one line per row — reads as "N call sites, here they are". When a function calls
61
+ the target twice, only the first line appeared and the count was lower than the
62
+ number of call sites. Ground truth of four calls reported as three.
63
+
64
+ Anyone sizing an edit from that count under-sizes it. Worse, the reporting
65
+ session initially scored a correct answer as a bug, because cross-checking with
66
+ grep found a call the tool had "missed" — it had not missed it, it had merged
67
+ it. Each row now carries `callSiteLines` with every line for that caller, and
68
+ the result carries `callSiteCount` alongside `count`, which is documented as
69
+ counting rows. Renaming the field alone would not have been enough: it would
70
+ have left the reader doing arithmetic they had no data for.
71
+
72
+ ## A team repo with dead hooks had no safe way out
73
+
74
+ Every exit mutated something. Plain `repair` fixed graph rows and left the
75
+ background indexer dead, so the schema drifted again. `init` rewrote tracked
76
+ `AGENTS.md`, `GEMINI.md` and `.mcp.json` — churn every teammate diffs, on a
77
+ branch that has nothing to do with it. `configure --scope personal` stripped the
78
+ shared block for everyone else. The one clean option, `init --scope personal`,
79
+ is precisely the one refused while tracked team integration is active.
80
+
81
+ `knodin repair --lifecycle` rewrites the managed Git hooks and the background
82
+ refresh script and touches nothing else — no indexing, no graph rows, and it
83
+ never enters the agent-configuration path at any scope. Fault and remedy now
84
+ match in scope: the fault is in the hooks' contents, so the hooks' contents are
85
+ what changes. Every remediation message that used to say "run `knodin init`" for
86
+ a lifecycle fault now points here instead.
87
+
88
+ This does not settle the separate question of whether a per-developer scope
89
+ preference should mutate team-shared tracked files. It removes the trap.
90
+
91
+ ## Hooks that predate the interpreter fallback are now named before they break
92
+
93
+ Scripts generated before the `KNODIN_NODE` fallback pin an absolute Homebrew
94
+ Cellar interpreter with nothing to fall back to. Homebrew deletes that directory
95
+ on upgrade, so every background refresh afterwards dies with exit 127 — in the
96
+ background, where the only trace is a log file — and the graph drifts until
97
+ someone happens to run `status`. One `brew upgrade` did this to three
98
+ repositories on one machine in a day.
99
+
100
+ The existing check could not see these at all: it keys off a `KNODIN_NODE=`
101
+ line, and these scripts have none, so the most dangerous shape read as healthy.
102
+ Health now reports a script that predates the fallback **while its interpreter
103
+ still exists**, because the whole point is that it is a time bomb rather than an
104
+ already-detonated one. A script it cannot recognise is still left alone; an
105
+ unparseable hook is not evidence of a pinned interpreter, and guessing would
106
+ invent a failure rather than report one.
107
+
108
+ ## Search ranks a depended-upon implementation above its own fresh duplicate
109
+
110
+ Semantic similarity alone ranked a minutes-old duplicate above the
111
+ implementation it duplicated. The duplicate is short and made almost entirely of
112
+ the query's own words; the canonical version is diluted by an actual body. The
113
+ searcher read their own new leaf as evidence that nothing existed.
114
+
115
+ Degree is now weighted into rank fusion, capped at about three rank positions.
116
+ It is deliberately a small weight, because the opposite failure is equally real
117
+ and lands on the *same symbols*: a seam is a documented symbol with no callers,
118
+ and a large centrality weight would bury seams to fix duplicates — trading one
119
+ silent miss for another. A connected implementation climbs past neighbours it
120
+ was scoring near; it does not win from anywhere. Symbols with no inbound
121
+ references are excluded from the centrality ranking rather than ranked last, so
122
+ an uncalled symbol is never penalised, it only forgoes a boost it has no
123
+ evidence for.
124
+
125
+ An accompanying change — bounding the source body inside the embedding budget —
126
+ was written, measured, justified in a comment, given a schema bump, and then
127
+ **reverted before release**. On the deterministic bag-of-words embedder the suite
128
+ runs, it raised the diluted implementation's similarity from 0.163 to 0.212. On
129
+ the real MiniLM model it does nothing: across two body shapes, three query
130
+ phrasings and both budgets, the duplicate wins all twelve times, and the cap
131
+ never moves the winner. Its apparent benefit came from the test fixture, which
132
+ emitted forty copies of one line — truncating identical lines removes a
133
+ redundancy bag-of-words punishes and a transformer largely ignores.
134
+
135
+ The general lesson is worth more than the change would have been: a short
136
+ document made almost entirely of query tokens beats a long one under cosine
137
+ similarity, and no reweighting of a single vector's inputs changes that. Graph
138
+ degree is the right remedy here precisely because it is not a similarity signal.
139
+
140
+ ## Prose queries got no lexical signal at all, and now do
141
+
142
+ A query phrased as prose did not surface the symbol whose docstring restates it;
143
+ symbols sharing a word or two in their *identifier* won instead. The ticket
144
+ framed this as docstring prose being under-weighted against code in the
145
+ embedding. That premise turned out to be false — for the reported symbol the
146
+ embedded text is already about 95% docstring, and it still lost.
147
+
148
+ The actual cause was in rank fusion. The lexical channel selected the AND-matched
149
+ set unless every semantic score was bit-identical, which never happens under a
150
+ real model, so the OR set was computed on every query and discarded on every
151
+ query. And for a query phrased as prose the AND clause is empty by construction:
152
+ it demands one symbol containing every surviving query word, function words
153
+ included. Nothing matches, so the lexical contribution was zero for every
154
+ candidate and "hybrid search" was pure vector search. The docstring's BM25
155
+ evidence existed the whole time, in the set being thrown away.
156
+
157
+ Both operators are now fused as separate channels, the partial one at 0.75 weight
158
+ and truncated to its top 30. Because the AND set is a subset of the OR set by
159
+ construction, an exact match collects both terms and a partial match collects
160
+ one — so the precision bonus falls out of that containment rather than needing
161
+ machinery of its own. The weight buys a property worth stating: lexical evidence
162
+ can rescue a symbol the embedder under-ranked, and cannot resurrect one the
163
+ embedder judged irrelevant.
164
+
165
+ A partial match must also clear a quorum: it has to echo at least two of the
166
+ query's words. A single incidental token is not evidence, and this index cannot
167
+ tell the difference unaided — FTS5 here has no stemming and does not split
168
+ identifiers, so for "session authentication and token validations" the class
169
+ that does the work matches *nothing* (its docstring says "sessions" and
170
+ "validation") while a handler containing "session" matches once. Without the
171
+ quorum that handler outranked the authenticator, and no weight fixed it: every
172
+ value from 0.75 down to 0.15 flipped the same way, because the problem was which
173
+ symbol earned credit rather than how much.
174
+
175
+ Measured on the new benchmark below: **MRR 0.5178 → 0.754, recall@10 0.80 →
176
+ 0.925**, with 19 of 40 queries improving, 4 regressing and 17 unchanged — a
177
+ two-sided exact sign test at p = 0.0026. Dev and test halves moved together,
178
+ which is what you want when nothing was tuned against the labels. The largest
179
+ gains were symbols that had been effectively unreachable: rank 24 → 1, 28 → 3,
180
+ 14 → 2.
181
+
182
+ ## Doc comments were being lost, and misattributed
183
+
184
+ Two defects in how a doc comment is matched to the thing it documents. Both fed
185
+ the embedding input and the FTS `summary` column, so both corrupted retrieval
186
+ directly — and they point in opposite directions, which is why neither showed up
187
+ as an obvious symptom.
188
+
189
+ **Multi-line `//` comments kept only their last line.** tree-sitter gives each
190
+ `//` line its own node and the extractor read a single previous sibling, so the
191
+ loop meant to rejoin lines could never fire for this style. The first line of a
192
+ doc comment is usually the sentence saying what the thing is for — exactly what a
193
+ capability question matches on — so this silently removed the most useful text
194
+ and left a grammatical fragment that still looked like a well-formed summary.
195
+ Block comments are one node and were never affected, which is why the damage
196
+ stayed invisible in codebases that prefer them.
197
+
198
+ **And a symbol with no doc comment adopted its neighbour's.** The sibling walk
199
+ stepped over as many as five non-comment nodes hunting for a comment, so prose
200
+ written about one symbol was embedded against another. Measured on this
201
+ repository before the fix: **857 of 1,329 documented symbols under `src/` — 64% —
202
+ shared their summary text with another symbol**, and spot checks showed plain
203
+ misattribution rather than genuine repetition, including an interface carrying a
204
+ neighbouring function's docstring. After the fix, 718 symbols are documented and
205
+ **none** share text.
206
+
207
+ That second one is the worse of the pair. Absent prose merely loses a hit;
208
+ misattributed prose manufactures a confident wrong one.
209
+
210
+ A related gap is recorded but not fixed: a decorated declaration still never
211
+ receives its doc comment, because the decorator is a sibling of the declaration
212
+ but a child of the exporting statement. It was verified to predate both fixes and
213
+ is pinned by a characterization test.
214
+
215
+ ## A benchmark for retrieval quality, and why it could not be a test
216
+
217
+ `npm run bench:c103` measures whether a capability question phrased as prose
218
+ finds the symbol implementing it, through the real ranker and the real embedding
219
+ model, over knodin's own source at a pinned commit. Forty hand-written queries,
220
+ 15 dev / 25 test, MRR primary.
221
+
222
+ It exists as an opt-in benchmark rather than a spec because the fast suite
223
+ installs a deterministic bag-of-words embedder, and that embedder produces the
224
+ **opposite** answer on exactly this case: an identifier match beats a docstring
225
+ match on it, while the docstring wins by 2.75x on the real model. Two ranking
226
+ assertions written for this release were deleted for pinning that inversion. A
227
+ test that asserts the reverse of production is worse than no test — it fails
228
+ changes that help users and passes changes that hurt them.
229
+
230
+ So the fast suite now asserts only ranking properties no choice of embedder can
231
+ change, which in practice means rank arithmetic over graph structure, and
232
+ retrieval quality is measured where it can be measured honestly. The existing
233
+ `c86` evaluation could not host this: it ranks raw text by exact cosine, never
234
+ touching fusion or the ranking weights, and is saturated at recall@5 = 1.0.
235
+
236
+ ## What is not fixed
237
+
238
+ **Mean-pooling dilution inside long docstrings.** Only one sentence of a
239
+ multi-sentence docstring may be on topic, and averaged over the whole comment it
240
+ becomes a minority of the vector while a short, entirely-on-topic competitor does
241
+ not. Splitting docstrings into sentence chunks and max-pooling was prototyped
242
+ against the real model and moves the reported symbol from rank 4 of 5 to rank 2 —
243
+ but it needs roughly 1.5x the vectors, a composite primary key, and a re-embed
244
+ migration.
245
+
246
+ It is deliberately **not** in this release. The lexical fix above addressed the
247
+ same failure for no migration at all, and the benchmark now says prose retrieval
248
+ sits at MRR 0.754 with recall@10 of 0.95. Chunking should be re-measured against
249
+ that new baseline before anyone pays for it — which is the whole reason the
250
+ benchmark was built first.
251
+
252
+ Worth stating plainly: the benchmark corpus is knodin's own source, and the
253
+ defect was reported against a different codebase. The improvement is real and
254
+ significant here; it has not been demonstrated there.
255
+
256
+ ## Smaller things
257
+
258
+ `repair` now seeds from an indexed sibling worktree when there is **no** database
259
+ at all, reflink-copying a baseline and reconciling only the differing paths
260
+ instead of rebuilding the corpus from scratch. A database that exists but is
261
+ damaged is deliberately excluded — that is the case `repair` exists for, and
262
+ seeding it would replace a verifiable local rebuild with another checkout's
263
+ state.
264
+
265
+ `readGitHeadFast` is deleted. Its caller invoked it only when `.git` was absent,
266
+ which was its own first bail-out, so the two conditions were exactly
267
+ anti-correlated and the function could only ever return `null` — making the
268
+ `&& headUnchanged` term it fed inert. Coverage confirmed zero hits across the
269
+ whole suite. Git history settled which side was the bug: the fast HEAD read
270
+ landed first, and the later `lease = 0` for git-backed repositories superseded
271
+ it.
272
+
273
+ Two timing-sensitive specs no longer produce false pre-push failures. Both had
274
+ been widened only for instrumented coverage runs, on the theory that
275
+ instrumentation caused the contention. It does not — the failures reproduce on
276
+ ordinary uninstrumented runs under host load — and the uninstrumented run is
277
+ exactly the one developers gate on. The budgets are now unconditional, matching
278
+ the correction already recorded in `SUBPROCESS_BUDGET_MS`. A timeout that fires
279
+ on a loaded machine reports a failure that did not happen, and a gate whose
280
+ failures have to be re-run in isolation before they can be believed is not
281
+ doing its job.
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "knodin",
3
- "version": "0.10.8",
3
+ "version": "0.12.0",
4
4
  "knodin": {
5
- "compatibility": "compatible"
5
+ "compatibility": "breaking"
6
6
  },
7
7
  "description": "knodin — source-evidenced local code intelligence with known bounds. Stable identity, fresh evidence, truthful budgets, and recoverable bounded views.",
8
8
  "license": "MIT",
@@ -63,6 +63,8 @@
63
63
  "docs/releases/0.10.6.md",
64
64
  "docs/releases/0.10.7.md",
65
65
  "docs/releases/0.10.8.md",
66
+ "docs/releases/0.11.0.md",
67
+ "docs/releases/0.12.0.md",
66
68
  "docs/releases/0.3.0.md",
67
69
  "docs/releases/0.4.0.md",
68
70
  "docs/releases/0.4.1.md",
@@ -121,6 +123,7 @@
121
123
  "check:hygiene": "node scripts/check-repository-hygiene.mjs",
122
124
  "check:roadmap": "tsx scripts/check-roadmap.ts",
123
125
  "verify:c79": "tsx scripts/verify-c79.ts",
126
+ "bench:c103": "tsx benchmarks/evaluations/c103-prose-retrieval/runner.ts",
124
127
  "bench:c80": "tsx benchmarks/evaluations/c80-structural-routing/runner.ts",
125
128
  "verify:c80": "tsx scripts/verify-c80.ts",
126
129
  "bench:c81": "tsx benchmarks/evaluations/c81-powered-benchmark/runner.ts",