Skip to content
System One

Entity alignment with System One models

Two catalogues describe overlapping sets of the same products, and a rough first pass hands you candidate pairs. One Jev Score question with a level per outcome decides each pair, and yes/no questions about individual fields tell a curator where the two sources disagree.

The problem

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. Product catalogues, customer records, company registries, a knowledge graph taking in a new feed. Some cheap first pass has already compared the sources and produced candidate pairs worth a closer look. What’s left is a judgment call on each pair, and the two mistakes are not symmetric.

Merging two records that aren’t the same thing is expensive. Every fact about either one now describes the merged record, anything linked to either comes along, and undoing it later means working out which fact came from where. Missing a match only leaves a duplicate. So the decision needs a third outcome for pairs that are neither safe to merge nor safe to drop.

String similarity can’t make that call, because the sources word things differently on purpose. Jev can, and TypeSafe’s cookbook puts the whole decision in one question.

What the state looks like

Both records go into a single state, named so the questions are about the pair rather than about either side alone. State is the content being judged, sent once per request, and here that’s one request per candidate pair. What you spend follows the number of pairs the first pass handed you, not the size of either catalogue.

Send the fields as they arrive. The cookbook leaves its text exactly as published, HTML entities and mangled characters included, because that’s what the model will meet in production.

{
  "entity_a": {
    "name": "Ambleside Amber Ale",
    "brewery": "Bridge Brewing Company",
    "style": "American Amber / Red Ale",
    "abv": "5.50 %"
  },
  "entity_b": {
    "name": "Bridge Ambleside Amber Ale - Pomegranate & Galena Hops",
    "brewery": "Bridge Brewing Company",
    "style": "Amber Ale",
    "abv": "5.50 %"
  }
}

The questions you ask

One Score carries the decision. Yes/no questions about individual fields ride along in the same request.

from typesafe_sdk import Noul, Score, TypeSafeClient

client = TypeSafeClient()

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.",
]

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?"),
}

response = client.system_one(
    state={"entity_a": pair["entity_a"], "entity_b": pair["entity_b"]},
    questions=questions,
)

A Score is Jev’s ordered-rating primitive: you write a sentence per level, and the number that comes back is the probability-weighted mean of the level positions counting from 0. It’s the right shape here because the three levels are the three things you can do with a pair, and they’re ordered from “not the same” through “possibly” to “the same”. A Choice would treat them as unrelated labels and lose that order. A Noul could get there through thresholding, but you’d be fitting a number instead of writing a sentence. The primitives guide compares the three shapes side by side.

There’s no threshold constant anywhere in the question. The level descriptions are the whole decision, and you can write them before seeing a single result, which isn’t true of a cutoff you have to tune. Write the middle level most carefully, since it decides what a person ends up looking at.

The Nouls are yes/no questions that return a probability from 0 to 1 that the answer is yes. They don’t decide anything. They tell a curator which field the two sources disagree on, and they cost nothing extra because TypeSafe reports no speed penalty for additional questions in one request. Alcohol content gets no question at all: comparing two numbers is arithmetic, and that belongs in code.

Decision policy

Round the score to the nearest level, and that level names the outcome. There is no other rule.

OUTCOME = {0: "leave unlinked", 1: "curator queue", 2: "assert sameAs"}

def route(score_value):
    return OUTCOME[min(int(score_value + 0.5), len(LEVELS) - 1)]

Your code owns what each outcome does. “assert sameAs” writes the link that performs the merge, “leave unlinked” does nothing, and the curator queue collects the pairs a person decides, with the three field probabilities attached so they can see where the disagreement is.

The cookbook’s 450 candidate pairs split 40 merged, 50 to the curator, and 360 left unlinked. The scores don’t sit neatly on whole numbers, and most land near 0.25: two unrelated beers still share a style vocabulary, so the model gives the middle level a little probability rather than none. What decides a pair is which side of a cut point it falls on, and how close it sits to a level doesn’t enter into it.

The cut points aren’t equally busy. Nine pairs sit within 0.1 of the upper cut at 1.5, which is the one deciding what gets merged. Forty-seven sit that close to the lower cut at 0.5, which only decides whether a curator sees the pair. Neither number is tuned. Both follow from how you worded the levels, and rewording the middle level is what moves pairs between the curator and the discard pile.

Confidence comes back on the Score too, and it works as a second gate: the pair scoring 1.30 at confidence 0.27 was one where the sources described the same beer’s style differently. That’s the confidence gating pattern layered on top.

When not to use this

Numeric fields don’t belong in the judgment. Alcohol content, price, weight, dimensions: Jev is weak at arithmetic and at telling whether two numeric values are near each other, so compare them in code and pass a named bucket if the model needs to know. Dates work the same way, since they’re read as text rather than as ordered quantities.

Don’t push whole records with dozens of irrelevant fields into the state, because accuracy falls as unrelated detail grows. Pick the fields that identify the thing. And don’t expect the Score and the Nouls to agree arithmetically: TypeSafe documents that structural invariants between separate questions aren’t guaranteed, so a pair can score high on the same-product level while one field noul reads low. Use the nouls as evidence for a person, not as a check on the score. If your first pass matched on exact strings only, the model will mostly confirm what you already knew, so spend the budget widening candidate generation instead.

FAQ

Why a Score rather than a Choice over three labels?

The three outcomes are ordered, and a Score keeps that order in the number. Rounding to the nearest level gives you two cut points that follow from how you wrote the levels, rather than unrelated probabilities you’d have to combine. A Choice also loses the in-between reading, where a pair sits at 1.3 instead of landing on a rung.

How is this different from a similarity threshold?

A threshold is a number you fit to your data and refit whenever the data shifts. Level descriptions are sentences you can write before seeing a single result, and they encode what the middle case actually is: a variant, a special edition, a name that could refer to either product. The judgment moves from a tuned constant into readable text.

What should the curator queue actually contain?

The pair, the score, and the field-level probabilities showing where the sources diverge. In the cookbook’s 1.30-scoring pair the name and brewery read 0.95 and 0.94 while style read 0.35, which points a curator straight at the field to check. Without those extra questions the queue entry would say only “unsure”.

Does this scale to a large catalogue?

It scales with candidate pairs, not with catalogue size, so the first pass governs the cost. At Jev’s $0.042 per million input tokens with output free, 450 short pairs cost a fraction of a cent. Keep the concurrency modest: the cookbook uses a small pool because the public endpoint rate limits at 1,200 requests per minute.

Examples in the wild

More retrieval and knowledge use cases