Commit Graph

2717 Commits

Author SHA1 Message Date
Elie Habib
0169245f45 feat(seo): BlogPosting schema, FAQPage JSON-LD, extensible author system (#2284)
* 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
2026-03-26 12:48:56 +04:00
Elie Habib
5b08ce3788 fix(simulation): surface market_cascade path in UI — increase uiTheaters topPaths cap 2→3 (#2285)
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.
2026-03-26 12:43:03 +04:00
Elie Habib
7709c6f302 fix(seed-cot): unblock CFTC fetch — switch to publicreporting.cftc.gov Socrata API (#2283)
* 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.
2026-03-26 12:28:08 +04:00
Elie Habib
e234a962d1 fix(sentry): broaden ss_bootstrap_config filter + 2 new noise patterns (#2275)
- 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)
2026-03-26 10:42:16 +04:00
Elie Habib
d5a754e478 fix(circuit-breaker): evict invalid cached data when shouldCache fails — unblocks BIS/BLS tabs (#2274)
* 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
2026-03-26 10:13:10 +04:00
Elie Habib
85317dfbb4 feat(seed): switch economic calendar from Finnhub to FRED API (#2272)
* 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
2026-03-26 10:10:59 +04:00
Elie Habib
3c8c5bf64a fix(panels): remove description blob from AI Market Implications; refresh every 3h (#2270) 2026-03-26 10:05:33 +04:00
Elie Habib
0e1714e559 fix(seed): write seed-meta when validateFn rejects empty data (#2273)
* 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
2026-03-26 10:04:55 +04:00
Elie Habib
d07f8a6c99 feat(cii): wire earthquakes + sanctions into CII score; fix 3 brief context gaps (#2269)
* 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).
2026-03-26 09:55:10 +04:00
Elie Habib
54cc8d6cc7 fix(panels): allow drag from anywhere in panel body, prevent text selection (#2271)
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.
2026-03-26 09:39:43 +04:00
Elie Habib
41d964265e fix(map): sanctions layer invisible on WebGL map (#2267)
* 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.
2026-03-26 08:52:48 +04:00
Elie Habib
c37bba279e fix(health): calendar keys are OK when empty (between seasons) (#2266)
* 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++.
2026-03-26 08:41:55 +04:00
Elie Habib
a7c7daa318 fix(outages): null description crashes popup, silencing all click interactions (#2265)
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.
2026-03-26 08:32:42 +04:00
Elie Habib
b0af1ad84f feat(simulation): geographic theater diversity + market_cascade economic paths (#2264)
* 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>
2026-03-26 08:31:19 +04:00
Elie Habib
2939b1f4a1 feat(finance-panels): add 7 macro/market panels + Daily Brief context (issues #2245-#2253) (#2258)
* feat(fear-greed): add regime state label, action stance badge, divergence warnings

Closes #2245

* feat(finance-panels): add 7 new finance panels + Daily Brief macro context

Implements issues #2245 (F&G Regime), #2246 (Sector Heatmap bars),
#2247 (MacroTiles), #2248 (FSI), #2249 (Yield Curve), #2250 (Earnings
Calendar), #2251 (Economic Calendar), #2252 (COT Positioning),
#2253 (Daily Brief prompt extension).

New panels:
- MacroTilesPanel: CPI YoY, Unemployment, GDP, Fed Rate tiles via FRED
- FSIPanel: Financial Stress Indicator gauge (HYG/TLT/VIX/HY-spread)
- YieldCurvePanel: SVG yield curve chart with inverted/normal badge
- EarningsCalendarPanel: Finnhub earnings calendar with BMO/AMC/BEAT/MISS
- EconomicCalendarPanel: FOMC/CPI/NFP events with impact badges
- CotPositioningPanel: CFTC disaggregated COT positioning bars
- MarketPanel: adds sorted bar chart view above sector heatmap grid

New RPCs:
- ListEarningsCalendar (market/v1)
- GetCotPositioning (market/v1)
- GetEconomicCalendar (economic/v1)

Seed scripts:
- seed-earnings-calendar.mjs (Finnhub, 14-day window, TTL 12h)
- seed-economic-calendar.mjs (Finnhub, 30-day window, TTL 12h)
- seed-cot.mjs (CFTC disaggregated text file, TTL 7d)
- seed-economy.mjs: adds yield curve tenors DGS1MO/3MO/6MO/1/2/5/30
- seed-fear-greed.mjs: adds FSI computation + sector performance

Daily Brief: extends buildDailyMarketBrief with optional regime,
yield curve, and sector context fed to the LLM summarization prompt.

All panels default enabled in FINANCE_PANELS, disabled in FULL_PANELS.

🤖 Generated with Claude Sonnet 4.6 via Claude Code (https://claude.ai/claude-code) + Compound Engineering v2.40.0

Co-Authored-By: Claude Sonnet 4.6 (200K context) <noreply@anthropic.com>

* fix(finance-panels): address code review P1/P2 findings

P1 - Security/Correctness:
- EconomicCalendarPanel: add escapeHtml on all 7 Finnhub-sourced fields
- EconomicCalendarPanel: fix panel contract (public fetchData():boolean,
  remove constructor self-init, add retry callbacks to all showError calls)
- YieldCurvePanel: fix NaN in xPos() when count <= 1 (divide-by-zero)
- seed-earnings-calendar: move Finnhub API key from URL to X-Finnhub-Token header
- seed-economic-calendar: move Finnhub API key from URL to X-Finnhub-Token header
- seed-earnings-calendar: add isMain guard around runSeed() call
- health.js + bootstrap.js: register earningsCalendar, econCalendar, cotPositioning keys
- health.js dataSize(): add earnings + instruments to property name list

P2 - Quality:
- FSIPanel: change !resp.fsiValue → resp.fsiValue <= 0 (rejects valid zero)
- data-loader: fix Promise.allSettled type inference via indexed destructure
- seed-fear-greed: allowlist cnnLabel against known values before writing to Redis
- seed-economic-calendar: remove unused sleep import
- seed-earnings-calendar + econ-calendar: increase TTL 43200 → 129600 (36h = 3x interval)
- YieldCurvePanel: use SERIES_IDS const in RPC call (single source of truth)

* fix(bootstrap): remove on-demand panel keys from bootstrap.js

earningsCalendar, econCalendar, cotPositioning panels fetch via RPC
on demand — they have no getHydratedData consumer in src/ and must
not be in api/bootstrap.js. They remain in api/health.js BOOTSTRAP_KEYS
for staleness monitoring.

* fix(compound-engineering): fix markdown lint error in local settings

* fix(finance-panels): resolve all P3 code-review findings

- 030: MacroTilesPanel: add `deltaFormat?` field to MacroTile interface,
  define per-tile delta formatters (CPI pp, GDP localeString+B),
  replace fragile tile.id switch in tileHtml with fmt = deltaFormat ?? format
- 031: FSIPanel: check getHydratedData('fearGreedIndex') at top of
  fetchData(); extract fsi/vix/hySpread from headerMetrics and render
  synchronously; fall back to live RPC only when bootstrap absent
- 032: All 6 finance panels: extract lazy module-level client singletons
  (EconomicServiceClient or MarketServiceClient) so the client is
  constructed at most once per panel module lifetime, not on every fetchData
- 033: get-fred-series-batch: add BAMLC0A0CM and SOFR to ALLOWED_SERIES
  (both seeded by seed-economy.mjs but previously unreachable via RPC)

* fix(finance-panels): health.js SEED_META, FSI calibration, seed-cot catch handler

- health.js: add SEED_META entries for earningsCalendar (1440min), econCalendar
  (1440min), cotPositioning (14400min) — without these, stopped seeds only alarm
  CRIT:EMPTY after TTL expiry instead of earlier WARN:STALE_SEED
- seed-cot.mjs: replace bare await with .catch() handler consistent with other seeds
- seed-fear-greed.mjs: recalibrate FSI thresholds to match formula output range
  (Low>=1.5, Moderate>=0.8, Elevated>=0.3; old values >=0.08/0.05/0.03 were
  calibrated for [0,0.15] but formula yields ~1-2 in normal conditions)
- FSIPanel.ts: fix gauge fillPct range to [0, 2.5] matching recalibrated thresholds
- todos: fix MD022/MD032 markdown lint errors in P3 review files

---------

Co-authored-by: Claude Sonnet 4.6 (200K context) <noreply@anthropic.com>
2026-03-26 08:03:09 +04:00
Elie Habib
5a24e8d60c fix(thermal): bump TTL 3h->6h and maxStaleMin 240->360 to stop 503 flapping (#2263)
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)
2026-03-26 07:09:33 +04:00
Elie Habib
01d18a2774 fix(economic): BIS seed — write exchange/credit keys via afterPublish (Central Banks tab fix) (#2257)
* 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.
2026-03-26 06:54:18 +04:00
Elie Habib
d851921fe1 fix(relay): refresh seed-meta on empty NWS response to prevent false STALE_SEED (#2256)
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)
2026-03-25 23:21:46 +04:00
Elie Habib
6dc2e97465 fix(forecasts): clean up NEXUS prob table layout (#2254)
* fix(forecasts): clean up NEXUS prob table — hide Analysis toggles until hover, remove empty nexus wrapper

* fix(forecasts): match NEXUS prob table to playground — correct columns, header row, theater card sizing

* fix(forecasts): resolve PR review — remove duplicate css rule, restore calibration in detail, touch fallback for Analysis toggle
2026-03-25 23:06:57 +04:00
Elie Habib
df04fecbca fix(health): bump weatherAlerts maxStaleMin 30→45 (3× 15min relay interval) (#2255) 2026-03-25 23:01:32 +04:00
Elie Habib
45469fae3b feat(forecasts): NEXUS panel redesign + simulation theater data via theaterSummariesJson (#2244)
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)
2026-03-25 22:44:48 +04:00
Elie Habib
b46878db79 fix(layers): unhide sanctions layer on flat map (#2243)
* 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.
2026-03-25 22:02:19 +04:00
Elie Habib
c4ada136a4 fix(simulation): hoist VALID_RUN_ID_RE above _isDirectRun to fix TDZ crash on enqueue (#2241) 2026-03-25 20:23:55 +04:00
Elie Habib
6e04a22ef4 fix(forecasts): semantic dedup + maritime gate + self-critique on cards (#2240)
* 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
2026-03-25 20:08:40 +04:00
Elie Habib
a2fac1d404 fix(panels): show radar error state on fetch failure across 22 panels (#2228)
* 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()
2026-03-25 18:10:48 +04:00
Elie Habib
54eda4b54b fix(bls): replace BLS API with FRED to unblock Labor tab in Macro Stress panel (#2238)
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).
2026-03-25 17:43:21 +04:00
Elie Habib
75d3d29bcd feat(contact): include submitter IP and country in enterprise notification email (#2239) 2026-03-25 17:42:47 +04:00
Elie Habib
81b8bc5bc6 fix(fear-greed): correct M2SL YoY window (12→52 weeks), WALCL WoW→MoM, VIX9D gate (#2236)
* 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.
2026-03-25 17:29:13 +04:00
Elie Habib
13549a3907 fix(fear-greed): expand sector coverage, remove bogus C:ISSU, fix WALCL cadence (#2237)
* 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).
2026-03-25 15:48:31 +04:00
Elie Habib
3085e154b1 fix(grocery-index): canola oil, tighter caps, outlier gate, sticky col, wow badge (#2234)
* 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.
2026-03-25 15:47:52 +04:00
Elie Habib
01f6057389 feat(simulation): MiroFish Phase 2 — theater-limited simulation runner (#2220)
* 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).
2026-03-25 13:55:59 +04:00
Elie Habib
8f27a871f5 fix(health): bump usniFleet maxStaleMin 480→720 (2× 6h relay interval) (#2235) 2026-03-25 13:47:49 +04:00
Elie Habib
08cdf25865 fix(panels): Hormuz — remove summary text, per-chart colors, interactive tooltip (#2231)
* 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
2026-03-25 11:10:03 +04:00
Elie Habib
8465810167 fix(health+seed): hormuz — accurate record count and stronger validateFn (#2232)
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.
2026-03-25 10:47:08 +04:00
Elie Habib
b785281a11 fix(cross-source-signals): use locationName not location object in radiation summary (#2233)
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.
2026-03-25 10:46:54 +04:00
Elie Habib
e33f856388 fix(seed): change hormuz chart window from 24h to 30 days (#2229)
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.
2026-03-25 06:58:28 +04:00
Elie Habib
c6a4d11c64 fix(health): bump wildfires maxStaleMin 120→360 for FIRMS NRT midnight reset (#2227)
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.
2026-03-25 06:46:42 +04:00
Elie Habib
80d747730a fix(seed): skip writing empty per-key flow entries in afterPublish (#2223)
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.
2026-03-25 06:43:51 +04:00
Elie Habib
e964f9d43b fix(fear-greed): fix SMA200 null (range=1y) and VIX neutral calibration (#2224)
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.
2026-03-25 06:40:48 +04:00
Elie Habib
537ff8c2c6 fix(cross-source-signals): show radar error state when seeder hasn't run or fetch fails (#2221)
- 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)
2026-03-24 23:41:54 +04:00
Elie Habib
b947b25e0a fix(fear-greed): recalibrate Credit and Liquidity scoring formulas (#2222)
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)
2026-03-24 23:41:34 +04:00
Elie Habib
e548e6cca5 feat(intelligence): cross-source signal aggregator with composite escalation (#2143) (#2164)
* feat(intelligence): cross-source signal aggregator with composite escalation (#2143)

Adds a threshold-based signal aggregator seeder that reads 15+ already-seeded
Redis keys every 15 minutes, ranks cross-domain signals by severity, and detects
composite escalation when >=3 signal categories co-fire in the same theater.

* fix(cross-source-signals): wire panel data loading, inline styles, seeder cleanup

- New src/services/cross-source-signals.ts: fetch via IntelligenceServiceClient with circuit breaker
- data-loader.ts: add loadCrossSourceSignals() + startup batch entry (SITE_VARIANT !== 'happy' guard)
- App.ts: add primeVisiblePanelData entry + scheduleRefresh at 15min interval
- base.ts: add crossSourceSignals: 15 * 60 * 1000 to REFRESH_INTERVALS
- CrossSourceSignalsPanel.ts: replace all CSS class usage with inline styles (MarketImplicationsPanel pattern)
- seed-cross-source-signals.mjs: remove dead isMain var, fix afterPublish double-write, deterministic signal IDs, GDELT per-topic tone keys (military/nuclear/maritime) with 3-point declining trend + < -1.5 threshold per spec, bundled topics fallback

* fix(cross-source-signals): complete bootstrap wiring, seeder fixes, cmd-k entry

- cache-keys.ts: add crossSourceSignals to BOOTSTRAP_CACHE_KEYS + BOOTSTRAP_TIERS (slow)
- bootstrap.js: add crossSourceSignals key + SLOW_KEYS entry
- cross-source-signals.ts: add getHydratedData('crossSourceSignals') bootstrap hydration
- seed script: fix isDeclinig typo, maritime theater ternary (Global->Indo-Pacific), displacement year dynamic
- commands.ts: add panel:cross-source-signals to cmd-k

* feat(cross-source-signals): redesign panel — severity bars, filled badges, icons, theater pills

- 4px severity accent bar on all signal rows (scannable without reading badges)
- Filled severity badges: CRITICAL=solid red/white, HIGH=faint red bg, MED=faint yellow bg
- Type badge emoji prefix:  composite, 🔴 geo-physical, 📡 EW, ✈️ military, 📊 market, ⚠️ geopolitical
- Composite card: full glow (box-shadow) instead of 3px left border only
- Theater pill with inline age: "Middle East · 8m ago"
- Contributor pills: individual chips instead of dot-separated string
- Pulsing dot on composite escalation banner

* fix(cross-source-signals): code review fixes — module-level Sets, signal cap, keyframe scoping, OREF expansion

- Replace per-call Array literals in list-cross-source-signals.ts with module-level Set constants for O(1) lookups
- Add index-based fallback ID in normalizeSignal to avoid undefined ids
- Remove unused military:flights:stale:v1 from SOURCE_KEYS
- Add MAX_SIGNALS=30 cap before writing to Redis
- Expand extractOrefAlertCluster to any "do not travel" advisory (not just Israel)
- Add BASE_WEIGHT inline documentation explaining scoring scale
- Fix animation keyframe: move from setContent() <style> block to constructor (injected once), rename to cross-source-pulse-dot
- Fix GDELT extractor to read per-topic gdelt:intel:tone:{topic} keys with correct decline logic
- Fix isDeclinig typo, maritime dead ternary, and displacement year reference
2026-03-24 23:18:31 +04:00
Elie Habib
f87c8c71c4 feat(forecast): Phase 2 simulation package read path (#2219)
* 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.
2026-03-24 22:45:22 +04:00
Elie Habib
ded27047a6 feat(forecast-panel): prediction market layout (Option A) (#2218)
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
2026-03-24 21:44:41 +04:00
Elie Habib
2fa8151bca fix(macro-signals): rename panel to BTC Regime, add ₿ BTC badge (#2215)
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.
2026-03-24 20:55:20 +04:00
Haozhe Wu
0e3a32c598 fix(security): gate localhost CORS origins behind NODE_ENV check (#2104)
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>
2026-03-24 20:55:10 +04:00
Elie Habib
12f45ac5cc feat(fear-greed): replace score number with SVG semicircular gauge (#2214)
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.
2026-03-24 20:50:15 +04:00
Elie Habib
fde7959560 fix(sentry): filter Android WebView JS bridge callback ReferenceErrors (#2213)
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.
2026-03-24 20:48:25 +04:00
Elie Habib
c0e241cadd fix(fear-greed): fix AAII bear extraction — use table cell position not header label (#2211)
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%.
2026-03-24 20:44:58 +04:00
Elie Habib
b7e6333877 feat(deep-forecast): Phase 1 simulation package export contract (#2204)
* 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>
2026-03-24 20:40:52 +04:00