feat(explore): markdown index (#361) with a section-first doc tier - #1699
feat(explore): markdown index (#361) with a section-first doc tier#1699bompus wants to merge 5 commits into
Conversation
… doc tier Ports QingNagi/codegraph#361 (markdown extractor, heading nodes, name-matcher and resolution hooks) onto experimental and adds the section-first doc tier from feature/md-section-first: a doc-shaped query renders the best headed sections of the markdown file it names, ranked by idf-weighted line hits with path tokens weighted zero, capped at DOC_FILE_CAP per file. Markdown reaches an answer only through that tier, generated-file detection ignores markdown bodies, and the budget tiers count code files only so a README-heavy repo keeps its code answers.
Real-repo run: the index half holds up, the retrieval half is brittleRan this against vitejs/vite (84 markdown files) alongside upstream Indexing works1,635 → 1,719 files, exactly the 84 markdown files. 3,130 markdown nodes, and the sections carry real heading names: Code queries are unaffected on three of four probes — byte-identical rankings and graph mass against Retrieval works when it fires, and it fires well
That second one is a good answer out of 1,719 files. But several natural doc queries return no markdown at all
The bare filename is the sharpest case: the query is the file name, and the user gets an empty response. It is not the seed lookup. I called it directly against the built index, and So the candidate is found and then lost downstream, in section selection. Two rules in I want to be straight about how far I took that: I confirmed the seed layer directly and I can show the working/failing boundary, but I did not step through section scoring for each failing query, and adding a third term ( Suggested shape, and I'm happy to write it: when the query names a The |
Root cause of the retrieval misses, and a correction to my last commentI said above that two rules in
|
… is its name
collectDocSeeds honoured `named` at the file gate and ignored it at the section
gate, so a query that was just a filename found the file and then discarded it
for want of a scoring line: `CONTRIBUTING.md` returned nothing at all.
Three points, one cause. A term appearing in the file path is weighted 0, which
for a bare-filename query is the only term there is; that disables lineScore
(which needs two distinct terms on a line) and coveredHeading (which needs a
term of non-zero weight) at the same time, so every section scores 0 and the
seed is dropped whole.
- Sections fall back to the file's opening headings when a named seed scores
none, mirroring the `named` escape the file gate already has.
- coveredHeading matches on presence when every term is path-zeroed: rarity is
meaningless with one term, coverage still is.
- coveredHeading filters heading words by DOC_QUERY_NOISE as well. Those words
are stripped from the query but were still demanded of the heading, so any
heading containing one ("Cloning the repo on Windows") was uncoverable by any
query at all.
The `hits < 2` file gate is unchanged, so a single-word doc query such as
`readme` still misses; relaxing it widens the gate for every doc-word query and
needs its own measurement.
…stop hiding headings
`readme` returned nothing. The two-hit file gate is unreachable for a query
with one significant term — termScore counts distinct terms, so `hits` cannot
exceed `terms.length` — and the section layer had no notion of naming at all.
A term equal to a file's own stem is the same statement of intent as spelling
the path: the user has said which file they want. It now unlocks the two-hit
gate and the section fallback, but deliberately NOT the DOC_LOW_PATH bypass,
which stays the privilege of an explicitly spelled path — `readme` should not
surface a fixture copy.
The gate itself becomes `hits < Math.min(2, terms.length)`. Two hits still keep
a code question that merely shares a word ("War Room popout") from pulling a
plan file above the code; a one-term query cannot reach two, and having passed
DOC_WORD that term IS the doc word, so there is no code question to protect
against.
Measured on vite (84 markdown files), same index, two builds: output is
byte-identical across 8 code queries and the 3 doc queries that already worked,
and the 5 queries that returned nothing now return the right file.
The new test drives the doc tier through ToolHandler rather than asserting on
extraction, because every miss that motivated this was a retrieval miss found
by querying a real repo — which no test here was doing.
The wasm arm emits code -> documentation `references` refs from any string literal that looks like a markdown path; the kernel emitted none, so a file routed to the kernel silently lost those edges. Port the candidate finder and normalizer to markdown.rs and call it from all 15 routed languages. The pair is one macro (markdown_refs_impl!) because the wasm arm it must match is one implementation. Its string half runs at both walker sites in every language; its subtree half is needed only where the walker stops before a declaration's value (tsjs, python, java, csharp, ruby, lua) and is unused elsewhere by design. Markdown refs reach the store through addReference, which denormalizes both filePath and language, unlike the ordinary ref path. REF_FLAG_LANGUAGE joins REF_FLAG_FILE_PATH so decode re-attaches both, and only on these refs -- parity compares ref objects whole, so an extra field on an ordinary ref fails. Every language's torture fixture gains the same eight shapes, including the two rejections (escape above the repo root, and a URL that is NOT rejected: the candidate regex matches from the `//` inside `https://`, so the scheme look-back never fires and both arms emit example.com/remote.md). Verified: 13/13 kernel parity files pass; cargo build clean; 25 Rust unit tests pass; tsc 0 errors; full suite 4189 passed / 24 failed with 0 AssertionErrors, against a 4185/26-with-2-assertions baseline -- the remaining 24 are the host's EPERM/open-files/timeout teardown class.
… unresolved anchor Three defects found re-deriving every figure from the retained arm JSONs: - The 60 bare-`vite` imports were described as the fuzzy matcher declining. They never reach it. matchByExactName runs first (name-matcher.ts:2652 vs 2656) and returns `exact-match` when exactly one candidate survives; the markdown index adds two more `vite` nodes, so that branch stops firing. Measured independently by the colbymchenry#1699 author on their own arms. - "The extra 363 ms is the 84 markdown files" conflated two hops. 363 ms is the v1.6.0 -> fork gap; only 140 ms of it is the fork's markdown files, and the other 223 ms is upstream's per-file cost rise. - Unresolved references were quoted at 635 with no row in the table and no hop named. Added the row, and stated 587 for main -> fork. Everything else re-derived and unchanged: 23/23/0 failures, 3,174/4,171/4,228 passing, node and edge totals, 93 lost rows (71 imports / 22 calls, 60 onto `vite`, one self-edge), attribution 71/12/12/4/0 summing to 99 over a 93-row union, and the six interleaved timings with their medians and spreads.
…rnative that is not valid The footnote offered 79 as a distinct-key reading of the same delta. It is not a second valid unit, it is an under-count. `edges` carries `line` and `col`, and neither this delta's key nor the one used to cross-check it included col, so two genuine references on one source line collapse into one key: `import corsMiddleware from 'cors'` emits an edge for the specifier at col 0 and one for the binding at col 7. Keyed on source, target, kind, line and col, distinct equals rows exactly — 27,778 = 27,778 on main — so the row counts in this section are the edge counts and there is no lower figure to reconcile against. Measured by the colbymchenry#1699 author, who also withdrew a duplicate-edge defect that was the same projection artifact seen from the other side. The schema is the check: src/db/schema.sql declares col on edges.
Follow-up to #361 and #1439, with the measurement from this comment on #361.
What this carries. #361's markdown extractor as it stands (headings, sections, tables and links as nodes; name-matcher and resolution hooks; its five test files), rebased onto
main, plus a doc tier incodegraph_exploreand three fixes the index needs to leave code answers alone.The doc tier. A doc-shaped query that names a markdown file renders that file's best sections first and whole: the top three by idf-weighted line hits, where a term that also appears in the file's own path weighs zero (it located the file, not the line) and a heading the query covers word for word counts as a named section; 8k characters per file, spent in score order and rendered in file order. The blast-radius, relationships and "additional files" blocks stay off unless a code file rendered too. Code queries take the same path they did before.
Three fixes for a shared index.
detectGeneratedFileskips the header check for markdown: a README that quotes "generated by" was dropped from the index.The server instructions now say markdown is indexed; #361's text still listed docs under what codegraph does not index, and measured as merged the model never called explore for a doc question (24 cells, 0 calls).
The retrieval fixes (
bc3dfc9,625e2f8)The tier as first pushed could find a file and then render nothing of it. Pointing it at a real 84-file repo instead of a fixture turned up five queries that returned no result at all —
CONTRIBUTING.md,readme, and three phrasings that name a heading containing a common word. One cause runs under all of them: the tier honoured "the query names this file" at the file gate and then forgot it at the section gate. Named got a file past the two-hit rule and pastDOC_LOW_PATH, and thenif (sections.length === 0) continuedropped it for want of a scoring line.Nothing could score such a line, because three rules combine to guarantee zero:
lineScoreneeds two distinct terms on one line; a one-term query cannot reach two.coveredHeadingrequires a term of non-zero weight, which by the first rule does not exist.bc3dfc9fixes the last two.coveredHeadingfalls back to presence when no term carries weight — the rarity signal is meaningless with one term, but "the query covers this heading" still means something — and a named file with no scoring section falls back to its first three non-wrapper headings. A user who types a filename has already said which file they want.625e2f8adds the two smaller ones:readmenames README.md as surely asREADME.mddoes, but carries no.mdforMD_PATHto see, so it never becamenamedat all. A term equal to the file's own stem now unlocks the two-hit gate and the section fallback — but deliberately not theDOC_LOW_PATHbypass, which stays the privilege of an explicitly spelled path.readmeshould not surface a fixture copy, and a test asserts it does not.DOC_QUERY_NOISEwords were stripped from the query and then demanded of the heading. Filtered on one side only, so a heading containing such a word could not be covered by any query that exists: "Cloning the repo on Windows" can never haverepocovered, becauserepois never a term. Both sides are filtered now.Measured. All five dead queries return their file and the right section. Against the change, 11 control queries — 3 doc, 8 code — are byte-identical across two builds on the same index; the tier is retrieval-only, so the same index serves both arms and only
dist/is rebuilt.__tests__/explore-doc-tier-named.test.tsis new: 5 assertions driven throughToolHandler, not against the helper, and it ablates to exactly 3 failures against the pre-fixtools.ts.Markdown path references under the native kernel (fixed in 56577ad)
An earlier revision of this PR disclosed this as a known limitation. It is now fixed, and the history is left here because the way it was caught is the useful part.
The gap.
extractMarkdownPathReferences*— the code→document edge, a code file's string literal resolving to the heading it names — was implemented only on the wasmTreeSitterExtractor.tryKernelExtractreturns before that extractor is constructed, so for any language inDEFAULT_ROUTEDthe capture never ran, and two of this PR's own tests failed whenever a kernel prebuild was present. It was invisible on CI because the prebuilds are not tracked in git, so an ordinary checkout runs the wasm path, and the one workflow step that runs with prebuilds downloaded runs__tests__/kernel-*.test.tsonly — never the full suite with a kernel loaded.The fix. The candidate finder and normalizer are ported to
codegraph-kernel/src/markdown.rsand called from all 15 routed languages, not only the JS family. The two extraction methods are one macro (markdown_refs_impl!) because the wasm arm they must match is one implementation. The string half runs at both walker sites in every language; the subtree half is needed only where a walker stops before a declaration's value (tsjs, python, java, csharp, ruby, lua) and is unused elsewhere by design, which is stated at the macro rather than left as a puzzle.One wire-format detail: markdown refs reach the store through
addReference, which denormalizes bothfilePathandlanguage, unlike the ordinary ref path.REF_FLAG_LANGUAGEjoinsREF_FLAG_FILE_PATHsodecodere-attaches both, and only on these refs — parity compares ref objects whole, so an extra field on an ordinary ref fails the gate.How the fixtures earned it. Every language's torture fixture gains the same eight shapes, including both rejection cases. On the first run after wiring, 8 of 12 parity files passed and java, csharp, ruby and lua each came up short by exactly 7 refs — the seven variable initializers. An identical shortfall across four independent walkers is a structural fact rather than four separate bugs, and it pointed straight at the cause: those walkers stop before a declaration's value. That diagnosis was only available because the number was the same in all four.
One shape is worth calling out because it is a deliberate non-rejection.
https://example.com/remote.mdis not rejected: the candidate regex matches starting at the//inside the scheme, so the://look-back never fires and both arms emitexample.com/remote.md. My first unit test asserted the opposite and failed; the test was wrong, not the port. It is now pinned by the parity fixture rather than by an assertion I reasoned my way to.Scope. Markdown files were never affected:
markdownis not inDEFAULT_ROUTED, so.mdfiles are indexed by their own extractor and the doc tier, the sections, and the retrieval fixes always worked normally.Measured (doc tier). Headless Claude Code, Opus, a repo with 109 markdown files, six doc-question tasks, three rounds of 12 fresh cells against the shipped build with the same repo rules:
Every explore call chose the right file and section; the misses are the model grepping a file the prompt already names, and one prompt it reads as being about its own scheduling tools. Cost per cell $0.30 against $0.54.
Code answers on the same repo, one fresh session per query, 25 code queries against
main: 11 byte-identical, 3 the same lines reordered, 10 swap a fourth- or fifth-ranked padding file (markdown documents shift FTS tie ranks), and one prose prompt with no identifier answers from two markdown files instead of five unrelated code files.One column reads as a precision regression and is not one. Edges with
provenance = 'heuristic'multiply on this branch — on vitest, 219 → 5,089. Splitting them by whether either endpoint is a.mdfile:b9ca4b7contains)Code-side heuristic edges are identical to the edge; every added one is a markdown section-
containsedge, which is structural rather than inferred. The same split on vite reads 36 → 3,082 with the code side unchanged — different repo, different totals, same invariant. Found jointly with the session reviewing this branch, who spotted the column and knew how it would read.A code-side change this branch does cause, and did not claim. Indexing markdown alters code resolution as a side effect. On vite, against
b9ca4b7: 71 edges removed, 0 added.playground/ssr-html/test-stacktrace.js::vitepackages/vite/src/node/preview.ts::corsdocs/.vitepress/theme/composables/sponsor.ts::Sponsors…/server/middlewares/hostCheck.ts::host-validation-middleware…/server/index.ts::ViteDevServerAn earlier revision gave this as "71 rows, 69 distinct" and explained the gap as two valid units. That was wrong in a way worth stating, because the smaller number was the defective one. My dedupe key was
(kind, source, line, target)with no column, and an import line carries two distinct references — the module specifier at column 0 and the binding it introduces a few columns later. The key merged those two into one entry, on this diff and everywhere else I had used it. Addingcolmakes distinct and rows agree exactly, here (71) and on the whole baseline graph (27,778 = 27,778). There is one unit; the second number was a key that could not see a column.The 60 are
import … from 'vite'— the npm package — previously resolving onto that playground file's ownvitenode. They carriedresolvedBy: exact-match, so this is candidate dilution atmatchByExactName, not the fuzzy path: nodes namedvitego 159 → 161, the two additions beingREADME.mdanddocs/guide/cli.md, and thecandidates.length === 1branch that stampsexact-matchno longer sees a unique survivor. It declines, correctly — the true target is external and has never been in the graph.So a reviewer diffing edge counts meets a 71-edge drop on a PR that presents itself as retrieval-only. It is a precision gain rather than a regression, it is unrelated to the heuristic column above, and it is the largest single code-side effect measured on vite for any change in this area. Identified by the session reviewing this branch; the mechanism and the candidate-pool counts are mine.
Tests.
cargo build --releaseexits 0 with no dead-code warnings, 25 Rust unit tests pass, andtsc --noEmitexits 0. All 13 kernel parity files pass, now including markdown shapes in every language's fixture. Full suite on Node v24.16.0 (the versionscripts/build-bundle.shvendors, insideengines.node), Windows, with a kernel prebuild present: 4,189 passed, 24 failed, 0 assertion failures.Two corrections to how this paragraph read in an earlier revision.
The first is mine. It previously reported 4,185 passed / 26 failed with 2 assertion failures, and described those two as pre-existing before correcting that. They were caused by this PR — the markdown path-reference tests — and the ablation that first told me otherwise was not a control at all: I reverted to a commit on this branch, which still contained the whole feature. Both now pass, which is the difference between the two runs.
The second is about the remaining 24, and it is a wrong cause rather than an imprecise one. I attributed them to Windows Defender on-access scanning of binaries outside
C:\Program Files. They are Windows teardown defects — an unclosed db handle, an un-awaited child,waitForMarkerreturning on existence — and #1717 fixes them:The single survivor is
__tests__/sync.test.ts→ "persists an oversized skipped file so later syncs do not retry it (#1557)", a 5 s timeout. That one is scanner-sensitive: it writes'const value = 1;\n'×70,000, close to a worst case for on-access scanning, which a separate investigation measured at 12.6 s to first-read against ~1 ms unscanned. So the Defender finding is real and explains exactly one of the 24; generalising it to the whole class was the error. Neither version changed a number in this PR — every claim here is a count, not a latency — but the ablation that would have caught it was merging #1717, which I had reviewed without ever running against this branch.Re-index after upgrading: the markdown nodes are written while indexing.