From d13c898b179b4bc5ee5a64347b3923fbbe40541c Mon Sep 17 00:00:00 2001 From: cloudygreybeard <192177508+cloudygreybeard@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:19:06 +1000 Subject: [PATCH] feat: add Monte Carlo simulation and fast-forward mode Add --monte-carlo N flag for repeated trials with a live-updating horizontal histogram rendered on stderr. The empirical distribution converges to uniform as N increases, providing a visual proof of fairness. Final frequency table written to stdout in TSV format. Add --fast-forward flag to skip the wheel animation for any run, printing only the selected item. In Monte Carlo mode, fast-forward accumulates trials silently, updating only the histogram. New exported function Select() provides random selection without animation, used as the fast path for both modes. --- README.md | 63 +++++++--- cmd/root.go | 101 +++++++++++----- internal/wheel/histogram.go | 109 +++++++++++++++++ internal/wheel/histogram_test.go | 193 +++++++++++++++++++++++++++++++ internal/wheel/wheel.go | 14 +++ 5 files changed, 441 insertions(+), 39 deletions(-) create mode 100644 internal/wheel/histogram.go create mode 100644 internal/wheel/histogram_test.go diff --git a/README.md b/README.md index 81793b2..fb02c95 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,21 @@ spin -f nominees.txt | xargs notify-send The animation appears on the terminal (via stderr) while only the selected item passes through the pipe (via stdout). +### 2.7 Monte Carlo verification + +A Monte Carlo simulation consists of repeated independent random trials +used to estimate a quantity that may be difficult to compute +analytically[^5][^6]. Here, the quantity of interest is the probability +distribution over the item set. Since each trial selects uniformly at +random from $n$ items, the expected frequency of each item after $N$ +trials is $N/n$. By the law of large numbers, the empirical frequencies +converge to the true (uniform) probabilities as $N \to \infty$. + +The `--monte-carlo` flag runs $N$ independent trials and displays a +live-updating histogram of the accumulated results. This provides a +visual proof of fairness: with sufficient trials, all bars should +converge to equal length. + ## 3 Installation ### Homebrew (macOS and Linux) @@ -266,14 +281,34 @@ spin --drag 0 red green blue yellow spin --drag 0.5 red green blue yellow ``` -**Example 5.** Capturing the result in a shell variable for subsequent -processing: +**Example 5.** Selecting without the wheel display, printing only the +result: + +```bash +spin --fast-forward Alice Bob Carol Dave Eve +``` + +**Example 6.** Running a Monte Carlo simulation of 1000 trials to verify +the uniformity of the distribution. The histogram updates live on the +terminal; the final frequency table is written to stdout: ```bash -WINNER=$(echo "heads tails" | spin) -echo "The result is: $WINNER" +spin --monte-carlo 1000 --fast-forward a b c d e ``` +With five items and 1000 trials, each item should appear approximately +200 times. The `--fast-forward` flag is recommended for large trial +counts; without it, each trial displays the full wheel animation. + +**Example 7.** Capturing the frequency table for further analysis: + +```bash +spin --monte-carlo 10000 --fast-forward a b c d e > results.tsv +``` + +The TSV output contains one row per item with the count and percentage, +suitable for piping to `sort`, `awk`, or a plotting tool. + ### 4.3 Physics parameters | Flag | Default | Physical meaning | @@ -298,15 +333,17 @@ summarised in the following table: ## 5 Flags ``` - -f, --file string path to input file - -s, --separator string item separator regex (default: whitespace) - --start string starting position (1-indexed, wraps via modulo) or "random" (default "random") - --force float spin force (default 1) - --mass float wheel mass (default 1) - --friction float coefficient of kinetic friction (default 0.2) - --drag float aerodynamic drag coefficient (default 0.1) - -m, --max-delay duration delay threshold at which the wheel stops (default 500ms) - -h, --help help for spin + -f, --file string path to input file + -s, --separator string item separator regex (default: whitespace) + --start string starting position (1-indexed, wraps via modulo) or "random" (default "random") + --force float spin force (default 1) + --mass float wheel mass (default 1) + --friction float coefficient of kinetic friction (default 0.2) + --drag float aerodynamic drag coefficient (default 0.1) + -m, --max-delay duration delay threshold at which the wheel stops (default 500ms) + -n, --monte-carlo int run N trials and display a frequency histogram + -q, --fast-forward skip the wheel animation (print result only) + -h, --help help for spin ``` ## 6 Development diff --git a/cmd/root.go b/cmd/root.go index a45a5c0..8eddcd8 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -39,21 +39,21 @@ var rootCmd = &cobra.Command{ Long: `spin reads items from positional arguments, stdin, or a file and randomly selects one, displaying the result with a spinning wheel that decelerates to a stop. -The animation models a physical prize wheel with Coulomb friction. The -inter-item delay follows exact rotational kinematics: +The animation models a physical prize wheel with Coulomb friction and +aerodynamic drag. The winner is determined by crypto/rand before the +display begins. - v(t) = v0 - alpha * t - dt[k] = ( sqrt(v0^2 - 2*alpha*k) - sqrt(v0^2 - 2*alpha*(k+1)) ) / alpha - -where v0 is proportional to force/mass and alpha is proportional to friction. - -The winner is determined by crypto/rand before the animation begins.`, +Use --monte-carlo to run repeated trials and display a live histogram of +the empirical distribution.`, Example: ` spin apple banana cherry echo "apple banana cherry" | spin spin -f items.txt spin -f items.txt -s "," spin --force 2.0 --friction 0.1 red green blue - spin --start 3 a b c d e`, + spin --start 3 a b c d e + spin --fast-forward a b c d e + spin --monte-carlo 1000 a b c d e + spin --monte-carlo 500 --fast-forward a b c d e`, Args: cobra.ArbitraryArgs, RunE: runSpin, SilenceUsage: true, @@ -68,6 +68,8 @@ func init() { rootCmd.Flags().Float64("friction", 0.2, "coefficient of kinetic friction (higher = faster stop)") rootCmd.Flags().Float64("drag", 0.1, "aerodynamic drag coefficient (0 = pure Coulomb friction)") rootCmd.Flags().DurationP("max-delay", "m", 500*time.Millisecond, "delay threshold at which the wheel stops") + rootCmd.Flags().IntP("monte-carlo", "n", 0, "run N trials and display a frequency histogram") + rootCmd.Flags().BoolP("fast-forward", "q", false, "skip the wheel animation (print result only)") } // Execute runs the root command. @@ -84,37 +86,88 @@ func runSpin(cmd *cobra.Command, args []string) error { return cmd.Help() } - startIdx, err := parseStart(cmd) + trials, _ := cmd.Flags().GetInt("monte-carlo") + fastForward, _ := cmd.Flags().GetBool("fast-forward") + + if trials > 0 { + return runMonteCarlo(cmd, items, trials, fastForward) + } + + return runSingle(cmd, items, fastForward) +} + +func runSingle(cmd *cobra.Command, items []string, fastForward bool) error { + if fastForward { + winner, err := wheel.Select(items) + if err != nil { + return err + } + fmt.Println(winner) + return nil + } + + cfg, err := buildConfig(cmd) if err != nil { return err } + winner, err := wheel.Spin(os.Stderr, items, cfg) + if err != nil { + return err + } + + fmt.Println(winner) + return nil +} + +func runMonteCarlo(_ *cobra.Command, items []string, trials int, fastForward bool) error { + if trials <= 0 { + return fmt.Errorf("monte-carlo trials must be positive, got %d", trials) + } + + hist := wheel.NewHistogram(items) + + for i := range trials { + winner, err := wheel.Select(items) + if err != nil { + return err + } + hist.Record(winner) + + if !fastForward || i == trials-1 { + hist.Render(os.Stderr, 70, i > 0) + if !fastForward { + time.Sleep(10 * time.Millisecond) + } + } + } + + hist.WriteTSV(os.Stdout) + return nil +} + +func buildConfig(cmd *cobra.Command) (wheel.Config, error) { + startIdx, err := parseStart(cmd) + if err != nil { + return wheel.Config{}, err + } + force, _ := cmd.Flags().GetFloat64("force") mass, _ := cmd.Flags().GetFloat64("mass") friction, _ := cmd.Flags().GetFloat64("friction") drag, _ := cmd.Flags().GetFloat64("drag") maxDelay, _ := cmd.Flags().GetDuration("max-delay") - cfg := wheel.Config{ + return wheel.Config{ Force: force, Mass: mass, Friction: friction, Drag: drag, MaxDelay: maxDelay, Start: startIdx, - } - - winner, err := wheel.Spin(os.Stderr, items, cfg) - if err != nil { - return err - } - - fmt.Println(winner) - return nil + }, nil } -// parseStart converts the --start flag value to a 0-indexed position -// or -1 for "random". func parseStart(cmd *cobra.Command) (int, error) { s, _ := cmd.Flags().GetString("start") if s == "random" { @@ -130,10 +183,6 @@ func parseStart(cmd *cobra.Command) (int, error) { return n - 1, nil } -// readItems collects items from the first available source: -// 1. --file flag -// 2. positional arguments -// 3. piped stdin func readItems(cmd *cobra.Command, args []string) ([]string, error) { filePath, _ := cmd.Flags().GetString("file") separator, _ := cmd.Flags().GetString("separator") diff --git a/internal/wheel/histogram.go b/internal/wheel/histogram.go new file mode 100644 index 0000000..05d3d15 --- /dev/null +++ b/internal/wheel/histogram.go @@ -0,0 +1,109 @@ +// Copyright 2026 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package wheel + +import ( + "fmt" + "io" + "strings" +) + +// Histogram accumulates selection counts and renders a horizontal +// bar chart suitable for displaying Monte Carlo trial results. +type Histogram struct { + items []string + counts map[string]int + total int +} + +// NewHistogram creates a histogram for the given items, preserving +// their original order. +func NewHistogram(items []string) *Histogram { + return &Histogram{ + items: items, + counts: make(map[string]int, len(items)), + } +} + +// Record increments the count for the given item. +func (h *Histogram) Record(item string) { + h.counts[item]++ + h.total++ +} + +// Total returns the number of trials recorded. +func (h *Histogram) Total() int { + return h.total +} + +// Render draws the histogram as a horizontal bar chart to w. The width +// parameter controls the total line width (set to 60 if unknown). The +// chart is preceded by enough ANSI "cursor up" sequences to overwrite +// a previous rendering of the same histogram, enabling live updates. +func (h *Histogram) Render(w io.Writer, width int, overwrite bool) { + if width < 30 { + width = 60 + } + + labelWidth := maxItemLen(h.items) + // Layout: " label |bars count (pct%)" + // Reserve space: 2 + labelWidth + 3 ("|") + count/pct (~16 chars) + barBudget := width - labelWidth - 21 + if barBudget < 10 { + barBudget = 10 + } + + maxCount := 0 + for _, item := range h.items { + if c := h.counts[item]; c > maxCount { + maxCount = c + } + } + + lines := len(h.items) + 1 // items + summary line + if overwrite { + // Move cursor up to overwrite the previous rendering. + fmt.Fprintf(w, "\033[%dA", lines) + } + + for _, item := range h.items { + c := h.counts[item] + barLen := 0 + if maxCount > 0 { + barLen = c * barBudget / maxCount + } + pct := 0.0 + if h.total > 0 { + pct = 100.0 * float64(c) / float64(h.total) + } + bar := strings.Repeat("\u2588", barLen) + fmt.Fprintf(w, " %-*s |%-*s %5d (%5.1f%%)\n", + labelWidth, item, barBudget, bar, c, pct) + } + fmt.Fprintf(w, " trials: %d\n", h.total) +} + +// WriteTSV writes the final frequency table to w in tab-separated +// format: item, count, percentage. Suitable for piping to other tools. +func (h *Histogram) WriteTSV(w io.Writer) { + for _, item := range h.items { + c := h.counts[item] + pct := 0.0 + if h.total > 0 { + pct = 100.0 * float64(c) / float64(h.total) + } + fmt.Fprintf(w, "%s\t%d\t%.2f%%\n", item, c, pct) + } +} diff --git a/internal/wheel/histogram_test.go b/internal/wheel/histogram_test.go new file mode 100644 index 0000000..9e8c9c1 --- /dev/null +++ b/internal/wheel/histogram_test.go @@ -0,0 +1,193 @@ +// Copyright 2026 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package wheel + +import ( + "bytes" + "strings" + "testing" +) + +func TestHistogramRecord(t *testing.T) { + h := NewHistogram([]string{"a", "b", "c"}) + + h.Record("a") + h.Record("b") + h.Record("a") + + if h.Total() != 3 { + t.Errorf("Total() = %d, want 3", h.Total()) + } + if h.counts["a"] != 2 { + t.Errorf("counts[a] = %d, want 2", h.counts["a"]) + } + if h.counts["b"] != 1 { + t.Errorf("counts[b] = %d, want 1", h.counts["b"]) + } + if h.counts["c"] != 0 { + t.Errorf("counts[c] = %d, want 0", h.counts["c"]) + } +} + +func TestHistogramRenderContainsItems(t *testing.T) { + h := NewHistogram([]string{"alpha", "bravo", "charlie"}) + h.Record("alpha") + h.Record("bravo") + h.Record("bravo") + h.Record("charlie") + h.Record("charlie") + h.Record("charlie") + + var buf bytes.Buffer + h.Render(&buf, 60, false) + output := buf.String() + + for _, item := range []string{"alpha", "bravo", "charlie"} { + if !strings.Contains(output, item) { + t.Errorf("Render output missing item %q", item) + } + } + if !strings.Contains(output, "trials: 6") { + t.Errorf("Render output missing trial count; got:\n%s", output) + } +} + +func TestHistogramRenderBarProportions(t *testing.T) { + h := NewHistogram([]string{"a", "b"}) + for range 100 { + h.Record("a") + } + for range 50 { + h.Record("b") + } + + var buf bytes.Buffer + h.Render(&buf, 60, false) + output := buf.String() + + lines := strings.Split(strings.TrimSpace(output), "\n") + if len(lines) < 2 { + t.Fatalf("expected at least 2 lines, got %d", len(lines)) + } + + aBlocks := strings.Count(lines[0], "\u2588") + bBlocks := strings.Count(lines[1], "\u2588") + + if aBlocks <= bBlocks { + t.Errorf("item a (count=100) has %d blocks <= item b (count=50) %d blocks", + aBlocks, bBlocks) + } +} + +func TestHistogramRenderOverwrite(t *testing.T) { + h := NewHistogram([]string{"x", "y"}) + h.Record("x") + + var buf bytes.Buffer + h.Render(&buf, 60, true) + output := buf.String() + + if !strings.Contains(output, "\033[") { + t.Error("overwrite=true should produce ANSI cursor movement sequences") + } +} + +func TestHistogramRenderNoOverwrite(t *testing.T) { + h := NewHistogram([]string{"x", "y"}) + h.Record("x") + + var buf bytes.Buffer + h.Render(&buf, 60, false) + output := buf.String() + + if strings.Contains(output, "\033[") { + t.Error("overwrite=false should not produce ANSI sequences") + } +} + +func TestHistogramWriteTSV(t *testing.T) { + h := NewHistogram([]string{"a", "b", "c"}) + h.Record("a") + h.Record("a") + h.Record("b") + + var buf bytes.Buffer + h.WriteTSV(&buf) + output := buf.String() + + lines := strings.Split(strings.TrimSpace(output), "\n") + if len(lines) != 3 { + t.Fatalf("WriteTSV produced %d lines, want 3", len(lines)) + } + + // Line format: "item\tcount\tpct%" + if !strings.HasPrefix(lines[0], "a\t2\t") { + t.Errorf("line 0 = %q, want prefix \"a\\t2\\t\"", lines[0]) + } + if !strings.HasPrefix(lines[1], "b\t1\t") { + t.Errorf("line 1 = %q, want prefix \"b\\t1\\t\"", lines[1]) + } + if !strings.HasPrefix(lines[2], "c\t0\t") { + t.Errorf("line 2 = %q, want prefix \"c\\t0\\t\"", lines[2]) + } +} + +func TestHistogramWriteTSVPercentages(t *testing.T) { + h := NewHistogram([]string{"a", "b"}) + for range 75 { + h.Record("a") + } + for range 25 { + h.Record("b") + } + + var buf bytes.Buffer + h.WriteTSV(&buf) + output := buf.String() + + if !strings.Contains(output, "75.00%") { + t.Errorf("expected 75.00%% in output:\n%s", output) + } + if !strings.Contains(output, "25.00%") { + t.Errorf("expected 25.00%% in output:\n%s", output) + } +} + +func TestSelectReturnsItemFromList(t *testing.T) { + items := []string{"alpha", "bravo", "charlie"} + for range 20 { + winner, err := Select(items) + if err != nil { + t.Fatalf("Select() error: %v", err) + } + found := false + for _, item := range items { + if winner == item { + found = true + break + } + } + if !found { + t.Errorf("Select() returned %q, not in item list", winner) + } + } +} + +func TestSelectEmptyList(t *testing.T) { + _, err := Select(nil) + if err == nil { + t.Fatal("Select(nil) expected error, got nil") + } +} diff --git a/internal/wheel/wheel.go b/internal/wheel/wheel.go index 263ff58..6ab393a 100644 --- a/internal/wheel/wheel.go +++ b/internal/wheel/wheel.go @@ -287,6 +287,20 @@ func delayScheduleDrag(v0, ac, beta, maxDelaySec float64) []time.Duration { return delays } +// Select picks a uniformly random item from items using crypto/rand, +// without any visual display. It is the fast path used by Monte Carlo +// simulation and fast-forward mode. +func Select(items []string) (string, error) { + if len(items) == 0 { + return "", fmt.Errorf("no items to select from") + } + idx, err := secureRandomInt(len(items)) + if err != nil { + return "", fmt.Errorf("selecting item: %w", err) + } + return items[idx], nil +} + // alignStart returns the 0-indexed starting position so that after // totalTicks single-step advances through n items the wheel lands on // winnerIdx. If userStart >= 0 it is taken modulo n; otherwise the