Knowledge graph entity alignment
September 17, 2026 · View on GitHub
A key problem in knowledge graphs is deciding whether an incoming entity duplicates an
existing one, especially when natural language from disparate sources is all that’s
available. Given potential duplicate pairs, a single TypeSafeScoredecides
whether each pair is a duplicate, or whether it deserves a closer look from a curator.Suppose two data sources describe overlapping sets of the same things, and you need to
know which entry on one side is the same thing as which entry on the other. A knowledge
graph calls those entriesentities, and holds the facts recorded about each. Some cheap
but rough first pass has already compared the two sources and picked out 450 pairs worth a
closer look. What remains is to make a judgment call on each pair.Merging two entities inappropriately is the more expensive mistake, since every fact about
either entity now describes the merged one, and anything linked to either comes along too.
Undoing it later means working out which fact came from where. Missing a match only leaves
a duplicate, so the judgment call needs a third option: pairs that are neither safe to
merge nor safe to drop.The judgment is aScorewith one level for each of the three outcomes:- different product— leave the two entities unlinked
- related, but possibly not the same— hand it to a curator to decide
- same product— merge them
We use a Score because we want to attach a semantic label, the score criteria, directly to
each outcome, including the middle outcome. A Noul question could accomplish this
indirectly through
thresholding on its output instead, and a Choice would lose the ordered relationship of the
three outcomes.Next, for each field of the entity we want to consider,Noulquestions about whether
those
fields match can ride along in the same request. These nouls provide more detailed
information for the curator, if the score lands neither in the “same product” nor
“different product” levels.You end up with aroute()that takes one candidate pair and returns one of the three
outcomes, with no threshold you had to fit to your own data.one request, four questions
different
same
related
which field they disagree on
one candidate pair both entities, one state
**Score:**how do the two relate? · different product · related, but possibly not the same · same product
**Nouls:**one per compared field · same name? · same brewery? · same style?
round to the nearest level
leave unlinked
assert sameAs
curator queue
Setup
pip install matplotlib ipython "typesafe-sdk>=0.5.7" cooksafe --extra-index-url https://pypi.typesafe.ai/
then setTYPESAFE_API_KEY. Every call is cached tojson_cache.json, which ships with
the cookbook, so re-rendering replays the published numbers without calling the API. Delete
that file to re-run everything live.Numbers below came fromjev-1.12on 2026-08-11.```
import json
import os
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import matplotlib import matplotlib.pyplot as plt from cooksafe import JsonCache, make_playground_link from IPython.display import Markdown, display from typesafe_sdk import Noul, Score, TypeSafeClient
matplotlib.use("Agg") # headless render
TYPESAFE_MODEL = "jev-1.12" MAX_WORKERS = 6 # small pool; the public endpoint rate-limits above roughly eight
client = TypeSafeClient( api_key=os.environ.get( "TYPESAFE_API_KEY", "cache-only" ), # keyless kernels replay the cache
base_url=os.environ.get("TYPESAFE_ENDPOINT"), timeout=120.0, ) json_cache = JsonCache(Path("json_cache.json"))
## Load the candidate pairs
The pairs come from a published benchmark set, the Beer data from the Magellan collection:
two beer catalogues scraped from different websites, already cut down to 450 pairs by that
first rough pass. Each entity carries four fields: name, brewery, style, and alcohol
content. Each pair also carries`known_same_as`, the benchmark’s own answer.The text is left exactly as published, without pre-processing: HTML entities that were
never converted back to characters, apostrophes split off as separate words, a few
characters decoded wrongly.One request goes out per pair, so what you spend follows the number of pairs you were
handed rather than the size of either source.```
PAIRS = json.loads(Path("candidate_pairs.json").read_text(encoding="utf-8"))
BY_ID = {pair["id"]: pair for pair in PAIRS}
print(f"{len(PAIRS)} candidate pairs. The first one, as the model will see it:")
print(json.dumps({k: PAIRS[0][k] for k in ("entity_a", "entity_b")}, indent=2)[:420])
450 candidate pairs. The first one, as the model will see it:
{
"entity_a": {
"name": "C N Red Imperial Red Ale",
"brewery": "Redwood Lodge",
"style": "American Amber / Red Ale",
"abv": "8.10 %"
},
"entity_b": {
"name": "Kinetic Infrared Imperial Red Ale",
"brewery": "Kinetic Brewing Company",
"style": "American Strong Ale",
"abv": "9.30 %"
}
}
Ask one Score question and three Noul questions per candidate pair
Both entities go into a single state, asentity_aandentity_b, so the questions are
about thepairand not about either side on its own. All four ride in one request.The three level descriptions below are the entire decision: each level is one outcome.
There is no threshold constant anywhere in this file. You can also write these descriptions
before you have seen a single score, which is not true of a number you have to fit.The middle level is the one worth writing carefully. Here it covers variants, special
editions, and names that could plausibly refer to either product, so those reach a curator
instead of being merged or dropped.OUTCOMEnames the three outcomes. The merge outcome is calledassert sameAsbecausesameAsis the standard way to record that two entities are the same thing, and writing
one is how the merge actually happens.Three of the four fields get aNoulquestion: name, brewery, and style. Alcohol content
gets none, because comparing two numbers is arithmetic; compute it in code if you want it.
To use this on another kind of data you rewriteQUESTIONSandLEVELS. The only other
code that knows about beer is the two functions that print results, which name the fields.```
LEVELS = [
"They describe two different products.",
"They describe closely related products that may or may not be the same one: "
"a variant, a special edition, or a name that could plausibly refer to either.",
"They describe one and the same product.",
]
OUTCOME = {0: "leave unlinked", 1: "curator queue", 2: "assert sameAs"}
QUESTIONS = { "link_state": Score( instructions="How do the two entity descriptions relate as products?", criteria=LEVELS, ), "same_name": Noul( instructions="Do the two entities state the same beer name?", ), "same_brewery": Noul( instructions="Are the two entities from the same brewery?", ), "same_style": Noul( instructions="Do the two entities describe the same beer style?", ), }
@json_cache def score(pair_id: str) -> dict: """One request about one candidate pair -> the score plus the three noul answers.""" pair = BY_ID[pair_id] response = client.system_one( state={"entity_a": pair["entity_a"], "entity_b": pair["entity_b"]}, questions=QUESTIONS, model=TYPESAFE_MODEL, ) link = response.answers["link_state"] return { "score": link.score, "probabilities": link.probabilities, "confidence": link.confidence, "properties": { k: response.answers[k].noul for k in QUESTIONS if k != "link_state" },
tokens and requests are the durable units; don't cache a derived cost
"input_tokens": response.usage.input_tokens or 0, "output_tokens": response.usage.output_tokens or 0, }
def route(score_value: float) -> str: """The whole decision rule: the nearest level names the outcome.""" return OUTCOME[min(int(score_value + 0.5), len(LEVELS) - 1)]
def show(pair_id: str) -> None: pair, result = BY_ID[pair_id], score(pair_id) print( f"{pair_id} score {result['score']:.2f} confidence {result['confidence']:.2f}" f" -> {route(result['score'])}" ) for side in ("entity_a", "entity_b"): e = pair[side] print(f" {e['name'][:44]:<46}{e['brewery'][:30]:<32}{e['style'][:22]}") nouls = result["properties"] print( f" name {nouls['same_name']:.2f} brewery {nouls['same_brewery']:.2f} " f"style {nouls['same_style']:.2f}" )
Four pairs.`c446`is one product and`c427`is two. The other two land in the middle level
for different reasons:`c100`has the same name and brewery but the sources word its style
differently, while`c428`pairs a beer with a fruit-and-hop variant of it.```
for pair_id in ("c446", "c427", "c100", "c428"):
show(pair_id)
print()
c446 score 1.94 confidence 0.92 -> assert sameAs
Thomas Hooker Old Marley Barleywine Thomas Hooker Brewing Company American Barleywine
Thomas Hooker Old Marley Barleywine Thomas Hooker Brewing Company Barley Wine
name 0.97 brewery 0.99 style 0.81
c427 score 0.03 confidence 0.95 -> leave unlinked
Frost Quake Bourbon Barrel Aged Barley Wine Wellington County Brewery American Barleywine
Lompoc Bourbon Barrel Aged Proletariat Red A Lompoc Brewing Amber Ale
name 0.02 brewery 0.09 style 0.08
c100 score 1.30 confidence 0.27 -> curator queue
Belle Gueule Rousse Brasseurs R.J. American Amber / Red A
Belle Gueule Rousse Brasseurs RJ Amber Lager/Vienna
name 0.95 brewery 0.94 style 0.35
c428 score 1.10 confidence 0.77 -> curator queue
Ambleside Amber Ale Bridge Brewing Company American Amber / Red A
Bridge Ambleside Amber Ale - Pomegranate & G Bridge Brewing Company Amber Ale
name 0.63 brewery 0.98 style 0.74
Route every candidate pair
# 450 candidate pairs, one request each; a small pool keeps a live run to a few minutes.
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
scored = list(pool.map(lambda pair: score(pair["id"]), PAIRS))
scores = [result["score"] for result in scored]
by_outcome: dict[str, list[str]] = {name: [] for name in OUTCOME.values()}
for pair, s in zip(PAIRS, scores):
by_outcome[route(s)].append(pair["id"])
SURFACE, INK, INK2, MUTED = "#fcfcfb", "#0b0b0b", "#52514e", "#898781"
GRID, AXIS, BLUE, ORANGE = "#e1e0d9", "#c3c2b7", "#2a78d6", "#eb6834"
BINS, TOP = 20, len(LEVELS) - 1
counts = [0] * BINS
for s in scores:
counts[min(int(s / TOP * BINS), BINS - 1)] += 1
centers = [(i + 0.5) / BINS * TOP for i in range(BINS)]
queued = [c if route(x) == "curator queue" else 0 for c, x in zip(counts, centers)]
settled = [c if route(x) != "curator queue" else 0 for c, x in zip(counts, centers)]
fig, ax = plt.subplots(figsize=(7.2, 3.6), facecolor=SURFACE)
ax.set_facecolor(SURFACE)
for side in ("top", "right"):
ax.spines[side].set_visible(False)
for side in ("left", "bottom"):
ax.spines[side].set_color(AXIS)
ax.tick_params(colors=MUTED, labelcolor=INK2, labelsize=9)
ax.set_axisbelow(True)
ax.grid(axis="y", color=GRID, linewidth=0.8)
ax.bar(
centers, settled, width=TOP / BINS * 0.9, color=BLUE, label="settled automatically"
)
ax.bar(
centers, queued, width=TOP / BINS * 0.9, color=ORANGE, label="sent to the curator"
)
for edge in (0.5, 1.5):
ax.axvline(edge, color=INK2, linewidth=1, linestyle="--")
ax.set_xticks([0, 0.5, 1, 1.5, 2])
ax.set_xticklabels(["0\ndifferent", "0.5", "1\nrelated", "1.5", "2\nsame"])
ax.set_xlabel("score for the pair", color=INK2, fontsize=9)
ax.set_ylabel("candidate pairs", color=INK2, fontsize=9)
ax.set_title(
f"{len(PAIRS)} candidate pairs, scored once each",
loc="left",
color=INK,
fontsize=11,
)
ax.legend(frameon=False, labelcolor=INK2, fontsize=9)
display(fig)
plt.close(fig)
for name in ("assert sameAs", "curator queue", "leave unlinked"):
n = len(by_outcome[name])
print(f"{name:<16}{n:>5} ({n / len(PAIRS):>5.1%})")
assert sameAs 40 ( 8.9%)
curator queue 50 (11.1%)
leave unlinked 360 (80.0%)
The two score values whereroute()changes its answer are the cut points. Most pairs
settle: 360 score below the lower cut point and 40 above the upper one, leaving 50 for the
curator.On this set the scores do not sit neatly on the whole numbers. Most land near 0.25. Two
beers with nothing in common might still share a style name, and their brewery names might
look alike, so the model gives the middle level some of its probability instead of none.
What decides a pair is which side of a cut point it falls on. How near it sits to a level
does not enter into it.The two cut points are not equally crowded. Nine pairs sit within 0.1 of the upper one, at
1.5, which is the one deciding what gets merged into the graph. Forty-seven sit that close
to the lower one, at 0.5, which only decides whether a curator sees the pair. Neither
number is something you tune. Both follow from how you worded the levels, and the wording
of the middle level is what moves pairs between the curator and the pairs left unlinked.
Open it in the playground
The playground link below opensc428, which scored 1.10 and went to the curator.
It pairsAmbleside Amber AlewithBridge Ambleside Amber Ale - Pomegranate & Galena
Hops: same brewery, same alcohol content. All four questions come with it.```
playground_link = make_playground_link(
{"entity_a": BY_ID["c428"]["entity_a"], "entity_b": BY_ID["c428"]["entity_b"]},
QUESTIONS,
models=[TYPESAFE_MODEL],
)
display(
Markdown(
f"🔗 Open this pair + questions in the TypeSafe playground"
)
)
[Open this pair + questions in the TypeSafe playground →](https://console.typesafe.ai/playground#share/N4IgJg9gxgrgtgUwHYBcAqCAeKQC4AEIwAOiMigJYoCeA+gIakEkhL2JP6kCCcARgBsEAZwpgE+XnwQAnSUNIAaLiD4yEAd1nVOpAEIyxAcwkHNFJEfwBhCHAAO9JDpDLSwmgrwresilCdJfll8AHp8ACUEMHkEJRV6PgA3XRAAVgA6AGYABnwAUlIAXzcyVCo6Pk4WNg5vfUMwEyDBETEJKRDuIXwAWnwABTsEIxknehQJADJ8AHF6ITZ8AAkIe2F40jVNbVSDY1N1DQsrWwcnF1KPai8CHmC5brjXBOTUzNyC4qKXkHsZOz2FDCDDYbxEUgCCwAa1oHgmz2YpBo9kRKmEUAg6k2ICghkmhkY3gA2qQ0AALBDUfDiDGGaT4FAaCA0igAMzZsnI+H+EDAMCgwIyOIpVJpIjxFAZUAEEGECAE1PUAgRMV5-MFwkZ5Im+Dg9GpWL1BvwSAgKHwDJQlPwwnYEggSAQBHo+CS9EJqGUruEqKgFAW+GiVAojuURtdtQk1t1mJgAjVKpgokESoQnLkKBZCColJkwpeZMp1NpkoZjokThi1okdsQPIBGpQBYAuqULB4ZALKI6NvUQKsNDSWTXGcyg+UaOK6RQgaGkFrlQj8PQteru8IAPzFK722hR6rI6io1Jm+M4jsoLuC+d9u4gAAiI5tTOzk4oIltKGXo7rEmkIRRtuIAlOie7bFoMguEiIAomipBngIF4Lle3a3qk3DqNq0bjuQIafmyAJwNhtr2paRzaMBoHuHu1y3PgLBwaeEDnoWICXtePYLqkT4ka+E6UJQn6lvS0Y2n+loICEdEIFRPzKCA9D2BQABqsiiI64JJAAjL88pCIK0QALJ8gqwgkiAABWCBJL02kZNpABMIAtkUQA)