Skip to content

Commit 1b56a5c

Browse files
Fix verb context leakage in reducer memoization
The reducer memoized subtree scores by (node, traversal key), minting a fresh key only when descending into an enable_prep_bonus-tagged child (SagnInnskot). The untagged wrapper nonterminals above it (e.g. SagnInnskotAtv, SagnInnskotAtv?) were memoized under the enclosing key, and such a wrapper is often shared between verb contexts - for example between a relative clause and its main clause. The first-visited context's score, including its verb/preposition bonus, was then reused verbatim in the other context. In "Það átti sér stað í umdæmi lögreglustöðvar tvö, sem sér um Hafnarfjörð og Garðabæ", the main clause reused the +7 'sjá um' bonus that belongs inside the relative clause, tying the two attachments at 39-39; the family-index tie-break then pulled "um Hafnarfjörð og Garðabæ" out of the relative clause, visibly truncating it on greynir.is. Replace the traversal-key machinery with context-signature memoization: the memo key is (node, sig) where sig captures exactly the state a subtree score can depend on - the active prep-bonus verb list and the current verb, as id-tuples. Token nodes are keyed by the prep-bonus context alone; begin_prep_scope-tagged nodes, noun phrases and empty nodes reset the context on entry and get a neutral signature, preserving memo sharing. Contexts now follow reducer state rather than traversal order, so shared subtrees can never leak scores between verb contexts, and identical contexts share memoized scores instead of being re-scored per encounter. enter_key_scope, exit_key_scope, _PREP_SCOPE_SET and KeyTuple are removed. Reduction remains deterministic; performance is unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent c909d53 commit 1b56a5c

2 files changed

Lines changed: 116 additions & 83 deletions

File tree

src/reynir/reducer.py

Lines changed: 66 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -78,12 +78,17 @@
7878
memoize the score of these nodes by context, so that the scores
7979
between contexts can be different.
8080
81-
Verb contexts span from tree nodes tagged with "enable_prep_bonus"
82-
(typically the SagnInnskot nonterminal), through their descendant nodes down
83-
to those tagged with "begin_prep_scope" or "purge_prep", or noun phrase
84-
nonterminal nodes ("Nl_*"). The preposition nodes that actually receive
85-
different scores depending on the context are terminal nodes whose names
86-
have the form fs_*.
81+
This is done by memoizing subtree scores on the tuple (node, context
82+
signature), where the context signature captures the two pieces of
83+
reducer state that a subtree score can depend on: the preposition
84+
bonus verbs in effect (established by nodes tagged "enable_prep_bonus",
85+
typically SagnInnskot) and the current verb (which an enclosed
86+
"enable_prep_bonus" node would capture). Nodes that reset both on
87+
entry - those tagged "begin_prep_scope", and noun phrase nonterminals
88+
("Nl_*") - are context-independent and get a neutral signature,
89+
preserving full sharing of their memoized scores. The preposition
90+
nodes that actually receive different scores depending on the context
91+
are terminal nodes whose names have the form fs_*.
8792
8893
"""
8994

@@ -121,7 +126,6 @@ class ResultDict(TypedDict, total=False):
121126
BonusCache = Dict[Tuple[BIN_Terminal, str, BIN_Terminal, BIN_Token], int]
122127
FinalsDict = Dict[int, Set[BIN_Terminal]]
123128
TokensDict = Dict[int, BIN_Token]
124-
KeyTuple = Tuple[Node, int]
125129

126130
# Reducer result dictionary with a null score
127131
NULL_SC: ResultDict = {"sc": 0}
@@ -132,11 +136,6 @@ class ResultDict(TypedDict, total=False):
132136

133137
_CASES_SET = BIN_Token.CASES_SET
134138

135-
# Tags of nonterminals that allow us to stop copying nodes
136-
# in the preposition unpacker
137-
_PREP_SCOPE_SET = frozenset(
138-
("begin_prep_scope", "purge_prep", "no_prep", "enable_prep_bonus")
139-
)
140139
_CONTAINED_VERBS_SET = frozenset(("begin_prep_scope", "purge_verb"))
141140

142141
# BÍN categories ('fl') of person and entity names
@@ -386,61 +385,60 @@ def visit_token(self, node: Node) -> ResultDict:
386385
return d
387386

388387
def go(self, root_node: Node) -> ResultDict:
389-
"""Perform the reduction, but first split the tree underneath
390-
nodes that have the enable_prep_bonus tag"""
391-
392-
# Memoization/caching dict, keyed by node and memoization key
393-
visited: Dict[KeyTuple, ResultDict] = dict()
394-
# Current memoization key
395-
current_key = 0
396-
# Next memoization key to use
397-
next_key = 0
398-
399-
def enter_key_scope(node: Node) -> bool:
400-
"""Return True for a node whose score should not be
401-
memoized within the shared packed parse forest"""
402-
if not node.is_completed or node.nonterminal is None:
403-
return False
404-
return node.nonterminal.has_tag("enable_prep_bonus")
405-
406-
def exit_key_scope(node: Node) -> bool:
407-
"""Return True if it is safe to resume memoization
408-
of subtree scores from this node onwards"""
409-
if not node.is_completed:
410-
return False
411-
nt = node.nonterminal
412-
if nt is not None:
413-
if nt.has_any_tag(_PREP_SCOPE_SET):
414-
# Entering a subtree that has its own scope:
415-
# resume memoization until further notice
416-
return True
417-
if nt.is_noun_phrase:
418-
# Once we've gone through a preposition node,
419-
# it is safe to memoize the enclosed noun phrase subtree
420-
return True
421-
if node.is_empty:
422-
# Explicitly nullable nonterminal with no child:
423-
# always OK to memoize
424-
return True
425-
return False
388+
"""Perform the reduction, scoring shared packed subtrees
389+
separately for each distinct verb context they occur in"""
390+
391+
# Memoization/caching dict, keyed by node and context signature
392+
visited: Dict[Tuple[Node, Any], ResultDict] = dict()
393+
# Signature of a context-independent subtree
394+
NEUTRAL: Tuple[Any, Any] = (None, None)
395+
396+
def vsig(vl: Optional[VerbList]) -> Any:
397+
"""Hashable identity signature of a verb list"""
398+
return (
399+
None
400+
if vl is None
401+
else tuple((id(t), id(tok)) for t, tok in vl)
402+
)
403+
404+
def context_sig(w: Node) -> Any:
405+
"""Return the part of the reducer state that the score of
406+
the subtree rooted at w can depend on. Subtrees that are
407+
shared between different verb contexts must be scored
408+
separately for each context, since preposition terminals
409+
within them receive different verb/preposition bonuses."""
410+
if w._token is not None:
411+
# A token score depends only on the active
412+
# preposition bonus verbs (and only for fs terminals,
413+
# but the signature is cheap enough to include always)
414+
pb = self.get_prep_bonus()
415+
return None if pb is None else vsig(pb)
416+
if w.is_completed:
417+
nt = w.nonterminal
418+
if nt is not None and (
419+
nt.has_tag("begin_prep_scope") or nt.is_noun_phrase
420+
):
421+
# This node resets both the prep bonus zone and the
422+
# current verb on entry, so its score is the same
423+
# in all contexts
424+
return NEUTRAL
425+
if w.is_empty:
426+
# Explicitly nullable nonterminal with no child
427+
return NEUTRAL
428+
# The score may depend both on the enclosing prep bonus zone
429+
# (for enclosed fs terminals) and on the current verb (which
430+
# an enclosed enable_prep_bonus node would capture)
431+
return (vsig(self.get_prep_bonus()), vsig(self.get_current_verb()))
426432

427433
def calc_score(w: Node) -> ResultDict:
428-
"""Navigate from (w, current_key) where w is a node and current_key
429-
is an integer navigation key, carefully controlling the memoization
430-
of already visited nodes. When navigating into
431-
nodes marked enable_prep_bonus, we create a new unique
432-
navigation key, since such nodes - although stored in shared
433-
packed form - may have different scores depending on the
434-
enclosing (verb) context and thus should not share memoized results.
435-
"""
436-
nonlocal current_key, next_key
437-
# Has this (node, current_key) tuple been memoized?
438-
v = visited.get((w, current_key))
434+
"""Calculate the score of the subtree rooted at w within
435+
the current verb context, memoized on (node, context)"""
436+
sig = context_sig(w)
437+
# Has this (node, context) combination been memoized?
438+
v = visited.get((w, sig))
439439
if v is not None:
440440
# Yes: return the previously calculated result
441441
return v
442-
# We have not seen this (node, current_key) combination before:
443-
# reduce it, calculate its score and memoize it
444442
if w._token is not None:
445443
# Return the score of this terminal option
446444
v = self.visit_token(w)
@@ -454,36 +452,21 @@ def calc_score(w: Node) -> ResultDict:
454452
scope.start_family(family_ix, prod)
455453
for ch in children:
456454
if ch is not None:
457-
prev_key = current_key
458-
if enter_key_scope(ch):
459-
# This child subtree has an enable_prep_bonus flag:
460-
# make sure we navigate separately through it
461-
# sincle enclosed prepositions may have different
462-
# scores in other subtrees.
463-
# Generate a new unique memoization key to use
464-
# when navigating through this child subtree.
465-
next_key += 1
466-
current_key = next_key
467-
elif current_key != 0 and exit_key_scope(ch):
468-
# We no longer need a separate memoization key
469-
# for this child subtree
470-
current_key = 0
471455
scope.add_child(family_ix, calc_score(ch))
472-
current_key = prev_key
473456
# Return a dict describing the winning family of children
474457
# (derivation) including an "sc" field for its score.
475458
# !!! TODO: We might be pruning the parse forest too
476-
# !!! early here - there could be a different verb scope
477-
# !!! above this node that would cause a different child
478-
# !!! to be culled. However a test case to demonstrate this
479-
# !!! has yet to be identified/created.
459+
# !!! early here - a node shared between contexts is
460+
# !!! culled by whichever context scores it first, even
461+
# !!! though a later context might prefer a different
462+
# !!! child family.
480463
v = scope.process(w)
481464
# The winning family is now the only remaining family
482465
# of children of this node; the others have been culled.
483466
else:
484467
v = NULL_SC
485-
# Memoize the result for this (node, current_key) combination
486-
visited[(w, current_key)] = v
468+
# Memoize the result for this (node, context) combination
469+
visited[(w, sig)] = v
487470
w.score = v["sc"]
488471
return v
489472

test/test_parse.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2450,6 +2450,55 @@ def test_skommu_eftir_ad(r) -> None:
24502450
assert s and s.tree
24512451

24522452

2453+
def _rel_clause_pp(r, sentence: str) -> str:
2454+
"""Parse and return the contents of the first CP-REL in flat form"""
2455+
s = r.parse_single(sentence)
2456+
assert s and s.tree
2457+
flat = s.tree.flat
2458+
assert "CP-REL" in flat
2459+
return flat[flat.index("CP-REL") : flat.index("/CP-REL")]
2460+
2461+
2462+
def test_rel_clause_pp_attachment(r) -> None:
2463+
"""A PP that matches the relative clause verb ('sjá um X') must stay
2464+
inside the relative clause instead of escaping to the main clause.
2465+
This used to fail when a shared preposition subtree was scored in
2466+
one verb context and the memoized score reused in another."""
2467+
for sentence in (
2468+
"Það átti sér stað í umdæmi lögreglustöðvar tvö, "
2469+
"sem sér um Hafnarfjörð og Garðabæ.",
2470+
"Slysið varð í umdæmi stöðvarinnar, sem sér um Hafnarfjörð og Garðabæ.",
2471+
"Hún las bókina um konuna, sem sér um Hafnarfjörð.",
2472+
):
2473+
assert "fs_þf" in _rel_clause_pp(r, sentence), sentence
2474+
# Exact tree for the sentence from greynir.is that exposed the bug
2475+
s = r.parse_single(
2476+
"Það átti sér stað í umdæmi lögreglustöðvar tvö, "
2477+
"sem sér um Hafnarfjörð og Garðabæ."
2478+
)
2479+
assert s and s.tree
2480+
assert (
2481+
s.tree.flat_with_all_variants
2482+
== "S0 S-MAIN IP NP-SUBJ pfn_et_hk_nf_p3 /NP-SUBJ VP VP "
2483+
"so_2_þgf_þf_et_fh_gm_p3_þt /VP NP-IOBJ abfn_þgf /NP-IOBJ "
2484+
"NP-OBJ no_et_kk_þf PP P fs_þf /P NP no_et_hk_þf "
2485+
"NP-POSS no_ef_et_kvk to_ft_hk_nf /NP-POSS /NP /PP p "
2486+
"CP-REL C stt /C IP VP VP so_0_et_fh_gm_nt_p3 /VP "
2487+
"PP P fs_þf /P NP no_et_kk_þf C st /C no_et_kk_þf /NP /PP "
2488+
"/VP /IP /CP-REL /NP-OBJ /VP /IP /S-MAIN p /S0"
2489+
)
2490+
# A preposition that matches the main verb (fresta e-u vegna e-s)
2491+
# must still attach to the verb phrase, not the object noun phrase
2492+
s = r.parse_single("Dómarinn frestaði mótinu vegna veðurs.")
2493+
assert s and s.tree
2494+
assert (
2495+
s.tree.flat
2496+
== "S0 S-MAIN IP NP-SUBJ no_et_nf_kk /NP-SUBJ VP VP so_1_þgf_et_p3 /VP "
2497+
"NP-OBJ no_et_þgf_hk /NP-OBJ PP P fs_ef /P NP no_et_ef_kk /NP /PP "
2498+
"/VP /IP /S-MAIN p /S0"
2499+
)
2500+
2501+
24532502
if __name__ == "__main__":
24542503
# When invoked as a main module, do a verbose test
24552504
from reynir import Greynir
@@ -2509,4 +2558,5 @@ def test_skommu_eftir_ad(r) -> None:
25092558
test_pronoun_postposed_adjective(g)
25102559
test_designation_numeral(g)
25112560
test_skommu_eftir_ad(g)
2561+
test_rel_clause_pp_attachment(g)
25122562
g.__class__.cleanup()

0 commit comments

Comments
 (0)