"""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()]
# =====================================================================
# Inverse queries: substitution finder + sensory profile search
# =====================================================================
_SENSORY_SLIDER_KEYS = [
("sweet", ["sweet_score/", "cf_sweet/"]),
("sour", ["sour_score/", "cf_sour/"]),
("bitter", ["bitter_score/","cf_bitter/"]),
("umami", ["umami_score/", "cf_umami/", "cf_meaty/"]),
("fatty", ["fatty_score/", "cf_fatty/"]),
("pungent", ["pungent_score/", "cf_pungent/"]),
("savory", ["cf_savory/"]),
("citrus", ["cf_citrus/"]),
("woody", ["cf_woody/"]),
("earthy", ["cf_earthy/"]),
]
def _poles_with_prefix(m, prefix):
return [k for k in m.supervised_poles.keys() if k.startswith(prefix)]
def _aggregate_pole(m, prefixes):
keys = []
for p in prefixes: keys.extend(_poles_with_prefix(m, p))
if not keys: return None
vecs = np.stack([_unit(m.supervised_poles[k]) for k in keys], axis=0)
return _unit(vecs.mean(axis=0))
def _dominant_cuisine_pole(m, seed_name):
if seed_name not in m.vocab: return None
v = _unit(m.E[m.vocab[seed_name]])
best, best_sim = None, -1e9
for c in _CUISINES:
key = f"cuisine:{c}"
if key not in m.supervised_poles: continue
p = _unit(m.supervised_poles[key])
s = float(v @ p)
if s > best_sim: best_sim, best = s, p
return best
def substitute_finder(seed, sibling, k, must_share_group, same_nova, diff_cuisine):
if not seed or seed not in MODELS[sibling].vocab:
return [], "_(pick a seed ingredient)_"
m = MODELS[sibling]
v = _unit(m.E[m.vocab[seed]])
q = v
notes_dir = []
if same_nova:
nova_keys = _poles_with_prefix(m, "nova_level/")
if nova_keys:
sims = [(k_, float(v @ _unit(m.supervised_poles[k_]))) for k_ in nova_keys]
top_key, _ = max(sims, key=lambda x: x[1])
q = _slerp(q, _unit(m.supervised_poles[top_key]), 15)
notes_dir.append("pulled toward NOVA peer")
if diff_cuisine:
d_cui = _dominant_cuisine_pole(m, seed)
if d_cui is not None:
q = _slerp(q, -d_cui, 30)
notes_dir.append("rotated 30° from dominant cuisine")
q = _unit(q)
seed_group = _NAME_TO_GROUP.get(seed, "Other")
wide = _topk(m, q, max(int(k) * 8, 40), exclude=[seed])
rows = []
for name, sim in wide:
grp = _NAME_TO_GROUP.get(name, "Other")
if must_share_group and grp != seed_group: continue
bits = [f"group: {grp}"] + notes_dir
rows.append([name, f"{sim:.4f}", grp, "; ".join(bits)])
if len(rows) >= int(k): break
return rows, f"Seed **{seed}** (group: {seed_group}). {len(rows)} substitutes."
def sensory_search(sibling, k, *slider_values):
m = MODELS[sibling]
weights = dict(zip([lbl for lbl, _ in _SENSORY_SLIDER_KEYS], slider_values))
parts, used = [], []
for label, prefixes in _SENSORY_SLIDER_KEYS:
w = float(weights.get(label, 0.0))
if w <= 0: continue
pole = _aggregate_pole(m, prefixes)
if pole is None: continue
parts.append(w * pole)
used.append(f"{label}×{w:.2f}")
if not parts: return [], "_(move at least one slider above 0)_"
q = _unit(np.sum(parts, axis=0))
rows = [[n, f"{s:.4f}", _NAME_TO_GROUP.get(n, "Other")]
for n, s in _topk(m, q, int(k), exclude=[])]
return rows, "**Target axes:** " + " · ".join(used)
# =====================================================================
# Inspect: ingredient passport + mode wiki + cultural context
# =====================================================================
import matplotlib.gridspec as gridspec
PASSPORT_SIBS = ["cooc", "core", "chem"]
PASSPORT_SENS = ["sweet","sour","bitter","umami","fatty","pungent"]
def _find_sensory_pole(m, axis):
for cand in (f"cf_{axis}", f"{axis}_score", axis):
if cand in m.supervised_poles:
return _unit(m.supervised_poles[cand])
# fuzzy
for k, v in m.supervised_poles.items():
if axis in k.lower():
return _unit(v)
return None
def _sensory_profile(name):
out = {}
for axis in PASSPORT_SENS:
vals = []
for sib in PASSPORT_SIBS:
m = MODELS[sib]
if name not in m.vocab: continue
v = _unit(m.E[m.vocab[name]])
p = _find_sensory_pole(m, axis)
if p is not None: vals.append(float(v @ p))
out[axis] = float(np.mean(vals)) if vals else 0.0
return out
def render_passport_html(name):
"""Native HTML/CSS passport that lives inside the white Gradio page.
Returns (html_str, sensory_radar_png_figure)."""
if not name:
return "
Pick an ingredient.
", None
pretty = name.replace("_", " ").title()
group = _NAME_TO_GROUP.get(name, "Other")
sens = _sensory_profile(name)
# Build sensory radar as a small standalone figure
fig, ax = plt.subplots(figsize=(4.5, 4.5), subplot_kw={"projection": "polar"})
fig.patch.set_facecolor("#ffffff")
ax.set_facecolor("#ffffff")
theta = np.linspace(0, 2*np.pi, len(PASSPORT_SENS), endpoint=False)
r = np.array([max(0.0, sens[a]) for a in PASSPORT_SENS])
theta_c = np.concatenate([theta, theta[:1]])
r_c = np.concatenate([r, r[:1]])
ax.plot(theta_c, r_c, color=KAIKAKU_ACCENT, lw=2.2)
ax.fill(theta_c, r_c, color=KAIKAKU_ACCENT, alpha=0.22)
ax.set_xticks(theta)
ax.set_xticklabels([a.title() for a in PASSPORT_SENS], color="#0f172a", fontsize=11, fontweight="bold")
ax.set_yticklabels([])
ax.set_ylim(0, max(0.5, float(r.max()) + 0.08))
ax.grid(color="#cbd5e1", alpha=0.7, lw=0.5)
ax.spines["polar"].set_color("#94a3b8")
plt.tight_layout()
radar_fig = fig
# Compute neighbours per sibling
nb_blocks = []
for sib in PASSPORT_SIBS:
m = MODELS[sib]
if name not in m.vocab:
rows_html = "(not in vocab)
"
else:
q = _unit(m.E[m.vocab[name]])
items = []
for nb, sim in _topk(m, q, 5, exclude=[name]):
items.append(
f""
f"{nb.replace('_', ' ')}"
f"{sim:.3f}"
f"
"
)
rows_html = "".join(items)
nb_blocks.append(
f""
f"
{sib.upper()} · NEAREST NEIGHBOURS
"
f"{rows_html}
"
)
# Cuisine affiliation bars
m_chem = MODELS["chem"]
cuisine_html = ""
if name in m_chem.vocab:
v = _unit(m_chem.E[m_chem.vocab[name]])
vals = []
for cu in _CUISINES:
key = f"cuisine:{cu}"
if key in m_chem.supervised_poles:
vals.append((cu.replace("_", " "), float(v @ _unit(m_chem.supervised_poles[key]))))
vmax = max((abs(v_) for _, v_ in vals), default=1.0) or 1.0
cuisine_rows = []
for label, val in vals:
pct = abs(val) / vmax * 100
bar_color = KAIKAKU_ACCENT if val >= 0 else "#f59e0b"
cuisine_rows.append(
f""
f"
{label}"
f"
"
f"
{val:+.3f}"
f"
"
)
cuisine_html = "".join(cuisine_rows)
else:
cuisine_html = "(not in chem vocab)
"
# Closest factor modes
scored = []
for sib in PASSPORT_SIBS:
m = MODELS[sib]
if name not in m.vocab: continue
v = _unit(m.E[m.vocab[name]])
for md in m.modes:
if md.kind != "factor": continue
scored.append((float(_unit(md.pole) @ v), sib, md))
scored.sort(key=lambda x: -x[0])
mode_rows = []
for sim, sib, md in scored[:3]:
members = ", ".join(n.replace("_", " ") for n in md.members[:6])
mode_rows.append(
f""
f"
"
f"
{sib.upper()}"
f"{md.label}
"
f"
cos {sim:.3f}"
f"
"
f"
{members}
"
f"
"
)
html = f"""
{pretty}
INGREDIENT PASSPORT · FOOD GROUP: {group.upper()}
{''.join(nb_blocks)}
CUISINE AFFILIATION (chem)
{cuisine_html}
CLOSEST EMERGENT FACTOR MODES (top 3 across siblings)
{''.join(mode_rows) if mode_rows else "
(no modes)
"}
"""
return html, radar_fig
def render_passport(name):
"""Legacy entry point - delegate to the HTML version and discard the radar."""
html, _radar = render_passport_html(name)
# Return a placeholder matplotlib fig (small) since callers may expect one
fig, ax = plt.subplots(figsize=(0.1, 0.1))
ax.axis("off")
return fig
def _render_passport_dummy(name):
fig = plt.figure(figsize=(12, 10.5), facecolor=KAIKAKU_DARK)
fig.patch.set_facecolor(KAIKAKU_DARK)
gs = gridspec.GridSpec(4, 6, figure=fig,
height_ratios=[0.85, 2.0, 2.4, 1.8],
hspace=0.40, wspace=0.30, left=0.05, right=0.96, top=0.95, bottom=0.05)
def styled(ax, title=None):
ax.set_facecolor(KAIKAKU_DARK)
for s in ax.spines.values():
s.set_color(KAIKAKU_ACCENT_LIGHT); s.set_alpha(0.25)
ax.tick_params(colors=KAIKAKU_ACCENT_LIGHT, labelsize=8)
if title:
ax.set_title(title, color=KAIKAKU_ACCENT_LIGHT, fontsize=10,
family="monospace", loc="left", pad=8)
return ax
# Headline (row 0)
ax_h = fig.add_subplot(gs[0, :]); ax_h.axis("off")
pretty = (name or "").replace("_", " ").upper()
ax_h.text(0.0, 0.62, pretty, color=KAIKAKU_ACCENT_LIGHT,
fontsize=34, fontweight="bold", va="center")
group = _NAME_TO_GROUP.get(name, "Other")
ax_h.text(0.0, 0.15, f"INGREDIENT PASSPORT · FOOD GROUP: {group.upper()}",
color=KAIKAKU_ACCENT, fontsize=10, family="monospace", va="center")
ax_h.plot([0, 1], [0.05, 0.05], color=KAIKAKU_ACCENT, lw=1.5, transform=ax_h.transAxes)
# Row 1: 3 neighbour panels (each spans 2 cols of a 6-col grid)
for i, sib in enumerate(PASSPORT_SIBS):
ax = styled(fig.add_subplot(gs[1, i*2:(i+1)*2]),
title=f"[{sib.upper()}] NEAREST NEIGHBOURS")
ax.set_xticks([]); ax.set_yticks([])
m = MODELS[sib]
if name not in m.vocab:
ax.text(0.5, 0.5, "(not in vocab)", ha="center", va="center",
color=KAIKAKU_ACCENT_LIGHT, transform=ax.transAxes, fontsize=10)
continue
q = _unit(m.E[m.vocab[name]])
for row, (nb, sim) in enumerate(_topk(m, q, 5, exclude=[name])):
y = 0.88 - row * 0.18
ax.text(0.04, y, nb.replace("_", " "), color="#FFFFFF",
fontsize=11, family="monospace", transform=ax.transAxes)
ax.text(0.96, y, f"{sim:.3f}", color=KAIKAKU_ACCENT_LIGHT,
fontsize=10, family="monospace", ha="right", transform=ax.transAxes)
# Row 2: sensory radar (cols 0-1) + cuisine bar (cols 2-5)
ax_radar_host = fig.add_subplot(gs[2, 0:2]); ax_radar_host.axis("off")
ax_radar_host.text(0.0, 1.02, "// SENSORY RADAR",
color=KAIKAKU_ACCENT_LIGHT, fontsize=10,
family="monospace", transform=ax_radar_host.transAxes)
bb = ax_radar_host.get_position()
pad_x, pad_y = bb.width * 0.06, bb.height * 0.06
ax_polar = fig.add_axes(
[bb.x0 + pad_x, bb.y0 + pad_y, bb.width - 2*pad_x, bb.height - 2*pad_y - 0.012],
projection="polar")
sens = _sensory_profile(name)
theta = np.linspace(0, 2*np.pi, len(PASSPORT_SENS), endpoint=False)
r = np.array([max(0.0, sens[a]) for a in PASSPORT_SENS])
theta_c = np.concatenate([theta, theta[:1]])
r_c = np.concatenate([r, r[:1]])
ax_polar.set_facecolor(KAIKAKU_DARK)
ax_polar.plot(theta_c, r_c, color=KAIKAKU_ACCENT_LIGHT, lw=2)
ax_polar.fill(theta_c, r_c, color=KAIKAKU_ACCENT, alpha=0.35)
ax_polar.set_xticks(theta)
ax_polar.set_xticklabels([a.upper() for a in PASSPORT_SENS],
color=KAIKAKU_ACCENT_LIGHT, fontsize=8, family="monospace")
ax_polar.set_yticklabels([])
ax_polar.set_ylim(0, max(0.5, float(r.max()) + 0.05))
ax_polar.grid(color=KAIKAKU_ACCENT_LIGHT, alpha=0.20, lw=0.5)
# Cuisine bar (row 2, cols 2-5)
ax_c = styled(fig.add_subplot(gs[2, 2:6]), title="// CUISINE AFFILIATION (chem)")
m = MODELS["chem"]
if name in m.vocab:
v = _unit(m.E[m.vocab[name]])
vals, labels = [], []
for cu in _CUISINES:
key = f"cuisine:{cu}"
if key in m.supervised_poles:
vals.append(float(v @ _unit(m.supervised_poles[key])))
labels.append(cu.replace("_", " "))
y_pos = np.arange(len(labels))
bar_colors = [KAIKAKU_ACCENT_LIGHT if x >= 0 else "#F4B86E" for x in vals]
ax_c.barh(y_pos, vals, color=bar_colors, edgecolor=KAIKAKU_ACCENT, linewidth=0.6)
ax_c.set_yticks(y_pos); ax_c.set_yticklabels(labels, color="#FFFFFF", fontsize=9)
ax_c.axvline(0, color=KAIKAKU_ACCENT_LIGHT, alpha=0.4, lw=0.8)
ax_c.invert_yaxis()
else:
ax_c.text(0.5, 0.5, "(not in chem vocab)", ha="center", va="center",
color=KAIKAKU_ACCENT_LIGHT, transform=ax_c.transAxes)
# Row 3: closest factor modes (all 6 cols)
ax_m = styled(fig.add_subplot(gs[3, :]),
title="// CLOSEST EMERGENT FACTOR MODES (top 3 across siblings)")
ax_m.set_xticks([]); ax_m.set_yticks([])
scored = []
for sib in PASSPORT_SIBS:
m = MODELS[sib]
if name not in m.vocab: continue
v = _unit(m.E[m.vocab[name]])
for md in m.modes:
if md.kind != "factor": continue
scored.append((float(_unit(md.pole) @ v), sib, md))
scored.sort(key=lambda x: -x[0])
for row, (sim, sib, md) in enumerate(scored[:3]):
y = 0.85 - row * 0.30
ax_m.text(0.01, y, f"[{sib.upper()}]", color=KAIKAKU_ACCENT,
fontsize=10, family="monospace", transform=ax_m.transAxes)
ax_m.text(0.09, y, md.label, color="#FFFFFF",
fontsize=12, fontweight="bold", transform=ax_m.transAxes)
ax_m.text(0.99, y, f"cos {sim:.3f}", color=KAIKAKU_ACCENT_LIGHT,
fontsize=10, family="monospace", ha="right", transform=ax_m.transAxes)
members = ", ".join(md.members[:6])
ax_m.text(0.09, y - 0.09, members, color=KAIKAKU_ACCENT_LIGHT,
fontsize=9, family="monospace", transform=ax_m.transAxes)
return fig
def _mode_choices_searchable(sibling):
m = MODELS[sibling]
out = []
for md in m.modes:
pz = getattr(md, "prop_z_mean", None)
z = f" z={pz:+.2f}" if isinstance(pz, (int, float)) else ""
out.append((f"[{md.kind}] {md.label} ({md.mode_id}, n={md.n_members}{z})", md.mode_id))
return sorted(out, key=lambda x: x[0].lower())
def render_mode_wiki(sibling, mode_id):
if not sibling or not mode_id: return "_Pick a mode._"
m = MODELS[sibling]
target = next((md for md in m.modes if md.mode_id == mode_id), None)
if target is None: return f"_Mode `{mode_id}` not found._"
pole = _unit(target.pole)
members = [n for n in (target.members or []) if n in m.vocab]
if members:
idxs = np.array([m.vocab[n] for n in members])
sims_mem = m.E[idxs] @ pole
members = [members[i] for i in np.argsort(-sims_mem)]
related = []
for md in m.modes:
if md.mode_id == target.mode_id: continue
related.append((md, float(_unit(md.pole) @ pole)))
related.sort(key=lambda x: -x[1])
sup = sorted(((k, float(_unit(v) @ pole)) for k, v in m.supervised_poles.items()),
key=lambda x: -x[1])[:3]
spotlight = ", ".join(f"**{n.replace('_',' ')}**" for n in members[:3]) or "_(none)_"
out = [f"## {target.label}",
f"`{target.mode_id}` · sibling **{sibling}** · kind **{target.kind}** · "
f"property **{target.property}** · members **{target.n_members}**",
f"\n### Spotlight\n{spotlight}",
"\n### All members (cosine-ordered)",
", ".join(n.replace("_", " ") for n in members) or "_(none)_",
"\n### Closest related modes",
"| mode_id | label | kind | cosine |", "|---|---|---|---:|"]
for md, sim in related[:5]:
out.append(f"| `{md.mode_id}` | {md.label} | {md.kind} | {sim:.3f} |")
out.append("\n### Top supervised directions")
out.append("| direction | cosine |"); out.append("|---|---:|")
for k, sim in sup: out.append(f"| `{k}` | {sim:.3f} |")
return "\n".join(out)
def _load_cuisine_taxonomy():
"""Try to load the cuisine_macroregions.json shipped with the corpus dataset; fall back to inline."""
try:
from huggingface_hub import hf_hub_download
p = hf_hub_download("Kaikaku/epicure-corpus-resources",
"data/cuisine_macroregions.json", repo_type="dataset")
return json.loads(open(p).read())
except Exception:
return {
"East_Asian": {"traditions": ["Chinese", "Korean"]},
"Western_Atlantic": {"traditions": ["American","British","German","Scandinavian"]},
"Mediterranean": {"traditions": ["Italian","French","Iberian","Greek","Levantine","North African","Turkish"]},
"Eastern_European": {"traditions": ["Russian","Ukrainian","Polish","Hungarian","Georgian"]},
"Southeast_Asian": {"traditions": ["Thai","Vietnamese","Filipino","Indonesian","Malay"]},
"South_Asian": {"traditions": ["Indian","Pakistani","Sri Lankan","Bangladeshi"]},
"Latin_American": {"traditions": ["Mexican","Caribbean","Brazilian","Peruvian","Colombian"]},
"Japanese": {"traditions": ["Japanese"]},
}
_CUISINE_TAXONOMY = _load_cuisine_taxonomy()
def cultural_context(ingredient, sibling="chem", k=4):
if not ingredient or ingredient not in MODELS[sibling].vocab:
return [], "_(pick an ingredient)_"
m = MODELS[sibling]
v = _unit(m.E[m.vocab[ingredient]])
scored = []
for c in _CUISINES:
key = f"cuisine:{c}"
if key in m.supervised_poles:
scored.append((c, float(v @ _unit(m.supervised_poles[key]))))
scored.sort(key=lambda x: -x[1])
top = scored[:int(k)]
rows = []
all_trads = []
for region, sim in top:
trads = _CUISINE_TAXONOMY.get(region, {}).get("traditions", [])
all_trads.extend(trads)
rows.append([region.replace("_", " "), f"{sim:+.3f}", ", ".join(trads)])
md = (f"**{ingredient}** aligns most strongly with these culinary traditions: \n"
f"{', '.join(sorted(set(all_trads)))}.\n\n"
f"_The Epicure paper normalised source-language recipes to a single English canonical vocabulary; "
f"per-language ingredient names were not persisted. This view surfaces the **culinary geography** "
f"the model learned instead._")
return rows, md
# =====================================================================
# Constellations: sibling alignment + recipe constellation
# =====================================================================
def render_3d_atlas(sibling, basket, show_neighbours=True, k=8, color_mode="food group"):
"""Interactive 3D UMAP. Drag/scroll to navigate. Basket pops in mint, neighbours amber."""
m = MODELS[sibling]
coords2 = UMAP_DATA[sibling]
n = len(NAMES_BY_IDX)
# third axis = standardised PC1 of the embedding
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 = (coords2.max() - coords2.min()) * 0.25
z = (pc1 * scale).astype(np.float32)
# Colors: by food group OR by closest cuisine
if color_mode == "cuisine":
cuisine_pole_keys = [c for c in _CUISINES if f"cuisine:{c}" in m.supervised_poles]
cuisine_poles = np.stack([_unit(m.supervised_poles[f"cuisine:{c}"]) for c in cuisine_pole_keys])
Xn = m.E / np.linalg.norm(m.E, axis=1, keepdims=True)
sims = Xn @ cuisine_poles.T
best = sims.argmax(axis=1)
palette = ["#288B79","#F4B86E","#E8C0E8","#9BC9E8","#D7E89B","#FFAA8A","#A8D5CA","#E8E0A0"]
colors = [palette[best[i] % len(palette)] for i in range(n)]
hover_text = [
f"{NAMES_BY_IDX[i]}
closest cuisine: {cuisine_pole_keys[best[i]].replace('_',' ')}
group: {FOOD_GROUPS[i]}"
for i in range(n)
]
else:
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:
c = _basket_centroid(m, basket)
if c is not None:
for nm, _ in _topk(m, c, int(k), exclude=basket):
neighbour_set.add(nm)
keep = lambda i: NAMES_BY_IDX[i] not in basket_set and NAMES_BY_IDX[i] not in neighbour_set
bg_idx = [i for i in range(n) if keep(i)]
fig = go.Figure()
fig.add_trace(go.Scatter3d(
x=[float(coords2[i,0]) for i in bg_idx],
y=[float(coords2[i,1]) for i in bg_idx],
z=[float(z[i]) for i in bg_idx],
mode="markers",
marker=dict(size=3, color=[colors[i] for i in bg_idx], opacity=0.55, line=dict(width=0)),
text=[hover_text[i] for i in bg_idx],
hovertemplate="%{text}",
name="ingredients", showlegend=False,
))
if neighbour_set:
ni = [i for i in range(n) if NAMES_BY_IDX[i] in neighbour_set]
fig.add_trace(go.Scatter3d(
x=[float(coords2[i,0]) for i in ni],
y=[float(coords2[i,1]) for i in ni],
z=[float(z[i]) for i in ni],
mode="markers+text",
marker=dict(size=8, color="#F59E0B", opacity=0.96,
line=dict(color="#ffffff", width=1.5),
symbol="circle"),
text=[NAMES_BY_IDX[i].replace("_"," ") for i in ni],
textposition="top center",
textfont=dict(size=11, color="#1f2937", family="Inter"),
hovertemplate="%{text} (neighbour)",
name=f"top-{int(k)} neighbours",
))
if basket_idxs:
fig.add_trace(go.Scatter3d(
x=[float(coords2[i,0]) for i in basket_idxs],
y=[float(coords2[i,1]) for i in basket_idxs],
z=[float(z[i]) for i in basket_idxs],
mode="markers+text",
marker=dict(size=15, color=KAIKAKU_ACCENT, symbol="diamond",
line=dict(color="#0f172a", width=2)),
text=[NAMES_BY_IDX[i].replace("_"," ") for i in basket_idxs],
textposition="top center",
textfont=dict(size=13, color="#0f172a", family="Inter"),
hovertemplate="%{text} (basket)",
name="basket",
))
fig.update_layout(
title=dict(
text=f"3D Atlas · Epicure-{sibling.capitalize()} · 1,790 ingredients · drag to rotate, scroll to zoom",
font=dict(size=13, color="#0f172a", family="Inter"), x=0.02, xanchor="left",
),
scene=dict(
xaxis=dict(title="UMAP 1", color="#475569", gridcolor="#e2e8f0",
backgroundcolor="#ffffff", showspikes=False, zeroline=False),
yaxis=dict(title="UMAP 2", color="#475569", gridcolor="#e2e8f0",
backgroundcolor="#ffffff", showspikes=False, zeroline=False),
zaxis=dict(title="PC1", color="#475569", gridcolor="#e2e8f0",
backgroundcolor="#ffffff", showspikes=False, zeroline=False),
bgcolor="#ffffff",
camera=dict(up=dict(x=0, y=0, z=1), eye=dict(x=1.6, y=1.6, z=1.0)),
aspectmode="cube",
),
height=720,
margin=dict(l=10, r=10, t=46, b=10),
paper_bgcolor="#ffffff",
legend=dict(orientation="h", yanchor="bottom", y=0.0, xanchor="right", x=1.0,
font=dict(color="#0f172a", size=11), bgcolor="rgba(255,255,255,0.85)",
bordercolor="#e2e8f0", borderwidth=1),
)
return fig
def render_sibling_alignment(ingredient):
if not ingredient or ingredient not in MODELS["cooc"].vocab:
fig, ax = _gallery_axes(figsize=(16, 5))
ax.text(0.5, 0.5, "Pick an ingredient", ha="center", va="center",
transform=ax.transAxes, color=GALLERY_TXTDIM)
return fig
sibs = ["cooc", "core", "chem"]
fig, axes = plt.subplots(1, 3, figsize=(16, 5), facecolor=GALLERY_BG,
gridspec_kw=dict(wspace=0.06))
top1 = []
try: from scipy.stats import gaussian_kde
except Exception: gaussian_kde = None
for ax, sib in zip(axes, sibs):
ax.set_facecolor(GALLERY_BG)
for s in ax.spines.values(): s.set_visible(False)
ax.tick_params(left=False, bottom=False, labelleft=False, labelbottom=False)
m = MODELS[sib]; coords = UMAP_DATA[sib]
xmin, xmax = float(coords[:,0].min()-0.6), float(coords[:,0].max()+0.6)
ymin, ymax = float(coords[:,1].min()-0.6), float(coords[:,1].max()+0.6)
if gaussian_kde is not None:
try:
kde = gaussian_kde(coords.T, bw_method=0.20)
xx, yy = np.meshgrid(np.linspace(xmin, xmax, 120), np.linspace(ymin, ymax, 120))
zz = kde(np.vstack([xx.ravel(), yy.ravel()])).reshape(xx.shape)
ax.contour(xx, yy, zz, levels=12, colors=GALLERY_GRID, alpha=0.45, linewidths=0.55)
except Exception: pass
ax.scatter(coords[:,0], coords[:,1], s=2, c=GALLERY_DUST, alpha=0.5, linewidths=0, zorder=2)
q = _unit(m.E[m.vocab[ingredient]])
nb = _topk(m, q, 3, exclude=[ingredient])
top1.append(nb[0][0] if nb else "-")
# Label placement with vertical fan-out to avoid overlap
for j, (nm, _s) in enumerate(nb):
p = coords[m.vocab[nm]]
ax.scatter([p[0]], [p[1]], s=130, c="#F4B86E", edgecolors="white", linewidths=0.8, zorder=4)
# Stagger labels vertically: top label up, second to right, third down
dx = 0.25 if j == 1 else 0.0
dy = (0.45, 0.0, -0.45)[j % 3]
ha = "left" if j == 1 else "center"
ax.text(p[0] + dx, p[1] + dy, nm.replace("_", " "), color=GALLERY_TEXT,
fontsize=9.5, fontweight="bold", alpha=0.95, ha=ha,
va=("bottom" if dy > 0 else "top" if dy < 0 else "center"), zorder=5)
sp = coords[m.vocab[ingredient]]
ax.scatter([sp[0]], [sp[1]], s=420, c=KAIKAKU_ACCENT_LIGHT, marker="*",
edgecolors="white", linewidths=1.2, zorder=6)
ax.text(sp[0], sp[1] - 0.45, ingredient.replace("_", " "), color=KAIKAKU_ACCENT_LIGHT,
ha="center", va="top", fontsize=11.5, fontweight="bold", zorder=7,
bbox=dict(boxstyle="round,pad=0.18", facecolor=GALLERY_BG,
edgecolor=KAIKAKU_ACCENT_LIGHT, alpha=0.85, linewidth=0.6))
ax.set_xlim(xmin, xmax); ax.set_ylim(ymin, ymax); ax.set_aspect("equal")
ax.set_title(f"{sib.upper()} · {ingredient}", color=GALLERY_TXTDIM,
fontsize=11, family="monospace", pad=6)
sub = " ".join(f"{s.upper()}→{t}" for s, t in zip(sibs, top1))
fig.suptitle(f"SIBLING ALIGNMENT · top-1 per sibling: {sub}",
color=GALLERY_TXTDIM, fontsize=11, family="monospace", y=0.02)
plt.tight_layout(rect=[0, 0.04, 1, 0.97])
return fig
def render_recipe_constellation(sibling, ingredients):
m = MODELS[sibling]; coords = UMAP_DATA[sibling]
fig, ax = _gallery_axes(figsize=(11, 9))
xmin, xmax = float(coords[:,0].min()-0.6), float(coords[:,0].max()+0.6)
ymin, ymax = float(coords[:,1].min()-0.6), float(coords[:,1].max()+0.6)
try:
from scipy.stats import gaussian_kde
kde = gaussian_kde(coords.T, bw_method=0.20)
xx, yy = np.meshgrid(np.linspace(xmin, xmax, 140), np.linspace(ymin, ymax, 140))
zz = kde(np.vstack([xx.ravel(), yy.ravel()])).reshape(xx.shape)
ax.contour(xx, yy, zz, levels=10, colors=GALLERY_GRID, alpha=0.4, linewidths=0.5)
except Exception: pass
ax.scatter(coords[:,0], coords[:,1], s=2, c=GALLERY_DUST, alpha=0.5, linewidths=0, zorder=2)
valid = [n for n in (ingredients or []) if n in m.vocab]
if not valid:
ax.text(0.5, 0.5, "Pick ingredients", ha="center", va="center",
transform=ax.transAxes, color=GALLERY_TXTDIM)
ax.set_xlim(xmin, xmax); ax.set_ylim(ymin, ymax); ax.set_aspect("equal"); return fig
idxs = [m.vocab[n] for n in valid]
pts = coords[idxs]
vecs = np.stack([_unit(m.E[i]) for i in idxs])
n = len(valid)
degree = np.zeros(n, dtype=int)
if n >= 2:
sim = vecs @ vecs.T
np.fill_diagonal(sim, -np.inf)
k_near = min(2, n - 1)
edges = set()
for i in range(n):
for j in np.argsort(-sim[i])[:k_near]:
a, b = sorted((i, int(j)))
if a == b or (a, b) in edges: continue
edges.add((a, b)); degree[a] += 1; degree[b] += 1
from matplotlib.patches import FancyArrowPatch
for (a, b) in edges:
w = max(0.0, float(sim[a, b]))
alpha = float(np.clip(0.25 + 0.65 * w, 0.15, 0.9))
arc = FancyArrowPatch((pts[a,0], pts[a,1]), (pts[b,0], pts[b,1]),
connectionstyle="arc3,rad=0.18", arrowstyle="-",
color=KAIKAKU_ACCENT_LIGHT, lw=0.9, alpha=alpha, zorder=3)
ax.add_patch(arc)
sizes = 140 + 90 * degree
ax.scatter(pts[:,0], pts[:,1], s=sizes, c="#F4B86E", marker="*",
edgecolors="white", linewidths=0.9, zorder=5)
for name, p in zip(valid, pts):
ax.text(p[0], p[1]+0.30, name, color=GALLERY_TEXT, ha="center",
fontsize=9.5, fontweight="bold", zorder=6)
ax.set_xlim(xmin, xmax); ax.set_ylim(ymin, ymax); ax.set_aspect("equal")
ax.text(0.02, 0.97, f"RECIPE CONSTELLATION · {sibling.upper()}",
transform=ax.transAxes, color=GALLERY_TXTDIM,
fontsize=11, family="monospace", va="top")
fig.text(0.5, 0.04, " · ".join(valid), ha="center", color=GALLERY_TEXT, fontsize=10)
plt.tight_layout(rect=[0, 0.06, 1, 1])
return fig
# =====================================================================
# Simpler additions: recipe coherence rating + direction-quality heatmap
# =====================================================================
def recipe_coherence(sibling, basket):
"""Return mean pairwise cosine within basket + a rating label + a tiny bar visual."""
m = MODELS[sibling]
valid = [n for n in (basket or []) if n in m.vocab]
if len(valid) < 2:
return "_Add 2+ ingredients to score._"
idxs = [m.vocab[n] for n in valid]
sub = m.E[idxs] / np.linalg.norm(m.E[idxs], axis=1, keepdims=True)
sim = sub @ sub.T
np.fill_diagonal(sim, np.nan)
mean_sim = float(np.nanmean(sim))
# rating bands
if mean_sim < 0.10: rating = "Scattered (very diverse)"
elif mean_sim < 0.25: rating = "Eclectic"
elif mean_sim < 0.40: rating = "Coherent"
elif mean_sim < 0.55: rating = "Tightly coherent"
else: rating = "Possibly redundant"
pct = int(np.clip(mean_sim, 0, 1) * 100)
bar = "█" * (pct // 5) + "░" * (20 - pct // 5)
return (f"**Recipe coherence:** mean pairwise cosine = `{mean_sim:.3f}` \n"
f"**Rating:** {rating} \n"
f"`{bar}` {pct}%")
def render_direction_quality_heatmap():
"""Paper §3.2 as a matplotlib heatmap (Plotly mixed-scale was breaking the render)."""
rho_probes = [
("CF baked-in (Spearman ρ)", [0.28, 0.40, 0.46]),
("CF basic-taste held-out (ρ)", [0.32, 0.42, 0.47]),
("USDA macros (ρ)", [0.41, 0.45, 0.49]),
]
d_probes = [
("Cuisine, mean Cohen's d", [2.43, 2.70, 3.07]),
]
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(11, 5.6), facecolor=GALLERY_BG,
gridspec_kw=dict(height_ratios=[3, 1], hspace=0.35))
for ax in (ax1, ax2):
ax.set_facecolor(GALLERY_BG)
for s in ax.spines.values(): s.set_visible(False)
# Panel 1: rho (range 0.2-0.5)
z1 = np.array([r[1] for r in rho_probes])
im1 = ax1.imshow(z1, cmap="viridis", vmin=0.20, vmax=0.55, aspect="auto")
ax1.set_xticks([0, 1, 2]); ax1.set_xticklabels(["Cooc", "Core", "Chem"],
color=GALLERY_TEXT, fontsize=12, fontweight="bold")
ax1.set_yticks(range(len(rho_probes)))
ax1.set_yticklabels([r[0] for r in rho_probes], color=GALLERY_TEXT, fontsize=11)
ax1.tick_params(colors=GALLERY_TEXT)
for i, row in enumerate(z1):
for j, v in enumerate(row):
ax1.text(j, i, f"{v:.2f}", ha="center", va="center",
color=("white" if v < 0.38 else "#111111"),
fontsize=15, fontweight="bold")
# Panel 2: Cohen's d (range 2-3)
z2 = np.array([r[1] for r in d_probes])
im2 = ax2.imshow(z2, cmap="viridis", vmin=2.2, vmax=3.2, aspect="auto")
ax2.set_xticks([0, 1, 2]); ax2.set_xticklabels(["Cooc", "Core", "Chem"],
color=GALLERY_TEXT, fontsize=12, fontweight="bold")
ax2.set_yticks(range(len(d_probes)))
ax2.set_yticklabels([r[0] for r in d_probes], color=GALLERY_TEXT, fontsize=11)
ax2.tick_params(colors=GALLERY_TEXT)
for i, row in enumerate(z2):
for j, v in enumerate(row):
ax2.text(j, i, f"{v:.2f}", ha="center", va="center",
color=("white" if v < 2.7 else "#111111"),
fontsize=15, fontweight="bold")
fig.suptitle("DIRECTION QUALITY · Cooc < Core < Chem (paper §3.2)",
color=GALLERY_TXTDIM, fontsize=12, family="monospace", y=0.97)
plt.tight_layout(rect=[0, 0, 1, 0.94])
return fig
# =====================================================================
# Gallery: six aesthetic visualisations.
# All use a shared dark-teal Kaikaku palette so they hang together.
# =====================================================================
GALLERY_BG = KAIKAKU_DARK
GALLERY_GRID = "#1F4548"
GALLERY_DUST = "#1A3D3F"
GALLERY_TEXT = "#E8F4F1"
GALLERY_TXTDIM = KAIKAKU_ACCENT_LIGHT
GALLERY_MODE_PALETTE = [
"#B5E6D2", "#F4B86E", "#E8C0E8", "#9BC9E8", "#D7E89B",
"#FFAA8A", "#A8D5CA", "#E8E0A0",
]
# All cuisine names we display
_CUISINES = ["East_Asian","Japanese","Southeast_Asian","South_Asian",
"Mediterranean","Eastern_European","Latin_American","Western_Atlantic"]
def _gallery_axes(figsize=(10, 9)):
fig, ax = plt.subplots(figsize=figsize, facecolor=GALLERY_BG)
ax.set_facecolor(GALLERY_BG)
for s in ax.spines.values(): s.set_visible(False)
ax.tick_params(left=False, bottom=False, labelleft=False, labelbottom=False)
return fig, ax
# ---------- (1) Factor decomposition poster ----------
def factor_options(sibling: str):
"""Return list of (descriptive_label, factor_id) tuples for the factor dropdown."""
m = MODELS[sibling]
by_fid: dict[str, list] = {}
for md in m.modes:
if md.kind != "factor": continue
by_fid.setdefault(md.property, []).append(md.label)
def sort_key(fid):
last = fid.split("_")[-1]
return int(last) if last.isdigit() else 999
out = []
for fid in sorted(by_fid.keys(), key=sort_key):
labels = by_fid[fid]
# Take first 2-3 mode-label keywords as a preview
preview = " · ".join(lab[:32] for lab in labels[:2])
out.append((f"{fid} — {preview}", fid))
return out
def factor_id_list(sibling: str):
"""Just the bare F_n IDs, for the actual handler input."""
return [fid for _, fid in factor_options(sibling)]
def render_factor_poster(sibling: str, factor_id: str):
"""Reference-screenshot style: dark teal background, faint contour texture,
GMM-mode point clusters in pastel colours, callouts with leader lines, bottom stat line."""
m = MODELS[sibling]
coords = UMAP_DATA[sibling]
factor_modes = [md for md in m.modes if md.kind == "factor" and md.property == factor_id]
if not factor_modes:
fig, ax = _gallery_axes()
ax.text(0.5, 0.5, "(no factor selected)", ha="center", va="center",
color=GALLERY_TXTDIM, transform=ax.transAxes)
return fig
fig, ax = _gallery_axes(figsize=(11, 10))
xmin, xmax = float(coords[:,0].min()-0.8), float(coords[:,0].max()+0.8)
ymin, ymax = float(coords[:,1].min()-0.8), float(coords[:,1].max()+0.8)
# Topographic background: KDE of all ingredients, contoured
try:
from scipy.stats import gaussian_kde
kde = gaussian_kde(coords.T, bw_method=0.18)
xx, yy = np.meshgrid(np.linspace(xmin, xmax, 160), np.linspace(ymin, ymax, 160))
zz = kde(np.vstack([xx.ravel(), yy.ravel()])).reshape(xx.shape)
ax.contour(xx, yy, zz, levels=14, colors=GALLERY_GRID, alpha=0.45, linewidths=0.6)
except Exception:
pass
# Faint background scatter of every ingredient
ax.scatter(coords[:, 0], coords[:, 1], s=2, c=GALLERY_DUST, alpha=0.5,
linewidths=0, zorder=2)
# Plot each mode's members + callout
name_to_idx = MODELS[sibling].vocab
used_corners = []
corners = [(xmax-0.5, ymax-0.5), (xmin+0.5, ymax-0.5),
(xmax-0.5, ymin+0.5), (xmin+0.5, ymin+0.5),
(xmax-0.5, (ymin+ymax)/2), (xmin+0.5, (ymin+ymax)/2)]
for i, mode in enumerate(factor_modes):
idxs = [name_to_idx[n] for n in mode.members if n in name_to_idx]
if not idxs: continue
color = GALLERY_MODE_PALETTE[i % len(GALLERY_MODE_PALETTE)]
pts = coords[idxs]
# Scatter with size jitter for a painterly look
sizes = np.random.RandomState(i).randint(50, 130, len(idxs))
ax.scatter(pts[:,0], pts[:,1], s=sizes, c=color, alpha=0.92,
edgecolors="white", linewidths=0.6, zorder=4)
# Callout
cx, cy = float(pts[:,0].mean()), float(pts[:,1].mean())
# pick a corner not yet used
corner = corners[i % len(corners)]
used_corners.append(corner)
ax.annotate(
f"M{mode.mode_id.split('M')[-1]} · {mode.label.upper()}",
xy=(cx, cy), xytext=corner,
color=GALLERY_TEXT, fontsize=9, family="monospace",
ha=("right" if corner[0] > (xmin+xmax)/2 else "left"),
va="center",
arrowprops=dict(arrowstyle="-", color=GALLERY_TXTDIM, lw=0.7,
connectionstyle="arc3,rad=0.1"),
zorder=5,
)
ax.set_xlim(xmin, xmax); ax.set_ylim(ymin, ymax); ax.set_aspect("equal")
# Title strip top-left
ax.text(0.02, 0.97,
f"EPICURE · FACTOR DECOMPOSITION · {factor_id.upper()}",
transform=ax.transAxes, color=GALLERY_TXTDIM,
fontsize=11, family="monospace", va="top")
# Bottom stat line
n_modes = len(factor_modes)
total_n = sum(md.n_members for md in factor_modes)
fig.text(0.5, 0.07,
"All of human cooking compressed into 2 megabytes.",
ha="center", color=GALLERY_TEXT, fontsize=15)
fig.text(0.5, 0.04,
f"SIBLING {sibling.upper()} · {n_modes} MODES · {total_n} INGREDIENTS · 300-D EMBEDDING",
ha="center", color=GALLERY_TXTDIM, fontsize=9, family="monospace")
plt.tight_layout(rect=[0, 0.09, 1, 1])
return fig
# ---------- (2) Cuisine compass (polar radar) ----------
def _cuisine_pole(m, region):
key = f"cuisine:{region}"
if key in m.supervised_poles:
return _unit(m.supervised_poles[key])
return None
def render_cuisine_compass(sibling: str, ingredients: list[str]):
m = MODELS[sibling]
valid = [n for n in (ingredients or []) if n in m.vocab]
if not valid:
fig = go.Figure()
fig.add_annotation(text="Pick at least one ingredient",
showarrow=False, font=dict(color=GALLERY_TXTDIM, size=14),
xref="paper", yref="paper", x=0.5, y=0.5)
fig.update_layout(paper_bgcolor=GALLERY_BG, plot_bgcolor=GALLERY_BG, height=520)
return fig
poles = {c: _cuisine_pole(m, c) for c in _CUISINES}
poles = {c: p for c, p in poles.items() if p is not None}
cuisines = list(poles.keys())
fig = go.Figure()
palette = GALLERY_MODE_PALETTE
for i, ing in enumerate(valid):
v = _unit(m.E[m.vocab[ing]])
radii = [float(v @ poles[c]) for c in cuisines]
# close the polygon
radii_closed = radii + [radii[0]]
labels_closed = cuisines + [cuisines[0]]
color = palette[i % len(palette)]
fig.add_trace(go.Scatterpolar(
r=radii_closed, theta=labels_closed,
fill="toself", name=ing,
fillcolor=f"rgba({int(color[1:3],16)},{int(color[3:5],16)},{int(color[5:7],16)},0.25)",
line=dict(color=color, width=2),
marker=dict(size=6, color=color),
hovertemplate="%{theta}
cos = %{r:.3f}" + ing + "",
))
fig.update_layout(
polar=dict(
bgcolor=GALLERY_BG,
radialaxis=dict(visible=True, gridcolor=GALLERY_GRID, range=[-0.2, 0.8],
tickfont=dict(color=GALLERY_TXTDIM, size=9),
angle=90, tickangle=90),
angularaxis=dict(gridcolor=GALLERY_GRID,
tickfont=dict(color=GALLERY_TEXT, size=11)),
),
paper_bgcolor=GALLERY_BG,
font=dict(color=GALLERY_TEXT),
legend=dict(font=dict(color=GALLERY_TEXT), bgcolor="rgba(0,0,0,0)"),
title=dict(text=f"CUISINE COMPASS · {sibling.upper()}",
font=dict(color=GALLERY_TXTDIM, size=12, family="monospace"),
x=0.02, xanchor="left"),
height=560, margin=dict(l=60, r=60, t=70, b=40),
)
return fig
# ---------- (7) Arithmetic vector art ----------
def render_arithmetic_vector(sibling: str, positives: list[str], negatives: list[str]):
"""Visualise centroid(positives) - centroid(negatives) as vector arrows on the UMAP."""
m = MODELS[sibling]
coords = UMAP_DATA[sibling]
fig, ax = _gallery_axes(figsize=(11, 9))
xmin, xmax = float(coords[:,0].min()-0.8), float(coords[:,0].max()+0.8)
ymin, ymax = float(coords[:,1].min()-0.8), float(coords[:,1].max()+0.8)
try:
from scipy.stats import gaussian_kde
kde = gaussian_kde(coords.T, bw_method=0.20)
xx, yy = np.meshgrid(np.linspace(xmin, xmax, 140), np.linspace(ymin, ymax, 140))
zz = kde(np.vstack([xx.ravel(), yy.ravel()])).reshape(xx.shape)
ax.contour(xx, yy, zz, levels=10, colors=GALLERY_GRID, alpha=0.4, linewidths=0.5)
except Exception: pass
ax.scatter(coords[:,0], coords[:,1], s=2, c=GALLERY_DUST, alpha=0.45)
if not positives:
ax.text(0.5, 0.5, "Pick at least one positive ingredient", ha="center",
va="center", transform=ax.transAxes, color=GALLERY_TXTDIM)
return fig
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
def project(vec, k=8):
sims = m.E @ vec
idxs = np.argsort(-sims)[:k]
return coords[idxs].mean(axis=0), idxs
pos_pt, pos_top = project(pos)
res_pt, res_top = project(q)
# Plot positives in mint
for n in positives:
if n in m.vocab:
p = coords[m.vocab[n]]
ax.scatter([p[0]], [p[1]], s=140, c=KAIKAKU_ACCENT_LIGHT,
edgecolors="white", linewidths=0.8, zorder=4)
ax.text(p[0], p[1]+0.25, n, color=KAIKAKU_ACCENT_LIGHT, ha="center",
fontsize=10, fontweight="bold", zorder=5)
# Plot negatives in warm
for n in (negatives or []):
if n in m.vocab:
p = coords[m.vocab[n]]
ax.scatter([p[0]], [p[1]], s=140, c="#F4B86E",
edgecolors="white", linewidths=0.8, zorder=4)
ax.text(p[0], p[1]+0.25, "− " + n, color="#F4B86E", ha="center",
fontsize=10, fontweight="bold", zorder=5)
# Plot result as star
ax.scatter([res_pt[0]], [res_pt[1]], s=420, c="#FFFFFF", marker="*",
edgecolors=KAIKAKU_ACCENT_LIGHT, linewidths=1.5, zorder=6)
# Draw arrow from positives centroid -> result
ax.annotate("", xy=res_pt, xytext=pos_pt,
arrowprops=dict(arrowstyle="->", color=KAIKAKU_ACCENT_LIGHT, lw=1.8),
zorder=5)
# Label top-K of result
for idx in res_top[:5]:
p = coords[idx]
ax.text(p[0]+0.10, p[1], NAMES_BY_IDX[idx], color=GALLERY_TEXT,
fontsize=8, alpha=0.85, zorder=4)
ax.scatter([p[0]], [p[1]], s=30, c="#FFFFFF", alpha=0.6,
edgecolors=GALLERY_TXTDIM, linewidths=0.6, zorder=3)
ax.set_xlim(xmin, xmax); ax.set_ylim(ymin, ymax); ax.set_aspect("equal")
title = " + ".join(positives) + (" − " + " − ".join(negatives) if negatives else "")
ax.text(0.02, 0.97, f"VECTOR ARITHMETIC · {sibling.upper()}",
transform=ax.transAxes, color=GALLERY_TXTDIM,
fontsize=11, family="monospace", va="top")
ax.text(0.02, 0.93, title, transform=ax.transAxes, color=GALLERY_TEXT,
fontsize=13, va="top")
res_names = ", ".join(NAMES_BY_IDX[i] for i in res_top[:5])
fig.text(0.5, 0.04, "Result top-5: " + res_names,
ha="center", color=GALLERY_TXTDIM, fontsize=10, family="monospace")
plt.tight_layout(rect=[0, 0.06, 1, 1])
return fig
# ---------- (8) Cuisine phylogeny (dendrogram) ----------
def render_cuisine_phylogeny(sibling: str):
m = MODELS[sibling]
cuisines = [c for c in _CUISINES if f"cuisine:{c}" in m.supervised_poles]
if len(cuisines) < 2:
fig, ax = _gallery_axes(figsize=(10, 5))
ax.text(0.5, 0.5, "Cuisine poles unavailable for this sibling",
ha="center", va="center", transform=ax.transAxes, color=GALLERY_TXTDIM)
return fig
poles = np.stack([_unit(m.supervised_poles[f"cuisine:{c}"]) for c in cuisines])
# cosine distance matrix
D = 1 - poles @ poles.T
# condensed distance
n = len(cuisines)
cond = []
for i in range(n):
for j in range(i+1, n):
cond.append(max(0.0, float(D[i, j])))
from scipy.cluster.hierarchy import linkage, dendrogram
Z = linkage(np.array(cond), method="average")
fig, ax = _gallery_axes(figsize=(11, 6))
matplotlib.rcParams["lines.linewidth"] = 1.4
ddata = dendrogram(Z, labels=cuisines, ax=ax, color_threshold=0,
above_threshold_color=KAIKAKU_ACCENT_LIGHT,
leaf_font_size=11, leaf_rotation=0)
ax.tick_params(axis="x", colors=GALLERY_TEXT, labelsize=10, pad=8)
ax.tick_params(axis="y", colors=GALLERY_TXTDIM, labelsize=8, labelleft=True, left=True)
ax.spines["bottom"].set_visible(False)
ax.spines["left"].set_visible(True); ax.spines["left"].set_color(GALLERY_TXTDIM)
ax.set_ylabel("cosine distance", color=GALLERY_TXTDIM, fontsize=10)
ax.set_title("", color=GALLERY_TEXT)
fig.text(0.02, 0.95, f"CUISINE PHYLOGENY · {sibling.upper()}",
color=GALLERY_TXTDIM, fontsize=11, family="monospace")
fig.text(0.02, 0.92, "Hierarchical clustering of cuisine pole vectors (cosine, average linkage)",
color=GALLERY_TEXT, fontsize=10)
plt.tight_layout(rect=[0, 0, 1, 0.93])
return fig
# ---------- (6) Cuisine cosine map (matrix-art version of chord) ----------
def render_cuisine_cosine_map(sibling: str):
m = MODELS[sibling]
cuisines = [c for c in _CUISINES if f"cuisine:{c}" in m.supervised_poles]
poles = np.stack([_unit(m.supervised_poles[f"cuisine:{c}"]) for c in cuisines])
S = poles @ poles.T
fig, ax = _gallery_axes(figsize=(9, 8))
# custom colormap: deep teal -> mint
from matplotlib.colors import LinearSegmentedColormap
cmap = LinearSegmentedColormap.from_list("kaikaku", [GALLERY_BG, GALLERY_DUST, KAIKAKU_ACCENT, KAIKAKU_ACCENT_LIGHT])
im = ax.imshow(S, cmap=cmap, vmin=0.0, vmax=1.0, aspect="auto")
ax.set_xticks(range(len(cuisines))); ax.set_yticks(range(len(cuisines)))
ax.set_xticklabels([c.replace("_"," ") for c in cuisines], rotation=35, ha="right",
color=GALLERY_TEXT, fontsize=10)
ax.set_yticklabels([c.replace("_"," ") for c in cuisines], color=GALLERY_TEXT, fontsize=10)
ax.tick_params(left=True, bottom=False)
for i in range(len(cuisines)):
for j in range(len(cuisines)):
v = float(S[i,j])
ax.text(j, i, f"{v:.2f}", ha="center", va="center",
color=("white" if v < 0.5 else GALLERY_BG), fontsize=10)
cb = plt.colorbar(im, ax=ax, shrink=0.8)
cb.outline.set_visible(False)
cb.ax.yaxis.set_tick_params(color=GALLERY_TXTDIM)
plt.setp(plt.getp(cb.ax.axes, "yticklabels"), color=GALLERY_TXTDIM)
cb.set_label("cosine", color=GALLERY_TXTDIM)
fig.text(0.02, 0.96, f"CUISINE COSINE MAP · {sibling.upper()}",
color=GALLERY_TXTDIM, fontsize=11, family="monospace")
fig.text(0.02, 0.93, "Pairwise cosine similarity between cuisine pole vectors",
color=GALLERY_TEXT, fontsize=10)
plt.tight_layout(rect=[0, 0, 1, 0.92])
return fig
# ---------- (3) SLERP trajectory frames ----------
def render_slerp_trajectory(sibling: str, seed: str, direction: str, max_theta: int = 60):
"""Show the rotation as 5 stacked UMAP panels at increasing angles."""
m = MODELS[sibling]
coords = UMAP_DATA[sibling]
if seed not in m.vocab or direction not in m.supervised_poles:
fig, ax = _gallery_axes(figsize=(11, 4))
ax.text(0.5, 0.5, "Pick a valid seed and direction",
ha="center", va="center", transform=ax.transAxes, color=GALLERY_TXTDIM)
return fig
v = _unit(m.E[m.vocab[seed]])
d = _unit(m.supervised_poles[direction])
thetas = [0, int(max_theta*0.33), int(max_theta*0.66), int(max_theta)]
xmin, xmax = float(coords[:,0].min()-0.5), float(coords[:,0].max()+0.5)
ymin, ymax = float(coords[:,1].min()-0.5), float(coords[:,1].max()+0.5)
fig, axes = plt.subplots(1, len(thetas), figsize=(15, 4.2), facecolor=GALLERY_BG,
gridspec_kw=dict(wspace=0.05))
for ax, theta in zip(axes, thetas):
ax.set_facecolor(GALLERY_BG)
for s in ax.spines.values(): s.set_visible(False)
ax.tick_params(left=False, bottom=False, labelleft=False, labelbottom=False)
# background
ax.scatter(coords[:,0], coords[:,1], s=1.8, c=GALLERY_DUST, alpha=0.5)
q = _slerp(v, d, theta)
# top-K of current query
sims = m.E @ q
top = np.argsort(-sims)[:6]
# exclude seed
seed_idx = m.vocab[seed]
top = [i for i in top if i != seed_idx][:5]
# seed in mint
sp = coords[seed_idx]
ax.scatter([sp[0]], [sp[1]], s=200, c=KAIKAKU_ACCENT_LIGHT,
edgecolors="white", linewidths=0.8, marker="*", zorder=4)
ax.text(sp[0], sp[1]+0.3, seed, color=KAIKAKU_ACCENT_LIGHT, ha="center",
fontsize=10, fontweight="bold", zorder=5)
# rotated query top-K
for idx in top:
p = coords[idx]
ax.scatter([p[0]], [p[1]], s=80, c="#F4B86E", edgecolors="white",
linewidths=0.5, alpha=0.9, zorder=4)
ax.text(p[0]+0.05, p[1], NAMES_BY_IDX[idx], color=GALLERY_TEXT,
fontsize=8, alpha=0.9, zorder=4)
ax.set_xlim(xmin, xmax); ax.set_ylim(ymin, ymax); ax.set_aspect("equal")
ax.set_title(f"θ = {theta}°", color=GALLERY_TXTDIM, fontsize=11,
family="monospace", pad=6)
fig.suptitle(f"SLERP TRAJECTORY · {seed} → {direction} · {sibling.upper()}",
color=GALLERY_TXTDIM, fontsize=12, family="monospace", y=0.98)
return fig
# ===== 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"""
/* Force LIGHT mode regardless of OS / browser preference (iOS dark-mode hijack fix) */
:root, html, body, .gradio-container, .dark {{
color-scheme: light !important;
background-color: #ffffff !important;
color: #0f172a !important;
}}
.dark {{ color-scheme: light !important; }}
.gradio-container, .gradio-container * {{
--body-background-fill: #ffffff !important;
--body-text-color: #0f172a !important;
--block-background-fill: #ffffff !important;
--block-label-background-fill: transparent !important;
--background-fill-primary: #ffffff !important;
--background-fill-secondary: #f8fafc !important;
--input-background-fill: #ffffff !important;
}}
.gradio-container {{ max-width: 1280px !important; background: #ffffff !important; }}
footer {{ visibility: hidden; }}
/* Labels: plain dark text, no chip background */
.gradio-container label, .gradio-container .label,
.gradio-container [data-testid="block-label"], .gradio-container .block-label,
.gradio-container .gr-block-label, .gradio-container span.label-wrap {{
color: #0f172a !important; font-weight: 600 !important;
background: transparent !important; box-shadow: none !important;
padding: 0 !important; border: none !important;
}}
/* Tab labels readable */
.gradio-container button[role="tab"] {{
color: #334155 !important; font-weight: 500 !important; background: transparent !important;
}}
.gradio-container button[role="tab"][aria-selected="true"] {{
color: {KAIKAKU_ACCENT} !important; border-bottom-color: {KAIKAKU_ACCENT} !important; font-weight: 700 !important;
}}
/* Primary button */
.gradio-container button.primary, .gradio-container .primary > button {{
background: {KAIKAKU_ACCENT} !important; color: #ffffff !important;
border-color: {KAIKAKU_ACCENT} !important; font-weight: 600 !important;
}}
/* Tables readable on white */
.gradio-container table thead th,
.gradio-container .gr-dataframe thead th,
.gradio-container [class*="dataframe"] thead th {{
color: #0f172a !important; font-weight: 700 !important; background: #f8fafc !important;
}}
.gradio-container table tbody td,
.gradio-container .gr-dataframe tbody td,
.gradio-container [class*="dataframe"] tbody td {{
color: #0f172a !important; background: #ffffff !important;
}}
.gradio-container [class*="dataframe"] tbody tr:nth-child(even) td {{
background: #fafbfc !important;
}}
/* Dropdown / Textbox readable */
.gradio-container input, .gradio-container textarea, .gradio-container .gr-dropdown,
.gradio-container [data-testid="dropdown"] {{
background: #ffffff !important; color: #0f172a !important;
}}
.gradio-container .token, .gradio-container .gr-dropdown .token {{
background: #f1f5f9 !important; color: #0f172a !important;
}}
/* Spectrum bar */
.spectrum-bar {{
display: flex; align-items: stretch; margin: 12px 0 4px 0; min-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: 8px 12px; color: #0f172a !important; min-width: 0;
}}
.spectrum-cell-1 {{ background: #f0f9f6 !important; }}
.spectrum-cell-2 {{ background: #d8efe7 !important; }}
.spectrum-cell-3 {{ background: #b8dfd1 !important; }}
.spectrum-name {{ font-weight: 700; font-size: 0.95em; color: #0f172a !important; }}
.spectrum-sub {{ font-size: 0.78em; color: #475569 !important; line-height: 1.3; }}
.spectrum-arrow {{ width: 18px; background: transparent !important; display: flex; align-items: center; justify-content: center; color: #94a3b8 !important; flex-shrink: 0; }}
/* Mobile responsive: stack the three sibling result tables on narrow screens */
@media (max-width: 768px) {{
.gradio-container .compare-row {{ flex-direction: column !important; }}
.gradio-container .compare-row > * {{ width: 100% !important; min-width: 0 !important; }}
.spectrum-cell {{ padding: 6px 8px; }}
.spectrum-sub {{ font-size: 0.7em; }}
.spectrum-name {{ font-size: 0.85em; }}
.spectrum-bar {{ min-height: 64px; }}
.gradio-container {{ padding: 0 8px !important; }}
h1 {{ font-size: 1.4em !important; }}
}}
@media (max-width: 480px) {{
.spectrum-sub {{ display: none; }}
.spectrum-arrow {{ width: 12px; }}
.spectrum-bar {{ min-height: 40px; }}
.spectrum-cell {{ padding: 4px 6px; }}
}}
"""
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(elem_classes=["compare-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(elem_classes=["compare-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(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(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",
)
# ---------- Tab: INVERSE QUERIES ----------
with gr.Tab("Inverse queries"):
with gr.Tabs():
with gr.Tab("Substitution finder"):
gr.Markdown("I'm out of X — what's the closest substitute? Optional constraints.")
with gr.Row():
sub_seed = gr.Dropdown(choices=ALL_INGREDIENTS, label="Seed ingredient", value="mascarpone_cheese")
sub_sib = gr.Dropdown(choices=["cooc","core","chem"], value="chem", label="Sibling")
sub_k = gr.Slider(3, 20, value=10, step=1, label="K")
with gr.Row():
sub_grp = gr.Checkbox(label="Must share food group", value=True)
sub_nova = gr.Checkbox(label="Pull toward same NOVA level", value=False)
sub_cui = gr.Checkbox(label="Rotate 30° from dominant cuisine", value=False)
sub_btn = gr.Button("Find substitutes", variant="primary")
sub_df = gr.Dataframe(headers=["Substitute","Cosine","Food group","Notes"],
wrap=True, label="Top-K substitutes")
sub_md = gr.Markdown()
sub_btn.click(substitute_finder,
inputs=[sub_seed, sub_sib, sub_k, sub_grp, sub_nova, sub_cui],
outputs=[sub_df, sub_md], show_progress="minimal")
gr.Examples(
examples=[
["mascarpone_cheese","chem",10,True,False,False],
["fish_sauce","chem",10,False,False,False],
["saffron","chem",8,False,False,True],
["beef","core",8,True,False,False],
],
inputs=[sub_seed, sub_sib, sub_k, sub_grp, sub_nova, sub_cui],
label="Try one",
)
with gr.Tab("Sensory profile search"):
gr.Markdown("Drag sliders for the sensory axes you want; tool returns ingredients matching that profile.")
with gr.Row():
sp_sib = gr.Dropdown(choices=["cooc","core","chem"], value="chem", label="Sibling")
sp_k = gr.Slider(5, 30, value=15, step=1, label="K")
sp_sliders = []
with gr.Row():
for label, _ in _SENSORY_SLIDER_KEYS[:5]:
sp_sliders.append(gr.Slider(0, 1, value=0, step=0.05, label=label))
with gr.Row():
for label, _ in _SENSORY_SLIDER_KEYS[5:]:
sp_sliders.append(gr.Slider(0, 1, value=0, step=0.05, label=label))
sp_btn = gr.Button("Search by profile", variant="primary")
sp_df = gr.Dataframe(headers=["Ingredient","Cosine","Food group"], wrap=True, label="Top-K")
sp_md = gr.Markdown()
sp_btn.click(sensory_search, inputs=[sp_sib, sp_k, *sp_sliders],
outputs=[sp_df, sp_md], show_progress="minimal")
# ---------- Tab: INSPECT ----------
with gr.Tab("Inspect"):
with gr.Tabs():
with gr.Tab("Ingredient passport"):
gr.Markdown("Single-page dossier for one ingredient.")
with gr.Row():
pp_pick = gr.Dropdown(choices=ALL_INGREDIENTS, label="Ingredient", value="basil")
pp_btn = gr.Button("Generate passport", variant="primary")
with gr.Row():
with gr.Column(scale=3):
pp_html = gr.HTML(value="Pick an ingredient above and click Generate passport.
")
with gr.Column(scale=1):
gr.Markdown(f"SENSORY RADAR
")
pp_radar = gr.Plot(label="")
def _passport_outputs(name):
h, r = render_passport_html(name)
return h, r
pp_btn.click(_passport_outputs, inputs=[pp_pick], outputs=[pp_html, pp_radar], show_progress="minimal")
pp_pick.change(_passport_outputs, inputs=[pp_pick], outputs=[pp_html, pp_radar], show_progress="minimal")
with gr.Tab("Mode wiki"):
gr.Markdown("Click into any of the ~500 modes for a per-mode wiki page.")
with gr.Row():
wk_sib = gr.Radio(choices=["cooc","core","chem"], value="chem", label="Sibling")
wk_mode = gr.Dropdown(choices=_mode_choices_searchable("chem"),
label="Mode", filterable=True)
wk_md = gr.Markdown()
wk_sib.change(lambda s: gr.Dropdown(choices=_mode_choices_searchable(s), value=None),
inputs=[wk_sib], outputs=[wk_mode])
wk_mode.change(render_mode_wiki, inputs=[wk_sib, wk_mode], outputs=[wk_md])
with gr.Tab("Cultural context"):
gr.Markdown("Map an ingredient to its cuisine traditions. Paper-grounded; English-only because the source-language names were not persisted by the LLM pipeline.")
with gr.Row():
cx_ing = gr.Dropdown(choices=ALL_INGREDIENTS, label="Ingredient", value="gochujang" if "gochujang" in MODELS["chem"].vocab else "miso")
cx_sib = gr.Radio(choices=["cooc","core","chem"], value="chem", label="Sibling")
cx_k = gr.Slider(2, 6, value=4, step=1, label="Top-K cuisines")
cx_btn = gr.Button("Show context", variant="primary")
cx_df = gr.Dataframe(headers=["Macro-region","Cosine","Constituent traditions"],
wrap=True, label="Closest cuisine macro-regions")
cx_md = gr.Markdown()
cx_btn.click(cultural_context, inputs=[cx_ing, cx_sib, cx_k],
outputs=[cx_df, cx_md], show_progress="minimal")
cx_ing.change(cultural_context, inputs=[cx_ing, cx_sib, cx_k],
outputs=[cx_df, cx_md])
# ---------- Tab: CONSTELLATIONS ----------
with gr.Tab("3D Atlas"):
gr.Markdown(
"**Interactive 3D map of all 1,790 ingredients.** "
"Drag with mouse to rotate. Scroll to zoom. Hover for ingredient names. "
"Basket members appear as mint diamonds; their nearest neighbours pop in amber."
)
with gr.Row():
atl_sib = gr.Radio(choices=["cooc","core","chem"], value="chem", label="Sibling")
atl_color = gr.Radio(choices=["food group", "cuisine"], value="food group",
label="Color points by")
atl_basket = gr.Dropdown(
choices=ALL_INGREDIENTS,
value=["miso","basil","chocolate","tomato"],
label="Highlight these ingredients",
multiselect=True, max_choices=15,
)
with gr.Row():
atl_show_nb = gr.Checkbox(value=True, label="Show top-K neighbours")
atl_k = gr.Slider(3, 20, value=8, step=1, label="K")
atl_btn = gr.Button("Update", variant="primary")
atl_plot = gr.Plot(label="")
atl_btn.click(render_3d_atlas,
inputs=[atl_sib, atl_basket, atl_show_nb, atl_k, atl_color],
outputs=atl_plot, show_progress="minimal")
atl_sib.change(render_3d_atlas,
inputs=[atl_sib, atl_basket, atl_show_nb, atl_k, atl_color],
outputs=atl_plot, show_progress="minimal")
atl_color.change(render_3d_atlas,
inputs=[atl_sib, atl_basket, atl_show_nb, atl_k, atl_color],
outputs=atl_plot, show_progress="minimal")
atl_basket.change(render_3d_atlas,
inputs=[atl_sib, atl_basket, atl_show_nb, atl_k, atl_color],
outputs=atl_plot, show_progress="minimal")
gr.Examples(
examples=[
["chem", "food group", ["miso","basil","chocolate","tomato"], True, 8],
["chem", "cuisine", ["chicken","lemongrass","coconut_milk","fish_sauce"], True, 10],
["core", "cuisine", ["tomato","mozzarella_cheese","basil","olive_oil"], True, 8],
["chem", "food group", ["cumin","coriander","turmeric","cardamom","fenugreek_seed"], True, 10],
["cooc", "food group", ["red_wine","brandy","whiskey","bourbon","cognac"], True, 8],
],
inputs=[atl_sib, atl_color, atl_basket, atl_show_nb, atl_k],
label="Try one of these baskets",
)
# ---------- Tab 5: GALLERY ----------
with gr.Tab("Gallery"):
gr.Markdown("Six aesthetic views of the model. All rendered in the Kaikaku palette.")
with gr.Tabs():
# --- Factor poster ---
with gr.Tab("Factor poster"):
gr.Markdown(
"**How to read this.** Each sibling has **20 emergent ICA factors** (`F_0` to `F_19`), "
"ranked by stability across random seeds (`F_0` is most reproducible). "
"A factor is an unsupervised latent dimension discovered by FastICA on the embedding "
"with food-group variance projected out. "
"Each factor's top-quartile ingredients are partitioned into **3-7 GMM modes** "
"(`M0`, `M1`, ...) — culinary neighbourhoods along that factor. "
"Mode labels (e.g. *Chinese Wok Essentials*) are Claude-generated from member contents. "
"Pick a factor below; the dropdown shows a preview of its mode labels."
)
_fp_choices = factor_options("chem")
_fp_default = _fp_choices[0][1] if _fp_choices else ""
with gr.Row():
fp_sib = gr.Radio(choices=["cooc","core","chem"], value="chem",
label="Sibling", scale=1)
fp_factor = gr.Dropdown(choices=_fp_choices,
value=_fp_default,
label="Factor (preview of mode labels shown)", scale=3)
fp_btn = gr.Button("Render", variant="primary", scale=1)
fp_plot = gr.Plot(label="")
fp_btn.click(render_factor_poster, inputs=[fp_sib, fp_factor], outputs=fp_plot, show_progress="full")
def _refresh_factor_choices(s):
choices = factor_options(s)
return gr.Dropdown(choices=choices, value=choices[0][1] if choices else None)
fp_sib.change(_refresh_factor_choices, inputs=fp_sib, outputs=fp_factor)
fp_factor.change(render_factor_poster, inputs=[fp_sib, fp_factor], outputs=fp_plot, show_progress="minimal")
# --- Cuisine compass ---
with gr.Tab("Cuisine compass"):
with gr.Row():
cc_sib = gr.Radio(choices=["cooc","core","chem"], value="chem", label="Sibling")
cc_ings = gr.Dropdown(choices=ALL_INGREDIENTS, value=["miso","basil","cumin"],
label="Ingredients (1-5 polygons)", multiselect=True, max_choices=5)
cc_btn = gr.Button("Render", variant="primary")
cc_plot = gr.Plot(label="")
cc_btn.click(render_cuisine_compass, inputs=[cc_sib, cc_ings], outputs=cc_plot, show_progress="minimal")
# --- Arithmetic vector art ---
with gr.Tab("Vector arithmetic"):
with gr.Row():
va_sib = gr.Radio(choices=["cooc","core","chem"], value="core", label="Sibling")
va_pos = gr.Dropdown(choices=ALL_INGREDIENTS, value=["miso"], label="Positives", multiselect=True, max_choices=5)
va_neg = gr.Dropdown(choices=ALL_INGREDIENTS, value=["salt"], label="Negatives", multiselect=True, max_choices=5)
va_btn = gr.Button("Render", variant="primary")
va_plot = gr.Plot(label="")
va_btn.click(render_arithmetic_vector, inputs=[va_sib, va_pos, va_neg], outputs=va_plot, show_progress="full")
# --- Cuisine phylogeny ---
with gr.Tab("Cuisine phylogeny"):
with gr.Row():
ph_sib = gr.Radio(choices=["cooc","core","chem"], value="chem", label="Sibling")
ph_btn = gr.Button("Render", variant="primary")
ph_plot = gr.Plot(label="")
ph_btn.click(render_cuisine_phylogeny, inputs=[ph_sib], outputs=ph_plot, show_progress="minimal")
# --- Cuisine cosine map ---
with gr.Tab("Cuisine cosine map"):
with gr.Row():
cm_sib = gr.Radio(choices=["cooc","core","chem"], value="chem", label="Sibling")
cm_btn = gr.Button("Render", variant="primary")
cm_plot = gr.Plot(label="")
cm_btn.click(render_cuisine_cosine_map, inputs=[cm_sib], outputs=cm_plot, show_progress="minimal")
# --- SLERP trajectory ---
with gr.Tab("SLERP trajectory"):
with gr.Row():
st_sib = gr.Radio(choices=["cooc","core","chem"], value="chem", label="Sibling")
st_seed = gr.Dropdown(choices=ALL_INGREDIENTS, value="rice", label="Seed")
st_dir = gr.Dropdown(choices=_supervised_choices("chem"),
value="cuisine:South_Asian", label="Direction")
st_max = gr.Slider(15, 90, value=60, step=15, label="Max θ (deg)")
st_btn = gr.Button("Render", variant="primary")
st_plot = gr.Plot(label="")
st_btn.click(render_slerp_trajectory, inputs=[st_sib, st_seed, st_dir, st_max], outputs=st_plot, show_progress="full")
st_sib.change(lambda s: gr.Dropdown(choices=_supervised_choices(s), value=None),
inputs=st_sib, outputs=st_dir)
# ---- 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(server_name="0.0.0.0", server_port=7860, ssr_mode=False)