josefchen's picture
Add 6 features: mode-click->UMAP, food-group filter, why-explainers, recipe builder, public API, saved queries (BrowserState)
e030cbd verified
Raw
History Blame
56.5 kB
"""Epicure Explorer: chef-facing operators over the three sibling embeddings.
Features:
- Basket pairings (with pairwise cosine heatmap)
- Supervised SLERP (with "why these results" explainer)
- Emergent SLERP (with explainer)
- Arithmetic (Mikolov-style, with explainer)
- Mode atlas (click row -> highlight on UMAP)
- Compare siblings (one query, three columns)
- UMAP visualisation (2D / 3D)
- Parse my fridge (free-text -> canonical vocab via rapidfuzz)
- Recipe builder (hybrid retrieval: rapidfuzz + sentence-transformers over mode labels)
- Saved queries (per-browser persistence via gr.BrowserState)
- Public developer API (gr.api endpoints for neighbours / slerp / arithmetic / embed)
- Food-group filter on every ingredient dropdown
Paper: https://arxiv.org/abs/2605.22391
"""
from __future__ import annotations
import os
import re
import sys
import json
import uuid
from datetime import datetime, timezone
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"
KAIKAKU_TEXT = "#0F2D2F"
KAIKAKU_MUTED = "#5A7878"
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)
print(f"[epicure-explorer] food group labels: {len(FOOD_GROUPS)} ingredients", flush=True)
# ===== Feature 5: food-group filter helpers =====
_NAME_TO_GROUP: dict[str, str] = {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: str) -> list[str]:
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: str, 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 helpers =====
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)
# ===== Feature 4: explainer helpers =====
def _fmt_nb_inline(pairs):
return ", ".join(f"{n} ({s:+.2f})" for n, s in pairs)
def _slerp_explainer(m, basket, direction_keys, theta, q, v, d, kind):
if v is None or d is None or q is None:
return "_(no rotation applied)_"
cos_theta = float(q @ v)
travelled = min(max(float(theta) / 90.0, 0.0), 1.0)
dir_nb = _topk(m, _unit(d), k=5, exclude=basket or [])
seed_nb = _topk(m, v, k=3, exclude=basket or [])
dir_names = ", ".join(n for n, _ in dir_nb[:3])
label = "direction pole" if kind == "supervised" else "factor-mode pole"
dirs_str = " + ".join(direction_keys) if direction_keys else "(none)"
return (
f"**Why these results** \n"
f"- Rotated query vs. seed centroid: cos = {cos_theta:.3f} (theta = {float(theta):.0f}°; "
f"{travelled*100:.0f}% of the way to the {label}). \n"
f"- {label.capitalize()} ({dirs_str}) nearest in vocab: {_fmt_nb_inline(dir_nb)}. \n"
f"- Seed basket's own top-3 (baseline): {_fmt_nb_inline(seed_nb)}. \n"
f"- At {float(theta):.0f}° the query lands near: {dir_names}."
)
def _arithmetic_explainer(m, positives, negatives, q, pos_v, neg_v):
if q is None:
return "_(no result: missing positives)_"
pos_sims = [(n, float(_unit(m.E[m.vocab[n]]) @ q)) for n in (positives or []) if n in m.vocab]
neg_sims = [(n, float(_unit(m.E[m.vocab[n]]) @ q)) for n in (negatives or []) if n in m.vocab]
top = _topk(m, q, k=1, exclude=(positives or []) + (negatives or []))
top_name, top_sim = top[0] if top else ("(none)", 0.0)
pos_part = ", ".join(f"{n} ({s:+.2f})" for n, s in pos_sims) or "(none)"
neg_part = ", ".join(f"{n} ({s:+.2f})" for n, s in neg_sims) or "(none)"
input_max = max((s for _, s in pos_sims + neg_sims), default=0.0)
if pos_sims or neg_sims:
gap = top_sim - input_max
if gap > 0.05:
interp = (f"Result sits closer to **{top_name}** ({top_sim:+.2f}) "
f"than to any input (max {input_max:+.2f}); the embedding separates these concepts.")
else:
interp = (f"Result is dominated by the inputs themselves "
f"(top neighbour {top_name} only {gap:+.2f} above max input cosine).")
else:
interp = f"Result top neighbour: {top_name} ({top_sim:+.2f})."
return (
f"**Why these results** \n"
f"- Result vs. positives: {pos_part}. \n"
f"- Result vs. negatives: {neg_part}. \n"
f"- {interp}"
)
# ===== heatmap =====
def _basket_heatmap(m, basket):
valid = [n for n in (basket or []) if n in m.vocab]
fig, ax = plt.subplots(figsize=(6, 5))
if len(valid) < 2:
ax.text(0.5, 0.5, "Add 2+ ingredients to see pairwise cosines",
ha="center", va="center", fontsize=13, 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])
color = "white" if v < 0.55 else "black"
ax.text(j, i, f"{v:.2f}", ha="center", va="center", fontsize=10, color=color)
cb = plt.colorbar(im, ax=ax)
cb.set_label("cosine")
ax.set_title("Pairwise cosine within the basket", fontsize=12)
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]}<br>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[str] = set()
if show_neighbours and basket_idxs:
centroid = _basket_centroid(m, basket)
if centroid is not None:
nb_pairs = _topk(m, centroid, k=int(k), exclude=basket)
neighbour_set = {nm for nm, _ in nb_pairs}
bg_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 bg_keep(i)]
bg_y = [float(coords2[i, 1]) for i in range(n) if bg_keep(i)]
bg_z = [float(z[i]) for i in range(n) if bg_keep(i)] if three_d else None
bg_c = [colors[i] for i in range(n) if bg_keep(i)]
bg_h = [hover_text[i] for i in range(n) if bg_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, line=dict(width=0)),
text=bg_h, hovertemplate="%{text}<extra></extra>", 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, line=dict(width=0)),
text=bg_h, hovertemplate="%{text}<extra></extra>", 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
nlabels = [NAMES_BY_IDX[i] for i in ni]
marker = 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
kwargs = dict(mode="markers+text", marker=marker, text=nlabels, textposition="top center",
textfont=dict(size=10),
hovertemplate="<b>%{text}</b> (neighbour)<extra></extra>",
name=f"top-{k} neighbours")
fig.add_trace(TR(x=nx, y=ny, z=nz, **kwargs) if three_d else TR(x=nx, y=ny, **kwargs))
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
blabels = [NAMES_BY_IDX[i] for i in basket_idxs]
marker = 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
kwargs = dict(mode="markers+text", marker=marker, text=blabels, textposition="top center",
textfont=dict(size=13, color="#111111"),
hovertemplate="<b>%{text}</b> (basket)<extra></extra>", name="basket")
fig.add_trace(TR(x=bx, y=by, z=bz, **kwargs) if three_d else TR(x=bx, y=by, **kwargs))
title_suffix = " (3D)" if three_d else ""
fig.update_layout(
title=dict(text=f"UMAP of Epicure-{sibling.capitalize()}{title_suffix} - {n} ingredients", font=dict(size=15)),
height=650, margin=dict(l=40, r=40, t=60, 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="#eeeeee", zeroline=False, title="UMAP 1")
fig.update_yaxes(showgrid=True, gridcolor="#eeeeee", 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
# ===== tab handlers (with explainers) =====
def basket_pairings(sibling, basket, k):
m = MODELS[sibling]
centroid = _basket_centroid(m, basket)
if centroid is None:
return [], [], _basket_heatmap(m, [])
nb = _topk(m, centroid, k, exclude=basket or [])
scored = [(mode.mode_id, mode.label, mode.kind, float(_unit(mode.pole) @ centroid)) for mode in m.modes]
scored.sort(key=lambda x: -x[3])
heatmap = _basket_heatmap(m, basket)
return (
[[name, f"{sim:.4f}"] for name, sim in nb],
[[mid, label, kind, f"{sim:.4f}"] for mid, label, kind, sim in scored[:k]],
heatmap,
)
def supervised_slerp_multi(sibling, basket, directions, theta, k):
m = MODELS[sibling]
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, _slerp_explainer(m, basket, directions or [], theta, q, v, d, "supervised")
def emergent_slerp_multi(sibling, basket, mode_labels, theta, k):
m = MODELS[sibling]
label_to_id = {f"{mode.label} ({mode.mode_id})": mode.mode_id for mode in m.modes if mode.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 factor mode selected)_"
q = _slerp(v, d, theta)
rows = [[n, f"{s:.4f}"] for n, s in _topk(m, q, k, basket)]
return rows, _slerp_explainer(m, basket, mode_ids, theta, q, v, d, "emergent")
def arithmetic(sibling, positives, negatives, k):
m = MODELS[sibling]
pos = _basket_centroid(m, positives)
if pos is None:
return [], "_(no positives provided)_"
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, (positives or []) + (negatives or []))]
return rows, _arithmetic_explainer(m, positives or [], negatives or [], q, pos, neg)
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[:12])])
rows.sort(key=lambda r: (r[1], -r[4]))
return rows
def compare_siblings(basket, directions, theta, k):
out = []
for sib in ["cooc","core","chem"]:
m = MODELS[sib]
v = _basket_centroid(m, basket)
if v is None: out.append([]); continue
valid_dirs = [d for d in (directions or []) if d in m.supervised_poles]
if valid_dirs:
d_vec = _stack_directions(m, valid_dirs)
q = _slerp(v, d_vec, theta) if d_vec is not None else v
else:
q = v
hits = _topk(m, q, k=k, exclude=basket)
out.append([[n, f"{s:.4f}"] for n, s in hits])
return out[0], out[1], out[2]
# ===== Feature 6: recipe builder (lazy-loaded sentence-transformer) =====
_ST_MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2"
_ST = None
def _get_st():
global _ST
if _ST is None:
print(f"[epicure-explorer] loading {_ST_MODEL_NAME} (first call, ~80MB)", flush=True)
from sentence_transformers import SentenceTransformer
_ST = SentenceTransformer(_ST_MODEL_NAME, device="cpu")
return _ST
@lru_cache(maxsize=4)
def _mode_label_matrix(sibling: str):
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]
_PROMPT_STOPWORDS = {
"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","plate","plates",
}
_TOKEN_RE = re.compile(r"[A-Za-z][A-Za-z\-']{1,}")
def suggest_basket(prompt, sibling, k=10):
if not prompt or not prompt.strip():
return [], [], "Type a dish description first."
vocab = list(MODELS[sibling].vocab.keys())
vocab_sp = [v.replace("_", " ") for v in vocab]
raw_tokens = _TOKEN_RE.findall(prompt.lower())
tokens = [t for t in raw_tokens if t not in _PROMPT_STOPWORDS 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, ""))
s_new = max(s_existing, sim * 100.0)
thematic[name] = (s_new, 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]:
tag = "both" if prev else "thematic"
combined[name] = (sc, tag)
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:
dm = ", ".join(sorted({f"`{n}` (from '{t}')" for t, n, _ in direct_evidence}))
lines.append(f"**Direct mentions:** {dm}")
else:
lines.append("**Direct mentions:** _none cleared score threshold_")
if thematic_modes:
bits = []
id_to_mode = {md.mode_id: md for md in MODELS[sibling].modes if md.kind == "factor"}
for mid, lab, sim in thematic_modes:
sample = ", ".join(id_to_mode[mid].members[:4])
bits.append(f"`{lab}` (cos {sim:.2f}; e.g. {sample})")
lines.append("**Matched factor modes:** " + "; ".join(bits))
else:
lines.append("**Matched factor modes:** _no mode label cleared cosine 0.25_")
return rows, names, "\n\n".join(lines)
# ===== fridge parser =====
_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 _name_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_key(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_key)
return candidates[0]
def parse_fridge(raw_text, sibling, 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, cleaned]); continue
rows.append([line.strip(), match, round(score, 1), cleaned])
matched.append(match)
seen, dedup = set(), []
for n in matched:
if n not in seen: seen.add(n); dedup.append(n)
return rows, dedup
# ===== Feature 8: public API endpoints =====
def _suggest(name: str, sibling: str, n: int = 5) -> list[str]:
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 _validate_sibling(sibling):
if sibling not in MODELS:
return {"error": f"sibling '{sibling}' not in {{cooc, core, chem}}",
"suggestions": ["cooc","core","chem"]}
return None
def _validate_ingredient(name, sibling, field="ingredient"):
if not isinstance(name, str) or not name:
return {"error": f"{field} must be a non-empty string"}
if name not in MODELS[sibling].vocab:
return {"error": f"{field} '{name}' not in vocab",
"suggestions": _suggest(name, sibling)}
return None
def api_neighbors(ingredient, sibling="chem", k=5):
err = _validate_sibling(sibling) or _validate_ingredient(ingredient, sibling)
if err: return err
m = MODELS[sibling]
q = _unit(m.E[m.vocab[ingredient]])
pairs = _topk(m, q, int(k), exclude=[ingredient])
return [{"name": n, "cosine": round(float(s), 6)} for n, s in pairs]
def api_slerp(seed, direction, theta_deg=30, sibling="chem", k=5):
err = _validate_sibling(sibling) or _validate_ingredient(seed, sibling, "seed")
if err: return err
m = MODELS[sibling]
if direction not in m.supervised_poles:
return {"error": f"direction '{direction}' not a supervised pole",
"suggestions": sorted(m.supervised_poles.keys())[:10]}
v = _unit(m.E[m.vocab[seed]])
d = _unit(m.supervised_poles[direction])
q = _slerp(v, d, float(theta_deg))
pairs = _topk(m, q, int(k), exclude=[seed])
return [{"name": n, "cosine": round(float(s), 6)} for n, s in pairs]
def api_arithmetic(positives, negatives, sibling="chem", k=5):
err = _validate_sibling(sibling)
if err: return err
positives = list(positives or [])
negatives = list(negatives or [])
if not positives:
return {"error": "positives must be a non-empty list"}
m = MODELS[sibling]
unknown = [x for x in positives + negatives if x not in m.vocab]
if unknown:
return {"error": f"unknown ingredients: {unknown}",
"suggestions": {x: _suggest(x, sibling) for x in 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
pairs = _topk(m, q, int(k), exclude=positives + negatives)
return [{"name": n, "cosine": round(float(s), 6)} for n, s in pairs]
def api_embed(ingredient, sibling="chem"):
err = _validate_sibling(sibling) or _validate_ingredient(ingredient, sibling)
if err: return err
m = MODELS[sibling]
v = _unit(m.E[m.vocab[ingredient]])
return [float(x) for x in v.tolist()]
def api_list_directions(sibling="chem"):
err = _validate_sibling(sibling)
if err: return err
return sorted(MODELS[sibling].supervised_poles.keys())
def api_list_factor_modes(sibling="chem"):
err = _validate_sibling(sibling)
if err: return err
return [{"mode_id": mode.mode_id, "label": str(mode.label),
"kind": str(mode.kind), "property": str(mode.property),
"n_members": int(mode.n_members)}
for mode in MODELS[sibling].modes if mode.kind == "factor"]
# ===== Feature 9: saved queries helpers =====
TAB_IDS = {
"basket": "tab_basket",
"supervised_slerp": "tab_sup",
"emergent_slerp": "tab_em",
"arithmetic": "tab_ar",
"compare": "tab_cmp",
}
TAB_LABELS = {
"basket": "Basket pairings",
"supervised_slerp": "Supervised SLERP",
"emergent_slerp": "Emergent SLERP",
"arithmetic": "Arithmetic",
"compare": "Compare siblings",
}
def _summarise(tab, inputs):
sib = inputs.get("sibling", "")
if tab == "basket":
return f"[{sib}] basket: {', '.join(inputs.get('basket', [])[:3])} k={inputs.get('k')}"
if tab == "supervised_slerp":
b = ", ".join(inputs.get("basket", [])[:2])
d = ", ".join(inputs.get("directions", [])[:2])
return f"[{sib}] {b} +{inputs.get('theta')}° -> {d}"
if tab == "emergent_slerp":
b = ", ".join(inputs.get("basket", [])[:2])
return f"[{sib}] {b} +{inputs.get('theta')}° -> {len(inputs.get('modes', []))} factor modes"
if tab == "arithmetic":
p = " + ".join(inputs.get("positives", [])[:2])
n = " + ".join(inputs.get("negatives", [])[:2])
return f"[{sib}] {p}" + (f" - {n}" if n else "")
if tab == "compare":
return f"[3 siblings] {', '.join(inputs.get('basket', [])[:2])} +{inputs.get('theta')}°"
return "(unknown)"
def save_query(saved, tab, inputs_dict):
saved = list(saved or [])
rec = {
"id": str(uuid.uuid4()),
"created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"tab": tab,
"inputs": inputs_dict,
"summary": _summarise(tab, inputs_dict),
}
saved.insert(0, rec)
saved = saved[:200]
return saved, _render_saved(saved)
def delete_query(saved, qid):
saved = [q for q in (saved or []) if q.get("id") != qid]
return saved, _render_saved(saved)
def _render_saved(saved):
return [[q["created_at"], TAB_LABELS.get(q["tab"], q["tab"]), q["summary"], q["id"]]
for q in (saved or [])]
# ===== Theme + CSS =====
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_background_fill_hover="#e2e8f0",
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, .gradio-container .gr-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 button.primary:hover {{ background: {KAIKAKU_ACCENT_HOVER} !important; border-color: {KAIKAKU_ACCENT_HOVER} !important; }}
.gradio-container table thead th, .gradio-container .gr-dataframe thead th {{
color: #0f172a !important; font-weight: 700 !important; background: #f8fafc !important;
}}
.gradio-container table tbody td {{ color: #0f172a !important; }}
.sibling-card {{
border-left: 3px solid {KAIKAKU_ACCENT}; padding: 10px 14px;
margin: 6px 0; background: #f8fafc; border-radius: 4px;
}}
.sibling-name {{ color: {KAIKAKU_DARK}; font-weight: 700; font-size: 1.02em; }}
.sibling-desc {{ color: #334155; font-size: 0.95em; line-height: 1.5; }}
"""
_INITIAL_UMAP = umap_view("chem", ["chicken","lemon","garlic"], True, 8, three_d=False)
_INITIAL_HEATMAP = _basket_heatmap(MODELS["chem"], ["chicken","lemon","garlic"])
SIBLING_CARDS = """
<div class="sibling-card">
<div class="sibling-name">Cooc - recipe-context only</div>
<div class="sibling-desc">Walks recipe co-occurrence (NPMI graph) only. Neighbours are recipe <em>companions</em>: things that get cooked with the seed. Isotropic geometry (PR=173.6 of 300). Best for "what else do I cook with X".</div>
</div>
<div class="sibling-card">
<div class="sibling-name">Core - blended (the middle ground)</div>
<div class="sibling-desc">Typed FlavorDB compound walks blended with injected I-I walks at ii_repeat=10. Concentrated geometry (PR=94.2), tightest emergent modes. Chemistry-aware but keeps recipe context.</div>
</div>
<div class="sibling-card">
<div class="sibling-name">Chem - chemistry only</div>
<div class="sibling-desc">Typed FlavorDB compound metapaths only (ii_repeat=0). Neighbours are flavour-profile <em>peers</em>: things that share aroma chemistry with the seed. Best supervised-direction recovery; cuisine Cohen's d = 3.07 across 8 macro-regions.</div>
</div>
"""
# ===== Helper for ingredient picker with food-group filter =====
def _ingredient_picker(label, default_value, multiselect=True, max_choices=10):
radio = gr.Radio(choices=FOOD_GROUP_CHOICES, value="All",
label=f"{label} - food group filter", interactive=True)
dd = gr.Dropdown(choices=ALL_INGREDIENTS, value=default_value, label=label,
multiselect=multiselect, max_choices=max_choices)
radio.change(_filter_dropdown, inputs=[radio, dd], outputs=dd, show_progress="hidden")
return radio, dd
# ===== UI =====
with gr.Blocks(title="Epicure Explorer", theme=THEME, css=CUSTOM_CSS) as demo:
saved_state = gr.BrowserState(default_value=[], storage_key="epicure_saved_queries_v1")
gr.Markdown(
"""# Epicure Explorer
Chef-facing operators over three sibling ingredient embeddings (Cooc / Core / Chem) 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(SIBLING_CARDS)
sibling = gr.Radio(choices=["cooc","core","chem"], value="chem", label="Sibling embedding to query")
shared_basket = gr.State([])
with gr.Tabs() as tabs:
# ---------- Tab 1: Basket pairings ----------
with gr.Tab("Basket pairings", id="tab_basket"):
gr.Markdown("Pick one or more ingredients. The tool averages their unit vectors and returns nearest neighbours plus closest modes of that centroid.")
basket_radio, basket = _ingredient_picker("Ingredient basket (pick 1+)", ["chicken","lemon","garlic"])
k_pair = gr.Slider(1, 15, value=8, step=1, label="K")
with gr.Row():
pair_btn = gr.Button("Find pairings", variant="primary")
save_basket_btn = gr.Button("Save this query", variant="secondary")
with gr.Row():
nb_table = gr.Dataframe(headers=["Neighbour","Cosine"], label="Top-K nearest neighbours", interactive=False)
mode_table = gr.Dataframe(headers=["Mode id","Label","Kind","Cosine"], label="Closest modes", interactive=False)
heatmap_plot = gr.Plot(value=_INITIAL_HEATMAP, label="Pairwise cosine (matplotlib)")
pair_btn.click(basket_pairings, inputs=[sibling, basket, k_pair],
outputs=[nb_table, mode_table, heatmap_plot], show_progress="full")
gr.Examples(
examples=[
["chem", ["chicken","lemon","garlic"], 8],
["core", ["miso","ginger","sesame_oil"], 8],
["chem", ["tomato","basil","mozzarella_cheese"], 8],
["cooc", ["chocolate","strawberry","cream"], 8],
["chem", ["cumin","coriander","turmeric"], 8],
["core", ["soy_sauce","ginger","scallion"], 8],
["chem", ["red_wine","beef","rosemary"], 8],
["core", ["coconut_milk","lemongrass","fish_sauce"], 8],
],
inputs=[sibling, basket, k_pair],
label="Try one of these baskets",
)
# ---------- Tab 2: Supervised SLERP ----------
with gr.Tab("Supervised SLERP", id="tab_sup"):
gr.Markdown("Rotate the seed basket toward one or more supervised pole vectors.")
sup_radio, sup_basket = _ingredient_picker("Seed basket (pick 1+)", ["rice"])
sup_dirs = gr.Dropdown(choices=_supervised_choices("chem"), value=["cuisine:South_Asian"],
label="Supervised directions (pick 1+; summed)",
multiselect=True, max_choices=5)
sup_theta = gr.Slider(0, 90, value=30, step=5, label="Rotation angle (deg)")
sup_k = gr.Slider(1, 15, value=8, step=1, label="K")
with gr.Row():
sup_btn = gr.Button("Rotate", variant="primary")
save_sup_btn = gr.Button("Save this query", variant="secondary")
sup_table = gr.Dataframe(headers=["Ingredient","Cosine"], label="Top-K rotated-query neighbours")
sup_explainer = gr.Markdown()
sup_btn.click(supervised_slerp_multi,
inputs=[sibling, sup_basket, sup_dirs, sup_theta, sup_k],
outputs=[sup_table, sup_explainer], show_progress="full")
sibling.change(lambda s: gr.Dropdown(choices=_supervised_choices(s), value=[]),
inputs=sibling, outputs=sup_dirs)
gr.Examples(
examples=[
["chem", ["rice"], ["cuisine:South_Asian"], 30, 8],
["chem", ["corn"], ["cuisine:Latin_American"], 30, 8],
["core", ["chicken"], ["cuisine:Mediterranean"], 45, 8],
["core", ["tomato","basil"], ["cuisine:Southeast_Asian"], 45, 8],
["chem", ["beef"], ["cuisine:East_Asian"], 60, 8],
["cooc", ["chocolate"], ["cuisine:Latin_American"], 30, 8],
],
inputs=[sibling, sup_basket, sup_dirs, sup_theta, sup_k],
label="Try one of these rotations",
)
# ---------- Tab 3: Emergent SLERP ----------
with gr.Tab("Emergent SLERP", id="tab_em"):
gr.Markdown("Rotate the seed basket toward one or more emergent FastICA factor-mode poles.")
em_radio, em_basket = _ingredient_picker("Seed basket (pick 1+)", ["chocolate"])
factor_opts = _factor_mode_choices("chem")
em_modes = gr.Dropdown(choices=[label for label, _ in factor_opts],
value=[factor_opts[0][0]] if factor_opts else [],
label="Factor modes (pick 1+; summed)", multiselect=True, max_choices=5)
em_theta = gr.Slider(0, 90, value=30, step=5, label="Rotation angle (deg)")
em_k = gr.Slider(1, 15, value=8, step=1, label="K")
with gr.Row():
em_btn = gr.Button("Rotate", variant="primary")
save_em_btn = gr.Button("Save this query", variant="secondary")
em_table = gr.Dataframe(headers=["Ingredient","Cosine"], label="Top-K rotated-query neighbours")
em_explainer = gr.Markdown()
em_btn.click(emergent_slerp_multi,
inputs=[sibling, em_basket, em_modes, em_theta, em_k],
outputs=[em_table, em_explainer], show_progress="full")
sibling.change(lambda s: gr.Dropdown(choices=[label for label, _ in _factor_mode_choices(s)], value=[]),
inputs=sibling, outputs=em_modes)
# ---------- Tab 4: Arithmetic ----------
with gr.Tab("Arithmetic", id="tab_ar"):
gr.Markdown("Mikolov-style vector arithmetic: `centroid(positives) - centroid(negatives)`, then top-K neighbours. Killer demo: `miso - salt` on Core.")
pos_radio, pos_box = _ingredient_picker("Positives (added)", ["miso"])
neg_radio, neg_box = _ingredient_picker("Negatives (subtracted)", ["salt"])
ar_k = gr.Slider(1, 15, value=8, step=1, label="K")
with gr.Row():
ar_btn = gr.Button("Compute", variant="primary")
save_ar_btn = gr.Button("Save this query", variant="secondary")
ar_table = gr.Dataframe(headers=["Ingredient","Cosine"], label="Top-K nearest to result vector")
ar_explainer = gr.Markdown()
ar_btn.click(arithmetic, inputs=[sibling, pos_box, neg_box, ar_k],
outputs=[ar_table, ar_explainer], show_progress="full")
gr.Examples(
examples=[
["core", ["miso"], ["salt"], 8],
["core", ["chicken","tofu"], ["beef"], 8],
["cooc", ["basil","cumin"], ["parsley"], 8],
["chem", ["chocolate"], ["sugar"], 8],
["chem", ["wine"], ["beer"], 8],
["core", ["bread"], ["flour"], 8],
["core", ["coffee"], ["milk"], 8],
["chem", ["mozzarella_cheese"], ["milk"], 8],
],
inputs=[sibling, pos_box, neg_box, ar_k],
label="Try one of these arithmetic queries",
)
# ---------- Tab 5: Mode atlas (click row -> UMAP) ----------
with gr.Tab("Mode atlas", id="tab_atlas"):
gr.Markdown(
"Browse the GMM mode atlas. Cooc 150 / Core 193 / Chem 200 modes. "
"**Click any row** to send that mode's members to the UMAP tab as a basket."
)
atlas_kind = gr.Radio(choices=["all","factor","continuous","binary"], value="all", label="Mode kind")
atlas_search = gr.Textbox(label="Search labels / properties", placeholder="e.g. South Asian, baking, fiber", value="")
atlas_btn = gr.Button("Browse modes", variant="primary")
atlas_table = gr.Dataframe(
headers=["mode_id","kind","property","label","n_members","top members"],
label="Modes (click a row to highlight on UMAP)",
wrap=True, interactive=False,
)
atlas_btn.click(browse_modes, inputs=[sibling, atlas_kind, atlas_search], outputs=atlas_table, show_progress="full")
# ---------- Tab 6: Compare siblings ----------
with gr.Tab("Compare siblings", id="tab_cmp"):
gr.Markdown("Same query, three siblings, side by side.")
cmp_radio, cmp_basket = _ingredient_picker("Seed basket", ["chicken"])
cmp_dirs = gr.Dropdown(choices=_supervised_choices("chem"), value=[],
label="Optional directions (empty = pure pairings)",
multiselect=True, max_choices=5)
cmp_theta = gr.Slider(0, 90, value=30, step=5, label="Rotation angle (deg)")
cmp_k = gr.Slider(1, 15, value=8, step=1, label="K")
with gr.Row():
cmp_btn = gr.Button("Compare across siblings", variant="primary")
save_cmp_btn = gr.Button("Save this query", variant="secondary")
with gr.Row():
cmp_cooc = gr.Dataframe(headers=["Cooc neighbour","Cosine"], label="Cooc (recipe-context)")
cmp_core = gr.Dataframe(headers=["Core neighbour","Cosine"], label="Core (blended)")
cmp_chem = gr.Dataframe(headers=["Chem neighbour","Cosine"], label="Chem (chemistry)")
cmp_btn.click(compare_siblings, inputs=[cmp_basket, cmp_dirs, cmp_theta, cmp_k],
outputs=[cmp_cooc, cmp_core, cmp_chem], show_progress="full")
# ---------- Tab 7: UMAP ----------
with gr.Tab("UMAP visualisation", id="tab_umap"):
gr.Markdown(
"2-D UMAP of the 1,790-ingredient embedding (cosine, n_neighbors=30, min_dist=0.03). "
"Points coloured by food group. Basket members appear as accent stars; top-K neighbours as amber dots."
)
umap_radio, umap_basket = _ingredient_picker("Highlight these ingredients", ["chicken","lemon","garlic"])
with gr.Row():
umap_show_nb = gr.Checkbox(value=True, label="Show top-K neighbours of basket centroid")
umap_3d = gr.Checkbox(value=False, label="3-D perspective (UMAP + PC1)")
umap_k = gr.Slider(1, 20, value=10, step=1, label="K neighbours")
umap_btn = gr.Button("Update plot", variant="primary")
umap_plot = gr.Plot(value=_INITIAL_UMAP, label="UMAP")
umap_btn.click(umap_view, inputs=[sibling, umap_basket, umap_show_nb, umap_k, umap_3d],
outputs=umap_plot, show_progress="full")
sibling.change(umap_view, inputs=[sibling, umap_basket, umap_show_nb, umap_k, umap_3d],
outputs=umap_plot)
# ---------- Tab 8: Parse my fridge ----------
with gr.Tab("Parse my fridge", id="tab_fridge"):
gr.Markdown(
"Paste a free-text ingredient list. Quantities, units, and prep notes are stripped, "
"then each line is fuzzy-matched to canonical vocab. Click **Send matched to Basket tab** "
"to populate the Basket Pairings input."
)
fridge_text = gr.Textbox(
label="Free-text ingredients (one per line or semicolon-separated)",
lines=8,
value=("2 boneless chicken thighs\n1 cup coconut milk\n1 tbsp fish sauce (or soy sauce)\n"
"fresh lemongrass, bruised\n3 cloves garlic, minced\n1 inch fresh ginger\n"
"juice of one lime\nsalt to taste"),
)
fridge_min = gr.Slider(40, 100, value=70, step=5, label="Min match score (rapidfuzz)")
with gr.Row():
fridge_btn = gr.Button("Parse and match", variant="primary")
fridge_send = gr.Button("Send matched to Basket tab", variant="secondary")
fridge_table = gr.Dataframe(
headers=["Input line", "Canonical match", "Score", "Cleaned"],
label="Parsed matches", interactive=False,
)
fridge_matched = gr.Textbox(label="Matched ingredients", interactive=False)
def _parse(txt, sib, mn):
rows, matches = parse_fridge(txt, sib, int(mn))
return rows, ", ".join(matches), matches
fridge_btn.click(_parse, inputs=[fridge_text, sibling, fridge_min],
outputs=[fridge_table, fridge_matched, shared_basket], show_progress="full")
fridge_send.click(lambda matches: gr.Dropdown(value=matches[:10] if matches else []),
inputs=[shared_basket], outputs=[basket])
# ---------- Tab 9: Recipe builder ----------
with gr.Tab("Recipe builder", id="tab_recipe"):
gr.Markdown(
"Describe a dish in plain English. Hybrid retrieval: rapidfuzz token matching for direct "
"ingredient mentions + sentence-transformer cosine against the sibling's factor-mode labels "
"for thematic matches. First call after Space cold-start downloads ~80MB encoder (one-time)."
)
rb_prompt = gr.Textbox(label="Dish description", lines=3,
value="I'm making Thai green curry for 4 people")
rb_k = gr.Slider(4, 20, value=10, step=1, label="Suggestions (K)")
rb_btn = gr.Button("Suggest starter basket", variant="primary")
rb_table = gr.Dataframe(
headers=["Ingredient", "Source", "Score"],
label="Suggested basket (source = direct / thematic / both)", interactive=False,
)
rb_explainer = gr.Markdown()
rb_matched = gr.State([])
rb_send = gr.Button("Send to Basket tab", variant="secondary")
def _rb(prompt, sib, k):
rows, names, md = suggest_basket(prompt, sib, int(k))
return rows, md, names
rb_btn.click(_rb, inputs=[rb_prompt, sibling, rb_k],
outputs=[rb_table, rb_explainer, rb_matched], show_progress="full")
rb_send.click(lambda names: gr.Dropdown(value=(names or [])[:10]),
inputs=[rb_matched], outputs=[basket])
gr.Examples(
examples=[
["I'm making Thai green curry for 4 people", 10],
["spicy vegetarian taco filling", 10],
["weeknight pasta with tomatoes and herbs", 10],
["Japanese miso-glazed salmon and greens", 10],
["Moroccan tagine with lamb and dried fruit", 10],
],
inputs=[rb_prompt, rb_k],
label="Try one of these prompts",
)
# ---------- Tab 10: Saved queries ----------
with gr.Tab("Saved queries", id="tab_saved"):
gr.Markdown(
"Stored locally in your browser via `localStorage` (gr.BrowserState). "
"~5 MB quota; per-browser, not per-account. Clearing browser data wipes them."
)
saved_table = gr.Dataframe(
headers=["created_at", "tab", "summary", "id"],
label="Your saved queries (newest first)", interactive=False, wrap=True,
)
with gr.Row():
selected_id = gr.State("")
del_btn = gr.Button("Delete selected", variant="secondary")
def _on_select(saved, evt: gr.SelectData):
if evt is None or evt.index is None:
return ""
row = evt.index[0] if isinstance(evt.index, (list, tuple)) else evt.index
return (saved or [{}])[row].get("id", "") if row < len(saved or []) else ""
saved_table.select(_on_select, inputs=[saved_state], outputs=selected_id)
del_btn.click(delete_query, inputs=[saved_state, selected_id],
outputs=[saved_state, saved_table])
demo.load(lambda s: _render_saved(s), inputs=saved_state, outputs=saved_table)
# ---- Wire Save buttons (after all tabs exist so all components are in scope) ----
save_basket_btn.click(
lambda s, sib, b, k: save_query(s, "basket",
{"sibling": sib, "basket": b or [], "k": int(k)}),
inputs=[saved_state, sibling, basket, k_pair],
outputs=[saved_state, saved_table],
)
save_sup_btn.click(
lambda s, sib, b, d, th, k: save_query(s, "supervised_slerp",
{"sibling": sib, "basket": b or [], "directions": d or [], "theta": float(th), "k": int(k)}),
inputs=[saved_state, sibling, sup_basket, sup_dirs, sup_theta, sup_k],
outputs=[saved_state, saved_table],
)
save_em_btn.click(
lambda s, sib, b, m, th, k: save_query(s, "emergent_slerp",
{"sibling": sib, "basket": b or [], "modes": m or [], "theta": float(th), "k": int(k)}),
inputs=[saved_state, sibling, em_basket, em_modes, em_theta, em_k],
outputs=[saved_state, saved_table],
)
save_ar_btn.click(
lambda s, sib, p, n, k: save_query(s, "arithmetic",
{"sibling": sib, "positives": p or [], "negatives": n or [], "k": int(k)}),
inputs=[saved_state, sibling, pos_box, neg_box, ar_k],
outputs=[saved_state, saved_table],
)
save_cmp_btn.click(
lambda s, b, d, th, k: save_query(s, "compare",
{"basket": b or [], "directions": d or [], "theta": float(th), "k": int(k)}),
inputs=[saved_state, cmp_basket, cmp_dirs, cmp_theta, cmp_k],
outputs=[saved_state, saved_table],
)
# ---- Mode atlas row click -> UMAP highlight + jump to UMAP tab ----
def atlas_row_to_umap(sibling_value, table_value, show_nb, k_value, three_d_value, evt: gr.SelectData):
if evt is None or evt.index is None or table_value is None:
return gr.update(), gr.update(), gr.update(), gr.update()
row = evt.index[0] if isinstance(evt.index, (list, tuple)) else evt.index
try:
clicked_mode_id = (table_value.iloc[row, 0] if hasattr(table_value, "iloc")
else table_value[row][0])
except Exception:
return gr.update(), gr.update(), gr.update(), gr.update()
m = MODELS[sibling_value]
mode = next((md for md in m.modes if md.mode_id == clicked_mode_id), None)
if mode is None:
return gr.update(), gr.update(), gr.update(), gr.update()
members = [n for n in mode.members if n in m.vocab][:10]
if not members:
return gr.update(), gr.update(), gr.update(), gr.update()
fig = umap_view(sibling_value, members, bool(show_nb), int(k_value), three_d=bool(three_d_value))
return (
gr.Dropdown(value=members),
fig,
members,
gr.Tabs(selected="tab_umap"),
)
atlas_table.select(
atlas_row_to_umap,
inputs=[sibling, atlas_table, umap_show_nb, umap_k, umap_3d],
outputs=[umap_basket, umap_plot, shared_basket, tabs],
show_progress="hidden",
)
# ---- Public API endpoints ----
gr.api(api_neighbors, api_name="neighbors")
gr.api(api_slerp, api_name="slerp")
gr.api(api_arithmetic, api_name="arithmetic")
gr.api(api_embed, api_name="embed")
gr.api(api_list_directions, api_name="list_directions")
gr.api(api_list_factor_modes, api_name="list_factor_modes")
gr.Markdown(
"""---
### Developer API
These operators are also exposed as JSON endpoints. See `/?view=api` for the auto-generated schema.
```python
from gradio_client import Client
c = Client("Kaikaku/epicure-explorer")
c.predict("garlic", "chem", 5, api_name="/neighbors")
c.predict("rice", "cuisine:South_Asian", 30, "chem", 5, api_name="/slerp")
c.predict(["miso"], ["salt"], "core", 8, api_name="/arithmetic")
c.predict("garlic", "chem", api_name="/embed") # 300-D L2-normalised vector
c.predict("chem", api_name="/list_directions")
c.predict("chem", api_name="/list_factor_modes")
```
Endpoints validate inputs and return `{"error": "...", "suggestions": [...]}` on bad input. Free-tier limits: ~1-2 req/sec shared, no auth, Space sleeps after ~48h idle (cold start ~30-60s on next request).
---
**Cite:** Radzikowski and Chen, 2026, *Epicure: Navigating the Emergent Geometry of Food Ingredient Embeddings*, [arXiv:2605.22391](https://arxiv.org/abs/2605.22391). Artefacts: [epicure-cooc](https://huggingface.co/Kaikaku/epicure-cooc) | [epicure-core](https://huggingface.co/Kaikaku/epicure-core) | [epicure-chem](https://huggingface.co/Kaikaku/epicure-chem) | [corpus dataset](https://huggingface.co/datasets/Kaikaku/epicure-corpus-resources)
"""
)
if __name__ == "__main__":
demo.launch()