Improve randomisation of answers
This commit is contained in:
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
Reference in New Issue
Block a user