josefchen commited on
Commit
56c3b09
verified
1 Parent(s): ec40f89

Simplify UI: 10 tabs -> 4 (Explore/Transform/Map/From text). Spectrum bar. Killer demo pre-rendered. Substring search defaults.

Browse files
Files changed (2) hide show
  1. __pycache__/app.cpython-310.pyc +0 -0
  2. app.py +464 -835
__pycache__/app.cpython-310.pyc CHANGED
Binary files a/__pycache__/app.cpython-310.pyc and b/__pycache__/app.cpython-310.pyc differ
 
app.py CHANGED
@@ -1,30 +1,17 @@
1
- """Epicure Explorer: chef-facing operators over the three sibling embeddings.
2
-
3
- Features:
4
- - Basket pairings (with pairwise cosine heatmap)
5
- - Supervised SLERP (with "why these results" explainer)
6
- - Emergent SLERP (with explainer)
7
- - Arithmetic (Mikolov-style, with explainer)
8
- - Mode atlas (click row -> highlight on UMAP)
9
- - Compare siblings (one query, three columns)
10
- - UMAP visualisation (2D / 3D)
11
- - Parse my fridge (free-text -> canonical vocab via rapidfuzz)
12
- - Recipe builder (hybrid retrieval: rapidfuzz + sentence-transformers over mode labels)
13
- - Saved queries (per-browser persistence via gr.BrowserState)
14
- - Public developer API (gr.api endpoints for neighbours / slerp / arithmetic / embed)
15
- - Food-group filter on every ingredient dropdown
16
 
17
  Paper: https://arxiv.org/abs/2605.22391
18
  """
19
 
20
  from __future__ import annotations
21
 
22
- import os
23
- import re
24
- import sys
25
- import json
26
- import uuid
27
- from datetime import datetime, timezone
28
  from functools import lru_cache
29
 
30
  import numpy as np
@@ -45,25 +32,19 @@ except ImportError:
45
  from rapidfuzz import process as fuzz_process, fuzz as fuzz_scorers
46
 
47
  # ===== Kaikaku brand =====
48
- KAIKAKU_DARK = "#0F2D2F"
49
- KAIKAKU_DEEP = "#0A1F20"
50
- KAIKAKU_MID = "#1A3D3F"
51
- KAIKAKU_EDGE = "#2A4D4F"
52
- KAIKAKU_ACCENT = "#288B79"
53
  KAIKAKU_ACCENT_HOVER = "#1E6E5F"
54
  KAIKAKU_ACCENT_LIGHT = "#A8D5CA"
55
- KAIKAKU_TEXT = "#0F2D2F"
56
- KAIKAKU_MUTED = "#5A7878"
57
 
58
  plt.rcParams.update({
59
- "figure.facecolor": "#ffffff",
60
- "axes.facecolor": "#ffffff",
61
- "axes.edgecolor": "#cccccc",
62
- "axes.labelcolor": "#111111",
63
- "xtick.color": "#333333",
64
- "ytick.color": "#333333",
65
- "text.color": "#111111",
66
- "savefig.facecolor": "#ffffff",
67
  })
68
 
69
  MODELS = {
@@ -77,33 +58,25 @@ _HERE = os.path.dirname(os.path.abspath(__file__))
77
  UMAP_DATA = np.load(os.path.join(_HERE, "umap_2d.npz"))
78
  _lab = json.load(open(os.path.join(_HERE, "ingredient_labels.json")))
79
  NAMES_BY_IDX: list[str] = _lab["names"]
80
- FOOD_GROUPS: list[str] = _lab["food_groups"]
81
 
82
  FG_COLORS = {
83
- "Vegetable": "#2ca02c",
84
- "Fruit": "#e377c2",
85
- "Grain": "#bcbd22",
86
- "Dairy": "#17becf",
87
- "Spice": "#d62728",
88
- "Pantry": "#ff7f0e",
89
- "Beverage": "#9467bd",
90
- "Other": "#cccccc",
91
  }
92
 
93
  print(f"[epicure-explorer] models loaded: {list(MODELS)}", flush=True)
94
- print(f"[epicure-explorer] food group labels: {len(FOOD_GROUPS)} ingredients", flush=True)
95
-
96
- # ===== Feature 5: food-group filter helpers =====
97
 
98
- _NAME_TO_GROUP: dict[str, str] = {NAMES_BY_IDX[i]: FOOD_GROUPS[i] for i in range(len(NAMES_BY_IDX))}
99
- FOOD_GROUP_CHOICES = ["All", "Vegetable", "Spice", "Fruit", "Dairy", "Grain", "Pantry", "Beverage", "Other"]
 
100
 
101
- def _choices_for_group(group: str) -> list[str]:
102
  if not group or group == "All":
103
  return ALL_INGREDIENTS
104
  return sorted(n for n in ALL_INGREDIENTS if _NAME_TO_GROUP.get(n, "Other") == group)
105
 
106
- def _filter_dropdown(group: str, current_value):
107
  new_choices = _choices_for_group(group)
108
  allowed = set(new_choices)
109
  cur = current_value or []
@@ -113,7 +86,7 @@ def _filter_dropdown(group: str, current_value):
113
  kept = [v for v in cur if v in allowed]
114
  return gr.Dropdown(choices=new_choices, value=kept)
115
 
116
- # ===== math helpers =====
117
 
118
  def _unit(v, eps=1e-9):
119
  n = np.linalg.norm(v); return v / max(n, eps)
@@ -155,87 +128,29 @@ def _slerp(v, d, theta_deg):
155
  if n < 1e-9: return v
156
  d_perp = d_perp / n
157
  th = np.deg2rad(float(theta_deg))
158
- return _unit(np.cos(th)*v + np.sin(th)*d_perp)
159
-
160
- # ===== Feature 4: explainer helpers =====
161
-
162
- def _fmt_nb_inline(pairs):
163
- return ", ".join(f"{n} ({s:+.2f})" for n, s in pairs)
164
 
165
- def _slerp_explainer(m, basket, direction_keys, theta, q, v, d, kind):
166
- if v is None or d is None or q is None:
167
- return "_(no rotation applied)_"
168
- cos_theta = float(q @ v)
169
- travelled = min(max(float(theta) / 90.0, 0.0), 1.0)
170
- dir_nb = _topk(m, _unit(d), k=5, exclude=basket or [])
171
- seed_nb = _topk(m, v, k=3, exclude=basket or [])
172
- dir_names = ", ".join(n for n, _ in dir_nb[:3])
173
- label = "direction pole" if kind == "supervised" else "factor-mode pole"
174
- dirs_str = " + ".join(direction_keys) if direction_keys else "(none)"
175
- return (
176
- f"**Why these results** \n"
177
- f"- Rotated query vs. seed centroid: cos = {cos_theta:.3f} (theta = {float(theta):.0f}掳; "
178
- f"{travelled*100:.0f}% of the way to the {label}). \n"
179
- f"- {label.capitalize()} ({dirs_str}) nearest in vocab: {_fmt_nb_inline(dir_nb)}. \n"
180
- f"- Seed basket's own top-3 (baseline): {_fmt_nb_inline(seed_nb)}. \n"
181
- f"- At {float(theta):.0f}掳 the query lands near: {dir_names}."
182
- )
183
-
184
- def _arithmetic_explainer(m, positives, negatives, q, pos_v, neg_v):
185
- if q is None:
186
- return "_(no result: missing positives)_"
187
- pos_sims = [(n, float(_unit(m.E[m.vocab[n]]) @ q)) for n in (positives or []) if n in m.vocab]
188
- neg_sims = [(n, float(_unit(m.E[m.vocab[n]]) @ q)) for n in (negatives or []) if n in m.vocab]
189
- top = _topk(m, q, k=1, exclude=(positives or []) + (negatives or []))
190
- top_name, top_sim = top[0] if top else ("(none)", 0.0)
191
- pos_part = ", ".join(f"{n} ({s:+.2f})" for n, s in pos_sims) or "(none)"
192
- neg_part = ", ".join(f"{n} ({s:+.2f})" for n, s in neg_sims) or "(none)"
193
- input_max = max((s for _, s in pos_sims + neg_sims), default=0.0)
194
- if pos_sims or neg_sims:
195
- gap = top_sim - input_max
196
- if gap > 0.05:
197
- interp = (f"Result sits closer to **{top_name}** ({top_sim:+.2f}) "
198
- f"than to any input (max {input_max:+.2f}); the embedding separates these concepts.")
199
- else:
200
- interp = (f"Result is dominated by the inputs themselves "
201
- f"(top neighbour {top_name} only {gap:+.2f} above max input cosine).")
202
- else:
203
- interp = f"Result top neighbour: {top_name} ({top_sim:+.2f})."
204
- return (
205
- f"**Why these results** \n"
206
- f"- Result vs. positives: {pos_part}. \n"
207
- f"- Result vs. negatives: {neg_part}. \n"
208
- f"- {interp}"
209
- )
210
-
211
- # ===== heatmap =====
212
 
213
  def _basket_heatmap(m, basket):
214
  valid = [n for n in (basket or []) if n in m.vocab]
215
- fig, ax = plt.subplots(figsize=(6, 5))
216
  if len(valid) < 2:
217
  ax.text(0.5, 0.5, "Add 2+ ingredients to see pairwise cosines",
218
- ha="center", va="center", fontsize=13, color="#888",
219
- transform=ax.transAxes)
220
- ax.axis("off")
221
- plt.tight_layout()
222
- return fig
223
  idxs = [m.vocab[n] for n in valid]
224
  sub = m.E[idxs]
225
  sim = sub @ sub.T
226
  im = ax.imshow(sim, cmap="viridis", vmin=-0.2, vmax=1.0, aspect="auto")
227
- ax.set_xticks(range(len(valid)))
228
- ax.set_yticks(range(len(valid)))
229
- ax.set_xticklabels(valid, rotation=35, ha="right")
230
- ax.set_yticklabels(valid)
231
  for i in range(len(valid)):
232
  for j in range(len(valid)):
233
  v = float(sim[i, j])
234
- color = "white" if v < 0.55 else "black"
235
- ax.text(j, i, f"{v:.2f}", ha="center", va="center", fontsize=10, color=color)
236
- cb = plt.colorbar(im, ax=ax)
237
- cb.set_label("cosine")
238
- ax.set_title("Pairwise cosine within the basket", fontsize=12)
239
  plt.tight_layout()
240
  return fig
241
 
@@ -248,7 +163,7 @@ def _umap_coords(sibling, three_d):
248
  m = MODELS[sibling]
249
  E = m.E - m.E.mean(axis=0, keepdims=True)
250
  _, _, Vt = np.linalg.svd(E, full_matrices=False)
251
- pc1 = (E @ Vt[0])
252
  pc1 = (pc1 - pc1.mean()) / (pc1.std() + 1e-9)
253
  scale = (base.max() - base.min()) * 0.25
254
  return base, (pc1 * scale).astype(np.float32)
@@ -261,169 +176,224 @@ def umap_view(sibling, basket, show_neighbours, k, three_d=False):
261
  hover_text = [f"{NAMES_BY_IDX[i]}<br>group: {FOOD_GROUPS[i]}" for i in range(n)]
262
  basket_set = set(basket or [])
263
  basket_idxs = [m.vocab[b] for b in (basket or []) if b in m.vocab]
264
- neighbour_set: set[str] = set()
265
  if show_neighbours and basket_idxs:
266
  centroid = _basket_centroid(m, basket)
267
  if centroid is not None:
268
- nb_pairs = _topk(m, centroid, k=int(k), exclude=basket)
269
- neighbour_set = {nm for nm, _ in nb_pairs}
270
- bg_keep = lambda i: NAMES_BY_IDX[i] not in basket_set and NAMES_BY_IDX[i] not in neighbour_set
271
- bg_x = [float(coords2[i, 0]) for i in range(n) if bg_keep(i)]
272
- bg_y = [float(coords2[i, 1]) for i in range(n) if bg_keep(i)]
273
- bg_z = [float(z[i]) for i in range(n) if bg_keep(i)] if three_d else None
274
- bg_c = [colors[i] for i in range(n) if bg_keep(i)]
275
- bg_h = [hover_text[i] for i in range(n) if bg_keep(i)]
276
  fig = go.Figure()
277
  if three_d:
278
- fig.add_trace(go.Scatter3d(
279
- x=bg_x, y=bg_y, z=bg_z, mode="markers",
280
- marker=dict(size=3, color=bg_c, opacity=0.55, line=dict(width=0)),
281
- text=bg_h, hovertemplate="%{text}<extra></extra>", name="ingredients", showlegend=False,
282
- ))
283
  else:
284
- fig.add_trace(go.Scattergl(
285
- x=bg_x, y=bg_y, mode="markers",
286
- marker=dict(size=5, color=bg_c, opacity=0.65, line=dict(width=0)),
287
- text=bg_h, hovertemplate="%{text}<extra></extra>", name="ingredients", showlegend=False,
288
- ))
289
  if neighbour_set:
290
  ni = [i for i in range(n) if NAMES_BY_IDX[i] in neighbour_set]
291
- nx = [float(coords2[i, 0]) for i in ni]
292
- ny = [float(coords2[i, 1]) for i in ni]
293
  nz = [float(z[i]) for i in ni] if three_d else None
294
- nlabels = [NAMES_BY_IDX[i] for i in ni]
295
- marker = dict(size=11 if not three_d else 6, color="#ff8800", opacity=0.95,
296
- line=dict(color="#ffffff", width=1.2))
297
  TR = go.Scatter3d if three_d else go.Scatter
298
- kwargs = dict(mode="markers+text", marker=marker, text=nlabels, textposition="top center",
299
- textfont=dict(size=10),
300
- hovertemplate="<b>%{text}</b> (neighbour)<extra></extra>",
301
- name=f"top-{k} neighbours")
302
- fig.add_trace(TR(x=nx, y=ny, z=nz, **kwargs) if three_d else TR(x=nx, y=ny, **kwargs))
303
  if basket_idxs:
304
  bx = [float(coords2[i, 0]) for i in basket_idxs]
305
  by = [float(coords2[i, 1]) for i in basket_idxs]
306
  bz = [float(z[i]) for i in basket_idxs] if three_d else None
307
- blabels = [NAMES_BY_IDX[i] for i in basket_idxs]
308
- marker = dict(size=18 if not three_d else 9, color=KAIKAKU_ACCENT,
309
- symbol="star" if not three_d else "diamond",
310
- line=dict(color="#111111", width=1.5))
311
  TR = go.Scatter3d if three_d else go.Scatter
312
- kwargs = dict(mode="markers+text", marker=marker, text=blabels, textposition="top center",
313
- textfont=dict(size=13, color="#111111"),
314
- hovertemplate="<b>%{text}</b> (basket)<extra></extra>", name="basket")
315
- fig.add_trace(TR(x=bx, y=by, z=bz, **kwargs) if three_d else TR(x=bx, y=by, **kwargs))
316
- title_suffix = " (3D)" if three_d else ""
317
  fig.update_layout(
318
- title=dict(text=f"UMAP of Epicure-{sibling.capitalize()}{title_suffix} - {n} ingredients", font=dict(size=15)),
319
- height=650, margin=dict(l=40, r=40, t=60, b=40),
320
  paper_bgcolor="#ffffff", plot_bgcolor="#ffffff",
321
  legend=dict(orientation="v", x=1.02, y=1, font=dict(size=11)),
322
  )
323
  if not three_d:
324
- fig.update_xaxes(showgrid=True, gridcolor="#eeeeee", zeroline=False, title="UMAP 1")
325
- fig.update_yaxes(showgrid=True, gridcolor="#eeeeee", zeroline=False, title="UMAP 2")
326
  else:
327
  fig.update_layout(scene=dict(xaxis=dict(title="UMAP 1"), yaxis=dict(title="UMAP 2"),
328
  zaxis=dict(title="PC1 (z)"), bgcolor="#ffffff"))
329
  return fig
330
 
331
- # ===== tab handlers (with explainers) =====
332
 
333
- def basket_pairings(sibling, basket, k):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
334
  m = MODELS[sibling]
335
- centroid = _basket_centroid(m, basket)
336
- if centroid is None:
337
- return [], [], _basket_heatmap(m, [])
338
- nb = _topk(m, centroid, k, exclude=basket or [])
339
- scored = [(mode.mode_id, mode.label, mode.kind, float(_unit(mode.pole) @ centroid)) for mode in m.modes]
340
- scored.sort(key=lambda x: -x[3])
341
- heatmap = _basket_heatmap(m, basket)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
342
  return (
343
- [[name, f"{sim:.4f}"] for name, sim in nb],
344
- [[mid, label, kind, f"{sim:.4f}"] for mid, label, kind, sim in scored[:k]],
345
- heatmap,
 
346
  )
347
 
348
- def supervised_slerp_multi(sibling, basket, directions, theta, k):
349
- m = MODELS[sibling]
350
- v = _basket_centroid(m, basket)
351
- if v is None:
352
- return [], "_(empty basket)_"
353
- d = _stack_directions(m, directions, use_factor_pole=False)
354
- if d is None:
355
- return [[n, f"{s:.4f}"] for n, s in _topk(m, v, k, basket)], "_(no direction selected)_"
356
- q = _slerp(v, d, theta)
357
- rows = [[n, f"{s:.4f}"] for n, s in _topk(m, q, k, basket)]
358
- return rows, _slerp_explainer(m, basket, directions or [], theta, q, v, d, "supervised")
359
-
360
- def emergent_slerp_multi(sibling, basket, mode_labels, theta, k):
361
- m = MODELS[sibling]
362
- label_to_id = {f"{mode.label} ({mode.mode_id})": mode.mode_id for mode in m.modes if mode.kind == "factor"}
363
- mode_ids = [label_to_id[lab] for lab in (mode_labels or []) if lab in label_to_id]
364
- v = _basket_centroid(m, basket)
365
- if v is None:
366
- return [], "_(empty basket)_"
367
- d = _stack_directions(m, mode_ids, use_factor_pole=True)
368
- if d is None:
369
- return [[n, f"{s:.4f}"] for n, s in _topk(m, v, k, basket)], "_(no factor mode selected)_"
370
- q = _slerp(v, d, theta)
371
- rows = [[n, f"{s:.4f}"] for n, s in _topk(m, q, k, basket)]
372
- return rows, _slerp_explainer(m, basket, mode_ids, theta, q, v, d, "emergent")
373
-
374
- def arithmetic(sibling, positives, negatives, k):
375
- m = MODELS[sibling]
376
- pos = _basket_centroid(m, positives)
377
- if pos is None:
378
- return [], "_(no positives provided)_"
379
- neg = _basket_centroid(m, negatives) if negatives else None
380
- q = _unit(pos - neg) if neg is not None else pos
381
- rows = [[n, f"{s:.4f}"] for n, s in _topk(m, q, k, (positives or []) + (negatives or []))]
382
- return rows, _arithmetic_explainer(m, positives or [], negatives or [], q, pos, neg)
383
 
384
- def browse_modes(sibling, kind_filter, query):
385
- m = MODELS[sibling]
386
- rows, q = [], (query or "").strip().lower()
387
- for mode in m.modes:
388
- if kind_filter != "all" and mode.kind != kind_filter:
389
- continue
390
- if q and q not in mode.label.lower() and q not in mode.property.lower():
391
- continue
392
- rows.append([mode.mode_id, mode.kind, mode.property, mode.label, mode.n_members,
393
- ", ".join(mode.members[:12])])
394
- rows.sort(key=lambda r: (r[1], -r[4]))
395
- return rows
396
 
397
- def compare_siblings(basket, directions, theta, k):
398
- out = []
399
- for sib in ["cooc","core","chem"]:
400
- m = MODELS[sib]
401
- v = _basket_centroid(m, basket)
402
- if v is None: out.append([]); continue
403
- valid_dirs = [d for d in (directions or []) if d in m.supervised_poles]
404
- if valid_dirs:
405
- d_vec = _stack_directions(m, valid_dirs)
406
- q = _slerp(v, d_vec, theta) if d_vec is not None else v
407
- else:
408
- q = v
409
- hits = _topk(m, q, k=k, exclude=basket)
410
- out.append([[n, f"{s:.4f}"] for n, s in hits])
411
- return out[0], out[1], out[2]
 
 
 
 
412
 
413
- # ===== Feature 6: recipe builder (lazy-loaded sentence-transformer) =====
 
 
 
 
 
 
 
 
 
 
 
414
 
415
- _ST_MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
416
  _ST = None
417
  def _get_st():
418
  global _ST
419
  if _ST is None:
420
- print(f"[epicure-explorer] loading {_ST_MODEL_NAME} (first call, ~80MB)", flush=True)
421
  from sentence_transformers import SentenceTransformer
422
- _ST = SentenceTransformer(_ST_MODEL_NAME, device="cpu")
423
  return _ST
424
 
425
  @lru_cache(maxsize=4)
426
- def _mode_label_matrix(sibling: str):
427
  m = MODELS[sibling]
428
  modes = [md for md in m.modes if md.kind == "factor"]
429
  if not modes:
@@ -438,21 +408,18 @@ def _mode_quartile(mode):
438
  n = max(4, min(12, (len(members) + 3) // 4))
439
  return members[:n]
440
 
441
- _PROMPT_STOPWORDS = {
442
- "i","im","i'm","a","an","the","for","of","with","and","or","some","my","me","we",
443
- "make","making","cook","cooking","prepare","preparing","want","need","to","tonight",
444
- "people","person","servings","dinner","lunch","dish","recipe","quick","easy",
445
- "tasty","yummy","good","great","food","meal","style","plate","plates",
446
- }
447
- _TOKEN_RE = re.compile(r"[A-Za-z][A-Za-z\-']{1,}")
448
 
449
- def suggest_basket(prompt, sibling, k=10):
450
  if not prompt or not prompt.strip():
451
- return [], [], "Type a dish description first."
452
  vocab = list(MODELS[sibling].vocab.keys())
453
- vocab_sp = [v.replace("_", " ") for v in vocab]
454
- raw_tokens = _TOKEN_RE.findall(prompt.lower())
455
- tokens = [t for t in raw_tokens if t not in _PROMPT_STOPWORDS and len(t) > 2]
456
  direct = {}
457
  direct_evidence = []
458
  for tok in tokens:
@@ -476,252 +443,92 @@ def suggest_basket(prompt, sibling, k=10):
476
  for mid, lab, sim in picked:
477
  for name in _mode_quartile(id_to_mode[mid]):
478
  s_existing, _ = thematic.get(name, (0.0, ""))
479
- s_new = max(s_existing, sim * 100.0)
480
- thematic[name] = (s_new, lab)
481
  combined = {}
482
- for name, sc in direct.items():
483
- combined[name] = (sc, "direct")
484
  for name, (sc, lab) in thematic.items():
485
  prev = combined.get(name)
486
  if prev is None or sc > prev[0]:
487
- tag = "both" if prev else "thematic"
488
- combined[name] = (sc, tag)
489
  ranked = sorted(combined.items(),
490
  key=lambda kv: (-kv[1][0], 0 if kv[1][1] != "thematic" else 1, kv[0]))[:int(k)]
491
  rows = [[name, src, round(score, 1)] for name, (score, src) in ranked]
492
  names = [name for name, _ in ranked]
493
  lines = []
494
  if direct_evidence:
495
- dm = ", ".join(sorted({f"`{n}` (from '{t}')" for t, n, _ in direct_evidence}))
496
- lines.append(f"**Direct mentions:** {dm}")
497
- else:
498
- lines.append("**Direct mentions:** _none cleared score threshold_")
499
  if thematic_modes:
500
- bits = []
501
- id_to_mode = {md.mode_id: md for md in MODELS[sibling].modes if md.kind == "factor"}
502
- for mid, lab, sim in thematic_modes:
503
- sample = ", ".join(id_to_mode[mid].members[:4])
504
- bits.append(f"`{lab}` (cos {sim:.2f}; e.g. {sample})")
505
- lines.append("**Matched factor modes:** " + "; ".join(bits))
506
- else:
507
- lines.append("**Matched factor modes:** _no mode label cleared cosine 0.25_")
508
- return rows, names, "\n\n".join(lines)
509
-
510
- # ===== fridge parser =====
511
-
512
- _LINE_SPLIT = re.compile(r"[\n;]")
513
- _BRACKET = re.compile(r"\([^)]*\)")
514
- _QTY = (r"(?:\d+(?:[\.,/]\d+)?|a|an|one|two|three|four|five|six|seven|eight|nine|ten|half|quarter)")
515
- _UNIT = (r"(?:cups?|tbsp\.?|tablespoons?|tsp\.?|teaspoons?|oz\.?|ounces?|lbs?\.?|pounds?|"
516
- r"grams?|kgs?|kilos?|ml|liters?|litres?|cloves?|bunches?|sprigs?|pinch(?:es)?|"
517
- r"slices?|pieces?|cans?|packets?|sticks?|leaves?|stalks?|heads?|inch(?:es)?|"
518
- r"splash(?:es)?|dash(?:es)?|drops?|handfuls?|large|small|medium)")
519
- _LEADING_QTY = re.compile(rf"^\s*{_QTY}\s+(?:{_UNIT}\b\s*)?(?:of\s+)?", re.IGNORECASE)
520
- _LEADING_UNIT_ONLY = re.compile(rf"^\s*{_UNIT}\b\s*(?:of\s+)?", re.IGNORECASE)
521
- _JUICE_OF = re.compile(rf"^\s*(?:juice|zest)\s+(?:of\s+)?(?:{_QTY}\s+)?", re.IGNORECASE)
522
- _LEADING_PREP = re.compile(
523
- r"^\s*(?:fresh|dried|cooked|frozen|raw|ripe|firm|boneless|skinless|smoked|low[- ]fat)\s+",
524
- re.IGNORECASE)
525
- _TRAILING_PREP = re.compile(
526
- r"\s*,\s*(?:chopped|minced|diced|sliced|grated|crushed|whole|ground|peeled|"
527
- r"to taste|optional|finely|coarsely|cubed|shredded|julienned|halved|quartered|warmed|"
528
- r"toasted|roasted|bruised|melted|softened|cooked|drained|rinsed|patted dry|trimmed|"
529
- r"deveined|seeded|stemmed|crumbled).*$", re.IGNORECASE)
530
- _KNOWN_PLURALS = {"tortillas":"tortilla","thighs":"thigh","leaves":"leaf","onions":"onion",
531
- "potatoes":"potato","tomatoes":"tomato","cloves":"clove"}
532
 
533
- def _clean_line(line):
534
- s = line.strip().lower()
535
- s = _BRACKET.sub(" ", s)
536
- if "juice" in s or "zest" in s:
537
- s = _JUICE_OF.sub("", s)
538
- s = _TRAILING_PREP.sub("", s)
539
- s = _LEADING_QTY.sub("", s)
540
- s = _LEADING_UNIT_ONLY.sub("", s)
541
- s = _LEADING_PREP.sub("", s)
542
- s = _LEADING_PREP.sub("", s)
543
- tokens = [_KNOWN_PLURALS.get(t, t) for t in s.split()]
544
- return re.sub(r"\s+", " ", " ".join(tokens)).strip()
545
 
546
- def _fuzzy_lookup(cleaned, vocab, vocab_sp, min_score):
547
- if not cleaned: return None, 0.0
548
- candidates = []
549
- for scorer in (fuzz_scorers.token_set_ratio, fuzz_scorers.WRatio, fuzz_scorers.partial_ratio):
550
- hits = fuzz_process.extract(cleaned, vocab_sp, scorer=scorer, score_cutoff=min_score, limit=10)
551
- for _name_sp, score, idx in hits:
552
- candidates.append((vocab[idx], float(score)))
553
- if not candidates: return None, 0.0
554
- cleaned_tokens = set(cleaned.split())
555
- def rank_key(c):
556
- name, score = c
557
- nt = set(name.replace("_"," ").split())
558
- return (-score, 0 if nt.issubset(cleaned_tokens) else 1, -len(name))
559
- candidates.sort(key=rank_key)
560
- return candidates[0]
561
 
562
- def parse_fridge(raw_text, sibling, min_score=70):
563
- if not raw_text or not raw_text.strip(): return [], []
564
- vocab = list(MODELS[sibling].vocab.keys())
565
- vocab_sp = [v.replace("_"," ") for v in vocab]
566
- rows, matched = [], []
567
- for line in _LINE_SPLIT.split(raw_text):
568
- if not line.strip(): continue
569
- cleaned = _clean_line(line)
570
- if not cleaned:
571
- rows.append([line.strip(), "(empty)", 0.0, ""]); continue
572
- match, score = _fuzzy_lookup(cleaned, vocab, vocab_sp, int(min_score))
573
- if match is None:
574
- tokens = cleaned.split()
575
- if len(tokens) > 1:
576
- match, score = _fuzzy_lookup(" ".join(tokens[:-1]), vocab, vocab_sp, int(min_score))
577
- if match is None:
578
- rows.append([line.strip(), "(no match)", 0.0, cleaned]); continue
579
- rows.append([line.strip(), match, round(score, 1), cleaned])
580
- matched.append(match)
581
- seen, dedup = set(), []
582
- for n in matched:
583
- if n not in seen: seen.add(n); dedup.append(n)
584
- return rows, dedup
585
 
586
- # ===== Feature 8: public API endpoints =====
587
 
588
- def _suggest(name: str, sibling: str, n: int = 5) -> list[str]:
589
  vocab = list(MODELS[sibling].vocab.keys())
590
  hits = fuzz_process.extract((name or "").lower().replace(" ", "_"),
591
  vocab, scorer=fuzz_scorers.WRatio, limit=n)
592
  return [h[0] for h in hits]
593
 
594
- def _validate_sibling(sibling):
595
- if sibling not in MODELS:
596
- return {"error": f"sibling '{sibling}' not in {{cooc, core, chem}}",
597
- "suggestions": ["cooc","core","chem"]}
598
- return None
599
-
600
- def _validate_ingredient(name, sibling, field="ingredient"):
601
- if not isinstance(name, str) or not name:
602
- return {"error": f"{field} must be a non-empty string"}
603
- if name not in MODELS[sibling].vocab:
604
- return {"error": f"{field} '{name}' not in vocab",
605
- "suggestions": _suggest(name, sibling)}
606
- return None
607
-
608
  def api_neighbors(ingredient, sibling="chem", k=5):
609
- err = _validate_sibling(sibling) or _validate_ingredient(ingredient, sibling)
610
- if err: return err
611
  m = MODELS[sibling]
612
  q = _unit(m.E[m.vocab[ingredient]])
613
- pairs = _topk(m, q, int(k), exclude=[ingredient])
614
- return [{"name": n, "cosine": round(float(s), 6)} for n, s in pairs]
615
 
616
  def api_slerp(seed, direction, theta_deg=30, sibling="chem", k=5):
617
- err = _validate_sibling(sibling) or _validate_ingredient(seed, sibling, "seed")
618
- if err: return err
619
  m = MODELS[sibling]
620
- if direction not in m.supervised_poles:
621
- return {"error": f"direction '{direction}' not a supervised pole",
622
- "suggestions": sorted(m.supervised_poles.keys())[:10]}
623
  v = _unit(m.E[m.vocab[seed]])
624
  d = _unit(m.supervised_poles[direction])
625
  q = _slerp(v, d, float(theta_deg))
626
- pairs = _topk(m, q, int(k), exclude=[seed])
627
- return [{"name": n, "cosine": round(float(s), 6)} for n, s in pairs]
628
 
629
  def api_arithmetic(positives, negatives, sibling="chem", k=5):
630
- err = _validate_sibling(sibling)
631
- if err: return err
632
- positives = list(positives or [])
633
- negatives = list(negatives or [])
634
- if not positives:
635
- return {"error": "positives must be a non-empty list"}
636
  m = MODELS[sibling]
637
  unknown = [x for x in positives + negatives if x not in m.vocab]
638
- if unknown:
639
- return {"error": f"unknown ingredients: {unknown}",
640
- "suggestions": {x: _suggest(x, sibling) for x in unknown}}
641
  pos = _basket_centroid(m, positives)
642
  neg = _basket_centroid(m, negatives) if negatives else None
643
  q = _unit(pos - neg) if neg is not None else pos
644
- pairs = _topk(m, q, int(k), exclude=positives + negatives)
645
- return [{"name": n, "cosine": round(float(s), 6)} for n, s in pairs]
646
 
647
  def api_embed(ingredient, sibling="chem"):
648
- err = _validate_sibling(sibling) or _validate_ingredient(ingredient, sibling)
649
- if err: return err
650
  m = MODELS[sibling]
651
- v = _unit(m.E[m.vocab[ingredient]])
652
- return [float(x) for x in v.tolist()]
653
 
654
- def api_list_directions(sibling="chem"):
655
- err = _validate_sibling(sibling)
656
- if err: return err
657
- return sorted(MODELS[sibling].supervised_poles.keys())
658
-
659
- def api_list_factor_modes(sibling="chem"):
660
- err = _validate_sibling(sibling)
661
- if err: return err
662
- return [{"mode_id": mode.mode_id, "label": str(mode.label),
663
- "kind": str(mode.kind), "property": str(mode.property),
664
- "n_members": int(mode.n_members)}
665
- for mode in MODELS[sibling].modes if mode.kind == "factor"]
666
-
667
- # ===== Feature 9: saved queries helpers =====
668
-
669
- TAB_IDS = {
670
- "basket": "tab_basket",
671
- "supervised_slerp": "tab_sup",
672
- "emergent_slerp": "tab_em",
673
- "arithmetic": "tab_ar",
674
- "compare": "tab_cmp",
675
- }
676
- TAB_LABELS = {
677
- "basket": "Basket pairings",
678
- "supervised_slerp": "Supervised SLERP",
679
- "emergent_slerp": "Emergent SLERP",
680
- "arithmetic": "Arithmetic",
681
- "compare": "Compare siblings",
682
- }
683
-
684
- def _summarise(tab, inputs):
685
- sib = inputs.get("sibling", "")
686
- if tab == "basket":
687
- return f"[{sib}] basket: {', '.join(inputs.get('basket', [])[:3])} k={inputs.get('k')}"
688
- if tab == "supervised_slerp":
689
- b = ", ".join(inputs.get("basket", [])[:2])
690
- d = ", ".join(inputs.get("directions", [])[:2])
691
- return f"[{sib}] {b} +{inputs.get('theta')}掳 -> {d}"
692
- if tab == "emergent_slerp":
693
- b = ", ".join(inputs.get("basket", [])[:2])
694
- return f"[{sib}] {b} +{inputs.get('theta')}掳 -> {len(inputs.get('modes', []))} factor modes"
695
- if tab == "arithmetic":
696
- p = " + ".join(inputs.get("positives", [])[:2])
697
- n = " + ".join(inputs.get("negatives", [])[:2])
698
- return f"[{sib}] {p}" + (f" - {n}" if n else "")
699
- if tab == "compare":
700
- return f"[3 siblings] {', '.join(inputs.get('basket', [])[:2])} +{inputs.get('theta')}掳"
701
- return "(unknown)"
702
-
703
- def save_query(saved, tab, inputs_dict):
704
- saved = list(saved or [])
705
- rec = {
706
- "id": str(uuid.uuid4()),
707
- "created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
708
- "tab": tab,
709
- "inputs": inputs_dict,
710
- "summary": _summarise(tab, inputs_dict),
711
- }
712
- saved.insert(0, rec)
713
- saved = saved[:200]
714
- return saved, _render_saved(saved)
715
-
716
- def delete_query(saved, qid):
717
- saved = [q for q in (saved or []) if q.get("id") != qid]
718
- return saved, _render_saved(saved)
719
-
720
- def _render_saved(saved):
721
- return [[q["created_at"], TAB_LABELS.get(q["tab"], q["tab"]), q["summary"], q["id"]]
722
- for q in (saved or [])]
723
-
724
- # ===== Theme + CSS =====
725
 
726
  THEME = gr.themes.Soft(
727
  primary_hue=gr.themes.Color(
@@ -733,18 +540,14 @@ THEME = gr.themes.Soft(
733
  neutral_hue="slate",
734
  font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui", "sans-serif"],
735
  ).set(
736
- block_label_text_color="#1f2937",
737
- block_label_text_weight="600",
738
- block_title_text_color="#0f172a",
739
- block_title_text_weight="700",
740
- body_text_color="#0f172a",
741
- body_text_color_subdued="#475569",
742
  button_primary_background_fill=KAIKAKU_ACCENT,
743
  button_primary_background_fill_hover=KAIKAKU_ACCENT_HOVER,
744
  button_primary_text_color="#ffffff",
745
  button_primary_border_color=KAIKAKU_ACCENT,
746
  button_secondary_background_fill="#f1f5f9",
747
- button_secondary_background_fill_hover="#e2e8f0",
748
  button_secondary_text_color=KAIKAKU_DARK,
749
  slider_color=KAIKAKU_ACCENT,
750
  color_accent=KAIKAKU_ACCENT,
@@ -754,8 +557,7 @@ CUSTOM_CSS = f"""
754
  .gradio-container {{max-width: 1280px !important;}}
755
  footer {{visibility: hidden;}}
756
  .gradio-container label, .gradio-container .label,
757
- .gradio-container [data-testid="block-label"],
758
- .gradio-container .block-label, .gradio-container .gr-block-label {{
759
  color: #0f172a !important; font-weight: 600 !important; background: transparent !important;
760
  }}
761
  .gradio-container button[role="tab"] {{ color: #334155 !important; font-weight: 500 !important; }}
@@ -766,428 +568,255 @@ footer {{visibility: hidden;}}
766
  background: {KAIKAKU_ACCENT} !important; color: #ffffff !important;
767
  border-color: {KAIKAKU_ACCENT} !important; font-weight: 600 !important;
768
  }}
769
- .gradio-container button.primary:hover {{ background: {KAIKAKU_ACCENT_HOVER} !important; border-color: {KAIKAKU_ACCENT_HOVER} !important; }}
770
- .gradio-container table thead th, .gradio-container .gr-dataframe thead th {{
771
  color: #0f172a !important; font-weight: 700 !important; background: #f8fafc !important;
772
  }}
773
  .gradio-container table tbody td {{ color: #0f172a !important; }}
774
- .sibling-card {{
775
- border-left: 3px solid {KAIKAKU_ACCENT}; padding: 10px 14px;
776
- margin: 6px 0; background: #f8fafc; border-radius: 4px;
 
 
 
777
  }}
778
- .sibling-name {{ color: {KAIKAKU_DARK}; font-weight: 700; font-size: 1.02em; }}
779
- .sibling-desc {{ color: #334155; font-size: 0.95em; line-height: 1.5; }}
 
 
 
 
 
 
 
 
780
  """
781
 
782
- _INITIAL_UMAP = umap_view("chem", ["chicken","lemon","garlic"], True, 8, three_d=False)
783
- _INITIAL_HEATMAP = _basket_heatmap(MODELS["chem"], ["chicken","lemon","garlic"])
784
-
785
- SIBLING_CARDS = """
786
- <div class="sibling-card">
787
- <div class="sibling-name">Cooc - recipe-context only</div>
788
- <div class="sibling-desc">Walks recipe co-occurrence (NPMI graph) only. Neighbours are recipe <em>companions</em>: things that get cooked with the seed. Isotropic geometry (PR=173.6 of 300). Best for "what else do I cook with X".</div>
789
- </div>
790
- <div class="sibling-card">
791
- <div class="sibling-name">Core - blended (the middle ground)</div>
792
- <div class="sibling-desc">Typed FlavorDB compound walks blended with injected I-I walks at ii_repeat=10. Concentrated geometry (PR=94.2), tightest emergent modes. Chemistry-aware but keeps recipe context.</div>
793
- </div>
794
- <div class="sibling-card">
795
- <div class="sibling-name">Chem - chemistry only</div>
796
- <div class="sibling-desc">Typed FlavorDB compound metapaths only (ii_repeat=0). Neighbours are flavour-profile <em>peers</em>: things that share aroma chemistry with the seed. Best supervised-direction recovery; cuisine Cohen's d = 3.07 across 8 macro-regions.</div>
 
797
  </div>
798
  """
799
 
800
- # ===== Helper for ingredient picker with food-group filter =====
801
 
802
- def _ingredient_picker(label, default_value, multiselect=True, max_choices=10):
803
- radio = gr.Radio(choices=FOOD_GROUP_CHOICES, value="All",
804
- label=f"{label} - food group filter", interactive=True)
805
- dd = gr.Dropdown(choices=ALL_INGREDIENTS, value=default_value, label=label,
806
- multiselect=multiselect, max_choices=max_choices)
807
- radio.change(_filter_dropdown, inputs=[radio, dd], outputs=dd, show_progress="hidden")
808
- return radio, dd
809
 
810
  # ===== UI =====
811
 
812
  with gr.Blocks(title="Epicure Explorer", theme=THEME, css=CUSTOM_CSS) as demo:
813
 
814
- saved_state = gr.BrowserState(default_value=[], storage_key="epicure_saved_queries_v1")
815
-
816
  gr.Markdown(
817
  """# Epicure Explorer
818
- Chef-facing operators over three sibling ingredient embeddings (Cooc / Core / Chem) from
819
- [arXiv:2605.22391](https://arxiv.org/abs/2605.22391). 1,790 canonical ingredients across 7 languages,
820
- 300-D Metapath2Vec, controlled chemistry-vs-recipe-context spectrum."""
821
  )
 
822
 
823
- gr.HTML(SIBLING_CARDS)
824
-
825
- sibling = gr.Radio(choices=["cooc","core","chem"], value="chem", label="Sibling embedding to query")
826
 
827
- shared_basket = gr.State([])
828
-
829
- with gr.Tabs() as tabs:
830
-
831
- # ---------- Tab 1: Basket pairings ----------
832
- with gr.Tab("Basket pairings", id="tab_basket"):
833
- gr.Markdown("Pick one or more ingredients. The tool averages their unit vectors and returns nearest neighbours plus closest modes of that centroid.")
834
- basket_radio, basket = _ingredient_picker("Ingredient basket (pick 1+)", ["chicken","lemon","garlic"])
835
- k_pair = gr.Slider(1, 15, value=8, step=1, label="K")
836
  with gr.Row():
837
- pair_btn = gr.Button("Find pairings", variant="primary")
838
- save_basket_btn = gr.Button("Save this query", variant="secondary")
 
 
839
  with gr.Row():
840
- nb_table = gr.Dataframe(headers=["Neighbour","Cosine"], label="Top-K nearest neighbours", interactive=False)
841
- mode_table = gr.Dataframe(headers=["Mode id","Label","Kind","Cosine"], label="Closest modes", interactive=False)
842
- heatmap_plot = gr.Plot(value=_INITIAL_HEATMAP, label="Pairwise cosine (matplotlib)")
843
- pair_btn.click(basket_pairings, inputs=[sibling, basket, k_pair],
844
- outputs=[nb_table, mode_table, heatmap_plot], show_progress="full")
845
  gr.Examples(
846
  examples=[
847
- ["chem", ["chicken","lemon","garlic"], 8],
848
- ["core", ["miso","ginger","sesame_oil"], 8],
849
- ["chem", ["tomato","basil","mozzarella_cheese"], 8],
850
- ["cooc", ["chocolate","strawberry","cream"], 8],
851
- ["chem", ["cumin","coriander","turmeric"], 8],
852
- ["core", ["soy_sauce","ginger","scallion"], 8],
853
- ["chem", ["red_wine","beef","rosemary"], 8],
854
- ["core", ["coconut_milk","lemongrass","fish_sauce"], 8],
855
  ],
856
- inputs=[sibling, basket, k_pair],
857
- label="Try one of these baskets",
858
  )
859
 
860
- # ---------- Tab 2: Supervised SLERP ----------
861
- with gr.Tab("Supervised SLERP", id="tab_sup"):
862
- gr.Markdown("Rotate the seed basket toward one or more supervised pole vectors.")
863
- sup_radio, sup_basket = _ingredient_picker("Seed basket (pick 1+)", ["rice"])
864
- sup_dirs = gr.Dropdown(choices=_supervised_choices("chem"), value=["cuisine:South_Asian"],
865
- label="Supervised directions (pick 1+; summed)",
866
- multiselect=True, max_choices=5)
867
- sup_theta = gr.Slider(0, 90, value=30, step=5, label="Rotation angle (deg)")
868
- sup_k = gr.Slider(1, 15, value=8, step=1, label="K")
869
  with gr.Row():
870
- sup_btn = gr.Button("Rotate", variant="primary")
871
- save_sup_btn = gr.Button("Save this query", variant="secondary")
872
- sup_table = gr.Dataframe(headers=["Ingredient","Cosine"], label="Top-K rotated-query neighbours")
873
- sup_explainer = gr.Markdown()
874
- sup_btn.click(supervised_slerp_multi,
875
- inputs=[sibling, sup_basket, sup_dirs, sup_theta, sup_k],
876
- outputs=[sup_table, sup_explainer], show_progress="full")
877
- sibling.change(lambda s: gr.Dropdown(choices=_supervised_choices(s), value=[]),
878
- inputs=sibling, outputs=sup_dirs)
879
- gr.Examples(
880
- examples=[
881
- ["chem", ["rice"], ["cuisine:South_Asian"], 30, 8],
882
- ["chem", ["corn"], ["cuisine:Latin_American"], 30, 8],
883
- ["core", ["chicken"], ["cuisine:Mediterranean"], 45, 8],
884
- ["core", ["tomato","basil"], ["cuisine:Southeast_Asian"], 45, 8],
885
- ["chem", ["beef"], ["cuisine:East_Asian"], 60, 8],
886
- ["cooc", ["chocolate"], ["cuisine:Latin_American"], 30, 8],
887
- ],
888
- inputs=[sibling, sup_basket, sup_dirs, sup_theta, sup_k],
889
- label="Try one of these rotations",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
890
  )
891
 
892
- # ---------- Tab 3: Emergent SLERP ----------
893
- with gr.Tab("Emergent SLERP", id="tab_em"):
894
- gr.Markdown("Rotate the seed basket toward one or more emergent FastICA factor-mode poles.")
895
- em_radio, em_basket = _ingredient_picker("Seed basket (pick 1+)", ["chocolate"])
896
- factor_opts = _factor_mode_choices("chem")
897
- em_modes = gr.Dropdown(choices=[label for label, _ in factor_opts],
898
- value=[factor_opts[0][0]] if factor_opts else [],
899
- label="Factor modes (pick 1+; summed)", multiselect=True, max_choices=5)
900
- em_theta = gr.Slider(0, 90, value=30, step=5, label="Rotation angle (deg)")
901
- em_k = gr.Slider(1, 15, value=8, step=1, label="K")
902
  with gr.Row():
903
- em_btn = gr.Button("Rotate", variant="primary")
904
- save_em_btn = gr.Button("Save this query", variant="secondary")
905
- em_table = gr.Dataframe(headers=["Ingredient","Cosine"], label="Top-K rotated-query neighbours")
906
- em_explainer = gr.Markdown()
907
- em_btn.click(emergent_slerp_multi,
908
- inputs=[sibling, em_basket, em_modes, em_theta, em_k],
909
- outputs=[em_table, em_explainer], show_progress="full")
910
- sibling.change(lambda s: gr.Dropdown(choices=[label for label, _ in _factor_mode_choices(s)], value=[]),
911
- inputs=sibling, outputs=em_modes)
912
-
913
- # ---------- Tab 4: Arithmetic ----------
914
- with gr.Tab("Arithmetic", id="tab_ar"):
915
- gr.Markdown("Mikolov-style vector arithmetic: `centroid(positives) - centroid(negatives)`, then top-K neighbours. Killer demo: `miso - salt` on Core.")
916
- pos_radio, pos_box = _ingredient_picker("Positives (added)", ["miso"])
917
- neg_radio, neg_box = _ingredient_picker("Negatives (subtracted)", ["salt"])
918
- ar_k = gr.Slider(1, 15, value=8, step=1, label="K")
919
  with gr.Row():
920
- ar_btn = gr.Button("Compute", variant="primary")
921
- save_ar_btn = gr.Button("Save this query", variant="secondary")
922
- ar_table = gr.Dataframe(headers=["Ingredient","Cosine"], label="Top-K nearest to result vector")
923
- ar_explainer = gr.Markdown()
924
- ar_btn.click(arithmetic, inputs=[sibling, pos_box, neg_box, ar_k],
925
- outputs=[ar_table, ar_explainer], show_progress="full")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
926
  gr.Examples(
927
  examples=[
928
- ["core", ["miso"], ["salt"], 8],
929
- ["core", ["chicken","tofu"], ["beef"], 8],
930
- ["cooc", ["basil","cumin"], ["parsley"], 8],
931
- ["chem", ["chocolate"], ["sugar"], 8],
932
- ["chem", ["wine"], ["beer"], 8],
933
- ["core", ["bread"], ["flour"], 8],
934
- ["core", ["coffee"], ["milk"], 8],
935
- ["chem", ["mozzarella_cheese"], ["milk"], 8],
936
  ],
937
- inputs=[sibling, pos_box, neg_box, ar_k],
938
- label="Try one of these arithmetic queries",
939
  )
940
 
941
- # ---------- Tab 5: Mode atlas (click row -> UMAP) ----------
942
- with gr.Tab("Mode atlas", id="tab_atlas"):
943
- gr.Markdown(
944
- "Browse the GMM mode atlas. Cooc 150 / Core 193 / Chem 200 modes. "
945
- "**Click any row** to send that mode's members to the UMAP tab as a basket."
946
- )
947
- atlas_kind = gr.Radio(choices=["all","factor","continuous","binary"], value="all", label="Mode kind")
948
- atlas_search = gr.Textbox(label="Search labels / properties", placeholder="e.g. South Asian, baking, fiber", value="")
949
- atlas_btn = gr.Button("Browse modes", variant="primary")
950
- atlas_table = gr.Dataframe(
951
- headers=["mode_id","kind","property","label","n_members","top members"],
952
- label="Modes (click a row to highlight on UMAP)",
953
- wrap=True, interactive=False,
954
- )
955
- atlas_btn.click(browse_modes, inputs=[sibling, atlas_kind, atlas_search], outputs=atlas_table, show_progress="full")
956
-
957
- # ---------- Tab 6: Compare siblings ----------
958
- with gr.Tab("Compare siblings", id="tab_cmp"):
959
- gr.Markdown("Same query, three siblings, side by side.")
960
- cmp_radio, cmp_basket = _ingredient_picker("Seed basket", ["chicken"])
961
- cmp_dirs = gr.Dropdown(choices=_supervised_choices("chem"), value=[],
962
- label="Optional directions (empty = pure pairings)",
963
- multiselect=True, max_choices=5)
964
- cmp_theta = gr.Slider(0, 90, value=30, step=5, label="Rotation angle (deg)")
965
- cmp_k = gr.Slider(1, 15, value=8, step=1, label="K")
966
- with gr.Row():
967
- cmp_btn = gr.Button("Compare across siblings", variant="primary")
968
- save_cmp_btn = gr.Button("Save this query", variant="secondary")
969
  with gr.Row():
970
- cmp_cooc = gr.Dataframe(headers=["Cooc neighbour","Cosine"], label="Cooc (recipe-context)")
971
- cmp_core = gr.Dataframe(headers=["Core neighbour","Cosine"], label="Core (blended)")
972
- cmp_chem = gr.Dataframe(headers=["Chem neighbour","Cosine"], label="Chem (chemistry)")
973
- cmp_btn.click(compare_siblings, inputs=[cmp_basket, cmp_dirs, cmp_theta, cmp_k],
974
- outputs=[cmp_cooc, cmp_core, cmp_chem], show_progress="full")
975
-
976
- # ---------- Tab 7: UMAP ----------
977
- with gr.Tab("UMAP visualisation", id="tab_umap"):
978
- gr.Markdown(
979
- "2-D UMAP of the 1,790-ingredient embedding (cosine, n_neighbors=30, min_dist=0.03). "
980
- "Points coloured by food group. Basket members appear as accent stars; top-K neighbours as amber dots."
981
- )
982
- umap_radio, umap_basket = _ingredient_picker("Highlight these ingredients", ["chicken","lemon","garlic"])
983
  with gr.Row():
984
- umap_show_nb = gr.Checkbox(value=True, label="Show top-K neighbours of basket centroid")
985
- umap_3d = gr.Checkbox(value=False, label="3-D perspective (UMAP + PC1)")
986
- umap_k = gr.Slider(1, 20, value=10, step=1, label="K neighbours")
987
- umap_btn = gr.Button("Update plot", variant="primary")
988
- umap_plot = gr.Plot(value=_INITIAL_UMAP, label="UMAP")
989
- umap_btn.click(umap_view, inputs=[sibling, umap_basket, umap_show_nb, umap_k, umap_3d],
990
- outputs=umap_plot, show_progress="full")
991
- sibling.change(umap_view, inputs=[sibling, umap_basket, umap_show_nb, umap_k, umap_3d],
992
- outputs=umap_plot)
993
-
994
- # ---------- Tab 8: Parse my fridge ----------
995
- with gr.Tab("Parse my fridge", id="tab_fridge"):
996
- gr.Markdown(
997
- "Paste a free-text ingredient list. Quantities, units, and prep notes are stripped, "
998
- "then each line is fuzzy-matched to canonical vocab. Click **Send matched to Basket tab** "
999
- "to populate the Basket Pairings input."
 
1000
  )
1001
- fridge_text = gr.Textbox(
1002
- label="Free-text ingredients (one per line or semicolon-separated)",
1003
- lines=8,
1004
- value=("2 boneless chicken thighs\n1 cup coconut milk\n1 tbsp fish sauce (or soy sauce)\n"
1005
- "fresh lemongrass, bruised\n3 cloves garlic, minced\n1 inch fresh ginger\n"
1006
- "juice of one lime\nsalt to taste"),
1007
  )
1008
- fridge_min = gr.Slider(40, 100, value=70, step=5, label="Min match score (rapidfuzz)")
1009
  with gr.Row():
1010
- fridge_btn = gr.Button("Parse and match", variant="primary")
1011
- fridge_send = gr.Button("Send matched to Basket tab", variant="secondary")
1012
- fridge_table = gr.Dataframe(
1013
- headers=["Input line", "Canonical match", "Score", "Cleaned"],
1014
- label="Parsed matches", interactive=False,
1015
- )
1016
- fridge_matched = gr.Textbox(label="Matched ingredients", interactive=False)
1017
- def _parse(txt, sib, mn):
1018
- rows, matches = parse_fridge(txt, sib, int(mn))
1019
- return rows, ", ".join(matches), matches
1020
- fridge_btn.click(_parse, inputs=[fridge_text, sibling, fridge_min],
1021
- outputs=[fridge_table, fridge_matched, shared_basket], show_progress="full")
1022
- fridge_send.click(lambda matches: gr.Dropdown(value=matches[:10] if matches else []),
1023
- inputs=[shared_basket], outputs=[basket])
1024
-
1025
- # ---------- Tab 9: Recipe builder ----------
1026
- with gr.Tab("Recipe builder", id="tab_recipe"):
1027
- gr.Markdown(
1028
- "Describe a dish in plain English. Hybrid retrieval: rapidfuzz token matching for direct "
1029
- "ingredient mentions + sentence-transformer cosine against the sibling's factor-mode labels "
1030
- "for thematic matches. First call after Space cold-start downloads ~80MB encoder (one-time)."
1031
- )
1032
- rb_prompt = gr.Textbox(label="Dish description", lines=3,
1033
- value="I'm making Thai green curry for 4 people")
1034
- rb_k = gr.Slider(4, 20, value=10, step=1, label="Suggestions (K)")
1035
- rb_btn = gr.Button("Suggest starter basket", variant="primary")
1036
- rb_table = gr.Dataframe(
1037
- headers=["Ingredient", "Source", "Score"],
1038
- label="Suggested basket (source = direct / thematic / both)", interactive=False,
1039
- )
1040
- rb_explainer = gr.Markdown()
1041
- rb_matched = gr.State([])
1042
- rb_send = gr.Button("Send to Basket tab", variant="secondary")
1043
- def _rb(prompt, sib, k):
1044
- rows, names, md = suggest_basket(prompt, sib, int(k))
1045
- return rows, md, names
1046
- rb_btn.click(_rb, inputs=[rb_prompt, sibling, rb_k],
1047
- outputs=[rb_table, rb_explainer, rb_matched], show_progress="full")
1048
- rb_send.click(lambda names: gr.Dropdown(value=(names or [])[:10]),
1049
- inputs=[rb_matched], outputs=[basket])
1050
  gr.Examples(
1051
  examples=[
1052
- ["I'm making Thai green curry for 4 people", 10],
1053
- ["spicy vegetarian taco filling", 10],
1054
- ["weeknight pasta with tomatoes and herbs", 10],
1055
- ["Japanese miso-glazed salmon and greens", 10],
1056
- ["Moroccan tagine with lamb and dried fruit", 10],
1057
  ],
1058
- inputs=[rb_prompt, rb_k],
1059
- label="Try one of these prompts",
1060
- )
1061
-
1062
- # ---------- Tab 10: Saved queries ----------
1063
- with gr.Tab("Saved queries", id="tab_saved"):
1064
- gr.Markdown(
1065
- "Stored locally in your browser via `localStorage` (gr.BrowserState). "
1066
- "~5 MB quota; per-browser, not per-account. Clearing browser data wipes them."
1067
- )
1068
- saved_table = gr.Dataframe(
1069
- headers=["created_at", "tab", "summary", "id"],
1070
- label="Your saved queries (newest first)", interactive=False, wrap=True,
1071
  )
1072
- with gr.Row():
1073
- selected_id = gr.State("")
1074
- del_btn = gr.Button("Delete selected", variant="secondary")
1075
- def _on_select(saved, evt: gr.SelectData):
1076
- if evt is None or evt.index is None:
1077
- return ""
1078
- row = evt.index[0] if isinstance(evt.index, (list, tuple)) else evt.index
1079
- return (saved or [{}])[row].get("id", "") if row < len(saved or []) else ""
1080
- saved_table.select(_on_select, inputs=[saved_state], outputs=selected_id)
1081
- del_btn.click(delete_query, inputs=[saved_state, selected_id],
1082
- outputs=[saved_state, saved_table])
1083
- demo.load(lambda s: _render_saved(s), inputs=saved_state, outputs=saved_table)
1084
-
1085
- # ---- Wire Save buttons (after all tabs exist so all components are in scope) ----
1086
- save_basket_btn.click(
1087
- lambda s, sib, b, k: save_query(s, "basket",
1088
- {"sibling": sib, "basket": b or [], "k": int(k)}),
1089
- inputs=[saved_state, sibling, basket, k_pair],
1090
- outputs=[saved_state, saved_table],
1091
- )
1092
- save_sup_btn.click(
1093
- lambda s, sib, b, d, th, k: save_query(s, "supervised_slerp",
1094
- {"sibling": sib, "basket": b or [], "directions": d or [], "theta": float(th), "k": int(k)}),
1095
- inputs=[saved_state, sibling, sup_basket, sup_dirs, sup_theta, sup_k],
1096
- outputs=[saved_state, saved_table],
1097
- )
1098
- save_em_btn.click(
1099
- lambda s, sib, b, m, th, k: save_query(s, "emergent_slerp",
1100
- {"sibling": sib, "basket": b or [], "modes": m or [], "theta": float(th), "k": int(k)}),
1101
- inputs=[saved_state, sibling, em_basket, em_modes, em_theta, em_k],
1102
- outputs=[saved_state, saved_table],
1103
- )
1104
- save_ar_btn.click(
1105
- lambda s, sib, p, n, k: save_query(s, "arithmetic",
1106
- {"sibling": sib, "positives": p or [], "negatives": n or [], "k": int(k)}),
1107
- inputs=[saved_state, sibling, pos_box, neg_box, ar_k],
1108
- outputs=[saved_state, saved_table],
1109
- )
1110
- save_cmp_btn.click(
1111
- lambda s, b, d, th, k: save_query(s, "compare",
1112
- {"basket": b or [], "directions": d or [], "theta": float(th), "k": int(k)}),
1113
- inputs=[saved_state, cmp_basket, cmp_dirs, cmp_theta, cmp_k],
1114
- outputs=[saved_state, saved_table],
1115
- )
1116
-
1117
- # ---- Mode atlas row click -> UMAP highlight + jump to UMAP tab ----
1118
- def atlas_row_to_umap(sibling_value, table_value, show_nb, k_value, three_d_value, evt: gr.SelectData):
1119
- if evt is None or evt.index is None or table_value is None:
1120
- return gr.update(), gr.update(), gr.update(), gr.update()
1121
- row = evt.index[0] if isinstance(evt.index, (list, tuple)) else evt.index
1122
- try:
1123
- clicked_mode_id = (table_value.iloc[row, 0] if hasattr(table_value, "iloc")
1124
- else table_value[row][0])
1125
- except Exception:
1126
- return gr.update(), gr.update(), gr.update(), gr.update()
1127
- m = MODELS[sibling_value]
1128
- mode = next((md for md in m.modes if md.mode_id == clicked_mode_id), None)
1129
- if mode is None:
1130
- return gr.update(), gr.update(), gr.update(), gr.update()
1131
- members = [n for n in mode.members if n in m.vocab][:10]
1132
- if not members:
1133
- return gr.update(), gr.update(), gr.update(), gr.update()
1134
- fig = umap_view(sibling_value, members, bool(show_nb), int(k_value), three_d=bool(three_d_value))
1135
- return (
1136
- gr.Dropdown(value=members),
1137
- fig,
1138
- members,
1139
- gr.Tabs(selected="tab_umap"),
1140
- )
1141
- atlas_table.select(
1142
- atlas_row_to_umap,
1143
- inputs=[sibling, atlas_table, umap_show_nb, umap_k, umap_3d],
1144
- outputs=[umap_basket, umap_plot, shared_basket, tabs],
1145
- show_progress="hidden",
1146
- )
1147
 
1148
- # ---- Public API endpoints via hidden buttons (gr.api not always available) ----
1149
  with gr.Group(visible=False):
1150
  api_in_s1 = gr.Textbox(visible=False)
1151
  api_in_s2 = gr.Textbox(visible=False)
1152
- api_in_n = gr.Number(visible=False, value=5)
1153
  api_in_n2 = gr.Number(visible=False, value=30)
1154
  api_in_l1 = gr.JSON(visible=False, value=[])
1155
  api_in_l2 = gr.JSON(visible=False, value=[])
1156
- api_out = gr.JSON(visible=False)
1157
- api_btn_neigh = gr.Button(visible=False)
1158
- api_btn_slerp = gr.Button(visible=False)
1159
- api_btn_arith = gr.Button(visible=False)
1160
- api_btn_embed = gr.Button(visible=False)
1161
- api_btn_dirs = gr.Button(visible=False)
1162
- api_btn_modes = gr.Button(visible=False)
1163
- api_btn_neigh.click(api_neighbors, inputs=[api_in_s1, api_in_s2, api_in_n], outputs=api_out, api_name="neighbors")
1164
- api_btn_slerp.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")
1165
- api_btn_arith.click(api_arithmetic, inputs=[api_in_l1, api_in_l2, api_in_s1, api_in_n], outputs=api_out, api_name="arithmetic")
1166
- api_btn_embed.click(api_embed, inputs=[api_in_s1, api_in_s2], outputs=api_out, api_name="embed")
1167
- api_btn_dirs.click(api_list_directions, inputs=[api_in_s1], outputs=api_out, api_name="list_directions")
1168
- api_btn_modes.click(api_list_factor_modes, inputs=[api_in_s1], outputs=api_out, api_name="list_factor_modes")
1169
 
1170
  gr.Markdown(
1171
  """---
1172
- ### Developer API
1173
-
1174
- These operators are also exposed as JSON endpoints. See `/?view=api` for the auto-generated schema.
1175
-
1176
- ```python
1177
- from gradio_client import Client
1178
- c = Client("Kaikaku/epicure-explorer")
1179
- c.predict("garlic", "chem", 5, api_name="/neighbors")
1180
- c.predict("rice", "cuisine:South_Asian", 30, "chem", 5, api_name="/slerp")
1181
- c.predict(["miso"], ["salt"], "core", 8, api_name="/arithmetic")
1182
- c.predict("garlic", "chem", api_name="/embed") # 300-D L2-normalised vector
1183
- c.predict("chem", api_name="/list_directions")
1184
- c.predict("chem", api_name="/list_factor_modes")
1185
- ```
1186
-
1187
- Endpoints validate inputs and return `{"error": "...", "suggestions": [...]}` on bad input. Free-tier limits: ~1-2 req/sec shared, no auth, Space sleeps after ~48h idle (cold start ~30-60s on next request).
1188
-
1189
- ---
1190
- **Cite:** Radzikowski and Chen, 2026, *Epicure: Navigating the Emergent Geometry of Food Ingredient Embeddings*, [arXiv:2605.22391](https://arxiv.org/abs/2605.22391). Artefacts: [epicure-cooc](https://huggingface.co/Kaikaku/epicure-cooc) | [epicure-core](https://huggingface.co/Kaikaku/epicure-core) | [epicure-chem](https://huggingface.co/Kaikaku/epicure-chem) | [corpus dataset](https://huggingface.co/datasets/Kaikaku/epicure-corpus-resources)
1191
  """
1192
  )
1193
 
 
1
+ """Epicure Explorer - chef-facing operators over three sibling ingredient embeddings.
2
+
3
+ Simplified UI: 4 tabs.
4
+ - Explore : pick ingredients, see neighbours across all three siblings at once.
5
+ - Transform: rotate or do arithmetic on the basket (one tab for all three operators).
6
+ - Map : UMAP visualisation.
7
+ - From text: paste a recipe / dish description, fuzzy-match to canonical vocab.
 
 
 
 
 
 
 
 
8
 
9
  Paper: https://arxiv.org/abs/2605.22391
10
  """
11
 
12
  from __future__ import annotations
13
 
14
+ import os, re, sys, json
 
 
 
 
 
15
  from functools import lru_cache
16
 
17
  import numpy as np
 
32
  from rapidfuzz import process as fuzz_process, fuzz as fuzz_scorers
33
 
34
  # ===== Kaikaku brand =====
35
+ KAIKAKU_DARK = "#0F2D2F"
36
+ KAIKAKU_DEEP = "#0A1F20"
37
+ KAIKAKU_MID = "#1A3D3F"
38
+ KAIKAKU_EDGE = "#2A4D4F"
39
+ KAIKAKU_ACCENT = "#288B79"
40
  KAIKAKU_ACCENT_HOVER = "#1E6E5F"
41
  KAIKAKU_ACCENT_LIGHT = "#A8D5CA"
 
 
42
 
43
  plt.rcParams.update({
44
+ "figure.facecolor": "#ffffff", "axes.facecolor": "#ffffff",
45
+ "axes.edgecolor": "#cccccc", "axes.labelcolor": "#111111",
46
+ "xtick.color": "#333333", "ytick.color": "#333333",
47
+ "text.color": "#111111", "savefig.facecolor": "#ffffff",
 
 
 
 
48
  })
49
 
50
  MODELS = {
 
58
  UMAP_DATA = np.load(os.path.join(_HERE, "umap_2d.npz"))
59
  _lab = json.load(open(os.path.join(_HERE, "ingredient_labels.json")))
60
  NAMES_BY_IDX: list[str] = _lab["names"]
61
+ FOOD_GROUPS: list[str] = _lab["food_groups"]
62
 
63
  FG_COLORS = {
64
+ "Vegetable":"#2ca02c","Fruit":"#e377c2","Grain":"#bcbd22","Dairy":"#17becf",
65
+ "Spice":"#d62728","Pantry":"#ff7f0e","Beverage":"#9467bd","Other":"#cccccc",
 
 
 
 
 
 
66
  }
67
 
68
  print(f"[epicure-explorer] models loaded: {list(MODELS)}", flush=True)
 
 
 
69
 
70
+ # Food-group filter helpers
71
+ _NAME_TO_GROUP = {NAMES_BY_IDX[i]: FOOD_GROUPS[i] for i in range(len(NAMES_BY_IDX))}
72
+ FOOD_GROUP_CHOICES = ["All","Vegetable","Spice","Fruit","Dairy","Grain","Pantry","Beverage","Other"]
73
 
74
+ def _choices_for_group(group):
75
  if not group or group == "All":
76
  return ALL_INGREDIENTS
77
  return sorted(n for n in ALL_INGREDIENTS if _NAME_TO_GROUP.get(n, "Other") == group)
78
 
79
+ def _filter_dropdown(group, current_value):
80
  new_choices = _choices_for_group(group)
81
  allowed = set(new_choices)
82
  cur = current_value or []
 
86
  kept = [v for v in cur if v in allowed]
87
  return gr.Dropdown(choices=new_choices, value=kept)
88
 
89
+ # ===== math =====
90
 
91
  def _unit(v, eps=1e-9):
92
  n = np.linalg.norm(v); return v / max(n, eps)
 
128
  if n < 1e-9: return v
129
  d_perp = d_perp / n
130
  th = np.deg2rad(float(theta_deg))
131
+ return _unit(np.cos(th) * v + np.sin(th) * d_perp)
 
 
 
 
 
132
 
133
+ # ===== Heatmap =====
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
 
135
  def _basket_heatmap(m, basket):
136
  valid = [n for n in (basket or []) if n in m.vocab]
137
+ fig, ax = plt.subplots(figsize=(5.5, 4.5))
138
  if len(valid) < 2:
139
  ax.text(0.5, 0.5, "Add 2+ ingredients to see pairwise cosines",
140
+ ha="center", va="center", fontsize=12, color="#888", transform=ax.transAxes)
141
+ ax.axis("off"); plt.tight_layout(); return fig
 
 
 
142
  idxs = [m.vocab[n] for n in valid]
143
  sub = m.E[idxs]
144
  sim = sub @ sub.T
145
  im = ax.imshow(sim, cmap="viridis", vmin=-0.2, vmax=1.0, aspect="auto")
146
+ ax.set_xticks(range(len(valid))); ax.set_yticks(range(len(valid)))
147
+ ax.set_xticklabels(valid, rotation=35, ha="right"); ax.set_yticklabels(valid)
 
 
148
  for i in range(len(valid)):
149
  for j in range(len(valid)):
150
  v = float(sim[i, j])
151
+ ax.text(j, i, f"{v:.2f}", ha="center", va="center", fontsize=9,
152
+ color=("white" if v < 0.55 else "black"))
153
+ cb = plt.colorbar(im, ax=ax); cb.set_label("cosine")
 
 
154
  plt.tight_layout()
155
  return fig
156
 
 
163
  m = MODELS[sibling]
164
  E = m.E - m.E.mean(axis=0, keepdims=True)
165
  _, _, Vt = np.linalg.svd(E, full_matrices=False)
166
+ pc1 = E @ Vt[0]
167
  pc1 = (pc1 - pc1.mean()) / (pc1.std() + 1e-9)
168
  scale = (base.max() - base.min()) * 0.25
169
  return base, (pc1 * scale).astype(np.float32)
 
176
  hover_text = [f"{NAMES_BY_IDX[i]}<br>group: {FOOD_GROUPS[i]}" for i in range(n)]
177
  basket_set = set(basket or [])
178
  basket_idxs = [m.vocab[b] for b in (basket or []) if b in m.vocab]
179
+ neighbour_set = set()
180
  if show_neighbours and basket_idxs:
181
  centroid = _basket_centroid(m, basket)
182
  if centroid is not None:
183
+ nb = _topk(m, centroid, k=int(k), exclude=basket)
184
+ neighbour_set = {nm for nm, _ in nb}
185
+ keep = lambda i: NAMES_BY_IDX[i] not in basket_set and NAMES_BY_IDX[i] not in neighbour_set
186
+ bg_x = [float(coords2[i, 0]) for i in range(n) if keep(i)]
187
+ bg_y = [float(coords2[i, 1]) for i in range(n) if keep(i)]
188
+ bg_z = [float(z[i]) for i in range(n) if keep(i)] if three_d else None
189
+ bg_c = [colors[i] for i in range(n) if keep(i)]
190
+ bg_h = [hover_text[i] for i in range(n) if keep(i)]
191
  fig = go.Figure()
192
  if three_d:
193
+ fig.add_trace(go.Scatter3d(x=bg_x, y=bg_y, z=bg_z, mode="markers",
194
+ marker=dict(size=3, color=bg_c, opacity=0.55), text=bg_h,
195
+ hovertemplate="%{text}<extra></extra>", name="ingredients", showlegend=False))
 
 
196
  else:
197
+ fig.add_trace(go.Scattergl(x=bg_x, y=bg_y, mode="markers",
198
+ marker=dict(size=5, color=bg_c, opacity=0.65), text=bg_h,
199
+ hovertemplate="%{text}<extra></extra>", name="ingredients", showlegend=False))
 
 
200
  if neighbour_set:
201
  ni = [i for i in range(n) if NAMES_BY_IDX[i] in neighbour_set]
202
+ nx = [float(coords2[i, 0]) for i in ni]; ny = [float(coords2[i, 1]) for i in ni]
 
203
  nz = [float(z[i]) for i in ni] if three_d else None
204
+ nl = [NAMES_BY_IDX[i] for i in ni]
205
+ mk = dict(size=11 if not three_d else 6, color="#ff8800", opacity=0.95,
206
+ line=dict(color="#ffffff", width=1.2))
207
  TR = go.Scatter3d if three_d else go.Scatter
208
+ kw = dict(mode="markers+text", marker=mk, text=nl, textposition="top center",
209
+ textfont=dict(size=10),
210
+ hovertemplate="<b>%{text}</b> (neighbour)<extra></extra>",
211
+ name=f"top-{k} neighbours")
212
+ fig.add_trace(TR(x=nx, y=ny, z=nz, **kw) if three_d else TR(x=nx, y=ny, **kw))
213
  if basket_idxs:
214
  bx = [float(coords2[i, 0]) for i in basket_idxs]
215
  by = [float(coords2[i, 1]) for i in basket_idxs]
216
  bz = [float(z[i]) for i in basket_idxs] if three_d else None
217
+ bl = [NAMES_BY_IDX[i] for i in basket_idxs]
218
+ mk = dict(size=18 if not three_d else 9, color=KAIKAKU_ACCENT,
219
+ symbol="star" if not three_d else "diamond",
220
+ line=dict(color="#111111", width=1.5))
221
  TR = go.Scatter3d if three_d else go.Scatter
222
+ kw = dict(mode="markers+text", marker=mk, text=bl, textposition="top center",
223
+ textfont=dict(size=13, color="#111111"),
224
+ hovertemplate="<b>%{text}</b> (basket)<extra></extra>", name="basket")
225
+ fig.add_trace(TR(x=bx, y=by, z=bz, **kw) if three_d else TR(x=bx, y=by, **kw))
 
226
  fig.update_layout(
227
+ title=dict(text=f"UMAP - Epicure-{sibling.capitalize()}{' (3D)' if three_d else ''}", font=dict(size=14)),
228
+ height=620, margin=dict(l=40, r=40, t=50, b=40),
229
  paper_bgcolor="#ffffff", plot_bgcolor="#ffffff",
230
  legend=dict(orientation="v", x=1.02, y=1, font=dict(size=11)),
231
  )
232
  if not three_d:
233
+ fig.update_xaxes(showgrid=True, gridcolor="#eee", zeroline=False, title="UMAP 1")
234
+ fig.update_yaxes(showgrid=True, gridcolor="#eee", zeroline=False, title="UMAP 2")
235
  else:
236
  fig.update_layout(scene=dict(xaxis=dict(title="UMAP 1"), yaxis=dict(title="UMAP 2"),
237
  zaxis=dict(title="PC1 (z)"), bgcolor="#ffffff"))
238
  return fig
239
 
240
+ # ===== Explore: side-by-side neighbours across siblings =====
241
 
242
+ def explore_all_siblings(basket, k):
243
+ """Returns 3 dataframes (Cooc/Core/Chem neighbours), heatmap, and mode tables per sibling."""
244
+ out_nb = []
245
+ out_modes = []
246
+ for sib in ["cooc","core","chem"]:
247
+ m = MODELS[sib]
248
+ c = _basket_centroid(m, basket)
249
+ if c is None:
250
+ out_nb.append([]); out_modes.append([]); continue
251
+ nb = _topk(m, c, int(k), exclude=basket or [])
252
+ out_nb.append([[n, f"{s:.4f}"] for n, s in nb])
253
+ scored = [(mode.mode_id, mode.label, mode.kind, float(_unit(mode.pole) @ c)) for mode in m.modes]
254
+ scored.sort(key=lambda x: -x[3])
255
+ out_modes.append([[mid, label, kind, f"{sim:.3f}"] for mid, label, kind, sim in scored[:5]])
256
+ heat = _basket_heatmap(MODELS["chem"], basket)
257
+ return out_nb[0], out_nb[1], out_nb[2], heat, out_modes[0], out_modes[1], out_modes[2]
258
+
259
+ # ===== Transform: unified operator =====
260
+
261
+ def transform(sibling, op, basket, directions, mode_labels, theta, negatives, k):
262
  m = MODELS[sibling]
263
+ if op == "Rotate to supervised direction":
264
+ v = _basket_centroid(m, basket)
265
+ if v is None: return [], "_(empty basket)_"
266
+ d = _stack_directions(m, directions, use_factor_pole=False)
267
+ if d is None: return [[n, f"{s:.4f}"] for n, s in _topk(m, v, k, basket)], "_(no direction selected)_"
268
+ q = _slerp(v, d, theta)
269
+ rows = [[n, f"{s:.4f}"] for n, s in _topk(m, q, k, basket)]
270
+ return rows, _explain_slerp(m, basket, directions or [], theta, q, v, d)
271
+ if op == "Rotate to emergent mode":
272
+ label_to_id = {f"{md.label} ({md.mode_id})": md.mode_id for md in m.modes if md.kind == "factor"}
273
+ mode_ids = [label_to_id[lab] for lab in (mode_labels or []) if lab in label_to_id]
274
+ v = _basket_centroid(m, basket)
275
+ if v is None: return [], "_(empty basket)_"
276
+ d = _stack_directions(m, mode_ids, use_factor_pole=True)
277
+ if d is None: return [[n, f"{s:.4f}"] for n, s in _topk(m, v, k, basket)], "_(no mode selected)_"
278
+ q = _slerp(v, d, theta)
279
+ rows = [[n, f"{s:.4f}"] for n, s in _topk(m, q, k, basket)]
280
+ return rows, _explain_slerp(m, basket, mode_ids, theta, q, v, d)
281
+ # Arithmetic
282
+ pos = _basket_centroid(m, basket)
283
+ if pos is None: return [], "_(no positives)_"
284
+ neg = _basket_centroid(m, negatives) if negatives else None
285
+ q = _unit(pos - neg) if neg is not None else pos
286
+ rows = [[n, f"{s:.4f}"] for n, s in _topk(m, q, k, (basket or []) + (negatives or []))]
287
+ return rows, _explain_arithmetic(m, basket, negatives or [], q)
288
+
289
+ def _explain_slerp(m, basket, dir_keys, theta, q, v, d):
290
+ if q is None or v is None or d is None: return ""
291
+ cos_theta = float(q @ v)
292
+ travelled = min(max(float(theta) / 90.0, 0.0), 1.0)
293
+ dir_nb = _topk(m, _unit(d), 5, exclude=basket or [])
294
+ seed_nb = _topk(m, v, 3, exclude=basket or [])
295
+ dirs_str = " + ".join(dir_keys) if dir_keys else "(none)"
296
  return (
297
+ f"**Why these results.** Rotated cos to seed = {cos_theta:.3f} "
298
+ f"({travelled*100:.0f}% of the way to {dirs_str}). "
299
+ f"Direction's own neighbourhood: {', '.join(n for n, _ in dir_nb[:5])}. "
300
+ f"Seed basket's own top-3: {', '.join(n for n, _ in seed_nb)}."
301
  )
302
 
303
+ def _explain_arithmetic(m, positives, negatives, q):
304
+ if q is None: return ""
305
+ pos_sims = [(n, float(_unit(m.E[m.vocab[n]]) @ q)) for n in positives if n in m.vocab]
306
+ neg_sims = [(n, float(_unit(m.E[m.vocab[n]]) @ q)) for n in negatives if n in m.vocab]
307
+ pp = ", ".join(f"{n} ({s:+.2f})" for n, s in pos_sims) or "(none)"
308
+ np_ = ", ".join(f"{n} ({s:+.2f})" for n, s in neg_sims) or "(none)"
309
+ return f"**Why these results.** Result vs positives: {pp}. Result vs negatives: {np_}."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
310
 
311
+ # ===== From-text: combined fridge parser + recipe builder =====
 
 
 
 
 
 
 
 
 
 
 
312
 
313
+ _LINE_SPLIT = re.compile(r"[\n;]")
314
+ _BRACKET = re.compile(r"\([^)]*\)")
315
+ _QTY = r"(?:\d+(?:[\.,/]\d+)?|a|an|one|two|three|four|five|six|seven|eight|nine|ten|half|quarter)"
316
+ _UNIT = (r"(?:cups?|tbsp\.?|tablespoons?|tsp\.?|teaspoons?|oz\.?|ounces?|lbs?\.?|pounds?|"
317
+ r"grams?|kgs?|kilos?|ml|liters?|litres?|cloves?|bunches?|sprigs?|pinch(?:es)?|"
318
+ r"slices?|pieces?|cans?|packets?|sticks?|leaves?|stalks?|heads?|inch(?:es)?|"
319
+ r"splash(?:es)?|dash(?:es)?|drops?|handfuls?|large|small|medium)")
320
+ _LEADING_QTY = re.compile(rf"^\s*{_QTY}\s+(?:{_UNIT}\b\s*)?(?:of\s+)?", re.IGNORECASE)
321
+ _LEADING_UNIT_ONLY = re.compile(rf"^\s*{_UNIT}\b\s*(?:of\s+)?", re.IGNORECASE)
322
+ _JUICE_OF = re.compile(rf"^\s*(?:juice|zest)\s+(?:of\s+)?(?:{_QTY}\s+)?", re.IGNORECASE)
323
+ _LEADING_PREP = re.compile(
324
+ r"^\s*(?:fresh|dried|cooked|frozen|raw|ripe|firm|boneless|skinless|smoked|low[- ]fat)\s+", re.IGNORECASE)
325
+ _TRAILING_PREP = re.compile(
326
+ r"\s*,\s*(?:chopped|minced|diced|sliced|grated|crushed|whole|ground|peeled|"
327
+ r"to taste|optional|finely|coarsely|cubed|shredded|julienned|halved|quartered|warmed|"
328
+ r"toasted|roasted|bruised|melted|softened|cooked|drained|rinsed|patted dry|trimmed|"
329
+ r"deveined|seeded|stemmed|crumbled).*$", re.IGNORECASE)
330
+ _KNOWN_PLURALS = {"tortillas":"tortilla","thighs":"thigh","leaves":"leaf","onions":"onion",
331
+ "potatoes":"potato","tomatoes":"tomato","cloves":"clove"}
332
 
333
+ def _clean_line(line):
334
+ s = line.strip().lower()
335
+ s = _BRACKET.sub(" ", s)
336
+ if "juice" in s or "zest" in s:
337
+ s = _JUICE_OF.sub("", s)
338
+ s = _TRAILING_PREP.sub("", s)
339
+ s = _LEADING_QTY.sub("", s)
340
+ s = _LEADING_UNIT_ONLY.sub("", s)
341
+ s = _LEADING_PREP.sub("", s)
342
+ s = _LEADING_PREP.sub("", s)
343
+ tokens = [_KNOWN_PLURALS.get(t, t) for t in s.split()]
344
+ return re.sub(r"\s+", " ", " ".join(tokens)).strip()
345
 
346
+ def _fuzzy_lookup(cleaned, vocab, vocab_sp, min_score):
347
+ if not cleaned: return None, 0.0
348
+ candidates = []
349
+ for scorer in (fuzz_scorers.token_set_ratio, fuzz_scorers.WRatio, fuzz_scorers.partial_ratio):
350
+ hits = fuzz_process.extract(cleaned, vocab_sp, scorer=scorer, score_cutoff=min_score, limit=10)
351
+ for _sp, score, idx in hits:
352
+ candidates.append((vocab[idx], float(score)))
353
+ if not candidates: return None, 0.0
354
+ cleaned_tokens = set(cleaned.split())
355
+ def rank(c):
356
+ name, score = c
357
+ nt = set(name.replace("_", " ").split())
358
+ return (-score, 0 if nt.issubset(cleaned_tokens) else 1, -len(name))
359
+ candidates.sort(key=rank)
360
+ return candidates[0]
361
+
362
+ def parse_fridge(raw_text, sibling="chem", min_score=70):
363
+ if not raw_text or not raw_text.strip(): return [], []
364
+ vocab = list(MODELS[sibling].vocab.keys())
365
+ vocab_sp = [v.replace("_", " ") for v in vocab]
366
+ rows, matched = [], []
367
+ for line in _LINE_SPLIT.split(raw_text):
368
+ if not line.strip(): continue
369
+ cleaned = _clean_line(line)
370
+ if not cleaned:
371
+ rows.append([line.strip(), "(empty)", 0.0]); continue
372
+ match, score = _fuzzy_lookup(cleaned, vocab, vocab_sp, int(min_score))
373
+ if match is None:
374
+ tokens = cleaned.split()
375
+ if len(tokens) > 1:
376
+ match, score = _fuzzy_lookup(" ".join(tokens[:-1]), vocab, vocab_sp, int(min_score))
377
+ if match is None:
378
+ rows.append([line.strip(), "(no match)", 0.0]); continue
379
+ rows.append([line.strip(), match, round(score, 1)])
380
+ matched.append(match)
381
+ seen, dedup = set(), []
382
+ for n in matched:
383
+ if n not in seen: seen.add(n); dedup.append(n)
384
+ return rows, dedup
385
+
386
+ # Sentence-transformer for thematic queries
387
  _ST = None
388
  def _get_st():
389
  global _ST
390
  if _ST is None:
 
391
  from sentence_transformers import SentenceTransformer
392
+ _ST = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2", device="cpu")
393
  return _ST
394
 
395
  @lru_cache(maxsize=4)
396
+ def _mode_label_matrix(sibling):
397
  m = MODELS[sibling]
398
  modes = [md for md in m.modes if md.kind == "factor"]
399
  if not modes:
 
408
  n = max(4, min(12, (len(members) + 3) // 4))
409
  return members[:n]
410
 
411
+ _STOP = {"i","im","i'm","a","an","the","for","of","with","and","or","some","my","me","we",
412
+ "make","making","cook","cooking","prepare","preparing","want","need","to","tonight",
413
+ "people","person","servings","dinner","lunch","dish","recipe","quick","easy",
414
+ "tasty","yummy","good","great","food","meal","style"}
415
+ _TOK_RE = re.compile(r"[A-Za-z][A-Za-z\-']{1,}")
 
 
416
 
417
+ def suggest_basket(prompt, sibling="chem", k=10):
418
  if not prompt or not prompt.strip():
419
+ return [], [], "_(empty prompt)_"
420
  vocab = list(MODELS[sibling].vocab.keys())
421
+ vocab_sp = [v.replace("_"," ") for v in vocab]
422
+ tokens = [t for t in _TOK_RE.findall(prompt.lower()) if t not in _STOP and len(t) > 2]
 
423
  direct = {}
424
  direct_evidence = []
425
  for tok in tokens:
 
443
  for mid, lab, sim in picked:
444
  for name in _mode_quartile(id_to_mode[mid]):
445
  s_existing, _ = thematic.get(name, (0.0, ""))
446
+ thematic[name] = (max(s_existing, sim * 100.0), lab)
 
447
  combined = {}
448
+ for name, sc in direct.items(): combined[name] = (sc, "direct")
 
449
  for name, (sc, lab) in thematic.items():
450
  prev = combined.get(name)
451
  if prev is None or sc > prev[0]:
452
+ combined[name] = (sc, "both" if prev else "thematic")
 
453
  ranked = sorted(combined.items(),
454
  key=lambda kv: (-kv[1][0], 0 if kv[1][1] != "thematic" else 1, kv[0]))[:int(k)]
455
  rows = [[name, src, round(score, 1)] for name, (score, src) in ranked]
456
  names = [name for name, _ in ranked]
457
  lines = []
458
  if direct_evidence:
459
+ lines.append("**Direct mentions:** " + ", ".join(sorted({f"`{n}`" for _, n, _ in direct_evidence})))
 
 
 
460
  if thematic_modes:
461
+ lines.append("**Matched modes:** " + "; ".join(f"`{lab}` (cos {sim:.2f})" for _, lab, sim in thematic_modes))
462
+ return rows, names, "\n\n".join(lines) if lines else "_(no matches)_"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
463
 
464
+ def parse_or_suggest(text, sibling, mode_choice):
465
+ """Auto-detect: fridge-list if mostly short lines with units; recipe-prompt otherwise."""
466
+ if not text or not text.strip(): return [], "_(empty)_", []
467
+ if mode_choice == "Recipe / dish description":
468
+ rows, names, expl = suggest_basket(text, sibling, 10)
469
+ return rows, expl, names
470
+ rows, names = parse_fridge(text, sibling, 70)
471
+ return rows, f"Matched {len(names)} ingredients.", names
 
 
 
 
472
 
473
+ # ===== Mode atlas (used inside Explore Accordion) =====
 
 
 
 
 
 
 
 
 
 
 
 
 
 
474
 
475
+ def browse_modes(sibling, kind_filter, query):
476
+ m = MODELS[sibling]
477
+ rows, q = [], (query or "").strip().lower()
478
+ for mode in m.modes:
479
+ if kind_filter != "all" and mode.kind != kind_filter:
480
+ continue
481
+ if q and q not in mode.label.lower() and q not in mode.property.lower():
482
+ continue
483
+ rows.append([mode.mode_id, mode.kind, mode.property, mode.label, mode.n_members,
484
+ ", ".join(mode.members[:10])])
485
+ rows.sort(key=lambda r: (r[1], -r[4]))
486
+ return rows
 
 
 
 
 
 
 
 
 
 
 
487
 
488
+ # ===== Public API endpoint helpers =====
489
 
490
+ def _suggest(name, sibling, n=5):
491
  vocab = list(MODELS[sibling].vocab.keys())
492
  hits = fuzz_process.extract((name or "").lower().replace(" ", "_"),
493
  vocab, scorer=fuzz_scorers.WRatio, limit=n)
494
  return [h[0] for h in hits]
495
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
496
  def api_neighbors(ingredient, sibling="chem", k=5):
497
+ if sibling not in MODELS: return {"error": "bad sibling"}
498
+ if ingredient not in MODELS[sibling].vocab: return {"error": f"'{ingredient}' not in vocab", "suggestions": _suggest(ingredient, sibling)}
499
  m = MODELS[sibling]
500
  q = _unit(m.E[m.vocab[ingredient]])
501
+ return [{"name": n, "cosine": round(float(s), 6)} for n, s in _topk(m, q, int(k), [ingredient])]
 
502
 
503
  def api_slerp(seed, direction, theta_deg=30, sibling="chem", k=5):
504
+ if sibling not in MODELS: return {"error": "bad sibling"}
 
505
  m = MODELS[sibling]
506
+ if seed not in m.vocab: return {"error": f"'{seed}' not in vocab", "suggestions": _suggest(seed, sibling)}
507
+ if direction not in m.supervised_poles: return {"error": f"'{direction}' not a supervised pole"}
 
508
  v = _unit(m.E[m.vocab[seed]])
509
  d = _unit(m.supervised_poles[direction])
510
  q = _slerp(v, d, float(theta_deg))
511
+ return [{"name": n, "cosine": round(float(s), 6)} for n, s in _topk(m, q, int(k), [seed])]
 
512
 
513
  def api_arithmetic(positives, negatives, sibling="chem", k=5):
514
+ if sibling not in MODELS: return {"error": "bad sibling"}
515
+ positives = list(positives or []); negatives = list(negatives or [])
516
+ if not positives: return {"error": "positives must be non-empty"}
 
 
 
517
  m = MODELS[sibling]
518
  unknown = [x for x in positives + negatives if x not in m.vocab]
519
+ if unknown: return {"error": f"unknown: {unknown}"}
 
 
520
  pos = _basket_centroid(m, positives)
521
  neg = _basket_centroid(m, negatives) if negatives else None
522
  q = _unit(pos - neg) if neg is not None else pos
523
+ return [{"name": n, "cosine": round(float(s), 6)} for n, s in _topk(m, q, int(k), positives + negatives)]
 
524
 
525
  def api_embed(ingredient, sibling="chem"):
526
+ if sibling not in MODELS: return {"error": "bad sibling"}
 
527
  m = MODELS[sibling]
528
+ if ingredient not in m.vocab: return {"error": f"'{ingredient}' not in vocab"}
529
+ return [float(x) for x in _unit(m.E[m.vocab[ingredient]]).tolist()]
530
 
531
+ # ===== Theme =====
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
532
 
533
  THEME = gr.themes.Soft(
534
  primary_hue=gr.themes.Color(
 
540
  neutral_hue="slate",
541
  font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui", "sans-serif"],
542
  ).set(
543
+ block_label_text_color="#1f2937", block_label_text_weight="600",
544
+ block_title_text_color="#0f172a", block_title_text_weight="700",
545
+ body_text_color="#0f172a", body_text_color_subdued="#475569",
 
 
 
546
  button_primary_background_fill=KAIKAKU_ACCENT,
547
  button_primary_background_fill_hover=KAIKAKU_ACCENT_HOVER,
548
  button_primary_text_color="#ffffff",
549
  button_primary_border_color=KAIKAKU_ACCENT,
550
  button_secondary_background_fill="#f1f5f9",
 
551
  button_secondary_text_color=KAIKAKU_DARK,
552
  slider_color=KAIKAKU_ACCENT,
553
  color_accent=KAIKAKU_ACCENT,
 
557
  .gradio-container {{max-width: 1280px !important;}}
558
  footer {{visibility: hidden;}}
559
  .gradio-container label, .gradio-container .label,
560
+ .gradio-container [data-testid="block-label"], .gradio-container .block-label {{
 
561
  color: #0f172a !important; font-weight: 600 !important; background: transparent !important;
562
  }}
563
  .gradio-container button[role="tab"] {{ color: #334155 !important; font-weight: 500 !important; }}
 
568
  background: {KAIKAKU_ACCENT} !important; color: #ffffff !important;
569
  border-color: {KAIKAKU_ACCENT} !important; font-weight: 600 !important;
570
  }}
571
+ .gradio-container table thead th {{
 
572
  color: #0f172a !important; font-weight: 700 !important; background: #f8fafc !important;
573
  }}
574
  .gradio-container table tbody td {{ color: #0f172a !important; }}
575
+
576
+ /* Spectrum bar */
577
+ .spectrum-bar {{
578
+ display: flex; align-items: stretch; margin: 12px 0 4px 0; height: 56px;
579
+ border-radius: 8px; overflow: hidden;
580
+ box-shadow: 0 1px 2px rgba(0,0,0,0.05);
581
  }}
582
+ .spectrum-cell {{
583
+ flex: 1; display: flex; flex-direction: column; justify-content: center;
584
+ padding: 6px 14px; color: #0f172a;
585
+ }}
586
+ .spectrum-cell-1 {{ background: #f0f9f6; }}
587
+ .spectrum-cell-2 {{ background: #d8efe7; }}
588
+ .spectrum-cell-3 {{ background: #b8dfd1; }}
589
+ .spectrum-name {{ font-weight: 700; font-size: 0.95em; }}
590
+ .spectrum-sub {{ font-size: 0.8em; color: #475569; }}
591
+ .spectrum-arrow {{ width: 16px; background: transparent; display:flex; align-items:center; justify-content:center; color: #94a3b8; }}
592
  """
593
 
594
+ SPECTRUM_BAR = """
595
+ <div class="spectrum-bar">
596
+ <div class="spectrum-cell spectrum-cell-1">
597
+ <div class="spectrum-name">Cooc</div>
598
+ <div class="spectrum-sub">recipe co-occurrence; neighbours = recipe companions</div>
599
+ </div>
600
+ <div class="spectrum-arrow">&#8594;</div>
601
+ <div class="spectrum-cell spectrum-cell-2">
602
+ <div class="spectrum-name">Core</div>
603
+ <div class="spectrum-sub">blended; concentrated geometry; tightest emergent modes</div>
604
+ </div>
605
+ <div class="spectrum-arrow">&#8594;</div>
606
+ <div class="spectrum-cell spectrum-cell-3">
607
+ <div class="spectrum-name">Chem</div>
608
+ <div class="spectrum-sub">FlavorDB compound metapaths; neighbours = flavour-profile peers</div>
609
+ </div>
610
  </div>
611
  """
612
 
613
+ # ===== Pre-rendered killer demo on landing =====
614
 
615
+ _DEFAULT_BASKET = ["chicken","lemon","garlic"]
616
+ _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)
617
+ _INIT_UMAP = umap_view("chem", _DEFAULT_BASKET, True, 8)
 
 
 
 
618
 
619
  # ===== UI =====
620
 
621
  with gr.Blocks(title="Epicure Explorer", theme=THEME, css=CUSTOM_CSS) as demo:
622
 
 
 
623
  gr.Markdown(
624
  """# Epicure Explorer
625
+ Three sibling ingredient embeddings from [arXiv:2605.22391](https://arxiv.org/abs/2605.22391).
626
+ 1,790 canonical ingredients across 7 languages; 300-D Metapath2Vec; controlled chemistry-vs-recipe-context spectrum.
627
+ """
628
  )
629
+ gr.HTML(SPECTRUM_BAR)
630
 
631
+ with gr.Tabs():
 
 
632
 
633
+ # ---------- Tab 1: EXPLORE ----------
634
+ with gr.Tab("Explore"):
635
+ gr.Markdown("Pick ingredients. See nearest neighbours in **all three siblings side-by-side** so the spectrum shows in one screen.")
 
 
 
 
 
 
636
  with gr.Row():
637
+ ex_basket = gr.Dropdown(choices=ALL_INGREDIENTS, value=_DEFAULT_BASKET,
638
+ label="Ingredient basket", multiselect=True, max_choices=10,
639
+ scale=4)
640
+ ex_k = gr.Slider(3, 15, value=8, step=1, label="K", scale=1)
641
  with gr.Row():
642
+ ex_fg = gr.Radio(choices=FOOD_GROUP_CHOICES, value="All",
643
+ label="Filter dropdown by food group", interactive=True, scale=3)
644
+ ex_btn = gr.Button("Find neighbours", variant="primary", scale=1)
645
+ ex_fg.change(_filter_dropdown, inputs=[ex_fg, ex_basket], outputs=ex_basket, show_progress="hidden")
646
+
647
  gr.Examples(
648
  examples=[
649
+ [["chicken","lemon","garlic"], 8],
650
+ [["miso","ginger","sesame_oil"], 8],
651
+ [["tomato","basil","mozzarella_cheese"], 8],
652
+ [["chocolate","strawberry","cream"], 8],
653
+ [["cumin","coriander","turmeric"], 8],
654
+ [["coconut_milk","lemongrass","fish_sauce"], 8],
655
+ [["red_wine","beef","rosemary"], 8],
 
656
  ],
657
+ inputs=[ex_basket, ex_k],
658
+ label="Try a basket (one click)",
659
  )
660
 
 
 
 
 
 
 
 
 
 
661
  with gr.Row():
662
+ ex_nb_cooc = gr.Dataframe(value=_INIT_NB_COOC, headers=["Cooc","cos"],
663
+ label="Cooc (recipe-context)", interactive=False)
664
+ ex_nb_core = gr.Dataframe(value=_INIT_NB_CORE, headers=["Core","cos"],
665
+ label="Core (blended)", interactive=False)
666
+ ex_nb_chem = gr.Dataframe(value=_INIT_NB_CHEM, headers=["Chem","cos"],
667
+ label="Chem (chemistry)", interactive=False)
668
+
669
+ with gr.Accordion("Closest modes (per sibling)", open=False):
670
+ with gr.Row():
671
+ ex_md_cooc = gr.Dataframe(value=_INIT_MD_COOC, headers=["id","label","kind","cos"],
672
+ label="Cooc top modes", interactive=False, wrap=True)
673
+ ex_md_core = gr.Dataframe(value=_INIT_MD_CORE, headers=["id","label","kind","cos"],
674
+ label="Core top modes", interactive=False, wrap=True)
675
+ ex_md_chem = gr.Dataframe(value=_INIT_MD_CHEM, headers=["id","label","kind","cos"],
676
+ label="Chem top modes", interactive=False, wrap=True)
677
+
678
+ with gr.Accordion("Pairwise coherence (basket members)", open=False):
679
+ ex_heat = gr.Plot(value=_INIT_HEATMAP, label="Heatmap")
680
+
681
+ with gr.Accordion("Browse the mode atlas (150-200 modes per sibling)", open=False):
682
+ with gr.Row():
683
+ atlas_sib = gr.Radio(choices=["cooc","core","chem"], value="chem", label="Sibling")
684
+ atlas_kind = gr.Radio(choices=["all","factor","continuous","binary"], value="all", label="Kind")
685
+ atlas_q = gr.Textbox(label="Search labels", placeholder="e.g. South Asian, baking", scale=2)
686
+ atlas_btn = gr.Button("Browse", variant="primary")
687
+ atlas_table = gr.Dataframe(
688
+ headers=["mode_id","kind","property","label","n_members","top members"],
689
+ interactive=False, wrap=True,
690
+ )
691
+ atlas_btn.click(browse_modes, inputs=[atlas_sib, atlas_kind, atlas_q], outputs=atlas_table)
692
+
693
+ ex_btn.click(
694
+ explore_all_siblings,
695
+ inputs=[ex_basket, ex_k],
696
+ outputs=[ex_nb_cooc, ex_nb_core, ex_nb_chem, ex_heat, ex_md_cooc, ex_md_core, ex_md_chem],
697
+ show_progress="minimal",
698
  )
699
 
700
+ # ---------- Tab 2: TRANSFORM ----------
701
+ with gr.Tab("Transform"):
702
+ gr.Markdown("Rotate the basket toward a direction, an emergent mode, or compute `basket - negatives`. **All three operators on one form.**")
703
+ with gr.Row():
704
+ tx_sib = gr.Radio(choices=["cooc","core","chem"], value="core", label="Sibling")
705
+ tx_op = gr.Radio(
706
+ choices=["Rotate to supervised direction","Rotate to emergent mode","Arithmetic (basket - negatives)"],
707
+ value="Arithmetic (basket - negatives)", label="Operation",
708
+ )
 
709
  with gr.Row():
710
+ tx_basket = gr.Dropdown(choices=ALL_INGREDIENTS, value=["miso"], label="Basket / positives",
711
+ multiselect=True, max_choices=10, scale=3)
712
+ tx_neg = gr.Dropdown(choices=ALL_INGREDIENTS, value=["salt"], label="Negatives (Arithmetic only)",
713
+ multiselect=True, max_choices=10, scale=2)
 
 
 
 
 
 
 
 
 
 
 
 
714
  with gr.Row():
715
+ tx_dirs = gr.Dropdown(choices=_supervised_choices("core"), value=[],
716
+ label="Supervised directions (for 'Rotate to supervised')",
717
+ multiselect=True, max_choices=5, scale=3)
718
+ tx_modes = gr.Dropdown(choices=[lab for lab, _ in _factor_mode_choices("core")], value=[],
719
+ label="Factor modes (for 'Rotate to emergent')",
720
+ multiselect=True, max_choices=5, scale=3)
721
+ with gr.Row():
722
+ tx_theta = gr.Slider(0, 90, value=30, step=5, label="Rotation angle (deg, SLERP only)", scale=2)
723
+ tx_k = gr.Slider(3, 15, value=8, step=1, label="K", scale=1)
724
+ tx_btn = gr.Button("Run", variant="primary", scale=1)
725
+ tx_sib.change(lambda s: gr.Dropdown(choices=_supervised_choices(s), value=[]),
726
+ inputs=tx_sib, outputs=tx_dirs)
727
+ tx_sib.change(lambda s: gr.Dropdown(choices=[lab for lab, _ in _factor_mode_choices(s)], value=[]),
728
+ inputs=tx_sib, outputs=tx_modes)
729
+ tx_table = gr.Dataframe(headers=["Ingredient","cos"], label="Top-K result", interactive=False)
730
+ tx_why = gr.Markdown()
731
+ tx_btn.click(
732
+ transform,
733
+ inputs=[tx_sib, tx_op, tx_basket, tx_dirs, tx_modes, tx_theta, tx_neg, tx_k],
734
+ outputs=[tx_table, tx_why], show_progress="minimal",
735
+ )
736
  gr.Examples(
737
  examples=[
738
+ ["core", "Arithmetic (basket - negatives)", ["miso"], [], [], 30, ["salt"], 8],
739
+ ["core", "Arithmetic (basket - negatives)", ["coffee"], [], [], 30, ["milk"], 8],
740
+ ["chem", "Arithmetic (basket - negatives)", ["chocolate"], [], [], 30, ["sugar"], 8],
741
+ ["chem", "Rotate to supervised direction", ["rice"], ["cuisine:South_Asian"], [], 30, [], 8],
742
+ ["chem", "Rotate to supervised direction", ["corn"], ["cuisine:Latin_American"], [], 30, [], 8],
 
 
 
743
  ],
744
+ inputs=[tx_sib, tx_op, tx_basket, tx_dirs, tx_modes, tx_theta, tx_neg, tx_k],
745
+ label="Try one of these",
746
  )
747
 
748
+ # ---------- Tab 3: MAP ----------
749
+ with gr.Tab("Map"):
750
+ gr.Markdown("UMAP of the 1,790-ingredient embedding (cosine, n_neighbors=30, min_dist=0.03; paper Fig 1).")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
751
  with gr.Row():
752
+ map_sib = gr.Radio(choices=["cooc","core","chem"], value="chem", label="Sibling", scale=1)
753
+ map_basket = gr.Dropdown(choices=ALL_INGREDIENTS, value=_DEFAULT_BASKET,
754
+ label="Highlight basket", multiselect=True, max_choices=10, scale=3)
 
 
 
 
 
 
 
 
 
 
755
  with gr.Row():
756
+ map_3d = gr.Checkbox(value=False, label="3-D")
757
+ map_nb = gr.Checkbox(value=True, label="Show top-K neighbours")
758
+ map_k = gr.Slider(3, 20, value=10, step=1, label="K", scale=1)
759
+ map_btn = gr.Button("Update", variant="primary", scale=1)
760
+ map_plot = gr.Plot(value=_INIT_UMAP, label="UMAP")
761
+ map_btn.click(umap_view, inputs=[map_sib, map_basket, map_nb, map_k, map_3d], outputs=map_plot,
762
+ show_progress="minimal")
763
+
764
+ # ---------- Tab 4: FROM TEXT ----------
765
+ with gr.Tab("From text"):
766
+ 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.")
767
+ ft_text = gr.Textbox(
768
+ label="Free text",
769
+ lines=6,
770
+ value="I'm making Thai green curry for 4 people",
771
+ placeholder=("Either a dish description ('I'm making Thai green curry for 4'), or "
772
+ "an ingredient list ('2 chicken thighs / 1 cup coconut milk / fish sauce / ...')"),
773
  )
774
+ ft_mode = gr.Radio(
775
+ choices=["Recipe / dish description", "Ingredient list (shopping list / fridge)"],
776
+ value="Recipe / dish description",
777
+ label="Treat as",
 
 
778
  )
 
779
  with gr.Row():
780
+ ft_sib = gr.Radio(choices=["cooc","core","chem"], value="chem", label="Sibling")
781
+ ft_btn = gr.Button("Match", variant="primary")
782
+ ft_send = gr.Button("Send to Explore", variant="secondary")
783
+ ft_table = gr.Dataframe(headers=["Input","Match","Score"], interactive=False, label="Matched ingredients")
784
+ ft_expl = gr.Markdown()
785
+ ft_matched = gr.State([])
786
+ ft_btn.click(parse_or_suggest, inputs=[ft_text, ft_sib, ft_mode],
787
+ outputs=[ft_table, ft_expl, ft_matched], show_progress="full")
788
+ ft_send.click(lambda names: gr.Dropdown(value=(names or [])[:10]),
789
+ inputs=[ft_matched], outputs=[ex_basket])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
790
  gr.Examples(
791
  examples=[
792
+ ["I'm making Thai green curry for 4 people", "Recipe / dish description"],
793
+ ["spicy vegetarian taco filling", "Recipe / dish description"],
794
+ ["Japanese miso-glazed salmon and greens", "Recipe / dish description"],
795
+ ["2 boneless chicken thighs\n1 cup coconut milk\n1 tbsp fish sauce\nfresh lemongrass\n3 cloves garlic\njuice of one lime",
796
+ "Ingredient list (shopping list / fridge)"],
797
  ],
798
+ inputs=[ft_text, ft_mode],
799
+ label="Try one of these",
 
 
 
 
 
 
 
 
 
 
 
800
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
801
 
802
+ # ---- Hidden API endpoints ----
803
  with gr.Group(visible=False):
804
  api_in_s1 = gr.Textbox(visible=False)
805
  api_in_s2 = gr.Textbox(visible=False)
806
+ api_in_n = gr.Number(visible=False, value=5)
807
  api_in_n2 = gr.Number(visible=False, value=30)
808
  api_in_l1 = gr.JSON(visible=False, value=[])
809
  api_in_l2 = gr.JSON(visible=False, value=[])
810
+ api_out = gr.JSON(visible=False)
811
+ gr.Button(visible=False).click(api_neighbors, inputs=[api_in_s1, api_in_s2, api_in_n], outputs=api_out, api_name="neighbors")
812
+ 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")
813
+ 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")
814
+ gr.Button(visible=False).click(api_embed, inputs=[api_in_s1, api_in_s2], outputs=api_out, api_name="embed")
 
 
 
 
 
 
 
 
815
 
816
  gr.Markdown(
817
  """---
818
+ **Cite:** Radzikowski and Chen, 2026, *Epicure: Navigating the Emergent Geometry of Food Ingredient Embeddings*, [arXiv:2605.22391](https://arxiv.org/abs/2605.22391).
819
+ 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)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
820
  """
821
  )
822