Skip to content

Commit 978c6d0

Browse files
Implement native (C++) token/terminal matching (Phases 0-2)
Move the majority of token/terminal match decisions from Python callbacks into the C++ Earley core, per doc/cpp-matching-design.md. - binparser.py: build_matching_table() classifies each terminal into a native matching kind (strong/lemma literals, default-category, noun, adjective, adverb, masked abfn/pfn, töl) or T_PYTHON for semantics that stay in Python (verbs, prepositions, proper names, ending constraints, etc.); encode_token_matching_data() packs each token's BÍN meanings into MeaningRec arrays sharing the existing VBIT/fbits bit space. Literal lemmas/forms are interned so literal matching reduces to integer identity. - eparser.cpp/.h: Parser::evalMatch() decides matches natively from the TerminalSpec table and per-column TokenRec data (fetched once per column via the new MeaningsFunc callback and cached per token key); Column::matches() falls back to the Python callback for T_PYTHON terminals and non-word tokens. A parity mode double-checks every native decision against the Python matcher. - fastparser.py: install the table at parser construction, gated so that subclasses which override token wrapping (e.g. GreynirCorrect) automatically keep pure Python matching; also disable via the _USE_CPP_MATCHING class attribute or GREYNIR_DISABLE_CPP_MATCHING=1. Parity mode via GREYNIR_MATCHING_PARITY=1. Phase 0 measurement: 69% of match queries are natively answerable. Measured effect: ~89% of matching callbacks eliminated; typical fresh-text parsing 2.2x faster on CPython 3.13 (1.40s -> 0.65s for the benchmark set), test suite ~20% faster, PyPy ~20% faster cold; warm-cache and long-sentence workloads unchanged. Query-level parity: zero discrepancies over the test corpus. Incidental pre-existing finding, documented in the design doc: reduction of exact score ties is unstable across repeated parses, so the new equivalence test compares forests, not reduced trees. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 2c06e89 commit 978c6d0

7 files changed

Lines changed: 858 additions & 6 deletions

File tree

doc/cpp-matching-design.md

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,38 @@
11
# Design sketch: moving token/terminal matching into the C++ core
22

3-
Status: DRAFT for discussion — 2026-07-13
3+
Status: IMPLEMENTED (Phases 0-2) — 2026-07-14
4+
5+
## 0. Results (added after implementation)
6+
7+
Phase 0 instrumentation over a 242-sentence corpus: 1.74M match queries,
8+
of which 69.1% classify as natively answerable (55.9% lemma literals).
9+
10+
Implemented as designed, with one delivery difference: token matching
11+
data is fetched via a `MeaningsFunc` callback once per Earley column
12+
(mirroring the existing `alloc_func` handshake) and cached per token key.
13+
Additionally, unknown-word tokens (no BÍN meanings) are handled natively
14+
via a per-terminal constant flag (`TF_MATCHES_EMPTY`).
15+
16+
Measured (CPython 3.13, typical 10-25 token sentences, cold matching
17+
cache): **2.2x faster** overall parse time (1.40s -> 0.65s for the
18+
8-sentence benchmark set); ~89% of Python matching callbacks eliminated.
19+
The full test suite runs ~20% faster. PyPy 3.11: ~20% faster cold.
20+
Warm-cache and long-sentence workloads: unchanged, as predicted.
21+
22+
Verification: query-level parity mode (`GREYNIR_MATCHING_PARITY=1`,
23+
where every native decision is compared against the Python matcher)
24+
reports zero discrepancies over the test corpus; the full test suite
25+
passes with the fast path enabled by default. The fast path can be
26+
disabled with `GREYNIR_DISABLE_CPP_MATCHING=1`, via the
27+
`Fast_Parser._USE_CPP_MATCHING` class attribute, and is automatically
28+
disabled for parser subclasses that override token wrapping
29+
(the GreynirCorrect compatibility gate).
30+
31+
Incidental finding during verification: reduction of exact score ties
32+
is not stable across repeated parses of the same sentence (independent
33+
of this work - it reproduces with pure Python matching); see
34+
test_native_matching.py for why forests, not reduced trees, are
35+
compared there.
436

537
## 1. Motivation and measured cost
638

src/reynir/binparser.py

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ class used for parsing text.
6262
import os
6363
import time
6464
import re
65+
import struct
6566

6667
from datetime import datetime
6768
from functools import reduce, lru_cache
@@ -2537,3 +2538,254 @@ def describe_token(
25372538
else:
25382539
d["v"] = t.val
25392540
return d
2541+
2542+
2543+
# ----------------------------------------------------------------------
2544+
# Native (C++) token/terminal matching support
2545+
#
2546+
# The functions below build a matching table that enables the C++
2547+
# Earley parser core (eparser.cpp) to decide most token/terminal match
2548+
# queries natively, without calling back into Python. Terminals whose
2549+
# matching semantics are not natively encodable (verbs, prepositions,
2550+
# proper names, ending constraints, etc.) are marked T_PYTHON and
2551+
# continue to be matched through the regular Python callback.
2552+
# The binary record layouts here mirror, byte for byte, the TerminalSpec,
2553+
# MeaningRec and TokenRec structures declared in eparser.h.
2554+
2555+
# Terminal spec kinds (see eparser.h)
2556+
_T_PYTHON = 0
2557+
_T_TEXT = 1
2558+
_T_TEXT_CAT = 2
2559+
_T_LEMMA = 3
2560+
_T_CAT_DEFAULT = 4
2561+
_T_CAT_MASK = 5
2562+
_T_CAT_FIRST = 6
2563+
_T_CAT_NOUN = 7
2564+
_T_CAT_LO = 8
2565+
_T_CAT_AO = 9
2566+
2567+
# Terminal spec flags
2568+
_TF_ABBREV = 0x0100
2569+
_TF_MM_EXCLUDE = 0x0200
2570+
_TF_MATCHES_EMPTY = 0x0400
2571+
2572+
# Token header flags
2573+
_TKF_FAST = 0x00010000
2574+
_TKF_EMPTY_WORD = 0x00020000
2575+
2576+
# Meaning record flags
2577+
_MF_NO_BEYGING = 1
2578+
_MF_NAME_FL = 2
2579+
_MF_IS_NOUN = 4
2580+
_MF_IS_LO_SO = 8
2581+
2582+
# Terminal categories whose matchers cannot be encoded natively
2583+
_PYTHON_FIRSTS = frozenset(("so", "eo", "fs", "person", "gata", "sérnafn", "stt"))
2584+
2585+
_STRUCT_SPEC = struct.Struct("<IIIIQQ") # TerminalSpec
2586+
_STRUCT_HDR = struct.Struct("<II") # TokenRec
2587+
_STRUCT_REC = struct.Struct("<IIIIQ") # MeaningRec
2588+
_STRUCT_MASKS = struct.Struct("<QQQQQ") # MatchMasks
2589+
2590+
2591+
class MatchingTable:
2592+
2593+
"""Precomputed data enabling the C++ parser core to decide most
2594+
token/terminal matches natively; built once per loaded grammar"""
2595+
2596+
__slots__ = ("specs", "num_specs", "masks", "lexicon", "catmap")
2597+
2598+
def __init__(
2599+
self,
2600+
specs: bytes,
2601+
num_specs: int,
2602+
masks: bytes,
2603+
lexicon: Dict[str, int],
2604+
catmap: Dict[str, int],
2605+
) -> None:
2606+
self.specs = specs
2607+
self.num_specs = num_specs
2608+
self.masks = masks
2609+
# Interned literal lemma/form strings from the grammar
2610+
self.lexicon = lexicon
2611+
# Interned word category (ordfl/terminal first part) strings
2612+
self.catmap = catmap
2613+
2614+
2615+
def _make_terminal_spec(
2616+
t: Terminal, lexicon: Dict[str, int], catmap: Dict[str, int]
2617+
) -> bytes:
2618+
"""Classify a terminal and encode its native matching spec.
2619+
Any terminal that is not fully understood is conservatively
2620+
classified as T_PYTHON."""
2621+
2622+
def intern_str(s: str) -> int:
2623+
ix = lexicon.get(s)
2624+
if ix is None:
2625+
ix = lexicon[s] = len(lexicon) + 1
2626+
return ix
2627+
2628+
def intern_cat(s: str) -> int:
2629+
ix = catmap.get(s)
2630+
if ix is None:
2631+
ix = catmap[s] = len(catmap) + 1
2632+
return ix
2633+
2634+
kind = _T_PYTHON
2635+
flags = 0
2636+
cat = 0
2637+
lit = 0
2638+
fbits = 0
2639+
mask = 0
2640+
# Note: exact type checks, not isinstance(); terminal subclasses
2641+
# (such as SequenceTerminal, or terminals from derived packages)
2642+
# may have different matching semantics and stay on the Python path
2643+
if type(t) is BIN_LiteralTerminal:
2644+
first = t._first
2645+
if t._strong:
2646+
if t._cat is None:
2647+
# "form": pure token text identity (shortcut_match)
2648+
kind = _T_TEXT
2649+
lit = intern_str(first)
2650+
elif t._match_cat != "punctuation":
2651+
# "form:cat": token text identity plus meaning category
2652+
kind = _T_TEXT_CAT
2653+
lit = intern_str(first)
2654+
cat = intern_cat(t._match_cat or "")
2655+
else:
2656+
if t._match_cat != "punctuation" and not t.has_any_vbits(
2657+
BIN_Token.VBIT_ENDING | BIN_Token.VBIT_SCASES
2658+
):
2659+
# 'lemma:cat'_variants: lemma identity, optional category,
2660+
# default matcher feature logic
2661+
kind = _T_LEMMA
2662+
lit = intern_str(first)
2663+
cat = intern_cat(t._match_cat) if t._match_cat else 0
2664+
fbits = t._fbits
2665+
if (
2666+
t._match_cat == "so"
2667+
and not first[:1].isupper()
2668+
and not t.is_mm
2669+
):
2670+
# matcher_lemma_literal(): verb lemma literals don't
2671+
# match middle voice meanings unless _mm is specified
2672+
flags |= _TF_MM_EXCLUDE
2673+
elif type(t) is BIN_Terminal:
2674+
first = t.first
2675+
if first not in _PYTHON_FIRSTS and not t.has_any_vbits(
2676+
BIN_Token.VBIT_ENDING | BIN_Token.VBIT_SCASES
2677+
):
2678+
fbits = t._fbits
2679+
if first == "no":
2680+
kind = _T_CAT_NOUN
2681+
if t.is_abbrev:
2682+
flags |= _TF_ABBREV
2683+
if t.has_vbits(
2684+
BIN_Token.VBIT_ET | BIN_Token.VBIT_HK
2685+
) and not t.has_vbits(BIN_Token.VBIT_GR):
2686+
# This terminal matches unknown words,
2687+
# cf. the fallback in BIN_Token.matches_WORD()
2688+
flags |= _TF_MATCHES_EMPTY
2689+
elif first == "lo":
2690+
kind = _T_CAT_LO
2691+
cat = intern_cat("lo")
2692+
elif first == "ao":
2693+
kind = _T_CAT_AO
2694+
cat = intern_cat("ao")
2695+
elif first == "abfn":
2696+
# Check the case only (matcher_abfn)
2697+
kind = _T_CAT_MASK
2698+
cat = intern_cat("abfn")
2699+
mask = BIN_Token.VBIT_CASES
2700+
elif first == "pfn":
2701+
# Check the case and number only (matcher_pfn)
2702+
kind = _T_CAT_MASK
2703+
cat = intern_cat("pfn")
2704+
mask = BIN_Token.VBIT_CASES | BIN_Token.VBIT_NUMBER
2705+
elif first == "töl":
2706+
# Category check only (matcher_töl)
2707+
kind = _T_CAT_FIRST
2708+
cat = intern_cat("töl")
2709+
else:
2710+
# All remaining categories use matcher_default()
2711+
kind = _T_CAT_DEFAULT
2712+
cat = intern_cat(first)
2713+
return _STRUCT_SPEC.pack(kind | flags, cat, lit, 0, fbits, mask)
2714+
2715+
2716+
def build_matching_table(grammar: "BIN_Grammar") -> MatchingTable:
2717+
"""Build (and cache on the grammar object) the native matching table"""
2718+
table: Optional[MatchingTable] = getattr(grammar, "_matching_table", None)
2719+
if table is not None:
2720+
return table
2721+
lexicon: Dict[str, int] = {}
2722+
catmap: Dict[str, int] = {}
2723+
# Pre-intern all known BÍN categories and their mapped forms, so that
2724+
# common meaning categories receive ids regardless of terminal order
2725+
for c in BIN_Token.KIND:
2726+
catmap.setdefault(c, len(catmap) + 1)
2727+
for c in BIN_Token.KIND.values():
2728+
catmap.setdefault(c, len(catmap) + 1)
2729+
num = grammar.num_terminals
2730+
empty = _STRUCT_SPEC.pack(_T_PYTHON, 0, 0, 0, 0, 0)
2731+
specs: List[bytes] = [empty] * (num + 1)
2732+
for t in grammar.terminals.values():
2733+
if 1 <= t.index <= num:
2734+
specs[t.index] = _make_terminal_spec(t, lexicon, catmap)
2735+
masks = _STRUCT_MASKS.pack(
2736+
BIN_Token.VBIT_GENDERS,
2737+
BIN_Token.VBIT_NUMBER,
2738+
BIN_Token.VBIT_ET,
2739+
BIN_Token.VBIT_GR,
2740+
BIN_Token.VBIT_MM,
2741+
)
2742+
table = MatchingTable(b"".join(specs), num + 1, masks, lexicon, catmap)
2743+
setattr(grammar, "_matching_table", table)
2744+
return table
2745+
2746+
2747+
def encode_token_matching_data(token: BIN_Token, table: MatchingTable) -> bytes:
2748+
"""Encode a token, along with its BÍN meanings, into the packed
2749+
native matching format (a TokenRec header followed by MeaningRec
2750+
entries; see eparser.h)"""
2751+
lexicon = table.lexicon
2752+
catmap = table.catmap
2753+
form_id = lexicon.get(token.t1_lower, 0)
2754+
count_flags = 0
2755+
parts: List[bytes] = []
2756+
if token.t0 == TOK.WORD:
2757+
if token.t2:
2758+
genders_map = BIN_Token.GENDERS_MAP
2759+
genders_set = BIN_Token.GENDERS_SET
2760+
kind_map = BIN_Token.KIND
2761+
get_fbits = BIN_Token.get_fbits
2762+
pack = _STRUCT_REC.pack
2763+
for m in token.meanings:
2764+
ordfl = m.ordfl
2765+
mf = 0
2766+
if m.beyging == "-":
2767+
mf |= _MF_NO_BEYGING
2768+
if m.fl == "nafn" or m.fl == "ætt":
2769+
mf |= _MF_NAME_FL
2770+
if ordfl in genders_set:
2771+
mf |= _MF_IS_NOUN
2772+
elif ordfl == "lo" or ordfl == "so":
2773+
mf |= _MF_IS_LO_SO
2774+
# For nouns, the gender is coded in ordfl; append it to
2775+
# the beyging field so that the corresponding feature bit
2776+
# is included (mirrors matcher_default/matcher_no)
2777+
fb = get_fbits(m.beyging + genders_map.get(ordfl, ""))
2778+
parts.append(
2779+
pack(
2780+
lexicon.get(m.stofn, 0),
2781+
catmap.get(ordfl, 0),
2782+
catmap.get(kind_map.get(ordfl, ordfl), 0),
2783+
mf,
2784+
fb,
2785+
)
2786+
)
2787+
count_flags = _TKF_FAST | len(parts)
2788+
else:
2789+
# Word token with no BÍN meanings (unknown word)
2790+
count_flags = _TKF_EMPTY_WORD
2791+
return _STRUCT_HDR.pack(form_id, count_flags) + b"".join(parts)

0 commit comments

Comments
 (0)