Spaces:
Running
Running
9 new features: substitution finder, sensory search, ingredient passport, mode wiki, cultural context, sibling alignment, recipe constellation, paper-stats heatmap, recipe coherence
Browse files- __pycache__/app.cpython-310.pyc +0 -0
- app.py +623 -0
__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
|
@@ -528,6 +528,501 @@ def api_embed(ingredient, sibling="chem"):
|
|
| 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 |
# =====================================================================
|
| 532 |
# Gallery: six aesthetic visualisations.
|
| 533 |
# All use a shared dark-teal Kaikaku palette so they hang together.
|
|
@@ -1247,6 +1742,134 @@ Three sibling ingredient embeddings from [arXiv:2605.22391](https://arxiv.org/ab
|
|
| 1247 |
label="Try one of these",
|
| 1248 |
)
|
| 1249 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1250 |
# ---------- Tab 5: GALLERY ----------
|
| 1251 |
with gr.Tab("Gallery"):
|
| 1252 |
gr.Markdown("Six aesthetic views of the model. All rendered in the Kaikaku palette.")
|
|
|
|
| 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 |
+
# =====================================================================
|
| 532 |
+
# Inverse queries: substitution finder + sensory profile search
|
| 533 |
+
# =====================================================================
|
| 534 |
+
|
| 535 |
+
_SENSORY_SLIDER_KEYS = [
|
| 536 |
+
("sweet", ["sweet_score/", "cf_sweet/"]),
|
| 537 |
+
("sour", ["sour_score/", "cf_sour/"]),
|
| 538 |
+
("bitter", ["bitter_score/","cf_bitter/"]),
|
| 539 |
+
("umami", ["umami_score/", "cf_umami/", "cf_meaty/"]),
|
| 540 |
+
("fatty", ["fatty_score/", "cf_fatty/"]),
|
| 541 |
+
("pungent", ["pungent_score/", "cf_pungent/"]),
|
| 542 |
+
("savory", ["cf_savory/"]),
|
| 543 |
+
("citrus", ["cf_citrus/"]),
|
| 544 |
+
("woody", ["cf_woody/"]),
|
| 545 |
+
("earthy", ["cf_earthy/"]),
|
| 546 |
+
]
|
| 547 |
+
|
| 548 |
+
def _poles_with_prefix(m, prefix):
|
| 549 |
+
return [k for k in m.supervised_poles.keys() if k.startswith(prefix)]
|
| 550 |
+
|
| 551 |
+
def _aggregate_pole(m, prefixes):
|
| 552 |
+
keys = []
|
| 553 |
+
for p in prefixes: keys.extend(_poles_with_prefix(m, p))
|
| 554 |
+
if not keys: return None
|
| 555 |
+
vecs = np.stack([_unit(m.supervised_poles[k]) for k in keys], axis=0)
|
| 556 |
+
return _unit(vecs.mean(axis=0))
|
| 557 |
+
|
| 558 |
+
def _dominant_cuisine_pole(m, seed_name):
|
| 559 |
+
if seed_name not in m.vocab: return None
|
| 560 |
+
v = _unit(m.E[m.vocab[seed_name]])
|
| 561 |
+
best, best_sim = None, -1e9
|
| 562 |
+
for c in _CUISINES:
|
| 563 |
+
key = f"cuisine:{c}"
|
| 564 |
+
if key not in m.supervised_poles: continue
|
| 565 |
+
p = _unit(m.supervised_poles[key])
|
| 566 |
+
s = float(v @ p)
|
| 567 |
+
if s > best_sim: best_sim, best = s, p
|
| 568 |
+
return best
|
| 569 |
+
|
| 570 |
+
def substitute_finder(seed, sibling, k, must_share_group, same_nova, diff_cuisine):
|
| 571 |
+
if not seed or seed not in MODELS[sibling].vocab:
|
| 572 |
+
return [], "_(pick a seed ingredient)_"
|
| 573 |
+
m = MODELS[sibling]
|
| 574 |
+
v = _unit(m.E[m.vocab[seed]])
|
| 575 |
+
q = v
|
| 576 |
+
notes_dir = []
|
| 577 |
+
if same_nova:
|
| 578 |
+
nova_keys = _poles_with_prefix(m, "nova_level/")
|
| 579 |
+
if nova_keys:
|
| 580 |
+
sims = [(k_, float(v @ _unit(m.supervised_poles[k_]))) for k_ in nova_keys]
|
| 581 |
+
top_key, _ = max(sims, key=lambda x: x[1])
|
| 582 |
+
q = _slerp(q, _unit(m.supervised_poles[top_key]), 15)
|
| 583 |
+
notes_dir.append("pulled toward NOVA peer")
|
| 584 |
+
if diff_cuisine:
|
| 585 |
+
d_cui = _dominant_cuisine_pole(m, seed)
|
| 586 |
+
if d_cui is not None:
|
| 587 |
+
q = _slerp(q, -d_cui, 30)
|
| 588 |
+
notes_dir.append("rotated 30° from dominant cuisine")
|
| 589 |
+
q = _unit(q)
|
| 590 |
+
seed_group = _NAME_TO_GROUP.get(seed, "Other")
|
| 591 |
+
wide = _topk(m, q, max(int(k) * 8, 40), exclude=[seed])
|
| 592 |
+
rows = []
|
| 593 |
+
for name, sim in wide:
|
| 594 |
+
grp = _NAME_TO_GROUP.get(name, "Other")
|
| 595 |
+
if must_share_group and grp != seed_group: continue
|
| 596 |
+
bits = [f"group: {grp}"] + notes_dir
|
| 597 |
+
rows.append([name, f"{sim:.4f}", grp, "; ".join(bits)])
|
| 598 |
+
if len(rows) >= int(k): break
|
| 599 |
+
return rows, f"Seed **{seed}** (group: {seed_group}). {len(rows)} substitutes."
|
| 600 |
+
|
| 601 |
+
def sensory_search(sibling, k, *slider_values):
|
| 602 |
+
m = MODELS[sibling]
|
| 603 |
+
weights = dict(zip([lbl for lbl, _ in _SENSORY_SLIDER_KEYS], slider_values))
|
| 604 |
+
parts, used = [], []
|
| 605 |
+
for label, prefixes in _SENSORY_SLIDER_KEYS:
|
| 606 |
+
w = float(weights.get(label, 0.0))
|
| 607 |
+
if w <= 0: continue
|
| 608 |
+
pole = _aggregate_pole(m, prefixes)
|
| 609 |
+
if pole is None: continue
|
| 610 |
+
parts.append(w * pole)
|
| 611 |
+
used.append(f"{label}×{w:.2f}")
|
| 612 |
+
if not parts: return [], "_(move at least one slider above 0)_"
|
| 613 |
+
q = _unit(np.sum(parts, axis=0))
|
| 614 |
+
rows = [[n, f"{s:.4f}", _NAME_TO_GROUP.get(n, "Other")]
|
| 615 |
+
for n, s in _topk(m, q, int(k), exclude=[])]
|
| 616 |
+
return rows, "**Target axes:** " + " · ".join(used)
|
| 617 |
+
|
| 618 |
+
# =====================================================================
|
| 619 |
+
# Inspect: ingredient passport + mode wiki + cultural context
|
| 620 |
+
# =====================================================================
|
| 621 |
+
|
| 622 |
+
import matplotlib.gridspec as gridspec
|
| 623 |
+
|
| 624 |
+
PASSPORT_SIBS = ["cooc", "core", "chem"]
|
| 625 |
+
PASSPORT_SENS = ["sweet","sour","bitter","umami","fatty","pungent"]
|
| 626 |
+
|
| 627 |
+
def _find_sensory_pole(m, axis):
|
| 628 |
+
for cand in (f"cf_{axis}", f"{axis}_score", axis):
|
| 629 |
+
if cand in m.supervised_poles:
|
| 630 |
+
return _unit(m.supervised_poles[cand])
|
| 631 |
+
# fuzzy
|
| 632 |
+
for k, v in m.supervised_poles.items():
|
| 633 |
+
if axis in k.lower():
|
| 634 |
+
return _unit(v)
|
| 635 |
+
return None
|
| 636 |
+
|
| 637 |
+
def _sensory_profile(name):
|
| 638 |
+
out = {}
|
| 639 |
+
for axis in PASSPORT_SENS:
|
| 640 |
+
vals = []
|
| 641 |
+
for sib in PASSPORT_SIBS:
|
| 642 |
+
m = MODELS[sib]
|
| 643 |
+
if name not in m.vocab: continue
|
| 644 |
+
v = _unit(m.E[m.vocab[name]])
|
| 645 |
+
p = _find_sensory_pole(m, axis)
|
| 646 |
+
if p is not None: vals.append(float(v @ p))
|
| 647 |
+
out[axis] = float(np.mean(vals)) if vals else 0.0
|
| 648 |
+
return out
|
| 649 |
+
|
| 650 |
+
def render_passport(name):
|
| 651 |
+
fig = plt.figure(figsize=(11.7, 16.5), facecolor=KAIKAKU_DARK)
|
| 652 |
+
fig.patch.set_facecolor(KAIKAKU_DARK)
|
| 653 |
+
gs = gridspec.GridSpec(6, 3, figure=fig,
|
| 654 |
+
height_ratios=[0.9, 0.4, 2.4, 2.0, 2.0, 0.4],
|
| 655 |
+
hspace=0.55, wspace=0.35, left=0.06, right=0.94, top=0.96, bottom=0.04)
|
| 656 |
+
def styled(ax, title=None):
|
| 657 |
+
ax.set_facecolor(KAIKAKU_DARK)
|
| 658 |
+
for s in ax.spines.values():
|
| 659 |
+
s.set_color(KAIKAKU_ACCENT_LIGHT); s.set_alpha(0.25)
|
| 660 |
+
ax.tick_params(colors=KAIKAKU_ACCENT_LIGHT, labelsize=8)
|
| 661 |
+
if title:
|
| 662 |
+
ax.set_title(title, color=KAIKAKU_ACCENT_LIGHT, fontsize=10,
|
| 663 |
+
family="monospace", loc="left", pad=8)
|
| 664 |
+
return ax
|
| 665 |
+
# Headline
|
| 666 |
+
ax_h = fig.add_subplot(gs[0, :]); ax_h.axis("off")
|
| 667 |
+
pretty = (name or "").replace("_", " ").upper()
|
| 668 |
+
ax_h.text(0.0, 0.55, pretty, color=KAIKAKU_ACCENT_LIGHT,
|
| 669 |
+
fontsize=42, fontweight="bold", va="center")
|
| 670 |
+
group = _NAME_TO_GROUP.get(name, "Other")
|
| 671 |
+
ax_h.text(0.0, 0.05, f"INGREDIENT PASSPORT · FOOD GROUP: {group.upper()}",
|
| 672 |
+
color=KAIKAKU_ACCENT, fontsize=11, family="monospace", va="center")
|
| 673 |
+
ax_h.plot([0, 1], [0.0, 0.0], color=KAIKAKU_ACCENT, lw=2, transform=ax_h.transAxes)
|
| 674 |
+
# Section label
|
| 675 |
+
ax_s = fig.add_subplot(gs[1, :]); ax_s.axis("off")
|
| 676 |
+
ax_s.text(0.0, 0.5, "// NEAREST NEIGHBOURS PER SIBLING",
|
| 677 |
+
color=KAIKAKU_ACCENT_LIGHT, fontsize=11, family="monospace", va="center")
|
| 678 |
+
# Neighbours
|
| 679 |
+
for i, sib in enumerate(PASSPORT_SIBS):
|
| 680 |
+
ax = styled(fig.add_subplot(gs[2, i]), title=f"[{sib.upper()}]")
|
| 681 |
+
ax.set_xticks([]); ax.set_yticks([])
|
| 682 |
+
m = MODELS[sib]
|
| 683 |
+
if name not in m.vocab:
|
| 684 |
+
ax.text(0.5, 0.5, "(not in vocab)", ha="center", va="center",
|
| 685 |
+
color=KAIKAKU_ACCENT_LIGHT, transform=ax.transAxes, fontsize=10)
|
| 686 |
+
continue
|
| 687 |
+
q = _unit(m.E[m.vocab[name]])
|
| 688 |
+
for row, (nb, sim) in enumerate(_topk(m, q, 5, exclude=[name])):
|
| 689 |
+
y = 0.88 - row * 0.18
|
| 690 |
+
ax.text(0.04, y, nb.replace("_", " "), color="#FFFFFF",
|
| 691 |
+
fontsize=11, family="monospace", transform=ax.transAxes)
|
| 692 |
+
ax.text(0.96, y, f"{sim:.3f}", color=KAIKAKU_ACCENT_LIGHT,
|
| 693 |
+
fontsize=10, family="monospace", ha="right", transform=ax.transAxes)
|
| 694 |
+
# Sensory radar (polar inset)
|
| 695 |
+
ax_radar_host = fig.add_subplot(gs[3, 0]); ax_radar_host.axis("off")
|
| 696 |
+
ax_radar_host.text(0.0, 1.02, "// SENSORY RADAR",
|
| 697 |
+
color=KAIKAKU_ACCENT_LIGHT, fontsize=11,
|
| 698 |
+
family="monospace", transform=ax_radar_host.transAxes)
|
| 699 |
+
bb = ax_radar_host.get_position()
|
| 700 |
+
pad_x, pad_y = bb.width * 0.08, bb.height * 0.10
|
| 701 |
+
ax_polar = fig.add_axes(
|
| 702 |
+
[bb.x0 + pad_x, bb.y0 + pad_y, bb.width - 2*pad_x, bb.height - 2*pad_y - 0.01],
|
| 703 |
+
projection="polar")
|
| 704 |
+
sens = _sensory_profile(name)
|
| 705 |
+
theta = np.linspace(0, 2*np.pi, len(PASSPORT_SENS), endpoint=False)
|
| 706 |
+
r = np.array([max(0.0, sens[a]) for a in PASSPORT_SENS])
|
| 707 |
+
theta_c = np.concatenate([theta, theta[:1]])
|
| 708 |
+
r_c = np.concatenate([r, r[:1]])
|
| 709 |
+
ax_polar.set_facecolor(KAIKAKU_DARK)
|
| 710 |
+
ax_polar.plot(theta_c, r_c, color=KAIKAKU_ACCENT_LIGHT, lw=2)
|
| 711 |
+
ax_polar.fill(theta_c, r_c, color=KAIKAKU_ACCENT, alpha=0.35)
|
| 712 |
+
ax_polar.set_xticks(theta)
|
| 713 |
+
ax_polar.set_xticklabels([a.upper() for a in PASSPORT_SENS],
|
| 714 |
+
color=KAIKAKU_ACCENT_LIGHT, fontsize=9, family="monospace")
|
| 715 |
+
ax_polar.set_yticklabels([])
|
| 716 |
+
ax_polar.set_ylim(0, max(0.6, float(r.max()) + 0.05))
|
| 717 |
+
ax_polar.grid(color=KAIKAKU_ACCENT_LIGHT, alpha=0.20, lw=0.6)
|
| 718 |
+
# Cuisine bar
|
| 719 |
+
ax_c = styled(fig.add_subplot(gs[3, 1:]), title="// CUISINE AFFILIATION (chem)")
|
| 720 |
+
m = MODELS["chem"]
|
| 721 |
+
if name in m.vocab:
|
| 722 |
+
v = _unit(m.E[m.vocab[name]])
|
| 723 |
+
vals, labels = [], []
|
| 724 |
+
for cu in _CUISINES:
|
| 725 |
+
key = f"cuisine:{cu}"
|
| 726 |
+
if key in m.supervised_poles:
|
| 727 |
+
vals.append(float(v @ _unit(m.supervised_poles[key])))
|
| 728 |
+
labels.append(cu.replace("_", " "))
|
| 729 |
+
y_pos = np.arange(len(labels))
|
| 730 |
+
bar_colors = [KAIKAKU_ACCENT_LIGHT if x >= 0 else "#F4B86E" for x in vals]
|
| 731 |
+
ax_c.barh(y_pos, vals, color=bar_colors, edgecolor=KAIKAKU_ACCENT, linewidth=0.6)
|
| 732 |
+
ax_c.set_yticks(y_pos); ax_c.set_yticklabels(labels, color="#FFFFFF", fontsize=9)
|
| 733 |
+
ax_c.axvline(0, color=KAIKAKU_ACCENT_LIGHT, alpha=0.4, lw=0.8)
|
| 734 |
+
ax_c.invert_yaxis()
|
| 735 |
+
else:
|
| 736 |
+
ax_c.text(0.5, 0.5, "(not in chem vocab)", ha="center", va="center",
|
| 737 |
+
color=KAIKAKU_ACCENT_LIGHT, transform=ax_c.transAxes)
|
| 738 |
+
# Closest 3 emergent modes
|
| 739 |
+
ax_m = styled(fig.add_subplot(gs[4, :]),
|
| 740 |
+
title="// CLOSEST EMERGENT FACTOR MODES (top 3 across siblings)")
|
| 741 |
+
ax_m.set_xticks([]); ax_m.set_yticks([])
|
| 742 |
+
scored = []
|
| 743 |
+
for sib in PASSPORT_SIBS:
|
| 744 |
+
m = MODELS[sib]
|
| 745 |
+
if name not in m.vocab: continue
|
| 746 |
+
v = _unit(m.E[m.vocab[name]])
|
| 747 |
+
for md in m.modes:
|
| 748 |
+
if md.kind != "factor": continue
|
| 749 |
+
scored.append((float(_unit(md.pole) @ v), sib, md))
|
| 750 |
+
scored.sort(key=lambda x: -x[0])
|
| 751 |
+
for row, (sim, sib, md) in enumerate(scored[:3]):
|
| 752 |
+
y = 0.82 - row * 0.30
|
| 753 |
+
ax_m.text(0.01, y, f"[{sib.upper()}]", color=KAIKAKU_ACCENT,
|
| 754 |
+
fontsize=11, family="monospace", transform=ax_m.transAxes)
|
| 755 |
+
ax_m.text(0.10, y, md.label, color="#FFFFFF",
|
| 756 |
+
fontsize=13, fontweight="bold", transform=ax_m.transAxes)
|
| 757 |
+
ax_m.text(0.99, y, f"cos {sim:.3f}", color=KAIKAKU_ACCENT_LIGHT,
|
| 758 |
+
fontsize=10, family="monospace", ha="right", transform=ax_m.transAxes)
|
| 759 |
+
members = ", ".join(md.members[:6])
|
| 760 |
+
ax_m.text(0.10, y - 0.10, members, color=KAIKAKU_ACCENT_LIGHT,
|
| 761 |
+
fontsize=9, family="monospace", transform=ax_m.transAxes)
|
| 762 |
+
# Footer
|
| 763 |
+
ax_f = fig.add_subplot(gs[5, :]); ax_f.axis("off")
|
| 764 |
+
ax_f.text(0.5, 0.5, "EPICURE · 300-D INGREDIENT EMBEDDING · KAIKAKU",
|
| 765 |
+
ha="center", va="center", color=KAIKAKU_ACCENT,
|
| 766 |
+
fontsize=9, family="monospace", transform=ax_f.transAxes)
|
| 767 |
+
return fig
|
| 768 |
+
|
| 769 |
+
|
| 770 |
+
def _mode_choices_searchable(sibling):
|
| 771 |
+
m = MODELS[sibling]
|
| 772 |
+
out = []
|
| 773 |
+
for md in m.modes:
|
| 774 |
+
z = f" z={md.prop_z_mean:+.2f}" if isinstance(md.prop_z_mean, (int, float)) else ""
|
| 775 |
+
out.append((f"[{md.kind}] {md.label} ({md.mode_id}, n={md.n_members}{z})", md.mode_id))
|
| 776 |
+
return sorted(out, key=lambda x: x[0].lower())
|
| 777 |
+
|
| 778 |
+
def render_mode_wiki(sibling, mode_id):
|
| 779 |
+
if not sibling or not mode_id: return "_Pick a mode._"
|
| 780 |
+
m = MODELS[sibling]
|
| 781 |
+
target = next((md for md in m.modes if md.mode_id == mode_id), None)
|
| 782 |
+
if target is None: return f"_Mode `{mode_id}` not found._"
|
| 783 |
+
pole = _unit(target.pole)
|
| 784 |
+
members = [n for n in (target.members or []) if n in m.vocab]
|
| 785 |
+
if members:
|
| 786 |
+
idxs = np.array([m.vocab[n] for n in members])
|
| 787 |
+
sims_mem = m.E[idxs] @ pole
|
| 788 |
+
members = [members[i] for i in np.argsort(-sims_mem)]
|
| 789 |
+
related = []
|
| 790 |
+
for md in m.modes:
|
| 791 |
+
if md.mode_id == target.mode_id: continue
|
| 792 |
+
related.append((md, float(_unit(md.pole) @ pole)))
|
| 793 |
+
related.sort(key=lambda x: -x[1])
|
| 794 |
+
sup = sorted(((k, float(_unit(v) @ pole)) for k, v in m.supervised_poles.items()),
|
| 795 |
+
key=lambda x: -x[1])[:3]
|
| 796 |
+
spotlight = ", ".join(f"**{n.replace('_',' ')}**" for n in members[:3]) or "_(none)_"
|
| 797 |
+
out = [f"## {target.label}",
|
| 798 |
+
f"`{target.mode_id}` · sibling **{sibling}** · kind **{target.kind}** · "
|
| 799 |
+
f"property **{target.property}** · members **{target.n_members}**",
|
| 800 |
+
f"\n### Spotlight\n{spotlight}",
|
| 801 |
+
"\n### All members (cosine-ordered)",
|
| 802 |
+
", ".join(n.replace("_", " ") for n in members) or "_(none)_",
|
| 803 |
+
"\n### Closest related modes",
|
| 804 |
+
"| mode_id | label | kind | cosine |", "|---|---|---|---:|"]
|
| 805 |
+
for md, sim in related[:5]:
|
| 806 |
+
out.append(f"| `{md.mode_id}` | {md.label} | {md.kind} | {sim:.3f} |")
|
| 807 |
+
out.append("\n### Top supervised directions")
|
| 808 |
+
out.append("| direction | cosine |"); out.append("|---|---:|")
|
| 809 |
+
for k, sim in sup: out.append(f"| `{k}` | {sim:.3f} |")
|
| 810 |
+
return "\n".join(out)
|
| 811 |
+
|
| 812 |
+
|
| 813 |
+
def _load_cuisine_taxonomy():
|
| 814 |
+
"""Try to load the cuisine_macroregions.json shipped with the corpus dataset; fall back to inline."""
|
| 815 |
+
try:
|
| 816 |
+
from huggingface_hub import hf_hub_download
|
| 817 |
+
p = hf_hub_download("Kaikaku/epicure-corpus-resources",
|
| 818 |
+
"data/cuisine_macroregions.json", repo_type="dataset")
|
| 819 |
+
return json.loads(open(p).read())
|
| 820 |
+
except Exception:
|
| 821 |
+
return {
|
| 822 |
+
"East_Asian": {"traditions": ["Chinese", "Korean"]},
|
| 823 |
+
"Western_Atlantic": {"traditions": ["American","British","German","Scandinavian"]},
|
| 824 |
+
"Mediterranean": {"traditions": ["Italian","French","Iberian","Greek","Levantine","North African","Turkish"]},
|
| 825 |
+
"Eastern_European": {"traditions": ["Russian","Ukrainian","Polish","Hungarian","Georgian"]},
|
| 826 |
+
"Southeast_Asian": {"traditions": ["Thai","Vietnamese","Filipino","Indonesian","Malay"]},
|
| 827 |
+
"South_Asian": {"traditions": ["Indian","Pakistani","Sri Lankan","Bangladeshi"]},
|
| 828 |
+
"Latin_American": {"traditions": ["Mexican","Caribbean","Brazilian","Peruvian","Colombian"]},
|
| 829 |
+
"Japanese": {"traditions": ["Japanese"]},
|
| 830 |
+
}
|
| 831 |
+
|
| 832 |
+
_CUISINE_TAXONOMY = _load_cuisine_taxonomy()
|
| 833 |
+
|
| 834 |
+
def cultural_context(ingredient, sibling="chem", k=4):
|
| 835 |
+
if not ingredient or ingredient not in MODELS[sibling].vocab:
|
| 836 |
+
return [], "_(pick an ingredient)_"
|
| 837 |
+
m = MODELS[sibling]
|
| 838 |
+
v = _unit(m.E[m.vocab[ingredient]])
|
| 839 |
+
scored = []
|
| 840 |
+
for c in _CUISINES:
|
| 841 |
+
key = f"cuisine:{c}"
|
| 842 |
+
if key in m.supervised_poles:
|
| 843 |
+
scored.append((c, float(v @ _unit(m.supervised_poles[key]))))
|
| 844 |
+
scored.sort(key=lambda x: -x[1])
|
| 845 |
+
top = scored[:int(k)]
|
| 846 |
+
rows = []
|
| 847 |
+
all_trads = []
|
| 848 |
+
for region, sim in top:
|
| 849 |
+
trads = _CUISINE_TAXONOMY.get(region, {}).get("traditions", [])
|
| 850 |
+
all_trads.extend(trads)
|
| 851 |
+
rows.append([region.replace("_", " "), f"{sim:+.3f}", ", ".join(trads)])
|
| 852 |
+
md = (f"**{ingredient}** aligns most strongly with these culinary traditions: \n"
|
| 853 |
+
f"{', '.join(sorted(set(all_trads)))}.\n\n"
|
| 854 |
+
f"_The Epicure paper normalised source-language recipes to a single English canonical vocabulary; "
|
| 855 |
+
f"per-language ingredient names were not persisted. This view surfaces the **culinary geography** "
|
| 856 |
+
f"the model learned instead._")
|
| 857 |
+
return rows, md
|
| 858 |
+
|
| 859 |
+
|
| 860 |
+
# =====================================================================
|
| 861 |
+
# Constellations: sibling alignment + recipe constellation
|
| 862 |
+
# =====================================================================
|
| 863 |
+
|
| 864 |
+
def render_sibling_alignment(ingredient):
|
| 865 |
+
if not ingredient or ingredient not in MODELS["cooc"].vocab:
|
| 866 |
+
fig, ax = _gallery_axes(figsize=(16, 5))
|
| 867 |
+
ax.text(0.5, 0.5, "Pick an ingredient", ha="center", va="center",
|
| 868 |
+
transform=ax.transAxes, color=GALLERY_TXTDIM)
|
| 869 |
+
return fig
|
| 870 |
+
sibs = ["cooc", "core", "chem"]
|
| 871 |
+
fig, axes = plt.subplots(1, 3, figsize=(16, 5), facecolor=GALLERY_BG,
|
| 872 |
+
gridspec_kw=dict(wspace=0.06))
|
| 873 |
+
top1 = []
|
| 874 |
+
try: from scipy.stats import gaussian_kde
|
| 875 |
+
except Exception: gaussian_kde = None
|
| 876 |
+
for ax, sib in zip(axes, sibs):
|
| 877 |
+
ax.set_facecolor(GALLERY_BG)
|
| 878 |
+
for s in ax.spines.values(): s.set_visible(False)
|
| 879 |
+
ax.tick_params(left=False, bottom=False, labelleft=False, labelbottom=False)
|
| 880 |
+
m = MODELS[sib]; coords = UMAP_DATA[sib]
|
| 881 |
+
xmin, xmax = float(coords[:,0].min()-0.6), float(coords[:,0].max()+0.6)
|
| 882 |
+
ymin, ymax = float(coords[:,1].min()-0.6), float(coords[:,1].max()+0.6)
|
| 883 |
+
if gaussian_kde is not None:
|
| 884 |
+
try:
|
| 885 |
+
kde = gaussian_kde(coords.T, bw_method=0.20)
|
| 886 |
+
xx, yy = np.meshgrid(np.linspace(xmin, xmax, 120), np.linspace(ymin, ymax, 120))
|
| 887 |
+
zz = kde(np.vstack([xx.ravel(), yy.ravel()])).reshape(xx.shape)
|
| 888 |
+
ax.contour(xx, yy, zz, levels=12, colors=GALLERY_GRID, alpha=0.45, linewidths=0.55)
|
| 889 |
+
except Exception: pass
|
| 890 |
+
ax.scatter(coords[:,0], coords[:,1], s=2, c=GALLERY_DUST, alpha=0.5, linewidths=0, zorder=2)
|
| 891 |
+
q = _unit(m.E[m.vocab[ingredient]])
|
| 892 |
+
nb = _topk(m, q, 5, exclude=[ingredient])
|
| 893 |
+
top1.append(nb[0][0] if nb else "-")
|
| 894 |
+
for nm, _s in nb:
|
| 895 |
+
p = coords[m.vocab[nm]]
|
| 896 |
+
ax.scatter([p[0]], [p[1]], s=110, c="#F4B86E", edgecolors="white", linewidths=0.7, zorder=4)
|
| 897 |
+
ax.text(p[0]+0.10, p[1]+0.10, nm, color=GALLERY_TEXT, fontsize=8.5, alpha=0.95, zorder=5)
|
| 898 |
+
sp = coords[m.vocab[ingredient]]
|
| 899 |
+
ax.scatter([sp[0]], [sp[1]], s=380, c=KAIKAKU_ACCENT_LIGHT, marker="*",
|
| 900 |
+
edgecolors="white", linewidths=1.0, zorder=6)
|
| 901 |
+
ax.text(sp[0], sp[1]+0.32, ingredient, color=KAIKAKU_ACCENT_LIGHT, ha="center",
|
| 902 |
+
fontsize=10.5, fontweight="bold", zorder=7)
|
| 903 |
+
ax.set_xlim(xmin, xmax); ax.set_ylim(ymin, ymax); ax.set_aspect("equal")
|
| 904 |
+
ax.set_title(f"{sib.upper()} · {ingredient}", color=GALLERY_TXTDIM,
|
| 905 |
+
fontsize=11, family="monospace", pad=6)
|
| 906 |
+
sub = " ".join(f"{s.upper()}→{t}" for s, t in zip(sibs, top1))
|
| 907 |
+
fig.suptitle(f"SIBLING ALIGNMENT · top-1 per sibling: {sub}",
|
| 908 |
+
color=GALLERY_TXTDIM, fontsize=11, family="monospace", y=0.02)
|
| 909 |
+
plt.tight_layout(rect=[0, 0.04, 1, 0.97])
|
| 910 |
+
return fig
|
| 911 |
+
|
| 912 |
+
|
| 913 |
+
def render_recipe_constellation(sibling, ingredients):
|
| 914 |
+
m = MODELS[sibling]; coords = UMAP_DATA[sibling]
|
| 915 |
+
fig, ax = _gallery_axes(figsize=(11, 9))
|
| 916 |
+
xmin, xmax = float(coords[:,0].min()-0.6), float(coords[:,0].max()+0.6)
|
| 917 |
+
ymin, ymax = float(coords[:,1].min()-0.6), float(coords[:,1].max()+0.6)
|
| 918 |
+
try:
|
| 919 |
+
from scipy.stats import gaussian_kde
|
| 920 |
+
kde = gaussian_kde(coords.T, bw_method=0.20)
|
| 921 |
+
xx, yy = np.meshgrid(np.linspace(xmin, xmax, 140), np.linspace(ymin, ymax, 140))
|
| 922 |
+
zz = kde(np.vstack([xx.ravel(), yy.ravel()])).reshape(xx.shape)
|
| 923 |
+
ax.contour(xx, yy, zz, levels=10, colors=GALLERY_GRID, alpha=0.4, linewidths=0.5)
|
| 924 |
+
except Exception: pass
|
| 925 |
+
ax.scatter(coords[:,0], coords[:,1], s=2, c=GALLERY_DUST, alpha=0.5, linewidths=0, zorder=2)
|
| 926 |
+
valid = [n for n in (ingredients or []) if n in m.vocab]
|
| 927 |
+
if not valid:
|
| 928 |
+
ax.text(0.5, 0.5, "Pick ingredients", ha="center", va="center",
|
| 929 |
+
transform=ax.transAxes, color=GALLERY_TXTDIM)
|
| 930 |
+
ax.set_xlim(xmin, xmax); ax.set_ylim(ymin, ymax); ax.set_aspect("equal"); return fig
|
| 931 |
+
idxs = [m.vocab[n] for n in valid]
|
| 932 |
+
pts = coords[idxs]
|
| 933 |
+
vecs = np.stack([_unit(m.E[i]) for i in idxs])
|
| 934 |
+
n = len(valid)
|
| 935 |
+
degree = np.zeros(n, dtype=int)
|
| 936 |
+
if n >= 2:
|
| 937 |
+
sim = vecs @ vecs.T
|
| 938 |
+
np.fill_diagonal(sim, -np.inf)
|
| 939 |
+
k_near = min(2, n - 1)
|
| 940 |
+
edges = set()
|
| 941 |
+
for i in range(n):
|
| 942 |
+
for j in np.argsort(-sim[i])[:k_near]:
|
| 943 |
+
a, b = sorted((i, int(j)))
|
| 944 |
+
if a == b or (a, b) in edges: continue
|
| 945 |
+
edges.add((a, b)); degree[a] += 1; degree[b] += 1
|
| 946 |
+
from matplotlib.patches import FancyArrowPatch
|
| 947 |
+
for (a, b) in edges:
|
| 948 |
+
w = max(0.0, float(sim[a, b]))
|
| 949 |
+
alpha = float(np.clip(0.25 + 0.65 * w, 0.15, 0.9))
|
| 950 |
+
arc = FancyArrowPatch((pts[a,0], pts[a,1]), (pts[b,0], pts[b,1]),
|
| 951 |
+
connectionstyle="arc3,rad=0.18", arrowstyle="-",
|
| 952 |
+
color=KAIKAKU_ACCENT_LIGHT, lw=0.9, alpha=alpha, zorder=3)
|
| 953 |
+
ax.add_patch(arc)
|
| 954 |
+
sizes = 140 + 90 * degree
|
| 955 |
+
ax.scatter(pts[:,0], pts[:,1], s=sizes, c="#F4B86E", marker="*",
|
| 956 |
+
edgecolors="white", linewidths=0.9, zorder=5)
|
| 957 |
+
for name, p in zip(valid, pts):
|
| 958 |
+
ax.text(p[0], p[1]+0.30, name, color=GALLERY_TEXT, ha="center",
|
| 959 |
+
fontsize=9.5, fontweight="bold", zorder=6)
|
| 960 |
+
ax.set_xlim(xmin, xmax); ax.set_ylim(ymin, ymax); ax.set_aspect("equal")
|
| 961 |
+
ax.text(0.02, 0.97, f"RECIPE CONSTELLATION · {sibling.upper()}",
|
| 962 |
+
transform=ax.transAxes, color=GALLERY_TXTDIM,
|
| 963 |
+
fontsize=11, family="monospace", va="top")
|
| 964 |
+
fig.text(0.5, 0.04, " · ".join(valid), ha="center", color=GALLERY_TEXT, fontsize=10)
|
| 965 |
+
plt.tight_layout(rect=[0, 0.06, 1, 1])
|
| 966 |
+
return fig
|
| 967 |
+
|
| 968 |
+
|
| 969 |
+
# =====================================================================
|
| 970 |
+
# Simpler additions: recipe coherence rating + direction-quality heatmap
|
| 971 |
+
# =====================================================================
|
| 972 |
+
|
| 973 |
+
def recipe_coherence(sibling, basket):
|
| 974 |
+
"""Return mean pairwise cosine within basket + a rating label + a tiny bar visual."""
|
| 975 |
+
m = MODELS[sibling]
|
| 976 |
+
valid = [n for n in (basket or []) if n in m.vocab]
|
| 977 |
+
if len(valid) < 2:
|
| 978 |
+
return "_Add 2+ ingredients to score._"
|
| 979 |
+
idxs = [m.vocab[n] for n in valid]
|
| 980 |
+
sub = m.E[idxs] / np.linalg.norm(m.E[idxs], axis=1, keepdims=True)
|
| 981 |
+
sim = sub @ sub.T
|
| 982 |
+
np.fill_diagonal(sim, np.nan)
|
| 983 |
+
mean_sim = float(np.nanmean(sim))
|
| 984 |
+
# rating bands
|
| 985 |
+
if mean_sim < 0.10: rating = "Scattered (very diverse)"
|
| 986 |
+
elif mean_sim < 0.25: rating = "Eclectic"
|
| 987 |
+
elif mean_sim < 0.40: rating = "Coherent"
|
| 988 |
+
elif mean_sim < 0.55: rating = "Tightly coherent"
|
| 989 |
+
else: rating = "Possibly redundant"
|
| 990 |
+
pct = int(np.clip(mean_sim, 0, 1) * 100)
|
| 991 |
+
bar = "█" * (pct // 5) + "░" * (20 - pct // 5)
|
| 992 |
+
return (f"**Recipe coherence:** mean pairwise cosine = `{mean_sim:.3f}` \n"
|
| 993 |
+
f"**Rating:** {rating} \n"
|
| 994 |
+
f"`{bar}` {pct}%")
|
| 995 |
+
|
| 996 |
+
|
| 997 |
+
def render_direction_quality_heatmap():
|
| 998 |
+
"""Paper §3.2 table as a Plotly heatmap."""
|
| 999 |
+
probes = [
|
| 1000 |
+
("CF baked-in", "Compound feature (seen by Core/Chem walk schema)", [0.28, 0.40, 0.46]),
|
| 1001 |
+
("CF basic-taste (held-out)", "Sweet/sour/bitter/salt/umami", [0.32, 0.42, 0.47]),
|
| 1002 |
+
("USDA macros", "Protein/fat/carb/fibre/etc.", [0.41, 0.45, 0.49]),
|
| 1003 |
+
("Cuisine (Cohen's d)", "8 macro-regions, one-vs-rest", [2.43, 2.70, 3.07]),
|
| 1004 |
+
]
|
| 1005 |
+
z = np.array([row[2] for row in probes])
|
| 1006 |
+
text = [[f"{v:.2f}" for v in row] for row in z]
|
| 1007 |
+
fig = go.Figure(go.Heatmap(
|
| 1008 |
+
z=z, x=["Cooc", "Core", "Chem"], y=[r[0] for r in probes],
|
| 1009 |
+
text=text, texttemplate="%{text}", textfont=dict(size=14, color="white"),
|
| 1010 |
+
colorscale=[[0, GALLERY_BG], [0.3, GALLERY_DUST], [0.7, KAIKAKU_ACCENT], [1, KAIKAKU_ACCENT_LIGHT]],
|
| 1011 |
+
showscale=True, colorbar=dict(title="score"),
|
| 1012 |
+
hovertemplate="<b>%{y}</b><br>%{x}: %{z:.3f}<extra></extra>",
|
| 1013 |
+
))
|
| 1014 |
+
fig.update_layout(
|
| 1015 |
+
title=dict(text="DIRECTION QUALITY · Cooc < Core < Chem (paper §3.2)",
|
| 1016 |
+
font=dict(size=14, color=GALLERY_TEXT, family="monospace")),
|
| 1017 |
+
paper_bgcolor=GALLERY_BG, plot_bgcolor=GALLERY_BG,
|
| 1018 |
+
height=380, margin=dict(l=180, r=40, t=70, b=40),
|
| 1019 |
+
font=dict(color=GALLERY_TEXT),
|
| 1020 |
+
xaxis=dict(tickfont=dict(color=GALLERY_TEXT, size=12)),
|
| 1021 |
+
yaxis=dict(tickfont=dict(color=GALLERY_TEXT, size=11), autorange="reversed"),
|
| 1022 |
+
)
|
| 1023 |
+
return fig
|
| 1024 |
+
|
| 1025 |
+
|
| 1026 |
# =====================================================================
|
| 1027 |
# Gallery: six aesthetic visualisations.
|
| 1028 |
# All use a shared dark-teal Kaikaku palette so they hang together.
|
|
|
|
| 1742 |
label="Try one of these",
|
| 1743 |
)
|
| 1744 |
|
| 1745 |
+
# ---------- Tab: INVERSE QUERIES ----------
|
| 1746 |
+
with gr.Tab("Inverse queries"):
|
| 1747 |
+
with gr.Tabs():
|
| 1748 |
+
with gr.Tab("Substitution finder"):
|
| 1749 |
+
gr.Markdown("I'm out of X — what's the closest substitute? Optional constraints.")
|
| 1750 |
+
with gr.Row():
|
| 1751 |
+
sub_seed = gr.Dropdown(choices=ALL_INGREDIENTS, label="Seed ingredient", value="mascarpone_cheese")
|
| 1752 |
+
sub_sib = gr.Dropdown(choices=["cooc","core","chem"], value="chem", label="Sibling")
|
| 1753 |
+
sub_k = gr.Slider(3, 20, value=10, step=1, label="K")
|
| 1754 |
+
with gr.Row():
|
| 1755 |
+
sub_grp = gr.Checkbox(label="Must share food group", value=True)
|
| 1756 |
+
sub_nova = gr.Checkbox(label="Pull toward same NOVA level", value=False)
|
| 1757 |
+
sub_cui = gr.Checkbox(label="Rotate 30° from dominant cuisine", value=False)
|
| 1758 |
+
sub_btn = gr.Button("Find substitutes", variant="primary")
|
| 1759 |
+
sub_df = gr.Dataframe(headers=["Substitute","Cosine","Food group","Notes"],
|
| 1760 |
+
wrap=True, label="Top-K substitutes")
|
| 1761 |
+
sub_md = gr.Markdown()
|
| 1762 |
+
sub_btn.click(substitute_finder,
|
| 1763 |
+
inputs=[sub_seed, sub_sib, sub_k, sub_grp, sub_nova, sub_cui],
|
| 1764 |
+
outputs=[sub_df, sub_md], show_progress="minimal")
|
| 1765 |
+
gr.Examples(
|
| 1766 |
+
examples=[
|
| 1767 |
+
["mascarpone_cheese","chem",10,True,False,False],
|
| 1768 |
+
["fish_sauce","chem",10,False,False,False],
|
| 1769 |
+
["saffron","chem",8,False,False,True],
|
| 1770 |
+
["beef","core",8,True,False,False],
|
| 1771 |
+
],
|
| 1772 |
+
inputs=[sub_seed, sub_sib, sub_k, sub_grp, sub_nova, sub_cui],
|
| 1773 |
+
label="Try one",
|
| 1774 |
+
)
|
| 1775 |
+
|
| 1776 |
+
with gr.Tab("Sensory profile search"):
|
| 1777 |
+
gr.Markdown("Drag sliders for the sensory axes you want; tool returns ingredients matching that profile.")
|
| 1778 |
+
with gr.Row():
|
| 1779 |
+
sp_sib = gr.Dropdown(choices=["cooc","core","chem"], value="chem", label="Sibling")
|
| 1780 |
+
sp_k = gr.Slider(5, 30, value=15, step=1, label="K")
|
| 1781 |
+
sp_sliders = []
|
| 1782 |
+
with gr.Row():
|
| 1783 |
+
for label, _ in _SENSORY_SLIDER_KEYS[:5]:
|
| 1784 |
+
sp_sliders.append(gr.Slider(0, 1, value=0, step=0.05, label=label))
|
| 1785 |
+
with gr.Row():
|
| 1786 |
+
for label, _ in _SENSORY_SLIDER_KEYS[5:]:
|
| 1787 |
+
sp_sliders.append(gr.Slider(0, 1, value=0, step=0.05, label=label))
|
| 1788 |
+
sp_btn = gr.Button("Search by profile", variant="primary")
|
| 1789 |
+
sp_df = gr.Dataframe(headers=["Ingredient","Cosine","Food group"], wrap=True, label="Top-K")
|
| 1790 |
+
sp_md = gr.Markdown()
|
| 1791 |
+
sp_btn.click(sensory_search, inputs=[sp_sib, sp_k, *sp_sliders],
|
| 1792 |
+
outputs=[sp_df, sp_md], show_progress="minimal")
|
| 1793 |
+
|
| 1794 |
+
# ---------- Tab: INSPECT ----------
|
| 1795 |
+
with gr.Tab("Inspect"):
|
| 1796 |
+
with gr.Tabs():
|
| 1797 |
+
with gr.Tab("Ingredient passport"):
|
| 1798 |
+
gr.Markdown("Single-page dossier for one ingredient. Tweetable PNG.")
|
| 1799 |
+
with gr.Row():
|
| 1800 |
+
pp_pick = gr.Dropdown(choices=ALL_INGREDIENTS, label="Ingredient", value="basil")
|
| 1801 |
+
pp_btn = gr.Button("Generate passport", variant="primary")
|
| 1802 |
+
pp_plot = gr.Plot(label="")
|
| 1803 |
+
pp_btn.click(render_passport, inputs=[pp_pick], outputs=[pp_plot], show_progress="full")
|
| 1804 |
+
pp_pick.change(render_passport, inputs=[pp_pick], outputs=[pp_plot], show_progress="minimal")
|
| 1805 |
+
# render initial value
|
| 1806 |
+
demo.load(render_passport, inputs=[pp_pick], outputs=[pp_plot])
|
| 1807 |
+
|
| 1808 |
+
with gr.Tab("Mode wiki"):
|
| 1809 |
+
gr.Markdown("Click into any of the ~500 modes for a per-mode wiki page.")
|
| 1810 |
+
with gr.Row():
|
| 1811 |
+
wk_sib = gr.Radio(choices=["cooc","core","chem"], value="chem", label="Sibling")
|
| 1812 |
+
wk_mode = gr.Dropdown(choices=_mode_choices_searchable("chem"),
|
| 1813 |
+
label="Mode", filterable=True)
|
| 1814 |
+
wk_md = gr.Markdown()
|
| 1815 |
+
wk_sib.change(lambda s: gr.Dropdown(choices=_mode_choices_searchable(s), value=None),
|
| 1816 |
+
inputs=[wk_sib], outputs=[wk_mode])
|
| 1817 |
+
wk_mode.change(render_mode_wiki, inputs=[wk_sib, wk_mode], outputs=[wk_md])
|
| 1818 |
+
|
| 1819 |
+
with gr.Tab("Cultural context"):
|
| 1820 |
+
gr.Markdown("Map an ingredient to its cuisine traditions. Paper-grounded; English-only because the source-language names were not persisted by the LLM pipeline.")
|
| 1821 |
+
with gr.Row():
|
| 1822 |
+
cx_ing = gr.Dropdown(choices=ALL_INGREDIENTS, label="Ingredient", value="gochujang" if "gochujang" in MODELS["chem"].vocab else "miso")
|
| 1823 |
+
cx_sib = gr.Radio(choices=["cooc","core","chem"], value="chem", label="Sibling")
|
| 1824 |
+
cx_k = gr.Slider(2, 6, value=4, step=1, label="Top-K cuisines")
|
| 1825 |
+
cx_btn = gr.Button("Show context", variant="primary")
|
| 1826 |
+
cx_df = gr.Dataframe(headers=["Macro-region","Cosine","Constituent traditions"],
|
| 1827 |
+
wrap=True, label="Closest cuisine macro-regions")
|
| 1828 |
+
cx_md = gr.Markdown()
|
| 1829 |
+
cx_btn.click(cultural_context, inputs=[cx_ing, cx_sib, cx_k],
|
| 1830 |
+
outputs=[cx_df, cx_md], show_progress="minimal")
|
| 1831 |
+
cx_ing.change(cultural_context, inputs=[cx_ing, cx_sib, cx_k],
|
| 1832 |
+
outputs=[cx_df, cx_md])
|
| 1833 |
+
|
| 1834 |
+
# ---------- Tab: CONSTELLATIONS ----------
|
| 1835 |
+
with gr.Tab("Constellations"):
|
| 1836 |
+
with gr.Tabs():
|
| 1837 |
+
with gr.Tab("Sibling alignment"):
|
| 1838 |
+
gr.Markdown("Same ingredient on all three UMAPs. The spectrum-of-models thesis as a single image.")
|
| 1839 |
+
with gr.Row():
|
| 1840 |
+
sa_ing = gr.Dropdown(choices=ALL_INGREDIENTS, value="basil", label="Ingredient")
|
| 1841 |
+
sa_btn = gr.Button("Render", variant="primary")
|
| 1842 |
+
sa_plot = gr.Plot(label="")
|
| 1843 |
+
sa_btn.click(render_sibling_alignment, inputs=[sa_ing], outputs=[sa_plot], show_progress="full")
|
| 1844 |
+
sa_ing.change(render_sibling_alignment, inputs=[sa_ing], outputs=[sa_plot])
|
| 1845 |
+
demo.load(render_sibling_alignment, inputs=[sa_ing], outputs=[sa_plot])
|
| 1846 |
+
|
| 1847 |
+
with gr.Tab("Recipe constellation"):
|
| 1848 |
+
gr.Markdown("A recipe drawn as a constellation on the UMAP, edges to nearest basket-mates.")
|
| 1849 |
+
with gr.Row():
|
| 1850 |
+
rc_sib = gr.Radio(choices=["cooc","core","chem"], value="chem", label="Sibling")
|
| 1851 |
+
rc_ings = gr.Dropdown(choices=ALL_INGREDIENTS, multiselect=True,
|
| 1852 |
+
value=["tomato","basil","garlic","olive_oil","mozzarella_cheese"],
|
| 1853 |
+
label="Recipe ingredients", max_choices=12)
|
| 1854 |
+
rc_btn = gr.Button("Render", variant="primary")
|
| 1855 |
+
rc_plot = gr.Plot(label="")
|
| 1856 |
+
rc_btn.click(render_recipe_constellation, inputs=[rc_sib, rc_ings], outputs=[rc_plot], show_progress="full")
|
| 1857 |
+
demo.load(render_recipe_constellation, inputs=[rc_sib, rc_ings], outputs=[rc_plot])
|
| 1858 |
+
gr.Examples(
|
| 1859 |
+
examples=[
|
| 1860 |
+
["chem", ["tomato","basil","garlic","olive_oil","mozzarella_cheese"]],
|
| 1861 |
+
["chem", ["chicken","lemongrass","coconut_milk","fish_sauce","lime","ginger","chili_pepper"]],
|
| 1862 |
+
["chem", ["beef","cumin","coriander","onion","garlic","tomato","cinnamon"]],
|
| 1863 |
+
["core", ["chocolate","strawberry","cream","sugar","vanilla"]],
|
| 1864 |
+
],
|
| 1865 |
+
inputs=[rc_sib, rc_ings], label="Try a recipe",
|
| 1866 |
+
)
|
| 1867 |
+
|
| 1868 |
+
# ---------- Tab: PAPER STATS ----------
|
| 1869 |
+
with gr.Tab("Paper stats"):
|
| 1870 |
+
gr.Markdown("Headline numbers from the paper §3.2. The Cooc < Core < Chem ordering on every probe stratum.")
|
| 1871 |
+
dq_plot = gr.Plot(value=render_direction_quality_heatmap(), label="")
|
| 1872 |
+
|
| 1873 |
# ---------- Tab 5: GALLERY ----------
|
| 1874 |
with gr.Tab("Gallery"):
|
| 1875 |
gr.Markdown("Six aesthetic views of the model. All rendered in the Kaikaku palette.")
|