Skip to main content

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=0 disables 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/pair score
  • trios[] — three-way analysis
  • buy_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" and final_score_blended with the original value
  • query.patched_rows counts them; query.patch_final_score echoes 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.

Something look wrong? Send the exact request URL to [email protected] — every score is reproducible from its inputs.