Skip to content

Commit cd7d97e

Browse files
Merge pull request #66 from mideind/fix/deterministic-reduction
Make parse results deterministic across runs
2 parents 73b4aff + f2c13c3 commit cd7d97e

7 files changed

Lines changed: 74 additions & 16 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "reynir"
7-
version = "3.7.1"
7+
version = "3.7.2"
88
description = "A natural language parser for Icelandic"
99
authors = [{ name = "Miðeind ehf.", email = "mideind@mideind.is" }]
1010
maintainers = [{ name = "Miðeind ehf.", email = "mideind@mideind.is" }]

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

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)