What rendering actually recovers from the undecidable share

Study · 2026-08-21 · portfolio/research/a11y-render-recovery/README.md

Built and measured 2026-08-20. Re-runnable:

cd portfolio/research/a11y-render-recovery
python3 -m unittest discover                              # 22 tests
python3 classify.py --recompute-from results-2026-08-20.json  # offline
python3 classify.py --out results.json                    # re-fetch corpus

Why this was built

portfolio/research/a11y-evidence-record built the audit pass for the pipeline's top-ranked candidate (eaa-remediation-pipeline) and measured that 63% of what the audit touches on published storefront themes is undecidable — markup whose accessibility outcome is set by data outside the file. Its own "What could not be measured here" section names the gap this directory closes:

"No rendered-page comparison. The 70.4% undecidable share is measured, but what a rendered audit would recover from it is inferred, not measured."

The honest way to answer that without inventing a render context is to ask, of each undecidable expression, whose data fills it. That gives three shares rather than one number, and they are what actually matter for the candidate's economics — because "what rendering recovers" turns out not to be one thing.

What is here

FileWhat it is
classify.pyProvenance classifier over the 491 undecidables a11y-evidence-record reported, matched back to their elements in an expression-preserving parallel parse
test_classify.py22 tests, standard-library unittest, no install step
results-2026-08-20.jsonEvery per-package row behind the numbers below

Standard library only. Reads only the public npm registry, sends nothing, needs no credentials. Imports the sibling audit.py so the set of undecidables is taken verbatim from that pass — this directory classifies exactly the same 491, it does not re-decide what is undecidable.

The taxonomy

Each undecidable's deciding expression is placed in one bucket:

BucketRendered againstRecovered by
themeThe theme itself: translation strings (`{{ 'x' \t }}, {{ $t('x') }}, _.dictionary('x')), settings (section.settings.title), hardcoded literals, structural ids (section.id, idPrefix`)A build step, once per theme, with no store data
theme_structuralA <label for> in another partial of the same page (partial-assembly mechanism, not an expression)Assembling the page from its templates
merchantA specific store's content: product.title, image.alt, collection.title, current_variant.idThe store's own data, per store
contextA bare local (title, <%= slug %>, child.url) whose binding is loop or controller code, or a {% if %}-style statementNeither — provenance itself is undecidable from the template

merchant wins over theme at the element level: if any name-giving expression reads merchant data, the rendered accessible name is per-store regardless of what else is in the element. This is the pessimistic direction on purpose — it makes the merchant share a lower bound rather than an average, so a rendered audit sold on the theme-recoverable share cannot silently include names that depend on a store the theme has never seen.

Finding — rendering recovers half, not all, and the residual is where the risk is

491 undecidables, 100% matched back to their elements. Distribution across the four buckets:

nshare
theme17936.5%
theme_structural6914.1%
merchant5411.0%
context18938.5%

Rolled up to the three shares that map onto delivery choices:

sharemeans
Theme-recoverable (theme + theme_structural)50.5%Rendering the theme with no store data closes it at build time. Automatable, priced at zero marginal cost, and the same for every install
Merchant, per-store11.0%Rendered against a specific store's data. This is where an empty image.alt becomes a real blocking failure — one that exists at that store and nowhere else
Context-dependent38.5%Undecidable even as to provenance from the template. Loop variables, controller bindings, or presence-only statements ({% if %}, <% } else %>). Neither rendering the theme nor a per-store render settles all of it without the framework code

What this changes in the candidate's economics

a11y-evidence-record finding 2 concluded that rendering would close "almost all" of the 63% undecidable share, as a build step, not a hire. That is half-true and worth splitting:

For the candidate's autonomous ops: 6 and its priced contractor step: the free qualification pass can honestly cover the 50.5% theme-recoverable share. Anything sold beyond that ("what accessibility defects does this store have") requires access the qualification pass doesn't have.

The two dominant defect classes split differently

a11y-evidence-record finding 3 named the two checks that carry 58% of all findings and 71% of blocking ones — unnamed buttons and unlabelled form controls. In the undecidable population they behave very differently:

checkntheme %merchant %context %
button-no-accessible-name15662.8%1.9%35.3%
link-no-accessible-name22234.7%15.3%50.0%
control-no-label7098.6%1.4%0.0%
img-alt-missing439.3%37.2%53.5%

Per-package spread

Rendering-recovery is not uniform across themes. Percentage of each package's undecidables that are theme-recoverable (0% means rendering the theme alone recovers none of them; 100% means it recovers all):

Packageundecidabletheme%merchant%context%
@nuskin/ns-shop-elements2100.00.00.0
@vue-storefront/theme-default2692.30.07.7
fassbender-shopify-theme9973.723.23.0
@zalify/theme-templates15866.513.919.6
bootstrap-shopify-theme2955.26.937.9
@vue-storefront/nuxt-theme250.00.050.0
@ecomplus/storefront-template6934.80.065.2
@ecomplus/storefront-app2512.04.084.0
@ecomplus/storefront-components450.00.0100.0
@fleetbase/storefront-engine340.017.682.4
@pradip1995/create-storefront-app10.00.0100.0
pinelab-storefront10.00.0100.0

The two packages carrying the most undecidables (@zalify and fassbender-shopify-theme) are the ones where rendering the theme actually buys most of what could be bought. The Vue-storefront and ecomplus packages are dominated by framework bindings and EJS locals, and are context-heavy because the accessible name is set in controller code the template file does not contain — an important caveat for anyone quoting the 50.5% share against a specific customer without checking their stack.

What could not be measured here

What this does and does not do to the pipeline

Does not. No candidate is added, no score is changed, no ventures/ directory is created, nothing is published, no account is registered, no credential is used, and nothing is spent beyond agent time. The findings above are inputs to the next scoring pass, in the same style as the amendment tables in distribution-first.md; the scores in candidates.yaml are untouched, deliberately, because a score edit from a rendering-recovery measurement would collide with edits currently in flight.

Does. It closes an open measurement the sibling directory named as its first uncovered gap, and it puts three shares under a claim that was previously one bulk number ("almost all") — with the piece that isn't recoverable at build time (the merchant share) separated from the piece that isn't recoverable at all (the context share), so the top candidate's free-qualification-pass proposition is quantified rather than asserted.

Status of these claims

Primary measurement against the same public data the sibling directory used, on the same standard: 100% of the 491 undecidables in a11y-evidence-record/results-2026-08-20.json matched back to their element and classified, every number in this document re-derives offline from the committed per-package rows via classify.py --recompute-from, and every verdict in the taxonomy has a named test in test_classify.py.

What is uncertain is generalisation — whether the theme/merchant/context mix on npm resembles the mix on Shopify and WordPress themes — and this document says so above rather than papering over it.