josefchen commited on
Commit
bcb5888
·
verified ·
1 Parent(s): 4851c45

Add Gallery tab: factor poster, cuisine compass, vector arithmetic art, phylogeny, cosine map, SLERP trajectory

Browse files
Files changed (2) hide show
  1. __pycache__/app.cpython-310.pyc +0 -0
  2. app.py +451 -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,389 @@ 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
  # ===== Theme =====
532
 
533
  THEME = gr.themes.Soft(
@@ -864,6 +1247,74 @@ Three sibling ingredient embeddings from [arXiv:2605.22391](https://arxiv.org/ab
864
  label="Try one of these",
865
  )
866
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
867
  # ---- Hidden API endpoints ----
868
  with gr.Group(visible=False):
869
  api_in_s1 = gr.Textbox(visible=False)
 
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.
534
+ # =====================================================================
535
+
536
+ GALLERY_BG = KAIKAKU_DARK
537
+ GALLERY_GRID = "#1F4548"
538
+ GALLERY_DUST = "#1A3D3F"
539
+ GALLERY_TEXT = "#E8F4F1"
540
+ GALLERY_TXTDIM = KAIKAKU_ACCENT_LIGHT
541
+ GALLERY_MODE_PALETTE = [
542
+ "#B5E6D2", "#F4B86E", "#E8C0E8", "#9BC9E8", "#D7E89B",
543
+ "#FFAA8A", "#A8D5CA", "#E8E0A0",
544
+ ]
545
+
546
+ # All cuisine names we display
547
+ _CUISINES = ["East_Asian","Japanese","Southeast_Asian","South_Asian",
548
+ "Mediterranean","Eastern_European","Latin_American","Western_Atlantic"]
549
+
550
+
551
+ def _gallery_axes(figsize=(10, 9)):
552
+ fig, ax = plt.subplots(figsize=figsize, facecolor=GALLERY_BG)
553
+ ax.set_facecolor(GALLERY_BG)
554
+ for s in ax.spines.values(): s.set_visible(False)
555
+ ax.tick_params(left=False, bottom=False, labelleft=False, labelbottom=False)
556
+ return fig, ax
557
+
558
+
559
+ # ---------- (1) Factor decomposition poster ----------
560
+
561
+ def factor_options(sibling: str):
562
+ m = MODELS[sibling]
563
+ fids = sorted({md.property for md in m.modes if md.kind == "factor"},
564
+ key=lambda x: int(x.split("_")[-1]) if x.split("_")[-1].isdigit() else 99)
565
+ return fids
566
+
567
+ def render_factor_poster(sibling: str, factor_id: str):
568
+ """Reference-screenshot style: dark teal background, faint contour texture,
569
+ GMM-mode point clusters in pastel colours, callouts with leader lines, bottom stat line."""
570
+ m = MODELS[sibling]
571
+ coords = UMAP_DATA[sibling]
572
+ factor_modes = [md for md in m.modes if md.kind == "factor" and md.property == factor_id]
573
+ if not factor_modes:
574
+ fig, ax = _gallery_axes()
575
+ ax.text(0.5, 0.5, "(no factor selected)", ha="center", va="center",
576
+ color=GALLERY_TXTDIM, transform=ax.transAxes)
577
+ return fig
578
+
579
+ fig, ax = _gallery_axes(figsize=(11, 10))
580
+ xmin, xmax = float(coords[:,0].min()-0.8), float(coords[:,0].max()+0.8)
581
+ ymin, ymax = float(coords[:,1].min()-0.8), float(coords[:,1].max()+0.8)
582
+
583
+ # Topographic background: KDE of all ingredients, contoured
584
+ try:
585
+ from scipy.stats import gaussian_kde
586
+ kde = gaussian_kde(coords.T, bw_method=0.18)
587
+ xx, yy = np.meshgrid(np.linspace(xmin, xmax, 160), np.linspace(ymin, ymax, 160))
588
+ zz = kde(np.vstack([xx.ravel(), yy.ravel()])).reshape(xx.shape)
589
+ ax.contour(xx, yy, zz, levels=14, colors=GALLERY_GRID, alpha=0.45, linewidths=0.6)
590
+ except Exception:
591
+ pass
592
+
593
+ # Faint background scatter of every ingredient
594
+ ax.scatter(coords[:, 0], coords[:, 1], s=2, c=GALLERY_DUST, alpha=0.5,
595
+ linewidths=0, zorder=2)
596
+
597
+ # Plot each mode's members + callout
598
+ name_to_idx = MODELS[sibling].vocab
599
+ used_corners = []
600
+ corners = [(xmax-0.5, ymax-0.5), (xmin+0.5, ymax-0.5),
601
+ (xmax-0.5, ymin+0.5), (xmin+0.5, ymin+0.5),
602
+ (xmax-0.5, (ymin+ymax)/2), (xmin+0.5, (ymin+ymax)/2)]
603
+ for i, mode in enumerate(factor_modes):
604
+ idxs = [name_to_idx[n] for n in mode.members if n in name_to_idx]
605
+ if not idxs: continue
606
+ color = GALLERY_MODE_PALETTE[i % len(GALLERY_MODE_PALETTE)]
607
+ pts = coords[idxs]
608
+ # Scatter with size jitter for a painterly look
609
+ sizes = np.random.RandomState(i).randint(50, 130, len(idxs))
610
+ ax.scatter(pts[:,0], pts[:,1], s=sizes, c=color, alpha=0.92,
611
+ edgecolors="white", linewidths=0.6, zorder=4)
612
+ # Callout
613
+ cx, cy = float(pts[:,0].mean()), float(pts[:,1].mean())
614
+ # pick a corner not yet used
615
+ corner = corners[i % len(corners)]
616
+ used_corners.append(corner)
617
+ ax.annotate(
618
+ f"M{mode.mode_id.split('M')[-1]} · {mode.label.upper()}",
619
+ xy=(cx, cy), xytext=corner,
620
+ color=GALLERY_TEXT, fontsize=9, family="monospace",
621
+ ha=("right" if corner[0] > (xmin+xmax)/2 else "left"),
622
+ va="center",
623
+ arrowprops=dict(arrowstyle="-", color=GALLERY_TXTDIM, lw=0.7,
624
+ connectionstyle="arc3,rad=0.1"),
625
+ zorder=5,
626
+ )
627
+
628
+ ax.set_xlim(xmin, xmax); ax.set_ylim(ymin, ymax); ax.set_aspect("equal")
629
+
630
+ # Title strip top-left
631
+ ax.text(0.02, 0.97,
632
+ f"EPICURE · FACTOR DECOMPOSITION · {factor_id.upper()}",
633
+ transform=ax.transAxes, color=GALLERY_TXTDIM,
634
+ fontsize=11, family="monospace", va="top")
635
+
636
+ # Bottom stat line
637
+ n_modes = len(factor_modes)
638
+ total_n = sum(md.n_members for md in factor_modes)
639
+ fig.text(0.5, 0.07,
640
+ "All of human cooking compressed into 2 megabytes.",
641
+ ha="center", color=GALLERY_TEXT, fontsize=15)
642
+ fig.text(0.5, 0.04,
643
+ f"SIBLING {sibling.upper()} · {n_modes} MODES · {total_n} INGREDIENTS · 300-D EMBEDDING",
644
+ ha="center", color=GALLERY_TXTDIM, fontsize=9, family="monospace")
645
+ plt.tight_layout(rect=[0, 0.09, 1, 1])
646
+ return fig
647
+
648
+
649
+ # ---------- (2) Cuisine compass (polar radar) ----------
650
+
651
+ def _cuisine_pole(m, region):
652
+ key = f"cuisine:{region}"
653
+ if key in m.supervised_poles:
654
+ return _unit(m.supervised_poles[key])
655
+ return None
656
+
657
+ def render_cuisine_compass(sibling: str, ingredients: list[str]):
658
+ m = MODELS[sibling]
659
+ valid = [n for n in (ingredients or []) if n in m.vocab]
660
+ if not valid:
661
+ fig = go.Figure()
662
+ fig.add_annotation(text="Pick at least one ingredient",
663
+ showarrow=False, font=dict(color=GALLERY_TXTDIM, size=14),
664
+ xref="paper", yref="paper", x=0.5, y=0.5)
665
+ fig.update_layout(paper_bgcolor=GALLERY_BG, plot_bgcolor=GALLERY_BG, height=520)
666
+ return fig
667
+ poles = {c: _cuisine_pole(m, c) for c in _CUISINES}
668
+ poles = {c: p for c, p in poles.items() if p is not None}
669
+ cuisines = list(poles.keys())
670
+ fig = go.Figure()
671
+ palette = GALLERY_MODE_PALETTE
672
+ for i, ing in enumerate(valid):
673
+ v = _unit(m.E[m.vocab[ing]])
674
+ radii = [float(v @ poles[c]) for c in cuisines]
675
+ # close the polygon
676
+ radii_closed = radii + [radii[0]]
677
+ labels_closed = cuisines + [cuisines[0]]
678
+ color = palette[i % len(palette)]
679
+ fig.add_trace(go.Scatterpolar(
680
+ r=radii_closed, theta=labels_closed,
681
+ fill="toself", name=ing,
682
+ fillcolor=f"rgba({int(color[1:3],16)},{int(color[3:5],16)},{int(color[5:7],16)},0.25)",
683
+ line=dict(color=color, width=2),
684
+ marker=dict(size=6, color=color),
685
+ hovertemplate="%{theta}<br>cos = %{r:.3f}<extra>" + ing + "</extra>",
686
+ ))
687
+ fig.update_layout(
688
+ polar=dict(
689
+ bgcolor=GALLERY_BG,
690
+ radialaxis=dict(visible=True, gridcolor=GALLERY_GRID, range=[-0.2, 0.8],
691
+ tickfont=dict(color=GALLERY_TXTDIM, size=9),
692
+ angle=90, tickangle=90),
693
+ angularaxis=dict(gridcolor=GALLERY_GRID,
694
+ tickfont=dict(color=GALLERY_TEXT, size=11)),
695
+ ),
696
+ paper_bgcolor=GALLERY_BG,
697
+ font=dict(color=GALLERY_TEXT),
698
+ legend=dict(font=dict(color=GALLERY_TEXT), bgcolor="rgba(0,0,0,0)"),
699
+ title=dict(text=f"CUISINE COMPASS · {sibling.upper()}",
700
+ font=dict(color=GALLERY_TXTDIM, size=12, family="monospace"),
701
+ x=0.02, xanchor="left"),
702
+ height=560, margin=dict(l=60, r=60, t=70, b=40),
703
+ )
704
+ return fig
705
+
706
+
707
+ # ---------- (7) Arithmetic vector art ----------
708
+
709
+ def render_arithmetic_vector(sibling: str, positives: list[str], negatives: list[str]):
710
+ """Visualise centroid(positives) - centroid(negatives) as vector arrows on the UMAP."""
711
+ m = MODELS[sibling]
712
+ coords = UMAP_DATA[sibling]
713
+ fig, ax = _gallery_axes(figsize=(11, 9))
714
+ xmin, xmax = float(coords[:,0].min()-0.8), float(coords[:,0].max()+0.8)
715
+ ymin, ymax = float(coords[:,1].min()-0.8), float(coords[:,1].max()+0.8)
716
+ try:
717
+ from scipy.stats import gaussian_kde
718
+ kde = gaussian_kde(coords.T, bw_method=0.20)
719
+ xx, yy = np.meshgrid(np.linspace(xmin, xmax, 140), np.linspace(ymin, ymax, 140))
720
+ zz = kde(np.vstack([xx.ravel(), yy.ravel()])).reshape(xx.shape)
721
+ ax.contour(xx, yy, zz, levels=10, colors=GALLERY_GRID, alpha=0.4, linewidths=0.5)
722
+ except Exception: pass
723
+ ax.scatter(coords[:,0], coords[:,1], s=2, c=GALLERY_DUST, alpha=0.45)
724
+
725
+ if not positives:
726
+ ax.text(0.5, 0.5, "Pick at least one positive ingredient", ha="center",
727
+ va="center", transform=ax.transAxes, color=GALLERY_TXTDIM)
728
+ return fig
729
+
730
+ pos = _basket_centroid(m, positives)
731
+ neg = _basket_centroid(m, negatives) if negatives else None
732
+ q = _unit(pos - neg) if neg is not None else pos
733
+
734
+ def project(vec, k=8):
735
+ sims = m.E @ vec
736
+ idxs = np.argsort(-sims)[:k]
737
+ return coords[idxs].mean(axis=0), idxs
738
+
739
+ pos_pt, pos_top = project(pos)
740
+ res_pt, res_top = project(q)
741
+
742
+ # Plot positives in mint
743
+ for n in positives:
744
+ if n in m.vocab:
745
+ p = coords[m.vocab[n]]
746
+ ax.scatter([p[0]], [p[1]], s=140, c=KAIKAKU_ACCENT_LIGHT,
747
+ edgecolors="white", linewidths=0.8, zorder=4)
748
+ ax.text(p[0], p[1]+0.25, n, color=KAIKAKU_ACCENT_LIGHT, ha="center",
749
+ fontsize=10, fontweight="bold", zorder=5)
750
+ # Plot negatives in warm
751
+ for n in (negatives or []):
752
+ if n in m.vocab:
753
+ p = coords[m.vocab[n]]
754
+ ax.scatter([p[0]], [p[1]], s=140, c="#F4B86E",
755
+ edgecolors="white", linewidths=0.8, zorder=4)
756
+ ax.text(p[0], p[1]+0.25, "− " + n, color="#F4B86E", ha="center",
757
+ fontsize=10, fontweight="bold", zorder=5)
758
+ # Plot result as star
759
+ ax.scatter([res_pt[0]], [res_pt[1]], s=420, c="#FFFFFF", marker="*",
760
+ edgecolors=KAIKAKU_ACCENT_LIGHT, linewidths=1.5, zorder=6)
761
+ # Draw arrow from positives centroid -> result
762
+ ax.annotate("", xy=res_pt, xytext=pos_pt,
763
+ arrowprops=dict(arrowstyle="->", color=KAIKAKU_ACCENT_LIGHT, lw=1.8),
764
+ zorder=5)
765
+ # Label top-K of result
766
+ for idx in res_top[:5]:
767
+ p = coords[idx]
768
+ ax.text(p[0]+0.10, p[1], NAMES_BY_IDX[idx], color=GALLERY_TEXT,
769
+ fontsize=8, alpha=0.85, zorder=4)
770
+ ax.scatter([p[0]], [p[1]], s=30, c="#FFFFFF", alpha=0.6,
771
+ edgecolors=GALLERY_TXTDIM, linewidths=0.6, zorder=3)
772
+
773
+ ax.set_xlim(xmin, xmax); ax.set_ylim(ymin, ymax); ax.set_aspect("equal")
774
+ title = " + ".join(positives) + (" − " + " − ".join(negatives) if negatives else "")
775
+ ax.text(0.02, 0.97, f"VECTOR ARITHMETIC · {sibling.upper()}",
776
+ transform=ax.transAxes, color=GALLERY_TXTDIM,
777
+ fontsize=11, family="monospace", va="top")
778
+ ax.text(0.02, 0.93, title, transform=ax.transAxes, color=GALLERY_TEXT,
779
+ fontsize=13, va="top")
780
+ res_names = ", ".join(NAMES_BY_IDX[i] for i in res_top[:5])
781
+ fig.text(0.5, 0.04, "Result top-5: " + res_names,
782
+ ha="center", color=GALLERY_TXTDIM, fontsize=10, family="monospace")
783
+ plt.tight_layout(rect=[0, 0.06, 1, 1])
784
+ return fig
785
+
786
+
787
+ # ---------- (8) Cuisine phylogeny (dendrogram) ----------
788
+
789
+ def render_cuisine_phylogeny(sibling: str):
790
+ m = MODELS[sibling]
791
+ cuisines = [c for c in _CUISINES if f"cuisine:{c}" in m.supervised_poles]
792
+ if len(cuisines) < 2:
793
+ fig, ax = _gallery_axes(figsize=(10, 5))
794
+ ax.text(0.5, 0.5, "Cuisine poles unavailable for this sibling",
795
+ ha="center", va="center", transform=ax.transAxes, color=GALLERY_TXTDIM)
796
+ return fig
797
+ poles = np.stack([_unit(m.supervised_poles[f"cuisine:{c}"]) for c in cuisines])
798
+ # cosine distance matrix
799
+ D = 1 - poles @ poles.T
800
+ # condensed distance
801
+ n = len(cuisines)
802
+ cond = []
803
+ for i in range(n):
804
+ for j in range(i+1, n):
805
+ cond.append(max(0.0, float(D[i, j])))
806
+ from scipy.cluster.hierarchy import linkage, dendrogram
807
+ Z = linkage(np.array(cond), method="average")
808
+ fig, ax = _gallery_axes(figsize=(11, 6))
809
+ matplotlib.rcParams["lines.linewidth"] = 1.4
810
+ ddata = dendrogram(Z, labels=cuisines, ax=ax, color_threshold=0,
811
+ above_threshold_color=KAIKAKU_ACCENT_LIGHT,
812
+ leaf_font_size=11, leaf_rotation=0)
813
+ ax.tick_params(axis="x", colors=GALLERY_TEXT, labelsize=10, pad=8)
814
+ ax.tick_params(axis="y", colors=GALLERY_TXTDIM, labelsize=8, labelleft=True, left=True)
815
+ ax.spines["bottom"].set_visible(False)
816
+ ax.spines["left"].set_visible(True); ax.spines["left"].set_color(GALLERY_TXTDIM)
817
+ ax.set_ylabel("cosine distance", color=GALLERY_TXTDIM, fontsize=10)
818
+ ax.set_title("", color=GALLERY_TEXT)
819
+ fig.text(0.02, 0.95, f"CUISINE PHYLOGENY · {sibling.upper()}",
820
+ color=GALLERY_TXTDIM, fontsize=11, family="monospace")
821
+ fig.text(0.02, 0.92, "Hierarchical clustering of cuisine pole vectors (cosine, average linkage)",
822
+ color=GALLERY_TEXT, fontsize=10)
823
+ plt.tight_layout(rect=[0, 0, 1, 0.93])
824
+ return fig
825
+
826
+
827
+ # ---------- (6) Cuisine cosine map (matrix-art version of chord) ----------
828
+
829
+ def render_cuisine_cosine_map(sibling: str):
830
+ m = MODELS[sibling]
831
+ cuisines = [c for c in _CUISINES if f"cuisine:{c}" in m.supervised_poles]
832
+ poles = np.stack([_unit(m.supervised_poles[f"cuisine:{c}"]) for c in cuisines])
833
+ S = poles @ poles.T
834
+ fig, ax = _gallery_axes(figsize=(9, 8))
835
+ # custom colormap: deep teal -> mint
836
+ from matplotlib.colors import LinearSegmentedColormap
837
+ cmap = LinearSegmentedColormap.from_list("kaikaku", [GALLERY_BG, GALLERY_DUST, KAIKAKU_ACCENT, KAIKAKU_ACCENT_LIGHT])
838
+ im = ax.imshow(S, cmap=cmap, vmin=0.0, vmax=1.0, aspect="auto")
839
+ ax.set_xticks(range(len(cuisines))); ax.set_yticks(range(len(cuisines)))
840
+ ax.set_xticklabels([c.replace("_"," ") for c in cuisines], rotation=35, ha="right",
841
+ color=GALLERY_TEXT, fontsize=10)
842
+ ax.set_yticklabels([c.replace("_"," ") for c in cuisines], color=GALLERY_TEXT, fontsize=10)
843
+ ax.tick_params(left=True, bottom=False)
844
+ for i in range(len(cuisines)):
845
+ for j in range(len(cuisines)):
846
+ v = float(S[i,j])
847
+ ax.text(j, i, f"{v:.2f}", ha="center", va="center",
848
+ color=("white" if v < 0.5 else GALLERY_BG), fontsize=10)
849
+ cb = plt.colorbar(im, ax=ax, shrink=0.8)
850
+ cb.outline.set_visible(False)
851
+ cb.ax.yaxis.set_tick_params(color=GALLERY_TXTDIM)
852
+ plt.setp(plt.getp(cb.ax.axes, "yticklabels"), color=GALLERY_TXTDIM)
853
+ cb.set_label("cosine", color=GALLERY_TXTDIM)
854
+ fig.text(0.02, 0.96, f"CUISINE COSINE MAP · {sibling.upper()}",
855
+ color=GALLERY_TXTDIM, fontsize=11, family="monospace")
856
+ fig.text(0.02, 0.93, "Pairwise cosine similarity between cuisine pole vectors",
857
+ color=GALLERY_TEXT, fontsize=10)
858
+ plt.tight_layout(rect=[0, 0, 1, 0.92])
859
+ return fig
860
+
861
+
862
+ # ---------- (3) SLERP trajectory frames ----------
863
+
864
+ def render_slerp_trajectory(sibling: str, seed: str, direction: str, max_theta: int = 60):
865
+ """Show the rotation as 5 stacked UMAP panels at increasing angles."""
866
+ m = MODELS[sibling]
867
+ coords = UMAP_DATA[sibling]
868
+ if seed not in m.vocab or direction not in m.supervised_poles:
869
+ fig, ax = _gallery_axes(figsize=(11, 4))
870
+ ax.text(0.5, 0.5, "Pick a valid seed and direction",
871
+ ha="center", va="center", transform=ax.transAxes, color=GALLERY_TXTDIM)
872
+ return fig
873
+ v = _unit(m.E[m.vocab[seed]])
874
+ d = _unit(m.supervised_poles[direction])
875
+ thetas = [0, int(max_theta*0.33), int(max_theta*0.66), int(max_theta)]
876
+ xmin, xmax = float(coords[:,0].min()-0.5), float(coords[:,0].max()+0.5)
877
+ ymin, ymax = float(coords[:,1].min()-0.5), float(coords[:,1].max()+0.5)
878
+ fig, axes = plt.subplots(1, len(thetas), figsize=(15, 4.2), facecolor=GALLERY_BG,
879
+ gridspec_kw=dict(wspace=0.05))
880
+ for ax, theta in zip(axes, thetas):
881
+ ax.set_facecolor(GALLERY_BG)
882
+ for s in ax.spines.values(): s.set_visible(False)
883
+ ax.tick_params(left=False, bottom=False, labelleft=False, labelbottom=False)
884
+ # background
885
+ ax.scatter(coords[:,0], coords[:,1], s=1.8, c=GALLERY_DUST, alpha=0.5)
886
+ q = _slerp(v, d, theta)
887
+ # top-K of current query
888
+ sims = m.E @ q
889
+ top = np.argsort(-sims)[:6]
890
+ # exclude seed
891
+ seed_idx = m.vocab[seed]
892
+ top = [i for i in top if i != seed_idx][:5]
893
+ # seed in mint
894
+ sp = coords[seed_idx]
895
+ ax.scatter([sp[0]], [sp[1]], s=200, c=KAIKAKU_ACCENT_LIGHT,
896
+ edgecolors="white", linewidths=0.8, marker="*", zorder=4)
897
+ ax.text(sp[0], sp[1]+0.3, seed, color=KAIKAKU_ACCENT_LIGHT, ha="center",
898
+ fontsize=10, fontweight="bold", zorder=5)
899
+ # rotated query top-K
900
+ for idx in top:
901
+ p = coords[idx]
902
+ ax.scatter([p[0]], [p[1]], s=80, c="#F4B86E", edgecolors="white",
903
+ linewidths=0.5, alpha=0.9, zorder=4)
904
+ ax.text(p[0]+0.05, p[1], NAMES_BY_IDX[idx], color=GALLERY_TEXT,
905
+ fontsize=8, alpha=0.9, zorder=4)
906
+ ax.set_xlim(xmin, xmax); ax.set_ylim(ymin, ymax); ax.set_aspect("equal")
907
+ ax.set_title(f"θ = {theta}°", color=GALLERY_TXTDIM, fontsize=11,
908
+ family="monospace", pad=6)
909
+ fig.suptitle(f"SLERP TRAJECTORY · {seed} → {direction} · {sibling.upper()}",
910
+ color=GALLERY_TXTDIM, fontsize=12, family="monospace", y=0.98)
911
+ return fig
912
+
913
+
914
  # ===== Theme =====
915
 
916
  THEME = gr.themes.Soft(
 
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.")
1253
+ with gr.Tabs():
1254
+ # --- Factor poster ---
1255
+ with gr.Tab("Factor poster"):
1256
+ with gr.Row():
1257
+ fp_sib = gr.Radio(choices=["cooc","core","chem"], value="chem",
1258
+ label="Sibling", scale=1)
1259
+ fp_factor = gr.Dropdown(choices=factor_options("chem"),
1260
+ value=factor_options("chem")[0] if factor_options("chem") else None,
1261
+ label="Factor", scale=2)
1262
+ fp_btn = gr.Button("Render", variant="primary", scale=1)
1263
+ fp_plot = gr.Plot(label="", value=render_factor_poster("chem", factor_options("chem")[0] if factor_options("chem") else ""))
1264
+ fp_btn.click(render_factor_poster, inputs=[fp_sib, fp_factor], outputs=fp_plot, show_progress="full")
1265
+ fp_sib.change(lambda s: gr.Dropdown(choices=factor_options(s), value=factor_options(s)[0] if factor_options(s) else None),
1266
+ inputs=fp_sib, outputs=fp_factor)
1267
+
1268
+ # --- Cuisine compass ---
1269
+ with gr.Tab("Cuisine compass"):
1270
+ with gr.Row():
1271
+ cc_sib = gr.Radio(choices=["cooc","core","chem"], value="chem", label="Sibling")
1272
+ cc_ings = gr.Dropdown(choices=ALL_INGREDIENTS, value=["miso","basil","cumin"],
1273
+ label="Ingredients (1-5 polygons)", multiselect=True, max_choices=5)
1274
+ cc_btn = gr.Button("Render", variant="primary")
1275
+ cc_plot = gr.Plot(label="", value=render_cuisine_compass("chem", ["miso","basil","cumin"]))
1276
+ cc_btn.click(render_cuisine_compass, inputs=[cc_sib, cc_ings], outputs=cc_plot, show_progress="minimal")
1277
+
1278
+ # --- Arithmetic vector art ---
1279
+ with gr.Tab("Vector arithmetic"):
1280
+ with gr.Row():
1281
+ va_sib = gr.Radio(choices=["cooc","core","chem"], value="core", label="Sibling")
1282
+ va_pos = gr.Dropdown(choices=ALL_INGREDIENTS, value=["miso"], label="Positives", multiselect=True, max_choices=5)
1283
+ va_neg = gr.Dropdown(choices=ALL_INGREDIENTS, value=["salt"], label="Negatives", multiselect=True, max_choices=5)
1284
+ va_btn = gr.Button("Render", variant="primary")
1285
+ va_plot = gr.Plot(label="", value=render_arithmetic_vector("core", ["miso"], ["salt"]))
1286
+ va_btn.click(render_arithmetic_vector, inputs=[va_sib, va_pos, va_neg], outputs=va_plot, show_progress="full")
1287
+
1288
+ # --- Cuisine phylogeny ---
1289
+ with gr.Tab("Cuisine phylogeny"):
1290
+ with gr.Row():
1291
+ ph_sib = gr.Radio(choices=["cooc","core","chem"], value="chem", label="Sibling")
1292
+ ph_btn = gr.Button("Render", variant="primary")
1293
+ ph_plot = gr.Plot(label="", value=render_cuisine_phylogeny("chem"))
1294
+ ph_btn.click(render_cuisine_phylogeny, inputs=[ph_sib], outputs=ph_plot, show_progress="minimal")
1295
+
1296
+ # --- Cuisine cosine map ---
1297
+ with gr.Tab("Cuisine cosine map"):
1298
+ with gr.Row():
1299
+ cm_sib = gr.Radio(choices=["cooc","core","chem"], value="chem", label="Sibling")
1300
+ cm_btn = gr.Button("Render", variant="primary")
1301
+ cm_plot = gr.Plot(label="", value=render_cuisine_cosine_map("chem"))
1302
+ cm_btn.click(render_cuisine_cosine_map, inputs=[cm_sib], outputs=cm_plot, show_progress="minimal")
1303
+
1304
+ # --- SLERP trajectory ---
1305
+ with gr.Tab("SLERP trajectory"):
1306
+ with gr.Row():
1307
+ st_sib = gr.Radio(choices=["cooc","core","chem"], value="chem", label="Sibling")
1308
+ st_seed = gr.Dropdown(choices=ALL_INGREDIENTS, value="rice", label="Seed")
1309
+ st_dir = gr.Dropdown(choices=_supervised_choices("chem"),
1310
+ value="cuisine:South_Asian", label="Direction")
1311
+ st_max = gr.Slider(15, 90, value=60, step=15, label="Max θ (deg)")
1312
+ st_btn = gr.Button("Render", variant="primary")
1313
+ st_plot = gr.Plot(label="", value=render_slerp_trajectory("chem", "rice", "cuisine:South_Asian", 60))
1314
+ st_btn.click(render_slerp_trajectory, inputs=[st_sib, st_seed, st_dir, st_max], outputs=st_plot, show_progress="full")
1315
+ st_sib.change(lambda s: gr.Dropdown(choices=_supervised_choices(s), value=None),
1316
+ inputs=st_sib, outputs=st_dir)
1317
+
1318
  # ---- Hidden API endpoints ----
1319
  with gr.Group(visible=False):
1320
  api_in_s1 = gr.Textbox(visible=False)