Skip to content

Commit 1e25ca6

Browse files
Make parse results deterministic across runs
Repeated parses of the same sentence could yield different trees whenever the parse forest contained subtrees with exactly equal reduction scores. The root cause was State::getHash() in eparser.cpp XOR-ing raw heap pointers (m_pProd, m_pw) into the hash: the Column hash-bin enumeration order then varied with malloc addresses, changing the order of addFamily() calls when building the SPPF, and thereby which equal-score family the reducer's index-based tie-breaker picked. - eparser.h/eparser.cpp: make State::getHash() content-based, using Production::getId() and a new Label::getHash()/Node::getHash() (nonterminal index, dot, production id, token span) instead of pointer values. State equality is unchanged. - grammar.py: hash Terminal and Nonterminal by their creation-order sequence number instead of id(), so that set/dict iteration order over grammar items is stable across processes and PYTHONHASHSEED values. The cached _hash snapshot is kept separate from _index, which is renumbered after the grammar is read. - test_parse.py: add test_deterministic_reduction with sentences that previously flipped between equal-score parses (verified to fail against the old parser core). - test_native_matching.py: update a comment that documented the old nondeterministic tie-breaking. Parse results are now stable across repeated parses, processes and hash seeds. Note that this can change which of two equally-scored parses is returned, compared to earlier versions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 73b4aff commit 1e25ca6

5 files changed

Lines changed: 72 additions & 14 deletions

File tree

src/reynir/eparser.cpp

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -115,10 +115,18 @@ friend class AllocReporter;
115115

116116
UINT getHash(void) const
117117
{
118-
return ((UINT)this->m_iNt) ^
119-
((UINT)((uintptr_t)this->m_pProd) & 0xFFFFFFFF) ^
120-
(this->m_nDot << 7) ^ (this->m_nStart << 9) ^
121-
(((UINT)((uintptr_t)this->m_pw) & 0xFFFFFFFF) << 1);
118+
// Content-based hash: this must not depend on memory
119+
// addresses, since the hash determines the order in which
120+
// states are enumerated from a Column's hash bins, which
121+
// in turn determines the order of families of children
122+
// in the resulting parse forest. Using pointer values here
123+
// would make parse results nondeterministic between runs
124+
// whenever the reducer encounters exact score ties.
125+
UINT h = ((UINT)this->m_iNt) ^
126+
(this->m_nDot << 7) ^ (this->m_nStart << 9);
127+
h = h * 31 + (this->m_pProd ? this->m_pProd->getId() : (UINT)-1);
128+
h = h * 31 + (this->m_pw ? this->m_pw->getHash() : 0);
129+
return h;
122130
}
123131
BOOL operator==(const State& other) const
124132
{

src/reynir/eparser.h

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,20 @@ class Label {
270270
this->m_nJ == other.m_nJ;
271271
}
272272

273+
// Content-based hash, deliberately independent of memory
274+
// addresses (the production is represented by its id, not
275+
// its pointer), so that hash-derived orderings are stable
276+
// between runs
277+
UINT getHash(void) const
278+
{
279+
UINT h = (UINT)this->m_iNt;
280+
h = h * 31 + this->m_nDot;
281+
h = h * 31 + (this->m_pProd ? this->m_pProd->getId() : (UINT)-1);
282+
h = h * 31 + this->m_nI;
283+
h = h * 31 + this->m_nJ;
284+
return h;
285+
}
286+
273287
};
274288

275289

@@ -310,6 +324,10 @@ class Node {
310324
BOOL hasLabel(const Label& label) const
311325
{ return this->m_label == label; }
312326

327+
// Deterministic content-based hash of this node's label
328+
UINT getHash(void) const
329+
{ return this->m_label.getHash(); }
330+
313331
void dump(Grammar*);
314332

315333
static UINT numCombinations(Node*);

src/reynir/grammar.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -178,12 +178,15 @@ def __init__(self, name: str, fname: Optional[str] = None, line: int = 0) -> Non
178178
# Give all nonterminals a unique, negative sequence number for hashing purposes
179179
self._index = Nonterminal._index
180180
Nonterminal._index -= 1
181-
self._hash = id(self).__hash__()
181+
# Use the creation-order sequence number as the hash. It is
182+
# deterministic between runs (unlike id()), so that iteration
183+
# order over sets and dicts of nonterminals is stable, and it
184+
# never changes during the object's lifetime (unlike self._index,
185+
# which may be renumbered after the grammar has been processed).
186+
self._hash = self._index
182187

183188
def __hash__(self) -> int:
184-
"""Use the id of this nonterminal as a basis for the hash"""
185-
# The index may change after the entire grammar has been
186-
# read and processed; therefore it is not suitable for hashing
189+
"""Return the cached, deterministic hash of this nonterminal"""
187190
return self._hash
188191

189192
def __eq__(self, o: Any) -> bool:
@@ -274,8 +277,13 @@ def __init__(self, name: str) -> None:
274277
self._name = name
275278
self._index = Terminal._index
276279
Terminal._index += 1
277-
# The hash is used quite often so it is worth caching
278-
self._hash = id(self).__hash__()
280+
# The hash is used quite often so it is worth caching.
281+
# Use the creation-order sequence number: it is deterministic
282+
# between runs (unlike id()), so that iteration order over sets
283+
# and dicts of terminals is stable, and it never changes during
284+
# the object's lifetime (unlike self._index, which may be
285+
# renumbered after the grammar has been processed).
286+
self._hash = self._index
279287

280288
def __hash__(self) -> int:
281289
return self._hash

test/test_native_matching.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,10 +131,11 @@
131131
def _parse_results(disable_native: bool):
132132
"""Parse the corpus with a fresh parser, native matching on or off.
133133
Returns, per sentence, the number of parse tree combinations in the
134-
forest (or None if the sentence did not parse). Note that we compare
135-
forest sizes rather than reduced trees, since the reducer may break
136-
exact score ties differently between runs; the forest itself is
137-
fully determined by the token/terminal match results."""
134+
forest (or None if the sentence did not parse). We compare forest
135+
sizes since they are fully determined by the token/terminal match
136+
results, which is exactly what this module tests. (Reduced trees
137+
are nowadays deterministic as well; see test_parse.py::
138+
test_deterministic_reduction.)"""
138139
key = "GREYNIR_DISABLE_CPP_MATCHING"
139140
old = os.environ.get(key)
140141
try:

test/test_parse.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2209,6 +2209,29 @@ def test_þau(r):
22092209
# assert s.tree.S.IP.NP_SUBJ.PP.NP.tidy_text == "þeim Gunnlaugi"
22102210

22112211

2212+
def test_deterministic_reduction(r):
2213+
"""Repeated parses of the same sentence must yield the same tree,
2214+
even when the parse forest contains subtrees with exactly equal
2215+
scores. The ambiguous sentences below used to come out differently
2216+
between runs when the C++ parser's Earley state hash included
2217+
memory addresses, making the family order in the forest - and
2218+
thereby the reducer's tie-breaking - nondeterministic."""
2219+
sentences = [
2220+
"Ása sá sól.",
2221+
"Konan sem kom í heimsókn í gær ætlar að kaupa nýja íbúð í miðbænum.",
2222+
"Hr. Jón Jónsson býr á Laugavegi 26 og á 3,4 milljónir króna í banka.",
2223+
"Það rignir sjaldan í Reykjavík í júlí en þó gerist það stundum.",
2224+
"Tuttugu og þrír hestar, fimm kýr og tólf kindur voru á bænum.",
2225+
]
2226+
for sent in sentences:
2227+
flats = set()
2228+
for _ in range(5):
2229+
s = r.parse_single(sent)
2230+
assert s is not None and s.tree is not None
2231+
flats.add(s.tree.flat)
2232+
assert len(flats) == 1, f"Nondeterministic parse of '{sent}': {flats}"
2233+
2234+
22122235
def test_aukafall(r):
22132236
s = r.parse_single("Mér blöskrar framkoma Páls.")
22142237
assert s and s.tree

0 commit comments

Comments
 (0)