"""Epicure Explorer - chef-facing operators over three sibling ingredient embeddings.
Simplified UI: 4 tabs.
- Explore : pick ingredients, see neighbours across all three siblings at once.
- Transform: rotate or do arithmetic on the basket (one tab for all three operators).
- Map : UMAP visualisation.
- From text: paste a recipe / dish description, fuzzy-match to canonical vocab.
Paper: https://arxiv.org/abs/2605.22391
"""
from __future__ import annotations
import os, re, sys, json
from functools import lru_cache
import numpy as np
import gradio as gr
import plotly.graph_objects as go
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
try:
from epicure import Epicure
except ImportError:
from huggingface_hub import hf_hub_download
epicure_py = hf_hub_download("Kaikaku/epicure-cooc", "epicure.py")
sys.path.insert(0, os.path.dirname(epicure_py))
from epicure import Epicure
from rapidfuzz import process as fuzz_process, fuzz as fuzz_scorers
# ===== Kaikaku brand =====
KAIKAKU_DARK = "#0F2D2F"
KAIKAKU_DEEP = "#0A1F20"
KAIKAKU_MID = "#1A3D3F"
KAIKAKU_EDGE = "#2A4D4F"
KAIKAKU_ACCENT = "#288B79"
KAIKAKU_ACCENT_HOVER = "#1E6E5F"
KAIKAKU_ACCENT_LIGHT = "#A8D5CA"
plt.rcParams.update({
"figure.facecolor": "#ffffff", "axes.facecolor": "#ffffff",
"axes.edgecolor": "#cccccc", "axes.labelcolor": "#111111",
"xtick.color": "#333333", "ytick.color": "#333333",
"text.color": "#111111", "savefig.facecolor": "#ffffff",
})
MODELS = {
"cooc": Epicure.from_pretrained("Kaikaku/epicure-cooc"),
"core": Epicure.from_pretrained("Kaikaku/epicure-core"),
"chem": Epicure.from_pretrained("Kaikaku/epicure-chem"),
}
ALL_INGREDIENTS = sorted(MODELS["cooc"].vocab.keys())
_HERE = os.path.dirname(os.path.abspath(__file__))
UMAP_DATA = np.load(os.path.join(_HERE, "umap_2d.npz"))
_lab = json.load(open(os.path.join(_HERE, "ingredient_labels.json")))
NAMES_BY_IDX: list[str] = _lab["names"]
FOOD_GROUPS: list[str] = _lab["food_groups"]
FG_COLORS = {
"Vegetable":"#2ca02c","Fruit":"#e377c2","Grain":"#bcbd22","Dairy":"#17becf",
"Spice":"#d62728","Pantry":"#ff7f0e","Beverage":"#9467bd","Other":"#cccccc",
}
print(f"[epicure-explorer] models loaded: {list(MODELS)}", flush=True)
# Food-group filter helpers
_NAME_TO_GROUP = {NAMES_BY_IDX[i]: FOOD_GROUPS[i] for i in range(len(NAMES_BY_IDX))}
FOOD_GROUP_CHOICES = ["All","Vegetable","Spice","Fruit","Dairy","Grain","Pantry","Beverage","Other"]
def _choices_for_group(group):
if not group or group == "All":
return ALL_INGREDIENTS
return sorted(n for n in ALL_INGREDIENTS if _NAME_TO_GROUP.get(n, "Other") == group)
def _filter_dropdown(group, current_value):
new_choices = _choices_for_group(group)
allowed = set(new_choices)
cur = current_value or []
if isinstance(cur, str):
kept = cur if cur in allowed else None
else:
kept = [v for v in cur if v in allowed]
return gr.Dropdown(choices=new_choices, value=kept)
# ===== math =====
def _unit(v, eps=1e-9):
n = np.linalg.norm(v); return v / max(n, eps)
def _basket_centroid(m, names):
valid = [n for n in (names or []) if n in m.vocab]
if not valid: return None
return _unit(m.E[[m.vocab[n] for n in valid]].mean(axis=0))
def _stack_directions(m, keys, use_factor_pole=False):
poles = []
for k in keys or []:
if use_factor_pole:
for mode in m.modes:
if mode.mode_id == k:
poles.append(_unit(mode.pole)); break
else:
if k in m.supervised_poles:
poles.append(_unit(m.supervised_poles[k]))
if not poles: return None
return _unit(np.stack(poles, axis=0).sum(axis=0))
def _topk(m, q, k, exclude):
sims = m.E @ q
for n in exclude or []:
if n in m.vocab: sims[m.vocab[n]] = -np.inf
order = np.argsort(-sims)
return [(m.itos[int(i)], float(sims[i])) for i in order[:k]]
def _supervised_choices(sibling):
return sorted(MODELS[sibling].supervised_poles.keys())
def _factor_mode_choices(sibling):
return [(f"{m.label} ({m.mode_id})", m.mode_id) for m in MODELS[sibling].modes if m.kind == "factor"]
def _slerp(v, d, theta_deg):
d_perp = d - (d @ v) * v
n = np.linalg.norm(d_perp)
if n < 1e-9: return v
d_perp = d_perp / n
th = np.deg2rad(float(theta_deg))
return _unit(np.cos(th) * v + np.sin(th) * d_perp)
# ===== Heatmap =====
def _basket_heatmap(m, basket):
valid = [n for n in (basket or []) if n in m.vocab]
fig, ax = plt.subplots(figsize=(5.5, 4.5))
if len(valid) < 2:
ax.text(0.5, 0.5, "Add 2+ ingredients to see pairwise cosines",
ha="center", va="center", fontsize=12, color="#888", transform=ax.transAxes)
ax.axis("off"); plt.tight_layout(); return fig
idxs = [m.vocab[n] for n in valid]
sub = m.E[idxs]
sim = sub @ sub.T
im = ax.imshow(sim, cmap="viridis", vmin=-0.2, vmax=1.0, aspect="auto")
ax.set_xticks(range(len(valid))); ax.set_yticks(range(len(valid)))
ax.set_xticklabels(valid, rotation=35, ha="right"); ax.set_yticklabels(valid)
for i in range(len(valid)):
for j in range(len(valid)):
v = float(sim[i, j])
ax.text(j, i, f"{v:.2f}", ha="center", va="center", fontsize=9,
color=("white" if v < 0.55 else "black"))
cb = plt.colorbar(im, ax=ax); cb.set_label("cosine")
plt.tight_layout()
return fig
# ===== UMAP =====
def _umap_coords(sibling, three_d):
base = UMAP_DATA[sibling]
if not three_d:
return base, None
m = MODELS[sibling]
E = m.E - m.E.mean(axis=0, keepdims=True)
_, _, Vt = np.linalg.svd(E, full_matrices=False)
pc1 = E @ Vt[0]
pc1 = (pc1 - pc1.mean()) / (pc1.std() + 1e-9)
scale = (base.max() - base.min()) * 0.25
return base, (pc1 * scale).astype(np.float32)
def umap_view(sibling, basket, show_neighbours, k, three_d=False):
coords2, z = _umap_coords(sibling, three_d)
m = MODELS[sibling]
n = len(NAMES_BY_IDX)
colors = [FG_COLORS.get(fg, "#cccccc") for fg in FOOD_GROUPS]
hover_text = [f"{NAMES_BY_IDX[i]}
group: {FOOD_GROUPS[i]}" for i in range(n)]
basket_set = set(basket or [])
basket_idxs = [m.vocab[b] for b in (basket or []) if b in m.vocab]
neighbour_set = set()
if show_neighbours and basket_idxs:
centroid = _basket_centroid(m, basket)
if centroid is not None:
nb = _topk(m, centroid, k=int(k), exclude=basket)
neighbour_set = {nm for nm, _ in nb}
keep = lambda i: NAMES_BY_IDX[i] not in basket_set and NAMES_BY_IDX[i] not in neighbour_set
bg_x = [float(coords2[i, 0]) for i in range(n) if keep(i)]
bg_y = [float(coords2[i, 1]) for i in range(n) if keep(i)]
bg_z = [float(z[i]) for i in range(n) if keep(i)] if three_d else None
bg_c = [colors[i] for i in range(n) if keep(i)]
bg_h = [hover_text[i] for i in range(n) if keep(i)]
fig = go.Figure()
if three_d:
fig.add_trace(go.Scatter3d(x=bg_x, y=bg_y, z=bg_z, mode="markers",
marker=dict(size=3, color=bg_c, opacity=0.55), text=bg_h,
hovertemplate="%{text}", name="ingredients", showlegend=False))
else:
fig.add_trace(go.Scattergl(x=bg_x, y=bg_y, mode="markers",
marker=dict(size=5, color=bg_c, opacity=0.65), text=bg_h,
hovertemplate="%{text}", name="ingredients", showlegend=False))
if neighbour_set:
ni = [i for i in range(n) if NAMES_BY_IDX[i] in neighbour_set]
nx = [float(coords2[i, 0]) for i in ni]; ny = [float(coords2[i, 1]) for i in ni]
nz = [float(z[i]) for i in ni] if three_d else None
nl = [NAMES_BY_IDX[i] for i in ni]
mk = dict(size=11 if not three_d else 6, color="#ff8800", opacity=0.95,
line=dict(color="#ffffff", width=1.2))
TR = go.Scatter3d if three_d else go.Scatter
kw = dict(mode="markers+text", marker=mk, text=nl, textposition="top center",
textfont=dict(size=10),
hovertemplate="%{text} (neighbour)",
name=f"top-{k} neighbours")
fig.add_trace(TR(x=nx, y=ny, z=nz, **kw) if three_d else TR(x=nx, y=ny, **kw))
if basket_idxs:
bx = [float(coords2[i, 0]) for i in basket_idxs]
by = [float(coords2[i, 1]) for i in basket_idxs]
bz = [float(z[i]) for i in basket_idxs] if three_d else None
bl = [NAMES_BY_IDX[i] for i in basket_idxs]
mk = dict(size=18 if not three_d else 9, color=KAIKAKU_ACCENT,
symbol="star" if not three_d else "diamond",
line=dict(color="#111111", width=1.5))
TR = go.Scatter3d if three_d else go.Scatter
kw = dict(mode="markers+text", marker=mk, text=bl, textposition="top center",
textfont=dict(size=13, color="#111111"),
hovertemplate="%{text} (basket)", name="basket")
fig.add_trace(TR(x=bx, y=by, z=bz, **kw) if three_d else TR(x=bx, y=by, **kw))
fig.update_layout(
title=dict(text=f"UMAP - Epicure-{sibling.capitalize()}{' (3D)' if three_d else ''}", font=dict(size=14)),
height=620, margin=dict(l=40, r=40, t=50, b=40),
paper_bgcolor="#ffffff", plot_bgcolor="#ffffff",
legend=dict(orientation="v", x=1.02, y=1, font=dict(size=11)),
)
if not three_d:
fig.update_xaxes(showgrid=True, gridcolor="#eee", zeroline=False, title="UMAP 1")
fig.update_yaxes(showgrid=True, gridcolor="#eee", zeroline=False, title="UMAP 2")
else:
fig.update_layout(scene=dict(xaxis=dict(title="UMAP 1"), yaxis=dict(title="UMAP 2"),
zaxis=dict(title="PC1 (z)"), bgcolor="#ffffff"))
return fig
# ===== Explore: side-by-side neighbours across siblings =====
def explore_all_siblings(basket, k):
"""Returns 3 dataframes (Cooc/Core/Chem neighbours), heatmap, and mode tables per sibling."""
out_nb = []
out_modes = []
for sib in ["cooc","core","chem"]:
m = MODELS[sib]
c = _basket_centroid(m, basket)
if c is None:
out_nb.append([]); out_modes.append([]); continue
nb = _topk(m, c, int(k), exclude=basket or [])
out_nb.append([[n, f"{s:.4f}"] for n, s in nb])
scored = [(mode.mode_id, mode.label, mode.kind, float(_unit(mode.pole) @ c)) for mode in m.modes]
scored.sort(key=lambda x: -x[3])
out_modes.append([[mid, label, kind, f"{sim:.3f}"] for mid, label, kind, sim in scored[:5]])
heat = _basket_heatmap(MODELS["chem"], basket)
return out_nb[0], out_nb[1], out_nb[2], heat, out_modes[0], out_modes[1], out_modes[2]
# ===== Transform: unified operator =====
def transform(sibling, op, basket, directions, mode_labels, theta, negatives, k):
m = MODELS[sibling]
if op == "Rotate to supervised direction":
v = _basket_centroid(m, basket)
if v is None: return [], "_(empty basket)_"
d = _stack_directions(m, directions, use_factor_pole=False)
if d is None: return [[n, f"{s:.4f}"] for n, s in _topk(m, v, k, basket)], "_(no direction selected)_"
q = _slerp(v, d, theta)
rows = [[n, f"{s:.4f}"] for n, s in _topk(m, q, k, basket)]
return rows, _explain_slerp(m, basket, directions or [], theta, q, v, d)
if op == "Rotate to emergent mode":
label_to_id = {f"{md.label} ({md.mode_id})": md.mode_id for md in m.modes if md.kind == "factor"}
mode_ids = [label_to_id[lab] for lab in (mode_labels or []) if lab in label_to_id]
v = _basket_centroid(m, basket)
if v is None: return [], "_(empty basket)_"
d = _stack_directions(m, mode_ids, use_factor_pole=True)
if d is None: return [[n, f"{s:.4f}"] for n, s in _topk(m, v, k, basket)], "_(no mode selected)_"
q = _slerp(v, d, theta)
rows = [[n, f"{s:.4f}"] for n, s in _topk(m, q, k, basket)]
return rows, _explain_slerp(m, basket, mode_ids, theta, q, v, d)
# Arithmetic
pos = _basket_centroid(m, basket)
if pos is None: return [], "_(no positives)_"
neg = _basket_centroid(m, negatives) if negatives else None
q = _unit(pos - neg) if neg is not None else pos
rows = [[n, f"{s:.4f}"] for n, s in _topk(m, q, k, (basket or []) + (negatives or []))]
return rows, _explain_arithmetic(m, basket, negatives or [], q)
def _explain_slerp(m, basket, dir_keys, theta, q, v, d):
if q is None or v is None or d is None: return ""
cos_theta = float(q @ v)
travelled = min(max(float(theta) / 90.0, 0.0), 1.0)
dir_nb = _topk(m, _unit(d), 5, exclude=basket or [])
seed_nb = _topk(m, v, 3, exclude=basket or [])
dirs_str = " + ".join(dir_keys) if dir_keys else "(none)"
return (
f"**Why these results.** Rotated cos to seed = {cos_theta:.3f} "
f"({travelled*100:.0f}% of the way to {dirs_str}). "
f"Direction's own neighbourhood: {', '.join(n for n, _ in dir_nb[:5])}. "
f"Seed basket's own top-3: {', '.join(n for n, _ in seed_nb)}."
)
def _explain_arithmetic(m, positives, negatives, q):
if q is None: return ""
pos_sims = [(n, float(_unit(m.E[m.vocab[n]]) @ q)) for n in positives if n in m.vocab]
neg_sims = [(n, float(_unit(m.E[m.vocab[n]]) @ q)) for n in negatives if n in m.vocab]
pp = ", ".join(f"{n} ({s:+.2f})" for n, s in pos_sims) or "(none)"
np_ = ", ".join(f"{n} ({s:+.2f})" for n, s in neg_sims) or "(none)"
return f"**Why these results.** Result vs positives: {pp}. Result vs negatives: {np_}."
# ===== From-text: combined fridge parser + recipe builder =====
_LINE_SPLIT = re.compile(r"[\n;]")
_BRACKET = re.compile(r"\([^)]*\)")
_QTY = r"(?:\d+(?:[\.,/]\d+)?|a|an|one|two|three|four|five|six|seven|eight|nine|ten|half|quarter)"
_UNIT = (r"(?:cups?|tbsp\.?|tablespoons?|tsp\.?|teaspoons?|oz\.?|ounces?|lbs?\.?|pounds?|"
r"grams?|kgs?|kilos?|ml|liters?|litres?|cloves?|bunches?|sprigs?|pinch(?:es)?|"
r"slices?|pieces?|cans?|packets?|sticks?|leaves?|stalks?|heads?|inch(?:es)?|"
r"splash(?:es)?|dash(?:es)?|drops?|handfuls?|large|small|medium)")
_LEADING_QTY = re.compile(rf"^\s*{_QTY}\s+(?:{_UNIT}\b\s*)?(?:of\s+)?", re.IGNORECASE)
_LEADING_UNIT_ONLY = re.compile(rf"^\s*{_UNIT}\b\s*(?:of\s+)?", re.IGNORECASE)
_JUICE_OF = re.compile(rf"^\s*(?:juice|zest)\s+(?:of\s+)?(?:{_QTY}\s+)?", re.IGNORECASE)
_LEADING_PREP = re.compile(
r"^\s*(?:fresh|dried|cooked|frozen|raw|ripe|firm|boneless|skinless|smoked|low[- ]fat)\s+", re.IGNORECASE)
_TRAILING_PREP = re.compile(
r"\s*,\s*(?:chopped|minced|diced|sliced|grated|crushed|whole|ground|peeled|"
r"to taste|optional|finely|coarsely|cubed|shredded|julienned|halved|quartered|warmed|"
r"toasted|roasted|bruised|melted|softened|cooked|drained|rinsed|patted dry|trimmed|"
r"deveined|seeded|stemmed|crumbled).*$", re.IGNORECASE)
_KNOWN_PLURALS = {"tortillas":"tortilla","thighs":"thigh","leaves":"leaf","onions":"onion",
"potatoes":"potato","tomatoes":"tomato","cloves":"clove"}
def _clean_line(line):
s = line.strip().lower()
s = _BRACKET.sub(" ", s)
if "juice" in s or "zest" in s:
s = _JUICE_OF.sub("", s)
s = _TRAILING_PREP.sub("", s)
s = _LEADING_QTY.sub("", s)
s = _LEADING_UNIT_ONLY.sub("", s)
s = _LEADING_PREP.sub("", s)
s = _LEADING_PREP.sub("", s)
tokens = [_KNOWN_PLURALS.get(t, t) for t in s.split()]
return re.sub(r"\s+", " ", " ".join(tokens)).strip()
def _fuzzy_lookup(cleaned, vocab, vocab_sp, min_score):
if not cleaned: return None, 0.0
candidates = []
for scorer in (fuzz_scorers.token_set_ratio, fuzz_scorers.WRatio, fuzz_scorers.partial_ratio):
hits = fuzz_process.extract(cleaned, vocab_sp, scorer=scorer, score_cutoff=min_score, limit=10)
for _sp, score, idx in hits:
candidates.append((vocab[idx], float(score)))
if not candidates: return None, 0.0
cleaned_tokens = set(cleaned.split())
def rank(c):
name, score = c
nt = set(name.replace("_", " ").split())
return (-score, 0 if nt.issubset(cleaned_tokens) else 1, -len(name))
candidates.sort(key=rank)
return candidates[0]
def parse_fridge(raw_text, sibling="chem", min_score=70):
if not raw_text or not raw_text.strip(): return [], []
vocab = list(MODELS[sibling].vocab.keys())
vocab_sp = [v.replace("_", " ") for v in vocab]
rows, matched = [], []
for line in _LINE_SPLIT.split(raw_text):
if not line.strip(): continue
cleaned = _clean_line(line)
if not cleaned:
rows.append([line.strip(), "(empty)", 0.0]); continue
match, score = _fuzzy_lookup(cleaned, vocab, vocab_sp, int(min_score))
if match is None:
tokens = cleaned.split()
if len(tokens) > 1:
match, score = _fuzzy_lookup(" ".join(tokens[:-1]), vocab, vocab_sp, int(min_score))
if match is None:
rows.append([line.strip(), "(no match)", 0.0]); continue
rows.append([line.strip(), match, round(score, 1)])
matched.append(match)
seen, dedup = set(), []
for n in matched:
if n not in seen: seen.add(n); dedup.append(n)
return rows, dedup
# Sentence-transformer for thematic queries
_ST = None
def _get_st():
global _ST
if _ST is None:
from sentence_transformers import SentenceTransformer
_ST = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2", device="cpu")
return _ST
@lru_cache(maxsize=4)
def _mode_label_matrix(sibling):
m = MODELS[sibling]
modes = [md for md in m.modes if md.kind == "factor"]
if not modes:
return [], [], np.zeros((0, 384), dtype=np.float32)
labels = [md.label for md in modes]
mids = [md.mode_id for md in modes]
M = _get_st().encode(labels, normalize_embeddings=True, convert_to_numpy=True)
return mids, labels, M.astype(np.float32)
def _mode_quartile(mode):
members = list(mode.members or [])
n = max(4, min(12, (len(members) + 3) // 4))
return members[:n]
_STOP = {"i","im","i'm","a","an","the","for","of","with","and","or","some","my","me","we",
"make","making","cook","cooking","prepare","preparing","want","need","to","tonight",
"people","person","servings","dinner","lunch","dish","recipe","quick","easy",
"tasty","yummy","good","great","food","meal","style"}
_TOK_RE = re.compile(r"[A-Za-z][A-Za-z\-']{1,}")
def suggest_basket(prompt, sibling="chem", k=10):
if not prompt or not prompt.strip():
return [], [], "_(empty prompt)_"
vocab = list(MODELS[sibling].vocab.keys())
vocab_sp = [v.replace("_"," ") for v in vocab]
tokens = [t for t in _TOK_RE.findall(prompt.lower()) if t not in _STOP and len(t) > 2]
direct = {}
direct_evidence = []
for tok in tokens:
hits = fuzz_process.extract(tok, vocab_sp, scorer=fuzz_scorers.token_set_ratio,
score_cutoff=88, limit=2)
for _sp, score, idx in hits:
name = vocab[idx]
if score > direct.get(name, 0):
direct[name] = float(score)
direct_evidence.append((tok, name, float(score)))
mids, labels, M = _mode_label_matrix(sibling)
thematic = {}
thematic_modes = []
if M.shape[0] > 0:
q = _get_st().encode([prompt], normalize_embeddings=True, convert_to_numpy=True)[0]
sims = M @ q
order = np.argsort(-sims)
picked = [(mids[i], labels[i], float(sims[i])) for i in order[:3] if sims[i] >= 0.25]
thematic_modes = picked
id_to_mode = {md.mode_id: md for md in MODELS[sibling].modes if md.kind == "factor"}
for mid, lab, sim in picked:
for name in _mode_quartile(id_to_mode[mid]):
s_existing, _ = thematic.get(name, (0.0, ""))
thematic[name] = (max(s_existing, sim * 100.0), lab)
combined = {}
for name, sc in direct.items(): combined[name] = (sc, "direct")
for name, (sc, lab) in thematic.items():
prev = combined.get(name)
if prev is None or sc > prev[0]:
combined[name] = (sc, "both" if prev else "thematic")
ranked = sorted(combined.items(),
key=lambda kv: (-kv[1][0], 0 if kv[1][1] != "thematic" else 1, kv[0]))[:int(k)]
rows = [[name, src, round(score, 1)] for name, (score, src) in ranked]
names = [name for name, _ in ranked]
lines = []
if direct_evidence:
lines.append("**Direct mentions:** " + ", ".join(sorted({f"`{n}`" for _, n, _ in direct_evidence})))
if thematic_modes:
lines.append("**Matched modes:** " + "; ".join(f"`{lab}` (cos {sim:.2f})" for _, lab, sim in thematic_modes))
return rows, names, "\n\n".join(lines) if lines else "_(no matches)_"
def parse_or_suggest(text, sibling, mode_choice):
"""Auto-detect: fridge-list if mostly short lines with units; recipe-prompt otherwise."""
if not text or not text.strip(): return [], "_(empty)_", []
if mode_choice == "Recipe / dish description":
rows, names, expl = suggest_basket(text, sibling, 10)
return rows, expl, names
rows, names = parse_fridge(text, sibling, 70)
return rows, f"Matched {len(names)} ingredients.", names
# ===== Mode atlas (used inside Explore Accordion) =====
def browse_modes(sibling, kind_filter, query):
m = MODELS[sibling]
rows, q = [], (query or "").strip().lower()
for mode in m.modes:
if kind_filter != "all" and mode.kind != kind_filter:
continue
if q and q not in mode.label.lower() and q not in mode.property.lower():
continue
rows.append([mode.mode_id, mode.kind, mode.property, mode.label, mode.n_members,
", ".join(mode.members[:10])])
rows.sort(key=lambda r: (r[1], -r[4]))
return rows
# ===== Public API endpoint helpers =====
def _suggest(name, sibling, n=5):
vocab = list(MODELS[sibling].vocab.keys())
hits = fuzz_process.extract((name or "").lower().replace(" ", "_"),
vocab, scorer=fuzz_scorers.WRatio, limit=n)
return [h[0] for h in hits]
def api_neighbors(ingredient, sibling="chem", k=5):
if sibling not in MODELS: return {"error": "bad sibling"}
if ingredient not in MODELS[sibling].vocab: return {"error": f"'{ingredient}' not in vocab", "suggestions": _suggest(ingredient, sibling)}
m = MODELS[sibling]
q = _unit(m.E[m.vocab[ingredient]])
return [{"name": n, "cosine": round(float(s), 6)} for n, s in _topk(m, q, int(k), [ingredient])]
def api_slerp(seed, direction, theta_deg=30, sibling="chem", k=5):
if sibling not in MODELS: return {"error": "bad sibling"}
m = MODELS[sibling]
if seed not in m.vocab: return {"error": f"'{seed}' not in vocab", "suggestions": _suggest(seed, sibling)}
if direction not in m.supervised_poles: return {"error": f"'{direction}' not a supervised pole"}
v = _unit(m.E[m.vocab[seed]])
d = _unit(m.supervised_poles[direction])
q = _slerp(v, d, float(theta_deg))
return [{"name": n, "cosine": round(float(s), 6)} for n, s in _topk(m, q, int(k), [seed])]
def api_arithmetic(positives, negatives, sibling="chem", k=5):
if sibling not in MODELS: return {"error": "bad sibling"}
positives = list(positives or []); negatives = list(negatives or [])
if not positives: return {"error": "positives must be non-empty"}
m = MODELS[sibling]
unknown = [x for x in positives + negatives if x not in m.vocab]
if unknown: return {"error": f"unknown: {unknown}"}
pos = _basket_centroid(m, positives)
neg = _basket_centroid(m, negatives) if negatives else None
q = _unit(pos - neg) if neg is not None else pos
return [{"name": n, "cosine": round(float(s), 6)} for n, s in _topk(m, q, int(k), positives + negatives)]
def api_embed(ingredient, sibling="chem"):
if sibling not in MODELS: return {"error": "bad sibling"}
m = MODELS[sibling]
if ingredient not in m.vocab: return {"error": f"'{ingredient}' not in vocab"}
return [float(x) for x in _unit(m.E[m.vocab[ingredient]]).tolist()]
# ===== Theme =====
THEME = gr.themes.Soft(
primary_hue=gr.themes.Color(
c50="#E8F4F1", c100="#C8E6DE", c200=KAIKAKU_ACCENT_LIGHT,
c300="#7BBAA9", c400="#4DA08F", c500=KAIKAKU_ACCENT,
c600=KAIKAKU_ACCENT_HOVER, c700="#155547", c800="#0F3B33",
c900=KAIKAKU_DARK, c950=KAIKAKU_DEEP,
),
neutral_hue="slate",
font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui", "sans-serif"],
).set(
block_label_text_color="#1f2937", block_label_text_weight="600",
block_title_text_color="#0f172a", block_title_text_weight="700",
body_text_color="#0f172a", body_text_color_subdued="#475569",
button_primary_background_fill=KAIKAKU_ACCENT,
button_primary_background_fill_hover=KAIKAKU_ACCENT_HOVER,
button_primary_text_color="#ffffff",
button_primary_border_color=KAIKAKU_ACCENT,
button_secondary_background_fill="#f1f5f9",
button_secondary_text_color=KAIKAKU_DARK,
slider_color=KAIKAKU_ACCENT,
color_accent=KAIKAKU_ACCENT,
)
CUSTOM_CSS = f"""
.gradio-container {{max-width: 1280px !important;}}
footer {{visibility: hidden;}}
.gradio-container label, .gradio-container .label,
.gradio-container [data-testid="block-label"], .gradio-container .block-label {{
color: #0f172a !important; font-weight: 600 !important; background: transparent !important;
}}
.gradio-container button[role="tab"] {{ color: #334155 !important; font-weight: 500 !important; }}
.gradio-container button[role="tab"][aria-selected="true"] {{
color: {KAIKAKU_ACCENT} !important; border-bottom-color: {KAIKAKU_ACCENT} !important; font-weight: 700 !important;
}}
.gradio-container button.primary, .gradio-container .primary > button {{
background: {KAIKAKU_ACCENT} !important; color: #ffffff !important;
border-color: {KAIKAKU_ACCENT} !important; font-weight: 600 !important;
}}
.gradio-container table thead th {{
color: #0f172a !important; font-weight: 700 !important; background: #f8fafc !important;
}}
.gradio-container table tbody td {{ color: #0f172a !important; }}
/* Spectrum bar */
.spectrum-bar {{
display: flex; align-items: stretch; margin: 12px 0 4px 0; height: 56px;
border-radius: 8px; overflow: hidden;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
}}
.spectrum-cell {{
flex: 1; display: flex; flex-direction: column; justify-content: center;
padding: 6px 14px; color: #0f172a;
}}
.spectrum-cell-1 {{ background: #f0f9f6; }}
.spectrum-cell-2 {{ background: #d8efe7; }}
.spectrum-cell-3 {{ background: #b8dfd1; }}
.spectrum-name {{ font-weight: 700; font-size: 0.95em; }}
.spectrum-sub {{ font-size: 0.8em; color: #475569; }}
.spectrum-arrow {{ width: 16px; background: transparent; display:flex; align-items:center; justify-content:center; color: #94a3b8; }}
"""
SPECTRUM_BAR = """
Cooc
recipe co-occurrence; neighbours = recipe companions
→
Core
blended; concentrated geometry; tightest emergent modes
→
Chem
FlavorDB compound metapaths; neighbours = flavour-profile peers
"""
# ===== Pre-rendered killer demo on landing =====
_DEFAULT_BASKET = ["chicken","lemon","garlic"]
_INIT_NB_COOC, _INIT_NB_CORE, _INIT_NB_CHEM, _INIT_HEATMAP, _INIT_MD_COOC, _INIT_MD_CORE, _INIT_MD_CHEM = explore_all_siblings(_DEFAULT_BASKET, 8)
_INIT_UMAP = umap_view("chem", _DEFAULT_BASKET, True, 8)
# ===== UI =====
with gr.Blocks(title="Epicure Explorer", theme=THEME, css=CUSTOM_CSS) as demo:
gr.Markdown(
"""# Epicure Explorer
Three sibling ingredient embeddings from [arXiv:2605.22391](https://arxiv.org/abs/2605.22391).
1,790 canonical ingredients across 7 languages; 300-D Metapath2Vec; controlled chemistry-vs-recipe-context spectrum.
"""
)
gr.HTML(SPECTRUM_BAR)
with gr.Tabs():
# ---------- Tab 1: EXPLORE ----------
with gr.Tab("Explore"):
gr.Markdown("Pick ingredients. See nearest neighbours in **all three siblings side-by-side** so the spectrum shows in one screen.")
with gr.Row():
ex_basket = gr.Dropdown(choices=ALL_INGREDIENTS, value=_DEFAULT_BASKET,
label="Ingredient basket", multiselect=True, max_choices=10,
scale=4)
ex_k = gr.Slider(3, 15, value=8, step=1, label="K", scale=1)
with gr.Row():
ex_fg = gr.Radio(choices=FOOD_GROUP_CHOICES, value="All",
label="Filter dropdown by food group", interactive=True, scale=3)
ex_btn = gr.Button("Find neighbours", variant="primary", scale=1)
ex_fg.change(_filter_dropdown, inputs=[ex_fg, ex_basket], outputs=ex_basket, show_progress="hidden")
gr.Examples(
examples=[
[["chicken","lemon","garlic"], 8],
[["miso","ginger","sesame_oil"], 8],
[["tomato","basil","mozzarella_cheese"], 8],
[["chocolate","strawberry","cream"], 8],
[["cumin","coriander","turmeric"], 8],
[["coconut_milk","lemongrass","fish_sauce"], 8],
[["red_wine","beef","rosemary"], 8],
],
inputs=[ex_basket, ex_k],
label="Try a basket (one click)",
)
with gr.Row():
ex_nb_cooc = gr.Dataframe(value=_INIT_NB_COOC, headers=["Cooc","cos"],
label="Cooc (recipe-context)", interactive=False)
ex_nb_core = gr.Dataframe(value=_INIT_NB_CORE, headers=["Core","cos"],
label="Core (blended)", interactive=False)
ex_nb_chem = gr.Dataframe(value=_INIT_NB_CHEM, headers=["Chem","cos"],
label="Chem (chemistry)", interactive=False)
with gr.Accordion("Closest modes (per sibling)", open=False):
with gr.Row():
ex_md_cooc = gr.Dataframe(value=_INIT_MD_COOC, headers=["id","label","kind","cos"],
label="Cooc top modes", interactive=False, wrap=True)
ex_md_core = gr.Dataframe(value=_INIT_MD_CORE, headers=["id","label","kind","cos"],
label="Core top modes", interactive=False, wrap=True)
ex_md_chem = gr.Dataframe(value=_INIT_MD_CHEM, headers=["id","label","kind","cos"],
label="Chem top modes", interactive=False, wrap=True)
with gr.Accordion("Pairwise coherence (basket members)", open=False):
ex_heat = gr.Plot(value=_INIT_HEATMAP, label="Heatmap")
with gr.Accordion("Browse the mode atlas (150-200 modes per sibling)", open=False):
with gr.Row():
atlas_sib = gr.Radio(choices=["cooc","core","chem"], value="chem", label="Sibling")
atlas_kind = gr.Radio(choices=["all","factor","continuous","binary"], value="all", label="Kind")
atlas_q = gr.Textbox(label="Search labels", placeholder="e.g. South Asian, baking", scale=2)
atlas_btn = gr.Button("Browse", variant="primary")
atlas_table = gr.Dataframe(
headers=["mode_id","kind","property","label","n_members","top members"],
interactive=False, wrap=True,
)
atlas_btn.click(browse_modes, inputs=[atlas_sib, atlas_kind, atlas_q], outputs=atlas_table)
ex_btn.click(
explore_all_siblings,
inputs=[ex_basket, ex_k],
outputs=[ex_nb_cooc, ex_nb_core, ex_nb_chem, ex_heat, ex_md_cooc, ex_md_core, ex_md_chem],
show_progress="minimal",
)
# ---------- Tab 2: TRANSFORM ----------
with gr.Tab("Transform"):
gr.Markdown("Rotate the basket toward a direction, an emergent mode, or compute `basket - negatives`. **All three operators on one form.**")
with gr.Row():
tx_sib = gr.Radio(choices=["cooc","core","chem"], value="core", label="Sibling")
tx_op = gr.Radio(
choices=["Rotate to supervised direction","Rotate to emergent mode","Arithmetic (basket - negatives)"],
value="Arithmetic (basket - negatives)", label="Operation",
)
with gr.Row():
tx_basket = gr.Dropdown(choices=ALL_INGREDIENTS, value=["miso"], label="Basket / positives",
multiselect=True, max_choices=10, scale=3)
tx_neg = gr.Dropdown(choices=ALL_INGREDIENTS, value=["salt"], label="Negatives (Arithmetic only)",
multiselect=True, max_choices=10, scale=2)
with gr.Row():
tx_dirs = gr.Dropdown(choices=_supervised_choices("core"), value=[],
label="Supervised directions (for 'Rotate to supervised')",
multiselect=True, max_choices=5, scale=3)
tx_modes = gr.Dropdown(choices=[lab for lab, _ in _factor_mode_choices("core")], value=[],
label="Factor modes (for 'Rotate to emergent')",
multiselect=True, max_choices=5, scale=3)
with gr.Row():
tx_theta = gr.Slider(0, 90, value=30, step=5, label="Rotation angle (deg, SLERP only)", scale=2)
tx_k = gr.Slider(3, 15, value=8, step=1, label="K", scale=1)
tx_btn = gr.Button("Run", variant="primary", scale=1)
tx_sib.change(lambda s: gr.Dropdown(choices=_supervised_choices(s), value=[]),
inputs=tx_sib, outputs=tx_dirs)
tx_sib.change(lambda s: gr.Dropdown(choices=[lab for lab, _ in _factor_mode_choices(s)], value=[]),
inputs=tx_sib, outputs=tx_modes)
tx_table = gr.Dataframe(headers=["Ingredient","cos"], label="Top-K result", interactive=False)
tx_why = gr.Markdown()
tx_btn.click(
transform,
inputs=[tx_sib, tx_op, tx_basket, tx_dirs, tx_modes, tx_theta, tx_neg, tx_k],
outputs=[tx_table, tx_why], show_progress="minimal",
)
gr.Examples(
examples=[
["core", "Arithmetic (basket - negatives)", ["miso"], [], [], 30, ["salt"], 8],
["core", "Arithmetic (basket - negatives)", ["coffee"], [], [], 30, ["milk"], 8],
["chem", "Arithmetic (basket - negatives)", ["chocolate"], [], [], 30, ["sugar"], 8],
["chem", "Rotate to supervised direction", ["rice"], ["cuisine:South_Asian"], [], 30, [], 8],
["chem", "Rotate to supervised direction", ["corn"], ["cuisine:Latin_American"], [], 30, [], 8],
],
inputs=[tx_sib, tx_op, tx_basket, tx_dirs, tx_modes, tx_theta, tx_neg, tx_k],
label="Try one of these",
)
# ---------- Tab 3: MAP ----------
with gr.Tab("Map"):
gr.Markdown("UMAP of the 1,790-ingredient embedding (cosine, n_neighbors=30, min_dist=0.03; paper Fig 1).")
with gr.Row():
map_sib = gr.Radio(choices=["cooc","core","chem"], value="chem", label="Sibling", scale=1)
map_basket = gr.Dropdown(choices=ALL_INGREDIENTS, value=_DEFAULT_BASKET,
label="Highlight basket", multiselect=True, max_choices=10, scale=3)
with gr.Row():
map_3d = gr.Checkbox(value=False, label="3-D")
map_nb = gr.Checkbox(value=True, label="Show top-K neighbours")
map_k = gr.Slider(3, 20, value=10, step=1, label="K", scale=1)
map_btn = gr.Button("Update", variant="primary", scale=1)
map_plot = gr.Plot(value=_INIT_UMAP, label="UMAP")
map_btn.click(umap_view, inputs=[map_sib, map_basket, map_nb, map_k, map_3d], outputs=map_plot,
show_progress="minimal")
# ---------- Tab 4: FROM TEXT ----------
with gr.Tab("From text"):
gr.Markdown("Paste a **shopping list / recipe ingredients** to get canonical matches, **or a dish description** to get thematic suggestions. Send the result into the Explore tab.")
ft_text = gr.Textbox(
label="Free text",
lines=6,
value="I'm making Thai green curry for 4 people",
placeholder=("Either a dish description ('I'm making Thai green curry for 4'), or "
"an ingredient list ('2 chicken thighs / 1 cup coconut milk / fish sauce / ...')"),
)
ft_mode = gr.Radio(
choices=["Recipe / dish description", "Ingredient list (shopping list / fridge)"],
value="Recipe / dish description",
label="Treat as",
)
with gr.Row():
ft_sib = gr.Radio(choices=["cooc","core","chem"], value="chem", label="Sibling")
ft_btn = gr.Button("Match", variant="primary")
ft_send = gr.Button("Send to Explore", variant="secondary")
ft_table = gr.Dataframe(headers=["Input","Match","Score"], interactive=False, label="Matched ingredients")
ft_expl = gr.Markdown()
ft_matched = gr.State([])
ft_btn.click(parse_or_suggest, inputs=[ft_text, ft_sib, ft_mode],
outputs=[ft_table, ft_expl, ft_matched], show_progress="full")
ft_send.click(lambda names: gr.Dropdown(value=(names or [])[:10]),
inputs=[ft_matched], outputs=[ex_basket])
gr.Examples(
examples=[
["I'm making Thai green curry for 4 people", "Recipe / dish description"],
["spicy vegetarian taco filling", "Recipe / dish description"],
["Japanese miso-glazed salmon and greens", "Recipe / dish description"],
["2 boneless chicken thighs\n1 cup coconut milk\n1 tbsp fish sauce\nfresh lemongrass\n3 cloves garlic\njuice of one lime",
"Ingredient list (shopping list / fridge)"],
],
inputs=[ft_text, ft_mode],
label="Try one of these",
)
# ---- Hidden API endpoints ----
with gr.Group(visible=False):
api_in_s1 = gr.Textbox(visible=False)
api_in_s2 = gr.Textbox(visible=False)
api_in_n = gr.Number(visible=False, value=5)
api_in_n2 = gr.Number(visible=False, value=30)
api_in_l1 = gr.JSON(visible=False, value=[])
api_in_l2 = gr.JSON(visible=False, value=[])
api_out = gr.JSON(visible=False)
gr.Button(visible=False).click(api_neighbors, inputs=[api_in_s1, api_in_s2, api_in_n], outputs=api_out, api_name="neighbors")
gr.Button(visible=False).click(api_slerp, inputs=[api_in_s1, api_in_s2, api_in_n2, gr.Textbox(visible=False, value="chem"), api_in_n], outputs=api_out, api_name="slerp")
gr.Button(visible=False).click(api_arithmetic, inputs=[api_in_l1, api_in_l2, api_in_s1, api_in_n], outputs=api_out, api_name="arithmetic")
gr.Button(visible=False).click(api_embed, inputs=[api_in_s1, api_in_s2], outputs=api_out, api_name="embed")
gr.Markdown(
"""---
**Cite:** Radzikowski and Chen, 2026, *Epicure: Navigating the Emergent Geometry of Food Ingredient Embeddings*, [arXiv:2605.22391](https://arxiv.org/abs/2605.22391).
Models: [epicure-cooc](https://huggingface.co/Kaikaku/epicure-cooc) · [epicure-core](https://huggingface.co/Kaikaku/epicure-core) · [epicure-chem](https://huggingface.co/Kaikaku/epicure-chem) · [dataset](https://huggingface.co/datasets/Kaikaku/epicure-corpus-resources) · [API](/?view=api)
"""
)
if __name__ == "__main__":
demo.launch()