Score decides
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 entries entities, 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.
We use a Score, with each level describing one of the three outcomes, to perform
the judgment:
- different product — leave the two entities unlinked
- related, but possibly not the same — hand it to a curator to decide
- same product — merge them
Nouls 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 a route() that takes one candidate pair and returns one of the three
outcomes, with no threshold you had to fit to your own data.
Setup
TYPESAFE_API_KEY. Every call is cached to json_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 from jev-1.12 on 2026-08-11.
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 carriesknown_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.
Ask one Score and three Nouls per candidate pair
Both entities go into a single state, asentity_a and entity_b, so the questions are
about the pair and 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.
OUTCOME names the three outcomes. The merge outcome is called assert sameAs because
sameAs is 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 a Noul: 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 rewrite QUESTIONS and LEVELS. The only other
code that knows about beer is the two functions that print results, which name the fields.
c446 is clearly one product and c427 clearly 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.
Route every candidate pair

route() 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. The bulk lands around 0.25,
because two unrelated beers still tend to share a style name and a similar-looking brewery
name, so the model gives the middle level some of its probability rather than none. What
decides a pair is which side of a cut point it falls on, not how near it sits to a level.
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 pairs Ambleside Amber Ale with Bridge Ambleside Amber Ale - Pomegranate & Galena
Hops: same brewery, same alcohol content. All four questions come with it.

