---
title: "Cookbook: Knowledge graph entity alignment"
type: cookbook
tags: [cookbook, entity-alignment, score, noul, deduplication]
created: 2026-09-17
updated: 2026-09-17
confidence: high
sources:
  - raw/docs/cookbooks__entity_alignment.md
jev_version: "jev-1.13.0"
sdk_python: "0.6.0"
summary: "One three-level Score decides merge / curate / leave-unlinked for 450 candidate entity pairs, with three Nouls riding along to tell the curator which field disagrees."
---

# Cookbook: Knowledge graph entity alignment

> **TL;DR** Put both entities in one state as `entity_a` / `entity_b`, ask one `Score` whose three `criteria` levels *are* the three outcomes (different product → leave unlinked, related-but-maybe-not → curator queue, same product → assert `sameAs`), and route by rounding the score to the nearest level. Three `Noul` questions (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 [[guides/choosing-a-primitive]].

## 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:

```python
state={"entity_a": pair["entity_a"], "entity_b": pair["entity_b"]}
```

```json
{
  "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:

```python
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

```python
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 `QUESTIONS` and `LEVELS`. 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 `Noul` questions 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

- **`cooksafe` is not publicly installable.** The install line is `pip install matplotlib ipython "typesafe-sdk>=0.5.7" cooksafe --extra-index-url https://pypi.typesafe.ai/`. `cooksafe` is a TypeSafe helper on a private index; `pypi.typesafe.ai` returned 404 publicly on 2026-09-17. Use `pip install typesafe-sdk` and 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 a `console.typesafe.ai/playground#share/...` URL. Neither affects the result.
- **Concurrency limit.** The cookbook caps at `MAX_WORKERS = 6` with 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 returns `429` ([[reference/models-and-pricing]]). See also [[reference/rate-limits-and-errors]].
- **Score confidence is not the routing signal here.** `c100` routed to the curator on its *score* (1.30), and its confidence happened to be low (0.27); `c428` routed 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 the `min`, a score of exactly 2.0 would index past the end.
- **`abv` is in the state but has no question.** The model can still use it as context for `link_state`; only the per-field Noul is omitted.
- **Model pinning.** All numbers are `jev-1.12` from 2026-08-11; `jev-latest` now resolves to `jev-1.13.0`.

## Related

- [[cookbooks/overview]] — the cookbook index
- [[concepts/score]] — levels, `criteria` as an ordered sequence, rounding
- [[concepts/noul]] — the 0–1 yes/no primitive
- [[guides/choosing-a-primitive]] — why Score beat Choice and Noul here
- [[patterns/fan-out]] — one request, many questions
- [[cookbooks/semantic-find]] — the other "match records across sources" cookbook
- [[reference/python-sdk-questions]] — `Score` and `Noul` constructor fields

## Sources

- raw/docs/cookbooks__entity_alignment.md (https://docs.typesafe.ai/cookbooks/entity_alignment)
