Shrink token from 16b to 8b, improving memory usage

This commit is contained in:
John 2026-09-23 01:28:09 -05:00
parent ce14c9d53b
commit b977f07297
8 changed files with 69 additions and 49 deletions

View File

@ -111,8 +111,9 @@ with EPUB styles, punctuation, and repeated invented names:
``` ```
The output directory must be empty unless `--overwrite` is supplied. Copy the The output directory must be empty unless `--overwrite` is supplied. Copy the
resulting `Example Book` directory into `/books` on the card. A resulting `Example Book` directory into `/books` on the card. A section is
section and its `.rsvp` sidecar may each be up to 1 MiB. The converter writes limited to 150,000 words; its source text and `.rsvp` sidecar may each be up
to 1 MiB. The converter writes
`CONVERSION_REPORT.txt` in the book directory. It records any unsupported `CONVERSION_REPORT.txt` in the book directory. It records any unsupported
non-ASCII characters left after non-ASCII characters left after
normalizing common punctuation, ligatures, accents, whitespace, fractions, and normalizing common punctuation, ligatures, accents, whitespace, fractions, and
@ -255,8 +256,9 @@ cloud account, or synchronization feature.
## Limits of the first MVP ## Limits of the first MVP
- It reads sections up to 1 MiB from `/books/<book>/<section>.txt`, in the - It reads sections up to 150,000 words and 1 MiB from
order declared by that book's `TOC.txt`. `/books/<book>/<section>.txt`, in the order declared by that book's
`TOC.txt`.
- The built-in display font is best for printable ASCII text. UTF-8 text files - The built-in display font is best for printable ASCII text. UTF-8 text files
are accepted, but accented/non-Latin characters need a Unicode font addition are accepted, but accented/non-Latin characters need a Unicode font addition
before they will render correctly. before they will render correctly.

15
TODO.md
View File

@ -14,16 +14,17 @@ profile that preserves the current cadence.
## Converter preflight and safe section splitting ## Converter preflight and safe section splitting
Add a `--check` mode that reports section and sidecar sizes, filename issues, Add a `--check` mode that reports section word counts, section and sidecar
and unsupported characters without writing output. Offer optional splitting at sizes, filename issues, and unsupported characters without writing output.
paragraph boundaries for sections that exceed the device's 1 MiB file limit. Offer optional splitting at paragraph boundaries for sections that exceed the
device's 150,000-word or 1 MiB file limits.
## Memory-limit hardware validation ## Memory-limit hardware validation
Stress-test the 1 MiB section and sidecar limits on real hardware, record PSRAM Stress-test the 150,000-word and 1 MiB section/sidecar limits on real hardware,
headroom, and lower the supported limit if token indexing or sidecar metadata record PSRAM headroom, and lower the supported limit if token indexing or
leaves insufficient memory for reliable reading. Include adversarial short-word sidecar metadata leaves insufficient memory for reliable reading. Include
and dense-sidecar inputs, not just ordinary prose. adversarial short-word and dense-sidecar inputs, not just ordinary prose.
## Reading continuity preference ## Reading continuity preference

View File

@ -91,24 +91,6 @@ BLOCK_TAGS = {
"h6", "h6",
} }
# These are useful TOC entries but generally aren't chapters.
FRONT_MATTER = {
"contents",
"table of contents",
"toc",
"cover",
"title page",
"copyright",
"copyright page",
"dedication",
"epigraph",
"acknowledgments",
"acknowledgements",
"about the author",
"also by",
"colophon",
}
FAT_FORBIDDEN_FILENAME_CHARACTERS = re.compile(r'[<>:"/\\|?*\x00-\x1f]') FAT_FORBIDDEN_FILENAME_CHARACTERS = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
STYLE_ITALIC = 0x01 STYLE_ITALIC = 0x01
@ -123,8 +105,10 @@ CSS_RULE = re.compile(r"([^{}]+)\{([^{}]*)\}")
CSS_CLASS = re.compile(r"\.([A-Za-z_-][A-Za-z0-9_-]*)") CSS_CLASS = re.compile(r"\.([A-Za-z_-][A-Za-z0-9_-]*)")
WORD_LOOKUP_KEY = re.compile(r"^[^A-Za-z]*([A-Za-z]+(?:['-][A-Za-z]+)*)[^A-Za-z]*$") WORD_LOOKUP_KEY = re.compile(r"^[^A-Za-z]*([A-Za-z]+(?:['-][A-Za-z]+)*)[^A-Za-z]*$")
MAX_SECTION_BYTES = 1024 * 1024 MAX_SECTION_BYTES = 1024 * 1024
MAX_TOKEN_BYTES = 256 * 1024 MAX_SECTION_WORDS = 150_000
MAX_TOKEN_BYTES = 65_535
MAX_SECTION_NAME_BYTES = 200 MAX_SECTION_NAME_BYTES = 200
RESERVED_SECTION_NAMES = {"toc", "conversion_report"}
TIMING_NONE = 0 TIMING_NONE = 0
TIMING_DOUBLE = 2 TIMING_DOUBLE = 2
@ -335,9 +319,13 @@ def lookup_word_key(token):
def validate_token_lengths(text): def validate_token_lengths(text):
"""Reject a malformed section containing an impractically long token.""" """Reject sections whose token index would exceed the device memory budget."""
for word_number, match in enumerate(re.finditer(r"\S+", text), start=1): for word_number, match in enumerate(re.finditer(r"\S+", text), start=1):
if word_number > MAX_SECTION_WORDS:
raise ValueError(
f"section exceeds the {MAX_SECTION_WORDS} word limit"
)
if len(match.group().encode("utf-8")) > MAX_TOKEN_BYTES: if len(match.group().encode("utf-8")) > MAX_TOKEN_BYTES:
raise ValueError( raise ValueError(
f"token {word_number} exceeds the {MAX_TOKEN_BYTES} byte limit" f"token {word_number} exceeds the {MAX_TOKEN_BYTES} byte limit"
@ -1397,7 +1385,7 @@ def convert_epub(epub_path, output_dir, overwrite=False):
toc_entries = [] toc_entries = []
prepared_sections = [] prepared_sections = []
used_names = set() used_names = set(RESERVED_SECTION_NAMES)
for number, chapter in enumerate( for number, chapter in enumerate(
chapters, chapters,
@ -1418,7 +1406,7 @@ def convert_epub(epub_path, output_dir, overwrite=False):
try: try:
validate_token_lengths(chapter["text"]) validate_token_lengths(chapter["text"])
except ValueError as exc: except ValueError as exc:
print(f"Error: {title!r} contains an invalid token: {exc}") print(f"Error: {title!r} exceeds reader limits: {exc}")
sys.exit(1) sys.exit(1)
sidecar_bytes = style_sidecar_bytes( sidecar_bytes = style_sidecar_bytes(
chapter["text"], chapter["style_records"] chapter["text"], chapter["style_records"]

View File

@ -24,9 +24,13 @@ constexpr uint8_t kButtonNext = 16;
constexpr char kBooksDirectory[] = "/books"; constexpr char kBooksDirectory[] = "/books";
// Source sections and their optional RSVP sidecars are loaded into memory. // Source sections and their optional RSVP sidecars are loaded into memory.
constexpr size_t kMaxSectionBytes = 1024 * 1024; constexpr size_t kMaxSectionBytes = 1024 * 1024;
// A single token can be a long URL or identifier, but should never consume a // Each token occupies eight bytes. Limiting a section to 150,000 tokens keeps
// material fraction of the reader's working memory. // its token index near 1.2 MiB, leaving PSRAM headroom for source text,
constexpr uint32_t kMaxTokenBytes = 256 * 1024; // sidecar metadata, and the display runtime.
constexpr uint32_t kMaxSectionWords = 150000;
// Token lengths are stored in uint16_t. A 64 KiB token is already much longer
// than the display can usefully render.
constexpr uint16_t kMaxTokenBytes = UINT16_MAX;
constexpr uint16_t kDefaultWpm = 300; constexpr uint16_t kDefaultWpm = 300;
constexpr uint16_t kMinimumWpm = 100; constexpr uint16_t kMinimumWpm = 100;

View File

@ -5,10 +5,10 @@
struct Token { struct Token {
uint32_t offset; uint32_t offset;
uint32_t length; uint16_t length;
uint32_t paragraph;
uint8_t trailingLineBreaks; uint8_t trailingLineBreaks;
}; };
static_assert(sizeof(Token) == 8, "Token storage must remain memory-budgeted");
struct StyleToken { struct StyleToken {
uint32_t index; uint32_t index;
@ -42,6 +42,7 @@ class Reader {
uint64_t sourceFingerprint_ = 0; uint64_t sourceFingerprint_ = 0;
bool loadSidecar(const std::vector<uint8_t>& sidecar); bool loadSidecar(const std::vector<uint8_t>& sidecar);
uint32_t paragraphStartFor(uint32_t wordIndex) const;
void seekStyleToken(); void seekStyleToken();
const StyleToken* currentStyleToken() const; const StyleToken* currentStyleToken() const;
}; };

View File

@ -154,13 +154,14 @@ bool Reader::load(const String& book, const std::vector<uint8_t>& sidecar) {
} }
if (whitespace && inWord) { if (whitespace && inWord) {
const uint32_t wordLength = i - wordStart; const uint32_t wordLength = i - wordStart;
if (wordLength > config::kMaxTokenBytes) { if (wordLength > config::kMaxTokenBytes ||
tokens_.size() >= config::kMaxSectionWords) {
tokens_.clear(); tokens_.clear();
paragraphStarts_.clear(); paragraphStarts_.clear();
return false; return false;
} }
tokens_.push_back({wordStart, wordLength, tokens_.push_back(
static_cast<uint32_t>(paragraphStarts_.size() - 1), 0}); {wordStart, static_cast<uint16_t>(wordLength), 0});
inWord = false; inWord = false;
} }
if (c == '\n') { if (c == '\n') {
@ -225,7 +226,7 @@ bool Reader::loadSidecar(const std::vector<uint8_t>& sidecar) {
void Reader::restoreAtParagraphStart(uint32_t savedWord) { void Reader::restoreAtParagraphStart(uint32_t savedWord) {
if (tokens_.empty()) return; if (tokens_.empty()) return;
index_ = savedWord < tokens_.size() ? savedWord : tokens_.size() - 1; index_ = savedWord < tokens_.size() ? savedWord : tokens_.size() - 1;
index_ = paragraphStarts_[tokens_[index_].paragraph]; index_ = paragraphStartFor(index_);
seekStyleToken(); seekStyleToken();
} }
@ -247,22 +248,25 @@ bool Reader::advance() {
bool Reader::previousParagraph() { bool Reader::previousParagraph() {
if (tokens_.empty()) return false; if (tokens_.empty()) return false;
const uint32_t paragraph = tokens_[index_].paragraph; const auto nextParagraph = std::upper_bound(
if (paragraph == 0) { paragraphStarts_.begin(), paragraphStarts_.end(), index_);
const auto currentParagraph = std::prev(nextParagraph);
if (currentParagraph == paragraphStarts_.begin()) {
index_ = 0; index_ = 0;
seekStyleToken(); seekStyleToken();
return false; return false;
} }
index_ = paragraphStarts_[paragraph - 1]; index_ = *std::prev(currentParagraph);
seekStyleToken(); seekStyleToken();
return true; return true;
} }
bool Reader::nextParagraph() { bool Reader::nextParagraph() {
if (tokens_.empty()) return false; if (tokens_.empty()) return false;
const uint32_t paragraph = tokens_[index_].paragraph; const auto nextParagraph = std::upper_bound(
if (paragraph + 1 >= paragraphStarts_.size()) return false; paragraphStarts_.begin(), paragraphStarts_.end(), index_);
index_ = paragraphStarts_[paragraph + 1]; if (nextParagraph == paragraphStarts_.end()) return false;
index_ = *nextParagraph;
seekStyleToken(); seekStyleToken();
return true; return true;
} }
@ -286,6 +290,12 @@ uint8_t Reader::progressPercent() const {
lastIndex); lastIndex);
} }
uint32_t Reader::paragraphStartFor(uint32_t wordIndex) const {
const auto nextParagraph = std::upper_bound(
paragraphStarts_.begin(), paragraphStarts_.end(), wordIndex);
return *std::prev(nextParagraph);
}
void Reader::seekStyleToken() { void Reader::seekStyleToken() {
const auto record = std::lower_bound( const auto record = std::lower_bound(
styleTokens_.begin(), styleTokens_.end(), index_, styleTokens_.begin(), styleTokens_.end(), index_,

View File

@ -503,7 +503,7 @@ void loop() {
handleTouch(); handleTouch();
handleInput(); handleInput();
if (playing && millis() >= nextWordMs) { if (playing && static_cast<int32_t>(millis() - nextWordMs) >= 0) {
if (!reader.advance()) { if (!reader.advance()) {
String nextChapterPath; String nextChapterPath;
if (library.nextChapter(state.bookPath, nextChapterPath) && if (library.nextChapter(state.bookPath, nextChapterPath) &&

View File

@ -13,6 +13,7 @@ from epub_to_chapters import (
TIMING_DOUBLE, TIMING_DOUBLE,
TIMING_TRIPLE, TIMING_TRIPLE,
MAX_TOKEN_BYTES, MAX_TOKEN_BYTES,
MAX_SECTION_WORDS,
add_unfamiliar_word_timing, add_unfamiliar_word_timing,
decode_styled_text, decode_styled_text,
document_root, document_root,
@ -67,6 +68,15 @@ class DisplayTextNormalizationTests(unittest.TestCase):
self.assertLessEqual(len(name.encode("utf-8")), 200) self.assertLessEqual(len(name.encode("utf-8")), 200)
def test_section_names_do_not_collide_with_generated_files(self):
used_names = {"toc", "conversion_report"}
self.assertEqual(output_section_name("TOC", 1, used_names), "TOC (2)")
self.assertEqual(
output_section_name("CONVERSION_REPORT", 2, used_names),
"CONVERSION_REPORT (2)",
)
class TokenValidationTests(unittest.TestCase): class TokenValidationTests(unittest.TestCase):
def test_allows_a_token_at_the_configured_byte_limit(self): def test_allows_a_token_at_the_configured_byte_limit(self):
@ -76,6 +86,10 @@ class TokenValidationTests(unittest.TestCase):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
validate_token_lengths("a" * (MAX_TOKEN_BYTES + 1)) validate_token_lengths("a" * (MAX_TOKEN_BYTES + 1))
def test_rejects_sections_with_too_many_words(self):
with self.assertRaises(ValueError):
validate_token_lengths("a " * (MAX_SECTION_WORDS + 1))
class ConversionReportTests(unittest.TestCase): class ConversionReportTests(unittest.TestCase):
def test_reports_remaining_non_ascii_characters_by_generated_file(self): def test_reports_remaining_non_ascii_characters_by_generated_file(self):