josefchen commited on
Commit
212b63f
·
verified ·
1 Parent(s): bcb5888

9 new features: substitution finder, sensory search, ingredient passport, mode wiki, cultural context, sibling alignment, recipe constellation, paper-stats heatmap, recipe coherence

Browse files
Files changed (2) hide show
  1. __pycache__/app.cpython-310.pyc +0 -0
  2. 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 &lt; Core &lt; 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.")