Brainy Brainy
Docs Brainy

Filter Operator Conformance

In this section

brainy is a drop-in accelerator, so the law for find() is same answers, only faster. This page is the per-operator accounting of that law: what brainy does with every operator in Brainy's filter vocabulary, and why.

There are exactly three verdicts, and no fourth. An operator is served (brainy returns what pure-JS Brainy returns), served beyond the baseline (brainy returns the correct rows where pure-JS Brainy returns an empty result), or refused by name (brainy throws, naming the operator and the field). What never happens is a quiet wrong answer.

The table

Operator

Verdict

eq, equals, is

served

ne, notEquals, isNot

served

in, oneOf

served

gt, greaterThan

served

gte, greaterThanOrEqual, greaterEqual

served

lt, lessThan

served

lte, lessThanOrEqual, lessEqual

served

between

served

contains

served

exists, missing

served

allOf, anyOf, not (combinators)

served

hasAll

served beyond the baseline

noneOf

served beyond the baseline

excludes

served beyond the baseline

startsWith

refused by name

endsWith

refused by name

length

refused by name

matches

refused by name

Aliases are not a footnote here. An alias that silently went unhandled would answer empty on its own and drop its clause beside another operator — the same failure as an unhandled operator — so every alias spelling is pinned to return exactly its canonical twin's rows.

Why four operators are refused

startsWith, endsWith, length and matches are in Brainy's vocabulary, and BOTH engines refuse all four by name — Brainy always has; Open Brainy joined at 10.4.4, upgrading what used to be a silent empty answer to the same named refusal. This is a proof, not a shortfall.

The metadata index does not store your values; it stores normalized posting keys. Strings are lowercased and trimmed, arrays are posted one element at a time, numbers are canonicalized, and anything longer than 100 UTF-16 units is hashed. That normalization is what makes the index small and fast, and it erases exactly the distinctions these operators read:

These two rows…

…collapse to one posting key because of

…but need opposite answers from

'Alice' and 'alice'

lowercasing

startsWith: 'A'

' bob' and 'bob'

trimming

startsWith: ' '

['ax'] and 'ax'

one posting per array element

startsWith: 'ax'

42 and '42'

canonical numbers

startsWith: '4'

['a','b'] and ['a','b','a']

one posting per array element

length: 2

Brainy's own matcher requires typeof value === 'string' for startsWith and endsWith, and Array.isArray(value) for length — so each pair above genuinely needs two different verdicts. No function of a single posting key can return two different answers, which makes serving these from the index impossible rather than merely unimplemented.

Two escape routes were measured and closed:

  • Answer a superset and let the host narrow. find() does not re-apply the value matcher to what the metadata index returns — the index's answer for a clause is the final answer — so a superset ships as an under-constrained result set. Measured on the baseline engine, pinned in the suite.

  • Read the raw values instead. The column store keeps raw strings, but it flattens arrays exactly as the index does and infers one type per field, so it cannot separate ['ax'] from 'ax' or recover an array's length either.

matches carries a second, independent reason: JavaScript RegExp and Rust's regex are different dialects (backreferences, lookaround, Unicode class spellings), so even with the raw value in hand a native evaluation would diverge on exactly the patterns that distinguish them.

What to write instead

The durable fix is to make the predicate's answer an indexed value at write time, then filter on it with a served operator:

Instead of

Write

Then filter

title: { startsWith: 'Ali' }

a titlePrefix3 field

{ titlePrefix3: 'ali' }

title: { endsWith: '.pdf' }

an extension field

{ extension: 'pdf' }

tags: { length: 3 }

a tagCount number field

{ tagCount: 3 }

sku: { matches: '^AB-' }

a skuPrefix field

{ skuPrefix: 'ab' }

Otherwise, narrow with a served operator and apply the predicate in your own code over the returned rows — the honest version of what a superset would have done, with the row budget visible to you.

The posting-set trio — parity since Open Brainy 10.4.4

hasAll, noneOf and excludes are posting-list expressions — an intersection, a universe-minus-union, and a negation of contains respectively — and both engines serve all three identically under the set law. (Through Open Brainy 10.4.3 the reference engine's index path answered them with a silent empty result; this section used to document that divergence and promised to change when the upstream cure landed. It landed in 10.4.4, the pins on the old behavior redded exactly as designed, and parity is now the pinned contract.)

The set law — how the three answer at their edges

The index posts a field's value as a set of elements: a scalar is a singleton, an array is its elements, a missing field is the empty set. The set operators are served with exactly those semantics, and the edges are pinned in stringOperatorConformance.test.ts:

clause

set law

Brainy's value-matcher (never runs on find())

excludes: v, field missing

matches — the empty set lacks everything

no match (it presupposes an array)

excludes: v, scalar field = v

no match — a scalar is a singleton set

no match, but for a different reason (non-array never matches)

noneOf: [v], array containing v

no match — the sets intersect

matches (a literal includes an array value never equals)

hasAll: [v], scalar field = v

matches[v] ⊆ {v}

no match (requires Array.isArray(value))

hasAll: []

matches every row, missing fields included

matches array-valued fields only

hasAll/noneOf with a non-array operand

refused by name — never match-everything

type mismatch, no match

The matcher column is the reference engine's value-level matcher frozen at 10.3.1 — it never runs on the find() path, so no shipped behavior disagrees with the set law today: since Open Brainy 10.4.4, find() on both engines answers these edges by the set law.

The two laws are a jointly frozen contract (agreed with Brainy 2026-08-19, VENUE-ENGINE-CONFORMANCE-GAPS), and they split by call site, never by pipeline stage: find() follows the set law end-to-end — index evaluation and find()'s own egress re-check — on both engines, so a row the index correctly serves under the set law is never silently stripped downstream. The value-level matcher keeps the matcher law for its direct callers only. Open Brainy's find() gained exactly that set-law serving in 10.4.4 — the index evaluator cure this paragraph used to anticipate — and the parity is pinned here.

One anticipated asymmetry never materialized, and that is worth recording: an earlier draft expected the reference engine's cure to serve raw startsWith/endsWith from its own registry while Brainy kept refusing. Open Brainy 10.4.4 chose the refusal law instead — both engines refuse the four by name, because a loud refusal beats a quietly wrong answer on either engine. Code that needs prefix queries writes a slug field (see "What to write instead").

Known divergence: negation on a string field with case or padding

brainy's metadata index normalizes a value before posting it — lowercased and trimmed — and Brainy's does not. For positive operators that difference is invisible: brainy answers a superset and Brainy narrows it with an exact post-filter, so { title: 'Alice' } matches two rows inside brainy's index and returns exactly the one row whose title really is 'Alice'.

A negation gets no such repair. brainy computes "everything except this value's posting bucket", and that bucket holds every case and whitespace variant of the operand, so rows that merely look like the operand are dropped — and no post-filter can add a row back.

Measured, on a corpus holding both 'Alice' and 'alice':

await brain.find({ where: { title: { ne: 'alice' } } })
// pure-JS Brainy: includes the row titled 'Alice'
// brainy:            does notJS

noneOf and excludes inherit the same shape, and are affected more, because Brainy does not post-filter those at all.

Scope. Only string fields whose values differ from the operand solely by letter case or surrounding whitespace. Numeric fields, boolean fields, and string fields written in a consistent case (slugs, enums, ids — the usual shape for a field you filter on) are unaffected, and their parity is pinned. If your negation filters on free-text values, normalize case at write time until this is closed.

This is filed, and stringOperatorConformance pins the divergence's exact extent so that closing it reds the gate deliberately rather than silently.

The not combinator

not is not evaluated inside brainy's posting-list dispatch. Brainy applies the negation itself, downstream of whatever the metadata-index provider returns, so brainy deliberately returns the un-negated set and lets Brainy subtract — which is what its own JS index evaluator does. The suite pins parity in every position (not beside a field, not of an oneOf, not inside allOf, and standalone) rather than asserting a hand-written set, so brainy tracks Brainy here by construction.

How this page stays true

src/native/stringOperatorConformance.test.ts runs both engines — a brainy-backed brain and a pure-JS brain over byte-identical data — and compares their answers operator by operator. Nothing on this page is a hand-written expectation that could drift from what Brainy really does.

It also carries a vocabulary-completeness pin: it reads Brainy's operator tokens out of its shipped source at runtime and asserts every one is either served by brainy or refused by brainy by name. An operator Brainy adds in a future release lands in neither bucket and reds the gate — so the question "are we missing any operators?" is answered by the test suite forever, instead of by someone re-deriving it.