@hyperfixi/testing-framework 3.3.0 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/CHANGELOG.md +199 -1
  2. package/package.json +9 -10
  3. package/src/agent-bench/README.md +48 -1
  4. package/src/agent-bench/agent-bench.test.ts +7 -0
  5. package/src/agent-bench/harness.ts +39 -50
  6. package/src/multilingual/README.md +30 -11
  7. package/src/multilingual/bare-render-fidelity.ts +1 -1
  8. package/src/multilingual/cli.ts +5 -3
  9. package/src/multilingual/direct-path-shapes-gate.ts +56 -0
  10. package/src/multilingual/direct-path-shapes.1.test.ts +12 -0
  11. package/src/multilingual/direct-path-shapes.2.test.ts +12 -0
  12. package/src/multilingual/direct-path-shapes.3.test.ts +12 -0
  13. package/src/multilingual/direct-path-shapes.cases.json +2621 -0
  14. package/src/multilingual/direct-path-shapes.ts +459 -0
  15. package/src/multilingual/orchestrator.ts +2 -1
  16. package/src/multilingual/render-fidelity.ts +1 -1
  17. package/src/multilingual/shipped-examples-execution.test.ts +32 -96
  18. package/src/multilingual/shipped-examples-execution.ts +31 -121
  19. package/src/multilingual/shipped-sources-engine.test.ts +19 -19
  20. package/src/multilingual/shipped-sources-localized.test.ts +87 -0
  21. package/src/multilingual/shipped-sources-validity.ts +87 -99
  22. package/src/multilingual/validators/execution-validator.test.ts +81 -23
  23. package/src/multilingual/validators/execution-validator.ts +135 -91
  24. package/src/multilingual/validators/parse-validator.ts +7 -20
  25. package/src/multilingual/value-matrix.accepted.test.ts +8 -14
  26. package/src/multilingual/value-matrix.isolation.test.ts +7 -6
  27. package/src/multilingual/value-matrix.ts +32 -84
  28. package/src/multilingual/shipped-sources-validity.test.ts +0 -95
@@ -0,0 +1,459 @@
1
+ /**
2
+ * Direct-path shapes on the text path.
3
+ *
4
+ * Core's `src/multilingual/*-direct-path.test.ts` files each pin a shape core's
5
+ * multilingual DIRECT path ran: an English source rendered into the 23
6
+ * languages, compiled with `{ language }` on core (`parseSemantic → buildAST`),
7
+ * installed in a fixture and triggered. That path retires with core's parser
8
+ * (Phase C6 of the engine cutover); text is the interchange. This gate runs the
9
+ * same cases — extracted once into `direct-path-shapes.cases.json` (287 of the
10
+ * 316; the other 29 assert only on an AST) — the way the product now runs a
11
+ * translation: the foreign text AS WRITTEN on `@hyperfixi/engine`, with
12
+ * `@lokascript/hyperscript-adapter`'s plugin installed and `lang` on <html>.
13
+ *
14
+ * The oracle is upstream `_hyperscript` running the English source. Where
15
+ * upstream rejects the English (a core-only form the reader still accepts),
16
+ * the reference is upstream running the English the adapter wrote, which
17
+ * holds the renderer to upstream's spelling (`tell #m to show` is written
18
+ * `tell #m show end`).
19
+ *
20
+ * A run's signature is the effect-signature diff of the body, with text nodes
21
+ * normalized first (each `append` adds one, and the signature reads leaf text
22
+ * only for an element with a single text child), plus a log of the calls a
23
+ * DOM diff cannot show: `fetch`, `history.back/forward`, `window.open`,
24
+ * `scrollIntoView`, `scrollTo`, and `location.hash`.
25
+ *
26
+ * One jsdom window for the whole run, as in a browser: upstream resolves
27
+ * `document` through the window it saw when it loaded, so a window per run
28
+ * would point its `from document` at a stale one. Node owns a global
29
+ * `EventTarget`, which the globals bootstrap leaves alone; upstream's
30
+ * `wait for` checks `instanceof EventTarget`, so it is pointed at jsdom's for
31
+ * the run.
32
+ *
33
+ * KNOWN lists the cases that fail, each with why. The gate fails on a case
34
+ * that fails and is not listed, and on a listed case that passes: the list
35
+ * only shrinks.
36
+ *
37
+ * Node-only (the node vitest environment; see the shipped-examples gate).
38
+ */
39
+
40
+ import { readFileSync } from 'node:fs';
41
+ import { createRequire } from 'node:module';
42
+ import path from 'node:path';
43
+ import { fileURLToPath, pathToFileURL } from 'node:url';
44
+ import { JSDOM } from 'jsdom';
45
+ import { snapshot, diffSnapshots } from './effect-signature';
46
+ import { installGlobals } from './shipped-examples-execution';
47
+ import { FOREIGN_LANGUAGES } from './value-matrix';
48
+
49
+ export interface DirectPathCase {
50
+ /** `<core test file stem>#<n>`. */
51
+ id: string;
52
+ /** The English source the core test rendered into each language. */
53
+ source: string;
54
+ fixture: string;
55
+ /** The element the handler is installed on (default `button`). */
56
+ installOn: string | null;
57
+ event: string | null;
58
+ /** The languages the core test ran; `[]` = English only. */
59
+ languages: 'all23' | string[];
60
+ }
61
+
62
+ const CASES_PATH = path.resolve(
63
+ path.dirname(fileURLToPath(import.meta.url)),
64
+ 'direct-path-shapes.cases.json'
65
+ );
66
+
67
+ export function loadDirectPathCases(): DirectPathCase[] {
68
+ return JSON.parse(readFileSync(CASES_PATH, 'utf8')) as DirectPathCase[];
69
+ }
70
+
71
+ /**
72
+ * The cases that fail, and why. `core-only`: upstream rejects the English and
73
+ * nothing re-renders it (an English source reaches the engine as written).
74
+ * `renderer`: the render back to English loses the shape. `vacuous`: neither
75
+ * engine does anything, so there is nothing to compare. The nine `core-only`
76
+ * translations and the one `renderer` loss listed when the gate landed (C2b)
77
+ * were closed by English writing upstream's spelling
78
+ * (`semantic/src/explicit/upstream-spelling.ts`, C2c).
79
+ */
80
+ export const KNOWN: Readonly<
81
+ Record<string, { family: 'core-only' | 'renderer' | 'vacuous'; reason: string }>
82
+ > = {
83
+ 'condition-phrases-direct-path#97': {
84
+ family: 'core-only',
85
+ reason: '`#d1 have .x`, English only: an English source reaches the engine as written',
86
+ },
87
+ 'condition-phrases-direct-path#98': {
88
+ family: 'core-only',
89
+ reason: '`#d1 have .y`, English only: an English source reaches the engine as written',
90
+ },
91
+ 'go-direct-path#2': {
92
+ family: 'vacuous',
93
+ reason: '`go forward` calls nothing on either engine (core-only)',
94
+ },
95
+ 'class-query-scope-direct-path#4': {
96
+ family: 'vacuous',
97
+ reason: '`if .item is in #list` changes nothing on upstream; English only',
98
+ },
99
+ };
100
+
101
+ type Step =
102
+ | {
103
+ ev: string;
104
+ init?: Record<string, unknown>;
105
+ /** Dispatch on the document instead of the install target. */
106
+ onDocument?: boolean;
107
+ ctor?: 'CustomEvent' | 'KeyboardEvent' | 'MouseEvent';
108
+ }
109
+ | { settle: number };
110
+
111
+ const settle = (ms: number): Step => ({ settle: ms });
112
+ const click: Step = { ev: 'click', ctor: 'MouseEvent', init: { bubbles: true } };
113
+ const key = (ev: string, init: Record<string, unknown> = {}): Step => ({
114
+ ev,
115
+ ctor: 'KeyboardEvent',
116
+ init: { bubbles: true, ...init },
117
+ });
118
+
119
+ /** How long a plain case settles after its event (ms). */
120
+ const SETTLE_MS = 15;
121
+
122
+ /**
123
+ * Each case's events, as its core test sends them. A case with several
124
+ * variants runs each on a fresh install. Default: the case's event, bubbling,
125
+ * then SETTLE_MS.
126
+ */
127
+ const SCRIPTS: Readonly<Record<string, Step[][]>> = {
128
+ 'custom-or-event-direct-path#1': [
129
+ [{ ev: 'myEvent', ctor: 'CustomEvent', init: { bubbles: true } }, settle(SETTLE_MS)],
130
+ ],
131
+ 'fetch-do-not-throw-direct-path#1': [[click, settle(30)]],
132
+ 'fetch-response-type-direct-path#1': [[click, settle(30)]],
133
+ 'naked-url-interpolation-direct-path#1': [[{ ev: 'input', init: { bubbles: true } }, settle(30)]],
134
+ 'handler-head-direct-path#1': [
135
+ [click, settle(SETTLE_MS)],
136
+ [key('keydown'), settle(SETTLE_MS)],
137
+ ],
138
+ 'handler-head-direct-path#2': [
139
+ [key('keydown', { key: 'Escape' }), settle(SETTLE_MS)],
140
+ [key('keydown', { key: 'a' }), settle(SETTLE_MS)],
141
+ ],
142
+ 'handler-head-direct-path#3': [
143
+ [{ ev: 'click', ctor: 'MouseEvent', init: { clientX: 42, bubbles: true } }, settle(SETTLE_MS)],
144
+ ],
145
+ 'handler-head-direct-path#4': [
146
+ [
147
+ { ev: 'myEvent', ctor: 'CustomEvent', init: { detail: 'D', bubbles: true } },
148
+ settle(SETTLE_MS),
149
+ ],
150
+ ],
151
+ 'handler-head-direct-path#5': [
152
+ [
153
+ { ev: 'mousemove', ctor: 'MouseEvent', init: { clientX: 4, bubbles: true } },
154
+ settle(SETTLE_MS),
155
+ ],
156
+ ],
157
+ 'handler-head-direct-path#6': [[key('keydown', { key: 'q' }), settle(SETTLE_MS)]],
158
+ 'on-first-click-direct-path#1': [[click, settle(SETTLE_MS), click, settle(SETTLE_MS)]],
159
+ 'until-loop-direct-path#1': [[click, settle(120)]],
160
+ 'until-loop-direct-path#2': [[click, settle(120)]],
161
+ 'wait-direct-path#1': [[click, settle(20), key('keyup'), settle(20)]],
162
+ 'wait-direct-path#2': [[click, settle(40)]],
163
+ 'wait-direct-path#3': [
164
+ [
165
+ click,
166
+ settle(20),
167
+ { ev: 'pointermove', ctor: 'MouseEvent', onDocument: true, init: { clientX: 7 } },
168
+ settle(20),
169
+ ],
170
+ ],
171
+ 'wait-direct-path#4': [[click, settle(60)]],
172
+ 'wait-direct-path#5': [
173
+ [
174
+ { ev: 'pointerdown', ctor: 'MouseEvent', init: { clientX: 3 } },
175
+ settle(20),
176
+ { ev: 'pointerdown', ctor: 'MouseEvent', init: { clientX: 5 } },
177
+ settle(20),
178
+ ],
179
+ ],
180
+ 'wait-direct-path#6': [
181
+ [
182
+ click,
183
+ settle(20),
184
+ { ev: 'mousemove', ctor: 'MouseEvent', init: { bubbles: true } },
185
+ settle(20),
186
+ ],
187
+ ],
188
+ };
189
+
190
+ function scriptOf(c: DirectPathCase): Step[][] {
191
+ return (
192
+ SCRIPTS[c.id] ?? [[{ ev: c.event ?? 'click', init: { bubbles: true } }, settle(SETTLE_MS)]]
193
+ );
194
+ }
195
+
196
+ /** What this needs of a host that reads scripts off attributes. */
197
+ interface ScriptHost {
198
+ parse(source: string): { errors?: Array<{ message: string }> } | undefined;
199
+ processNode(element: Element): void;
200
+ evaluate(source: string): unknown;
201
+ }
202
+
203
+ export interface PairResult {
204
+ lang: string;
205
+ /** The translation, as the core test wrote it: `render(parse_en(source), lang)`. */
206
+ written: string | null;
207
+ /** The adapter's English for it. */
208
+ english: string;
209
+ /** The translation on the engine. */
210
+ got: string;
211
+ /** What it must equal: upstream's result for the English (or for `english`). */
212
+ ref: string;
213
+ ok: boolean;
214
+ }
215
+
216
+ export interface DirectPathResult {
217
+ id: string;
218
+ /** Upstream on the English source (the oracle), or why there is none. */
219
+ want: string;
220
+ /** The engine on the English source. */
221
+ eng: string;
222
+ pairs: PairResult[];
223
+ }
224
+
225
+ const EMPTY = /^∅( ‖ ∅)*$/;
226
+
227
+ /** Whether a case fails: no usable oracle, the engine's English differs, or a pair differs. */
228
+ export function caseFails(r: DirectPathResult): boolean {
229
+ if (r.want.startsWith('✗') || EMPTY.test(r.want)) {
230
+ return r.pairs.length === 0 || r.pairs.some(p => !p.ok);
231
+ }
232
+ return r.eng !== r.want || r.pairs.some(p => !p.ok);
233
+ }
234
+
235
+ export interface DirectPathHosts {
236
+ runCase(c: DirectPathCase): Promise<DirectPathResult>;
237
+ /** Restore the console, the rejection listeners and EventTarget. */
238
+ close(): Promise<void>;
239
+ }
240
+
241
+ /**
242
+ * Load upstream and the engine (with the plugin) on one jsdom window. Until
243
+ * `close()` the console is silenced and unhandled rejections are trapped:
244
+ * failing lanes fail by design and both engines report on the console.
245
+ */
246
+ export async function initDirectPathHosts(): Promise<DirectPathHosts> {
247
+ const dom = new JSDOM('<!doctype html><html><head></head><body></body></html>', {
248
+ url: 'http://localhost/',
249
+ });
250
+ installGlobals(dom);
251
+ const g = globalThis as Record<string, unknown>;
252
+ const savedEventTarget = g.EventTarget;
253
+ const window = dom.window;
254
+ g.EventTarget = window.EventTarget;
255
+
256
+ const saved = {
257
+ log: console.log,
258
+ warn: console.warn,
259
+ error: console.error,
260
+ info: console.info,
261
+ };
262
+ const quiet = (): void => {};
263
+ console.log = console.warn = console.error = console.info = quiet;
264
+ const rejectionListeners = process.listeners('unhandledRejection');
265
+ process.removeAllListeners('unhandledRejection');
266
+ process.on('unhandledRejection', quiet);
267
+
268
+ // The calls a DOM diff cannot show, logged per run.
269
+ const calls: string[] = [];
270
+ let fetchStatus = 200;
271
+ const fetchStub = async (url: unknown): Promise<Response> => {
272
+ calls.push(`fetch ${String(url)}`);
273
+ return fetchStatus === 200
274
+ ? new Response('found')
275
+ : new Response('gone', { status: fetchStatus, statusText: 'Not Found' });
276
+ };
277
+ Reflect.set(window, 'fetch', fetchStub);
278
+ g.fetch = fetchStub;
279
+ Object.defineProperty(window.history, 'back', { value: () => calls.push('history.back') });
280
+ Object.defineProperty(window.history, 'forward', {
281
+ value: () => calls.push('history.forward'),
282
+ });
283
+ Reflect.set(window, 'open', (...args: unknown[]) => {
284
+ calls.push(`open ${args.map(String).join(',')}`);
285
+ return null;
286
+ });
287
+ Reflect.set(window, 'scrollTo', (...args: unknown[]) => {
288
+ calls.push(`scrollTo ${JSON.stringify(args)}`);
289
+ });
290
+ window.Element.prototype.scrollIntoView = function (this: Element, options?: unknown) {
291
+ calls.push(
292
+ `scrollIntoView ${this.id || this.tagName.toLowerCase()} ${JSON.stringify(options ?? null)}`
293
+ );
294
+ };
295
+
296
+ const require = createRequire(import.meta.url);
297
+ const esm = require.resolve('hyperscript.org').replace(/[^/\\]+$/, '_hyperscript.esm.js');
298
+ const upstream = (await import(pathToFileURL(esm).href)).default as ScriptHost;
299
+ const engineModule = await import('@hyperfixi/engine');
300
+ engineModule.register(...engineModule.everything);
301
+ const { hyperscriptI18n, preprocess } = await import('@lokascript/hyperscript-adapter');
302
+ engineModule.api.use(hyperscriptI18n());
303
+ const engine: ScriptHost = engineModule.api;
304
+ const { parseSemantic, render } = await import('@lokascript/semantic');
305
+
306
+ const document = window.document;
307
+ let behaviorSerial = 0;
308
+ const letter = (n: number): string => String.fromCharCode(65 + (n % 26));
309
+
310
+ /** One run: a fresh body, the handler installed, the steps sent; the signature. */
311
+ const run = async (
312
+ host: ScriptHost,
313
+ c: DirectPathCase,
314
+ code: string,
315
+ lang: string,
316
+ steps: Step[]
317
+ ): Promise<string> => {
318
+ calls.length = 0;
319
+ fetchStatus = c.id.startsWith('fetch-do-not-throw') ? 404 : 200;
320
+ window.history.replaceState(null, '', '/');
321
+ document.documentElement.removeAttribute('lang');
322
+ try {
323
+ let install = code;
324
+ if (c.id.startsWith('behavior-handlers')) {
325
+ // The source defines a behavior: define it (under a fresh name), then
326
+ // install it in English, as the core test does.
327
+ const name = `Demo${letter(behaviorSerial)}${letter(Math.floor(behaviorSerial / 26))}`;
328
+ behaviorSerial++;
329
+ const definition = code.replace(/Demo[A-Z]+/g, name);
330
+ host.evaluate(lang === 'en' ? definition : preprocess(definition, lang));
331
+ install = `install ${name}(h: 1)`;
332
+ lang = 'en';
333
+ }
334
+ document.body.innerHTML = c.fixture;
335
+ if (c.id === 'variable-targets-direct-path#5') {
336
+ document
337
+ .querySelectorAll('.x')
338
+ .forEach((el: Element) => el.addEventListener('foo', () => el.classList.add('got')));
339
+ }
340
+ if (lang !== 'en') document.documentElement.setAttribute('lang', lang);
341
+ const target = document.querySelector(c.installOn ?? 'button');
342
+ if (!target) return '✗no install target';
343
+ target.setAttribute('_', install);
344
+ document.body
345
+ .querySelectorAll('*')
346
+ .forEach((el: Element, i: number) => el.setAttribute('data-exec-key', String(i)));
347
+ host.processNode(target);
348
+ await new Promise(resolve => setTimeout(resolve, 0));
349
+ document.body.normalize();
350
+ const before = snapshot(document);
351
+ for (const step of steps) {
352
+ if ('settle' in step) {
353
+ await new Promise(resolve => setTimeout(resolve, step.settle));
354
+ continue;
355
+ }
356
+ const Ctor: typeof Event = step.ctor ? window[step.ctor] : window.Event;
357
+ (step.onDocument ? document : target).dispatchEvent(new Ctor(step.ev, step.init ?? {}));
358
+ }
359
+ document.body.normalize();
360
+ const out = diffSnapshots(before, snapshot(document));
361
+ if (window.location.hash) out.push(`hash ${window.location.hash}`);
362
+ out.push(...calls);
363
+ return out.join(' | ') || '∅';
364
+ } catch (e) {
365
+ return `✗threw ${(e as Error).message.split('\n')[0] ?? ''}`;
366
+ }
367
+ };
368
+
369
+ const runAll = async (
370
+ host: ScriptHost,
371
+ c: DirectPathCase,
372
+ code: string,
373
+ lang: string
374
+ ): Promise<string> => {
375
+ const parts: string[] = [];
376
+ for (const steps of scriptOf(c)) parts.push(await run(host, c, code, lang, steps));
377
+ return parts.join(' ‖ ');
378
+ };
379
+ const rejects = (host: ScriptHost, code: string): string | undefined =>
380
+ host.parse(code)?.errors?.[0]?.message.split('\n')[0];
381
+
382
+ return {
383
+ async runCase(c) {
384
+ // A behavior definition is not a handler: it is checked by running it.
385
+ const rejected = c.id.startsWith('behavior-handlers')
386
+ ? undefined
387
+ : rejects(upstream, c.source);
388
+ const want = rejected
389
+ ? `✗upstream rejects: ${rejected}`
390
+ : await runAll(upstream, c, c.source, 'en');
391
+ const eng = await runAll(engine, c, c.source, 'en');
392
+ const languages = c.languages === 'all23' ? [...FOREIGN_LANGUAGES] : c.languages;
393
+ const node = parseSemantic(c.source, 'en').node;
394
+ const pairs: PairResult[] = [];
395
+ for (const lang of languages) {
396
+ let written: string | null = null;
397
+ try {
398
+ written = node ? render(node, lang) : null;
399
+ } catch {
400
+ written = null;
401
+ }
402
+ if (written === null) {
403
+ pairs.push({ lang, written, english: '', got: '✗untranslatable', ref: want, ok: false });
404
+ continue;
405
+ }
406
+ let english: string;
407
+ try {
408
+ english = preprocess(written, lang);
409
+ } catch (e) {
410
+ english = `✗${(e as Error).message}`;
411
+ }
412
+ const got = await runAll(engine, c, written, lang);
413
+ let ref = want;
414
+ if (rejected && !english.startsWith('✗') && !rejects(upstream, english)) {
415
+ ref = await runAll(upstream, c, english, 'en');
416
+ }
417
+ const ok = got === ref && !EMPTY.test(ref) && !ref.startsWith('✗');
418
+ pairs.push({ lang, written, english, got, ref, ok });
419
+ }
420
+ return { id: c.id, want, eng, pairs };
421
+ },
422
+ async close() {
423
+ await new Promise(resolve => setTimeout(resolve, 0));
424
+ process.off('unhandledRejection', quiet);
425
+ for (const listener of rejectionListeners) process.on('unhandledRejection', listener);
426
+ Object.assign(console, saved);
427
+ g.EventTarget = savedEventTarget;
428
+ },
429
+ };
430
+ }
431
+
432
+ /** A failing case, for a report line. */
433
+ export function describeFailure(r: DirectPathResult): string {
434
+ const head = `${r.id}: want ${r.want.slice(0, 120)}`;
435
+ if (!r.want.startsWith('✗') && !EMPTY.test(r.want) && r.eng !== r.want) {
436
+ return `${head}\n engine English: ${r.eng.slice(0, 120)}`;
437
+ }
438
+ const bad = r.pairs.filter(p => !p.ok);
439
+ const first = bad[0];
440
+ return first
441
+ ? `${head}\n ${bad.length} language(s), e.g. ${first.lang}: ${first.written}\n → ${first.english}\n got ${first.got.slice(0, 120)}`
442
+ : head;
443
+ }
444
+
445
+ /**
446
+ * One shard of the gate: every case whose index ≡ `shard` (mod `of`), so the
447
+ * shards run in parallel. Returns its results; the caller asserts.
448
+ */
449
+ export async function runDirectPathShard(shard: number, of: number): Promise<DirectPathResult[]> {
450
+ const cases = loadDirectPathCases().filter((_, i) => i % of === shard);
451
+ const hosts = await initDirectPathHosts();
452
+ try {
453
+ const results: DirectPathResult[] = [];
454
+ for (const c of cases) results.push(await hosts.runCase(c));
455
+ return results;
456
+ } finally {
457
+ await hosts.close();
458
+ }
459
+ }
@@ -398,9 +398,10 @@ export class TestOrchestrator {
398
398
  await validator.initialize();
399
399
 
400
400
  // codeExampleId -> en effect signature (only clean, effectful references).
401
+ // The reference is upstream's: the oracle, not the engine under test.
401
402
  const reference = new Map<string, string[]>();
402
403
  for (const [id, code] of sources.get('en') ?? []) {
403
- const res = await validator.execute(id, code, 'en');
404
+ const res = await validator.execute(id, code, 'en', 'upstream');
404
405
  if (!res.error && res.effects.length > 0) reference.set(id, res.effects);
405
406
  }
406
407
  if (reference.size === 0) return;
@@ -8,7 +8,7 @@
8
8
  * writes — it never calls `render(node, L)` at all. `canonical-validity` is
9
9
  * en→en and `foreign-canonical-validity` is foreign→en, both of which pass.
10
10
  * So `render(parse(en), L)` — the function behind MCP `translate_code`,
11
- * `hyperfixi.translate`, `getAllTranslations`, core's `MultilingualHyperscript`
11
+ * `hyperfixi.translate`, `getAllTranslations`, core's `multilingual` entry
12
12
  * and the VS Code "Show in my language" badge — was measured by nothing.
13
13
  *
14
14
  * When it was finally measured (2026-08-26) it was 73.3% structurally clean