This is the engineering follow-up to Go Do Stuff: A Living Taste Profile and Live-Search Event Agent →. That post covers the product thesis and the 7-section preferences.md model. This post covers how we built an evaluation harness (buddy-eval), ran 34 experiments across four waves, and re-architected the search pipeline to cut median latency by 53.7% and marginal cost by 19.6% while tying baseline quality 15-15 in blind pairwise A/B testing.
I wanted to move beyond biased friends-and-family testing by running geo-targeted Reddit ads in Oakland and East Bay subreddits (r/oakland, r/berkeley at ~$10/day for $100 to $200 of cold-traffic R&D) and supporting a flat $5 to $6/month membership. I rejected pay-per-search credit systems because credits create "slot-machine anxiety" whenever a user gets a stack they do not love.
Before sending paid cold traffic into the app, two numbers forced an audit. First, clicking Scout for Events took 90 to 120+ seconds, which is fatal for a cold visitor staring at a spinner. Second, unit economics were tight because of how Google bills Gemini Grounding with Google Search: while gemini-3.8-flash LLM tokens ($0.75/1M input, $3.75/1M output and thinking) only cost $0.02-$0.04 per stack, Google Search Grounding bills per individual web search query executed by the model ($14.00 per 1,000 queries, or $0.014 per query after the 5,000 queries/month free tier). Our pipeline averaged 10.2 executed web queries locally (11.9 on our 30-case benchmark), costing $0.18-$0.21 per stack. On a $5.00/mo plan ($4.55 net after Stripe's 2.9% + $0.30), 5 to 10 stacks/month ($0.90-$1.80) works, unless an experiment accidentally balloons web queries to 17 or 18 per stack ($0.30-$0.34) and wipes out the margin.
I set one non-negotiable rule before we changed a line of code: I would rather accept a 20 to 40% gross margin with diverse, word-of-mouth-worthy 7 to 8 card stacks than chase an 80% software margin with repetitive, generic recommendations. We needed a real yardstick first.
1. Baseline Audit: Where the 97 Seconds & $0.21 Went
Before touching prompts or parameters, we inspected discovery_batches.telemetry_json from our first six real discovery batches in SQLite and traced the code path end to end. The original four-stage pipeline was an "untyped prose relay" taking 95 to 135 seconds of wall time:
- 1. Accidental tool combo + silent retry risk (
google_search+url_context): Stage 2 passed bothGoogleSearch()andUrlContext()inside atry / exceptthat retried withGoogleSearch()alone on failure. Telemetry showeddeep_read_urls = 0across all six batches. - 2. Serial multi-hop search inside one monolithic call: Passing 8 to 10 ultra-specific queries into a single
generate_contentcall meant Gemini reasoned, searched a few queries, read snippets, reasoned again, and ran follow-up searches (11.9executed queries on average) before writing a single token of output. - 3. Double LLM generation ("The Untyped Prose Relay"): Stage 2 wrote a 1,500 to 2,500 word Markdown essay (8,500 to 15,300 characters), and Stage 3 read that entire essay plus
preferences.mdagain to rewrite the exact same events intoStructuredDiscoveryOutputJSON, paying output and thinking latency twice. - 4. Full-chain redirect resolution (
follow_redirects=True): Our resolver calledhttpx.Client(follow_redirects=True).head(uri)with aGETfallback and4.0stimeout per URL, and deduplicated after network calls, waiting on slow third-party venue servers instead of just reading Google's edge302 Locationheader. - 5. Blocking image scraping + uncapped reasoning budgets:
fetch_event_images_parallelblocked the SSEcompleteevent for 5 to 10 seconds at the end, and neither Stage 1 nor Stage 3 set an explicitthinking_budget.
[Stage 1: Search Planner] (7.0-7.6s)
Reads preferences.md + prompt -> SearchPlanSchema JSON (8-10 narrow queries)
|
v
[Stage 2: Monolithic Grounded Search + Essay Generation] (66.7-68.6s mean, up to 115s)
All 8-10 queries stuffed into ONE generate_content() call
Enabled BOTH GoogleSearch() AND UrlContext() in a try/except fallback
Model runs serial multi-hop search turns -> writes 1,500-2,500 word Markdown essay
|
v
[Stage 2b: Eager Redirect Resolution] (3.1-8.0s)
Resolves ALL 30-40 vertexaisearch redirect URLs via httpx (follow_redirects=True),
opening TLS handshakes to 35+ external venue sites (even the ~30 URLs Stage 3 drops)
|
v
[Stage 3: Full Re-Read & JSON Re-Generation] (20.4-22.3s)
Re-reads preferences.md + 12k-char essay + 35 URLs
Spends 3,500-11,000 uncapped thinking tokens reconciling prose claims vs URLs
|
v
[Stage 4: Synchronous OpenGraph Image Scraping] (5.0-10.0s blocking SSE)
Scrapes HTML of all 5-8 venue sites BEFORE emitting the SSE complete event2. One Pure Function Shared by Production and Eval
A strict design requirement before writing any eval script was zero duplicated business logic. We did not want an evaluation script that re-implemented a test version of the prompts while the FastAPI app drifted separately. We refactored the production pipeline into a pure, database-independent function (execute_discovery_pipeline) parameterized by a single Pydantic model (DiscoveryConfig) that both FastAPI (run_structured_discovery) and buddy-eval call directly.
Initially, our telemetry only tracked total tokens and Stage 2 elapsed seconds, making it impossible to tell if a Stage 2 tweak quietly hurt Stage 3. We expanded SearchDiagnostics to capture per-stage wall-clock seconds (plan_seconds, search_seconds, redirect_seconds, extract_seconds, image_seconds), per-stage input_tokens, output_tokens, and thinking_tokens (with stage_token_telemetry_complete: bool so Stage 3 snapshot replays can replace extraction token counts cleanly without double-counting), exact executed_web_queries from candidate.grounding_metadata.web_search_queries, and a 16-character SHA-256 config_fingerprint computed over canonical sorted JSON.
class DiscoveryConfig(BaseModel):
# 1. Model & Provider Mode
model_name: str = "gemini-3.8-flash"
provider_mode: str = "gemini"
# 2. Stage 1: Query Generation (Planner) Knobs
exploitation_query_range: tuple[int, int] = (3, 3)
adjacent_query_range: tuple[int, int] = (2, 2)
wildcard_query_range: tuple[int, int] = (1, 1)
query_granularity_style: str = "hybrid" # "specific" | "multi_yield_clusters" | "hybrid"
plan_thinking_budget: int | None = 512
# 3. Stage 2: Grounded Search & Synthesis Knobs
enable_url_context_on_pass1: bool = False
search_execution_mode: str = "parallel_per_query" # "single_call" | "parallel_buckets" | "parallel_per_query"
per_query_candidate_limit: int = 4
single_search_per_worker: bool = True
compact_worker_profile_digest: bool = True
drop_ungrounded_worker_outputs: bool = True
attach_per_query_sources_inline: bool = True
single_hop_redirects: bool = True
# 4. Stage 3 & 4: Selection Criteria, Extraction & Image Knobs
target_card_range: tuple[int, int] = (5, 8)
extract_thinking_budget: int | None = 512
fetch_images: bool = True
# 5. Marginal Cost Constants
cost_per_web_query_usd: float = 0.014 # $14 / 1,000 grounded web queries
cost_per_1m_input_tokens_usd: float = 0.75
cost_per_1m_output_tokens_usd: float = 3.753. buddy-eval: Dataset, 4-Layer Rubric, Replay, & Cloud Ledger
Because live local events change every day, grading a live-search agent is much harder than grading static Q&A. We built uv run buddy-eval around a 100-case canonical dataset: 10 realistic 7-section preferences.md personas (1. Oakland maker/games, 2. SF intellectual salons, 3. Brooklyn offbeat arts, 4. Chicago culinary craft, 5. Portland stewardship/outdoors, 6. Austin sober weeknights, 7. Seattle cozy rainy weekend, 8. Minneapolis community maker, 9. Boulder smaller-market stress test, and 10. Denver cold-start sparse profile 30 seconds after onboarding) crossed with 10 relative-time prompt archetypes (weekend, tonight, weeknight, budget under $20, solo Saturday, hands-on, surprise, indoor cozy, daytime done by 5pm, and deeper cut neighborhood spaces) resolved dynamically against the runtime date.
We structured execution into three tiers: a 2-case Dry Run (--tier smoke --limit 2, ~1 min, ~$0.30) to catch broken schemas, a 12-case Smoke Suite (--tier smoke, ~4 min, ~$2.00) spanning all 10 cities, and a 30-case Stratified Benchmark (--tier full --limit 30 --seed 42) using a deterministic seeded Latin-square sampler (dataset_loader.py) that guarantees every contender runs on the exact same 30 (persona, prompt) pairs with 3 prompts per city and 3 instances of each prompt archetype at 30% of the 100-case cost.
Every case is graded across four layers:
- Layer 1: Hard Deterministic Checks (
evals/metrics_deterministic.py): 100% code-verified checks forcitation_integrity_rate(everysource_urlexists indiagnostics.sources),deep_link_rate(specific path/query vs bare domain),temporal_valid_rate,in_target_card_range(5-8cards),field_completeness_rate,no_em_dash_rate,zero_repeat_rateagainst Section 7, andbucket_coverage_score(1.0when all 3 discovery buckets are present). - Layer 2: Decomposed Structured LLM Judge (
evals/judge.py): Callsgemini-3.8-flash(temperature=0.1,thinking_budget=512,BatchJudgeEvaluationschema) to grade each card onviolates_hard_exclusion(exclusion_pass_rate),experiential_mechanics_fit(1-5),local_specificity_score(1-5),description_clarity_score(1-5), and a 2 to 3 wordactivity_archetype(like"wheel pottery"or"linocut printing") sounique_archetype_ratiocatches 3 pottery classes mathematically. Combines these withintra_batch_diversity_scoreandexploration_leap_qualityinto a0-100weightedcomposite_quality_score(25exclusion +25fit +20diversity +15specificity +15exploration/overall). - Layer 3: Position-Debiased Blind Pairwise A/B (
buddy-eval compare <run_a> <run_b>): Deterministically hashescase_id(int(md5(case_id), 16) % 2 == 1) to swapSTACK_1andSTACK_250/50 across the dataset, eliminating first-position bias, and picks a winner on overall stack, fit, diversity, and specificity. - Layer 4: Zero-Search-Cost Stage 3 Snapshot Replay (
buddy-eval replay --from-run <tag>): Re-runs only Stage 3 extraction and Layer 1/2 judging against saveddiagnostics.raw_synthesisanddiagnostics.sourcesin ~30 seconds for$0.00in Google Search fees, isolating Stage 3 prompt and thinking-budget changes with zero live-web variance.
Deep Dive: Cloud MLOps ledger (GCS + 6-tab Google Sheet + Cloud Run Jobs)
Running dozens of experiments locally created artifact sprawl, and a single flat CSV in Cloud Storage would hit race conditions when running parallel benchmark jobs. We built a 3-part cloud persistence stack:
Before launching any cloud run, uv run buddy-eval estimate-cost prints expected executed queries, search grounding USD, token USD, judge USD, and wall-clock minutes. Then ./scripts/run-eval-job.sh triggers the event-buddy-eval Cloud Run Job (2 vCPU, 2Gi RAM, 3600s timeout), which writes straight to GCS and updates the Google Sheet live.
- Immutable JSON Artifacts in GCS (
gs://<project>-evals/evals/): Stores full-fidelityruns/<tag>.jsonandconfigs/<tag>.json. Callingload_run_artifact(tag)auto-downloads from GCS on local cache miss so cloud runs can be inspected (show), replayed (replay), or compared (compare) from a laptop immediately. - Normalized 6-Tab Google Sheets Workbook (
evals/sheets_export.py): Every run upserts into six normalized tabs:Run Summary(1 row/run executive dashboard),Presets(deduplicated 1 row per unique 16-charconfig_fingerprintacross all 38 config fields),Runs,Cases,Events(1 row per recommended card with judge scores), andQueries(every planned and executed search string). Runninguv run buddy-eval rebuild-sheetdeterministically reconstructs all 6 tabs from GCS JSONs.
4. Wave 1: Why Prompt Tuning Inside a Monolith Hit a Wall
Over four days (Nov 5 to 8, 2026), we executed 34 tracked evaluation runs and replays across four waves. Wave 1 tested whether we could solve latency and cost purely through prompt tuning inside the original monolithic architecture.
Our first hypothesis (fast-multi-yield) asked Stage 1 for 4 broad OR-cluster queries (2 Exploitation, 1 Adjacent, 1 Wildcard, such as "Oakland Berkeley (ceramics OR printmaking OR sewing OR woodworking) workshop class November 2026"), disabled url_context on Pass 1, and asked Stage 2 for compact notes. That immediately hit our first surprise: on the initial 2-case dry run (dryrun-fast-multi-yield), Stage 1 planned 4.0 queries, yet Gemini decomposed those OR clauses into 8.5 institution-specific follow-up searches inside its tool loop and took 83.1s (slower than baseline's 75.0s dry run). After we added search_execution_instructions explicitly forbidding Gemini from decomposing OR queries into follow-up searches (dryrun-fast-multi-yield-v2), executed queries dropped to 4.0, cost dropped to $0.091, latency dropped to 70.1s, and composite quality hit 99.0/100.
Then came the second surprise: 2-case dry runs are operational gates, not quality proof. When we ran the full 12-case smoke suite (2026-11-06-fast-multi-yield-smoke vs 2026-11-06-baseline-smoke), latency fell 32% (100.6s -> 68.5s), executed web queries fell 66% (9.8 -> 3.3), marginal cost fell 58% ($0.182 -> $0.077), and composite quality scored a respectable 92.3 vs 96.4. Yet in blind pairwise A/B judging, fast-multi-yield won 1 and lost 11 (8% win rate). Broad OR queries missed specific Section 5 studio anchors, surfaced generic city-wide listings, and dropped intra-batch diversity from 4.67 to 4.17.
Next we tried a 6-query hybrid plan (hybrid-6q-quality: 2 precise anchor Exploitation + 1 constraint Exploitation + 1 precise Adjacent + 1 broad OR Adjacent + 1 Wildcard) and paired it with 2-thread bucket parallelism (parallel_bucket_execution=True, running Exploitation in Thread 1 and Adjacent + Wildcard in Thread 2). Sequential hybrid-6q-quality recovered quality (97.3/100, 1-1 blind A/B split vs baseline at $0.109/stack), while staying slow at 99.5s. Turning on 2-bucket parallelism (dryrun-parallel-hybrid-6q-quality) cut Stage 2 search by 17% (62.2s -> 51.5s), only for Stage 3 extraction to blow up from 28.1s to 37.7s (net wall-clock gain <1%, 99.5s -> 98.6s) while collapsing the San Francisco case to just 2 cards.
Deep Dive: Diagnosing the 2-card SF collapse with $0 Stage 3 replays
Was Stage 3 being too strict, or was Stage 2 producing broken output? We ran five zero-search-cost Stage 3 replays (replay-parallel-control, replay-parallel-min5, replay-parallel-compact, replay-parallel-budget2048, replay-sequential-hybrid-budget2048) against the saved search evidence.
Even when instructed harder to return at least 5 cards (replay-parallel-min5), Stage 3 spent 79.1s and 11,016 thinking tokens on San Francisco and still returned 2 cards. Inspecting raw_synthesis exposed the smoking gun: Thread 1 wrote a prose essay claiming 11 SF events while citing bare ungrounded domains (https://roxie.com, https://citylights.com/events) that did not exist in grounding_chunks, whereas Thread 2 ran 4 searches and wrote: "Based on the executed search queries... only two candidate events meet every concrete verification requirement." Concatenating two independent prose essays with one URL list at the bottom handed Stage 3 a self-contradictory document. And when we tried capping extract_thinking_budget=2048 on noisy prose (replay-sequential-hybrid-budget2048), Stage 3 dropped from 28.1s to 12.9s while SF quality tanked to 88.7.
5. Wave 2: 1-Worker-Per-Query Parallel Map-Reduce (E1-E5)
Stepping back on Day 2, we realized we were sweeping parameters inside an inefficient architecture. Why run one monolithic call (or two bucket threads where queries still queue serially) when Python can spawn one dedicated worker per planned query at t=0?
We engineered five structural changes (E1 through E5) in mvp/search.py:
- E1 (
search_execution_mode = "parallel_per_query"): Flatten allNplanned queries intoNtasks and launch allNworkers simultaneously viaThreadPoolExecutor(max_workers=len(query_tasks)). Zero queries ever wait behind another query. - E2 (Structured Per-Query Candidate Records + Inline Sources): Forbid workers from writing global introductions or essays. Each worker outputs only 1 to 4 terse, pipe-delimited records (
[Event Name] | [Exact Day, Date & Time] | [Venue & Address] | [Price] | [Bucket]). Any worker output with zerogrounding_chunksis dropped (drop_ungrounded_worker_outputs=True), and each worker's resolvedVerified Canonical Sources for Query k:are attached inline directly below its candidate block (attach_per_query_sources_inline=True). - E3 (
single_hop_redirects = True): Deduplicateraw_sourcesbefore making network requests, share onehttpx.Client(follow_redirects=False, timeout=2.5), send a singleHEADrequest tovertexaisearch.cloud.google.com, and readresp.headers["location"]directly without opening TLS handshakes to 35 external venue servers. Benchmark on 32 redirect URLs:4.54s -> 0.61s(-86.7%locally,0.25son Cloud Run). - E4 (Progressive SSE
cards_ready): Emit the full stack of cards to the browser the instant Stage 3 completes (0simage wait on the critical path) while scraping flyers asynchronously. - E5 (
plan_thinking_budget = 512): Capping Stage 1 planner thinking tokens cut Stage 1 latency from7.0sto4.3s(-38.6%) with zero loss in query specificity.
def resolve_redirect_url(uri: str, timeout: float = 2.5, single_hop: bool = True) -> str:
if "vertexaisearch.cloud.google.com/grounding-api-redirect" not in (uri or ""):
return uri
if single_hop:
with httpx.Client(follow_redirects=False, timeout=timeout) as hop_client:
resp = hop_client.head(uri)
loc = resp.headers.get("location", "").strip()
if loc.startswith("http"):
return loc
return uri
with ThreadPoolExecutor(max_workers=len(query_tasks)) as pool:
worker_results = list(pool.map(_exec_single_query, query_tasks))
for item in worker_results:
w_text = item["text"]
if not w_text or w_text.upper() == "NONE":
continue
if cfg.drop_ungrounded_worker_outputs and not item["raw_chunks"]:
continue
src_lines = "\n".join(f"- [{s.title}]({s.resolved_uri})" for s in item["worker_sources"])
synthesis_parts.append(
f"### Query {item['idx']} [{item['bucket']}]: {item['query']}\n"
f"{w_text}\n\n"
f"Verified Canonical Sources for Query {item['idx']}:\n{src_lines}"
)Deep Dive: Two Wave 2 bugs ("Output Only NONE" bailouts & missing worker anchors)
Our first PER_QUERY_GROUNDING_PROMPT_TEMPLATE ended with: "If zero qualifying events are found for this query, output only NONE." In 2026-11-07-parallel-per-query-baseline-smoke, NYC budget quality crashed to 64.7 because 6 out of 9 parallel workers saw a narrow 10-word query string, ran 1 literal search, and took the easy escape hatch by outputting NONE. Replacing that bailout with instructions to broaden slightly to local studio or collective calendars when an exact phrase returns zero hits jumped NYC quality from 64.7 to 98.3.
Second, Stage 2 originally only received plan_data.reframed_goal (1 sentence) rather than preferences.md. When a parallel worker landed on a local roundup calendar, it had no idea which venues were the user's Section 5 Positive Anchors or Section 6 Untried Hypotheses. Injecting profile context into each worker (2026-11-07-parallel-per-query-baseline-v2-smoke) pushed 12-case Smoke Composite Quality to 97.8/100 (4.97/5.0 local specificity) and hit a 6W-6L (50%-50%) blind A/B tie against baseline while cutting mean latency from 100.6s to 61.7s (131.8s -> 71.6s p90).
6. Wave 3: Fixing the Cost Spike, Card-Count Dip, & Stage 3 Latency
Comparing our two Wave 2 winners exposed a sharp unit-economics tradeoff: parallel-per-query-baseline-v2-smoke achieved 97.8 quality and 7.2 cards/stack, yet because its 8 parallel workers broadened narrow queries into 2 searches each, executed web queries jumped to 17.6 per stack ($0.338/stack), which was far too expensive for a $5/mo subscription. Meanwhile, parallel-per-query-hybrid-6q-v3-smoke was faster (56.7s) and cheaper (12.4 queries), yet only averaged 5.58 cards per stack instead of 7-8.
We ran three surgical experiments (C1, C2+C3, and Stage 3 deduplication) to get ~$0.17 cost and 7+ cards/stack at the same time:
- Experiment C1 (Removing the
1-Event-Per-Query-BlockCeiling,replay-hybrid-6q-v3-7to8cards): Our Stage 3 prompt had a diversity rule stating "Select at most 1 event per### Queryblock." With 6 queries,6 x 1 = 6mathematically capped the stack at 6 cards. Changing that rule to "Select at most 1 event per venue and at most 1 event per narrow activity subtype" (allowing 2 distinct events from a rich calendar roundup found by 1 query) in a$0.00Stage 3 replay jumped average cards from5.58to7.17and improved blind A/B vs baseline from3W-9Lto6W-6L (50%-50%). - Experiment C2 + C3 (
single_search_per_worker = True+ Compact Profile Digest,parallel-per-query-lean-6q-smoke): We instructed each of the 6 parallel workers to run 1 Google search for its query (only running a 2nd if the 1st returned zero local hits) and extract up toper_query_candidate_limit = 4candidates, and addedextract_compact_profile_digest()to pass only Sections 2, 3, 5, and 6 ofpreferences.md(saving ~400 input tokens per worker x 6 workers). A quick dry run (dryrun-parallel-per-query-lean-6q) testedsearch_thinking_budget=1024, which cut Stage 2 to9.0swhile causing a worker with0thinking tokens to pull Monday/Wednesday events for a weekend prompt and bloating Stage 3 to64.8s; keepingsearch_thinking_budget=Nonefixed it immediately. Across the 12-case smoke suite,lean-6qhit55.2smean latency (-45.1%),64.2sp90 (-51.3%),7.0executed queries ($0.170/stack), and97.8/100quality with5.00/5.0diversity (99.0%unique archetypes). - Final Stage 3 Sweep (Stripping Duplicate URL Footers +
extract_thinking_budget = 512): Auditing the Stage 2 -> Stage 3 boundary revealed thatexecute_event_searchappended### Grounded Source URLsat the bottom ofraw_synthesisANDbuild_structure_promptappendedResolved Canonical Source URLs:a second time even when inline per-query sources were already attached. Stripping both redundant footers (replay-lean-6q-dedup-uncapped) pushed 12-case smoke quality to98.08/100(100.0%unique archetypes, the highest score of the project). And because Stage 3 now read clean, pre-structured candidate records instead of messy prose, cappingextract_thinking_budget = 512(replay-lean-6q-dedup-ext512) cut Stage 3 extraction in half (32.2s -> 15.2s), dropping 12-case mean latency to44.4s(44.9sp50,49.8sp90) at$0.162/stack and7.0cards/stack.
def extract_compact_profile_digest(preferences_markdown: str) -> str:
"""Keep Sections 2 (Defaults), 3 (Exclusions), 5 (Positive Anchors), and 6 (Untried Hypotheses)
while omitting verbose Section 4 tables and Section 7 historical logs across 6 parallel workers.
"""
keep_prefixes = ("## 2.", "## 3.", "## 5.", "## 6.")
skip_prefixes = ("## 1.", "## 4.", "## 7.")
kept_lines, keeping = [], False
for line in preferences_markdown.splitlines():
stripped = line.strip()
if stripped.startswith(keep_prefixes):
keeping = True
kept_lines.append(line)
elif stripped.startswith(skip_prefixes) or stripped.startswith("## "):
keeping = False
elif keeping:
kept_lines.append(line)
return "\n".join(kept_lines).strip() or preferences_markdown.strip()7. Wave 4: The 30-Case Stratified Cloud Run Benchmark (seed=42)
Earlier smoke comparisons had a subtle temporal confounder: comparing a Saturday candidate run against Friday's saved baseline-smoke run caused unfair losses on "tonight or tomorrow night" prompts because Friday events were now in the past. To make our production ship decision with zero day-of-week drift, we queued all three contenders simultaneously on Sunday, Nov 8, 2026 via Google Cloud Run Jobs against the exact same 30 Latin-square stratified cases (--tier full --limit 30 --seed 42 --workers 3):
Across 30 cases (216 recommended event cards spanning 10 cities and 10 prompt archetypes), a position-debiased blind pairwise judge preferred parallel-per-query-lean-6q-fastext (19414f416e158235) literally 50.0% of the time (15-15) against both the 96.9s original baseline (b0f3a599415f5bbf) and the 63.2s uncapped Stage 3 variant (lean-6q-uncapped, which itself beat baseline 16-14):
- Wall Latency Median (
p50):96.25s(baseline) ->60.55s(uncapped) ->44.59s(fastext,-53.7%,-51.7sfaster). - Wall Latency Mean &
p90: Mean dropped from96.93sto45.81s(-52.7%);p90tail latency dropped from108.08s(or131.8spre-redirect fix) to53.00s(-51.0%). - Per-Stage Breakdown (
baselinevsfastext): Stage 1 Planner7.64s -> 4.29s(-43.8%); Stage 2 Grounded Search68.56s -> 26.10s(-61.9%); Stage 2b Redirects0.25s(down from4.54spre-E3); Stage 3 Extraction20.40s -> 15.11s(-25.9%, and-52.1%vs31.54suncapped). - Web Queries & Marginal Cost: Planned/executed queries dropped from
7.90 / 11.90(357total) to6.00 / 7.43(223total,-37.6%), cutting paid-tier marginal cost from$0.2140/stack ($6.43run) to$0.1720/stack ($5.15run,-19.6%). - Stack Completeness & Deterministic Checks:
30 / 30cases succeeded (100%in-range with7.23cards/stack),100.0%citation integrity (up from99.5%),100.0%temporal validity and zero repeats, and100.0%discovery bucket coverage across all 3 buckets (up from92.2%). - Judge Scores & Blind Pairwise A/B: Intra-batch diversity improved from
4.40(89.5%unique archetypes) to4.53(90.9%unique) (4.80onuncapped), composite quality held statistically flat (95.55vs95.14fastextand96.96uncapped), and blind A/B tied15-15(50%) against bothbaselineanduncapped.
[Stage 1: Hybrid 6-Query Planner] (~4.3s, plan_thinking_budget=512)
Emits 6 queries: 3 Exploitation (2 anchor-venue + 1 constraint)
2 Adjacent (1 specific + 1 multi-yield OR cluster)
1 Wildcard (lateral leap preserving social mechanics)
|
+---+---+---+---+---+ ThreadPoolExecutor(max_workers=6) at t=0
v v v v v v
[Stage 2: 6 Simultaneous Single-Query Grounded Workers] (~24-26s wall time total)
Compact Profile Digest (Sections 2, 3, 5, 6 only) + 1-search budget
Outputs 1-4 terse pipe-delimited candidate records (0 essay prose)
Single-hop 302 redirect resolution (~0.25s) + inline canonical sources per query
|
v
[Stage 3: Deduplicated Curation & JSON Extraction] (~15.1s, extract_thinking_budget=512)
Strips duplicate URL footers -> enforces 1 event/venue & 1 event/subtype -> 7-8 cards
|
+--> IMMEDIATELY emits SSE cards_ready (User starts reading Card #1)
v
[Stage 4: Per-Card Async Image Hydration + Browser Preloading] (0s blocking wait)8. Post-Benchmark Catch: Fixing 3/8 Missing Flyers & Next-Card Preloading
When we promoted parallel-per-query-lean-6q-fastext to the local default and ran a live Oakland underground music search (49.84s end to end), the events and latency felt great, except only 3 out of 8 cards displayed flyer images and flyers on later cards popped in late when flipping through the stack. Inspecting that exact 8-card batch uncovered three bugs across scraping, SSE delivery, and browser DOM mounting:
Combining those three backend fallbacks took that exact local batch from 3/8 (37.5%) to 8/8 (100%) resolved images. On the delivery side, we replaced the end-of-batch image barrier with ThreadPoolExecutor + as_completed (prioritizing Card #1 first), committing each URL and emitting an individual event: card_image ({candidate_id, image_url}) over SSE the millisecond each flyer resolves. In SvelteKit (DiscoverBoard.svelte and EventVisual.svelte), only currentEvent is mounted in the DOM, so <img loading="lazy"> meant Cards #2 through #8 never started downloading bytes until the user clicked Next Card. Switching the active banner to loading="eager" + fetchpriority="high" and adding a reactive $effect preloader (new Image() with referrerPolicy = "no-referrer") that warms the current card + next 2 cards first made flipping through the stack instant.
- 1. Cloudflare / Anti-Bot
403s on Event Sites (ra.co): Resident Advisor (ra.co/events/...) blocked standard ChromeUser-Agentheaders with403 Forbidden, yet returned200 OKwith full OpenGraph flyer metadata when retried with link-preview bot headers (User-Agent: WhatsApp/2.23.20.0). - 2. Text-Only Aggregator Tables (
19hz.info) & Minimal Venue Shells (tiat.place): Two cards cited19hz.info/eventlisting_BayArea.php(a bare HTML table with zero images, plus our oldfetch_event_images_parallelkeyed its dictionary bysource_url, making it impossible for two cards sharing an aggregator URL to get distinct flyers), whiletiat.placeembeddedhttps://luma.com/tiat. We added_extract_outbound_event_url(html_text, title_hint=cand.title)to locate the<tr>/<li>row matching the card's title (or embedded Luma/RA/Eventbrite/Dice/Partiful/TicketWeb links) and hop one step per candidate. - 3. Hard Cloudflare Challenges (
crybaby.live): When both Chrome and WhatsApp headers returned403,_fetch_microlink_fallback_image()(https://api.microlink.io?url=...on401/403/429/503) cleanly extracted the underlying TicketWeb flyer (https://i.ticketweb.com/...jpg).
9. Zero-Disruption Production Deployment on Cloud Run
After verifying the pipeline and image streaming locally (30 passed in pytest and 0 errors in svelte-check), we shipped to production on Google Cloud (us-central1). A single multi-stage Dockerfile (node:22-alpine compiling the SvelteKit static SPA -> python:3.12-slim + uv:0.6.3 bundling api/, mvp/, evals/, and web/build/ under non-root appuser) powers all three cloud workloads: the event-buddy Cloud Run Service (timeout=300s), the event-buddy-migrate Alembic job, and the event-buddy-eval benchmark job.
Because a live search holds an open SSE connection for ~45 seconds, ./scripts/deploy.sh (make deploy-fast) validates that all GCP Secret Manager secrets have ENABLED versions and runs ./scripts/check-active-users.sh twice (once before Cloud Build and once immediately before traffic cutover) so a deployment never severs an active user's in-flight search stream. Once Cloud Build finishes the immutable <timestamp>-<git-sha> image and Cloud Run cuts traffic over, the script polls /api/health and /api/ready (verifying live Neon Postgres connectivity) before completing in 2m 53s.