Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 50 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 |
Expand All @@ -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
Expand Down
101 changes: 75 additions & 26 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand All @@ -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" {
Expand All @@ -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")
Expand Down
109 changes: 109 additions & 0 deletions internal/wheel/histogram.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading