Skip to content

Rewrite search-by-meaning around vec0; enforce the distcheck platform mirror - #1009

Merged
thekevinscott merged 7 commits into
mainfrom
claude/issues-1003-1004-kfn8ug
Aug 21, 2026
Merged

thekevinscott merged 7 commits into
mainfrom
claude/issues-1003-1004-kfn8ug

Conversation

@thekevinbot

@thekevinbot thekevinbot commented Aug 21, 2026 •

Copy link
Copy Markdown
Collaborator

Two independent changes, on one branch because the session was scoped to both issues.

Closes #1003
Closes #1004


1. Docs: the two semantic-search shapes, and two fixes to the vec0 template (#1003)

#1010 merged while this branch was open, landing #960's templates on a new page, docs/howto/search-indexes.md, which frames search-by-meaning.md as the no-config guide. Git merges the two cleanly — neither touched the other's file — but the result would have taught the same vec0 recipe twice with different details. So this follows the page that landed rather than competing with it: the index recipe lives once, on search-indexes.md, and search-by-meaning.md keeps the path-table shape and links out.

What #1003 asked for survives that split:

  • The trade is stated explicitly, as a table up front — setup, freshness, per-query cost, top-k form — plus the caveat that an index only pays off when the table outlives the query (--persist, or dirsql server): a one-shot dirsql query against an ephemeral index rebuilds the table, and therefore re-embeds the corpus, before it answers, which is strictly more work than the subquery.
  • The ::: tip Top-k is LIMIT k block is retired. It existed to explain why MATCH … AND k = did not apply; that is now a row in the trade table and one clause in the walk-through.
  • The model-id / config-hash consequence (Docs: rewrite search-by-meaning around a vec0 index instead of per-row embed() #1003 item 5) is on search-indexes.md, where the ddl batch is, and linked from the trade section.

Two fixes to the template search-indexes.md ships

Both found by running it, not by reading it:

  • distance_metric=cosine was missing. vec0 defaults to L2, which is not the metric the rest of dirsql's semantic search uses — vec_distance_cosine() backs both the plugin's one-liner and search-by-meaning. The two agree only for vectors of equal length, so documents embedding to different magnitudes rank differently. Demonstrated against real sqlite-vec with three notes whose vectors are [1,1], [5,5], [2,1] and the query [1,1]:

    default (L2):            a.md 0.0     c.md 1.0     b.md 5.657
    distance_metric=cosine:  a.md 2.2e-16 b.md 2.2e-16 c.md 0.0513
    

    b and c swap. (My first pass claimed this with a unit-vector fixture, where the two metrics cannot diverge — that evidence didn't support the claim, so this is the run that does.)

  • The insert trigger needed WHEN new.body IS NOT NULL. embed(NULL) is NULL, and vec0 rejects a NULL vector outright, so one row with missing text fails the whole table load rather than its own insert:

    dirsql query: failed to load config: SQLite error: Inserted vector for the "embedding"
    column is invalid: Input must have type BLOB (compact format) or TEXT (JSON), found NULL
    

    With the guard the row still lands in notes; only its vector is skipped. FTS5 needs no equivalent — it indexes a NULL happily.

Also worth noting for anyone reading #960: its sketch's embed(:q, '') would fail — the worker treats a two-arg call's second argument as a real model id (StaticModel.from_pretrained('')). The page that landed already spells the id out in both places, which is right.

Manual verification

HuggingFace is blocked by the sandbox proxy (CONNECT → 403), so the model itself could not be downloaded. Everything dirsql does was exercised for real against a locally built CLI (cargo build -p dirsql --features cli) and real sqlite-vec 0.1.9 from PyPI, with a stub embed() worker speaking the genuine [[dirsql.function]] worker protocol. Only model inference was substituted; function dispatch, trigger-time invocation, JSON-text vector binding, vec0 KNN, and the persist sweep are all the real code paths.

The corrected [[table]] block was extracted verbatim from search-indexes.md (only float[512] → float[2] for the stub's width) and run:

$ dirsql query "SELECT n.slug, v.distance FROM notes_vec AS v JOIN notes AS n ON n.rowid = v.rowid
                WHERE v.embedding MATCH embed('pasta cook', '…') AND k = 3 ORDER BY v.distance"
[{"distance":2.220446049250313e-16,"slug":"a"},{"distance":2.220446049250313e-16,"slug":"b"},{"distance":0.051316700875759125,"slug":"c"}]

$ dirsql query "SELECT (SELECT COUNT(*) FROM notes) AS notes, (SELECT COUNT(*) FROM notes_vec) AS vecs"
[{"notes":4,"vecs":3}]     # the empty file's NULL body is skipped, not fatal

Also confirmed earlier in the session: delete propagation across a --persist restart ([{"notes":2,"vecs":2}] after removing a file — the AFTER DELETE trigger fires through the incremental sweep), and that a wrong float[N] fails with Dimension mismatch … Expected 7 dimensions but received 8. pnpm build in docs/ exits clean (VitePress fails on broken links).

One thing I could not verify: the real vector width of minishlab/potion-retrieval-32M. HF is unreachable from the sandbox and neither the repo nor the model2vec README records it. search-indexes.md already handles this correctly — it makes the probe a required step and treats 512 as a placeholder — so nothing here depends on the number being right, but it is worth a maintainer eye.


2. Checks: enforce the platforms.py ↔ platforms.ts mirror (#1004)

#1004 is a discussion, not a work item, and asked which framing wins before anything is deleted. The maintainer chose the subset framing, enforced now — so exe goes and the mechanism behind it gets a gate, rather than tidying the symptom and leaving the drift possible.

Its claims check out: Platform.exe is read only by its own three test assertions, and git log --all -S'exe' -- packages/ts/src/platforms.ts is empty — the TypeScript side has never carried the field, so the comment justifying it has been false since it was written.

The check

dirsql-checks platforms-mirror reads both tables as source text — the Python one through ast, the TypeScript one stripped of comments and handed to json's own decoder. Neither is imported: internals/checks depends on neither internals/distcheck nor a node toolchain, and a check that executes the file it audits can be fooled by the file. A table it cannot parse fails rather than passing on an empty read, because a mirror check that silently stops comparing passes forever.

decide.py's SHARED is the invariant as data — every Python field and the TypeScript property it is sourced from (slug ← libName minus the @dirsql/lib- prefix, which is what librarySlug() returns). From that one table it derives four failures:

a Python field outside SHARED drift by construction — the exe case
a published target with no Python row a half-added target; the message prints the Platform(...) line to paste
a Python row that is not a published target distcheck resolving a sub-package the release never publishes
a shared field whose values disagree names the field and both values

triple and libc staying TypeScript-only is the subset working as intended — the framing the Python docstring already claimed and the PLATFORMS comment contradicted. That comment now states the subset and names the check enforcing it.

Wired as its own workflow (platforms-mirror-check.yml) rather than a job in internals-distcheck-ci.yml: that lane's path filter does not watch packages/ts, which is exactly where the half-added-target case starts.

Red/green

The removal of exe is exempt (AGENTS.md), but the gate is new behavior:

  • RED — d617620 pushed the integration + e2e tests alone. Run 32441254650 failed on exactly one job, internals/checks integration tests, with ModuleNotFoundError: No module named 'checks.platforms_mirror'. Every other job passed.
  • GREEN — c9a387f added the check and deleted exe.
  • Mutation follow-up — 6a12c1e. See below.

The mutation round

CI found 33 surviving mutants that the local gate had not, because the gate scopes to the diff and the new modules were still untracked when it ran — it mutated nothing in them. (Lesson: run that gate after committing.)

Most were unkillable by any assertion rather than merely untested, so the fix was structural, not an exemption:

  • Comparing a parsed identifier against a module-level string literal admits an == → is mutant that CPython's interning makes genuinely equivalent. Those name comparisons are now dict and set lookups: classes and module bindings are collected by name and looked up, and a row's callee is tested for membership in ROW_CALLS.
  • _assigned_names drops the len(targets) == 1 guard and its targets[0], whose [-1] mutant was equivalent under that very guard — and it now reads A = PLATFORMS = (...) correctly as a side effect.
  • The hand-rolled TypeScript scanner is gone, taking seventeen survivors in its index arithmetic and bounds checks with it. Comments and string literals are one regex alternation, and json's raw_decode finds the end of the array, so nothing counts brackets.
  • typescript_value keys its per-field derivation off a DERIVE table instead of comparing the field name, and gate.run drops the keyword-only marker *, — cosmic-ray mutates it to the positional-only /, which the interpreter accepts and no caller can observe.

New assertions cover the rest: the positional-arity guard from both sides, a field whose value sorts above platforms.ts as well as below, a class and a table bound under names sorting either side of the real ones, and the string unwrapping pinned to an exact value. 224 mutants, no survivors locally with the files committed (versus 140 in the run that missed them). Zero exemptions — the repo's count stays at zero.

Manual verification

$ uv run --project internals/checks dirsql-checks platforms-mirror
ok platforms-mirror: internals/distcheck/…/platforms.py mirrors the shared fields of
packages/ts/src/platforms.ts across 5 published target(s).                    # exit 0

Re-adding exe: bool:

::error::Platform.exe has no counterpart in packages/ts/src/platforms.ts. platforms.py holds a
deliberate subset of the published target (node_platform, node_arch, slug, os, cpu), so a field
the mirror cannot source is drift: delete it, or add the property to the TypeScript `Platform`
interface and to SHARED here.                                                 # exit 1

Simulating a half-added win32-arm64 target in platforms.ts only:

::error::win32-arm64 is published by platforms.ts but has no row in platforms.py. Add
Platform('win32', 'arm64', 'win32-arm64-msvc', ['win32'], ['arm64']) to PLATFORMS in
internals/distcheck/src/distcheck/node_flow/platforms.py, or the node distcheck flow cannot
resolve the new target on that host.                                          # exit 1

Both scratch edits were reverted.

Gates run locally

dirsql-checks preflight --conventions internals-checks-ci.yml --conventions internals-distcheck-ci.yml — 0 failing pairs across both roots: declared-deps, colocated-test, unit-lint, integration-lint, unit-coverage (100%), e2e-verify; mutation re-run separately post-commit at 224 mutants, no survivors. Full suites: 461 passed (internals/checks), 65 passed / 2 skipped (internals/distcheck).


E2E Verification

  • Ran e2e suites locally for every affected SDK
  • Python SDK e2e: N/A — no packages/python change
  • TypeScript SDK e2e: N/A — no packages/ts change
  • Rust core e2e (if applicable): N/A — no packages/rust change
  • packages/python/e2e-attestations/<branch>.json — N/A, packages/python untouched
  • packages/ts/e2e-attestations/<branch>.json — N/A, packages/ts untouched
  • Command(s) run: uvx testing-conventions e2e attest 'uv run python -m pytest tests/e2e -x -q' in internals/checks (its own e2e-verify gate is scoped to internals/checks/src, which this PR changes)
  • Result summary: 23 passed; receipt committed at internals/checks/e2e-attestations/claude-issues-1003-1004-kfn8ug.json (abdcae0). The docs half is N/A - docs only.

Changelog / Migrations

  • Changelog fragment — not required: nothing under packages/ or plugins/ changed. docs/, internals/checks/, internals/distcheck/ and .github/workflows/ are all outside changed_packages.
  • Migration fragment — not required: Platform.exe is repo-only, never published, and nothing outside its own tests read it, so no public API or runtime behavior changes.

PARITY.md is untouched: no SDK public API changed, and no parity drift is introduced or restored.

claude added 4 commits August 21, 2026 02:37
The guide taught semantic search only as a path-table subquery, which costs
one embed round trip per matched file per query on top of a walk that reads
every file's content. Since #956 the ddl batch can declare a vec0 virtual
table beside the row table and fill it from a trigger, so a query embeds the
question once and sqlite-vec scans in process.

The index shape is now the primary recipe: model-width probe, the [[table]]
config, the MATCH/k query joined back on rowid, what the two triggers
guarantee, and what a model-id edit costs. The path-table shape stays as the
zero-setup option with the trade stated explicitly, and the "Top-k is LIMIT k"
tip is folded into it -- it existed to explain why MATCH ... AND k = did not
apply, which stops being true once the primary example uses vec0.

Two corrections to the template sketched in #960, both found by running it:
vec0 defaults to L2, so the declaration needs distance_metric=cosine to agree
with vec_distance_cosine; and embed(NULL) is NULL, which vec0 rejects outright
and which fails the whole table load, so the insert trigger needs a
WHEN new.text IS NOT NULL guard.

Closes #1003
`internals/distcheck`'s `Platform` dataclass is a hand-maintained subset of
`packages/ts/src/platforms.ts`, asserted by a comment and checked by nothing.
It drifted both ways: `exe` sits on the Python side justified as mirroring the
TypeScript source of truth, which has never carried the field, while `triple`
and `libc` exist only on the TypeScript side. The same unchecked-mirror
mechanism is behind #972 and #996.

These tests specify a `dirsql-checks platforms-mirror` gate that holds the two
files to the subset framing: the shared fields agree row for row, every
published target has a row on both sides, and a Python field with no
TypeScript counterpart is drift. The last integration case runs it over the
repo's own two files, which is where it earns its keep.

Red: `checks.platforms_mirror` does not exist and the CLI has no such
subcommand.

Refs #1004
`dirsql-checks platforms-mirror` reads both platform tables as source text --
the Python one through `ast`, the TypeScript one through a comment-stripping
scan into `json` -- and holds them to the subset framing the Python docstring
already claimed: `SHARED` names every Python field and the TypeScript property
it comes from, both tables must carry the same targets, and a Python field
outside `SHARED` is drift by construction. `triple` and `libc` staying
TypeScript-only is the subset working, not a gap.

Neither side is imported. internals/checks depends on neither
internals/distcheck nor a node toolchain, and a check that executes the file it
audits can be fooled by the file. A table it cannot parse fails the check
rather than passing on an empty read -- a mirror check that silently stops
comparing passes forever.

With that in place `Platform.exe` goes: nothing outside its own tests read it,
and the comment keeping it alive cited a correspondence packages/ts has never
had. The "Keep in lockstep" comment it hid behind is replaced by one that
states the subset and names the check enforcing it.

Runs in its own workflow rather than as a job in internals-distcheck-ci.yml:
that lane's path filter does not watch packages/ts, which is exactly where a
half-added target starts.

Closes #1004
@thekevinbot thekevinbot changed the title docs(howto): lead search-by-meaning with a vec0 index Rewrite search-by-meaning around vec0; enforce the distcheck platform mirror Aug 21, 2026
claude added 3 commits August 21, 2026 03:24
CI found 33 surviving mutants the local run had not: the gate scopes to the
diff, and at the time it ran the new modules were still untracked, so it
mutated nothing in them. Run it after committing, not before.

Most survivors were unkillable by any assertion rather than merely untested.
Comparing a parsed identifier against a module-level string literal admits an
`==` -> `is` mutant that CPython's interning makes genuinely equivalent, so the
name comparisons become dict and set lookups instead: classes and module
bindings are collected by name and looked up, and a row's callee is tested for
membership in ROW_CALLS. `_assigned_names` also drops the `len(targets) == 1`
guard and its `targets[0]`, whose `[-1]` mutant was equivalent under that very
guard -- and it now reads `A = PLATFORMS = (...)` correctly as a side effect.

The hand-rolled TypeScript scanner is gone, and with it seventeen survivors in
its index arithmetic and bounds checks. Comments and string literals are now
one regex alternation, and `json`'s own `raw_decode` finds the end of the
array, so nothing counts brackets. The three bespoke "unterminated X" errors
collapse into one message that names every shape it cannot read.

`decide.typescript_value` keys its per-field derivation off a DERIVE table
rather than comparing the field name, and `gate.run` drops the keyword-only
marker, which cosmic-ray mutates to the positional-only one -- accepted by the
interpreter and unobservable to every caller.

New assertions cover what remains: the positional-arity guard from both sides,
a field whose value sorts above platforms.ts as well as below, a class and a
table bound under names sorting either side of the real ones, and the string
unwrapping pinned to an exact value.

Refs #1004
…plate

#1010 landed while this branch was open and put the vec0 template on its own
page, search-indexes.md, which frames search-by-meaning.md as the no-config
guide. Git merges the two cleanly because neither touched the other's file,
but the result would have taught the same recipe twice, with different
details. So the division of labour follows the page that landed: the index
recipe lives once, on search-indexes.md, and search-by-meaning.md keeps the
path-table shape and links out.

What #1003 asked for survives that split. The two shapes and their trade are
stated up front as a table -- setup, freshness, per-query cost, top-k form --
along with the caveat that an index only pays off when the table outlives the
query, since a one-shot run against an ephemeral index re-embeds the corpus
before answering. The "Top-k is LIMIT k" tip is retired: it existed to explain
why MATCH ... AND k = did not apply, which is now a row in that table and one
clause in the walk-through.

Two fixes to the template on search-indexes.md, both found by running it:

vec0 defaults to L2, which is not the metric the rest of dirsql's semantic
search uses -- vec_distance_cosine() backs both the plugin one-liner and
search-by-meaning. The two agree only for vectors of equal length, so
documents that embed to different magnitudes rank differently: over three
notes, L2 returned a, c, b where cosine returned a, b, c. The declaration now
carries distance_metric=cosine.

embed(NULL) is NULL and vec0 rejects a NULL vector, so one row with missing
text failed the whole table load rather than its own insert. The insert
trigger now carries WHEN new.body IS NOT NULL; the row still lands, only its
vector is skipped.

Closes #1003
@thekevinscott
thekevinscott merged commit a94d6c1 into main Aug 21, 2026
18 checks passed
@thekevinscott
thekevinscott deleted the claude/issues-1003-1004-kfn8ug branch August 21, 2026 15:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants