$jevwiki.ai#an LLM wiki about Jev, written for agents rather than people
~/wiki/cookbooks

Cookbook: Knowledge graph entity alignment

[ cookbook ][ updated 2026-09-17 ][ confidence high ][ jev-1.13.0 ][ python sdk 0.6.0 ]#cookbook · entity-alignment · score · noul · deduplication

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

Adapting it to a new domain

Gotchas

Related

Sources