# Seed Format & Verification

This document describes the seed library files in `priv/seeds/`, how new
libraries are generated, and how to verify a particular deal locally.

## Binary format

Each `priv/seeds/{game}-{variant}.bin` is a flat sequence of **8-byte
little-endian unsigned 64-bit integers**, one per pre-validated solvable
seed. There is no header and no separator — the file size in bytes is always
a multiple of 8, and the seed count is `byte_size / 8`.

Filename → `{game, variant}` mapping (see
`Cards4.Games.SinglePlayer.SeedLibrary`):

| Filename                  | Game       | Variant     |
| ------------------------- | ---------- | ----------- |
| `klondike-turn-1.bin`     | `klondike` | `turn-1`    |
| `klondike-turn-3.bin`     | `klondike` | `turn-3`    |
| `freecell-default.bin`    | `freecell` | `default`   |
| `spider-1-suit.bin`       | `spider`   | `1-suit`    |
| `spider-2-suit.bin`       | `spider`   | `2-suit`    |
| `spider-4-suit.bin`       | `spider`   | `4-suit`    |
| `yukon-default.bin`       | `yukon`    | `default`   |
| `pyramid-default.bin`     | `pyramid`  | `default`   |
| `tripeaks-default.bin`    | `tripeaks` | `default`   |

At application boot, `SeedLibrary.load_all/0` walks `priv/seeds/`, parses
every `*.bin` file into a list of seeds, and stores them in
`:persistent_term` keyed by `{game, variant}`. Runtime accessors
`SeedLibrary.random_seed/2` and `SeedLibrary.count/2` then read in O(1).

### Daily-library compatibility

The bytes, ordering, and count of a library used by a shipped daily challenge
are persistent user-visible data: the date hash is reduced modulo the library
count and then used as a positional index. Never regenerate, reorder, replace,
or resize one of those files in place. Introduce a versioned corpus and a dated
cutover before adding future seeds, so historical dates keep resolving to the
same deals and leaderboards.

## Regeneration

The `mix solver.precompute` task generates a new library by repeatedly
drawing random seeds, running the matching solver, and keeping only the
seeds that produce a solvable deal. Attempts run one per scheduler by default;
`--jobs N` overrides that. The checked-in corpus contract allows
**10 seconds per deal**: that is the task default and the budget used by both
the ExUnit regression gate and `scripts/solver_regression.exs`. A generation
run may use a stricter timeout, but never a larger one, for seeds intended for
the checked-in corpus.

Rejection rates vary enormously by game. Klondike and 1-suit Spider keep most
deals they try; multi-suit Spider keeps a minority, because
`Cards4.Games.Solver.Spider` is a beam search whose verdict is "no win found
within the budget", not "unwinnable". Budget accordingly — the task prints the
number of deals tried alongside the seeds written.

```bash
# Klondike Turn 1, 10,000 solvable seeds, 10s timeout per deal
mix solver.precompute --game klondike --variant turn-1 --count 10000 --timeout 10000

# Klondike Turn 3
mix solver.precompute --game klondike --variant turn-3 --count 10000 --timeout 10000

# FreeCell
mix solver.precompute --game freecell --variant default --count 10000 --timeout 10000

# Spider 1-suit
mix solver.precompute --game spider --variant 1-suit --count 10000 --timeout 10000

# Spider 2-suit / 4-suit — the shipped corpora, generated with a timeout inside
# the 10s contract so the regression gate keeps a margin. Two-suit kept 1000 of
# 1972 deals in ~16 minutes on 8 jobs; four-suit kept 500 of 6389 in ~79 minutes
# on 12. Four-suit ships 500 rather than 1000 because the keep rate is ~8%;
# growing it later means a versioned corpus, not a longer run over this file.
mix solver.precompute --game spider --variant 2-suit --count 1000 --timeout 5000
mix solver.precompute --game spider --variant 4-suit --count 500 --timeout 9000

# Yukon / Pyramid / TriPeaks
mix solver.precompute --game yukon --variant default --count 10000 --timeout 10000
mix solver.precompute --game pyramid --variant default --count 50 --timeout 10000
mix solver.precompute --game tripeaks --variant default --count 10000 --timeout 10000
```

> Run libraries **locally**, not in CI. Spider 4-suit can take hours to
> produce 10,000 solvable seeds. The committed `.bin` files are the
> authoritative input — CI just consumes them.

Seeds land in the file in the order attempts completed, which is not solve-time
order: `mix solver.precompute` runs `--jobs` attempts concurrently with
`ordered: false`, so an entry only sits ahead of the ones its own in-flight
batch finished after. Treat position in the file as arbitrary — the head of a
library is not its fastest-solving deals.

Both regression gates sample five entries per library rather than solving the
whole corpus, which is what keeps them quick even for corpora generated near
the timeout. Indices are `0..4` by default and are pinned per library via
`sample_indices` in `test/cards4/games/solver/regression_test.exs` and
`scripts/solver_regression.exs` where the default window proved slow. The
overrides exist because shipped seed positions are immutable — daily challenges
resolve by index, so a slow deal at the head gets sampled around, never moved.

## Collision math

Seeds are drawn from a 63-bit space (the high bit is reserved by
`Cards4.Games.Engine.Rand` to keep the value `non_neg_integer()` and to
match the unsigned-64 storage format used for the binary).

* Seed space: `2^63 = 9,223,372,036,854,775,808 ≈ 9.2 × 10^18` deals.
* Birthday-collision probability for `n` independently drawn seeds is
  approximately `n^2 / (2 · 2^63)`.
* For `n = 10,000` seeds: `10^8 / (2 · 9.2 × 10^18) ≈ 5.4 × 10^-12` — far
  below `10^-9`. The library has effectively zero internal collisions.

This means library entries are de-duplicated by construction, and player
sessions starting from a random library seed never collide in practice.

## Verification

To reproduce and inspect a single deal locally:

```bash
# Inspect a Klondike Turn 1 deal for seed 12345
mix run -e 'Cards4.Games.Klondike.init(seed: 12345) |> IO.inspect()'

# FreeCell + Microsoft deal compatibility (ms 617)
mix run -e 'Cards4.Games.FreeCell.init(ms_deal: 617) |> IO.inspect()'

# Spider 4-suit deal
mix run -e 'Cards4.Games.Spider.init(seed: 67890, suit_count: 4) |> IO.inspect()'
```

Each `init/1` call is deterministic given the same options, so the same
seed always reproduces the same starting tableau.

To check the file size + entry count of an existing library:

```bash
# Bytes (must be a multiple of 8)
wc -c priv/seeds/klondike-turn-1.bin

# Entry count
echo "$(($(wc -c < priv/seeds/klondike-turn-1.bin) / 8))"
```

To run the seed-library loader manually in an IEx session:

```elixir
iex> Cards4.Games.SinglePlayer.SeedLibrary.load_all()
:ok
iex> Cards4.Games.SinglePlayer.SeedLibrary.count("klondike", "turn-1")
1000
iex> Cards4.Games.SinglePlayer.SeedLibrary.random_seed("klondike", "turn-1")
{:ok, 1234567890123456789}
```

---

## Daily challenge seeds

One seeded UTC daily exists per `(game, variant)` pair. Every daily seed is
**always solvable** — it is drawn from the pre-validated `priv/seeds/{game}-{variant}.bin`
library, not generated on-the-fly.

### Derivation formula

```elixir
# Input tuple — all four elements are required
tuple = {game_atom, variant_atom, iso_date_string, "cards4.net.daily"}
# e.g. {:klondike, :"turn-1", "2026-06-15", "cards4.net.daily"}

# Map to a library index
library_size  = Cards4.Games.SinglePlayer.SeedLibrary.count(game, variant)
library_index = :erlang.phash2(tuple, library_size)

# Retrieve the pre-validated seed at that index
{:ok, seed} = Cards4.Games.SinglePlayer.SeedLibrary.seed_at(game, variant, library_index)
```

`iso_date_string` is always the **UTC date** in `"YYYY-MM-DD"` format.
`Date.utc_today/0` is the only authoritative date source — no timezone
conversion is ever applied.

### Public API

```elixir
# Get today's daily seed for Klondike Turn 1
iex> Cards4.Games.DailyChallenge.seed_for(:klondike, :"turn-1", ~D[2026-06-15])
{:ok, 4829301847562910}

# Get the full challenge metadata for today
iex> Cards4.Games.DailyChallenge.current_challenge(:klondike, :"turn-1")
%{date: ~D[2026-06-15], seed: 4829301847562910}
```

### Ship date

The PART 3 ship date constant is `@ship_date ~D[2026-06-15]`.

- Requests for dates **before** `@ship_date` return `{:error, :before_ship}`.
- Requests for **future** dates return `{:error, :future_date}`.
- The HTTP API translates these to `404` with a friendly message.

---

## Seed encoding

Human-shareable seeds use **Crockford Base32** — NOT standard RFC 4648 Base32.

### Alphabet

```
0 1 2 3 4 5 6 7 8 9 A B C D E F G H J K M N P Q R S T V W X Y Z
```

The letters `I`, `L`, `O`, and `U` are **excluded** to avoid visual ambiguity
(I/1, L/1, O/0, U/V). This gives 32 symbols.

### Length

Seeds are zero-padded to **13 characters**. This covers the full 63-bit seed
space: `32^13 = 2^65 > 2^63`.

### API

```elixir
# Encode a seed to a 13-char Crockford Base32 string
iex> Cards4.Games.Engine.Rand.encode_base32(4829301847562910)
"0004FKQR2MXZE"

# Decode back to an integer
iex> Cards4.Games.Engine.Rand.decode_base32("0004FKQR2MXZE")
{:ok, 4829301847562910}

# Invalid input
iex> Cards4.Games.Engine.Rand.decode_base32("INVALID_SEED_I")
{:error, :invalid}
```

```bash
# Shell example
mix run -e 'IO.puts Cards4.Games.Engine.Rand.encode_base32(4829301847562910)'
# => 0004FKQR2MXZE
```

The fairness verifier at `/about/fairness` accepts **Base32-encoded seeds
only** — raw integers are not accepted via the HTTP API.

---

## Variant atoms

Every supported `(game, variant)` pair, its Elixir atom representation, and
its PART 3 status:

| Game | Variant string | Elixir atom | Has daily? | Has solvable library? |
|------|---------------|-------------|------------|----------------------|
| Klondike | `"turn-1"` | `:"turn-1"` | YES | YES (`klondike-turn-1.bin`) |
| Klondike | `"turn-3"` | `:"turn-3"` | YES | YES (`klondike-turn-3.bin`) |
| Klondike | `"double"` | `:double_klondike` | NO (PART 3) | NO (PART 3) |
| FreeCell | `"default"` | `:default` | YES | YES (`freecell-default.bin`) |
| Spider | `"1-suit"` | `:"1-suit"` | YES | YES (`spider-1-suit.bin`) |
| Spider | `"2-suit"` | `:"2-suit"` | YES | YES (`spider-2-suit.bin`) |
| Spider | `"4-suit"` | `:"4-suit"` | YES | YES (`spider-4-suit.bin`) |

**Double Klondike** (`double_klondike`) has no solvable library in PART 3 and
therefore has no daily challenge. Requesting `solvable_only: true` for this
variant returns `{:error, :no_library_for_variant}` from the API.

---

## Anti-goals

These behaviours are explicitly forbidden and must never be introduced:

1. **No retroactive backfill.** There are no daily challenges before
   `@ship_date` (`2026-06-15`). Requests for earlier dates return HTTP 404
   with a friendly message ("Daily challenges begin on 2026-06-15"). No
   historical seeds are generated or stored.

2. **No timezone conversion in seed derivation.** `Date.utc_today/0` is the
   only date source. The seed derivation tuple always uses the UTC date string.
   Client-side timezone display (e.g. showing "Daily June 15 (UTC)" in the
   header) is cosmetic only and does not affect the seed.

3. **No solver-at-runtime for daily seeds.** Daily seeds come exclusively from
   the pre-validated `priv/seeds/*.bin` libraries loaded at boot by
   `Cards4.Games.SinglePlayer.SeedLibrary`. No solver ever runs in the request
   path. If a library is empty or missing, the API returns
   `{:error, :no_library}` — it does not fall back to a random unsolvable seed.

4. **No per-date sitemap URLs.** Only the daily root routes
   (`/solitaire/daily`, `/solitaire/turn-3/daily`, `/freecell/daily`,
   `/spider-solitaire/daily`, `/spider-solitaire/2-suit/daily`,
   `/spider-solitaire/4-suit/daily`) appear in `sitemap.xml`. Per-date
   archive URLs (`/solitaire/daily/2026-06-15`) are not indexed — this
   prevents sitemap churn and SEO bloat.
