josefchen commited on
Commit
dd6d94a
·
verified ·
1 Parent(s): 4e48ffc

Add gr.Examples, mode atlas browser, compare-siblings tab

Browse files
Files changed (1) hide show
  1. app.py +209 -113
app.py CHANGED
@@ -1,12 +1,15 @@
1
  """Epicure Explorer: chef-facing operators over the three sibling embeddings.
2
 
3
- Four tabs:
4
  - Basket pairings: pick 1+ ingredients, get neighbours and closest modes of the basket centroid.
5
- - Supervised SLERP: rotate a (possibly multi-ingredient) seed toward 1+ supervised poles.
6
- - Emergent SLERP: rotate a (possibly multi-ingredient) seed toward 1+ emergent factor modes.
7
- - Arithmetic: Mikolov-style 'positives - negatives' returning nearest neighbours.
 
 
8
 
9
  All three siblings (Cooc, Core, Chem) load on startup from public HF model repos.
 
10
  """
11
 
12
  from __future__ import annotations
@@ -16,7 +19,6 @@ import sys
16
  import numpy as np
17
  import gradio as gr
18
 
19
- # epicure.py is shipped alongside this app.py in the Space; fall back to HF if absent.
20
  try:
21
  from epicure import Epicure
22
  except ImportError:
@@ -30,34 +32,27 @@ MODELS = {
30
  "core": Epicure.from_pretrained("Kaikaku/epicure-core"),
31
  "chem": Epicure.from_pretrained("Kaikaku/epicure-chem"),
32
  }
33
-
34
  ALL_INGREDIENTS = sorted(MODELS["cooc"].vocab.keys())
35
 
 
36
 
37
  def _unit(v: np.ndarray, eps: float = 1e-9) -> np.ndarray:
38
- n = np.linalg.norm(v)
39
- return v / max(n, eps)
40
-
41
 
42
- def _basket_centroid(m: Epicure, names: list[str]) -> np.ndarray:
43
- """L2-normalised mean of the unit vectors of the named ingredients."""
44
  valid = [n for n in (names or []) if n in m.vocab]
45
  if not valid:
46
  return None
47
  idxs = [m.vocab[n] for n in valid]
48
- centroid = m.E[idxs].mean(axis=0)
49
- return _unit(centroid)
50
 
51
-
52
- def _stack_directions(m: Epicure, keys: list[str], use_factor_pole: bool = False) -> np.ndarray:
53
- """L2-normalised sum of the named supervised pole vectors (or factor mode poles)."""
54
  poles = []
55
  for k in keys or []:
56
  if use_factor_pole:
57
  for mode in m.modes:
58
  if mode.mode_id == k:
59
- poles.append(_unit(mode.pole))
60
- break
61
  else:
62
  if k in m.supervised_poles:
63
  poles.append(_unit(m.supervised_poles[k]))
@@ -65,8 +60,7 @@ def _stack_directions(m: Epicure, keys: list[str], use_factor_pole: bool = False
65
  return None
66
  return _unit(np.stack(poles, axis=0).sum(axis=0))
67
 
68
-
69
- def _topk_from_query(m: Epicure, q: np.ndarray, k: int, exclude: list[str]) -> list[tuple[str, float]]:
70
  sims = m.E @ q
71
  for name in exclude or []:
72
  if name in m.vocab:
@@ -74,97 +68,111 @@ def _topk_from_query(m: Epicure, q: np.ndarray, k: int, exclude: list[str]) -> l
74
  order = np.argsort(-sims)
75
  return [(m.itos[int(i)], float(sims[i])) for i in order[:k]]
76
 
77
-
78
  def _supervised_choices(sibling: str) -> list[str]:
79
  return sorted(MODELS[sibling].supervised_poles.keys())
80
 
81
-
82
  def _factor_mode_choices(sibling: str) -> list[tuple[str, str]]:
83
- return [
84
- (f"{m.label} ({m.mode_id})", m.mode_id)
85
- for m in MODELS[sibling].modes
86
- if m.kind == "factor"
87
- ]
88
 
 
 
 
 
 
 
 
 
 
 
 
89
 
90
- # ===== Tab 1: Basket pairings =====
91
  def basket_pairings(sibling: str, basket: list[str], k: int):
92
  m = MODELS[sibling]
93
  centroid = _basket_centroid(m, basket)
94
  if centroid is None:
95
  return [], []
96
- nb = _topk_from_query(m, centroid, k=k, exclude=basket or [])
97
- # Closest modes to the basket centroid
98
- scored = [
99
- (mode.mode_id, mode.label, mode.kind, float(_unit(mode.pole) @ centroid))
100
- for mode in m.modes
101
- ]
102
  scored.sort(key=lambda x: -x[3])
103
  return (
104
  [[name, f"{sim:.4f}"] for name, sim in nb],
105
  [[mid, label, kind, f"{sim:.4f}"] for mid, label, kind, sim in scored[:k]],
106
  )
107
 
108
-
109
- # ===== Tab 2: Supervised SLERP (multi-direction, multi-seed) =====
110
  def supervised_slerp_multi(sibling: str, basket: list[str], directions: list[str], theta: float, k: int):
111
  m = MODELS[sibling]
112
  v = _basket_centroid(m, basket)
113
  d = _stack_directions(m, directions, use_factor_pole=False)
114
- if v is None or d is None:
115
  return []
116
- # SLERP from v toward d
117
- d_perp = d - (d @ v) * v
118
- n_perp = np.linalg.norm(d_perp)
119
- if n_perp < 1e-9:
120
- return _topk_from_query(m, v, k=k, exclude=basket or [])
121
- d_perp = d_perp / n_perp
122
- theta_rad = np.deg2rad(float(theta))
123
- q = _unit(np.cos(theta_rad) * v + np.sin(theta_rad) * d_perp)
124
- hits = _topk_from_query(m, q, k=k, exclude=basket or [])
125
- return [[name, f"{sim:.4f}"] for name, sim in hits]
126
-
127
 
128
- # ===== Tab 3: Emergent SLERP (multi-direction, multi-seed) =====
129
  def emergent_slerp_multi(sibling: str, basket: list[str], mode_labels: list[str], theta: float, k: int):
130
  m = MODELS[sibling]
131
- # Resolve label strings back to mode_ids
132
  label_to_id = {f"{mode.label} ({mode.mode_id})": mode.mode_id for mode in m.modes if mode.kind == "factor"}
133
  mode_ids = [label_to_id[lab] for lab in (mode_labels or []) if lab in label_to_id]
134
  v = _basket_centroid(m, basket)
135
  d = _stack_directions(m, mode_ids, use_factor_pole=True)
136
- if v is None or d is None:
137
  return []
138
- d_perp = d - (d @ v) * v
139
- n_perp = np.linalg.norm(d_perp)
140
- if n_perp < 1e-9:
141
- return [[n, f"{s:.4f}"] for n, s in _topk_from_query(m, v, k=k, exclude=basket or [])]
142
- d_perp = d_perp / n_perp
143
- theta_rad = np.deg2rad(float(theta))
144
- q = _unit(np.cos(theta_rad) * v + np.sin(theta_rad) * d_perp)
145
- hits = _topk_from_query(m, q, k=k, exclude=basket or [])
146
- return [[name, f"{sim:.4f}"] for name, sim in hits]
147
 
148
-
149
- # ===== Tab 4: Mikolov arithmetic =====
150
  def arithmetic(sibling: str, positives: list[str], negatives: list[str], k: int):
151
  m = MODELS[sibling]
152
  pos = _basket_centroid(m, positives)
153
  if pos is None:
154
  return []
155
  neg = _basket_centroid(m, negatives) if negatives else None
156
- if neg is None:
157
- q = pos
158
- else:
159
- # pos - neg, then renormalise. This is the king - man + woman pattern reshaped:
160
- # the user supplies the 'positives' and 'negatives' sets directly.
161
- q = _unit(pos - neg)
162
- exclude = (positives or []) + (negatives or [])
163
- hits = _topk_from_query(m, q, k=k, exclude=exclude)
164
- return [[name, f"{sim:.4f}"] for name, sim in hits]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
 
166
 
167
  # ===== UI =====
 
168
  with gr.Blocks(title="Epicure Explorer") as demo:
169
  gr.Markdown(
170
  """# Epicure Explorer
@@ -175,77 +183,91 @@ from [arXiv:2605.22391](https://arxiv.org/abs/2605.22391).
175
  - **Cooc** walks recipe co-occurrence only. Neighbours are recipe companions.
176
  - **Core** blends typed FlavorDB compound walks with injected I-I walks. Concentrated geometry, tightest modes.
177
  - **Chem** walks typed FlavorDB compound metapaths only. Strongest supervised-direction recovery; neighbours are flavour-profile peers.
 
 
178
  """
179
  )
180
 
181
  sibling = gr.Radio(choices=["cooc", "core", "chem"], value="chem", label="Sibling embedding")
182
 
183
- # -------- Tab 1: Basket pairings --------
184
  with gr.Tab("Basket pairings"):
185
  gr.Markdown(
186
- "Pick one or more ingredients. The tool averages their unit vectors and returns "
187
- "what is nearest to that centroid in the embedding. Useful for 'what should I add "
188
- "to the ingredients I already have?'"
189
  )
190
  basket = gr.Dropdown(
191
- choices=ALL_INGREDIENTS,
192
- value=["chicken", "lemon", "garlic"],
193
- label="Ingredient basket (pick 1+)",
194
- multiselect=True,
195
- max_choices=10,
196
  )
197
  k_pair = gr.Slider(1, 15, value=8, step=1, label="K")
198
  pair_btn = gr.Button("Find pairings", variant="primary")
199
  with gr.Row():
200
- nb_table = gr.Dataframe(
201
- headers=["Neighbour", "Cosine"], label="Top-K nearest neighbours to basket centroid", interactive=False
202
- )
203
- mode_table = gr.Dataframe(
204
- headers=["Mode id", "Label", "Kind", "Cosine"],
205
- label="Closest modes (factor + supervised)", interactive=False
206
- )
207
  pair_btn.click(basket_pairings, inputs=[sibling, basket, k_pair], outputs=[nb_table, mode_table])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
 
209
- # -------- Tab 2: Supervised SLERP (multi) --------
210
  with gr.Tab("Supervised SLERP"):
211
  gr.Markdown(
212
  "Rotate the (possibly multi-ingredient) seed toward one or more supervised direction poles. "
213
  "Multiple directions are summed and L2-normalised before rotation, matching the paper's "
214
- "'chicken + processed + Western_Atlantic' style multi-constraint queries."
215
  )
216
  sup_basket = gr.Dropdown(
217
- choices=ALL_INGREDIENTS, value=["rice"], label="Seed basket (pick 1+)",
218
- multiselect=True, max_choices=10,
219
  )
220
  sup_dirs = gr.Dropdown(
221
- choices=_supervised_choices("chem"),
222
- value=["cuisine:South_Asian"],
223
  label="Supervised directions (pick 1+; summed before rotation)",
224
  multiselect=True, max_choices=5,
225
  )
226
  sup_theta = gr.Slider(0, 90, value=30, step=5, label="Rotation angle (deg)")
227
  sup_k = gr.Slider(1, 15, value=8, step=1, label="K")
228
  sup_btn = gr.Button("Rotate", variant="primary")
229
- sup_table = gr.Dataframe(headers=["Ingredient", "Cosine"], label="Top-K rotated-query neighbours")
230
- sup_btn.click(
231
- supervised_slerp_multi,
232
- inputs=[sibling, sup_basket, sup_dirs, sup_theta, sup_k],
233
- outputs=sup_table,
234
- )
235
  sibling.change(
236
  lambda s: gr.Dropdown(choices=_supervised_choices(s), value=[]),
237
  inputs=sibling, outputs=sup_dirs,
238
  )
 
 
 
 
 
 
 
 
 
 
 
 
239
 
240
- # -------- Tab 3: Emergent SLERP (multi) --------
241
  with gr.Tab("Emergent SLERP"):
242
  gr.Markdown(
243
  "Rotate the seed basket toward one or more emergent factor-mode poles discovered "
244
  "by multi-seed-stable FastICA + GMM. Stack mode targets to combine culinary axes."
245
  )
246
  em_basket = gr.Dropdown(
247
- choices=ALL_INGREDIENTS, value=["chocolate"], label="Seed basket (pick 1+)",
248
- multiselect=True, max_choices=10,
249
  )
250
  factor_opts = _factor_mode_choices("chem")
251
  em_modes = gr.Dropdown(
@@ -257,36 +279,110 @@ from [arXiv:2605.22391](https://arxiv.org/abs/2605.22391).
257
  em_theta = gr.Slider(0, 90, value=30, step=5, label="Rotation angle (deg)")
258
  em_k = gr.Slider(1, 15, value=8, step=1, label="K")
259
  em_btn = gr.Button("Rotate", variant="primary")
260
- em_table = gr.Dataframe(headers=["Ingredient", "Cosine"], label="Top-K rotated-query neighbours")
261
- em_btn.click(
262
- emergent_slerp_multi,
263
- inputs=[sibling, em_basket, em_modes, em_theta, em_k],
264
- outputs=em_table,
265
- )
266
  sibling.change(
267
  lambda s: gr.Dropdown(choices=[label for label, _ in _factor_mode_choices(s)], value=[]),
268
  inputs=sibling, outputs=em_modes,
269
  )
270
 
271
- # -------- Tab 4: Mikolov arithmetic --------
272
  with gr.Tab("Arithmetic"):
273
  gr.Markdown(
274
  "Classic Mikolov-style vector arithmetic: `centroid(positives) - centroid(negatives)`, "
275
- "then top-K nearest neighbours. Try `miso - salty` (no negative-set), or `chicken - "
276
- "Western + Asian` style queries (split your own intuition into positives and negatives)."
277
  )
278
  pos_box = gr.Dropdown(
279
  choices=ALL_INGREDIENTS, value=["miso"],
280
  label="Positives (added)", multiselect=True, max_choices=10,
281
  )
282
  neg_box = gr.Dropdown(
283
- choices=ALL_INGREDIENTS, value=[],
284
  label="Negatives (subtracted)", multiselect=True, max_choices=10,
285
  )
286
  ar_k = gr.Slider(1, 15, value=8, step=1, label="K")
287
  ar_btn = gr.Button("Compute", variant="primary")
288
- ar_table = gr.Dataframe(headers=["Ingredient", "Cosine"], label="Top-K nearest to result vector")
289
  ar_btn.click(arithmetic, inputs=[sibling, pos_box, neg_box, ar_k], outputs=ar_table)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
290
 
291
  gr.Markdown(
292
  """---
 
1
  """Epicure Explorer: chef-facing operators over the three sibling embeddings.
2
 
3
+ Six tabs:
4
  - Basket pairings: pick 1+ ingredients, get neighbours and closest modes of the basket centroid.
5
+ - Supervised SLERP: rotate a multi-ingredient seed toward one or more supervised poles.
6
+ - Emergent SLERP: rotate a seed toward one or more emergent factor-mode poles.
7
+ - Arithmetic: Mikolov-style 'centroid(positives) - centroid(negatives)' nearest neighbours.
8
+ - Mode atlas: browse all GMM modes per sibling with kind filter and label search.
9
+ - Compare siblings: run the same query across cooc/core/chem in three columns.
10
 
11
  All three siblings (Cooc, Core, Chem) load on startup from public HF model repos.
12
+ Paper: https://arxiv.org/abs/2605.22391
13
  """
14
 
15
  from __future__ import annotations
 
19
  import numpy as np
20
  import gradio as gr
21
 
 
22
  try:
23
  from epicure import Epicure
24
  except ImportError:
 
32
  "core": Epicure.from_pretrained("Kaikaku/epicure-core"),
33
  "chem": Epicure.from_pretrained("Kaikaku/epicure-chem"),
34
  }
 
35
  ALL_INGREDIENTS = sorted(MODELS["cooc"].vocab.keys())
36
 
37
+ # ===== math helpers =====
38
 
39
  def _unit(v: np.ndarray, eps: float = 1e-9) -> np.ndarray:
40
+ n = np.linalg.norm(v); return v / max(n, eps)
 
 
41
 
42
+ def _basket_centroid(m: Epicure, names: list[str]) -> np.ndarray | None:
 
43
  valid = [n for n in (names or []) if n in m.vocab]
44
  if not valid:
45
  return None
46
  idxs = [m.vocab[n] for n in valid]
47
+ return _unit(m.E[idxs].mean(axis=0))
 
48
 
49
+ def _stack_directions(m: Epicure, keys: list[str], use_factor_pole: bool = False) -> np.ndarray | None:
 
 
50
  poles = []
51
  for k in keys or []:
52
  if use_factor_pole:
53
  for mode in m.modes:
54
  if mode.mode_id == k:
55
+ poles.append(_unit(mode.pole)); break
 
56
  else:
57
  if k in m.supervised_poles:
58
  poles.append(_unit(m.supervised_poles[k]))
 
60
  return None
61
  return _unit(np.stack(poles, axis=0).sum(axis=0))
62
 
63
+ def _topk(m: Epicure, q: np.ndarray, k: int, exclude: list[str]) -> list[tuple[str, float]]:
 
64
  sims = m.E @ q
65
  for name in exclude or []:
66
  if name in m.vocab:
 
68
  order = np.argsort(-sims)
69
  return [(m.itos[int(i)], float(sims[i])) for i in order[:k]]
70
 
 
71
  def _supervised_choices(sibling: str) -> list[str]:
72
  return sorted(MODELS[sibling].supervised_poles.keys())
73
 
 
74
  def _factor_mode_choices(sibling: str) -> list[tuple[str, str]]:
75
+ return [(f"{m.label} ({m.mode_id})", m.mode_id) for m in MODELS[sibling].modes if m.kind == "factor"]
 
 
 
 
76
 
77
+ def _slerp(m: Epicure, v: np.ndarray, d: np.ndarray, theta_deg: float) -> np.ndarray:
78
+ d_perp = d - (d @ v) * v
79
+ n_perp = np.linalg.norm(d_perp)
80
+ if n_perp < 1e-9:
81
+ return v
82
+ d_perp = d_perp / n_perp
83
+ th = np.deg2rad(float(theta_deg))
84
+ return _unit(np.cos(th) * v + np.sin(th) * d_perp)
85
+
86
+
87
+ # ===== tab handlers =====
88
 
 
89
  def basket_pairings(sibling: str, basket: list[str], k: int):
90
  m = MODELS[sibling]
91
  centroid = _basket_centroid(m, basket)
92
  if centroid is None:
93
  return [], []
94
+ nb = _topk(m, centroid, k=k, exclude=basket or [])
95
+ scored = [(mode.mode_id, mode.label, mode.kind, float(_unit(mode.pole) @ centroid)) for mode in m.modes]
 
 
 
 
96
  scored.sort(key=lambda x: -x[3])
97
  return (
98
  [[name, f"{sim:.4f}"] for name, sim in nb],
99
  [[mid, label, kind, f"{sim:.4f}"] for mid, label, kind, sim in scored[:k]],
100
  )
101
 
 
 
102
  def supervised_slerp_multi(sibling: str, basket: list[str], directions: list[str], theta: float, k: int):
103
  m = MODELS[sibling]
104
  v = _basket_centroid(m, basket)
105
  d = _stack_directions(m, directions, use_factor_pole=False)
106
+ if v is None:
107
  return []
108
+ if d is None:
109
+ return [[n, f"{s:.4f}"] for n, s in _topk(m, v, k, basket)]
110
+ q = _slerp(m, v, d, theta)
111
+ return [[name, f"{sim:.4f}"] for name, sim in _topk(m, q, k, basket)]
 
 
 
 
 
 
 
112
 
 
113
  def emergent_slerp_multi(sibling: str, basket: list[str], mode_labels: list[str], theta: float, k: int):
114
  m = MODELS[sibling]
 
115
  label_to_id = {f"{mode.label} ({mode.mode_id})": mode.mode_id for mode in m.modes if mode.kind == "factor"}
116
  mode_ids = [label_to_id[lab] for lab in (mode_labels or []) if lab in label_to_id]
117
  v = _basket_centroid(m, basket)
118
  d = _stack_directions(m, mode_ids, use_factor_pole=True)
119
+ if v is None:
120
  return []
121
+ if d is None:
122
+ return [[n, f"{s:.4f}"] for n, s in _topk(m, v, k, basket)]
123
+ q = _slerp(m, v, d, theta)
124
+ return [[name, f"{sim:.4f}"] for name, sim in _topk(m, q, k, basket)]
 
 
 
 
 
125
 
 
 
126
  def arithmetic(sibling: str, positives: list[str], negatives: list[str], k: int):
127
  m = MODELS[sibling]
128
  pos = _basket_centroid(m, positives)
129
  if pos is None:
130
  return []
131
  neg = _basket_centroid(m, negatives) if negatives else None
132
+ q = _unit(pos - neg) if neg is not None else pos
133
+ return [[name, f"{sim:.4f}"] for name, sim in _topk(m, q, k, (positives or []) + (negatives or []))]
134
+
135
+ def browse_modes(sibling: str, kind_filter: str, query: str):
136
+ m = MODELS[sibling]
137
+ rows = []
138
+ q = (query or "").strip().lower()
139
+ for mode in m.modes:
140
+ if kind_filter != "all" and mode.kind != kind_filter:
141
+ continue
142
+ if q and q not in mode.label.lower() and q not in mode.property.lower():
143
+ continue
144
+ rows.append([
145
+ mode.mode_id,
146
+ mode.kind,
147
+ mode.property,
148
+ mode.label,
149
+ mode.n_members,
150
+ ", ".join(mode.members[:12]),
151
+ ])
152
+ rows.sort(key=lambda r: (r[1], -r[4]))
153
+ return rows
154
+
155
+ def compare_siblings(basket: list[str], directions: list[str], theta: float, k: int):
156
+ out = []
157
+ for sib in ["cooc", "core", "chem"]:
158
+ m = MODELS[sib]
159
+ v = _basket_centroid(m, basket)
160
+ if v is None:
161
+ out.append([]); continue
162
+ # Direction set can use any pole key; we intersect with this sibling's supervised_poles
163
+ valid_dirs = [d for d in (directions or []) if d in m.supervised_poles]
164
+ if valid_dirs:
165
+ d_vec = _stack_directions(m, valid_dirs)
166
+ q = _slerp(m, v, d_vec, theta) if d_vec is not None else v
167
+ else:
168
+ q = v
169
+ hits = _topk(m, q, k=k, exclude=basket)
170
+ out.append([[name, f"{sim:.4f}"] for name, sim in hits])
171
+ return out[0], out[1], out[2]
172
 
173
 
174
  # ===== UI =====
175
+
176
  with gr.Blocks(title="Epicure Explorer") as demo:
177
  gr.Markdown(
178
  """# Epicure Explorer
 
183
  - **Cooc** walks recipe co-occurrence only. Neighbours are recipe companions.
184
  - **Core** blends typed FlavorDB compound walks with injected I-I walks. Concentrated geometry, tightest modes.
185
  - **Chem** walks typed FlavorDB compound metapaths only. Strongest supervised-direction recovery; neighbours are flavour-profile peers.
186
+
187
+ Pick a sibling, then explore. Each tab has a few worked examples just below the form: click any row to populate the inputs.
188
  """
189
  )
190
 
191
  sibling = gr.Radio(choices=["cooc", "core", "chem"], value="chem", label="Sibling embedding")
192
 
193
+ # ---------- Tab 1: Basket pairings ----------
194
  with gr.Tab("Basket pairings"):
195
  gr.Markdown(
196
+ "Pick one or more ingredients. The tool averages their unit vectors and returns nearest "
197
+ "neighbours plus closest modes of that centroid. Useful for 'what should I add to what I have'."
 
198
  )
199
  basket = gr.Dropdown(
200
+ choices=ALL_INGREDIENTS, value=["chicken","lemon","garlic"],
201
+ label="Ingredient basket (pick 1+)", multiselect=True, max_choices=10,
 
 
 
202
  )
203
  k_pair = gr.Slider(1, 15, value=8, step=1, label="K")
204
  pair_btn = gr.Button("Find pairings", variant="primary")
205
  with gr.Row():
206
+ nb_table = gr.Dataframe(headers=["Neighbour","Cosine"], label="Top-K nearest neighbours", interactive=False)
207
+ mode_table = gr.Dataframe(headers=["Mode id","Label","Kind","Cosine"], label="Closest modes", interactive=False)
 
 
 
 
 
208
  pair_btn.click(basket_pairings, inputs=[sibling, basket, k_pair], outputs=[nb_table, mode_table])
209
+ gr.Examples(
210
+ examples=[
211
+ ["chem", ["chicken","lemon","garlic"], 8],
212
+ ["core", ["miso","ginger","sesame_oil"], 8],
213
+ ["chem", ["tomato","basil","mozzarella_cheese"], 8],
214
+ ["cooc", ["chocolate","strawberry","cream"], 8],
215
+ ["chem", ["cumin","coriander","turmeric"], 8],
216
+ ["core", ["soy_sauce","ginger","scallion"], 8],
217
+ ["chem", ["red_wine","beef","rosemary"], 8],
218
+ ["core", ["coconut_milk","lemongrass","fish_sauce"], 8],
219
+ ],
220
+ inputs=[sibling, basket, k_pair],
221
+ label="Try one of these baskets",
222
+ )
223
 
224
+ # ---------- Tab 2: Supervised SLERP ----------
225
  with gr.Tab("Supervised SLERP"):
226
  gr.Markdown(
227
  "Rotate the (possibly multi-ingredient) seed toward one or more supervised direction poles. "
228
  "Multiple directions are summed and L2-normalised before rotation, matching the paper's "
229
+ "multi-constraint queries (e.g. 'chicken + processed + Western_Atlantic')."
230
  )
231
  sup_basket = gr.Dropdown(
232
+ choices=ALL_INGREDIENTS, value=["rice"],
233
+ label="Seed basket (pick 1+)", multiselect=True, max_choices=10,
234
  )
235
  sup_dirs = gr.Dropdown(
236
+ choices=_supervised_choices("chem"), value=["cuisine:South_Asian"],
 
237
  label="Supervised directions (pick 1+; summed before rotation)",
238
  multiselect=True, max_choices=5,
239
  )
240
  sup_theta = gr.Slider(0, 90, value=30, step=5, label="Rotation angle (deg)")
241
  sup_k = gr.Slider(1, 15, value=8, step=1, label="K")
242
  sup_btn = gr.Button("Rotate", variant="primary")
243
+ sup_table = gr.Dataframe(headers=["Ingredient","Cosine"], label="Top-K rotated-query neighbours")
244
+ sup_btn.click(supervised_slerp_multi, inputs=[sibling, sup_basket, sup_dirs, sup_theta, sup_k], outputs=sup_table)
 
 
 
 
245
  sibling.change(
246
  lambda s: gr.Dropdown(choices=_supervised_choices(s), value=[]),
247
  inputs=sibling, outputs=sup_dirs,
248
  )
249
+ gr.Examples(
250
+ examples=[
251
+ ["chem", ["rice"], ["cuisine:South_Asian"], 30, 8],
252
+ ["chem", ["corn"], ["cuisine:Latin_American"], 30, 8],
253
+ ["core", ["chicken"], ["cuisine:Mediterranean"], 45, 8],
254
+ ["core", ["tomato","basil"], ["cuisine:Southeast_Asian"], 45, 8],
255
+ ["chem", ["beef"], ["cuisine:East_Asian"], 60, 8],
256
+ ["cooc", ["chocolate"], ["cuisine:Latin_American"], 30, 8],
257
+ ],
258
+ inputs=[sibling, sup_basket, sup_dirs, sup_theta, sup_k],
259
+ label="Try one of these rotations",
260
+ )
261
 
262
+ # ---------- Tab 3: Emergent SLERP ----------
263
  with gr.Tab("Emergent SLERP"):
264
  gr.Markdown(
265
  "Rotate the seed basket toward one or more emergent factor-mode poles discovered "
266
  "by multi-seed-stable FastICA + GMM. Stack mode targets to combine culinary axes."
267
  )
268
  em_basket = gr.Dropdown(
269
+ choices=ALL_INGREDIENTS, value=["chocolate"],
270
+ label="Seed basket (pick 1+)", multiselect=True, max_choices=10,
271
  )
272
  factor_opts = _factor_mode_choices("chem")
273
  em_modes = gr.Dropdown(
 
279
  em_theta = gr.Slider(0, 90, value=30, step=5, label="Rotation angle (deg)")
280
  em_k = gr.Slider(1, 15, value=8, step=1, label="K")
281
  em_btn = gr.Button("Rotate", variant="primary")
282
+ em_table = gr.Dataframe(headers=["Ingredient","Cosine"], label="Top-K rotated-query neighbours")
283
+ em_btn.click(emergent_slerp_multi, inputs=[sibling, em_basket, em_modes, em_theta, em_k], outputs=em_table)
 
 
 
 
284
  sibling.change(
285
  lambda s: gr.Dropdown(choices=[label for label, _ in _factor_mode_choices(s)], value=[]),
286
  inputs=sibling, outputs=em_modes,
287
  )
288
 
289
+ # ---------- Tab 4: Arithmetic ----------
290
  with gr.Tab("Arithmetic"):
291
  gr.Markdown(
292
  "Classic Mikolov-style vector arithmetic: `centroid(positives) - centroid(negatives)`, "
293
+ "then top-K nearest neighbours. The killer demo is `miso - salt` on Core (returns the "
294
+ "Japanese fermented-umami pantry minus the salty component): mirin, kombu, wakame, sake, dashi."
295
  )
296
  pos_box = gr.Dropdown(
297
  choices=ALL_INGREDIENTS, value=["miso"],
298
  label="Positives (added)", multiselect=True, max_choices=10,
299
  )
300
  neg_box = gr.Dropdown(
301
+ choices=ALL_INGREDIENTS, value=["salt"],
302
  label="Negatives (subtracted)", multiselect=True, max_choices=10,
303
  )
304
  ar_k = gr.Slider(1, 15, value=8, step=1, label="K")
305
  ar_btn = gr.Button("Compute", variant="primary")
306
+ ar_table = gr.Dataframe(headers=["Ingredient","Cosine"], label="Top-K nearest to result vector")
307
  ar_btn.click(arithmetic, inputs=[sibling, pos_box, neg_box, ar_k], outputs=ar_table)
308
+ gr.Examples(
309
+ examples=[
310
+ ["core", ["miso"], ["salt"], 8],
311
+ ["core", ["chicken","tofu"], ["beef"], 8],
312
+ ["cooc", ["basil","cumin"], ["parsley"], 8],
313
+ ["chem", ["chocolate"], ["sugar"], 8],
314
+ ["chem", ["wine"], ["beer"], 8],
315
+ ["core", ["bread"], ["flour"], 8],
316
+ ["core", ["coffee"], ["milk"], 8],
317
+ ["chem", ["mozzarella_cheese"], ["milk"], 8],
318
+ ],
319
+ inputs=[sibling, pos_box, neg_box, ar_k],
320
+ label="Try one of these arithmetic queries",
321
+ )
322
+
323
+ # ---------- Tab 5: Mode atlas browser ----------
324
+ with gr.Tab("Mode atlas"):
325
+ gr.Markdown(
326
+ "Browse the GMM mode atlas of the selected sibling. Cooc has 150 modes across 41 properties; "
327
+ "Core 193 / 44; Chem 200 / 43. `factor` modes are the emergent FastICA factor poles; "
328
+ "`continuous` modes are quartile partitions of NOVA / sensory / USDA scores; "
329
+ "`binary` modes are food-group buckets. Search by label or property substring."
330
+ )
331
+ atlas_kind = gr.Radio(
332
+ choices=["all","factor","continuous","binary"], value="all", label="Mode kind"
333
+ )
334
+ atlas_search = gr.Textbox(
335
+ label="Search labels / properties", placeholder="e.g. South Asian, baking, fiber",
336
+ value="",
337
+ )
338
+ atlas_btn = gr.Button("Browse modes", variant="primary")
339
+ atlas_table = gr.Dataframe(
340
+ headers=["mode_id","kind","property","label","n_members","top members"],
341
+ label="Modes (sorted by kind, then size descending)",
342
+ wrap=True, interactive=False,
343
+ )
344
+ atlas_btn.click(browse_modes, inputs=[sibling, atlas_kind, atlas_search], outputs=atlas_table)
345
+
346
+ # ---------- Tab 6: Compare siblings ----------
347
+ with gr.Tab("Compare siblings"):
348
+ gr.Markdown(
349
+ "Run the same query across all three siblings in one shot. This is the spectrum-of-models "
350
+ "view the paper is built around: Cooc shows recipe companions, Chem shows chemistry peers, "
351
+ "Core sits in between. Leave the direction empty for pure basket pairings."
352
+ )
353
+ cmp_basket = gr.Dropdown(
354
+ choices=ALL_INGREDIENTS, value=["chicken"],
355
+ label="Seed basket (pick 1+)", multiselect=True, max_choices=10,
356
+ )
357
+ cmp_dirs = gr.Dropdown(
358
+ choices=_supervised_choices("chem"), value=[],
359
+ label="Optional: supervised directions (leave empty for pure pairings)",
360
+ multiselect=True, max_choices=5,
361
+ )
362
+ cmp_theta = gr.Slider(0, 90, value=30, step=5, label="Rotation angle (deg; ignored if no directions)")
363
+ cmp_k = gr.Slider(1, 15, value=8, step=1, label="K")
364
+ cmp_btn = gr.Button("Compare across siblings", variant="primary")
365
+ with gr.Row():
366
+ cmp_cooc = gr.Dataframe(headers=["Cooc neighbour","Cosine"], label="Cooc (recipe-context)")
367
+ cmp_core = gr.Dataframe(headers=["Core neighbour","Cosine"], label="Core (blended)")
368
+ cmp_chem = gr.Dataframe(headers=["Chem neighbour","Cosine"], label="Chem (chemistry)")
369
+ cmp_btn.click(
370
+ compare_siblings,
371
+ inputs=[cmp_basket, cmp_dirs, cmp_theta, cmp_k],
372
+ outputs=[cmp_cooc, cmp_core, cmp_chem],
373
+ )
374
+ gr.Examples(
375
+ examples=[
376
+ [["chicken"], [], 0, 8],
377
+ [["basil"], [], 0, 8],
378
+ [["miso"], [], 0, 8],
379
+ [["rice"], ["cuisine:South_Asian"], 30, 8],
380
+ [["corn"], ["cuisine:Latin_American"], 30, 8],
381
+ [["chicken","onion"], ["cuisine:Mediterranean"], 45, 8],
382
+ ],
383
+ inputs=[cmp_basket, cmp_dirs, cmp_theta, cmp_k],
384
+ label="Try one of these side-by-side comparisons",
385
+ )
386
 
387
  gr.Markdown(
388
  """---