R1: Migration 0010 now writes revision 0 into every ground_dinner job payload that has none. Jobs queued before the upgrade carry only dinner_id, so the boot cleanup, which matches a pending dinner to its job by revision, failed those dinners as interrupted while their jobs were still queued. The migration runs before the job runner starts. A new test migrates a database to 0009, adds a pending Regular with a legacy queued job, upgrades and boots, and checks the dinner stays pending and is then grounded. R2: Archiving a dinner, or renaming a pending one, now cancels the grounding of any ground_dinner job running for it at an older revision, so no further search, page fetch, photo download or model call is made. The job is registered before it reads the dinner, so an archive or rename committed after that read always finds it. A stopped job succeeds and reports the dinner's current grounding status. The archive and rename tests now check that the job ends while its search is still held, with no page fetched and no model call for the stale name. R3: gen_folds.py writes web/src/dinners/letterFolds.json from the same table as folds_table.go, and names.ts folds through it instead of a hand-copied subset. names.ts also lowercases letter by letter, as Go's strings.ToLower does, rather than by context. TestLetterFoldsMatchPWA fails when the two tables differ. The shared cases add "Ƈhili", "Ꞔarbanzo Stew" and "ΜΟΥΣΑΚΑΣ" (final sigma). Maestro task 5037ff86-181d-448e-927b-c2fd2258a25d. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
126 lines
4.3 KiB
Python
126 lines
4.3 KiB
Python
#!/usr/bin/env python3
|
||
"""Generate folds_table.go, the letter-fold table used by harness.Normalize,
|
||
and its copy web/src/dinners/letterFolds.json, used by the PWA's
|
||
normalizeName (web/src/dinners/names.ts). TestLetterFoldsMatchPWA fails when
|
||
the two differ.
|
||
|
||
Run from this directory with `go generate ./internal/ai/harness` (or
|
||
`python3 gen_folds.py`; gofmt must be on PATH). It uses only the Python
|
||
standard library's unicodedata, so it works offline; the Unicode version it
|
||
was generated from is recorded in the output.
|
||
|
||
The table maps a code point to its lowercased base letters:
|
||
|
||
- every code point with a canonical decomposition that includes a
|
||
combining mark (category Mn) maps to its full canonical decomposition
|
||
with the marks removed, so a precomposed letter folds exactly like its
|
||
decomposed spelling once Normalize drops the marks (ở -> o, ǖ -> u).
|
||
Code points whose remaining base is not letters or digits (spacing
|
||
accents such as U+1FEE) are left out; Normalize treats both spellings
|
||
of those as punctuation;
|
||
- Latin letters with no decomposition whose name is "LATIN ... LETTER X
|
||
WITH ..." map to x (ø -> o, ł -> l, đ -> d);
|
||
- the Latin ligatures and letters listed in EXTRA map as given.
|
||
|
||
Each result is then folded again, letter by letter, until nothing changes
|
||
(ǿ -> ø -> o, ǽ -> æ -> ae). Normalize writes a result without folding it
|
||
again, so the table must already be fully reduced; the generator fails if a
|
||
result does not settle.
|
||
|
||
Hangul syllables are left out: their decomposition is into conjoining jamo,
|
||
not combining marks.
|
||
"""
|
||
|
||
import json
|
||
import re
|
||
import subprocess
|
||
import sys
|
||
import unicodedata
|
||
|
||
PWA_TABLE = "../../../web/src/dinners/letterFolds.json"
|
||
|
||
EXTRA = {
|
||
"ß": "ss", "ẞ": "ss", "æ": "ae", "Æ": "ae", "œ": "oe", "Œ": "oe",
|
||
"þ": "th", "Þ": "th", "ð": "d", "Ð": "d", "ı": "i", "ʼn": "n",
|
||
"ij": "ij", "IJ": "ij",
|
||
}
|
||
|
||
WITH = re.compile(r"^LATIN (?:SMALL|CAPITAL) LETTER ([A-Z]) WITH ")
|
||
|
||
|
||
def is_word(ch):
|
||
"""Reports whether Normalize keeps ch: a letter or a decimal digit."""
|
||
cat = unicodedata.category(ch)
|
||
return cat.startswith("L") or cat == "Nd"
|
||
|
||
|
||
def full_decomposition(ch):
|
||
return unicodedata.normalize("NFD", ch)
|
||
|
||
|
||
def fold(cp):
|
||
ch = chr(cp)
|
||
if ch in EXTRA:
|
||
return EXTRA[ch]
|
||
if 0xAC00 <= cp <= 0xD7A3:
|
||
return None
|
||
nfd = full_decomposition(ch)
|
||
if nfd != ch:
|
||
if not any(unicodedata.category(c) == "Mn" for c in nfd):
|
||
return None
|
||
base = "".join(c for c in nfd if unicodedata.category(c) != "Mn")
|
||
if not base or not all(is_word(c) for c in base):
|
||
return None
|
||
return base.lower()
|
||
m = WITH.match(unicodedata.name(ch, ""))
|
||
if m and unicodedata.category(ch) in ("Ll", "Lu", "Lt"):
|
||
return m.group(1).lower()
|
||
return None
|
||
|
||
|
||
def reduce(folds, s):
|
||
"""Folds every letter of s until nothing changes."""
|
||
for _ in range(len(folds)):
|
||
nxt = "".join(folds.get(c, c) for c in s)
|
||
if nxt == s:
|
||
return s
|
||
s = nxt
|
||
sys.exit("gen_folds.py: fold of %r does not settle" % s)
|
||
|
||
|
||
def main():
|
||
folds = {}
|
||
for cp in range(sys.maxunicode + 1):
|
||
folded = fold(cp)
|
||
if folded is not None:
|
||
folds[chr(cp)] = folded
|
||
entries = [(ord(ch), reduce(folds, folded)) for ch, folded in folds.items()]
|
||
out = [
|
||
"// Code generated by gen_folds.py from Unicode "
|
||
+ unicodedata.unidata_version + "; DO NOT EDIT.",
|
||
"",
|
||
"package harness",
|
||
"",
|
||
"// letterFolds maps a precomposed or stroked letter to its lowercased",
|
||
"// base letters. See gen_folds.py for how it is built.",
|
||
"var letterFolds = map[rune]string{",
|
||
]
|
||
for cp, folded in entries:
|
||
escaped = "".join(
|
||
c if 0x20 <= ord(c) < 0x7F and c not in '"\\' else "\\u%04x" % ord(c)
|
||
if ord(c) <= 0xFFFF else "\\U%08x" % ord(c)
|
||
for c in folded
|
||
)
|
||
out.append('\t0x%04X: "%s", // %s' % (cp, escaped, unicodedata.name(chr(cp))))
|
||
out.append("}")
|
||
with open("folds_table.go", "w", encoding="utf-8") as f:
|
||
f.write("\n".join(out) + "\n")
|
||
subprocess.run(["gofmt", "-w", "folds_table.go"], check=True)
|
||
with open(PWA_TABLE, "w", encoding="ascii") as f:
|
||
json.dump({chr(cp): folded for cp, folded in entries}, f, indent=2)
|
||
f.write("\n")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|