diff --git a/docs/round-11/README.md b/docs/round-11/README.md new file mode 100644 index 0000000..c82f34b --- /dev/null +++ b/docs/round-11/README.md @@ -0,0 +1,72 @@ +# Round 11 — Measuring stick + integer snap on release + +Replaces the gauge from PR #8 with a persistent 0–10 measuring stick, and snaps committed votes to whole numbers on release. The hold-to-pour gesture stays — slowing water during a hold remains the load-bearing pedagogy. Only the *committed* value is integer. + +Screenshots couldn't be auto-captured cleanly through the preview tooling for this round; the descriptions below reflect what was verified in the live dev preview, with the relevant DOM snapshots inline. + +## State 1 — All funnels at 0 + +What renders: each funnel shows the persistent 0–10 ruler on its outer right edge — major ticks with labels at `0/2/4/6/8/10`, minor ticks (no labels) at `1/3/5/7/9`. Pool reads `100 / 100 credits`. Each card readout shows `0 votes 0 credits`. + +Verified DOM: + +``` +[ + { label: "Votes for Kamala Harris", aria-valuenow: "0", + aria-valuetext: "0 votes, 0 credits" }, + { label: "Votes for Gavin Newsom", aria-valuenow: "0", + aria-valuetext: "0 votes, 0 credits" }, + ...same for all six funnels +] +``` + +Pool meter: `aria-valuetext = "100 of 100 credits remaining"`. + +## State 2 — Mid-hold (Harris ~1.5 s from empty) + +What renders: water rises continuously inside Harris's funnel, slowing as the funnel widens. Other funnels stay at 0. The 0–10 ruler is unchanged on every funnel — no fade, no state-dependent visibility. + +The eval probe captured the post-release state below; mid-hold the underlying `votes` is fractional (~2.7) but the displayed under-funnel readout rounds at the boundary, so the user sees `3 votes 9 credits` jump in real time as the rounded value changes. + +## State 3 — Post-release: snap to integer + +Verified DOM after a ~1.5 s hold: + +``` +{ + "harrisAriaText": "3 votes, 9 credits", + "harrisReadout": "3 votes 9 credits", + "harrisVoteValue": "3", + "pool": "91 of 100 credits remaining", + "poolDisplay": "91 / 100 credits" +} +``` + +Conservation: `100 − 9 = 91` ✓. Underlying `liveVotes ≈ √7.5 ≈ 2.74` rounded up to `3`; `clamp(3, cap=10, ⌊√(100 − 0)⌋ = 10) = 3`; reducer commits the integer. + +## Snap behaviour summary + +`snapVotesToInteger(live, item, votes, budget)` (in `src/math/qv.ts`): + +``` +committed = clamp(round(live), 0, ⌊√budget⌋, ⌊√(budget − Σ others²)⌋) +``` + +Tests (in `src/math/qv.test.ts`) pin both round-up paths: + +- 9.6 with `b = 5` (others using 25 credits, available = 75) → round = 10 → clamps to `⌊√75⌋ = 8`. +- 9.7 with `b = 4` (others using 16, available = 84) → round = 10 → clamps to `⌊√84⌋ = 9`. + +23 math tests + 10 reducer tests, all passing. + +## What changed + +- `src/math/qv.ts` — added `snapVotesToInteger`; reverted `maxVotes` to return `⌊√budget⌋`; reverted `clampVotesAgainstBudget` to floor any fractional input it sees (defence-in-depth at the reducer). +- `src/math/qv.test.ts` — updated `costForVotes` / `clampVotesAgainstBudget` tests for integer cap behaviour; added a `snapVotesToInteger` block covering both round-up edge cases. +- `src/components/Funnel.tsx` — removed the gauge from PR #8 (live arrow + two reference ticks, `GAUGE_W` reservation, `isAnyPouring` prop). Restored the funnel cavity to its pre-#8 width. Added the persistent 0–10 ruler in extended viewBox space past the V's right edge (the V itself is full size again). +- `src/components/LiquidQV.tsx` — replaced `clampVotesAgainstBudget` with `snapVotesToInteger` in `endPour`. Display formatter `fmt(n)` now returns `Math.round(n).toString()` instead of `toFixed(1)`. Dropped `isAnyPouring={Boolean(activePour)}` from the Funnel props. +- `src/components/CreditPool.tsx` — readout uses integer rounding. + +## What stayed the same + +The 2D triangle funnel rendering, the hold-to-pour gesture mechanics (constant volumetric rate, water rises smoothly during a hold), the conservation invariant, the pool reservoir, the pour stream, the intro copy, the on-load explainer, the footer disclaimer, and the default ballot. diff --git a/src/components/CreditPool.tsx b/src/components/CreditPool.tsx index f801706..48e5d5d 100644 --- a/src/components/CreditPool.tsx +++ b/src/components/CreditPool.tsx @@ -35,10 +35,11 @@ interface Props { export const CreditPool = ({ remaining, budget, readout, height = 84 }: Props) => { const reduceMotion = useReducedMotion(); const fillRatio = budget > 0 ? Math.max(0, Math.min(1, remaining / budget)) : 0; - const display = readout ?? (Math.round(remaining * 10) / 10).toFixed(1); - // ARIA mirrors the displayed one-decimal readout — what a sighted - // user reads is what a screen-reader user hears. - const remainingDisplay = Math.round(remaining * 10) / 10; + // Round 11 — integer display everywhere. Underlying `remaining` may + // still be fractional during a hold (the live derivation), but the + // visible number rounds at the boundary. + const remainingInt = Math.round(remaining); + const display = readout ?? remainingInt.toString(); return (
0. - * Position updates frame-for-frame during a hold (`instantUpdate`), - * and gets the same Framer-Motion settle as the water otherwise. - * - Two faint reference ticks at votes = 5 and votes = 10 (half-cap - * and cap) frame the range when the funnel is empty *and* nothing - * in the grid is being held. The moment any pour starts, ticks - * across every funnel cross-fade to zero so the indicator has the - * stage; on release they cross-fade back in. No ruler, no grid, - * no per-integer markings — the two anchor ticks are the whole - * scale. + * The vote axis is *linear in height* (votes = water height) so the + * tick spacing is even. The quadratic lives in the credits readout + * under the funnel; the ruler counts votes directly. * - * `votes` may be fractional (real-valued) at any time. ARIA reports - * the one-decimal-rounded value — same number a sighted user reads. + * `votes` is an integer at rest and may be fractional during an active + * hold (the parent passes the live continuous value). The water polygon + * and surface highlight render directly from it. ARIA reports the + * integer-rounded value — the same number the under-funnel readout + * shows. */ interface FunnelProps { - /** Real-valued vote level (rest or live). */ + /** Vote level — integer at rest, fractional during a live hold. */ votes: number; - /** Maximum allowed votes here (= √budget). */ + /** Maximum allowed votes here (= ⌊√budget⌋, integer cap). */ maxVotes: number; /** Visible label for screen readers and the slider's aria-valuetext. */ label: string; @@ -43,35 +39,27 @@ interface FunnelProps { onPourEnd: () => void; /** * Disable the water polygon's interpolation animation. Set during an - * active hold so the water tracks the live value frame-for-frame - * instead of lagging behind a moving Framer-Motion target. + * active hold so the water tracks the live `votes` prop frame-for- + * frame; off otherwise so the snap-on-release transition gets a soft + * settle. */ instantUpdate?: boolean; - /** - * True when *any* funnel in the grid is currently being held (the - * parent flips this on the first pointerdown / keydown and off on - * release). Drives the cross-fade of the reference ticks across the - * grid — they hide during a pour so the live indicator owns the - * stage, and fade back in on release. - */ - isAnyPouring?: boolean; /** Pixel width of the funnel SVG. Height auto-derives from 45° geometry. */ size?: number; /** Override CSS custom properties on the wrapper. */ style?: CSSProperties; } -/** One-decimal rounding used both visually and for ARIA values. */ -const round1 = (n: number): number => Math.round(n * 10) / 10; - -// Gauge layer constants. Tuned visually against a 220-wide funnel. -const GAUGE_W = 36; // horizontal room reserved past the V's right edge -const ARROW_OFFSET = 4; // gap from the V's right edge to the arrow tip -const ARROW_SIZE = 7; // arrow side length (the left-pointing triangle) -const TICK_LENGTH = 8; // reference tick mark length (horizontal) -const TICK_OFFSET = 4; // gap from the V's right edge to the tick start -const FADE_MS = 250; -const POSITION_MS = 180; +// Ruler layout constants. The funnel cavity stays at its pre-#8 +// proportions (size − pads, no GAUGE_W subtraction); the ruler lives +// in extra viewBox width past the V's right edge. +const RULER_GAP = 4; // gap from V's right edge to the ruler's tick anchor +const MAJOR_TICK_W = 10; +const MINOR_TICK_W = 5; +const LABEL_OFFSET = 4; +const LABEL_FONT_SIZE = 10; +const LABEL_RESERVE = 14; // approx pixel room for "10" / "0" labels +const RULER_RIGHT_PAD = 4; const POSITION_EASE = [0.22, 1, 0.36, 1] as const; export const Funnel = ({ @@ -81,27 +69,22 @@ export const Funnel = ({ onPourStart, onPourEnd, instantUpdate = false, - isAnyPouring = false, size = 220, style, }: FunnelProps) => { const reduceMotion = useReducedMotion(); const sliderId = useId(); - // Track which key is currently driving a hold-pour. Only one hold at a - // time per funnel — pressing a second key while holding the first is - // ignored. Release of the original key ends the pour. const holdKeyRef = useRef(null); - // SVG layout — width-driven. Funnel cavity = (size − pads − gauge); - // V height = funnel cavity / 2 (45° walls). + // SVG layout — width-driven. Funnel cavity = size − pads (no gauge + // subtraction); V height = funnel cavity / 2 (45° walls). const PAD_TOP = 14; const PAD_LEFT = 14; const PAD_RIGHT = 14; const PAD_BOTTOM = 18; - const funnelWidth = size - PAD_LEFT - PAD_RIGHT - GAUGE_W; + const funnelWidth = size - PAD_LEFT - PAD_RIGHT; const usableHeight = funnelWidth / 2; - const viewBoxH = PAD_TOP + usableHeight + PAD_BOTTOM; const cx = PAD_LEFT + funnelWidth / 2; const apexY = PAD_TOP + usableHeight; const SCALE = maxVotes > 0 ? usableHeight / maxVotes : 1; @@ -116,44 +99,41 @@ export const Funnel = ({ const outlinePath = `M ${cx - fullH} ${apexY - fullH} L ${cx} ${apexY} L ${cx + fullH} ${apexY - fullH}`; const rimY = apexY - fullH; - // Gauge geometry — anchored just past the V's right edge. + // Ruler geometry. Tick "axis" is at rulerAxisX; ticks point LEFT + // (toward the water) so a major tick spans [rulerAxisX − MAJOR_TICK_W, + // rulerAxisX]. Labels sit just to the right of the axis. const rightEdgeX = cx + fullH; - const indicatorX = rightEdgeX + ARROW_OFFSET; - const tickX1 = rightEdgeX + TICK_OFFSET; - const tickX2 = tickX1 + TICK_LENGTH; - // Reference ticks: half-cap (votes 5) and cap (votes 10 / rim). - const tickHalfY = apexY - 5 * SCALE; - const tickFullY = rimY; - const indicatorY = apexY - h; + const rulerAxisX = rightEdgeX + RULER_GAP + MAJOR_TICK_W; + const labelX = rulerAxisX + LABEL_OFFSET; + const viewBoxW = labelX + LABEL_RESERVE + RULER_RIGHT_PAD; + const viewBoxH = PAD_TOP + usableHeight + PAD_BOTTOM; - const announcedVotes = round1(votes); - const showIndicator = announcedVotes > 0; - const showTicks = announcedVotes <= 0 && !isAnyPouring; + // The y-position of vote level v on the ruler is the same as the + // water-surface y at that vote level — apexY − v × SCALE — so the + // ruler reads directly off the water. + const tickY = (voteLevel: number) => apexY - voteLevel * SCALE; + + // 0/2/4/6/8/10 — major. 1/3/5/7/9 — minor. + const MAJOR_VALUES = [0, 2, 4, 6, 8, 10]; + const MINOR_VALUES = [1, 3, 5, 7, 9]; // Keyboard: // Space / Enter held → continuous pour-in (release ends pour) // Shift + Space/Enter held → continuous pour-out (drain) - // - // Arrow keys, Page Up/Dn, Home/End — all gone. There are no tap - // shortcuts. Every input goes through the same physics. const handleKeyDown = (e: KeyboardEvent) => { if (e.key === ' ' || e.key === 'Spacebar' || e.key === 'Enter') { - // Don't restart the pour on OS-level repeat events. if (e.repeat || holdKeyRef.current) return; e.preventDefault(); holdKeyRef.current = e.key; onPourStart(e.shiftKey ? 'out' : 'in'); } }; - const handleKeyUp = (e: KeyboardEvent) => { if (holdKeyRef.current && e.key === holdKeyRef.current) { holdKeyRef.current = null; onPourEnd(); } }; - - // Defensive: if focus is lost mid-hold, end the pour. useEffect(() => { const cancel = () => { if (!holdKeyRef.current) return; @@ -164,17 +144,18 @@ export const Funnel = ({ return () => window.removeEventListener('blur', cancel); }, [onPourEnd]); - const announcedCredits = round1(votes * votes); + const announcedVotes = Math.round(votes); + const announcedCredits = announcedVotes * announcedVotes; return ( ) : ( @@ -208,7 +190,7 @@ export const Funnel = ({ initial={false} fill="var(--lqv-water)" animate={{ d: waterPath }} - transition={{ duration: 0.18, ease: [0.22, 1, 0.36, 1] }} + transition={{ duration: 0.15, ease: POSITION_EASE }} style={{ pointerEvents: 'none' }} /> )} @@ -237,7 +219,7 @@ export const Funnel = ({ strokeWidth={1.25} strokeOpacity={0.7} animate={{ x1: cx - h, x2: cx + h, y1: apexY - h, y2: apexY - h }} - transition={{ duration: 0.18, ease: [0.22, 1, 0.36, 1] }} + transition={{ duration: 0.15, ease: POSITION_EASE }} style={{ pointerEvents: 'none' }} /> ))} @@ -263,91 +245,51 @@ export const Funnel = ({ strokeLinecap="round" /> - {/* Reference ticks — half-cap and full-cap. The cross-fade - between ticks ⇆ indicator runs through plain CSS opacity - transitions instead of Framer Motion: the SVG-attribute path - framer takes for opacity on didn't reliably re-render - after `animate` prop changes here, and a CSS transition - on `style.opacity` handles it cleanly. */} - - - - - - {/* Live indicator — left-pointing arrow + numeric label, anchored - at (indicatorX, indicatorY). Position updates instantly during - a live hold; cross-fades on votes ⇆ 0 via CSS opacity. */} - - {instantUpdate || reduceMotion ? ( - - + {/* Measuring stick — persistent 0–10 ruler on the outer right + edge. Major ticks (with labels) at 0/2/4/6/8/10; minor ticks + (no labels) at 1/3/5/7/9. Tick lines extend LEFT from + `rulerAxisX` toward the water; labels sit just right of the + axis. Always visible — no fade behaviour. */} + ); }; - -const IndicatorContents = ({ votes }: { votes: number }) => ( - <> - {/* Left-pointing arrow — apex at (0, 0) (the gauge anchor); base - on the right at (ARROW_SIZE, ±ARROW_SIZE/2). */} - - {/* Numeric label, vertically centered on the gauge anchor. */} - - {votes.toFixed(1)} - - -); diff --git a/src/components/LiquidQV.tsx b/src/components/LiquidQV.tsx index 8f7d902..69033f2 100644 --- a/src/components/LiquidQV.tsx +++ b/src/components/LiquidQV.tsx @@ -10,6 +10,7 @@ import { costForVotes, maxVotes as capFor, remainingCredits, + snapVotesToInteger, } from '../math/qv'; import { initialState, reducer } from '../lib/reducer'; import { defaultBallot, BALLOT_PROMPT } from '../data/defaultBallot'; @@ -70,8 +71,10 @@ const themeToCssVars = (theme: ThemeOverrides | undefined): Record (Math.round(n * 10) / 10).toFixed(1); +/** Display formatter — integers everywhere. State may still be + * fractional during a hold (the live derivation), but every visible + * number rounds at the boundary. */ +const fmt = (n: number): string => Math.round(n).toString(); /** * Compute the live (in-flight) vote count for the active item at a @@ -201,15 +204,18 @@ export const LiquidQV = ({ if (!pour) return; const startVotes = state.votes[pour.itemId] ?? 0; - // Use the live continuous value at the moment of release. No - // rounding to integer — the value is what duration × rate produced. + // Snap to the nearest integer that fits the cap and the remaining + // pool. Round-half-up via Math.round, then clamp DOWN if the + // rounded value would overdraw — see the brief's edge cases: + // a release at 9.6 with others holding the pool to 75 credits + // rounds to 10, then clamps to ⌊√75⌋ = 8. const liveAtRelease = computeLiveVotes( pour, state.votes, state.budget, performance.now(), ); - const finalVotes = clampVotesAgainstBudget( + const finalVotes = snapVotesToInteger( liveAtRelease.activeVotes, pour.itemId, state.votes, @@ -346,7 +352,6 @@ export const LiquidQV = ({ maxVotes={cap} label={`Votes for ${item.title}`} instantUpdate={isActive} - isAnyPouring={Boolean(activePour)} onPourStart={(direction) => direction === 'in' ? handlers.startPourIn() : handlers.startPourOut() } diff --git a/src/math/qv.test.ts b/src/math/qv.test.ts index 03ba1f6..372a635 100644 --- a/src/math/qv.test.ts +++ b/src/math/qv.test.ts @@ -5,11 +5,12 @@ import { costForVotes, maxVotes, remainingCredits, + snapVotesToInteger, totalCreditsSpent, } from './qv'; describe('costForVotes', () => { - it('squares vote count exactly (no flooring)', () => { + it('squares vote count exactly', () => { expect(costForVotes(0)).toBe(0); expect(costForVotes(1)).toBe(1); expect(costForVotes(2)).toBe(4); @@ -17,11 +18,9 @@ describe('costForVotes', () => { expect(costForVotes(10)).toBe(100); }); - it('passes fractional inputs through unchanged', () => { + it('squares fractional inputs (used by the live derivation during a hold)', () => { expect(costForVotes(2.5)).toBeCloseTo(6.25); - expect(costForVotes(2.9)).toBeCloseTo(8.41); expect(costForVotes(0.5)).toBeCloseTo(0.25); - expect(costForVotes(0.1)).toBeCloseTo(0.01); }); it('returns zero for negative or non-finite inputs', () => { @@ -32,18 +31,17 @@ describe('costForVotes', () => { }); describe('maxVotes', () => { - it('returns √budget without flooring', () => { + it('returns ⌊√budget⌋ — integer cap per funnel', () => { expect(maxVotes(100)).toBe(10); expect(maxVotes(81)).toBe(9); - expect(maxVotes(50)).toBeCloseTo(7.0710678); + expect(maxVotes(50)).toBe(7); // ⌊√50⌋ = 7; leaves 1 credit at the cap expect(maxVotes(0)).toBe(0); }); }); describe('totalCreditsSpent', () => { - it('sums squared real-valued votes across items', () => { + it('sums squared votes across items', () => { expect(totalCreditsSpent({ a: 3, b: 4 })).toBe(9 + 16); - expect(totalCreditsSpent({ a: 2.5, b: 1.5 })).toBeCloseTo(6.25 + 2.25); expect(totalCreditsSpent({})).toBe(0); }); }); @@ -54,11 +52,6 @@ describe('remainingCredits', () => { expect(remainingCredits(100, {})).toBe(100); }); - it('handles fractional votes correctly', () => { - // a=2.6 (6.76), b=4.1 (16.81), c=1.7 (2.89) → spent 26.46 - expect(remainingCredits(100, { a: 2.6, b: 4.1, c: 1.7 })).toBeCloseTo(73.54); - }); - it('floors at zero rather than going negative', () => { expect(remainingCredits(10, { a: 5 })).toBe(0); }); @@ -73,74 +66,95 @@ describe('availableCreditsFor', () => { it('ignores the item being asked about', () => { // a's own contribution is excluded — the function answers // "if a's slot were empty, how many credits would be left?" - expect(availableCreditsFor('a', { a: 2.5, b: 4 }, 100)).toBe(100 - 16); + expect(availableCreditsFor('a', { a: 5, b: 4 }, 100)).toBe(100 - 16); }); }); describe('clampVotesAgainstBudget', () => { - it('returns the proposed value when there is room (and stays fractional)', () => { + it('returns the proposed integer when there is room', () => { expect(clampVotesAgainstBudget(2, 'a', {}, 100)).toBe(2); - expect(clampVotesAgainstBudget(3.7, 'a', {}, 100)).toBeCloseTo(3.7); - expect(clampVotesAgainstBudget(0.4, 'a', {}, 100)).toBeCloseTo(0.4); }); - it('caps at √budget when proposing more', () => { + it('caps at ⌊√budget⌋ when proposing more', () => { expect(clampVotesAgainstBudget(99, 'a', {}, 100)).toBe(10); - expect(clampVotesAgainstBudget(99, 'a', {}, 50)).toBeCloseTo(Math.sqrt(50)); - }); - - it('respects fractional credits already locked by other items', () => { - // b at 2.5 → 6.25 spent → 93.75 left → max √93.75 ≈ 9.6824 votes here. - expect(clampVotesAgainstBudget(99, 'a', { b: 2.5 }, 100)).toBeCloseTo(Math.sqrt(93.75)); + expect(clampVotesAgainstBudget(99, 'a', {}, 50)).toBe(7); }); - it('respects integer-cap-equivalent when other items use whole credits', () => { + it("respects integer credits already locked by other items", () => { // b at 8 → 64 spent → 36 left → max √36 = 6 votes here. expect(clampVotesAgainstBudget(99, 'a', { b: 8 }, 100)).toBe(6); }); + it('floors any fractional input it receives', () => { + expect(clampVotesAgainstBudget(3.7, 'a', {}, 100)).toBe(3); + expect(clampVotesAgainstBudget(0.4, 'a', {}, 100)).toBe(0); + }); + it('returns zero for invalid input', () => { expect(clampVotesAgainstBudget(-1, 'a', {}, 100)).toBe(0); expect(clampVotesAgainstBudget(NaN, 'a', {}, 100)).toBe(0); }); - it('preserves the conservation invariant after clamping (real-valued)', () => { + it('preserves the conservation invariant after clamping', () => { const budget = 100; - const votes = { a: 6.2, b: 3.8, c: 2.1 }; // 38.44 + 14.44 + 4.41 = 57.29 spent + const votes = { a: 6, b: 4, c: 2 }; // 36 + 16 + 4 = 56 spent const clamped = clampVotesAgainstBudget(99, 'd', votes, budget); - const total = - costForVotes(clamped) + costForVotes(6.2) + costForVotes(3.8) + costForVotes(2.1); - expect(total).toBeLessThanOrEqual(budget + 1e-9); + const total = costForVotes(clamped) + 36 + 16 + 4; + expect(total).toBeLessThanOrEqual(budget); + }); + + it('returns an integer for any input', () => { + for (const v of [0, 1, 2.4, 3.999, 7.5, 9.9, 10, 99]) { + expect(Number.isInteger(clampVotesAgainstBudget(v, 'a', {}, 100))).toBe(true); + } }); }); -describe('press duration physics', () => { - // The hold-only interaction model: transferred credits = duration × rate, - // applied to the funnel via votes = √(startCredits + transferred). These - // tests pin the math the UI relies on, not any UI behaviour itself. - const RATE = 5; // credits per second — must match LiquidQV's POUR_RATE - - it('a 200 ms press from empty transfers ~1 credit, ~1 vote', () => { - const transferred = 0.2 * RATE; // 1 credit - const votes = Math.sqrt(0 + transferred); - expect(transferred).toBeCloseTo(1); - expect(votes).toBeCloseTo(1); - }); - - it('a 1.4 s press from empty lands near 2.65 votes, 7 credits', () => { - const transferred = 1.4 * RATE; // 7 credits - const votes = Math.sqrt(0 + transferred); - expect(transferred).toBeCloseTo(7); - expect(votes).toBeCloseTo(2.6457513); - }); - - it('the same hold duration yields a smaller delta as the funnel fills', () => { - // From empty: 1.4 s of pour → √7 ≈ 2.65 votes (delta from 0 ≈ 2.65) - const fromEmpty = Math.sqrt(0 + 1.4 * RATE); - // From 2.6 votes (6.76 credits): another 1.4 s → √(6.76 + 7) ≈ 3.71 - const fromMid = Math.sqrt(6.76 + 1.4 * RATE); - expect(fromEmpty).toBeCloseTo(2.6457513); - expect(fromMid).toBeCloseTo(3.71, 1); - expect(fromMid - 2.6).toBeLessThan(fromEmpty - 0); // delta shrinks +describe('snapVotesToInteger', () => { + it('rounds to the nearest integer when there is room', () => { + expect(snapVotesToInteger(2.4, 'a', {}, 100)).toBe(2); + expect(snapVotesToInteger(2.5, 'a', {}, 100)).toBe(3); // ties go up + expect(snapVotesToInteger(2.6, 'a', {}, 100)).toBe(3); + expect(snapVotesToInteger(0.49, 'a', {}, 100)).toBe(0); + expect(snapVotesToInteger(0.5, 'a', {}, 100)).toBe(1); + }); + + it('caps at ⌊√budget⌋', () => { + // Empty budget pool, live held to 9.9 → rounds to 10, fits. + expect(snapVotesToInteger(9.9, 'a', {}, 100)).toBe(10); + // Live held above the cap (e.g. user held past the rim somehow). + expect(snapVotesToInteger(11, 'a', {}, 100)).toBe(10); + }); + + it('clamps DOWN when round-up would exceed the remaining pool (the brief example)', () => { + // Others have used 25 credits (e.g. b=5). Available = 75. + // ⌊√75⌋ = 8. Live ≈ 9.6 → round = 10 → clamps to 8. + expect(snapVotesToInteger(9.6, 'a', { b: 5 }, 100)).toBe(8); + }); + + it('clamps DOWN when round-up would overdraw a partially-spent pool', () => { + // Others used 16 (b=4). Available = 84. ⌊√84⌋ = 9. + // Live held to 9.7 → round = 10 → clamps to 9. + expect(snapVotesToInteger(9.7, 'a', { b: 4 }, 100)).toBe(9); + }); + + it('returns zero for invalid or non-positive input', () => { + expect(snapVotesToInteger(0, 'a', {}, 100)).toBe(0); + expect(snapVotesToInteger(-1, 'a', {}, 100)).toBe(0); + expect(snapVotesToInteger(NaN, 'a', {}, 100)).toBe(0); + }); + + it('always returns an integer', () => { + for (const v of [0, 0.5, 1.4, 2.7, 3.0001, 4.99, 5.5, 7.4, 9.99]) { + expect(Number.isInteger(snapVotesToInteger(v, 'a', {}, 100))).toBe(true); + } + }); + + it('preserves the conservation invariant across all funnels', () => { + const budget = 100; + const votes = { a: 6, b: 4, c: 2 }; // 36 + 16 + 4 = 56 spent + const snapped = snapVotesToInteger(9.7, 'd', votes, budget); + const total = costForVotes(snapped) + 36 + 16 + 4; + expect(total).toBeLessThanOrEqual(budget); }); }); diff --git a/src/math/qv.ts b/src/math/qv.ts index eea8547..dd10001 100644 --- a/src/math/qv.ts +++ b/src/math/qv.ts @@ -11,14 +11,16 @@ * width at height h is 2h, so area below height h is h². Pouring water is * pouring credits; the visible surface level is the vote count. * - * Round 6 (continuous votes): votes and credits are real-valued - * end-to-end. There is no integer mode and no rounding in the math - * layer. A single press obeys the same physics as a long hold — - * transferred credits = duration × rate. Display-time rounding to one - * decimal lives in the components, never here. + * Round 11 (measuring stick + integer snap on release): committed votes + * are whole numbers. The hold-to-pour gesture remains the load-bearing + * pedagogy — water rises continuously and slows visibly during a hold — + * but on release the value snaps to the nearest integer that fits the + * cap and the remaining pool. Display formatters round at the boundary; + * conservation math is defined on the committed (integer) state. * - * All functions are pure and clamped at zero — negative votes are out of - * scope (see project README for v2 plans). + * The live derivation during a hold still works in continuous values + * (uses Math.sqrt and v*v directly in LiquidQV's `computeLiveVotes`). + * It doesn't reach for these primitives mid-pour; only at commit. */ export const costForVotes = (votes: number): number => { @@ -27,16 +29,16 @@ export const costForVotes = (votes: number): number => { }; /** - * Maximum votes a single funnel can hold given the budget. For an - * integer budget of 100, this is exactly √100 = 10 — a single - * fully-loaded funnel drains the pool exactly. + * Maximum integer votes a single funnel can hold given the budget. + * Floor of √budget — for budget 100 this is exactly 10. For non-square + * budgets (e.g. 50) it leaves a small remainder at the cap. */ export const maxVotes = (budget: number): number => { if (!Number.isFinite(budget) || budget <= 0) return 0; - return Math.sqrt(budget); + return Math.floor(Math.sqrt(budget)); }; -/** Sum of credits spent across all vote allocations (real-valued). */ +/** Sum of credits spent across all vote allocations. */ export const totalCreditsSpent = (votes: Record): number => { let sum = 0; for (const v of Object.values(votes)) sum += costForVotes(v); @@ -67,9 +69,10 @@ export const availableCreditsFor = ( }; /** - * Clamp a proposed (real-valued) vote level to the legal range — at - * most √budget per funnel, and at most √(budget − others' credits). - * Returns a real number; rounding is the display layer's problem. + * Clamp a proposed vote level to the largest *integer* that respects + * the per-funnel cap and the remaining pool. Used by the reducer as + * a safety net for any 'set' dispatch — anything reaching this point + * gets floored. */ export const clampVotesAgainstBudget = ( proposedVotes: number, @@ -79,6 +82,33 @@ export const clampVotesAgainstBudget = ( ): number => { if (!Number.isFinite(proposedVotes) || proposedVotes <= 0) return 0; const cap = maxVotes(budget); - const ceilingFromBudget = Math.sqrt(availableCreditsFor(itemId, votes, budget)); - return Math.max(0, Math.min(proposedVotes, cap, ceilingFromBudget)); + const ceilingFromBudget = Math.floor(Math.sqrt(availableCreditsFor(itemId, votes, budget))); + return Math.max(0, Math.min(Math.floor(proposedVotes), cap, ceilingFromBudget)); +}; + +/** + * Snap a live (typically fractional) vote level to the nearest *integer* + * that fits the cap and the remaining pool. This is the "release" + * commit path: take where the user lifted, round to nearest, then clamp + * down if that would overdraw. + * + * committed = clamp(round(live), 0, cap, ⌊√availableCredits⌋) + * + * Rounding uses Math.round (ties go up: 0.5 → 1, 1.5 → 2, …). Clamp is + * applied AFTER rounding so that a release at 9.6 rounds to 10 first, + * then clamps to whatever integer actually fits the pool — matching + * the spec's "snap-up exceeds cap" / "snap-up would overdraw pool" + * cases (both fall through to the clamp). + */ +export const snapVotesToInteger = ( + liveVotes: number, + itemId: string, + votes: Record, + budget: number, +): number => { + if (!Number.isFinite(liveVotes) || liveVotes <= 0) return 0; + const rounded = Math.round(liveVotes); + const cap = maxVotes(budget); + const ceilingFromBudget = Math.floor(Math.sqrt(availableCreditsFor(itemId, votes, budget))); + return Math.max(0, Math.min(rounded, cap, ceilingFromBudget)); };