probat-mcp 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of probat-mcp might be problematic. Click here for more details.

@@ -0,0 +1,2279 @@
1
+ Metadata-Version: 2.4
2
+ Name: probat-mcp
3
+ Version: 0.1.0
4
+ Summary: Visual UI snip tool for A/B variant generation — drag to select a component, get design variants instantly
5
+ License: MIT
6
+ Keywords: a/b testing,design,mcp,playwright,ui
7
+ Classifier: License :: OSI Approved :: MIT License
8
+ Classifier: Programming Language :: Python :: 3
9
+ Requires-Python: >=3.11
10
+ Requires-Dist: httpx>=0.27.0
11
+ Requires-Dist: mcp[cli]>=1.0.0
12
+ Requires-Dist: playwright>=1.40.0
13
+ Description-Content-Type: text/markdown
14
+
15
+ <p align="center">
16
+ <img src="images/orpheus_logo.png" alt="Orpheus Logo" width="200">
17
+ </p>
18
+
19
+ # Orpheus — Technical Design Document
20
+
21
+ **Version:** 1.0
22
+ **Date:** 2026-03-19
23
+ **Status:** Draft
24
+
25
+ ---
26
+
27
+ ## Table of Contents
28
+
29
+ 1. Executive Summary
30
+ 2. Problem Statement
31
+ 3. System Overview
32
+ 4. Component Design
33
+ 5. Data Models
34
+ 6. Retrieval Pipeline
35
+ 7. Learning-to-Rank System
36
+ 8. Attribution System
37
+ 9. Human-in-the-Loop Flow
38
+ 10. Multi-Metric Ingestion
39
+ 11. Corpus Design
40
+ 12. Corpus Quality Validation
41
+ 13. Corpus Improvement Loops
42
+ 14. Integration Architecture (PostHog, GA4)
43
+ 15. Limitations and Known Tradeoffs
44
+ 16. React SDK Expansion Plan
45
+ 17. One-Week MVP
46
+ 18. 4-Week Build Plan (Compressed Rollout)
47
+ 19. Project Structure
48
+
49
+ ---
50
+
51
+ ## 1. Executive Summary
52
+
53
+ Design decisions in UI/UX work are currently made through a combination of intuition, tribal knowledge, and periodic A/B testing — but these three information sources rarely talk to each other. A designer proposes a variant, engineers run the test, results go into a spreadsheet, and the next designer starts from scratch. The A/B UI Variant Recommender closes this loop: it generates design variants informed by what has actually worked, improves from every test result and user selection it observes, and always explains exactly why it proposed what it proposed.
54
+
55
+ The system operates as an MCP (Model Context Protocol) server, integrating directly into AI coding tools like Claude Code, Cursor, and Windsurf. When a developer or designer asks for UI variants for a hero section, pricing table, or CTA button, the system retrieves the top candidates per domain from a curated corpus (style, color, typography, layout, UX patterns), anchors Variant 1 on the `ui-reasoning.csv` expert recommendation for that product/page context, then selects Variants 2 and 3 by maximizing embedding distance from already-chosen docs — guaranteeing visual diversity without pigeonholing into pre-defined strategy buckets. Strategy labels (e.g. "bold", "layered") are assigned post-hoc as descriptive hypotheses, not used to constrain retrieval. A learning-to-rank model trained on selection events, edit events, and A/B outcomes re-ranks future recommendations. Every variant ships with full attribution tracing back to specific corpus documents and experiments. The customer selects one, optionally edits it, runs the A/B test, and the result flows back into the model.
56
+
57
+ Three non-negotiable requirements drive every architectural decision: ideation quality (the variants must be genuinely good starting points), adaptability (the system must improve from outcomes), and referenceability (every recommendation must be traceable to specific evidence a human can read and evaluate).
58
+
59
+ ---
60
+
61
+ ## 2. Problem Statement
62
+
63
+ ### Requirements
64
+
65
+ **R1 — Good ideation**
66
+ Given a query describing a UI component and context, the system must return n variants that are meaningfully different from each other and represent genuinely good design starting points. "Good" means: consistent with industry best practices, appropriate for the product type and page context, and diverse enough that running an A/B test between them would yield actionable learning.
67
+
68
+ **R2 — Adaptability**
69
+ Recommendations must improve over time as A/B test results and user selections accumulate. A customer who has run 50 experiments should receive materially better recommendations than on day 1. The learning must be sample-efficient — A/B tests are expensive and slow (days to weeks per experiment).
70
+
71
+ **R3 — Referenceability**
72
+ Every variant must be accompanied by an explanation that a developer or PM can evaluate. "The model thinks this is good" is not acceptable. Acceptable: "This pattern has been selected 8 times over alternatives in similar contexts, and experiment exp-291 (acme-corp, checkout CTA, +23% CVR) contributes 0.18 weight to this recommendation."
73
+
74
+ ### Why Existing Tools Don't Solve This
75
+
76
+ - **Design tools** (Figma, Framer): great for visual design, no connection to conversion data
77
+ - **A/B testing tools** (Optimizely, VWO): measure variants, don't generate them
78
+ - **AI coding tools** (Copilot, Cursor): generate UI code with no design intelligence
79
+ - **Component libraries** (shadcn, MUI): solve consistency, not ideation or optimization
80
+
81
+ No existing tool owns the gap between "what should I try" and "what actually worked."
82
+
83
+ ---
84
+
85
+ ## 3. System Overview
86
+
87
+ ![Orpheus architecture](images/orpheus-architecture.svg)
88
+
89
+ ```
90
+ ┌──────────────────────────────────────────────────────────────────────────────┐
91
+ │ ORPHEUS MCP SERVER │
92
+ │ │
93
+ │ ┌────────────────────────────────────────────────────────────────────────┐ │
94
+ │ │ RETRIEVAL — per domain, unconstrained │ │
95
+ │ │ │ │
96
+ │ │ Query ──┬── BM25 (sparse) ──┐ │ │
97
+ │ │ └── Dense (ChromaDB)─┴── RRF Fusion → top 8 per domain │ │
98
+ │ │ │ │
99
+ │ │ Domains: style · color · typography · landing · ux │ │
100
+ │ │ One shared embedding per request (single OpenAI call) │ │
101
+ │ └────────────────────────┬───────────────────────────────────────────────┘ │
102
+ │ │ top 8 candidates × 5 domains │
103
+ │ ┌────────────────────────▼───────────────────────────────────────────────┐ │
104
+ │ │ CORPUS-ANCHORED COMPOSITION │ │
105
+ │ │ │ │
106
+ │ │ ┌─────────────────────────────────────────────────────┐ │ │
107
+ │ │ │ VARIANT 1 — ui-reasoning.csv anchor │ │ │
108
+ │ │ │ │ │ │
109
+ │ │ │ lookup(product_type, page_context) │ │ │
110
+ │ │ │ → Style_Priority, Color_Mood, Typography_Mood │ │ │
111
+ │ │ │ select_best_match(candidates, reasoning_keywords) │ │ │
112
+ │ │ │ → 1 doc per domain │ │ │
113
+ │ │ └──────────────────────┬──────────────────────────────┘ │ │
114
+ │ │ │ used_doc_ids = {v1 doc ids} │ │
115
+ │ │ ┌──────────────────────▼──────────────────────────────┐ │ │
116
+ │ │ │ VARIANT 2 — max embedding distance from V1 │ │ │
117
+ │ │ │ │ │ │
118
+ │ │ │ for each domain: │ │ │
119
+ │ │ │ argmax cosine_distance(candidate, v1_doc) │ │ │
120
+ │ │ │ where candidate.doc_id not in used_doc_ids │ │ │
121
+ │ │ └──────────────────────┬──────────────────────────────┘ │ │
122
+ │ │ │ used_doc_ids += {v2 doc ids} │ │
123
+ │ │ ┌──────────────────────▼──────────────────────────────┐ │ │
124
+ │ │ │ VARIANT 3 — max distance from V1 and V2 │ │ │
125
+ │ │ │ │ │ │
126
+ │ │ │ for each domain: │ │ │
127
+ │ │ │ argmax min(dist(c, v1), dist(c, v2)) │ │ │
128
+ │ │ │ where candidate.doc_id not in used_doc_ids │ │ │
129
+ │ │ └──────────────────────┬──────────────────────────────┘ │ │
130
+ │ │ │ │ │
131
+ │ │ ┌──────────────────────▼──────────────────────────────┐ │ │
132
+ │ │ │ POST-HOC LABELING │ │ │
133
+ │ │ │ assign nearest strategy label to each bundle │ │ │
134
+ │ │ │ (descriptive only — does not affect selection) │ │ │
135
+ │ │ │ apply brand_constraints (filter + override) │ │ │
136
+ │ │ └──────────────────────┬──────────────────────────────┘ │ │
137
+ │ └────────────────────────┬───────────────────────────────────────────────┘ │
138
+ │ │ 3 ComposedVariants + full attribution │
139
+ │ ┌────────────────────────▼───────────────────────────────────────────────┐ │
140
+ │ │ LTR RE-RANKING (bundle-level) │ │
141
+ │ │ │ │
142
+ │ │ Features: style_doc_win_rate, color_doc_win_rate, │ │
143
+ │ │ style_color_combo_win_rate, context_similarity │ │
144
+ │ │ │ │
145
+ │ │ LambdaRank → re-order 3 bundles by predicted win probability │ │
146
+ │ │ (global baseline → per-customer model as data accumulates) │ │
147
+ │ └────────────────────────┬───────────────────────────────────────────────┘ │
148
+ │ ▼ │
149
+ │ 3 ranked ComposedVariants with full attribution │
150
+ └───────────────────────────┬──────────────────────────────────────────────────┘
151
+ │
152
+ ┌───────────────┼───────────────┐
153
+ ▼ ▼ ▼
154
+ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
155
+ │ VS Code │ │ Web App │ │ MCP Tool │
156
+ │ Webview │ │ Dashboard │ │ Response │
157
+ │ (engineer) │ │ (PM/design)│ │ (API) │
158
+ └──────┬──────┘ └──────┬──────┘ └─────────────┘
159
+ │ │
160
+ ▼ ▼
161
+ ┌──────────────────────────────────────────────────┐
162
+ │ FEEDBACK SIGNALS │
163
+ │ │
164
+ │ 1. select → selection_events (bundle-level) │
165
+ │ pairwise labels, weight 0.3 │
166
+ │ │
167
+ │ 2. edit → edit_events (domain-level) ◄───── │─ most granular signal
168
+ │ swap_doc: original_doc → replacement_doc │
169
+ │ freeform: original_doc → corpus gap flagged │
170
+ │ partial: same doc, minor tweak (0.3× wt) │
171
+ │ │
172
+ │ 3. A/B → ab_results (CVR, return_7d, ...) │
173
+ │ staged: day 0 / day 7 / day 30 │
174
+ │ │
175
+ │ 4. SDK → probat_events (rage, dead clicks) │
176
+ │ 5. heatmap → heatmap_clicks │──► Supabase
177
+ └────────────────────┬─────────────────────────────┘
178
+ │
179
+ ┌────────────┴────────────┐
180
+ ▼ ▼
181
+ ┌──────────────────┐ ┌────────────────────────┐
182
+ │ NIGHTLY WIN RATE │ │ LTR RETRAINING │
183
+ │ UPDATE │ │ │
184
+ │ │ │ selection ──┐ │
185
+ │ update │ │ edits ──┼─► ltr_ │
186
+ │ global_win_rate │ │ ab_results ──┘ training_examples
187
+ │ in ChromaDB │ │ │
188
+ │ metadata per doc │ │ Confidence decay: │
189
+ │ │ │ half-life = 90 days │
190
+ └──────────────────┘ └────────────────────────┘
191
+ ```
192
+
193
+ ---
194
+
195
+ ## 4. Component Design
196
+
197
+ ### 4.1 Corpus (Knowledge Base)
198
+
199
+ **What it is:** A collection of ~2,000 prose documents describing design patterns, serialized from CSV databases covering 10 domains (style, color, typography, charts, landing patterns, product types, UX guidelines, icons, React performance, app interface guidelines).
200
+
201
+ **Why prose, not raw CSV:** Embedding models produce higher-quality vectors when they see coherent language. `"Style: Glassmorphism. Keywords: frosted glass, transparent panels, blur. Best For: SaaS dashboards, fintech apps."` embeds better than `"Glassmorphism,frosted glass,transparent,blur,SaaS"`.
202
+
203
+ **Why not a relational DB:** The documents are the retrieval unit. You need embeddings per document, BM25 scores per document, and LTR features per document. A flat document store (ChromaDB) is the correct abstraction.
204
+
205
+ **Representation decision:** Hybrid. Each document contains:
206
+ - Abstract design principles (for semantic embedding quality)
207
+ - Concrete implementation hints per stack (Tailwind classes, CSS variables)
208
+ - Synonym expansion (for BM25 lexical coverage)
209
+
210
+ This is better than pure abstract (too vague to implement) or pure code (not generalizable across stacks).
211
+
212
+ ### 4.2 Retrieval + Composition Pipeline
213
+
214
+ **Purpose:** Retrieve the top candidate docs per domain from the corpus, then compose them into three visually distinct, hypothesis-driven variant bundles. No LLM is involved in the design decisions.
215
+
216
+ **Critical distinction:** A variant is NOT a single retrieved document. A variant is a **composed combination** of 5 design decisions (style + color + typography + layout + UX pattern) assembled under a testable hypothesis. Retrieval finds the candidate pool; composition selects one doc per domain per variant, guaranteeing diversity across all three bundles.
217
+
218
+ **Why corpus-anchored, not strategy-constrained:** Pre-defined strategy buckets (e.g. trust/urgency/simplicity) pigeonhole retrieval — "bold" may be wrong for fintech, "trust" and "simplicity" may both retrieve Minimalism docs and produce identical variants. Instead, Variant 1 is anchored on `ui-reasoning.csv` (161 rows of curated expert recommendations by product/page type), and Variants 2 and 3 are selected by maximizing embedding distance from already-chosen docs. Strategy labels are assigned post-hoc as human-readable hypotheses. The corpus — not a hardcoded bucket — determines what "different" means for a given query.
219
+
220
+ ```
221
+ Step 1: Retrieve top 8 candidates per domain (shared embedding, 1 OpenAI call)
222
+ Step 2: Variant 1 — retrieve best matching ui_reasoning corpus doc via BM25 + dense
223
+ (same pipeline, domain_filter="ui_reasoning"), parse its prose to extract
224
+ Style Priority / Color Mood / Typography Mood, then keyword-score candidates
225
+ Step 3: Variant 2 — for each domain, pick the candidate most distant in embedding
226
+ space from Variant 1's doc (not in used_doc_ids)
227
+ Step 4: Variant 3 — for each domain, pick the candidate most distant from both
228
+ Variant 1 and Variant 2 (not in used_doc_ids)
229
+ Step 5: Assign post-hoc label (e.g. "bold", "layered") to each bundle by nearest
230
+ strategy embedding — descriptive only, does not affect selection
231
+ → LTR ranks bundles by predicted win probability
232
+ → Attribution traces each bundle back to its source docs
233
+ ```
234
+
235
+ **Why hybrid (BM25 + dense):** They fail in opposite directions.
236
+ - BM25 misses semantic equivalence: "frosted glass" doesn't match "glassmorphism"
237
+ - Dense embeddings miss exact technical terms: `backdrop-filter` might not rank a glassmorphism doc above a general blur doc
238
+ - Hybrid retrieval consistently outperforms either alone by 5-15% on technical queries
239
+
240
+ **Why RRF over score normalization:** BM25 scores are unbounded floats; cosine similarities are 0-1. Averaging them requires arbitrary scale normalization. RRF uses only rank positions — rank 1 is rank 1 regardless of scale. Documents ranked highly by both methods score highest.
241
+
242
+ **Why Cohere rerank:** Both BM25 and embeddings are bi-encoders — they encode query and document independently. The cross-encoder (Cohere) reads them together, enabling it to understand which specific phrases in the query correspond to which parts of the document. This is qualitatively different and consistently higher quality. Run only on top 20 candidates to control latency and cost.
243
+
244
+ **Why MMR for candidate diversity:** Without diversity enforcement, retrieved candidates might cluster around one style. MMR penalizes candidates similar to already-selected ones, ensuring each strategy's search returns diverse building blocks.
245
+
246
+ **Why deterministic composition over LLM composition:** LLM composition has critical failure modes: fake diversity ("minimalist + clean" vs. "simple + modern" = same variant), incoherent bundles (brutalist typography + glassmorphism cards), vague hypotheses ("this is more engaging"), and hallucinated patterns not in the corpus. Deterministic strategy-based composition avoids all of these — strategies are pre-defined to differ on specific axes, each strategy has internally consistent moods, and every attribute maps directly to a search result. It's also faster (<100ms vs. 1-3s), free (no LLM call), deterministic (same input = same output), and attribution is automatic. This mirrors the approach in the existing `DesignSystemGenerator` which already does rule-based multi-domain search → priority scoring → deterministic assembly.
247
+
248
+ ### 4.3 Learning-to-Rank (LambdaRank)
249
+
250
+ **Purpose:** Re-rank retrieved candidates based on what has worked for this customer in similar contexts.
251
+
252
+ **Why LTR over signal blending (the alternative):** Signal blending assigns per-attribute win rates independently (glassmorphism: 0.4 win rate, minimalism: 0.7 win rate). LTR learns that glassmorphism + dark background + subtle animation is a coherent package that wins together — it models attribute interactions. LTR also directly optimizes a ranking objective (NDCG) rather than a heuristic score formula.
253
+
254
+ **Why LambdaRank specifically over Bayesian optimization:** BO optimizes a single function with sample efficiency — excellent for hyperparameter tuning. But it cannot generate candidates (only select between fixed arms), its GP posterior is not interpretable (kills R3), and it doesn't handle categorical design attributes natively without extensions (CoCaBO). LambdaRank is interpretable (feature importance = which design attributes matter), referenceable (training examples are specific experiments), and fits your data volume (hundreds of training examples, not thousands).
255
+
256
+ **Why LTR over contextual bandits (LinUCB):** LinUCB requires pre-defined arms. Your design space is open-ended — corpus grows. LinUCB's UCB scores are not user-facing explanations. LTR feature importance is.
257
+
258
+ ### 4.4 Attribution System
259
+
260
+ **Purpose:** Satisfy R3. Every recommendation must be traceable to specific evidence.
261
+
262
+ **Why stored at generation time:** Attribution cannot be reconstructed after the fact. The LTR model weights, the specific experiments that contributed, the retrieval scores at each stage — these change as the model retrains. Store them when the recommendation is generated or lose them permanently.
263
+
264
+ ### 4.5 Human-in-the-Loop
265
+
266
+ **Purpose:** Capture selection and edit signals as free training data for LTR.
267
+
268
+ **Key insight:** Selection events are pairwise LTR labels. Customer picks B over A → `(context, B_attrs, A_attrs): B > A`. These arrive before any A/B test runs and cost nothing to collect. Edit signals are preference labels at attribute granularity. Both improve the LTR model without requiring additional A/B experiments.
269
+
270
+ ---
271
+
272
+ ## 5. Data Models
273
+
274
+ ### 5.1 ChromaDB Collection: `design_knowledge`
275
+
276
+ ```python
277
+ # One document per CSV row, serialized as prose
278
+ {
279
+ "id": "style_styles_3", # domain_file_rowindex
280
+ "document": "Style: Glassmorphism. Type: General. Keywords: frosted glass...",
281
+ "embedding": [...], # text-embedding-3-large, 3072-dim
282
+ "metadata": {
283
+ "domain": "style",
284
+ "source_file": "styles.csv",
285
+ "row_index": 3,
286
+ "style_category": "Glassmorphism",
287
+ "complexity": "Medium", # Low / Medium / High
288
+ "performance": "Good",
289
+ "accessibility": "Medium",
290
+ "product_type": "", # populated for color + product domains
291
+ "severity": "", # populated for ux + react + web domains
292
+ "global_win_rate": 0.0, # updated from A/B outcomes
293
+ "global_appearance_count": 0,
294
+ }
295
+ }
296
+ ```
297
+
298
+ ### 5.2 Supabase (Postgres) — Existing Tables Reused
299
+
300
+ Orpheus builds on the existing Probat Supabase schema. These tables are already in production and need no changes:
301
+
302
+ | Existing Table | Orpheus Role |
303
+ |---|---|
304
+ | `app_users` | Customer identity (replaces design doc's `customers`) |
305
+ | `experiment_runs` | Top-level experiment record |
306
+ | `experiment_configs` | Variant weights, status, activation |
307
+ | `experiments` | Individual variants (`json_variant`, `react_variant`, `component`, `platform`) |
308
+ | `experiment_proposals` | Maps to Orpheus queries (`site_url`, `component`, `payload`) |
309
+ | `probat_events` | SDK events — impressions, clicks, conversions (generic event table) |
310
+ | `probat_assignments` | Sticky variant assignments per visitor |
311
+ | `experiment_metrics` | Time-series metrics with `metric_name`, `metric_value`, `dimensions` |
312
+ | `metric_definitions` | **Extensible metric registry** — already has `name`, `description`, `unit`, `is_higher_better`. New metrics = new row, no migration. |
313
+ | `heatmap_clicks` | Click coordinates, element info, session tracking |
314
+ | `heatmap_cursor_movements` | Cursor movement tracking |
315
+
316
+ ### 5.3 Supabase (Postgres) — New Orpheus Tables`
317
+
318
+ These tables extend the existing schema for Orpheus-specific functionality:
319
+
320
+ ```sql
321
+ -- Hard constraints on design recommendations — applied as post-retrieval overrides
322
+ -- token_type: 'primary_color' | 'secondary_color' | 'cta_color' |
323
+ -- 'font_heading' | 'font_body' | 'border_radius' |
324
+ -- 'forbidden_style' | 'required_style'
325
+ CREATE TABLE brand_tokens (
326
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
327
+ user_id UUID NOT NULL REFERENCES app_users(id) ON DELETE CASCADE,
328
+ token_type TEXT NOT NULL,
329
+ token_value TEXT NOT NULL,
330
+ created_at TIMESTAMPTZ DEFAULT now()
331
+ );
332
+ CREATE INDEX idx_brand_tokens_user ON brand_tokens(user_id, token_type);
333
+
334
+ -- Soft guidance — applied as LTR feature weights
335
+ CREATE TABLE soft_guidance (
336
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
337
+ user_id UUID NOT NULL REFERENCES app_users(id) ON DELETE CASCADE,
338
+ guidance_text TEXT NOT NULL,
339
+ weight_delta REAL NOT NULL DEFAULT 1.0, -- multiplier on relevant features
340
+ created_at TIMESTAMPTZ DEFAULT now()
341
+ );
342
+
343
+ -- Customer optimization goals for composite scoring
344
+ CREATE TABLE optimization_goals (
345
+ user_id UUID PRIMARY KEY REFERENCES app_users(id) ON DELETE CASCADE,
346
+ primary_metric TEXT NOT NULL DEFAULT 'cvr',
347
+ secondary_metric TEXT,
348
+ guardrail_json JSONB -- {"bounce_rate": {"max": 0.60}, "rage_click_rate": {"max": 0.05}}
349
+ );
350
+
351
+ -- Orpheus variant attribution — stored at generation time, links to existing experiments
352
+ CREATE TABLE orpheus_variants (
353
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
354
+ experiment_run_id UUID NOT NULL REFERENCES experiment_runs(id) ON DELETE CASCADE,
355
+ variant_id TEXT NOT NULL,
356
+ hypothesis TEXT NOT NULL, -- testable claim
357
+ attributes_json JSONB NOT NULL, -- full attribute dict
358
+ source_docs_json JSONB, -- [doc_id, ...] from retrieval
359
+ attribution_json JSONB, -- full attribution at generation time
360
+ was_selected BOOLEAN DEFAULT FALSE,
361
+ was_edited BOOLEAN DEFAULT FALSE,
362
+ edit_distance INTEGER DEFAULT 0,
363
+ edited_attrs_json JSONB, -- post-edit attributes (for attribution rule)
364
+ created_at TIMESTAMPTZ DEFAULT now()
365
+ );
366
+ CREATE INDEX idx_orpheus_variants_run ON orpheus_variants(experiment_run_id);
367
+
368
+ -- Pairwise LTR training data from selection events
369
+ -- selected_variant_id was preferred over each rejected_variant_id
370
+ CREATE TABLE selection_events (
371
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
372
+ experiment_run_id UUID NOT NULL REFERENCES experiment_runs(id) ON DELETE CASCADE,
373
+ user_id UUID NOT NULL REFERENCES app_users(id) ON DELETE CASCADE,
374
+ component_type TEXT,
375
+ page_context TEXT,
376
+ selected_variant_id TEXT NOT NULL,
377
+ rejected_variant_ids JSONB NOT NULL, -- JSON array of variant IDs
378
+ weight REAL DEFAULT 0.3, -- weaker than A/B result
379
+ recorded_at TIMESTAMPTZ DEFAULT now()
380
+ );
381
+
382
+ -- Edit events — domain-level preference signals (more granular than selection events)
383
+ -- edit_type: 'swap_doc' — user replaced the corpus doc for a domain
384
+ -- original_doc_id → replacement_doc_id (both known)
385
+ -- signal: negative label on original, positive on replacement
386
+ -- 'freeform' — user typed their own value, not in corpus
387
+ -- original_doc_id → NULL (corpus gap flagged)
388
+ -- signal: negative label on original, gap candidate logged
389
+ -- 'partial' — user kept the doc but tweaked a parameter (e.g. blur intensity)
390
+ -- signal: soft negative at 0.3× weight
391
+ CREATE TABLE edit_events (
392
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
393
+ experiment_run_id UUID NOT NULL REFERENCES experiment_runs(id) ON DELETE CASCADE,
394
+ user_id UUID NOT NULL REFERENCES app_users(id) ON DELETE CASCADE,
395
+ variant_id TEXT NOT NULL,
396
+ domain TEXT NOT NULL, -- 'style' | 'color' | 'typography' | 'landing' | 'ux'
397
+ edit_type TEXT NOT NULL, -- 'swap_doc' | 'freeform' | 'partial'
398
+ original_doc_id TEXT NOT NULL, -- corpus doc that was replaced
399
+ replacement_doc_id TEXT, -- NULL for freeform edits
400
+ original_attrs_json JSONB NOT NULL, -- full bundle before edit
401
+ edited_attrs_json JSONB NOT NULL, -- full bundle after edit
402
+ edit_distance INTEGER NOT NULL, -- total fields changed across all domains
403
+ confidence_weight REAL NOT NULL, -- 0.8 (≤2 edits), 0.4 (≤5), 0.1 (>5)
404
+ page_type TEXT,
405
+ industry TEXT,
406
+ recorded_at TIMESTAMPTZ DEFAULT now()
407
+ );
408
+
409
+ -- Auto-apply patterns: repeated edits of same attribute
410
+ CREATE TABLE auto_apply_patterns (
411
+ user_id UUID NOT NULL REFERENCES app_users(id) ON DELETE CASCADE,
412
+ attribute_key TEXT NOT NULL,
413
+ preferred_value TEXT NOT NULL,
414
+ frequency INTEGER NOT NULL,
415
+ last_seen TIMESTAMPTZ DEFAULT now(),
416
+ PRIMARY KEY (user_id, attribute_key)
417
+ );
418
+
419
+ -- Staged A/B results — composite scores per stage
420
+ -- Metrics themselves live in experiment_metrics (existing table)
421
+ -- This table stores the computed composite scores and winner determination
422
+ CREATE TABLE ab_results (
423
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
424
+ experiment_run_id UUID NOT NULL REFERENCES experiment_runs(id) ON DELETE CASCADE,
425
+ variant_id TEXT NOT NULL,
426
+ stage INTEGER NOT NULL DEFAULT 1, -- 1, 2, or 3
427
+ impressions INTEGER,
428
+ conversions INTEGER,
429
+ metrics_json JSONB, -- snapshot of all metrics for this stage
430
+ composite_score REAL, -- weighted composite across metrics
431
+ is_winner BOOLEAN DEFAULT FALSE,
432
+ test_confidence REAL,
433
+ recorded_at TIMESTAMPTZ DEFAULT now()
434
+ );
435
+
436
+ -- LTR training examples (derived from selection_events + ab_results)
437
+ CREATE TABLE ltr_training_examples (
438
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
439
+ user_id UUID NOT NULL REFERENCES app_users(id) ON DELETE CASCADE,
440
+ component_type TEXT,
441
+ page_context TEXT,
442
+ query TEXT,
443
+ context_features_json JSONB NOT NULL, -- all feature values for context
444
+ variant_features_json JSONB NOT NULL, -- all feature values for variant
445
+ label REAL NOT NULL, -- relevance score: 0.0 to 1.0
446
+ weight REAL DEFAULT 1.0,
447
+ source TEXT, -- 'selection' | 'ab_result_stage1' | 'ab_result_stage2' | 'ab_result_stage3'
448
+ created_at TIMESTAMPTZ DEFAULT now()
449
+ );
450
+ CREATE INDEX idx_ltr_user ON ltr_training_examples(user_id, component_type, page_context);
451
+ ```
452
+
453
+ **Key integration points with existing schema:**
454
+ - `experiment_runs.id` is the FK for all Orpheus tables (not a separate experiment system)
455
+ - Individual metrics flow through `experiment_metrics` (existing) → `ab_results` (new) computes composite scores
456
+ - `metric_definitions` (existing) is the extensible registry — new metrics = `INSERT INTO metric_definitions` + adapter, no migration
457
+ - `probat_events` (existing) captures SDK events — Orpheus reads from it for rage clicks, dead clicks, form abandonment
458
+ - `heatmap_clicks` (existing) feeds frustration signals into composite scoring
459
+
460
+ ### 5.4 ComposedVariant Dataclass
461
+
462
+ The `DesignVariant` name is retired. The composed bundle is now `ComposedVariant` — one instance per variant returned to the user. The individual retrieved doc is a `RetrievedDoc` (what the retrieval pipeline returns).
463
+
464
+ ```python
465
+ @dataclass
466
+ class RetrievedDoc:
467
+ """A single doc retrieved from ChromaDB. Output of RetrievalPipeline."""
468
+ doc_id: str
469
+ text: str
470
+ metadata: dict
471
+ embedding: list[float] # stored embedding from ChromaDB (no extra API call)
472
+ bm25_rank: int | None
473
+ dense_rank: int | None
474
+ rrf_score: float
475
+
476
+ @dataclass
477
+ class ComposedVariant:
478
+ """A fully assembled design variant bundle. Output of compose_variants()."""
479
+ variant_id: str
480
+ label: str # post-hoc strategy label: "minimal" | "bold" | "layered"
481
+ # assigned by nearest strategy embedding, NOT used for selection
482
+ hypothesis: str # human-readable testable claim derived from label
483
+
484
+ # One doc per domain — selected by ui-reasoning.csv anchor (V1) or max embedding distance (V2, V3)
485
+ docs: dict[str, RetrievedDoc] # domain → doc: style, color, typography, landing, ux
486
+
487
+ # Attribution — stored at generation time, never reconstructed later
488
+ source_doc_ids: list[str] # [doc_id for each domain]
489
+ selection_method: str # "ui_reasoning_anchor" | "max_distance_v2" | "max_distance_v3"
490
+ reasoning_rule_used: dict # the ui-reasoning.csv row that drove V1 (None for V2, V3)
491
+ retrieval_scores: dict # {doc_id: {bm25_rank, dense_rank, rrf_score}}
492
+ ltr_feature_importance: dict # {feature_name: weight} — populated after LTR built
493
+ contributing_experiments: list[dict] # [{experiment_id, customer, result, weight}] — post-LTR
494
+ corpus_basis: list[str] # human-readable: "Style: Minimalism — style_styles_0"
495
+ confidence: float # composite 0.0-1.0
496
+ low_confidence_flag: bool
497
+
498
+ # Constraint and auto-apply tracking
499
+ brand_applied: bool
500
+ auto_applied_attrs: list[str] # attributes auto-applied from user's edit patterns
501
+ flags: list[str] # warnings (forbidden style, low confidence, cold start, etc.)
502
+ ```
503
+
504
+ ---
505
+
506
+ ## 6. Retrieval Pipeline
507
+
508
+ ### 6.1 Stage 1 — Hybrid Recall
509
+
510
+ **BM25 (sparse retrieval):**
511
+ ```python
512
+ # rank_bm25, BM25Okapi, k1=1.5 b=0.75 (defaults)
513
+ # Index built from tokenized prose documents at server startup
514
+ # Corpus: ~2000 documents, fits in memory, rebuilds in <200ms
515
+
516
+ tokens = query.lower().split()
517
+ bm25_scores = bm25_index.get_scores(tokens)
518
+ bm25_results = top_100_by_score(bm25_scores) # (doc_id, score)
519
+ ```
520
+
521
+ **Dense retrieval:**
522
+ ```python
523
+ # ChromaDB with cosine similarity metric
524
+ # Query embedded with text-embedding-3-large (3072 dims)
525
+ # Pre-computed document embeddings stored in ChromaDB
526
+
527
+ query_embedding = openai.embed(query) # ~200ms
528
+ dense_results = chroma.query(
529
+ query_embeddings=[query_embedding],
530
+ n_results=100,
531
+ where={"domain": {"$in": relevant_domains}} # optional domain pre-filter
532
+ )
533
+ # Returns (doc_id, cosine_distance) → convert to (doc_id, 1 - distance)
534
+ ```
535
+
536
+ ### 6.2 Stage 2 — RRF Fusion
537
+
538
+ ```python
539
+ K = 60 # standard constant — dampens top-position advantage
540
+
541
+ def rrf(bm25_ranked, dense_ranked):
542
+ scores = {}
543
+ for rank, (doc_id, _) in enumerate(bm25_ranked, start=1):
544
+ scores[doc_id] = scores.get(doc_id, 0) + 1.0 / (K + rank)
545
+ for rank, (doc_id, _) in enumerate(dense_ranked, start=1):
546
+ scores[doc_id] = scores.get(doc_id, 0) + 1.0 / (K + rank)
547
+ return sorted(scores.items(), key=lambda x: x[1], reverse=True)
548
+
549
+ # A document ranked #1 by BM25 and #2 by dense scores:
550
+ # 1/(60+1) + 1/(60+2) = 0.01639 + 0.01613 = 0.03252
551
+ # Beats a document ranked #1 by only one method:
552
+ # 1/(60+1) + 0 = 0.01639
553
+ ```
554
+
555
+ Returns top 20 by RRF score.
556
+
557
+ ### 6.3 Stage 3 — Cohere Cross-Encoder Rerank
558
+
559
+ ```python
560
+ # Reads query + document together (cross-encoder)
561
+ # Qualitatively different from bi-encoder: understands specific phrase relationships
562
+ # Run on top 20 only — ~300ms, ~$0.001 per call
563
+
564
+ response = cohere.rerank(
565
+ model="rerank-v3.5",
566
+ query=query,
567
+ documents=[doc_texts[id] for id in top20_ids],
568
+ top_n=8
569
+ )
570
+ # Returns (doc_id, relevance_score) sorted descending
571
+ # Fallback: if Cohere unavailable, use RRF order[:8]
572
+ ```
573
+
574
+ Cache results by hash(query + doc_ids) with 5-minute TTL to reduce cost during development.
575
+
576
+ ### 6.4 Stage 4 — LTR Pre-filter (Individual Docs)
577
+
578
+ ```python
579
+ # Optional: LTR scores individual docs before strategy composition to boost candidate pool quality
580
+ # Lightweight pass — filters out docs with historically poor performance for this customer
581
+ # The main LTR re-ranking happens at Stage 7 on composed variant bundles
582
+
583
+ features = extract_features(query, candidate_doc, customer_context)
584
+ ltr_scores = ltr_model.predict(features) # per-customer model or global
585
+ # Remove bottom 25% by LTR score — don't compose with docs that have consistently lost
586
+ candidates = [doc for doc in candidates if ltr_scores[doc.id] > percentile_25]
587
+ ```
588
+
589
+ ### 6.5 Stage 5 — MMR Diversity Selection
590
+
591
+ ```python
592
+ # Maximal Marginal Relevance
593
+ # λ=0.5 balances relevance vs diversity
594
+ # Similarity computed on embedding vectors (already in memory from dense retrieval)
595
+
596
+ def mmr(candidates, embeddings, n, lambda_=0.5):
597
+ selected = []
598
+ remaining = list(candidates)
599
+
600
+ for _ in range(n):
601
+ best, best_score = None, float('-inf')
602
+ for doc_id, relevance in remaining:
603
+ if not selected:
604
+ score = relevance
605
+ else:
606
+ max_sim = max(cosine(embeddings[doc_id], embeddings[s])
607
+ for s in selected)
608
+ score = lambda_ * relevance - (1 - lambda_) * max_sim
609
+ if score > best_score:
610
+ best, best_score = doc_id, score
611
+ selected.append(best)
612
+ remaining = [(d, r) for d, r in remaining if d != best]
613
+
614
+ return selected
615
+ ```
616
+
617
+ `λ=1.0` → pure relevance (top-k). `λ=0.0` → pure diversity. `λ=0.5` is the default. Expose as a parameter — a customer requesting 2 variants may want higher relevance (λ=0.7), a customer requesting 5 wants more diversity (λ=0.4).
618
+
619
+ ### 6.6 Stage 6 — Corpus-Anchored Composition
620
+
621
+ The retrieval pipeline (stages 1-5) provides the candidate pool. This stage selects one doc per domain per variant, producing three bundles that are guaranteed to differ visually — without pre-defining which directions to explore.
622
+
623
+ #### 6.6.1 Why Not Pre-Defined Strategy Buckets
624
+
625
+ The previous approach used hardcoded strategies (trust/urgency/simplicity) to bias retrieval, with a `STRATEGY_APPLICABILITY` lookup table mapping page context to strategy names. This has two critical failure modes:
626
+
627
+ 1. **Convergence** — "trust" and "simplicity" both target Minimalism docs. In a corpus with 86 style docs, two strategies frequently retrieve the same top doc. The `select_best_match` fallback doesn't prevent visually similar results.
628
+
629
+ 2. **Pigeonholing** — For fintech checkout, "bold" may be actively wrong. Pre-assigning strategies from a lookup table generates variants a designer would never ship, wasting A/B test budget on directions the corpus itself knows are inappropriate.
630
+
631
+ The correct mental model: strategies are a *label* for what you found, not a *constraint* on what you look for.
632
+
633
+ #### 6.6.2 The Anchor: `ui_reasoning` Corpus Domain
634
+
635
+ `ui-reasoning.csv` (161 rows, one per product/page category) is serialized into 161 prose docs and embedded into ChromaDB alongside all other corpus domains. Each doc looks like:
636
+
637
+ ```
638
+ UI Reasoning: Fintech/Crypto. Style Priority: Minimalism + Accessible & Ethical.
639
+ Color Mood: Navy + Trust Blue + Gold. Typography Mood: Professional + Trustworthy.
640
+ Recommended Pattern: Trust & Authority. Key Effects: Smooth state transitions.
641
+ ```
642
+
643
+ Finding the right rule is a **retrieval problem**, not a string matching problem. `select_variant_1` calls `pipeline.query_with_embedding(domain_filter="ui_reasoning", n_results=1)` — the same BM25 + dense pipeline used for every other domain. The top result is the best matching rule for this query. Its prose text is then parsed to extract `Style Priority`, `Color Mood`, `Typography Mood` etc., which become the keyword hints for `_select_best_match`.
644
+
645
+ **Why retrieval over CSV string matching:** The old approach used exact/partial/keyword string matching on `UI_Category` (e.g. "B2B SaaS checkout"). This is fragile — it misses semantic equivalents ("enterprise software" doesn't match "SaaS (General)"). The retrieval pipeline handles this naturally: "enterprise software checkout" embeds close to "SaaS (General)" in vector space and BM25 catches lexical overlap. The same system that finds the best style doc finds the best rule doc.
646
+
647
+ ```python
648
+ def select_variant_1(candidates_per_domain, product_type, page_context, pipeline, embedding):
649
+ # Find best matching rule via retrieval — not string matching
650
+ rule_candidates = pipeline.query_with_embedding(
651
+ f"{product_type} {page_context}",
652
+ embedding, # shared embedding, no extra OpenAI call
653
+ domain_filter="ui_reasoning",
654
+ n_results=1,
655
+ )
656
+ rule_fields = _parse_rule_doc(rule_candidates[0].text)
657
+ # rule_fields = {"Style Priority": "Minimalism + Flat Design", "Color Mood": "Trust blue + Accent contrast", ...}
658
+
659
+ docs = {}
660
+ for domain, candidates in candidates_per_domain.items():
661
+ keywords = _extract_keywords_from_rule(rule_fields, domain)
662
+ docs[domain] = _select_best_match(candidates, keywords)
663
+ # _select_best_match: +10 metadata style_category match, +3 any metadata, +1 prose text
664
+ return docs, rule_fields
665
+ ```
666
+
667
+ #### 6.6.3 Variants 2 and 3: Maximum Embedding Distance
668
+
669
+ After Variant 1 claims its docs, Variants 2 and 3 are chosen to maximize visual diversity. "Diversity" is defined in embedding space — not by category name or keyword. A doc is diverse if it is semantically far from already-chosen docs.
670
+
671
+ ```python
672
+ def select_by_max_distance(candidates, chosen_docs, used_doc_ids):
673
+ """
674
+ For each domain, pick the candidate most distant in embedding space
675
+ from all already-chosen docs. Enforces uniqueness across variants.
676
+ """
677
+ docs = {}
678
+ for domain, domain_candidates in candidates.items():
679
+ chosen_emb = chosen_docs[domain].embedding # from ChromaDB
680
+
681
+ best_doc, best_dist = None, -1.0
682
+ for c in domain_candidates:
683
+ if c.doc_id in used_doc_ids:
684
+ continue
685
+ dist = 1.0 - cosine_similarity(c.embedding, chosen_emb)
686
+ if dist > best_dist:
687
+ best_dist = dist
688
+ best_doc = c
689
+
690
+ docs[domain] = best_doc
691
+ used_doc_ids.add(best_doc.doc_id)
692
+ return docs
693
+
694
+ def compose_variants(query, product_type, page_context, customer_context):
695
+ # Step 1: retrieve top 8 per domain (one shared embedding)
696
+ embedding = pipeline._embed(query)
697
+ candidates = {
698
+ domain: pipeline.query_with_embedding(query, embedding, domain_filter=domain, n_results=8)
699
+ for domain in ["style", "color", "typography", "landing", "ux"]
700
+ }
701
+
702
+ used_doc_ids = set()
703
+
704
+ # Step 2: Variant 1 — ui-reasoning.csv anchor
705
+ v1_docs = select_variant_1(candidates, product_type, page_context)
706
+ used_doc_ids.update(d.doc_id for d in v1_docs.values())
707
+
708
+ # Step 3: Variant 2 — max distance from V1
709
+ v2_docs = select_by_max_distance(candidates, v1_docs, used_doc_ids)
710
+ used_doc_ids.update(d.doc_id for d in v2_docs.values())
711
+
712
+ # Step 4: Variant 3 — max distance from V1 and V2 combined
713
+ combined_chosen = {
714
+ domain: [v1_docs[domain], v2_docs[domain]] for domain in candidates
715
+ }
716
+ v3_docs = select_by_max_distance_from_multiple(candidates, combined_chosen, used_doc_ids)
717
+
718
+ # Step 5: Apply brand constraints, assign post-hoc labels, build attribution
719
+ variants = []
720
+ for docs in [v1_docs, v2_docs, v3_docs]:
721
+ docs = apply_brand_constraints(customer_context, docs)
722
+ label = assign_strategy_label(docs) # nearest strategy embedding
723
+ variants.append(ComposedVariant(
724
+ label=label,
725
+ hypothesis=STRATEGY_HYPOTHESES[label],
726
+ docs=docs,
727
+ source_doc_ids=[d.doc_id for d in docs.values()],
728
+ ))
729
+
730
+ return variants
731
+ ```
732
+
733
+ **Example output:**
734
+
735
+ ```
736
+ Query: "checkout button for B2B fintech"
737
+ ui-reasoning.csv match: "Fintech/Crypto" → Style: Minimalism + Accessible & Ethical,
738
+ Color: Navy + Trust Blue + Gold
739
+
740
+ Variant 1 (label: "minimal", assigned post-hoc):
741
+ Style: Minimalism (Flat Design) ← closest to reasoning keywords
742
+ Color: Navy + Trust blue ← closest to "Navy + Trust Blue + Gold"
743
+ Typography: Professional, clear, 16px ← closest to "Professional + Trustworthy"
744
+ Layout: Hero + Features + Trust CTA ← closest to "Trust & Authority" pattern
745
+ UX: Security badges, SOC2 ← closest to trust-focused UX guidelines
746
+
747
+ Variant 2 (label: "layered", assigned post-hoc):
748
+ Style: Glassmorphism ← most distant from Minimalism in embedding space
749
+ Color: Gradient aurora + accent ← most distant from Navy + Trust Blue
750
+ Typography: Modern geometric ← most distant from "clear readable"
751
+ Layout: Card-based layered sections ← most distant from linear feature list
752
+ UX: Animated transitions ← most distant from static trust signals
753
+
754
+ Variant 3 (label: "bold", assigned post-hoc):
755
+ Style: Vibrant & Block-based ← most distant from both V1 and V2
756
+ Color: High contrast primary ← most distant from both prior colors
757
+ Typography: Bold impactful headline ← most distant from both prior typography
758
+ Layout: Above-fold CTA, sticky ← most distant from both prior layouts
759
+ UX: Single action, urgency cue ← most distant from both prior UX docs
760
+ ```
761
+
762
+ All three variants are fully attributed. V1 is grounded in expert knowledge for this context. V2 and V3 are the corpus's answer to "what's most different?" — not a hardcoded bucket.
763
+
764
+ #### 6.6.4 Post-Hoc Strategy Labels
765
+
766
+ Strategy labels exist for human readability and hypothesis writing — not for retrieval. After composition, each bundle is labeled by finding the nearest strategy profile in embedding space:
767
+
768
+ ```python
769
+ STRATEGY_PROFILES = {
770
+ "minimal": "clean whitespace minimalism flat design system fonts single CTA",
771
+ "bold": "vibrant high contrast motion-driven energetic warm colors bold typography",
772
+ "layered": "glassmorphism depth frosted gradient modern geometric premium craft",
773
+ }
774
+
775
+ # Pre-compute at startup (3 OpenAI calls, cached forever)
776
+ STRATEGY_EMBEDDINGS = {k: embed(v) for k, v in STRATEGY_PROFILES.items()}
777
+
778
+ STRATEGY_HYPOTHESES = {
779
+ "minimal": "Reducing cognitive load and distraction increases conversion — single focal point, maximum whitespace, one action.",
780
+ "bold": "High visual energy and contrast drives immediate action — warm palette, impactful type, above-fold CTA.",
781
+ "layered": "Premium visual craft signals quality and builds trust — depth, gradients, and refined type create a memorable first impression.",
782
+ }
783
+
784
+ def assign_strategy_label(docs):
785
+ bundle_text = " ".join(d.text for d in docs.values())
786
+ bundle_emb = embed(bundle_text)
787
+ return max(STRATEGY_EMBEDDINGS, key=lambda k: cosine_similarity(bundle_emb, STRATEGY_EMBEDDINGS[k]))
788
+ ```
789
+
790
+ The label tells the user *what direction this variant represents*. It does not determine what docs were selected.
791
+
792
+ #### 6.6.5 Adaptation Over Time
793
+
794
+ On Day 0, Variant 1 is pure `ui-reasoning.csv` and Variants 2–3 are pure embedding distance. As selection and edit data accumulates, the selection step blends in win rates:
795
+
796
+ ```python
797
+ # After enough data (≥20 selection events for this context):
798
+ score = distance_from_chosen * 0.6 + doc.win_rate * 0.4
799
+ ```
800
+
801
+ Docs that have appeared in winning bundles get a preference boost while diversity is still enforced. Once LTR has ≥30 labeled examples, it takes over re-ranking entirely. The corpus-anchored composition stays fixed — LTR operates on the output order, not the selection logic.
802
+
803
+ #### 6.6.6 Why Not LLM Composition
804
+
805
+ | | LLM composition | Corpus-anchored composition |
806
+ |---|---|---|
807
+ | Fake diversity | Common ("clean" vs "minimal") | Impossible — enforced by embedding distance |
808
+ | Incoherent bundles | Possible (brutalist + glassmorphism) | Prevented — each doc is independently the best match for its domain |
809
+ | Hallucination | LLM can invent patterns not in corpus | Can't — only selects from retrieved candidates |
810
+ | Vague hypotheses | "this is more engaging" | Pre-written per strategy label, specific and testable |
811
+ | Speed | 1-3s (LLM call) | <200ms (5 domain searches + distance scoring) |
812
+ | Cost per call | $0.01-0.05 | ~$0.001 (1 embedding call) |
813
+ | Deterministic | No | Yes — same input, same output |
814
+ | Attribution | Hard (LLM may ignore docs) | Automatic — every doc maps to a retrieval score |
815
+
816
+ **Role of LLM:** Used downstream to **generate code/markup** for each variant (React, Tailwind, etc.), but the design decisions — which style, colors, layout — are deterministic.
817
+
818
+ ### 6.7 Stage 7 — LTR Re-ranking of Composed Variants
819
+
820
+ After composition, LTR re-ranks the composed variant bundles (not individual docs) by predicted win probability. Features are extracted per-bundle:
821
+
822
+ ```python
823
+ def extract_bundle_features(variant_bundle, customer_id, component_type, page_context):
824
+ return {
825
+ # Aggregate retrieval quality across source docs
826
+ "avg_cohere_score": mean([doc.cohere_score for doc in variant_bundle.source_docs]),
827
+ "min_cohere_score": min([doc.cohere_score for doc in variant_bundle.source_docs]),
828
+
829
+ # Label-level signals (strategy label is post-hoc — treated as a feature, not a constraint)
830
+ "strategy_label": variant_bundle.label, # "minimal" / "bold" / "layered"
831
+ "global_label_win_rate": float, # cross-customer win rate for this label
832
+
833
+ # Attribute-level signals (aggregated across the bundle's source docs)
834
+ "style_doc_win_rate": float, # global_win_rate from ChromaDB metadata
835
+ "color_doc_win_rate": float,
836
+ "typo_doc_win_rate": float,
837
+ "style_color_combo_win_rate": float, # how often this style+color pairing wins globally
838
+ "avg_doc_win_rate": float, # mean across all 5 domain docs
839
+
840
+ # Context match
841
+ "component_type_match": bool, # do source docs target this component type?
842
+ "page_context_match": bool,
843
+ }
844
+ ```
845
+
846
+ LTR scores full variant bundles, not individual building blocks. This means LTR can learn "urgency strategies win on checkout pages for this customer" and push urgency-composed variants higher.
847
+
848
+ ---
849
+
850
+ ## 7. Learning-to-Rank System
851
+
852
+ ### 7.1 Feature Engineering
853
+
854
+ Features per (context, candidate) pair:
855
+
856
+ ```python
857
+ def extract_features(query, candidate_doc, customer_id, component_type, page_context):
858
+ return {
859
+ # Retrieval quality signals
860
+ "bm25_score": float, # raw BM25 score
861
+ "dense_score": float, # cosine similarity
862
+ "rrf_score": float, # RRF merged score
863
+ "cohere_score": float, # cross-encoder relevance
864
+
865
+ # Document attributes (categorical → one-hot or embedding)
866
+ "domain": str, # style / color / typography / etc.
867
+ "style_category": str, # Glassmorphism / Minimalism / etc.
868
+ "complexity": str, # Low / Medium / High
869
+ "performance": str,
870
+ "accessibility": str,
871
+
872
+ # Customer historical signals for this document's attributes
873
+ "customer_style_win_rate": float, # historical win rate for this style
874
+ "customer_style_appearances": int, # how many times tested
875
+ "customer_style_confidence": float, # decayed confidence score
876
+
877
+ # Context match signals
878
+ "component_type_match": float, # did this doc appear in experiments with same component_type?
879
+ "page_context_match": float, # same for page_context
880
+ "query_domain_match": float, # does doc domain match detected query domain?
881
+
882
+ # Global signals
883
+ "global_win_rate": float, # win rate across all customers
884
+ "global_appearances": int,
885
+
886
+ # Temporal
887
+ "days_since_last_win": float, # recency of wins
888
+ }
889
+ ```
890
+
891
+ ### 7.2 Training Data Format
892
+
893
+ ```python
894
+ # Pairwise format for LambdaRank
895
+ # group = all candidates for one query/context
896
+ # label = relevance score (higher = more relevant/preferred)
897
+
898
+ # From selection event: customer picked B over A
899
+ # → B gets label 1.0, each rejected variant gets label 0.0
900
+ # → weight = 0.3 (selection is weaker signal than A/B result)
901
+
902
+ # From A/B result (winner):
903
+ # → winning variant: label = 1.0 * test_confidence
904
+ # → losing variant: label = 0.0
905
+ # → weight = normalized_cvr_lift * test_confidence (typically 0.5-1.0)
906
+
907
+ # From A/B result (staged):
908
+ # Stage 1 (day 0): immediate metrics, weight_multiplier = 0.6
909
+ # Stage 2 (day 7): 7-day metrics, weight_multiplier = 0.25
910
+ # Stage 3 (day 30): 30-day metrics, weight_multiplier = 0.15
911
+
912
+ import lightgbm as lgb
913
+
914
+ train_data = lgb.Dataset(
915
+ X, # feature matrix (n_examples × n_features)
916
+ label=y, # relevance labels
917
+ group=groups, # query groups (how many examples per query)
918
+ weight=w, # per-example weights
919
+ )
920
+
921
+ model = lgb.train(
922
+ params={
923
+ "objective": "lambdarank",
924
+ "metric": "ndcg",
925
+ "ndcg_eval_at": [3, 5],
926
+ "num_leaves": 31,
927
+ "learning_rate": 0.05,
928
+ },
929
+ train_set=train_data,
930
+ num_boost_round=100,
931
+ )
932
+ ```
933
+
934
+ ### 7.3 Model Architecture
935
+
936
+ **Per-customer vs global:**
937
+
938
+ - New customer (< 20 training examples): use global model, no per-customer model
939
+ - Growing customer (20-100 examples): global model with customer feature vector appended
940
+ - Mature customer (> 100 examples): customer-specific model fine-tuned from global
941
+
942
+ **Retraining cadence:** nightly batch job. LambdaRank on ~2000 examples trains in seconds. No streaming update needed.
943
+
944
+ **Cold start (global baseline):**
945
+ ```python
946
+ # Global model trained on all customers' data combined
947
+ # Customer features zeroed out for new customers
948
+ # Produces recommendations informed by aggregate patterns
949
+ # Gradually shifts to customer-specific as data accumulates
950
+ ```
951
+
952
+ ### 7.4 Hierarchical Fallback
953
+
954
+ When a customer-specific model doesn't have enough data for a specific context:
955
+
956
+ ```
957
+ customer + component_type + page_context (min 20 examples)
958
+ → customer + component_type (min 10 examples)
959
+ → customer only (min 5 examples)
960
+ → global baseline
961
+ ```
962
+
963
+ If a customer has strong signal for "checkout CTA" but nothing for "pricing hero", the pricing hero query uses global model while checkout CTA uses customer-specific.
964
+
965
+ ### 7.5 Confidence Decay
966
+
967
+ ```python
968
+ import math
969
+
970
+ HALF_LIFE_DAYS = 90.0
971
+
972
+ def decayed_label(original_label, days_since_experiment):
973
+ decay = math.pow(0.5, days_since_experiment / HALF_LIFE_DAYS)
974
+ return original_label * decay
975
+
976
+ # A 90-day-old experiment contributes 50% of its original weight
977
+ # A 180-day-old experiment contributes 25%
978
+ # Prevents stale signals from dominating after rebrand or strategy shift
979
+ ```
980
+
981
+ ---
982
+
983
+ ## 8. Attribution System
984
+
985
+ ### 8.1 What Gets Stored
986
+
987
+ At generation time, before returning variants to the user:
988
+
989
+ ```python
990
+ attribution = {
991
+ "retrieval": {
992
+ "bm25_rank": {doc_id: rank for each candidate},
993
+ "dense_rank": {doc_id: rank for each candidate},
994
+ "rrf_score": {doc_id: score},
995
+ "cohere_score": {doc_id: score},
996
+ },
997
+ "ltr": {
998
+ "model_version": "2026-03-19-acme-corp",
999
+ "feature_importance": {
1000
+ "customer_style_win_rate": 0.28,
1001
+ "cohere_score": 0.22,
1002
+ "component_type_match": 0.18,
1003
+ "global_win_rate": 0.12,
1004
+ # ...
1005
+ },
1006
+ "top_contributing_experiments": [
1007
+ {
1008
+ "experiment_id": "exp-291",
1009
+ "customer_id": "acme-corp", # anonymized for other customers
1010
+ "component_type": "cta-button",
1011
+ "page_context": "checkout",
1012
+ "result": "+23% CVR",
1013
+ "style": "minimalism",
1014
+ "weight_contributed": 0.18,
1015
+ "days_ago": 14
1016
+ },
1017
+ # up to 5 experiments
1018
+ ],
1019
+ "selection_contributions": 8, # how many selection events trained this
1020
+ },
1021
+ "corpus_basis": [
1022
+ "Minimalism: clean whitespace, no decorative elements, high contrast CTA",
1023
+ "Checkout patterns: single primary action, trust signals near CTA",
1024
+ "B2B SaaS: professional typography, data-forward layout",
1025
+ ],
1026
+ "confidence": 0.84,
1027
+ "low_confidence_flag": False,
1028
+ "cold_start_flag": False,
1029
+ }
1030
+ ```
1031
+
1032
+ ### 8.2 User-Facing Explanation
1033
+
1034
+ ```
1035
+ Variant 1 — Label: minimal (ui-reasoning.csv anchor for "Fintech/Crypto + checkout")
1036
+ Confidence: 84%
1037
+
1038
+ Hypothesis: Reducing cognitive load and distraction increases conversion — single
1039
+ focal point, maximum whitespace, one action. Grounded in expert recommendation for
1040
+ fintech checkout contexts.
1041
+
1042
+ Why this combination was recommended:
1043
+ • Variant 1 is anchored on ui-reasoning.csv row "Fintech/Crypto":
1044
+ Style_Priority=Minimalism+Accessible & Ethical, Color_Mood=Navy+Trust Blue+Gold
1045
+ • Minimalism style doc selected 8 times over alternatives in similar fintech contexts
1046
+ • Experiment exp-291 (+23% CVR, checkout CTA, 14 days ago) used the same style doc
1047
+ • Navy+Trust Blue color palette has 67% win rate on B2B fintech checkout globally
1048
+ • LTR model weights: doc win rate (28%), combo win rate (22%), context match (18%)
1049
+
1050
+ Design basis (corpus-anchored selection):
1051
+ • Style: Minimalism (Accessible & Ethical) — style_styles_0 — closest to ui-reasoning keywords
1052
+ • Color: Navy + Trust Blue + Gold — color_colors_14 — closest to "Navy + Trust Blue + Gold"
1053
+ • Typography: Professional, clear hierarchy — typography_typography_3 — closest to "Professional + Trustworthy"
1054
+ • Layout: Hero + Features + Trust CTA — landing_landing_2 — closest to "Trust & Authority" pattern
1055
+ • UX: Security badges, SOC2, low-commitment CTA — ux_ux-guidelines_11
1056
+
1057
+ ⚠ Animation was auto-adjusted to 'none' based on your 4 previous swap_doc edits.
1058
+ ```
1059
+
1060
+ ### 8.3 Referenceability for Single Mode
1061
+
1062
+ When `mode="single"`, use Wilson lower bound to pick the most conservative confident recommendation:
1063
+
1064
+ ```python
1065
+ from scipy.stats import beta as beta_dist
1066
+
1067
+ def wilson_lower_bound(wins, total, confidence=0.95):
1068
+ if total == 0:
1069
+ return 0.0
1070
+ # Lower bound of Beta credible interval
1071
+ alpha = 1 - confidence
1072
+ return beta_dist.ppf(alpha / 2, wins + 1, total - wins + 1)
1073
+
1074
+ # Combine with LTR score
1075
+ # final = 0.7 * ltr_score + 0.3 * wilson_lower_bound(wins, total)
1076
+ # Wilson penalizes high win rates based on tiny samples
1077
+ ```
1078
+
1079
+ ---
1080
+
1081
+ ## 9. Human-in-the-Loop Flow
1082
+
1083
+ ### 9.1 Selection Signal
1084
+
1085
+ ```
1086
+ Customer receives 3 variants: A (Glassmorphism), B (Minimalism), C (Flat Design)
1087
+ Customer picks B
1088
+
1089
+ → log_selection(selected=B, rejected=[A, C])
1090
+
1091
+ Pairwise training examples created:
1092
+ (context, B_features, A_features) → B > A, weight=0.3
1093
+ (context, B_features, C_features) → B > C, weight=0.3
1094
+
1095
+ Written to ltr_training_examples table
1096
+ Included in next nightly retraining
1097
+ ```
1098
+
1099
+ ### 9.2 Edit Signal
1100
+
1101
+ Edit events are **more granular than selection events** — they produce domain-level pairwise labels, not just bundle-level ones. Three edit types carry different signal weight.
1102
+
1103
+ **Type 1: swap_doc** — user replaced the corpus doc for a specific domain (cleanest signal)
1104
+ ```
1105
+ Customer picks Variant 1 (Minimalism bundle) but swaps the color doc:
1106
+ domain: "color"
1107
+ original_doc_id: "color_colors_4" (Navy + Trust Blue)
1108
+ replacement_doc_id: "color_colors_22" (Vibrant gradient)
1109
+
1110
+ → log_edit(domain="color", edit_type="swap_doc",
1111
+ original_doc_id="color_colors_4",
1112
+ replacement_doc_id="color_colors_22")
1113
+
1114
+ Domain-level pairwise labels created:
1115
+ (context, domain=color, replacement_doc) > (context, domain=color, original_doc)
1116
+ weight = 0.8 (if total edit_distance ≤ 2)
1117
+
1118
+ Corpus doc win rates updated:
1119
+ color_colors_22.global_win_rate ++
1120
+ color_colors_4.global_win_rate not credited (was rejected)
1121
+ ```
1122
+
1123
+ **Type 2: freeform** — user typed their own value, not in corpus (corpus gap signal)
1124
+ ```
1125
+ Customer edits color to #1A3A5C — not in any corpus doc
1126
+
1127
+ → log_edit(domain="color", edit_type="freeform",
1128
+ original_doc_id="color_colors_4",
1129
+ replacement_doc_id=NULL)
1130
+
1131
+ Signals written:
1132
+ Negative label on color_colors_4 for this context
1133
+ Gap candidate logged: {domain: "color", page_type: "checkout",
1134
+ industry: "fintech", value: "#1A3A5C", count: 1}
1135
+
1136
+ Nightly job: if gap candidate appears ≥3 times across different users
1137
+ → flag as corpus gap for human review
1138
+ → if confirmed, add new corpus row and re-embed
1139
+ ```
1140
+
1141
+ **Type 3: partial** — user kept the doc but tweaked a parameter (soft negative)
1142
+ ```
1143
+ Customer keeps Glassmorphism style but reduces blur intensity
1144
+
1145
+ → log_edit(domain="style", edit_type="partial",
1146
+ original_doc_id="style_styles_7",
1147
+ replacement_doc_id="style_styles_7") # same doc
1148
+
1149
+ Signals written:
1150
+ Soft negative on style_styles_7 at 0.3× weight
1151
+ (doc was right direction, not perfectly calibrated)
1152
+ ```
1153
+
1154
+ **Auto-apply patterns** (unchanged from before):
1155
+ ```
1156
+ edit_events table updated
1157
+ auto_apply_patterns checked:
1158
+ animation edits for acme-corp: 4 times → add to auto_apply_patterns
1159
+ border_radius edits: 2 times → not yet (threshold = 3)
1160
+ ```
1161
+
1162
+ ### 9.3 A/B Result Attribution Rule
1163
+
1164
+ **Critical:** always use the final edited attributes for signal attribution, never the original recommendation.
1165
+
1166
+ ```python
1167
+ # Wrong — credits recommendation that wasn't actually tested
1168
+ ingest_ab_result(variant_attributes=original_recommendation_attrs, winner=True)
1169
+
1170
+ # Correct — credits what was actually in production
1171
+ ingest_ab_result(variant_attributes=final_edited_attrs, winner=True)
1172
+ ```
1173
+
1174
+ This prevents false credit accumulation. A customer who completely redesigns a variant (edit_distance > 5) before testing — and it wins — should not significantly boost your recommendation's signal. The `confidence_weight` handles this:
1175
+
1176
+ ```python
1177
+ weight = normalized_cvr_lift * test_confidence * edit_confidence_weight(edit_distance)
1178
+ # edit_confidence_weight: ≤2 edits=0.8, ≤5=0.4, >5=0.1
1179
+ ```
1180
+
1181
+ ---
1182
+
1183
+ ## 10. Multi-Metric Ingestion
1184
+
1185
+ ### 10.1 Staged Approach
1186
+
1187
+ ```
1188
+ Day 0 — Stage 1 (60% of final signal):
1189
+ Metrics: CVR, bounce_rate, scroll_depth, rage_click_rate
1190
+ Available immediately from PostHog/GA4
1191
+ Composite score computed, written to ltr_training_examples at 0.6× weight
1192
+ Stages 2 and 3 scheduled
1193
+
1194
+ Day 7 — Stage 2 (25% of final signal):
1195
+ Metrics: return_7d, activation_rate, support_ticket_rate
1196
+ Check: does stage 2 direction match stage 1?
1197
+ If reversal detected: write correction, flag for review
1198
+ Written at 0.25× weight
1199
+
1200
+ Day 30 — Stage 3 (15% of final signal):
1201
+ Metrics: retention_d30, feature_adoption
1202
+ Final signal written at 0.15× weight
1203
+ Experiment marked fully_ingested = 1
1204
+ ```
1205
+
1206
+ ### 10.2 Composite Score
1207
+
1208
+ ```python
1209
+ PAGE_INTENT = {
1210
+ "checkout": "conversion", # lower time_to_convert = better
1211
+ "landing": "conversion",
1212
+ "pricing": "conversion",
1213
+ "dashboard": "efficiency", # lower task_completion_time = better
1214
+ "onboarding": "engagement", # higher time on page = better (up to a point)
1215
+ "docs": "engagement",
1216
+ }
1217
+
1218
+ ### Extensible Metric Registry (Critical Design Principle)
1219
+
1220
+ **Never hardcode metric names into composite score, LTR features, or any downstream logic.**
1221
+ All metric handling must loop over the registry — nothing in the system should know specific
1222
+ metric names. This ensures new metrics from new data sources can be added with zero code changes.
1223
+
1224
+ ```python
1225
+ # ──────────────────────────────────────────────────────────────
1226
+ # METRIC_REGISTRY is the single source of truth for all metrics.
1227
+ # Adding a new metric from ANY source = one dict entry + one adapter.
1228
+ # No migrations, no composite score changes, no LTR pipeline changes.
1229
+ # ──────────────────────────────────────────────────────────────
1230
+
1231
+ METRIC_REGISTRY = {
1232
+ # Stage 1 — Day 0
1233
+ "cvr": {"weight": +0.30, "direction": "up", "stage": 1, "source": "posthog"},
1234
+ "bounce_rate": {"weight": -0.15, "direction": "down", "stage": 1, "source": "posthog"},
1235
+ "scroll_depth": {"weight": +0.05, "direction": "up", "stage": 1, "source": "posthog"},
1236
+ "rage_click_rate": {"weight": -0.10, "direction": "down", "stage": 1, "source": "posthog"},
1237
+
1238
+ # Stage 2 — Day 7
1239
+ "return_7d": {"weight": +0.20, "direction": "up", "stage": 2, "source": "posthog"},
1240
+ "activation_rate": {"weight": +0.15, "direction": "up", "stage": 2, "source": "posthog"},
1241
+
1242
+ # Stage 3 — Day 30
1243
+ "retention_d30": {"weight": +0.15, "direction": "up", "stage": 3, "source": "posthog"},
1244
+ "feature_adoption": {"weight": +0.10, "direction": "up", "stage": 3, "source": "posthog"},
1245
+
1246
+ # ── Future sources: just add rows ──
1247
+ # "nps_score": {"weight": +0.10, "direction": "up", "stage": 2, "source": "delighted"},
1248
+ # "support_tickets": {"weight": -0.08, "direction": "down", "stage": 2, "source": "intercom"},
1249
+ # "mrr_expansion": {"weight": +0.20, "direction": "up", "stage": 3, "source": "stripe"},
1250
+ }
1251
+ ```
1252
+
1253
+ **To add a new metric from a new data source, you do exactly 3 things:**
1254
+
1255
+ 1. **Add a source adapter** — one class that fetches the metric value:
1256
+ ```python
1257
+ class IntercomAdapter:
1258
+ """Each source adapter implements one method."""
1259
+ def get_metrics(self, experiment_id: str, date_range: tuple) -> dict:
1260
+ # Returns: {"support_tickets": 14}
1261
+ # Keys must match METRIC_REGISTRY keys
1262
+ tickets = self.client.conversations.search(experiment_id, date_range)
1263
+ return {"support_tickets": len(tickets)}
1264
+ ```
1265
+
1266
+ 2. **Add a row to METRIC_REGISTRY** — weight, direction, stage, source name.
1267
+
1268
+ 3. **Nothing else.** Everything downstream reads from the registry:
1269
+
1270
+ ```python
1271
+ def composite_score(metrics: dict, baseline_metrics: dict, page_intent: str) -> float:
1272
+ """Loops over registry — never references specific metric names."""
1273
+ score = 0.0
1274
+ intent_filter = PAGE_INTENT_METRIC_FILTER.get(page_intent, list(METRIC_REGISTRY.keys()))
1275
+
1276
+ for metric_name, config in METRIC_REGISTRY.items():
1277
+ if metric_name not in intent_filter:
1278
+ continue
1279
+ value = metrics.get(metric_name)
1280
+ baseline = baseline_metrics.get(metric_name)
1281
+ if value is None or baseline is None or baseline == 0:
1282
+ continue
1283
+
1284
+ normalized = (value - baseline) / abs(baseline)
1285
+ normalized = max(-1.0, min(1.0, normalized))
1286
+
1287
+ # Flip sign for "down" metrics (lower = better)
1288
+ if config["direction"] == "down":
1289
+ normalized *= -1
1290
+
1291
+ score += config["weight"] * normalized
1292
+ return score
1293
+ ```
1294
+
1295
+ **Why this matters for LTR:** New metrics become new features in training data automatically. LightGBM handles missing features natively (splits on available data only), so old training examples without the new metric remain valid. The model learns the new signal's importance as data accumulates — no retraining pipeline changes.
1296
+
1297
+ **Why `metrics_json` in signals.db is a JSON blob, not fixed columns:** Adding a new metric never requires a database migration. The schema `metrics_json TEXT` stores `{"cvr": 0.12, "bounce_rate": 0.45, "support_tickets": 3}` — any key-value pair, any source, any time.
1298
+ ```
1299
+
1300
+ ### 10.3 Extended SDK Metric Registry
1301
+
1302
+ The full set of signals available across PostHog and GA4, organized by availability and what they measure. Not all metrics are used in every composite score — each has a `page_intent` scope that controls when it's relevant.
1303
+
1304
+ #### Interaction Quality (Stage 1 — Day 0)
1305
+
1306
+ | Metric | Source | Signal Type | Page Intent | Weight |
1307
+ |--------|--------|-------------|-------------|--------|
1308
+ | `cvr` | PostHog/GA4 | Primary outcome | conversion | +0.30 |
1309
+ | `bounce_rate` | PostHog/GA4 | Exit quality | all | -0.15 |
1310
+ | `scroll_depth` | PostHog | Engagement depth | engagement | +0.05 |
1311
+ | `rage_click_rate` | PostHog | Frustration | all | -0.10 |
1312
+ | `dead_click_rate` | PostHog | Confusion (click w/ no response) | all | -0.08 |
1313
+ | `u_turn_rate` | PostHog | Immediate regret (back within 5s) | conversion | -0.07 |
1314
+ | `time_to_first_interaction` | PostHog | Above-fold clarity | landing | -0.05 (lower = better) |
1315
+ | `cta_click_rate` | PostHog | Primary CTA engagement | conversion | +0.12 |
1316
+ | `form_start_rate` | PostHog | Intent signal | conversion | +0.08 |
1317
+ | `form_abandonment_rate` | PostHog | Friction signal | conversion | -0.12 |
1318
+ | `error_encounter_rate` | PostHog | Technical friction | all | -0.10 |
1319
+ | `hover_time_above_fold` | PostHog | Visual engagement | landing | +0.04 |
1320
+ | `exit_rate` | GA4 | Page-level exit (vs bounce = session) | all | -0.08 |
1321
+ | `avg_session_duration` | GA4 | Engagement breadth | engagement | +0.06 |
1322
+ | `pages_per_session` | GA4 | Exploration depth | engagement | +0.05 |
1323
+
1324
+ #### Retention and Activation (Stage 2 — Day 7)
1325
+
1326
+ | Metric | Source | Signal Type | Page Intent | Weight |
1327
+ |--------|--------|-------------|-------------|--------|
1328
+ | `return_7d` | PostHog | Early retention | all | +0.20 |
1329
+ | `activation_rate` | PostHog | Completed key action (setup, first purchase, etc.) | onboarding | +0.15 |
1330
+ | `support_ticket_rate` | PostHog/CRM | Confusion / UX failure | all | -0.10 |
1331
+ | `nps_survey_response` | PostHog | Qualitative satisfaction proxy | all | +0.08 |
1332
+ | `trial_to_paid_cvr` | PostHog | Business-critical | saas | +0.25 |
1333
+ | `upgrade_click_rate` | PostHog | Expansion intent | saas/pricing | +0.12 |
1334
+ | `new_vs_returning_split` | GA4 | Audience quality signal (segment only, not scored) | — | segmentation |
1335
+ | `traffic_source_cvr_delta` | GA4 | Paid vs organic CVR gap (flag if variant only works for warm users) | conversion | audit |
1336
+
1337
+ #### Long-term Value (Stage 3 — Day 30)
1338
+
1339
+ | Metric | Source | Signal Type | Page Intent | Weight |
1340
+ |--------|--------|-------------|-------------|--------|
1341
+ | `retention_d30` | PostHog | Deep retention | all | +0.15 |
1342
+ | `feature_adoption` | PostHog | Product engagement breadth | saas/dashboard | +0.10 |
1343
+ | `expansion_mrr_delta` | PostHog/billing | Revenue expansion | saas | +0.20 |
1344
+ | `churn_rate_delta` | PostHog/billing | Retention health | saas | -0.20 |
1345
+ | `ltv_proxy` | derived | 30d revenue ÷ acquisition cost | ecommerce/saas | +0.15 |
1346
+ | `user_cohort_d30_retention` | GA4 | Cohort-level stickiness | all | +0.12 |
1347
+
1348
+ #### Page-Intent-Specific Routing
1349
+
1350
+ Not every metric should contribute to every composite score. Apply this routing table before computing:
1351
+
1352
+ ```python
1353
+ PAGE_INTENT_METRIC_FILTER = {
1354
+ "conversion": [
1355
+ "cvr", "bounce_rate", "rage_click_rate", "dead_click_rate",
1356
+ "u_turn_rate", "cta_click_rate", "form_start_rate",
1357
+ "form_abandonment_rate", "time_to_first_interaction",
1358
+ "return_7d", "trial_to_paid_cvr",
1359
+ ],
1360
+ "engagement": [
1361
+ "scroll_depth", "avg_session_duration", "pages_per_session",
1362
+ "hover_time_above_fold", "rage_click_rate", "dead_click_rate",
1363
+ "return_7d", "nps_survey_response",
1364
+ ],
1365
+ "efficiency": [
1366
+ "task_completion_time", # lower = better, flip sign
1367
+ "dead_click_rate", "error_encounter_rate",
1368
+ "support_ticket_rate", "form_abandonment_rate",
1369
+ ],
1370
+ "onboarding": [
1371
+ "activation_rate", "time_to_first_interaction",
1372
+ "form_abandonment_rate", "support_ticket_rate",
1373
+ "feature_adoption", "return_7d",
1374
+ ],
1375
+ "retention": [
1376
+ "retention_d30", "churn_rate_delta", "expansion_mrr_delta",
1377
+ "feature_adoption", "nps_survey_response", "ltv_proxy",
1378
+ ],
1379
+ }
1380
+ ```
1381
+
1382
+ Metrics not in the active `page_intent` filter are collected but excluded from composite scoring for that experiment. They're still stored and available for segmentation.
1383
+
1384
+ #### Guardrail-Only Metrics
1385
+
1386
+ These are never scored positively — they only trigger disqualification when thresholds are breached:
1387
+
1388
+ ```python
1389
+ GUARDRAIL_DEFAULTS = {
1390
+ "rage_click_rate": {"max": 0.08}, # >8% rage clicks = disqualify
1391
+ "dead_click_rate": {"max": 0.12},
1392
+ "error_encounter_rate": {"max": 0.03}, # >3% JS errors = disqualify
1393
+ "form_abandonment_rate":{"max": 0.75}, # >75% abandonment = disqualify
1394
+ "churn_rate_delta": {"max": 0.02}, # variant increases churn >2% = disqualify
1395
+ }
1396
+ ```
1397
+
1398
+ These guardrails apply globally. Customers can tighten them but not loosen past the defaults.
1399
+
1400
+ ### 10.4 Customer Optimization Goals
1401
+
1402
+ Customers can override default metric weights:
1403
+
1404
+ ```python
1405
+ # register_customer() accepts:
1406
+ optimization_goal = {
1407
+ "primary_metric": "retention_d30", # up-weight this
1408
+ "secondary_metric": "activation_rate",
1409
+ "guardrails": {
1410
+ "bounce_rate": {"max": 0.60}, # disqualify if exceeded
1411
+ "rage_click_rate": {"max": 0.05},
1412
+ }
1413
+ }
1414
+ ```
1415
+
1416
+ Primary metric gets 50% weight, secondary gets 30%, remaining 20% split across others. Guardrail violations disqualify the variant regardless of other scores.
1417
+
1418
+ ---
1419
+
1420
+ ## 11. Corpus Design
1421
+
1422
+ ### 11.1 Starting Corpus
1423
+
1424
+ Migrated from ui-ux-pro-max-skill CSV databases:
1425
+
1426
+ | Domain | Source File | Rows | Notes |
1427
+ |--------|-------------|------|-------|
1428
+ | style | styles.csv | 67 | Core visual design patterns |
1429
+ | color | colors.csv | 96 | Full semantic token sets |
1430
+ | typography | typography.csv | 57 | Font pairings + Google Fonts URLs |
1431
+ | chart | charts.csv | 25 | Chart type recommendations |
1432
+ | landing | landing.csv | 24 | Page structure + CTA patterns |
1433
+ | product | products.csv | 161 | Product type → style mappings |
1434
+ | ux | ux-guidelines.csv | 99 | UX best practices + anti-patterns |
1435
+ | icons | icons.csv | 50 | Icon libraries |
1436
+ | react | react-performance.csv | 30 | React/Next.js patterns |
1437
+ | web | app-interface.csv | 20 | App interface guidelines |
1438
+ | **component_patterns** | **new** | **0→50+** | **Component-level actionable patterns** |
1439
+
1440
+ `google-fonts.csv` (1923 rows) is a lookup table — never embedded, queried by name only.
1441
+
1442
+ ### 11.2 New Domain: `component_patterns`
1443
+
1444
+ The existing CSV data is **system-level** (design system for an app). You need **component-level** patterns for the A/B testing use case (specific, actionable, at the button/card/hero granularity).
1445
+
1446
+ Each row:
1447
+ ```
1448
+ component_type: checkout-cta-button
1449
+ page_context: checkout
1450
+ pattern_name: High-Contrast Single CTA
1451
+ description: Single primary CTA with high contrast ratio (7:1+), no competing
1452
+ secondary actions visible, trust signals (lock icon, SSL text)
1453
+ immediately below button.
1454
+ implementation: bg-orange-500 text-white font-semibold px-8 py-4 rounded-lg
1455
+ cursor-pointer hover:opacity-90 transition-200
1456
+ + trust badge below
1457
+ best_for: B2B SaaS checkout, e-commerce payment, subscription signup
1458
+ win_signals: High CVR on checkout pages, low rage clicks, high scroll completion
1459
+ anti_patterns: Multiple CTAs competing, ghost button as primary, missing trust signals
1460
+ source: Design best practices + aggregated A/B patterns
1461
+ ```
1462
+
1463
+ This domain is built incrementally from winning A/B variants and session replay analysis.
1464
+
1465
+ ### 11.3 Prose Serialization
1466
+
1467
+ Each row is serialized as labeled prose for better embedding quality:
1468
+
1469
+ ```python
1470
+ def serialize_style_row(row):
1471
+ synonyms = SYNONYM_MAP.get(row["Style Category"].lower(), [])
1472
+ synonym_text = f"Also known as: {', '.join(synonyms)}." if synonyms else ""
1473
+ return (
1474
+ f"Style: {row['Style Category']}. "
1475
+ f"Type: {row['Type']}. "
1476
+ f"Keywords: {row['Keywords']}. "
1477
+ f"Best For: {row['Best For']}. "
1478
+ f"Effects and Animation: {row['Effects & Animation']}. "
1479
+ f"Performance: {row['Performance']}. "
1480
+ f"Accessibility: {row['Accessibility']}. "
1481
+ f"CSS Keywords: {row['CSS/Technical Keywords']}. "
1482
+ f"AI Prompt: {row['AI Prompt Keywords']}. "
1483
+ f"{synonym_text}"
1484
+ )
1485
+
1486
+ SYNONYM_MAP = {
1487
+ "glassmorphism": [
1488
+ "frosted glass", "glass effect", "blur panel", "translucent surface",
1489
+ "frosted panel", "glass morphism", "backdrop blur"
1490
+ ],
1491
+ "minimalism": [
1492
+ "minimal", "clean design", "whitespace heavy", "simple ui",
1493
+ "flat clean", "stripped back", "uncluttered"
1494
+ ],
1495
+ "neumorphism": [
1496
+ "soft ui", "embossed", "pressed button", "soft shadow ui",
1497
+ "skeuomorphic soft", "inset shadow"
1498
+ ],
1499
+ # ... all 67 styles
1500
+ }
1501
+ ```
1502
+
1503
+ ### 11.4 Representation: Hybrid Abstract + Concrete
1504
+
1505
+ Each document includes:
1506
+ 1. Abstract design principles (for semantic search quality)
1507
+ 2. Tailwind class examples (for developer actionability)
1508
+ 3. CSS variable names (for design token integration)
1509
+
1510
+ ```
1511
+ Style: Glassmorphism.
1512
+ Principles: Creates depth through translucency; UI elements appear to float
1513
+ above a blurred background; communicates modernity and layering.
1514
+ Tailwind implementation: backdrop-blur-md bg-white/10 border border-white/20
1515
+ rounded-xl shadow-lg
1516
+ CSS variables: --glass-bg: rgba(255,255,255,0.1); --glass-blur: 12px;
1517
+ --glass-border: rgba(255,255,255,0.2)
1518
+ Best for: SaaS dashboards, fintech, modern landing pages with image backgrounds.
1519
+ Avoid: Low-contrast text on glass; purely decorative blur without purpose.
1520
+ ```
1521
+
1522
+ ---
1523
+
1524
+ ## 12. Corpus Quality Validation
1525
+
1526
+ ### 12.1 Coverage Matrix
1527
+
1528
+ Before launch, map every cell of:
1529
+ ```
1530
+ product_type × component_type × page_context
1531
+ ```
1532
+
1533
+ Each cell must have ≥1 relevant document. Use a script to detect gaps:
1534
+
1535
+ ```python
1536
+ PRODUCT_TYPES = ["saas", "fintech", "ecommerce", "healthcare", "gaming", ...]
1537
+ COMPONENT_TYPES = ["hero", "cta", "pricing-card", "navbar", "modal", "form", ...]
1538
+ PAGE_CONTEXTS = ["landing", "checkout", "dashboard", "onboarding", "settings", ...]
1539
+
1540
+ for product in PRODUCT_TYPES:
1541
+ for component in COMPONENT_TYPES:
1542
+ query = f"{product} {component}"
1543
+ results = retrieval_pipeline(query, top_k=5)
1544
+ if not results or max_score(results) < COVERAGE_THRESHOLD:
1545
+ print(f"GAP: {product} × {component}")
1546
+ ```
1547
+
1548
+ Target: zero gaps for the top 20 product types × top 10 component types.
1549
+
1550
+ ### 12.2 Offline Evaluation Test Set
1551
+
1552
+ Curate 50-100 (query, expected_doc_ids) pairs manually:
1553
+
1554
+ ```python
1555
+ TEST_QUERIES = [
1556
+ {
1557
+ "query": "glassmorphism fintech dashboard hero section",
1558
+ "relevant_docs": ["style_styles_3", "color_colors_12", "landing_landing_7"],
1559
+ "irrelevant_docs": ["style_styles_45"], # brutalism — wrong style
1560
+ },
1561
+ {
1562
+ "query": "checkout CTA button high contrast B2B",
1563
+ "relevant_docs": ["ux_ux_guidelines_8", "style_styles_1", "landing_landing_3"],
1564
+ },
1565
+ {
1566
+ "query": "mobile bottom navigation max items",
1567
+ "relevant_docs": ["ux_ux_guidelines_24"],
1568
+ },
1569
+ # ... 50+ more
1570
+ ]
1571
+ ```
1572
+
1573
+ **Metrics:**
1574
+
1575
+ ```
1576
+ Recall@5: fraction of relevant docs appearing in top 5 results
1577
+ Target: ≥ 0.80 before launch
1578
+
1579
+ MRR (Mean Reciprocal Rank): 1/rank_of_first_relevant_result
1580
+ Target: ≥ 0.70
1581
+
1582
+ NDCG@5: normalized discounted cumulative gain, accounts for rank position
1583
+ Target: ≥ 0.75
1584
+
1585
+ Precision@3: of top 3 results, fraction that are relevant
1586
+ Target: ≥ 0.65
1587
+ ```
1588
+
1589
+ ```python
1590
+ def evaluate_corpus(test_queries, pipeline):
1591
+ recall_scores, mrr_scores, ndcg_scores = [], [], []
1592
+
1593
+ for test in test_queries:
1594
+ results = pipeline(test["query"], top_k=5)
1595
+ result_ids = [r.doc_id for r in results]
1596
+
1597
+ # Recall@5
1598
+ relevant = set(test["relevant_docs"])
1599
+ hits = sum(1 for r in result_ids if r in relevant)
1600
+ recall_scores.append(hits / len(relevant))
1601
+
1602
+ # MRR
1603
+ for rank, doc_id in enumerate(result_ids, 1):
1604
+ if doc_id in relevant:
1605
+ mrr_scores.append(1.0 / rank)
1606
+ break
1607
+ else:
1608
+ mrr_scores.append(0.0)
1609
+
1610
+ # NDCG@5
1611
+ gains = [1 if r in relevant else 0 for r in result_ids]
1612
+ dcg = sum(g / math.log2(i + 2) for i, g in enumerate(gains))
1613
+ ideal = sum(1 / math.log2(i + 2) for i in range(min(len(relevant), 5)))
1614
+ ndcg_scores.append(dcg / ideal if ideal > 0 else 0.0)
1615
+
1616
+ return {
1617
+ "Recall@5": mean(recall_scores),
1618
+ "MRR": mean(mrr_scores),
1619
+ "NDCG@5": mean(ndcg_scores),
1620
+ }
1621
+ ```
1622
+
1623
+ ### 12.3 Vocabulary Gap Detection
1624
+
1625
+ Queries that score poorly on BM25 but should be relevant indicate vocabulary gaps:
1626
+
1627
+ ```python
1628
+ def detect_vocabulary_gaps(test_queries, bm25_index):
1629
+ gaps = []
1630
+ for test in test_queries:
1631
+ bm25_results = bm25_index.search(test["query"], top_k=10)
1632
+ relevant_bm25_scores = [
1633
+ score for doc_id, score in bm25_results
1634
+ if doc_id in test["relevant_docs"]
1635
+ ]
1636
+ if not relevant_bm25_scores or max(relevant_bm25_scores) < 0.5:
1637
+ gaps.append({
1638
+ "query": test["query"],
1639
+ "expected": test["relevant_docs"],
1640
+ "diagnosis": "BM25 miss — add synonyms or enrich document vocabulary"
1641
+ })
1642
+ return gaps
1643
+ ```
1644
+
1645
+ For each gap: identify which query tokens are absent from the relevant document, add them as synonyms.
1646
+
1647
+ ### 12.4 Inter-Rater Reliability
1648
+
1649
+ For the top 20 most common queries, have 2+ human raters independently grade retrieval results as relevant/irrelevant. Measure Cohen's kappa:
1650
+
1651
+ ```python
1652
+ from sklearn.metrics import cohen_kappa_score
1653
+
1654
+ # Rater 1 grades: [1, 1, 0, 1, 0, ...] (1=relevant, 0=irrelevant)
1655
+ # Rater 2 grades: [1, 0, 0, 1, 1, ...]
1656
+ kappa = cohen_kappa_score(rater1_labels, rater2_labels)
1657
+ # Target: κ ≥ 0.70 (substantial agreement)
1658
+ # Below 0.70 means your relevance definition is ambiguous
1659
+ ```
1660
+
1661
+ Low kappa indicates the corpus documents are ambiguous — they don't clearly belong to one design concept. Rewrite or split those documents.
1662
+
1663
+ ### 12.5 Embedding Quality Check
1664
+
1665
+ Visualize document clusters to verify semantic coherence:
1666
+
1667
+ ```python
1668
+ import umap
1669
+ import matplotlib.pyplot as plt
1670
+
1671
+ # Fetch all embeddings from ChromaDB
1672
+ all_embeddings = chroma.get(include=["embeddings", "metadatas"])
1673
+ embeddings_matrix = np.array(all_embeddings["embeddings"])
1674
+ domains = [m["domain"] for m in all_embeddings["metadatas"]]
1675
+
1676
+ # Reduce to 2D
1677
+ reducer = umap.UMAP(n_components=2, random_state=42)
1678
+ embeddings_2d = reducer.fit_transform(embeddings_matrix)
1679
+
1680
+ # Plot — same-domain documents should cluster together
1681
+ plt.scatter(embeddings_2d[:, 0], embeddings_2d[:, 1],
1682
+ c=[domain_colors[d] for d in domains], alpha=0.6)
1683
+ ```
1684
+
1685
+ **What good looks like:** documents from the same domain cluster together; style documents are near each other; ux documents form their own cluster; there's some overlap between related domains (product ↔ color, style ↔ typography).
1686
+
1687
+ **What bad looks like:** all documents mixed randomly, no cluster structure — indicates prose serialization is too uniform (all docs sound the same) or embeddings are low quality.
1688
+
1689
+ ### 12.6 LTR Offline Evaluation
1690
+
1691
+ Before deploying a new LTR model, evaluate on held-out experiments:
1692
+
1693
+ ```python
1694
+ # Hold out 20% of experiments as test set (by time — use oldest 80% for train)
1695
+ # Predict rankings on test set, compare to actual A/B outcomes
1696
+
1697
+ # Metric: NDCG@3 on test set
1698
+ # Target: NDCG@3 > 0.72 before deploying customer-specific model
1699
+
1700
+ # Reversal rate: fraction of test experiments where LTR ranked the loser above winner
1701
+ # Target: reversal rate < 20%
1702
+ ```
1703
+
1704
+ ---
1705
+
1706
+ ## 13. Corpus Improvement Loops
1707
+
1708
+ ### Loop 1 — Query Log Gap Analysis (Weekly)
1709
+
1710
+ ```python
1711
+ # Identify queries where customers heavily edited all variants (edit_distance > 5)
1712
+ failing_queries = db.query("""
1713
+ SELECT e.query, e.component_type, e.page_context, AVG(ev.edit_distance) as avg_edit
1714
+ FROM experiments e
1715
+ JOIN edit_events ev ON e.experiment_id = ev.experiment_id
1716
+ GROUP BY e.experiment_id
1717
+ HAVING AVG(ev.edit_distance) > 4
1718
+ ORDER BY avg_edit DESC
1719
+ LIMIT 50
1720
+ """)
1721
+ # Each row = a corpus gap. Review and add missing documents.
1722
+ ```
1723
+
1724
+ ### Loop 2 — Winning Variant Extraction (After Each Batch of 10 Experiments)
1725
+
1726
+ ```python
1727
+ # Find A/B winners with moderate edit distance (customer improved on your rec)
1728
+ winning_edited_variants = db.query("""
1729
+ SELECT sv.attributes_json, sv.edited_attrs_json, ar.composite_score,
1730
+ e.component_type, e.page_context
1731
+ FROM shown_variants sv
1732
+ JOIN ab_results ar ON sv.experiment_id = ar.experiment_id
1733
+ AND sv.variant_id = ar.variant_id
1734
+ WHERE ar.is_winner = 1
1735
+ AND sv.edit_distance BETWEEN 2 AND 5 -- improved but not rebuilt
1736
+ AND ar.composite_score > 0.15 -- meaningful lift
1737
+ ORDER BY ar.composite_score DESC
1738
+ LIMIT 20
1739
+ """)
1740
+ # Review each — abstract into a component_patterns row
1741
+ ```
1742
+
1743
+ ### Loop 3 — Document Win Rate Tracking (Monthly)
1744
+
1745
+ ```python
1746
+ # Track which corpus documents appear in winning vs losing variants
1747
+ doc_performance = db.query("""
1748
+ SELECT json_each.value as doc_id,
1749
+ AVG(ar.is_winner) as win_rate,
1750
+ COUNT(*) as appearances
1751
+ FROM shown_variants sv,
1752
+ json_each(sv.source_docs_json) as json_each
1753
+ JOIN ab_results ar ON sv.experiment_id = ar.experiment_id
1754
+ AND sv.variant_id = ar.variant_id
1755
+ WHERE ar.impressions >= 500
1756
+ GROUP BY doc_id
1757
+ HAVING appearances >= 5
1758
+ ORDER BY win_rate ASC
1759
+ """)
1760
+ # Bottom 10% by win_rate → review for revision or deprecation
1761
+ # Top 10% → promote with boosted global_win_rate in ChromaDB metadata
1762
+ ```
1763
+
1764
+ ### Loop 4 — Session Replay Review (After Each PostHog Sync)
1765
+
1766
+ ```python
1767
+ # Flag losing variant sessions with high frustration signals
1768
+ sessions_to_review = posthog.get_recordings(filters={
1769
+ "experiment_loser": True,
1770
+ "rage_clicks__gte": 3,
1771
+ "duration__lte": 20, # bounced quickly
1772
+ })
1773
+ # Human reviews these sessions
1774
+ # Each observed UI failure → new row in ux-guidelines.csv
1775
+ # "Users clicked the ghost button expecting it to be the primary action"
1776
+ # → Anti-pattern: ghost button as primary CTA on conversion pages
1777
+ ```
1778
+
1779
+ ### Loop 5 — Synonym Expansion (After Each Vocabulary Gap Analysis)
1780
+
1781
+ ```python
1782
+ # Queries that hit embedding but miss BM25 = vocabulary gap
1783
+ dense_hits_bm25_miss = [
1784
+ q for q in test_queries
1785
+ if dense_recall(q) > 0.8 and bm25_recall(q) < 0.4
1786
+ ]
1787
+ # For each: identify missing tokens, add to SYNONYM_MAP
1788
+ # Rebuild BM25 index (fast, < 1 minute)
1789
+ ```
1790
+
1791
+ ### Loop 6 — Quarterly Trend Review
1792
+
1793
+ Structured review of:
1794
+ - Dribbble/Behance: new design patterns gaining traction
1795
+ - Major product redesigns (Notion, Linear, Vercel, Stripe)
1796
+ - New CSS capabilities (container queries, CSS nesting, etc.)
1797
+ - Updated accessibility guidelines (WCAG 2.2, WCAG 3.0 draft)
1798
+
1799
+ Each trend producing ≥3 distinct component variations earns a new corpus row.
1800
+
1801
+ ---
1802
+
1803
+ ## 14. Integration Architecture
1804
+
1805
+ ### 14.1 PostHog
1806
+
1807
+ ```python
1808
+ class PostHogIntegration:
1809
+ def get_experiment_metrics(self, experiment_id) -> dict:
1810
+ # Returns: CVR, bounce_rate, scroll_depth, rage_click_rate,
1811
+ # avg_time_to_convert, funnel_drop_off_step
1812
+
1813
+ def get_7day_metrics(self, experiment_id) -> dict:
1814
+ # Returns: return_7d, activation_rate, support_ticket_rate
1815
+
1816
+ def get_30day_metrics(self, experiment_id) -> dict:
1817
+ # Returns: retention_d30, feature_adoption_rate
1818
+
1819
+ def get_flagged_sessions(self, experiment_id, variant="loser") -> list:
1820
+ # Returns: session IDs with rage_clicks > 3 and duration < 20s
1821
+ # Used for corpus improvement Loop 4
1822
+
1823
+ def get_funnel_drop_off(self, funnel_id) -> dict:
1824
+ # Returns: drop-off rate per step
1825
+ # Used to suggest highest-impact page for recommendation
1826
+ ```
1827
+
1828
+ PostHog's native experiment system makes this the simplest integration — experiment_id maps 1:1 to your experiment_id.
1829
+
1830
+ ### 14.2 GA4
1831
+
1832
+ ```python
1833
+ class GA4Integration:
1834
+ def get_engagement_metrics(self, page_path, date_range) -> dict:
1835
+ # Returns: engagement_rate, avg_session_duration,
1836
+ # scrolled_users_pct, bounce_rate
1837
+
1838
+ def get_traffic_source_breakdown(self, page_path) -> dict:
1839
+ # Returns: CVR by channel (organic, paid, direct, social)
1840
+ # Used to segment signals: paid search users respond differently
1841
+
1842
+ def get_funnel_report(self, funnel_steps) -> dict:
1843
+ # Returns: drop-off at each step
1844
+ # 24-48hr lag vs PostHog real-time
1845
+
1846
+ def get_user_segment_performance(self, segment_dimension) -> dict:
1847
+ # Returns: conversion by user property (plan_type, company_size, etc.)
1848
+ ```
1849
+
1850
+ GA4 does not have native A/B experiment tracking (Google Optimize deprecated 2023). Customers must push experiment data as custom events for attribution. This requires customer-side instrumentation — document clearly as a setup requirement.
1851
+
1852
+ **GA4 unique value:** traffic source segmentation. If paid search users (cold audience) respond differently to design variants than direct users (warm audience), signals should be stored with traffic_segment dimension:
1853
+
1854
+ ```python
1855
+ update_ltr_training_example(
1856
+ ...,
1857
+ context_features={
1858
+ "traffic_segment": "paid_search",
1859
+ # other features...
1860
+ }
1861
+ )
1862
+ ```
1863
+
1864
+ ---
1865
+
1866
+ ## 15. Limitations and Known Tradeoffs
1867
+
1868
+ ### 15.1 Feedback Loop Dependency
1869
+
1870
+ Adaptability (R2) depends on customers completing the SDK integration and running A/B tests regularly. Historically, 60-70% of B2B customers in API-dependent products don't complete instrumentation. Day 1 value must be strong from corpus alone. Treat adaptability as a retention feature, not an acquisition feature.
1871
+
1872
+ **Mitigation:** ensure global baseline (cross-customer signals) provides meaningful lift over pure retrieval from day 1.
1873
+
1874
+ ### 15.2 Attribution Granularity vs Privacy
1875
+
1876
+ Contributing experiment data shown in attribution may reveal competitive information if customers share the same global baseline. Anonymize customer IDs in cross-customer attribution ("8 experiments in similar contexts" not "acme-corp experiment exp-291").
1877
+
1878
+ ### 15.3 Corpus Staleness
1879
+
1880
+ Design trends shift. The existing CSV data was last curated for general UI generation, not A/B testing component patterns. Quarterly reviews and winning variant extraction mitigate this but require ongoing editorial work.
1881
+
1882
+ ### 15.4 LTR Cold Start
1883
+
1884
+ New customers see global model recommendations. Global model is trained on the aggregate of all customers' data — it may not reflect niche product types (e.g., IoT dashboard, AR/VR interface) if those are underrepresented in the customer base. Coverage matrix validation catches systematic gaps; emerging niches are a known blind spot.
1885
+
1886
+ ### 15.5 Multi-Metric Composite Score Is Opinionated
1887
+
1888
+ The composite score formula embeds value judgments (CVR matters more than scroll depth). Customers with different optimization goals must configure this explicitly — don't assume defaults are right. Surface low-confidence warnings when composite score is driven by few metrics.
1889
+
1890
+ ### 15.6 Cohere Rerank Latency and Cost
1891
+
1892
+ ~300ms latency per query, ~$0.001 per 20-document rerank. At scale (10,000 queries/day), this is $10/day — negligible. At 100,000 queries/day, $100/day — still manageable but worth monitoring. Implement fallback to RRF order if Cohere is unavailable.
1893
+
1894
+ ---
1895
+
1896
+ ## 16. React SDK Expansion Plan
1897
+
1898
+ The current React SDK tracks CTR only. This section defines the phased metric expansion required for the composite score and LTR training pipeline.
1899
+
1900
+ ### SDK Week 1 — Frustration + Engagement Signals
1901
+
1902
+ Add via DOM event listeners, no backend changes:
1903
+
1904
+ | Metric | Implementation | Why first |
1905
+ |--------|---------------|-----------|
1906
+ | `rage_click_rate` | 3+ clicks within 500ms on same element | Highest-signal negative metric, cheap to implement |
1907
+ | `dead_click_rate` | click with no DOM mutation within 300ms (MutationObserver) | Catches broken/confusing UI that CTR misses |
1908
+ | `scroll_depth` | IntersectionObserver on 25/50/75/100% markers | Tells you if users even see the variant |
1909
+
1910
+ These three + existing CTR give a complete Stage 1 signal set.
1911
+
1912
+ ### SDK Week 2 — Form + CTA Micro-Funnel
1913
+
1914
+ | Metric | Implementation | Why |
1915
+ |--------|---------------|-----|
1916
+ | `form_start_rate` | `focus` event on first form field | Intent signal |
1917
+ | `form_abandonment_rate` | started (focus) but never submitted | Friction detection |
1918
+ | `cta_click_rate` | click on elements with `data-variant-cta` attribute | Primary conversion action, distinct from general CTR |
1919
+
1920
+ Convention: SDK consumers tag their primary CTA with `data-variant-cta` and variant wrapper with `data-variant-id`.
1921
+
1922
+ ### SDK Week 3 — Return Visit + Session Linkage
1923
+
1924
+ | Metric | Implementation | Why |
1925
+ |--------|---------------|-----|
1926
+ | `return_7d` | localStorage flag on first visit, check on return within 7d window | Stage 2 anchor metric, measures if design drives re-engagement |
1927
+ | `session_id` linkage | Generate UUID per session (tab open → 30min idle timeout), attach to all events | Enables `pages_per_session` and `avg_session_duration` computation downstream |
1928
+
1929
+ This is the hardest week — requires persistence and identity stitching across page loads.
1930
+
1931
+ ### SDK Week 4 — Package + Instrument
1932
+
1933
+ 1. Bundle SDK, write integration guide (< 10 min setup for customer)
1934
+ 2. Dogfood on 1 pilot customer
1935
+ 3. `data-variant-id` attribute convention documented and enforced
1936
+ 4. Events batch-flushed every 5s (or on `beforeunload`) to minimize network overhead
1937
+ 5. SDK size budget: < 8KB gzipped
1938
+
1939
+ **End state:** 9 metrics (CTR + 8 new), covering Stage 1 fully and Stage 2 partially. Enough for composite score + LTR training.
1940
+
1941
+ **Deferred to month 2:** retention/churn/LTV (requires billing integration), NPS (separate surface), Next.js/Vue/Svelte ports (event logic is framework-agnostic, only hooks wrapper changes).
1942
+
1943
+ ---
1944
+
1945
+ ## 17. One-Week MVP
1946
+
1947
+ ### Goal
1948
+
1949
+ Validate one thing: **does retrieval + diversity give customers better variant ideas than they'd come up with themselves?**
1950
+
1951
+ No LTR, no A/B ingestion, no PostHog, no customer accounts, no staged metrics. Just: describe a component → get 3 diverse, attributed variants → customer says "I'd test this" or "this is useless."
1952
+
1953
+ ### What you build
1954
+
1955
+ | Day | Deliverable |
1956
+ |-----|-------------|
1957
+ | Day 1 | **Ground truth construction.** Pick 15 companies (5 B2B SaaS, 5 e-commerce, 5 marketplace/fintech). Screenshot 3 pages each (landing, pricing, checkout). Decompose into attributes. Write 30-35 queries with expected results → `ground_truth.csv`. |
1958
+ | Day 2 | **Corpus validation + fixes.** Run ground truth queries against existing CSV. Grade top 5 per query. Compute Recall@5, Precision@3, MRR. Fix gaps: add missing CSV rows, rewrite noisy rows, add synonyms. Write prose serializer. Write 10 `component_patterns` rows. Re-run, confirm ≥75% usefulness. |
1959
+ | Day 3 | **Retrieval pipeline.** OpenAI embedding + ChromaDB ingestion. BM25 index. Single MCP tool: `recommend_variants(component, page_type, industry)`. BM25 + dense → RRF → MMR per strategy. Deterministic strategy-based composition: 3 strategies × multi-domain search → `select_best_match` per domain → assemble variant bundles. Returns 3 variants with source attribution. Skip Cohere rerank. |
1960
+ | Day 4 | **Self-test.** Run 5 ground truth queries end-to-end through MCP. Verify results make sense visually. Fix any obvious ranking issues. |
1961
+ | Day 5 | **User validation.** Put it in front of 3-5 people. Collect feedback. |
1962
+
1963
+ ### What you skip
1964
+
1965
+ | Component | Why safe to skip |
1966
+ |-----------|-----------------|
1967
+ | LTR / LambdaRank | No training data yet |
1968
+ | Cohere rerank | RRF + MMR is sufficient to validate the concept |
1969
+ | Customer accounts / Supabase tables | Everyone gets global corpus |
1970
+ | React SDK | Manual feedback via Slack/form at 5 users |
1971
+ | PostHog / GA4 integration | No experiments running yet |
1972
+ | Staged metric ingestion | Nothing to ingest |
1973
+ | Edit signal loop | Ask verbally what they'd change |
1974
+ | Multimodal embeddings | Text-only proves the core thesis |
1975
+
1976
+ ### Validation protocol
1977
+
1978
+ Run a blind head-to-head with each test user:
1979
+
1980
+ ```
1981
+ For each query, show:
1982
+ Column A: Orpheus MVP's 3 variants (unlabeled)
1983
+ Column B: Raw Claude's 3 variants for the same prompt (unlabeled)
1984
+
1985
+ Ask:
1986
+ 1. Which column would you A/B test? (A / B / both / neither)
1987
+ 2. What's missing that you expected to see?
1988
+ 3. Did the explanation make sense? (yes / no)
1989
+ ```
1990
+
1991
+ - Question 1 validates R1 (ideation quality)
1992
+ - Question 2 reveals corpus gaps
1993
+ - Question 3 validates R3 (referenceability)
1994
+
1995
+ **5 users × 3 queries each = 15 data points.**
1996
+
1997
+ ### Pass/fail criteria
1998
+
1999
+ | Signal | Pass | Fail |
2000
+ |--------|------|------|
2001
+ | "I'd A/B test 2+ of 3 variants" | ≥60% of responses | <60% → corpus needs work |
2002
+ | Users prefer Orpheus over raw Claude | Win or tie on majority of queries | Lose consistently → rethink approach |
2003
+ | "Explanation makes sense" | ≥80% yes | <80% → attribution format needs rework |
2004
+ | "Nothing missing" or minor gaps | ≤3 unique gap themes across all users | >3 themes → corpus has systematic holes |
2005
+
2006
+ **If MVP passes:** proceed to 4-week full build. You also get 15 selection signals (which variants they picked) — first LTR training data for free.
2007
+
2008
+ **If MVP fails:** fix corpus and retrieval before adding any complexity. LTR cannot save bad candidates.
2009
+
2010
+ ---
2011
+
2012
+ ## 18. 4-Week Build Plan (Compressed Rollout)
2013
+
2014
+ The original 10-week rollout compressed to 4 weeks. Key tradeoffs: corpus validation runs in parallel with infra (not gated), PostHog/GA4 deferred to post-launch, hardening is continuous not phased.
2015
+
2016
+ ### Week 1 — Corpus + Retrieval Pipeline
2017
+
2018
+ **Day 1: Ground truth construction (before touching any code)**
2019
+ 1. Pick 15 companies across 3 verticals:
2020
+ - B2B SaaS (5): Stripe, Linear, Vercel, Notion, Clerk
2021
+ - E-commerce (5): Shopify storefront, Allbirds, Glossier, Apple Store, Warby Parker
2022
+ - Marketplace/Fintech (5): Airbnb, Coinbase, Robinhood, Lemonade, Wise
2023
+ 2. Screenshot 3 pages per company (landing, pricing/product, signup/checkout) → 45 screenshots
2024
+ 3. Decompose each screenshot into attributes using template:
2025
+ `Company | Page | Style | Colors | Typography | CTA | Layout | Trust signals | Notable pattern`
2026
+ 4. Group decompositions into 20-25 queries with expected results (flip: "what would a customer type to get this?")
2027
+ 5. Add 10 Dribbble/Awwwards edge-case queries (glassmorphism, brutalism, bento grid, etc.)
2028
+ 6. Final deliverable: `ground_truth.csv` — 30-35 rows, columns: `query | expected_style | expected_color | expected_layout | expected_ux | notes`
2029
+
2030
+ **Day 2: Corpus prep (parallelize with infra)**
2031
+ 7. Run all 30-35 ground truth queries against existing CSV using `search.py`, grade top 5 results per query (1=relevant, 0=not)
2032
+ 8. Compute Recall@5, Precision@3, MRR — identify gap categories (missing content vs. noisy ranking vs. vocabulary mismatch)
2033
+ 9. Fix gaps: add missing rows to CSVs, rewrite noisy rows, add synonyms for vocabulary mismatches
2034
+ 10. Write prose serializer for all 10 CSV domains
2035
+ 11. Synonym expansion for top 30 design terms (informed by step 8 vocabulary gaps)
2036
+ 12. Write 10 `component_patterns` rows (prioritize patterns found in ground truth that have no corpus match)
2037
+ 13. Re-run ground truth queries, confirm ≥75% usefulness
2038
+
2039
+ **Days 3-4: Retrieval infra**
2040
+ 14. OpenAI embedding + ChromaDB ingestion script
2041
+ 15. BM25 index builder
2042
+ 16. Minimal MCP server shell: one tool (`recommend_variants`), dense-only, returns top 3
2043
+
2044
+ **Day 5: Fusion + composition stack**
2045
+ 17. RRF fusion (BM25 + dense)
2046
+ 18. Cohere rerank + 5-min TTL cache + fallback to RRF order
2047
+ 19. `_embed()` + `query_with_embedding()` methods on `RetrievalPipeline` (share one embedding across all domain searches)
2048
+ 20. `select_variant_1()` — best match per domain using `ui-reasoning.csv` anchor keywords (`_find_reasoning_rule` + `_select_best_match`)
2049
+ 21. `compose_variants()` — retrieve top 8 per domain → V1 via reasoning anchor → V2/V3 via max embedding distance → post-hoc label assignment → assemble `ComposedVariant` bundles with attribution
2050
+
2051
+ **Gate:** Recall@5 ≥ 0.80, MRR ≥ 0.70 on test set. 3 composed variants for "checkout button B2B SaaS" differ on ≥3 of 5 axes (diversity spot-check). If not met, fix corpus over weekend before proceeding.
2052
+
2053
+ ### Week 2 — Customer Layer + LTR Foundation
2054
+
2055
+ **Days 1-2: Customer data model (Supabase)**
2056
+ 1. Supabase migrations for new Orpheus tables: `brand_tokens`, `soft_guidance`, `optimization_goals`, `orpheus_variants`, `selection_events`, `edit_events`, `auto_apply_patterns`, `ab_results`, `ltr_training_examples`
2057
+ 2. SQLAlchemy models for new tables (extend existing `models.py` in Gushi repo)
2058
+ 3. `register_customer` tool — brand tokens, color constraints, typography → writes to `brand_tokens` table
2059
+ 4. Brand constraint application in retrieval (filter + boost)
2060
+ 5. `ComposedVariant` + `RetrievedDoc` dataclasses with full attribution fields
2061
+
2062
+ **Days 3-5: LTR system**
2063
+ 6. Feature extraction pipeline — bundle-level features (§6.7): aggregate retrieval scores, hypothesis category win rate, attribute win rates, context match
2064
+ 7. LambdaRank training script (lightgbm)
2065
+ 8. Generate synthetic training data: simulate 50 experiments from corpus
2066
+ 9. Train global baseline model
2067
+ 10. LTR re-ranking integrated at bundle level (after strategy composition, §6.7)
2068
+ 11. Attribution populated: feature importance + source docs per variant bundle
2069
+
2070
+ **Gate:** end-to-end call returns 3 hypothesis-driven, attributed variants respecting brand constraints. NDCG@3 > 0.65 on synthetic held-out set.
2071
+
2072
+ ### Week 3 — Human-in-the-Loop + Signals
2073
+
2074
+ **Days 1-2: Selection + edit loop**
2075
+ 1. `log_selection` tool → pairwise training examples to `selection_events` (Supabase)
2076
+ 2. `log_edit` tool → diff extraction, edit_distance, confidence weight → `edit_events` (Supabase)
2077
+ 3. Edit attribution rule enforced (use edited attrs for A/B credit, not original)
2078
+ 4. Auto-apply trigger: 3+ repeated edits above threshold → `auto_apply_patterns` + corpus candidate
2079
+
2080
+ **Days 3-4: A/B ingestion**
2081
+ 5. `ingest_ab_result` tool — reads from `experiment_metrics` (existing), writes composite to `ab_results` (new)
2082
+ 6. Composite score computation with `METRIC_WEIGHTS` + `PAGE_INTENT_METRIC_FILTER`
2083
+ 7. Stage 2/3 scheduled jobs (day 7, day 30) → Supabase edge functions or cron
2084
+ 8. Nightly LTR retraining job (cron) — reads from `ltr_training_examples`
2085
+
2086
+ **Day 5: SDK wiring**
2087
+ 9. Wire React SDK events → `log_selection` / `ingest_ab_result` endpoints
2088
+ 10. Verify full loop: recommend → select → edit → ingest → recommend again → verify ranking shift
2089
+
2090
+ **Gate:** full feedback loop verified end-to-end with simulated customer data.
2091
+
2092
+ ### Week 4 — Multi-Metric + Ship
2093
+
2094
+ **Days 1-2: Composite score expansion**
2095
+ 1. `PAGE_INTENT_METRIC_FILTER` routing
2096
+ 2. `GUARDRAIL_DEFAULTS` enforcement (disqualify on breach)
2097
+ 3. Customer optimization goals (primary/secondary metric override)
2098
+ 4. Reversal detection between stages
2099
+
2100
+ **Days 3-4: Integration stubs + hardening**
2101
+ 5. PostHog integration — experiment metrics pull (Stage 1 only, stages 2-3 post-launch)
2102
+ 6. GA4 stub — engagement metrics + traffic source segmentation (full integration post-launch)
2103
+ 7. Offline evaluation suite (automated, runs on corpus change)
2104
+ 8. Vocabulary gap detection script
2105
+
2106
+ **Day 5: Ship**
2107
+ 9. SDK packaged + integration guide
2108
+ 10. MCP server deployed for pilot customer
2109
+ 11. Attribution format validated with pilot (R3 spot-check)
2110
+
2111
+ **Gate:** pilot customer can install SDK, run experiment, see recommendation shift. All three requirements (R1/R2/R3) verified.
2112
+
2113
+ ### What's deferred to month 2
2114
+
2115
+ | Item | Why deferred |
2116
+ |------|-------------|
2117
+ | Full PostHog Stage 2/3 pull automation | Needs 30 days of real data to test |
2118
+ | GA4 full integration | Requires customer-side custom event setup |
2119
+ | Embedding cluster visualization (UMAP) | Nice-to-have diagnostics, not blocking |
2120
+ | Next.js/Vue/Svelte SDK ports | React SDK proves the event schema first |
2121
+ | Cohen's kappa inter-rater validation | Need 2+ raters, recruit post-launch |
2122
+ | Win rate tracking by document | Need real experiment volume |
2123
+
2124
+ ---
2125
+
2126
+ ## 19. Project Structure
2127
+
2128
+ ### 1-Week MVP
2129
+
2130
+ Minimal — just enough to validate retrieval + diversity:
2131
+
2132
+ ```
2133
+ orpheus/
2134
+ ├── README.md
2135
+ ├── images/
2136
+ ├── corpus/
2137
+ │ ├── csv/ # Raw CSV data (10 domains)
2138
+ │ ├── prose/ # Serialized prose documents
2139
+ │ └── ground_truth.csv # 30-35 validation queries
2140
+ ├── scripts/
2141
+ │ ├── serialize_corpus.py # CSV → prose serializer
2142
+ │ ├── ingest.py # Embed + load into ChromaDB
2143
+ │ └── evaluate.py # Recall@5, Precision@3, MRR
2144
+ ├── server/
2145
+ │ ├── __init__.py
2146
+ │ ├── mcp_server.py # MCP shell — single tool
2147
+ │ ├── retrieval.py # BM25 + dense + RRF + MMR
2148
+ │ └── config.py # API keys, model names
2149
+ ├── chroma_data/ # ChromaDB persistent storage
2150
+ ├── requirements.txt
2151
+ └── pyproject.toml
2152
+ ```
2153
+
2154
+ No LTR, no customer state, no SDK. One tool (`recommend_variants`), flat structure, no premature nesting.
2155
+
2156
+ ### 4-Week Full Build
2157
+
2158
+ Grows from the MVP — same roots, more modules:
2159
+
2160
+ ```
2161
+ orpheus/
2162
+ ├── README.md
2163
+ ├── images/
2164
+ ├── corpus/
2165
+ │ ├── csv/ # Raw CSV data
2166
+ │ ├── prose/ # Serialized prose docs
2167
+ │ ├── component_patterns/ # Component pattern docs
2168
+ │ ├── ground_truth.csv
2169
+ │ └── validation/
2170
+ │ ├── gap_analysis.py # Query log gap detection
2171
+ │ └── quality_scores.py # Coverage & freshness checks
2172
+ ├── scripts/
2173
+ │ ├── serialize_corpus.py
2174
+ │ ├── ingest.py
2175
+ │ ├── evaluate.py
2176
+ │ └── synthetic_data.py # Generate synthetic experiments for LTR
2177
+ ├── server/
2178
+ │ ├── __init__.py
2179
+ │ ├── mcp_server.py # MCP entry point — all tools registered here
2180
+ │ ├── config.py
2181
+ │ ├── retrieval/
2182
+ │ │ ├── __init__.py
2183
+ │ │ ├── bm25.py
2184
+ │ │ ├── dense.py # Embedding search
2185
+ │ │ ├── fusion.py # RRF
2186
+ │ │ ├── rerank.py # Cohere cross-encoder + cache
2187
+ │ │ ├── diversity.py # MMR
2188
+ │ │ └── composer.py # compose_variants() — corpus-anchored composition (§6.6)
2189
+ │ │ # STRATEGY_PROFILES, STRATEGY_HYPOTHESES, assign_strategy_label()
2190
+ │ │ # select_variant_1() via ui-reasoning.csv anchor
2191
+ │ │ # select_by_max_distance() for V2 and V3
2192
+ │ ├── ltr/
2193
+ │ │ ├── __init__.py
2194
+ │ │ ├── features.py # Bundle-level feature extraction (§6.7) — strategy win rates, attribute win rates
2195
+ │ │ ├── train.py # LambdaRank (lightgbm)
2196
+ │ │ ├── predict.py # Score variant bundles
2197
+ │ │ └── retrain_job.py # Nightly cron entry point
2198
+ │ ├── customers/
2199
+ │ │ ├── __init__.py
2200
+ │ │ ├── brand_constraints.py # Filter + boost logic
2201
+ │ │ └── supabase.py # Supabase queries: brand_tokens, soft_guidance, optimization_goals
2202
+ │ ├── signals/
2203
+ │ │ ├── __init__.py
2204
+ │ │ ├── selection.py # log_selection → pairwise labels → selection_events table
2205
+ │ │ ├── edits.py # log_edit → diff extraction → edit_events table
2206
+ │ │ ├── ab_ingestion.py # ingest_ab_result → reads experiment_metrics, writes ab_results
2207
+ │ │ ├── composite_score.py # METRIC_WEIGHTS, PAGE_INTENT_METRIC_FILTER, guardrails
2208
+ │ │ └── supabase.py # Supabase queries for all signal tables
2209
+ │ ├── attribution/
2210
+ │ │ ├── __init__.py
2211
+ │ │ ├── builder.py # Build ComposedVariant with full attribution
2212
+ │ │ └── explanation.py # User-facing explanation generator
2213
+ │ └── integrations/
2214
+ │ ├── __init__.py
2215
+ │ ├── posthog.py # Stage 1 experiment pull
2216
+ │ └── ga4.py # Stub
2217
+ ├── sdk/
2218
+ │ ├── package.json
2219
+ │ ├── src/
2220
+ │ │ ├── index.ts # SDK entry point
2221
+ │ │ ├── events.ts # CTR, rage_click, dead_click, scroll_depth
2222
+ │ │ ├── forms.ts # form_start, form_abandonment, cta_click
2223
+ │ │ ├── sessions.ts # session_id, return_7d
2224
+ │ │ └── flush.ts # Batch flush + beforeunload
2225
+ │ └── tsconfig.json
2226
+ ├── supabase/
2227
+ │ └── migrations/
2228
+ │ └── orpheus_tables.sql # New Orpheus tables (§5.3)
2229
+ ├── chroma_data/
2230
+ ├── models/ # Trained LTR model artifacts
2231
+ ├── tests/
2232
+ │ ├── test_retrieval.py
2233
+ │ ├── test_composition.py # Corpus-anchored composition + embedding-distance diversity tests
2234
+ │ ├── test_ltr.py
2235
+ │ ├── test_signals.py
2236
+ │ └── test_attribution.py
2237
+ ├── requirements.txt
2238
+ └── pyproject.toml
2239
+ ```
2240
+
2241
+ ### Key Decisions
2242
+
2243
+ 1. **`server/retrieval/` split from monolith** — MVP has one `retrieval.py`; by Week 2 you're adding LTR scoring and corpus-anchored composition, so the pipeline stages need their own files. `composer.py` orchestrates per-domain retrieval → ui-reasoning.csv anchor → max-distance selection → post-hoc label assignment (§6.6). There is no `strategies.py` — strategy labels are post-hoc and live as constants inside `composer.py`.
2244
+
2245
+ 2. **`server/customers/` and `server/signals/` are separate** — different concerns, same Supabase instance. Customers = brand constraints (applied during retrieval). Signals = feedback loop (consumed by LTR training). Both access Supabase via their own `supabase.py` query modules.
2246
+
2247
+ 3. **`server/attribution/` is its own module** — attribution is stored at generation time and never reconstructed (§4.4). Keeping it separate enforces that boundary.
2248
+
2249
+ 4. **`sdk/` is a separate TypeScript package** — it ships to customers as an npm package with its own build. Lives in the repo but is independently deployable.
2250
+
2251
+ 5. **`corpus/validation/` not `scripts/`** — corpus quality checks are ongoing (Loop 1, §13), not one-off scripts.
2252
+
2253
+ 6. **`supabase/migrations/`** — all new Orpheus tables live in a single migration file. Existing Probat tables (`app_users`, `experiment_runs`, `experiment_metrics`, `probat_events`, etc.) are referenced via FK but never modified.
2254
+
2255
+ ### Migration Path (MVP → Full Build)
2256
+
2257
+ | When | What changes |
2258
+ |------|-------------|
2259
+ | MVP Day 5 | `server/retrieval.py` splits into `server/retrieval/*.py` (incl. `composer.py`); add `_embed()` + `query_with_embedding()` methods |
2260
+ | Week 2 Day 1 | Run `supabase/migrations/orpheus_tables.sql`. Add `server/customers/`, `server/signals/` |
2261
+ | Week 2 Day 3 | Add `server/ltr/` with bundle-level features |
2262
+ | Week 3 | Add `server/attribution/`, wire signals to Supabase |
2263
+ | Week 4 | Add `server/integrations/`, `sdk/` |
2264
+
2265
+ No rewrites — single files promote into modules as complexity demands.
2266
+
2267
+ ---
2268
+
2269
+ ## Summary
2270
+
2271
+ The architecture satisfies all three requirements:
2272
+
2273
+ **R1 (Good ideation):** Hybrid retrieval (BM25 + dense + RRF) per domain, followed by corpus-anchored composition: Variant 1 from `ui-reasoning.csv` expert knowledge, Variants 2–3 by maximum embedding distance with uniqueness enforcement. Strategy labels are post-hoc descriptors, not retrieval constraints — the corpus determines what "different" means for each query. No LLM in the design decision loop — deterministic, fast, fully attributed.
2274
+
2275
+ **R2 (Adaptability):** Three feedback layers that activate progressively as data accumulates. Layer 1: selection events → bundle-level pairwise labels (free, immediate). Layer 2: edit events → domain-level pairwise labels, split by type (swap_doc, freeform, partial); freeform edits additionally surface corpus gaps. Layer 3: A/B results → CVR-weighted labels, staged at day 0/7/30. LambdaRank trains on all three sources nightly. `global_win_rate` in ChromaDB metadata updates nightly so retrieval is adaptive even before LTR is built.
2276
+
2277
+ **R3 (Referenceability):** Attribution stored at generation time in `ComposedVariant`. Every recommendation traceable to specific corpus doc IDs, the `ui-reasoning.csv` row that anchored V1, retrieval scores per stage, LTR feature importance, and contributing experiments. User-facing explanation generated from stored attribution — never reconstructed after the fact.
2278
+
2279
+ The biggest execution risk is corpus quality. Every other component amplifies the corpus — it cannot compensate for gaps in it. Week 1 corpus validation is the most important gate in the entire rollout.