Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/api/ForecastService.openapi.json

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions docs/api/ForecastService.openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 8 additions & 0 deletions proto/worldmonitor/forecast/v1/forecast.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
3 changes: 3 additions & 0 deletions scripts/seed-forecasts.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}

Expand Down
56 changes: 52 additions & 4 deletions src/components/ForecastPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Comment on lines +210 to +211
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Hover label reveal causes row height jump

.fc-prob-row is a CSS Grid with align-items: center. When .fc-sim-label switches from display: none to display: block on hover, the first grid column (.fc-prob-label) grows taller, re-centering all other columns (probability bar, trend, domain tag) to the new row height. This produces a visible layout shift and can make adjacent cells feel jittery, particularly noticeable when quickly scanning forecasts.

Consider visibility: hidden + height: 1.2em instead of toggling display, so the row height is always reserved:

Suggested change
.fc-sim-label { font-size: 9px; display: none; margin-top: 2px; line-height: 1.2; }
.fc-prob-item:hover .fc-sim-label { display: block; }
.fc-sim-label { font-size: 9px; visibility: hidden; height: 1.2em; margin-top: 2px; line-height: 1.2; }
.fc-prob-item:hover .fc-sim-label { visibility: visible; }

This pre-allocates the space even when the label is hidden and avoids the reflow.

`;
document.head.appendChild(style);
}
Expand Down Expand Up @@ -439,13 +449,17 @@ export class ForecastPanel extends Panel {
).join('')}`
: '';

const simBarHtml = this.renderSimBar(f);
const demoted = f.demotedBySimulation ?? false;

return `
<div class="fc-prob-item">
<div class="fc-prob-row">
<span class="fc-prob-label"
style="border-left:2px solid ${catColor}47;padding-left:6px">
<div class="fc-prob-row"${demoted ? ' style="opacity:0.5"' : ''}>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Demoted-row opacity: 0.5 stacks with fc-sim-bar opacity, defeating hover intent

CSS opacity is applied multiplicatively through the DOM tree. Setting opacity: 0.5 on fc-prob-row (this line) means the fc-sim-bar inside it can only reach 0.5 × 0.9 = 0.45 effective opacity on hover — not the 0.9 the CSS rule targets. More critically, the .fc-sim-label text that is supposed to become fully legible on hover also inherits the 0.5 dimming, keeping it at half readability.

For demoted forecasts this is most important — the "AI flag: dropped" label is the signal users most need to read.

One approach is to apply the dimming to the title text only (rather than the whole row), leaving the sim bar and label at full stacking opacity:

Suggested change
<div class="fc-prob-row"${demoted ? ' style="opacity:0.5"' : ''}>
<div class="fc-prob-row">
<div class="fc-prob-label"
style="border-left:2px solid ${catColor}47;padding-left:6px${demoted ? ';opacity:0.5' : ''}">

Alternatively, move the dimming to the individual text/bar cells so the sim-bar-wrap can opt out.

<div class="fc-prob-label"
style="border-left:2px solid ${catColor}47;padding-left:6px">
${escapeHtml(f.title)}
</span>
${simBarHtml}
</div>
<div class="fc-bar-wrap">
<div class="fc-prob-bar-track">
<div class="fc-prob-bar-fill" style="background:${probColor};width:${pct}%"></div>
Expand All @@ -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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 simPathConfidence fallback conflicts with proto "0 = not set" semantics

The proto comment explicitly documents 0 = not set for sim_path_confidence. Using ?? 1.0 as the fallback means that if simPathConfidence arrives as undefined at runtime (e.g. a Forecast object constructed without this field, or data in-flight before the plumbing PR lands), the bar will render as a full-width green "AI signal" — the exact opposite of "not set."

The correct fallback is 0, which matches the proto sentinel and falls through to the amber/moderate branch with minimum bar width (20%):

Suggested change
const conf = f.simPathConfidence ?? 1.0;
const conf = f.simPathConfidence ?? 0;

With proto3 wire encoding this ?? 0 is dead code for well-formed responses, but it makes the JS fallback safe for any Forecast object that omits the field.

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 `<div class="fc-sim-bar-wrap">
<div class="fc-sim-bar" style="width:${barWidthPct}%;background:${barColor}"></div>
<span class="fc-sim-label" style="color:${barColor}">${escapeHtml(labelText)}</span>
</div>`;
}

// ── Detail sections (shared by rows) ────────────────────────────────────

private renderDetailBody(f: Forecast): string {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ export interface Forecast {
perspectives?: Perspectives;
projections?: Projections;
caseFile?: ForecastCase;
simulationAdjustment: number;
simPathConfidence: number;
demotedBySimulation: boolean;
}

export interface ForecastSignal {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ export interface Forecast {
perspectives?: Perspectives;
projections?: Projections;
caseFile?: ForecastCase;
simulationAdjustment: number;
simPathConfidence: number;
demotedBySimulation: boolean;
}

export interface ForecastSignal {
Expand Down
Loading