V4 multi-factor scoring · 2026-05-09 Unified Pipeline (V2 + V4)

The Halulu Algorithm

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.

~8–14s total ~$0.02 warm · ~$0.18 cold per search Top 9 results

1. The pipeline: 4 endpoints, scored on the server

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.

1
Claude Haiku 5.5 · 1–2s

Understand

Parse query → QueryContext: cuisineTypes, dishMentions, vibeKeywords, occasion, dietary, priceHint, queryType, primaryFocus, sentimentWeights, and searchVariations (the 1–5 search strings Step 2 will use).
lib/search/understand.ts
2
Places API · 3–5s

Discover (+ reviews folded in)

Text search + 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.
lib/search/discover.ts
3
Claude Haiku 5.5 · 3–5s

Analyze

Per-restaurant analysis: dishMatchConfidence, dishSentiment, trend, best pro/con. Reviews + enriched data arrive inline from Step 2.
lib/search/analyze.ts
4
Server · instant

Score & Rank

Pure math on the server, with no API call: gates + linear map + trend bonus. Sort by score, tiebreak by review count. The iPhone search runs it inside runSearchPipeline (through /api/search/run).
lib/scoring.ts

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.

2. The analyze pool: 18 + 12 split

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.

Pool A

Top-rated · 9 seats

Sorted by Google rating descending. Pure quality picks regardless of distance.

TOP_RATED_SEATS = 9
Pool B

Far-discovery · 3 seats

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.8
FAR_FLOOR_REVIEWS = 100
Total seats: 30. Cost and parallel-analyze time are unchanged from the prior cap, but a 4.2★ shop 12 km away now stands a chance against the 4.7★ chain 1 km away. That gap mattered after the donut-ring fix on 2026-05-07.

3. Hard gates: binary kill switches

Run before scoring. Any restaurant that fails either gate is dropped. No weighted penalty, no second chance.

!

Google rating < 3.0

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
!

dishMatchConfidence < 5 (dish queries only)

For queryType === 'dish', the place must demonstrate it actually serves the dish. Cuisine and vibe queries skip this gate.

DISH_CONFIDENCE_FLOOR = 5

4. The score formula: V4

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.

HALULU_SCORE = map(primary(queryType), 5–10 → 1.0–4.85)
+ trendBonus + distanceBonus + googleRatingBonus
clamped to [1.0, 5.0]
The primary signal

Picked by queryType

queryTypePrimary
dishdishSentiment
cuisinedishSentiment
vibesentimentBreakdown.ambiance
multi-constraintweighted blend
ambiguousweighted 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.

The trend

Recency signal: small but real

TrendBonus
improving+ 0.08
stable0
declining− 0.10

Asymmetric on purpose: a slipping place loses more than an improving place gains. Recent reviews matter.

The distance

Scaled to user's chosen radius

distanceKm vs maxDistanceBonus
< 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%.

The Google rating

Tiered bonus, weighted by review count

googleRatingBase bonus
≥ 4.5+ 0.15
4.0 – 4.5+ 0.05
3.5 – 4.00
3.0 – 3.5− 0.10
< 3.0GATE, 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.

Why query-aware: V3 used dishSentiment alone, which broke for vibe queries like "karaoke restaurant". There is no "karaoke dish" to score. V4 picks the right primary per queryType:
  • dish/cuisine → dishSentiment stays primary (targeted: "how good is their shawarma")
  • vibe → sentimentBreakdown.ambiance takes over (targeted: "how good is the karaoke vibe")
  • multi-constraint / ambiguous → weighted blend of food/service/ambiance/value using the per-query sentimentWeights from Step 1
dishMatchConfidence stays a hard gate (< 5 drops dish queries). It is a binary "do they serve it" signal, useless for ranking.

5. Try it: interactive scoring

The actual calculateV4Score from lib/scoring.ts, running in your browser. Slide the inputs to see the math.

4.6
Halulu Score

5b. The full journey: every decision in the algorithm

Read top-to-bottom. Yellow boxes are decisions, red boxes are where a restaurant gets dropped. This is what runs for every single search.

USER INPUT "shawarma in Riyadh"
+ user coordinates · language (en/ar) · maxDistance (Super Yumi filter)
Step 1 · Claude Haiku 5.5 UNDERSTAND the query
One Claude Haiku 5.5 call (Anthropic SDK structured output, Zod-validated). Extracts a 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.
lib/search/understand.ts · ~1–2s
queryType is one of:
dishshawarma, kebab, pizza, kunafa
cuisineitalian, lebanese, japanese
viberomantic, family, rooftop
multi-constraintcombinations
ambiguousfallback
queryType propagates downstream in two places: Gate 2 only fires when 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.)
Step 2 · Places API DISCOVER candidates (+ reviews folded in)
Places API 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.
Reviews folded in (2026-05-30): the field mask carries 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).
Donut-ring sampling (only when 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.
lib/search/discover.ts · ~1–3s
Split the analyze pool into 2 lanes
POOL A Top-rated · 9 seats
Top 9 by Google rating, regardless of distance.
POOL B Far-discovery · 3 seats
Excluded from Pool A · ≥ 3.8★ · ≥ 100 reviews · sorted by distance descending.
Up to ~30 candidates proceed to analyze (18 + 12 is the cap; can be fewer if rawDiscovered is small). The pool is intentionally NOT shrunk to the 9 we display: dishSentiment is independent of Google rating, so a small rating-ranked pool would hide specialists. Reviews + enriched data ride along INLINE from Step 2, so there is no separate reviews fetch. Restaurants that don't make Pool A and fail Pool B's thresholds are silently dropped here, with no second chance.
Step 3 · Claude Haiku 5.5 (parallel) ANALYZE per restaurant
One Claude Haiku 5.5 call per restaurant, all in parallel. Anthropic SDK structured output (Zod-validated). Inputs: name, placeId, primaryType; reviews (stratified sample, see box below; each review line shows the rating and, when Google sends it, the age of the review, for example 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.
Review sampling (stratified, not rating-desc): Pure rating-desc + slice was chopping the negative tail off (Google ratings skew positive; top-8-by-rating ≈ all 4-5★ → bestCon empty). Now: filter into positive (≥4★) + negative (≤3★) pools, reserve up to 2 slots for negatives if any exist, fill the remaining slots with 4-5★ reviews in the order discover returned them (no sort). Cap at 8 reviews. Order is deterministic (positives first, then negatives) for run-to-run stability. The age on each line lets the model weigh recent reviews and judge the trend.
Outputs per restaurant, used by V4 scoring: 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.
bestCon prompt cascade (REAL ALWAYS, never fabricate):
  1. PRIMARY, if any [1★]/[2★]/[3★] review exists, quote from it (8+ words).
  2. FALLBACK, if all reviews are [4★]/[5★], extract a verbatim CRITIQUE FRAGMENT from inside a positive review (e.g. quote "a bit pricey" from a 5★ saying "great food but a bit pricey"). 4+ words OK. Must be a SUBSTRING of an actual review.
  3. EMPTY, only when zero critique exists anywhere. Honest empty > fabrication.
Backed by per-restaurant [ANALYZE-V2] EmptyCon: diagnostic logging (negativeReviews count) so audit trails confirm each empty is "honestly empty" and not "the model punted."
Schema also requires (collected but currently unused outside of V4 multi-factor blends): reviewsAnalyzed, searchTermMentioned. These consume AI output tokens but feed nothing downstream. The field reviewRecencyMonths was removed on 2026-11-11 for the same reason.
lib/search/analyze.ts · ~3–5s
Step 4 · Server (instant) SCORE & RANK
Now the per-restaurant gauntlet begins. Each analyzed restaurant runs through:
Gate 1: googleRating ≥ 3.0?
YES
continue
NO
structural problems no good dish can rescue
Gate 2: queryType is "dish" AND dishMatchConfidence < 5?
YES (gate fires)
place doesn't actually serve the dish
NO
continue to scoring
Cuisine and vibe queries skip Gate 2. For those, match evidence is just a tiebreaker, not a kill switch.
THE FORMULA: V4 primary = pickPrimarySentiment(analysis, queryContext)
  dish, cuisine → analysis.dishSentiment
  vibe → analysis.sentimentBreakdown.ambiance
  multi, ambiguous → weighted blend (food/service/ambiance/value)

clamped = clamp(primary, 0, 10)
raw = 1.0 + ((clamped − 5) / 5) × 3.85 // compressed to leave headroom for bonuses

trendBonus = +0.08 / 0 / −0.10
distanceBonus = 0 / −0.10 / −0.20 (vs maxDistance × 0.5 / 0.8)
ratingBonus = baseBonus × min(1, reviewCount / 500) (positive only)
  baseBonus = +0.15 / +0.05 / 0 / −0.10 (≥4.5 / ≥4.0 / ≥3.5 / <3.5)

aiRating = clamp(raw + trendBonus + distanceBonus + ratingBonus, 1.0, 5.0)
SORT aiRating descending, tiebreak by reviewCount
No diversity dampening (it caused 5.0 to display below 4.6, confusing UX). The hard gates already filter cross-cuisine spam.
SLICE Take top 9
Tail is dropped silently. The next steps only see the survivors.
Badge assignment loop · max 2 badges total · score ≥ 3.5 to qualify
1️⃣ Hidden Gem score ≥ 3.8
50 ≤ rev ≤ 500
Google ≥ 4.0
2️⃣ Crowd Tested score ≥ 4.0
rev ≥ 1500
3️⃣ Best Value score ≥ 4.0
Google ≤ 4.0
rev ≥ 100
Awarded in order. As soon as 2 badges are assigned, the loop exits, even if a later category has eligible candidates.
RENDER 9 ranked restaurants, up to 2 badged, with bestPro/bestCon translations
→ markers + carousel (mobile) or sidebar (desktop)

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.

6. Badges: competitive, max 2 per search

Badges are awarded after scoring, in priority order. A restaurant must score ≥ 3.5 to be eligible. Priority: Hidden Gem > Crowd Tested > Best Value.

🔮 Hidden Gem

Great dish at an undiscovered place

  • aiRating ≥ 3.8
  • 50 ≤ reviews ≤ 500
  • Google ≥ 4.0

Tiebreak: fewer reviews wins (more "hidden").

🏆 Crowd Tested

Great dish, proven by thousands

  • aiRating ≥ 4.0
  • reviews ≥ 1500

Tiebreak: more reviews wins (more proven).

💎 Best Value

AI found a gem at an underrated place

  • aiRating ≥ 4.0
  • Google ≤ 4.0
  • reviews ≥ 100

Tiebreak: largest aiRating − googleRating gap.

7. Replacement log

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 onlyPromoted 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.tsLatent 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 findRestaurantsMultiStepV2 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 flagV3 was hardcoded after rollout finished.
ALGORITHM_V2_ROLLOUT_PCT flagSame, no more gradual rollout.
Per-user feature-flag fetchOne pipeline, no branching.
Diversity dampeningCaused confusing UI: 5.0 score listed below a 4.6 score after dampening. The hard gate already filters irrelevant cuisines.
trending, premium, local-favorite badgesReplaced by Hidden Gem / Crowd Tested / Best Value.

8. Key files (reading order)

FileRole
handlers/search/run-pipeline.tsThe in-process orchestrator behind /api/search/run. It calls the stage functions of understand, discover and analyze.
lib/search/understand.tsAI query intent (Claude Haiku 5.5) → QueryContext.
lib/search/discover.tsPlaces Text Search + donut-ring sampling; reviews + enriched folded in (dual-language).
api/search/run.tsThe only search endpoint: the iPhone app's one-call search. It runs the pipeline and scores on the server.
lib/search/analyze.tsPer-restaurant AI analysis (Claude Haiku 5.5).
lib/scoring.tsV4 query-aware multi-factor scoring + badge assignment. Runs on the server.
lib/search/types.tsShared types: QueryContext, RestaurantAnalysis, etc.
lib/cache/supabase-cache.tsCache helpers: searchTextCacheGet/Set (geohash6-keyed, 28d TTL), inline geohashEncode. Fail-open: errors return null/silent.
handlers/crons/refresh-place-cache.tsDaily 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.tsDaily 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.

9. Cache architecture (Phase 1–4, shipped 2026-05-12)

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.

LayerKeyTTLWhat it stores
searchtext_cache(geohash6, query, language)28 daysPlaces 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):

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).

10. AI models and cost: before and today

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.

Where each model runs in the product

ModelWhere it runsCalls per searchStatus
Claude Haiku 5.5
claude-haiku-5-5
Step 1, understand: reads the craving and writes the QueryContext (lib/search/understand.ts)1Live
Claude Haiku 5.5Step 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 parallelLive
Claude Haiku 5.5The iPhone app search: the same two steps, run inside api/search/run.ts through runSearchPipelinesame as the websiteLive
Google Places Text Search (not an AI model)Step 2, discover: finds the places and brings their reviewsabout 4 on a cold search, 0 when cachedLive, unchanged

Price per token

ModelInput, per 1M tokensOutput, 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).

Cost of one search, measured

Before: Gemini 3.5 Flash Lite
(measured 2026-07-30)
Today: Claude Haiku 5.5
(measured 2026-11-04)
Change
AI tokens per search62,574 in + 6,345 out134,152 in + 8,069 outClaude counts about 2x the tokens for the same text
AI cost per search$0.0346$0.0174−50%
Places, cold searchabout $0.16about $0.16same
Cold search, total$0.1972about $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.32still 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.

Why Haiku: the one-time model test (2026-11-04)

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 FlashClaude Haiku 5.5 (today)Claude Sonnet 5.5 (quality ceiling)
AI cost per search$0.034at least $0.071 ($0.14 from 2027-01-01)$0.016$0.32
Dish score moves between two runs0.30 to 0.430.180.16one run
Missing translations6 to 9000
Con quote found in the reviews96%100%100%100%
Ranking agrees with Sonnet (1.00 = the same)0.80 to 0.851.000.801.00
Analyze call, median time1.6 s3.8 s2.3 s3.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.

Generated from a code read of 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.