Skip to content

Codex/port hubs from main#1

Closed
cheesss wants to merge 86 commits into
mainfrom
codex/port-hubs-from-main
Closed

Codex/port hubs from main#1
cheesss wants to merge 86 commits into
mainfrom
codex/port-hubs-from-main

Conversation

@cheesss

@cheesss cheesss commented May 19, 2026

Copy link
Copy Markdown
Owner

Summary

Type of change

  • Bug fix
  • New feature
  • New data source / feed
  • New map layer
  • Refactor / code cleanup
  • Documentation
  • CI / Build / Infrastructure

Affected areas

  • Map / Globe
  • News panels / RSS feeds
  • AI Insights / World Brief
  • Market Radar / Crypto
  • Desktop app (Tauri)
  • API endpoints (/api/*)
  • Config / Settings
  • Other:

Checklist

  • Tested on worldmonitor.app variant
  • Tested on tech.worldmonitor.app variant (if applicable)
  • Updated at least one feature page or update note for user-facing changes
  • Updated site/ and/or docs/ for any public-facing behavior change
  • Updated navigation if new pages or sections were added
  • Ran npm run docs:build
  • Ran npm run public:sync if this should be reflected in the public repo or site
  • If docs were not updated, explained why in the PR description
  • New RSS feed domains added to api/rss-proxy.js allowlist (if adding feeds)
  • No API keys or secrets committed
  • TypeScript compiles without errors (npm run typecheck)

Screenshots

cheesss and others added 30 commits April 15, 2026 14:29
…, investigation packet

- event-dashboard.html: Add Simulate button for approval items (dryRun preflight)
  → calls POST /api/approval-queue/{id}/review with dryRun:true, renders [DRY RUN] banner
  → item stays in inbox (not removed), shown in info tone with blue badge
- event-dashboard.html: Add .inbox-result.info CSS for DRY RUN tone
- event-dashboard.html: Expand inboxAction catch → structured log + POST /api/runtime-issues
- event-dashboard.html: Fix inboxBulkAction silent catch {} → console.warn with id/error
- scripts/event-dashboard-api.mjs: Add POST /api/runtime-issues (capture issue envelope)
  and GET /api/runtime-issues (list last 50 issues)
- scripts/_shared/runtime-issue-writer.mjs (new): captureRuntimeIssue(), classifyIssue(),
  safeToAutoFix() — writes data/runtime-issues/YYYY-MM-DD/{id}.json
- scripts/_shared/surface-route-map.mjs (new): static surface→file map + getFilesForIssue()
- scripts/generate-codex-investigation-packet.mjs (new): CLI tool — reads issue by ID,
  collects file excerpts + server log, builds compact packet, optionally sends to Claude API
- docs/CODEX_REMEDIATION_LOOP_PLAN_2026-04-15.md (new): full plan with per-step Codex prompts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…n, banners, bulk guards

- surface navigation: all 5 nav buttons switch active surface, URL hash updates
- Simulate button: appears only for approval items, calls dryRun API, shows [DRY RUN]
  banner in info tone, item stays in inbox (not removed)
- action result banners: EXECUTED / REJECTED / FAILED on API response variants
- bulk action guards: buttons disabled with no selection; mixed approval+triage
  selection disables incompatible bulk accept
- stale badge: risk-snapshot with old oldestInternalUpdatedAt triggers stale chip
- all tests use page.route() mocking — no live DB required

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…erts

- src/components/DeckGLMap.ts: default MAP_INTERACTION_MODE flat (was 3d)
  → terrain source only added in 3d mode, reducing cold-start GPU load
- src/theme-map-lens.ts: add renderPausedByHost flag — refreshDynamicData()
  and applyContext() bail early when iframe is hidden; resume triggers full
  re-render; add wm-map-lens-visibility message handler; remove 'military'
  layer from crossDomain + geopolitical presets (reduces default layer count)
- scripts/generate-structural-alerts.mjs: add resolveBaselineContext() helper;
  lifecycle alert severity downgraded to 'medium' for small/near-zero baselines;
  alertScore capped at 46/52 for low-baseline signals; adds baselineNote to detail

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…pdate

- scripts/event-dashboard-api.mjs: event-uplift-grades query adds LATERAL
  join to article_event_map+articles to return top 3 source articles per signal
- event-dashboard.html: normalizeSignalToReviewItem maps sources array from API
- event-dashboard.html: renderReviewItemCard renders [1][2][3] citation chips
  using existing .signal-cite CSS; click shows source title/date/link popover
- event-dashboard.html: showSignalCitePopover() — per-card popover from inline
  JSON data, reuses .cite-popover CSS; no global cite registry pollution
- event-dashboard.html: today feed title now dynamic — "Recent Events (7d fallback)"
  when meta.window !== '24h'; fallback badge text clarified
- event-dashboard.html: daily-digest header shows source name when fallback
  (e.g. "Source: article_fallback_72h (72h-fallback)")
- docs/DASHBOARD_DATA_CONTINUITY_AUDIT_2026-04-14.md: updated status table —
  P1 #5 structural alerts resolved, P1 #8 transmission freshness resolved;
  remaining acceptance criteria listed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…tasks, opensky path

- scripts/discover-emerging-tech.mjs: source filter now supports LIKE patterns
  (e.g. 'google news' → LIKE 'google news:%', 'foo*' → LIKE 'foo%');
  default sources list includes 'google news'
- scripts/generate-tech-report.mjs: loadTopicContext rewritten — uses
  buildTopicArticleProfile for keyword-based candidate matching; loads up to
  300 linked articles; excludes arxiv for geopolitics topics
- scripts/master-daemon.mjs: taskGenerateStructuralAlerts runs all 4 periods
  (week/month/quarter/year) sequentially; adds taskRefreshEventMarketTransmission
  at 2h interval; updates startup log
- scripts/proposal-executor.mjs: handleAddRss imports evaluateAndRegisterFeed
  from _shared/discovered-source-registry.mjs (was importing from TS server file)
- src/services/military-flights.ts: OpenSky proxy URL corrected to
  /api/opensky/states/all (was /api/opensky)
- src/services/server/autonomous-discovery.ts: load rss-allowed-domains.json
  via readFileSync instead of static import (avoids bundler JSON assert issues)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Ops surface: new Diagnostics section with Runtime Issues panel
- Loads GET /api/runtime-issues on surface switch (lazy, same pattern as inbox)
- Renders each issue: classification badge (External/API/UI/Action/Freshness),
  surface, action, timestamp, error message (truncated to 120 chars)
- Issue ID shown at bottom — click to copy to clipboard (for use with
  generate-codex-investigation-packet.mjs --issue-id)
- Badge count in section title updates on load
- CSS: .runtime-issue-row, .cls-external/api/ui/action/other classification colors
- switchSurface('ops') now triggers refreshRuntimeIssues()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Phase 0 — source-probe.mjs
  - New shared module: probeSource() with 9-adapter cascade
  - Adapters: direct-feed, html-alternate-feed, wordpress-rss,
    sitemap-news, json-ld, open-graph, html-list, playwright(stub), llm-selector(stub)
  - Quality breakdown: itemCount, recentItemCount, titleDiversity,
    duplicateRate, spamRate, themeRelevance, sourceFreshness
  - Never throws — all failures return structured SourceProbeResult
  - Per-adapter 8s timeout, total 15s probe deadline

Phase 1 — self-heal-sources.mjs
  - Probe gate before queueForApproval for untrusted URLs
  - Only quality-passing (recentItems≥3, nextAction=register|review)
    candidates enter approval queue
  - Rejected/needs-adapter candidates logged to automation log
  - Approval payload now includes probe evidence metadata

Phase 2 — proposal-executor.mjs
  - add-rss dryRun performs real Source Probe instead of generic response
  - Returns resolvedUrl, connectorKind, qualityScore, sampleItems,
    wouldRegister, wouldSeedCount

Phase 3 — approval-queue.mjs + event-dashboard-api.mjs
  - approval-queue: needs-fix status supported as reviewable decision
  - getPendingApprovals returns needs-fix items alongside pending
  - API: execution.skipped=true marks approval as needs-fix, not executed

Phase 4 — proposal-executor.mjs handleAddRss
  - Uses resolvedUrl from probe for registration and article seeding
  - probe.sampleItems reused directly when count≥3 (avoids re-fetch)
  - Returns resolvedUrl, connectorKind, probeTraceId in result

Phase 5 — event-dashboard.html
  - DRY RUN banner shows probe metadata: resolvedUrl, connectorKind,
    qualityScore, recentItemCount, sample headlines, warnings
  - SKIPPED result shows warning tone, Accept button disabled
  - needs-fix rows stay visible with NEEDS FIX banner
  - Approval cards show probe evidence from payload

Phase 6 — source-adapter-proposal.mjs
  - CLI: probe → Claude API → adapter repair proposal
  - buildRepairPrompt() generates structured repair context
  - dry-run mode prints packet without API call
  - Tests: 6 source-probe + 3 adapter-proposal (all pass)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Phase 0 (semantic contract):
- deriveResponseMeta now derives valueOrigin (observed/estimated/research)
  and validAsOf from the response mode, keeping withMeta as the single patch
  point for all 13 callers.
- inferResponseMode accepts the extended mode enum (nowcast/imputed/composite/
  mirrored/backfill/replay) via an explicit whitelist; unknown modes fall
  back to 'live' so payloads stay safe.
- MODE_STALE_THRESHOLD_HOURS gains per-mode thresholds; mirrored is always
  stale with an explicit reason.

Phase 0.5 (signal_history origin tagging):
- Migrations add value_origin + writer_id columns to signal_history and tag
  legacy GDELT-proxy rows (marketStress/transmissionStrength/eventIntensity/
  gpr) as 'proxy' with writer_id='master-pipeline-step0-gdelt'.
- New scripts/_shared/signal-history-writer.mjs centralises writes so that
  every INSERT carries explicit origin metadata. refresh-fred-signals-to-nas,
  refresh-market-quotes-to-nas, refresh-event-market-transmission,
  master-pipeline STEP 0, and backfill-new-sources all route through it.
- classifySignalQuality returns proxy/composite/imputed statuses in addition
  to observed/mirrored/stale; buildSignalSummary exposes signalOrigin map
  alongside signalQuality for per-signal provenance.

Phase 0.6 (source hygiene):
- add-articles-source-metadata.mjs migration adds wire_source,
  publisher_group, market_relevance columns + check constraint + indexes.
- shared/publisher-groups.json maps 70+ domains into canonical groups with
  high/medium/low relevance tiers (collapses EuroNews 7 langs, France24 4
  langs, Le Monde en/fr into single groups; tiers 93% of feeds).
- scripts/_shared/source-classifier.mjs resolves publisher_group +
  wire_source from URL/title/body. backfill-article-source-metadata.mjs
  populates legacy rows in 2k-row batches.
- scripts/_shared/source-concentration.mjs computes HHI, effectiveSourceCount,
  wireDominated, topShare; wire duplicates collapse into a single bucket so
  AP/Reuters syndication no longer inflates diversity.
- add-canonical-events-hhi.mjs migration adds source_hhi /
  effective_source_count / wire_dominated / top_source_share columns.
  recompute-canonical-events-hhi.mjs rebuilds them from publisher_group
  annotations.

Tests: 29 new assertions across event-dashboard-freshness-contract,
source-classifier, and source-concentration suites. All pass.

Planning docs: docs/NOWCAST_PLAN_ISSUES_2026-04-17.md captures the full
issue inventory driving these phases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…egistry

Phase 1 (storage):
- create-nowcast-tables.mjs provisions estimated_signal_nowcasts,
  nowcast_reconciliation, nowcast_training_snapshots on NAS.
- buildSignalSummary fuses estimated_signal_nowcasts into /api/kpi-summary
  for any signal whose observed value is missing or non-observed. Response
  meta.mode flips to 'nowcast' when any KPI is estimated; per-signal values
  are exposed under signals.nowcasts with method/confidence/interval fields.

Phase 2a-d (rate + commodity + fx + composite nowcasts):
- train-rates-nowcast.py trains vintage-aware ridge models for
  hy_credit_spread, treasury10y, yieldSpread, ig_credit_spread. Walk-forward
  holdout with 90% interval and baseline (carry-forward) comparison.
- train-commodity-fx-nowcast.py handles oilPrice (XLE/USO/XOM/CVX) and
  dollarIndex (UUP/FXE). Drops weekend rows implicitly via ETF close alignment.
- compute-rates-nowcast.(py|mjs) runs inference every 30 minutes, respects
  the market session floor via market-calendar.mjs, and writes confidence
  bounded to [session_floor, 1.0].
- compute-composite-nowcasts.mjs derives marketStress from nowcasted inputs
  via RSS interval combination and session-scaled confidence. Never
  overwrites observed values.

Phase 2e (clean eventIntensity):
- compute-event-intensity-nowcast.mjs counts canonical events where
  contributing articles have market_relevance in {high, medium}, collapses
  wire duplicates, and normalises against the 30-day hour-of-day baseline.
  Asian session blackouts no longer register as low intensity.

Phase 3 (UI):
- event-dashboard.html gains a new NOWCAST trust chip (sky-blue) rendered
  above the freshness chip when valueOrigin='estimated'. Chip title shows
  method + confidence + market session. KPI strip propagates
  nowcasts[0].confidence/intervalLow/intervalHigh into renderTrustRow.

Phase 4 (source gate + regime mask):
- create-nowcast-source-gate.mjs provisions nowcast_source_eligibility with
  18 seed rules mapping each nowcast target to its allowed proxies,
  max_lag_hours, holdout_mae_max, and regime_mask. ig_credit_spread disables
  shock-regime inference because the HYG/VIX relationship breaks down.
- scripts/_shared/nowcast-source-gate.mjs detects current regime from VIX
  and returns abstain + reason when eligible sources < 2 or 24h MAE exceeds
  drift threshold.

Phase 5 (registry + reconciliation):
- create-model-registry.mjs adds model_registry with candidate → shadow →
  active → deprecated lifecycle.
- reconcile-nowcasts.mjs runs every 15 minutes, pairs unreconciled
  estimated_signal_nowcasts with the first observed signal_history row on
  or after target_ts, writes nowcast_reconciliation with abs_error/
  pct_error/within_interval/calibration_bucket, and emits alerts when
  24h coverage falls below 70%.
- promote-nowcast-model.mjs gates candidate→shadow on holdout MAE < baseline
  * 0.85 AND coverage≥0.80, and shadow→active on 30-day live MAE ≤ holdout
  * 1.2 AND coverage≥0.80.

Master-daemon registry gets four new tasks:
  - rates-nowcast         (30 min)
  - composite-nowcasts    (30 min)
  - event-intensity-nowcast (30 min)
  - reconcile-nowcasts    (15 min)

Tests: 35 nowcast-related assertions + 96 CI core tests all pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Review by user correctly flagged two gaps in the previous commit series:
Phase 4 had the gate defined but not enforced by any writer, and tests
covered only the semantic contract and source hygiene surfaces, not the
full estimated_signal_nowcasts → reconcile → fusion path.

Gate enforcement:
- Extract pure evaluateGate() from nowcast-source-gate.mjs so the same
  logic can be exercised without a live client.
- Add Python port scripts/_shared/nowcast_source_gate.py (evaluate_gate +
  check_eligible_sources) that mirrors the JS implementation field-by-field.
- compute-rates-nowcast.py now calls check_eligible_sources() before
  INSERTing into estimated_signal_nowcasts; abstain short-circuits the
  write path and returns a structured reason.
- compute-composite-nowcasts.mjs gates marketStress via checkEligibleSources
  before writeComposite(); the shock-regime gate flips marketStress to
  abstain per its seed rules.
- compute-event-intensity-nowcast.mjs calls the gate with its two proxy
  sources (article_count_hourly, gdelt_event_count) so the task aborts
  cleanly when the gate rejects the setup.
- create-nowcast-source-gate.mjs grows seed rules for marketStress
  (shock-disabled) and eventIntensity.

Pure-function refactors for testability:
- classifyReconciliation + classifyDriftAlert extracted from
  reconcile-nowcasts.mjs. Coverage and bucket thresholds stay in one place.
- evaluateCandidateGate + evaluateShadowGate extracted from
  promote-nowcast-model.mjs; the legacy evaluateCandidate/Shadow wrappers
  now delegate to the pure functions.
- fuseNowcastsIntoLookup extracted from event-dashboard-api.mjs's
  buildSignalSummary so the observed-vs-estimated merge can be tested
  without a pg connection.

New test suites (42 assertions across 6 files):
- nowcast-source-gate.test.mjs: evaluateGate abstain paths, regime mask,
  drift threshold, fake-client checkEligibleSources with shock regime.
- reconcile-nowcasts.test.mjs: bucket classification, interval coverage,
  pctError handling, drift alert severity ladder.
- promote-nowcast-model.test.mjs: candidate/shadow gate thresholds and
  failure reasons.
- nowcast-fusion.test.mjs: observed always wins, proxy gets replaced,
  anyEstimated flag propagation.
- compute-composite-nowcasts.test.mjs: source-level wiring check proving
  the gate is consulted before writeComposite.
- compute-rates-nowcast-gate-wiring.test.mjs: ensures the Python INSERT
  cannot run before check_eligible_sources(); locks lag_hours derivation.

Totals: 77 nowcast-related assertions + 96 CI core tests, all passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace per-row UPDATE loops with UNNEST-array bulk UPDATEs.

backfill-article-source-metadata.mjs: 2000 UPDATEs/batch → 1 UPDATE/batch
via UNNEST($1::bigint[], $2::text[], $3::text[], $4::text[]).
Observed: 68k rows, 13 min → seconds.

recompute-canonical-events-hhi.mjs: per-event SELECT+UPDATE → single
JOIN-load all article-event pairs, group in memory, bulk UPDATE 5000
events/batch via UNNEST of 6 parallel arrays.
Observed: 62k events, 100+ min projected → ~30 s.

Logic identical; WHERE/COALESCE semantics preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Close the structural gap where the nowcast pipeline assumed market_quotes
had 180-day history it had no way to populate. Three disconnected layers
shared the same wrong premise: daemon refresh collected only 8 hardcoded
symbols, trainers/inference required 10+ additional feature symbols, and
the handoff jumped straight to --window 180 training with no bootstrap
step.

Single source of truth
  scripts/_shared/market-quote-symbols.json
    coreSnapshots + nowcastFeatures per target + signalMappings
  scripts/_shared/market-quote-symbols.mjs
    Node accessors (getAllRequiredSymbols, getSignalMappings, etc.)

Consumers now read from the JSON
  refresh-market-quotes-to-nas.mjs — DEFAULT_MARKET_QUOTE_SYMBOLS derives
    from coreSnapshots ∪ flatten(nowcastFeatures). Daemon polls the full
    required set, not just the original 8.
  train-rates-nowcast.py, train-commodity-fx-nowcast.py — TargetSpec
    .proxy_market_symbols derives from nowcastFeatures[target].

Bootstrap
  scripts/bootstrap-market-quotes-history.mjs (new)
    1. Fuse from worldmonitor_intel.historical_raw_items warm store
       (yahoo-{SYM} and yahoo-{SYM}-5y dataset_ids).
    2. Patch the remainder via Yahoo chart API (range=1y, interval=1d).
    3. Idempotent — skip rows already at (symbol, observed_at).
  Verified: 18/18 required symbols ≥120 trading days in last 180d after
  one run.

Coverage audit (blocks training/inference on insufficient history)
  scripts/_shared/market-quote-coverage.mjs   (JS, pure + DB wrapper)
  scripts/_shared/market_quote_coverage.py    (Python port, kept in sync)
  Hooked into:
    train-rates-nowcast.py main() — abort_if_missing → RuntimeError
    train-commodity-fx-nowcast.py main() — same
    compute-rates-nowcast.py predict_and_write() — abstain per-target
  Inference abstain is per-target so a gap on one signal doesn't kill
  the whole rates-nowcast task.

Docs
  docs/NOWCAST_HANDOFF_2026-04-18.md — added §5.0 bootstrap step,
  marked §6-#1/#5 as resolved, added §6-#8/#9 for the structural fixes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two defensive layers between failing models and the live dashboard:

1. Acceptance gate now blocks .pkl save (not advisory).
   scripts/_shared/nowcast_acceptance_gate.py — thresholds moved out of
   prose into a shared Python helper (MAE < baseline * 0.85, cov90 >= 0.80,
   N >= 120). Both trainers evaluate the gate after train_and_validate and
   exit 3 before calling save_model when it fails. --validate still prints
   the verdict but bypasses the exit so humans can iterate.

   Interval halfwidth also switched from Gaussian 1.645σ to empirical p90
   of |train residuals| — more honest calibration for heavy-tailed
   residuals, and exposes genuine holdout-train variance mismatch instead
   of papering over it.

2. fuse filter requires promotion_state ∈ ('shadow','active').
   scripts/event-dashboard-api.mjs loadLatestNowcastsForSignals — now
   JOINs model_registry and drops any estimated_signal_nowcasts row whose
   model version isn't registered as shadow or active. Candidate or
   unregistered models accumulate reconciliation data but never reach
   buildSignalSummary → dashboard. promote-nowcast-model.mjs is the only
   path to surface a model.

Phase C first-run outcome (documented in handoff §5.2): 0/6 targets
cleared the gate. Ridge + ETF proxies can't beat naive carry-forward on
1–3 bp/day FRED moves. oilPrice had +51% MAE but its holdout variance
exceeds train variance — no in-sample interval calibration reaches the
0.80 coverage target. Added §6-#10 and §6-#11 with the structural
implications.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase E partial activation. With 0/6 targets clearing the acceptance gate
in the first training run, leaving rates-nowcast on a 30m cron produces
only "no trained model" abstains for all 4 rates targets every cycle —
pure log noise. Disable by default; operator opts in with
NOWCAST_RATES_ENABLED=true once the rates redesign track produces usable
models.

Other three nowcast tasks (composite-nowcasts, event-intensity-nowcast,
reconcile-nowcasts) stay active on restart. Phase D smoke test confirmed
they behave correctly with no trained rates models — composite gate-
abstains on stale inputs, event-intensity writes valid rows, reconcile
no-ops when nothing to pair.

Startup banner now logs "[disabled] rates-nowcast ..." so the absence is
visible in ops logs rather than silently missing.

Handoff §5.4 updated with partial-activation instructions and Phase D
smoke test verdict.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase C showed ridge + ETF proxies cannot beat naive carry-forward on
slow-moving FRED rate targets — this is an architectural mismatch, not
a data or tuning problem, so it belongs in its own track rather than
mixed into the operational activation work.

The track doc captures the evidence (per-target MAE / cov / N from
Phase C validate), the mismatch taxonomy (scale, signal-to-noise,
residual distribution, tight baseline), and candidate directions
(change-target regression, regime-conditional, conformal intervals,
non-linear models, intraday features, or dropping un-beatable targets)
without prescribing a ranking.

Hard constraints for future rates work: acceptance gate stays enforced
(not advisory), promote-nowcast-model.mjs is still the only path to
dashboard, and market_quote_coverage.abort_if_missing() must front every
new trainer.

Timing: pick up 2–4 weeks after the three active nowcast tasks settle
in production. Not this session.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Doc inventory + reorganization after the nowcast activation session:

Status markers on superseded design docs
  NOWCAST_PLAN_ISSUES_2026-04-17.md — superseded by NOWCAST_HANDOFF_2026-04-18
  NOWCAST_ESTIMATION_ARCHITECTURE_PLAN_2026-04-17.md — superseded, all phases
    landed across commits 62825c9 / 746a0e5 / 8bce577 / 87c21b6
  (Plan doc was also untracked until now — add to git alongside the status.)

DOCUMENTATION.md canonical-docs table
  Added NOWCAST_HANDOFF_2026-04-18, NOWCAST_RATES_REDESIGN_TRACK_2026-04-18,
  CONNECTED_SYSTEM_WORKFLOW_DETAILED_EXPLAINED_2026-04-18, and
  CONNECTED_SYSTEM_WORKFLOW_VISUAL_2026-04-18 so the index matches the
  post-nowcast state. Companion render artefacts for the detailed
  workflow (.html / .svg / .png / .mmd) added as one collapsed row.

CONNECTED_SYSTEM_WORKFLOW docs
  Previously untracked — pulled into git. Narrative explainer covers
  layer-by-layer roles, source-add / keyword+theme-add paths, the
  observed/estimated split, and which paths actually matter in ops today.
  Zoomable HTML + SVG + PNG + Mermaid source are companion renders.

CLAUDE.md — rules 9 through 13
  9. Nowcast acceptance gate is enforced (.pkl save refused on fail)
  10. fuseNowcastsIntoLookup filters to promotion_state shadow|active
  11. Symbol SoT is scripts/_shared/market-quote-symbols.json
  12. >5000-row migrations must use UNNEST bulk UPDATE, not per-row loops
  13. New external data stores need bootstrap-first, not natural accumulation

Also refreshed the NAS-tables and scripts tables with the nowcast entries
(estimated_signal_nowcasts, nowcast_reconciliation, nowcast_source_eligibility,
model_registry, market_quotes, plus the trainer/compute/gate helpers).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…k INSERT

Yahoo chart API helper consolidation
  Three separate implementations (parseYahooQuotePayload + fetchYahooQuote
  in refresh-market-quotes-to-nas.mjs, fetchYahooDaily in bootstrap-
  market-quotes-history.mjs, seed-side parseYahooChart + fetchYahooChart
  ViaRelay in _seed-utils.mjs) had drifted — different timeouts, different
  parser output shapes, different error handling.

  New scripts/_shared/yahoo-chart.mjs:
    fetchYahooChart(symbol, { range, interval, timeoutMs })
    parseQuoteSnapshot(data, symbol)   — latest-snapshot parser
    parseDailyBars(data, symbol)       — full daily time series parser

  Non-seed consumers now import from the shared module:
    refresh-market-quotes-to-nas.mjs — uses fetchYahooChart + parseQuoteSnapshot
    bootstrap-market-quotes-history.mjs — uses fetchYahooChart + parseDailyBars

  Seed scripts (_seed-utils.mjs, seed-market-quotes.mjs, seed-gulf-quotes.mjs)
  intentionally NOT touched — their parsers are tuned to the seed cache
  format and migrating them is out of scope for this pass.

  parseYahooQuotePayload re-exported from refresh-market-quotes-to-nas.mjs
  for backward compat with any external importer of that name.

proposal-executor handleAddTheme bulk INSERT
  Previous implementation ran a 3-nested loop (articles × symbols × horizons)
  with up to 12k round-trips per large proposal: per-article published_at
  lookup, per-(symbol,horizon) price lookup, and per-row INSERT into
  labeled_outcomes. Worst case hit ~6000 INSERTs gated by ~6000 price
  queries.

  Rewrite:
    1. One SELECT over articles.id = ANY($1) → Map(id → published_at).
    2. One price-series query per symbol covering the full window
       [min published_at, max published_at + max_horizon+2d]. Price filter
       per (article, horizon) now runs in JS against the in-memory series.
    3. Single UNNEST bulk INSERT into labeled_outcomes, ON CONFLICT DO NOTHING.

  Round-trips reduced to 1 + resolvedSymbols.length + 1 (~20-ish for a
  typical proposal). Existing proposal-executor tests (7) still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ens, cache, topic-scoring)

First wave of the mega-file split planned in NOWCAST_HANDOFF §refactor.
Target file drops from 3273 → 2925 LOC (-11%) while staying API-compatible.

_shared/dashboard-map-lens.mjs (70 LOC)
  MAP_LENS_FILTER_TERMS, MAP_LENS_ANCHORS, TRANSMISSION_TARGETS +
  normalizeLensFilter / normalizeLensText / matchesLensFilter /
  inferMapLensAnchor. Pure, no DB coupling.

_shared/dashboard-cache.mjs (70 LOC)
  CACHE_DIR + readJsonCache / writeJsonCache / hasRenderableData /
  toCacheToken / buildCacheKey / hasDynamicSinceParams / buildSinceToken.
  resolveWithCache stays in main (couples to withMeta + buildJsonResponse).

_shared/dashboard-topic-scoring.mjs (160 LOC)
  TOPIC_ARTICLE_GENERIC_TERMS / TOPIC_ARTICLE_STOPWORDS /
  GEO_CONTEXT_PATTERNS + sanitizeTopicText / splitTopicTerms /
  buildTopicArticleProfile / buildTopicRecentArticleScore. Re-exported
  from event-dashboard-api.mjs to keep
  tests/event-dashboard-topic-article-matching.test.mjs import path stable.

Tests unchanged: 24/24 pass across the three dashboard-touching test
files. No behavioural change — pure extraction plus import rewiring.

Two more modules left in the planned split (signal-quality, nowcast-fusion).
Those land separately because signal-quality is bigger and nowcast-fusion
has to land last to reference the extracted classifySignalQuality helpers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… nowcast-fusion

Second wave of the mega-file split. Target drops from 2925 → 2622 LOC
(now 19.9% below its pre-split 3273-LOC starting point).

_shared/dashboard-signal-quality.mjs (299 LOC)
  SIGNAL_LABELS / KPI_SIGNAL_CHANNELS / SIGNAL_STALE_THRESHOLD_HOURS,
  DATA_TIMESTAMP_KEYS, MODE_STALE_THRESHOLD_HOURS, ALLOWED_RESPONSE_MODES,
  OBSERVED_MODES, ESTIMATED_MODES +
  toIsoTimestamp, collectPayloadTimestamps, latestInternalTimestamp,
  firstTimestamp, inferResponseMode, deriveValueOrigin, signalAgeHours,
  classifySignalQuality, deriveResponseMeta, withMeta.
  All pure. loadLatestSignalsWithQuality + detectLiveQuoteFeed stay in
  main because they need the pg Pool + safeQuery.

_shared/dashboard-nowcast-fusion.mjs (52 LOC)
  fuseNowcastsIntoLookup (pure combiner — the one the
  tests/nowcast-fusion.test.mjs suite exercises).
  loadLatestNowcastsForSignals stays in main — DB-coupled reader +
  the shadow/active promotion_state filter.

Re-exports added to main for all four test-imported names
(classifySignalQuality, withMeta, deriveResponseMeta, fuseNowcastsIntoLookup)
so no test file needs a path change.

Net effect of the full split (5 modules, two commits):
    3273 → 2622  main LOC (-651, -19.9%)
    651        new LOC across 5 _shared/dashboard-*.mjs modules
    24/24      tests pass throughout — no behavioural change

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the remaining bulk-INSERT candidates flagged in the 2026-04-18 audit.

incremental-event-engine.mjs (STEP 4, ~50-200 rows/run)
  Per-event INSERT into event_features (26 columns) → single UNNEST bulk
  INSERT with 26 parallel arrays. Daily daemon cycle now takes one
  round-trip for the whole event_features refresh instead of N.

incremental-event-engine-fast.mjs (STEP 4, parallel path)
  Same pattern applied to the "fast" variant. Both variants now bulk.

proposal-executor.mjs handleAddTheme — 2 remaining loops
  L934-942 auto_article_themes per-article INSERT → bulk with UNNEST
  (bigint[], text[], double precision[], text[]).
  L952-959 auto_theme_symbols per-symbol INSERT → bulk with 6-array UNNEST.
  Together with the earlier labeled_outcomes rewrite (commit 329e907),
  handleAddTheme for a large proposal now runs in ~23 queries instead of
  ~12k.

No behavioural change — same ON CONFLICT semantics, same output rows.
Existing tests (proposal-executor + proposal-executor-guardrails) pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… 10 plan files

Three sub-docs carved out of CONNECTED_SYSTEM_WORKFLOW_DETAILED_EXPLAINED
per the user's roadmap, each focused on a single path with code +
table-level detail the parent doc intentionally avoids:

  WORKFLOW_SOURCE_ADD_PATH_2026-04-18.md
    Detectors (discover-emerging-tech, self-heal-sources, backfill-new-sources)
    → quality gate (isLowSignalAddRssProposal) → codex_proposals →
    Decision Inbox → proposal-executor handleAddRss → discovered-source-registry,
    plus the self-heal cascade into source-repair / source-retire proposals.

  WORKFLOW_KEYWORD_THEME_ADD_PATH_2026-04-18.md
    Distinguishes system-generated theme candidates (generate-codex-theme-proposals,
    fast-keyword-extractor, label-discovery-topics) from operator-proposed add-theme.
    Walks the post-rewrite handleAddTheme bulk pipeline: match articles,
    auto_article_themes, auto_theme_symbols, labeled_outcomes, sensitivity
    matrix — now ~23 queries instead of ~12k. Covers downstream propagation
    into event engine + evidence surfaces.

  WORKFLOW_OPENCLAW_INTEGRATION_PATH_2026-04-18.md
    Compresses the 1402-line OPENCLAW_INTEGRATION_ARCHITECTURE into four
    operational surfaces: channels, TaskFlow, source-repair delivery,
    scheduler retry escalation, briefing. Emphasises the boundary rule
    (Lattice = state of record, OpenClaw = orchestration) and the read-only
    tool surface exposed via event-dashboard-api.

DOCUMENTATION.md index updated — all three new docs added to the canonical
table directly below the parent workflow doc.

Status marker normalization (10 files)
  Added `> **Status**:` blockquote header to dated phase-plan docs that
  were missing one, and converted inline `Status:` lines to the same
  blockquote convention. Covers backtest quickstart / deep-dive / explainer,
  NAS_BACKTEST_STORAGE_PLAN, TEMPORAL_FEATURE_UPGRADE, ANALYST_WORKSTATION_REFRAME,
  CODEX_REMEDIATION_LOOP_PLAN, DYNAMIC_ISSUE_DETECTION_AND_CODEX_REMEDIATION_PLAN,
  LIVE_BACKFILL_DATA_BOUNDARY_PLAN, SOURCE_PROPOSAL_INGESTION_REDESIGN.
  DOCUMENTATION.md's archive-note convention now matches reality across
  all dated plan files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Backend (7 new API routes):
  - /api/event-timeline       — 90d events + regime + VIX
  - /api/event-narrative/:id  — Codex-authored 2-paragraph summary (cached)
  - /api/similar-events/:id   — pgvector cosine over article embeddings
  - /api/regime-scenario      — VIX/yield/oil slider predictions
  - /api/asset-dossier/:sym   — per-ticker theme/uplift/events
  - /api/weekly-digest        — Codex structured weekly brief
  - /api/correlation-breaks   — 30d vs 90d Pearson break detector

Frontend — Investigate surface AI Analysis Lab:
  - Event Timeline SVG: dot radius ∝ √|t|, hover tooltip (title/theme/
    grade/|t|/|uplift|/articles/sources), drag-to-zoom with adaptive
    month→week→day ticks, reset-zoom button + double-click reset
  - Correlation Breaks: table → 45° scatter (x=corr 90d, y=corr 30d);
    color/size by |Δ|; hover tooltip with pair/direction/Δ/values
  - Weekly Digest panel, Regime Scenario Lab sliders, Asset Dossier,
    clickable timeline dots → peek overlay (with narrative + similar)

Session-scope additions (pre-AI-Lab):
  - event-intelligence-builder (hot events / theme-impact / explain-event
    / source-diversity / meta-model-health)
  - nowcast-status-builder (model registry + drift + gate verdicts)
  - event-decision-alerts webhook emitter
  - incremental-event-engine SQL fix (UPDATE FROM JOIN alias correlation)
  - plugins/openclaw-lattice-control-plane (5 new MCP tools)
  - OpenClaw persona files (IDENTITY/SOUL/TOOLS/USER/HEARTBEAT.md)
  - interactive-verify / verify-ai-interactive / screenshot-ai-interactive

Docs cleanup:
  - delete 12 shipped/historical plan docs (AUTOMATION_RAG pair, backtest
    roadmap + operator quickstart + experiment queue, compute-language,
    emerging-tech, gdelt/guardian, resilience, map-redesign, bootstrap +
    discovery upgrade logs)
  - fix dangling refs in BACKTEST_SYSTEM_EXPLAINER, HANDOFF_BRIEFING,
    AGENT_QUICKSTART
  - update DOCUMENTATION.md index

Verification: scripts/_shared/verify-ai-interactive.mjs passes 15/15
(hover tooltip, brush zoom, reset, scatter hover, dot sizing, dblclick).

Known: 2 Codex endpoints require CODEX_MODEL env override because
~/.codex/config.toml defaults to gpt-5.5 (not accessible on this account).
Set CODEX_MODEL=gpt-5.4 when spawning the API server.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…, webhook signing

Reliability fixes from project audit. Top priorities first.

HIGH severity:

- intelligence-automation.ts:3262 — worker loop now wraps cycle in try/catch
  with consecutive-failure exponential backoff (cap 30min). Previous code
  exited the do-while on any throw, leaving the process alive but never
  running another cycle. This matches the Apr 22 incident: scheduler started
  17:49 KST, produced 0 logs for 4 days while staying alive in the process
  table — first cycle threw on init, loop died, process kept by open pg pool.

- intelligence-automation.ts:863 — writeJsonFile is now atomic (write to
  .tmp.<pid>.<ts> then rename). Previous naive writeFile could corrupt the
  263KB intelligence-scheduler-state.json mid-write if API server's read
  raced with scheduler's write.

MEDIUM severity:

- event-dashboard-api.mjs:165 — pg Pool max bumped 6 → 20 (env-overridable),
  added idle/connect timeouts. AI Lab introduced 7 new routes; dashboard
  refresh + concurrent users could saturate at max=6.

- openclaw-webhook-emitter.mjs:357 — added HMAC-SHA256 signing
  (x-lattice-signature: t=<ts>,v1=<hex>) alongside the existing Bearer token.
  Receiver must verify timestamp + body signature to prevent replay/forge
  if the bearer token leaks.

- codex-agent.mjs:150 — removed unconditional shell:true; only enable for
  .cmd entrypoints on Windows. Prevents stray cmd.exe windows + small
  metacharacter injection surface in future refactors.

LOW severity:

- event-dashboard.html:3629 — date math now in UTC (setUTCHours, ms
  arithmetic) instead of local-midnight + setDate. Fixes ±1 day visual
  misalignment between event dots (UTC ISO from API) and timeline grid
  (was local-midnight) for users not in UTC.

- scripts/migrations/add-articles-embedding-index.mjs — new idempotent
  migration. Adds IVFFlat index on articles.embedding (cosine ops). Current
  scale (73k articles) does ~200ms full scan; not a hard problem yet, but
  scales linearly. Index is offline-create, run when convenient.

- verify-ai-interactive.mjs — pointed at Vite :3000 instead of dead :8088
  static helper.

Verification: TS check passes, AI Lab interactive regression 15/15 passing.

Skipped (false positives from initial audit):
- master-daemon PG client release pattern — try/finally is correct.
- Frontend listener accumulation — innerHTML rebuild GCs old DOM + listeners.
- DEFAULT_ALLOWED_FILES env override — actually Object.freeze hardcoded.
- pg pool exhaustion in event-dashboard-api — uses pool.query() (auto release).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tion, HMAC verify helper

1. package.json: register master-daemon as `npm run daemon` (and `daemon:once`,
   `daemon:task`). Apr 22 incident root cause was that `master-daemon.mjs`
   had no npm script — when the dev environment was restarted, only
   intelligence-scheduler / vite / sidecar came back; master-daemon was
   silently missed and stayed dead for 4 days. With a script, future
   restarts will pick it up via the standard launcher pattern.

2. scripts/migrations/add-articles-embedding-index.mjs: now idempotent.
   First run discovered an existing `articles_embedding_idx` (lists=100,
   created out-of-band, not committed in any migration script) and
   created a duplicate `articles_embedding_cos_idx` (lists=200). I dropped
   my duplicate; updated the script to skip when any IVFFlat index on
   articles.embedding already exists. Also exposed via
   `npm run migrate:articles-embedding-index`.

3. openclaw-webhook-emitter.mjs: added `verifyLatticeWebhookSignature()`
   helper for receivers. Parses the `x-lattice-signature: t=<ts>,v1=<hex>`
   header, enforces a tolerance window (default 300s replay protection),
   and uses `timingSafeEqual` for constant-time HMAC comparison. Round-trip
   tested against 6 cases (valid, wrong secret, tampered body, expired,
   missing header, missing body — all expected outcomes).

Note: webhook receiver in OpenClaw gateway itself is upstream
(node_modules/openclaw, not in this repo) and runs on localhost only,
so HMAC is defense-in-depth here. Helper exists so future in-repo
receivers (e.g., a custom plugin webhook handler) can call it directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After running full Playwright walkthrough across all five surfaces
(home/inbox/investigate/geo/ops) plus the OpenClaw web UI, found 11
issues. Fixing 7 in this commit; 4 deferred (OpenClaw auth = upstream,
date format unification = invasive, Geo legend counts = needs DeckGL
deep dive, widget loading timeout = needs per-widget audit).

HIGH severity:

- schema-automation.mjs: replace DROP+ADD CONSTRAINT pattern with
  idempotent DO block + IF NOT EXISTS check. Old pattern was
  non-transactional and raced when multiple workers (master-daemon +
  intelligence-scheduler + API server) called ensureAutomationSchema()
  concurrently — second writer's ADD failed with
  "constraint approval_queue_status_check already exists". This was
  surfacing as runtime issues blocking inbox.accept clicks for users.

- master-daemon.mjs: bump auto-pipeline-labels limit 200 → 1500
  (env-overridable). 8,435 articles unlabeled vs 18 labeled in 24h
  window meant labels were never going to catch up at 200/2h while
  ingest is ~800/h. New cadence: 1500 labels per 2h cycle = 750/h,
  outpaces ingest rate. Existing backlog drained separately via
  one-shot 10k batch (manual).

MEDIUM severity:

- event-dashboard.html renderSelectedProofCard: branch by item.type.
  Approval/proposal items now show URL + reason instead of the empty
  "DIRECTION -- UPLIFT -- T-STAT -- CONTROLS --" grid that confused
  users into thinking the analysis failed. Triage shows keyword pills.
  E2 signals keep the full proof chain. Each non-event type also gets
  an inline hint pointing at the action buttons in the preview panel.

- DeckGLMap.ts borderStream marquee: replace alarming
  "AWAITING NEXT DISCOVERY CYCLE" placeholder with informative
  "READY :: live map active · no new source ops this cycle · next
  refresh in ~5min". Previously made users think the map was empty
  even when 36+ markers were plotted; the marquee is just source-ops
  history, not the main data layer.

LOW severity:

- event-dashboard.html loadEventNarrative: include event id + ISO UTC
  timestamp ("Event #99523 · 2026-04-26 04:12 UTC") instead of bare
  locale time string. Tooltip shows full ISO for copy-paste.

- event-dashboard.html refreshScenarioLab: add column headers
  (theme/symbol/avg %/hit rate/×regime) above the predictions list.
  Title attribute clarifies semantics: "if event in this theme fires
  while regime matches, expected reaction over post-event window."

- event-dashboard.html refreshWeeklyDigest: replace static "60-90s"
  placeholder with animated progress bar + "Ns elapsed" counter +
  pulsing dot. Bar tops out at 95% (so it never claims completion
  before the actual fetch resolves) and clears on both success and
  error paths. Prevents users from concluding the page is frozen
  during the long Codex round-trip.

Verification: AI Lab interactive regression 15/15 still passing,
TS compile clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same root-cause fix as master-daemon registration: data-accumulator.mjs
was a third standalone scheduler (yahoo prices + GDELT backfill into
worldmonitor_intel.historical_raw_items) that had no npm script and was
silently missing from the running fleet.

Discovered via labeling pipeline diagnostic: auto-pipeline step 3 needs
forward-window prices to label outcomes, but the warm yahoo-chart store
was 24 days stale (max valid_time_start = 2026-04-02 vs today 2026-04-26)
because nobody had data-accumulator running.

Started one instance as continuous (every 2h, 5 symbols/cycle, 65 total)
in this session. Now also exposed via:
  npm run daemon:accumulator        # continuous
  npm run daemon:accumulator:once   # single cycle

Coverage will catch up over ~26h as the cycle works through 65 symbols.
auto-pipeline-labels limit bump (200→1500) from previous commit will
then start clearing the 8,435-article unlabeled backlog.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit found 30 widgets across Investigate (17) and Ops (13) surfaces
that visually appeared "loading" forever even though they had real
content rendered inside. Root cause: render functions populated the
elements via innerHTML but never stripped the .loading class, so the
.loading CSS (color:transparent + skeleton sweep + ::after "loading"
overlay) kept hiding the actual data.

Two ways to fix:
  (a) Touch every render function in 30+ places to strip .loading
  (b) Make the CSS conditional on actually-empty content

This commit takes (b): use :not(:has(*)) so the skeleton applies only
when the element has zero child elements (i.e., still in its bootstrap
"Loading…" text state). As soon as innerHTML is replaced with a table,
chart, or any element-bearing content, the skeleton disappears
automatically — no JS bookkeeping required.

Verified via Playwright across all surfaces:
  total .loading elements found: 17
  withSkeletonOverContent: 0  ← was 30+ before
  withoutSkeleton:        17
  emptyAndStillLoading:    0

Also commits scripts/_shared/widget-loading-audit.mjs which catches
this regression class — future audits can re-run it to detect new
widgets that fall into the same trap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Systematic audit found that 24h master-daemon was running fine, but
4 NAS tables were silently going stale because their feeders had no
schedule. Cascade impact: no new E2-grade events for 41 days.

Audit results (NAS tables on 2026-04-27):

| Table                                | Stale | Cause                        |
|--------------------------------------|-------|------------------------------|
| worldmonitor_intel.* (yahoo-chart)   | 24d   | data-accumulator unscheduled |
| worldmonitor_intel.* (gdelt-doc)     | 28d   | same                         |
| worldmonitor_intel.* (fred raw)      | 27d   | same                         |
| market_returns                       | 30d   | build-market-returns.py      |
|                                      |       | unscheduled                  |
| labeled_outcomes                     | 27d   | cascade from above           |
| event_uplift (last 60d window)       | 99rows| cascade — matched_controls   |
|                                      |       | can't build for recent dates |
| model_eval                           | 16d   | train-meta-model unscheduled |

Three-store anti-pattern: same data lives in `market_quotes` (live,
15min refresh — fine), `worldmonitor_intel.historical_raw_items`
(warm store — stale), and `market_returns` (aggregate — stale). Each
has its own feeder with separate schedule. Live works for the dashboard
display, warm + aggregate are needed for the analytical pipeline (event
grading, alpha decay, scenario lab) — those broke.

Changes:

1. data-accumulator.mjs — make YAHOO_BATCH_SIZE / cycle interval /
   GDELT rate / sidecar port / max backfill days all env-overridable.
   Default batch raised 10 → 30. At 5 syms/2h cycle (the previous
   default) the warm store fell 24+ days behind ingest.

2. master-daemon.mjs TASKS — add two new scheduled tasks:
   - `build-market-returns` (HOUR_6_MS): rebuilds market_returns from
     labeled_outcomes + Yahoo. Runs `python build-market-returns.py`.
     Verified live: completed in 5.8s on first cycle.
   - `train-meta-model` (WEEK_1_MS): weekly retrain.
   `data-accumulator-tick` was tried as an inline task but it blocked
   the entire master-daemon for 10+ min per cycle. Removed — comment
   in TASKS dict points operators to `npm run daemon:accumulator`
   for the standalone continuous worker.

3. build-market-returns.py SQL fix — same UPDATE FROM JOIN alias bug
   we fixed in incremental-event-engine.mjs last week was still
   present here. PostgreSQL doesn't allow target-table alias in JOIN
   ON; moved alias-dependent predicates (`mr.horizon = lo.horizon`,
   sector CASE) to the WHERE clause. Without this fix the script
   built market_returns rows but failed before updating labeled_outcomes
   (caught it on first live run today).

Runtime verification:
- Manual `python scripts/build-market-returns.py` after fix:
  SPY: 2,751 returns rows. Sector ETFs: 14,568. abnormal_return
  updated 408. sector_return updated 93,274. Coverage 528,423/620,862
  (85.1%). market_returns advanced 2026-03-27 → 2026-03-30.
- master-daemon's build-market-returns task ran successfully (5.8s,
  ok=true) on first cycle after restart.
- event_uplift grew 341k → 345k (+4k); E2 28,571 → 29,394 (+823);
  matched_controls 239k → 242k (+2k) after engine recompute.

Recovery is partial because of cascade: market_returns is bounded by
labeled_outcomes which is bounded by warm yahoo. data-accumulator
standalone (now running batch=30) needs ~4-6h to fully catch up
warm yahoo, after which the rest of the chain unblocks naturally.
ETA for fresh E2 on dashboard: ~12-24h.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… embedding throughput

Audit found two more pipeline gaps causing user-visible noise:

1. Hot Events panel was dominated by arXiv-style singleton entries
   (emerging-tech: 14,515 events / 28 days = 518/day, mostly
   article_count=1 source_count=1) plus 1,547+ dt-* hash-named
   dynamic theme codes that hadn't been promoted to canonical taxonomy.
   Result: dashboard's top 12 was 11x emerging-tech singletons + 1x
   real graded event. Other themes (cybersecurity, geopolitics,
   supply-chain-security) were getting clipped out.

2. Embedding refresh was running at 500/cycle but the unembedded
   backlog had grown to 5,931 articles last 7 days (probably ollama
   transient failures or rate-limit during catchup). Without
   embeddings, similarity search and theme classification for those
   articles is degraded.

Changes:

- event-intelligence-builder.mjs (buildHotEventsPayload): add
  `article_count >= 2` AND `theme NOT LIKE 'dt-%'` predicates to
  both pools (recent-by-volume + top-by-grade) of the UNION query.
  Singletons aren't bona-fide events — they're individual
  publications. Dynamic-theme codes belong to the discovery pipeline,
  not user-facing surfaces. Verified live: top-12 went from 11x
  emerging-tech singletons to a balanced mix of 8 themes with
  multi-article confirmation each.

- master-daemon.mjs taskGenerateEmbeddings: limit env-overridable
  with raised default (500 → 2000) and batch overridable. At 2000/h
  the 5,931-article backlog clears in ~3 cycles, then catches incoming
  ~800/day comfortably. Previous 500 default was tuned for steady
  state, not catchup.

Verified:
- Hot Events theme distribution: clean-energy 2, emerging-tech 2,
  supply-chain-security 1, cybersecurity 1, space 2, technology-general
  1, conflict 2, environment-general 1 (vs previously almost all
  emerging-tech)
- AI Lab interactive regression 15/15 still passing
- TS compile clean
- Sam Altman E2 (41-day-old) still on top — it's the only graded
  event in window. Will be replaced naturally as warm yahoo backfill
  produces fresh E2s in the next 12-24h cascade.

Remaining open items (deferred — separate ML PR):
- model_registry: 0 rows (ML inference not running)
- meta-model-server.py port 8100 not running
- canonical_events historical pollution (6,874 dup theme/date groups)
  — display filter handles user-facing impact; cleanup is optional.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cheesss and others added 25 commits April 30, 2026 16:50
User-value gap: with the validated lane sometimes empty (data state, not
code), the dashboard could show nothing actionable for hours. This commit
adds an always-on trending signal driven by raw canonical_events +
article_count — works even when event_uplift is sparse.

scripts/_shared/event-intelligence-builder.mjs
  + buildTrendingThemesPayload(pool, { windowDays, limit, minArticleCount })
    Window-over-window article volume change ranking. Per theme:
      events_now / events_prev
      articles_now / articles_prev
      sources_now
      latest_event_at
      articlesChangePct  (null when prev = 0 → trendStatus 'newly-active')
      eventsChangePct
      trendStatus  surge | rising | stable | cooling | newly-active | no-baseline
                   (≥100% / ≥25% / ±25% / <-25% bands)
    SQL is two CTEs — recent vs previous window — joined and ordered by
    pct change DESC, articles_now DESC.
    Filters: article_count >= minArticleCount, theme NOT LIKE 'dt-%'.

scripts/event-dashboard-api.mjs — new route:
  GET /api/themes/trending?window=7&limit=12&min=2
    Returns the ranking. Window capped at 30d, limit at 50.

src/dashboard/sl-banners.mjs — new banner:
  buildTrendingBanner(payload) renders top 5 themes as
    "↑↑ supply-chain-security (328a · +369%)"
    "↑↑ clean-energy           (178a · +456%)"
    ...
  Uses arrow icons by trend-status band so the user sees acceleration at
  a glance even before clicking through to a theme brief.
  Refreshed on every 60-s sl-banners tick alongside ops/status and
  hot-events. Always rendered (not just on validated=0) so it complements
  the validated lane rather than replacing it.

Live verification on the current data state (no validated/pending events
recently):
  /api/themes/trending?window=7&limit=8 returned:
    surge  biotech               now=52   Δ+642.9%
    surge  aerospace             now=61   Δ+510%
    surge  clean-energy          now=178  Δ+456.3%
    surge  conflict              now=99   Δ+395%
    surge  supply-chain-security now=328  Δ+368.6%
    surge  monetary-policy       now=8    Δ+300%
    surge  emerging-tech         now=246  Δ+261.8%
    surge  cloud-infrastructure  now=137  Δ+234.1%

This is exactly the "엉뚱한 대시보드를 벗어나서 쓸 만한 브리핑 도구" framing
the user pushed for — the dashboard now always shows actionable themes
ranked by real volume change, even when the deeper validation pipeline
is still catching up on the controls + uplift backlog.

68/68 product-side tests still pass; typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…illars

Four user-facing additions, all hooked into the existing sl-banners overlay
so event-dashboard.html stays untouched.

A1 — Today's Top Decision hero card
  src/dashboard/sl-banners.mjs: buildTopDecisionBanner(hot, trending)
  Picks first validated > pending > top-trending. Renders title + theme +
  grade + score + lane pill, plus a "Show evidence ▾" toggle button. When
  validated lane is empty, falls back to top trending theme so the
  dashboard always opens with something concrete instead of a blank.

A2 — Evidence drawer expanded inline
  attachEvidenceToggleHandlers(stack) binds click on the toggle button.
  Calls existing /api/explain-event/<id>, then injects a sibling row with:
    - up to 3 uplift rows: "▸ NVDA 1d — grade E2, |t|=2.78, uplift 1.45%, n_controls=3"
    - up to 3 article citations: "▸ Reuters: <title>"
    - matched controls regime summary: "Matched controls cover regimes: ..."
  Re-clicking the button collapses. matched_controls + |t|-stat + uplift +
  citations on one screen — the previously-buried evidence is now one
  click from the hero.

A3 — /api/dashboard/now-do prescriptive recommendation
  scripts/event-dashboard-api.mjs: priority queue:
    1. ops critical actionable → "⚠ <condition>" + run command
    2. validated > 0 → "✓ N validated signal(s) ready for review"
    3. pending > 0 → "○ N pending validation — blocked on controls or t-stat"
    4. trending top theme → "✦ <theme> surging — N articles, +X%"
    5. idle → "· System idle"
  src/dashboard/sl-banners.mjs: buildNowDoBanner renders at the very top
  of the stack so the operator always sees one prescriptive line.

A4 — /api/dashboard/health-summary unified pillars
  scripts/event-dashboard-api.mjs: 4 pillars (data / pipeline / model /
  product) each with their own level (ok/warning/critical) plus an
  overall roll-up. Pulls ops/status, product-quality, hot-events.modelTrust
  in parallel, classifies each pillar, returns:
    overall: 'critical' | 'warning' | 'ok' | 'unknown'
    pillars: { data, pipeline, model, product }
    actionables: ops actionableInstructions[]
    laneCounts, themeFraming
  src/dashboard/sl-banners.mjs: buildHealthPillarsBanner renders 4 dots
  + per-pillar one-line detail. Gives the operator (and any external
  evaluator) one place to see system health instead of stitching four
  endpoints.

sl-banners stack order, top to bottom = priority:
  1. Now-do (A3)
  2. Today's top decision + evidence drawer (A1+A2)
  3. System health pillars (A4)
  4. Ops actionable (existing)
  5. Hot Events framing + lane breakdown (existing)
  6. Trending themes fallback (N7)

Live verification:
  /api/dashboard/now-do  → "✓ 2 validated signals ready for review"
  /api/dashboard/health-summary → overall=warning,
                                 data=warning, pipeline=ok, model=ok, product=ok
  Vite serves updated sl-banners.mjs (9 new function symbols visible).

68/68 product-side tests still pass; typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The user added a domain dictionary (THEME_RELEVANCE_TERMS) to
event-product-score.mjs that gives computeThemeKeywordOverlap a richer
signal than pure tokenization. Two existing tests assumed the old
token-only behaviour:

- "word boundaries" test used a title with 'NVIDIA' under theme 'ai-ml'.
  'nvidia' is a legitimate ai-ml domain term, so the new dictionary fires
  domain-overlap correctly. Updated the title to one with no domain hits
  ("Investors gain on consumer chip-free demand surprise") so the test
  still pinpoints the word-boundary check.
- "themeRelevance boosts when overlap is strong" expected rationale tag
  /keyword-overlap:\d/. The dictionary path emits domain-overlap:NN%, so
  the assertion now accepts either prefix: /(keyword|domain)-overlap:\d/.

31/31 N1 tests now pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A 5-step intro modal that runs once per browser (localStorage flag
'lattice-onboarded'). Skips silently on subsequent loads, in iframes,
and during E2E (?e2e=1).

src/dashboard/sl-onboarding.mjs (new)
  - STEPS array, 5 entries:
    1. Welcome — "Validated market signals with proof, not noise"
    2. Five surfaces, one workflow (Home / Inbox / Investigate / Geo / Ops)
    3. Lanes tell you trust (validated/pending/watch/noise color coding)
    4. Decision Inbox keyboard (J/K/A/R/S) + audit trail
    5. Self-monitoring system (health pillars, stale auto-refresh)
  - Modal-based (not coachmarks) because the inline 7,971-line dashboard
    has dynamic surfaces — positional anchors would break across resize
    and surface switches. Plain modal works until the G2 split.
  - Esc / click-outside / Enter / Space all advance or dismiss.
  - Skip button records "onboarded" so it never shows again.

event-dashboard.html: ONE-LINE addition just before </head>:
  <script type="module" src="/src/dashboard/sl-onboarding.mjs"></script>

Verified: vite serves the module (HTTP 200, function symbols visible).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces templated brief content with Codex-generated specific narrative
on demand, with caching + daily budget guard + graceful fallback.

scripts/_shared/ai-analysis-builder.mjs
  + buildThemeNarrativePayload(pool, { theme, period, briefPayload, forceRefresh })
    Returns { ok, theme, period, narrative: { whatChanged, whyMatters,
    evidence, caveats, monitor, related }, generatedAt, cached }.
  + Cache: theme_narrative_cache table (auto-created), 24h TTL keyed by
    (theme, period). PRIMARY KEY allows clean upsert.
  + Cost guard: LATTICE_LLM_DAILY_BUDGET (default 20) — counts today's
    cache rows and refuses new generations once exceeded. Returns
    { ok: true, exhausted: true, reason: '...' } so callers can fall
    back without retry storms.
  + Codex prompt buildThemeNarrativePrompt: passes the templated brief
    as context with explicit "replace, don't paraphrase" instruction.
    Output schema is enforced JSON; parsed safely with Array.isArray
    checks + slice() bounds (4 lines per section, 6 for evidence).
  + Codex CLI failure (CLI missing, JSON malformed, etc.) returns
    { ok: false, error: '...' } — never throws.

scripts/event-dashboard-api.mjs — /api/theme-brief/<theme>?narrative=llm
  + Detect ?narrative=llm; bypass resolveWithCache (LLM path has its
    own cache layer in theme_narrative_cache).
  + enhanceWithNarrative(briefPayload):
    - Calls buildThemeNarrativePayload.
    - On success, MERGES narrative into briefStructure: replaces a
      section's content only when LLM produced ≥1 line for it; original
      template stays as fallback for empty sections.
    - For evidence: replaces .items but keeps existing .classes from
      the templated builder.
    - For related: maps narrative array → related.entities.
  + Returns { ...brief, narrativeSource: 'cache'|'llm-fresh'|
    'budget-exhausted'|'llm-failed', narrativeGeneratedAt,
    narrativeError } so the dashboard can show provenance.

Live verification on /api/theme-brief/clean-energy?period=quarter&narrative=llm:
  narrativeSource: llm-fresh  (first call) → cache (subsequent within 24h)

  Sample LLM output:
    whatChanged:
      - "Clean-energy coverage snapped back into growth at 382 stories,
         reversing the prior quarter's softer trend."
      - "It still owned 93.15% of Environment General coverage; the
         0.6-pt share dip is minor, not a breakdown."
    whyMatters:
      - "This is more than noise: volume, +174.82% y/y growth, and
         strong category share improved at the same time."
      - "The visible corporate signal is deployment-led: shipping
         decarbonization activity points to retrofit spending, not
         headline-only interest."
    caveats:
      - "Base effects matter: a weak prior quarter can make the rebound
         look cleaner than the underlying demand really is."
      - "Breadth is not yet deep; source diversity is 0.8763 and
         geography spans 7, so leadership may still be narrow."
    monitor:
      - "Check whether next-quarter gains spread beyond shipping into
         grid, storage, utilities, and industrial efficiency names."
      - "Prioritize harder proof: project awards, filings, patents,
         earnings commentary, and regulator actions tied to deployment."

This replaces the template "Monitor whether X keeps positive
acceleration" lines with specific, sector-naming, context-aware analysis
— a meaningful step from B-grade brief content toward A-grade.

Default behaviour (no ?narrative=llm) is unchanged so existing dashboards
keep getting templated briefs. Opt-in path lets the dashboard or LLM
narrative track ramp incrementally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The existing settings.html serves desktop runtime config (Tauri secrets,
Ollama models, runtime feature flags) — that 1,360-line module is for
power-user / desktop deployment configuration. THIS commit adds an
orthogonal "in-app preferences" surface for normal dashboard users:
display language, lane filter default, alert thresholds, polling cadence.

scripts/_shared/user-prefs.mjs (new)
  - File-backed at data/user-prefs.json (gitignored).
  - Single-user mode for now (user_id='default'); shape is forward-
    compatible with the deferred multi-tenant track.
  - getUserPrefs / setUserPrefs (validating partial merge) / resetUserPrefs.
  - Validation: language ∈ {en, ko}; defaultLane ∈ {all, validated,
    pending, watch, noise}; refreshIntervalMs clamped 10..600 s; alert
    thresholds clamped to sensible ranges.

scripts/event-dashboard-api.mjs — new /api/user-prefs route:
  GET    /api/user-prefs[?user=]   — read merged-with-defaults prefs
  POST   /api/user-prefs           — partial merge update
  DELETE /api/user-prefs           — reset to defaults

src/dashboard/sl-prefs.mjs (new)
  - Adds a fixed gear button at bottom-left (#sl-prefs-toggle).
  - Click → modal with three sections (Display / Alerts / Refresh) +
    "Re-show onboarding tour" action.
  - Save persists via /api/user-prefs and falls back to localStorage on
    API failure so the UX feels responsive offline.
  - Reset button confirmable, then calls DELETE.
  - Modal styled with the shared token system; honours --bg-surface,
    --accent, etc.

event-dashboard.html: ONE-LINE addition before </head>:
  <script type="module" src="/src/dashboard/sl-prefs.mjs"></script>

.gitignore: data/user-prefs.json (runtime artifact).

Live verification:
  GET  /api/user-prefs                                → defaults
  POST /api/user-prefs {language:"ko", ...}           → persisted
  Vite serves the new module (HTTP 200).

Distinct intentionally from settings.html — that file remains the
desktop runtime config surface; this is the in-app UX preferences pane.
A future cleanup can fold both into a single "preferences" surface, but
the dashboard's WIP state on settings.html makes this slice safer for now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
scripts/build-public-demo-snapshot.mjs (new) — single command to extract
a sanitized N-month slice from NAS PostgreSQL into one JSON file. The
file can be inspected by external evaluators or loaded by a sandbox
build of the dashboard (LATTICE_DEMO_MODE=1, see C3).

What the snapshot contains:
  - themes[]              theme aggregates (event_count, article_count,
                          latest_event_date)
  - canonicalEvents[]     id, theme, title, eventDate, articleCount,
                          sourceCount (article_count >= 2, no dt-* themes)
  - articles[]            id, title, source, publisherGroup, publishedAt,
                          url, marketRelevance, summary (truncated 280ch)
  - eventUplift[]         canonicalEventId, symbol, horizon, uplift,
                          tStat, evidenceGrade, nControls
  - modelPredictions[]    canonicalEventId, symbol, horizon, modelVersion,
                          alphaProb, expectedAlpha, downsideRisk, timeToPeak,
                          evidenceGrade, createdAt
  - attribution           sources list + license note

PII / sanitization:
  - email-like and phone-like patterns scrubbed from titles and summaries
  - article body truncated to 280 chars (avoids verbatim republication)
  - URLs kept (linking to original publishers is fine and required by
    most data licenses)

Defaults:
  --months=6   window
  --output=data/public-demo/lattice-snapshot-<YYYY-MM-DD>.json

.gitignore: data/public-demo/ (regenerated artifact, large)

Live verification with --months=2:
  ✓ 2.13 MB JSON in 163 ms
  counts: 33 themes / 328 events / 3,653 articles / 443 uplift /
          3,312 predictions
  Sample event: ai-ml "Elon Musk's worst enemy in court is Elon Musk"
                articleCount=21, sourceCount=13
  Sample uplift: ICLN 2w, t=-8.29, grade=E0, n_controls=2
  Sample prediction: GOOGL 1w, alpha_prob=0.518, expected_alpha=2.09

Bug fix during dev: model_predictions schema is (alpha_prob,
expected_alpha, downside_risk, time_to_peak, evidence_grade) — not a
single 'prediction' column. Updated the SELECT.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When LATTICE_DEMO_MODE=1, the API serves the read-only public snapshot
and refuses every mutation. Lets the dashboard run on Vercel / any
static host without NAS access — external evaluators see real signal
output in 5 seconds without us shipping DB credentials.

scripts/_shared/demo-mode.mjs (new)
  - isDemoMode()                — env LATTICE_DEMO_MODE === '1' | 'true'
  - blockIfDemoMode(method)     — returns { status:403, body:{...} } for
                                  POST/PUT/DELETE/PATCH; null otherwise.
  - loadDemoSnapshot()          — finds + caches the latest snapshot from
                                  data/public-demo/ (1h TTL, env override
                                  via LATTICE_DEMO_SNAPSHOT_PATH).
  - clearDemoSnapshotCache()    — for tests.

scripts/event-dashboard-api.mjs
  - resolveEventDashboardResponse intercepts ALL writes early when in
    demo mode → 403 with explanatory body.
  - New /api/demo/snapshot — metadata + themes (compact).
  - New /api/demo/snapshot/<section>?limit=&offset= — paginated section
    access (events|articles|uplift|predictions). 2 KB requests, demo-
    friendly.

src/dashboard/sl-banners.mjs
  - On every refresh tick, fetches /api/demo/snapshot. When demoMode is
    true, prepends a warn-style banner at the top of the stack:
    "Demo data · read-only · X-month slice · N events / N articles /
     N uplift rows. Writes return 403."

docs/SANDBOX_DEPLOY.md (new)
  - Local dry-run instructions:
      LATTICE_DEMO_MODE=1 node scripts/event-dashboard-api.mjs &
      LATTICE_API_BASE=http://127.0.0.1:46200 npm run dev:vite
  - Vercel deploy recipe + env vars to set:
      LATTICE_DEMO_MODE=1
      LATTICE_DEMO_SNAPSHOT_PATH=public/lattice-snapshot.json
  - What the evaluator sees / what's blocked / refresh cadence.

Live verification:
  ✓ LATTICE_DEMO_MODE=1 → /api/demo/snapshot returns
    { ok:true, demoMode:true, counts:{themes:33, events:328,
      articles:3653, uplift:443, predictions:3312} }
  ✓ POST /api/watchlist while in demo mode → 403 with
    "Demo mode is read-only. Writes (review / watchlist mutations /
     preferences) are disabled..."
  ✓ Without LATTICE_DEMO_MODE → POST /api/user-prefs returns ok:true
    (writes restored).

This unblocks the public sandbox URL track (Sprint 3 Vercel deploy is
configuration, not code).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three Sprint 3 deliverables wrapped into one commit because they
together complete the "investor demo readiness" track.

B1 — /api/model-comparison + model-comparison.html
  scripts/_shared/model-comparison.mjs (new):
    buildModelComparisonPayload(pool) reads model_eval, groups by
    model_version, computes mean and worst across folds for Brier,
    ECE, log-loss, top20 precision, alpha hit rate, deflated Sharpe.
    Adds a synthetic "naive-random-top20" baseline (Brier 0.25, ECE
    0.10, top20 0.20, hit rate 0.50) as comparison anchor.
    lift{} envelope exposes Brier improvement %, ECE improvement %,
    top20 lift %, hit rate lift % vs the baseline.

  scripts/event-dashboard-api.mjs:
    GET /api/model-comparison route.

  model-comparison.html (new):
    - Hero: 4 cards (Active model, Brier improvement, top20 lift,
      hit-rate lift) — green for positive, red for negative.
    - Table: every model_version side-by-side with mean/worst Brier
      and ECE, top20 precision, hit rate, deflated Sharpe, sample count.
      Best model highlighted in green; baseline row muted.
    - Methodology block: promotion gate (CLAUDE.md §10), walk-forward
      details, baseline assumptions.
    - Links to / dashboard, raw JSON, /license.html, /data-sources.html.

  Live verification:
    meta-v1-20260411-0710     Brier 0.216  ECE 0.099  top20 0.250  ← best
    meta-v1-20260430-0633     Brier 0.281  ECE 0.162  top20 0.313
    meta-v2-20260411-1506     Brier 0.287  ECE 0.267  top20 0.263
    naive-random-top20        Brier 0.250  ECE 0.100  top20 0.200

    lift over naive: Brier +13.6%, ECE +1.1%, top20 +25%, hit rate +36.5%

B2 — comparison_lift metric in /api/product-quality
  scripts/_shared/product-quality-metrics.mjs:
    + computeComparisonLiftMetric(pool) imported from model-comparison.
    + metrics.comparison_lift  = mean(brierLift, top20Lift), clamped 0..1
    + details.comparison_lift  exposes brierLift, top20Lift, activeModel,
                                sample count

    Live: comparison_lift = 0.193 with brierLift 0.136, top20Lift 0.25.
    > 0.10 threshold = "model meaningfully beats naive baseline".

E1 — License / terms / data sources static pages
  - license.html: AGPL v3.0 explanation + "what's in the source vs not"
                  + "not financial advice" callout.
  - data-sources.html: 4 attribution tables — News/RSS, Market, Research,
                       Models — with each provider's terms link.
  - terms.html: appropriate vs inappropriate use cases, no-warranty
                callout, demo-mode caveats, liability.
  - All three load /src/dashboard/shared/tokens.css for visual coherence
    with the dashboard. Cross-link to each other and back to /event-dashboard.html.

vite.config.ts: 4 new HTML entry points added to rollupOptions.input
  (modelComparison, license, dataSources, terms) so production builds
  emit them as proper static pages.

Verified:
  ✓ Vite serves all 4 new pages (HTTP 200).
  ✓ typecheck clean.
  ✓ 60/60 product-side tests pass.
  ✓ release-readiness 7 OK + 0 WARN + 0 SKIP. semantic-health FAIL is
    data-state (12k stale predictions, modelTrust=disabled) — not a
    code regression.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three overlay modules (sl-banners, sl-onboarding, sl-prefs) each duplicated:
  - element factory (el / \$)
  - API_BASE resolver
  - JSON fetch with try/catch
  - <style> block injection (~50 lines each, near-identical)
  - DOMContentLoaded boot scaffolding

Centralised into:

src/dashboard/shared/dom-utils.mjs (new, 93 lines):
  - el(tag, props, children)         — class/style/data-*/Node-or-string aware factory
  - API_BASE                         — single resolver from window.LATTICE_API_BASE
  - fetchJson(pathOrUrl)             — null on non-OK (except 503), never throws
  - ensureOverlayStylesheet()        — idempotent <link> injection
  - deferUntilIdle(callback, delay)  — DOMContentLoaded + setTimeout boot helper

src/dashboard/shared/sl-overlay.css (new, 405 lines):
  Single shared stylesheet covering banner stack, onboarding modal, and
  prefs modal. All selectors use var(--token, fallback) so design-token
  changes propagate without code edits.

Net diff:
  sl-banners.mjs        603 → 530   (-73)
  sl-onboarding.mjs     196 → 142   (-54)
  sl-prefs.mjs          281 → 227   (-54)
  module subtotal      1080 → 899   (-181)
  + dom-utils.mjs        +93
  + sl-overlay.css      +405 (replaces ~150 lines of inline CSS across modules)

Wire-size win at runtime: the stylesheet is loaded once (cacheable),
versus three runtime-injected <style> blocks each parsed separately.

Behaviour parity:
  - All public exports unchanged (refreshBanners, refreshBriefStructure,
    startOnboarding, openPrefsModal).
  - DOMContentLoaded timing preserved via deferUntilIdle.
  - ensureOverlayStylesheet is idempotent so any module can call it without
    fighting another.

Verified:
  ✓ typecheck clean
  ✓ Vite serves the new dom-utils.mjs and sl-overlay.css (HTTP 200)
  ✓ sl-* modules import the new helpers (visible in vite-served output)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
17 routes shared the same try/catch + logger.warn + 500 envelope:
  try {
    const payload = await build...();
    return buildJsonResponse(payload, ...);
  } catch (err) {
    logger.warn('<route> route failed', { error: String(err?.message || err) });
    return buildJsonResponse({ ok: false, error: String(err?.message || err) }, 500);
  }

scripts/_shared/route-helper.mjs (new):
  - runRoute({ name, logger, build, buildJsonResponse }) — one-shot wrapper.
    - build() returning a payload → 200 envelope.
    - build() returning { payload, statusCode } → custom status.
    - build() throwing → logger.warn + 500 envelope.
  - makeRouteHandler(logger, buildJsonResponse) — factory for the bound
    `handle(name, fn)` callsite shape.

scripts/event-dashboard-api.mjs:
  - Imports makeRouteHandler.
  - resolveEventDashboardResponse instantiates `handle = makeRouteHandler(...)`.
  - Refactored 6 simple routes to use it:
      /api/model-comparison
      /api/themes/trending
      /api/meta-model-health        (custom status: 503 on critical)
      /api/explain-event/<id>       (custom status: 404 on miss)
      /api/theme-impact/<theme>     (custom status: 400 on bad input)
      /api/source-diversity-audit   (custom status: 503 on critical)

  Each route loses 4 lines of boilerplate. Routes with more complex
  logic (custom error envelopes, multi-method handlers, dynamic since
  cache, demo-mode interplay) were left as-is to keep the diff
  reviewable.

Boilerplate count:
  before: 17 'logger.warn ... route failed' occurrences
  after:  11 (6 refactored)

Verified:
  ✓ typecheck clean
  ✓ 60/60 product-side tests pass
  ✓ All 6 refactored routes return HTTP 200 in live testing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The dashboard fires /api/dashboard/now-do, /api/dashboard/health-summary,
and the underlying /api/ops/status, /api/hot-events, /api/themes/trending,
/api/product-quality endpoints together every 60s. Without caching, the
shared heavy SQL builders (ops-status, hot-events, trending-themes,
product-quality) ran 2-3 times per tick for identical args.

Added scripts/_shared/memoize-payload.mjs with:
- memoize(name, builder, ttlMs) returning a wrapper that caches the
  Promise (so concurrent in-flight calls share one query)
- stableArgsKey() that skips pool/client args (anything with .query)
- failed-promise eviction so errors don't poison the cache

Wired the four heavy builders through memoized closures in
event-dashboard-api.mjs, with pool closed over so the cache key is
computed from non-pool args only. Live test confirms identical
generatedAt within TTL and fresh build after 11s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Foundational token vocabulary for the Palantir/Bloomberg-grade redesign.
All --v3-* tokens are additive — older tokens above remain so existing
inline event-dashboard.html style block keeps rendering until per-surface
modules adopt v3.

Tokens added:
- Lane (categorical): validated/pending/watch/noise — Radix dark scales
- Direction (Bloomberg-semantic, deuteranopia-safe): up=blue, down=red,
  amber=NEUTRAL data text only (corrects v1/v2 misuse of amber as accent)
- Severity: critical/warning/info/ok
- Background depth: 5-step slate-tinted scale
- Borders: hairline/soft/strong/focus
- Text: 4-step luminosity scale (no opacity tricks)
- Evidence ladder: luminosity-based E0~E4 (fixes existing opacity-0.25 a11y bug)
- Radius: 3-tier (8/12/16) only
- Spacing: 4px base scale
- Freshness pill: 3-state (Live/Stale/Paused, per Smashing 2025)
- Motion: Linear-style timing + 3 easings

Also created src/dashboard/v3/index.mjs entry that imports four phase
modules; subsequent agents fill them in (parallel-safe, each owns its file).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four phases built in parallel by isolated agents (each owning their files
under src/dashboard/v3/), then integrated via a single index.mjs entry and
one <script type="module"> tag in event-dashboard.html.

Phase 1 — State messaging (337 JS / 234 CSS):
- mountFreshnessPill: 3-state Live/Stale/Paused per Smashing 2025 spec
- mountSkeleton: Carbon-style staged skeleton (list/card/table/tile)
- runOptimistic: SWR-style optimistic UI helper
- pulseValue: value-change glow with up/down/neutral direction tint
- Auto-attach to [data-v3-freshness] hosts (opt-in, no-op when absent)

Phase 2 — Global chrome (662 lines / 5 files):
- mountTickerBar: 6-cell status strip (model, articles/24h, validated,
  gate, ECE, sync); aria-live=off, opt-in via [data-v3-ticker] on body;
  fetches /api/dashboard/health-summary + /api/ops/status every 60s
- mountCheatsheet: ?-toggled slide-in panel (Slack-style, NOT modal),
  static catalog of all Lattice key bindings
- mountHistoryStack: Cmd+[/] back/forward over surface changes,
  MutationObserver on body[data-surface], 50-entry in-memory stack

Phase 3 — Additive surface components (1,119 lines / 7 files):
- mountSparkline: pure SVG, no deps
- mountTreemap: d3-hierarchy squarified, fallback stacked-bar
- mountMatchedDag: hand-rolled SVG DAG for treatment ↔ controls
- mountTimeSeries: lightweight-charts wrapper, lazy-imported
- mountDenseTable: tabulator-tables wrapper, lazy-imported
- All opt-in (no auto-mount), called by surfaces in a future PR
- Adds deps: d3-hierarchy ^3.1.2, lightweight-charts ^5.2.0,
  tabulator-tables ^6.4.0

Phase 4 — Motion primitives (297 JS / 28 CSS):
- anim/slideIn/slideOut/fadeIn/fadeOut/pulseGlow/flashOnUpdate/
  animateSurfaceSwap — Web Animations API, no deps
- Honors prefers-reduced-motion: reduce (read at call time so OS toggles
  take effect mid-session)
- Re-entry on same element + id cancels prior animation cleanly

Integration:
- src/dashboard/v3/index.mjs imports all four phase entries; each module
  self-mounts on deferUntilIdle
- event-dashboard.html: 7 lines added (link + script tag + ticker opt-in
  attribute on body)
- npx tsc --noEmit passes
- Vite serves /src/dashboard/v3/* with HTTP 200, no compile warnings

Total: ~2,800 lines across 17 v3 files. Old surfaces unaffected — all
new components are opt-in or invoked by future PRs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds src/dashboard/v3/wire.mjs (and wire.css) which scans the live DOM
after first idle and attaches phase 1-4 components to known surface
selectors — without modifying event-dashboard.html's inline markup or
inline <style> block. Removing this file removes only the polish, not
any user-facing functionality.

Wired in:
1. Freshness pill on each surface's hero card (#now-do-card, inbox stat
   strip, investigate hero, ops system-health) — falls back silently if
   the selector doesn't exist
2. .v3-num utility class stamped on .brief-stat-value, .kpi-chip-value,
   .story-rank, [data-numeric] etc. — tabular-nums + slashed-zero
3. Ops pillar sparklines (60-tick rolling buffer, fed by
   /api/dashboard/health-summary every 60s)
4. Inbox accept/reject/snooze: optimistic UI (.v3-row-* tints) +
   directional pulseGlow via capture-phase listener (additive, never
   cancels the page's own click handler)
5. Surface tab swap: animateSurfaceSwap fade via MutationObserver on
   body[data-surface]
6. Value pulse on any [data-pulse-on-update] cell — direction inferred
   from the parsed-number delta (up/down/neutral)

wire.css fixes the audit-flagged a11y bug for E0 evidence rows
(--text-muted opacity 0.25 was nearly invisible) by overriding to the
v3 luminosity-based --v3-ev-e0/e1 ladder. Also adds 32px body
padding-top when [data-v3-ticker] is set so the new ticker bar doesn't
overlap content, and brightness(0.92) dim on the rest of the page when
the cheatsheet sidebar is open.

npx tsc --noEmit passes; Vite serves wire.mjs and wire.css at 200.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Refines wire.mjs after grepping the actual event-dashboard.html structure
to use selectors that exist:

- Freshness pills: #decision-cockpit / #hot-events-section /
  #lattice-trust-strip etc. (verified existing IDs, with priority-ordered
  fallbacks so the first match wins)
- .v3-num utility class: extended to .tstrip-value, .since-value,
  .since-delta, .kpi-regime-badge, .count (surface-nav badges) — all
  numeric cells the monolith renders into
- New wirePulseTargets(): stamps data-pulse-on-update on the live KPI
  cells (#kpi-vix, #kpi-risk, #kpi-spread, #kpi-oil, #kpi-dollar,
  #tstrip-*-value, #since-inbox, #since-synd, #inbox-total-count) so
  the existing MutationObserver in wireValuePulse() catches each refresh
  and pulses with up/down/neutral colour based on the parsed delta

The page's own JS rewrites these cells on every poll (60s tick); the
pulse glows the cell briefly so the eye catches changes — Geckoboard
2025 rule, "animate changes, not renders".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Upgrades the v3 token palette from "professional-restrained" to
"high-end-fancy" matching Linear / Palantir / Vercel's tonal range, and
re-routes the legacy --accent / --signal-green / --bg-* aliases via
wire.css :root so the entire monolith picks up the new colours without
any HTML edit.

Tokens — tokens.css:
- Lanes: emerald-400 / amber-400 / blue-400 (Linear-saturation, was
  Radix muted scales)
- Direction: sky-400 up / rose-500 down / amber-400 neutral (hot, glowy)
- Severity: rose-400 critical (was tomato), so glows pop
- Background: indigo-tinted near-black (#08081a → #2a2e47) replaces
  flat slate (#020617 → #475569). Subtle warm tint reads premium.
- Borders: indigo-tinted instead of slate
- Evidence E4: mint-cyan #00f5d4 (matrix high-conviction tone)
- New signature: cyan-400 primary (--v3-accent), violet-400 secondary
  (--v3-accent-2), pink-400 special (--v3-accent-3)
- New glows: --v3-glow-validated/-critical/-accent (radial box-shadow
  presets)
- New gradients: --v3-grad-aurora (cyan→violet→pink), --v3-grad-tile,
  --v3-grad-validated/-critical/-violet
- New frosted glass: --v3-glass-bg + --v3-glass-blur (20px, 180% sat)

Wire.css :root override — re-routes legacy tokens:
- --accent: cyan signature (was lime-yellow #d8f99d)
- --bg-void/-base/-surface/-elevated: indigo-tinted scale
- --signal-green/-amber/-red/-blue: mapped to v3 lane/severity
- Effect: all inline monolith CSS that reads var(--accent) etc. now
  resolves to fancy palette — page-wide shift, zero HTML edit

Visual upgrades — wire.css:
- Ticker bar: frosted glass + cyan-tinted bottom border + soft glow
- Cheatsheet panel: frosted glass + violet edge tint, cyan kbd chips
- Freshness pill: live dot has 8+16px emerald glow halo
- Validated evidence rows (E3/E4): emerald-mint left bar with bloom
- Cockpit/accent cards: aurora gradient (cyan → violet → pink) overlay
- Surface nav active tab: cyan underline with linear-gradient + glow
- Trust strip values: cyan text-shadow on tabular numbers
- Focus ring: 4-layer cyan halo (Linear-style)
- Inbox optimistic rows: glow + linear gradient instead of flat tint
- Critical banners: rose box-shadow glow
- KPI chip values: Bloomberg-correct amber-glow on neutral data
- Sparklines: cyan stroke + drop-shadow filter
- Theme/trust/since strips: subtle aurora-tone backdrop
- Section kickers: linear-gradient text-fill (cyan → violet)

prefers-reduced-motion: drops backdrop-filter to avoid GPU cost on
low-end devices, keeps glows.

npx tsc --noEmit passes; Vite serves all files at 200.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
P0 — must-fix
 1. delete 4 Snapshot cards on Home (Risk/Macro/Validation/Source-Ops dup the Trust Strip)
 2. inbox actions → "verb → destination" template (Accept→Followed, Reject→Archive, Watch→Tracking…)
 3. cross-theme card front shows only claim+lane+score+evidence; novelty/seed-sim/pathway/
    diagnostics/secondary actions move into <details>
 4. Theme Brief: lifecycle/YoY/accel/source-div collapsed into a single ●●●○ Brief Trust pill
    (hover surfaces full numbers + diagnostics); Related Entities + Adjacent Pathways demoted
    into a single closed <details> with chip preview on the closed summary
 5. Scenario Lab: per-slider why-copy on hover (VIX/Yield/Oil) + no-change explainer that
    suggests specific knob moves when no theme classification flips
 6. Ops: Today's Events / Issue Temp / Tech Trends / Codex Discoveries / Anomalies /
    Pending Validation moved into a collapsed legacy-feeds <details> with destination labels
    (→ Inbox, → Investigate); element IDs preserved so existing JS loaders still write
 7. empty-state copy pass: "loading X" → "what would be here + how to fill it" rule

P1 — high leverage
 1. Home reference drawer summary rewritten ("hot events · evidence chain · alerts · …"),
    closed-by-default reaffirmed
 2. inbox filter row: state filters → segmented control; type filters → chip strip;
    zero-count badges hide via .seg-count.is-zero
 3. inbox bulk operations folded behind a "Bulk… ▾" <details> menu with per-type
    breakdown ("3 → Followed · 2 → Run · …"); Shift-S = custom snooze duration; B opens menu
 4. Investigate: 3-column brief grid above the fold (Theme Brief · Current Regime ·
    Scenario Lab); Scenario Lab moved up from AI Lab section
 5. Investigate drawers: jump-link tab row above the two reference <details> with renamed
    summaries (Discovery·Lifecycle·Breadth·Watchlist | Lab·Asset·Correlation·AI Digest)
 6. Personal Tracking: compact chip strip on Home (loadTrackedTargets renders both chips
    and full list); add form folded into a <details> with "+ Add tracking target" summary
 7. Trust Strip absorbs Since Strip: original since-* IDs hidden but populated; compact
    one-line since-summary replaces the multi-pill bar; deltas appended to DATA/SOURCE
    pillar tooltips
 8. canonical 4-state lattice-dot system (validated/candidate/stale/provisional) — applied
    to inbox-list rendering and cross-theme cards; replaces ad-hoc fresh/status mini-chips

P2 — polish
 1. scenario URL share: VIX/Yield/Oil encoded in URL params, applied on page load
 2. cross-theme "Not useful" → review endpoint posts feedbackKind=not-useful for the
    discovery model
 3. inboxSnoozeCustom() prompts for hours/days/until-date; Shift-S keyboard shortcut
 4. citation copy buttons in showCitationPopover (Markdown link + BibTeX entry)
 5. reference drawer last-open persistence via localStorage (home-reference-drawer +
    investigate-trend-drawer + investigate-analysis-drawer)
 6. keyboard shortcuts: G T (Theme Brief deep link), D (toggle surface drawer), B (Inbox
    bulk menu), O (open/collapse all <details> on active surface)
 7. first-run modal replaces the "How To Use This Dashboard" home card; F key reopens it;
    auto-shown once (gated by localStorage:lattice:first-run-shown)

Net: +1561 / -156 in event-dashboard.html. No JS loaders broken — all renamed/relocated
elements keep their original IDs so background fetchers continue to write to them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WHY
The post-IA dashboard still had ~163 inline font-size:9-10.5px hits, text-soft
at 45% opacity, and text-muted at 25% — all of which fail WCAG AA at small
sizes against the bg-void background. Letter-spacing was inconsistent across
uppercase labels and line-height defaulted to ~1.2 (too tight for analyst
reading sessions).

WHAT
1. tokens.css — bumped text opacity to .96 / .80 / .60 / .42 (was .95 / .75
   / .45 / .25). Added a semantic type scale (--fs-caps-xs / --fs-2xs / --fs-xs
   / --fs-sm / --fs-md / --fs-lg / --fs-xl / --fs-2xl), line-height tokens
   (--lh-tight 1.2 / --lh-snug 1.35 / --lh-default 1.5 / --lh-relaxed 1.6),
   letter-spacing tokens for uppercase, and font-weight tokens (Geist 400-700).

2. event-dashboard.html — inline :root mirrored. Body baseline 12px → 13px.
   Added antialiasing + text-rendering:optimizeLegibility. Added a typography
   refinement block at the end of the inline <style> that overrides ~30
   commonly-used classes:
   - section-kicker / brief-section-title / brief-stat-label / tstrip-label →
     normalized to --fs-caps-xs (10.5px) with --ls-caps (.1em) and --fw-semi
   - section-title → --fs-lg (16px) with --lh-tight
   - card-title → --fs-md with --fw-semi
   - meta-line / section-meta → --fs-xs (12px) with --lh-default
   - badge → --fs-2xs (11px) instead of 9-10px
   - inbox-shortcut / inbox-arrow / inbox-dest → 10.5-12px (was 9-10px)
   - xtc-* claim, lane chip, score, reason → consistent scale + brighter colors
   - brief-trust-pill → 12-15px (was 9.5-13px)
   - scenario-empty-explainer / scenario-pred-row → readable
   - inbox-filter-* → 11-12px with weight differentiation
   - inbox-item-title → 13px with snug line-height
   - tracking-chip → 12px with hover lift
   - lattice-dot → 8px (was 7px), stronger glow on validated/provisional
   - lattice-since-summary → 11px with proper hierarchy
   - first-run modal → 13-14px with kbd styling
   - cite-popover → readable copy buttons

3. Universal numeric tabular-nums slashed-zero applied to every value cell
   (brief-stat-value, kpi-chip-value, tstrip-value, since-value, scenario
   predictions, regime KPIs, seg-count, cite-num, etc.).

4. Color refinements:
   - Cross-theme lane chips now have borders + 18% bg + brighter foreground
     (lane-validated #5eebac, lane-research #7eb6fa, lane-noise #cbd5e1)
   - inbox-filter-seg.active gains an inset ring for clearer state
   - tracking chips: brighter green text + hover lift
   - 4-state lattice-dot: validated green glow tightened, provisional violet
     glow added

5. Accessibility:
   - focus-visible outline (2px cyan ring) on every interactive element —
     keyboard users always see focus
   - @media (prefers-contrast:more) overrides text tokens to fully solid
     white scale for users who request it

6. Defensive rule — any remaining inline font-size:9-10px gets forced up to
   --fs-caps-xs (10.5px) via attribute selector, so legacy markup stays legible
   without per-line audits.

7. wire.css — V3 ticker label aligned to --fs-caps-xs and --fw-semi (was 9.5px).
   V3 luminosity-based text colors left unchanged — already accessible against
   the indigo-tinted backgrounds.

Net: tokens.css +44/-5, event-dashboard.html +120/-6, wire.css +3/-2.
No JS changes. Cascade order ensures this pass overrides earlier rules without
editing them, so reverting is just removing the typography block at the end
of the inline <style>.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WHY
Reports for subject="cloud-infrastructure" silently picked unrelated rows:
  symbol_signal_report  -> TLT (long-duration treasury, theme_exposure=0.05)
  event_signal_report   -> Medicare AI EFF lawsuit
  cross_theme_report    -> Vertiv (right by accident — top score)
The DB adapter filters were patterns like
  WHERE ($1::text IS NULL OR ce.theme = $1)
which silently match all rows when the subject string can't be coerced into
the adapter's expected key shape. A pack labeled "cloud-infrastructure" is
worse than no pack — it actively misleads.

A second issue: theme_trend_aggregates rows reported article_count=0 in the
window with computed acceleration = -145%. The aggregate row was stale vs
the current article→theme mapping, but nothing flagged the inconsistency.

WHAT
1. NEW scripts/_shared/report-subject-fidelity.mjs
   - resolveSubjectKey(input) — canonical {kind, key, raw} ('cloud-infrastructure'
     classified as kind='theme' instead of being passed through ambiguously)
   - buildNoBoundCandidateBundle({reportType, requestedSubject, reason}) —
     structurally valid no-data bundle so the report explicitly says "no
     subject-bound X candidate for Y" instead of falling back silently
   - attachSubjectFidelity(bundle, {requestedSubject, matchStatus, ...}) —
     stamps every bundle with metadata.requestedSubject + subjectMatchStatus
     (subject-bound | no-bound-candidate | fallback-used | system-wide).
     fallback-used appends CAV-SUBJECT-FALLBACK so the warning renders inline.
   - classifySubjectMatch — hyphen/underscore tolerance for theme slugs
   - isSystemWideReportType — regime + system_quality bypass binding

2. NEW scripts/_shared/report-data-diagnostics.mjs
   Detects 5 inconsistency patterns and appends caveats:
   - aggregate-orphan: article_count=0 with non-zero YoY/accel/novelty
   - evidence-aggregate-mismatch: recent_evidence>0 but article_count=0
   - baseline-distortion: |acceleration| > 500% (base period near zero)
   - low-sample-overfit: regime_multiplier > 5 with sample_size < 10
   - unsampled-tstat: t_stat present without sample_size
   Stamps bundle.metadata.diagnosticSignals so quality.mjs can use the
   booleans for grade caps.

3. report-db-adapter.mjs (modified, all 6 builders)
   - Theme: strict subject filter; allowFallback gate before falling back to
     top-by-article-count
   - Event: derive theme from subject (kind='theme'); strict ce.theme = $1
     filter; allowFallback gate before top-by-uplift fallback
   - Cross-theme: include $themeKey = ANY(c.themes) match (theme arrays);
     allowFallback gate
   - Symbol: when subject is a theme slug, query stock_sensitivity_matrix
     WHERE theme = $themeKey (was matching all rows when slug couldn't be
     coerced to ticker)
   - Regime / system_quality: matchStatus='system-wide' (always correct)
   - All builders now call attachSubjectFidelity + applyReportDataDiagnostics
     before returning

4. scripts/generate-intelligence-report.mjs
   - parseArgs now handles --key=value syntax (was only --key value)
   - allowFallback flag wired (default false — strict)

VERIFIED via DB regeneration of cloud-infrastructure pack:
  theme_report → cloud-infrastructure (was: cloud-infrastructure by luck;
    now: subject-bound + diagnostic flags hasAggregateOrphan=true,
    hasEvidenceMismatch=true)
  event_signal_report → 92891 "Data Center Tech Lobbyists" (was: 98244
    Medicare AI EFF lawsuit — completely unrelated)
  symbol_signal_report → GLD@cloud-infrastructure (was: TLT, exposure=0.05;
    now: filtered to rows with theme=cloud-infrastructure +
    hasOverfitRisk=true diagnostic flagged)
  cross_theme_report → 217 Vertiv (still 217, but now via themes[] array
    match — Vertiv genuinely is a data-center power company)
  Negative case: --subject=nonexistent-fictional-theme → produces
    "No event signal report bound to nonexistent-fictional-theme" report
    with quality C (was: would have picked Medicare AI as fallback).

This is foundational. Phases 2-5 (data richness, analyst priors, Codex
synthesis, cross-asset linkage) all build on this fidelity layer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…adth, knowledge edges

The Phase 1 theme bundle had 4 metrics + 6 article evidence + a few
regime impact rows. That is enough to verify subject binding but not
enough for prose to say anything specific.

Phase 2 adds:
  - top 8 subtopics from theme_evolution (lifecycle stage, momentum)
  - up to 6 peer symbols from stock_sensitivity_matrix bucketed by
    sign and significance (positive_high / neutral / negative_high)
  - multi-regime symbol comparison (regime_conditional_impact grouped
    by symbol; only symbols observed in 2+ regimes)
  - top 8 knowledge_edges from theme node → connected entities
    (suppliers, components, materials, technologies)
  - 30-day event timeline from canonical_events + matched
    event_hawkes_intensity (surge flag, normalized temperature)
  - Hawkes daily intensity series (up to 30d)

The bundle keeps its existing shape — additions ride on the existing
metrics / evidence / marketReactions arrays plus a new
metadata.themeContext snapshot that downstream prose can read.

Verified: cloud-infrastructure bundle now ships with
  metrics: 12 (was 4)
  evidence: 12 (was 6)
  marketReactions: 8 (was 5)
  metadata.themeContext.subtopics: 8
  metadata.themeContext.peerSymbols.negative: 3
  metadata.themeContext.regimeBySymbol: 5
  metadata.themeContext.knowledgeConnections: 8 (sample: theme→fiber
    via 'requires' relation, confidence 0.58)
  metadata.themeContext.events: 10 (sample: VMware Cloud Foundation)
  metadata.themeContext.hawkesSeries: 27 daily points

Phase 3 will use this context to generate entity-specific prose
instead of meta-templates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…st sections

The Phase 1+2 bundle now ships with rich themeContext (subtopics, peer
symbols, multi-regime impacts, knowledge edges, event timeline). Phase 3
makes the prose USE that data.

Before (Phase 1 baseline meta-prose):
  "article_count is 0 articles in the week lens; this is the measurement
   the report should explain before adding narrative interpretation."

After (Phase 3 typed prose):
  "Cloud Infrastructure aggregate row reports article_count=0 but 10
   canonical events fired in the last 30 days (0 surges). Read the
   aggregate as stale; trust the event timeline and recent evidence
   ledger instead."
  "Symbol exposure (6 candidates): negative-zscore: GLD, CVX, COP.
   Treat zscore<1 candidates as noise."
  "GOOGL: +5.22% avg return, t-stat 0.95, sample_size=185. Status:
   validated. Multi-period sample — usable as proxy."
  "Catalyst lane: Cloud Infrastructure requires data center power
   (component, evidence_count=20, source_diversity=9, confidence=0.55).
   Watch data center power for theme-specific reads."
  "Working stance is bearish-on-attention (subtopic in declining
   lifecycle (momentum -100); aggregate row is orphaned — distrust the
   multipliers). Bull case: ... Bear case: ... Invalidator: ..."

WHAT
1. NEW scripts/_shared/report-analyst-typed.mjs (~480 lines)
   buildThemeAnalystSections — uses themeContext, subtopics, peers,
     regimes, knowledge edges, events; produces stance + invalidator +
     specific market transmission rows
   buildEventAnalystSections — quotes event title, lists sources,
     enumerates significant t-stats, defines decision-relevance rule
   buildSymbolAnalystSections — names ticker + theme, classifies
     exposure as positive/negative/neutral, surfaces overfit risk
   buildCrossThemeAnalystSections — bottleneck-vs-adjacency frame;
     seed-lock check; substitute-availability invalidator
   buildTypedAnalystSections dispatcher

2. report-llm-analyst.mjs (modified)
   generateDeterministicAnalystDraft now delegates section-by-section
   to typed builder; null sections fall back to generic prose. Pulls
   in 12 of 18 sections from typed for theme/event/symbol/cross-theme
   reports. Model id changed to 'rule-based-typed-analyst' when typed
   sections are populated.

3. report-validator.mjs (modified)
   knownNumericStrings now also includes numbers from themeContext
   (subtopic momentum/accel/rank, peer zscores, regime multipliers,
   knowledge edge confidence/evidence_count, event articleCounts,
   hawkes intensity), the sensitivity row, the candidate row, the
   event row, plus standard analyst-prior thresholds (1, 1.5, 2, 3,
   5, 7, 10, 14, 21, 30, 0.4-0.8). Negative + signed-percent forms
   added (+5.22%, -5.22%).
   Tokenization strips ISO dates ("2026-05-04") and quoted titles
   ("404 Media", "VMware Cloud Foundation") so numbers inside them
   are not flagged as unknown numeric claims.
   COMMON_UPPERCASE_WORDS expanded with business abbreviations (CEO,
   CTO, PPA, EDP, AND, NA, MW, GW, GPU, etc.).

4. report-theme-context.mjs (modified)
   loadSubtopics: DISTINCT ON sub_theme — keep only latest period per
     subtopic (was duplicating with conflicting momentum readings)
   loadPeerSymbols: dedup by symbol, keep max-|zscore| row (was
     producing GLD twice in negative-zscore list)
   loadRegimeImpacts: dedup by (symbol, regime, horizon); keep
     symbols with 2+ rows (was filtering out same-regime / different-
     horizon pairs)

VERIFIED with cloud-infrastructure pack regeneration:
  theme_report: warning grade=B blockers=0
  event_signal_report: warning grade=B blockers=0
  symbol_signal_report: warning grade=B blockers=0
  cross_theme_bottleneck_report: warning grade=C blockers=0
All 4 subject-bound types now produce validation-clean reports with
entity-specific prose. Quality stays at B/C because data caveats
(stale, low source diversity) appropriately cap the grade.

Phase 4 next: hook ai-analysis-builder.mjs as a real LLM provider
behind validator gates for narrative voice on top of the typed sections.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WHY
The Phase 3 typed sections produce structured, evidence-bound prose, but
they read like a JSON renderer ("Subtopic ABC: rank 4 in parent theme XYZ,
lifecycle declining, momentum -100"). Bridgewater-style narrative requires
synthesis voice that reasons across the lenses (attention / transmission /
graph) and articulates explicit bull / bear cases with invalidators.

WHAT
1. NEW scripts/_shared/report-codex-narrator.mjs (~250 lines)
   - buildBundleDigest: compresses bundle into a JSON skeleton suitable
     for LLM context (metrics + evidence + reactions + caveats + figures
     + themeContext digest); strips raw article text to keep tokens bounded
   - buildTypedDigest: passes the typed sections in as ground truth so
     Codex synthesizes ON TOP, not from scratch
   - generateCodexNarrative: spawns runCodexJsonPrompt with a strict
     analyst prompt that forbids investment language, requires every
     numeric claim to be cited from bundle ids, mandates explicit
     invalidator thresholds. Returns 4 sections (narrativeHead /
     bullThesis / bearThesis / invalidator)
   - validateCodexNarrative: per-sentence allow-list check (numbers in
     bundle metrics, tickers in marketReactions, no buy/sell phrases).
     Hard-drops sentences with unknown numerics or forbidden phrases;
     soft-warns on uppercase tokens (English caps were over-filtering
     valid prose like "Through the more reliable lenses…")
   - narrativeToAnalystBlocks: shape conversion to compiler-ready blocks

2. NEW scripts/_shared/report-validator-helpers.mjs
   Extracted knownNumericStrings + allowedTickerStrings so both narrator
   and validator share the same allow-lists. Avoids import cycle from
   report-llm-analyst → narrator → validator.

3. report-llm-analyst.mjs (modified)
   generateReportAnalystDraft now accepts options.provider='codex'.
   Always starts from the typed/deterministic draft (the floor); when
   codex is requested, generates narrative on top, validates, and
   attaches blocks. Codex failure / validation failure falls back to
   typed-only with diagnostic metadata (codexAttempted, codexError,
   codexDropped).

4. report-compiler.mjs (modified)
   HTML + Markdown renderers now emit:
     - "Analyst Narrative" section (codexNarrativeHead) above Executive
       Brief
     - "Bull / Bear Synthesis" section after Working Thesis, with three
       sub-headings (Bull thesis / Bear thesis / What would change my
       mind)
   Both render only when codex sections are present; typed-only reports
   are unchanged.

VERIFIED with cloud-infrastructure theme report regeneration:
  - Codex call took 3:43; output parsed cleanly
  - Narrative head (excerpt):
    "Cloud Infrastructure reads as bearish on attention, but the
     aggregate is orphaned, so the event timeline and knowledge graph
     are the trustworthy lenses rather than the trend row itself. The
     core inconsistency is that the weekly aggregate shows 0 articles,
     0 percent share, and 0 novelty while still carrying a -145.8333
     percent acceleration print, even as the recent evidence ledger
     still contains 6 fresh items..."
  - Bear thesis ends with explicit invalidator citing MET-THEME-ARTICLES,
    MET-PEER-COUNT, MRKT-PEER-GLD-1m, MRKT-PEER-CVX-2w, MRKT-PEER-COP-2w
  - 7 sentences hard-dropped (unknown_numeric — full-precision tokens
    Codex emitted that aren't in the bundle's exact decimal form), 26
    soft-warns (capitalized English words). Remaining narrative is
    coherent, evidence-bound, professional analyst voice.

Phase 5 next: cross-asset linkage paths + historical regime analogue
retrieval, so the narrator can frame "this looks like 2017's hyperscaler
cycle" type observations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…logues

Bridgewater-style framing requires reasoning across hops ("data-center
buildout pressures copper") and historical pattern matching ("this looks
like the 2017 hyperscaler cycle"). Phase 5 supplies the underlying data
so the Codex narrator can produce that framing.

WHAT
1. NEW scripts/_shared/report-cross-asset-paths.mjs (~140 lines)
   loadCrossAssetPaths(client, themeKey):
     - Locates the theme node in knowledge_nodes
     - 1-hop edges via knowledge_edges → connected entities (component,
       material, supplier, technology, company)
     - 2-hop expansion to entities connected to the 1-hop set, excluding
       the theme itself; aggregated path-confidence = product of edge
       confidences, top 8 by score
     - tagTradableEndpoints: matches endpoint canonical_name against
       stock_sensitivity_matrix.symbol; tradable endpoints can be cited
       by ticker by the narrator
   crossAssetPathsToBundleAdditions: emits MET-CROSS-ASSET-PATH-COUNT
   metric + bundle.metadata.crossAssetPaths extension

2. NEW scripts/_shared/report-historical-analogues.mjs (~150 lines)
   loadThemeHistoricalAnalogues:
     - Pulls full event_hawkes_intensity series for the theme (default
       365-day lookback)
     - Slices into rolling 30-day windows; computes feature vector
       [mean, max, surge_count, stdev] per window
     - Cosine similarity between current 30-day window and all past
       non-overlapping windows; top 3 analogues
   tagAnalogueContext: enriches each analogue with the top 3 canonical
   events that fired in that window so the narrator can reference
   specific past episodes by title/date
   Falls back gracefully if Hawkes history < 60 days (insufficient
   data — current cloud-infrastructure case)

3. report-db-adapter.mjs (modified)
   buildDbThemeReportBundle now also loads cross-asset paths and
   historical analogues; their metric + extension contributions are
   merged into bundle.metrics and bundle.metadata.

4. report-codex-narrator.mjs (modified)
   buildBundleDigest passes crossAssetPaths and historicalAnalogues
   into the LLM context.
   NARRATOR_PROMPT extended with:
     - Cross-asset framing rules: trace at least one 2-hop path with
       relation_type and confidence in the narrative head
     - Historical analogue framing: when top similarity > 0.85, cite
       the analogue window's start date, similarity, and 1-2 of its
       contextEvents

5. report-validator.mjs + report-validator-helpers.mjs (modified)
   knownNumericStrings now also accepts path scores + confidence values
   from crossAssetPaths and analogue similarity / profile values from
   historicalAnalogues, so narrator can cite them without unknown-numeric
   blockers.

VERIFIED on cloud-infrastructure (typed-only, no Codex call):
  Cross-asset paths: 6 paths surfaced
    - data center (requires, conf 0.58) → end
    - cooling (requires, conf 0.58) → end
    - fiber (requires, conf 0.58) → end
    - 3 more 1-hop entities
  2-hop expansion produced 0 paths because the existing knowledge_edges
  graph is shallow for cloud-infrastructure — most components don't have
  onward edges yet. This is a data-density issue, not a code bug. As the
  research-os pipeline accumulates more edges, 2-hop paths will populate
  automatically.

  Historical analogues: skipped — 45 days of Hawkes history available,
  needs 60+ for 2 non-overlapping 30-day windows. Will activate once the
  event_hawkes_intensity table has 60+ days for the theme.

Total bundle metrics: 13 (Phase 1 baseline = 4)
                     +9 metrics added across Phase 2/3/5 enrichments

The report system is now structurally ready to produce Bridgewater-style
narratives whenever the data is dense enough. The remaining gap is data
maturity (knowledge_edges 2-hop density, multi-month Hawkes history),
not pipeline architecture.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@cheesss

cheesss commented May 19, 2026

Copy link
Copy Markdown
Owner Author

.

cheesss added 2 commits May 20, 2026 01:09
…tion artifacts

- Make visualStatus the primary dashboard/API readiness source
- Demote productTier to evidence-tier display when closure is blocked
- Reconcile raw discovery coverage against the accepted evidence matrix
- Block pre-reconciliation artifacts from appearing review-ready
- Add contradiction warnings and regression tests
- Document readiness source-of-truth in the automation runbook
- Add mechanism seed generation, storage, evidence planning, closure, provider gap review, adapter proposals, and daemon/self-improvement flows
- Wire research seed and provider gap review surfaces into the dashboard API/UI
- Extend report backfill/provider execution and frontier parent readiness behavior
- Update GitHub README, script guide, and runtime artifact ignore rules
- Add regression coverage for seed lifecycle, provider gap closure, dashboard review, valuation readiness, and frontier parent flow

cheesss commented May 20, 2026

Copy link
Copy Markdown
Owner Author

Closing this oversized PR in favor of the split stacked PR set:

  1. [codex] Split 1: baseline dashboard runtime changes #2 [codex] Split 1: baseline dashboard runtime changes
  2. [codex] Split 2: report closure evidence guardrails #3 [codex] Split 2: report closure evidence guardrails
  3. [codex] Split 3: mechanism seed research loop #4 [codex] Split 3: mechanism seed research loop
  4. [codex] Split 4: bias-aware backfill and news pipeline stability #5 [codex] Split 4: bias-aware backfill and news pipeline stability

The split removes the generated/cache/backup churn from the review path and keeps each follow-up layer reviewable against its direct base.

@cheesss cheesss closed this May 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant