Compare commits
3 Commits
7bf6f98ee2
...
5c6fbebd65
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c6fbebd65 | |||
|
3ce060947d
|
|||
|
fdc5932d7d
|
@@ -83,8 +83,11 @@ data/ ──[Stage 2: amateurfunk_anki.py]──► anki/
|
|||||||
block is appended to the back; a "low confidence" badge shows for
|
block is appended to the back; a "low confidence" badge shows for
|
||||||
entries with `confidence < 7`.
|
entries with `confidence < 7`.
|
||||||
4. Hand-roll the v11 Anki collection (SQLite + JSON config) and
|
4. Hand-roll the v11 Anki collection (SQLite + JSON config) and
|
||||||
package it as a `.apkg` ZIP with deterministic timestamps. Same
|
package it as a `.apkg` ZIP with deterministic timestamps. By
|
||||||
input → byte-identical output across runs.
|
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,
|
The Anki design decisions (shuffle seeding, deterministic build epoch,
|
||||||
SVG dark-mode handling, schema choices) live in `DESIGN.md` §7.
|
SVG dark-mode handling, schema choices) live in `DESIGN.md` §7.
|
||||||
|
|||||||
@@ -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
|
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.
|
Anki `.apkg` files that turn every catalog question into a flash card.
|
||||||
Same input must produce byte-identical output across runs — this is
|
By default, each build uses a fresh shuffle seed so repeated imports
|
||||||
important so generated decks can be checksummed and cached cleanly.
|
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
|
### 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
|
- `--data DIR` — fetch output root (default `./data`). Must contain
|
||||||
`manifest-latest.json` pointing at a per-edition directory.
|
`manifest-latest.json` pointing at a per-edition directory.
|
||||||
- `--out DIR` — destination for `.apkg` files (default `./anki`).
|
- `--out DIR` — destination for `.apkg` files (default `./anki`).
|
||||||
- `--seed STR` — deterministic seed for answer shuffling. The default
|
- `--seed STR` — deterministic seed for answer shuffling. Omit it
|
||||||
is fixed; changing it produces a different (but still deterministic)
|
for the normal study-deck behavior: a fresh seed is generated for
|
||||||
shuffle.
|
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 timestamp epoch. By default we
|
||||||
derive it from the manifest's `fetched_at`; this flag is mainly for
|
derive it from the manifest's `fetched_at`; this flag is mainly for
|
||||||
tests and explicit rebuilds.
|
tests and explicit rebuilds.
|
||||||
@@ -565,8 +567,10 @@ for in-Anki filtering, and harmless besides.
|
|||||||
|
|
||||||
### Determinism
|
### Determinism
|
||||||
|
|
||||||
The contract is: same catalog in → same `.apkg` bytes out.
|
The default CLI intentionally is not byte-deterministic because it
|
||||||
Determinism rests on three things:
|
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
|
1. **Stable IDs.** `stable_id(namespace, text)` hashes a
|
||||||
namespaced key with SHA-1 and squashes into the standard
|
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
|
last step, the inner SQLite would be identical but the
|
||||||
archive's per-entry mtimes would still vary between runs.
|
archive's per-entry mtimes would still vary between runs.
|
||||||
|
|
||||||
The combined effect: two runs with the same `data/` produce
|
The combined effect: two runs with the same `data/`, `--seed`, and
|
||||||
byte-identical sha256 on each `.apkg`. Verified during review.
|
timestamp inputs produce byte-identical sha256 on each `.apkg`.
|
||||||
|
Verified during review.
|
||||||
|
|
||||||
### Rendering decisions worth knowing
|
### Rendering decisions worth knowing
|
||||||
|
|
||||||
@@ -636,7 +641,7 @@ byte-identical sha256 on each `.apkg`. Verified during review.
|
|||||||
`explanations.json` (CLI: `--explanations`) and, for any
|
`explanations.json` (CLI: `--explanations`) and, for any
|
||||||
question number found there, appends an English explanation
|
question number found there, appends an English explanation
|
||||||
block to the back of the card. The block is styled distinctly
|
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`
|
a small "low confidence" badge when the entry's `confidence`
|
||||||
field is below `LOW_CONFIDENCE_THRESHOLD` (= 7). `revision` and
|
field is below `LOW_CONFIDENCE_THRESHOLD` (= 7). `revision` and
|
||||||
the raw `confidence` number are editorial-only and never
|
the raw `confidence` number are editorial-only and never
|
||||||
|
|||||||
+13
-7
@@ -32,7 +32,7 @@ Every entry MUST have exactly these four fields:
|
|||||||
| Field | Type | Constraint |
|
| Field | Type | Constraint |
|
||||||
|---------------|---------|--------------------------------------------|
|
|---------------|---------|--------------------------------------------|
|
||||||
| `revision` | integer | `>= 1`. Starts at `1`, bumps on improvement |
|
| `revision` | integer | `>= 1`. Starts at `1`, bumps on improvement |
|
||||||
| `explanation` | string | Non-empty. **English.** Terse. WHY-focused |
|
| `explanation` | string | Non-empty. **English.** Correct & helpful, WHY-focused |
|
||||||
| `source` | string | Non-empty. URL or citation like `AFuV §16(2)` |
|
| `source` | string | Non-empty. URL or citation like `AFuV §16(2)` |
|
||||||
| `confidence` | integer | `1..10` inclusive. See scale in §5 |
|
| `confidence` | integer | `1..10` inclusive. See scale in §5 |
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ modify Python code to ship a new explanation. End-to-end:
|
|||||||
inline `$...$` LaTeX rewritten to MathJax `\(...\)`, same as the
|
inline `$...$` LaTeX rewritten to MathJax `\(...\)`, same as the
|
||||||
question text), and a "Source: ..." line.
|
question text), and a "Source: ..." line.
|
||||||
3. The block lands inside `.af-back` at the very end, styled by the
|
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
|
metadata, separated from the answer by a top border. When the
|
||||||
entry's `confidence` is **below 7**, a small "low confidence"
|
entry's `confidence` is **below 7**, a small "low confidence"
|
||||||
badge appears next to the **Explanation** header — a hint to the
|
badge appears next to the **Explanation** header — a hint to the
|
||||||
@@ -141,10 +141,15 @@ source citation.
|
|||||||
- **Language: English.** The questions and answers stay in German
|
- **Language: English.** The questions and answers stay in German
|
||||||
on the card; the explanation is the one English element. Don't
|
on the card; the explanation is the one English element. Don't
|
||||||
switch.
|
switch.
|
||||||
- **Terse.** Aim for 1–3 sentences. The card already shows the
|
- **Correct and helpful first; length follows the topic.** There is
|
||||||
question, the correct answer text, and the breadcrumb — the
|
no sentence cap. Use as much room as the question genuinely needs —
|
||||||
explanation should add the missing *why*, not restate any of
|
a one-line arithmetic restatement for a simple lookup, a full
|
||||||
those.
|
worked derivation or a multi-step rule walkthrough where that is
|
||||||
|
what makes the answer *click*. Don't pad: every sentence should
|
||||||
|
earn its place by adding correctness, context, or a usable hook.
|
||||||
|
The card already shows the question, the correct answer text, and
|
||||||
|
the breadcrumb — the explanation adds the missing *why* and the
|
||||||
|
understanding needed to get there, not a restatement of those.
|
||||||
- **Lead with the reasoning.** Don't open with "The correct answer
|
- **Lead with the reasoning.** Don't open with "The correct answer
|
||||||
is X because..." — the card already declares the correct answer
|
is X because..." — the card already declares the correct answer
|
||||||
one line above. Go straight to the principle, the formula, the
|
one line above. Go straight to the principle, the formula, the
|
||||||
@@ -406,7 +411,8 @@ Before saving, confirm:
|
|||||||
|
|
||||||
- [ ] Key is the exact catalog `number` (case-sensitive).
|
- [ ] Key is the exact catalog `number` (case-sensitive).
|
||||||
- [ ] All four fields present, correct types, in range.
|
- [ ] All four fields present, correct types, in range.
|
||||||
- [ ] `explanation` is English, terse, WHY-focused.
|
- [ ] `explanation` is English, correct, helpful, WHY-focused — as
|
||||||
|
long as the topic needs, no longer.
|
||||||
- [ ] `source` is non-empty and as primary as possible.
|
- [ ] `source` is non-empty and as primary as possible.
|
||||||
- [ ] `revision` is correct (1 for new, bumped for improvement).
|
- [ ] `revision` is correct (1 for new, bumped for improvement).
|
||||||
- [ ] `confidence` honestly reflects how well the source supports
|
- [ ] `confidence` honestly reflects how well the source supports
|
||||||
|
|||||||
+39
-5
@@ -33,6 +33,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
import re
|
import re
|
||||||
|
import secrets
|
||||||
import shutil
|
import shutil
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import sys
|
import sys
|
||||||
@@ -204,7 +205,7 @@ def load_explanations(path):
|
|||||||
{
|
{
|
||||||
"NA101": {
|
"NA101": {
|
||||||
"revision": 1,
|
"revision": 1,
|
||||||
"explanation": "Terse English text explaining why ...",
|
"explanation": "English text explaining why ...",
|
||||||
"source": "https://... or AFuV §16(2)",
|
"source": "https://... or AFuV §16(2)",
|
||||||
"confidence": 7
|
"confidence": 7
|
||||||
},
|
},
|
||||||
@@ -1415,7 +1416,7 @@ CARD_CSS = """
|
|||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: #333;
|
color: #333;
|
||||||
font-style: italic;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
.af-explanation-header {
|
.af-explanation-header {
|
||||||
font-family: Arial, sans-serif;
|
font-family: Arial, sans-serif;
|
||||||
@@ -1451,6 +1452,28 @@ CARD_CSS = """
|
|||||||
color: #1a73e8;
|
color: #1a73e8;
|
||||||
text-decoration: none;
|
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
|
) 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):
|
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.
|
||||||
|
|
||||||
@@ -1546,8 +1576,11 @@ def _parse_args(argv):
|
|||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--seed",
|
"--seed",
|
||||||
default="amateurfunk-anki-v1",
|
default=None,
|
||||||
help="deterministic seed for answer shuffling",
|
help=(
|
||||||
|
"deterministic seed for answer shuffling; omit to reshuffle "
|
||||||
|
"answers on every build"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--epoch",
|
"--epoch",
|
||||||
@@ -1573,11 +1606,12 @@ def _parse_args(argv):
|
|||||||
def main(argv=None):
|
def main(argv=None):
|
||||||
"""Top-level entry point. Returns an exit code; never raises."""
|
"""Top-level entry point. Returns an exit code; never raises."""
|
||||||
args = _parse_args(argv)
|
args = _parse_args(argv)
|
||||||
|
seed = resolve_shuffle_seed(args.seed)
|
||||||
try:
|
try:
|
||||||
results = build_all(
|
results = build_all(
|
||||||
args.data,
|
args.data,
|
||||||
args.out,
|
args.out,
|
||||||
seed=args.seed,
|
seed=seed,
|
||||||
override_epoch=args.epoch,
|
override_epoch=args.epoch,
|
||||||
explanations_path=args.explanations,
|
explanations_path=args.explanations,
|
||||||
)
|
)
|
||||||
|
|||||||
+1254
-1254
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@ import tempfile
|
|||||||
import unittest
|
import unittest
|
||||||
import zipfile
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
import amateurfunk_anki as aa
|
import amateurfunk_anki as aa
|
||||||
|
|
||||||
@@ -108,6 +109,24 @@ def extract_collection(apkg_path: Path, tmp: Path):
|
|||||||
return tmp / "collection.anki2", media, names
|
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):
|
class TestAnkiBuild(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.tmp = tempfile.TemporaryDirectory()
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
@@ -479,6 +498,7 @@ class TestAnkiBuild(unittest.TestCase):
|
|||||||
common = [
|
common = [
|
||||||
"--data", str(self.data_dir),
|
"--data", str(self.data_dir),
|
||||||
"--explanations", str(self.explanations_path),
|
"--explanations", str(self.explanations_path),
|
||||||
|
"--seed", "stable-test-seed",
|
||||||
"--epoch", "1234567890",
|
"--epoch", "1234567890",
|
||||||
]
|
]
|
||||||
aa.main([*common, "--out", str(out_a)])
|
aa.main([*common, "--out", str(out_a)])
|
||||||
@@ -488,6 +508,32 @@ class TestAnkiBuild(unittest.TestCase):
|
|||||||
second = out_b / first.name
|
second = out_b / first.name
|
||||||
self.assertEqual(first.read_bytes(), second.read_bytes(), 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):
|
class TestRealExplanationsFile(unittest.TestCase):
|
||||||
"""Validate the repo's actual explanations.json against the live catalog.
|
"""Validate the repo's actual explanations.json against the live catalog.
|
||||||
|
|||||||
Reference in New Issue
Block a user