/** * Taking a reward apart, and putting it back together with different weights. * * The reward editor is the point of the whole site: change what "good" means * and watch the ranking move. This module is the arithmetic behind that, and it * has one job beyond adding numbers up — never to manufacture one. An unscored * component stays `null` all the way to the bar chart. */ import { isNotScored, rewardTotal } from './episode'; import type { RewardComponent, RewardValues } from './types'; /** Weights closer than this are the same weight. Guards float drift in sliders. */ export const WEIGHT_EPSILON = 1e-9; /** A user's edits to the shipped weights, keyed by component. */ export type WeightOverrides = Readonly>; export interface RewardRow { key: string; /** Plain English, straight off the component. */ label: string; description: string; /** The raw score the environment emitted. `null` means not scored. */ score: number | null; /** The weight in force — shipped, or edited, depending what you passed in. */ weight: number; /** `score x weight`, the component's actual contribution. `null` when unscored. */ value: number | null; role: RewardComponent['role']; } export interface RewardBreakdown { rows: RewardRow[]; /** Sum of the scored contributions, or `null` when nothing was scored. */ total: number | null; /** How many components carry a real number. */ scored: number; /** Components the environment did not grade. Rendered as "not scored". */ unscored: string[]; } /** Per-component rows plus the total, ready to render. */ export function decompose( values: RewardValues, components: readonly RewardComponent[], ): RewardBreakdown { const rows: RewardRow[] = components.map((component) => { const raw = values[component.key]; const scored = !isNotScored(raw); return { key: component.key, label: component.label, description: component.description, score: scored ? raw : null, weight: component.weight, value: scored ? raw * component.weight : null, role: component.role, }; }); return { rows, total: rewardTotal(values, components), scored: rows.filter((row) => row.score !== null).length, unscored: rows.filter((row) => row.score === null).map((row) => row.key), }; } /** * Apply the visitor's weight edits and renormalise so the weights sum to 1.0. * * Renormalising is what makes the editor honest. Without it, dragging one * slider up raises the total for every arm at once and the ranking looks like * it moved when only the scale did. With it, the visitor is trading weight * between components — which is the actual decision a reward designer makes. * * Negative weights are clamped to zero: a negative weight survives * normalisation as a sign flip somewhere else in the vector, and the resulting * chart is arithmetically correct and completely unreadable. If you want a * component to subtract, that belongs in the environment's grader, not here. * * If every weight is edited to zero the result is all zeros — there is no * honest way to normalise a zero vector, and inventing an equal split would be * putting words in the visitor's mouth. Call `weightsAreDegenerate()` on the * result and render "no weight assigned" rather than a 0.00 total. */ export function reweight( components: readonly RewardComponent[], overrides: WeightOverrides, ): RewardComponent[] { const clamped = components.map((component) => { const override = overrides[component.key]; const weight = override === undefined || !Number.isFinite(override) ? component.weight : override; return { component, weight: Math.max(0, weight) }; }); const sum = clamped.reduce((acc, entry) => acc + entry.weight, 0); if (sum <= WEIGHT_EPSILON) { return clamped.map(({ component }) => ({ ...component, weight: 0 })); } return clamped.map(({ component, weight }) => ({ ...component, weight: weight / sum })); } /** True when `reweight` could not normalise, i.e. everything was zeroed. */ export function weightsAreDegenerate(components: readonly RewardComponent[]): boolean { return components.reduce((acc, c) => acc + c.weight, 0) <= WEIGHT_EPSILON; } /** * Has the visitor actually changed anything? * * Pass `components` whenever you have them. Without them this can only ask * "are there any override keys", which reports an edit for a slider that was * dragged and put back — and then the page shows a "modified reward" badge over * the shipped numbers, which is a lie in the other direction. */ export function isEdited( overrides: WeightOverrides, components?: readonly RewardComponent[], ): boolean { const keys = Object.keys(overrides); if (keys.length === 0) return false; if (!components) return true; return components.some((component) => { const override = overrides[component.key]; if (override === undefined || !Number.isFinite(override)) return false; return Math.abs(override - component.weight) > WEIGHT_EPSILON; }); } /** Drop overrides that match the shipped weight, so a reset yields a clean URL. */ export function pruneOverrides( overrides: WeightOverrides, components: readonly RewardComponent[], ): WeightOverrides { const out: Record = {}; for (const component of components) { const override = overrides[component.key]; if (override === undefined || !Number.isFinite(override)) continue; if (Math.abs(override - component.weight) > WEIGHT_EPSILON) out[component.key] = override; } return out; }