Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion docs/pipeline/graph.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,15 @@ cd pipeline/graph && PYTHONPATH=src python -m graph.cli \
--in /path/to/brain-md --out /path/to/brain-md-graphed --min-mentions 2
```

Output is deterministic for a given input: safe to delete and rebuild anytime.
Output is deterministic for a given input: safe to delete and rebuild anytime. The input is the
pages' **content** — not the order the filesystem happens to yield them in, so re-running the build
over an unchanged corpus cannot move anything. Every choice that could tie is decided by a stated
rule: the entity's type and its aliases by most-mentioned, then by **codepoint** order (not locale
collation, so for equal-length names it prefers the more-ASCII spelling); its title by
non-ALL-CAPS → shortest → most-mentioned → codepoint.

One caveat, since it is the same class of surprise: slugs are assigned in key order, and two
different keys can slugify identically (`Foo & Bar` and `Foo + Bar` both give `foo-bar`), with the
second taking a `-2` suffix. So adding an entity whose key sorts earlier *can* move an existing
one's node path — not because of walk order, but because the disambiguation depends on the set of
keys. Rare, and the build rewrites every link consistently, but it is not "nothing ever moves".
17 changes: 13 additions & 4 deletions pipeline/graph/src/graph/entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@


def _best_title(names: Counter) -> str:
"""Canonical title: prefer non-ALL-CAPS, then shorter (fewer suffixes), then most frequent."""
return sorted(names, key=lambda n: (n.isupper(), len(n), -names[n]))[0]
"""Canonical title: prefer non-ALL-CAPS, then shorter (fewer suffixes), then most frequent, then
alphabetical. That last key is what makes it a RULE rather than merely stable: without it a tie
fell through to the Counter's first-encounter order, i.e. the order os.walk yielded the pages."""
return sorted(names, key=lambda n: (n.isupper(), len(n), -names[n], n))[0]


def build_entities(mention_counts, min_mentions: int = 2, registry=None) -> dict:
Expand All @@ -30,7 +32,11 @@ def build_entities(mention_counts, min_mentions: int = 2, registry=None) -> dict
if g["total"] < min_mentions:
continue
registered = registry.entities.get(key) if registry else None
typ = (registered or {}).get("type") or g["types"].most_common(1)[0][0]
# most frequent, then alphabetical. Counter.most_common breaks ties by first-encounter, and
# this value becomes the node's FILE PATH (entities/<type>/<slug>) and every wikilink to it —
# so a tie let an unrelated file added to brain-md relocate an entity and rewrite the links.
typ = ((registered or {}).get("type")
or min(g["types"], key=lambda t: (-g["types"][t], t)))
base = f"entities/{typ}/{slugify(key)}"
slug, i = base, 2
while slug in used: # disambiguate slug collisions
Expand All @@ -40,6 +46,9 @@ def build_entities(mention_counts, min_mentions: int = 2, registry=None) -> dict
"slug": slug,
"title": (registered or {}).get("name") or _best_title(g["names"]),
"type": typ,
"aliases": list(g["names"]), "mentions": g["total"],
# most frequent first, then alphabetical: render_node truncates to aliases[:8], so this
# decides WHICH survive — insertion order made that the walk order too.
"aliases": sorted(g["names"], key=lambda n: (-g["names"][n], n)),
"mentions": g["total"],
}
return entities
60 changes: 60 additions & 0 deletions pipeline/graph/tests/test_entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,66 @@ def test_dominant_type_wins():
ms = [("Initech", "company", 5), ("Initech", "organization", 1)]
ents = build_entities(ms, min_mentions=1)
assert next(iter(ents.values()))["type"] == "company"
# ...and frequency beats the alphabetical tie-break, which only applies to an actual tie. This
# case is the one that discriminates: here the dominant type sorts LATER than the rare one, so a
# purely alphabetical rule would pick "company" and pass the assertion above by coincidence.
ms = [("Initech", "organization", 5), ("Initech", "company", 1)]
ents = build_entities(ms, min_mentions=1)
assert next(iter(ents.values()))["type"] == "organization"


def test_output_is_independent_of_mention_order():
"""docs/pipeline/graph.md promises "Output is deterministic for a given input", and the input is
the pages' CONTENT — not the order os.walk happened to yield them in. Three tie-breaks read a
Counter, which resolves ties by first-encounter, so build_graph fed them raw directory-entry
order: adding an unrelated file to brain-md could flip an entity's type, and with it its node
FILE PATH and every wikilink pointing at it."""
cases = {
"type tie": [("Initech", "company", 1), ("Initech", "organization", 1)],
"title tie": [("Acme Corp", "company", 1), ("Acme GmbH", "company", 1)],
"aliases": [("Acme Corp", "company", 5), ("ACME CORP", "company", 1),
("Acme Corp", "company", 1), ("acme corp", "company", 1)],
}
for label, ms in cases.items():
assert build_entities(ms, min_mentions=1) == build_entities(ms[::-1], min_mentions=1), label


def test_title_prefers_non_all_caps_over_shortest():
"""The documented title rule is an ORDERED list — non-ALL-CAPS first, then shortest — and this
is the only case that pins that order: `IBM` is shorter AND more frequent, yet loses to
`Ibm Global` because it shouts. Without this, reordering the first two keys passes the whole
suite, and docs/pipeline/graph.md would be promising a rule nothing checks.

(`test_dedup_variants_into_one_entity` cannot pin it: there the non-caps name is also the
shortest, so both orderings agree.)"""
from collections import Counter

from graph.entities import _best_title
assert _best_title(Counter({"IBM": 5, "Ibm Global": 1})) == "Ibm Global"
assert _best_title(Counter({"ACME": 3, "Acme Systems Group": 1})) == "Acme Systems Group"
# ...and among equally-shouty names the later keys decide, shortest first
assert _best_title(Counter({"IBM CORP": 1, "IBM": 1})) == "IBM"


def test_tie_breaks_are_alphabetical_not_incidental():
"""The tie-break has to be a stated rule, not just 'stable': most frequent, then alphabetical.
Pinned so a future change cannot make it deterministic-but-arbitrary."""
ents = build_entities([("Initech", "organization", 1), ("Initech", "company", 1)], min_mentions=1)
assert ents["initech"]["type"] == "company" # tie -> alphabetically first
assert ents["initech"]["slug"] == "entities/company/initech"

ents = build_entities([("Acme GmbH", "company", 1), ("Acme Corp", "company", 1)], min_mentions=1)
assert next(iter(ents.values()))["title"] == "Acme Corp"

# aliases: most frequent first, so render_node's aliases[:8] keeps the ones that matter. These
# spellings all normalize to one key, so they are aliases of a single entity rather than three.
ms = [("Acme Corp", "company", 1), ("acme corp", "company", 9), ("ACME CORP", "company", 5)]
ents = build_entities(ms, min_mentions=1)
assert ents["acme"]["aliases"] == ["acme corp", "ACME CORP", "Acme Corp"]
# ...and on a count tie, alphabetically
ms = [("Bee Corp", "company", 2), ("Ape Corp", "company", 2)]
ents = build_entities(ms, min_mentions=1)
assert [e["aliases"] for e in ents.values()] == [["Ape Corp"], ["Bee Corp"]]


def test_slug_collision_disambiguated():
Expand Down
40 changes: 40 additions & 0 deletions pipeline/graph/tests/test_registry_merges.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,46 @@ def test_apply_merge_never_leaves_the_registry_self_contradictory(tmp_path):
assert _alias_conflicts(reg) == before == ["initech"]


def test_build_graph_output_is_byte_identical_under_any_walk_order(tmp_path, monkeypatch):
"""The determinism claim end to end, not just at build_entities: the WHOLE output tree — node
pages, rewritten docs and every wikilink — must be byte-identical however the filesystem yields
the inputs. A type tie used to decide the node's file path, so walk order rewrote links."""
import os
import shutil

brain = tmp_path / "brain"
(brain / "entities/x").mkdir(parents=True)
# a genuine tie: two documents, two guesses at the same company's type
(brain / "entities/x/a.md").write_text(
"---\nmentions:\n - { name: Initech, type: company }\n---\n\n# A\n\nbody\n")
(brain / "entities/x/b.md").write_text(
"---\nmentions:\n - { name: Initech, type: organization }\n---\n\n# B\n\nbody\n")
# the title and alias ties have to live in SEPARATE files: within one file the mention order is
# fixed by the frontmatter, so only cross-file order can flip them
(brain / "entities/x/c.md").write_text(
"---\nmentions:\n - { name: Acme Corp, type: company }\n---\n\n# C\n\nbody\n")
(brain / "entities/x/d.md").write_text(
"---\nmentions:\n - { name: Acme GmbH, type: company }\n---\n\n# D\n\nbody\n")
# ...and enough spellings that render_node's alias list has more than one entry after the title
# is excluded, or its ORDER cannot show up in the output at all
for i, spelling in enumerate(("ACME CORP", "Acme Corp", "acme corp")):
(brain / f"entities/x/e{i}.md").write_text(
f"---\nmentions:\n - {{ name: {spelling}, type: company }}\n---\n\n# E{i}\n\nbody\n")

real_walk = os.walk

def snapshot(reverse):
monkeypatch.setattr(os, "walk", lambda root, **kw: [
(d, sorted(dirs, reverse=reverse), sorted(files, reverse=reverse))
for d, dirs, files in real_walk(root, **kw)])
out = tmp_path / f"out-{reverse}"
shutil.rmtree(out, ignore_errors=True)
build_graph(str(brain), str(out), min_mentions=1)
return {str(p.relative_to(out)): p.read_text() for p in out.rglob("*.md")}

assert snapshot(False) == snapshot(True)


def test_build_graph_with_registry_writes_canonical_node(tmp_path):
brain = tmp_path / "brain"
(brain / "entities/g").mkdir(parents=True)
Expand Down
Loading