"""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"
" 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)