Skip to content

Report the index shortfall the reindex audit was structurally unable to see - #162

Merged
jpr5 merged 5 commits into
mainfrom
fix/reindex-audit-reports-shortfall
Sep 11, 2026
Merged

Report the index shortfall the reindex audit was structurally unable to see#162
jpr5 merged 5 commits into
mainfrom
fix/reindex-audit-reports-shortfall

Conversation

@jpr5

@jpr5 jpr5 commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

The audit could not report an index that had shrunk

reindex-audit.ts Check 3 only ever fired on db_has_more. The db_has_fewer
direction was in the finding type and was never implemented:

// Check 3 — Count divergence (db_has_more only; db_has_fewer is expected
// when the indexer filters low-semantic-value files like SVGs, base64, etc.)
if (dbCount > diskCount) { ... }

The stated reason is real — the indexer legitimately drops files with no
extractable prose — but "some shortfall is normal" got implemented as "all
shortfall is invisible."

Motivation: three bugs the check watched and never mentioned

  1. Wrong crawl scope. The CopilotKit API reference (184 .mdx) lived in a
    sibling directory outside the configured walk root; 183 of 184 pages were
    live on the site and absent from the index.
  2. A wedged item. The code source sat in a false error from 2026-09-01
    to 2026-09-11: one file produced a 36 KB chunk and a non-retryable 400, and
    the orchestrator refused to advance past it.
  3. Successful extraction of nothing. 130 of 682 prose .mdx files are pure
    JSX stubs whose text lives in the excluded src/content/snippets/**. They
    are walked, matched, read, stripped to empty, and chunk to zero.
    markdown.ts returns [], pipeline.ts writes nothing — both silently. A
    19% shortfall that stood for months.

Honest scope note: this change catches #2 and #3. It does not catch #1
when the walk root itself is wrong, the disk side of the comparison is wrong
too, so disk and index agree. #1 needs a config-level scope check, not this one.

What changed

Check 3 reports both directions. The shortfall is measured on the set
difference
(disk minus index), not on diskCount - dbCount. The raw delta is
a lossy proxy: a source holding one stale row and missing one real file has
matching counts and reports nothing — the same blindness in miniature. The set
difference fires in every case the count comparison would, plus that one, and
it yields the actual paths, so the finding names the files instead of
saying "the count is off by 130."

Keeping the signal from becoming noise. The finding fires only above a
per-source baseline: unindexed_tolerance, a new optional file-source key,
defaulting to 5% of walked files, with an absolute floor of 3 files so
a small source does not alert on one or two legitimately-empty ones. The floor
is bypassed when a source explicitly sets 0.

What a healthy source looks like after this change: it emits nothing.
A source dropping a handful of empty or content-free files per reindex stays
silent at the default. A source that has been cleaned up sets
unindexed_tolerance: 0 and then hears about the very first regression — that
is the intended end state, and it is why the lever is per-source rather than a
global mute. The failure mode this design is guarding against is an alarm that
fires on every source on every reindex and gets muted within a week, which is
how the direction came to be suppressed outright in the first place.

unindexed_tolerance is deliberately not in the source fingerprint
(source-fingerprint.ts uses an explicit allowlist): it changes reporting, not
what gets indexed, so changing it must not force a reindex.

Dedup key now includes the direction. A source can produce both directions
in one run; source:check alone let one overwrite the other and suppress its
Slack alert.

The two silent drop sites are now audible. Every return [] in
chunkMarkdown warns with the file and a distinct reason — empty file / MDX
stripping left no prose / every split trimmed to empty — because those have
completely different fixes. (There was a third silent path at the bottom of the
function; it got the same treatment.) pipeline.indexItem logs that the index
actually lost the item, prefixed by source and id, greppable next to
next_acquire_reason and quarantined_items:

[chunker] no chunks for docs/quickstart.mdx: MDX stripping left no prose (the file is JSX/imports only — its text may live in a snippet or component that is not indexed); it will not be indexed
[pipeline:docs] docs/quickstart.mdx produced zero chunks; nothing indexed (any previously indexed chunks for it are being cleared)

Should a zero-chunk file be its own audit finding?

No — and it does not need to be, because the shortfall finding already names
it individually.
A zero-chunk file is written as replaceChunksForFile(src, id, []), which deletes the row, so it is by construction a disk file absent
from the index and it lands in the shortfall finding's samples by path. A
separate persisted zero-chunk finding would need the indexer to write a second
record of the same fact into a second store, which can go stale against the
chunks table and would report a file as dropped after someone fixed it. The
audit's two sources of truth are disk and the index; keeping it that way means
the finding cannot lie. The per-file reason — which the audit genuinely
cannot know — is what the new chunker warnings supply, at the one place that
does know.

Red-green

RED — 40 .mdx pages, 8 of them pure-JSX stubs, run through the real
chunker and the real pipeline (only the DB and the disk walk are faked), so
the test exercises the actual drop path:

stdout | reports a db_has_fewer finding when pure-JSX stubs chunk to zero
RED PROOF findings = []

× reports a db_has_fewer finding when pure-JSX stubs chunk to zero
  AssertionError: expected undefined to be defined

Preconditions in the same run passed: disk 40 files, index 32. The shrink was
real and the audit said nothing.

GREEN — same test, unchanged:

stderr | [reindex-audit] docs — count_divergence: 8 issues (db_has_fewer): page-0.mdx, page-5.mdx, page-10.mdx, page-15.mdx, page-20.mdx
stdout | GREEN_PROOF [{"source":"docs","check":"count_divergence","count":8,
          "samples":["page-0.mdx","page-5.mdx","page-10.mdx","page-15.mdx","page-20.mdx",
                     "page-25.mdx","page-30.mdx","page-35.mdx"],"direction":"db_has_fewer"}]
✓ reports a db_has_fewer finding when pure-JSX stubs chunk to zero

Negative assertion — a source skipping a normal share (1 of 40, 2.5%)
produces findings === [], and the same shortfall does report once the
source sets unindexed_tolerance: 0:

✓ does NOT report a shortfall for a source skipping a normal share of files
✓ reports that same small shortfall once the source sets unindexed_tolerance: 0

Mutation-tested so the negative assertion is not vacuous — replacing
unindexed.length > budget with > 0 fails it:

× does NOT report a shortfall for a source skipping a normal share of files
  Tests  1 failed | 2 passed (3)

The chunker and pipeline warnings were red-greened the same way (2 chunker
tests and 1 pipeline test failed before the warnings existed), each paired with
a "does not warn on a normal file" assertion.

Local gate

npm run build ✅ · npx tsc --noEmit ✅ · npx tsc --noEmit -p tsconfig.scripts.json ✅ ·
node scripts/check-test-shapes.mjs ✅ · npx prettier --check ✅ ·
npm test189 passed | 1 skipped (190 files), 3712 passed | 1 skipped. No pre-existing failures.

What an operator does when this fires

Open the named files. Three outcomes, in order of likelihood:

  1. The files are stubs or otherwise have no indexable prose — real but
    legitimate. Either fix the extraction (inline the snippet, widen the source)
    or, if the drop is genuinely correct, raise that source's
    unindexed_tolerance past the measured baseline and leave a note saying why.
  2. The files should have indexed and did not — grep the reindex logs for
    [chunker] no chunks for <path> and produced zero chunks; the reason is
    there.
  3. No chunker warning for them at all — the source never got that far.
    Check /health for a wedged source and a held state token (Make a reindex actually pick up a change to a source's crawl configuration #160, Unwedge the code source: a URL in a template literal, an 8192-token chunk, and an unbounded retry #161).

…le to the audit

Forty .mdx pages, eight of them pure-JSX stubs whose prose lives in an
excluded snippet. The real chunker strips them to nothing and returns [],
the real pipeline writes nothing, and the index ends up holding 32 of 40
files. runReindexAudit() reports [].
Check 3 only ever fired on db_has_more. The db_has_fewer direction existed
in the finding type and was never implemented, so an index holding fewer
files than disk produced no finding at all — the blindness that let a
wrong crawl scope, a wedged source, and 130 zero-chunk stubs run undetected.

Measured on the set difference (disk minus db), not the raw count delta:
the delta is a lossy proxy that misses an equal-count stale/missing swap,
and the set difference yields the actual paths so the finding names the
files. Gated on a per-source tolerance (5% of walked files by default,
with a 3-file floor for small sources, overridable via the new
unindexed_tolerance source key) so a healthy source stays silent instead
of emitting a finding on every reindex.

The dedup cache now keys on direction too; both directions can fire for
one source in one run, and the old key let one overwrite the other.
chunkMarkdown returned [] from three places and pipeline.indexItem wrote
nothing, all without a word. A walked, matched, read file left the
pipeline and the only trace was a file count that quietly did not add up.

Each chunker exit now names the file and a distinct reason — empty file,
MDX stripping left no prose, every split trimmed to empty — because the
fixes differ. The pipeline logs that the index actually lost the item,
prefixed by source and id.
check-test-shapes flagged the `as never` on the pipeline's embedding
provider; it is an EmbeddingProvider, so say so.
@jpr5
jpr5 merged commit 8c4c289 into main Sep 11, 2026
7 checks passed
@jpr5
jpr5 deleted the fix/reindex-audit-reports-shortfall branch September 11, 2026 02:59
jpr5 added a commit that referenced this pull request Sep 11, 2026
## Why

`reindex-audit` compares what is on disk against what is in the index —
but "what is on disk" is enumerated by walking the path the **config**
points at, the same path the indexer used. When that walk root is wrong
or too narrow, both halves are blind identically and agree perfectly.

That is how the CopilotKit API reference — 184 `.mdx` files under
`showcase/shell-docs/src/content/reference/` — stayed invisible for
months. The docs source's `path` was the sibling directory
`content/docs/`. The indexer walked `docs/` and found 682 files; the
audit walked `docs/` and found 682; the index held 682. A perfect match,
with 183 live pages missing from search. #159 fixed that specific config
and #162 added shortfall reporting, but neither can see the *next* one,
because the comparison is anchored to the config rather than to the
repository.

## What this adds

Check 4, `unclaimed_content` (`src/indexing/unclaimed-audit.ts`): walk
the whole **repository** and report indexable-looking files that no
source's walk root and patterns would ever reach.

Noise control is the entire problem, and it is harder than #162's — most
of a repo is legitimately unclaimed, and a check that reports every
unclaimed file gets muted, which is exactly how the original blindness
arose. So a cluster is reported only when it has the shape the reference
tree had:

1. **Maximal unclaimed subtree.** The highest directory holding no
claimed file whose parent does hold one. `content/reference/` surfaces
as one 184-file finding instead of fragmenting into `reference/hooks/`,
`reference/components/` and a dozen more — each too small to notice and
far too many to read.
2. **At least 10 files of one extension**
(`MIN_UNCLAIMED_CLUSTER_FILES`). The "large cohesive directory" half of
the shape. A scattered handful is not a signal.
3. **An extension the repository already publishes through that same
parent.** Derived from what the configured sources actually index, not a
hardcoded list — a repo full of `.mdx` with one unclaimed `.mdx` tree
beside a claimed one is the signal. Stray `.ts` scripts next to an
indexed docs tree are not.
4. **`exclude_patterns` count as accounted for.** A file a source's own
exclusion vetoes was reviewed and rejected on purpose. The exclusion
only counts when one of *that source's include patterns* would otherwise
have taken the file — otherwise a code source excluding all of
`showcase/` would silently vouch for every `.mdx` under `showcase/` too,
which is the exact tree the reference pages were hiding in.
5. **Generated directories are never walked** — dot-directories
wholesale, plus `node_modules`, `dist`, `build`, `out`, `coverage`,
`target`, `vendor`, `venv`, `__pycache__`, `site-packages`,
`storybook-static`.

Claims are computed from **every** configured source on the repo, not
just the ones that reindexed. Findings are attributed to the first
audited source on that repo so #162's dedup cache can retire them; the
dedup key includes the directory so two trees on one source stay two
findings.

### Opting out

New per-source `unclaimed_exempt_paths` key, following #162's per-source
`unindexed_tolerance` precedent. Repo-root-relative prefixes or globs,
**pooled across every source reading the same repository** — any one of
their operators can vouch for a subtree. An operator reviews a directory
once, records it, and never sees it again. Documented in
`docs/config/index.html`.

### Surfacing

The existing `[reindex-audit]` log stream and the existing Slack alert,
both with the directory, file count and extension, and the remedy
inline:

```
[reindex-audit] docs — unclaimed_content: 96 .mdx files under showcase/shell-docs/src/content/ag-ui/
that no configured source claims (widen a source's path/file_patterns, or record it in
unclaimed_exempt_paths if it is correctly unclaimed): ...
```

Not added to `/health` or admin `index-stats`: both project
`index_state` rows, and audit findings are not persisted. Giving them a
durable home is a real change with its own schema and lifecycle
questions, and bolting an in-memory last-run cache onto an operator
endpoint would be worse than the log line.

### What a healthy repo looks like after this

Silent. `pathfinder` and (after the opt-outs below) `aimock` produce no
findings at all. A healthy repo emits a finding only when someone adds a
new content tree and forgets to point a source at it — which is the day
you want to hear about it, not six months later.

## What it fires on today, against the real configs

Run over fresh checkouts (CopilotKit `ce10479`, ag-ui `e606ce3`, aimock
`42eaaa2`, pathfinder `8c4c289`). Nine clusters before tuning; seven are
content nobody meant to index and are now recorded as exempt in
`deploy/`:

| Repo | Tree | Verdict |
|---|---|---|
| CopilotKit | `content/snippets/` (98 `.mdx`) | MDX partials inlined at
render time — exempt |
| CopilotKit | `examples/` (26 `.mdx`) | example-app READMEs — exempt |
| CopilotKit | `showcase/integrations/` (80 `.mdx`) | per-integration
setup notes — exempt |
| ag-ui | `apps/` (371 `.ts`, 25 `.mdx`) | the dojo demo app — exempt |
| ag-ui | `sdks/dotnet/` (30 `.ts`) | cross-language Vitest harness —
exempt |
| aimock | `scripts/` (13 `.ts`) | repo maintenance tooling — exempt |

**The two left unexempted are real, and the first one is the same bug
again:**

- **`showcase/shell-docs/src/content/ag-ui/` — 96 `.mdx`.** Served live
at `docs.copilotkit.ai/ag-ui/<slug>` by `src/app/ag-ui/[[...slug]]`, and
**all 96 are in the production sitemap** (`curl -s
https://docs.copilotkit.ai/sitemap.xml | grep -c /ag-ui/` → `96`; two
spot-checked URLs return 200). No source indexes one of them. This is
`content/reference/` again, sitting in the same directory, still open —
found by the check on its first real run. Deliberately not exempted:
whether to index them here or leave them to `docs.ag-ui.com` (which
indexes a different repo) is a call for the docs owner, and until it is
made the audit should keep saying so.
- **ag-ui `middlewares/` — 34 `.ts`.** Six published `@ag-ui/*` packages
(`a2a-middleware`, `mcp-middleware`, …) whose source `search-ag-ui-code`
cannot reach.

No re-tuning was needed: nothing in the first run was build output, a
test fixture storm, or `node_modules`. Scan cost is 1.3s for CopilotKit,
under 300ms for the rest, once per reindex, off the request path.

## Red–green

**RED** — the test builds a real temp-dir repo with a claimed
`content/docs/` tree and an unclaimed sibling `content/reference/` of
the same file type, and runs the real `walkSourceFiles` over it. Against
unmodified code it fails by finding **nothing**, not by erroring:

```
 FAIL  src/__tests__/reindex-audit-unclaimed.test.ts > reports the reference tree no source's walk root can reach
AssertionError: expected [] to have a length of 1 but got +0
 FAIL  src/__tests__/reindex-audit-unclaimed.test.ts > reports a deliberately-excluded directory until it is opted out
AssertionError: expected [] to deeply equal [ …(2) ]
- [ "showcase/shell-docs/src/content/reference", "showcase/shell-docs/src/content/snippets" ]
+ []

 Test Files  1 failed (1)
      Tests  2 failed | 3 passed (5)
```

**GREEN** — same test unchanged: the finding names
`showcase/shell-docs/src/content/reference`, extension `.mdx`, count 35,
with sample paths. `Tests 5 passed (5)`.

**Negatives, each mutation-proven** — every noise control was deleted in
turn and the suite went red:

| Mutation | Result |
|---|---|
| `unclaimed_exempt_paths` ignored | 4 failed — the opt-out is
load-bearing |
| `exclude_patterns` no longer account | 4 failed —
`packages/core/tests` (40 `.ts`) fires |
| `MIN_UNCLAIMED_CLUSTER_FILES` 10 → 1 | 5 failed — `content/misc` (3
`.mdx`) fires |
| dot-directory skip removed | 4 failed — `.venv` (60 `.py`) fires |
| `build` removed from generated dirs | 4 failed — `build/lib` (60
`.py`) fires |
| same-extension-under-parent rule removed | 1 failed — `sdks/` fires |

Two of the negatives were *vacuous on the first attempt* and the
mutation run is what caught it: the generated-output fixture was sitting
under a claim glob (so it was classified `claimed`, not skipped), and a
later revision routed through `site-packages`, which a different rule
was silencing. Both fixtures were rebuilt until the intended rule was
the only thing keeping them quiet.

## Gate

`npm run build` ✅ · `npx tsc --noEmit` ✅ · `npm test` → **3717 passed, 1
skipped, 0 failed** ✅ · `node scripts/check-test-shapes.mjs` ✅ · `npx
prettier --check` on every touched file ✅. No pre-existing failures to
control for.

`reindex-audit.test.ts` and `reindex-audit-shortfall.test.ts` now spread
`importOriginal()` into their `../indexing/utils.js` mock: the new check
matches patterns through that module, and a module-shaped hole there
would have failed silently.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant