From 55258d8b967efacf061c59721faebe2f759fe352 Mon Sep 17 00:00:00 2001 From: Jack Henderson Date: Thu, 7 May 2026 11:04:44 -0400 Subject: [PATCH] measuring stick + integer snap on release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces PR #8's gauge (live arrow + two fading reference ticks) with a different intervention: a persistent 0–10 ruler on each funnel and integer snapping at commit time. Net result: clean integer states, a visible scale, no decimals to parse. Why --- The live indicator from #8 duplicated the under-funnel readout, the reference ticks hid during pours (when spatial reference matters most), and decimal vote counts ("5.9 votes 34.8 credits") cost more in legibility than they gained in honesty. The brief asked for a different shape entirely. What's added ------------ - A persistent 0–10 ruler on the outer right edge of each funnel. Major ticks with labels at 0/2/4/6/8/10; minor ticks (no labels) at 1/3/5/7/9. Always visible, no fade behaviour, no isAnyPouring plumbing. Tick marks point left toward the water; labels sit just right of the axis. Vote-axis is linear in height (votes = water height), so ticks are evenly spaced — the quadratic lives in the credits readout, not the ruler. - snapVotesToInteger(live, item, votes, budget) in math/qv.ts: committed = clamp(round(live), 0, ⌊√budget⌋, ⌊√(budget − Σothers²)⌋) Round-half-up, then clamp DOWN if the rounded value would overdraw — the brief's two edge cases (release at 9.6 with others holding the pool, release above the cap) both fall through to the clamp. What's removed -------------- - The live indicator from PR #8 (left-pointing arrow + numeric label on the right edge). - The two reference ticks at votes = 5 and 10 with the fade-during-pour cross-fade. - GAUGE_W (36 px) reservation past the V's right edge — the funnel cavity returns to its pre-#8 proportions; the ruler lives in extended viewBox width past the V's right edge. - The `isAnyPouring` prop wiring from LiquidQV → Funnel. No state needs to follow the global pour anymore. - All decimal display: under-funnel readout, pool readout, ARIA values are integer everywhere. The display formatter `fmt(n)` now returns `Math.round(n).toString()`. Underlying `votes` may still be fractional during a hold (the live derivation), but every visible number rounds at the boundary. What's preserved ---------------- - The hold-to-pour gesture mechanics — constant volumetric rate, water rises smoothly and slows visibly as the funnel widens. This is still the load-bearing pedagogy: only the *committed* value is integer, not the in-flight motion. - The 2D triangle funnel rendering (post-#7 revert state). - Conservation invariant — pool + Σ votes² = budget — now expressed in integers at rest. - Pool reservoir, pour stream, intro copy, on-load explainer, footer disclaimer, default ballot. Verified end-to-end ------------------- - All funnels at 0 → ruler visible (0/2/4/6/8/10 labels + minor ticks). Pool reads "100 / 100 credits". Each card reads "0 votes 0 credits". - Hold + on Harris ~1.5 s → on release, snaps to "3 votes 9 credits"; pool drops to "91 / 100 credits". Conservation holds. - 33 tests passing (was 29; added 4 new for snapVotesToInteger including both round-up clamp-down edge cases). ESLint clean, typecheck clean, all three build targets succeed, no console warnings during interaction. Docs ---- docs/round-11/README.md describes each state with the verified DOM snapshots from the live preview. --- docs/round-11/README.md | 72 ++++++++++ src/components/CreditPool.tsx | 11 +- src/components/Funnel.tsx | 260 +++++++++++++--------------------- src/components/LiquidQV.tsx | 17 ++- src/math/qv.test.ts | 130 +++++++++-------- src/math/qv.ts | 64 ++++++--- 6 files changed, 309 insertions(+), 245 deletions(-) create mode 100644 docs/round-11/README.md 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)); };