* feat(seo): BlogPosting schema, FAQPage JSON-LD, author system, AI crawler welcome
Blog structured data:
- Change @type Article to BlogPosting for all blog posts
- Author: Organization to Person with extensible default (Elie Habib)
- Add per-post author/authorUrl/authorBio/modifiedDate frontmatter fields
- Auto-extract FAQPage JSON-LD from FAQ sections in all 17 posts
- Show Updated date when modifiedDate differs from pubDate
- Add author bio section with GitHub avatar and fallback
Main app:
- Add commodity variant to middleware VARIANT_HOST_MAP and VARIANT_OG
- Add commodity.worldmonitor.app to sitemap.xml
- Shorten index.html meta description to 136 chars (was 161)
- Remove worksFor block from index.html author JSON-LD
- Welcome all bots in robots.txt (removed per-bot blocks, global allows)
- Update llms.txt: five variants listed, all 17 blog post URLs added
* fix(seo): scope FAQ regex to section boundary, use author-aware avatar
- extractFaqLd now slices only to the next ## heading (was: to end of body)
preventing bold text in post-FAQ sections from being mistakenly extracted
- Avatar src now derived from DEFAULT_AUTHOR_GITHUB constant (koala73)
only when using the default author; custom authors fall back to favicon
so multi-author posts show a correct image instead of the wrong profile
The simulation produces 3 paths (escalation, containment, market_cascade) but
writeSimulationOutcome was slicing to 2, silently dropping market_cascade before
writing the Redis pointer. The UI and ForecastPanel never saw the economic cascade path.
* fix(seed-cot): switch from blocked www.cftc.gov to publicreporting.cftc.gov Socrata API
www.cftc.gov/dea/newcot/c_disaggrt.txt returns HTTP 403 from Railway container
IPs (same IP-blocking pattern as api.bls.gov before PR #2238).
Replace with two CFTC Socrata endpoints that allow programmatic access:
- yw9f-hn96 (TFF Combined): ES, NQ, ZN, ZT, EC, JY
- rxbv-e226 (Disaggregated All Combined): GC, CL
Market name patterns updated to match current Socrata naming.
Output shape unchanged.
* fix(seed-cot): truncate ISO timestamp in parseDate; restore envId filter in railway script
parseDate fell through for Socrata ISO timestamps (2026-03-17T00:00:00.000),
storing the full string instead of YYYY-MM-DD. Fix: slice(0, 10) as fallback.
railway-set-watch-paths: environmentId filter was accidentally dropped from
serviceInstances query, risking wrong-environment instance selection.
- Widen Surfly filter from Safari-only ("Can't find variable:") to also
cover Chrome ("is not defined") by matching on /ss_bootstrap_config/
- Add /Can only call Window\.setTimeout on instances of Window/ for iOS
Safari cross-frame setTimeout errors from 3rd-party injected scripts
- Add /^Can't find variable: _G$/ for browser extension/userscript _G
global injection (33 events / 18 users, all "global code" frames)
* fix(circuit-breaker): evict invalid cached data when shouldCache predicate fails
Circuit breakers with persistCache=true would serve stale empty data (e.g.
[] or { rates: [] }) indefinitely within the 15-30min TTL window. This
caused the Central Banks and Labor tabs in the Macro Stress panel to never
appear even after the underlying seeders started returning real data.
- circuit-breaker.ts: when shouldCache option is provided, evict cached
entries that fail the predicate before checking fresh/SWR paths. This
forces a live fetch instead of serving known-invalid cached data.
- economic/index.ts: add shouldCache guards to blsBreaker (r.length > 0)
and all three BIS breakers (rates/entries length > 0) so empty responses
are never written to persistent cache and existing empty entries are
evicted on next call.
* fix(circuit-breaker): address Greptile P2 comments on PR #2274
- Use local shouldCache variable instead of options.shouldCache directly
(the default () => true means the condition is always false without an
explicit predicate — redundant guard removed, local var is cleaner)
- Document the fire-and-forget race window in comment
- Add 3 tests for the new shouldCache eviction path:
evicts invalid cached data and fetches fresh, skips eviction for valid
data, and preserves existing behavior when no predicate is provided
* feat(seed): switch economic calendar from Finnhub to FRED API
Finnhub /calendar/economic requires a $3,500/mo premium subscription;
free tier returns HTTP 403 on every call (confirmed in production logs).
FRED (St. Louis Fed) provides the same government-scheduled release
dates for free using FRED_API_KEY, already present in Railway env for
seed-economy and seed-bls-series. No new API key or cost required.
Sources now used:
- Release 10: CPI (BLS)
- Release 50: Nonfarm Payrolls (BLS)
- Release 53: GDP (BEA)
- Release 54: PCE / Personal Income (BEA)
- Release 9: Retail Sales (Census Bureau)
- Hardcoded: FOMC rate decision dates (2026, Fed annual schedule)
FRED tracks the full year schedule in advance via
include_release_dates_with_no_data=true. Tested locally: 11 events
seeded to Redis successfully.
* fix(seed): add User-Agent header and FOMC expiry warning
Addresses Greptile review comments on PR #2272:
- P2: re-import CHROME_UA and pass as User-Agent on FRED fetch
(project convention from AGENTS.md)
- P1: warn when FOMC_DATES_2026 has no upcoming dates so operators
know to update the constant for the new year
* feat(seed): switch economic calendar from Finnhub to FRED API
Finnhub /calendar/economic requires a $3500/mo premium subscription.
FRED (St. Louis Fed) provides official government-scheduled release
dates for free using the existing FRED_API_KEY already in Railway env.
Sources:
- Release 10: CPI (BLS)
- Release 50: Nonfarm Payrolls (BLS)
- Release 53: GDP (BEA)
- Release 54: PCE / Personal Income (BEA)
- Release 9: Retail Sales (Census Bureau)
- Hardcoded: FOMC rate decision dates (Fed, published annually)
FRED tracks the full year schedule in advance via
include_release_dates_with_no_data=true. No new API key needed.
* fix(panels): remove description blob from AI Market Implications; refresh every 3h
* fix(seed): write seed-meta even when validateFn rejects empty data
When a seed runs but finds no publishable data (e.g. no earnings in the
next 14-day window, no econ events scheduled), runSeed calls extendExistingTtl
which only extends keys that already exist. If seed-meta was never written
(first run or expired), health sees seedStale=true → STALE_SEED warn even
though the seeder is healthy.
Fix: call writeFreshnessMetadata(count=0) in the skipped path so health can
distinguish 'seeder ran, nothing to publish' from 'seeder stopped running'.
* fix(seed): add User-Agent to FRED fetch; make FOMC dates year-keyed not hardcoded
Greptile P2s from PR #2273:
- Missing User-Agent: CHROME_UA added to fetchFredReleaseDates per AGENTS.md
- FOMC_DATES_2026 constant would silently return empty FOMC list from Jan 2027;
restructured as FOMC_DATES_BY_YEAR map, buildFomcEvents merges current + next
year so there is always a lookahead window until next year's dates are added
* feat(cii): wire earthquakes + sanctions into CII score; add displacement/climate/tier1 to AI brief context
Gap 1 — displacement outflow, climate stress, isTier1 now included in
buildBriefContextSnapshot() so the AI brief sees displacement/climate
data for relevant countries.
Gap 2 — ingestEarthquakesForCII(): M5.5–6.4 = +2, M6.5–7.4 = +5,
M7.5+ = +10, capped at +25. Earthquakes showed in the brief signal count
but contributed zero to the CII score.
Gap 3 — ingestSanctionsForCII(): tiered boost (3/5/8/12) by designation
count + +2 escalation for new entries. Sanctions showed in the brief
context but didn't affect the instability score.
Both ingests called in data-loader after their respective cache writes,
consistent with all other ingest functions.
* fix(cii): add 7-day recency filter to earthquake CII ingest
Without a time window, all M5.5+ quakes in the USGS cache (up to 30
days) accumulated — permanently pinning Japan, Indonesia, Philippines
etc. at the +25 earthquake boost cap even during quiet periods.
Added a 7-day lookback cutoff on eq.occurredAt. Also moved processedCount
increment after the recency check so stats count only contributing events.
Also passes now= for testability (consistent with other CII calc functions).
Removes .panel-content from the mousedown exclusion list so panels can
be dragged from any non-interactive area inside them (not just the header).
Adds user-select: none to .panel-content to prevent text getting highlighted
when initiating a drag — avoids the "select the time" problem on World Clock.
* fix(health): extend EMPTY_DATA_OK_KEYS check to bootstrap loop
Greptile correctly identified the fix was a no-op: EMPTY_DATA_OK_KEYS was
only consulted in the STANDALONE_KEYS loop, not BOOTSTRAP_KEYS. All three
calendar keys are bootstrap keys, so critCount was still incremented.
Mirror the same seedStale branching already present in the standalone loop
into the bootstrap loop so EMPTY_DATA_OK members get OK/STALE_SEED instead
of EMPTY/EMPTY_DATA/critCount++.
* fix(map): implement sanctions choropleth layer in DeckGLMap
The sanctions layer toggle did nothing on the WebGL map — DeckGLMap had
no rendering logic, only a help text label. Only Map.ts (2D SVG) had the
updateCountryFills() implementation.
Add createSanctionsChoroplethLayer() as a GeoJsonLayer using
SANCTIONED_COUNTRIES_ALPHA2 (new export) since countriesGeoJsonData keys
by ISO3166-1-Alpha-2, not the numeric IDs used by Map.ts/TopoJSON.
Wire it into the layer pipeline after the CII choropleth.
Alpha values match Map.ts: severe=89, high=64, moderate=51 (0-255).
* Revert "fix(health): extend EMPTY_DATA_OK_KEYS check to bootstrap loop"
This reverts commit cc1405f495.
* fix(health): add earningsCalendar, econCalendar, cotPositioning to EMPTY_DATA_OK_KEYS
Calendar data is legitimately empty between seasons (no earnings scheduled,
no econ events, COT release pending). Treating 0-record state as CRIT causes
503s when seeds run but publish nothing due to non-empty validateFn.
With EMPTY_DATA_OK_KEYS: empty key → OK (seed fresh) or STALE_SEED (warn).
Stops the 3-CRIT → DEGRADED → 503 flap when calendars have no upcoming events.
* fix(health): extend EMPTY_DATA_OK_KEYS check to bootstrap loop
Greptile correctly identified the fix was a no-op: EMPTY_DATA_OK_KEYS was
only consulted in the STANDALONE_KEYS loop, not BOOTSTRAP_KEYS. All three
calendar keys are bootstrap keys, so critCount was still incremented.
Mirror the same seedStale branching already present in the standalone loop
into the bootstrap loop so EMPTY_DATA_OK members get OK/STALE_SEED instead
of EMPTY/EMPTY_DATA/critCount++.
Cloudflare Radar annotations can have null description. The popup renderer
called description.slice(0, 250) unconditionally — TypeError was silently
swallowed by DeckGL's onClick, so no popup ever appeared on click.
Also fixes tooltip to show outage.title (always set in seed) instead of
obj.asn which doesn't exist on InternetOutage, and normalizes null
descriptions to '' in the seed for future robustness.
* feat(simulation): geographic theater diversity + market_cascade economic paths
Three improvements to the MiroFish simulation pipeline:
1. Geographic deduplication: adds THEATER_GEO_GROUPS constant mapping
CHOKEPOINT_MARKET_REGIONS values to macro-groups (MENA, AsiaPacific,
EastEurope, etc.). buildSimulationPackageFromDeepSnapshot now skips
candidates whose macro-group is already represented, preventing
Red Sea + Middle East (both MENA) from appearing as separate theaters.
2. Label cleanup: strips trailing (stateKind) parenthetical from theater
labels before writing to selectedTheaters, so "Black Sea maritime
disruption state (supply_chain)" becomes "Black Sea maritime disruption
state" in the UI.
3. market_cascade path: renames spillover → market_cascade across 4 sites
(evaluationTargets, Round 1 prompt + JSON template, Round 2 prompt +
JSON template, tryParseSimulationRoundPayload expectedIds). The
market_cascade path instructs the LLM to model 2nd/3rd order economic
consequences: energy price direction ($/bbl), freight rate delta,
downstream sector impacts, and FX stress on import-dependent economies.
Tests: 176 pass (3 net new — geo-dedup, label cleanup, market_cascade
prompt; plus updated entity-collision and path-validation tests).
🤖 Generated with Claude Sonnet 4.6 via Claude Code (https://claude.ai/claude-code) + Compound Engineering v2.49.0
Co-Authored-By: Claude Sonnet 4.6 (200K context) <noreply@anthropic.com>
* docs: fix markdownlint MD032 in simulation diversity plan
---------
Co-authored-by: Claude Sonnet 4.6 (200K context) <noreply@anthropic.com>
Root cause: thermalEscalation cron runs every 2h but CACHE_TTL was 3h (1.5x).
Any Railway delay caused the key to expire before the next run, triggering
EMPTY -> CRIT -> 503. Confirmed via health:last-failure Redis snapshot:
thermalEscalation:EMPTY(183min) at 2026-03-26T00:04:17Z (and 04:04, 06:03)
- seed-thermal-escalation.mjs: TTL 3h -> 6h (3x the 2h cron interval)
- health.js: maxStaleMin 240 -> 360 (3x interval, consistent with pattern)
* fix(economic): write BIS exchange/credit keys via afterPublish, not .then()
runSeed() calls process.exit(0) internally so .then() is unreachable.
The exchange and credit keys were never written to Redis, leaving
economic:bis:eer:v1 and economic:bis:credit:v1 empty.
Also fixes the canonical key shape: publishTransform now stores only
{ rates: [...] } at economic:bis:policy:v1 instead of the compound
{ policy, exchange, credit } object, matching what getBisPolicyRates
expects. Previously hasBis was always false → Central Banks tab hidden.
* fix(economic): fix validate() shape mismatch and restore exit(1) on fatal
validateFn receives post-transform data { rates: [...] }, not the raw
{ policy, exchange, credit } shape. The old check always returned falsy,
causing atomicPublish to skip every write.
Also restores process.exit(1) on fatal error so Railway alerts on seed
failures instead of silently exiting clean.
Root cause: seedWeatherAlerts() had two early-return paths that skipped the
seed-meta upstash write — alerts.length===0 (quiet weather) and !resp.ok.
After a transient NWS fetch failure, subsequent successful-but-empty runs
never bumped fetchedAt, causing health.js to see the old timestamp grow stale.
- Update seed-meta on alerts.length===0 (NWS OK, just no active alerts)
- Keep !resp.ok path as-is (prolonged NWS outage should still alert)
- Add weatherAlerts to EMPTY_DATA_OK_KEYS (0 alerts = valid quiet state)
Redesigns ForecastPanel with a theater-first NEXUS layout that surfaces simulation
outcomes alongside forecast probabilities in a unified view. Adds the
theaterSummariesJson field to the GetSimulationOutcome proto so the server can return
pre-condensed UI data from a single Redis read without any additional R2 fetches.
- proto: add theater_summaries_json = 9 to GetSimulationOutcomeResponse
- seed-forecasts.mjs: embed condensed uiTheaters array in Redis pointer at write time
- get-simulation-outcome.ts: serialize pointer.uiTheaters → theaterSummariesJson in RPC response
- src/services/forecast.ts: add fetchSimulationOutcome() returning theaterSummariesJson string
- src/app/data-loader.ts: load simulation outcome alongside forecasts, call updateSimulation()
- ForecastPanel.ts: full NEXUS redesign with SVG circular gauges, expandable theater detail,
compact prob table, CSS custom property for per-theater accent color, race condition guard
(skip render when forecasts array still empty on simulation arrival)
* fix(layers): unhide sanctions layer on flat map
Sanctions was registered with renderers:[] making it invisible in the
layers panel. Changed to ['flat'] and added to VARIANT_LAYER_ORDER for
full/finance/commodity variants. Removed now-redundant SVG_ONLY_LAYERS
workaround.
* fix(layers): remove dead SVG_ONLY_LAYERS constant and no-op loop
SVG_ONLY_LAYERS was emptied but not fully removed. Delete the constant
and simplify getAllowedLayerKeys to a one-liner since the loop was a
permanent no-op.
* fix(forecasts): cross-state semantic dedup + maritime eligibility gate + card counter-thesis
Problem: AI Forecasts panel showed 7 near-identical "Inflation from [sea]" bets
(Baltic Sea 66%, Red Sea 66%, Persian Gulf 66%, Hormuz 70%...) with no insight,
plus irrelevant bets (Brazil maritime energy, Cuba inflation). No self-critique
visible at card level.
Root cause: deriveStateDrivenForecasts iterates every maritime state and generates
one forecast per bucket — each state gets a unique solo:${id} family so the
cross-family cap never fires. Result: N seas × 1 bucket = N identical bets.
Fix 1 — Cross-state semantic dedup (seed-forecasts.mjs):
After generating all derived forecasts, group by (bucketId x stateKind). Keep at
most 2 per group, requiring >=6% probability spread between kept forecasts.
7 "Inflation from maritime_disruption" bets -> max 2, differentiated.
Fix 2 — Maritime eligibility gate (seed-forecasts.mjs):
energy/freight supply_chain buckets now require source stateKind to be maritime
(maritime_disruption, port_disruption, shipping_disruption, chokepoint_closure,
naval_blockade, piracy_escalation). Blocks Brazil security escalation -> maritime
energy and Cuba infrastructure -> inflation bets with no causal path.
Fix 3 — Self-critique on card (ForecastPanel.ts):
Surface contrarianCase or first counterEvidence summary as a small red italic
note directly on the forecast card, visible without clicking into Analysis.
* fix(forecasts): add transport_pressure to maritime gate allowlist
* fix(forecasts): close maritime gate falsy bypass + fix dedup sort monotonicity
* fix(forecasts): hoist MARITIME set to module scope + typed caseFile cast
* fix(simulation): enqueue simulation task after package write completes
* fix(panels): show radar error state on fetch failure across 22 panels
Add showError() call in catch blocks for 21 data-loader loaders so all
panels display the red radar error state instead of staying on "Loading..."
when upstream fetches fail. Also wraps ConsumerPricesPanel.fetchData() in
try/catch with showError + retry since it self-fetches.
Panels covered: stock-analysis, stock-backtest, tech-events, weather,
infra-outages, cyber-threats, protests, webcams, flight-delays,
energy-complex, trade-policy, supply-chain, satellite-fires,
security-advisories, sanctions-pressure, radiation-watch,
thermal-escalation, displacement, climate, oref-sirens,
population-exposure, consumer-prices.
* fix(panels): correct wrong panel IDs and fix ConsumerPrices error state
data-loader.ts:
- tech-events → events (actual registered panel ID)
- infra-outages → internet-disruptions (actual registered panel ID)
- Remove callPanel showError for weather/cyber-threats/protests/webcams/
flight-delays — these are map layers with no panel registration, so
those calls were no-ops
ConsumerPricesPanel.ts:
- Check overview.upstreamUnavailable after Promise.all and call showError()
with retry, since service functions swallow transport failures and return
the emptyOverview default (upstreamUnavailable:true) rather than rejecting
— the try/catch alone was dead code for this failure mode
* fix(panels): remove events showError from map-only catch, fix all-markets path
- Remove callPanel('events', 'showError') from loadTechEvents catch —
that loader runs a map-specific query (conference/mappable/90d) and
TechEventsPanel manages its own independent data fetch; a map failure
must not blank a healthy panel
- Add upstreamUnavailable check in ConsumerPricesPanel fetchData()
all-markets branch — the default view (market='all') called
fetchAllMarketsOverview() without checking the result, so all users
on the default view still got "Pending data" rows instead of the
radar error state
* fix(panels): remove update([], 0) after showError in satellite-fires catch
update() always re-renders, so calling it after showError() was
immediately replacing the radar error state with the empty dataset UI.
* revert(panels): drop ConsumerPricesPanel error state changes
upstreamUnavailable:true is set by the server on both cache miss
(seed not yet populated) and transport failure — the two cases are
indistinguishable from the client. Treating it as a hard error would
show the radar error screen on fresh deploys before the seeder has run.
The existing seeding placeholder is accurate for both states.
ConsumerPricesPanel is out of scope for this PR.
* fix(panels): remove dead showError calls from cache-fallback paths; add retry callbacks
stock-analysis / stock-backtest catch blocks had callPanel('showError')
at the top, immediately followed by a cache-fallback fetch that calls
renderAnalyses/renderBacktests (setContent) on success — wiping the
error state. Remove the premature callPanel calls; the explicit
panel.showError() at the end of each catch already handles the
no-cache path correctly.
Add onRetry callbacks for panels with long refresh intervals so
transient failures show a retry button instead of a permanent error
state lasting up to 6 hours:
- energy-complex (6h interval): → () => this.loadOilAnalytics()
- trade-policy (~1h): → () => this.loadTradePolicy()
- supply-chain (~1h): → () => this.loadSupplyChain()
api.bls.gov rejects HTTPS CONNECT tunnels from Railway container IPs even
through Decodo proxy (gate.decodo.com returns 403 on CONNECT for .gov domains).
FRED mirrors the national BLS series with identical data and no IP restrictions.
- seed-bls-series.mjs: rewrite to fetch USPRIV + ECIALLCIV from FRED
(api.stlouisfed.org works through Railway, confirmed by existing seed-economy).
Converts FRED date format to BLS observation shape (year/period/periodName/value)
so the existing handler and frontend parsing are unchanged.
Metro-area LAUMT* series dropped — no FRED equivalent available.
- get-bls-series.ts: update KNOWN_SERIES_IDS allowlist to USPRIV + ECIALLCIV
- economic/index.ts: update BLS_SERIES IDs and clear BLS_METRO_IDS
This unblocks the Labor tab in the Macro Stress panel which has shown blank
since the seeder was deployed (every BLS fetch = timeout/fetch failed).
* fix(panels): show radar error state on fetch failure across 22 panels
Add showError() call in catch blocks for 21 data-loader loaders so all
panels display the red radar error state instead of staying on "Loading..."
when upstream fetches fail. Also wraps ConsumerPricesPanel.fetchData() in
try/catch with showError + retry since it self-fetches.
Panels covered: stock-analysis, stock-backtest, tech-events, weather,
infra-outages, cyber-threats, protests, webcams, flight-delays,
energy-complex, trade-policy, supply-chain, satellite-fires,
security-advisories, sanctions-pressure, radiation-watch,
thermal-escalation, displacement, climate, oref-sirens,
population-exposure, consumer-prices.
* fix(panels): correct wrong panel IDs and fix ConsumerPrices error state
data-loader.ts:
- tech-events → events (actual registered panel ID)
- infra-outages → internet-disruptions (actual registered panel ID)
- Remove callPanel showError for weather/cyber-threats/protests/webcams/
flight-delays — these are map layers with no panel registration, so
those calls were no-ops
ConsumerPricesPanel.ts:
- Check overview.upstreamUnavailable after Promise.all and call showError()
with retry, since service functions swallow transport failures and return
the emptyOverview default (upstreamUnavailable:true) rather than rejecting
— the try/catch alone was dead code for this failure mode
* fix(panels): remove events showError from map-only catch, fix all-markets path
- Remove callPanel('events', 'showError') from loadTechEvents catch —
that loader runs a map-specific query (conference/mappable/90d) and
TechEventsPanel manages its own independent data fetch; a map failure
must not blank a healthy panel
- Add upstreamUnavailable check in ConsumerPricesPanel fetchData()
all-markets branch — the default view (market='all') called
fetchAllMarketsOverview() without checking the result, so all users
on the default view still got "Pending data" rows instead of the
radar error state
* fix(panels): remove update([], 0) after showError in satellite-fires catch
update() always re-renders, so calling it after showError() was
immediately replacing the radar error state with the empty dataset UI.
* revert(panels): drop ConsumerPricesPanel error state changes
upstreamUnavailable:true is set by the server on both cache miss
(seed not yet populated) and transport failure — the two cases are
indistinguishable from the client. Treating it as a hard error would
show the radar error screen on fresh deploys before the seeder has run.
The existing seeding placeholder is accurate for both states.
ConsumerPricesPanel is out of scope for this PR.
* fix(panels): remove dead showError calls from cache-fallback paths; add retry callbacks
stock-analysis / stock-backtest catch blocks had callPanel('showError')
at the top, immediately followed by a cache-fallback fetch that calls
renderAnalyses/renderBacktests (setContent) on success — wiping the
error state. Remove the premature callPanel calls; the explicit
panel.showError() at the end of each catch already handles the
no-cache path correctly.
Add onRetry callbacks for panels with long refresh intervals so
transient failures show a retry button instead of a permanent error
state lasting up to 6 hours:
- energy-complex (6h interval): → () => this.loadOilAnalytics()
- trade-policy (~1h): → () => this.loadTradePolicy()
- supply-chain (~1h): → () => this.loadSupplyChain()
* fix(fear-greed): correct M2SL YoY window (12→52 weeks), WALCL MoM (1→4 weeks), VIX9D gate
M2SL converted to weekly in 2021. fredNMonthsAgo(m2Obs, 12) = 12 weeks ≈ 3
months, not 12 months. True YoY requires 52 weekly observations. m2Score
was systematically understating expansionary conditions.
WALCL (Fed balance sheet) is weekly. fredNMonthsAgo(walclObs, 1) = 1 week
(WoW noise from settlements), not MoM. Use 4 observations for a cleaner
~1-month signal.
VIX term structure condition gated on both vix9d and vix3m, but vix9d was
unused in the actual score. If ^VIX9D failed on Yahoo, the entire term
structure fell back to neutral 50 even when ^VIX3M was available. Now
gates on vix3m only.
* fix(fear-greed): expand sector coverage, remove bogus C:ISSU, fix WALCL cadence
- Add 7 missing S&P sectors to YAHOO_SYMBOLS and momentum sectorCloses:
XLY, XLP, XLI, XLB, XLU, XLRE, XLC (was only 4: XLK, XLF, XLE, XLV)
Bias toward tech+financials was understating sector RSI in defensive rallies
- Remove C:ISSU advance/decline proxy — C: prefix is Yahoo crypto pair notation,
not a real A/D symbol; silent corruption risk if Yahoo returns a stale price
- WALCL momentum: fredNMonthsAgo(walclObs, 1→4) — 1 observation on weekly
WALCL data is 1 week (noise), 4 observations = 1 month (meaningful trend)
* fix(cmd-k): strip double Panel: prefix from new commands and economic-correlation
resolveCommandLabel already prepends 'Panel: ' via i18n. Commands without
a matching panels.* i18n key fall back to cmd.label, causing double prefix
when cmd.label itself started with 'Panel: '. Affects all 23 new commands
added in the previous commit plus the pre-existing panel:economic-correlation.
Fix: label field now holds the bare panel name as fallback (no prefix).
* fix(grocery-index): switch oil to canola, tighten caps, fix scroll + wow badge
- Change oil item query from "sunflower cooking oil" to "canola oil 1L"
and lower ITEM_USD_MAX.oil from 15 to 10 (canola tops ~$7 globally)
- Evict all *:oil learned routes on seed startup since product changed
- Tighten ITEM_USD_MAX caps: sugar 8->3.5, pasta 4->3.5, milk 8->5,
flour 8->4.5, bread 8->6, salt 5->2.5 to prevent organic/bulk mismatches
- Add 4x median cross-country outlier gate with Redis learned route
eviction (catches France sugar $6.94 which was 4.75x median $1.46)
- Strengthen validateFn: reject seed if <5 countries have >=40% coverage
- Fix sticky first column in gb-scroll so item names don't scroll under prices
- Add missing .gb-wow CSS rule so WoW badges render in total row
* fix(grocery-index): one-time oil migration guard, WoW version gate, main.css dedupe
P1: Gate oil route eviction behind a Redis migration sentinel (_migration:canola-oil-v1)
so it only fires once and learned canola routes persist across subsequent weekly runs.
P1: Add BASKET_VERSION=2 constant; suppress WoW when prevSnapshot.basketVersion differs
to prevent bogus deltas on the first canola seed run comparing against a sunflower baseline.
P2: Update main.css gb-item-col, gb-item-name, and gb-wow rules to match panels.css intent.
The more-specific .gb-table selectors and later cascade position caused main.css to override
sticky positioning, min-width: 110px, and gb-wow sizing back to old values.
* feat(simulation): MiroFish Phase 2 — theater-limited simulation runner
Adds the simulation execution layer that consumes simulation-package.json
and produces simulation-outcome.json for maritime chokepoint + energy/logistics
theaters, closing the WorldMonitor → MiroFish handoff loop.
Changes:
- scripts/seed-forecasts.mjs: 2-round LLM simulation runner (prompt builders,
JSON extractor, runTheaterSimulation, writeSimulationOutcome, task queue
with NX dedup lock, runSimulationWorker poll loop)
- scripts/process-simulation-tasks.mjs: standalone worker entry point
- proto: GetSimulationOutcome RPC + make generate
- server/worldmonitor/forecast/v1/get-simulation-outcome.ts: RPC handler
- server/gateway.ts: slow tier for get-simulation-outcome
- api/health.js: simulationOutcomeLatest in STANDALONE + ON_DEMAND keys
- tests: 14 new tests for simulation runner functions
* fix(simulation): address P1/P2 code review findings from PR #2220
Security (P1 #018):
- sanitizeForPrompt() applied to all entity/seed fields interpolated into
Round 1 prompt (entityId, class, stance, seedId, type, timing)
- sanitizeForPrompt() applied to actorId and entityIds in Round 2 prompt
- sanitizeForPrompt() + length caps applied to all LLM array fields written
to R2 (dominantReactions, stabilizers, invalidators, keyActors, timingMarkers)
Validation (P1 #019):
- Added validateRunId() regex guard
- Applied in enqueueSimulationTask() and processNextSimulationTask() loop
Type safety (P1 #020):
- Added isOutcomePointer() and isPackagePointer() type guards in TS handlers
- Replaced unsafe as-casts with runtime-validated guards in both handlers
Correctness (P2 #022):
- Log warning when pkgPointer.runId does not match task runId
Architecture (P2 #024):
- isMaritimeChokeEnergyCandidate() accepts both flat and nested topBucketId
- Call site simplified to pass theater directly
Performance (P2 #025):
- SIMULATION_ROUND1_MAX_TOKENS raised 1800 to 2200
- Added max 3 initialReactions instruction to Round 1 prompt
Maintainability (P2 #026):
- Simulation pointer keys exported from server/_shared/cache-keys.ts
- Both TS handlers import from shared location
Documentation (P2 #027):
- Strengthened runId no-op description in proto and OpenAPI spec
* fix(todos): add blank lines around lists in markdown todo files
* style(api): reformat openapi yaml to match linter output
* test(simulation): add flat-shape filter test + getSimulationOutcome handler coverage
Two tests identified as missing during PR #2220 review:
1. isMaritimeChokeEnergyCandidate flat-shape tests — covers the || candidate.topBucketId
normalization added in the P1/P2 review pass. The existing tests only used the nested
marketContext.topBucketId shape; this adds the flat root-field shape that arrives from
the simulation-package.json JSON (selectedTheaters entries have topBucketId at root).
2. getSimulationOutcome handler structural tests — verifies the isOutcomePointer guard,
found:false NOT_FOUND return, found:true success path, note population on runId mismatch,
and redis_unavailable error string. Follows the readSrc static-analysis pattern used
elsewhere in server-handlers.test.mjs (handler imports Redis so full integration test
would require a test Redis instance).
* fix(panels): Hormuz — remove summary text, per-chart colors, interactive tooltip
- Remove summary text blob (user-facing text from WTO page)
- Per-chart colors: crude=#e67e22, LNG=#1abc9c, fertilizer=#9b59b6, agriculture=#27ae60
- Zero-value bars in red to signal disruption
- Interactive bar hover: fixed-position tooltip shows date + value + unit
- Event delegation on stable this.element so setContent debounce does not break listeners
* fix(panels): label-based unit detection, deduplicate bar data attrs, clamp tooltip
- Revert unit from index-based (idx===0) to label-based (chart.label.includes('crude_oil'))
so unit stays correct if seed CHART_CONFIGS order ever changes
- Remove redundant data attrs from visible rects; only hit rects need them
- Clamp tooltip top to Math.max(8, ...) to prevent viewport overflow on top charts
health.js dataSize() was falling back to Object.keys count for the hormuz
data shape (no known array field matched), reporting records=8 (top-level
keys) while charts[] was empty. Added 'charts' to the known-fields list.
seed-hormuz validateFn now requires at least one chart with actual series
data, so a run where Power BI fails (empty charts) will skip publishing and
extend the existing TTL instead of overwriting good data with empty charts.
a.location is the proto LatLng object {latitude, longitude}, not a string.
This was producing "Radiation anomaly: [object Object] – 28 reading" in
the Cross-Source Signal Aggregator panel. Use a.locationName (the string
field) with fallbacks to stationName, country, region.
24h cutoff filtered out all Power BI data points (which are daily
shipping statistics, always older than 24h), resulting in empty charts.
Panel should show 30 days of trend data.
FIRMS NRT data resets at midnight UTC. All three VIIRS satellite sources
(SNPP, NOAA20, NOAA21) return 0 records for 3-6h after the cutover as new
passes accumulate. The seed correctly skips writing and extends TTL, but
health was falsely alerting STALE_SEED for this expected window.
A second Comtrade seed run (hitting API quota limits) was overwriting
previously good per-key Redis entries with empty flows arrays.
afterPublish now guards against writing zero-record entries,
preserving valid historical data from prior successful runs.
Two more scoring bugs in addition to the Credit/Liquidity fixes in #2222.
SMA200 always null (range=3mo → range=1y):
Yahoo was fetched with range=3mo (~63 trading days). SMA200 requires 200 bars
so it was ALWAYS null. The trend formula falls back to dist200=0 → score=25
(below-average-MA penalty capped). With 1y data, SPX is still above its 200dMA
despite the current correction, so aboveCount rises from 0→1 and dist200
reflects the actual buffer above the long-term trend (score ~47-53 instead of 25).
VIX neutral point shifted (range 12–40 → 12–35):
Old range put neutral at VIX=26, but the historical long-run average is ~19-20.
VIX=26.57 scored 48 (neutral). With the corrected 12–35 range, neutral is at
~23.5 and VIX=26.57 scores ~37 (mild fear) — more consistent with CNN F&G=15,
AAII Bear=52%, and the VIX backwardation term structure observed simultaneously.
- Panel: call showError() with radar UI when evaluatedAt=0 (seeder never ran yet)
- Panel: add showFetchError() method for network failure path
- data-loader: call showFetchError() on catch instead of silently logging
- Seeder: log names of missing Redis source keys (was only showing count)
Three calibration bugs were producing inflated Credit (88) and Liquidity (69)
scores even while VIX>26, CNN F&G=15, and AAII Bear=52% signaled market stress.
Credit fixes:
- HY OAS baseline was 3.0% (near all-time tights), causing scores ≈100 in
normal conditions. Recalibrated to range 2.0%–10.0% (long-run avg ~5.0%).
New score at 3.19%: 85 vs 96 previously.
- IG OAS similarly recalibrated to range 0.4%–3.0% (long-run avg ~1.3%).
- Trend now uses fredNTradingDaysAgo(obs, 20) (~1 calendar month for daily
series) instead of fredNMonthsAgo(obs, 1) which was stepping back only
1 observation (= yesterday) on daily FRED data — comparing Friday→Monday
noise, not a real 1-month trend.
Liquidity fix:
- M2 YoY multiplier reduced from 10x to 5x. With 10x, perfectly normal 5%
annual M2 growth pegged the score at 100 (max greed), masking SOFR's
restrictive signal. With 5x, 5% growth ≈ 75 (moderately accommodative).
Net effect at current market conditions:
- Credit: 88 → 68 (HY spreads still tight, but widening trend now reflected)
- Liquidity: 69 → 59 (M2 growth acknowledged but not dominant)
* feat(forecast): Phase 2 simulation package read path (getSimulationPackage RPC + Redis existence key)
- writeSimulationPackage now writes forecast:simulation-package:latest to Redis after
successful R2 write, containing { runId, pkgKey, schemaVersion, theaterCount, generatedAt }
with TTL matching TRACE_REDIS_TTL_SECONDS (60 days)
- New getSimulationPackage RPC handler reads Redis key, returns pointer metadata without
requiring an R2 fetch (zero R2 cost for existence check)
- Wired into ForecastServiceHandler and server/gateway.ts cache tier (medium)
- Proto: GetSimulationPackage RPC + get_simulation_package.proto message definitions
- api/health.js: simulationPackageLatest added to STANDALONE_KEYS + ON_DEMAND_KEYS
- Tests: SIMULATION_PACKAGE_LATEST_KEY constant + writeSimulationPackage null-guard test
Closes todo #017 (Phase 2 prerequisites for MiroFish integration)
* chore(generated): regenerate proto types for GetSimulationPackage RPC
* fix(simulation-rpc): distinguish Redis failure from not-found; signal runId mismatch
- Add `error` field to GetSimulationPackageResponse: populated with
"redis_unavailable" on Redis errors so callers can distinguish a
healthy not-found (found=false, error="") from a Redis failure
(found=false, error="redis_unavailable"). Adds console.warn on error.
- Add `note` field: populated when req.runId is supplied but does not
match the latest package's runId, signalling that per-run filtering
is not yet active (Phase 3).
- Add proto comment on run_id: "Currently ignored; reserved for Phase 3"
- Add milliseconds annotation to generated_at description.
- Simplify handler: extract NOT_FOUND constant, remove SimulationPackagePointer
interface, remove || '' / || 0 guards on guaranteed-present fields.
- Regenerate all buf-generated files.
Fixes todos #018 (runId silently ignored) and #019 (error indistinguishable
from not-found). Also resolves todos #022 (simplifications) and #023
(OpenAPI required fields / generatedAt unit annotation).
* fix(simulation-rpc): change cache tier from medium to slow (aligns with deep-run update frequency)
* fix(simulation-rpc): fix key prefixing, make Redis errors reachable, no-cache not-found
Three P1 regressions caught in external review:
1. Key prefix bug: getCachedJson() applies preview:<sha>: prefix in non-production
environments, but writeSimulationPackage writes the raw key via a direct Redis
command. In preview/dev the RPC always returned found:false even when the package
existed. Fix: new getRawJson() in redis.ts always uses the unprefixed key AND throws
on failure instead of swallowing errors.
2. redis_unavailable unreachable: getCachedJson swallows fetch failures and missing-
credentials by returning null, so the catch block for redis_unavailable was dead
code. getRawJson() throws on HTTP errors and missing credentials, making the
error: "redis_unavailable" contract actually reachable.
3. Negative-cache stampede: slow tier caches every 200 GET. A request before any deep
run wrote a package returned { found:false } which the CDN cached for up to 1h,
breaking post-run discovery. Fix: markNoCacheResponse() on both not-found and
error paths so they are served fresh on every request.
Replace the verbose analysis card layout with a Kalshi/Polymarket-style
prediction market UI:
- YES/NO outcome pills with large probability percentages
- Color-coded: green ≥60%, yellow 40-59%, red <40%
- Category tag per domain with domain accent color
- Region + trend indicator (↑ ↓ →) on bottom row
- 2-col auto-fill grid (min 240px per card), 6px gap, 4px border-radius
- Removed: probability bar, time-horizon projections row
- Kept: Analysis / Signals collapsible toggles, full detail section
Replaces "Market Regime" with "BTC Regime" — clearer that all signals
(BTC Trend, Hash Rate, Mayer Multiple, Flow) are Bitcoin-focused. Adds
a small orange ₿ BTC pill in the OVERALL verdict bar for extra context.
The JS CORS module unconditionally allows localhost and 127.0.0.1
origins, unlike server/cors.ts which correctly gates them behind
process.env.NODE_ENV === 'production'. In production, a local
process can make credentialed cross-origin requests to all API
endpoints served by the JS module without authentication.
Align the JS module with the TS version by only allowing bare
localhost/127.0.0.1 in non-production environments.
Co-authored-by: warren618 <warren618@users.noreply.github.com>
Co-authored-by: Elie Habib <elie.habib@gmail.com>
Replaces the plain numeric score display with a 5-zone semicircular
odometer gauge. The gauge shows colored arc segments from red (extreme
fear) through yellow (neutral) to green (extreme greed), with a needle
pointing to the current score and inline SVG text for score, label, and
delta-vs-previous.
All 5 errors come from the same Android 16 / Chrome WebView 146 session
where a native host app calls onHide/onShow/onReady/tapAt/removeHighlight
as global JS functions on our page. These don't exist on our end, so
WebView throws ReferenceError from an anonymous eval context.
Extended existing WebView callback pattern rather than adding new entries.
All 5 Sentry issues marked resolved with inNextRelease: true.
Bullish|Neutral|Bearish are column headers; data follows in the next row.
Label-based regex /Bearish[^%]*?([\d.]+)%/ was catching the Bullish data
cell (30.4%) because it is the first % sign after the Bearish header in
DOM order.
Fix: extract first 3 tableTxt percentage cells positionally.
Columns: Bullish(0) | Neutral(1) | Bearish(2).
Result: bear now correctly reads 52.0% instead of 30.4%.
* feat(deep-forecast): Phase 1 simulation package export contract
Add buildSimulationPackageFromDeepSnapshot and writeSimulationPackage to
produce simulation-package.json alongside deep-snapshot.json on every eligible
fast run, completing Phase 1 of the WorldMonitor → MiroFish bridge defined in
docs/internal/wm-mirofish-gap.md.
Phase 1 scope: maritime chokepoint + energy/logistics theaters only.
A candidate qualifies if its routeFacilityKey is a known chokepoint in
CHOKEPOINT_MARKET_REGIONS and the top bucket is energy or freight, or the
commodityKey is an energy commodity.
Package shape (schemaVersion: v1):
- selectedTheaters: top 1–3 qualifying candidates with theater ID, route,
commodity, bucket, channel, and rankingScore
- simulationRequirement: deterministic template per theater (no LLM, fully
cacheable), built from label, stateKind, route, commodity, channel, and
criticalSignalTypes
- structuralWorld: filtered stateUnits, worldSignals, transmission edges,
market buckets, situationClusters, situationFamilies touching the theater
- entities: extracted from actorRegistry (forecastId overlap), stateUnit
actors, and evidence table actor entries; classified into 7 entity classes
(state_actor, military_or_security_actor, regulator_or_central_bank,
exporter_or_importer, logistics_operator, market_participant,
media_or_public_bloc); falls back to anchor set if extraction finds nothing
- eventSeeds: headline evidence → live_news, disruption-keyword signal
evidence → observed_disruption; T+0h timing; relative timing format
- constraints: route_chokepoint_status (hard if criticalSignalLift ≥ 0.25),
commodity_exposure (always hard), market_admissibility (soft, channel
routing), known_invalidators (soft, when contradictionScore ≥ 0.10)
- evaluationTargets: deterministic escalation/containment/spillover path
questions + T+24h/T+48h/T+72h timing markers per theater
Also adds 6 missing chokepoints to CHOKEPOINT_MARKET_REGIONS:
Baltic Sea, Danish Straits, Strait of Gibraltar, Panama Canal,
Lombok Strait, Cape of Good Hope.
writeSimulationPackage fires-and-forgets after writeDeepForecastSnapshot
so it does not add latency to the critical seed path.
17 new unit tests covering: theater filter, package shape, simulationRequirement
content, eventSeeds, constraints (hard/soft), evaluationTargets structure,
entity extraction, key format, and 3-theater cap.
🤖 Generated with Claude Sonnet 4.6 via Claude Code (https://claude.ai/claude-code) + Compound Engineering v2.49.0
Co-Authored-By: Claude Sonnet 4.6 (200K context) <noreply@anthropic.com>
* fix(simulation-package): address P1+P2 code review issues from PR #2204
P1 fixes:
- inferEntityClassFromName: use word-boundary regex to prevent "force"
substring false positives (e.g. "workforce", "Salesforce")
- buildSimulationPackageEntities: key Map entries by candidateStateId
instead of dominantRegion to prevent collision across theaters
sharing the same region
- writeSimulationPackage call site: pass priorWorldState so
actorRegistry is available to buildSimulationPackageFromDeepSnapshot
P2 fixes:
- buildSimulationRequirementText: apply sanitizeForPrompt to
theater.label, stateKind, topChannel, and critTypes before
string interpolation (stored prompt injection risk)
- buildSimulationPackageEventSeeds: apply sanitizeForPrompt to
entry.text before .slice(0, 200)
- isMaritimeChokeEnergyCandidate: replace new Set() allocation
per call with Array.includes for 2-element arrays
- buildSimulationPackageEntities: convert allForecastIds to Set
before actor registry loop (O(n²) → O(n))
- buildSimulationPackageEvaluationTargets: add missing candidate
guard with console.warn when candidate is undefined for theater
- selectedTheaters map: add label fallback to dominantRegion /
'unknown theater' to prevent "undefined" in simulationRequirement
Tests: 6 new unit tests covering the word-boundary fix, entity key
collision, injection stripping, and undefined label guard
---------
Co-authored-by: Claude Sonnet 4.6 (200K context) <noreply@anthropic.com>