-
Notifications
You must be signed in to change notification settings - Fork 34
Fix CSP eval and analytics violations #5008
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| 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 | ||
| ); | ||
| } | ||
| ); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,3 @@ | ||
| import { uncmin } from "numeric"; | ||
|
|
||
| import { | ||
| DistributionQuantileComponent, | ||
| DistributionSliderComponent, | ||
|
|
@@ -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) { | ||
| 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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
| */ | ||
|
|
@@ -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, | ||
| }, | ||
| ]; | ||
| } | ||
|
|
||
There was a problem hiding this comment.
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.