Skip to content

Commit 0cb37d0

Browse files
Defer forest reduction until after all contexts are scored
The reducer used to call node.reduce_to() while scoring, inside _ReductionScope.process(). A node shared between verb contexts was thus culled by whichever context happened to score it first, and later contexts scored the already-reduced node - the long-standing "!!! TODO: We might be pruning the parse forest too early here". Split the work into two passes. The scoring pass no longer modifies the forest; it scores each (node, context) combination against the node's full set of child families and records the winning family index along with each child's context signature. The reduction pass then walks the winning tree from the root, reducing every node to the family that won in the context the node is actually used in. A node reached through more than one context in the winning tree can only be physically reduced one way; the first context to reach it decides, as before - but its score is now correct in every context. Nonterminals tagged no_reduce keep all their child families, with the walk descending into every family, preserving query processing behavior. Also: make NULL_SC immutable (a MappingProxyType) since it is shared between all empty nodes; pick the winning family with max() instead of a full sort; and fix a stale comment in process() that described a dict copy that was never made (sc is the node's own accumulator, not a child's memoized dict). Reduction output is unchanged on a 30-sentence regression corpus (identical tree digests), remains deterministic across processes, and performance is unchanged. 139 tests pass; mypy and ruff clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 1b56a5c commit 0cb37d0

1 file changed

Lines changed: 81 additions & 41 deletions

File tree

src/reynir/reducer.py

Lines changed: 81 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@
9696
from typing_extensions import TypedDict, Required
9797

9898
from collections import defaultdict
99+
from types import MappingProxyType
99100

100101
from tokenizer.definitions import BIN_Tuple
101102

@@ -126,9 +127,13 @@ class ResultDict(TypedDict, total=False):
126127
BonusCache = Dict[Tuple[BIN_Terminal, str, BIN_Terminal, BIN_Token], int]
127128
FinalsDict = Dict[int, Set[BIN_Terminal]]
128129
TokensDict = Dict[int, BIN_Token]
130+
# Winning family index and per-family lists of (child, context signature)
131+
DecisionTuple = Tuple[int, List[List[Tuple[Node, Any]]]]
129132

130-
# Reducer result dictionary with a null score
131-
NULL_SC: ResultDict = {"sc": 0}
133+
# Reducer result dictionary with a null score, shared between
134+
# empty nodes; wrapped in a read-only proxy so that it cannot
135+
# be corrupted by accidental modification
136+
NULL_SC: ResultDict = cast(ResultDict, MappingProxyType({"sc": 0}))
132137

133138
_VERB_PREP_BONUS = 7 # Give 7 extra points for a verb/preposition match
134139
_VERB_PREP_PENALTY = -2 # Subtract 2 points for a non-match
@@ -196,43 +201,35 @@ def add_child(self, ix: int, rd: ResultDict) -> None:
196201
if key == "sl":
197202
self.reducer.set_current_verb(rd["sl"])
198203

199-
def process(self, node: Node) -> ResultDict:
204+
def process(self, node: Node) -> Tuple[ResultDict, int]:
200205
"""After accumulating scores for all possible productions
201206
of this nonterminal (families of children), find the
202-
highest scoring one and reduce the tree to that child only"""
207+
highest scoring one and return its result dict along with
208+
its family index. The actual reduction of the tree is
209+
deferred to a separate pass, once all contexts in which
210+
this node occurs have been scored."""
203211
try:
204212

205213
csc = self.sc
206214
if not csc:
207215
# Empty node
208-
return NULL_SC
216+
return NULL_SC, 0
209217

210218
nt = node.nonterminal if node.is_completed else None
211219

212220
if len(csc) == 1:
213221
# Not ambiguous: only one result, do a shortcut
214-
# Will raise an exception if not exactly one value
215-
[sc] = csc.values()
222+
# Will raise an exception if not exactly one item
223+
[(ix, sc)] = csc.items()
216224
else:
217-
# Eliminate all families except the best scoring one
218-
# Sort in decreasing order by score, using the family index
219-
# as a tie-breaker for determinism
220-
s = sorted(csc.items(), key=lambda x: (x[1]["sc"], -x[0]), reverse=True)
221-
# This is the best scoring family
222-
# (and the one with the lowest index
223-
# if there are many with the same score)
224-
ix, sc = s[0]
225-
# If the node nonterminal is marked as "no_reduce",
226-
# we leave the child families in place. This feature
227-
# is used in query processing.
228-
if nt is None or not nt.no_reduce:
229-
# And now for the key action of the reducer:
230-
# Eliminate all other families
231-
node.reduce_to(ix)
225+
# Find the best scoring family, using the lowest
226+
# family index as a tie-breaker for determinism
227+
ix, sc = max(csc.items(), key=lambda x: (x[1]["sc"], -x[0]))
232228

233229
if nt is not None:
234-
# We will be adjusting the result: make sure we do so on
235-
# a separate dict copy (we don't want to clobber the child's dict)
230+
# Adjust the winning family's score. Note that sc is this
231+
# node's own accumulator dict (created in add_child()),
232+
# not a child's memoized dict, so we can modify it freely.
236233
# Get score adjustment for this nonterminal, if any
237234
# (This is the $score(+/-N) pragma from Greynir.grammar)
238235
sc["sc"] += self.reducer._score_adj.get(nt, 0)
@@ -264,7 +261,7 @@ def process(self, node: Node) -> ResultDict:
264261
sc.pop("so", None) # Simpler than if "so" in sc: del sc["so"]
265262
sc.pop("sl", None)
266263

267-
return sc
264+
return sc, ix
268265

269266
finally:
270267
# Make sure we pop everything that was pushed in __init__()
@@ -430,10 +427,18 @@ def context_sig(w: Node) -> Any:
430427
# an enclosed enable_prep_bonus node would capture)
431428
return (vsig(self.get_prep_bonus()), vsig(self.get_current_verb()))
432429

433-
def calc_score(w: Node) -> ResultDict:
430+
# Winning family index and per-family child lists (with the
431+
# context signatures they were scored under), keyed like visited.
432+
# This records the scoring pass's decisions so that the actual
433+
# reduction can be deferred until all contexts have been scored.
434+
decisions: Dict[Tuple[Node, Any], DecisionTuple] = dict()
435+
436+
def calc_score(w: Node, sig: Any) -> ResultDict:
434437
"""Calculate the score of the subtree rooted at w within
435-
the current verb context, memoized on (node, context)"""
436-
sig = context_sig(w)
438+
the current verb context, memoized on (node, context).
439+
The sig parameter is context_sig(w), computed by the caller.
440+
This pass does not modify the forest; it only scores it
441+
and records the winning family of each (node, context)."""
437442
# Has this (node, context) combination been memoized?
438443
v = visited.get((w, sig))
439444
if v is not None:
@@ -447,31 +452,66 @@ def calc_score(w: Node) -> ResultDict:
447452
# of children, i.e. multiple possible derivations:
448453
# Init container for family results
449454
scope = _ReductionScope(self, w)
455+
fam_children: List[List[Tuple[Node, Any]]] = []
450456
# Go through each family and calculate its score
451457
for family_ix, (prod, children) in enumerate(w._families):
452458
scope.start_family(family_ix, prod)
459+
this_fam: List[Tuple[Node, Any]] = []
453460
for ch in children:
454461
if ch is not None:
455-
scope.add_child(family_ix, calc_score(ch))
456-
# Return a dict describing the winning family of children
457-
# (derivation) including an "sc" field for its score.
458-
# !!! TODO: We might be pruning the parse forest too
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.
463-
v = scope.process(w)
464-
# The winning family is now the only remaining family
465-
# of children of this node; the others have been culled.
462+
chsig = context_sig(ch)
463+
this_fam.append((ch, chsig))
464+
scope.add_child(family_ix, calc_score(ch, chsig))
465+
fam_children.append(this_fam)
466+
# Obtain a dict describing the winning family of children
467+
# (derivation), including an "sc" field for its score,
468+
# along with the winning family index
469+
v, chosen_ix = scope.process(w)
470+
decisions[(w, sig)] = (chosen_ix, fam_children)
466471
else:
467472
v = NULL_SC
468473
# Memoize the result for this (node, context) combination
469474
visited[(w, sig)] = v
470475
w.score = v["sc"]
471476
return v
472477

473-
# Start the scoring and reduction process at the root
474-
return calc_score(root_node)
478+
# Nodes already reduced in the reduction pass. A node shared
479+
# between contexts can only be physically reduced one way; the
480+
# first context to reach it in the winning tree decides.
481+
reduced: Set[Node] = set()
482+
483+
def apply_reduction(w: Node, sig: Any) -> None:
484+
"""Second pass: walk the winning tree, reducing each node
485+
to the family that won in the context the node is actually
486+
used in, as recorded by the scoring pass"""
487+
if w in reduced:
488+
return
489+
reduced.add(w)
490+
entry = decisions.get((w, sig))
491+
if entry is None:
492+
# Token or empty node: nothing to reduce
493+
return
494+
chosen_ix, fam_children = entry
495+
nt = w.nonterminal if w.is_completed else None
496+
if nt is not None and nt.no_reduce:
497+
# Leave the child families of this nonterminal in place;
498+
# this feature is used in query processing
499+
for fam in fam_children:
500+
for ch, chsig in fam:
501+
apply_reduction(ch, chsig)
502+
else:
503+
# The key action of the reducer:
504+
# eliminate all families except the winning one
505+
w.reduce_to(chosen_ix)
506+
for ch, chsig in fam_children[chosen_ix]:
507+
apply_reduction(ch, chsig)
508+
509+
# First pass: score the forest without modifying it
510+
root_sig = context_sig(root_node)
511+
result = calc_score(root_node, root_sig)
512+
# Second pass: reduce the forest to the winning tree
513+
apply_reduction(root_node, root_sig)
514+
return result
475515

476516

477517
class OptionFinder(ParseForestNavigator):

0 commit comments

Comments
 (0)