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

The Halulu Algorithm

One Gemini-powered pipeline. Same engine, same results quality, for free and paid users. The Basic / Advanced toggle controls filter visibility, not algorithm quality.

~8–14s total ~$0.01–0.05 per search Top 9 results

1. The pipeline — 3 endpoints + client scoring

Single entrypoint findRestaurantsAdvanced in services/geminiService.ts. No fallback paths, no flag branches.

1
Gemini · 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).
/api/search/understand
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.
/api/search/discover
3
Gemini · 3–5s

Analyze

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

Score & Rank

Pure JS math — gates + linear map + trend bonus. Sort by score, tiebreak by review count.
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 Gemini. 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 Pro 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/cuisinedishSentiment stays primary (targeted: "how good is their shawarma")
  • vibesentimentBreakdown.ambiance takes over (targeted: "how good is the karaoke vibe")
  • multi-constraint / ambiguous → weighted blend of food/service/ambiance/value using Gemini's per-query sentimentWeights
dishMatchConfidence stays a hard gate (< 5 drops dish queries) — it's 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 (Pro filter)
Step 1 · Gemini UNDERSTAND the query
Single Gemini call (Vercel AI SDK 6 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.
/api/search/understand · ~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 currently unused — V3 score is dishSentiment-only.)
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 handlers/search/understand.ts:33 instructs Gemini 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.
/api/search/discover · ~3–5s
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 · Gemini (parallel) ANALYZE per restaurant
One Gemini call per restaurant, all in parallel. Vercel AI SDK 6 structured output (Zod-validated). Inputs: name, placeId, primaryType; reviews (stratified sample, see box below); enriched place data folded in from Step 2's Text Search (editorialSummary, priceLevel); the QueryContext from Step 1; user's language. Forces temperature: 0 + "USE ONE DECIMAL PLACE" + scale anchoring (10.0=extraordinary, 8.0=excellent, ...) for stable scoring.
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 remaining slots with highest-rated positives. Cap at 8 reviews. Order is deterministic (positives first, then negatives) for temperature=0 stability.
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), bestPro, bestCon, and their translations.
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 "Gemini punted."
Schema also requires (collected but currently unused outside of V4 multi-factor blends): reviewRecencyMonths, reviewsAnalyzed, searchTermMentioned. These consume Gemini output tokens but feed nothing downstream.
/api/search/analyze · ~3–5s
Step 4 · Client (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 Gemini call · coral is a Places API call · mint is pure client-side JS. 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%. Pro 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; legacy V1 paths inside discover.ts and analyze.ts still exist but are unreachable from the client.
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
services/geminiService.tsClient orchestrator. Single entrypoint findRestaurantsAdvanced.
api/search/understand.tsGemini query intent → QueryContext.
api/search/discover.tsPlaces Text Search + donut-ring sampling; reviews + enriched folded in (dual-language).
api/search/reviews.tsLegacy places.get review fetch, no longer called by the v2 client path (2026-05-30).
api/search/analyze.tsPer-restaurant Gemini analysis.
lib/scoring.tsClient-side V4 query-aware multi-factor scoring + badge assignment.
lib/search/types.tsShared types: QueryContext, RestaurantAnalysis, etc.
lib/cache/supabase-cache.tsCache helpers: l2CacheGet/Set (place_cache, 28d TTL), searchTextCacheGet/Set (geohash6-keyed, 28d), inline geohashEncode. Fail-open: errors return null/silent.
handlers/crons/refresh-place-cache.tsWeekly rebake. Refreshes hot place_cache entries expiring in <7 days, evicts entries untouched in 60+ days. Runs Sun 03:00 UTC.
handlers/crons/cache-health-check.tsDaily sentinel. Cross-references search_analytics vs place_cache.created_at; emails admin if traffic happened but no cache writes. Runs daily 09:00 UTC.

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

Three-table Supabase cache, 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 ~$0.19 directly (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: place_cache is now dormant — the v2 client path no longer calls /reviews, so nothing writes it; reviews are cached inside searchtext_cache instead. The rebake cron and cache-health-check sentinel below still target place_cache, so they are pending re-evaluation (the sentinel will report "traffic but no place_cache writes" daily until repointed at searchtext_cache).

LayerKeyTTLWhat it stores
place_cache(place_id, language)28 daysPlaces places.get response — reviews + editorialSummary + priceLevel. Dormant since the 2026-05-30 fold-in: the v2 path no longer writes it.
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).
search_cachecache_key2 hoursL1 full-search cache. Defined in schema; not wired into pipeline yet.

Why 28 days, not 30: Google Maps Platform ToS §3.2.3 caps Places content caching at 30 days. 28 leaves a 2-day buffer for the weekly rebake to refresh hot entries before they tip past the limit.

Hit tracking: both place_cache and searchtext_cache have hit_count + last_hit_at columns. Reads fire-and-forget an atomic RPC (increment_place_cache_hit, increment_searchtext_cache_hit) so the rebake cron can distinguish hot vs cold without affecting read latency.

Rebake logic (handlers/crons/refresh-place-cache.ts, Sun 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).

Generated from a code read of lib/scoring.ts, services/geminiService.ts, lib/search/types.ts, lib/search/schemas.ts, lib/api/ai-client.ts, all four endpoint files in api/search/ (reviews.ts now legacy, unused by the v2 path), the handler at handlers/search/understand.ts, and the architecture section of CLAUDE.md. Pipeline currently runs 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) since 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 is ~$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.