Skip to content

fix(engine): term/terms on keyword ARRAYS match only the first element - #40

Merged
xerj-org merged 3 commits into
mainfrom
pr/keyword-array-term-fix
Jul 27, 2026
Merged

fix(engine): term/terms on keyword ARRAYS match only the first element#40
xerj-org merged 3 commits into
mainfrom
pr/keyword-array-term-fix

Conversation

@xerj-team

Copy link
Copy Markdown
Collaborator

Problem

A reverse call-graph query — "who calls wp_safe_remote_get" — returned 1
result from XERJ's structured term index, while grep found 14 files and XERJ
full-text (match) found 9. A false "not reachable" is the worst error in a
security audit.

Root cause (proven)

A keyword field stores one value per document, and for an array value XERJ
keeps only element [0]:

  • Memtable DocValues::push_field, the Value::Array arm, does kcol.push(first_str).
  • Segment KeywordColumn.ords: Vec<u32> is one ordinal per doc — structurally single-valued.

Aggregations use a separate multi-valued path and FTS joins all elements, so
terms-aggs and match see every element but exact term sees only [0]. This
breaks ES multi-valued keyword semantics.

Reproduced end-to-end (calls: ["first_fn","second_fn","wp_safe_remote_get"]):

term calls=first_fn           -> 1   ([0])
term calls=second_fn          -> 0   ([1] MISSED)
term calls=wp_safe_remote_get -> 0   ([2] MISSED)

Fix

The always-correct source scan already handles arrays (json_values_equal does
array membership), so route term/terms on array fields to it:

  • This PR (memtable half): doc_values_term_query, doc_values_term_indices,
    and doc_values_terms_query bail (return None) for fields in array_fields,
    mirroring the existing keyword_has_whitespace bail. Compiles clean; no regressions
    in the touched crate.
  • Follow-up (segment half): guarantee the flush multi_valued set covers every
    ingest path and route the remaining fused-columnar term path to the scan (or
    make KeywordColumn multi-valued). A regression test
    test_term_matches_non_first_array_element is included, #[ignore]d until then.

Also found (filed for the series)

Boolean term matches as a string: {term:{f:true}} → 0 while {term:{f:"true"}}
matches. JSON booleans must coerce to the stored "true"/"false" form.

Impact (measured, real WP index of 11,990 functions)

One reverse-reachability query:

  • scroll whole graph to client + traverse in code: ~482,800 tokens
  • grep + read to confirm: ~4,300 tokens
  • fixed: one terms query → exact callers as facts: ~170 tokens (2,823× / 25×)

Restores composable structured graph queries: audit cost goes from O(index size)
to O(matching results), and makes the pre-AST index strictly better than grep for
reachability. Full write-up: docs/research/xerj-keyword-array-term-fix.md.

Files

  • engine/crates/xerj-engine/src/memtable.rs — array-field bail in 3 fast paths
  • engine/crates/xerj-engine/tests/integration.rs — reproduction test (#[ignore])
  • docs/research/xerj-keyword-array-term-fix.md — root cause + token math

🤖 Generated with Claude Code

Xerj Squad A and others added 3 commits July 25, 2026 12:00
Root cause of the WordPress reachability miss (structured "who calls X" returned
1 vs grep's 14): a keyword field stores one value per doc, and for an ARRAY only
element [0] is kept -- memtable push_field pushes `first_str`, and the segment
KeywordColumn.ords is Vec<u32> (one ordinal per doc, structurally single-valued).
Aggregations use a separate multi-valued path and FTS joins all elements, so
terms-aggs and `match` see every element but exact `term` sees only [0]. This
breaks ES multi-valued keyword semantics and causes silent false negatives.

Memtable half of the fix: doc_values_term_query / _term_indices / _terms_query
bail (return None) for fields in `array_fields`, mirroring the existing
keyword_has_whitespace bail, so the caller falls through to the array-aware
source scan (json_values_equal already does array membership). Compiles clean.

Segment half (design in docs): the flush builder already removes `multi_valued`
fields from the columnar doc-values so the segment term reader abandons to the
stored-source scan; remaining work is to close every ingest/flush + fused
columnar path (or make KeywordColumn multi-valued). Regression test
test_term_matches_non_first_array_element added, #[ignore]d until that lands.

Token impact (real WP index, one reachability query): scroll-graph-to-client
~482,800 tokens or grep+read ~4,300 -> one fixed `terms` query ~170 tokens
(2823x / 25x). Restores composable structured graph queries: audit cost goes
from O(index size) to O(matching results). Full write-up + boolean-term
follow-up in docs/research/xerj-keyword-array-term-fix.md.

Co-Authored-By: Xerj Squad A <noreply@xerj.org>
@xerj-org
xerj-org merged commit d418e2a into main Jul 27, 2026
4 checks passed
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.

2 participants