josefchen commited on
Commit
4520425
·
verified ·
1 Parent(s): 8fba7bd

Kaikaku brand: dark teal + mint. UMAP single-trace (fix empty). Matplotlib heatmap (fix empty). All three sibling cards always visible.

Browse files
Files changed (2) hide show
  1. __pycache__/app.cpython-310.pyc +0 -0
  2. app.py +327 -259
__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
@@ -9,6 +9,10 @@ import json
9
  import numpy as np
10
  import gradio as gr
11
  import plotly.graph_objects as go
 
 
 
 
12
 
13
  try:
14
  from epicure import Epicure
@@ -20,6 +24,28 @@ except ImportError:
20
 
21
  from rapidfuzz import process as fuzz_process, fuzz as fuzz_scorers
22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  MODELS = {
24
  "cooc": Epicure.from_pretrained("Kaikaku/epicure-cooc"),
25
  "core": Epicure.from_pretrained("Kaikaku/epicure-core"),
@@ -28,27 +54,27 @@ MODELS = {
28
  ALL_INGREDIENTS = sorted(MODELS["cooc"].vocab.keys())
29
 
30
  _HERE = os.path.dirname(os.path.abspath(__file__))
31
- UMAP = np.load(os.path.join(_HERE, "umap_2d.npz"))
32
  _lab = json.load(open(os.path.join(_HERE, "ingredient_labels.json")))
33
- NAMES_BY_IDX = _lab["names"]
34
- FOOD_GROUPS = _lab["food_groups"]
35
 
36
  FG_COLORS = {
37
- "Vegetable": "#2ca02c",
38
- "Fruit": "#e377c2",
39
- "Grain": "#bcbd22",
40
- "Dairy": "#17becf",
41
- "Spice": "#d62728",
42
- "Pantry": "#ff7f0e",
43
- "Beverage": "#9467bd",
44
- "Other": "#cccccc",
45
  }
46
 
47
- SIBLING_BLURBS = {
48
- "cooc": "**Cooc** walks recipe co-occurrence only. Neighbours are recipe companions: ingredients that *get cooked with* the seed.",
49
- "core": "**Core** blends typed FlavorDB compound walks with injected I-I walks at ii_repeat=10. Concentrated geometry (PR=94), tightest emergent modes.",
50
- "chem": "**Chem** walks typed FlavorDB compound metapaths only (ii_repeat=0). Neighbours are flavour-profile peers: ingredients that *share aroma chemistry* with the seed.",
51
- }
52
 
53
  # ===== math helpers =====
54
 
@@ -94,13 +120,189 @@ def _slerp(v, d, theta_deg):
94
  th = np.deg2rad(float(theta_deg))
95
  return _unit(np.cos(th)*v + np.sin(th)*d_perp)
96
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  # ===== tab handlers =====
98
 
99
  def basket_pairings(sibling, basket, k):
100
  m = MODELS[sibling]
101
  centroid = _basket_centroid(m, basket)
102
  if centroid is None:
103
- return [], [], None
104
  nb = _topk(m, centroid, k, exclude=basket or [])
105
  scored = [(mode.mode_id, mode.label, mode.kind, float(_unit(mode.pole) @ centroid)) for mode in m.modes]
106
  scored.sort(key=lambda x: -x[3])
@@ -111,33 +313,6 @@ def basket_pairings(sibling, basket, k):
111
  heatmap,
112
  )
113
 
114
- def _basket_heatmap(m, basket):
115
- valid = [n for n in (basket or []) if n in m.vocab]
116
- if len(valid) < 2:
117
- # Empty figure with a hint
118
- fig = go.Figure()
119
- fig.add_annotation(text="Add 2+ ingredients to see pairwise cosines",
120
- showarrow=False, xref="paper", yref="paper", x=0.5, y=0.5,
121
- font=dict(size=14, color="#888"))
122
- fig.update_layout(height=420, plot_bgcolor="#fafafa", paper_bgcolor="#fafafa")
123
- fig.update_xaxes(visible=False); fig.update_yaxes(visible=False)
124
- return fig
125
- idxs = [m.vocab[n] for n in valid]
126
- sub = m.E[idxs]
127
- sim = sub @ sub.T
128
- fig = go.Figure(go.Heatmap(
129
- z=sim, x=valid, y=valid,
130
- colorscale="Viridis", zmin=-0.2, zmax=1.0,
131
- colorbar=dict(title="cos"),
132
- hovertemplate="%{y} <> %{x}<br>cos = %{z:.3f}<extra></extra>",
133
- ))
134
- fig.update_layout(
135
- title=dict(text="Pairwise cosine within the basket", font=dict(size=14)),
136
- height=420, margin=dict(l=80, r=20, t=50, b=80),
137
- paper_bgcolor="#ffffff", plot_bgcolor="#ffffff",
138
- )
139
- return fig
140
-
141
  def supervised_slerp_multi(sibling, basket, directions, theta, k):
142
  m = MODELS[sibling]
143
  v = _basket_centroid(m, basket)
@@ -185,8 +360,7 @@ def compare_siblings(basket, directions, theta, k):
185
  for sib in ["cooc","core","chem"]:
186
  m = MODELS[sib]
187
  v = _basket_centroid(m, basket)
188
- if v is None:
189
- out.append([]); continue
190
  valid_dirs = [d for d in (directions or []) if d in m.supervised_poles]
191
  if valid_dirs:
192
  d_vec = _stack_directions(m, valid_dirs)
@@ -197,111 +371,6 @@ def compare_siblings(basket, directions, theta, k):
197
  out.append([[n, f"{s:.4f}"] for n, s in hits])
198
  return out[0], out[1], out[2]
199
 
200
-
201
- def _umap_coords(sibling, three_d):
202
- """Lift the 2D UMAP into 3D by appending the embedding's third principal axis if requested."""
203
- base = UMAP[sibling] # (1790, 2)
204
- if not three_d:
205
- return base, None
206
- # Compute a third dim via simple PCA on the underlying embedding
207
- m = MODELS[sibling]
208
- E = m.E - m.E.mean(axis=0, keepdims=True)
209
- # First three PCs
210
- U, S, Vt = np.linalg.svd(E, full_matrices=False)
211
- pc1 = (E @ Vt[0]); pc1 = (pc1 - pc1.mean()) / (pc1.std() + 1e-9)
212
- # Combine base 2D with pc1 scaled to the same range
213
- scale = (base.max() - base.min()) * 0.25
214
- z = pc1 * scale
215
- return base, z.astype(np.float32)
216
-
217
- def umap_view(sibling, basket, show_neighbours, k, three_d=False):
218
- coords2, z = _umap_coords(sibling, three_d)
219
- m = MODELS[sibling]
220
- name_to_idx = m.vocab
221
- by_group = {}
222
- for i, fg in enumerate(FOOD_GROUPS):
223
- by_group.setdefault(fg, []).append(i)
224
- order = ["Other"] + [g for g in FG_COLORS if g != "Other"]
225
-
226
- fig = go.Figure()
227
-
228
- def add_scatter(name, idxs, marker, text, hover, mode="markers"):
229
- if three_d:
230
- fig.add_trace(go.Scatter3d(
231
- x=coords2[idxs,0], y=coords2[idxs,1], z=z[idxs],
232
- mode=mode, name=name, marker=marker, text=text, hovertemplate=hover,
233
- textfont=dict(size=10),
234
- ))
235
- else:
236
- fig.add_trace(go.Scatter(
237
- x=coords2[idxs,0], y=coords2[idxs,1],
238
- mode=mode, name=name, marker=marker, text=text, hovertemplate=hover,
239
- textfont=dict(size=10),
240
- ))
241
-
242
- for fg in order:
243
- if fg not in by_group: continue
244
- idxs = by_group[fg]
245
- marker = dict(
246
- size=4 if not three_d else 3,
247
- color=FG_COLORS.get(fg, "#888888"),
248
- opacity=0.35 if fg == "Other" else 0.7,
249
- line=dict(width=0),
250
- )
251
- add_scatter(fg, idxs, marker,
252
- [NAMES_BY_IDX[i] for i in idxs],
253
- "%{text}<br>group: " + fg + "<extra></extra>")
254
-
255
- if basket:
256
- bi = [name_to_idx[b] for b in basket if b in name_to_idx]
257
- if bi:
258
- marker = dict(
259
- size=16 if not three_d else 8,
260
- color="#e30613",
261
- symbol="star" if not three_d else "diamond",
262
- line=dict(color="white", width=2),
263
- )
264
- add_scatter("Basket", bi, marker,
265
- [NAMES_BY_IDX[i] for i in bi],
266
- "<b>%{text}</b><extra></extra>",
267
- mode="markers+text")
268
-
269
- if show_neighbours:
270
- centroid = _basket_centroid(m, basket)
271
- if centroid is not None:
272
- nb_pairs = _topk(m, centroid, k=int(k), exclude=basket)
273
- nb_idxs = [name_to_idx[n] for n, _ in nb_pairs if n in name_to_idx]
274
- if nb_idxs:
275
- marker = dict(
276
- size=10 if not three_d else 6,
277
- color="#ff8800",
278
- symbol="circle",
279
- line=dict(color="white", width=1),
280
- )
281
- add_scatter(f"Top-{k} neighbours", nb_idxs, marker,
282
- [NAMES_BY_IDX[i] for i in nb_idxs],
283
- "<b>%{text}</b> (neighbour)<extra></extra>",
284
- mode="markers+text")
285
-
286
- title_suffix = " (3D, PCA z-axis)" if three_d else ""
287
- fig.update_layout(
288
- title=dict(text=f"UMAP of Epicure-{sibling.capitalize()}{title_suffix}", font=dict(size=15)),
289
- height=650,
290
- legend=dict(orientation="v", x=1.02, y=1, font=dict(size=11), bgcolor="rgba(255,255,255,0.8)"),
291
- margin=dict(l=40, r=160, t=60, b=40),
292
- paper_bgcolor="#ffffff", plot_bgcolor="#ffffff",
293
- )
294
- if not three_d:
295
- fig.update_xaxes(showgrid=True, gridcolor="#eee", zeroline=False, title="UMAP 1")
296
- fig.update_yaxes(showgrid=True, gridcolor="#eee", zeroline=False, title="UMAP 2")
297
- else:
298
- fig.update_layout(scene=dict(
299
- xaxis_title="UMAP 1", yaxis_title="UMAP 2", zaxis_title="PC1 (z)",
300
- bgcolor="#ffffff",
301
- ))
302
- return fig
303
-
304
-
305
  # ===== fridge parser =====
306
 
307
  _LINE_SPLIT = re.compile(r"[\n;]")
@@ -342,8 +411,7 @@ def _clean_line(line):
342
  s = _LEADING_PREP.sub("", s)
343
  s = _LEADING_PREP.sub("", s)
344
  tokens = [_KNOWN_PLURALS.get(t, t) for t in s.split()]
345
- s = " ".join(tokens)
346
- return re.sub(r"\s+", " ", s).strip()
347
 
348
  def _fuzzy_lookup(cleaned, vocab, vocab_sp, min_score):
349
  if not cleaned: return None, 0.0
@@ -388,44 +456,101 @@ def parse_fridge(raw_text, sibling, min_score=70):
388
 
389
  # ===== UI =====
390
 
391
- THEME = gr.themes.Soft(
392
- primary_hue="red",
393
- secondary_hue="orange",
 
 
 
 
 
 
 
 
394
  neutral_hue="slate",
395
  font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui", "sans-serif"],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
396
  )
397
 
398
- # Precompute the initial UMAP for the default sibling+basket so the tab is not empty on first open.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
399
  _INITIAL_UMAP = umap_view("chem", ["chicken","lemon","garlic"], True, 8, three_d=False)
400
  _INITIAL_HEATMAP = _basket_heatmap(MODELS["chem"], ["chicken","lemon","garlic"])
401
 
402
- with gr.Blocks(title="Epicure Explorer", theme=THEME, css="""
403
- .gradio-container {max-width: 1280px !important;}
404
- footer {visibility: hidden;}
405
- h1 {margin-bottom: 0.2em;}
406
- .subtitle {color: #666; font-size: 0.95em; margin-top: 0;}
407
- """) as demo:
 
 
 
 
 
 
 
 
 
 
408
 
409
  gr.Markdown(
410
- """# Epicure Explorer
411
- <p class="subtitle">Chef-facing operators over three sibling ingredient embeddings (Cooc / Core / Chem) from
412
- <a href="https://arxiv.org/abs/2605.22391" target="_blank">arXiv:2605.22391</a>.
413
- 1,790 canonical ingredients across 7 languages, 300-D Metapath2Vec embeddings, controlled chemistry-vs-recipe-context spectrum.</p>"""
414
  )
415
 
416
- sibling = gr.Radio(choices=["cooc","core","chem"], value="chem", label="Sibling embedding")
417
- sibling_help = gr.Markdown(SIBLING_BLURBS["chem"])
418
- sibling.change(lambda s: SIBLING_BLURBS[s], inputs=sibling, outputs=sibling_help)
419
 
420
- # Shared state for cross-tab routing (e.g. Parse fridge -> Basket)
421
  shared_basket = gr.State([])
422
 
423
  # ---------- Tab 1: Basket pairings + heatmap ----------
424
  with gr.Tab("Basket pairings"):
425
  gr.Markdown(
426
  "Pick one or more ingredients. Tool averages their unit vectors and returns nearest neighbours "
427
- "plus closest modes of that centroid. The heatmap shows whether the basket is coherent "
428
- "(bright off-diagonals) or scattered."
429
  )
430
  basket = gr.Dropdown(
431
  choices=ALL_INGREDIENTS, value=["chicken","lemon","garlic"],
@@ -436,7 +561,7 @@ h1 {margin-bottom: 0.2em;}
436
  with gr.Row():
437
  nb_table = gr.Dataframe(headers=["Neighbour","Cosine"], label="Top-K nearest neighbours", interactive=False)
438
  mode_table = gr.Dataframe(headers=["Mode id","Label","Kind","Cosine"], label="Closest modes", interactive=False)
439
- heatmap_plot = gr.Plot(value=_INITIAL_HEATMAP, label="Pairwise cosine within the basket")
440
  pair_btn.click(
441
  basket_pairings, inputs=[sibling, basket, k_pair],
442
  outputs=[nb_table, mode_table, heatmap_plot],
@@ -459,18 +584,10 @@ h1 {margin-bottom: 0.2em;}
459
 
460
  # ---------- Tab 2: Supervised SLERP ----------
461
  with gr.Tab("Supervised SLERP"):
462
- gr.Markdown(
463
- "Rotate the seed basket toward one or more supervised direction poles (cuisine, food group, "
464
- "NOVA, sensory, USDA macros). Multiple directions are summed before rotation."
465
- )
466
- sup_basket = gr.Dropdown(
467
- choices=ALL_INGREDIENTS, value=["rice"],
468
- label="Seed basket (pick 1+)", multiselect=True, max_choices=10,
469
- )
470
- sup_dirs = gr.Dropdown(
471
- choices=_supervised_choices("chem"), value=["cuisine:South_Asian"],
472
- label="Supervised directions (pick 1+; summed)", multiselect=True, max_choices=5,
473
- )
474
  sup_theta = gr.Slider(0, 90, value=30, step=5, label="Rotation angle (deg)")
475
  sup_k = gr.Slider(1, 15, value=8, step=1, label="K")
476
  sup_btn = gr.Button("Rotate", variant="primary")
@@ -494,20 +611,12 @@ h1 {margin-bottom: 0.2em;}
494
 
495
  # ---------- Tab 3: Emergent SLERP ----------
496
  with gr.Tab("Emergent SLERP"):
497
- gr.Markdown(
498
- "Rotate the seed basket toward one or more emergent factor-mode poles discovered "
499
- "by multi-seed-stable FastICA + GMM."
500
- )
501
- em_basket = gr.Dropdown(
502
- choices=ALL_INGREDIENTS, value=["chocolate"],
503
- label="Seed basket (pick 1+)", multiselect=True, max_choices=10,
504
- )
505
  factor_opts = _factor_mode_choices("chem")
506
- em_modes = gr.Dropdown(
507
- choices=[label for label, _ in factor_opts],
508
- value=[factor_opts[0][0]] if factor_opts else [],
509
- label="Factor modes (pick 1+; summed)", multiselect=True, max_choices=5,
510
- )
511
  em_theta = gr.Slider(0, 90, value=30, step=5, label="Rotation angle (deg)")
512
  em_k = gr.Slider(1, 15, value=8, step=1, label="K")
513
  em_btn = gr.Button("Rotate", variant="primary")
@@ -519,10 +628,7 @@ h1 {margin-bottom: 0.2em;}
519
 
520
  # ---------- Tab 4: Arithmetic ----------
521
  with gr.Tab("Arithmetic"):
522
- gr.Markdown(
523
- "Mikolov-style vector arithmetic: `centroid(positives) - centroid(negatives)`, "
524
- "then top-K nearest neighbours. The killer demo is `miso - salt` on Core."
525
- )
526
  pos_box = gr.Dropdown(choices=ALL_INGREDIENTS, value=["miso"], label="Positives", multiselect=True, max_choices=10)
527
  neg_box = gr.Dropdown(choices=ALL_INGREDIENTS, value=["salt"], label="Negatives", multiselect=True, max_choices=10)
528
  ar_k = gr.Slider(1, 15, value=8, step=1, label="K")
@@ -546,11 +652,7 @@ h1 {margin-bottom: 0.2em;}
546
 
547
  # ---------- Tab 5: Mode atlas ----------
548
  with gr.Tab("Mode atlas"):
549
- gr.Markdown(
550
- "Browse the GMM mode atlas of the selected sibling. Cooc 150 modes / Core 193 / Chem 200. "
551
- "`factor` = emergent FastICA modes; `continuous` = quartile partitions of NOVA/sensory/USDA; "
552
- "`binary` = food-group buckets."
553
- )
554
  atlas_kind = gr.Radio(choices=["all","factor","continuous","binary"], value="all", label="Mode kind")
555
  atlas_search = gr.Textbox(label="Search labels / properties", placeholder="e.g. South Asian, baking, fiber", value="")
556
  atlas_btn = gr.Button("Browse modes", variant="primary")
@@ -562,14 +664,10 @@ h1 {margin-bottom: 0.2em;}
562
 
563
  # ---------- Tab 6: Compare siblings ----------
564
  with gr.Tab("Compare siblings"):
565
- gr.Markdown(
566
- "Same query, three siblings, side by side. The spectrum-of-models thesis visible in one screen."
567
- )
568
  cmp_basket = gr.Dropdown(choices=ALL_INGREDIENTS, value=["chicken"], label="Seed basket", multiselect=True, max_choices=10)
569
- cmp_dirs = gr.Dropdown(
570
- choices=_supervised_choices("chem"), value=[],
571
- label="Optional directions (leave empty for pure pairings)", multiselect=True, max_choices=5,
572
- )
573
  cmp_theta = gr.Slider(0, 90, value=30, step=5, label="Rotation angle (deg)")
574
  cmp_k = gr.Slider(1, 15, value=8, step=1, label="K")
575
  cmp_btn = gr.Button("Compare across siblings", variant="primary")
@@ -579,66 +677,39 @@ h1 {margin-bottom: 0.2em;}
579
  cmp_chem = gr.Dataframe(headers=["Chem neighbour","Cosine"], label="Chem (chemistry)")
580
  cmp_btn.click(compare_siblings, inputs=[cmp_basket, cmp_dirs, cmp_theta, cmp_k],
581
  outputs=[cmp_cooc, cmp_core, cmp_chem], show_progress="full")
582
- gr.Examples(
583
- examples=[
584
- [["chicken"], [], 0, 8],
585
- [["basil"], [], 0, 8],
586
- [["miso"], [], 0, 8],
587
- [["rice"], ["cuisine:South_Asian"], 30, 8],
588
- [["corn"], ["cuisine:Latin_American"], 30, 8],
589
- [["chicken","onion"], ["cuisine:Mediterranean"], 45, 8],
590
- ],
591
- inputs=[cmp_basket, cmp_dirs, cmp_theta, cmp_k],
592
- label="Try one of these side-by-side comparisons",
593
- )
594
 
595
  # ---------- Tab 7: UMAP visualisation ----------
596
  with gr.Tab("UMAP visualisation"):
597
  gr.Markdown(
598
- "2-D UMAP projection of the 1,790-ingredient embedding (cosine metric, n_neighbors=30, min_dist=0.03 "
599
- "-- paper Figure 1 hyperparameters). Points coloured by food group. Add ingredients to the basket "
600
- "to highlight them as red stars; their nearest neighbours appear as orange circles. "
601
- "Toggle 3D for a perspective view (third axis is PC1 of the embedding)."
602
  )
603
- with gr.Row():
604
- umap_basket = gr.Dropdown(
605
- choices=ALL_INGREDIENTS, value=["chicken","lemon","garlic"],
606
- label="Highlight these ingredients", multiselect=True, max_choices=10,
607
- )
608
  with gr.Row():
609
  umap_show_nb = gr.Checkbox(value=True, label="Show top-K neighbours of basket centroid")
610
  umap_3d = gr.Checkbox(value=False, label="3-D perspective (UMAP + PC1)")
611
  umap_k = gr.Slider(1, 20, value=10, step=1, label="K neighbours")
612
  umap_btn = gr.Button("Update plot", variant="primary")
613
  umap_plot = gr.Plot(value=_INITIAL_UMAP, label="UMAP")
614
- umap_btn.click(umap_view,
615
- inputs=[sibling, umap_basket, umap_show_nb, umap_k, umap_3d],
616
  outputs=umap_plot, show_progress="full")
617
- # Auto-refresh on sibling change
618
- sibling.change(umap_view,
619
- inputs=[sibling, umap_basket, umap_show_nb, umap_k, umap_3d],
620
  outputs=umap_plot)
621
- gr.Markdown("*Tip: scroll-zoom and box-zoom are enabled. Double-click to reset. Click a legend item to hide that food group.*")
622
 
623
  # ---------- Tab 8: Parse my fridge ----------
624
  with gr.Tab("Parse my fridge"):
625
  gr.Markdown(
626
- "Paste a free-text ingredient list. Tool strips quantities and prep notes, then fuzzy-matches "
627
- "each line to canonical vocab. Hit **Send to Basket** to route the matched set into the Basket-pairings tab."
 
628
  )
629
  fridge_text = gr.Textbox(
630
  label="Free-text ingredients (one per line or semicolon-separated)",
631
  lines=8,
632
- value=(
633
- "2 boneless chicken thighs\n"
634
- "1 cup coconut milk\n"
635
- "1 tbsp fish sauce (or soy sauce)\n"
636
- "fresh lemongrass, bruised\n"
637
- "3 cloves garlic, minced\n"
638
- "1 inch fresh ginger\n"
639
- "juice of one lime\n"
640
- "salt to taste"
641
- ),
642
  )
643
  fridge_min = gr.Slider(40, 100, value=70, step=5, label="Min match score (rapidfuzz)")
644
  with gr.Row():
@@ -653,11 +724,8 @@ h1 {margin-bottom: 0.2em;}
653
  def _parse(txt, sib, mn):
654
  rows, matches = parse_fridge(txt, sib, int(mn))
655
  return rows, ", ".join(matches), matches
656
- fridge_btn.click(
657
- _parse, inputs=[fridge_text, sibling, fridge_min],
658
- outputs=[fridge_table, fridge_matched, shared_basket],
659
- show_progress="full",
660
- )
661
 
662
  def _send_to_basket(matches):
663
  return gr.Dropdown(value=matches[:10] if matches else [])
 
9
  import numpy as np
10
  import gradio as gr
11
  import plotly.graph_objects as go
12
+ import matplotlib
13
+ matplotlib.use("Agg")
14
+ import matplotlib.pyplot as plt
15
+ from matplotlib.patches import Patch
16
 
17
  try:
18
  from epicure import Epicure
 
24
 
25
  from rapidfuzz import process as fuzz_process, fuzz as fuzz_scorers
26
 
27
+ # ===== Kaikaku brand =====
28
+ KAIKAKU_DARK = "#0F2D2F"
29
+ KAIKAKU_DEEP = "#0A1F20"
30
+ KAIKAKU_MID = "#1A3D3F"
31
+ KAIKAKU_EDGE = "#2A4D4F"
32
+ KAIKAKU_MINT = "#B5E6D2"
33
+ KAIKAKU_MINT_BRIGHT = "#D8F0E5"
34
+ KAIKAKU_TEXT = "#E8F4F1"
35
+ KAIKAKU_MUTED = "#7AA8A2"
36
+
37
+ # Plotly default for dark mode
38
+ plt.rcParams.update({
39
+ "figure.facecolor": KAIKAKU_DARK,
40
+ "axes.facecolor": KAIKAKU_DARK,
41
+ "axes.edgecolor": KAIKAKU_EDGE,
42
+ "axes.labelcolor": KAIKAKU_TEXT,
43
+ "xtick.color": KAIKAKU_TEXT,
44
+ "ytick.color": KAIKAKU_TEXT,
45
+ "text.color": KAIKAKU_TEXT,
46
+ "savefig.facecolor": KAIKAKU_DARK,
47
+ })
48
+
49
  MODELS = {
50
  "cooc": Epicure.from_pretrained("Kaikaku/epicure-cooc"),
51
  "core": Epicure.from_pretrained("Kaikaku/epicure-core"),
 
54
  ALL_INGREDIENTS = sorted(MODELS["cooc"].vocab.keys())
55
 
56
  _HERE = os.path.dirname(os.path.abspath(__file__))
57
+ UMAP_DATA = np.load(os.path.join(_HERE, "umap_2d.npz"))
58
  _lab = json.load(open(os.path.join(_HERE, "ingredient_labels.json")))
59
+ NAMES_BY_IDX: list[str] = _lab["names"]
60
+ FOOD_GROUPS: list[str] = _lab["food_groups"]
61
 
62
  FG_COLORS = {
63
+ "Vegetable": "#9BD7A8",
64
+ "Fruit": "#F0A8C8",
65
+ "Grain": "#E8D67A",
66
+ "Dairy": "#9BCFE8",
67
+ "Spice": "#F08A7A",
68
+ "Pantry": "#E8B47A",
69
+ "Beverage": "#B59CE8",
70
+ "Other": "#5A7878",
71
  }
72
 
73
+ # Sanity-check log on import so Space logs show whether assets loaded
74
+ print(f"[epicure-explorer] models loaded: {list(MODELS)}", flush=True)
75
+ print(f"[epicure-explorer] UMAP shapes: {{cooc:{UMAP_DATA['cooc'].shape}, core:{UMAP_DATA['core'].shape}, chem:{UMAP_DATA['chem'].shape}}}", flush=True)
76
+ print(f"[epicure-explorer] food group labels: {len(FOOD_GROUPS)} ingredients, "
77
+ f"{sum(1 for fg in FOOD_GROUPS if fg != 'Other')} with concrete group", flush=True)
78
 
79
  # ===== math helpers =====
80
 
 
120
  th = np.deg2rad(float(theta_deg))
121
  return _unit(np.cos(th)*v + np.sin(th)*d_perp)
122
 
123
+ # ===== heatmap (matplotlib, reliable) =====
124
+
125
+ def _basket_heatmap(m, basket):
126
+ valid = [n for n in (basket or []) if n in m.vocab]
127
+ fig, ax = plt.subplots(figsize=(6, 5))
128
+ if len(valid) < 2:
129
+ ax.text(0.5, 0.5, "Add 2+ ingredients to see pairwise cosines",
130
+ ha="center", va="center", fontsize=13, color=KAIKAKU_MUTED,
131
+ transform=ax.transAxes)
132
+ ax.set_facecolor(KAIKAKU_DARK)
133
+ ax.axis("off")
134
+ fig.patch.set_facecolor(KAIKAKU_DARK)
135
+ plt.tight_layout()
136
+ return fig
137
+ idxs = [m.vocab[n] for n in valid]
138
+ sub = m.E[idxs]
139
+ sim = sub @ sub.T
140
+ im = ax.imshow(sim, cmap="viridis", vmin=-0.2, vmax=1.0, aspect="auto")
141
+ ax.set_xticks(range(len(valid)))
142
+ ax.set_yticks(range(len(valid)))
143
+ ax.set_xticklabels(valid, rotation=35, ha="right", color=KAIKAKU_TEXT)
144
+ ax.set_yticklabels(valid, color=KAIKAKU_TEXT)
145
+ for i in range(len(valid)):
146
+ for j in range(len(valid)):
147
+ v = float(sim[i, j])
148
+ color = "white" if v < 0.55 else "black"
149
+ ax.text(j, i, f"{v:.2f}", ha="center", va="center", fontsize=10, color=color)
150
+ cb = plt.colorbar(im, ax=ax)
151
+ cb.ax.yaxis.set_tick_params(color=KAIKAKU_TEXT)
152
+ plt.setp(plt.getp(cb.ax.axes, "yticklabels"), color=KAIKAKU_TEXT)
153
+ cb.set_label("cosine", color=KAIKAKU_TEXT)
154
+ ax.set_title("Pairwise cosine within the basket", color=KAIKAKU_TEXT, fontsize=12)
155
+ ax.set_facecolor(KAIKAKU_DARK)
156
+ fig.patch.set_facecolor(KAIKAKU_DARK)
157
+ plt.tight_layout()
158
+ return fig
159
+
160
+ # ===== UMAP (Plotly, SINGLE TRACE, bulletproof) =====
161
+
162
+ def _umap_coords(sibling, three_d):
163
+ base = UMAP_DATA[sibling]
164
+ if not three_d:
165
+ return base, None
166
+ m = MODELS[sibling]
167
+ E = m.E - m.E.mean(axis=0, keepdims=True)
168
+ _, _, Vt = np.linalg.svd(E, full_matrices=False)
169
+ pc1 = (E @ Vt[0])
170
+ pc1 = (pc1 - pc1.mean()) / (pc1.std() + 1e-9)
171
+ scale = (base.max() - base.min()) * 0.25
172
+ return base, (pc1 * scale).astype(np.float32)
173
+
174
+ def umap_view(sibling, basket, show_neighbours, k, three_d=False):
175
+ coords2, z = _umap_coords(sibling, three_d)
176
+ m = MODELS[sibling]
177
+ n = len(NAMES_BY_IDX)
178
+
179
+ # Pre-compute marker colors and hover text per ingredient
180
+ colors = [FG_COLORS.get(fg, KAIKAKU_MUTED) for fg in FOOD_GROUPS]
181
+ hover_text = [f"{NAMES_BY_IDX[i]}<br>group: {FOOD_GROUPS[i]}" for i in range(n)]
182
+
183
+ basket_set = set(basket or [])
184
+ basket_idxs = [m.vocab[b] for b in (basket or []) if b in m.vocab]
185
+
186
+ neighbour_set: set[str] = set()
187
+ if show_neighbours and basket_idxs:
188
+ centroid = _basket_centroid(m, basket)
189
+ if centroid is not None:
190
+ nb_pairs = _topk(m, centroid, k=int(k), exclude=basket)
191
+ neighbour_set = {nm for nm, _ in nb_pairs}
192
+
193
+ # SINGLE background trace: all 1790 points coloured by food group.
194
+ # One trace beats N traces for reliability in gr.Plot.
195
+ bg_x = [float(coords2[i, 0]) for i in range(n) if NAMES_BY_IDX[i] not in basket_set and NAMES_BY_IDX[i] not in neighbour_set]
196
+ bg_y = [float(coords2[i, 1]) for i in range(n) if NAMES_BY_IDX[i] not in basket_set and NAMES_BY_IDX[i] not in neighbour_set]
197
+ bg_z = [float(z[i]) for i in range(n) if NAMES_BY_IDX[i] not in basket_set and NAMES_BY_IDX[i] not in neighbour_set] if three_d else None
198
+ bg_c = [colors[i] for i in range(n) if NAMES_BY_IDX[i] not in basket_set and NAMES_BY_IDX[i] not in neighbour_set]
199
+ bg_h = [hover_text[i] for i in range(n) if NAMES_BY_IDX[i] not in basket_set and NAMES_BY_IDX[i] not in neighbour_set]
200
+
201
+ fig = go.Figure()
202
+
203
+ if three_d:
204
+ fig.add_trace(go.Scatter3d(
205
+ x=bg_x, y=bg_y, z=bg_z, mode="markers",
206
+ marker=dict(size=3, color=bg_c, opacity=0.55, line=dict(width=0)),
207
+ text=bg_h, hovertemplate="%{text}<extra></extra>", name="ingredients",
208
+ showlegend=False,
209
+ ))
210
+ else:
211
+ fig.add_trace(go.Scattergl(
212
+ x=bg_x, y=bg_y, mode="markers",
213
+ marker=dict(size=5, color=bg_c, opacity=0.65, line=dict(width=0)),
214
+ text=bg_h, hovertemplate="%{text}<extra></extra>", name="ingredients",
215
+ showlegend=False,
216
+ ))
217
+
218
+ # Neighbour highlights (orange-ish mint glow)
219
+ if neighbour_set:
220
+ ni = [i for i in range(n) if NAMES_BY_IDX[i] in neighbour_set]
221
+ nx = [float(coords2[i, 0]) for i in ni]
222
+ ny = [float(coords2[i, 1]) for i in ni]
223
+ nz = [float(z[i]) for i in ni] if three_d else None
224
+ nlabels = [NAMES_BY_IDX[i] for i in ni]
225
+ marker = dict(size=12 if not three_d else 7,
226
+ color="#F4B86E", # warm amber against the dark teal
227
+ opacity=0.95,
228
+ line=dict(color=KAIKAKU_MINT_BRIGHT, width=1.2))
229
+ if three_d:
230
+ fig.add_trace(go.Scatter3d(
231
+ x=nx, y=ny, z=nz, mode="markers+text",
232
+ marker=marker, text=nlabels, textposition="top center",
233
+ textfont=dict(color=KAIKAKU_TEXT, size=10),
234
+ hovertemplate="<b>%{text}</b> (neighbour)<extra></extra>",
235
+ name=f"top-{k} neighbours",
236
+ ))
237
+ else:
238
+ fig.add_trace(go.Scatter(
239
+ x=nx, y=ny, mode="markers+text",
240
+ marker=marker, text=nlabels, textposition="top center",
241
+ textfont=dict(color=KAIKAKU_TEXT, size=10),
242
+ hovertemplate="<b>%{text}</b> (neighbour)<extra></extra>",
243
+ name=f"top-{k} neighbours",
244
+ ))
245
+
246
+ # Basket highlights (mint star)
247
+ if basket_idxs:
248
+ bx = [float(coords2[i, 0]) for i in basket_idxs]
249
+ by = [float(coords2[i, 1]) for i in basket_idxs]
250
+ bz = [float(z[i]) for i in basket_idxs] if three_d else None
251
+ blabels = [NAMES_BY_IDX[i] for i in basket_idxs]
252
+ marker = dict(size=18 if not three_d else 9,
253
+ color=KAIKAKU_MINT,
254
+ symbol="star" if not three_d else "diamond",
255
+ line=dict(color=KAIKAKU_DARK, width=2.5))
256
+ if three_d:
257
+ fig.add_trace(go.Scatter3d(
258
+ x=bx, y=by, z=bz, mode="markers+text",
259
+ marker=marker, text=blabels, textposition="top center",
260
+ textfont=dict(color=KAIKAKU_MINT_BRIGHT, size=13),
261
+ hovertemplate="<b>%{text}</b> (basket)<extra></extra>", name="basket",
262
+ ))
263
+ else:
264
+ fig.add_trace(go.Scatter(
265
+ x=bx, y=by, mode="markers+text",
266
+ marker=marker, text=blabels, textposition="top center",
267
+ textfont=dict(color=KAIKAKU_MINT_BRIGHT, size=13),
268
+ hovertemplate="<b>%{text}</b> (basket)<extra></extra>", name="basket",
269
+ ))
270
+
271
+ title_suffix = " (3D)" if three_d else ""
272
+ fig.update_layout(
273
+ title=dict(text=f"UMAP of Epicure-{sibling.capitalize()}{title_suffix} - {n} ingredients",
274
+ font=dict(color=KAIKAKU_TEXT, size=15)),
275
+ height=650, margin=dict(l=40, r=40, t=60, b=40),
276
+ paper_bgcolor=KAIKAKU_DARK, plot_bgcolor=KAIKAKU_DARK,
277
+ font=dict(color=KAIKAKU_TEXT),
278
+ legend=dict(orientation="v", x=1.02, y=1,
279
+ bgcolor="rgba(26,61,63,0.85)",
280
+ bordercolor=KAIKAKU_EDGE,
281
+ font=dict(color=KAIKAKU_TEXT, size=11)),
282
+ )
283
+ if not three_d:
284
+ fig.update_xaxes(showgrid=True, gridcolor=KAIKAKU_EDGE, zeroline=False,
285
+ title=dict(text="UMAP 1", font=dict(color=KAIKAKU_TEXT)),
286
+ tickfont=dict(color=KAIKAKU_TEXT))
287
+ fig.update_yaxes(showgrid=True, gridcolor=KAIKAKU_EDGE, zeroline=False,
288
+ title=dict(text="UMAP 2", font=dict(color=KAIKAKU_TEXT)),
289
+ tickfont=dict(color=KAIKAKU_TEXT))
290
+ else:
291
+ fig.update_layout(scene=dict(
292
+ xaxis=dict(title="UMAP 1", color=KAIKAKU_TEXT, backgroundcolor=KAIKAKU_DARK, gridcolor=KAIKAKU_EDGE),
293
+ yaxis=dict(title="UMAP 2", color=KAIKAKU_TEXT, backgroundcolor=KAIKAKU_DARK, gridcolor=KAIKAKU_EDGE),
294
+ zaxis=dict(title="PC1 (z)", color=KAIKAKU_TEXT, backgroundcolor=KAIKAKU_DARK, gridcolor=KAIKAKU_EDGE),
295
+ bgcolor=KAIKAKU_DARK,
296
+ ))
297
+ return fig
298
+
299
  # ===== tab handlers =====
300
 
301
  def basket_pairings(sibling, basket, k):
302
  m = MODELS[sibling]
303
  centroid = _basket_centroid(m, basket)
304
  if centroid is None:
305
+ return [], [], _basket_heatmap(m, [])
306
  nb = _topk(m, centroid, k, exclude=basket or [])
307
  scored = [(mode.mode_id, mode.label, mode.kind, float(_unit(mode.pole) @ centroid)) for mode in m.modes]
308
  scored.sort(key=lambda x: -x[3])
 
313
  heatmap,
314
  )
315
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
316
  def supervised_slerp_multi(sibling, basket, directions, theta, k):
317
  m = MODELS[sibling]
318
  v = _basket_centroid(m, basket)
 
360
  for sib in ["cooc","core","chem"]:
361
  m = MODELS[sib]
362
  v = _basket_centroid(m, basket)
363
+ if v is None: out.append([]); continue
 
364
  valid_dirs = [d for d in (directions or []) if d in m.supervised_poles]
365
  if valid_dirs:
366
  d_vec = _stack_directions(m, valid_dirs)
 
371
  out.append([[n, f"{s:.4f}"] for n, s in hits])
372
  return out[0], out[1], out[2]
373
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
374
  # ===== fridge parser =====
375
 
376
  _LINE_SPLIT = re.compile(r"[\n;]")
 
411
  s = _LEADING_PREP.sub("", s)
412
  s = _LEADING_PREP.sub("", s)
413
  tokens = [_KNOWN_PLURALS.get(t, t) for t in s.split()]
414
+ return re.sub(r"\s+", " ", " ".join(tokens)).strip()
 
415
 
416
  def _fuzzy_lookup(cleaned, vocab, vocab_sp, min_score):
417
  if not cleaned: return None, 0.0
 
456
 
457
  # ===== UI =====
458
 
459
+ # Brand-coloured Soft theme. Mint primary, dark teal background.
460
+ mint_palette = gr.themes.Color(
461
+ c50="#F0FAF6", c100=KAIKAKU_MINT_BRIGHT, c200=KAIKAKU_MINT,
462
+ c300="#92DCBE", c400="#6FD2AA", c500="#4CC896",
463
+ c600="#3FA579", c700="#32835C", c800=KAIKAKU_MID,
464
+ c900=KAIKAKU_DARK, c950=KAIKAKU_DEEP,
465
+ )
466
+
467
+ THEME = gr.themes.Base(
468
+ primary_hue=mint_palette,
469
+ secondary_hue=mint_palette,
470
  neutral_hue="slate",
471
  font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui", "sans-serif"],
472
+ ).set(
473
+ body_background_fill=KAIKAKU_DARK,
474
+ body_text_color=KAIKAKU_TEXT,
475
+ background_fill_primary=KAIKAKU_DARK,
476
+ background_fill_secondary=KAIKAKU_MID,
477
+ block_background_fill=KAIKAKU_MID,
478
+ block_border_color=KAIKAKU_EDGE,
479
+ block_border_width="1px",
480
+ block_label_background_fill=KAIKAKU_MID,
481
+ block_label_text_color=KAIKAKU_MINT,
482
+ block_title_text_color=KAIKAKU_MINT,
483
+ button_primary_background_fill=KAIKAKU_MINT,
484
+ button_primary_background_fill_hover=KAIKAKU_MINT_BRIGHT,
485
+ button_primary_text_color=KAIKAKU_DARK,
486
+ button_primary_border_color=KAIKAKU_MINT,
487
+ button_secondary_background_fill=KAIKAKU_MID,
488
+ button_secondary_background_fill_hover=KAIKAKU_EDGE,
489
+ button_secondary_text_color=KAIKAKU_MINT,
490
+ border_color_primary=KAIKAKU_EDGE,
491
+ input_background_fill=KAIKAKU_DEEP,
492
+ input_border_color=KAIKAKU_EDGE,
493
+ input_placeholder_color=KAIKAKU_MUTED,
494
+ checkbox_background_color=KAIKAKU_DEEP,
495
+ checkbox_background_color_selected=KAIKAKU_MINT,
496
+ slider_color=KAIKAKU_MINT,
497
+ color_accent=KAIKAKU_MINT,
498
+ color_accent_soft=KAIKAKU_MID,
499
  )
500
 
501
+ CUSTOM_CSS = f"""
502
+ .gradio-container {{max-width: 1280px !important; background: {KAIKAKU_DARK} !important;}}
503
+ footer {{visibility: hidden;}}
504
+ h1, h2, h3 {{color: {KAIKAKU_MINT};}}
505
+ a {{color: {KAIKAKU_MINT};}}
506
+ .sibling-card {{
507
+ background: {KAIKAKU_MID}; border: 1px solid {KAIKAKU_EDGE};
508
+ border-radius: 8px; padding: 12px 16px; margin: 4px 0;
509
+ }}
510
+ .sibling-name {{color: {KAIKAKU_MINT}; font-weight: 600; font-size: 1.05em;}}
511
+ .sibling-desc {{color: {KAIKAKU_TEXT}; opacity: 0.85; font-size: 0.95em; line-height: 1.4;}}
512
+ .gr-dataframe table {{color: {KAIKAKU_TEXT} !important;}}
513
+ """
514
+
515
+ # Precompute initial figures so plots are populated on first page load
516
  _INITIAL_UMAP = umap_view("chem", ["chicken","lemon","garlic"], True, 8, three_d=False)
517
  _INITIAL_HEATMAP = _basket_heatmap(MODELS["chem"], ["chicken","lemon","garlic"])
518
 
519
+ SIBLING_CARDS = f"""
520
+ <div class="sibling-card">
521
+ <span class="sibling-name">Cooc</span>
522
+ <span class="sibling-desc"> - Walks recipe co-occurrence only. Neighbours are recipe companions: ingredients that <em>get cooked with</em> the seed. Isotropic geometry (PR=173.6 of 300). Best for "what else do I cook with X".</span>
523
+ </div>
524
+ <div class="sibling-card">
525
+ <span class="sibling-name">Core</span>
526
+ <span class="sibling-desc"> - Blends typed FlavorDB compound walks with injected I-I walks at ii_repeat=10. Concentrated geometry (PR=94.2), tightest emergent modes. The middle-ground sibling: chemistry-aware but keeps recipe context.</span>
527
+ </div>
528
+ <div class="sibling-card">
529
+ <span class="sibling-name">Chem</span>
530
+ <span class="sibling-desc"> - Walks typed FlavorDB compound metapaths only (ii_repeat=0). Neighbours are flavour-profile peers: ingredients that <em>share aroma chemistry</em>. Best supervised-direction recovery; cuisine Cohen's d = 3.07 over 8 macro-regions.</span>
531
+ </div>
532
+ """
533
+
534
+ with gr.Blocks(title="Epicure Explorer", theme=THEME, css=CUSTOM_CSS) as demo:
535
 
536
  gr.Markdown(
537
+ f"""# Epicure Explorer
538
+ Chef-facing operators over three sibling ingredient embeddings (Cooc / Core / Chem) from
539
+ [arXiv:2605.22391](https://arxiv.org/abs/2605.22391). 1,790 canonical ingredients across 7 languages,
540
+ 300-D Metapath2Vec, controlled chemistry-vs-recipe-context spectrum."""
541
  )
542
 
543
+ gr.HTML(SIBLING_CARDS)
544
+
545
+ sibling = gr.Radio(choices=["cooc","core","chem"], value="chem", label="Sibling embedding to query")
546
 
 
547
  shared_basket = gr.State([])
548
 
549
  # ---------- Tab 1: Basket pairings + heatmap ----------
550
  with gr.Tab("Basket pairings"):
551
  gr.Markdown(
552
  "Pick one or more ingredients. Tool averages their unit vectors and returns nearest neighbours "
553
+ "plus closest modes of that centroid. The heatmap shows whether the basket is coherent."
 
554
  )
555
  basket = gr.Dropdown(
556
  choices=ALL_INGREDIENTS, value=["chicken","lemon","garlic"],
 
561
  with gr.Row():
562
  nb_table = gr.Dataframe(headers=["Neighbour","Cosine"], label="Top-K nearest neighbours", interactive=False)
563
  mode_table = gr.Dataframe(headers=["Mode id","Label","Kind","Cosine"], label="Closest modes", interactive=False)
564
+ heatmap_plot = gr.Plot(value=_INITIAL_HEATMAP, label="Pairwise cosine (matplotlib)")
565
  pair_btn.click(
566
  basket_pairings, inputs=[sibling, basket, k_pair],
567
  outputs=[nb_table, mode_table, heatmap_plot],
 
584
 
585
  # ---------- Tab 2: Supervised SLERP ----------
586
  with gr.Tab("Supervised SLERP"):
587
+ gr.Markdown("Rotate the seed basket toward one or more supervised direction poles. Multiple directions are summed.")
588
+ sup_basket = gr.Dropdown(choices=ALL_INGREDIENTS, value=["rice"], label="Seed basket (pick 1+)", multiselect=True, max_choices=10)
589
+ sup_dirs = gr.Dropdown(choices=_supervised_choices("chem"), value=["cuisine:South_Asian"],
590
+ label="Supervised directions (pick 1+)", multiselect=True, max_choices=5)
 
 
 
 
 
 
 
 
591
  sup_theta = gr.Slider(0, 90, value=30, step=5, label="Rotation angle (deg)")
592
  sup_k = gr.Slider(1, 15, value=8, step=1, label="K")
593
  sup_btn = gr.Button("Rotate", variant="primary")
 
611
 
612
  # ---------- Tab 3: Emergent SLERP ----------
613
  with gr.Tab("Emergent SLERP"):
614
+ gr.Markdown("Rotate the seed basket toward one or more emergent FastICA factor-mode poles.")
615
+ em_basket = gr.Dropdown(choices=ALL_INGREDIENTS, value=["chocolate"], label="Seed basket (pick 1+)", multiselect=True, max_choices=10)
 
 
 
 
 
 
616
  factor_opts = _factor_mode_choices("chem")
617
+ em_modes = gr.Dropdown(choices=[label for label, _ in factor_opts],
618
+ value=[factor_opts[0][0]] if factor_opts else [],
619
+ label="Factor modes (pick 1+)", multiselect=True, max_choices=5)
 
 
620
  em_theta = gr.Slider(0, 90, value=30, step=5, label="Rotation angle (deg)")
621
  em_k = gr.Slider(1, 15, value=8, step=1, label="K")
622
  em_btn = gr.Button("Rotate", variant="primary")
 
628
 
629
  # ---------- Tab 4: Arithmetic ----------
630
  with gr.Tab("Arithmetic"):
631
+ gr.Markdown("Mikolov-style vector arithmetic: `centroid(positives) - centroid(negatives)`, then top-K neighbours.")
 
 
 
632
  pos_box = gr.Dropdown(choices=ALL_INGREDIENTS, value=["miso"], label="Positives", multiselect=True, max_choices=10)
633
  neg_box = gr.Dropdown(choices=ALL_INGREDIENTS, value=["salt"], label="Negatives", multiselect=True, max_choices=10)
634
  ar_k = gr.Slider(1, 15, value=8, step=1, label="K")
 
652
 
653
  # ---------- Tab 5: Mode atlas ----------
654
  with gr.Tab("Mode atlas"):
655
+ gr.Markdown("Browse the GMM mode atlas. Cooc 150 / Core 193 / Chem 200 modes.")
 
 
 
 
656
  atlas_kind = gr.Radio(choices=["all","factor","continuous","binary"], value="all", label="Mode kind")
657
  atlas_search = gr.Textbox(label="Search labels / properties", placeholder="e.g. South Asian, baking, fiber", value="")
658
  atlas_btn = gr.Button("Browse modes", variant="primary")
 
664
 
665
  # ---------- Tab 6: Compare siblings ----------
666
  with gr.Tab("Compare siblings"):
667
+ gr.Markdown("Same query, three siblings, side by side. The spectrum-of-models thesis in one screen.")
 
 
668
  cmp_basket = gr.Dropdown(choices=ALL_INGREDIENTS, value=["chicken"], label="Seed basket", multiselect=True, max_choices=10)
669
+ cmp_dirs = gr.Dropdown(choices=_supervised_choices("chem"), value=[],
670
+ label="Optional directions (empty = pure pairings)", multiselect=True, max_choices=5)
 
 
671
  cmp_theta = gr.Slider(0, 90, value=30, step=5, label="Rotation angle (deg)")
672
  cmp_k = gr.Slider(1, 15, value=8, step=1, label="K")
673
  cmp_btn = gr.Button("Compare across siblings", variant="primary")
 
677
  cmp_chem = gr.Dataframe(headers=["Chem neighbour","Cosine"], label="Chem (chemistry)")
678
  cmp_btn.click(compare_siblings, inputs=[cmp_basket, cmp_dirs, cmp_theta, cmp_k],
679
  outputs=[cmp_cooc, cmp_core, cmp_chem], show_progress="full")
 
 
 
 
 
 
 
 
 
 
 
 
680
 
681
  # ---------- Tab 7: UMAP visualisation ----------
682
  with gr.Tab("UMAP visualisation"):
683
  gr.Markdown(
684
+ "2-D UMAP of the 1,790-ingredient embedding (cosine, n_neighbors=30, min_dist=0.03 -- paper Figure 1). "
685
+ "Points coloured by food group. Basket members appear as mint stars; top-K neighbours as amber dots."
 
 
686
  )
687
+ umap_basket = gr.Dropdown(choices=ALL_INGREDIENTS, value=["chicken","lemon","garlic"],
688
+ label="Highlight these ingredients", multiselect=True, max_choices=10)
 
 
 
689
  with gr.Row():
690
  umap_show_nb = gr.Checkbox(value=True, label="Show top-K neighbours of basket centroid")
691
  umap_3d = gr.Checkbox(value=False, label="3-D perspective (UMAP + PC1)")
692
  umap_k = gr.Slider(1, 20, value=10, step=1, label="K neighbours")
693
  umap_btn = gr.Button("Update plot", variant="primary")
694
  umap_plot = gr.Plot(value=_INITIAL_UMAP, label="UMAP")
695
+ umap_btn.click(umap_view, inputs=[sibling, umap_basket, umap_show_nb, umap_k, umap_3d],
 
696
  outputs=umap_plot, show_progress="full")
697
+ sibling.change(umap_view, inputs=[sibling, umap_basket, umap_show_nb, umap_k, umap_3d],
 
 
698
  outputs=umap_plot)
 
699
 
700
  # ---------- Tab 8: Parse my fridge ----------
701
  with gr.Tab("Parse my fridge"):
702
  gr.Markdown(
703
+ "Paste a free-text ingredient list. Quantities, units, and prep notes are stripped, "
704
+ "then each line is fuzzy-matched to canonical vocab. "
705
+ "Click **Send matched to Basket tab** to populate the Basket Pairings input."
706
  )
707
  fridge_text = gr.Textbox(
708
  label="Free-text ingredients (one per line or semicolon-separated)",
709
  lines=8,
710
+ value=("2 boneless chicken thighs\n1 cup coconut milk\n1 tbsp fish sauce (or soy sauce)\n"
711
+ "fresh lemongrass, bruised\n3 cloves garlic, minced\n1 inch fresh ginger\n"
712
+ "juice of one lime\nsalt to taste"),
 
 
 
 
 
 
 
713
  )
714
  fridge_min = gr.Slider(40, 100, value=70, step=5, label="Min match score (rapidfuzz)")
715
  with gr.Row():
 
724
  def _parse(txt, sib, mn):
725
  rows, matches = parse_fridge(txt, sib, int(mn))
726
  return rows, ", ".join(matches), matches
727
+ fridge_btn.click(_parse, inputs=[fridge_text, sibling, fridge_min],
728
+ outputs=[fridge_table, fridge_matched, shared_basket], show_progress="full")
 
 
 
729
 
730
  def _send_to_basket(matches):
731
  return gr.Dropdown(value=matches[:10] if matches else [])