Skip to main content
A key problem in knowledge graphs is deciding whether an incoming entity duplicates an existing one, especially when natural language from disparate sources is all that’s available. Given potential duplicate pairs, a single TypeSafe 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
We use a Score because we want to attach a semantic label, the score criteria, directly to each outcome, including the middle outcome. A Noul could accomplish this indirectly through thresholding on its output instead, and a Choice would lose the ordered relationship of the three outcomes. Next, for each field of the entity we want to consider, 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

then set 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 carries known_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, as entity_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.
Four pairs. 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

output The two score values where 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 opens c428, 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.
Open this pair + questions in the TypeSafe playground →