"""Epicure Explorer: chef-facing operators over the three sibling embeddings.""" from __future__ import annotations import os import re import sys import json import numpy as np import gradio as gr import plotly.graph_objects as go import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt from matplotlib.patches import Patch 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" # darker teal-green - readable on white KAIKAKU_ACCENT_HOVER = "#1E6E5F" KAIKAKU_ACCENT_LIGHT = "#A8D5CA" # background tints only KAIKAKU_MINT = KAIKAKU_ACCENT # backwards-compat aliases used elsewhere KAIKAKU_MINT_BRIGHT = KAIKAKU_ACCENT_HOVER KAIKAKU_TEXT = "#0F2D2F" KAIKAKU_MUTED = "#5A7878" # Light matplotlib defaults; mint is an accent only 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", } # Sanity-check log on import so Space logs show whether assets loaded print(f"[epicure-explorer] models loaded: {list(MODELS)}", flush=True) print(f"[epicure-explorer] UMAP shapes: {{cooc:{UMAP_DATA['cooc'].shape}, core:{UMAP_DATA['core'].shape}, chem:{UMAP_DATA['chem'].shape}}}", flush=True) print(f"[epicure-explorer] food group labels: {len(FOOD_GROUPS)} ingredients, " f"{sum(1 for fg in FOOD_GROUPS if fg != 'Other')} with concrete group", flush=True) # ===== 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) # ===== heatmap (matplotlib, reliable) ===== 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 (Plotly, SINGLE TRACE, bulletproof) ===== 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) # Pre-compute marker colors and hover text per ingredient colors = [FG_COLORS.get(fg, KAIKAKU_MUTED) 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[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} # SINGLE background trace: all 1790 points coloured by food group. # One trace beats N traces for reliability in gr.Plot. bg_x = [float(coords2[i, 0]) for i in range(n) if NAMES_BY_IDX[i] not in basket_set and NAMES_BY_IDX[i] not in neighbour_set] bg_y = [float(coords2[i, 1]) for i in range(n) if NAMES_BY_IDX[i] not in basket_set and NAMES_BY_IDX[i] not in neighbour_set] bg_z = [float(z[i]) for i in range(n) if NAMES_BY_IDX[i] not in basket_set and NAMES_BY_IDX[i] not in neighbour_set] if three_d else None bg_c = [colors[i] for i in range(n) if NAMES_BY_IDX[i] not in basket_set and NAMES_BY_IDX[i] not in neighbour_set] bg_h = [hover_text[i] for i in range(n) if NAMES_BY_IDX[i] not in basket_set and NAMES_BY_IDX[i] not in neighbour_set] 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}", 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}", name="ingredients", showlegend=False, )) # Neighbour highlights (amber) 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="%{text} (neighbour)", 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)) # Basket highlights (mint star, accent only) 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_MINT, 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="%{text} (basket)", 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 ===== 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 [] 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)] q = _slerp(v, d, theta) return [[n, f"{s:.4f}"] for n, s in _topk(m, q, k, basket)] 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 [] 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)] q = _slerp(v, d, theta) return [[n, f"{s:.4f}"] for n, s in _topk(m, q, k, basket)] def arithmetic(sibling, positives, negatives, k): m = MODELS[sibling] pos = _basket_centroid(m, positives) if pos is None: return [] neg = _basket_centroid(m, negatives) if negatives else None q = _unit(pos - neg) if neg is not None else pos return [[n, f"{s:.4f}"] for n, s in _topk(m, q, k, (positives or []) + (negatives or []))] 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] # ===== fridge parser ===== _LINE_SPLIT = re.compile(r"[\n;]") _BRACKET = re.compile(r"\([^)]*\)") _QTY = (r"(?:\d+(?:[\.,/]\d+)?|" r"a|an|one|two|three|four|five|six|seven|eight|nine|ten|half|quarter)") _UNIT = (r"(?:cups?|tbsp\.?|tablespoons?|tsp\.?|teaspoons?|" r"oz\.?|ounces?|lbs?\.?|pounds?|grams?|kgs?|kilos?|" r"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 # ===== UI ===== # Theme: light background, BLACK text everywhere, accent color reserved for # interactive UI (buttons, sliders, focused borders) and brand cues. 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( # Force readable label/title text instead of letting Soft tint them with primary 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", # Primary button: dark accent + white text -> high contrast 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;}} /* Make sure NOTHING uses the faded light-mint label color */ .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; }} /* Tab labels readable */ .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; }} /* Primary button: dark accent + white text */ .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; }} /* Dataframe headers: black, bold, readable */ .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 */ .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;}} """ # Precompute initial figures so plots are populated on first page load _INITIAL_UMAP = umap_view("chem", ["chicken","lemon","garlic"], True, 8, three_d=False) _INITIAL_HEATMAP = _basket_heatmap(MODELS["chem"], ["chicken","lemon","garlic"]) SIBLING_CARDS = """
Cooc - recipe-context only
Walks recipe co-occurrence (NPMI graph) only. Neighbours are recipe companions: things that get cooked with the seed. Isotropic geometry (PR=173.6 of 300). Best for "what else do I cook with X".
Core - blended (the middle ground)
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.
Chem - chemistry only
Typed FlavorDB compound metapaths only (ii_repeat=0). Neighbours are flavour-profile peers: things that share aroma chemistry with the seed. Best supervised-direction recovery; cuisine Cohen's d = 3.07 across 8 macro-regions.
""" with gr.Blocks(title="Epicure Explorer", theme=THEME, css=CUSTOM_CSS) as demo: gr.Markdown( f"""# 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([]) # ---------- Tab 1: Basket pairings + heatmap ---------- with gr.Tab("Basket pairings"): gr.Markdown( "Pick one or more ingredients. Tool averages their unit vectors and returns nearest neighbours " "plus closest modes of that centroid. The heatmap shows whether the basket is coherent." ) basket = gr.Dropdown( choices=ALL_INGREDIENTS, value=["chicken","lemon","garlic"], label="Ingredient basket (pick 1+)", multiselect=True, max_choices=10, ) k_pair = gr.Slider(1, 15, value=8, step=1, label="K") pair_btn = gr.Button("Find pairings", variant="primary") 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"): gr.Markdown("Rotate the seed basket toward one or more supervised direction poles. Multiple directions are summed.") sup_basket = gr.Dropdown(choices=ALL_INGREDIENTS, value=["rice"], label="Seed basket (pick 1+)", multiselect=True, max_choices=10) sup_dirs = gr.Dropdown(choices=_supervised_choices("chem"), value=["cuisine:South_Asian"], label="Supervised directions (pick 1+)", 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") sup_btn = gr.Button("Rotate", variant="primary") sup_table = gr.Dataframe(headers=["Ingredient","Cosine"], label="Top-K rotated-query neighbours") sup_btn.click(supervised_slerp_multi, inputs=[sibling, sup_basket, sup_dirs, sup_theta, sup_k], outputs=sup_table, 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"): gr.Markdown("Rotate the seed basket toward one or more emergent FastICA factor-mode poles.") em_basket = gr.Dropdown(choices=ALL_INGREDIENTS, value=["chocolate"], label="Seed basket (pick 1+)", multiselect=True, max_choices=10) 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+)", 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") em_btn = gr.Button("Rotate", variant="primary") em_table = gr.Dataframe(headers=["Ingredient","Cosine"], label="Top-K rotated-query neighbours") em_btn.click(emergent_slerp_multi, inputs=[sibling, em_basket, em_modes, em_theta, em_k], outputs=em_table, 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"): gr.Markdown("Mikolov-style vector arithmetic: `centroid(positives) - centroid(negatives)`, then top-K neighbours.") pos_box = gr.Dropdown(choices=ALL_INGREDIENTS, value=["miso"], label="Positives", multiselect=True, max_choices=10) neg_box = gr.Dropdown(choices=ALL_INGREDIENTS, value=["salt"], label="Negatives", multiselect=True, max_choices=10) ar_k = gr.Slider(1, 15, value=8, step=1, label="K") ar_btn = gr.Button("Compute", variant="primary") ar_table = gr.Dataframe(headers=["Ingredient","Cosine"], label="Top-K nearest to result vector") ar_btn.click(arithmetic, inputs=[sibling, pos_box, neg_box, ar_k], outputs=ar_table, 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 ---------- with gr.Tab("Mode atlas"): gr.Markdown("Browse the GMM mode atlas. Cooc 150 / Core 193 / Chem 200 modes.") 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 (sorted by kind, then size descending)", 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"): gr.Markdown("Same query, three siblings, side by side. The spectrum-of-models thesis in one screen.") cmp_basket = gr.Dropdown(choices=ALL_INGREDIENTS, value=["chicken"], label="Seed basket", multiselect=True, max_choices=10) 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") cmp_btn = gr.Button("Compare across siblings", variant="primary") 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 visualisation ---------- with gr.Tab("UMAP visualisation"): gr.Markdown( "2-D UMAP of the 1,790-ingredient embedding (cosine, n_neighbors=30, min_dist=0.03 -- paper Figure 1). " "Points coloured by food group. Basket members appear as mint stars; top-K neighbours as amber dots." ) umap_basket = gr.Dropdown(choices=ALL_INGREDIENTS, value=["chicken","lemon","garlic"], label="Highlight these ingredients", multiselect=True, max_choices=10) 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"): 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") def _send_to_basket(matches): return gr.Dropdown(value=matches[:10] if matches else []) fridge_send.click(_send_to_basket, inputs=[shared_basket], outputs=[basket]) 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). 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()