⚡ 2026-08-27 performance remediation
Superseded — 2026-08-27
Its
1..72standing gate is superseded by the 2026-08-27 Cluster 5 record, which moved the standing gate to1..76 failures=0the same day. Preserved as observed.
Scope: WRDP bench, 2026-08-27, Cluster 4 — the implementation of the Cluster 3 performance audit. Citations are against the installed source under wp/wp-content/plugins/quizwizz/. Every figure on this page was measured this session as a quiescent median of three with nothing else touching the database; the “before” column is the Cluster 3 baseline, and where this session’s re-measurement disagreed with it, both numbers are shown. Cluster 1 is the play surface repair page, Cluster 2 is the playable quiz repair page, and the harness contract is the probe harness page.
Results against target
Four code changes, two indexes. Query budgets are the deterministic result; wall-clock is reported as a median of three with its runs.
| # | hot path | before | after code | after code + indexes | target | verdict |
|---|---|---|---|---|---|---|
| 1 | SubjectTaxonomy::admin_tree() | 56.2ms / 48q | 4.9ms / 2q | 5.6ms / 2q | ≤20ms / ≤6q | met |
| 2 | settings page assembly | 218.3ms / 55q | 103.7ms / 3q | 112.4ms / 3q | ≤180ms / ≤13q | met |
| 3 | QuizController::attempt_payload() | 44.5ms / 33q | 12.8ms / 5q | 18.6ms / 5q | ≤20ms / ≤8q | met |
| 4 | AdminStatistics::payload() cold, TTL lapse | 134.1ms / 20q | 2.23ms / 2q | — | ≤5ms / ≤2q | met |
| 5 | AdminStatistics::payload() warm | 0.4ms / 0q | 0.23ms / 0q | — | ≤1ms / 0q | met |
| 6 | catalog CSS on a player surface | 6,077B, 1 request | 917B, 1 request | — | drop 6,077B | met, −5,160B |
| 7 | capacity + coverage builders combined | 60.0ms / 4q | — | 42.8ms / 2q | ≤35ms / 2q | queries met, ms missed by 7.8ms |
| 8 | whole AdminStatistics::build() | 88.4ms / 12q | 92.3ms / 10q | 78.2ms / 10q | ≤65ms / 10q | queries met, ms missed by 13.2ms |
Runs behind the medians: admin_tree 4.3 / 5.6 / 6.5; build() 71.0 / 78.2 / 85.5; attempt_payload 12.5 / 18.6 / 26.9; settings page 111.6 / 112.4 / 132.2; cold payload() 1.57 / 2.23 / 4.18; capacity 40.4 / 42.4 / 43.0 and coverage 0.2 / 0.4 / 0.5.
attempt_payload reads 12.8ms before the indexes and 18.6ms after. That is not an index regression: the two sample sets overlap (12.6 / 12.8 / 22.7 against 12.5 / 18.6 / 26.9) and the path issues no query the new keys serve. It is reported as measured rather than smoothed.
Why two millisecond targets were missed
This is the substantive finding of the cluster, and it is a flaw in the audit’s projection rather than in the implementation.
The audit ranked build_capacity_section() at 39.5ms and build_coverage_section() at 20.5ms and projected that consolidating their four queries into two would land under 35ms. Measured inside build(), total SQL is 63.6ms of an 81.2ms call, and one query owns almost all of it:
ms=81.2 queries=10 logged=10 sql_ms=63.6 php_ms=17.6
40.15ms SELECT s.subject_slug AS slug, i.lang AS lang, COUNT(*) AS usable
FROM wp_qwizz_question_subjects s
INNER JOIN wp_qwizz_question_index i ON i.question_id = s.question_id AND i.is_valid = 1
GROUP BY s.subject_slug, i.lang LIMIT 20040
5.01ms SELECT lang, COUNT(*) AS usable FROM wp_qwizz_question_index WHERE is_valid = 1 GROUP BY lang
6.52ms SELECT post_status, COUNT(*) AS num_posts FROM wp_posts WHERE post_type = 'qw_question' GROUP BY post_status
The subject/language matrix is a full aggregate over a 9,315-row join table against 9,069 valid questions. Its floor is ~37ms post-index, which is itself above the 35ms combined target. No consolidation of scans can reach a target that one irreducible scan already exceeds. The 35ms and 65ms figures were derived by treating two isolated per-builder medians as independently reducible; they are not.
What the change did buy, measured: coverage collapsed from 20.5ms / 1q to 0.4ms / 0q, because pairs are now derived in PHP from the matrix instead of re-running the join. Capacity rose from 39.5ms to 42.4ms as it absorbed the widened query. Net −17.2ms and −2 queries on the pair, and −2 queries on build().
The audit’s revert contingency, decided on measurement
The Cluster 3 plan said to revert the matrix (2.1.1 and 2.1.3 together) if it measured slower than the queries it replaced. Both readings, medians of three:
| query | pre-index | post-index |
|---|---|---|
| the one matrix query (after) | 42.17ms | 36.96ms |
| standalone valid COUNT (before, removed by 2.1.2) | 5.23ms | 2.67ms |
| subject aggregate (before) | 19.94ms | 18.18ms |
| coverage join (before) | 19.66ms | 15.37ms |
| sum of the three replaced | 44.83ms | 36.22ms |
Pre-index — the state the plan ordered this comparison in — the matrix is 2.7ms faster than the three queries it replaced, so the contingency does not trigger. Post-index the matrix is 0.7ms slower than that sum and 3.4ms slower than the two queries it strictly replaces, inside the run-to-run spread of both sets.
The matrix is kept, and the deciding evidence is the builder-level number rather than the query-level proxy: reverting would return coverage’s 20.5ms builder to reclaim at most 3.4ms of SQL. The proxy existed to predict the builder outcome; where they disagree, the builder measurement is the one that describes what a user waits for.
Files changed (14)
All bench-local, releaseEligible: false.
Plugin source, staged under /tmp/qwfix/ and installed with sudo -n install -o www-data -g www-data -m 0644:
includes/SubjectTaxonomy.php— new privateterm_counts()resolves the 24-slug canonical catalog in oneget_terms();pool_count()andpool_breakdown()gained an optional third?array $counts = nullparameter that preserves today’s exact per-slug behaviour when omitted, so no callsite migrated;admin_tree()builds the map once; the dead main-termget_term_by()inpool_breakdown()is gone.includes/Admin/AdminStatistics.php— subject aggregate widened to a(slug, lang)matrix withMAX_LANGS = 40and a truncation guard; the standalone validCOUNT(*)deleted and the total derived from the language aggregate; coverage pairs derived in PHP; stale-while-refresh added on a second store withSTALE_MAX = 1200, anOperationalLock-wrappedqwizz_refresh_admin_statisticsworker, andinvalidate()clearing both stores.includes/Install/StateRegistry.php—qwizz_admin_statistics_staleadded tooperational_option_names(),qwizz_refresh_admin_statisticstoscheduled_hooks(), so uninstall leaks neither.includes/QuestionProjector.php—difficulty()switched fromwp_get_post_terms()toget_the_terms(), the only one of the two that consults the object term cache.includes/Rest/QuizController.php— one_prime_post_caches( $raw_ids, true, true )before the projection loop.assets/css/qwizz-embeds.css— 6,077B → 5,158B; the empty-state rules removed.assets/css/qwizz-empty.css— new, 917B.includes/Bootstrap/ContentProvider.php— registersqwizz-empty;qwizz-embedsnow depends on it.includes/Frontend/Shortcodes/LibraryShortcode.php— newenqueue_empty(); the pre-head scanner split so quiz names pull only the small sheet and catalog names still pull the full one.includes/Frontend/QuizEmbed.php—unavailable()andpassword_required_unavailable()enqueueqwizz-emptyinstead of the catalog.includes/Install/Schema/QuestionIndex.php—KEY is_valid_lang (is_valid,lang).includes/Install/Schema/SettingsBackups.php—KEY created_by_created_backup (created_by,created_at,backup_id).includes/Installer.php—SCHEMA_VERSION1.6.7→1.6.8.QUIZWIZZ_VERSIONuntouched.includes/Admin/DashboardPresenter.php— the falsephpcs:ignorejustification replaced with the measured truth.
scripts/quizwizz-probe.sh also changed. It is host-owned harness, not plugin source, and is deliberately excluded from the patch and both digest sidecars.
Every changed PHP file passed sudo -n docker compose exec wordpress php -l wp-content/plugins/quizwizz/<relpath>.
The stale mirror cannot live in the transient
AdminStatistics::invalidate() deletes the statistics transient and is wired to 16 mutation hooks. A stale-while-refresh mirror kept in that transient would therefore keep answering with pre-mutation numbers — the opposite of what invalidation exists for. The mirror is a separate non-autoloaded option that invalidate() also deletes, following the existing two-tier shape in Ai/Models.php:18-73.
Proven live, all three paths:
- mutation —
invalidate()cleared both stores, and the nextpayload()rebuilt synchronously in 94.3ms / 8q withgenerated_atage 0s. This is the assertion the whole design exists to satisfy. - TTL lapse — after a natural 300s expiry (never a forced delete),
payload()returned in 5.11ms / 1q serving a generation 347s old and scheduled the refresh; steady-state median across three clean runs is 2.23ms / 2q. - ceiling — a mirror aged to 2,000s against
STALE_MAX = 1200forced a synchronous rebuild: 107.9ms / 13q, returned age 0s. - worker —
wp cron event run qwizz_refresh_admin_statisticscompleted in 0.151s, refreshed both stores to age 16s, cleared its own schedule, and added no line tologs/debug.log. The subsequent serve was warm at 0.23ms / 0q.
The option is 48,425 bytes and autoload is off. Autoloading it would put 48KB on every request across the whole site.
Output identity
A faster builder that returns different numbers is a regression, not a result. Three digests were captured before any edit and re-checked afterwards inside a quiescent window:
tree=505bf158f57dc08b1e3378d5e20fbbdb19eadf4a0260e3b42922b307e1078b56
build_stable=27268636ab9dc37f2f9a06d8d24e11842c2ec9081c1bb8ac802bc67b82a95536
payload=7d0bcc1c3c2a37058aeaa2c396b68b5f8b0a69c787866e66b41d85e17f62df40
All three matched at three separate points: after the four code slices, again after the schema migration to 1.6.8, and again after a full ten-question browser play plus cleanup. build_stable deliberately covers only questions, subjects, coverage and threshold: generated_at always moves, quizzes moves when a quiz is created or deleted, and activity moves when a play is recorded — including by the harness itself.
The admin_tree() node shapes are unchanged and remain deliberately non-uniform: 2 main nodes carrying id,nm,pool,pool_own,pool_sub,on,children,kind, which nest 12 child rows carrying only id,nm,pool,on, plus 10 standalone leaves carrying id,nm,pool,on,children,kind — 12 top-level nodes and 24 canonical subjects.
The two indexes
Operator-approved live DDL, applied through the plugin’s own migration path (Installer::maybe_upgrade()), never a hand-written ALTER TABLE. PluginOptions::schema_version() returns 1.6.8; dbDelta added both keys and changed no data.
SHOW INDEX confirms is_valid_lang (is_valid, lang) on wp_qwizz_question_index and created_by_created_backup (created_by, created_at, backup_id) on wp_qwizz_settings_backups.
The backups list query, before and after:
| type | key | rows | Extra | |
|---|---|---|---|---|
| before | ALL | NULL | 33 | Using where; Using filesort |
| after | ref | created_by_created_backup | 10 | Using where |
type ALL, key NULL and Using filesort are all gone, which is exactly what task 6.1.3 required.
Index-only delta on everything else, measured with the code already in place: matrix 42.17 → 36.96ms, coverage join 19.66 → 15.37ms, standalone COUNT 5.23 → 2.67ms, subject aggregate 19.94 → 18.18ms, whole build() 92.3 → 78.2ms (−14.1ms, −15%).
Wave 9 — performance remediation contract
Wave 9 was added to scripts/quizwizz-probe.sh. As of this cluster the harness accepted --wave 1|2|3|4|5|6|7|8|9|all, and the standing gate moved 1..55 → 1..72, assertions=72 failures=0. Wave 9 holds 17 assertions, TAP 56-72. It creates no posts, terms or attempts: every assertion reads existing state, quiz 99 and page 1289. Both figures were superseded later the same day by Cluster 5, which added Wave 10 and moved the gate to 1..76 across waves 1-10 — see the not-found and token-scope record.
Wave 8 needed one change. Its assertion 54 pinned catalog-css=1 on the empty-content play route, which task 5.1 deliberately inverts; it now pins catalog-css=0 empty-css=1. Left alone it would have failed, and the full gate did fail exactly there once, before the reconciliation.
The 17 assertions cover four query budgets, three output digests, node shapes, the read-only guarantee of attempt_payload(), all three stale paths, the non-autoloaded option, worker and teardown registration, the stylesheet split on both surfaces, and the schema version.
Mutation proof
Each subsystem was reverted to its pristine bytes in turn, wave 9 run, and the fixed bytes reinstalled with a sha256 match confirmed. Every restore matched.
| reverted | measured regression | assertions that failed |
|---|---|---|
SubjectTaxonomy.php | admin_tree 2q → 48q | 1 |
AdminStatistics.php | build 10q → 12q, stale serve 0q → 9q, served-from-mirror 0 | 2, 10, 11, 14 |
QuestionProjector.php + QuizController.php | attempt_payload 5q → 33q | 3 |
| the four stylesheet-split files | player surface pulls the catalog sheet again | 15, 16 |
The reverted figures reproduce the Cluster 3 baseline exactly — 48, 12 and 33 queries — which is independent confirmation that the audit measured the same code this cluster changed.
Coverage gaps, published rather than hidden
Three wave 9 assertions cannot be made to fail by reverting a file, and saying so is part of the contract:
- assertion 12, the
STALE_MAXceiling. PristineAdminStatisticsnever serves a stale mirror at all, so “a mirror past the ceiling forces a rebuild” is trivially true of the unfixed code. The assertion guards only a future regression that keeps stale-serving while dropping the ceiling. - assertions 5, 6 and 7, the output digests. Pristine passes them by definition — they are the reference. They are regression guards, not fix-presence guards.
- assertion 17, schema
1.6.8. It asserts database state. Reverting the two schema declaration files does not drop keys that already exist, so a file revert cannot fail it.
This is the same class of gap as the wave 8 CTA belt recorded on the Cluster 2 page.
Browser verification
Cache disabled throughout, https://wrdp.loca.zone.
- settings category tree — all 24 canonical checkboxes render and are visible, with real mixed toggle states (23 on, 1 off) and correct
data-category-idvalues. Pool counts ship in the page’s localizedqwizzAdminData.categoriespayload as 24 non-negative integers totalling 13,644. - dashboard statistics rail —
122 quizzes · 9.069 usable questions · 483 subjects,0 need review,451 undersupplied · 6 empty,2 finished plays. No error, noNaN, no unavailable state. - full play of quiz 99 — all ten questions answered through the real player, reaching the score screen:
Quiz complete: 3 of 10, 30 percent, ten answer-review rows with per-question correct/incorrect marking, Retry and print actions. Priming did not disturb the play flow. - library page 1289 — card grid renders with 14 quizzes found; stylesheets arrive in dependency order
qwizz-tokens,qwizz-empty,qwizz-embeds. - library no-results state — the extracted rules paint fully where the tokens resolve:
dashed 1px, soft background, 14px radius, centred grid,grid-column: 1 / -1, 24px padding. This proves the source-order override survived the split, since the enriched re-declaration must still beat the base rule. - unavailable card on the play route — renders with grid layout, centred copy, 14px radius, 24px padding, 8px gap, 52×52px icon tile, and loads
qwizz-empty.csswith no catalog sheet. Its dashed border and soft background do not paint — see the finding below.
New finding — the unavailable card is unstyled on the play route, and always was
Task 5.1.4 expected a dashed border and a painted icon tile on the play route. They are absent. A matched A/B proves this is pre-existing, not caused by the split.
Reverting all four stylesheet-split files to pristine and reloading the same fixture route returns byte-identical computed values:
| property | pristine | after split |
|---|---|---|
| border | none 0px | none 0px |
| background | rgba(0, 0, 0, 0) | rgba(0, 0, 0, 0) |
| icon background | rgba(0, 0, 0, 0) | rgba(0, 0, 0, 0) |
| icon box-shadow | absent | absent |
--qw-line2 at the element | empty | empty |
| border-radius / padding / display / gap / icon size | 14px / 24px / grid / 8px / 52×52 | identical |
Cause: qwizz-tokens.css scopes its public-surface custom properties to .qwizz-builder, .qwizz-player, .qwizz-editor, .qwizz-catalog, .qwizz-subjects, .qwizz-user, .qwizz-library. QuizEmbed::unavailable() on the play route renders the card without any of those ancestors, so every var( --qw-… ) reference resolves to nothing and each declaration using one is invalid at computed-value time. On the library page the .qwizz-library wrapper is present and the same rules paint correctly.
OPEN, not fixed here: closing it means giving the standalone unavailable card a token-scope ancestor, which is a markup change to a shared empty state on a surface this cluster only made cheaper. It is recorded for the operator rather than folded into performance work.
Corrections to the record
DashboardPresenter.php:320— the justificationbounded to five rows on indexed meta keyswas false and is replaced with the measured truth:LIMIT 5bounds returned rows, not examined ones, andEXPLAINstarts at all 122qw_question_countmeta rows withUsing temporary; Using filesort. Thephpcs:ignoredirective itself stays; the suppression is still required.- the settings memoization page — Cluster 3 reported that two of its figures no longer reproduced. They do reproduce. Re-measured quiescent, medians of three: translated string ×1000 at 2.612114ms against a published 2.67ms,
SettingsStore::get()×1000 at 0.178099ms against 0.16ms, and totalwp-loadat 367.375ms / 4q against 359.9ms. The 2026-08-26 readings of 3.499985ms and 539.700ms were single runs taken while other measurement slices hit the same container. That page now carries a dated re-verification section, and the spot-check is superseded rather than the original measurements. No figure needed retracting. assets/css/qwizz-empty.cssis 917 bytes, not the projected 867. The two rule groups are exactly 867 bytes; the mandated/* QW-DESIGN */comment line adds 50. Byte conservation is exact: 5,158 + 917 + 2 removed blank lines = 6,077, and the new sheet is a byte-exact concatenation of the two removed groups.- The settings category section renders no pool numbers beside the checkboxes. Task 1.1.6 expected them visible; they exist only in the localized payload. The browser-checkable contract is the 24 checkboxes, their ids and their toggle states, plus the payload integers — all verified.
admin_tree()returns 2 main nodes and 10 standalone leaves, not the 12-and-12 split the flattened settings list suggests. The 12-and-12 reading is the settings page’s own flattening of 12 top-level rows plus 12 nested children.
Deliberately not changed
Closed in the audit and untouched here: attempt-write batching (ranks 4 and 13 — milliseconds spread across 11 separate HTTP requests, and buying them costs per-answer durability, resume and instant feedback), build_status_section() and build_activity_section() (ranks 10 and 12, 0.5–1.3ms, and the status change would replace core’s cached wp_count_posts()), the recent_quizzes() query rewrite (rank 8, ~4ms, and every indexed reformulation changes edge-case behaviour), presentation_schema() (rank 15, 0.62ms/call), and a wp_terms(name) index (a core-table change for under 1ms).
The recent_quizzes() comment was still corrected, because a false justification is a defect independent of whether the query is rewritten.
Canonical handoff
Three artifacts sit beside this page in q5vault/audits/qwizz/:
2026-08-27-cluster4-preimage.sha256— the 14 pristine digests captured before any edit2026-08-27-cluster4-postimage.sha256— the 14 installed digests2026-08-27-cluster4-source.patch— sha2560b60aa9f5bf469afd20ba03df77f010582a56e6b916b8fd6f20b63fdb6513e18, +210 / −72 real lines across 14 files. This patch was regenerated on 2026-08-27 during the canonical handoff: as first cut it had been diffed against an empty placeholder for the newassets/css/qwizz-empty.cssinstead of an absent path, and so failed to apply to any tree where that file genuinely does not exist — which is every canonical checkout. The superseded digest was65605c06a4fa31a809d1416ad5d7ebd993a5d242365a5a1369c47fc2da6fb91e; line counts are unchanged and the round-trip still reproduces the postimage byte-for-byte. See the canonical handoff record.
Ordering is digest-enforced rather than asserted in prose: the Cluster 4 preimage digests for the files Cluster 2 also touched equal the Cluster 2 postimage digests, so applying Cluster 4 first would not match. The patch was proved to round-trip: applied to the preimage tree it reproduces the postimage tree byte-for-byte in all 14 files.
releaseEligible: false. Nothing under vendor/, build/, provenance, release ZIPs, QUIZWIZZ_VERSION or db_data/ was modified. SCHEMA_VERSION was bumped, which is the migration mechanism, not a release version. The release owner applies Cluster 1, then Cluster 2, then Cluster 4 through the locked pipeline in BUILDING.md.
Bench state
wp_qwizz_attempts22, identical to the pre-cluster baseline. The mandated ten-question browser play created one row, which was deleted afterwards along with the empty-content fixture quiz; both deletions are recorded here rather than left implicit.MAX(ID)forqw_quiz10989 andqw_question10993, both equal to the task 0 maxima: no row above baseline.logs/debug.log38,544 lines, a delta of exactly zero from the task 0 baseline. The newest entries are WordPress automatic theme updates from 08:00 and 19:49 UTC, unrelated to this cluster.wp_qwizz_settings_backups33 → 34 rows.--wave allwrites 3 rows per run under retention pruning; this is designed gate behaviour, documented on the probe page, not residue.- schema version 1.6.8, both new keys live.
- One transient measurement fault of my own is disclosed: an early builder probe passed a wrong signature to
build_subject_section()and raised aTypeErrorin the CLI. It changed no data, and the finallogs/debug.logline count is unchanged.
Release boundary
releaseEligible: false — bench-local patches installed over the bind mount. The canonical QuizWizz repository needs the same changes through its locked build pipeline; until then the shipped plugin retains the 48-query settings page, the 33-query play start, the synchronous 134ms dashboard rebuild and the 6,077-byte stylesheet on every player surface. The release owner applies source changes through that pipeline. The harness contract this cluster extended is recorded in the probe harness page.