Cookbook: Knowledge graph entity alignment
TL;DR Put both entities in one state as
entity_a/entity_b, ask oneScorewhose threecriterialevels are the three outcomes (different product → leave unlinked, related-but-maybe-not → curator queue, same product → assertsameAs), and route by rounding the score to the nearest level. ThreeNoulquestions (name, brewery, style) ride in the same request so the curator sees which field disagrees. There is no threshold constant anywhere in the code.
Goal
Two data sources describe overlapping sets of the same things. A cheap first pass has already narrowed the comparison to 450 candidate pairs; the remaining work is a judgment call on each pair.
The asymmetry drives the design: "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 needs a third, middle option.
You end up with a route() that takes one candidate pair and returns one of three outcomes, "with no threshold you had to fit to your own data."
Why Score and not Choice or Noul
The cookbook is explicit: Score attaches a semantic label (the level's criteria text) directly to each outcome, including the middle one. "A Noul question could accomplish this indirectly through thresholding on its output instead, and a Choice question would lose the ordered relationship of the three outcomes." See Choosing between Choice, Score, Noul.
Inputs / state shape
Pairs come from the Beer data of the Magellan collection — two beer catalogues scraped from different websites, cut to 450 pairs. Each entity carries four fields: name, brewery, style, abv. Each pair also carries known_same_as, the benchmark's own answer (used only for reporting).
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."
The state is the whole pair, so the questions are about the pair and not about either side alone:
state={"entity_a": pair["entity_a"], "entity_b": pair["entity_b"]}
{
"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 %"
}
}
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."
Questions asked
Verbatim — four questions in one request:
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?",
),
}
This cookbook already uses the SDK 0.6.0 form: Score.criteria is an ordered sequence (a plain list), low end first. No migration needed.
The cookbook's own notes on the wording: "The three level descriptions below are the entire decision: each level is one outcome... 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." Alcohol content deliberately gets no Noul, "because comparing two numbers is arithmetic; compute it in code if you want it."
Combining logic in code
import json, os
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typesafe_sdk import Noul, Score, TypeSafeClient
TYPESAFE_MODEL = "jev-1.12" # the cookbook's original model id; current is jev-1.13.0 / jev-latest, see reference/models-and-pricing
MAX_WORKERS = 6 # small pool; the public endpoint rate-limits above roughly eight
client = TypeSafeClient(
# "cache-only" is a cookbook placeholder for replaying its cache; set TYPESAFE_API_KEY to call the API
api_key=os.environ.get("TYPESAFE_API_KEY", "cache-only"),
# TYPESAFE_ENDPOINT is a cookbook constant, not an SDK env var; the SDK reads TYPESAFE_BASE_URL, see reference/environment-variables
base_url=os.environ.get("TYPESAFE_ENDPOINT"),
timeout=120.0,
)
PAIRS = json.loads(Path("candidate_pairs.json").read_text(encoding="utf-8"))
BY_ID = {pair["id"]: pair for pair in PAIRS}
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)]
# 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))
by_outcome: dict[str, list[str]] = {name: [] for name in OUTCOME.values()}
for pair, result in zip(PAIRS, scored):
by_outcome[route(result["score"])].append(pair["id"])
route() is the entire decision rule — rounding a continuous score to the nearest level index. The implied cut points are 0.5 and 1.5.
Results the cookbook reports
Numbers came from jev-1.12 on 2026-08-11.
Four illustrative pairs:
| pair | score | conf | outcome | name / brewery / style nouls |
|---|---|---|---|---|
| c446 | 1.94 | 0.92 | assert sameAs | 0.97 / 0.99 / 0.81 |
| c427 | 0.03 | 0.95 | leave unlinked | 0.02 / 0.09 / 0.08 |
| c100 | 1.30 | 0.27 | curator queue | 0.95 / 0.94 / 0.35 |
| c428 | 1.10 | 0.77 | curator queue | 0.63 / 0.98 / 0.74 |
c100 is Belle Gueule Rousse from "Brasseurs R.J." vs "Brasseurs RJ" — same name and brewery, but the two sources word the style differently ("American Amber / Red Ale" vs "Amber Lager/Vienna"), and the same_style noul at 0.35 tells the curator exactly that. c428 pairs Ambleside Amber Ale with a pomegranate-and-hops variant of it.
Full run over 450 pairs:
assert sameAs 40 ( 8.9%)
curator queue 50 (11.1%)
leave unlinked 360 (80.0%)
Two observations the cookbook draws from the score distribution:
- "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."
Adapting it to a new domain
- Rewrite
QUESTIONSandLEVELS. Per the cookbook, "The only other code that knows about beer is the two functions that print results, which name the fields."route(), the threading, and the state assembly are domain-independent. - Keep the three-outcome shape: the cheap-mistake outcome, the expensive-mistake outcome, and a middle level whose wording decides what a human sees. Spend your editing time on the middle level — it is the control surface.
- Add or drop
Noulquestions to match the fields a curator would want to compare. Skip fields where a code comparison is exact (numbers, dates, IDs). - If your cost asymmetry is reversed (missing a match is expensive, merging is cheap), re-word the levels rather than adding a threshold (inferred).
Gotchas
cooksafeis not publicly installable. The install line ispip install matplotlib ipython "typesafe-sdk>=0.5.7" cooksafe --extra-index-url https://pypi.typesafe.ai/.cooksafeis a TypeSafe helper on a private index;pypi.typesafe.aireturned 404 publicly on 2026-09-17. Usepip install typesafe-sdkand reimplement the two helpers:JsonCache(Path("json_cache.json"))is a decorator memoizing JSON-serializable return values to a file keyed by the call arguments;make_playground_link(state, questions, models=[...])builds aconsole.typesafe.ai/playground#share/...URL. Neither affects the result.- Concurrency limit. The cookbook caps at
MAX_WORKERS = 6with the note that "the public endpoint rate-limits above roughly eight." That is the cookbook's own observation from its run on 2026-08-11, not a documented limit: the documented limits are 250,000 tokens per second and 1,200 requests per minute, and either one being exceeded returns429(Models, aliases, pricing, rate limits, context). See also HTTP status codes, rate limits, retry semantics. - Score confidence is not the routing signal here.
c100routed to the curator on its score (1.30), and its confidence happened to be low (0.27);c428routed to the curator at confidence 0.77. The level boundaries do the routing; confidence is extra information. - Rounding needs the clamp.
min(int(score_value + 0.5), len(LEVELS) - 1)— without themin, a score of exactly 2.0 would index past the end. abvis in the state but has no question. The model can still use it as context forlink_state; only the per-field Noul is omitted.- Model pinning. All numbers are
jev-1.12from 2026-08-11;jev-latestnow resolves tojev-1.13.0.
Related
- Cookbooks overview — the cookbook index
- Score questions — levels,
criteriaas an ordered sequence, rounding - Noul (yes/no) questions — the 0–1 yes/no primitive
- Choosing between Choice, Score, Noul — why Score beat Choice and Noul here
- Speculative fan-out — one request, many questions
- Cookbook: Line-by-line search — the other "match records across sources" cookbook
- Python SDK question types (Noul, Choice, Score) —
ScoreandNoulconstructor fields
Sources
- raw/docs/cookbooks__entity_alignment.md (https://docs.typesafe.ai/cookbooks/entity_alignment)