examples: simulator response gains examples_overlay block
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

Adds an informational `examples_overlay` field to SimulateResult,
populated from `summary_for_country` for the scenario's target country.
Never affects simulation paths — lookup failures are caught and logged,
yielding overlay=None. Wired into both /simulate and /compare; the
shared session in /compare is used sequentially because AsyncSession
is not safe for concurrent use.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Viktor Barzin 2026-05-28 22:45:15 +00:00
parent 249991557b
commit 9b32247fea
3 changed files with 146 additions and 8 deletions

View file

@ -522,6 +522,19 @@ class SimulateRequest(BaseModel):
col_target_city: str | None = None
class ExamplesOverlay(BaseModel):
"""Informational layer on a simulator response — real-world Reddit
examples for the scenario's target country. Never affects paths."""
country: str
count: int
portfolio_gbp_median: Decimal | None = None
portfolio_gbp_p25: Decimal | None = None
portfolio_gbp_p75: Decimal | None = None
annual_exp_gbp_median: Decimal | None = None
sample_links: list[str] = Field(default_factory=list)
class SimulateResult(BaseModel):
success_rate: Decimal
p10_ending_gbp: Decimal
@ -539,6 +552,10 @@ class SimulateResult(BaseModel):
col_multiplier_applied: Decimal | None = None
col_adjusted_spending_gbp: Decimal | None = None
col_target_city: str | None = None
# Informational only — real-world Reddit examples for the scenario's
# target country. Never affects simulation behaviour; failures during
# lookup are swallowed so the simulator response is never sunk.
examples_overlay: ExamplesOverlay | None = None
class CompareRequest(BaseModel):

View file

@ -10,23 +10,27 @@ Returns a fan-chart series in the same shape as
from __future__ import annotations
import asyncio
import logging
import time
from decimal import Decimal
from pathlib import Path
import numpy as np
from fastapi import APIRouter, HTTPException
from sqlalchemy.ext.asyncio import async_sessionmaker
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from fire_planner.api.dependencies import get_session
from fire_planner.api.schemas import (
CompareRequest,
CompareResult,
ExamplesOverlay,
GoalProbability,
ProjectionPoint,
SimulateRequest,
SimulateResult,
)
from fire_planner.col import compute_col_ratio, representative_city_for
from fire_planner.examples.service import summary_for_country
from fire_planner.flex_spending import FlexRule as EngineFlexRule
from fire_planner.glide_path import static
from fire_planner.goals_eval import evaluate_goals
@ -48,8 +52,59 @@ from fire_planner.simulator import SimulationResult, build_fixed_paths, simulate
router = APIRouter(tags=["simulate"])
log = logging.getLogger(__name__)
_RETURNS_CSV = Path("/data/shiller_returns.csv")
# Maps `SimulateRequest.jurisdiction` (lowercase slug used throughout the
# planner — e.g. "thailand") to the country name as stored in
# `fire_example.country` (e.g. "Thailand"). The keys mirror
# `JURISDICTION_REPRESENTATIVE_CITY` so the overlay covers every
# jurisdiction with a fixed country. `nomad` has no fixed country and is
# intentionally absent.
_JURISDICTION_COUNTRY: dict[str, str] = {
"uk": "United Kingdom",
"cyprus": "Cyprus",
"bulgaria": "Bulgaria",
"uae": "United Arab Emirates",
"malaysia": "Malaysia",
"thailand": "Thailand",
}
def _resolve_target_country_for_examples(req: SimulateRequest) -> str | None:
return _JURISDICTION_COUNTRY.get(req.jurisdiction.lower())
async def _build_examples_overlay(
session: AsyncSession,
req: SimulateRequest,
) -> ExamplesOverlay | None:
"""Look up real-world Reddit examples for the scenario's target
country. Returns None when the jurisdiction has no fixed country
(e.g. nomad), when no examples are stored, or when the lookup
fails for any reason examples are informational and must never
sink a successful simulation."""
try:
country = _resolve_target_country_for_examples(req)
if country is None:
return None
summary = await summary_for_country(session, country)
if summary.count == 0:
return None
return ExamplesOverlay(
country=summary.country,
count=summary.count,
portfolio_gbp_median=summary.portfolio_gbp.median,
portfolio_gbp_p25=summary.portfolio_gbp.p25,
portfolio_gbp_p75=summary.portfolio_gbp.p75,
annual_exp_gbp_median=summary.annual_exp_gbp.median,
sample_links=summary.sample_links,
)
except Exception:
log.warning("examples_overlay lookup failed", exc_info=True)
return None
def _resolve_col_adjustment(
req: SimulateRequest,
@ -227,6 +282,7 @@ def _to_response(
col_multiplier: Decimal | None = None,
col_adjusted_spend: Decimal | None = None,
col_target_city: str | None = None,
examples_overlay: ExamplesOverlay | None = None,
) -> SimulateResult:
# portfolio_real has n_years+1 columns (year 0 = seed, year k = end-of-year k).
# withdrawal_real / tax_real have n_years columns (year k = withdrawn in year k+1).
@ -282,11 +338,15 @@ def _to_response(
col_adjusted_spending_gbp=(Decimal(str(round(float(col_adjusted_spend), 2)))
if col_adjusted_spend is not None else None),
col_target_city=col_target_city,
examples_overlay=examples_overlay,
)
@router.post("/simulate", response_model=SimulateResult)
async def simulate_one(req: SimulateRequest) -> SimulateResult:
async def simulate_one(
req: SimulateRequest,
session: AsyncSession = Depends(get_session),
) -> SimulateResult:
"""Run one scenario synchronously, no DB write. ~1-3s for 5k paths."""
adjusted_req, mult, adj_spend, target_city = _resolve_col_adjustment(req)
paths = await _build_paths(adjusted_req)
@ -294,20 +354,34 @@ async def simulate_one(req: SimulateRequest) -> SimulateResult:
result, elapsed = await asyncio.to_thread(_project, adjusted_req, paths)
except KeyError as e:
raise HTTPException(status_code=400, detail=f"Unknown name: {e}") from None
return _to_response(result, elapsed, adjusted_req, mult, adj_spend, target_city)
overlay = await _build_examples_overlay(session, adjusted_req)
return _to_response(
result, elapsed, adjusted_req, mult, adj_spend, target_city, overlay)
@router.post("/compare", response_model=CompareResult)
async def compare_scenarios(req: CompareRequest) -> CompareResult:
async def compare_scenarios(
req: CompareRequest,
session: AsyncSession = Depends(get_session),
) -> CompareResult:
"""Run 2-5 scenarios in parallel, return all results."""
async def one(s: SimulateRequest) -> SimulateResult:
async def one(s: SimulateRequest) -> tuple[SimulationResult, float, SimulateRequest,
Decimal | None, Decimal | None, str | None]:
adjusted_s, mult, adj_spend, target_city = _resolve_col_adjustment(s)
paths = await _build_paths(adjusted_s)
result, elapsed = await asyncio.to_thread(_project, adjusted_s, paths)
return _to_response(result, elapsed, adjusted_s, mult, adj_spend, target_city)
return result, elapsed, adjusted_s, mult, adj_spend, target_city
try:
results = await asyncio.gather(*(one(s) for s in req.scenarios))
projected = await asyncio.gather(*(one(s) for s in req.scenarios))
except KeyError as e:
raise HTTPException(status_code=400, detail=f"Unknown name: {e}") from None
# Overlay lookups must run sequentially — AsyncSession is not safe for
# concurrent use. The lookup is fast (single SELECT) and informational
# only, so per-scenario serial cost is negligible.
results = []
for result, elapsed, adjusted_s, mult, adj_spend, target_city in projected:
overlay = await _build_examples_overlay(session, adjusted_s)
results.append(_to_response(
result, elapsed, adjusted_s, mult, adj_spend, target_city, overlay))
return CompareResult(results=results)

View file

@ -6,6 +6,7 @@ point, the point is the endpoint produces a valid response shape.
from __future__ import annotations
from collections.abc import AsyncIterator
from datetime import date
from decimal import Decimal
import pytest_asyncio
@ -14,6 +15,7 @@ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
from fire_planner.api.dependencies import get_session
from fire_planner.app import app
from fire_planner.db import FireExample
@pytest_asyncio.fixture
@ -165,3 +167,48 @@ async def test_compare_rejects_single_scenario(client: AsyncClient) -> None:
},
)
assert resp.status_code == 422 # pydantic validation
async def test_simulate_response_includes_examples_overlay(
client: AsyncClient, session: AsyncSession) -> None:
"""When examples exist for the scenario's target country, the
simulator response surfaces them as `examples_overlay` purely
informational, never affects the simulation path."""
for i, amount in enumerate([300_000, 400_000, 500_000], start=1):
session.add(FireExample(
reddit_id=f"th{i:02d}",
source_sub="ExpatFIRE",
post_url=f"https://reddit.com/r/ExpatFIRE/comments/th{i:02d}",
post_date=date(2026, 1, 1),
post_title=f"Thailand FIRE {i}",
country="Thailand",
portfolio_gbp=Decimal(str(amount)),
annual_exp_gbp=Decimal("24000"),
fi_status="FIRE",
llm_model="qwen3-8b",
))
await session.commit()
resp = await client.post(
"/simulate",
json={
"jurisdiction": "thailand",
"strategy": "trinity",
"leave_uk_year": 0,
"glide_path": "static_60_40",
"spending_gbp": "60000",
"nw_seed_gbp": "1500000",
"horizon_years": 30,
"n_paths": 100,
"seed": 42,
},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert "examples_overlay" in body
overlay = body["examples_overlay"]
assert overlay is not None
assert overlay["country"] == "Thailand"
assert overlay["count"] == 3
assert Decimal(overlay["portfolio_gbp_median"]) == Decimal("400000.00")
assert len(overlay["sample_links"]) == 3