From f67d042a42a22064da1104b687e3b20e58042f25 Mon Sep 17 00:00:00 2001 From: Elie Habib Date: Sun, 29 Mar 2026 22:50:16 +0400 Subject: [PATCH] feat(forecast): simulation confidence sub-bar in ForecastPanel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add three fields to Forecast proto (simulation_adjustment, sim_path_confidence, demoted_by_simulation) and implement a thin colored underbar below each forecast title that encodes simulation evidence without adding columns or text clutter. Visual design (Option D): - 2px colored bar, width = sim path confidence for positive adj, 100% for negative/demoted (structural, not confidence-dependent) - Green ≥0.70 conf, amber <0.70, orange negative, red demoted - Opacity 0.45 at rest; 0.9 + text label on hover - Plain language hover labels: "AI signal · +8%", "AI caution · −12%", "AI flag: dropped · −15%" — no "sim" jargon visible to users - Demoted rows dim to opacity 0.5 Passes through simulation fields in buildPublishedForecastPayload. No chip renders until the ExpandedPath → Forecast plumbing lands (follow-up PR); all rendering code is ready and typecheck clean. 🤖 Generated with Claude Sonnet 4.6 via Claude Code + Compound Engineering v2.49.0 Co-Authored-By: Claude Sonnet 4.6 (200K context) --- docs/api/ForecastService.openapi.json | 2 +- docs/api/ForecastService.openapi.yaml | 14 +++++ proto/worldmonitor/forecast/v1/forecast.proto | 8 +++ scripts/seed-forecasts.mjs | 3 + src/components/ForecastPanel.ts | 56 +++++++++++++++++-- .../forecast/v1/service_client.ts | 3 + .../forecast/v1/service_server.ts | 3 + 7 files changed, 84 insertions(+), 5 deletions(-) diff --git a/docs/api/ForecastService.openapi.json b/docs/api/ForecastService.openapi.json index 66c5e024dc..049c03bfc0 100644 --- a/docs/api/ForecastService.openapi.json +++ b/docs/api/ForecastService.openapi.json @@ -1 +1 @@ -{"components":{"schemas":{"CalibrationInfo":{"properties":{"drift":{"format":"double","type":"number"},"marketPrice":{"format":"double","type":"number"},"marketTitle":{"type":"string"},"source":{"type":"string"}},"type":"object"},"CascadeEffect":{"properties":{"domain":{"type":"string"},"effect":{"type":"string"},"probability":{"format":"double","type":"number"}},"type":"object"},"Error":{"description":"Error is returned when a handler encounters an error. It contains a simple error message that the developer can customize.","properties":{"message":{"description":"Error message (e.g., 'user not found', 'database connection failed')","type":"string"}},"type":"object"},"FieldViolation":{"description":"FieldViolation describes a single validation error for a specific field.","properties":{"description":{"description":"Human-readable description of the validation violation (e.g., 'must be a valid email address', 'required field missing')","type":"string"},"field":{"description":"The field path that failed validation (e.g., 'user.email' for nested fields). For header validation, this will be the header name (e.g., 'X-API-Key')","type":"string"}},"required":["field","description"],"type":"object"},"Forecast":{"properties":{"calibration":{"$ref":"#/components/schemas/CalibrationInfo"},"cascades":{"items":{"$ref":"#/components/schemas/CascadeEffect"},"type":"array"},"caseFile":{"$ref":"#/components/schemas/ForecastCase"},"confidence":{"format":"double","type":"number"},"createdAt":{"description":"Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"domain":{"type":"string"},"feedSummary":{"type":"string"},"id":{"type":"string"},"perspectives":{"$ref":"#/components/schemas/Perspectives"},"priorProbability":{"format":"double","type":"number"},"probability":{"format":"double","type":"number"},"projections":{"$ref":"#/components/schemas/Projections"},"region":{"type":"string"},"scenario":{"type":"string"},"signals":{"items":{"$ref":"#/components/schemas/ForecastSignal"},"type":"array"},"timeHorizon":{"type":"string"},"title":{"type":"string"},"trend":{"type":"string"},"updatedAt":{"description":"Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"}},"type":"object"},"ForecastActor":{"properties":{"category":{"type":"string"},"constraints":{"items":{"type":"string"},"type":"array"},"id":{"type":"string"},"influenceScore":{"format":"double","type":"number"},"likelyActions":{"items":{"type":"string"},"type":"array"},"name":{"type":"string"},"objectives":{"items":{"type":"string"},"type":"array"},"role":{"type":"string"}},"type":"object"},"ForecastBranch":{"properties":{"kind":{"type":"string"},"outcome":{"type":"string"},"projectedProbability":{"format":"double","type":"number"},"rounds":{"items":{"$ref":"#/components/schemas/ForecastBranchRound"},"type":"array"},"summary":{"type":"string"},"title":{"type":"string"}},"type":"object"},"ForecastBranchRound":{"properties":{"actorMoves":{"items":{"type":"string"},"type":"array"},"developments":{"items":{"type":"string"},"type":"array"},"focus":{"type":"string"},"probabilityShift":{"format":"double","type":"number"},"round":{"format":"int32","type":"integer"}},"type":"object"},"ForecastCase":{"properties":{"actorLenses":{"items":{"type":"string"},"type":"array"},"actors":{"items":{"$ref":"#/components/schemas/ForecastActor"},"type":"array"},"baseCase":{"type":"string"},"branches":{"items":{"$ref":"#/components/schemas/ForecastBranch"},"type":"array"},"changeItems":{"items":{"type":"string"},"type":"array"},"changeSummary":{"type":"string"},"contrarianCase":{"type":"string"},"counterEvidence":{"items":{"$ref":"#/components/schemas/ForecastCaseEvidence"},"type":"array"},"escalatoryCase":{"type":"string"},"supportingEvidence":{"items":{"$ref":"#/components/schemas/ForecastCaseEvidence"},"type":"array"},"triggers":{"items":{"type":"string"},"type":"array"},"worldState":{"$ref":"#/components/schemas/ForecastWorldState"}},"type":"object"},"ForecastCaseEvidence":{"properties":{"summary":{"type":"string"},"type":{"type":"string"},"weight":{"format":"double","type":"number"}},"type":"object"},"ForecastSignal":{"properties":{"type":{"type":"string"},"value":{"type":"string"},"weight":{"format":"double","type":"number"}},"type":"object"},"ForecastWorldState":{"properties":{"activePressures":{"items":{"type":"string"},"type":"array"},"keyUnknowns":{"items":{"type":"string"},"type":"array"},"stabilizers":{"items":{"type":"string"},"type":"array"},"summary":{"type":"string"}},"type":"object"},"GetForecastsRequest":{"properties":{"domain":{"type":"string"},"region":{"type":"string"}},"type":"object"},"GetForecastsResponse":{"properties":{"forecasts":{"items":{"$ref":"#/components/schemas/Forecast"},"type":"array"},"generatedAt":{"description":"Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"}},"type":"object"},"GetSimulationOutcomeRequest":{"properties":{"runId":{"description":"IMPORTANT: Currently a no-op. Always returns the latest available outcome regardless of runId.\n Per-run lookup is reserved for Phase 3. Check the response 'note' field when runId is supplied\n and you need to detect a mismatch between requested and returned run.","type":"string"}},"type":"object"},"GetSimulationOutcomeResponse":{"properties":{"error":{"description":"Populated when the Redis lookup failed. Distinguish from healthy not-found (found=false, error=\"\").\n Value: \"redis_unavailable\" on Redis errors.","type":"string"},"found":{"type":"boolean"},"generatedAt":{"description":"Unix timestamp in milliseconds (from Date.now()). Warning: Values \u003e 2^53 may lose precision in JavaScript.. Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"note":{"description":"Populated when req.runId was supplied but does not match the returned outcome's runId.\n Indicates that per-run filtering is not yet active and the latest outcome was returned instead.","type":"string"},"outcomeKey":{"type":"string"},"runId":{"type":"string"},"schemaVersion":{"type":"string"},"theaterCount":{"format":"int32","type":"integer"},"theaterSummariesJson":{"description":"JSON-encoded array of theater summaries for the UI (populated when found=true).\n Shape: Array\u003c{ theaterId, theaterLabel, stateKind, topPaths: [{label, summary, confidence, keyActors}], dominantReactions, stabilizers, invalidators }\u003e\n Parse with JSON.parse() on the client. Empty string when found=false.","type":"string"}},"type":"object"},"GetSimulationPackageRequest":{"properties":{"runId":{"description":"Currently ignored; always returns the latest package. Reserved for Phase 3 per-run lookup.","type":"string"}},"type":"object"},"GetSimulationPackageResponse":{"properties":{"error":{"description":"Populated when the Redis lookup failed. Distinguish from healthy not-found (found=false, error=\"\").\n Value: \"redis_unavailable\" on Redis errors.","type":"string"},"found":{"type":"boolean"},"generatedAt":{"description":"Unix timestamp in milliseconds (from Date.now()). Warning: Values \u003e 2^53 may lose precision in JavaScript.. Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"note":{"description":"Populated when req.runId was supplied but does not match the returned package's runId.\n Indicates that per-run filtering is not yet active and the latest package was returned instead.","type":"string"},"pkgKey":{"type":"string"},"runId":{"type":"string"},"schemaVersion":{"type":"string"},"theaterCount":{"format":"int32","type":"integer"}},"type":"object"},"Perspectives":{"properties":{"contrarian":{"type":"string"},"regional":{"type":"string"},"strategic":{"type":"string"}},"type":"object"},"Projections":{"properties":{"d30":{"format":"double","type":"number"},"d7":{"format":"double","type":"number"},"h24":{"format":"double","type":"number"}},"type":"object"},"ValidationError":{"description":"ValidationError is returned when request validation fails. It contains a list of field violations describing what went wrong.","properties":{"violations":{"description":"List of validation violations","items":{"$ref":"#/components/schemas/FieldViolation"},"type":"array"}},"required":["violations"],"type":"object"}}},"info":{"title":"ForecastService API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/forecast/v1/get-forecasts":{"get":{"operationId":"GetForecasts","parameters":[{"in":"query","name":"domain","required":false,"schema":{"type":"string"}},{"in":"query","name":"region","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetForecastsResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetForecasts","tags":["ForecastService"]}},"/api/forecast/v1/get-simulation-outcome":{"get":{"operationId":"GetSimulationOutcome","parameters":[{"description":"IMPORTANT: Currently a no-op. Always returns the latest available outcome regardless of runId.\n Per-run lookup is reserved for Phase 3. Check the response 'note' field when runId is supplied\n and you need to detect a mismatch between requested and returned run.","in":"query","name":"runId","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetSimulationOutcomeResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetSimulationOutcome","tags":["ForecastService"]}},"/api/forecast/v1/get-simulation-package":{"get":{"operationId":"GetSimulationPackage","parameters":[{"description":"Currently ignored; always returns the latest package. Reserved for Phase 3 per-run lookup.","in":"query","name":"runId","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetSimulationPackageResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetSimulationPackage","tags":["ForecastService"]}}}} \ No newline at end of file +{"components":{"schemas":{"CalibrationInfo":{"properties":{"drift":{"format":"double","type":"number"},"marketPrice":{"format":"double","type":"number"},"marketTitle":{"type":"string"},"source":{"type":"string"}},"type":"object"},"CascadeEffect":{"properties":{"domain":{"type":"string"},"effect":{"type":"string"},"probability":{"format":"double","type":"number"}},"type":"object"},"Error":{"description":"Error is returned when a handler encounters an error. It contains a simple error message that the developer can customize.","properties":{"message":{"description":"Error message (e.g., 'user not found', 'database connection failed')","type":"string"}},"type":"object"},"FieldViolation":{"description":"FieldViolation describes a single validation error for a specific field.","properties":{"description":{"description":"Human-readable description of the validation violation (e.g., 'must be a valid email address', 'required field missing')","type":"string"},"field":{"description":"The field path that failed validation (e.g., 'user.email' for nested fields). For header validation, this will be the header name (e.g., 'X-API-Key')","type":"string"}},"required":["field","description"],"type":"object"},"Forecast":{"properties":{"calibration":{"$ref":"#/components/schemas/CalibrationInfo"},"cascades":{"items":{"$ref":"#/components/schemas/CascadeEffect"},"type":"array"},"caseFile":{"$ref":"#/components/schemas/ForecastCase"},"confidence":{"format":"double","type":"number"},"createdAt":{"description":"Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"demotedBySimulation":{"type":"boolean"},"domain":{"type":"string"},"feedSummary":{"type":"string"},"id":{"type":"string"},"perspectives":{"$ref":"#/components/schemas/Perspectives"},"priorProbability":{"format":"double","type":"number"},"probability":{"format":"double","type":"number"},"projections":{"$ref":"#/components/schemas/Projections"},"region":{"type":"string"},"scenario":{"type":"string"},"signals":{"items":{"$ref":"#/components/schemas/ForecastSignal"},"type":"array"},"simPathConfidence":{"format":"double","type":"number"},"simulationAdjustment":{"description":"Simulation-scoring fields — populated when the deep forecast simulation pipeline\n has run for this forecast's state and produced a non-zero adjustment.\n simulation_adjustment: raw score delta (+0.08–+0.12 positive, -0.12/-0.15 negative).\n sim_path_confidence: clamped [0,1] confidence of the matched sim top-path; 0 = not set.\n demoted_by_simulation: true when a negative adjustment crossed the 0.50 acceptance threshold.","format":"double","type":"number"},"timeHorizon":{"type":"string"},"title":{"type":"string"},"trend":{"type":"string"},"updatedAt":{"description":"Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"}},"type":"object"},"ForecastActor":{"properties":{"category":{"type":"string"},"constraints":{"items":{"type":"string"},"type":"array"},"id":{"type":"string"},"influenceScore":{"format":"double","type":"number"},"likelyActions":{"items":{"type":"string"},"type":"array"},"name":{"type":"string"},"objectives":{"items":{"type":"string"},"type":"array"},"role":{"type":"string"}},"type":"object"},"ForecastBranch":{"properties":{"kind":{"type":"string"},"outcome":{"type":"string"},"projectedProbability":{"format":"double","type":"number"},"rounds":{"items":{"$ref":"#/components/schemas/ForecastBranchRound"},"type":"array"},"summary":{"type":"string"},"title":{"type":"string"}},"type":"object"},"ForecastBranchRound":{"properties":{"actorMoves":{"items":{"type":"string"},"type":"array"},"developments":{"items":{"type":"string"},"type":"array"},"focus":{"type":"string"},"probabilityShift":{"format":"double","type":"number"},"round":{"format":"int32","type":"integer"}},"type":"object"},"ForecastCase":{"properties":{"actorLenses":{"items":{"type":"string"},"type":"array"},"actors":{"items":{"$ref":"#/components/schemas/ForecastActor"},"type":"array"},"baseCase":{"type":"string"},"branches":{"items":{"$ref":"#/components/schemas/ForecastBranch"},"type":"array"},"changeItems":{"items":{"type":"string"},"type":"array"},"changeSummary":{"type":"string"},"contrarianCase":{"type":"string"},"counterEvidence":{"items":{"$ref":"#/components/schemas/ForecastCaseEvidence"},"type":"array"},"escalatoryCase":{"type":"string"},"supportingEvidence":{"items":{"$ref":"#/components/schemas/ForecastCaseEvidence"},"type":"array"},"triggers":{"items":{"type":"string"},"type":"array"},"worldState":{"$ref":"#/components/schemas/ForecastWorldState"}},"type":"object"},"ForecastCaseEvidence":{"properties":{"summary":{"type":"string"},"type":{"type":"string"},"weight":{"format":"double","type":"number"}},"type":"object"},"ForecastSignal":{"properties":{"type":{"type":"string"},"value":{"type":"string"},"weight":{"format":"double","type":"number"}},"type":"object"},"ForecastWorldState":{"properties":{"activePressures":{"items":{"type":"string"},"type":"array"},"keyUnknowns":{"items":{"type":"string"},"type":"array"},"stabilizers":{"items":{"type":"string"},"type":"array"},"summary":{"type":"string"}},"type":"object"},"GetForecastsRequest":{"properties":{"domain":{"type":"string"},"region":{"type":"string"}},"type":"object"},"GetForecastsResponse":{"properties":{"forecasts":{"items":{"$ref":"#/components/schemas/Forecast"},"type":"array"},"generatedAt":{"description":"Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"}},"type":"object"},"GetSimulationOutcomeRequest":{"properties":{"runId":{"description":"IMPORTANT: Currently a no-op. Always returns the latest available outcome regardless of runId.\n Per-run lookup is reserved for Phase 3. Check the response 'note' field when runId is supplied\n and you need to detect a mismatch between requested and returned run.","type":"string"}},"type":"object"},"GetSimulationOutcomeResponse":{"properties":{"error":{"description":"Populated when the Redis lookup failed. Distinguish from healthy not-found (found=false, error=\"\").\n Value: \"redis_unavailable\" on Redis errors.","type":"string"},"found":{"type":"boolean"},"generatedAt":{"description":"Unix timestamp in milliseconds (from Date.now()). Warning: Values \u003e 2^53 may lose precision in JavaScript.. Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"note":{"description":"Populated when req.runId was supplied but does not match the returned outcome's runId.\n Indicates that per-run filtering is not yet active and the latest outcome was returned instead.","type":"string"},"outcomeKey":{"type":"string"},"runId":{"type":"string"},"schemaVersion":{"type":"string"},"theaterCount":{"format":"int32","type":"integer"},"theaterSummariesJson":{"description":"JSON-encoded array of theater summaries for the UI (populated when found=true).\n Shape: Array\u003c{ theaterId, theaterLabel, stateKind, topPaths: [{label, summary, confidence, keyActors}], dominantReactions, stabilizers, invalidators }\u003e\n Parse with JSON.parse() on the client. Empty string when found=false.","type":"string"}},"type":"object"},"GetSimulationPackageRequest":{"properties":{"runId":{"description":"Currently ignored; always returns the latest package. Reserved for Phase 3 per-run lookup.","type":"string"}},"type":"object"},"GetSimulationPackageResponse":{"properties":{"error":{"description":"Populated when the Redis lookup failed. Distinguish from healthy not-found (found=false, error=\"\").\n Value: \"redis_unavailable\" on Redis errors.","type":"string"},"found":{"type":"boolean"},"generatedAt":{"description":"Unix timestamp in milliseconds (from Date.now()). Warning: Values \u003e 2^53 may lose precision in JavaScript.. Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"note":{"description":"Populated when req.runId was supplied but does not match the returned package's runId.\n Indicates that per-run filtering is not yet active and the latest package was returned instead.","type":"string"},"pkgKey":{"type":"string"},"runId":{"type":"string"},"schemaVersion":{"type":"string"},"theaterCount":{"format":"int32","type":"integer"}},"type":"object"},"Perspectives":{"properties":{"contrarian":{"type":"string"},"regional":{"type":"string"},"strategic":{"type":"string"}},"type":"object"},"Projections":{"properties":{"d30":{"format":"double","type":"number"},"d7":{"format":"double","type":"number"},"h24":{"format":"double","type":"number"}},"type":"object"},"ValidationError":{"description":"ValidationError is returned when request validation fails. It contains a list of field violations describing what went wrong.","properties":{"violations":{"description":"List of validation violations","items":{"$ref":"#/components/schemas/FieldViolation"},"type":"array"}},"required":["violations"],"type":"object"}}},"info":{"title":"ForecastService API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/forecast/v1/get-forecasts":{"get":{"operationId":"GetForecasts","parameters":[{"in":"query","name":"domain","required":false,"schema":{"type":"string"}},{"in":"query","name":"region","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetForecastsResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetForecasts","tags":["ForecastService"]}},"/api/forecast/v1/get-simulation-outcome":{"get":{"operationId":"GetSimulationOutcome","parameters":[{"description":"IMPORTANT: Currently a no-op. Always returns the latest available outcome regardless of runId.\n Per-run lookup is reserved for Phase 3. Check the response 'note' field when runId is supplied\n and you need to detect a mismatch between requested and returned run.","in":"query","name":"runId","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetSimulationOutcomeResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetSimulationOutcome","tags":["ForecastService"]}},"/api/forecast/v1/get-simulation-package":{"get":{"operationId":"GetSimulationPackage","parameters":[{"description":"Currently ignored; always returns the latest package. Reserved for Phase 3 per-run lookup.","in":"query","name":"runId","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetSimulationPackageResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetSimulationPackage","tags":["ForecastService"]}}}} \ No newline at end of file diff --git a/docs/api/ForecastService.openapi.yaml b/docs/api/ForecastService.openapi.yaml index c6e3a70c49..d453b3f0ae 100644 --- a/docs/api/ForecastService.openapi.yaml +++ b/docs/api/ForecastService.openapi.yaml @@ -209,6 +209,20 @@ components: $ref: '#/components/schemas/Projections' caseFile: $ref: '#/components/schemas/ForecastCase' + simulationAdjustment: + type: number + format: double + description: |- + Simulation-scoring fields — populated when the deep forecast simulation pipeline + has run for this forecast's state and produced a non-zero adjustment. + simulation_adjustment: raw score delta (+0.08–+0.12 positive, -0.12/-0.15 negative). + sim_path_confidence: clamped [0,1] confidence of the matched sim top-path; 0 = not set. + demoted_by_simulation: true when a negative adjustment crossed the 0.50 acceptance threshold. + simPathConfidence: + type: number + format: double + demotedBySimulation: + type: boolean ForecastSignal: type: object properties: diff --git a/proto/worldmonitor/forecast/v1/forecast.proto b/proto/worldmonitor/forecast/v1/forecast.proto index 99e19aec29..219c765557 100644 --- a/proto/worldmonitor/forecast/v1/forecast.proto +++ b/proto/worldmonitor/forecast/v1/forecast.proto @@ -111,4 +111,12 @@ message Forecast { Perspectives perspectives = 16; Projections projections = 17; ForecastCase case_file = 18; + // Simulation-scoring fields — populated when the deep forecast simulation pipeline + // has run for this forecast's state and produced a non-zero adjustment. + // simulation_adjustment: raw score delta (+0.08–+0.12 positive, -0.12/-0.15 negative). + // sim_path_confidence: clamped [0,1] confidence of the matched sim top-path; 0 = not set. + // demoted_by_simulation: true when a negative adjustment crossed the 0.50 acceptance threshold. + double simulation_adjustment = 20; + double sim_path_confidence = 21; + bool demoted_by_simulation = 22; } diff --git a/scripts/seed-forecasts.mjs b/scripts/seed-forecasts.mjs index 8b2c9286e9..815caba487 100644 --- a/scripts/seed-forecasts.mjs +++ b/scripts/seed-forecasts.mjs @@ -4923,6 +4923,9 @@ function buildPublishedForecastPayload(pred) { d30: Number(pred.projections.d30 || 0), } : null, caseFile: slimForecastCaseForPublish(pred.caseFile), + simulationAdjustment: Number(pred.simulationAdjustment || 0), + simPathConfidence: Number(pred.simPathConfidence || 0), + demotedBySimulation: !!pred.demotedBySimulation, }; } diff --git a/src/components/ForecastPanel.ts b/src/components/ForecastPanel.ts index f8ab7316f1..08eef83ad2 100644 --- a/src/components/ForecastPanel.ts +++ b/src/components/ForecastPanel.ts @@ -199,6 +199,16 @@ function injectStyles(): void { .fc-signal { color: var(--text-secondary, #a0a0a0); font-size: 11px; padding: 3px 0 3px 12px; line-height: 1.45; position: relative; margin-top: 2px; } .fc-signal::before { content: ''; position: absolute; left: 0; top: 9px; display: inline-block; width: 6px; height: 1px; background: var(--text-secondary, #555); } .fc-empty { padding: 20px; text-align: center; color: var(--text-secondary, #888); } + + /* ── Simulation confidence sub-bar (Option D) ────────────────────────── */ + /* Thin colored underbar below the forecast title. Width encodes sim */ + /* path confidence. At rest: barely visible. On row hover: full opacity */ + /* + text label reveals below the bar. Zero extra columns needed. */ + .fc-sim-bar-wrap { margin-top: 4px; } + .fc-sim-bar { height: 2px; border-radius: 1px; opacity: 0.45; transition: opacity 0.15s; } + .fc-prob-item:hover .fc-sim-bar { opacity: 0.9; } + .fc-sim-label { font-size: 9px; display: none; margin-top: 2px; line-height: 1.2; } + .fc-prob-item:hover .fc-sim-label { display: block; } `; document.head.appendChild(style); } @@ -439,13 +449,17 @@ export class ForecastPanel extends Panel { ).join('')}` : ''; + const simBarHtml = this.renderSimBar(f); + const demoted = f.demotedBySimulation ?? false; + return `
-
- +
+
${escapeHtml(f.title)} - + ${simBarHtml} +
@@ -468,6 +482,40 @@ export class ForecastPanel extends Panel { `; } + // ── Simulation confidence sub-bar ─────────────────────────────────────── + + private renderSimBar(f: Forecast): string { + const adj = f.simulationAdjustment ?? 0; + if (adj === 0) return ''; + + const conf = f.simPathConfidence ?? 1.0; + const demoted = f.demotedBySimulation ?? false; + const adjPct = Math.round(Math.abs(adj) * 100); + + let barColor: string; + let labelText: string; + + if (demoted) { + barColor = '#e05252'; + labelText = `AI flag: dropped · −${adjPct}%`; + } else if (adj > 0) { + barColor = conf >= 0.70 ? '#3fb950' : '#d29922'; + labelText = conf < 0.70 ? `AI signal (moderate) · +${adjPct}%` : `AI signal · +${adjPct}%`; + } else { + barColor = '#ea580c'; + labelText = `AI caution · −${adjPct}%`; + } + + // Width encodes sim-path confidence for positive adjustments (at least 20% so bar is visible). + // Negative adjustments use 100% width — structural signal, not confidence-dependent. + const barWidthPct = adj > 0 ? Math.round(Math.max(20, conf * 100)) : 100; + + return `
+
+ ${escapeHtml(labelText)} +
`; + } + // ── Detail sections (shared by rows) ──────────────────────────────────── private renderDetailBody(f: Forecast): string { diff --git a/src/generated/client/worldmonitor/forecast/v1/service_client.ts b/src/generated/client/worldmonitor/forecast/v1/service_client.ts index d8a9503b64..fdeaa53bd4 100644 --- a/src/generated/client/worldmonitor/forecast/v1/service_client.ts +++ b/src/generated/client/worldmonitor/forecast/v1/service_client.ts @@ -32,6 +32,9 @@ export interface Forecast { perspectives?: Perspectives; projections?: Projections; caseFile?: ForecastCase; + simulationAdjustment: number; + simPathConfidence: number; + demotedBySimulation: boolean; } export interface ForecastSignal { diff --git a/src/generated/server/worldmonitor/forecast/v1/service_server.ts b/src/generated/server/worldmonitor/forecast/v1/service_server.ts index d7bf7ac06a..1de0af48dd 100644 --- a/src/generated/server/worldmonitor/forecast/v1/service_server.ts +++ b/src/generated/server/worldmonitor/forecast/v1/service_server.ts @@ -32,6 +32,9 @@ export interface Forecast { perspectives?: Perspectives; projections?: Projections; caseFile?: ForecastCase; + simulationAdjustment: number; + simPathConfidence: number; + demotedBySimulation: boolean; } export interface ForecastSignal {