open-standards/aac/aac-rs/tools/ai/build_ai.py
Transaction Science 048d499620
Some checks failed
no-vanity-metrics / Scan committed Markdown for vanity metrics (push) Successful in 3s
no-vanity-metrics / WAI public text names no comparison (push) Successful in 3s
no-vanity-metrics / WAI public text names no company or product (push) Successful in 3s
standards workspaces build + test / eoc-rs (push) Successful in 1m51s
standards workspaces build + test / jcp-rs (push) Successful in 1m22s
standards workspaces build + test / proof-rs (push) Successful in 1m16s
standards workspaces build + test / sandbox-rs (push) Successful in 2m12s
standards workspaces build + test / smart-byte-rs (push) Successful in 3m37s
standards workspaces build + test / jouleclaw-rs (push) Has been cancelled
standards workspaces build + test / aac-rs (push) Has been cancelled
standards workspaces build + test / arl-rs (push) Has been cancelled
standards workspaces build + test / joule-code-rs (push) Has been cancelled
standards workspaces build + test / joule-ui-rs (push) Has been cancelled
standards workspaces build + test / joulecontract-rs (push) Has been cancelled
standards workspaces build + test / jouledesk-rs (push) Has been cancelled
standards workspaces build + test / joulehook-rs (push) Has been cancelled
standards workspaces build + test / map-rs (push) Has been cancelled
standards workspaces build + test / mesh-rs (push) Has been cancelled
standards workspaces build + test / openpay (push) Has been cancelled
standards workspaces build + test / sense-rs (push) Has been cancelled
standards workspaces build + test / macOS energy backend (jcp-energy) (push) Has been cancelled
fix(aac sentence helper): a larger model never costs the one that works
What review found in the two-model change, before release:

- Replacing E2B with E4B removed E2B even when E4B then could not run
  (out of memory), leaving no helper in any language. The model that
  worked is now kept beside the new one until the new one has run here;
  if it cannot, the one that worked is put back and the new one's parts
  go ("Gemma 4 E4B could not run on this device, so Gemma 4 E2B is kept").
  If the page dies during the check, the one that worked is in use when
  it opens again, and Settings can set the new one up without downloading.
- install() takes the model Settings described, so Continue on a partial
  download, or a language switch while Settings reads, cannot download
  another model than the size the person agreed to.
- The "larger model" view shows what is left of a stopped download, with
  Continue and "Remove what was downloaded"; its space check counts only
  what is left. A stopped download of a model not in use is shown and can
  be removed, and a finished install deletes every part it does not use.
- Settings clears what it showed when the language changes.
- Where the browser does not say how much memory the device has (Safari),
  a model needing more than 4 GB says so before download; too little
  memory names the model the language needs.
- A model the catalogue no longer lists is offered as an update.
- run.mjs needs MODEL= when a site has two models; build_ai.py refuses a
  published build missing a model a ready language uses, and checks
  --source ids.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-01 18:55:00 -04:00

341 lines
15 KiB
Python

#!/usr/bin/env python3
"""Build the sentence helper's model parts and their catalogue.
The models are Google's web builds of Gemma 4 for LiteRT-LM (E2B, and E4B
for languages E2B is not good enough in), each pinned by Hugging Face
revision, size and SHA-256 (MODELS below). Each is split into parts of at most
64 MiB, named by their contents, so the app's model store can check each part
in memory on a tablet and Cloudflare can cache every part. A catalogue holds
the models some ready language uses (or those named with --models, for an
evaluation build).
Outputs (schema "aac-ai-index/1", read by crates/aac-web/src/ai_helper.rs):
../aac-ai-packs/ beside the open-standards checkout ($AAC_AI_DIR
overrides; --out too): what the site builds copy
to /app/ai/ after tools/ai/check_packs.py
index.json pointer {schema, current}; serve no-store
index.<sha12>.json the immutable catalogue
LICENSES.md the model's licence and attribution
licences/Apache-2.0.<sha12>.txt the licence text
<model-id>/part-NN.<sha12>.bin
data/ai/index.json committed copy of the catalogue
data/ai/LICENSES.md committed copy of LICENSES.md
The languages, their status and the examples the model is shown come from
tools/ai/languages.json. A language is listed there only after testing.
Usage (from aac/aac-rs):
python3 tools/ai/build_ai.py # download pinned models once, build
python3 tools/ai/build_ai.py --source ID=FILE ... # use local copies (their pins are checked)
python3 tools/ai/build_ai.py --offline # cached downloads only
python3 tools/ai/build_ai.py --models ID --out DIR --no-commit
# an evaluation build of other models
Standard library only (Python 3.9 or later).
"""
import argparse
import hashlib
import json
import os
import re
import shutil
import sys
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1] # aac/aac-rs
CACHE = HERE / ".cache"
OUT_DEFAULT = Path(os.environ.get("AAC_AI_DIR") or ROOT.parents[2] / "aac-ai-packs")
COMMITTED = ROOT / "data" / "ai"
SCHEMA = "aac-ai-index/1"
# The runtime the parts are for: crates/aac-web/src/ai_helper.rs RUNTIME_ID.
RUNTIME = "[email protected]"
PART_BYTES = 64 * 1024 * 1024
# The day the sources were read: the catalogue's date, and every file's
# modification time, so a rebuild of the same sources is byte- and
# time-identical (rsync then sends nothing).
RETRIEVED = "2026-10-01"
EPOCH = int(datetime.fromisoformat(RETRIEVED).replace(tzinfo=timezone.utc).timestamp())
ATTRIBUTION = ("{name} by Google DeepMind, in Google AI Edge's web build for LiteRT-LM "
"(litert-community on Hugging Face)")
MODELS = {
"gemma-4-e2b-it-web": {
"repo": "litert-community/gemma-4-E2B-it-litert-lm",
"revision": "b3ca0d2f076785a8f4b2219ddbd2bdb99954eae1",
"file": "gemma-4-E2B-it-web.litertlm",
"bytes": 2008432640,
"sha256": "3a08e8d94e23b814ae5414469c370c503813949acb8ceaa17e4ebf8a35af35b5",
"name": "Gemma 4 E2B",
# What it held in GPU memory when measured (Chrome on a Mac, an iPad
# Air, 2026-09-30): about 1.8 GB.
"gpu_memory_bytes": 1_800_000_000,
"min_memory_gb": 4,
"covers": [],
},
"gemma-4-e4b-it-web": {
"repo": "litert-community/gemma-4-E4B-it-litert-lm",
"revision": "2eee7ac325f20eb8c9ac1d0e972f7c84663062da",
"file": "gemma-4-E4B-it-web.litertlm",
"bytes": 2969059328,
"sha256": "3904d826d5dddd25ea173e85204caec09e68ba038116e9b992b69cbdc94f57a0",
"name": "Gemma 4 E4B",
# Measured in Chrome on a Mac, 2026-10-01: a 3.0 GB GPU process.
"gpu_memory_bytes": 3_000_000_000,
"min_memory_gb": 8,
# The larger model serves every language the smaller one is ready in.
"covers": ["gemma-4-e2b-it-web"],
},
}
LICENCE = "Apache-2.0"
LICENCE_URL = "https://ai.google.dev/gemma/docs/gemma_4_license"
CONTEXT_TOKENS = 1024
# The Apache License 2.0 text, as LiteRT-LM ships it (third_party/litert-lm/LICENSE.txt).
APACHE = ROOT / "third_party" / "litert-lm" / "LICENSE.txt"
APACHE_SHA256 = "c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4"
class BuildError(Exception):
pass
def log(msg):
print(msg, flush=True)
def sha256_file(path):
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 22), b""):
h.update(chunk)
return h.hexdigest()
def dumps(obj):
return (json.dumps(obj, indent=1, ensure_ascii=False) + "\n").encode("utf-8")
def model_source(model, source, offline):
"""A pinned model's file: its --source, else the cached download, else
download it (into the cache, checked before it is used)."""
def check(path):
size = path.stat().st_size
if size != model["bytes"]:
raise BuildError(f"{path} is {size} bytes; the pin is {model['bytes']}")
got = sha256_file(path)
if got != model["sha256"]:
raise BuildError(f"{path} has sha256 {got}; the pin is {model['sha256']}")
return path
if source:
return check(Path(source))
cached = CACHE / model["revision"] / model["file"]
if cached.exists():
return check(cached)
if offline:
raise BuildError(f"{cached} is not cached and --offline was given")
url = f"https://huggingface.co/{model['repo']}/resolve/{model['revision']}/{model['file']}"
log(f"download: {url}")
cached.parent.mkdir(parents=True, exist_ok=True)
tmp = cached.with_name(cached.name + ".tmp")
with urllib.request.urlopen(url) as res, open(tmp, "wb") as out:
shutil.copyfileobj(res, out, 1 << 22)
check(tmp)
os.replace(tmp, cached)
return cached
def languages():
doc = json.loads((HERE / "languages.json").read_text(encoding="utf-8"))
langs = doc["languages"]
for code, l in langs.items():
if not re.fullmatch(r"[a-z]{2,3}", code):
raise BuildError(f"languages.json: code {code!r}")
if l["status"] == "ready":
if l.get("model") not in MODELS:
raise BuildError(f"languages.json: {code} is ready with an unknown model {l.get('model')!r}")
ex = l.get("examples", {})
if not ex.get("sentence") or any(len(s["out"]) != 3 for s in ex["sentence"]):
raise BuildError(f"languages.json: {code} needs sentence examples of 3")
if not ex.get("reply") or any(len(r["out"]) != 4 for r in ex["reply"]):
raise BuildError(f"languages.json: {code} needs reply examples of 4")
elif l["status"] != "not-yet":
raise BuildError(f"languages.json: {code} status {l['status']!r}")
return dict(sorted(langs.items()))
def split(model_id, source):
"""The model file in parts: (files to write, the parts as the catalogue lists them)."""
files, parts = {}, []
with open(source, "rb") as f:
n = 0
while True:
data = f.read(PART_BYTES)
if not data:
break
digest = hashlib.sha256(data).hexdigest()
rel = f"{model_id}/part-{n:02d}.{digest[:12]}.bin"
files[rel] = ("part", source, n * PART_BYTES, len(data), digest)
parts.append({"url": rel, "bytes": len(data), "sha256": digest})
n += 1
if sum(p["bytes"] for p in parts) != MODELS[model_id]["bytes"]:
raise BuildError(f"the parts of {model_id} do not add up to the model")
return files, parts
def plan(sources, model_ids):
"""Every file to write, as rel -> (bytes or (src, offset, size)), and the catalogue."""
if sha256_file(APACHE) != APACHE_SHA256:
raise BuildError(f"{APACHE} is not the Apache License 2.0 text pinned here")
apache = APACHE.read_bytes()
apache_rel = f"licences/Apache-2.0.{hashlib.sha256(apache).hexdigest()[:12]}.txt"
files = {apache_rel: ("data", apache)}
models = {}
for model_id in model_ids:
m = MODELS[model_id]
part_files, parts = split(model_id, sources[model_id])
files.update(part_files)
models[model_id] = {
"name": m["name"],
"runtime": RUNTIME,
"source": f"https://huggingface.co/{m['repo']}/blob/{m['revision']}/{m['file']}",
"bytes": m["bytes"],
"sha256": m["sha256"],
"parts": parts,
"licence": LICENCE,
"licence_url": LICENCE_URL,
"attribution": ATTRIBUTION.format(name=m["name"]),
"licence_file": apache_rel,
"gpu_memory_bytes": m["gpu_memory_bytes"],
"context_tokens": CONTEXT_TOKENS,
"min_memory_gb": m["min_memory_gb"],
"covers": [c for c in m["covers"] if c in model_ids],
}
catalogue = {"schema": SCHEMA, "generated": RETRIEVED, "models": models, "languages": languages()}
cat_bytes = dumps(catalogue)
cat_name = f"index.{hashlib.sha256(cat_bytes).hexdigest()[:12]}.json"
files[cat_name] = ("data", cat_bytes)
files["index.json"] = ("data", dumps({"schema": SCHEMA, "current": cat_name}))
files["LICENSES.md"] = ("data", licences_md(catalogue, apache_rel).encode("utf-8"))
return files, cat_bytes
def licences_md(catalogue, apache_rel):
out = [
"# Sentence helper: licences\n\n"
"The sentence helper in AuraSense AAC (aac-web) downloads one language model, on request, and runs it on "
"the person's device. Nothing they type is sent anywhere.\n"
]
for model_id, m in catalogue["models"].items():
ready = [f"{l['name']} ({c})" for c, l in catalogue["languages"].items()
if l["status"] == "ready" and l.get("model") == model_id]
out.append(
f"\n## {m['name']}\n\n"
f"- {m['attribution']}.\n"
f"- Licence: {m['licence']} ({m['licence_url']}); the text is `{apache_rel}`.\n"
f"- Source: {m['source']} ({m['bytes']} bytes, SHA-256 `{m['sha256']}`), unchanged, published here in "
f"{len(m['parts'])} parts of at most 64 MiB, each named by its own SHA-256. Together they are the file.\n"
f"- Size: {m['bytes'] / 1e9:.1f} GB. Ready in: {', '.join(ready) or 'no language yet'}.\n"
)
out.append(
"\nThe runtime that runs it (LiteRT-LM, Apache-2.0) is vendored with its own licence and notice under "
"`vendor/litert-lm-<version>-<revision>/`.\n"
)
return "".join(out)
def write_tree(out, files):
if out.exists() and any(out.iterdir()) and not (out / "index.json").exists():
raise BuildError(f"refusing to prune non-empty {out}: it has no index.json from an earlier build")
out.mkdir(parents=True, exist_ok=True)
# Parts and licences first, the catalogue after them, the pointer last: a
# run that stops part way never leaves a pointer to files not there yet.
def order(rel):
if rel == "index.json":
return (3, rel)
if rel == "LICENSES.md":
return (2, rel)
return (1 if re.fullmatch(r"index\.[0-9a-f]{12}\.json", rel) else 0, rel)
for rel in sorted(files, key=order):
spec = files[rel]
dest = out / rel
dest.parent.mkdir(parents=True, exist_ok=True)
if spec[0] == "part":
_, src, offset, size, digest = spec
fresh = dest.exists() and dest.stat().st_size == size and sha256_file(dest) == digest
if not fresh:
tmp = dest.with_name(dest.name + ".tmp")
with open(src, "rb") as f, open(tmp, "wb") as o:
f.seek(offset)
o.write(f.read(size))
os.replace(tmp, dest)
else:
data = spec[1]
if not (dest.exists() and dest.read_bytes() == data):
tmp = dest.with_name(dest.name + ".tmp")
tmp.write_bytes(data)
os.replace(tmp, dest)
os.chmod(dest, 0o644)
os.utime(dest, (EPOCH, EPOCH))
keep = set(files)
for p in sorted(out.rglob("*"), reverse=True):
rel = p.relative_to(out).as_posix()
if p.is_file() and rel not in keep:
p.unlink()
log(f"prune: {rel}")
elif p.is_dir() and not any(p.iterdir()):
p.rmdir()
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--source", action="append", default=[], metavar="ID=FILE",
help="a local copy of a pinned model file")
ap.add_argument("--offline", action="store_true", help="never download")
ap.add_argument("--models", help="comma-separated model ids (default: those a ready language uses)")
ap.add_argument("--out", type=Path, default=OUT_DEFAULT)
ap.add_argument("--no-commit", action="store_true", help="leave data/ai alone (an evaluation build)")
args = ap.parse_args()
try:
langs = languages()
model_ids = (args.models.split(",") if args.models
else sorted({l["model"] for l in langs.values() if l["status"] == "ready"}))
unknown = [m for m in model_ids if m not in MODELS]
if unknown or not model_ids:
raise BuildError(f"models {unknown or model_ids}: choose from {', '.join(MODELS)}")
# A published build has every model a ready language uses; one with
# fewer is an evaluation build, written elsewhere and not committed.
used = sorted({l["model"] for l in langs.values() if l["status"] == "ready"})
left_out = [m for m in used if m not in model_ids]
if left_out and (not args.no_commit or args.out == OUT_DEFAULT):
raise BuildError(f"ready languages use {', '.join(left_out)}: build every model they use, "
"or make an evaluation build with --out DIR --no-commit")
given = {}
for s in args.source:
model_id, sep, file = s.partition("=")
if not sep or model_id not in model_ids:
raise BuildError(f"--source {s!r}: give ID=FILE, with ID one of {', '.join(model_ids)}")
given[model_id] = file
sources = {m: model_source(MODELS[m], given.get(m), args.offline) for m in model_ids}
files, cat_bytes = plan(sources, model_ids)
write_tree(args.out, files)
if not args.no_commit:
COMMITTED.mkdir(parents=True, exist_ok=True)
(COMMITTED / "index.json").write_bytes(cat_bytes)
(COMMITTED / "LICENSES.md").write_bytes(files["LICENSES.md"][1])
except BuildError as e:
print(f"✗ {e}", file=sys.stderr)
return 1
parts = sum(1 for s in files.values() if s[0] == "part")
log(f"✓ {args.out}: {', '.join(model_ids)}, {parts} parts, catalogue {json.loads(files['index.json'][1])['current']}")
return 0
if __name__ == "__main__":
sys.exit(main())