Skip to content

Metadata discovery - #951

Draft
aaronccasanova wants to merge 7 commits into
tobi:mainfrom
aaronccasanova:feature/metadata-discovery-match
Draft

aaronccasanova wants to merge 7 commits into
tobi:mainfrom
aaronccasanova:feature/metadata-discovery-match

Conversation

@aaronccasanova

@aaronccasanova aaronccasanova commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

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.

qmd collection metadata notes
status  string  480 of 480 documents  3 distinct
  published  312
  draft      141
  archived    27

topics  string[]  388 of 480 documents  1,204 distinct
  typescript    140
  sqlite         92
  search         77
  architecture   61
  ...
1,194 more values, use --value-limit <n>, --value-offset <n>, or --all-values

priority  number  205 of 480 documents  5 distinct
  min 1  median 3  max 5
  1 (12)  2 (40)  3 (88)  4 (50)  5 (15)

Every value shown is one a filter will match, and the counts say how many documents each condition reaches:

qmd query "dependency injection" -c notes --filter '{
  "operator": "and",
  "operands": [
    { "field": "status", "operator": "eq", "value": "published" },
    { "field": "topics", "operator": "all", "value": ["typescript"] },
    { "field": "priority", "operator": "gte", "value": 3 }
  ]
}'

Narrowing the report uses the filter language itself. --match takes the same AST as --filter, evaluated against each metadata entry, so an agent that has learned one has learned the other:

qmd collection metadata notes --match '{
  "operator": "and",
  "operands": [
    { "field": "key", "operator": "eq", "value": "priority" },
    { "field": "value", "operator": "gte", "value": 3 }
  ]
}'
priority  number  153 of 480 documents  3 distinct
  min 3  median 3  max 5
  3 (88)  4 (50)  5 (15)

The same discovery is available on the SDK (listMetadata()), the MCP server (a metadata tool, plus key names in status), 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

  • Text and type operators on every surface that accepts a filter: contains, prefix, and suffix for substring matching over string values, type to match the stored type of a key's values, and an optional caseInsensitive flag 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-keys over keys, --value-limit/--value-offset/--all-values over the values of each key and type), and --sort count|value, --min-count <n> to order and trim values.
  • Metadata at a glance in the places people already look: qmd collection list names each collection's top keys, qmd collection show details them with a value preview, and qmd status summarizes coverage.
  • listMetadata(options) on the SDK, a metadata tool on the MCP server, POST /metadata on the HTTP server, and per-collection key names and types in the MCP status tool so an agent's first call already reveals that metadata exists.
  • One store function (listMetadata() in src/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

  • Nothing is indexed and nothing is migrated. Discovery is a read over document_metadata_values and its existing covering indexes.
  • Existing filter semantics are preserved. Discovery uses the same grammar and eligibility gate, with independent tests comparing both compiler scopes and entry matches against their expected results.
  • No second language. There is one grammar, applied to two kinds of record.
  • No --format json on collection commands (deferred in Add metadata support #910, still deferred). Structured output lives on the SDK, MCP, and HTTP.
  • Metadata remains opt-in. Documents without qmd.metadata keep their existing indexing and unfiltered-search behavior.

The mental model

The filter AST is a predicate grammar. A condition tests one field of the record under evaluation against value using operator, and nothing in that grammar is specific to documents. Discovery applies it to two kinds of record:

Flag Record evaluated Its fields A condition's field names
--filter A document Its metadata keys (status, topics, priority, ...) One of those keys
--match A metadata entry key, value One of those two

Same metadata, different unit. --filter narrows documents by their metadata. --match narrows the metadata itself. Every operator applies to both, including type, the text operators, caseInsensitive, and and/or/not. The only conditions a match rejects are exists and all, which have no meaning for a single entry.

--match Question answered
Which keys exist, with a window of values each
{"field":"key","operator":"eq","value":"topics"} Everything about one key
{"field":"key","operator":"prefix","value":"mem-"} A family of keys
{"field":"key","operator":"in","value":["tags","topics","labels"]} Which of these key names exist
{"field":"value","operator":"eq","value":"docs-team"} Which keys hold this value
{"field":"value","operator":"prefix","value":"2025-"} Which keys hold values shaped like this
{"field":"value","operator":"type","value":"boolean"} Which keys hold booleans
and of key eq priority and value gte 3 Values of one key above a threshold
and of key eq priority and value type number The numeric side of a key whose documents disagree on type

Because 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 --filter to 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:

qmd collection metadata notes \
  --match '{"field":"key","operator":"eq","value":"topics"}' \
  --filter '{"field":"status","operator":"eq","value":"published"}' \
  --value-limit 5
filter: 312 of 480 documents

topics  string[]  260 of 312 documents  811 distinct
  typescript    104
  sqlite         70
  search         58
  architecture   40
  mcp            31
806 more values, use --value-limit <n>, --value-offset <n>, or --all-values

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"}'
owner  string  212 of 480 documents  1 distinct
  docs-team  212

reviewers  string[]  97 of 480 documents  1 distinct
  docs-team  97

Reading the output

Rules observable in tests:

  • Counts are documents, not values. A document with topics: [a, b] contributes one to each. Coverage is "documents declaring this key", out of the documents the filter admits when there is one. The filter: line prints even when the filtered documents declare no metadata.
  • A bare string is exactly the value. A string prints as-is only when nothing else could be read from it, and otherwise as an escaped JSON string ("42" beside a number 42, "a (1), b" in a compact list). Bare strings still need quotes in a JSON operand; quoted ones are the operand.
  • Truncation is never silent, and nothing is unreachable. Both the key list and every value list are windowed in SQL with a default (50 keys, 10 values), and both page with an offset. Every windowed list ends with the exact remainder and the flags that reach it. Structured results carry totalKeys/remainingKeys and distinctValues/remainingValues, never left for the caller to infer.
  • Numbers report min, median, and max, plus the enumerated values when they fit, which is enough to write a sound gt/lt threshold in one call.
  • Type conflicts are reported, not resolved. Metadata is validated one document at a time, and nothing requires two documents to agree on a key's type, whether they sit in the same collection or in different ones. A key that holds numbers in some files and labels in others splits by type, each with its own document count, so you can see how much of the corpus a typed filter would reach:
qmd collection metadata work --match '{"field":"key","operator":"eq","value":"priority"}'
priority  number | string  1,222 of 1,620 documents
  number     18 docs  min 1  median 2  max 3
  string  1,204 docs  high (700), medium (380), low (124)

Across collections, each type also names where it comes from:

qmd collection metadata --match '{"field":"key","operator":"eq","value":"priority"}'
priority  number | string  1,427 of 2,100 documents
  number    223 docs  min 1  median 3  max 5               notes, work
  string  1,204 docs  high (700), medium (380), low (124)  work

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_stats report 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
interface ListMetadataOptions {
  collection?: string | string[];   // CLI positional, MCP/HTTP `collections`
  match?: MetadataMatch;            // same grammar, evaluated per metadata entry
  filter?: MetadataFilter;          // same AST as search
  keyLimit?: number;                // keys reported, default 50; Infinity removes the window
  keyOffset?: number;               // keys skipped, in report order
  valueLimit?: number;              // values per key and type, default 10; Infinity removes the window
  valueOffset?: number;             // values skipped per key and type, in `sort` order
  sort?: "count" | "value";
  minCount?: number;
}

interface ListMetadataResult {
  documents: number;                // active documents in scope
  filteredDocuments?: number;       // present only when a filter was given; then the coverage denominator
  totalKeys: number;                // keys with a matching entry, before the window
  keys: MetadataKeySummary[];       // the window, coverage order
  remainingKeys: number;            // keys after the window, exact
}

interface MetadataKeySummary {
  key: string;
  documents: number;
  types: MetadataKeyTypeSummary[];  // length > 1 is a type conflict
}

interface MetadataKeyTypeSummary {
  type: "string" | "number" | "boolean";
  multiValued: boolean;             // some document holds this key as an array
  documents: number;
  distinctValues: number;
  values: { value: string | number | boolean; documents: number }[];
  remainingValues: number;          // distinct values after the window, exact
  range?: { min: number; median: number; max: number };
  collections: string[];
}

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-limit on the CLI is keyLimit on 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 throws MetadataOptionError naming 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 a 400, and the SDK throws it, so no surface silently clamps.

minCount scopes only values, distinctValues, and remainingValues. Per-type documents, multiValued, range, and collections describe every matching value so coverage stays honest when the long tail is hidden. Median is computed over value rows (each element of a number[] counts), following Weaviate's aggregate semantics.

One grammar, two record types

src/metadata-filter.ts keeps one validator and one grammar. MetadataPredicate<Condition> is the recursive shape (a condition, or and/or/not over predicates), and the two public types instantiate it with the conditions each record admits: MetadataFilter = MetadataPredicate<MetadataCondition> over a document (any field, every operator) and MetadataMatch = MetadataPredicate<MetadataEntryCondition> over an entry (field is "key" | "value", without exists and all, 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() and parseMetadataMatch() share every rule and limit and differ in a record-type parameter: for an entry, a condition's field must be key or value, and exists and all are rejected with a message that says why. Errors name the failing JSON path in both, prefixed Invalid metadata filter at or Invalid metadata match at.

Compilation differs by target. compileMetadataFilter() uses candidate-local EXISTS probes for search and uncorrelated document-ID sets for discovery. compileMetadataMatch() emits a predicate over one document_metadata_values row: the value field resolves to the typed columns guarded by value_type, and the key field resolves to the key column 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. contains uses instr(). prefix and suffix compare UTF-8 bytes (substr(CAST(col AS BLOB), ...)) 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. 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. caseInsensitive folds ASCII on both sides: lower() on the column and the same A-Z range 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 through json_each.

Same gate, same scope, same compiler

Discovery builds one eligible-documents CTE with exactly the predicate filtered search uses (active = 1, current extraction_version, no extraction_error), the optional collection scope, and the optional filter from the same compileMetadataFilter() search calls. Every aggregate joins document_metadata_values to that CTE. This is why the PR can make the guarantee that every value discovery reports is matchable with eq under the same collections: a test asserts it by running searchFTS with an eq filter 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 list and 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 show requests five keys and three values through listMetadata(). 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 and metadataKeyCount.

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 0cc9856 and f32848b. Each number is a median of three calls after a warmup, without ANALYZE. These are workload measurements, not capacity or complexity guarantees.

Store call Bun before Bun after Node before Node after
Default discovery 5,767 ms 2,729 ms 5,394 ms 2,609 ms
Collection show shape (5 keys, 3 values) 1,064 ms 473 ms 770 ms 407 ms
One-key discovery 1,532 ms 730 ms 1,334 ms 669 ms
Range-filtered discovery 3,580 ms 2,260 ms 2,769 ms 1,988 ms

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:

Public path Before After
CLI status 452 ms 375 ms
CLI collection list 1,204 ms 606 ms
CLI collection show 1,085 ms 614 ms
MCP tools/list, including per-request initialization 1,403 ms 108 ms
MCP status, including per-request initialization 2,861 ms 666 ms

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
  • One snapshot. The whole report runs inside one deferred read transaction. Under WAL another connection can commit while the report reads, and its counts still come from one state. A test commits a second connection's write between the range statement and the median statement and checks the result is the original distribution (min 1, median 1, max 1), not the min 1, median 100, max 1 it produced before.
  • Medians. The even-count midpoint is taken in JavaScript, (lo + hi) / 2 when the sum is finite and lo / 2 + hi / 2 in the overflow region, so it is finite and correctly rounded across the double range, adjacent subnormals included. SQL's AVG sums first and returned Infinity for 1e308 and 1.5e308.
  • Window integers. Options accept safe integers only (1e30 passes Number.isInteger and then fails LIMIT ? with a datatype mismatch). Both drivers can bind safe integers as REAL, so the production value-window predicate casts before summing as INTEGER. The regression executes the emitted predicate and its bindings at Number.MAX_SAFE_INTEGER + 2, where JavaScript addition would round.
  • Binding budget. The widest planned statement is checked against 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. MetadataBindingBudgetError surfaces as a CLI exit, an MCP tool error, and an HTTP 400.
  • Vector lookup bindings. Exact scans and the capped global fallback bind candidate IDs as one JSON list during document lookup. Candidate count no longer consumes the headroom a parser-valid metadata filter needs under Node's variable limit. The filter is still applied to each document, including nonmatching paths that share content with a matching one.
  • Strings print as themselves. A string prints bare only when nothing else could be read from it. Empty, padded, containing a quote, backslash, control character, line separator, or a delimiter of the compact 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, which JSON.stringify leaves 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
  • Filter language (test/metadata-filter.test.ts, +12): the new operators and caseInsensitive through 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.
  • Predicate types (test/metadata-filter.test-d.ts, 5): compile-time assertions, run by vitest's typecheck pass, that MetadataMatch composes like a filter, admits only key and value, rejects exists and all, and that MetadataFilter still admits every document condition.
  • Search (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.
  • Store (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-field or, 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.
  • CLI (test/metadata-cli.test.ts, 24): every flag through a spawned qmd, 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, the list/show/status lines, 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 -n and --all.
  • Renderer (test/metadata-format.test.ts, 8): string identity (every ambiguous form quoted and round-tripping through JSON.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.
  • Surfaces (test/metadata-surfaces.test.ts, 16): the SDK method with windows, option errors, unsafe integers, and the binding budget, getStatus() keys and counts, the MCP metadata tool (paging, the filter line over an empty result, the budget as a tool error) and status tool, and POST /metadata including the documented 400 paths. 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

  • Numbers report min, median, max, and an enumeration when it fits. There is no histogram or percentile output yet. If a corpus needs one, range is the place to grow it compatibly.
  • Dates are strings, so a prefix match on 2025- finds them and --sort value orders them, but nothing ranges them as dates. That follows Add metadata support #910's first-version restriction and can be relaxed there first.
  • caseInsensitive folds ASCII letters. Full Unicode folding needs a folded column or an ICU build of SQLite, either of which is a storage change.
  • There is no regular-expression operator. The text operators cover substring, prefix, and suffix. If a need appears, the operator would be named for intent with one declared flavor, in both record types.
  • multiValued means "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.
  • Collection commands print text and reject unsupported format flags. Structured results are available from the SDK, MCP, and HTTP.

Related work

  • Name the filter condition's target field #956: names the condition's target field. This PR is based on it, and the two-record framing above is why that name is the right one.
  • Add metadata support #910: metadata and metadata filtering. This PR reads the tables and reuses the filter compiler and eligibility gate it introduced. It extends the filter grammar, keeps every existing semantic, and changes how the compiler's conditions reach rows (see Performance), which query and search inherit. It also prevents vector candidate IDs from exhausting the remaining SQL bindings when a large metadata filter is applied.

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
aaronccasanova force-pushed the feature/metadata-discovery-match branch 5 times, most recently from 0cc9856 to a6b579b Compare September 17, 2026 00:44
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
aaronccasanova force-pushed the feature/metadata-discovery-match branch from a6b579b to f32848b Compare September 17, 2026 04:44
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