One AI pipeline, on Claude Haiku 5.5 since 2026-11-04. Same engine, same results quality, for free and paid users. The Basic / Advanced toggle controls filter visibility, not algorithm quality.
The iPhone app calls one endpoint, /api/search/run: it runs understand, discover, analyze and the score in one request (handlers/search/run-pipeline.ts). The three web endpoints (understand, discover, analyze) were deleted on 2026-11-12, when the web search closed; their steps now live in lib/search/. No fallback paths, no flag branches.
QueryContext: cuisineTypes, dishMentions, vibeKeywords, occasion, dietary, priceHint, queryType, primaryFocus, sentimentWeights, and searchVariations (the 1–5 search strings Step 2 will use).locationBias.circle, then a baseline filter (≥ 50 reviews + within distance). Donut-ring offset sampling kicks in when maxDistance > 15 km. As of 2026-05-30 reviews fold in here: the field mask includes places.reviews + places.editorialSummary (Enterprise+Atmosphere SKU), so each Text Search call returns up to 5 reviews/place. Issued in both en + ar (languageCode biases the set) and deduped to up to 10 unique reviews/place. Prefers review.originalText over review.text. No separate /reviews places.get fan-out.dishMatchConfidence, dishSentiment, trend, best pro/con. Reviews + enriched data arrive inline from Step 2.runSearchPipeline (through /api/search/run).
Reviews fold-in (2026-05-30): reviews now ride inline on the discover Text Search call (Enterprise+Atmosphere field mask), eliminating the per-place places.get fan-out that was ~89% of cold cost. Per-search cost dropped ~$1.4–1.6 cold to ~$0.19 (~88% cut) in the architecture, not the cache. The Supabase cache (Phase 1–4, shipped 2026-05-12) still wraps the searchText call by (geohash6, query, language) for 28 days and layers on top for repeat geohashes. See section 9.
Discover returns up to ~40 candidates. We display 9, but deeply analyze up to ~30 (18 top-rated + 12 far). We do NOT shrink this to match the 9 we show: the final score is dishSentiment-primary and dishSentiment is independent of Google rating, so a small rating-ranked pool would hide the specialist (a 4.3★ place with the best dish) that V4 exists to surface. Reviews are folded into discover and already paid for every candidate, so a big pool costs only AI tokens. Pure top-by-rating filters out 4.0–4.4★ far places that the donut-ring sampling deliberately surfaced, so the seats are still split.
Sorted by Google rating descending. Pure quality picks regardless of distance.
TOP_RATED_SEATS = 9
Anything not already in Pool A, filtered to ≥ 3.8★ and ≥ 100 reviews, sorted by distance descending. Surfaces good places further away.
FAR_FLOOR_RATING = 3.8FAR_FLOOR_REVIEWS = 100
Run before scoring. Any restaurant that fails either gate is dropped. No weighted penalty, no second chance.
Hard floor. Anything below 3.0 stars is excluded regardless of dish quality. Reflects "this place has structural problems no good shawarma can rescue."
GOOGLE_RATING_FLOOR = 3.0
For queryType === 'dish', the place must demonstrate it actually serves the dish. Cuisine and vibe queries skip this gate.
DISH_CONFIDENCE_FLOOR = 5
Query-aware primary signal + three small additive modifiers. Total possible swing on top of primary-mapped raw: best +0.23, worst −0.40. Top picks stay top picks; the new factors break ties and surface near-misses.
| queryType | Primary |
|---|---|
| dish | dishSentiment |
| cuisine | dishSentiment |
| vibe | sentimentBreakdown.ambiance |
| multi-constraint | weighted blend |
| ambiguous | weighted blend |
"karaoke restaurant" is a vibe query, so its primary signal is the ambiance score, not how good the food is. Multi-constraint queries blend all four sentiment dimensions weighted by queryContext.sentimentWeights from /understand.
| Trend | Bonus |
|---|---|
| improving | + 0.08 |
| stable | 0 |
| declining | − 0.10 |
Asymmetric on purpose: a slipping place loses more than an improving place gains. Recent reviews matter.
| distanceKm vs maxDistance | Bonus |
|---|---|
| < 50% (close) | 0 |
| 50–80% (mid) | − 0.10 |
| ≥ 80% (far edge) | − 0.20 |
A Super Yumi user picking 50km is opting in to far results, so a flat ">10km penalty" would undo their choice. Penalty kicks in past 50% of the user's chosen radius, deepens past 80%.
| googleRating | Base bonus |
|---|---|
| ≥ 4.5 | + 0.15 |
| 4.0 – 4.5 | + 0.05 |
| 3.5 – 4.0 | 0 |
| 3.0 – 3.5 | − 0.10 |
| < 3.0 | GATE, drop |
Confidence weighting: positive bonuses are multiplied by min(1, reviewCount / 500), a 4.8★ place with 150 reviews gets only 30% of the EXCELLENT bonus (+0.045), while a 4.6★ place with 1500 reviews gets the full +0.15. Reflects the Law of Large Numbers, high-N ratings are statistically more reliable than low-N. The −0.10 penalty is NOT shrunk.
dishSentiment alone, which broke for vibe queries like "karaoke restaurant". There is no "karaoke dish" to score. V4 picks the right primary per queryType:
dishSentiment stays primary (targeted: "how good is their shawarma")sentimentBreakdown.ambiance takes over (targeted: "how good is the karaoke vibe")sentimentWeights from Step 1dishMatchConfidence stays a hard gate (< 5 drops dish queries). It is a binary "do they serve it" signal, useless for ranking.
The actual calculateV4Score from lib/scoring.ts, running in your browser. Slide the inputs to see the math.
Read top-to-bottom. Yellow boxes are decisions, red boxes are where a restaurant gets dropped. This is what runs for every single search.
QueryContext with cuisineTypes, dishMentions, vibeKeywords, occasion, dietary, priceHint (optional), maxPriceLocal (optional), primaryFocus, sentimentWeights (food/service/ambiance/value), queryType, and searchVariations: the 1–5 search strings Step 2 fans out into Places API calls.queryType === 'dish', and getMatchEvidence picks dishMatchConfidence for dish, cuisineMatchConfidence for cuisine, or the larger of the two for vibe / multi-constraint / ambiguous. (For non-dish queries the picked match evidence is not used: only the dish gate reads it.)searchText calls from the QueryContext searchVariations, capped at MAX_VARIATIONS = 2 and issued in both languages (en + ar): 2 variations × 2 languages = 4 calls at the 10 km default. The prompt at lib/search/understand.ts:34 instructs the model to return 2–4 strings; the Zod schema at schemas.ts:28 permits 1–5. Each call is centered at the user with locationBias.circle radius = user's maxDistance (default 10 km). maxResultCount: 20 per call. After all calls return, results are merged by placeId (reviews accumulated across language calls), then filtered to userRatingCount ≥ 50 and within maxDistance.places.reviews + places.editorialSummary (Enterprise+Atmosphere SKU, $0.040/call), so each Text Search returns up to 5 reviews/place. languageCode biases the set, so the en + ar calls return disjoint reviews, merged and deduped by normalized text to up to 10 unique reviews/place. Prefers review.originalText over review.text. This eliminates the per-place places.get fan-out (~89% of old cold cost).maxDistance > 15 km): 2 additional calls at offset centers north + south of the user. Offset distance = min(maxDistance × 0.6, 30) km. Each offset call uses a fixed 15 km radius, the primary language only, and only the first capped variation.3. [4★, 2 months ago] "text"); enriched place data folded in from Step 2's Text Search (editorialSummary, priceLevel); the QueryContext from Step 1; user's language. The Claude call sends no temperature, because the model rejects any value. It keeps "USE ONE DECIMAL PLACE" + scale anchoring (10.0=extraordinary, 8.0=excellent, ...) for stable scoring. In the 2026-11-04 test, the dish score moved 0.16 between runs on Haiku, and 0.30–0.43 on Gemini at temperature: 0.dishMatchConfidence (0–10), dishSentiment (0–10 with decimals), cuisineMatchConfidence, sentimentBreakdown (foodQuality/service/ambiance/value, 1–10 each; V4 uses these for vibe + multi-constraint queries), trend (improving/stable/declining, judged from the ages and the ratings in the review lines), bestPro, bestCon, and their translations, plus bestProReviewNumber and bestConReviewNumber: the number of the sampled review that each quote comes from. The server reads each number against the same sampled list, checks that the quote is inside that review, and attaches the Google author credit (name, profile link, photo, review link). The prompt never carries author names. A result card keeps a quote only when one of the place's reviews holds it: a quote that no review holds goes out with its translation and its credit.[ANALYZE-V2] EmptyCon: diagnostic logging (negativeReviews count) so audit trails confirm each empty is "honestly empty" and not "the model punted."
reviewsAnalyzed, searchTermMentioned. These consume AI output tokens but feed nothing downstream. The field reviewRecencyMonths was removed on 2026-11-11 for the same reason.Each teal step is a Claude Haiku 5.5 call · coral is a Places API call · mint is pure JavaScript on the server (inside /analyze for the website, inside the pipeline for the iPhone app). Yellow diamonds are decisions, red boxes mean exclusion.
Badges are awarded after scoring, in priority order. A restaurant must score ≥ 3.5 to be eligible. Priority: Hidden Gem > Crowd Tested > Best Value.
Tiebreak: fewer reviews wins (more "hidden").
Tiebreak: more reviews wins (more proven).
Tiebreak: largest aiRating − googleRating gap.
Each row is something that used to live in code, and is now gone. The old marketing doc may still describe these.
| Replaced on 2026-05-09 (V3 → V4) | Why |
|---|---|
V3 scoring (calculateV3Score: dishSentiment alone + trend) | Broke for vibe queries, "karaoke restaurant" was scored on how good the food was, not how good the vibe was. V4 picks the primary signal per queryType: dishSentiment for dish/cuisine, ambiance for vibe, weighted blend for multi-constraint. |
Discarded data in analysis.sentimentBreakdown (foodQuality / service / ambiance / value) | Gemini was already computing these (Zod-required in RestaurantAnalysisSchema). V3 ignored them. V4 uses ambiance as the vibe-query primary and blends all four for multi-constraint queries. |
| googleRating as binary gate only | Promoted to a small tiered modifier (+0.15 / +0.05 / 0 / −0.10). Captures statistical confidence without re-introducing V2's "big chains beat specialists", capped at +0.15 above the dishSentiment-driven raw. |
| Distance unused in scoring (filter + pool-split only) | Now a small additive modifier scaled to the user's chosen maxDistance. Penalty kicks in past 50% of their radius, deepens past 80%. Super Yumi user picking 50km opted in to far results, a flat ">10km penalty" would have undone their choice. |
QueryType = 'dish' | 'cuisine' | 'vibe' | 'ambiguous' in lib/search/types.ts | Latent type drift, the Zod schema returned 5 values including 'multi-constraint', but the TypeScript type only had 4. SENTIMENT_WEIGHTS lookup silently returned undefined for multi-constraint queries. Fixed in the same V4 commit. |
| Removed on 2026-05-06 (V2/V3 cleanup) | Why |
V1 algorithm, client entrypoint findRestaurantsMultiStep | V2 was at 100% rollout for 22 days. The client function is gone, and the legacy V1 paths in discover.ts and analyze.ts were deleted (2026-07-29 and 2026-11-04). |
| V1 scoring (Bayesian + 6 components) | Score = "how good is this restaurant" instead of "how good is their dish". Wrong question. |
V2 scoring (V1's Bayesian formula × dishMatchConfidence soft-gate multiplier 0.5–1.0, plus V1's trend bonuses) | Recovered from git show ccf9823^:lib/scoring.ts. Replaced by V3, which used dishSentiment alone, matchConfidence saturates at 10/10 for in-category restaurants and gives no ranking signal. |
SCORING_V3_ENABLED flag | V3 was hardcoded after rollout finished. |
ALGORITHM_V2_ROLLOUT_PCT flag | Same, no more gradual rollout. |
| Per-user feature-flag fetch | One pipeline, no branching. |
| Diversity dampening | Caused confusing UI: 5.0 score listed below a 4.6 score after dampening. The hard gate already filters irrelevant cuisines. |
trending, premium, local-favorite badges | Replaced by Hidden Gem / Crowd Tested / Best Value. |
| File | Role |
|---|---|
handlers/search/run-pipeline.ts | The in-process orchestrator behind /api/search/run. It calls the stage functions of understand, discover and analyze. |
lib/search/understand.ts | AI query intent (Claude Haiku 5.5) → QueryContext. |
lib/search/discover.ts | Places Text Search + donut-ring sampling; reviews + enriched folded in (dual-language). |
api/search/run.ts | The only search endpoint: the iPhone app's one-call search. It runs the pipeline and scores on the server. |
lib/search/analyze.ts | Per-restaurant AI analysis (Claude Haiku 5.5). |
lib/scoring.ts | V4 query-aware multi-factor scoring + badge assignment. Runs on the server. |
lib/search/types.ts | Shared types: QueryContext, RestaurantAnalysis, etc. |
lib/cache/supabase-cache.ts | Cache helpers: searchTextCacheGet/Set (geohash6-keyed, 28d TTL), inline geohashEncode. Fail-open: errors return null/silent. |
handlers/crons/refresh-place-cache.ts | Daily cache upkeep. Evicts searchtext_cache rows untouched in 60+ days and deletes every expired row. Runs each day at 03:00 UTC (weekly until 2026-11-11). |
handlers/crons/cache-health-check.ts | Daily sentinel. Compares search_analytics with the cache writes (expires_at) and hits (last_hit_at) of the last 24 hours; emails the admin if 3 or more searches happened and the cache had no write and no hit. Runs daily 09:00 UTC. |
One-table Supabase cache (three until 2026-11-11, two until 2026-11-12), shipped 2026-05-12 to cut the then-~$1.12 cold cost to ~$0.10 warm. Every cache layer is fail-open: if Supabase errors or env vars are missing, the live pipeline runs unaffected. Failures log with [ALERT] [CACHE-*] prefixes for grep-able alerts.
⚠️ Superseded as the primary cost lever by the reviews fold-in (2026-05-30). Cold cost is now about $0.18 directly (measured 2026-11-04). Reviews ride inline on the Text Search call, so the cache layers on top of an already-cheap baseline rather than being the main reducer. Knock-on: reviews are cached inside searchtext_cache. The place_cache table then served only the paste-a-link code, and both were removed on 2026-11-12.
| Layer | Key | TTL | What it stores |
|---|---|---|---|
searchtext_cache | (geohash6, query, language) | 28 days | Places searchText response, array of place objects, now incl. folded-in reviews + editorialSummary (2026-05-30). Query key includes radius + field-mask version: ${q}|r${km}km|fm${v} (bump L2_FIELDMASK_VERSION on any mask change so stale rows miss). |
Why 28 days, not 30: Google Maps Platform ToS §3.2.3 caps Places content caching at 30 days. A row has a 28-day lifetime, and the daily cron deletes it within one day of its expiry, so no row is older than 29 days. The full-search cache (search_cache) was never used and was removed on 2026-11-11.
Hit tracking: searchtext_cache has hit_count + last_hit_at columns. A read fires an atomic RPC (increment_searchtext_cache_hit) and does not wait, so the cron can tell hot rows from cold rows without a slower read.
Daily cron logic (handlers/crons/refresh-place-cache.ts, each day 03:00 UTC):
searchtext_cache rows where last_hit_at < now − 60d.expires_at < now. Until 2026-11-11 an expired row stayed until it went cold.Cache hit observability: failure logs use [ALERT] [CACHE-*] prefix. Free-tier alert: /api/crons/cache-health-check runs daily 09:00 UTC; emails admin if search_analytics shows traffic but no cache writes happened (catches the exact failure class, silent fail-open, that caused weeks of invisible breakage before 2026-05-12).
On 2026-11-04 the two AI steps moved from Gemini 3.5 Flash Lite to Claude Haiku 5.5. A side-by-side test on real reviews (scripts/compare-ai-models.ts) made the choice. Places, the V4 formula and the badges did not change.
Production uses one AI model: Claude Haiku 5.5. No Google AI (Gemini) code remains in the product since 2026-11-04. Google Places is the only Google service, and it supplies map and review data, not AI.
| Model | Where it runs | Calls per search | Status |
|---|---|---|---|
Claude Haiku 5.5claude-haiku-5-5 | Step 1, understand: reads the craving and writes the QueryContext (lib/search/understand.ts) | 1 | Live |
| Claude Haiku 5.5 | Step 3, analyze: reads the reviews of one place and writes the pro, the con and the dish score (lib/search/analyze.ts) | about 24 to 30, in parallel | Live |
| Claude Haiku 5.5 | The iPhone app search: the same two steps, run inside api/search/run.ts through runSearchPipeline | same as the website | Live |
| Google Places Text Search (not an AI model) | Step 2, discover: finds the places and brings their reviews | about 4 on a cold search, 0 when cached | Live, unchanged |
| Model | Input, per 1M tokens | Output, per 1M tokens |
|---|---|---|
| Before: Gemini 3.5 Flash Lite | $0.30 | $2.50 |
| Today: Claude Haiku 5.5 (prompts of 100K tokens or fewer) | $0.10 | $0.50 |
| Places Text Search, Enterprise + Atmosphere | $0.040 per call, before and today | |
Prices checked on the vendor pages on 2026-11-04. The code reads them only from lib/search/cost.ts (AI_MODEL_PRICES, SEARCH_PRICING).
| Before: Gemini 3.5 Flash Lite (measured 2026-07-30) | Today: Claude Haiku 5.5 (measured 2026-11-04) | Change | |
|---|---|---|---|
| AI tokens per search | 62,574 in + 6,345 out | 134,152 in + 8,069 out | Claude counts about 2x the tokens for the same text |
| AI cost per search | $0.0346 | $0.0174 | −50% |
| Places, cold search | about $0.16 | about $0.16 | same |
| Cold search, total | $0.1972 | about $0.177 | −10% |
| Warm repeat (Places from cache) | $0.0374 | $0.0176 | −53% |
| Free user, 3 cold searches a month | $0.59 | $0.53 | |
| Super Yumi user, 30 cold searches a month | $5.92 | $5.32 | still above the $5 price |
| Super Yumi user, 30 warm searches a month | $1.12 | $0.53 |
The price list suggests a 70% saving on AI. The real saving is 50%, because Claude counts about twice the input tokens of Gemini for the same prompt. Places is still most of a cold search, so the cache hit rate still decides the Super Yumi margin ($5 a month, $4.17 a month on the yearly plan). Measure again with scripts/verify-search-meter.ts; never scale an old number.
We ran this test once, on a laptop, to choose the model. Gemini 3.5 Flash Lite, Gemini 3.8 Flash and Claude Sonnet 5.5 never run in production. Sonnet served as the quality yardstick.
| Gemini 3.5 Flash Lite (before) | Gemini 3.8 Flash | Claude Haiku 5.5 (today) | Claude Sonnet 5.5 (quality ceiling) | |
|---|---|---|---|---|
| AI cost per search | $0.034 | at least $0.071 ($0.14 from 2027-01-01) | $0.016 | $0.32 |
| Dish score moves between two runs | 0.30 to 0.43 | 0.18 | 0.16 | one run |
| Missing translations | 6 to 9 | 0 | 0 | 0 |
| Con quote found in the reviews | 96% | 100% | 100% | 100% |
| Ranking agrees with Sonnet (1.00 = the same) | 0.80 to 0.85 | 1.00 | 0.80 | 1.00 |
| Analyze call, median time | 1.6 s | 3.8 s | 2.3 s | 3.4 s |
Test: 6 cravings and 10 real restaurants in Al Khobar, 3 runs per model, the same reviews for every model. Two visible effects of the switch: Haiku gives dish scores about 0.3 to 0.4 lower than Gemini did (closer to Sonnet), so Halulu Scores sit about 0.3 lower and fewer places pass the badge thresholds. Each AI step is also about 0.7 s slower at the median.
lib/scoring.ts, handlers/search/run-pipeline.ts, lib/search/types.ts, lib/search/schemas.ts, lib/api/ai-client.ts, the stage files lib/search/understand.ts, discover.ts and analyze.ts, the endpoint api/search/run.ts, and the architecture section of CLAUDE.md.
Since 2026-11-04 the understand and analyze calls run on claude-haiku-5-5 (Claude Haiku 5.5, $0.10/M input + $0.50/M output) through the Anthropic SDK. A Claude model id is fixed, so the provider cannot move it to a new model under us. Measured on one real search (2026-11-04, "shawarma", Al Khobar, 10 km): the AI share is $0.0174 (134,152 input + 8,069 output tokens). A warm repeat costs $0.0176 in total, and a cold search with 4 billed Places calls costs about $0.177 (about $0.197 on Gemini). Claude counts about twice the input tokens of Gemini for the same prompt, so input is about 77% of the AI bill. Haiku gives dishSentiment about 0.3–0.4 lower on average than Gemini did (8.06 against 8.27–8.49), closer to Claude Sonnet 5.5 (7.88). Prices live only in lib/search/cost.ts. Gemini era (history): from 2026-05-09 to 2026-11-04 the pipeline ran on gemini-flash-lite-latest, an alias Google re-points without warning: it resolved to gemini-3.1-flash-lite ($0.25/M input + $1.50/M output) from 2026-05-09, and to gemini-3.5-flash-lite ($0.30/M input + $2.50/M output) from 2026-07-29. That flip raised output price ~67% with no deploy on our side, and also broke every Gemini call until thinkingBudget: 0 was replaced by thinkingLevel: 'minimal' (Gemini 3.x dropped thinking_budget). Measured on one real cold search (2026-07-29): the Gemini share was ~$0.035 ($0.0346 at 3.5 prices vs $0.0252 for the identical tokens at 3.1), a blended +38% rather than +67%, because analyze runs about 10:1 input-to-output so input is 54% of the bill. The Places share is unchanged and still dominates. Reviews fold-in (2026-05-30) cut per-search cost ~$1.4–1.6 cold to ~$0.19 (~88%) in the architecture: reviews ride inline on the discover Text Search call (Enterprise+Atmosphere $0.040/call), eliminating the per-place places.get fan-out. SKU note: places.reviews is an Atmosphere field, so a places.get carrying reviews was Enterprise+Atmosphere $0.025/call (not the plain Enterprise $0.020 previously cited). The Supabase cache (Phase 1–4, shipped 2026-05-12) still layers on top for repeat geohashes. See section 9.
Scoring is V4 (query-aware multi-factor) as of 2026-05-09, replacing V3 (dishSentiment-only).
Review sampling is stratified (≤2 negative slots reserved) and bestCon uses a 3-level cascade (1-3★ primary → 4-5★ critique fragment fallback → empty), instrumented with per-restaurant EmptyCon diagnostics, landed 2026-05-09 after audit showed pure rating-desc sampling was chopping the negative tail before Gemini saw it.