diff --git a/CLAUDE.md b/CLAUDE.md index 91d08f5..1e19ae2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,8 +83,11 @@ data/ ──[Stage 2: amateurfunk_anki.py]──► anki/ block is appended to the back; a "low confidence" badge shows for entries with `confidence < 7`. 4. Hand-roll the v11 Anki collection (SQLite + JSON config) and - package it as a `.apkg` ZIP with deterministic timestamps. Same - input → byte-identical output across runs. + package it as a `.apkg` ZIP with deterministic timestamps. By + default each build mints a fresh shuffle seed, so answers are + reshuffled every run. Pass `--seed` (and `--epoch`) for the + reproducible-build contract: same catalog + same seed + same + timestamp → byte-identical output across runs. The Anki design decisions (shuffle seeding, deterministic build epoch, SVG dark-mode handling, schema choices) live in `DESIGN.md` §7. diff --git a/DESIGN.md b/DESIGN.md index 05ebae9..73d5252 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -463,8 +463,10 @@ These do not block the Stage 1 implementation: Given the per-edition directory produced by Stage 1, build a set of Anki `.apkg` files that turn every catalog question into a flash card. -Same input must produce byte-identical output across runs — this is -important so generated decks can be checksummed and cached cleanly. +By default, each build uses a fresh shuffle seed so repeated imports +vary the answer order. When `--seed` and `--epoch` are supplied, +the same input must produce byte-identical output across runs — useful +for tests, checksums, and cacheable release builds. ### CLI shape @@ -475,9 +477,9 @@ amateurfunk-anki [--data DIR] [--out DIR] [--seed STR] [--epoch INT] - `--data DIR` — fetch output root (default `./data`). Must contain `manifest-latest.json` pointing at a per-edition directory. - `--out DIR` — destination for `.apkg` files (default `./anki`). -- `--seed STR` — deterministic seed for answer shuffling. The default - is fixed; changing it produces a different (but still deterministic) - shuffle. +- `--seed STR` — deterministic seed for answer shuffling. Omit it + for the normal study-deck behavior: a fresh seed is generated for + each build, so answers are reshuffled every time decks are rebuilt. - `--epoch INT` — override the package timestamp epoch. By default we derive it from the manifest's `fetched_at`; this flag is mainly for tests and explicit rebuilds. @@ -565,8 +567,10 @@ for in-Anki filtering, and harmless besides. ### Determinism -The contract is: same catalog in → same `.apkg` bytes out. -Determinism rests on three things: +The default CLI intentionally is not byte-deterministic because it +generates a fresh shuffle seed each run. The reproducible-build +contract is: same catalog + same `--seed` + same timestamp inputs → +same `.apkg` bytes out. Determinism rests on three things: 1. **Stable IDs.** `stable_id(namespace, text)` hashes a namespaced key with SHA-1 and squashes into the standard @@ -587,8 +591,9 @@ Determinism rests on three things: last step, the inner SQLite would be identical but the archive's per-entry mtimes would still vary between runs. -The combined effect: two runs with the same `data/` produce -byte-identical sha256 on each `.apkg`. Verified during review. +The combined effect: two runs with the same `data/`, `--seed`, and +timestamp inputs produce byte-identical sha256 on each `.apkg`. +Verified during review. ### Rendering decisions worth knowing @@ -636,7 +641,7 @@ byte-identical sha256 on each `.apkg`. Verified during review. `explanations.json` (CLI: `--explanations`) and, for any question number found there, appends an English explanation block to the back of the card. The block is styled distinctly - (serif italic body, sans-serif metadata, top border) and shows + (serif body, sans-serif metadata, top border) and shows a small "low confidence" badge when the entry's `confidence` field is below `LOW_CONFIDENCE_THRESHOLD` (= 7). `revision` and the raw `confidence` number are editorial-only and never diff --git a/EXPLANATIONS.md b/EXPLANATIONS.md index 2a93dbb..3598ed7 100644 --- a/EXPLANATIONS.md +++ b/EXPLANATIONS.md @@ -82,7 +82,7 @@ modify Python code to ship a new explanation. End-to-end: inline `$...$` LaTeX rewritten to MathJax `\(...\)`, same as the question text), and a "Source: ..." line. 3. The block lands inside `.af-back` at the very end, styled by the - `.af-explanation*` CSS rules — serif italic body, sans-serif + `.af-explanation*` CSS rules — serif body, sans-serif metadata, separated from the answer by a top border. When the entry's `confidence` is **below 7**, a small "low confidence" badge appears next to the **Explanation** header — a hint to the diff --git a/amateurfunk_anki.py b/amateurfunk_anki.py index 97dcb11..3384e99 100644 --- a/amateurfunk_anki.py +++ b/amateurfunk_anki.py @@ -33,6 +33,7 @@ import json import os import random import re +import secrets import shutil import sqlite3 import sys @@ -1415,7 +1416,7 @@ CARD_CSS = """ font-size: 15px; line-height: 1.5; color: #333; - font-style: italic; + font-style: normal; } .af-explanation-header { font-family: Arial, sans-serif; @@ -1451,6 +1452,28 @@ CARD_CSS = """ color: #1a73e8; text-decoration: none; } +.nightMode .af-explanation, +.card.nightMode .af-explanation { + border-top-color: #555; + color: #ddd; +} +.nightMode .af-explanation-header, +.card.nightMode .af-explanation-header { + color: #aaa; +} +.nightMode .af-explanation-source, +.card.nightMode .af-explanation-source { + color: #bbb; +} +.nightMode .af-explanation-source a, +.card.nightMode .af-explanation-source a { + color: #8ab4f8; +} +.nightMode .af-explanation-low-confidence, +.card.nightMode .af-explanation-low-confidence { + background: #4a3510; + color: #ffd77a; +} """ @@ -1481,6 +1504,13 @@ def build_epoch_from_manifest(manifest, override_epoch=None): ) from e +def resolve_shuffle_seed(seed): + """Return the explicit shuffle seed or a fresh one for this build.""" + if seed is not None: + return seed + return secrets.token_hex(16) + + def build_all(data_dir, out_dir, seed, override_epoch=None, explanations_path=None): """Build every category's `.apkg` and return their result dicts. @@ -1546,8 +1576,11 @@ def _parse_args(argv): ) parser.add_argument( "--seed", - default="amateurfunk-anki-v1", - help="deterministic seed for answer shuffling", + default=None, + help=( + "deterministic seed for answer shuffling; omit to reshuffle " + "answers on every build" + ), ) parser.add_argument( "--epoch", @@ -1573,11 +1606,12 @@ def _parse_args(argv): def main(argv=None): """Top-level entry point. Returns an exit code; never raises.""" args = _parse_args(argv) + seed = resolve_shuffle_seed(args.seed) try: results = build_all( args.data, args.out, - seed=args.seed, + seed=seed, override_epoch=args.epoch, explanations_path=args.explanations, ) diff --git a/test_amateurfunk_anki.py b/test_amateurfunk_anki.py index 2e535b0..9ed3bb2 100644 --- a/test_amateurfunk_anki.py +++ b/test_amateurfunk_anki.py @@ -11,6 +11,7 @@ import tempfile import unittest import zipfile from pathlib import Path +from unittest.mock import patch import amateurfunk_anki as aa @@ -108,6 +109,24 @@ def extract_collection(apkg_path: Path, tmp: Path): return tmp / "collection.anki2", media, names +class TestResolveShuffleSeed(unittest.TestCase): + """Unit coverage for the shuffle-seed resolution itself. + + The CLI-level test patches `resolve_shuffle_seed` out, so the + actual default-seed behavior is only exercised here. + """ + + def test_explicit_seed_is_passed_through(self): + self.assertEqual(aa.resolve_shuffle_seed("my-seed"), "my-seed") + + def test_omitted_seed_mints_a_fresh_value_each_call(self): + first = aa.resolve_shuffle_seed(None) + second = aa.resolve_shuffle_seed(None) + self.assertIsInstance(first, str) + self.assertTrue(first) + self.assertNotEqual(first, second) + + class TestAnkiBuild(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() @@ -479,6 +498,7 @@ class TestAnkiBuild(unittest.TestCase): common = [ "--data", str(self.data_dir), "--explanations", str(self.explanations_path), + "--seed", "stable-test-seed", "--epoch", "1234567890", ] aa.main([*common, "--out", str(out_a)]) @@ -488,6 +508,32 @@ class TestAnkiBuild(unittest.TestCase): second = out_b / first.name self.assertEqual(first.read_bytes(), second.read_bytes(), first.name) + def test_cli_without_seed_reshuffles_each_build(self): + out_a = self.root / "anki-fresh-a" + out_b = self.root / "anki-fresh-b" + common = [ + "--data", str(self.data_dir), + "--explanations", str(self.explanations_path), + "--epoch", "1234567890", + ] + + with patch( + "amateurfunk_anki.resolve_shuffle_seed", + side_effect=["fresh-a", "fresh-b"], + ) as resolve_seed: + aa.main([*common, "--out", str(out_a)]) + aa.main([*common, "--out", str(out_b)]) + + resolved_inputs = [ + call.args[0] for call in resolve_seed.call_args_list + ] + self.assertEqual(resolved_inputs, [None, None]) + byte_equal = [ + first.read_bytes() == (out_b / first.name).read_bytes() + for first in sorted(out_a.glob("*.apkg")) + ] + self.assertFalse(all(byte_equal)) + class TestRealExplanationsFile(unittest.TestCase): """Validate the repo's actual explanations.json against the live catalog.