Skip to content
Draft
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
6 changes: 0 additions & 6 deletions front_end/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 0 additions & 2 deletions front_end/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,6 @@
"@types/lodash": "^4.17.24",
"@types/negotiator": "^0.6.4",
"@types/node": "^25.6.2",
"@types/numeric": "^1.2.6",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@visx/geo": "^3.12.0",
Expand Down Expand Up @@ -92,7 +91,6 @@
"next-router-mock": "^1.0.5",
"next-themes": "^0.4.6",
"nextjs-toploader": "^1.6.12",
"numeric": "^1.2.6",
"nuqs": "^2.8.9",
"openai": "^4.104.0",
"overlayscrollbars": "^2.15.1",
Expand Down
2 changes: 1 addition & 1 deletion front_end/src/utils/csp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export function buildCsp(nonce: string): string {
isDev ? `ws: wss:` : "",
PUBLIC_POSTHOG_BASE_URL,
sentryHost ? `https://${sentryHost}` : `https://*.ingest.sentry.io`,
`https://www.google-analytics.com https://*.analytics.google.com`,
`https://www.google-analytics.com https://*.google-analytics.com https://*.analytics.google.com https://www.google.com`,
// marketing pixel beacons
`https://www.facebook.com https://px.ads.linkedin.com https://pixel-config.reddit.com`,
]
Expand Down
107 changes: 107 additions & 0 deletions front_end/src/utils/forecasts/__tests__/switch_forecast_type.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import {
AggregationMethod,
DistributionQuantileComponent,
Quantile,
QuestionType,
QuestionWithNumericForecasts,
} from "@/types/question";
import { getSliderNumericForecastDataset } from "@/utils/forecasts/dataset";
import { getSliderDistributionFromQuantiles } from "@/utils/forecasts/switch_forecast_type";
import { computeQuartilesFromCDF } from "@/utils/math";

function makeQuestion(): QuestionWithNumericForecasts {
return {
id: 1,
title: "Test question",
description: "",
created_at: "",
updated_at: "",
scheduled_close_time: "",
scheduled_resolve_time: "",
type: QuestionType.Numeric,
scaling: {
range_min: 0,
range_max: 100,
zero_point: null,
},
open_lower_bound: false,
open_upper_bound: false,
inbound_outcome_count: 200,
resolution: null,
include_bots_in_aggregates: false,
question_weight: 1,
default_score_type: "peer",
default_aggregation_method: AggregationMethod.recency_weighted,
fine_print: null,
resolution_criteria: null,
label: "",
unit: "",
author_username: "",
post_id: 1,
aggregations: {
recency_weighted: { history: [], latest: undefined },
unweighted: { history: [], latest: undefined },
single_aggregation: { history: [], latest: undefined },
metaculus_prediction: { history: [], latest: undefined },
},
my_forecasts: undefined,
my_forecast: undefined,
};
}

function makeQuantiles(q1: number, q2: number, q3: number) {
return [
{ quantile: Quantile.lower, value: 0, isDirty: false },
{ quantile: Quantile.q1, value: q1, isDirty: false },
{ quantile: Quantile.q2, value: q2, isDirty: false },
{ quantile: Quantile.q3, value: q3, isDirty: false },
{ quantile: Quantile.upper, value: 0, isDirty: false },
] satisfies DistributionQuantileComponent;
}

function convertToSliderQuartiles(q1: number, q2: number, q3: number) {
const question = makeQuestion();
const slider = getSliderDistributionFromQuantiles(
makeQuantiles(q1, q2, q3),
question
);

expect(slider).toHaveLength(1);
const component = slider[0];
expect(component).toBeDefined();
if (!component) {
throw new Error("Expected slider component");
}
expect(component.left).toBeLessThan(component.center);
expect(component.center).toBeLessThan(component.right);

const { cdf } = getSliderNumericForecastDataset(slider, question);
return computeQuartilesFromCDF(cdf);
}

describe("getSliderDistributionFromQuantiles", () => {
it.each([
{ label: "balanced", q1: 25, q2: 50, q3: 75, maxError: 0.05 },
{ label: "narrow cluster", q1: 25, q2: 30, q3: 35, maxError: 0.05 },
{ label: "moderately left-skewed", q1: 15, q2: 30, q3: 70, maxError: 0.1 },
{ label: "very left-skewed", q1: 10, q2: 20, q3: 80, maxError: 0.25 },
{ label: "very wide uncertainty", q1: 5, q2: 50, q3: 95, maxError: 0.25 },
])(
"returns a valid slider distribution near the input quartiles for $label",
({ q1, q2, q3, maxError }) => {
const quartiles = convertToSliderQuartiles(q1, q2, q3);

expect(quartiles.lower25).toBeLessThan(quartiles.median);
expect(quartiles.median).toBeLessThan(quartiles.upper75);
expect(Math.abs(quartiles.lower25 - q1 / 100)).toBeLessThanOrEqual(
maxError
);
expect(Math.abs(quartiles.median - q2 / 100)).toBeLessThanOrEqual(
maxError
);
expect(Math.abs(quartiles.upper75 - q3 / 100)).toBeLessThanOrEqual(
maxError
);
}
);
});
178 changes: 116 additions & 62 deletions front_end/src/utils/forecasts/switch_forecast_type.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { uncmin } from "numeric";

import {
DistributionQuantileComponent,
DistributionSliderComponent,
Expand All @@ -15,6 +13,120 @@ import {
scaleInternalLocation,
} from "@/utils/math";

const LEFT_MIN = -0.15;
const RIGHT_MAX = 1.15;
const MIN_SLIDER_SPACING = 0.05;
const SEARCH_RADII = [0.35, 0.18, 0.08, 0.03, 0.01];

type NormalizedSliderParams = {
left: number;
center: number;
right: number;
};

function clamp01(value: number): number {
return Math.max(0, Math.min(1, value));
}

function buildSliderParams({
left,
center,
right,
}: NormalizedSliderParams): DistributionSliderComponent {
const leftValue =
LEFT_MIN + left * (RIGHT_MAX - LEFT_MIN - 2 * MIN_SLIDER_SPACING);
const centerValue =
leftValue +
MIN_SLIDER_SPACING +
center * (RIGHT_MAX - leftValue - 2 * MIN_SLIDER_SPACING);
const rightValue =
centerValue +
MIN_SLIDER_SPACING +
right * (RIGHT_MAX - centerValue - MIN_SLIDER_SPACING);

return {
left: leftValue,
center: centerValue,
right: rightValue,
weight: 1,
};
}

function scoreSliderParams(
candidate: NormalizedSliderParams,
question: Question,
q1: number,
q2: number,
q3: number
): number {
const { left, center, right } = buildSliderParams(candidate);
const quartiles = getUserContinuousQuartiles(
[{ left, center, right, weight: 1 }],
question
);
if (!quartiles) {
return Number.POSITIVE_INFINITY;
}

const leftCost =
quartiles.lower25 <= 0 && 0 < q1
? 1e6
: q1 < 1 && 1 <= quartiles.lower25
? 1e6
: (quartiles.lower25 - q1) ** 2;
const centerCost =
quartiles.median <= 0 && 0 < q2
? 1e6
: q2 < 1 && 1 <= quartiles.median
? 1e6
: (quartiles.median - q2) ** 2;
const rightCost =
quartiles.upper75 <= 0 && 0 < q3
? 1e6
: q3 < 1 && 1 <= quartiles.upper75
? 1e6
: (quartiles.upper75 - q3) ** 2;

return leftCost + centerCost + rightCost;
}

function searchSliderParams(
q1: number,
q2: number,
q3: number,
question: Question
): NormalizedSliderParams {
let best: NormalizedSliderParams = {
left: clamp01(q1),
center: clamp01(q2),
right: clamp01(q3),
};
let bestScore = scoreSliderParams(best, question, q1, q2, q3);

for (const radius of SEARCH_RADII) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Could we add an open-bound case here and tighten the search before merging? On a both-open 0–100 question with quartiles 5/65/95, this converts back to 14.14/52.20/99.43. The old optimizer came back at 6.86/60.66/99.21, so switching input modes can now move a valid forecast quite a lot. This feels risky for a core forecasting interaction.

const offsets = [-radius, -radius / 2, 0, radius / 2, radius];

for (const leftOffset of offsets) {
for (const centerOffset of offsets) {
for (const rightOffset of offsets) {
const candidate = {
left: clamp01(best.left + leftOffset),
center: clamp01(best.center + centerOffset),
right: clamp01(best.right + rightOffset),
};
const score = scoreSliderParams(candidate, question, q1, q2, q3);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This search is also seems fairly expensive for something that runs synchronously in the forecast UI. It scores 626 candidates, and each score rebuilds and standardizes the full CDF. Did we test the impact on UI and see any problems especially on group questions?

if (score < bestScore) {
best = candidate;
bestScore = score;
}
}
}
}
}

return best;
}

/**
* if user already have table forecast and want to switch to slider forecast tab
*/
Expand All @@ -35,68 +147,10 @@ export function getSliderDistributionFromQuantiles(
question
);

const costFunc = (params: number[]) => {
const BIG = 1e6;
const leftMin = -0.15;
const rightMax = 1.15;
const minSliderSpacing = 0.05;

const left = params.at(0) ?? 0.4;
const center = params.at(1) ?? 0.5;
const right = params.at(2) ?? 0.6;

// ---- hard-bounds & spacing penalties ------------------------------------
let P = 0;
if (left < leftMin) P += BIG * (leftMin - left) ** 2;
if (center < leftMin) P += BIG * (leftMin - center) ** 2;
if (right < leftMin) P += BIG * (leftMin - right) ** 2;
if (left > rightMax) P += BIG * (left - rightMax) ** 2;
if (center > rightMax) P += BIG * (center - rightMax) ** 2;
if (right > rightMax) P += BIG * (right - rightMax) ** 2;
if (center - left < minSliderSpacing)
P += BIG * (minSliderSpacing - (center - left)) ** 2;
if (right - center < minSliderSpacing)
P += BIG * (minSliderSpacing - (right - center)) ** 2;

// ---- quartile error -----------------------------------------------------
const quartiles = getUserContinuousQuartiles(
[{ left, right, center, weight: 1 }],
question
);
if (!quartiles) {
return 1e10;
}
const leftCost =
quartiles.lower25 <= 0 && 0 < q1
? BIG
: q1 < 1 && 1 <= quartiles.lower25
? BIG
: (quartiles.lower25 - q1) ** 2;
const centerCost =
quartiles.median <= 0 && 0 < q2
? BIG
: q2 < 1 && 1 <= quartiles.median
? BIG
: (quartiles.median - q2) ** 2;
const rightCost =
quartiles.upper75 <= 0 && 0 < q3
? BIG
: q3 < 1 && 1 <= quartiles.upper75
? BIG
: (quartiles.upper75 - q3) ** 2;

const E = leftCost + centerCost + rightCost;
return E + P;
};

const initialParams = [0.4, 0.5, 0.6];
const result = uncmin(costFunc, initialParams);
const result = buildSliderParams(searchSliderParams(q1, q2, q3, question));
return [
{
left: result.solution.at(0) ?? 0.4,
center: result.solution.at(1) ?? 0.5,
right: result.solution.at(2) ?? 0.6,
weight: 1,
...result,
},
];
}
Expand Down
Loading