Spaces:
Running
Running
| """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]}<br>group: {FOOD_GROUPS[i]}" for i in range(n)] | |
| basket_set = set(basket or []) | |
| basket_idxs = [m.vocab[b] for b in (basket or []) if b in m.vocab] | |
| neighbour_set = set() | |
| 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}<extra></extra>", name="ingredients", showlegend=False)) | |
| else: | |
| fig.add_trace(go.Scattergl(x=bg_x, y=bg_y, mode="markers", | |
| marker=dict(size=5, color=bg_c, opacity=0.65), text=bg_h, | |
| hovertemplate="%{text}<extra></extra>", name="ingredients", showlegend=False)) | |
| if neighbour_set: | |
| ni = [i for i in range(n) if NAMES_BY_IDX[i] in neighbour_set] | |
| nx = [float(coords2[i, 0]) for i in ni]; ny = [float(coords2[i, 1]) for i in ni] | |
| nz = [float(z[i]) for i in ni] if three_d else None | |
| 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="<b>%{text}</b> (neighbour)<extra></extra>", | |
| 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="<b>%{text}</b> (basket)<extra></extra>", 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 | |
| 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 "<p style='color:#888'>Pick an ingredient.</p>", 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 = "<div style='color:#94a3b8;font-style:italic;padding:8px'>(not in vocab)</div>" | |
| else: | |
| q = _unit(m.E[m.vocab[name]]) | |
| items = [] | |
| for nb, sim in _topk(m, q, 5, exclude=[name]): | |
| items.append( | |
| f"<div style='display:flex;justify-content:space-between;padding:5px 0;" | |
| f"border-bottom:1px solid #e2e8f0'>" | |
| f"<span style='color:#0f172a;font-weight:500'>{nb.replace('_', ' ')}</span>" | |
| f"<span style='color:{KAIKAKU_ACCENT};font-family:monospace;font-weight:600'>{sim:.3f}</span>" | |
| f"</div>" | |
| ) | |
| rows_html = "".join(items) | |
| nb_blocks.append( | |
| f"<div style='flex:1;min-width:0;background:#f8fafc;border:1px solid #e2e8f0;" | |
| f"border-radius:10px;padding:14px 16px'>" | |
| f"<div style='color:{KAIKAKU_ACCENT};font-family:monospace;font-size:0.78em;" | |
| f"font-weight:700;margin-bottom:10px;letter-spacing:0.04em'>{sib.upper()} · NEAREST NEIGHBOURS</div>" | |
| f"{rows_html}</div>" | |
| ) | |
| # 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"<div style='display:grid;grid-template-columns:140px 1fr 60px;gap:10px;" | |
| f"align-items:center;padding:5px 0'>" | |
| f"<span style='color:#0f172a;font-weight:500;font-size:0.92em'>{label}</span>" | |
| f"<div style='background:#e2e8f0;border-radius:6px;height:14px;position:relative;overflow:hidden'>" | |
| f"<div style='background:{bar_color};height:100%;width:{pct:.1f}%;border-radius:6px'></div>" | |
| f"</div>" | |
| f"<span style='color:{KAIKAKU_ACCENT};font-family:monospace;text-align:right;" | |
| f"font-weight:600;font-size:0.88em'>{val:+.3f}</span>" | |
| f"</div>" | |
| ) | |
| cuisine_html = "".join(cuisine_rows) | |
| else: | |
| cuisine_html = "<div style='color:#94a3b8;font-style:italic'>(not in chem vocab)</div>" | |
| # 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"<div style='background:#f8fafc;border:1px solid #e2e8f0;border-radius:10px;" | |
| f"padding:14px 16px;margin-bottom:10px'>" | |
| f"<div style='display:flex;justify-content:space-between;align-items:baseline;margin-bottom:6px'>" | |
| f"<div><span style='color:{KAIKAKU_ACCENT};font-family:monospace;font-size:0.78em;" | |
| f"font-weight:700;margin-right:10px;letter-spacing:0.04em'>{sib.upper()}</span>" | |
| f"<span style='color:#0f172a;font-weight:600;font-size:1.05em'>{md.label}</span></div>" | |
| f"<span style='color:{KAIKAKU_ACCENT};font-family:monospace;font-weight:600'>cos {sim:.3f}</span>" | |
| f"</div>" | |
| f"<div style='color:#475569;font-size:0.88em;line-height:1.4'>{members}</div>" | |
| f"</div>" | |
| ) | |
| html = f""" | |
| <div style='max-width:1180px;margin:0 auto'> | |
| <div style='border-bottom:3px solid {KAIKAKU_ACCENT};padding-bottom:8px;margin-bottom:18px'> | |
| <div style='font-size:2.2em;font-weight:800;color:#0f172a;letter-spacing:-0.02em;line-height:1.0'>{pretty}</div> | |
| <div style='color:{KAIKAKU_ACCENT};font-family:monospace;font-size:0.82em;font-weight:600; | |
| margin-top:6px;letter-spacing:0.04em'> | |
| INGREDIENT PASSPORT · FOOD GROUP: {group.upper()} | |
| </div> | |
| </div> | |
| <div style='display:flex;gap:14px;margin-bottom:18px;flex-wrap:wrap'>{''.join(nb_blocks)}</div> | |
| <div style='background:#f8fafc;border:1px solid #e2e8f0;border-radius:10px; | |
| padding:14px 18px;margin-bottom:18px'> | |
| <div style='color:{KAIKAKU_ACCENT};font-family:monospace;font-size:0.78em; | |
| font-weight:700;margin-bottom:10px;letter-spacing:0.04em'>CUISINE AFFILIATION (chem)</div> | |
| {cuisine_html} | |
| </div> | |
| <div> | |
| <div style='color:{KAIKAKU_ACCENT};font-family:monospace;font-size:0.78em; | |
| font-weight:700;margin-bottom:10px;letter-spacing:0.04em'> | |
| CLOSEST EMERGENT FACTOR MODES (top 3 across siblings) | |
| </div> | |
| {''.join(mode_rows) if mode_rows else "<div style='color:#94a3b8'>(no modes)</div>"} | |
| </div> | |
| </div> | |
| """ | |
| 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"<b>{NAMES_BY_IDX[i]}</b><br>closest cuisine: {cuisine_pole_keys[best[i]].replace('_',' ')}<br>group: {FOOD_GROUPS[i]}" | |
| for i in range(n) | |
| ] | |
| else: | |
| colors = [FG_COLORS.get(fg, "#cccccc") for fg in FOOD_GROUPS] | |
| hover_text = [f"<b>{NAMES_BY_IDX[i]}</b><br>group: {FOOD_GROUPS[i]}" for i in range(n)] | |
| basket_set = set(basket or []) | |
| basket_idxs = [m.vocab[b] for b in (basket or []) if b in m.vocab] | |
| neighbour_set = set() | |
| 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}<extra></extra>", | |
| 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="<b>%{text}</b> (neighbour)<extra></extra>", | |
| 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="<b>%{text}</b> (basket)<extra></extra>", | |
| name="basket", | |
| )) | |
| fig.update_layout( | |
| title=dict( | |
| text=f"<b>3D Atlas</b> · 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}<br>cos = %{r:.3f}<extra>" + ing + "</extra>", | |
| )) | |
| 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 = """ | |
| <div class="spectrum-bar"> | |
| <div class="spectrum-cell spectrum-cell-1"> | |
| <div class="spectrum-name">Cooc</div> | |
| <div class="spectrum-sub">recipe co-occurrence; neighbours = recipe companions</div> | |
| </div> | |
| <div class="spectrum-arrow">→</div> | |
| <div class="spectrum-cell spectrum-cell-2"> | |
| <div class="spectrum-name">Core</div> | |
| <div class="spectrum-sub">blended; concentrated geometry; tightest emergent modes</div> | |
| </div> | |
| <div class="spectrum-arrow">→</div> | |
| <div class="spectrum-cell spectrum-cell-3"> | |
| <div class="spectrum-name">Chem</div> | |
| <div class="spectrum-sub">FlavorDB compound metapaths; neighbours = flavour-profile peers</div> | |
| </div> | |
| </div> | |
| """ | |
| # ===== 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="<div style='padding:20px;color:#64748b'>Pick an ingredient above and click <b>Generate passport</b>.</div>") | |
| with gr.Column(scale=1): | |
| gr.Markdown(f"<div style='color:{KAIKAKU_ACCENT};font-family:monospace;" | |
| f"font-size:0.78em;font-weight:700;letter-spacing:0.04em'>SENSORY RADAR</div>") | |
| 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) | |