Chunk keys as 9-byte bytes digests; trans nested by hash -> lang

This commit is contained in:
2026-09-02 02:09:27 +00:00
parent f63bb2c482
commit cc750236bc
6 changed files with 55 additions and 38 deletions
+9 -8
View File
@@ -100,7 +100,7 @@ list blocks, tables, HTML blocks. A chunk's identity is its **source text**,
gettext-msgid style: gettext-msgid style:
```python ```python
chunk_key = blake3(normalize(chunk_text), digest_size=16).hex() chunk_key = blake3(normalize(chunk_text)).digest(9) # bytes; base64 at the JSON level
``` ```
(`normalize`: strip trailing whitespace per line, collapse surrounding blank (`normalize`: strip trailing whitespace per line, collapse surrounding blank
@@ -153,10 +153,11 @@ Full storage design and the `migrate_v3` restructuring live in
`docs/migrate.md`. The short version, as it concerns this document: `docs/migrate.md`. The short version, as it concerns this document:
- Originals **and** translations are content-addressed text chunks in flat - Originals **and** translations are content-addressed text chunks in flat
stores: `Data.chunks: dict[hash, str]` and stores: `Data.chunks: dict[bytes, str]` and
`Data.trans: dict[f"{chunk_hash}:{lang}", str]` — path-independent, so `Data.trans: dict[bytes, dict[str, str]]` (chunk hashlang → text) —
repeated paragraphs and menu titles are translated once and article moves path-independent, so repeated paragraphs and menu titles are translated
touch nothing. `Node.chunks: list[hash]` gives each article its order. once and article moves touch nothing. `Node.chunks: list[bytes]` gives
each article its order.
- `Node` gains **`language: str = ""`**, inherited down the tree like - `Node` gains **`language: str = ""`**, inherited down the tree like
`banner` (empty = nearest ancestor, front page last, site default `en` `banner` (empty = nearest ancestor, front page last, site default `en`
final). `select_language` and `<html lang>` use the resolved value instead final). `select_language` and `<html lang>` use the resolved value instead
@@ -177,7 +178,7 @@ def get_translation(path, lang, data) -> Translation | None:
if lang not in node.langs: if lang not in node.langs:
return None return None
hybrid = "\n\n".join( hybrid = "\n\n".join(
chunks[h] if h in node.no_trans else trans.get(f"{h}:{lang}", chunks[h]) chunks[h] if h in node.no_trans else trans.get(h, {}).get(lang, chunks[h])
for h in node.chunks for h in node.chunks
) )
for patch in data.patches.get(f"{path}:{lang}", []): for patch in data.patches.get(f"{path}:{lang}", []):
@@ -191,8 +192,8 @@ def get_translation(path, lang, data) -> Translation | None:
hreflang never probe the `trans` store chunk by chunk. A stale key is hreflang never probe the `trans` store chunk by chunk. A stale key is
benign (the "translation" just renders as the original). benign (the "translation" just renders as the original).
- `titles` for nav/sidebar/cards: each node's translated title is - `titles` for nav/sidebar/cards: each node's translated title is
`trans.get(f"{hash(node.title)}:{lang}")` with per-node fallback — one dict `trans.get(hash(node.title), {}).get(lang)` with per-node fallback — one
lookup per nav item at render time. dict lookup per nav item at render time.
- Cache invalidation: writes to `chunks` / `trans` / `patches` (translator, - Cache invalidation: writes to `chunks` / `trans` / `patches` (translator,
editor saves) call `_invalidate_pages()`, same as content writes. editor saves) call `_invalidate_pages()`, same as content writes.
+18 -12
View File
@@ -36,13 +36,13 @@ class Node(msgspec.Struct, omit_defaults=True):
... ...
#: Replaces `content: str | None`. None = pure category label; #: Replaces `content: str | None`. None = pure category label;
#: a list (possibly empty) = a page, as ordered chunk hashes. #: a list (possibly empty) = a page, as ordered chunk hashes.
chunks: list[str] | None = None chunks: list[bytes] | None = None
#: Primary language of the article (BCP-47 base tag). "" = inherit #: Primary language of the article (BCP-47 base tag). "" = inherit
#: (nearest ancestor, front page last, site default "en" final). #: (nearest ancestor, front page last, site default "en" final).
language: str = "" language: str = ""
#: Chunk hashes the editor marked "do not translate" (always served #: Chunk hashes the editor marked "do not translate" (always served
#: from the original). Presence-keys, value always True. #: from the original). Presence-keys, value always True.
no_trans: dict[str, True] = {} no_trans: dict[bytes, True] = {}
#: Languages this article is available in (besides its primary #: Languages this article is available in (besides its primary
#: language). Presence-keys, value always True — rendering, language #: language). Presence-keys, value always True — rendering, language
#: selection and hreflang alternates read this set instead of probing #: selection and hreflang alternates read this set instead of probing
@@ -52,12 +52,14 @@ class Node(msgspec.Struct, omit_defaults=True):
class Data(msgspec.Struct): class Data(msgspec.Struct):
... ...
#: All original-language text, content-addressed: blake3(normalized, #: All original-language text, content-addressed: blake3(normalized)
#: digest 16) hex -> Markdown chunk. Shared by every article. #: digest[:9] -> Markdown chunk. Shared by every article. Keys are
chunks: dict[str, str] = {} #: bytes; kanta/msgspec base64-encode them at the JSON level.
#: Machine translations: f"{chunk_hash}:{lang}" -> translated Markdown. chunks: dict[bytes, str] = {}
#: Machine translations: chunk hash -> lang -> translated Markdown
#: (nested, not tuple keys: msgspec's JSON serializer rejects them).
#: Also used for node titles (hash of the title text). #: Also used for node titles (hash of the title text).
trans: dict[str, str] = {} trans: dict[bytes, dict[str, str]] = {}
#: User override patches per article and language: #: User override patches per article and language:
#: f"{path}:{lang}" -> ordered patches (see localization.md). #: f"{path}:{lang}" -> ordered patches (see localization.md).
patches: dict[str, list[Patch]] = {} patches: dict[str, list[Patch]] = {}
@@ -70,7 +72,7 @@ Notes:
building hrefs. `migrate_v3` audits existing stored paths (translation building hrefs. `migrate_v3` audits existing stored paths (translation
keys, analytics references, any path-valued fields) and normalizes them. keys, analytics references, any path-valued fields) and normalizes them.
- **Titles are chunks too**, by hash only: the nav renderer looks up - **Titles are chunks too**, by hash only: the nav renderer looks up
`trans.get(f"{hash(node.title)}:{lang}")`. No separate title storage; `trans.get(hash(node.title), {}).get(lang)`. No separate title storage;
editing a title invalidates its translations automatically. editing a title invalidates its translations automatically.
- **Per-hunk options** live in two places: *inherent* options are derived at - **Per-hunk options** live in two places: *inherent* options are derived at
chunking time (code fences and HTML blocks are marked no-translate without chunking time (code fences and HTML blocks are marked no-translate without
@@ -88,7 +90,7 @@ that article rendering, `select_language`'s availability check, and hreflang
alternate links never enumerate chunks. It is written by whoever writes alternate links never enumerate chunks. It is written by whoever writes
translation data, in the same transaction: translation data, in the same transaction:
- **Translator job:** after writing `trans[f"{h}:{lang}"]` entries for an - **Translator job:** after writing `trans[h][lang]` entries for an
article's chunks (or its title), set `node.langs[lang] = True`. article's chunks (or its title), set `node.langs[lang] = True`.
- **Translated-view save:** appending the first patch for `f"{path}:{lang}"` - **Translated-view save:** appending the first patch for `f"{path}:{lang}"`
sets `node.langs[lang] = True` (patches alone make the version exist). sets `node.langs[lang] = True` (patches alone make the version exist).
@@ -101,7 +103,7 @@ translation data, in the same transaction:
- **Render:** `text = "\n\n".join(chunks[h] for h in node.chunks)` for the - **Render:** `text = "\n\n".join(chunks[h] for h in node.chunks)` for the
original; for language `L` (only ever attempted when `L in node.langs`), original; for language `L` (only ever attempted when `L in node.langs`),
per chunk `trans[f"{h}:{L}"]` unless missing or `h in node.no_trans`, per chunk `trans.get(h, {}).get(L)` unless missing or `h in node.no_trans`,
falling back to `chunks[h]`; then apply `patches.get(f"{path}:{L}", [])` falling back to `chunks[h]`; then apply `patches.get(f"{path}:{L}", [])`
in order (per-hunk, best effort); then `markdown.render` as today. All of in order (per-hunk, best effort); then `markdown.render` as today. All of
this assembles the `Translation` the phase-1 plumbing already consumes. this assembles the `Translation` the phase-1 plumbing already consumes.
@@ -135,10 +137,14 @@ Chunking must be deterministic and shared with render/save, so
## Implementation notes (deviations from the plan above) ## Implementation notes (deviations from the plan above)
- Chunking lives in `pagerite/chunks.py`; hashing uses the `blake3` package - Chunking lives in `pagerite/chunks.py`; hashing uses the `blake3` package
(already a dependency) with a 16-byte digest (`hexdigest(16)`). (already a dependency), truncated to a 9-byte `bytes` digest (kanta's
JSON persistence base64-encodes bytes keys to 12-char strings).
- `trans` is keyed `hash -> lang -> text` (nested dict), not by
`f"{hash}:{lang}"` tuples: msgspec's JSON serializer only supports
str-like/number-like dict keys, and kanta persists as JSON lines.
- `Translation.titles` stayed keyed by node path (phase-1 shape, views - `Translation.titles` stayed keyed by node path (phase-1 shape, views
untouched): `get_translation` builds it by walking the menu with the same untouched): `get_translation` builds it by walking the menu with the same
per-title `trans[f"{chunk_key(node.title)}:{lang}"]` lookups. per-title `trans.get(chunk_key(node.title), {}).get(lang)` lookups.
- Insert hunks anchor on the whole preceding block (not just its tail) — - Insert hunks anchor on the whole preceding block (not just its tail) —
a stronger, simpler search context. a stronger, simpler search context.
- `make_patch` diffs with `SequenceMatcher(autojunk=False)` so patches are - `make_patch` diffs with `SequenceMatcher(autojunk=False)` so patches are
+10 -5
View File
@@ -111,10 +111,15 @@ def _normalize(text: str) -> str:
return "\n".join(line.rstrip() for line in text.split("\n")).strip("\n") return "\n".join(line.rstrip() for line in text.split("\n")).strip("\n")
def chunk_key(text: str) -> str: def chunk_key(text: str) -> bytes:
"""Content key of a chunk: blake3 hex (16-byte digest, 32 hex chars) """Content key of a chunk: the first 9 bytes of the blake3 digest of
of the normalized text — the same hasher app.py's file store uses.""" the normalized text (72 bits — a site's chunk count stays far below
return blake3.blake3(_normalize(text).encode()).hexdigest(16) the birthday bound), using the same hasher as app.py's file store.
Keys are bytes: kanta/msgspec base64-encode them at the JSON
persistence level, so the raw database dicts carry 12-char strings.
"""
return blake3.blake3(_normalize(text).encode()).digest(9)
def needs_translation(chunk: str) -> bool: def needs_translation(chunk: str) -> bool:
@@ -137,7 +142,7 @@ def join_chunks(chunks: list[str]) -> str:
return "\n\n".join(chunks) + "\n" if chunks else "" return "\n\n".join(chunks) + "\n" if chunks else ""
def store_chunks(store: dict[str, str], markdown: str) -> list[str]: def store_chunks(store: dict[bytes, str], markdown: str) -> list[bytes]:
"""Chunk ``markdown`` into ``store`` (hash -> text); return the ordered """Chunk ``markdown`` into ``store`` (hash -> text); return the ordered
hashes. Unchanged chunks keep their hashes, so only genuinely new text hashes. Unchanged chunks keep their hashes, so only genuinely new text
lands in the kanta change diff. First writer wins: variants sharing a lands in the kanta change diff. First writer wins: variants sharing a
+13 -10
View File
@@ -39,16 +39,16 @@ class Node(msgspec.Struct, omit_defaults=True):
title: str = "" title: str = ""
order: float = 0 order: float = 0
#: Ordered chunk hashes into ``Data.chunks``; None = pure category #: Ordered chunk hashes (9-byte keys into ``Data.chunks``); None =
#: label (its URL renders a placeholder page), a list (possibly #: pure category label (its URL renders a placeholder page), a list
#: empty) = a page. #: (possibly empty) = a page.
chunks: list[str] | None = None chunks: list[bytes] | None = None
#: Primary language of the article (BCP-47 base tag). "" = inherit #: Primary language of the article (BCP-47 base tag). "" = inherit
#: (nearest ancestor, front page last, site default "en" final). #: (nearest ancestor, front page last, site default "en" final).
language: str = "" language: str = ""
#: Chunk hashes the editor marked "do not translate" (always served #: Chunk hashes the editor marked "do not translate" (always served
#: from the original). Presence-keys, value always True. #: from the original). Presence-keys, value always True.
no_trans: dict[str, bool] = {} no_trans: dict[bytes, bool] = {}
#: Languages this article is available in (besides its primary #: Languages this article is available in (besides its primary
#: language). Presence-keys, value always True — the availability #: language). Presence-keys, value always True — the availability
#: index for rendering, language selection and hreflang alternates; #: index for rendering, language selection and hreflang alternates;
@@ -102,11 +102,14 @@ class Data(msgspec.Struct):
#: /favicon.ico. #: /favicon.ico.
favicon: str = "" favicon: str = ""
#: All original-language page text, content-addressed: #: All original-language page text, content-addressed:
#: chunk_key -> Markdown chunk. Shared by every article. #: chunk_key (9 bytes; base64 at the JSON level) -> Markdown chunk.
chunks: dict[str, str] = {} #: Shared by every article.
#: Machine translations: f"{chunk_hash}:{lang}" -> translated chunks: dict[bytes, str] = {}
#: Markdown. Also used for node titles (hash of the title text). #: Machine translations: chunk hash -> lang -> translated Markdown
trans: dict[str, str] = {} #: (a nested dict rather than tuple keys, which msgspec's JSON
#: serializer does not support). Also used for node titles (hash of
#: the title text).
trans: dict[bytes, dict[str, str]] = {}
#: User override patches per article and language: #: User override patches per article and language:
#: f"{path}:{lang}" -> ordered patches (paths without leading slash). #: f"{path}:{lang}" -> ordered patches (paths without leading slash).
patches: dict[str, list[Patch]] = {} patches: dict[str, list[Patch]] = {}
+2 -2
View File
@@ -133,7 +133,7 @@ def hybrid_markdown(data: Data, node: Node, path: str, lang: str) -> str:
hybrid = join_chunks([ hybrid = join_chunks([
data.chunks.get(h, "") data.chunks.get(h, "")
if h in node.no_trans if h in node.no_trans
else data.trans.get(f"{h}:{lang}") or data.chunks.get(h, "") else data.trans.get(h, {}).get(lang) or data.chunks.get(h, "")
for h in node.chunks or [] for h in node.chunks or []
]) ])
for patch in data.patches.get(f"{path}:{lang}", []): for patch in data.patches.get(f"{path}:{lang}", []):
@@ -154,7 +154,7 @@ def title_map(data: Data, lang: str) -> dict[str, str]:
for slug, node in nodes.items(): for slug, node in nodes.items():
path = f"{prefix}/{slug}" if prefix else slug path = f"{prefix}/{slug}" if prefix else slug
if node.title: if node.title:
t = data.trans.get(f"{chunk_key(node.title)}:{lang}") t = data.trans.get(chunk_key(node.title), {}).get(lang)
if t: if t:
titles[path] = t titles[path] = t
walk(node.children, path) walk(node.children, path)
+3 -1
View File
@@ -141,6 +141,8 @@ def migrate_v3(d: dict) -> None:
list as ``chunks`` (an absent content stays absent, i.e. None = a list as ``chunks`` (an absent content stays absent, i.e. None = a
pure category label; "" chunks to an empty list = an empty page). pure category label; "" chunks to an empty list = an empty page).
Chunk keys are 9-byte blake3 digests; at this raw JSON level they are
base64 strings (decoding into the structs restores ``bytes`` keys).
``trans``/``patches`` start empty; the translator job fills them and ``trans``/``patches`` start empty; the translator job fills them and
maintains the ``langs`` index as translations land. ``language``, maintains the ``langs`` index as translations land. ``language``,
``no_trans`` and ``langs`` need nothing — struct defaults cover them. ``no_trans`` and ``langs`` need nothing — struct defaults cover them.
@@ -155,7 +157,7 @@ def migrate_v3(d: dict) -> None:
if isinstance(content, str): if isinstance(content, str):
hashes = [] hashes = []
for chunk in chunk_markdown(content): for chunk in chunk_markdown(content):
key = chunk_key(chunk) key = base64.b64encode(chunk_key(chunk)).decode()
store.setdefault(key, chunk) store.setdefault(key, chunk)
hashes.append(key) hashes.append(key)
node["chunks"] = hashes node["chunks"] = hashes