M3: the implication test, and the row closes #14

Merged
dev merged 2 commits from m3-partial-implication into main 2026-08-11 06:56:41 +00:00
Owner

Step 5 of docs/M3_INDEX_TYPES_DESIGN_REVIEW.md's order, and the last
item in M3's row. A partial index was maintained and enforced unique,
and every read scanned -- correct, but the speedup the option exists for
was never earned. tests/spec/indexes/ goes 42/42 to 48/48.

The test

A partial index may answer a query that cannot match a document its
filter left out. One-sided by construction: a false costs a scan, a
true has to be right, because too few documents is the one failure
worse than having no index at all.

Route one -- the query pins a value. Run the real matcher against a
stand-in document holding that value at the path, rather than
reimplementing eight operators against a comparison that would then have
two definitions. Sound because every operator check_partial_filter
admits is existential -- "some value at this path satisfies it" -- so a
document with more values at the path satisfies it at least as easily,
and every document the query matches has the pinned value among its
values there. Settles $eq, $in, $type, $exists and the bounds
together.

Two shapes break that argument and are refused rather than approximated,
each with its own row in the test table:

  • an array value. {a: [1, 2]} matches {a: [[1, 2], 3]}, whose
    values at a do not include 1 or 2 -- only one level is expanded, so
    the real document's value set is not a superset of the stand-in's.
  • a null value. {a: null} also matches a document with no a,
    which has no values at the path rather than more of them. The
    stand-in alone would report {a: {$exists: true}} as implied, so an
    empty document is tested too and both have to agree.

Route two -- bounds. The only route needing neither side to name a
document: {a: {$gt: 5}} implies {a: {$gt: 0}}. Inclusivity is where
it is decided -- $gte: 0 admits the endpoint $gt: 0 excludes.

$or on the filter's side is implied by one implied branch: sufficient,
not necessary, since a query can imply a disjunction without implying a
disjunct.

Soundness is against this server's matcher, not mongod's

Both halves of the question run the same code: query.matches_bytes
decides the index's contents in build_entries and re-filters every
candidate the plan yields. Where this server's comparison differs from
mongod's -- PLAN §6 records that the comparison operators are not
type-bracketed -- both halves are wrong together, which is a matching bug
and not a lost document.

What each gate can and cannot see

Measured with two mutations, and the answer is not symmetric:

query_implies_filter forced to partial.json unit test
true 23 pass / 7 fail, every failure "expected N, got N-1" red
false (the behaviour this replaces) 30 pass / 0 fail red

No client can observe that an index was used, only that an answer went
missing, and this server has no explain. So the corpus guards soundness
and the unit test on plan() is the only thing that sees the feature
work at all. Both numbers are in PLAN §6 and the corpus README so the
next change here knows which gate is load-bearing for which direction.

Corpus

Six cases added, each pairing a query that implies the filter with one
that does not and touches the same field: a query leaving the filter's
field out, an $in straddling the filter, a query for null against an
$exists filter, equalities inside and outside a range filter, a sort a
partial index could serve, and a unique partial index read.
partial.json 24 -> 30 cases.

Verification

  • 257/257 unit tests, ReleaseFast and ReleaseSafe
  • 88/88 fuzz
  • tests/spec/indexes/ 48/0, operators/ 125/0, positional/ 51/0, aggregate/ 70/0
  • pinned crud scorecard unchanged: 228 pass / 63 fail / 196 skip
  • full e2e matrix (e2e, e2e2 concurrent + crash-a/crash-b, e2e3-e2e7) and crash-fuzz green

Four mutations were run against the new unit table as well -- the whole
test forced true, the lower-bound comparison dropped, the null guard
dropped, the array guard dropped -- and each reddens it.

M3's row is closed. Next milestone is M4, sessions and transactions.

Step 5 of `docs/M3_INDEX_TYPES_DESIGN_REVIEW.md`'s order, and the last item in M3's row. A partial index was maintained and enforced `unique`, and every read scanned -- correct, but the speedup the option exists for was never earned. `tests/spec/indexes/` goes 42/42 to **48/48**. ### The test A partial index may answer a query that cannot match a document its filter left out. One-sided by construction: a `false` costs a scan, a `true` has to be right, because too few documents is the one failure worse than having no index at all. **Route one -- the query pins a value.** Run the *real matcher* against a stand-in document holding that value at the path, rather than reimplementing eight operators against a comparison that would then have two definitions. Sound because every operator `check_partial_filter` admits is existential -- "some value at this path satisfies it" -- so a document with more values at the path satisfies it at least as easily, and every document the query matches has the pinned value among its values there. Settles `$eq`, `$in`, `$type`, `$exists` and the bounds together. Two shapes break that argument and are refused rather than approximated, each with its own row in the test table: - an **array** value. `{a: [1, 2]}` matches `{a: [[1, 2], 3]}`, whose values at `a` do not include 1 or 2 -- only one level is expanded, so the real document's value set is not a superset of the stand-in's. - a **null** value. `{a: null}` also matches a document with no `a`, which has *no* values at the path rather than more of them. The stand-in alone would report `{a: {$exists: true}}` as implied, so an empty document is tested too and both have to agree. **Route two -- bounds.** The only route needing neither side to name a document: `{a: {$gt: 5}}` implies `{a: {$gt: 0}}`. Inclusivity is where it is decided -- `$gte: 0` admits the endpoint `$gt: 0` excludes. `$or` on the filter's side is implied by one implied branch: sufficient, not necessary, since a query can imply a disjunction without implying a disjunct. ### Soundness is against this server's matcher, not mongod's Both halves of the question run the same code: `query.matches_bytes` decides the index's contents in `build_entries` and re-filters every candidate the plan yields. Where this server's comparison differs from mongod's -- PLAN §6 records that the comparison operators are not type-bracketed -- both halves are wrong together, which is a matching bug and not a lost document. ### What each gate can and cannot see Measured with two mutations, and the answer is not symmetric: | `query_implies_filter` forced to | `partial.json` | unit test | |---|---|---| | `true` | **23 pass / 7 fail**, every failure "expected N, got N-1" | red | | `false` (the behaviour this replaces) | **30 pass / 0 fail** | red | No client can observe *that* an index was used, only that an answer went missing, and this server has no `explain`. So the corpus guards soundness and the unit test on `plan()` is the only thing that sees the feature work at all. Both numbers are in PLAN §6 and the corpus README so the next change here knows which gate is load-bearing for which direction. ### Corpus Six cases added, each pairing a query that implies the filter with one that does not and touches the same field: a query leaving the filter's field out, an `$in` straddling the filter, a query for null against an `$exists` filter, equalities inside and outside a range filter, a sort a partial index could serve, and a unique partial index read. `partial.json` 24 -> 30 cases. ### Verification - 257/257 unit tests, ReleaseFast **and** ReleaseSafe - 88/88 fuzz - `tests/spec/indexes/` 48/0, `operators/` 125/0, `positional/` 51/0, `aggregate/` 70/0 - pinned crud scorecard unchanged: 228 pass / 63 fail / 196 skip - full e2e matrix (e2e, e2e2 concurrent + crash-a/crash-b, e2e3-e2e7) and `crash-fuzz` green Four mutations were run against the new unit table as well -- the whole test forced true, the lower-bound comparison dropped, the null guard dropped, the array guard dropped -- and each reddens it. M3's row is closed. Next milestone is M4, sessions and transactions.
dev added 2 commits 2026-08-11 06:56:24 +00:00
Step 5, the last of `docs/M3_INDEX_TYPES_DESIGN_REVIEW.md`'s order and the
last item in M3's row. A partial index was maintained and enforced
`unique`, and every read scanned -- correct, but the speedup the option
exists for was never earned.

The test is one-sided by construction: a `false` costs a scan, a `true`
has to be right, because returning too few documents is the one failure
worse than having no index at all. Two routes, and a filter conjunct is
implied if either answers yes:

**Route one, the query pins a value.** Run the *real matcher* against a
stand-in document holding that value at the path, rather than
reimplementing eight operators against a comparison that would then have
two definitions. Sound because every operator `check_partial_filter`
admits is existential -- "some value at this path satisfies it" -- so a
document with more values at the path satisfies it at least as easily,
and every document the query matches has the pinned value among its
values there. Covers `$eq`, `$in`, `$type`, `$exists` and the bounds in
one stroke.

Two shapes break that argument and are refused rather than approximated,
and both have a row in the test table:

  - an **array** value. `{a: [1, 2]}` matches `{a: [[1, 2], 3]}`, whose
    values at `a` do not include 1 or 2 -- only one level is expanded, so
    the real document's value set is not a superset of the stand-in's.
  - a **null** value. `{a: null}` also matches a document with no `a`,
    which has no values at the path rather than more of them. The
    stand-in alone would report `{a: {$exists: true}}` as implied, so an
    empty document is tested too and both have to agree. This is the rule
    the previous commit's null fix made necessary and possible in the
    same breath.

**Route two, bounds.** The only route needing neither side to name a
document: `{a: {$gt: 5}}` implies `{a: {$gt: 0}}`. Inclusivity is where
it is decided -- `$gte: 0` admits the endpoint that `$gt: 0` excludes.

Soundness is judged against *this server's* matcher, not mongod's. Both
halves of the question run the same code: `query.matches_bytes` decides
the index's contents in `build_entries` and re-filters every candidate
the plan yields. Where this server's comparison differs from mongod's
(PLAN §6: the comparison operators are not type-bracketed) both halves
are wrong together, which is a matching bug and not a lost document.

`$or` on the filter's side is implied by one implied branch: sufficient,
not necessary, since a query can imply a disjunction without implying a
disjunct.

**What each gate can and cannot see, measured with two mutations.** With
`query_implies_filter` forced to `true`, `partial.json` goes 23/7 and
every failure reads "expected N, got N-1" -- the exact shape of the bug.
With it forced to `false` -- the behaviour this commit replaces -- the
corpus is 30/30, because no client can observe *that* an index was used,
only that an answer went missing. So the corpus guards soundness and the
unit test on `plan()` is the only thing that sees the feature work at
all; both are needed and the commit says which does which.

Six corpus cases added, each pairing a query that implies the filter with
one that does not and touches the same field: a query leaving the
filter's field out, an `$in` straddling the filter, a query for null
against an `$exists` filter, equalities inside and outside a range
filter, a sort a partial index could serve, and a unique partial index
read. `partial.json` 24 -> 30 cases, `tests/spec/indexes/` 42 -> 48.

Verified: 257/257 unit tests in ReleaseFast and ReleaseSafe, 88/88 fuzz,
all four corpora 0 fail, pinned scorecard unchanged at 228/63/196, the
full e2e matrix and crash-fuzz green.
All five steps of the index-types review landed:
the refusal, the recorded corpus, partial indexes, hashed indexes, and
the implication test. `tests/spec/indexes/` is 48/48 and all four
recorded corpora are green -- positional 51, operators 125, indexes 48,
aggregate 70.

The review gets a fourth correction, and it is about method rather than a
fact. §4 asserted the implication rule without saying how to test it; the
answer needed no comparison of its own, because every operator the
partial-filter grammar admits is existential, so running the real matcher
against a stand-in document settles five operators at once.

What the review missed entirely was the gates. A corpus recorded from
mongod can see an implication test that says yes too readily -- documents
go missing -- but not one that never says yes, because no client can
observe which index a read used and this server has no `explain`. Both
numbers are recorded in PLAN §6 and the corpus README so the next change
to this code knows which gate is load-bearing for which direction.

Nothing else in M3 remains. PLAN §6 still carries the items this row
turned up and deliberately left: comparison operators are not
type-bracketed, a dotted path through an empty array reads as absent, a
key-pattern direction may be any non-zero number, three positional
divergences, three operator omissions, and the four older engine items.
dev merged commit 35c1e6537c into main 2026-08-11 06:56:41 +00:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: dev/MultiforaDB#14