Laya WCAG 2.2 β€” violation classifier (ONNX, browser-ready)

A WCAG 2.2 accessibility-violation classifier for HTML markup, exported to ONNX and packaged for in-browser inference with onnxruntime-web. It powers https://turbolego.github.io/LayaWCAG/.

The model only sees the HTML text β€” no CSS, no rendered layout. Its output is a hint, not a verdict; run a real checker (axe-core, IBM Equal Access) for authoritative results.

Architecture

Encoder answerdotai/ModernBERT-base (421M params), frozen
Head Linear(1024, 63) + a small MLP on the 1.4.3 column only
Input [CLS] + html tokens[:254] + [SEP], input_ids + attention_mask
Output criterion_scores β€” 63 logits (BCEWithLogitsLoss β‡’ apply sigmoid)
Precision int8 encoder + fp32 MLP (~0.5 MB), 395 MB total
Max length 256 tokens

Each output column is one WCAG 2.2 criterion, in the order given by wcag_criteria.json (column 14 = 1.4.3 Contrast (Minimum)).

Training

Labels came from the IBM Equal Access checker run in real Chromium over crawled production pages (axe-labeling/ in the repo) β€” real layout is what makes contrast (1.4.3), focus-visible (2.4.7) and viewport-zoom (1.4.4) judgeable at all. The encoder is frozen; only the classifier head is trained.

v6 (joint head over all 63 criteria) was the first build where any criterion clearly worked β€” pos/neg probability gaps on held-out data: +0.56 (3.3.2), +0.32 (4.1.2), +0.28 (1.3.1), +0.20 (1.1.1), +0.22 (2.4.7).

Contrast fine-tune addressed 1.4.3, which v6 got backwards: good-contrast text scored higher than low-contrast text, so no threshold could separate them. Because a full retrain traded the working criteria against each other, only that one column was retrained:

1.4.3 Contrast before after
AUC (1,615 held-out rows) 0.629 0.933
Best F1 0.450 0.779

Method: precompute the frozen encoder's [CLS] embeddings once, train a Linear(1024,128) β†’ GELU β†’ Dropout β†’ Linear(128,1) head initialized from the deployed linear row, then bake the MLP into the graph as an additive correction masked to column 14. The other 62 criteria are provably untouched β€” verified that only column 14 moves in the ONNX.

Results

  • The site's "Clean semantic snippet" flags 0 of 63 criteria.
  • fp32 (node/ORT) gives 0 flags; onnxruntime-web (wasm + int8) shows 1–2 flags from other criteria (4.1.2, 2.4.4) that sit at 0.43–0.49 and cross 0.5 only from wasm rounding.

Known limits β€” read before trusting output

  • 52 of 63 criteria have no positive training example (media 1.2.x, timing 2.2.1, sensory 1.3.3, reflow 1.4.10). Those columns are noise and should be ignored.
  • Criteria that do work: 3.3.2, 4.1.2, 1.3.1, 1.1.1, 2.4.7, 1.4.3.
  • No CSS or rendered layout is available, so any criterion that depends on visual layout beyond inline colour is inherently weak.
  • Two hand-written micro-snippets (single <p> with a colour) still rank wrong β€” they are outside the training distribution. Real pages separate correctly.

Files

manifest.json              variant manifest: part list, size, sha256, chunk size
laya-wcag.onnx             the graph (307 KB) β€” external-data location: laya-wcag.onnx.data
laya-wcag.0000.onnx.data   weight parts, 15 Γ— 25,165,824 B + 1 Γ— 18,058,240 B
laya-wcag.0015.onnx.data   (395,545,600 B total)
tokenizer.json             ModernBERT tokenizer
tokenizer_config.json      tokenizer config
wcag_criteria.json         the 63 criteria, in classifier-column order

laya-wcag.onnx has no inline weights β€” stitch the 16 .onnx.data parts (in order, per manifest.json) into one laya-wcag.onnx.data next to the graph before loading.

Usage

import glob, json, numpy as np, onnxruntime as ort
from tokenizers import Tokenizer

# stitch the parts
buf = bytearray()
for p in sorted(glob.glob("laya-wcag.00*.onnx.data")):
    buf += open(p, "rb").read()
open("laya-wcag.onnx.data", "wb").write(bytes(buf))

sess = ort.InferenceSession(
    "laya-wcag.onnx",
    sess_options=ort.SessionOptions(),
    providers=["CPUExecutionProvider"],
)
tok = Tokenizer.from_file("tokenizer.json")
criteria = json.load(open("wcag_criteria.json"))

ids = tok.encode("<main><h1>Hi</h1><p>Hello</p></main>", add_special_tokens=False).ids
seq = [tok.token_to_id("[CLS]")] + ids[:254] + [tok.token_to_id("[SEP]")]
a = np.array([seq], dtype=np.int64)
logits = sess.run(None, {"input_ids": a, "attention_mask": np.ones_like(a)})[0]["criterion_scores"][0]
probs = 1 / (1 + np.exp(-logits))

for i, p in enumerate(probs):
    if p > 0.5:
        print(criteria[i]["criterion"], round(float(p), 3))

In the browser, see web/laya-wcag-core.js in turbolego/LayaWCAG β€” it mounts the stitched parts via ORT-web's externalData option, with path matching the graph's location string (laya-wcag.onnx.data, not a part filename).

Downloads last month

-

Downloads are not tracked for this model. How to track
Inference Providers NEW
This model isn't deployed by any Inference Provider. πŸ™‹ Ask for provider support