Engine+Parsing: Compile the Full Regex Dialect, and Stop t:/…/ Matching Nothing - #907
Open
daveycodez wants to merge 14 commits into
Open
Engine+Parsing: Compile the Full Regex Dialect, and Stop t:/…/ Matching Nothing#907daveycodez wants to merge 14 commits into
daveycodez wants to merge 14 commits into
Conversation
daveycodez
force-pushed
the
engine-regex-parity
branch
2 times, most recently
from
August 9, 2026 19:19
85a13f9 to
da4def6
Compare
daveycodez
force-pushed
the
engine-regex-parity
branch
from
August 16, 2026 12:02
8a4df78 to
61fa5a9
Compare
daveycodez
added a commit
to daveycodez/sylvan_librarian
that referenced
this pull request
Aug 17, 2026
… Directions `_FACE_OBJECT_FIELDS` did not carry `watermark`, so a non-front face's value was discarded at ingest while `_merge_processed_faces` copied face 0's up to `card_watermark`. That is one omission producing two opposite defects. THE SEARCH INDEX WAS SHORT. `Research // Development` (dis/155) is simic on its front face and izzet on its back, and api.scryfall.com answers it for BOTH `wm:simic` and `wm:izzet`; this answered simic alone. Over the whole 2026-08-16 default_cards bulk, 156 faced printings carry a watermark, 19 of them two distinct ones (never three, and never one whose front face lacks it), and every one of the ten Ravnica guilds was short 1-2 rows. THE CARD OBJECT WAS LONG. Scryfall sends a top-level `watermark` on 36,437 printings and on 0 of the 12,098 that have `card_faces` — no layout, no face count and no image count produces an exception. So every faced printing with a watermark emitted a key Scryfall sends on none of them. A split card is the shape that hides this: one image, one piece of cardboard, so no layout gate stands in for "has faces". WHAT MOVES - `_FACE_OBJECT_FIELDS` gains `"watermark"`, between `flavor_text` and `artist` — Scryfall's own position on every one of the 1,075 face occurrences in the bulk. - `PrintingFace` gains `card_watermark_id`. That row is where a per-face, per-printing value belongs, and it is also the only place it fits: `Printing` has no padding left, so a second id there rounds the row across every printing for a value 156 of them carry, while `PrintingFace` exists only for the faced ones. - `indexes.watermarks` holds EVERY value a printing carries, deduped per printing (`build_watermark_index`, extracted so the differential test calls the same code the commit pass does rather than a copy that can drift). - `second_text_field_value` becomes `extra_text_field_values`, an unbounded allocation-free iterator, and both `TextExact` and `TextRegex` route through one `tri_over_values`. - `ARCHIVE_FORMAT_VERSION` 2026081612 -> 2026081613. WHY THE FILTER WAS NOT THE HARD PART. `narrow_rec` marks the watermark postings TIGHT and the compose path calls them EXACT, and nothing downstream re-checks a leaf either one claimed. So the index and `tri` have to enumerate the SAME set, and a cap on what `tri` can see — "one more value" — would be a cap the index does not share: a third face watermark would have to either drop out of the filter silently or panic the build. It does neither. The per-printing dedupe is load-bearing on its own: `compose_printing_estimate` reads the postings LENGTH as the match count, so the 137 faced printings whose two faces AGREE would inflate it while the bitmap stayed right. Every other operator DECLINES rather than approximating, exactly as it did when the field was scalar: only `Eq` narrows or composes, and the ordering ops and the regex reach the general path where `tri` is the answer. TESTED. `watermark_is_per_face_and_every_leaf_still_matches_tri` asserts all four answers — the postings, the tight narrow set, the compose bitmap and the exact estimate — against one ground truth (`card_pass` + `residual_matches`, the trivalent walk the materializing plan verifies with), for every operator `wm:` accepts, over a fixture covering both-faces-disagree, both-faces-agree, unfaced, faces-all-bare and front-bare-back-set. Reverting the per-face `tri` arm fails it by name on the first assertion; removing the dedupe fails it on the postings assertion. MEASURED IN A DOWNSTREAM PORT before being carried here. Against a rebuilt store over the same 2026-08-16 corpus, sweeping all 76 distinct watermark values: exactly 10 changed, the ten guilds, +1 or +2 each. Of the 651 faced printings in all_cards that carry a face watermark, 0 now emit a top-level one and all 651 have face watermarks byte-identical to Scryfall's bulk; a 400-printing sample of the 36,437 unfaced ones is unchanged. ADAPTATIONS from that port, since the two trees have diverged: its `TextField` has two variants this branch does not (`FullOracleTextLower`, `TypeLine`), so the exhaustive match here is the shorter list; its card object reaches the top-level gate through a `two_image`/face-owned-key table this branch does not have, so the gate here is the direct `key == "watermark" && faces.is_some()`; and its `jbylund#907` regex dialect is not on this branch, so the test uses `regex::Regex`.
daveycodez
added a commit
to daveycodez/sylvan_librarian
that referenced
this pull request
Aug 17, 2026
… Directions `_FACE_OBJECT_FIELDS` did not carry `watermark`, so a non-front face's value was discarded at ingest while `_merge_processed_faces` copied face 0's up to `card_watermark`. That is one omission producing two opposite defects. THE SEARCH INDEX WAS SHORT. `Research // Development` (dis/155) is simic on its front face and izzet on its back, and api.scryfall.com answers it for BOTH `wm:simic` and `wm:izzet`; this answered simic alone. Over the whole 2026-08-16 default_cards bulk, 156 faced printings carry a watermark, 19 of them two distinct ones (never three, and never one whose front face lacks it), and every one of the ten Ravnica guilds was short 1-2 rows. THE CARD OBJECT WAS LONG. Scryfall sends a top-level `watermark` on 36,437 printings and on 0 of the 12,098 that have `card_faces` — no layout, no face count and no image count produces an exception. So every faced printing with a watermark emitted a key Scryfall sends on none of them. A split card is the shape that hides this: one image, one piece of cardboard, so no layout gate stands in for "has faces". WHAT MOVES - `_FACE_OBJECT_FIELDS` gains `"watermark"`, between `flavor_text` and `artist` — Scryfall's own position on every one of the 1,075 face occurrences in the bulk. - `PrintingFace` gains `card_watermark_id`. That row is where a per-face, per-printing value belongs, and it is also the only place it fits: `Printing` has no padding left, so a second id there rounds the row across every printing for a value 156 of them carry, while `PrintingFace` exists only for the faced ones. - `indexes.watermarks` holds EVERY value a printing carries, deduped per printing (`build_watermark_index`, extracted so the differential test calls the same code the commit pass does rather than a copy that can drift). - `second_text_field_value` becomes `extra_text_field_values`, an unbounded allocation-free iterator, and both `TextExact` and `TextRegex` route through one `tri_over_values`. - `ARCHIVE_FORMAT_VERSION` 2026081612 -> 2026081613. WHY THE FILTER WAS NOT THE HARD PART. `narrow_rec` marks the watermark postings TIGHT and the compose path calls them EXACT, and nothing downstream re-checks a leaf either one claimed. So the index and `tri` have to enumerate the SAME set, and a cap on what `tri` can see — "one more value" — would be a cap the index does not share: a third face watermark would have to either drop out of the filter silently or panic the build. It does neither. The per-printing dedupe is load-bearing on its own: `compose_printing_estimate` reads the postings LENGTH as the match count, so the 137 faced printings whose two faces AGREE would inflate it while the bitmap stayed right. Every other operator DECLINES rather than approximating, exactly as it did when the field was scalar: only `Eq` narrows or composes, and the ordering ops and the regex reach the general path where `tri` is the answer. TESTED. `watermark_is_per_face_and_every_leaf_still_matches_tri` asserts all four answers — the postings, the tight narrow set, the compose bitmap and the exact estimate — against one ground truth (`card_pass` + `residual_matches`, the trivalent walk the materializing plan verifies with), for every operator `wm:` accepts, over a fixture covering both-faces-disagree, both-faces-agree, unfaced, faces-all-bare and front-bare-back-set. Reverting the per-face `tri` arm fails it by name on the first assertion; removing the dedupe fails it on the postings assertion. MEASURED IN A DOWNSTREAM PORT before being carried here. Against a rebuilt store over the same 2026-08-16 corpus, sweeping all 76 distinct watermark values: exactly 10 changed, the ten guilds, +1 or +2 each. Of the 651 faced printings in all_cards that carry a face watermark, 0 now emit a top-level one and all 651 have face watermarks byte-identical to Scryfall's bulk; a 400-printing sample of the 36,437 unfaced ones is unchanged. ADAPTATIONS from that port, since the two trees have diverged: its `TextField` has two variants this branch does not (`FullOracleTextLower`, `TypeLine`), so the exhaustive match here is the shorter list; its card object reaches the top-level gate through a `two_image`/face-owned-key table this branch does not have, so the gate here is the direct `key == "watermark" && faces.is_some()`; and its `jbylund#907` regex dialect is not on this branch, so the test uses `regex::Regex`.
daveycodez
force-pushed
the
engine-regex-parity
branch
from
August 21, 2026 23:34
4f57a51 to
f35339f
Compare
Three regex cases raised out of build_filter on ordinary queries, and _search's blanket handler turned each into a silent PostgreSQL fallback -- so the SQL path was load-bearing for a documented feature rather than a crash net. - Lookaround. The regex crate omits it by design, which is what buys its linear-time guarantee, but it is on the documented feature list and Scryfall answers it (435 cards for `o:/draw (?!two)/ t:instant`). CompiledRegex tries `regex` first and falls back to fancy_regex only for patterns it rejects, so every pattern that compiles today keeps the linear engine -- and with it the jbylund#734 trigram narrowing, which reads the pattern through regex_syntax::parse. - ARE escapes. `\y`/`\Y`/`\Z` have exact regex-crate spellings and are rewritten in place, so they stay linear; `\m`/`\M` have none and become lookaround. Bracket expressions are copied through untouched. - Attributes with no TextField. Regex compiled onto four fields while the parser emits a RegexValueNode for nine, so set code, layout, border, watermark and collector number declined even though `~*` answers them on the SQL path. TextField::TypeLine reads the interned type_line_id already on AOracleCard, card-level like Layout. REGEX_BACKTRACK_NS100 is measured, not assigned: bench_backtrack_engine puts lookaround at 77x the linear engine per candidate (6,535 vs 85 ns/card). The engines are the same speed on patterns both accept -- 1.00x, fancy_regex delegates to regex when a pattern needs nothing more -- so the tier prices lookaround itself, not the dispatch. It wants re-fitting on the real corpus.
`t:/.../` never reached the engine as a regex. kwargs() ran `self.rhs.value.strip().title()` and emitted the result as a literal subtype, so `t:/^drag/` became the subtype "^Drag" and `t:/goblin|elf/` became "Goblin|Elf" -- types no card has. The query returned nothing, on both paths, with no error and therefore no fallback either. Scryfall returns 1,269 cards for `t:/goblin|elf/` and 4,316 for `t:/^legendary/`. The parser parity suite cannot see this: it compares the two parsers against each other, and both mangle it identically. A RegexValueNode on a type attribute now routes to the type line -- `type_line ~* ...` on the SQL path, TextField::TypeLine in the engine -- which is the column the type and subtype arrays are derived from. Bare literals are untouched: lower_literal_regexes already rewrites `t:/dragon/` to `t:dragon`, and that is measured identical against Scryfall (445 = 445, and the same for creature/elf/aura/legendary/equipment). `t:/^legendary/` at 4,316 against `t:legendary` at 4,348 is what shows the two are not the same predicate.
Differential run over 30,658 oracle cards, built from Scryfall's oracle_cards bulk data through this repo's own preprocess_card, against a Python `re` reference that shares no code with the engine. 13 of 13 match, including the four type-regex cases that returned a silent 0 before. Absolute totals deliberately are not compared against Scryfall's API: the import drops digital-only and funny-set cards (18,889 of 116,694 rows in default_cards), so the corpus is a subset by design. Scryfall settles what the queries mean; the reference settles whether the engine evaluates them right.
… Them
`o:/^Whenever you cast/ e:khm` misses Firja, Judge of Valor. Its oracle text is
Flying, lifelink
Whenever you cast your second spell each turn, …
and the pattern was compiled `(?i)`, so `^` could only mean the start of the
whole string. api.scryfall.com returns Firja for that query, and for
`o:/lifelink$/ e:khm` as well — measured 2026-08-16, along with the rest of the
mode:
| query (e:khm) | Scryfall | before |
|---|---|---|
| `o:/^Whenever you cast/` | 11 | 6 |
| `o:/lifelink$/` | 4 | 1 |
| `o:/^Flying$/` | 20 | 0 |
| `o:/Flying.Whenever/` | 0 | 0 |
| `o:/Flying\nWhenever/` | 6 | 6 |
So `^`/`$` are line anchors and `.` still stops at a newline — which is exactly
PostgreSQL ARE's newline-sensitive mode, the `(?n)` the SQL path this dialect
tracks would spell. In the `regex` crate that pair is `(?im)` with `s` left off,
because `.` already excludes `\n` there by default.
Oracle text is the only multi-line column, so nothing else moves: name, type
line, artist, set code and collector number have no newline for either anchor to
find, and `$` still matches at the end of the string (`o:/^flying, lifelink$/`
is 1 = 1 against Scryfall).
The flag string is now a constant. Two callers recover the raw pattern by
removing this exact prefix — `regex_tier` to price the shape, and jbylund#734's
literal-factor extraction to feed the trigram narrow — and a prefix that stops
matching costs the narrow silently, since a case-folded HIR yields classes
rather than literals and simply returns no factors. `query_regex_flags_stay_strippable`
is the test that would have caught changing the string without them.
`t:"artifact creature" cmc<=2` returns 360 rows on api.scryfall.com and nothing here. The quoted phrase reaches `build_filter` as ONE title-cased token, "Artifact Creature", which is neither a type nor a subtype — so `TypeCmp` gets an empty mask and the subtype containment gets a value no card carries. Both answer false for every card, with no error and therefore no signal: the same silent-empty shape as the `t:/…/` bug this PR already fixes, one operator over. Scryfall's rule, measured 2026-08-16 rather than assumed — the quoted string is matched against the whole type line as a case-insensitive substring: | query | Scryfall | |---|---| | `t:"artifact creature" cmc<=2` | 360 | | `t:"creature artifact" cmc<=2` | 0 — order matters, so it is not and-over-words | | `t:"tifact creat" cmc<=2` | 360 — neither side is word-anchored | | `t:"artifact creature" cmc<=2` | 360 — whitespace runs collapse | | `t:"creature — human" e:dsk` | 44 — the em dash is ordinary text | | `t:"creature - human" e:dsk` | 0 — …and a hyphen is not it | | `t:"human wizard" e:dsk` / reversed | 6 / 0 — subtype pairs are the same predicate | It compiles to a regex over the escaped literal so the `TextField::TypeLine` path this PR added carries it: case folding comes free with the query flags, and the jbylund#734 literal-factor trigram narrowing still applies, which a bare substring comparison here would not have. Containment operators only — `=`/`<`/`>` compare SETS on the type arrays, and a substring is not one, so those keep the membership path. Single-word values keep it too, which is the whole point of gating on whitespace rather than routing every `t:` through the type line. Verified on the Cloudflare port against a policy-clean set: `t:"human wizard" e:dsk` 6 = 6, `t:"snow land" e:khm` 17 = 17, `t:"legendary creature" e:dsk` 34 vs 35 (one printing that corpus excludes), reversed word order 0 = 0 on both. The SQL twin (`card.type_line ~*` over the same escaped literal, same whitespace rule) is on jbylund#926 with the rest of that sweep's parser work; this hunk is here because `TextField::TypeLine` exists only on this branch.
…ing It
`t:creat cmc<=2 e:khm` answers 39 on api.scryfall.com and answered 0 here. The
quoted multi-word case was fixed in the previous commit; the single token was
left resolving to type/subtype MEMBERSHIP, and "Creat" is neither a type nor a
subtype, so the mask and the vocabulary lookup both answered false for every
card.
There is one rule, and it is measured rather than inferred. Against `e:khm`
(323 prints), 2026-08-16:
t:creature 151 t:creat 151 t:reature 151 t:eatur 151
t:snow 47 t:no 47 ("no" inside "Snow")
t:elf 22 t:lf 25 ("lf" also inside "Wolf")
t:legend 42 t:legendary 42 (supertypes are in the line)
t:CREAT = t:Creat = t:creat (case-insensitive)
t:— 227 = t:"—" 227 (the em dash is ordinary text)
t:"// creature" 182, t:"creature //" 0 (so is the face join)
-t:creat 172, and 151 + 172 = 323 (a plain complement)
t=creature 151, t="legendary creature" 32 (`=` is the same substring —
set equality answers 0 there)
t:artifactcreature 0 (not a token-set test)
So a case-insensitive substring of the whole type line, for one word exactly as
much as for a phrase, under `:`, `>=` and `=`. `<`, `<=`, `>` and `!=` keep this
project's set-comparison meaning: Scryfall returns zero rows for those shapes,
so there is no behaviour to follow and the superset stays.
THE NARROWING IS THE REST OF THE COMMIT, and it is what the previous one was
missing. Routing a type predicate to `TextField::TypeLine` is correct and had
no narrowing arm at all — there is no trigram index over type lines, and the
fell through `narrow_rec` to the catch-all and ran a regex against every card.
Doing that to `t:creature` as well would have made the most common filter in
the syntax a corpus scan.
The type line is a tiny vocabulary, and that is the whole design: a real corpus
of 526,865 rows carries 3,965 distinct type lines, 127 KB of text. `CardIndexes`
gains `type_lines` — dense line id -> global string id, their lowercase copies,
and a CSR to the cards carrying each — and `bind_type_lines` evaluates the
predicate ONCE PER DISTINCT LINE, exactly as `bind` already does for artist and
flavor text. The answer is not a prefilter: a card's line id IS its type-line
identity, so the winners expand through the CSR to the exact card set and the
candidate arrives `tight` with nothing left to verify.
Measured on a 3,281-card partition of that corpus, best of 30, against an 88 us
paging floor and a 1,668 us full scan:
index no index (CARD_ENGINE_NO_TYPE_LINE_INDEX=1)
t:creature 108 us 367 us
t:creat 108 us 351 us
t:elf 39 us 280 us
t:"artifact creature" 68 us 445 us
t:zzzz (bind only) 6 us 234 us
t:creature c:r cmc>=4 112 us 146 us
The lowercase copies in the index are the reason the first column looks like
that: matching `(?i)creature` against mixed-case lines costs ~160 us per query,
because the prefilter fires on every case variant of the first byte; `memmem`
over pre-lowercased bytes costs ~5 us. A user's own `t:/…/` still runs against
the original line, where its pattern's case expectations still mean what they
say — and it now narrows through the same index instead of scanning.
Broad and narrow results take different shapes, on the 1/32 rule `HybridTagIndex`
already stores by: `t:creature` is 1,875 of 3,281 cards, which is 410 bytes as a
bitmap the algebra ANDs a word at a time against 7.5 KB of ids it would gather
by random access. That is the shape `TypeCmp` used to hand it from the type bit
planes, which is why replacing the mask with an index costs ~20 us rather than
the ~150 us the vector form measured.
`ARCHIVE_FORMAT_VERSION` moves because no struct size does: an archive without
the index would answer every `t:` query with zero rows and nothing would say so.
`o:"damage dealt by this creature also causes"` returns 0 on api.scryfall.com
and returned 68 here. The phrase exists only inside lifelink's reminder text,
which Scryfall's `o:` does not search — a second, stripped representation of the
oracle text, not a flag on the one the card object prints.
Measured 2026-08-16, `o:` against `fo:` (Scryfall's full-oracle operator, which
this project does not implement):
o:"damage dealt by this creature also causes" 0 fo: 71
o:"you may pay an additional" 0 fo: 268
o:"level up only as a sorcery" 0 fo: 25
o:"mana abilities can't be targeted" 0 fo: 24
o:/\(/ 0 fo:/\(/ e:khm 148
The last line is the general statement: not one parenthesis survives anywhere in
the corpus, so it is EVERY parenthesized run and not only the ones that sit on
their own line. `ft:` is untouched — `ft:/\(/` still returns 47 — so this is
specific to oracle text.
The whitespace rule is measured too, and it decides which side of the
parenthesis is eaten:
- `o:/\{e\}\sequal/` matches Aetherflux Conduit ("…an amount of {E} (energy
counters) equal to…") and `o:/\{e\}\s\sequal/` does not, so exactly one
space survives a mid-line reminder;
- `t:saga o:/^$/` returns 233 — every Saga — so the EMPTY LINE left behind by
a reminder that opens the text is still there.
Eating the whitespace AFTER the `)` instead would have lost that empty line, and
would also have joined `"Lifelink (Damage dealt…)\nWhen this creature dies"`
into a single line — which `o:/lifelink$/ e:khm` returning 4 rules out directly.
That query was the second symptom: it answered 2 here even after `$` learned to
anchor per line, because only the stripped form ends a line on `lifelink`.
`oracle_text_lower_id` is the whole of the change. It is read by exactly three
things — `TextField::OracleTextLower`, `TextSearchField::OracleTextLower` and
the oracle trigram index built from it — and emitted by none, so making it the
stripped form rather than a plain `to_lowercase()` moves the search surface
without touching a byte of any card object.
IT ALSO MAKES THE ARCHIVE SMALLER. Over 526,865 rows the column goes from 30,259
distinct texts / 5,196,005 bytes to 30,063 / 4,199,590 — 973 KB less, because
the stripped form is shorter and a card with no reminder text interns to the id
it already had. The trigram index built over it shrinks with it.
`ARCHIVE_FORMAT_VERSION` moves because the strings, the index and the word
dictionary are all different bytes for the same card, and no struct size says so.
Follow-up to the two commits above, from their CI:
- `clippy::large_enum_variant` on the `Needle` enum `bind_type_lines` used to
choose between the literal and the regex scan (a `memmem::Finder` is 288
bytes next to an 8-byte reference). Replaced with a `scan` helper taking the
predicate, which is what the enum was standing in for.
- `test_engine_property`'s synthetic corpus builds `card_types` and
`card_subtypes` but no `type_line`, so every `t:` fragment it generates
answered nothing once type predicates started reading the printed line. The
generator now emits a type line consistent with its two arrays — which is
what `card_processing` does for the real corpus — and the reference
evaluator matches `t:` as the substring it is.
- `test_type_eq_exact` asserted `t=creature` is set equality. It is not:
`t=creature e:khm` is 151 on api.scryfall.com, the same as `t:creature`, and
`t="legendary creature"` is 32 where set equality answers 0. The test now
pins the two operators to each other and records the divergence from the SQL
path, which still compares the array.
The commit two back made `o:` search oracle text with the reminder text taken
out, which is what api.scryfall.com does. That leaves the full text with no
searchable path at all, and Scryfall has one: `fo:`/`fulloracle:`.
o:"damage dealt by this creature also causes" 0 fo: 71
o:"you may pay an additional" 0 fo: 268
o:draw e:khm 39 fo: 57
o:/\(/ 0 corpus-wide fo:/\(/ e:khm 148
Both spellings share `oracle_text`'s COLUMN (the parser change on jbylund#926): the
stored text is the full one, so the SQL path answers `fo:` from it with no
second column and no migration. They are told apart HERE, by
`original_attribute`, because this engine is the only reader whose oracle column
is stripped.
`OracleCard` gains `oracle_full_lower_id`. The cost was measured before it was
paid: only 9,769 of the corpus's 30,259 distinct oracle texts differ from their
stripped form, so the interner charges 2.17 MB for the whole store and a card
with no reminder text shares the id it already had — against the 973 KB the
strip itself gave back.
DELIBERATELY INDEX-FREE. `o:` carries the trigram index because it is the common
operator; a second one over the unstripped text would cost ~5 MB to serve a rare
one. `fo:` evaluates per card, exactly as `o:` did before its index existed, and
the field falls through `narrow_rec` to the scan on purpose rather than by
oversight.
`ARCHIVE_FORMAT_VERSION` moves; `size_of::<AOracleCard>` moves with it, so the
header would catch a stale archive on its own.
… Substring
`t:god` is 96 on api.scryfall.com and this engine answers 104. The 8 extra are every
*Demigod* in the corpus — the substring rule this file measured is right about most
needles and wrong about the ones Scryfall indexes as types.
The rule is BOTH, split on Scryfall's own published type catalogs. Measured
2026-08-17, api.scryfall.com against a full store:
t:god 96 vs 104 ANCHORED the 8 extra were every Demigod
t:ape 45 vs 273 ANCHORED "ape" also sits inside Shapeshifter, Spellshaper
t:bat 54 vs 92 ANCHORED and inside Wombat and Incubator
t:ir 1906 = 1906 SUBSTRING `Plane — Ir` is a real type line, and no catalog holds "Ir"
t:las 43 = 43 SUBSTRING `Plane — Las Vegas` likewise; Bolas still matches
t:art 4171 = 4171 SUBSTRING `Creature — Art Lizard` likewise; Artifact still matches
The three SUBSTRING rows are why this is a fixed catalog — the union of the nine
`/catalog/*` type lists, 531 names — and not a vocabulary derived from the corpus's
own type lines. Plane types are printed, are in no catalog, and Scryfall does not
anchor on them: a corpus-derived rule would have anchored `t:ir` and answered 0 where
Scryfall answers 1,906.
The boundary is a TYPE-WORD boundary, so the three characters the catalog spells
inside a name bind: `t:urza` does not reach `Land — Urza's Mine` (the type is
`Urza's`, and `Urza` is a separate planeswalker type), while `t:worker`, which is in
no catalog, still substring-matches `Assembly-Worker` exactly as it does there.
Nothing else moves. Every row this file already recorded still answers the same
number, including the ones that look like they should shift: `t:creature` 18,753,
`e:khm t:snow` 47 = `t:no` 47, `t:elf` 22 against `t:lf` 25, `t:legend` 42 =
`t:legendary` 42.
What it still cannot do: Scryfall matches the type ARRAY, which holds subtypes it
never prints. `t:warrior` is 1,298 there against the printed line's 1,294 because
`Burakos, Party Leader` answers to all four party classes while its type_line reads
`Legendary Creature — Orc` on both sides. One card per party class, not derivable
from any published field.
A catalog is a snapshot: a creature type printed after the date above matches as a
substring until the list is refreshed, which is the pre-fix behaviour and the safe
direction to be stale in.
daveycodez
force-pushed
the
engine-regex-parity
branch
from
August 23, 2026 20:47
f35339f to
5b7121f
Compare
`-D warnings` on the lib target alone reads `canonical_type_names` as dead code — its only caller is the test that asserts the catalog's shape.
daveycodez
force-pushed
the
engine-regex-parity
branch
from
August 23, 2026 20:55
5b7121f to
9c214dc
Compare
jbylund
self-requested a review
August 26, 2026 12:52
This was referenced Aug 26, 2026
jbylund
added a commit
that referenced
this pull request
Aug 26, 2026
…ordering (#1047) Closes #1044 ## Summary - Add **parse-time static regex bounds** (`regex_budget.py`) on the post-rewrite AST, enforced against every regex leaf regardless of comparison operator (`:`, `=`, `!=`): leaf count, pattern bytes, lookarounds, alternations, AST depth, quantifiers (including products across nested repeats), and backreferences/conditional groups (rejected outright) → HTTP 400 before the engine runs. - Serve **`TextRegex` with fancy-regex 0.19** in the engine (`backtrack_limit=8192`), enabling lookarounds (`o:/draw (?!two)/`, `(?=.*…)`, `(?<=…)`), and split query errors into **`RetryableQueryError`** (SQL fallback) vs **`FatalQueryError` / `UnsupportedRegexError`** (400, no SQL retry). - Add **`REGEX_BACKTRACK_NS100`** to the verifier cost model so lookaround regexes sort after cheaper `And` predicates (~46× machinery on `real.store`; constant 380k). ## Static regex limits (`api/parsing/regex_budget.py`) | Limit | Bound | Rejects | |---|---|---| | Leaves per query | 10 | too many regex predicates in one query | | Pattern size | 256 UTF-8 bytes | oversized patterns | | Lookarounds per pattern | 4 | stacked `(?=…)` / `(?!…)` / `(?<=…)` / `(?<!…)` | | Alternations per pattern | 32 | `a\|b\|c\|…` blowup | | AST nodes per pattern | 64 | deeply/broadly structured patterns | | Parse depth per pattern | 16 | deep nesting | | Quantifier bound | 1024 | `{m,n}` explicit repeat, multiplied across nested groups (e.g. `(?:a{50}){50}`) | | Backreferences | 0 allowed | `\1`, `(?P=name)` | | Conditional groups | 0 allowed | `(?(id)yes\|no)` | Runtime backstop, independent of the static bounds above: `backtrack_limit=8192` per match in the Rust engine (`REGEX_BACKTRACK_LIMIT`, `card_engine/src/filter.rs`) — catches what static analysis can't predict. ## Out of scope (follow-ups) - **Stripped oracle / `fo:`** — #1046 tracks oracle vs full-oracle text; not here. - **Request wall-clock budget** — not filed yet; parse limits + per-match `backtrack_limit` only. - **PG/Scryfall escape parity** (`\y`, `t:/…/`, etc.) — deferred; fancy-regex covers lookarounds without a separate two-tier compiler. (Broader #907 parity can continue in follow-up PRs if needed.) ## Test plan - [x] `python -m pytest api/parsing/tests/test_regex_budget.py api/tests/test_parsing_errors.py -vvv` - [x] `cargo test --release --lib` in `card_engine/` (160 pass) - [x] Manual: PR-907-shaped queries parse and match on engine fixture (`o:/draw (?!two)/ t:instant`, etc.) - [x] `cargo test --release bench_verify_cost_clusters -- --ignored --nocapture` on `benchmarks/verify-order/real.store`
Upstream landed its own regex work while this branch was open (#security-regex-execution-budget), so the two designs met head-on in card_engine. Resolutions, in the order they matter: card_engine/src/filter.rs, regex_compat.rs — BOTH SIDES ADOPTED fancy-regex, for different halves of the same problem. Upstream compiles every query regex on it unconditionally, under a calibrated backtrack budget, with a compile-error prefix and a thread-local failure latch that surfaces exhaustion as UnsupportedRegexError. This branch keeps every pattern it can on the linear engine and reaches for fancy-regex only where the dialect needs it, under (?im) and the ARE escape translation. Kept both: CompiledRegex stays the compiled form, and compile_search_regex is now the seam that wraps it in upstream's budget and error prefix. The failure latch moved onto CompiledRegex::try_is_match, whose linear arm cannot fail -- so REGEX_BACKTRACK_LIMIT now bounds exactly the patterns it was calibrated for rather than all of them. Neither side's tests changed meaning. regex_tier — upstream added a pattern_requires_backtrack pre-check; this branch changed the stripped flag prefix from (?i) to QUERY_REGEX_FLAGS. Both, in that order. REGEX_BACKTRACK_NS100 — both sides added the constant, at the same value, with different measurements behind it (bench_regex_backtrack_tier and bench_backtrack_engine). One constant, both notes; git did not flag this one, because the two definitions did not overlap by line. card_engine/Cargo.toml — same shape: both sides added a fancy-regex dependency on non-adjacent lines, so the auto-merge produced a DUPLICATE KEY that cargo rejects. Upstream's entry kept, with its comment. card_engine/src/lib.rs — ARCHIVE_FORMAT_VERSION. Upstream's comment history kept intact; this branch's three entries collapse into one dated entry, since the header check is equality and only the final value is load-bearing. 2026082704, which is not in use on any other open branch. lib.rs also takes both sides of the bind seam: bind_type_lines runs, then check_regex_match_failed reads the latch -- bind itself runs regexes against the artist and flavor vocabularies, so the read belongs after both halves. tests.rs — regex_backtrack_exhaustion_surfaces_as_match_failure now compiles through the real search path instead of building a fancy-regex by hand, which also pins the production budget rather than a limit the test picked. Its pattern had to move: (?=a)(a+)+b does not exhaust the budget at any input length once the linear engine exists, because fancy-regex hands the plain (a+)+b tail to it and only the leading assertion backtracks. Nesting the lookahead inside the repetition, ((?=a)a+)+b, is what actually costs steps. Verified with the toolchain upstream CI pins: cargo test and clippy --all-targets -D warnings on 1.97.1, both crates, clean. Python suite 3,399 passing.
…Dialect We Accept
The regex execution budget that landed upstream measures every pattern with
Python's `re._parser` before the query runs. Python's `re` rejects `\y`, `\Y`,
`\m` and `\M` outright -- they are PostgreSQL ARE word-boundary escapes, with no
Python spelling -- so on this branch the security check turned four documented
operators into a user-visible "bad escape" error:
o:/\yizzet\y/ -> InvalidRegexPatternError: bad escape \y
That is the failure this branch exists to remove, arriving one layer earlier
than before. It is not caught by either side's tests: upstream has no ARE
pattern to check, and this branch's engine tests never reach the parser.
So the budget applies the same rewrite the engine does (`translate_are_escapes`
in card_engine/src/regex_compat.rs) before measuring. It measures a translated
COPY and leaves the stored pattern alone, because the SQL path hands that
pattern to PostgreSQL, which speaks ARE natively -- only the measurement needs
a Python-parseable spelling.
`\m`/`\M` become lookaround, exactly as they do on the engine, so they spend
lookaround budget. That is the accounting we want rather than an exemption:
lookaround is precisely what moves a pattern onto the backtracking engine that
MAX_LOOKAROUNDS_PER_PATTERN was calibrated to bound.
Verified the two copies agree rather than assuming it, including the cases
where they are supposed to REFUSE: `[\y]lit` is not a bracket expression either
engine accepts, and both reject it -- the rewrite skips class interiors rather
than inventing a meaning for them. `[]a]\yx` (a leading `]` is a literal member
under POSIX, so it does not close the class) translates identically on both
sides.
daveycodez
added a commit
to daveycodez/sylvan_librarian
that referenced
this pull request
Aug 29, 2026
jbylund#907's branch was pinned at e24a2c7 and main has moved, so this PR could not build a merge ref and no `pull_request` workflow ran. Two conflicts, both inherited from jbylund#907 rather than from anything on this branch — merging main into `origin/engine-regex-parity` alone produces the identical pair. ARCHIVE_FORMAT_VERSION. Both sides changed the store: main's 2026082501 adds per-order printing-span prefix sums inside `CardIndexes`, jbylund#907's 2026082704 adds the type-line index, the reminder-stripped `oracle_text_lower_id` and `oracle_full_lower_id`. The merged tree has both, so the constant has to reject a store built by either — 2026082704 is the higher of the two and does. Main's note is kept above it so the reason for its bump is not lost. card_engine/src/tests.rs was an append-vs-append with an EMPTY merge base on both sides, i.e. two sets of new tests landing at the same offset. Resolved as the union; no test on either side is dropped, and the counts say so — 190 Rust tests against 185 here and 5 new ones from main. Verified on the merge result, not on either parent: `cargo test` 190 passed, `cargo clippy --all-targets -- -D warnings` on 1.97.1 clean for both crates, `pytest --ignore=api/tests/test_integration_testcontainers.py` 3,543 passed, `ruff check` clean.
daveycodez
added a commit
to daveycodez/sylvan_librarian
that referenced
this pull request
Aug 29, 2026
…d Regex Syntax Applies" The page listed three columns (`name:`, `type:`, `oracle:`) where eleven take a pattern, and described the dialect as standard when three things in it are not: `~`, the nine `\s…` shorthands, and the PostgreSQL word-boundary escapes jbylund#907 added. Each of those is a query a searcher cannot discover by trying — `o:/\sm/` under the whitespace reading answers a plausible number rather than an error, and `~` reads as a stray character. Says what the shorthands MEAN rather than what they expand to, and names the two scope rules that are otherwise invisible: `~` is the alias on `oracle:` and the literal tilde everywhere else, and nothing is expanded inside a character class.
Two conflicts, both in `card_engine`, both append-shaped. `lib.rs` — the `ARCHIVE_FORMAT_VERSION` comment ledger. Main added `2026082501` (`SortPermutations` prefix sums) while this branch carries `2026082704` for the type-line index, the reminder-text strip and `oracle_full_lower_id`. Ours is the later date and the wider change, so the constant KEEPS `2026082704` and both entries stay in the ledger in date order — the history is the point of that block, and renumbering would collide with the values staked on the other open branches. `tests.rs` — both sides appended a new test section after `ARITH_TUPLE_BLOWUP_CARDS`. No shared symbols between them (upstream's six sigma/three-phase tests against this branch's fifteen regex-dialect ones), so both blocks are kept, upstream's first. `is:hybrid`/`is:phyrexian` (jbylund#1011, the merge that moved main) does not touch this branch: it edits `BOOLEAN_IS_TAGS` in `api/admin_resource.py` and its import tests, and nothing here reads or rewrites an `is:` tag. card_engine 180 tests pass, shared_cache clean, clippy 1.97.1 `-D warnings` clean on both crates, 3435 Python tests pass.
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.
Goal
Bring the in-memory engine to full capability on regex, so the SQL fallback stops being load-bearing and becomes what it was meant to be — a crash net.
_searchroutes to PostgreSQL whenever_search_engineraises. That handler reads as a backstop, but several ordinary, documented queries land in it, which means the SQL path is quietly answering things the engine was expected to answer. This PR closes the regex portion of that set.Motivation: the Cloudflare Workers port runs this engine compiled to wasm. There is no PostgreSQL there, so every one of these declines has to be absorbed by a D1/SQLite mirror of the corpus that exists purely to catch them — an expensive thing to maintain for queries the engine should be answering itself. That is the motivation, but the bugs below are upstream bugs and the fixes stand on their own here.
What was declining
o:/draw (?!two)/ t:instanto:/(?=.*sacrifice)draw/o:/(?<=draw )a card/name:/\yizzet\y/s:/^m1/,layout:/^trans/,wm:/^guild/,cn:/^1/Lookaround. The
regexcrate omits it by design — it is what buys the linear-time guarantee — but lookahead is on the documented feature list indocs/changelog/2025-02-02-regex-search.md, and Scryfall answers it.ARE escapes. PostgreSQL spells word boundaries
\y/\Y/\m/\Mand end-of-string\Z. Scryfall acceptsname:/\yizzet\y/; theregexcrate rejects all of them.Attributes with no
TextField. Regex compiled onto four fields while the parser emits aRegexValueNodefor nine.And one that was worse than a decline
t:/…/never reached the engine as a regex at all.kwargs()ranself.rhs.value.strip().title()and emitted the result as a literal subtype, sot:/^drag/became the subtype"^Drag"andt:/goblin|elf/became"Goblin|Elf"— types no card has.The query returned nothing, silently, on both the engine and SQL paths. No error meant no fallback either, so this one was invisible from every direction:
t:/goblin|elf/t:/^legendary/t:/dragon.*spirit/t:/elf (warrior|shaman)/test_parser_parity.pystructurally cannot catch this — it compares the two parsers against each other, and both mangle it identically.Bare literals were never affected:
lower_literal_regexesalready rewritest:/dragon/tot:dragon, and I measured that identical against Scryfall (445 = 445, likewise for creature/elf/aura/legendary/equipment).t:/^legendary/at 4,316 againstt:legendaryat 4,348 is the case that shows the two are not the same predicate: the anchored regex matches type lines starting with the supertype, the lookup matches it anywhere.Approach
Two-tier compilation (
card_engine/src/regex_compat.rs).CompiledRegextries theregexcrate first and reaches forfancy_regexonly on patterns it rejects. Everything that compiles today keeps the linear engine — and with it the #734 trigram narrowing, which reads the pattern back throughregex_syntax::parse. A backtracking pattern yields no literal factors and scans, which is already the behavior for any regex without a usable factor.Escape translation.
\y→\b,\Y→\B,\Z→\zare exact equivalents, so those patterns stay linear;\m/\Mhave none and become lookaround. Bracket expressions are copied through untouched — inside[…]these are ordinary escapes, not constraints.TextField::TypeLinereads the internedtype_line_idalready present onAOracleCard, card-level likeLayout, since Scryfall's type line is oracle data.Cost model
One constant, measured rather than assigned.
bench_backtrack_engineis self-contained (noreal.storeneeded) and reports two things:fancy_regexdelegates toregexwhenever a pattern needs nothing more, so routing through the backtracking arm costs nothing as a dispatch choice.85 ns/card, mean over negative lookahead / positive lookahead / lookbehind).
So
REGEX_BACKTRACK_NS100 = 380_000prices lookaround, not dispatch. It dwarfs every other tier deliberately: these are the one node kind the trigram narrow cannot read a literal factor out of, so ordering them last in anAndis the only lever the model has. This wants re-fitting against the real corpus with the calibration bench — I do not have one built.Verification
Correctness work, so per
docs/workflows/performance-pr-workflow.mdthe acceptance is differential rather than a benchmark story. 164 engine tests and 1,676 parser tests pass; clippy clean.Run over a real corpus: 30,658 oracle cards, built from Scryfall's
oracle_cardsbulk data through this repo's ownpreprocess_card, one row per oracle id so card-level fields are unambiguous. The reference applies Python'sreto the field the query names and counts distinct oracle ids — it shares no code with the engine, and for the PostgreSQL-only escapes it uses the independent equivalent spelling (\y→\b) rather than checking the translation against itself.o:/draw (?!two)/o:/(?=.*sacrifice)draw/o:/(?<=draw )a card/o:/^\{T\}:/name:/\yizzet\y/name:/\mizzet/t:/goblin|elf/t:/^legendary/t:/dragon.*spirit/t:/elf (warrior|shaman)/t:/^drag/t:/dragon/t:dragon)13 of 13 match. The
beforecolumn is measured the same way, with this branch's engine and the pre-change parser.One thing I deliberately did not do is compare absolute totals against the Scryfall API. The import drops digital-only and funny-set cards — 18,889 of the 116,694 rows in
default_cards— so the corpus is a subset by design and the totals should not line up. Scryfall settles what these queries mean; the reference settles whether the engine evaluates them correctly.One question for you
Regex on set code / layout / watermark / artist / collector number is a superset of Scryfall, which rejects all five with
400 All of your terms were ignored. I kept them, because the parser already accepts the syntax and the SQL path already answers it — declining in the engine only re-creates the fallback. But if you would rather match Scryfall, the right fix is rejecting them at the parser, and I am happy to switch.Design notes:
docs/issues/local-engine-regex-decline-parity.md(namedlocal-per the conventions; rename to an issue number if you file one).Follow-up: two more predicates that answered nothing, silently
A broad differential sweep against api.scryfall.com (328 queries) found two more members of the
class this PR is named for — a query that returns zero with no error, no warning and no fallback —
and both belong here rather than on a branch of their own.
^and$were not line anchorso:/^Whenever you cast/ e:khmmissed Firja, Judge of Valor, whose oracle text is"Flying, lifelink\nWhenever you cast your second spell each turn, …". The pattern was compiled(?i), so^could only mean the start of the whole string. Measured 2026-08-16:e:khm)o:/^Whenever you cast/o:/lifelink$/o:/^Flying$/o:/Flying.Whenever/o:/Flying\nWhenever/^/$are line anchors and.still stops at a newline — exactly PostgreSQL ARE'snewline-sensitive mode, the
(?n)the SQL path this dialect tracks would spell. In theregexcrate that pair is
(?im)withsleft off. Oracle text is the only multi-line column, so nothingelse moves.
The flag string is now a constant, because two callers recover the raw pattern by removing that
exact prefix —
regex_tierand #734's literal-factor extraction — and a prefix that stops matchingcosts the trigram narrow silently (a case-folded HIR yields classes, not literals, so no factor
comes back).
query_regex_flags_stay_strippableis the test that would catch it.A quoted multi-word type was a subtype nobody has
t:"artifact creature"reachesbuild_filteras ONE title-cased token, "Artifact Creature" —neither a type nor a subtype, so
TypeCmpgets an empty mask and the subtype containment gets avalue no card carries. 360 rows on Scryfall (
cmc<=2), zero here. Scryfall's rule, measured:t:"artifact creature" cmc<=2t:"creature artifact" cmc<=2t:"tifact creat" cmc<=2t:"artifact creature" cmc<=2t:"creature — human" e:dsk/t:"creature - human" e:dskt:"human wizard" e:dsk/ reversedIt compiles to a regex over the escaped literal, so
TextField::TypeLine— added by this PR —carries it, with case folding free and the #734 trigram narrowing intact. Containment operators
only;
=/</>compare sets and a substring is not one. Single-word values keep the membershippath and its indexes.
Why here.
TextField::TypeLineexists only on this branch, and both fixes are the same defectclass this PR opened with. The SQL twin of the quoted-type rule (
card.type_line ~*over the sameescaped literal, same whitespace rule) is on #926 with the rest of that sweep's parser work.
Verified on the Cloudflare port against a policy-clean set (
e:dsk,unique=prints):t:"human wizard" e:dsk6 = 6,t:"snow land" e:khm17 = 17,t:"legendary creature" e:dsk34 vs 35 (one printing that corpus excludes),
o:/^flying, lifelink$/ e:khm1 = 1.A SINGLE-WORD
t:is a substring too — and nothing was narrowing any of themThe commit above fixed the quoted phrase and left the single token on the membership path, where
t:creat cmc<=2 e:khmanswered 0 against Scryfall's 39. There is one rule for both. Measuredagainst
e:khm(323 prints), 2026-08-16:t:creature/t:creat/t:reature/t:eaturt:snow/t:not:elf/t:lft:legend/t:legendaryt:CREAT/t:Creat/t:creat(cmc<=2)t:—/t:"—"t:"// creature"/t:"creature //"-t:creatt=creature/t="legendary creature"=is the same substring — set equality answers 0 for the secondt:artifactcreatureSo
:,>=and=are all the substring.<,<=,>and!=keep this project'sset-comparison meaning: Scryfall returns zero rows for those shapes, so there is nothing measured
to follow and the superset stays. The
=change is a deliberate divergence from the SQL twinand is called out here rather than buried:
t=creaturemeans "the type array is exactly[Creature]" in SQL and "the type line contains 'creature'" in the engine after this.
The narrowing is the substantial half. Routing a type predicate to
TextField::TypeLinewascorrect and had no narrowing arm at all — there is no trigram index over type lines and the #734
factor arm is guarded to name/oracle — so
t:"artifact creature"fell throughnarrow_recto thecatch-all and ran a regex over every card. Extending that to
t:creaturewould have made the mostcommon filter in the syntax a corpus scan.
The type line is a tiny vocabulary and that is the whole design: 526,865 real rows carry 3,965
distinct type lines, 127 KB of text.
CardIndexesgainstype_lines(dense line id → globalstring id, their lowercase copies, and a CSR to the cards carrying each) and
bind_type_linesevaluates the predicate once per distinct line — the same shape
bindalready uses for artist andflavor text. A card's line id IS its type-line identity, so the winners expand to the EXACT card
set: the candidate arrives
tight, with nothing to verify per card.Measured on a 3,281-card store, best of 30, against an 88 us paging floor and a 1,668 us full scan:
CARD_ENGINE_NO_TYPE_LINE_INDEX=1t:creature(1,875 rows)t:creatt:elf(65 rows)t:"artifact creature"t:zzzz(bind only)t:creature c:r cmc>=4Two details earn their bytes. The index's lowercase copies: matching
(?i)creatureagainstmixed-case lines costs ~160 us per query because the prefilter fires on every case variant of the
first byte, where
memmemover pre-lowercased bytes costs ~5 us. And broad results come back as abitmap on the same 1/32 rule
HybridTagIndexstores by —t:creatureis 410 bytes that wayagainst 7.5 KB of ids gathered by random access, which is the shape
TypeCmpused to hand overfrom the type bit planes and why replacing the mask costs ~20 us rather than ~150 us. A user's own
t:/…/still runs against the original-case line and now narrows through the same index.ARCHIVE_FORMAT_VERSION→ 2026081601: no struct size moves, so an archive without the index wouldanswer every
t:query with zero rows and nothing would say so.o:searches oracle text with the reminder text taken outReported on this PR before it was fixed; it is fixed here now, because it is the other half of the
o:/lifelink$/ e:khmcase above (2 vs 4 even after$learned to anchor per line — only thestripped form ends a line on
lifelink).Measured 2026-08-16,
o:againstfo::o:fo:o:"damage dealt by this creature also causes"o:"you may pay an additional"o:"level up only as a sorcery"o:"mana abilities can't be targeted"o:/\(/fo:/\(/ e:khm148The last row is the general statement: not one parenthesis survives, so it is EVERY parenthesized
run and not only the ones on their own line.
ft:is untouched (ft:/\(/still 47).The whitespace rule decides which side of the parenthesis goes, and it is measured too:
o:/\{e\}\sequal/matches Aetherflux Conduit ("…an amount of {E} (energy counters) equal to…")and
o:/\{e\}\s\sequal/does not, so exactly one space survives a mid-line reminder; andt:saga o:/^$/returns 233 — every Saga — so the empty line a leading reminder leaves behind isstill there. Eating the whitespace after the
)would have lost both.oracle_text_lower_idis the whole change: it is read byTextField::OracleTextLower,TextSearchField::OracleTextLowerand the oracle trigram index, and emitted by nothing, so thesearch surface moves without touching a byte of any card object. It makes the archive smaller —
over 526,865 rows the column goes 30,259 distinct texts / 5,196,005 bytes → 30,063 / 4,199,590, so
973 KB less, since the stripped form is shorter and a card with no reminder text interns to the id
it already had.
ARCHIVE_FORMAT_VERSION→ 2026081602 for the same reason as above: the strings, the trigram indexand its word dictionary are different bytes for the same card, and no struct size says so.
Not implemented here:
fo:itself. Scryfall's full-oracle operator has no column, alias orindex in this project, so adding it is a
db_infoentry plus a second searchable text — a separatechange, and one the SQL path can serve from
oracle_textdirectly.Verified on the Cloudflare port, local mirror against api.scryfall.com, 2026-08-16:
t:creat cmc<=2 e:khm39 = 39,t:creature cmc<=2 e:khm39 = 39,t:reature e:khm151 = 151,t:snow/t:no47/47 = 47/47,t:elf/t:lf22/25 = 22/25,-t:creat e:khm172 = 172,o:"damage dealt by this creature also causes"0 = 0,o:/lifelink$/ e:khm4 = 4....and
fo:/fulloracle:gets the texto:stopped readingThe commit above is only half an answer on its own: once
o:stops searching reminder text,nothing searches it, and Scryfall has an operator that does. The parser half (both spellings
resolving to
oracle_text's column) is on #926; this is the engine half.o:fo:"damage dealt by this creature also causes""you may pay an additional"draw e:khm/\(/fo:/\(/ e:khm148OracleCardgainsoracle_full_lower_id. The cost was measured before it was paid: only9,769 of the corpus's 30,259 distinct oracle texts differ from their stripped form, so the
interner charges 2.17 MB for the whole store and a card with no reminder text shares the id it
already had — against the 973 KB the strip itself gave back.
Deliberately index-free.
o:carries the trigram index because it is the common operator; asecond one over the unstripped text would cost ~5 MB to serve a rare one.
fo:evaluates per card,exactly as
o:did before its index existed, and its field falls throughnarrow_recto the scanon purpose rather than by oversight.
Verified on the Cloudflare port against a local mirror, 2026-08-16:
fo:draw e:khm57 = 57,o:draw e:khm39 = 39,fo:lifelink e:khm8 = 8,fulloracle:lifelink e:khm8 = 8,fo:"level up only as a sorcery"25 = 25, andft:/flavor:unchanged at 7 = 7 (flavor text isnot stripped on either side —
ft:/\(/returns 47 on Scryfall).A correction to an earlier note on this PR. It said the parser "unescapes a backslash in a
regex literal", because
fo:/\(this creature/reaches the engine as(this creature. That was amisreading of a wire tree and there is no such bug: a backslash before a NON-word character IS that
character, so a pattern of nothing but literal text and escaped punctuation is a substring search,
and
lower_literal_regexesrewrites the leaf accordingly. Nothing is dropped — the leaf changesTYPE, from
RegexValueNodetoStringValueNode, because the query no longer needs a regex engine.Checked rather than argued, on a local mirror against api.scryfall.com (2026-08-16), including the
pairs that discriminate a literal from a metacharacter:
o:/target./ e:khmo:/target\./ e:khmname:/./ e:khmname:/\./ e:khmo:/\{t\}/ e:khmo:/\+1\/\+1/ e:khmname:/\,/ e:khmcn:/\d/ e:khmA pattern that keeps its regex leaf keeps its backslashes byte for byte —
\(a.b,^\(x,[\]],\bfooall survive intact, on all eleven regex-capable operators. #926 now pins BOTHhalves as properties rather than a value table (the old table would have passed just as well if
\(were being dropped instead of resolved), and the parity fixtures cover escaped metacharactersper operator so the two implementations cannot drift on which half a pattern is in.
Added: a
t:value that names a type matches the type word (a98fae9)t:godis 96 on api.scryfall.com and this engine answers 104. The 8 extra are everyDemigod in the corpus — the substring rule this PR measured is right about most needles and wrong
about the ones Scryfall indexes as types. (It is also an anchor that quietly poisoned three probes
in a later negation sweep, whose "extra rows" were all Demigods: a wrong anchor, not an empty one.)
The rule is both, split on Scryfall's own published type catalogs. Measured 2026-08-17:
t:godt:apet:batt:irPlane — Iris a real type line, in no catalogt:lasPlane — Las Vegas; Bolas still matchest:artCreature — Art Lizard; Artifact still matchesThe three substring rows are why this is a fixed catalog — the union of the nine
/catalog/*typelists, 531 names — and not a vocabulary derived from the corpus's own type lines. Plane types are
printed, are in no catalog, and Scryfall does not anchor on them: a corpus-derived rule would have
anchored
t:irand answered 0 where Scryfall answers 1,906.The boundary is a type-word boundary, so the three characters the catalog spells inside a name
bind:
t:urzadoes not reachLand — Urza's Mine(the type isUrza's, andUrzais a separateplaneswalker type), while
t:worker, which is in no catalog, still substring-matchesAssembly-Workerexactly as it does there.Nothing else moves. Every row this PR already recorded still answers the same number, including the
ones that look like they should shift:
t:creature18,753,e:khm t:snow47 =t:no47,t:elf22 against
t:lf25,t:legend42 =t:legendary42,t:gob563 =t:goblin563.What it still cannot do: Scryfall matches the type ARRAY, which holds subtypes it never prints.
t:warrioris 1,298 there against the printed line's 1,294, becauseBurakos, Party Leaderanswersto all four party classes while its
type_linereadsLegendary Creature — Orcon both sides. Onecard per party class, not derivable from any published field.
A catalog is a snapshot: a creature type printed after that date matches as a substring until the
list is refreshed, which is the pre-fix behaviour and the safe direction to be stale in. The
catalog's sortedness, size and lowercasing are asserted in a test, since a silently truncated list
would quietly return
t:to substring-matching every creature type at once.cargo test --lib170 pass, 0 fail. No stored data changes; this is query-side only.Rebased onto the regex execution budget, and two things that fell out
#security-regex-execution-budgetlanded while this was open. Both sides had independently reachedfor
fancy-regex— that work for a backtrack budget and error taxonomy, this branch for dialectcoverage — so the merge is an integration rather than a text reconciliation.
CompiledRegexstaysthe compiled form and
compile_search_regexbecame the seam that wraps it in the budget and theerror prefix; the failure latch moved to
CompiledRegex::try_is_match, and this branch's1,000,000 backtrack ceiling gave way to the budget's measured 8,192.
Two collisions auto-merged cleanly and would have been a red build, since neither side's
additions overlapped by line: a duplicate
fancy-regexkey incard_engine/Cargo.toml, whichcargo refuses to parse, and
REGEX_BACKTRACK_NS100defined twice infilter.rs.The budget's parse-time validator rejected four of the operators this PR documents.
regex_budget.pymeasures a pattern with Python'sre._parser, which does not speak ARE, so\y/\Y/\m/\Mraisedbad escape \y—o:/\yizzet\y/became a user-visibleInvalidRegexPatternErrorone layer before the engine, rather than the 20 rows it answers onScryfall. Neither side's tests covered the intersection. Fixed by running the engine's own
translate_are_escapesover a copy for measurement only: the stored pattern still reachesPostgreSQL, which speaks ARE natively. The Rust and Python copies were cross-checked to agree on
every case, including the ones both must refuse (
[\y]lit).The backtrack-exhaustion test was vacuous under the merged design.
(?=a)(a+)+bstopsexhausting the budget once a linear engine exists —
fancy-regexhands the plain(a+)+btailstraight to it. Confirmed empirically at n=20/40/100/200, then moved the lookahead inside the
repetition (
((?=a)a+)+b), which trips the limit as intended.Both fixes are a separate commit rather than buried inside the merge, since one of them changes a
security module.
One residual, left deliberately. The budget rejects backreferences outright while
CompiledRegexcan execute them, so the engine now supports a construct the parser will never handit. That is a security policy rather than a dialect question, so the budget's position stands.