Metadata discovery - #951
Draft
aaronccasanova wants to merge 7 commits into
Draft
aaronccasanova wants to merge 7 commits into
aaronccasanova wants to merge 7 commits into
Conversation
A filter condition is a predicate over one field of the record under evaluation: `field` names it, `operator` says how to compare, `value` is the operand. For a document, the fields are its metadata keys, and that special case is what the property was named after. Rename the property from `key` to `field` so the grammar describes itself without reference to what it happens to be applied to. Nothing else in the AST changes: `operator`, `value`, `operands`, and `operand` keep their names, and the parser rejects the old property the same way it rejects any unknown one, with the JSON path of the failing node. The rename lands before metadata filtering (tobi#910) ships, so no released version accepts `key`. Updates the README filter table and semantics, the skill, the MCP `query` tool description, the CLI help and error examples, the changelog entry, and every test that builds a condition. Assisted-by: Claude Fable 5.1 via Pi
aaronccasanova
force-pushed
the
feature/metadata-discovery-match
branch
5 times, most recently
from
September 17, 2026 00:44
0cc9856 to
a6b579b
Compare
Extend the filter AST with three text operators, a type test, and an optional case-folding flag, so a filter can reach values by substring and reach one side of a key whose documents disagree on type. - `contains`, `prefix`, and `suffix` take a non-empty string operand and match string values only. `contains` compiles to `instr()`. `prefix` and `suffix` compare UTF-8 bytes with the operand's byte length bound from JavaScript, because SQLite's text `length()` and `substr()` stop at an embedded NUL and the stored string domain admits one. - `type` takes `string`, `number`, or `boolean` and matches the stored type of a key's values. - `caseInsensitive: true` is accepted on any condition whose operand is a string or an array of strings. Both sides fold ASCII letters (SQLite's `lower()` and the same range in JavaScript), so the two agree exactly. Non-ASCII letters compare exactly. Each compiled condition now names how its correlated subquery reaches a document's rows. The covering indexes are (key, value, document_id), so an exact equality (`eq`, `in`, `all`) is one probe by value. Every other condition (ordered comparisons, `ne` and `nin` presence, `exists`, `type`, the text operators, and folded equalities, since `lower()` is opaque to the index) seeks the document's own rows through the primary key: a unary `+` on the key term hides it from the planner, which otherwise walks the key's whole value range once per document when `sqlite_stat1` is absent. On 4,000 documents that plan made one `priority > 10` count 376 ms and a `prefix` count 946 ms; both are under 5 ms with the seek, on Node and Bun, with or without statistics. A test asserts the plan for every operator in both states. Validation reports the failing JSON path as before. The README filter table, the MCP `query` tool description, the skill, and the changelog entry name the new operators. Bind vector candidate IDs as one JSON list during document lookup. A valid near-ceiling filter otherwise overflows Node's SQL variable limit when combined with candidate IDs, in both exact scans and the global fallback. A model-free regression crosses the 20,000-vector boundary and verifies that nonmatching documents sharing a content hash remain excluded. The plan guard accepts SQLite 3.43's USING INDEX label for the same point seek, retaining the check that rejects broad per-document key scans. Assisted-by: Claude Fable 5.1 via Pi
Report keys, typed values, document coverage, collection provenance, and numeric ranges over the same extraction gate as filtered search. `filter` selects documents and `match` selects entries. Both windows run in SQL with exact totals and remainders, including past-end pages. Discovery compiles filters to uncorrelated document sets. Search retains candidate-local seeks. Entry prefix/suffix comparisons return false, not NULL, for empty strings so negation partitions the accepted value domain. Read the report in one deferred transaction. Materialize key ranking once in the type-statistics statement and reuse its selected names thereafter. Read contributing collections with those statistics. Group value counts once for both the distinct total and the value window, retaining one total carrier per type even past the end. Preserve BINARY key and value ordering. Compute even medians without overflow or loss of adjacent subnormals. Validate safe-integer options and cast the production window operands to INTEGER before adding. Check the widest planned statement against the 30,000-parameter application budget before preparing discovery queries. Provide a lean key overview and a batched per-collection overview. Ordinal zero counts each document/key once regardless of array length, without reading values or maintaining another index or cache. Tests cover aggregation, gates, filters, matches, ordering, both windows, independent boolean expectations, live-writer snapshots, lifecycle changes, actual endpoint SQL, binding limits, and structural query-work guards with and without statistics. The independent 14,276-case predicate oracle and 120 aggregate comparisons pass on both drivers after these changes. Sort contributing collection names by UTF-8 bytes after reading their aggregate. This preserves SQLite BINARY order without requiring SQLite 3.44's aggregate ORDER BY syntax. The real discovery query and the filter, discovery, and vector suites also pass on SQLite 3.43.2. Assisted-by: Claude Fable 5.1 via Pi
Adds the CLI surface for metadata discovery so an agent can learn what to filter on before it queries: - `qmd collection metadata [name...]` prints one block per key: a header with type, coverage, and distinct count, then a value window. Strings list vertically with right-aligned counts, numbers show min/median/max plus the values when they fit, booleans print true/false counts. Keys whose documents disagree on type split per type, with contributing collections in the multi-collection view. - `--filter <json>` counts only matching documents. The output then opens with a `filter:` line stating how many documents pass, and every key's coverage is measured against that population, so the numbers an agent reads are the numbers a filtered search would see. - `--match <json>` selects which metadata entries are reported, with the same AST as `--filter` evaluated against each entry (a condition's `field` is the entry's `key` or `value`). Both JSON flags share one parser and report malformed JSON and invalid nodes the same way. - Two windows, named the same on every surface: `--key-limit <n>` (default 50) with `--key-offset <n>` and `--all-keys`, and `--value-limit <n>` (default 10) with `--value-offset <n>` and `--all-values`. Every windowed list ends with the exact remainder and the flags that reach it, and an offset past the last key says how many keys there are. `-n` and `--all` are the single-window search flags and exit with a pointer at the discovery ones. `--sort count|value` orders values, `--min-count` drops the tail. Omitting the name covers the default collections, as an unscoped search does. - src/metadata-format.ts holds the renderer so the MCP tool can print the same shape. It takes the CLI's color palette or none. The empty states render there too, so the filter line survives a filter whose documents declare no metadata. A string value prints bare only when the bare form is unambiguous. One that is empty, padded, contains a quote, a backslash, a control character, a line separator, or a delimiter of the compact `value (count), ...` list a type split prints, or that reads as a JSON number, boolean, or null, or as the list's remainder tail, prints as a JSON string with every control character escaped. One rule for both layouts, so a value never prints two ways, and `"a (1), b" (1)` cannot be read as two values. Ordinary values are unchanged, so the common case costs nothing. - A filter and match that together exceed the SQL binding budget exit with the store's message. - The pending-extraction warning prints on stderr whenever documents are gated out, since discovery always applies the gate. The renderer suite reads the compact list back the way a reader must, honoring quotes, and checks every item round-trips. Covers the keys view, both windows with paging and their footers, the filter line and denominator (including over an empty result), reverse lookup, a composed match across both fields, type selection, sort and min-count, default scope, empty matches, and exit codes for unknown collections and invalid flags, filters, and matches. The renderer has its own suite for string identity (round trip through JSON.parse, no raw control characters) and the empty states. Numeric flags reject unsafe integers as usage errors before opening the index, preserving the original input in the message. Unsupported format flags fail explicitly with a pointer to the structured surfaces, rather than silently returning text to a caller expecting JSON. Assisted-by: Claude Fable 5.1 via Pi
Makes metadata visible from the commands a user or agent already runs first, so the drill-down is discoverable without knowing it exists: - `qmd collection list` adds a `Metadata:` line per collection naming the top five keys by coverage and counting the rest (`+N more`). Omitted when the collection has no metadata, like `Ignore:`. - `qmd collection show` adds a `Metadata` section: a coverage line (keys, documents with metadata, pending extraction), then the top five keys as aligned rows with type, coverage, distinct count, and a short value preview (top three strings, numeric range and median, boolean counts, or a pointer when types disagree), then a pointer at `qmd collection metadata <name>` when keys were left out. - `qmd status` adds one summary line under Documents, placed with the existing pending-extraction line so the two read together. - Every view asks the store for exactly what it prints. `show` requests a five-key, three-value window and reads the total and remainder from the result, `list` reads the first five key names and counts the rest, and `status` counts keys without listing them, so none of them grows with the vocabulary. - src/metadata-store.ts gains the light queries these views need: `countDocumentsWithMetadata()` and an optional collection scope on `countDocumentsPendingMetadata()`. The pending-extraction warning that `collection metadata` and the filtered search commands print takes that scope, so it counts the collections the command reads and not the whole index. `status` keeps the index-wide count. Collection list batches all key overviews in one query, including the vocabulary totals, instead of counting and ranking separately for each collection. CLI status counts metadata-bearing documents with existence probes and counts keys from ordinal-zero rows, avoiding array expansion. Type-conflict drill-down hints escape apostrophes in the shell-quoted JSON operand. A POSIX shell round-trip regression ensures metadata keys remain literal data, including command-substitution syntax. Assisted-by: Claude Fable 5.1 via Pi
Ships discovery on the remaining surfaces with the same options object and result shape the CLI uses: - SDK: `listMetadata(options?)` on QMDStore under Collection Management, next to listCollections(). `match` and `filter` are validated at the runtime boundary like the search methods, and the window options by the store, so a caller sees MetadataFilterError, MetadataOptionError, or MetadataBindingBudgetError with the same message every surface prints. Discovery types, MetadataMatch, the error classes, the default limits, the binding budget, and parseMetadataMatch() are exported from the package root, and MetadataValueType now lives in metadata.ts beside the scalar types it describes. - MCP `metadata` tool: flat and read-only, taking `collections`, `match`, `filter` (both validated like the query tool's filter), `keyLimit`, `keyOffset`, `valueLimit`, `valueOffset`, `sort`, and `minCount`. The description teaches the one grammar over two record types (a document for `filter`, a metadata entry for `match`), a table of matches and the question each answers, how to read `totalKeys`, `remainingKeys`, `remainingValues`, and `range`, and how to page. Text content renders the CLI shape through the shared formatter, empty states included so the filter line survives them, naming the option that reaches each remainder; structuredContent is the ListMetadataResult. The schema's integers are safe integers, and a filter and match over the binding budget return a tool error. - MCP `status`: CollectionInfo gains `metadataKeyCount` and a windowed `metadataKeys` (the ten most covered keys with types), mirrored in StatusResult and listed in the text summary with `+N more` and a pointer at the metadata tool, so the call agents make first reveals that metadata exists without growing with the vocabulary. - HTTP `POST /metadata`: same body as the tool. 400 on a non-object body, a non-object or invalid match or filter, a bad sort, a non-array `collections`, a non-number window option, a number outside its domain, or a filter and match over the binding budget (the store's message, verbatim). Returns the ListMetadataResult as its own envelope. Logged like POST /query. Updates the exact tool-list assertions in test/mcp.test.ts and covers each surface in test/metadata-surfaces.test.ts, including paging, the 400 paths, invalid matches and options, unsafe integers, the binding budget on all three surfaces, the filter line over an empty tool result, and status structured content. Full status batches all collection metadata overviews in one query. An internal getStatusSummary path returns only the index facts initialization uses, without pretending uncomputed metadata counts are zero. MCP server creation uses that path, including each stateless HTTP request. The public SDK getStatus result and MCP status tool keep their metadata summaries. Regression tests exercise empty-string negation through SDK, HTTP, and MCP. A real MCP tools/list request succeeds with the metadata value table hidden in the isolated fixture, proving initialization does not read it. Restoring the eager status call makes that test fail. Assisted-by: Claude Fable 5.1 via Pi
- README: a "Metadata Discovery" subsection after "Metadata Filtering" with the mental model (same metadata, different unit: filter narrows documents, match narrows the metadata itself, one predicate grammar over both), a table of matches and the question each answers, a wide-to-narrow walkthrough ending in a validated query, exact CLI output for string, number, boolean, threshold, filtered, and type-conflict keys, the two windows, how they page, and what they do and do not bound, the reading rules (including when a string prints quoted and that a result is one snapshot), and the SDK method with its predicate, option, and error types, MCP tool, and HTTP route. Also the new subcommand in the collection commands block, the `metadata` tool parameters, and the `POST /metadata` endpoint. - CHANGELOG: one entry under Unreleased / Added. - skills/qmd/SKILL.md: a "Discover metadata before filtering" section written for an agent deciding what to filter on: the match shapes worth knowing, how to read the header and the filter line, the window footers and how to page past them, numeric ranges, and type splits. - CLAUDE.md: `qmd collection metadata` in the command table and the Collection Management block. Examples use the status, topics, priority, and reviewed keys the filtering docs already establish, so the two sections read as one. Qualify the windows as bounds on key/value lists, not query work or collection provenance. Document the current-data cost of status summaries and their exclusion from MCP initialization. Name the filter target field consistently and explain explicit rejection of unsupported CLI formats. Assisted-by: Claude Fable 5.1 via Pi
aaronccasanova
force-pushed
the
feature/metadata-discovery-match
branch
from
September 17, 2026 04:44
a6b579b to
f32848b
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #910, on top of #956. That PR let documents declare typed metadata and every search surface filter on it. This one closes the loop: every surface can now report the metadata keys, types, and value counts already in the index, so a filter can be written from what is indexed instead of guessed.
Every value shown is one a filter will match, and the counts say how many documents each condition reaches:
Narrowing the report uses the filter language itself.
--matchtakes the same AST as--filter, evaluated against each metadata entry, so an agent that has learned one has learned the other:The same discovery is available on the SDK (
listMetadata()), the MCP server (ametadatatool, plus key names instatus), and HTTP (POST /metadata), with the same options and the same result shape. It reads the tables #910 created, so there is no re-indexing, no schema change, and no new dependency.The motivating use case is the same one that motivated filtering: agentic retrieval. A filter is only useful if the caller knows which keys exist and what values they hold, and an agent working against an index it did not build has no way to learn that today except guessing or reading files one by one. Discovery gives it a first call that reveals the dimensions and a second call that validates a filter before it ever runs a search.
What this adds
contains,prefix, andsuffixfor substring matching over string values,typeto match the stored type of a key's values, and an optionalcaseInsensitiveflag on any condition whose value is a string.qmd collection metadata [name...], a drill-down with--filter <json>to count only documents matching a filter,--match <json>to select which metadata entries are reported (same AST, evaluated per entry), two windows that page (--key-limit/--key-offset/--all-keysover keys,--value-limit/--value-offset/--all-valuesover the values of each key and type), and--sort count|value,--min-count <n>to order and trim values.qmd collection listnames each collection's top keys,qmd collection showdetails them with a value preview, andqmd statussummarizes coverage.listMetadata(options)on the SDK, ametadatatool on the MCP server,POST /metadataon the HTTP server, and per-collection key names and types in the MCPstatustool so an agent's first call already reveals that metadata exists.listMetadata()insrc/metadata-store.ts) and one renderer (src/metadata-format.ts) behind every surface, so the CLI and the MCP tool print the same text and every structured surface returns the same object.What this deliberately does not change
document_metadata_valuesand its existing covering indexes.--format jsonon collection commands (deferred in Add metadata support #910, still deferred). Structured output lives on the SDK, MCP, and HTTP.qmd.metadatakeep their existing indexing and unfiltered-search behavior.The mental model
The filter AST is a predicate grammar. A condition tests one
fieldof the record under evaluation againstvalueusingoperator, and nothing in that grammar is specific to documents. Discovery applies it to two kinds of record:fieldnames--filterstatus,topics,priority, ...)--matchkey,valueSame metadata, different unit.
--filternarrows documents by their metadata.--matchnarrows the metadata itself. Every operator applies to both, includingtype, the text operators,caseInsensitive, andand/or/not. The only conditions a match rejects areexistsandall, which have no meaning for a single entry.--match{"field":"key","operator":"eq","value":"topics"}{"field":"key","operator":"prefix","value":"mem-"}{"field":"key","operator":"in","value":["tags","topics","labels"]}{"field":"value","operator":"eq","value":"docs-team"}{"field":"value","operator":"prefix","value":"2025-"}{"field":"value","operator":"type","value":"boolean"}andofkey eq priorityandvalue gte 3andofkey eq priorityandvalue type numberBecause the result is per key and per type, a composed match returns a mixture in one call: several keys, several value regions, or a condition joined across the two fields.
Adding
--filterto any row shows what remains after narrowing. The output opens with how many documents pass, and every coverage count is measured against that population, which is the number you want before committing to that filter in a query:A reverse lookup returns every key that holds a value, here a scalar key and an array key:
qmd collection metadata notes --match '{"field":"value","operator":"eq","value":"docs-team"}'Reading the output
Rules observable in tests:
topics: [a, b]contributes one to each. Coverage is "documents declaring this key", out of the documents the filter admits when there is one. Thefilter:line prints even when the filtered documents declare no metadata."42"beside a number42,"a (1), b"in a compact list). Bare strings still need quotes in a JSON operand; quoted ones are the operand.totalKeys/remainingKeysanddistinctValues/remainingValues, never left for the caller to infer.gt/ltthreshold in one call.qmd collection metadata work --match '{"field":"key","operator":"eq","value":"priority"}'Across collections, each type also names where it comes from:
qmd collection metadata --match '{"field":"key","operator":"eq","value":"priority"}'Adding
{"field":"value","operator":"type","value":"number"}to the match reports the numeric side alone, and{"field":"priority","operator":"type","value":"number"}is the filter that reaches exactly those documents.Design notes
The output shapes came out of a survey of how Elasticsearch terms aggregations, Weaviate aggregate queries, and Postgres
pg_statsreport cardinality, truncation, and distributions. The retrieval side reuses #910's machinery rather than adding a parallel path. Details collapsed below.One options object and one result on every surface
The result carries its own denominator so the "N of M documents" header can be computed on every surface without a second call, and so the HTTP route can return the object as its own envelope, identical to the MCP
structuredContent. Scope resolution is the caller's job, mirroring search: the CLI resolves omitted names to the default collections, MCP and HTTP fall back to their existing default collection list, and the SDK passes what it was given.The option names are the same on every surface (
--key-limiton the CLI iskeyLimiton the SDK, MCP, and HTTP), so an agent that learns one learns them all. Every option is validated at the store boundary and a value outside its domain throwsMetadataOptionErrornaming the option (Invalid keyLimit: expected a positive integer or Infinity, received 0). The CLI and MCP reject bad values up front with their own messages, HTTP returns the store's message as a400, and the SDK throws it, so no surface silently clamps.minCountscopes onlyvalues,distinctValues, andremainingValues. Per-typedocuments,multiValued,range, andcollectionsdescribe every matching value so coverage stays honest when the long tail is hidden. Median is computed over value rows (each element of anumber[]counts), following Weaviate's aggregate semantics.One grammar, two record types
src/metadata-filter.tskeeps one validator and one grammar.MetadataPredicate<Condition>is the recursive shape (a condition, orand/or/notover predicates), and the two public types instantiate it with the conditions each record admits:MetadataFilter = MetadataPredicate<MetadataCondition>over a document (anyfield, every operator) andMetadataMatch = MetadataPredicate<MetadataEntryCondition>over an entry (fieldis"key" | "value", withoutexistsandall, which speak about the set of values a document holds). So a document-only condition in a match is a TypeScript error for SDK users and a runtime error for everyone else, and the two cannot drift because they are one type. A vitest typecheck file (test/metadata-filter.test-d.ts) pins the shape.parseMetadataFilter()andparseMetadataMatch()share every rule and limit and differ in a record-type parameter: for an entry, a condition'sfieldmust bekeyorvalue, andexistsandallare rejected with a message that says why. Errors name the failing JSON path in both, prefixedInvalid metadata filter atorInvalid metadata match at.Compilation differs by target.
compileMetadataFilter()uses candidate-localEXISTSprobes for search and uncorrelated document-ID sets for discovery.compileMetadataMatch()emits a predicate over onedocument_metadata_valuesrow: thevaluefield resolves to the typed columns guarded byvalue_type, and thekeyfield resolves to thekeycolumn with the type fixed to'string', so a number or boolean operand against it fails its guard and matches nothing, the same outcome a type mismatch has in a filter. Both compilers share the operand-to-column logic, with independent boolean evaluation testing their typed semantics.Every operator has an exact SQL form and nothing is re-checked in JavaScript.
containsusesinstr().prefixandsuffixcompare UTF-8 bytes (substr(CAST(col AS BLOB), ...)) with the operand's byte length bound from JavaScript, because SQLite's textlength()andsubstr()stop at an embedded NUL and the stored string domain admits one. UTF-8 is self-synchronizing, so a byte-prefix of a whole operand is exactly a character-prefix.COALESCE(comparison, 0)makes an empty BLOB substring a false comparison, not SQL NULL, so negation includes the empty string when it should.caseInsensitivefolds ASCII on both sides:lower()on the column and the sameA-Zrange on the operand, so the two agree exactly and non-ASCII letters compare exactly. The match predicate lives in the JOIN of the aggregation queries, so a high-cardinality key never round-trips its values, and the matched key set for the array-ness pass binds as one JSON parameter throughjson_each.Same gate, same scope, same compiler
Discovery builds one eligible-documents CTE with exactly the predicate filtered search uses (
active = 1, currentextraction_version, noextraction_error), the optional collection scope, and the optional filter from the samecompileMetadataFilter()search calls. Every aggregate joinsdocument_metadata_valuesto that CTE. This is why the PR can make the guarantee that every value discovery reports is matchable withequnder the same collections: a test asserts it by runningsearchFTSwith aneqfilter on each reported value. Documents still pending extraction are counted in the denominator, excluded from the aggregates, and reported on stderr the way filtered search already reports them, scoped to the collections the command reads.Status views use dedicated queries.
collection listand SDK/MCP status batch the collection overviews, including vocabulary totals, in one query over ordinal-zero entries (one row per document/key). CLI status uses key counts and document-existence probes.collection showrequests five keys and three values throughlistMetadata(). MCP initialization reads only the index facts its instructions use, without computing metadata summaries. The public status result still carries each collection's ten most covered keys andmetadataKeyCount.Performance
Discovery reuses key selection and value-count aggregation within each report. Collection overviews are batched, and MCP initialization does not compute unused metadata summaries. The filter compiler also avoids repeated broad range scans in candidate filtering. These changes need no new index, cache, or re-indexing.
The windows bound key/value lists, not query work or contributing collection lists. Exact grouping, medians, ordering, and filter evaluation remain corpus-dependent.
Query work, measurements, and trade-offs
Filter access. Discovery compiles each document condition to an uncorrelated ID set. Candidate filtering keeps covering equality probes and guides range, presence, type, text, and folded-equality conditions toward document-local rows. Plan tests cover both statistics states. A corpus-scope set may read outside the final collection scope, so this is not a guarantee of work proportional to the returned region.
Shared report work. The selected-key ranking is materialized within the type-statistics statement, avoiding the small-window coroutine plan. The report then reuses those key names rather than rebuilding the ranking in each aggregate. Collection provenance is read with type statistics, then sorted by UTF-8 bytes without requiring SQLite 3.44's aggregate ordering syntax. Value counts, distinct totals, and pagination share one grouped query. A total-carrier row preserves exact counts past the end of a value window.
Status and initialization. One grouped overview query computes all collections' key coverage, types, and totals from ordinal-zero entries. MCP initialization uses a separate internal index-summary path, so every stateless HTTP request no longer pays for metadata summaries it discards. The explicit status tool still computes and returns current metadata summaries.
Author measurements on macOS arm64: 60,000 documents, 522,404 metadata rows, 47 keys, six collections, with arrays and type conflicts. Same seeded fixture and commands for
0cc9856andf32848b. Each number is a median of three calls after a warmup, withoutANALYZE. These are workload measurements, not capacity or complexity guarantees.Bun 1.3.6 uses SQLite 3.51.2. Node 24.15.0 uses better-sqlite3's SQLite 3.53.4. Process-startup costs are excluded above and included in these separate Bun CLI measurements:
The deliberate trade-off is fresh, exact metadata summaries without maintained aggregate state. An explicit SDK status call still costs about 0.5-0.7 seconds on this fixture, compared with roughly 74 ms for main's status without metadata summaries. Initialization avoids that additional work. A covering-index experiment gave a modest overview improvement, while a canonical-JSON implementation was slower, so neither was adopted.
Sorting and temporary B-trees remain, and a long read snapshot can retain WAL history. There is no claim of universal linear scaling, constant memory, or cost independent of the index. Broader workload tuning remains useful follow-up work.
Exact under concurrency and at the edges of the number line
min 1, median 1, max 1), not themin 1, median 100, max 1it produced before.(lo + hi) / 2when the sum is finite andlo / 2 + hi / 2in the overflow region, so it is finite and correctly rounded across the double range, adjacent subnormals included. SQL'sAVGsums first and returnedInfinityfor1e308and1.5e308.1e30passesNumber.isIntegerand then failsLIMIT ?with a datatype mismatch). Both drivers can bind safe integers asREAL, so the production value-window predicate casts before summing asINTEGER. The regression executes the emitted predicate and its bindings atNumber.MAX_SAFE_INTEGER + 2, where JavaScript addition would round.METADATA_SQL_BINDING_BUDGET(30,000, under SQLite's default 32,766) before discovery queries are prepared. The calculation covers key selection, where the match binds twice, and value aggregation, including the selected-key parameter and window operands.MetadataBindingBudgetErrorsurfaces as a CLI exit, an MCP tool error, and an HTTP 400.value (count), ...list, or reading as a JSON literal or the list's remainder tail, it prints as a JSON string with every control escaped (DEL, C1, U+2028/9 included, whichJSON.stringifyleaves literal). One rule for both layouts, so"a (1), b" (1)is one value and never two, and ordinary values cost nothing.Testing
132 added tests across seven suites. Runtime tests run under Node and Bun, with five additional compile-time assertions through Vitest.
Coverage and verification
test/metadata-filter.test.ts, +12): the new operators andcaseInsensitivethrough validation and SQL semantics (type mismatch never matches, array any-element semantics, whole-character prefix and suffix, the whole value past an embedded NUL including self-prefix and self-suffix, ASCII-only folding), every semantic case in both filter scopes, the plan of every operator with and without statistics, the corpus scope's SQL and plan, and the match parser and compiler.test/metadata-filter.test-d.ts, 5): compile-time assertions, run by vitest's typecheck pass, thatMetadataMatchcomposes like a filter, admits onlykeyandvalue, rejectsexistsandall, and thatMetadataFilterstill admits every document condition.test/metadata-search.test.ts, +1): a model-free, near-ceiling filter across the 20,000-vector exact/fallback boundary, including exclusion of a nonmatching path sharing the content hash.test/metadata-discovery.test.ts, 66): counting, the extraction gate, scope and filter narrowing, the match over both fields (text, membership, typed comparison, type selection, negation, cross-fieldor, case folding, validation), both windows with offsets, exact remainders past the end, the window applied to matched keys, pages as prefixes of the full result under one collation on both readers, the materialized key window and document-set filter at the five- and ten-key sizes with and without statistics, snapshot isolation against a writer that commits between the range and the median, option validation including unsafe integers and the largest safe ones, the actual emitted window predicate and bindings evaluated past 2^53, the binding budget (each predicate alone under it, together over it), sorts,minCount, ranges and medians across the double range, type splits within and across collections, bounded key overviews through lifecycle changes, batched status and initialization work guards, independent empty-string/composition expectations, the discovery/filtering agreement test, and UTF-8 collection provenance without SQLite 3.44-only syntax.test/metadata-cli.test.ts, 24): every flag through a spawnedqmd, byte-exact rendering of each key type, the type split, the filter line with its denominator including over a result with no metadata, paging both windows, a composed match, type selection, thelist/show/statuslines, the pending-extraction warning scoped to the collections read, and exit codes and messages for invalid-input cases, including unsafe integers, unsupported formats, and the search flags-nand--all.test/metadata-format.test.ts, 8): string identity (every ambiguous form quoted and round-tripping throughJSON.parse, plain strings bare, the compact list read back honoring quotes), no raw control character in the output, numbers and booleans bare, the empty states under a filter, and a POSIX shell round trip proving apostrophes and substitution syntax in a key remain literal data in a drill-down hint.test/metadata-surfaces.test.ts, 16): the SDK method with windows, option errors, unsafe integers, and the binding budget,getStatus()keys and counts, the MCPmetadatatool (paging, the filter line over an empty result, the budget as a tool error) andstatustool, andPOST /metadataincluding the documented400paths. Empty-string negation is exercised through SDK, HTTP, and MCP. A real MCP tools/list request succeeds while metadata value-table reads are unavailable in its isolated fixture.The empty-string, production endpoint, repeated key-ranking, MCP initialization, and shell-hint regressions each fail without their correction. An author rerun of the auditor's independent evaluator passes 14,276 predicate comparisons and 120 complete aggregate comparisons per runtime, without the experimental SQL workaround. Full suite: typecheck, lint, 1378 Node tests, 1373 Bun tests, and the package smoke all pass. The 105 filter, discovery, and vector tests also pass under SQLite 3.43.2 through Bun's custom-library path. The new vector regression fails before the candidate-ID correction, and the real discovery query fails on SQLite 3.43 before the ordering correction.
The final author close-out also rechecks exact 29,999/30,000/30,001 binding boundaries, live-writer interleavings, nested transactions and cleanup, independently expected lifecycle overviews, and an SDK workflow from empty metadata through annotation, discovery, filtering, and extraction-error recovery. These author checks are not represented as an independent review of the final head.
Trade-offs and future work
rangeis the place to grow it compatibly.prefixmatch on2025-finds them and--sort valueorders them, but nothing ranges them as dates. That follows Add metadata support #910's first-version restriction and can be relaxed there first.caseInsensitivefolds ASCII letters. Full Unicode folding needs a folded column or an ICU build of SQLite, either of which is a storage change.multiValuedmeans "some document holds more than one value". A key whose every array has one element is indistinguishable from a scalar key at the storage level and reports as scalar.Related work
field#956: names the condition's targetfield. This PR is based on it, and the two-record framing above is why that name is the right one.queryandsearchinherit. It also prevents vector candidate IDs from exhausting the remaining SQL bindings when a large metadata filter is applied.