Skip to content

Engine+Parsing: Compile the Full Regex Dialect, and Stop t:/…/ Matching Nothing - #907

Open
daveycodez wants to merge 14 commits into
jbylund:mainfrom
daveycodez:engine-regex-parity
Open

Engine+Parsing: Compile the Full Regex Dialect, and Stop t:/…/ Matching Nothing#907
daveycodez wants to merge 14 commits into
jbylund:mainfrom
daveycodez:engine-regex-parity

Conversation

@daveycodez

@daveycodez daveycodez commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

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.

_search routes to PostgreSQL whenever _search_engine raises. 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

query Scryfall before after
o:/draw (?!two)/ t:instant 435 decline → SQL engine
o:/(?=.*sacrifice)draw/ 33 decline → SQL engine
o:/(?<=draw )a card/ 2,542 decline → SQL engine
name:/\yizzet\y/ 20 decline → SQL engine
s:/^m1/, layout:/^trans/, wm:/^guild/, cn:/^1/ (rejected) decline → SQL engine

Lookaround. The regex crate omits it by design — it is what buys the linear-time guarantee — but lookahead is on the documented feature list in docs/changelog/2025-02-02-regex-search.md, and Scryfall answers it.

ARE escapes. PostgreSQL spells word boundaries \y/\Y/\m/\M and end-of-string \Z. Scryfall accepts name:/\yizzet\y/; the regex crate rejects all of them.

Attributes with no TextField. Regex compiled onto four fields while the parser emits a RegexValueNode for nine.

And one that was worse than a decline

t:/…/ never reached the engine as a regex at all. 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, silently, on both the engine and SQL paths. No error meant no fallback either, so this one was invisible from every direction:

query Scryfall before, measured
t:/goblin|elf/ 1,269 0
t:/^legendary/ 4,316 0
t:/dragon.*spirit/ 18 0
t:/elf (warrior|shaman)/ 180 0

test_parser_parity.py structurally cannot catch this — it compares the two parsers against each other, and both mangle it identically.

Bare literals were never affected: lower_literal_regexes already rewrites t:/dragon/ to t:dragon, and I measured that identical against Scryfall (445 = 445, likewise for creature/elf/aura/legendary/equipment). t:/^legendary/ at 4,316 against t:legendary at 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). CompiledRegex tries the regex crate first and reaches for fancy_regex only on patterns it rejects. Everything that compiles today keeps the linear engine — and with it the #734 trigram narrowing, which reads the pattern back through regex_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\z are exact equivalents, so those patterns stay linear; \m/\M have none and become lookaround. Bracket expressions are copied through untouched — inside […] these are ordinary escapes, not constraints.

TextField::TypeLine reads the interned type_line_id already present on AOracleCard, card-level like Layout, since Scryfall's type line is oracle data.

Cost model

One constant, measured rather than assigned. bench_backtrack_engine is self-contained (no real.store needed) and reports two things:

  • On patterns both engines accept, they are the same speed — 1.00x.
    fancy_regex delegates to regex whenever a pattern needs nothing more, so routing through the backtracking arm costs nothing as a dispatch choice.
  • Lookaround itself costs 77x the linear engine per candidate (6,535 vs
    85 ns/card, mean over negative lookahead / positive lookahead / lookbehind).

So REGEX_BACKTRACK_NS100 = 380_000 prices 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 an And is 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.md the 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_cards bulk data through this repo's own preprocess_card, one row per oracle id so card-level fields are unambiguous. The reference applies Python's re to 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.

query before after reference
o:/draw (?!two)/ decline → SQL 3,630 3,630
o:/(?=.*sacrifice)draw/ decline → SQL 247 247
o:/(?<=draw )a card/ decline → SQL 2,900 2,900
o:/^\{T\}:/ decline → SQL 1,035 1,035
name:/\yizzet\y/ decline → SQL 14 14
name:/\mizzet/ decline → SQL 14 14
t:/goblin|elf/ 0, silently 1,157 1,157
t:/^legendary/ 0, silently 3,926 3,926
t:/dragon.*spirit/ 0, silently 16 16
t:/elf (warrior|shaman)/ 0, silently 171 171
t:/^drag/ 0 0 0
t:/dragon/ 402 402 (lowered to t:dragon)

13 of 13 match. The before column 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 (named local- 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 anchors

o:/^Whenever you cast/ e:khm missed 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:

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

^/$ are line anchors and . still stops at a newline — 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. Oracle text is the only multi-line column, so nothing
else moves.

The flag string is now a constant, because two callers recover the raw pattern by removing that
exact prefix — regex_tier and #734's literal-factor extraction — and a prefix that stops matching
costs the trigram narrow silently (a case-folded HIR yields classes, not literals, so no factor
comes back). query_regex_flags_stay_strippable is the test that would catch it.

A quoted multi-word type was a subtype nobody has

t:"artifact creature" reaches build_filter as ONE title-cased token, "Artifact Creature" —
neither a type nor a subtype, so TypeCmp gets an empty mask and the subtype containment gets a
value no card carries. 360 rows on Scryfall (cmc<=2), zero here. Scryfall's rule, measured:

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 / t:"creature - human" e:dsk 44 / 0 — the em dash is text
t:"human wizard" e:dsk / reversed 6 / 0 — subtype pairs, same predicate

It 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 membership
path and its indexes.

Why here. TextField::TypeLine exists only on this branch, and both fixes are the same defect
class this PR opened with. The SQL twin of the quoted-type rule (card.type_line ~* over the same
escaped 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:dsk 6 = 6, t:"snow land" e:khm 17 = 17, t:"legendary creature" e:dsk
34 vs 35 (one printing that corpus excludes), o:/^flying, lifelink$/ e:khm 1 = 1.

A SINGLE-WORD t: is a substring too — and nothing was narrowing any of them

The commit above fixed the quoted phrase and left the single token on the membership path, where
t:creat cmc<=2 e:khm answered 0 against Scryfall's 39. There is one rule for both. Measured
against e:khm (323 prints), 2026-08-16:

query Scryfall what it shows
t:creature / t:creat / t:reature / t:eatur 151 each unanchored on both sides
t:snow / t:no 47 / 47 "no" inside "Snow"
t:elf / t:lf 22 / 25 "lf" also inside "Wolf"
t:legend / t:legendary 42 / 42 supertypes are in the line
t:CREAT / t:Creat / t:creat (cmc<=2) 39 each case-insensitive
t:— / t:"—" 227 / 227 the em dash is ordinary text
t:"// creature" / t:"creature //" 182 / 0 so is the face join
-t:creat 172, and 151 + 172 = 323 a plain complement
t=creature / t="legendary creature" 151 / 32 = is the same substring — set equality answers 0 for the second
t:artifactcreature 0 not a token-set test

So :, >= and = are all the substring. <, <=, > and != keep this project's
set-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 twin
and is called out here rather than buried: t=creature means "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::TypeLine was
correct 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 through narrow_rec to the
catch-all and ran a regex over every card. Extending that to t:creature 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: 526,865 real rows carry 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 — the same shape bind already uses for artist and
flavor 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:

query with the index CARD_ENGINE_NO_TYPE_LINE_INDEX=1
t:creature (1,875 rows) 108 us 367 us
t:creat 108 us 351 us
t:elf (65 rows) 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

Two details earn their bytes. The index's lowercase copies: matching (?i)creature against
mixed-case lines costs ~160 us per query because the prefilter fires on every case variant of the
first byte, where memmem over pre-lowercased bytes costs ~5 us. And broad results come back as a
bitmap on the same 1/32 rule HybridTagIndex stores by — t:creature is 410 bytes that way
against 7.5 KB of ids gathered by random access, which is the shape TypeCmp used to hand over
from 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 would
answer every t: query with zero rows and nothing would say so.

o: searches oracle text with the reminder text taken out

Reported on this PR before it was fixed; it is fixed here now, because it is the other half of the
o:/lifelink$/ e:khm case above (2 vs 4 even after $ learned to anchor per line — only the
stripped form ends a line on lifelink).

Measured 2026-08-16, o: against fo::

query o: fo:
o:"damage dealt by this creature also causes" 0 71
o:"you may pay an additional" 0 268
o:"level up only as a sorcery" 0 25
o:"mana abilities can't be targeted" 0 24
o:/\(/ 0 corpus-wide fo:/\(/ e:khm 148

The 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; and
t:saga o:/^$/ returns 233 — every Saga — so the empty line a leading reminder leaves behind is
still there. Eating the whitespace after the ) would have lost both.

oracle_text_lower_id is the whole change: it is read by TextField::OracleTextLower,
TextSearchField::OracleTextLower and the oracle trigram index, and emitted by nothing, so the
search 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 index
and 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 or
index in this project, so adding it is a db_info entry plus a second searchable text — a separate
change, and one the SQL path can serve from oracle_text directly.

Verified on the Cloudflare port, local mirror against api.scryfall.com, 2026-08-16:
t:creat cmc<=2 e:khm 39 = 39, t:creature cmc<=2 e:khm 39 = 39, t:reature e:khm 151 = 151,
t:snow/t:no 47/47 = 47/47, t:elf/t:lf 22/25 = 22/25, -t:creat e:khm 172 = 172,
o:"damage dealt by this creature also causes" 0 = 0, o:/lifelink$/ e:khm 4 = 4.

...and fo:/fulloracle: gets the text o: stopped reading

The 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.

query o: fo:
"damage dealt by this creature also causes" 0 71
"you may pay an additional" 0 268
draw e:khm 39 57
/\(/ 0 corpus-wide fo:/\(/ e:khm 148

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 its field falls through narrow_rec to the scan
on purpose rather than by oversight.

Verified on the Cloudflare port against a local mirror, 2026-08-16: fo:draw e:khm 57 = 57,
o:draw e:khm 39 = 39, fo:lifelink e:khm 8 = 8, fulloracle:lifelink e:khm 8 = 8,
fo:"level up only as a sorcery" 25 = 25, and ft:/flavor: unchanged at 7 = 7 (flavor text is
not 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 a
misreading 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_regexes rewrites the leaf accordingly. Nothing is dropped — the leaf changes
TYPE, from RegexValueNode to StringValueNode, 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:

query Scryfall mirror
o:/target./ e:khm 111 111
o:/target\./ e:khm 5 5
name:/./ e:khm 323 323
name:/\./ e:khm 0 0
o:/\{t\}/ e:khm 48 48
o:/\+1\/\+1/ e:khm 51 51
name:/\,/ e:khm 34 34
cn:/\d/ e:khm 323 323

A pattern that keeps its regex leaf keeps its backslashes byte for byte — \(a.b, ^\(x,
[\]], \bfoo all survive intact, on all eleven regex-capable operators. #926 now pins BOTH
halves 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 metacharacters
per 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: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 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:

query Scryfall substring rule which
t:god 96 104 anchored
t:ape 45 273 anchored — "ape" is inside Shapeshifter, Spellshaper
t:bat 54 92 anchored — and inside Wombat, Incubator
t:ir 1,906 1,906 substringPlane — Ir is a real type line, in no catalog
t:las 43 43 substringPlane — Las Vegas; Bolas still matches
t:art 4,171 4,171 substringCreature — Art Lizard; 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 PR 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, t:gob 563 = t:goblin 563.

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 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 --lib 170 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-budget landed while this was open. Both sides had independently reached
for fancy-regex — that work for a backtrack budget and error taxonomy, this branch for dialect
coverage — so the merge is an integration rather than a text reconciliation. CompiledRegex stays
the compiled form and compile_search_regex became the seam that wraps it in the budget and the
error prefix; the failure latch moved to CompiledRegex::try_is_match, and this branch's
1,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-regex key in card_engine/Cargo.toml, which
cargo refuses to parse, and REGEX_BACKTRACK_NS100 defined twice in filter.rs.

The budget's parse-time validator rejected four of the operators this PR documents.
regex_budget.py measures a pattern with Python's re._parser, which does not speak ARE, so
\y/\Y/\m/\M raised bad escape \yo:/\yizzet\y/ became a user-visible
InvalidRegexPatternError one layer before the engine, rather than the 20 rows it answers on
Scryfall. Neither side's tests covered the intersection. Fixed by running the engine's own
translate_are_escapes over a copy for measurement only: the stored pattern still reaches
PostgreSQL, 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+)+b stops
exhausting the budget once a linear engine exists — fancy-regex hands the plain (a+)+b tail
straight 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
CompiledRegex can execute them, so the engine now supports a construct the parser will never hand
it. That is a security policy rather than a dialect question, so the budget's position stands.

@github-actions github-actions Bot added parser All things parser related dependencies Pull requests that update a dependency file rust Pull requests that update rust code api Changes to the HTTP API / request handling in api_resource.py docs Improvements or additions to documentation card_engine Changes to the Rust query engine (card_engine) python size/L 317-999 changed lines labels Aug 9, 2026
@daveycodez
daveycodez force-pushed the engine-regex-parity branch 2 times, most recently from 85a13f9 to da4def6 Compare August 9, 2026 19:19
@github-actions github-actions Bot added size/XL 1000-3162 changed lines and removed size/L 317-999 changed lines labels Aug 16, 2026
@daveycodez
daveycodez force-pushed the engine-regex-parity branch from 8a4df78 to 61fa5a9 Compare August 16, 2026 12:02
@github-actions github-actions Bot added the tests Test suite additions or changes label Aug 16, 2026
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
daveycodez force-pushed the engine-regex-parity branch from 4f57a51 to f35339f Compare August 21, 2026 23:34
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
daveycodez force-pushed the engine-regex-parity branch from f35339f to 5b7121f Compare August 23, 2026 20:47
`-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
daveycodez force-pushed the engine-regex-parity branch from 5b7121f to 9c214dc Compare August 23, 2026 20:55
@jbylund
jbylund self-requested a review August 26, 2026 12:52
@jbylund jbylund self-assigned this 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api Changes to the HTTP API / request handling in api_resource.py card_engine Changes to the Rust query engine (card_engine) dependencies Pull requests that update a dependency file docs Improvements or additions to documentation parser All things parser related python rust Pull requests that update rust code size/XL 1000-3162 changed lines tests Test suite additions or changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants