Improve randomisation of answers

This commit is contained in:
2026-07-14 12:15:43 +02:00
parent 91c6c16348
commit 650ab37158
3 changed files with 79 additions and 14 deletions
+12 -10
View File
@@ -480,9 +480,10 @@ amateurfunk-anki [--data DIR] [--out DIR] [--seed STR] [--epoch INT]
- `--seed STR` — deterministic seed for answer shuffling. Omit it - `--seed STR` — deterministic seed for answer shuffling. Omit it
for the normal study-deck behavior: a fresh seed is generated for for the normal study-deck behavior: a fresh seed is generated for
each build, so answers are reshuffled every time decks are rebuilt. each build, so answers are reshuffled every time decks are rebuilt.
- `--epoch INT` — override the package timestamp epoch. By default we - `--epoch INT` — override the package and note-modification timestamp
derive it from the manifest's `fetched_at`; this flag is mainly for epoch. By default we use the current rebuild time so Anki recognizes
tests and explicit rebuilds. reshuffled notes as newer on re-import; this flag is mainly for tests
and explicit reproducible builds.
Exit codes: `0` success, `1` configuration / catalog / build error. Exit codes: `0` success, `1` configuration / catalog / build error.
There is no Stage-2 equivalent of the fetcher's `EXIT_BAD_STATE` There is no Stage-2 equivalent of the fetcher's `EXIT_BAD_STATE`
@@ -602,13 +603,14 @@ same `.apkg` bytes out. Determinism rests on three things:
2. **Stable shuffle.** `randomized_answers()` builds a per-question 2. **Stable shuffle.** `randomized_answers()` builds a per-question
`random.Random` seeded from SHA-256 of `random.Random` seeded from SHA-256 of
`f"{cli_seed}:{question_number}"`. `f"{cli_seed}:{question_number}"`.
3. **Stable timestamps.** Every `now` value in the collection (the 3. **Controlled timestamps.** Every `now` value in the collection (the
`mod` columns, the JSON config blob timestamps) is fixed to `mod` columns and JSON config blob timestamps) uses `build_epoch`.
`build_epoch` — derived from the manifest's `fetched_at`, or A normal rebuild sets it to the current time because the fresh
overridden via `--epoch`. ZIP member timestamps are also fixed shuffle changes note HTML and Anki only updates an existing note
via `ZipInfo(name, zip_datetime(build_epoch))`. Without this when the imported copy is newer. `--epoch` fixes it explicitly for
last step, the inner SQLite would be identical but the reproducible builds. ZIP member timestamps use the same value via
archive's per-entry mtimes would still vary between runs. `ZipInfo(name, zip_datetime(build_epoch))`; otherwise the inner
SQLite could be identical while the archive bytes still differed.
The combined effect: two runs with the same `data/`, `--seed`, and The combined effect: two runs with the same `data/`, `--seed`, and
timestamp inputs produce byte-identical sha256 on each `.apkg`. timestamp inputs produce byte-identical sha256 on each `.apkg`.
+18 -4
View File
@@ -37,6 +37,7 @@ import secrets
import shutil import shutil
import sqlite3 import sqlite3
import sys import sys
import time
import unicodedata import unicodedata
import zipfile import zipfile
from pathlib import Path from pathlib import Path
@@ -1790,6 +1791,19 @@ def resolve_shuffle_seed(seed):
return secrets.token_hex(16) return secrets.token_hex(16)
def build_epoch_for_rebuild(override_epoch=None):
"""Return the modification epoch for a multiple-choice deck build.
A normal rebuild reshuffles the answer HTML, so its notes must carry
the current time for Anki to recognize the imported copies as newer.
`--epoch` remains the explicit escape hatch for byte-reproducible
builds, where the caller intentionally fixes every timestamp.
"""
if override_epoch is not None:
return int(override_epoch)
return int(time.time())
def build_all(data_dir, out_dir, seed, override_epoch=None, explanations_path=None): def build_all(data_dir, out_dir, seed, override_epoch=None, explanations_path=None):
"""Build every category's `.apkg` and return their result dicts. """Build every category's `.apkg` and return their result dicts.
@@ -1798,8 +1812,8 @@ def build_all(data_dir, out_dir, seed, override_epoch=None, explanations_path=No
`.apkg` each. Raises `AnkiBuildError` on configuration / catalog `.apkg` each. Raises `AnkiBuildError` on configuration / catalog
/ explanation-schema problems. / explanation-schema problems.
""" """
edition_dir, manifest, catalog = load_latest_catalog(data_dir) edition_dir, _manifest, catalog = load_latest_catalog(data_dir)
build_epoch = build_epoch_from_manifest(manifest, override_epoch) build_epoch = build_epoch_for_rebuild(override_epoch)
explanations = load_explanations( explanations = load_explanations(
explanations_path if explanations_path is not None explanations_path if explanations_path is not None
else DEFAULT_EXPLANATIONS_PATH else DEFAULT_EXPLANATIONS_PATH
@@ -1866,8 +1880,8 @@ def _parse_args(argv):
type=int, type=int,
default=None, default=None,
help=( help=(
"override the package timestamp epoch; by default this is " "override the package and note modification timestamp epoch; "
"derived from manifest.json's fetched_at" "by default the current rebuild time is used"
), ),
) )
parser.add_argument( parser.add_argument(
+49
View File
@@ -127,6 +127,19 @@ class TestResolveShuffleSeed(unittest.TestCase):
self.assertNotEqual(first, second) self.assertNotEqual(first, second)
class TestBuildEpochForRebuild(unittest.TestCase):
"""Multiple-choice rebuilds must look newer to Anki by default."""
def test_default_uses_current_time(self):
with patch("amateurfunk_anki.time.time", return_value=1777777777.9):
self.assertEqual(aa.build_epoch_for_rebuild(), 1777777777)
def test_explicit_epoch_is_preserved_for_reproducible_builds(self):
with patch("amateurfunk_anki.time.time") as current_time:
self.assertEqual(aa.build_epoch_for_rebuild(1234567890), 1234567890)
current_time.assert_not_called()
class TestAnkiBuild(unittest.TestCase): class TestAnkiBuild(unittest.TestCase):
def setUp(self): def setUp(self):
self.tmp = tempfile.TemporaryDirectory() self.tmp = tempfile.TemporaryDirectory()
@@ -662,6 +675,42 @@ class TestAnkiBuild(unittest.TestCase):
] ]
self.assertFalse(all(byte_equal)) self.assertFalse(all(byte_equal))
def test_cli_default_rebuild_stamps_notes_as_newer(self):
out_a = self.root / "anki-current-a"
out_b = self.root / "anki-current-b"
common = [
"--data", str(self.data_dir),
"--explanations", str(self.explanations_path),
]
with (
patch(
"amateurfunk_anki.resolve_shuffle_seed",
side_effect=["fresh-a", "fresh-b"],
),
patch(
"amateurfunk_anki.time.time",
side_effect=[1777777777.9, 1777777788.1],
),
):
aa.main([*common, "--out", str(out_a)])
aa.main([*common, "--out", str(out_b)])
def note_mods(out_dir):
apkg = out_dir / "amateurfunk-technische-kenntnisse-n.apkg"
with tempfile.TemporaryDirectory() as tmp:
db_path, _media, _names = extract_collection(apkg, Path(tmp))
conn = sqlite3.connect(db_path)
try:
return {
row[0] for row in conn.execute("select mod from notes")
}
finally:
conn.close()
self.assertEqual(note_mods(out_a), {1777777777})
self.assertEqual(note_mods(out_b), {1777777788})
class TestIntrinsicSvgSize(unittest.TestCase): class TestIntrinsicSvgSize(unittest.TestCase):
"""Parsing of native figure dimensions — SVG only.""" """Parsing of native figure dimensions — SVG only."""