CompKitchen JSON API
Base URL: https://compkitchen.com
Every endpoint returns JSON. There is no SDK and no versioned path prefix; breaking changes are announced before they ship.
As of 2026-08-09 the graph holds 852 ingredients, 5,240 distinct compounds and 296,732 scored ingredient pairs, with recipe co-occurrence over 432,799 recipes.
The one thing to read before anything else
Every endpoint's score means something different. They are not comparable, and averaging across them is meaningless. This is the most common way to misuse this API.
| Endpoint | The number | What it actually is |
|---|---|---|
/api/pair |
final_score |
50% compound overlap + 50% recipe co-occurrence + a synergy term, plus bonuses |
/api/bridge |
score |
the mean of two /api/pair scores — candidate→A and candidate→B |
/api/sub |
final_score |
Jaccard similarity of compound sets. Not the pairing formula |
/api/formula-sub |
score |
how much of what the formula loses a candidate restores, minus a drift penalty |
/api/mood |
final_score |
geometric mean of descriptor-match intensity and proportion. recipe_score is N/A |
/api/pantry |
score |
three different meanings in one response — see that endpoint |
/api/compare |
jaccard |
raw set overlap. No pairing formula involved at all |
Where a result carries scoring_mode, it names which of these applies. Read it.
/api/pair values are /api/pair values. The Python engine.pair() method returns
different numbers for the same ingredients — different defaults, different post-processing.
Never compare a figure from one interface against the other.
Authentication
Send your key in the X-API-Key header. There is no Authorization: Bearer form and
no query-string parameter.
curl -H "X-API-Key: $COMPKITCHEN_KEY" \
"https://compkitchen.com/api/pair?ingredients=strawberry"
A signed-in browser session is a full peer of the API key — anything a key unlocks, a
session unlocks. If the header is missing or the key matches no user, the request falls
through to the session cookie and, failing that, is treated as anonymous. An invalid key
does not error; it silently downgrades you to the free tier. Check is_pro in the
response rather than assuming.
is_pro reports your ENTITLEMENT. It does not flip when a response is
briefly throttled: a throttled reply carries rate_limited: true plus a
reason, keeps every count exact, and only shortens the name lists. If you
see rate_limited, you are not on the wrong plan — wait for the hour to roll
or talk to us about bulk access.
Your key is on /account.
Rate limits
Sliding 60-second window.
| Caller | Limit | Bucket |
|---|---|---|
| Anonymous / free | 120 / min | client IP |
| Signed-in website (any paid plan) | 300 / min | user id |
Developer / enterprise via X-API-Key |
600 / min | api key |
Exceeding it returns 429 with {"detail": "Rate limit exceeded."}.
There is also a monthly quota, separate from the per-minute limiter and
agreed with you when a key is issued. Exhausting it returns a different 429
whose body names the allowance and the usage so far, with a Retry-After
measured in days rather than seconds. Retrying will not clear it; the fix is a
conversation. /api/health, /api/whoami and /api/version never count
against either limit.
The client IP is taken from CF-Connecting-IP, so requests are limited per real caller and
not per Cloudflare edge.
Some older material quotes 60/min. That figure belongs to a standalone app that is not what serves compkitchen.com. The numbers above are the live ones.
Free vs Pro
Free callers get the top 5 results in full, then locked stubs.
is_pro is present on every gated response. Key off is_pro, not off the presence of
the other fields — the free tier adds truncated, free_limit, total_count and
locked_count, and Pro omits all four.
A locked stub carries only:
{ "ingredient": "elderberry", "category": "fruit-berry", "novelty_tier": "common",
"shared_compound_count": 267, "is_novel": false, "is_expert": false, "locked": true }
Free callers also lose compound_details on the results they can see — the per-compound
threshold and OAV panel is Pro-only.
Which endpoints gate their compound LISTS. /api/compare, /api/compound and
/api/info truncate compound-name lists for callers without a key; the COUNTS are
always exact, so a short list is never a short profile. /api/formula-sub,
/api/pantry and /api/bridge return identical bodies either way. CSV export and
batch need a key and return 402 without one.
data_quality
Present on most scoring responses:
"data_quality": {
"ingredient_count": 852,
"recipe_count": 612557,
"flavordb_source": "...",
"recipe_source": "..."
}
A fifth key, warning, appears if and only if the engine is running on embedded
fallback data instead of the real corpus. If you see warning, do not trust the scores —
treat the response as a service-degradation signal.
Errors
Three shapes, all real:
{"detail": "At most 25 ingredients allowed."} // 422, flat string
{"detail": [{"type": "missing", "loc": ["query", "ingredients"], ...}]} // 422, FastAPI validation
{"detail": "Ingredient(s) not found in graph: ['notarealfood']",
"suggestions": ["nutmeg", "..."]} // 404, with suggestions
A 404 answers two different
questions, and keeps them apart. suggestions
is a spelling question — did you mean broccoli. For a
handful of ingredients we are asked for and do not hold, the response also carries
related:
a food question, answering “we do not have that, here is what
we do have near it”.
GET /api/pair?ingredients=galangal // 404
{"detail": "Ingredient(s) not found in graph: ['galangal']",
"suggestions": [],
"related": [{"ingredient": "ginger",
"why": "same family, different genus (Alpinia vs
Zingiber) — sharper and more piney",
"compound_count": 438}],
"related_note": "... related, NOT equivalent, and never
substituted for it automatically ..."}
These are neighbours, not
substitutions. Galangal is not ginger, tomatillo is not tomato, and for
kimchi or fish sauce the ferment is most of the aroma — so none of them are
wired into ingredient resolution, and asking for one still returns 404 rather than
quietly scoring something else. Every row says how it differs, and
related
is omitted entirely when we have nothing honest to offer.
| Code | When |
|---|---|
402 |
Pro-only endpoint (CSV export, batch) |
404 |
ingredient not in the graph — includes suggestions, sometimes related |
405 |
wrong method |
422 |
validation: missing, too many, empty, out of range, bad enum |
429 |
rate limit |
503 |
engine still loading after a deploy — retry shortly |
Input sanitising: NFKC normalise → lowercase → strip → drop every character outside
[a-z0-9 -] → reject if empty or over 60 characters. So Crème Fraîche becomes
crme frache and will 404. Send plain ASCII names.
Caching
Results are cached server-side for 6 hours, and the cache holds the ungated result with gating applied per request — a Pro-computed body can never reach a free caller through it. A deploy clears it.
Responses carry Cache-Control: public, max-age=60, s-maxage=300, except Pro responses
which use private, max-age=60 so a CDN cannot serve them to anyone else.
Endpoints
GET /api/pair
Arity selects the mode. This is the least obvious thing about the API:
ingredients |
Mode | Question answered |
|---|---|---|
| 1 | top_pairings |
what goes with this? |
| 2 | bridge |
what connects these two? |
| 3+ | gap |
what is missing from this set? |
| Param | Default | Notes |
|---|---|---|
ingredients |
required | comma-separated, max 25, deduplicated. An ingredient we cannot score is SKIPPED, not fatal — see below |
top_n |
10 |
1–100 |
scoring |
classic |
or science (70% compound + 30% aroma cosine, no recipe term) |
diet |
— | vegan, vegetarian, no-dairy, no-nuts, no-gluten, kosher, halal, no-eggs, no-alcohol. Unknown values are silently ignored |
cuisine |
— | applies only under classic; unknown values silently ignored |
cooking |
— | e.g. grilled. Forces scoring=science. Single-ingredient only |
measured |
0 |
1 swaps final_score for measured_score — the same formula with LLM-derived bonuses zeroed |
diversify |
1 |
0 turns OFF the category cap. On by default the ranked list holds at most 2–3 ingredients per food group, so a well-scoring fourth vegetable is absent rather than low. Set 0 for a score-ordered list |
aggregate |
min |
3+ ingredients only. min ranks a candidate on its WEAKEST pair with your set; avg ranks on the mean. Two ingredients always use the mean |
exclude_similar |
0 |
1 drops candidates that are near-substitutes for an input |
rank |
score |
familiar or jaccard re-rank on what cooks actually do rather than on what is chemically surprising — single ingredient only, see below |
exclude_substitutes |
0 |
1 drops candidates labelled relationship: "substitute" — narrower and more accurate than exclude_similar, see below |
curl "https://compkitchen.com/api/pair?ingredients=strawberry&top_n=5"
{"query": {"ingredients": ["strawberry"], "top_n": 10, "scoring": "classic",
"cooking_state": null, "measured_only": false},
"mode": "top_pairings", "count": 10,
"results": [{"ingredient": "kiwifruit", "final_score": 0.7802, "measured_score": 0.5742,
"compound_score": 0.2711, "recipe_score": 0.7027, "taste_score": 0.7319}]}
diet and cuisine are not echoed in query. If you need to know what was applied,
track it client-side. Everything else is echoed, including resolved and
substitutions — see below.
rank=familiar — the obvious answer instead of the interesting one
The default ranking is built on compound overlap plus NPMI, and NPMI measures
how SURPRISING a co-occurrence is, not how common. cream appears in 64,087
recipes, so pairing with strawberry 2,092 times is unremarkable to the maths
(recipe score 0.578). champagne appears in 918, so 78 co-occurrences look
striking (0.586). Strawberry's default list therefore leads with champagne and
contains neither cream nor chocolate. That is correct, and it is the point of the
engine — but it is not what a shopper expects.
&rank=familiar re-ranks the same rows on real co-occurrence, damped for
ubiquity, with the ingredients that are present because a recipe is a recipe
removed (salt, flour, sugar, egg, water, butter, milk, oil and a few more).
strawberry default pineapple, kiwifruit, champagne, rhubarb, rum
strawberry familiar raspberry, blueberry, cream, pineapple, vanilla, banana
chocolate familiar vanilla, coffee, peanut, cream, walnut, almond
tomato familiar onion, garlic, basil, oregano, beef, cumin
&rank=jaccard is a second way to ask it: recipe overlap divided by recipe
UNION, cooc / (recipes_A + recipes_B − cooc). NPMI divides by the joint
probability, so a rare ingredient is rewarded for any association at all — which
is why champagne ranked 4th for strawberry and cream did not appear. Jaccard's
denominator is dominated by whichever ingredient is common, so champagne lands
last:
strawberry x cooc partner recipes Jaccard NPMI
raspberry 1,082 5,293 0.1008 0.7198
cream 2,156 64,087 0.0315 0.5794
champagne 83 918 0.0113 0.6068
Neither is strictly better. The union denominator is harsher on common partners,
so jaccard also drops cream from strawberry where familiar keeps it —
harsher on the ubiquitous, kinder to same-frequency pairs. Both exclude the same
structural ingredients, because a union denominator still cannot tell a partner
from a pantry.
Scores are identical across all three — the same scoring applied to whichever
rows a ranking selects. Nothing is rescored and scoring_revision does not move.
But they are NOT the same rows reordered. Each ranks the whole candidate
space by its own criterion, so top_n returns substantially different lists:
across six ingredients the default and familiar top-10s overlapped 30%, and for
salmon they overlapped not at all. mode in the response tells you which one
ran.
Two limits worth knowing. They apply to a single ingredient; ask for one on a
basket and the response comes back with rank: "score" and a rank_note naming
the ranking it could not honour, rather than silently answering the other question. And the excluded-base
list is a judgement, not a measurement — frequency alone cannot separate a base
from a partner (onion is in 21.2% of recipes and is a partner, water is in
27.4% and is not). butter is on the excluded list, so lemon + butter will
not appear in a familiar list even though it is a real pairing; it still scores
normally on /api/pair-score, /api/basket and the default ranking.
Substitute or complement
Every result carries relationship, either "complement" or "substitute".
The engine measures how ALIKE two ingredients are. Being alike is what makes a
good substitute; a good pairing needs similar aroma but a different job in the
dish. Nothing separated those two questions, so chicken → pork (0.74) and
cheddar cheese → cottage cheese (0.91) came back as top pairings — true
statements answering the wrong question. On a random sample that was 40% of
top-5 hits for proteins and 36% for dairy.
A pair is labelled substitute when it is chemically alike and both sit in a
food group where only one can fill the slot — protein, dairy, grain, bakery, egg,
beverage, fungus. Both conditions are needed: fenugreek → turmeric scores 0.90
and shares a group, but a dish takes as many spices as you like, so it stays a
complement. Measured against 25 hand-labelled pairs the rule is 100% precise and
64% complete: it does not mislabel good pairings, and what it misses simply stays
as it is today.
substitute means "could stand in for", NOT "do not combine". The two are
independent, and plenty of pairs are both. beef + pork co-occur in 5,007
recipes — ragu and meatloaf use both — and chicken + pork in 5,570. They are
interchangeable AND classic together. Others are stand-ins only: cheddar +
cottage cheese is 194 recipes, mozzarella + provolone 95, red wine +
white wine zero.
So do not treat the label as a quality signal on its own. Read it beside
recipe_cooccurrence, which is in the same row: a substitute with thousands of
co-occurrences is a real combination, one with a handful is just a similar
ingredient. If you only want to remove same-role suggestions from a
"what next?" list, &exclude_substitutes=1 is the right control; if you are
judging whether two things go together, use the score and the recipe evidence.
relationship does not change any score and does not filter anything. On
/api/pair the default list is unchanged — chicken still returns pork at the
top. Pass &exclude_substitutes=1 to drop them, and the ones removed come back
in query.excluded_as_substitute. /api/pair-score and /api/basket carry the
field too.
This is deliberately narrower than exclude_similar, which asks
engine.substitute() and removes anything chemically alike — on chicken that
also drops mushroom (0.68) and rice (0.67), which are perfectly good
pairings. exclude_substitutes keeps them.
The same signal is wanted in opposite directions depending on the question:
/api/pair wants to demote substitutes, /api/formula-sub and
/api/reformulate-cost want to find them — replacing an expensive ingredient
with a cheaper one is exactly a substitution. Every row of /api/sub is
therefore labelled substitute, since that is what the endpoint is for.
An ingredient we don't have does not sink the request
If one of your ingredients is unrecognised, the rest are still scored. Three fields tell you what happened:
| Field | Meaning |
|---|---|
query.ingredients |
what you ASKED for, verbatim |
query.scored |
what actually took part |
query.unrecognised |
what was dropped, and why the two differ |
GET /api/pair?ingredients=chicken,garlic,gochugaru
"query": {"ingredients": ["chicken", "garlic", "gochugaru"],
"scored": ["chicken", "garlic"],
"unrecognised": ["gochugaru"]},
"mode": "bridge"
Watch mode. Dropping an ingredient changes the ARITY, and arity selects the
mode — three ingredients minus one unknown is a two-ingredient question, so the
response says bridge, not gap. It reports what it PERFORMED.
If NOTHING is recognisable there is no answer to give and you still get a 404, with the failing words named.
This previously 404'd the whole request over a single unknown word, which made
real recipe baskets unusable: a real recipe reliably contains one thing we lack.
/api/basket has always degraded this way, returning final_score: null with a
reason per pair.
Absence is not a zero
A pair missing from this list has not scored 0. Two filters remove well-scoring candidates before you ever see them, and neither is the score:
- the category cap above (
diversify=0disables it); - a duplicate-profile filter that drops a candidate sharing more than 90% of its compound profile with your input. Nothing disables this one.
To score exactly the pairs you name, use /api/pair-score (one pair) or /api/basket
(every pair in a set, one call). Neither ranks, so no filter can hide a row — and an
unscoreable pair comes back final_score: null with a reason, never 0.0.
Multi-ingredient responses
With two or more ingredients, every row carries the numbers behind its aggregate:
| Field | Meaning |
|---|---|
per_input |
the candidate's score against each input separately |
aggregate (in query) |
which rule was actually PERFORMED — avg or min. Echoed from behaviour, not from your request, so a partial deploy cannot mislabel weakest-link numbers as averages |
basket_internal |
the pairs among your own ingredients, scored once per request |
basket_average (per row) |
the mean of every pair in the basket that adding this candidate would produce — basket_internal plus that row's per_input |
basket_internal exists because ranking and display answer different questions. A
candidate is ranked on its pairs with your existing set; a user looking at a finished
basket sees the average of every pair in it, your own ingredients included. Those are
different numbers, and the second one used to change the moment the basket was committed.
Both are now in the same response, so nothing has to be reconstructed with extra calls.
Ranking is identical either way — the internal pairs are the same for every candidate, so
they shift all the averages by the same amount. basket_average is computed from the
published four-decimal scores, so adding up the rows printed beside it reproduces it
exactly.
basket_internal is absent for a single ingredient, where there is no basket to be
internal to.
{"basket_internal": [{"a": "tomato", "b": "basil", "final_score": 0.7356}],
"results": [{"ingredient": "oregano", "final_score": 0.7999, "basket_average": 0.7785,
"per_input": [{"ingredient": "tomato", "final_score": 0.7243},
{"ingredient": "basil", "final_score": 0.8755}]}]}
measured_score is the honest one. final_score includes bonuses derived by a language
model from chef pairings and aroma families. measured_score keeps only compound overlap,
recipe co-occurrence, synergy, cooking method and the external pairing signal. If you are
building something that has to defend its numbers, use measured=1.
GET /api/novel
Same scoring as /api/pair, filtered to compound ≥ 0.20 and recipe ≤ 0.15 by default. The engine RELAXES those thresholds adaptively when too few pairs qualify — as low as 0.05 compound and as high as 0.50 recipe — so rows below the nominal cut-off are expected, not a bug — pairs that
are chemically compatible but rarely cooked together. Takes ingredient (singular), not
ingredients.
GET /api/sub
final_score is Jaccard similarity of compound sets, not the pairing formula. It answers
"what most resembles this ingredient", which is not the same as "what can I use instead" —
it has no notion of function, texture or quantity.
novelty_tier is the empty string here, and is_novel / is_expert are always false.
GET /api/formula-sub
Replacing one member of a formula, in context of the rest. A near-identical candidate scores low when the outgoing ingredient's chemistry is already covered by its neighbours, because the formula never lost it.
| Param | Notes |
|---|---|
formula |
required, comma-separated, max 25 |
replace |
required, which member to swap out |
top_n |
default 10 |
diet, exclude |
same vocabulary as /api/sub |
Read thin_evidence before you read score. When only one or two compounds separate
the outgoing ingredient from the rest of the formula, the ranking rests on almost nothing
and high scores are noise. n_lost tells you how many compounds are actually at stake;
n_supplied_by_rest tells you how many the neighbours already cover.
weak_evidence is a different failure: the ingredient has no distinctive chemistry on
record anywhere, so nothing about it is informative. thin_evidence is a true fact about
this formula; weak_evidence is a gap in our data.
n_lost: 0 is a finding, not an error — it means the ingredient contributes no aroma
compound its neighbours do not already supply.
The score key is score, not final_score. No scoring_mode is emitted.
GET /api/mood
Ingredients matching a descriptor (smoky, bright, cozy). final_score is a geometric
mean of how many of the ingredient's compounds match and how strongly. recipe_score is
N/A here and scoring_mode is mood.
Two limits worth knowing. Matching is substring-on-compound-name, not chemical identity — a compound counts if the descriptor's fragment appears anywhere in its name. And multi-word descriptors must be space-separated; commas and ampersands are stripped by the sanitiser before the engine sees them.
GET /api/pantry
What you can make from what you have. Three different score meanings in one response:
combos[].score— the standard/api/pairscoretrios[]— three-way analysisbuy_next[]— ranked by how many new good pairings the addition unlocks, which is a breadth measure, not a pairing score
Partial failure is silent. An unrecognised ingredient is dropped rather than raising, as
long as at least one input resolves. query.ingredients echoes what you sent; the pantry
field is the authoritative list of what was actually scored. Compare them.
combos and trios require a graph edge between members, so a single-ingredient pantry
returns both empty while buy_next is still populated.
GET /api/compare
Side-by-side compound sets for two ingredients. jaccard is raw set overlap — no pairing
formula.
For three or more, use the page: /compare?ing=a&ing=b&ing=c. If two inputs resolve to
the same ingredient the page says so rather than silently returning a smaller grid.
One inconsistency to be aware of: shared_count is recomputed after display-cleaning the
compound lists, but a_only_count and b_only_count are not. If you need exact figures,
count the arrays yourself.
GET /api/bridge
The third ingredient connecting two others. score is the mean of the two pairing scores.
top_n is not exposed — you always get at most 6. Results are filtered for usefulness
rather than purely by score: same-category siblings of either input and beverages are
dropped, which is why the answer for blue cheese + chocolate is walnut rather than another
cheese. The candidate pool is truncated to the top 60 before that filtering.
GET /api/compound/{name}
Full record for one compound: odour description, threshold, aroma family, chemical class, CAS, PubChem CID, formula, molecular weight, volatility, and which foods carry it.
/api/compound-lookup?q= resolves a CAS number, PubChem CID or FEMA number to the compound.
Lookup endpoints
| Endpoint | Returns |
|---|---|
GET /api/info?ingredient= |
category, compound list and count, recipe count, top connections |
GET /api/ingredients |
every ingredient name in the graph |
GET /api/cooking-states?ingredient= |
valid cooking values for that ingredient |
GET /api/health |
{"status": "ok", "engine_ready": true} |
GET /api/whoami |
{"signed_in": bool, "is_premium": bool} — check your key works |
/api/health returning engine_ready: false means a deploy is warming; scoring endpoints
will 503 until it flips.
Pro-only: batch and export
POST /api/batch — up to 50 ingredients in one request.
curl -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"ingredients": ["strawberry", "garlic"], "top_n": 3}' \
https://compkitchen.com/api/batch
GET /api/export/pair.csv, sub.csv, mood.csv, compare.csv — same parameters as the
JSON endpoints, returned as CSV.
Anonymous callers get 402:
{"detail": "Batch pairings need a developer plan with an API key. Email [email protected] and we will set you up."}
What the API knows: /api/ingredients?include=
The default response is unchanged — {"ingredients": [...]}. Two additions are
opt-in.
include=aliases returns the resolution map. custard apple scores as
apple; without the map you cannot tell which of your inputs are aliases, and
if you are adjusting scores client-side using our compound counts you need to
look up the RESOLVED name. We shipped a bug doing exactly that wrong.
include=corpus_only lists terms that appear in the recipe corpus but reach
no scorable ingredient, with recipe counts and a reason:
{"term": "lard", "recipes": 12760, "reason": "no_compound_data"}
{"term": "soup", "recipes": 10348, "reason": "excluded"}
The two reasons are different facts. excluded means it is a category or a dish
and never will be an ingredient — do not go looking for soup's aroma profile.
no_compound_data means it is a food we simply hold no chemistry for.
This exists because recipe SEARCH finds chorizo happily while the pairing
engine has never heard of it, so the product could give two different answers
about whether an ingredient exists depending which surface you asked. This names
the gap rather than leaving it to be discovered.
When a zero means "unknown": patch_final_score
compound_score: 0.0 reports two opposite facts identically — these share
nothing, and we hold 13 compounds for this ingredient against a graph median of
142. The second is a statement about our coverage, and because final_score
is a 50/50 blend it costs the pair half its score:
| pair | co-occurrences | recipe | compound | final |
|---|---|---|---|---|
ghee + onion |
1,010 | 0.5820 | 0.0000 | 0.3339 |
macaroni + tomato |
789 | 0.5786 | 0.0000 | 0.3671 |
Two things let you handle it.
profile_sizes: how many compounds we hold
for each ingredient you asked about. Each result row's compound_count is the
PARTNER's, so the query side's own coverage was previously invisible and a zero
could not be interpreted. If you would rather compute your own adjustment, this
is the number you need.
It is on all three scoring
endpoints, beside profile_information, which adds how many of those
compounds actually discriminate — a profile can look healthy and be almost
entirely molecules every food carries. All three read the same source, so one
ingredient never reports two different sizes.
| Endpoint | Where |
|---|---|
/api/pair |
query.profile_sizes |
/api/basket |
query.profile_sizes |
/api/pair-score |
top level — this response has no query block |
This is the denominator behind a
low number. jalapeno × paneer scores about 5%, and
that is mostly a statement about us: paneer holds 26 compounds against
jalapeno's 103, and the compound half of the score is an overlap ratio, so a
near-empty profile cannot overlap with anything. 285 of our 852 ingredients hold
fewer than 50 compounds, against a median of 142.
patch_final_score=recipe_score does it server-side. When the thinner of the
two profiles is below 25 compounds — the same cut-off confidence already uses
for coverage_weak — final_score becomes the recipe score alone.
GET /api/pair?ingredients=ghee&top_n=5
-> butter, ginger, tamarind, potato, rice
GET /api/pair?ingredients=ghee&top_n=5&patch_final_score=recipe_score
-> ginger, tamarind, rice, chickpea, lamb
It changes SELECTION, not just order. chickpea and lamb are not in the
default top 5 at all. That is the reason this exists server-side rather than as
advice: you can re-order rows you were given, but you cannot recover a row that
never made the top n because a compound half we do not know dragged it out.
Nothing is lost or hidden:
- patched rows carry
score_basis: "recipe_only"andfinal_score_blendedwith the original value query.patched_rowscounts them;query.patch_final_scoreechoes what was performed- off by default, and an unpatched response is byte-identical to before — the new row fields appear only when a patch actually applied
- pairs where both profiles are full are never touched. A zero overlap between two well-characterised ingredients is a real finding and still stands.
The pairwise parts are available too. Every per_input entry and every
basket_internal pair now carries compound_score and recipe_score alongside
final_score. If you would rather compute your own adjustment across a basket,
those are the numbers you average over, and they previously carried only the
blend.
"per_input": [
{"ingredient": "ghee", "final_score": 0.3784, "compound_score": 0.0067, "recipe_score": 0.52},
{"ingredient": "onion", "final_score": 0.6969, "compound_score": 0.2461, "recipe_score": 0.6037}
]
Aliases are measured as what they score as. custard apple scores as
apple, so it is assessed on apple's 527 compounds, not on the raw string. An
input we cannot resolve at all is excluded from the assessment rather than
counted as zero — and if none of them resolve, no patch is applied, because
patching on no information would be a guess.
It works for baskets too. patch_final_score applies to one ingredient, two
(bridge) and three or more (gap) alike. query.profile_sizes carries the
coverage for every ingredient you asked about, and query.patched_rows counts
what was actually patched in the response you got.
Not yet the other direction. The symmetric case — thin recipe data falling back to the compound score — is deliberately not implemented, because roughly two thirds of pairs currently rest on thin recipe support for a reason that is being fixed at source. Adding it now would fire on our corpus coverage rather than on the food. It follows once that lands.
Detecting a change: watch both signals
Every response carries a data_quality block, and /api/version carries the same
two fields plus the changelog. Neither is sufficient on its own, and which one
moves tells you what kind of change happened.
| field | moves when | set by |
|---|---|---|
scoring_revision |
the way a score is COMPUTED changes | by hand, in the same commit as the change |
dataset_fingerprint |
the DATA underneath changes | derived from the loaded artifacts |
{
"data_quality": {
"scoring_revision": "2026-08-25.1",
"dataset_fingerprint": "5039562c89db"
}
}
dataset_fingerprint is a short digest derived from what is actually loaded —
ingredient and compound counts, the recipe corpus, the co-occurrence index and the
source strings. It is computed at request time rather than stored, so it cannot
drift from what is serving.
Why both. A scoring change with no data change moves scoring_revision and
leaves the fingerprint alone. A data change with no code change does the reverse —
and that case is real: adding measured odour thresholds moved published pair scores
with no line of code touched. Watching only scoring_revision would have reported
"unchanged" through it.
Equally, a content hash over the artifacts is not a substitute for
scoring_revision. The 2026-08-25 shrinkage moved every thinly-attested score while
leaving every artifact byte-identical.
If you cache our scores, key the cache on both. /api/version is served
no-store, so it is safe to poll.
Scoring changes: 2026-08-25.1
Every response carries data_quality.scoring_revision, and /api/version carries the
same value plus a one-line changelog. Watch it. This release moved published numbers.
Recipe co-occurrence is now shrunk toward zero when support is thin. NPMI is
scale-free — it measures how surprising a co-occurrence is, not how much evidence
stands behind it. Its normalisation maps independence to 0.5, so a single shared recipe
bought the same recipe score as genuine independence measured over thousands, worth a
quarter of a point of final_score. 30% of co-occurring pairs rest on exactly one
recipe. Scores are now multiplied by cooc / (cooc + 3): a pair with one recipe keeps a
quarter of its recipe score, ten keeps 77%, fifty keeps 94%.
| before | after | |
|---|---|---|
lemon + white wine (1 recipe) |
0.5289 | 0.3028 |
hops + beer (7 recipes) |
0.5952 | 0.4878 |
salmon + lemon (3,417 recipes) |
0.4237 | 0.4234 |
butter + milk (60,383 recipes) |
unchanged | unchanged |
Across a sample of the top pairings for 20 common ingredients: 46% of top-10 positions
are unchanged and 22% of entries are replaced. The replacements are well-attested pairs
that thin noise had been outranking — onion enters garlic's top 10, potato enters
chicken's. Median movement in that sample is −0.0019.
confidence now reads recipe support. It previously looked only at compound-side
signals while classic scoring is half recipe co-occurrence, so a pair resting on three
recipes could — and did — report high. A classic pair with five or fewer co-occurrences
is now capped at medium, and one with none cannot exceed low; the reason appears in
confidence_factors ("only 3 recipes use these together"). In a 1,200-pair sample, 368
pairs were labelled high on five or fewer recipes. None are now. Every band change is
a demotion — nothing was promoted.
scoring=science is arithmetically untouched, since it carries no recipe term. But
/api/novel and novelty_tier SELECT on recipe score, and novelty means low recipe
support, so which pairs read as under-explored has shifted: of candidates present both
before and after, none changed score by any amount, while none of ten sampled ingredients
kept an identical novel list. If you cache novel results, invalidate them.
Honest limits
Publishing these because you will hit them, and finding out from your own results is worse than being told.
Measured concentration reaches roughly 5% of the compound vocabulary. Which molecules are
present is broad; at what concentration is thin. Anything OAV-derived inherits that ceiling.
A pair can score well on shared compounds that are present in both foods far below their
odour thresholds — chemically real, perceptually irrelevant. measured_score and the
confidence field are the honest signals.
Recipe co-occurrence carries a Western bias. The corpus is largely English-language, so
combinations common in cuisines it under-represents will score lower on the recipe half than
they deserve. scoring=science drops the recipe term entirely if that matters to you.
Substitution is chemical, not culinary. /api/sub will offer peach for butter — they
genuinely share seven lactones. It knows nothing about fat content, structure or heat
behaviour.
Five compound names are held apart in scoring but counted as one molecule in the published
compound total, pending a chemistry review — for example geranial and citral, where one
is an isomer of the other's mixture. They are kept distinct because their CAS numbers differ,
and merging them would have rewritten 348 ingredients on an unreviewed judgement.
223 ingredients (26% of the graph) have no distinctive chemistry on record. They are
characterised only by common metabolites. /api/formula-sub reports this as weak_evidence;
elsewhere it shows up as an ingredient that pairs plausibly with everything and specifically
with nothing.
Support
[email protected]. If a score looks wrong, send the exact request URL — every score
is reproducible from its inputs, and we would rather find out.