+
+
+
+
+ {L.otherLang.label}
+ + {L.pageTitle.a} {L.pageTitle.b} {L.pageTitle.c} +
+{L.pageSub}
+ diff --git a/README.ko.md b/README.ko.md index 26cca1e..48296d0 100644 --- a/README.ko.md +++ b/README.ko.md @@ -12,6 +12,31 @@ 현재 Phase 1 진행 상태에는 `src/sid_reco/sid/` 패키지와 public `compile-sid-index` CLI도 포함된다. 이 흐름은 `data/processed/foodcom/sid_index/` 아래에 deterministic structured-item serialization 산출물, MLX embedding 산출물, CPU residual K-means codebook 결과, FAISS indexing 산출물을 저장한다. +## 라이브 데모 + +4-모듈 온라인 파이프라인(관심 스케치 → 시맨틱 검색 → 제로샷 리랭킹 → MSCP 신뢰도)을 26개 Food.com 시드 레시피 위에서 시각화하는 정적 HTML/JS 번들이다. `?lang=` URL 파라미터로 한국어/영어를 전환하며, 빌드 단계 없이 클라이언트에서 모두 실행된다. + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
for semantics; reset default margin. */
+.json-block { margin: 0; background: #0A0A0A; border: 1.5px solid #0A0A0A; border-radius: var(--r-md); padding: 16px 18px; font-family: var(--mono); font-size: 11px; color: #FAFAF9; white-space: pre-wrap; word-break: break-word; max-height: 400px; overflow-y: auto; line-height: 1.6; }
+.json-block code { font-family: inherit; }
+.json-key { color: #67E8F9; }
+.json-str { color: #86EFAC; }
+.json-num { color: #FCD34D; }
+
+/* Empty / placeholder */
+.empty { padding: 48px 20px; text-align: center; color: var(--ink-subtle); font-size: 13px; font-style: italic; }
+
+/* Flow connecting lines */
+.flow-arrow { display: grid; place-items: center; color: var(--ink-subtle); font-size: 18px; }
+
+/* Toggle */
+.toggle-row { display: flex; align-items: center; justify-content: space-between; padding: 6px 0; }
+.toggle-row .t-label { font-size: 12px; }
+.toggle { width: 32px; height: 18px; background: #FFFFFF; border: 1.5px solid #0A0A0A; border-radius: 9px; position: relative; transition: background 0.15s; flex-shrink: 0; }
+.toggle::after { content: ""; position: absolute; top: 1px; left: 1px; width: 12px; height: 12px; background: #0A0A0A; border-radius: 50%; transition: transform 0.15s, background 0.15s; }
+.toggle.on { background: var(--cyan); border-color: var(--cyan); }
+.toggle.on::after { transform: translateX(14px); background: #FFFFFF; }
+
+/* Metrics strip */
+.metrics { display: flex; gap: 20px; margin-bottom: 16px; padding: 12px 16px; background: #FFFFFF; border: 1.5px solid #0A0A0A; border-radius: var(--r-md); font-family: var(--mono); font-size: 11px; flex-wrap: wrap; box-shadow: 0 2px 0 #0A0A0A; }
+.metric { display: flex; flex-direction: column; gap: 2px; }
+.metric-label { color: var(--ink-subtle); text-transform: uppercase; letter-spacing: 0.08em; font-size: 9px; font-weight: 700; }
+.metric-val { color: #0A0A0A; font-size: 12px; font-weight: 700; }
+.metric-val.accent { color: var(--cyan); }
+
+.muted { color: var(--ink-subtle); }
+
+
+/* ===== Tab bar ===== */
+.tabbar { display: grid; grid-template-columns: repeat(7, 1fr); gap: 6px; margin-bottom: 20px; padding: 6px; background: #FFFFFF; border: 1.5px solid #0A0A0A; border-radius: var(--r-md); box-shadow: 0 2px 0 #0A0A0A; }
+.tab { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; padding: 10px 12px; border-radius: 8px; text-align: left; transition: background 0.15s, color 0.15s; min-width: 0; border: 1.5px solid transparent; }
+.tab:hover:not(.disabled):not(.active) { background: var(--bg-raised); }
+.tab.active { background: #0A0A0A; border-color: #0A0A0A; }
+.tab.active .tab-num { color: #FCD34D; }
+.tab.active .tab-label { color: #FFFFFF; }
+.tab.active .tab-sub { color: #A3A3A3; }
+.tab.disabled { opacity: 0.4; cursor: not-allowed; }
+.tab-num { font-family: var(--mono); font-size: 9px; letter-spacing: 0.12em; color: var(--ink-subtle); text-transform: uppercase; font-weight: 700; }
+.tab-label { font-size: 12.5px; font-weight: 700; color: #0A0A0A; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
+.tab-sub { font-size: 10px; color: var(--ink-subtle); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
+
+/* ===== Overview grid ===== */
+.overview-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-top: 18px; }
+.overview-card { width: 100%; background: #FFFFFF; border: 1.5px solid #0A0A0A; border-radius: var(--r-md); padding: 18px 18px 14px; cursor: pointer; transition: transform 0.15s, box-shadow 0.15s; display: flex; flex-direction: column; gap: 10px; text-align: left; box-shadow: 0 3px 0 #0A0A0A; }
+.overview-card:hover { transform: translateY(-2px); box-shadow: 0 5px 0 #0A0A0A; background: #FEF3C7; }
+.oc-num { font-family: var(--mono); font-size: 11px; letter-spacing: 0.08em; color: var(--cyan); font-weight: 700; }
+.oc-title { font-size: 15px; font-weight: 700; color: #0A0A0A; }
+.oc-body { display: flex; flex-direction: column; gap: 3px; font-family: var(--mono); font-size: 10.5px; color: var(--ink-muted); letter-spacing: 0.02em; }
+.oc-foot { margin-top: auto; padding-top: 10px; border-top: 1.5px dashed #A3A3A3; font-size: 11px; color: #0A0A0A; font-weight: 700; }
+
+/* ===== Language switch ===== */
+.lang-switch { display: inline-flex; align-items: center; justify-content: center; min-width: 44px; height: 32px; padding: 0 12px; border: 1.5px solid #0A0A0A; border-radius: 999px; color: #0A0A0A; font-family: var(--mono); font-size: 11px; letter-spacing: 0.1em; text-decoration: none; transition: all 0.15s; background: #FFFFFF; font-weight: 700; }
+.lang-switch:hover { color: #FFFFFF; background: #0A0A0A; }
+
+@media (max-width: 1200px) {
+ .tabbar { grid-template-columns: repeat(4, 1fr); }
+ .overview-grid { grid-template-columns: repeat(2, 1fr); }
+}
+
+/* Tablet — stack the sidebar above the main column. */
+@media (max-width: 768px) {
+ .app { grid-template-columns: 1fr; }
+ .sidebar {
+ height: auto; position: relative;
+ border-right: none; border-bottom: 2px solid #0A0A0A;
+ }
+ .pipeline { grid-template-columns: repeat(2, 1fr); }
+ .pipeline::before { display: none; }
+ .candidate-split { grid-template-columns: 1fr; }
+ .sketch-grid { grid-template-columns: 1fr; }
+ .kvp { grid-template-columns: 1fr; gap: 4px; }
+ .main { padding: 20px 18px 32px; }
+ .topbar-title { font-size: 22px; }
+}
+
+/* Mobile — tabbar becomes a horizontal scroller, single-column everywhere. */
+@media (max-width: 480px) {
+ .tabbar {
+ display: flex; overflow-x: auto;
+ scroll-snap-type: x mandatory;
+ -webkit-overflow-scrolling: touch;
+ }
+ .tab { flex: 0 0 140px; scroll-snap-align: start; }
+ .overview-grid { grid-template-columns: 1fr; }
+ .pipeline { grid-template-columns: 1fr; }
+ .final-cards { grid-template-columns: 1fr; }
+ .vote-row { grid-template-columns: 24px 1fr 60px; }
+ .vote-band { display: none; }
+ .topbar { flex-direction: column; align-items: flex-start; }
+ .metrics { gap: 14px; }
+}
diff --git a/docs/demo/data/pipeline.js b/docs/demo/data/pipeline.js
new file mode 100644
index 0000000..4f1dc64
--- /dev/null
+++ b/docs/demo/data/pipeline.js
@@ -0,0 +1,313 @@
+// ZeroAlign-Rec online pipeline — 4 modules.
+// Depends on: window.TAXONOMY (data/taxonomy.js), window.RECIPES (data/recipes.js)
+
+// ---------- Module 2.1: Interest-sketch extraction ----------
+
+// Keyword → taxonomy mapping. Order matters only for readability.
+const KEYWORD_MAP = [
+ { kws: ["spicy","spice","hot","heat"], facet: "flavor_profile", value: "spicy" },
+ { kws: ["creamy","rich"], facet: "flavor_profile", value: "rich" },
+ { kws: ["fresh","light"], facet: "flavor_profile", value: "fresh" },
+ { kws: ["umami","savory"], facet: "flavor_profile", value: "umami" },
+ { kws: ["sweet","dessert"], facet: "flavor_profile", value: "sweet" },
+ { kws: ["herb","herby"], facet: "flavor_profile", value: "herbaceous" },
+ { kws: ["hearty","comfort","cozy"], facet: "flavor_profile", value: "umami" },
+ { kws: ["not too heavy","not heavy","light"], facet: "effort", value: "weeknight" },
+ { kws: ["weeknight","weekday","fast","quick"], facet: "effort", value: "quick" },
+ { kws: ["weekend","project","impressive"], facet: "effort", value: "weekend-project" },
+ { kws: ["vegetarian"], facet: "dietary_style", value: "vegetarian" },
+ { kws: ["vegan"], facet: "dietary_style", value: "vegan" },
+ { kws: ["no gluten","gluten-free","gluten free"], facet: "dietary_style", value: "gluten-free" },
+ { kws: ["lunch"], facet: "meal_type", value: "lunch" },
+ { kws: ["dinner"], facet: "meal_type", value: "dinner" },
+ { kws: ["breakfast"], facet: "meal_type", value: "breakfast" },
+ { kws: ["soup"], facet: "dish_type", value: "soup" },
+ { kws: ["noodle","noodles"], facet: "dish_type", value: "noodles" },
+ { kws: ["bowl"], facet: "dish_type", value: "bowl" },
+];
+
+const recipeById = id => window.RECIPES.find(r => r.id === id);
+
+const pushUnique = (obj, key, value) => {
+ if (!obj[key]) obj[key] = [];
+ if (!obj[key].includes(value)) obj[key].push(value);
+};
+
+window.buildSketch = function (query, liked = [], disliked = [], hardFilters = {}) {
+ const q = (query || "").toLowerCase();
+ const sketch = {
+ summary: query || "(no free-text query)",
+ positive_facets: {},
+ negative_facets: {},
+ hard_filters: { ...hardFilters },
+ ambiguity_notes: [],
+ };
+
+ // Free-text → facets
+ for (const m of KEYWORD_MAP) {
+ if (m.kws.some(k => q.includes(k))) pushUnique(sketch.positive_facets, m.facet, m.value);
+ }
+
+ // Liked items → positive facets
+ for (const id of liked) {
+ const r = recipeById(id); if (!r) continue;
+ pushUnique(sketch.positive_facets, "cuisine", r.cuisine);
+ pushUnique(sketch.positive_facets, "dish_type", r.dish);
+ for (const f of r.flavors) pushUnique(sketch.positive_facets, "flavor_profile", f);
+ }
+
+ // Disliked items → negative flavor facets
+ for (const id of disliked) {
+ const r = recipeById(id); if (!r) continue;
+ for (const f of r.flavors) pushUnique(sketch.negative_facets, "flavor_profile", f);
+ }
+
+ // Ambiguity notes
+ if (!query || query.length < 10) {
+ sketch.ambiguity_notes.push("sparse query — relying on liked items");
+ }
+ if (q.includes("not too heavy") && q.includes("hearty")) {
+ sketch.ambiguity_notes.push("'hearty but not heavy' — ambiguous richness target");
+ }
+
+ return sketch;
+};
+
+
+// ---------- Module 2.2: Semantic search + CPU hard filter ----------
+
+// How each facet is matched against a recipe field.
+const FACET_MATCHERS = {
+ cuisine: (r, v) => r.cuisine === v,
+ dish_type: (r, v) => r.dish === v,
+ meal_type: (r, v) => r.meal === v,
+ effort: (r, v) => r.effort === v,
+ flavor_profile: (r, v) => r.flavors.includes(v),
+ cooking_method: (r, v) => r.method === v,
+ dietary_style: (r, v) => r.dietary.includes(v),
+};
+
+const FACET_WEIGHTS = {
+ flavor_profile: 0.30,
+ cuisine: 0.25,
+ dish_type: 0.20,
+ effort: 0.15,
+ meal_type: 0.10,
+};
+const DEFAULT_FACET_WEIGHT = 0.08;
+const NEGATIVE_PENALTY = 0.10;
+
+// Deterministic pseudo-random jitter so scores feel vector-y but stay reproducible.
+const noiseFor = id => ((id * 9301 + 49297) % 233280) / 233280 * 0.04;
+
+// Per-recipe score against the sketch.
+function scoreRecipe(recipe, sketch) {
+ let score = 0;
+ const matched = [];
+
+ for (const [facet, values] of Object.entries(sketch.positive_facets)) {
+ const w = FACET_WEIGHTS[facet] ?? DEFAULT_FACET_WEIGHT;
+ const match = FACET_MATCHERS[facet];
+ if (!match) continue;
+ for (const v of values) {
+ if (match(recipe, v)) { score += w; matched.push(`${facet}=${v}`); }
+ }
+ }
+
+ for (const [facet, values] of Object.entries(sketch.negative_facets || {})) {
+ const match = FACET_MATCHERS[facet];
+ if (!match) continue;
+ for (const v of values) if (match(recipe, v)) score -= NEGATIVE_PENALTY;
+ }
+
+ return { ...recipe, score: Math.max(0, score + noiseFor(recipe.id)), matched };
+}
+
+// Each entry returns either null (keep) or a reason string (drop).
+const HARD_FILTERS = {
+ dietary_style: (r, values) => {
+ for (const d of values) if (!r.dietary.includes(d)) return `missing dietary:${d}`;
+ return null;
+ },
+ cuisine: (r, values) => values.includes(r.cuisine) ? null : `cuisine not in ${values.join(",")}`,
+ max_time: (r, limit) => r.time > limit ? `time ${r.time}>${limit}` : null,
+};
+
+function applyHardFilters(candidates, hardFilters) {
+ const survivors = [];
+ const dropped = [];
+ for (const r of candidates) {
+ let reason = null;
+ for (const [key, spec] of Object.entries(hardFilters)) {
+ const fn = HARD_FILTERS[key];
+ if (!fn) continue;
+ reason = fn(r, spec);
+ if (reason) break;
+ }
+ if (reason) dropped.push({ id: r.id, title: r.title, reason });
+ else survivors.push(r);
+ }
+ return { survivors, dropped };
+}
+
+// Approximate collaborative-filter features derived from liked history.
+function attachCfFeatures(candidates, likedIds) {
+ const likedRecipes = likedIds.map(recipeById).filter(Boolean);
+ for (const r of candidates) {
+ r.popularity = r.pop;
+ r.cooccurrence = likedRecipes.filter(l => l.cuisine === r.cuisine || l.dish === r.dish).length;
+ }
+}
+
+window.semanticSearch = function (sketch, { topN = 100, topK = 30 } = {}) {
+ const top100 = window.RECIPES
+ .map(r => scoreRecipe(r, sketch))
+ .sort((a, b) => b.score - a.score)
+ .slice(0, topN);
+
+ const { survivors, dropped } = applyHardFilters(top100, sketch.hard_filters || {});
+ const top30 = survivors.slice(0, topK);
+ attachCfFeatures(top30, sketch._liked || []);
+
+ return { top100, top30, dropped };
+};
+
+
+// ---------- Module 2.3: Zero-shot rerank with K order-perturbed passes ----------
+
+const RERANK_COOC_WEIGHT = 0.20;
+const RERANK_POP_WEIGHT = 0.15;
+
+function buildRationale(candidate) {
+ const keyMatches = candidate.matched.slice(0, 2);
+ const popTag =
+ candidate.popularity > 0.75 ? "Strong popularity signal." :
+ candidate.popularity < 0.60 ? "Niche but aligned." :
+ "Balanced fit.";
+ return `Matches ${keyMatches.join(", ") || "taxonomy anchors"}. ${popTag}`;
+}
+
+window.zeroShotRerank = function (candidates, _sketch, { passes = 5 } = {}) {
+ const basis = candidates.map((c, idx) => ({
+ idx, id: c.id,
+ base: c.score * (1 + RERANK_COOC_WEIGHT * c.cooccurrence) * (1 + RERANK_POP_WEIGHT * c.popularity),
+ }));
+
+ // Each pass jitters basis scores with a pass-specific seed.
+ const passResults = [];
+ for (let p = 0; p < passes; p++) {
+ const seed = (p + 1) * 1.37;
+ const perturbed = basis
+ .map(b => ({ ...b, jittered: b.base + Math.sin(b.idx * seed) * 0.04 + Math.cos(b.id * seed) * 0.02 }))
+ .sort((a, b) => b.jittered - a.jittered);
+ passResults.push(perturbed.slice(0, 10).map(x => x.idx + 1)); // 1-indexed
+ }
+
+ // Build rationales once — shared across all passes.
+ const rationales = {};
+ const matchedPrefs = {};
+ candidates.forEach((c, idx) => {
+ rationales[idx + 1] = buildRationale(c);
+ matchedPrefs[idx + 1] = c.matched.slice(0, 3);
+ });
+
+ return { passes: passResults, rationales, matchedPrefs };
+};
+
+
+// ---------- Module 2.4: MSCP confidence + grounding ----------
+
+// DCG-style weight: rank 0 gets 1.0, rank 1 gets 0.63, etc.
+const dcgWeight = pos => 1 / Math.log2(pos + 2);
+
+const mscpBand = mscp => mscp >= 0.8 ? "HIGH" : mscp >= 0.5 ? "MEDIUM" : "LOW";
+
+function buildSid(recipe) {
+ const flavor = recipe.flavors?.[0] ?? "default";
+ return `SID::${recipe.cuisine.toLowerCase()}::${recipe.dish}::${flavor}::${recipe.id}`;
+}
+
+window.computeConfidenceAndGround = function (candidates, rerankResult, topK = 3) {
+ // 1. Aggregate DCG-weighted votes across passes.
+ const voteMap = {};
+ for (const pass of rerankResult.passes) {
+ pass.forEach((idx, pos) => {
+ voteMap[idx] = (voteMap[idx] || 0) + dcgWeight(pos);
+ });
+ }
+ const allVotes = Object.entries(voteMap)
+ .map(([idx, votes]) => ({ idx: +idx, votes }))
+ .sort((a, b) => b.votes - a.votes);
+
+ // 2. For the top-k winners, compute MSCP = fraction of passes in which they
+ // appeared inside the top-k slots.
+ const totalPasses = rerankResult.passes.length;
+ const items = allVotes
+ .slice(0, topK)
+ .map((entry, rank) => {
+ const recipe = candidates[entry.idx - 1];
+ if (!recipe) return null;
+
+ const appearTopK = rerankResult.passes.filter(p => p.slice(0, topK).includes(entry.idx)).length;
+ const bootstrap = rerankResult.passes.filter(p => p.includes(entry.idx)).length;
+ const mscp = appearTopK / totalPasses;
+
+ return {
+ rank: rank + 1,
+ idx: entry.idx,
+ recipe,
+ rationale: rerankResult.rationales[entry.idx],
+ matched_preferences: rerankResult.matchedPrefs[entry.idx],
+ mscp,
+ confidence_band: mscpBand(mscp),
+ bootstrap_support: bootstrap,
+ mapping_mode: "direct (id_map.jsonl)",
+ sid: buildSid(recipe),
+ };
+ })
+ .filter(Boolean);
+
+ return { items, allVotes };
+};
+
+
+// ---------- End-to-end pipeline ----------
+
+// Simulated wall-clock latencies so the UI can animate stage transitions.
+function simulatedTimings() {
+ const rand = n => Math.round(Math.random() * n);
+ return {
+ simSketch: 180 + rand(40),
+ simRetrieval: 35 + rand(10),
+ simRerank: 720 + rand(120),
+ simConfidence: 8 + rand(4),
+ };
+}
+
+window.runPipeline = function ({ query, liked, disliked, hardFilters, topK = 3 }) {
+ const t0 = performance.now();
+ const sketch = window.buildSketch(query, liked, disliked, hardFilters);
+ sketch._liked = liked; // passed through to retrieval for co-occurrence features
+ const t1 = performance.now();
+
+ const search = window.semanticSearch(sketch);
+ const t2 = performance.now();
+
+ const rerank = window.zeroShotRerank(search.top30, sketch, { passes: 5 });
+ const t3 = performance.now();
+
+ const conf = window.computeConfidenceAndGround(search.top30, rerank, topK);
+ const t4 = performance.now();
+
+ return {
+ sketch, search, rerank, conf,
+ timings: {
+ sketch: t1 - t0,
+ retrieval: t2 - t1,
+ rerank: t3 - t2,
+ confidence: t4 - t3,
+ total: t4 - t0,
+ ...simulatedTimings(),
+ },
+ };
+};
diff --git a/docs/demo/data/recipes.js b/docs/demo/data/recipes.js
new file mode 100644
index 0000000..99470fa
--- /dev/null
+++ b/docs/demo/data/recipes.js
@@ -0,0 +1,36 @@
+// Food.com seed recipes. Each recipe is tagged against window.TAXONOMY.
+// Field shape:
+// id, title, img, time (min), calories, cuisine,
+// dietary[], dish, meal, method, flavors[], effort, pop (0..1)
+
+const R = (id, title, img, time, calories, cuisine, dietary, dish, meal, method, flavors, effort, pop) =>
+ ({ id, title, img, time, calories, cuisine, dietary, dish, meal, method, flavors, effort, pop });
+
+window.RECIPES = [
+ R(101, "Miso-Glazed Salmon Rice Bowl", "#e8b084", 25, 540, "Japanese", ["pescatarian"], "bowl", "dinner", "seared", ["umami","sweet"], "weeknight", 0.88),
+ R(102, "Tuscan White Bean Stew", "#b9c39a", 45, 420, "Italian", ["vegan","vegetarian"], "stew", "dinner", "stewed", ["herbaceous","umami"], "weeknight", 0.62),
+ R(103, "Szechuan Dan Dan Noodles", "#c25a3a", 30, 610, "Chinese", ["omnivore"], "noodles", "dinner", "simmered", ["spicy","umami"], "weeknight", 0.81),
+ R(104, "Lemon Ricotta Pancakes", "#f3d9a4", 20, 480, "American", ["vegetarian"], "dessert", "breakfast", "fried", ["sweet","tangy"], "quick", 0.74),
+ R(105, "Harissa Roasted Cauliflower", "#d4744a", 35, 320, "Moroccan", ["vegan","vegetarian","gluten-free"], "side", "side", "roasted", ["spicy","smoky"], "weeknight", 0.58),
+ R(106, "Classic Beef Bourguignon", "#8b4a2f", 180, 720, "French", ["omnivore"], "stew", "dinner", "stewed", ["umami","rich"], "slow-cook", 0.69),
+ R(107, "Thai Green Curry with Tofu", "#9bb67a", 35, 520, "Thai", ["vegetarian","vegan"], "curry", "dinner", "simmered", ["spicy","creamy"], "weeknight", 0.79),
+ R(108, "Burrata & Heirloom Tomato Plate", "#e8a89a", 10, 380, "Italian", ["vegetarian","gluten-free"], "appetizer", "lunch", "raw", ["fresh","creamy"], "quick", 0.71),
+ R(109, "Korean Kimchi Jjigae", "#b83d2c", 40, 450, "Korean", ["omnivore"], "stew", "dinner", "stewed", ["spicy","umami"], "weeknight", 0.76),
+ R(110, "Wild Mushroom Risotto", "#c9a678", 40, 580, "Italian", ["vegetarian","gluten-free"], "rice-dish", "dinner", "simmered", ["umami","creamy"], "weeknight", 0.84),
+ R(111, "Shakshuka with Feta", "#d86a3f", 25, 410, "Middle-Eastern", ["vegetarian"], "stew", "breakfast", "simmered", ["spicy","umami"], "quick", 0.72),
+ R(112, "Vietnamese Pho Bo", "#a87c4f", 240, 540, "Vietnamese", ["omnivore"], "soup", "dinner", "simmered", ["umami","herbaceous"], "slow-cook", 0.77),
+ R(113, "Avocado Farro Grain Bowl", "#a8b87a", 20, 460, "Californian", ["vegetarian","vegan"], "bowl", "lunch", "no-cook", ["fresh","herbaceous"], "quick", 0.65),
+ R(114, "Carne Asada Tacos", "#c25e3a", 30, 580, "Mexican", ["gluten-free","omnivore"], "wrap", "dinner", "grilled", ["smoky","tangy"], "weeknight", 0.86),
+ R(115, "Dark Chocolate Olive Oil Cake", "#5a3a2c", 55, 520, "Italian", ["vegetarian"], "dessert", "dessert", "baked", ["sweet","rich"], "weekend-project", 0.70),
+ R(116, "Crispy Pork Banh Mi", "#d89a5a", 45, 620, "Vietnamese", ["omnivore"], "sandwich", "lunch", "fried", ["tangy","umami"], "weeknight", 0.73),
+ R(117, "Red Lentil Dal", "#e09a3a", 30, 340, "Indian", ["vegan","vegetarian","gluten-free"], "stew", "dinner", "simmered", ["spicy","creamy"], "weeknight", 0.60),
+ R(118, "Seared Scallops w/ Brown Butter", "#f0d9b4", 20, 380, "French", ["pescatarian","gluten-free"], "appetizer", "dinner", "seared", ["umami","rich"], "quick", 0.63),
+ R(119, "Smashed Cucumber Salad", "#7ba87a", 10, 120, "Chinese", ["vegan","vegetarian","gluten-free"], "salad", "side", "no-cook", ["spicy","tangy","fresh"], "quick", 0.55),
+ R(120, "Sourdough Margherita Pizza", "#d89a6a", 90, 680, "Italian", ["vegetarian"], "pizza", "dinner", "baked", ["umami","fresh"], "weekend-project", 0.89),
+ R(121, "Spicy Peanut Soba Noodles", "#c9834a", 20, 520, "Thai", ["vegetarian","vegan"], "noodles", "lunch", "no-cook", ["spicy","creamy"], "quick", 0.68),
+ R(122, "Roast Chicken with Lemon", "#e0b47a", 75, 620, "French", ["gluten-free","omnivore"], "roast", "dinner", "roasted", ["herbaceous","umami"], "weekend-project", 0.75),
+ R(123, "Matcha Tiramisu", "#9bb07a", 60, 460, "Japanese", ["vegetarian"], "dessert", "dessert", "no-cook", ["sweet","creamy"], "weekend-project", 0.66),
+ R(124, "Chickpea Shawarma Wrap", "#d8a74a", 25, 480, "Middle-Eastern", ["vegan","vegetarian"], "wrap", "lunch", "roasted", ["spicy","tangy"], "quick", 0.61),
+ R(125, "Creamy Tomato Pasta", "#d87a5a", 25, 560, "Italian", ["vegetarian"], "pasta", "dinner", "simmered", ["creamy","umami"], "weeknight", 0.82),
+ R(126, "Japchae Glass Noodles", "#b47a3a", 35, 500, "Korean", ["vegetarian"], "noodles", "dinner", "fried", ["umami","sweet"], "weeknight", 0.64),
+];
diff --git a/docs/demo/data/taxonomy.js b/docs/demo/data/taxonomy.js
new file mode 100644
index 0000000..be41872
--- /dev/null
+++ b/docs/demo/data/taxonomy.js
@@ -0,0 +1,31 @@
+// Master taxonomy — the vocabulary every downstream stage is guarded by.
+// Values here are the ONLY strings that can appear in sketches / hard filters.
+
+window.TAXONOMY = {
+ cuisine: [
+ "Italian", "Japanese", "Chinese", "Korean", "Thai", "Vietnamese",
+ "Indian", "Mexican", "French", "American", "Middle-Eastern",
+ "Moroccan", "Californian",
+ ],
+ dietary_style: [
+ "vegetarian", "vegan", "gluten-free", "pescatarian", "dairy-free", "omnivore",
+ ],
+ dish_type: [
+ "bowl", "soup", "stew", "noodles", "curry", "roast", "salad",
+ "sandwich", "dessert", "pizza", "pasta", "rice-dish", "wrap", "appetizer",
+ ],
+ meal_type: [
+ "breakfast", "brunch", "lunch", "dinner", "snack", "side", "dessert",
+ ],
+ cooking_method: [
+ "roasted", "grilled", "stewed", "fried", "baked",
+ "seared", "raw", "steamed", "simmered", "no-cook",
+ ],
+ flavor_profile: [
+ "spicy", "sweet", "umami", "tangy", "herbaceous",
+ "smoky", "creamy", "fresh", "rich",
+ ],
+ effort: [
+ "quick", "weeknight", "weekend-project", "slow-cook",
+ ],
+};
diff --git a/docs/demo/index.html b/docs/demo/index.html
new file mode 100644
index 0000000..bb50d61
--- /dev/null
+++ b/docs/demo/index.html
@@ -0,0 +1,29 @@
+
+
+
+
+ ZeroAlign-Rec
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/demo/screenshots/desktop-en.png b/docs/demo/screenshots/desktop-en.png
new file mode 100644
index 0000000..bbd3629
Binary files /dev/null and b/docs/demo/screenshots/desktop-en.png differ
diff --git a/docs/demo/screenshots/desktop-kr.png b/docs/demo/screenshots/desktop-kr.png
new file mode 100644
index 0000000..4904046
Binary files /dev/null and b/docs/demo/screenshots/desktop-kr.png differ
diff --git a/docs/demo/screenshots/mobile-en.png b/docs/demo/screenshots/mobile-en.png
new file mode 100644
index 0000000..0756a8b
Binary files /dev/null and b/docs/demo/screenshots/mobile-en.png differ
diff --git a/docs/demo/src/app.jsx b/docs/demo/src/app.jsx
new file mode 100644
index 0000000..7b2c1dc
--- /dev/null
+++ b/docs/demo/src/app.jsx
@@ -0,0 +1,16 @@
+/* Single entry — picks language from ?lang= URL param, then navigator.language.
+ * Accepts BCP-47 alias `?lang=ko` for the Korean bundle. */
+const lang = (() => {
+ let param = new URLSearchParams(location.search).get("lang");
+ if (param === "ko") param = "kr";
+ if (param === "en" || param === "kr") return param;
+ return navigator.language?.toLowerCase().startsWith("ko") ? "kr" : "en";
+})();
+
+const L = window.I18N[lang];
+document.documentElement.lang = lang === "kr" ? "ko" : "en";
+document.title = L.docTitle;
+
+ReactDOM.createRoot(document.getElementById("root")).render(
+
+);
diff --git a/docs/demo/src/components.jsx b/docs/demo/src/components.jsx
new file mode 100644
index 0000000..1147975
--- /dev/null
+++ b/docs/demo/src/components.jsx
@@ -0,0 +1,602 @@
+/* Shared React components for ZeroAlign-Rec demo.
+ * All components accept a locale table L (window.I18N.en or .kr).
+ * Exported to window at the bottom so thin entry files can use them. */
+
+const { useState, useEffect, useRef } = React;
+
+// ===== Utilities =====
+function shade(hex, amt) {
+ const h = hex.replace("#", "");
+ const r = parseInt(h.slice(0,2),16), g = parseInt(h.slice(2,4),16), b = parseInt(h.slice(4,6),16);
+ const clamp = v => Math.max(0, Math.min(255, Math.round(v)));
+ return `rgb(${clamp(r*(1+amt))},${clamp(g*(1+amt))},${clamp(b*(1+amt))})`;
+}
+
+function avgMscp(items) {
+ return items.length ? (items.reduce((s,i)=>s+i.mscp,0)/items.length).toFixed(2) : "—";
+}
+
+// Build a URL pointing at the same page but with `lang` overridden, preserving
+// every other query parameter (e.g. utm_source) the user arrived with.
+function buildLangHref(targetLang) {
+ const params = new URLSearchParams(location.search);
+ params.set("lang", targetLang);
+ return "?" + params.toString();
+}
+
+// ===== Icons =====
+const BrandMark = () => (
+
+);
+
+const PlayIcon = () => (
+
+);
+
+// ===== JSON preview =====
+// Escape HTML BEFORE the regex highlight pass so user-supplied strings
+// (e.g. sketch.summary, which echoes the query textarea) cannot inject
+// markup via dangerouslySetInnerHTML. The highlight regexes still match
+// because JSON.stringify's quote characters are not escaped.
+const escapeHtml = s =>
+ s.replace(/&/g, "&").replace(//g, ">");
+
+function JsonView({ data, label }) {
+ const json = escapeHtml(JSON.stringify(data, null, 2));
+ const highlighted = json
+ .replace(/("(?:[^"\\]|\\.)*")(\s*:)/g, '$1$2')
+ .replace(/:\s*("(?:[^"\\]|\\.)*")/g, ': $1')
+ .replace(/:\s*(-?\d+(?:\.\d+)?)/g, ': $1');
+ // gives the right semantics; tabIndex makes the scroll
+ // container reachable via keyboard.
+ return (
+
+
+
+ );
+}
+
+// ===== Sidebar =====
+function Sidebar({ L, preset, setPreset, query, setQuery, liked, disliked, toggleLiked, toggleDisliked, filters, setFilters, topK, setTopK, onRun, running }) {
+ return (
+
+ );
+}
+
+// ===== Tab bar =====
+function TabBar({ L, active, setActive, result }) {
+ // aria-disabled + tabindex=-1 keeps the queued tabs visible to AT and
+ // skipped from Tab order, instead of HTML `disabled` (which makes them
+ // disappear from focus traversal entirely).
+ return (
+
+ {L.tabs.map(t => {
+ const ready = result || t.id === "overview";
+ const isActive = active === t.id;
+ return (
+
+ );
+ })}
+
+ );
+}
+
+// ===== Stage strip =====
+function PipelineStrip({ L, stage, timings }) {
+ return (
+
+ {L.pipeline.map((s, i) => {
+ const state = stage > i ? "done" : stage === i ? "active" : "";
+ const t = timings?.[s.tKey];
+ return (
+
+
+ MODULE {s.num}
+
+
+ {s.title}
+ {s.sub}
+
+ {t != null && state === "done"
+ ? <>{L.stageDone(t)} · {L.runtimeTag}>
+ : state === "active" ? L.stageActive : L.stageQueued}
+
+
+ );
+ })}
+
+ );
+}
+
+// ===== Panel 2.1 — Interest Sketch =====
+function Panel21({ L, sketch }) {
+ if (!sketch) return null;
+ const hasPos = Object.keys(sketch.positive_facets).length > 0;
+ const hasNegOrHF = Object.keys(sketch.negative_facets).length > 0 || Object.keys(sketch.hard_filters).length > 0;
+ return (
+
+
+ MODULE 2.1{L.p21.title}
+ {L.p21.sub}
+
+ {L.p21.kvpSummary}{sketch.summary}
+ {sketch.ambiguity_notes.length > 0 && (
+ {L.p21.kvpAmbiguity}{sketch.ambiguity_notes.join(" · ")}
+ )}
+
+
+
+ {L.p21.positive}
+ {Object.entries(sketch.positive_facets).map(([f, vals]) => (
+
+ {f}
+ {vals.map(v => {v})}
+
+ ))}
+ {!hasPos && {L.p21.emptyPos}}
+
+
+ {L.p21.negative}
+ {Object.entries(sketch.negative_facets).map(([f, vals]) => (
+
+ {f}
+ {vals.map(v => ¬{v})}
+
+ ))}
+ {Object.entries(sketch.hard_filters).map(([f, vals]) => (
+
+ {f}
+ {vals.map(v => ={L.dietLabel(v)})}
+
+ ))}
+ {!hasNegOrHF && {L.p21.emptyNegHF}}
+
+
+
+ );
+}
+
+// ===== Panel 2.2 — Semantic search =====
+function Panel22({ L, search }) {
+ if (!search) return null;
+ const { top30, dropped } = search;
+ return (
+
+
+ MODULE 2.2{L.p22.title}
+ {L.p22.sub}
+
+
+
+ {L.p22.surviving} {top30.length}/30
+
+ {top30.map((c, i) => (
+
+ {i+1}
+
+
+ {c.title}
+ {L.p22.candMeta(c)}
+
+ {c.score.toFixed(3)}
+
+ ))}
+
+
+
+ {L.p22.dropped} {dropped.length}
+
+ {dropped.slice(0, 30).map(d => (
+
+ —
+
+
+ {d.title}
+ {d.reason}
+
+ drop
+
+ ))}
+ {dropped.length === 0 && {L.p22.noDrop}}
+
+
+
+
+ );
+}
+
+// ===== Panel 2.3 — Rerank passes =====
+function Panel23({ L, rerank, conf }) {
+ if (!rerank || !conf) return null;
+ const topSet = new Set((conf.items || []).map(x => x.idx));
+ return (
+
+
+ MODULE 2.3{L.p23.title}
+ {L.p23.sub}
+
+
+ {rerank.passes.map((p, pi) => (
+
+ {L.p23.pass} {pi+1}
+
+ {p.map((idx, pos) => (
+ {idx}
+ ))}
+
+
+ ))}
+
+
+ {L.p23.footLeft}{L.p23.footModel}{L.p23.footRight}
+
+
+ );
+}
+
+// ===== Panel 2.4 — MSCP confidence =====
+function Panel24({ L, conf, candidates }) {
+ if (!conf) return null;
+ const maxVote = conf.allVotes[0]?.votes || 1;
+ return (
+
+
+ MODULE 2.4{L.p24.title}
+ {L.p24.sub}
+
+
+ {L.p24.voteHeader}
+
+
+ {conf.allVotes.slice(0, 10).map((v) => {
+ const c = candidates[v.idx - 1];
+ const appearTopK = conf.items.find(x => x.idx === v.idx);
+ const mscp = appearTopK ? appearTopK.mscp : 0;
+ const band = mscp >= 0.8 ? "HIGH" : mscp >= 0.5 ? "MEDIUM" : mscp > 0 ? "LOW" : "—";
+ return (
+
+ #{v.idx}
+
+
+ {c?.title}
+
+ {L.p24.mscpLabel} {mscp.toFixed(2)}
+ {L.p24.band[band] || band}
+
+ );
+ })}
+
+
+ );
+}
+
+// ===== Final delivery cards =====
+function FinalDelivery({ L, conf }) {
+ if (!conf) return null;
+ return (
+
+
+ {L.tabs[1].num}{L.final.title}
+ {L.final.sub}
+
+
+ {conf.items.map(item => {
+ const r = item.recipe;
+ const bandLabel = L.p24.band[item.confidence_band] || item.confidence_band;
+ return (
+
+
+ #{item.rank}
+ {L.final.bandPrefix}{bandLabel} · {item.mscp.toFixed(2)}
+
+
+ {r.title}
+ {L.final.meta(r)}
+ {item.rationale}
+
+ {item.matched_preferences.map((m, i) => {m})}
+
+
+ {L.final.bootstrap(item.bootstrap_support)}
+ {item.sid}
+
+
+
+ );
+ })}
+
+
+ );
+}
+
+// ===== Metrics strip =====
+function MetricsStrip({ L, result }) {
+ const total = result.timings.simSketch + result.timings.simRetrieval + result.timings.simRerank + result.timings.simConfidence;
+ return (
+
+ {L.metrics.totalLatency}{total}ms
+ {L.metrics.retrieved}Top-{result.search.top100.length} → Top-{result.search.top30.length}
+ {L.metrics.droppedByHF}{result.search.dropped.length}
+ {L.metrics.rerankPasses}{result.rerank.passes.length}
+ {L.metrics.avgMscp}{avgMscp(result.conf.items)}
+ {L.metrics.ood}0%
+
+ );
+}
+
+// ===== Overview grid =====
+function OverviewGrid({ L, result, setActiveTab }) {
+ const posCount = Object.values(result.sketch.positive_facets).reduce((a,b)=>a+b.length,0);
+ const hfCount = Object.keys(result.sketch.hard_filters).length;
+ const cards = [
+ { tab: "m21", num: "2.1", title: L.overview.m21Title,
+ body: [L.overview.m21Body1(posCount), L.overview.m21Body2(hfCount)] },
+ { tab: "m22", num: "2.2", title: L.overview.m22Title,
+ body: [L.overview.m22Body1(result.search.top30.length), L.overview.m22Body2(result.search.dropped.length)] },
+ { tab: "m23", num: "2.3", title: L.overview.m23Title,
+ body: [L.overview.m23Body1(result.rerank.passes.length), L.overview.m23Body2] },
+ { tab: "m24", num: "2.4", title: L.overview.m24Title,
+ body: [L.overview.m24Body1(avgMscp(result.conf.items)), L.overview.m24Body2] },
+ ];
+ return (
+
+ {cards.map(c => (
+
+ ))}
+
+ );
+}
+
+// ===== JSON tab body =====
+function JsonTab({ L, result, topK }) {
+ const payload = {
+ sketch: {
+ summary: result.sketch.summary,
+ positive_facets: result.sketch.positive_facets,
+ negative_facets: result.sketch.negative_facets,
+ hard_filters: result.sketch.hard_filters,
+ ambiguity_notes: result.sketch.ambiguity_notes,
+ },
+ items: result.conf.items.map(i => ({
+ recipe_id: i.recipe.id,
+ sid_string: i.sid,
+ rank: i.rank,
+ title: i.recipe.title,
+ rationale: i.rationale,
+ matched_preferences: i.matched_preferences,
+ confidence_band: i.confidence_band,
+ mscp: +i.mscp.toFixed(3),
+ mapping_mode: i.mapping_mode,
+ bootstrap_support: i.bootstrap_support,
+ popularity: i.recipe.popularity,
+ cooccurrence_with_history: i.recipe.cooccurrence,
+ })),
+ rerank_summary: L.json.rerankSummary(topK),
+ confidence_summary: result.conf.items.every(i => i.confidence_band === "HIGH") ? L.json.confAllHigh : L.json.confMixed,
+ selected_candidate_indices: result.conf.items.map(i => i.idx),
+ };
+ return (
+
+
+ API{L.json.title}
+ {L.json.sub}
+
+
+
+ );
+}
+
+// ===== App shell =====
+function App({ L }) {
+ const presets = L.presets;
+ const [preset, setPreset] = useState(presets[0].id);
+ const [query, setQuery] = useState(presets[0].query);
+ const [liked, setLiked] = useState(presets[0].liked);
+ const [disliked, setDisliked] = useState(presets[0].disliked);
+ const [filters, setFilters] = useState(presets[0].filters);
+ const [topK, setTopK] = useState(presets[0].topK);
+ const [activeTab, setActiveTab] = useState("overview");
+ const [result, setResult] = useState(null);
+ const [running, setRunning] = useState(false);
+ const [stage, setStage] = useState(-1);
+
+ useEffect(() => {
+ const p = presets.find(x => x.id === preset);
+ if (!p) return;
+ setQuery(p.query);
+ setLiked(p.liked);
+ setDisliked(p.disliked);
+ setFilters(p.filters);
+ setTopK(p.topK);
+ }, [preset]);
+
+ const toggleLiked = id => setLiked(l => l.includes(id) ? l.filter(x=>x!==id) : [...l, id]);
+ const toggleDisliked = id => setDisliked(l => l.includes(id) ? l.filter(x=>x!==id) : [...l, id]);
+
+ const timeoutsRef = useRef([]);
+ const cancelPending = () => {
+ timeoutsRef.current.forEach(clearTimeout);
+ timeoutsRef.current = [];
+ };
+
+ const onRun = () => {
+ cancelPending();
+ setRunning(true);
+ setStage(-1);
+ setResult(null);
+ setActiveTab("overview");
+ const res = window.runPipeline({ query, liked, disliked, hardFilters: filters, topK });
+ const delays = [res.timings.simSketch, res.timings.simRetrieval, res.timings.simRerank, res.timings.simConfidence];
+ let cum = 0;
+ delays.forEach((d, i) => {
+ timeoutsRef.current.push(setTimeout(() => setStage(i), cum));
+ cum += d;
+ });
+ timeoutsRef.current.push(setTimeout(() => {
+ setResult(res);
+ setStage(4);
+ setRunning(false);
+ setActiveTab("final");
+ }, cum));
+ };
+
+ useEffect(() => { onRun(); return cancelPending; /* eslint-disable-next-line */ }, []);
+
+ const renderTab = () => {
+ if (!result) {
+ const [a, em, b] = L.idleText;
+ return {a}{em}{b};
+ }
+ switch (activeTab) {
+ case "overview":
+ return (
+ <>
+
+
+
+ >
+ );
+ case "final": return ;
+ case "m21": return ;
+ case "m22": return ;
+ case "m23": return ;
+ case "m24": return ;
+ case "json": return ;
+ default: return null;
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+ {L.pageTitle.a} {L.pageTitle.b} {L.pageTitle.c}
+
+ {L.pageSub}
+
+ {L.otherLang.label}
+
+
+
+
+ {renderTab()}
+
+
+ );
+}
+
+// Expose App to thin entry scripts (Babel scopes each