From 16b8002a4f51a079ee847cddf0d9456af75624a6 Mon Sep 17 00:00:00 2001 From: Nikita Date: Fri, 10 Jul 2026 13:56:21 +0300 Subject: [PATCH] fix: CSP eval and analytics violations --- front_end/bun.lock | 6 - front_end/package.json | 2 - front_end/src/utils/csp.ts | 2 +- .../__tests__/switch_forecast_type.test.ts | 107 +++++++++++ .../utils/forecasts/switch_forecast_type.ts | 178 ++++++++++++------ 5 files changed, 224 insertions(+), 71 deletions(-) create mode 100644 front_end/src/utils/forecasts/__tests__/switch_forecast_type.test.ts diff --git a/front_end/bun.lock b/front_end/bun.lock index 304e220f5f..e55e7172a5 100644 --- a/front_end/bun.lock +++ b/front_end/bun.lock @@ -40,7 +40,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", @@ -71,7 +70,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", @@ -1442,8 +1440,6 @@ "@types/node-fetch": ["@types/node-fetch@2.6.12", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.0" } }, "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA=="], - "@types/numeric": ["@types/numeric@1.2.6", "", {}, "sha512-gwEJEbdln8bWuYu//lspka5Hk+CFwFynDrGP2GAdp4sM1XuPbaKsBWmU0qkxHGGxShKjV1bouKTwHmtaQ9O97Q=="], - "@types/pg": ["@types/pg@8.15.6", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ=="], "@types/pg-pool": ["@types/pg-pool@2.0.7", "", { "dependencies": { "@types/pg": "*" } }, "sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng=="], @@ -2728,8 +2724,6 @@ "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], - "numeric": ["numeric@1.2.6", "", {}, "sha512-avBiDAP8siMa7AfJgYyuxw1oyII4z2sswS23+O+ZfV28KrtNzy0wxUFwi4f3RyM4eeeXNs1CThxR7pb5QQcMiw=="], - "nuqs": ["nuqs@2.8.9", "", { "dependencies": { "@standard-schema/spec": "1.0.0" }, "peerDependencies": { "@remix-run/react": ">=2", "@tanstack/react-router": "^1", "next": ">=14.2.0", "react": ">=18.2.0 || ^19.0.0-0", "react-router": "^5 || ^6 || ^7", "react-router-dom": "^5 || ^6 || ^7" }, "optionalPeers": ["@remix-run/react", "@tanstack/react-router", "next", "react-router", "react-router-dom"] }, "sha512-8ou6AEwsxMWSYo2qkfZtYFVzngwbKmg4c00HVxC1fF6CEJv3Fwm6eoZmfVPALB+vw8Udo7KL5uy96PFcYe1BIQ=="], "nwsapi": ["nwsapi@2.2.23", "", {}, "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ=="], diff --git a/front_end/package.json b/front_end/package.json index 365987c1b8..f16ca09186 100644 --- a/front_end/package.json +++ b/front_end/package.json @@ -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", @@ -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", diff --git a/front_end/src/utils/csp.ts b/front_end/src/utils/csp.ts index c03d618e5b..41dc6f5891 100644 --- a/front_end/src/utils/csp.ts +++ b/front_end/src/utils/csp.ts @@ -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`, ] diff --git a/front_end/src/utils/forecasts/__tests__/switch_forecast_type.test.ts b/front_end/src/utils/forecasts/__tests__/switch_forecast_type.test.ts new file mode 100644 index 0000000000..3fc79c89ec --- /dev/null +++ b/front_end/src/utils/forecasts/__tests__/switch_forecast_type.test.ts @@ -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 + ); + } + ); +}); diff --git a/front_end/src/utils/forecasts/switch_forecast_type.ts b/front_end/src/utils/forecasts/switch_forecast_type.ts index 28a6d769ae..d56ffe0cb4 100644 --- a/front_end/src/utils/forecasts/switch_forecast_type.ts +++ b/front_end/src/utils/forecasts/switch_forecast_type.ts @@ -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); + 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, }, ]; }