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 both GoogleSearch() and UrlContext() inside a try / except that retried with GoogleSearch() alone on failure. Telemetry showed deep_read_urls = 0 across all six batches.
  • 2. Serial multi-hop search inside one monolithic call: Passing 8 to 10 ultra-specific queries into a single generate_content call meant Gemini reasoned, searched a few queries, read snippets, reasoned again, and ran follow-up searches (11.9 executed 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.md again to rewrite the exact same events into StructuredDiscoveryOutput JSON, paying output and thinking latency twice.
  • 4. Full-chain redirect resolution (follow_redirects=True): Our resolver called httpx.Client(follow_redirects=True).head(uri) with a GET fallback and 4.0s timeout per URL, and deduplicated after network calls, waiting on slow third-party venue servers instead of just reading Google's edge 302 Location header.
  • 5. Blocking image scraping + uncapped reasoning budgets: fetch_event_images_parallel blocked the SSE complete event for 5 to 10 seconds at the end, and neither Stage 1 nor Stage 3 set an explicit thinking_budget.
original-pipeline.txt text
[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 event

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

DiscoveryConfig (mvp/search.py) python
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.75

3. 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 for citation_integrity_rate (every source_url exists in diagnostics.sources), deep_link_rate (specific path/query vs bare domain), temporal_valid_rate, in_target_card_range (5-8 cards), field_completeness_rate, no_em_dash_rate, zero_repeat_rate against Section 7, and bucket_coverage_score (1.0 when all 3 discovery buckets are present).
  • Layer 2: Decomposed Structured LLM Judge (evals/judge.py): Calls gemini-3.8-flash (temperature=0.1, thinking_budget=512, BatchJudgeEvaluation schema) to grade each card on violates_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 word activity_archetype (like "wheel pottery" or "linocut printing") so unique_archetype_ratio catches 3 pottery classes mathematically. Combines these with intra_batch_diversity_score and exploration_leap_quality into a 0-100 weighted composite_quality_score (25 exclusion + 25 fit + 20 diversity + 15 specificity + 15 exploration/overall).
  • Layer 3: Position-Debiased Blind Pairwise A/B (buddy-eval compare <run_a> <run_b>): Deterministically hashes case_id (int(md5(case_id), 16) % 2 == 1) to swap STACK_1 and STACK_2 50/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 saved diagnostics.raw_synthesis and diagnostics.sources in ~30 seconds for $0.00 in 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-fidelity runs/<tag>.json and configs/<tag>.json. Calling load_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-char config_fingerprint across all 38 config fields), Runs, Cases, Events (1 row per recommended card with judge scores), and Queries (every planned and executed search string). Running uv run buddy-eval rebuild-sheet deterministically 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 all N planned queries into N tasks and launch all N workers simultaneously via ThreadPoolExecutor(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 zero grounding_chunks is dropped (drop_ungrounded_worker_outputs=True), and each worker's resolved Verified Canonical Sources for Query k: are attached inline directly below its candidate block (attach_per_query_sources_inline=True).
  • E3 (single_hop_redirects = True): Deduplicate raw_sources before making network requests, share one httpx.Client(follow_redirects=False, timeout=2.5), send a single HEAD request to vertexaisearch.cloud.google.com, and read resp.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.25s on Cloud Run).
  • E4 (Progressive SSE cards_ready): Emit the full stack of cards to the browser the instant Stage 3 completes (0s image wait on the critical path) while scraping flyers asynchronously.
  • E5 (plan_thinking_budget = 512): Capping Stage 1 planner thinking tokens cut Stage 1 latency from 7.0s to 4.3s (-38.6%) with zero loss in query specificity.
mvp/search.py (parallel per-query + single-hop redirect) python
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-Block Ceiling, replay-hybrid-6q-v3-7to8cards): Our Stage 3 prompt had a diversity rule stating "Select at most 1 event per ### Query block." With 6 queries, 6 x 1 = 6 mathematically 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.00 Stage 3 replay jumped average cards from 5.58 to 7.17 and improved blind A/B vs baseline from 3W-9L to 6W-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 to per_query_candidate_limit = 4 candidates, and added extract_compact_profile_digest() to pass only Sections 2, 3, 5, and 6 of preferences.md (saving ~400 input tokens per worker x 6 workers). A quick dry run (dryrun-parallel-per-query-lean-6q) tested search_thinking_budget=1024, which cut Stage 2 to 9.0s while causing a worker with 0 thinking tokens to pull Monday/Wednesday events for a weekend prompt and bloating Stage 3 to 64.8s; keeping search_thinking_budget=None fixed it immediately. Across the 12-case smoke suite, lean-6q hit 55.2s mean latency (-45.1%), 64.2s p90 (-51.3%), 7.0 executed queries ($0.170/stack), and 97.8/100 quality with 5.00/5.0 diversity (99.0% unique archetypes).
  • Final Stage 3 Sweep (Stripping Duplicate URL Footers + extract_thinking_budget = 512): Auditing the Stage 2 -> Stage 3 boundary revealed that execute_event_search appended ### Grounded Source URLs at the bottom of raw_synthesis AND build_structure_prompt appended Resolved 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 to 98.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, capping extract_thinking_budget = 512 (replay-lean-6q-dedup-ext512) cut Stage 3 extraction in half (32.2s -> 15.2s), dropping 12-case mean latency to 44.4s (44.9s p50, 49.8s p90) at $0.162/stack and 7.0 cards/stack.
extract_compact_profile_digest.py python
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.7s faster).
  • Wall Latency Mean & p90: Mean dropped from 96.93s to 45.81s (-52.7%); p90 tail latency dropped from 108.08s (or 131.8s pre-redirect fix) to 53.00s (-51.0%).
  • Per-Stage Breakdown (baseline vs fastext): Stage 1 Planner 7.64s -> 4.29s (-43.8%); Stage 2 Grounded Search 68.56s -> 26.10s (-61.9%); Stage 2b Redirects 0.25s (down from 4.54s pre-E3); Stage 3 Extraction 20.40s -> 15.11s (-25.9%, and -52.1% vs 31.54s uncapped).
  • Web Queries & Marginal Cost: Planned/executed queries dropped from 7.90 / 11.90 (357 total) to 6.00 / 7.43 (223 total, -37.6%), cutting paid-tier marginal cost from $0.2140/stack ($6.43 run) to $0.1720/stack ($5.15 run, -19.6%).
  • Stack Completeness & Deterministic Checks: 30 / 30 cases succeeded (100% in-range with 7.23 cards/stack), 100.0% citation integrity (up from 99.5%), 100.0% temporal validity and zero repeats, and 100.0% discovery bucket coverage across all 3 buckets (up from 92.2%).
  • Judge Scores & Blind Pairwise A/B: Intra-batch diversity improved from 4.40 (89.5% unique archetypes) to 4.53 (90.9% unique) (4.80 on uncapped), composite quality held statistically flat (95.55 vs 95.14 fastext and 96.96 uncapped), and blind A/B tied 15-15 (50%) against both baseline and uncapped.
shipped-architecture.txt text
[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 Chrome User-Agent headers with 403 Forbidden, yet returned 200 OK with 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 cited 19hz.info/eventlisting_BayArea.php (a bare HTML table with zero images, plus our old fetch_event_images_parallel keyed its dictionary by source_url, making it impossible for two cards sharing an aggregator URL to get distinct flyers), while tiat.place embedded https://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 returned 403, _fetch_microlink_fallback_image() (https://api.microlink.io?url=... on 401/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.