vector-mirror 1.0.0

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.
@@ -0,0 +1,1983 @@
1
+ /**
2
+ * pipeline.js - Orchestrator for Vector Mirror v2.0
3
+ * Migrated from index.js (v1.6) + parseConstraints from mirror.js
4
+ *
5
+ * Connects: Renderer -> Grid -> Constraints -> Diff -> Arbitrate -> Emitter
6
+ * Phase 2: analyze/compare return { prose, structured }, new: inspect, palette, meta
7
+ * DEPENDS: adapters/renderer/playwright, core/grid, core/constraints, core/diff,
8
+ * core/arbitrate, adapters/emitter/prose, adapters/emitter/structured
9
+ */
10
+ import { randomUUID } from 'node:crypto';
11
+ import { readFileSync } from 'node:fs';
12
+ import { dirname, join } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { Mutex } from 'async-mutex';
15
+ import {
16
+ formatArrangeReport,
17
+ formatErrorWithLoss,
18
+ formatReport,
19
+ } from './adapters/emitter/prose.js';
20
+ import {
21
+ formatInspectStructured,
22
+ formatPaletteStructured,
23
+ formatStructured,
24
+ } from './adapters/emitter/structured.js';
25
+ import {
26
+ __probeFrozenGeometryAt,
27
+ closeResolver,
28
+ createResolver,
29
+ invalidateLaunches,
30
+ measureViewportDivergence,
31
+ resolve,
32
+ setDeadSignal,
33
+ } from './adapters/renderer/playwright.js';
34
+ import { arbitrate } from './core/arbitrate.js';
35
+ import {
36
+ arrangeConstraint,
37
+ checkConstraint,
38
+ isRegistered,
39
+ listConstraints,
40
+ requiresReference,
41
+ } from './core/constraints/registry.js';
42
+ import {
43
+ bboxTrustedForVerdict,
44
+ classifyCanvas,
45
+ gateCorrections,
46
+ } from './core/honesty.js';
47
+ import './core/constraints/loader.js'; // Side-effect: registers all constraints
48
+ import { computeDiff } from './core/diff.js';
49
+ import { mapToGridMap } from './core/grid.js';
50
+ import {
51
+ createBreaker,
52
+ createRenderOnce,
53
+ getBreakerStats,
54
+ isOurError,
55
+ livenessPing,
56
+ } from './lib/breaker.js';
57
+ import { buildTransform, hasTranslateTransform } from './lib/transforms.js';
58
+
59
+ let page = null;
60
+ let renderBreaker = null;
61
+
62
+ // §1.7 ADR-3: init()-Serialisierung. Zwei konkurrente Erst-Requests (beide
63
+ // sehen `!page`) wuerden sonst je ein createResolver feuern und `page`
64
+ // gegenseitig clobbern (einer evtl. mit null/superseded). `initPromise` teilt
65
+ // EINEN in-flight init ueber alle konkurrenten Aufrufer → genau ein Launch,
66
+ // kein Clobber, kein Doppel-Browser. Nach Settle wird es genullt, sodass ein
67
+ // Re-Init nach shutdown frisch laufen kann (dominierende Loesung statt
68
+ // verstreuter Assignment-Guards).
69
+ let initPromise = null;
70
+
71
+ // §1.7 Breaker-Recovery State (browser-gebunden).
72
+ // restartPromise (P1, LOAD-BEARING): on('halfOpen') startet den Browser-Restart
73
+ // und legt ihn HIER ab; fireResolve awaitet ihn (gegated, bounded) VOR dem
74
+ // Render. Opossum 9 awaitet den halfOpen-Handler NICHT — ohne dieses Gate
75
+ // feuert die Probe gegen einen noch nicht fertig gestarteten Browser und
76
+ // reopent (an internal spec p4). Bounded via Promise.race-Timeout → ehrlicher
77
+ // Fehler statt Hang (Blind-Trust, REGEL-8).
78
+ let restartPromise = null;
79
+
80
+ // consecutiveReopens (P3): Reopen-Loop-Schutz. on('open')++ , on('close')=0.
81
+ // Bei >2 aufeinanderfolgenden Reopens wird breaker.options.resetTimeout
82
+ // exponentiell verdoppelt (Cap 300000ms = 5min). Empirisch (an internal spec
83
+ // p3-probe): Opossum 9.0.0 liest options.resetTimeout im _startTimer FRISCH
84
+ // → Laufzeit-Mutation greift fuer den NAECHSTEN open-Zyklus. Daher reicht die
85
+ // dynamische Mutation; KEINE Breaker-Rekonstruktion / kein externer Timer noetig.
86
+ let consecutiveReopens = 0;
87
+
88
+ // §1.7 Restart-Timeout-Bound (Blind-Trust): chromium.launch hat KEIN
89
+ // Default-Timeout-Reject. Laeuft der Restart in diesen Bound, rejected das Gate
90
+ // → fireResolve liefert einen ehrlichen LOAD_FAILED-Pfad statt zu haengen.
91
+ const RESTART_TIMEOUT_MS = 15000;
92
+
93
+ // §1.7 P5 Komposition (Bulkhead + Queue): capacity:1 macht den Opossum-
94
+ // Semaphore reject-on-full (circuit.js nutzt semaphore.test(), NICHT die
95
+ // queuing-take()) — der 2. KONKURRENTE fire wuerde sonst hart mit ESEMLOCKED
96
+ // abgewiesen. Der Singleton-Browser braucht aber Serialisierung, nicht
97
+ // Rejection legitimer Aufrufer: dieser Pipeline-Mutex QUEUET konkurrente
98
+ // fireResolve-Aufrufe, sodass der Breaker IMMER genau einen fire zur Zeit
99
+ // sieht. Effekt: capacity:1 bleibt als Defense-in-Depth (P5 erfuellt) aktiv,
100
+ // trippt aber unter normaler Last nicht; parallele analyze()-Aufrufe laufen
101
+ // korrekt serialisiert durch (kein Cross-Page-Wettlauf, alle liefern Output).
102
+ // Aequivalent/komplementaer zum pageMutex im Adapter (der setContent+evaluate
103
+ // serialisiert) — hier eine Ebene hoeher, VOR dem Breaker-Gate.
104
+ const fireMutex = new Mutex();
105
+
106
+ // §1.7 Backoff-Basis + Cap. Test-only via __setBreakerOpts ueberschreibbar.
107
+ const BACKOFF_BASE_MS = 30000;
108
+ const BACKOFF_CAP_MS = 300000; // 5 min
109
+
110
+ // §1.7 Test-only: Breaker-Optionen fuer schnelle State-Transitions im
111
+ // Integrations-/Recovery-Test (analog __setRecycleAfter/__setMaxGrids). Wird
112
+ // VOR init() gesetzt; init() reicht sie an createBreaker durch.
113
+ let breakerOptsOverride = null;
114
+ export function __setBreakerOpts(opts) {
115
+ breakerOptsOverride = opts && typeof opts === 'object' ? opts : null;
116
+ }
117
+ /** Test-only: aktueller resetTimeout + consecutiveReopens (Backoff-Verifikation). */
118
+ export function __getRecoveryState() {
119
+ return {
120
+ consecutiveReopens,
121
+ resetTimeout: renderBreaker ? renderBreaker.options.resetTimeout : null,
122
+ restartPending: restartPromise !== null,
123
+ };
124
+ }
125
+
126
+ // §1.1 Stateless RPC: Map<analysisId, gridMap> haelt alle Snapshots. Kein
127
+ // impliziter „letzter" Modul-State — compare benoetigt explizite analysisId.
128
+ // Insertion-order LRU: Map.keys().next().value = oldest entry; eviction bei size >= maxGrids.
129
+ const grids = new Map();
130
+
131
+ /** Plan §1.3 Schicht 2: Bound die Anzahl gespeicherter Grid-States.
132
+ * Konsistenz mit RECYCLE_AFTER aus playwright.js. */
133
+ export const MAX_GRIDS = 20;
134
+ let maxGrids = MAX_GRIDS;
135
+
136
+ /** Test-only API: lower the cap to make Eviction-Logik isoliert prüfbar
137
+ * (analog __setRecycleAfter in renderer/playwright.js). */
138
+ export function __setMaxGrids(n) {
139
+ maxGrids = n;
140
+ }
141
+
142
+ // §1.4 Globale Bookmarks (B-3, O1): benannte langlebige Snapshots für Sniper-Loop.
143
+ // Eigener Namespace, getrennt von grids (UUID-keyed, kurzlebig). Speichert
144
+ // {gridMap, analysisId} damit compare-Output die UUID-Server-Garantie (§1.3) hält.
145
+ const bookmarks = new Map();
146
+ export const MAX_BOOKMARKS = 10;
147
+ let maxBookmarks = MAX_BOOKMARKS;
148
+ export function __setMaxBookmarks(n) {
149
+ maxBookmarks = n;
150
+ }
151
+
152
+ // §1.9 Eichkörper-Selftest: letzter Kalibrierungs-Stand (Modul-State). Wird von
153
+ // runSelftest() gesetzt und von getStatus() gelesen (status.calibration). Bleibt
154
+ // null bis der erste Selftest läuft; der Server-Start-Auto-Selftest (fire-and-
155
+ // forget nach connect) setzt 'PENDING' VOR dem Lauf, dann PASS/FAIL danach.
156
+ // REGEL-3: rein deskriptiver Mess-Stand, kein LLM-Content.
157
+ let lastCalibration = null;
158
+
159
+ /**
160
+ * Initializes the persistent browser instance and circuit-breaker.
161
+ *
162
+ * P1-03 (VM-SM-002): Nach createResolver() wird ein Liveness-Ping ausgefuehrt
163
+ * (Crash-Detection vor erstem Render) und der Breaker um createRenderOnce(resolve)
164
+ * verkabelt. Die DI-Verkabelung haelt den Hexagonal-Vertrag von lib/breaker.js
165
+ * (keine adapters/-Imports dort).
166
+ */
167
+ export async function init() {
168
+ // §1.7 ADR-3: konkurrente init() teilen EINEN in-flight Launch (kein Race auf
169
+ // `page`). Laeuft bereits ein init, awaiten alle Aufrufer dasselbe Promise.
170
+ if (page && renderBreaker) return;
171
+ if (initPromise) return initPromise;
172
+
173
+ // thisInit (reference-guard, analog halfOpen myRestart): nur DIESER init darf
174
+ // page/renderBreaker setzen. Ein konkurrentes shutdown nullt initPromise →
175
+ // `initPromise === thisInit` ist dann false → die Zuweisungen unterbleiben,
176
+ // KEINE Pipeline-State-Resurrection nach shutdown.
177
+ const thisInit = (async () => {
178
+ if (!page) {
179
+ let p;
180
+ try {
181
+ p = await createResolver();
182
+ } catch (err) {
183
+ throw new Error(`Browser-Start fehlgeschlagen: ${err.message}`);
184
+ }
185
+ // §1.7 ADR-3: createResolver kann `null` liefern (adapter-seitig
186
+ // superseded — z.B. konkurrentes shutdown bumpte den Epoch). Dann ist KEIN
187
+ // Browser gestartet → ehrlicher Fehler statt livenessPing(null).
188
+ if (!p) {
189
+ throw new Error('Browser-Start superseded (kein Renderer verfuegbar)');
190
+ }
191
+ // nur setzen wenn DIESER init noch aktuell ist (kein shutdown dazwischen).
192
+ if (initPromise !== thisInit) {
193
+ // superseded durch shutdown: die frische Page gehoert einem Browser, den
194
+ // closeResolver/invalidateLaunches bereits invalidiert hat → nicht
195
+ // installieren (Adapter-Epoch raeumt den Browser; hier kein State-Set).
196
+ return;
197
+ }
198
+ try {
199
+ await livenessPing(p);
200
+ } catch (err) {
201
+ throw new Error(`Browser-Start fehlgeschlagen: ${err.message}`);
202
+ }
203
+ if (initPromise !== thisInit) return;
204
+ page = p;
205
+ }
206
+ if (!renderBreaker && initPromise === thisInit) {
207
+ renderBreaker = createBreaker(
208
+ createRenderOnce(resolve),
209
+ breakerOptsOverride || {},
210
+ );
211
+ wireRecovery(renderBreaker);
212
+ }
213
+ })();
214
+ initPromise = thisInit;
215
+
216
+ try {
217
+ await thisInit;
218
+ } finally {
219
+ // nur den eigenen Eintrag aufraeumen (ein konkurrentes shutdown koennte
220
+ // initPromise bereits genullt/ersetzt haben).
221
+ if (initPromise === thisInit) initPromise = null;
222
+ }
223
+ }
224
+
225
+ /**
226
+ * §1.7 Breaker-Recovery-Verkabelung (einmalig pro Breaker-Instanz, in init()
227
+ * nach createBreaker). Komponiert die kohaerente Kette aus an internal spec §3:
228
+ *
229
+ * on('open') → CLEANUP: closeResolver() (schliesst Browser+Page, nullt
230
+ * Vars). Plus consecutiveReopens++ und (bei >2) exponentieller
231
+ * resetTimeout-Backoff (P3, Opossum-options-Mutation).
232
+ * on('halfOpen') → RESTART: restartPromise = createResolver() (idempotent,
233
+ * F-SVG-033) + livenessPing. KEIN await im Handler noetig
234
+ * (Opossum awaitet ihn nicht); das Promise ist der Sync-Punkt.
235
+ * on('close') → consecutiveReopens = 0 (Reset bei erfolgreicher Recovery).
236
+ *
237
+ * fireResolve() awaitet restartPromise (bounded) VOR renderBreaker.fire → die
238
+ * erste echte fire nach halfOpen IST die Probe, gegated bis der Browser steht.
239
+ *
240
+ * P4-DI: setDeadSignal verkabelt die adapter-seitigen crash/disconnected-
241
+ * Listener mit einem aktiven Failure-Pfad — OHNE dass lib/breaker.js oder die
242
+ * Pipeline einen Playwright-Listener direkt halten (Hexagonal). Das Dead-Signal
243
+ * markiert den Browser bereits adapter-intern als tot (resolve→LOAD_FAILED);
244
+ * dieser Callback dient der Observability/Reaktivitaet (kein Breaker-Import).
245
+ */
246
+ function wireRecovery(breaker) {
247
+ // Backoff-Basis = der konfigurierte resetTimeout des Breakers (haelt das
248
+ // Test-Override konsistent), gedeckelt durch BACKOFF_BASE_MS-Default.
249
+ const baseResetTimeout = breaker.options.resetTimeout || BACKOFF_BASE_MS;
250
+
251
+ breaker.on('open', () => {
252
+ consecutiveReopens++;
253
+ // CLEANUP: toten Browser schliessen (primaere Schliessung). closeResolver
254
+ // ist idempotent + wirft nicht (on('open') darf nie eine Exception werfen).
255
+ // Fire-and-forget: der Listener ist synchron; das await im Promise laeuft
256
+ // im Hintergrund, die nachfolgende createResolver-Idempotenz ist der
257
+ // Sicherheitsgurt falls dieser cleanup noch nicht fertig ist.
258
+ closeResolver().catch(() => {});
259
+ page = null;
260
+
261
+ // P3 Backoff: ab dem 3. Reopen resetTimeout exponentiell verdoppeln (Cap
262
+ // 5min). Opossum liest options.resetTimeout frisch beim naechsten Timer.
263
+ if (consecutiveReopens > 2) {
264
+ const exp = consecutiveReopens - 2; // 1,2,3,...
265
+ const next = Math.min(baseResetTimeout * 2 ** exp, BACKOFF_CAP_MS);
266
+ breaker.options.resetTimeout = next;
267
+ }
268
+ });
269
+
270
+ breaker.on('halfOpen', () => {
271
+ // RESTART: frischen Browser starten und in restartPromise legen. KEIN await
272
+ // hier (Opossum awaitet den Handler nicht); fireResolve gated darauf.
273
+ //
274
+ // §1.7 EPOCH-MODELL (Pipeline-Seite): die Zuweisung `page = p` und das
275
+ // `restartPromise = null` im finally sind reference-guarded gegen `myRestart`
276
+ // — nur der AKTUELLE Restart darf `page` setzen oder den restartPromise
277
+ // loeschen. So kann ein spaet fertig werdendes (superseded) L1 weder `page`
278
+ // ueberschreiben noch einen neueren restartPromise (L2) abraeumen. Liefert
279
+ // createResolver `null` (adapter-seitig superseded), wird `page` NICHT
280
+ // angefasst (und livenessPing NICHT auf null gerufen).
281
+ const myRestart = (async () => {
282
+ const p = await createResolver(); // idempotent (F-SVG-033) | null=superseded
283
+ if (p && restartPromise === myRestart) {
284
+ page = p;
285
+ await livenessPing(page);
286
+ }
287
+ })().finally(() => {
288
+ if (restartPromise === myRestart) restartPromise = null;
289
+ });
290
+ restartPromise = myRestart;
291
+ // unhandled-rejection vermeiden: das Gate in fireResolve faengt den Fehler;
292
+ // hier zusaetzlich ein no-op-catch, falls KEIN fire die Probe konsumiert.
293
+ myRestart.catch(() => {});
294
+ });
295
+
296
+ breaker.on('close', () => {
297
+ consecutiveReopens = 0;
298
+ breaker.options.resetTimeout = baseResetTimeout; // Backoff zuruecksetzen
299
+ });
300
+
301
+ // P4-DI: aktives Crash/Disconnect-Signal. Reiner Observability-Hook — der
302
+ // Adapter markiert den Browser bereits selbst tot (browserDead → LOAD_FAILED);
303
+ // der Breaker zaehlt das ueber den naechsten fire. Kein direkter Breaker-Call
304
+ // noetig (haelt die Hexagonal-Boundary; aktives breaker.open() waere ein
305
+ // Cross-Layer-Vertragsbruch-Risiko).
306
+ setDeadSignal(() => {
307
+ // Bewusst minimal: das Dead-Flag im Adapter ist die Wahrheit. Ein hartes
308
+ // breaker.open() hier wuerde den volumeThreshold-Pfad umgehen und ist
309
+ // nicht noetig — der naechste fire liefert LOAD_FAILED und zaehlt.
310
+ });
311
+ }
312
+
313
+ /**
314
+ * Shuts down the browser instance and seals the circuit-breaker.
315
+ * Nach shutdown() rejected `breaker.fire()` mit ESHUTDOWN — das verhindert
316
+ * Renders gegen eine geschlossene Page.
317
+ */
318
+ export async function shutdown() {
319
+ if (renderBreaker) {
320
+ renderBreaker.shutdown();
321
+ renderBreaker = null;
322
+ }
323
+ // §1.7: P4-DI-Callback abkoppeln, damit ein spaeter feuerndes disconnected-
324
+ // Event (durch das close unten) nicht in einen toten Pipeline-State signalt.
325
+ setDeadSignal(null);
326
+ // §1.7 EPOCH-MODELL: alle in-flight createResolver-Launches invalidieren, damit
327
+ // ein nach shutdown fertig werdender Launch KEINEN Browser/Page wiederherstellt
328
+ // (er sieht epoch !== launchEpoch → schliesst sich selbst, kein State-Eingriff).
329
+ // closeResolver() bumpt den Epoch ebenfalls; invalidateLaunches() VOR dem
330
+ // restartPromise-Detach macht die Reihenfolge explizit und deckt den Fall ab,
331
+ // dass kein closeResolver-Pfad mehr laeuft.
332
+ invalidateLaunches();
333
+ // §1.7 ADR-3: einen in-flight init invalidieren — `initPromise = null` macht
334
+ // dessen `initPromise === thisInit`-Guard false → er installiert weder page
335
+ // noch renderBreaker nach diesem shutdown (keine Pipeline-Resurrection). Der
336
+ // Adapter-Epoch (invalidateLaunches) raeumt den dabei gestarteten Browser.
337
+ initPromise = null;
338
+ // §1.7: laufenden Restart abwarten/verwerfen — ein noch nicht resolvtes
339
+ // restartPromise darf keine Page gegen den gerade geschlossenen Browser
340
+ // halten. closeResolver ist idempotent; restartPromise wird verworfen.
341
+ restartPromise = null;
342
+ consecutiveReopens = 0;
343
+ await closeResolver();
344
+ page = null;
345
+ grids.clear();
346
+ bookmarks.clear();
347
+ }
348
+
349
+ /**
350
+ * Internes Helper: ruft den Breaker auf und mappt sowohl
351
+ * Breaker-eigene Fehler (EOPENBREAKER/ETIMEDOUT/ESHUTDOWN/ESEMLOCKED) als auch
352
+ * geworfene Browser-Fehler in die `{ error, message }`-Form, die alle Aufrufer
353
+ * bereits mit `if (resolved.error) return ...` behandeln.
354
+ *
355
+ * USER-Fehler (INVALID_INPUT, SVG_TOO_LARGE, ...) werden von `renderOnce`
356
+ * NICHT geworfen, sondern als regulaerer Return durchgereicht — sie kommen
357
+ * unveraendert hier zurueck (siehe breaker.js Issue #564 Pitfall).
358
+ */
359
+ async function fireResolve(svgString) {
360
+ // §1.7 P5: konkurrente fireResolve-Aufrufe SERIALISIEREN (fireMutex queuet),
361
+ // damit der capacity:1-Breaker immer genau einen fire sieht und legitime
362
+ // parallele Aufrufer NICHT mit ESEMLOCKED abgewiesen werden (opossum
363
+ // semaphore.test() ist reject-on-full, kein queue). Der Probe-Gate +
364
+ // Breaker-fire laufen atomar pro Aufruf innerhalb des Locks.
365
+ return fireMutex.runExclusive(async () => {
366
+ // §1.7 P1 Probe-Gate (LOAD-BEARING): laeuft gerade ein halfOpen-Browser-
367
+ // Restart, MUSS er fertig sein, BEVOR die Probe-fire rendert — sonst probt
368
+ // Opossum gegen einen halb gestarteten Browser und reopent (an internal spec p4).
369
+ // Bounded via Promise.race-Timeout: ein haengender chromium.launch fuehrt
370
+ // zu einem EHRLICHEN Fehler (LOAD_FAILED), NICHT zu einem Hang (Blind-
371
+ // Trust, REGEL-8). Bei Restart-Fehler bleibt der Browser tot → fire liefert
372
+ // LOAD_FAILED → Breaker bleibt/wird open → EOPENBREAKER (ehrlich).
373
+ if (restartPromise) {
374
+ const pending = restartPromise;
375
+ let timer;
376
+ const bound = new Promise((_, rej) => {
377
+ timer = setTimeout(() => {
378
+ const e = new Error('Browser-Restart Timeout');
379
+ e.code = 'LOAD_FAILED';
380
+ rej(e);
381
+ }, RESTART_TIMEOUT_MS);
382
+ });
383
+ try {
384
+ await Promise.race([pending, bound]);
385
+ } catch {
386
+ // §1.7 Invariante 1 (HIGH): ein Restart, der den Bound ueberschreitet,
387
+ // darf die Recovery NICHT stallen. Das haengende restartPromise wird
388
+ // ABGEKOPPELT (nur falls es noch DAS hier gewartete ist — ein
389
+ // zwischenzeitlich neu gesetztes nicht ueberschreiben), sodass das Gate
390
+ // kuenftige fires nicht weiter blockiert. KEIN early return — wir fallen
391
+ // zu renderBreaker.fire DURCH: die fire trifft eine noch-nicht-fertige
392
+ // (null/stale) page → renderOnce wirft NO_PAGE/LOAD_FAILED (kind=BROWSER)
393
+ // → der Breaker ZAEHLT die fehlgeschlagene Probe → reopen → on('open')
394
+ // rueckt consecutiveReopens/Backoff vor und startet einen frischen
395
+ // Restart. Ein spaeterer erfolgreicher Restart recovert dann ehrlich.
396
+ // Das haengende `pending` laeuft im Hintergrund weiter aus (sein
397
+ // .finally nullt restartPromise; createResolver-Idempotenz raeumt einen
398
+ // evtl. doch noch startenden Browser beim naechsten Restart ab).
399
+ if (restartPromise === pending) restartPromise = null;
400
+ pending.catch(() => {}); // kein unhandled-rejection auf das abgekoppelte
401
+ } finally {
402
+ clearTimeout(timer);
403
+ }
404
+ }
405
+
406
+ try {
407
+ return await renderBreaker.fire(page, svgString);
408
+ } catch (err) {
409
+ if (isOurError(err)) {
410
+ const code = err.code || 'BREAKER_ERROR';
411
+ const message =
412
+ code === 'EOPENBREAKER'
413
+ ? 'Browser-Renderer temporaer nicht verfuegbar (Circuit open)'
414
+ : code === 'ETIMEDOUT'
415
+ ? `Render-Timeout ueberschritten: ${err.message}`
416
+ : code === 'ESHUTDOWN'
417
+ ? 'Renderer ist abgeschaltet'
418
+ : code === 'ESEMLOCKED'
419
+ ? 'Concurrency-Limit erreicht'
420
+ : err.message;
421
+ return { error: code, message };
422
+ }
423
+ // Browser-Fehler aus renderOnce (kind === 'BROWSER')
424
+ return { error: err.code || 'LOAD_FAILED', message: err.message };
425
+ }
426
+ });
427
+ }
428
+
429
+ /**
430
+ * Parses constraint strings into structured objects.
431
+ * Migrated from mirror.js:6-22 (ADR C-5: lives in pipeline, not registry)
432
+ *
433
+ * §H10 R11-21 PARSE-VERWEIGERUNG (O1): ein Constraint-String, der nicht
434
+ * VOLLSTAENDIG konsumiert gegen die Grammatik `#subjekt TYP [#referenz] [wert]`
435
+ * parst, wird nicht interpretiert, sondern sichtbar verweigert — er liefert
436
+ * einen CONSTRAINT_UNPARSEABLE-Marker (raw + problem) statt eines fabrizierten
437
+ * Pseudo-Constraints. Vier frühere Stille-Mechanismen fallen damit:
438
+ * 1. kein stiller Total-Drop mehr (1:1-Bilanz Input-Strings ↔ Eintraege),
439
+ * 2. kein „Alles-ist-ein-Typ" mehr (TYP-Token = GROSSBUCHSTABEN/Bindestrich;
440
+ * Garbage-Prosa wie 'garbage no hashes' wird nicht uminterpretiert —
441
+ * unbekannte, aber typ-foermige Tokens wie CENTRD-IN parsen weiter und
442
+ * behalten den CONSTRAINT_TYPE_UNKNOWN+Vorschlag-Pfad),
443
+ * 3. keine verschluckten Rest-Tokens mehr ('… 3 extra'),
444
+ * 4. keine Wert-Fabrikation mehr (nicht-numerischer DISTANCE-Wert →
445
+ * Verweigerung; fehlender Wert behaelt den Grammatik-Default 1).
446
+ * Grammatikkonforme Strings parsen byte-identisch zur bisherigen Form.
447
+ */
448
+ const CONSTRAINT_GRAMMAR_HINT = "Grammatik: '#subjekt TYP [#referenz] [wert]'";
449
+ // Echo-Hygiene (§H9-Vorbild prose.js#sanitizeValueEcho): der Roh-String ist
450
+ // Fremdtext — Whitespace ist durch split bereits kollabiert, Anführungszeichen
451
+ // neutralisieren, Länge kappen. Ein gekürztes Echo ist ehrlich markiert (…).
452
+ function constraintEcho(parts) {
453
+ const flat = parts.join(' ').replace(/"/g, "'");
454
+ return flat.length > 80 ? `${flat.slice(0, 80)}…` : flat;
455
+ }
456
+
457
+ export function parseConstraints(strings) {
458
+ if (!Array.isArray(strings)) return [];
459
+ return strings.map((str) => {
460
+ const parts = String(str).trim().split(/\s+/);
461
+ if (parts[0]?.toUpperCase() === 'CONSTRAINT') parts.shift();
462
+ const refuse = (problem) => ({
463
+ type: 'CONSTRAINT_UNPARSEABLE',
464
+ raw: constraintEcho(parts),
465
+ problem,
466
+ });
467
+ const cleanId = (s) => s?.replace(/^#/, '') || null;
468
+ const subject = cleanId(parts[0]);
469
+ if (!subject) return refuse('kein Subjekt-Token');
470
+ const type = parts[1];
471
+ if (type === undefined) return refuse('kein Typ-Token');
472
+ if (!/^[A-Z][A-Z-]*$/.test(type))
473
+ return refuse(
474
+ `'${parts[1]}' ist kein Typ-Token (GROSSBUCHSTABEN/Bindestrich)`,
475
+ );
476
+ // Totale Konsumption: DISTANCE-FROM konsumiert 4 Tokens, alle anderen
477
+ // Formen maximal 3 — jedes weitere Token macht den String unparsebar.
478
+ const maxTokens = type === 'DISTANCE-FROM' ? 4 : 3;
479
+ if (parts.length > maxTokens)
480
+ return refuse(
481
+ `Rest-Token nicht konsumiert: '${parts.slice(maxTokens).join(' ')}'`,
482
+ );
483
+ if (type === 'DISTANCE-FROM') {
484
+ // §H10 P2 (Patch-Runde): STRIKTER Numerik-Token, symmetrisch zum
485
+ // TYP-Regex oben. parseFloat teilinterpretierte '0x10'→0 (bewiesenes
486
+ // Falsch-PASS: Distanz ≥ 0 ist immer wahr), '3px'→3, '1.2.3'→1.2 und
487
+ // akzeptierte non-finites ('Infinity'). Akzeptiert wird NUR der
488
+ // vollständige finite Dezimal-Match; alles andere läuft sichtbar in
489
+ // die bestehende Verweigerungs-Schiene. FEHLENDER Wert behält den
490
+ // Grammatik-Default 1 (Kontroll-Pin R11-21).
491
+ if (parts[3] !== undefined && !/^[+-]?(\d+\.?\d*|\.\d+)$/.test(parts[3]))
492
+ return refuse(`DISTANCE-FROM-Wert '${parts[3]}' ist nicht numerisch`);
493
+ return {
494
+ type,
495
+ subject,
496
+ reference: cleanId(parts[2]),
497
+ value: parts[3] === undefined ? 1 : parseFloat(parts[3]),
498
+ };
499
+ }
500
+ if (type === 'COLOR') {
501
+ return {
502
+ type,
503
+ subject,
504
+ reference: null,
505
+ value: parts[2]?.toLowerCase(),
506
+ };
507
+ }
508
+ if (type === 'FILL') {
509
+ return { type, subject, reference: null };
510
+ }
511
+ return { type, subject, reference: cleanId(parts[2]) };
512
+ });
513
+ }
514
+
515
+ /**
516
+ * §H10 R11-21: EINE Wortlaut-Quelle für die Verweigerungs-Meldung
517
+ * (checkAllConstraints-detail + arrange-warning — Kanal-Parität).
518
+ */
519
+ function unparseableDetail(c) {
520
+ return `Constraint nicht parsebar: "${c.raw}" — ${c.problem}`;
521
+ }
522
+
523
+ /**
524
+ * §HEAL-5 (F-AT-2-005): Subjekt-Ehrlichkeits-Klassifikator für die VERDIKT-Wache
525
+ * in checkAllConstraints. Das System trägt zwei Ehrlichkeits-Ebenen — Element
526
+ * (Flags, vollständig bis MCP durchgereicht) und Verdikt (pass/reasonCode) —
527
+ * und die einzige generische Kopplung war FAILING-seitig (gateCorrections).
528
+ * Für PASS-Verdikte existierte KEINE Schiene (Verdikt-Vertrag aus der
529
+ * v1.6-Vor-Flag-Ära): clean-PASS über einem 0-Pixel- oder zeit-varianten
530
+ * Subjekt = stille Lüge (Boden-Wahrheit 4c8a6ed, P3/P4).
531
+ *
532
+ * Klassen (Spec heal_verdict_motion Edit #3):
533
+ * HART — subj.paint_visible === false (bewiesen 0 Tinte, exaktes false,
534
+ * NICHT 'indeterminate') → SUBJECT_NOT_PAINTED.
535
+ * WEICH-AKTIV — subj.motion_dependent === true (clock-rooted SMIL-GEOMETRIE,
536
+ * t0-Messung exakt, an anderem t anders) → SUBJECT_TIME_VARIANT.
537
+ * WEICH-VORBEREITET, DEAKTIVIERT — paint_visible 'indeterminate' ·
538
+ * state_dependent · media_dependent: der Klassifikator KENNT
539
+ * sie, aktiviert sie NICHT (0 Falsch-Vorbehalte, Kanon-
540
+ * Stabilität — registriertes Residuum; Aktivierung wäre
541
+ * Kanon-Drift ohne bewiesenen Bedarf).
542
+ *
543
+ * Liefert { reasonCode, rider } oder null (kein Vorbehalt). HART dominiert
544
+ * WEICH (ein paint-totes UND zeit-variantes Subjekt → SUBJECT_NOT_PAINTED).
545
+ */
546
+ // §H10 R11-06: Paint-Domäne der Constraint-Typen — NUR diese Verdikte hängen
547
+ // an der Paint-Wahrheit des Subjekts; Geometrie-Constraints auf einem rein
548
+ // paint-zeit-varianten Subjekt bleiben messbar wahr (INSIDE auf Blinker: die
549
+ // Geometrie IST zeitinvariant — eine messbare Wahrheit wird nicht verweigert).
550
+ // §H10 P4: NUR COLOR — FILL ist arrange-only (fill.js#check ⇒ immer pass:null,
551
+ // pass===true ist unerreichbar; eine FILL-Zelle hier wäre toter Ballast).
552
+ const PAINT_DOMAIN_CONSTRAINTS = new Set(['COLOR']);
553
+
554
+ function classifySubjectHonesty(subj, constraintType) {
555
+ if (!subj) return null;
556
+ if (subj.paint_visible === false) {
557
+ return { reasonCode: 'SUBJECT_NOT_PAINTED', rider: 'Subjekt malt 0 Pixel' };
558
+ }
559
+ // §H10 R11-07 (O-A): dritte Wache-Klasse — eine bbox, der das System SELBST
560
+ // misstraut (not_measurable: 3D-Transform / Non-SMIL-Motion), darf kein
561
+ // grünes Verdikt tragen. Die Entscheidungs-Semantik wohnt in honesty.js
562
+ // (REGEL-4/W1b-Pin: bboxTrustedForVerdict — Schwester von allowDeltas, das
563
+ // failing-seitig längst jede Pixel-Korrektur verweigert). EIN Prinzip: was
564
+ // keine Korrektur tragen darf, trägt auch kein PASS. Präzedenz: HART paint
565
+ // > not_measurable > time_variant. 'approximate' bleibt bewusst PASS-fähig
566
+ // (Anti-Über-Gaten: die Zahl ist annähernd wahr).
567
+ if (!bboxTrustedForVerdict(subj.bbox_reliability)) {
568
+ const causes = Array.isArray(subj.warnings)
569
+ ? subj.warnings.filter((w) =>
570
+ ['3D_TRANSFORM_ANCESTOR', 'NON_DETERMINISTIC_MOTION'].includes(w),
571
+ )
572
+ : [];
573
+ return {
574
+ reasonCode: 'SUBJECT_NOT_MEASURABLE',
575
+ rider: `Subjekt-bbox not_measurable${causes.length ? ` (${causes.join(', ')})` : ''}`,
576
+ };
577
+ }
578
+ if (subj.motion_dependent === true) {
579
+ return {
580
+ reasonCode: 'SUBJECT_TIME_VARIANT',
581
+ rider: 'geprüft @t0, Subjekt-Geometrie zeit-variant',
582
+ };
583
+ }
584
+ // §H10 R11-06 (Option A): Paint-Zeit-Achse — ein paint-zeit-variantes
585
+ // Subjekt (clock-rooted SMIL auf fill/opacity/…) degradiert NUR Paint-
586
+ // Domänen-Verdikte (COLOR): die @t0-Farbe ist exakt, aber an anderem t
587
+ // anders — ein clean PASS wäre die stille Blinker-Lüge. reasonCode
588
+ // SUBJECT_TIME_VARIANT WIEDERVERWENDET (gleiche Zeit-Achsen-Klasse),
589
+ // Rider paint-spezifisch.
590
+ if (
591
+ subj.paint_time_variant === true &&
592
+ PAINT_DOMAIN_CONSTRAINTS.has(constraintType)
593
+ ) {
594
+ return {
595
+ reasonCode: 'SUBJECT_TIME_VARIANT',
596
+ rider: 'geprüft @t0, Subjekt-Paint zeit-variant (SMIL auf Paint-Kanal)',
597
+ };
598
+ }
599
+ return null;
600
+ }
601
+
602
+ /**
603
+ * Checks all constraints against gridMap using the Registry.
604
+ * ADR-022: ctx = { grid, value? }
605
+ * Returns enriched results with constraintType + reference for structured output.
606
+ */
607
+ export function checkAllConstraints(constraints, gridMap) {
608
+ // §HEAL-7/B (F-TF-003): id-Häufigkeit EINMAL über die Szene zählen (Muster
609
+ // §HEAL-4 measureMediaStep R3, pl:816ff — Duplikat-IDs sind nicht
610
+ // attribuierbar, kein Raten).
611
+ const idCount = new Map();
612
+ for (const e of gridMap.elements) {
613
+ idCount.set(e.id, (idCount.get(e.id) || 0) + 1);
614
+ }
615
+ return constraints.map((c) => {
616
+ // §H10 R11-21 PARSE-VERWEIGERUNG (O1, Muster der Duplikat-ID-Wache unten):
617
+ // ein nicht (vollständig) parsebarer String wird nicht gemessen, sondern
618
+ // sichtbar verweigert — pass:null auf der existierenden unchecked-Schiene,
619
+ // reasonCode am Ursprung (D-004). hint trägt die Navigation (Grammatik).
620
+ if (c.type === 'CONSTRAINT_UNPARSEABLE') {
621
+ return {
622
+ pass: null,
623
+ reasonCode: 'CONSTRAINT_UNPARSEABLE',
624
+ reasonCategory: 'SPECIFICATION',
625
+ detail: unparseableDetail(c),
626
+ hint: `${unparseableDetail(c)}. ${CONSTRAINT_GRAMMAR_HINT}`,
627
+ };
628
+ }
629
+ // §HEAL-7/B DUPLIKAT-ID-EHRLICHKEIT (F-TF-003, Boden-Wahrheit f003): bei
630
+ // ambigem subject/ref (id N-fach in der Szene) maß .find() bisher STILL
631
+ // den ERSTEN Namensvetter und emittierte eine Korrektur, die die Prosa an
632
+ // ALLE Namensvettern heftete — ein Verdikt über ein nie eindeutig
633
+ // benanntes Element. Messung VERWEIGERN statt raten: pass:null +
634
+ // MEASUREMENT_AMBIGUOUS (existiert in REASON_CODES; per D-004-Historie ist
635
+ // ECHTE Ambiguität sein korrekter Einsatz) + N-fach-Detail. KEINE
636
+ // corrections (pass:null → arbitrate.buildUnchecked → unchecked[], der
637
+ // corrections-Producer konsumiert nur failing). VOR dem subj-find: bei
638
+ // count>1 wäre jeder find-Treffer bereits eine first-match-Lüge.
639
+ const ambigId = [c.subject, c.reference].find(
640
+ (id) => id != null && (idCount.get(id) || 0) > 1,
641
+ );
642
+ if (ambigId !== undefined) {
643
+ const n = idCount.get(ambigId);
644
+ return {
645
+ pass: null,
646
+ reasonCode: 'MEASUREMENT_AMBIGUOUS',
647
+ reasonCategory: 'SPECIFICATION',
648
+ detail: `id '${ambigId}' ist ${n}-fach — Messung verweigert, keine Korrektur`,
649
+ constraintType: c.type,
650
+ id: c.subject,
651
+ reference: c.reference,
652
+ };
653
+ }
654
+ const subj = gridMap.elements.find((e) => e.id === c.subject);
655
+ const ref = c.reference
656
+ ? gridMap.elements.find((e) => e.id === c.reference)
657
+ : null;
658
+ // D-004: praeziser reasonCode am Ursprung. Frueher fiel ein fehlendes
659
+ // subject/reference in arbitrate.js#buildUnchecked auf den generischen
660
+ // MEASUREMENT_AMBIGUOUS-Default zurueck (Default Z.171) — eine ungenaue
661
+ // Diagnose, obwohl die exakten Codes laengst in REASON_CODES definiert
662
+ // (aber unbenutzt) waren. Wir setzen sie hier, wo der Unterschied
663
+ // subject-fehlt vs. reference-fehlt eindeutig bekannt ist. Beides sind
664
+ // SPECIFICATION-Probleme: der Aufruf referenziert eine id, die im Scene
665
+ // nicht existiert. constraintType/id/reference werden mitgegeben, damit der
666
+ // unchecked-Eintrag identifizierbar bleibt (die spaetere Enrichment Z.473-475
667
+ // wird durch das fruehe return uebersprungen).
668
+ if (!subj) {
669
+ // §H10 R11-01 (Option B): Existenz-Register konsultieren, BEVOR die
670
+ // SPECIFICATION-Schuld vergeben wird. Existiert die id im Markup, wurde
671
+ // aber css-unsichtbar geskippt, ist der Aufruf KORREKT — das Auge misst
672
+ // @t0 nur nichts: eigener Code SUBJECT_HIDDEN, reasonCategory MODEL
673
+ // (D-004-Muster: reasonCode am Ursprung).
674
+ const hid = Array.isArray(gridMap.hidden)
675
+ ? gridMap.hidden.find((h) => h.id === c.subject)
676
+ : undefined;
677
+ if (hid)
678
+ return {
679
+ pass: null,
680
+ reasonCode: 'SUBJECT_HIDDEN',
681
+ reasonCategory: 'MODEL',
682
+ detail: `#${c.subject} existiert (${hid.axis}) — unsichtbar @t0, nicht gemessen`,
683
+ hint: `#${c.subject} existiert (${hid.axis}) — unsichtbar @t0, nicht gemessen; Sichtbarkeit herstellen oder Constraint entfernen`,
684
+ constraintType: c.type,
685
+ id: c.subject,
686
+ reference: c.reference,
687
+ };
688
+ return {
689
+ pass: null,
690
+ reasonCode: 'SUBJECT_NOT_FOUND',
691
+ reasonCategory: 'SPECIFICATION',
692
+ detail: `#${c.subject} nicht gefunden`,
693
+ constraintType: c.type,
694
+ id: c.subject,
695
+ reference: c.reference,
696
+ };
697
+ }
698
+ // §H10 R11-11 IDENTITÄTS-WACHE (O2): Subjekt === Referenz ⇒ das Verdikt
699
+ // wäre geometrie-UNABHÄNGIG (Tautologie bzw. konstantes false) — ein
700
+ // leeres Echo, kein Messergebnis. Messung verweigern statt raten
701
+ // (§HEAL-7/B-Muster): pass:null + SEMANTIC_SUSPICIOUS (deklariert in
702
+ // REASON_CODES, hier der erste ehrliche Einsatz) → unchecked → PARTIAL.
703
+ if (c.reference != null && c.reference === c.subject)
704
+ return {
705
+ pass: null,
706
+ reasonCode: 'SEMANTIC_SUSPICIOUS',
707
+ reasonCategory: 'SPECIFICATION',
708
+ detail: `Selbst-Referenz: Subjekt und Referenz sind dasselbe Element (#${c.subject}) — Verdikt wäre geometrie-unabhängig, Messung verweigert`,
709
+ hint: `Selbst-Referenz: Subjekt und Referenz sind dasselbe Element (#${c.subject}) — Referenz auf ein anderes Element wählen`,
710
+ constraintType: c.type,
711
+ id: c.subject,
712
+ reference: c.reference,
713
+ };
714
+ // HIGH-Fix (E6 Re-Review): REGEL-8 fail-closed. Eine referenz-PFLICHTIGE
715
+ // Constraint (DISTANCE-FROM, CENTERED-IN, INSIDE, NO-OVERLAP, ALIGNED-*,
716
+ // LEFT-OF, ABOVE, SAME-SIZE) OHNE aufloesbaren ref darf NIE in
717
+ // checkConstraint fallen — distance.js & Co. dereferenzieren `ref.bbox` und
718
+ // werfen sonst „Cannot read properties of null". Die vorige Wache
719
+ // `if (c.reference && !ref)` griff NUR bei truthy c.reference und liess den
720
+ // Fall „ref fehlt ganz" (c.reference null/''/nur '#' → parseConstraints
721
+ // liefert reference:null) durchrutschen → Crash.
722
+ //
723
+ // `requiresReference(c.type)` ist registry-getrieben (Default fail-closed
724
+ // true; nur COLOR/FILL markieren false). Damit ist die Menge nicht im
725
+ // pipeline-Code hartkodiert, sondern lebt beim jeweiligen Constraint.
726
+ //
727
+ // FINDING-1-Fix (E6 Re-Review-2): der Guard feuert NUR fuer REGISTRIERTE
728
+ // Typen (`isRegistered`). Sonst maskierte der fail-closed-Default
729
+ // (requiresReference(unbekannt)=true) bei Tippfehlern/Phantasie-Typen die
730
+ // echte Diagnose: `#a CENTRD-IN` lieferte „benoetigt Referenz" statt
731
+ // CONSTRAINT_TYPE_UNKNOWN + Vorschlag. Unbekannte Typen fliessen weiter zu
732
+ // checkConstraint (das fuer unbekannte Typen pass:null OHNE Deref liefert →
733
+ // kein Crash) → arbitrate.buildUnchecked vergibt CONSTRAINT_TYPE_UNKNOWN.
734
+ //
735
+ // Zwei ehrliche Diagnosen fuer bekannte ref-pflichtige Typen: c.reference
736
+ // gesetzt aber nicht auflösbar (zeigt auf nicht-existentes Element) vs. ganz
737
+ // fehlend.
738
+ if (isRegistered(c.type) && requiresReference(c.type) && !ref) {
739
+ // §H10 P1 (Patch-Runde, 3/3 Linsen): SYMMETRIE des Existenz-Registers —
740
+ // gridMap.hidden wird auch für die REFERENZ konsultiert, BEVOR die
741
+ // SPECIFICATION-Schuld vergeben wird. Eine css-versteckte Referenz
742
+ // (#v INSIDE #h, #h display:none) lief sonst in REFERENCE_NOT_FOUND
743
+ // (Existenz-Lüge, empirisch bewiesen): eigener Code REFERENCE_HIDDEN,
744
+ // reasonCategory MODEL — exakt die SUBJECT_HIDDEN-Schiene von oben.
745
+ const hidRef =
746
+ c.reference && Array.isArray(gridMap.hidden)
747
+ ? gridMap.hidden.find((h) => h.id === c.reference)
748
+ : undefined;
749
+ if (hidRef)
750
+ return {
751
+ pass: null,
752
+ reasonCode: 'REFERENCE_HIDDEN',
753
+ reasonCategory: 'MODEL',
754
+ detail: `#${c.reference} existiert (${hidRef.axis}) — unsichtbar @t0, nicht gemessen`,
755
+ hint: `#${c.reference} existiert (${hidRef.axis}) — unsichtbar @t0, nicht gemessen; Sichtbarkeit herstellen oder Constraint entfernen`,
756
+ constraintType: c.type,
757
+ id: c.subject,
758
+ reference: c.reference,
759
+ };
760
+ return {
761
+ pass: null,
762
+ reasonCode: 'REFERENCE_NOT_FOUND',
763
+ reasonCategory: 'SPECIFICATION',
764
+ detail: c.reference
765
+ ? `#${c.reference} nicht gefunden`
766
+ : `${c.type} benoetigt eine Referenz — keine angegeben`,
767
+ constraintType: c.type,
768
+ id: c.subject,
769
+ reference: c.reference,
770
+ };
771
+ }
772
+
773
+ const ctx = { grid: gridMap.grid };
774
+ if (c.value !== undefined) ctx.value = c.value;
775
+ const result = checkConstraint(c.type, subj, ref, ctx);
776
+ result.constraintType = c.type;
777
+ result.reference = c.reference;
778
+ result.id = c.subject;
779
+ // §HEAL-5 VERDIKT-WACHE (Post-Check, D-003/D-004-Muster: reasonCode am
780
+ // Ursprung — subj ist hier das VOLLE gridMap.elements-Objekt, die Flags
781
+ // liegen bereits vor). NUR pass:true wird degradiert (pass:null →
782
+ // arbitrate.buildUnchecked honoriert den Upstream-reasonCode → unchecked[]
783
+ // → deriveStatus → PARTIAL). pass:false bleibt fail — der geometrische
784
+ // Bruch ist WAHR (gateCorrections-Pfad unberührt). Der MESSWERT bleibt
785
+ // unverändert im detail (VISION P3: Vorbehalt ehrlich tragen, nie
786
+ // Wert-Eingriff): die Handler liefern bei pass:true detail:null → die
787
+ // gemessene t0-bbox des Subjekts IST der Messwert des Verdikts.
788
+ if (result.pass === true) {
789
+ // §H10 R11-06: der Constraint-Typ wird mitgegeben — die Paint-Zeit-
790
+ // Klasse degradiert nur Paint-Domänen-Verdikte (COLOR).
791
+ const honesty = classifySubjectHonesty(subj, c.type);
792
+ if (honesty) {
793
+ const messwert =
794
+ result.detail != null
795
+ ? result.detail
796
+ : subj.bbox
797
+ ? `geometrisch erfüllt @t0 (bbox x=${subj.bbox.x} y=${subj.bbox.y} w=${subj.bbox.w} h=${subj.bbox.h})`
798
+ : 'geometrisch erfüllt @t0';
799
+ return {
800
+ pass: null,
801
+ reasonCode: honesty.reasonCode,
802
+ reasonCategory: 'MODEL',
803
+ detail: `${messwert} — ${honesty.rider}`,
804
+ constraintType: c.type,
805
+ reference: c.reference,
806
+ id: c.subject,
807
+ };
808
+ }
809
+ }
810
+ return result;
811
+ });
812
+ }
813
+
814
+ /**
815
+ * §E1 Wahrheits-Gate am Emissions-Rand: baut die reliabilityById-Map EINMAL aus
816
+ * gridMap.elements (D-015 echt 3→1: war 1× structured-Map + 1× prose-find +
817
+ * 1× structured-inline) und schleust arbitrated.failing durch
818
+ * honesty.js#gateCorrections. Liefert die gegatete failing-Liste (jedes issue
819
+ * traegt _gated; bei deny um dx/dy/dw/dh + detail-Vorschreibung bereinigt). Der
820
+ * Caller setzt sie als `arbitrated.failing` (R3-Symmetrie) → BEIDE Formatter
821
+ * konsumieren GENAU EINE gegatete Quelle, kein ungegateter Seitenkanal. EINE
822
+ * Entscheidung pro issue, EINMAL, am einzigen Ort, der Reliability+Emission koppelt.
823
+ */
824
+ function gateFailing(gridMap, arbitrated) {
825
+ const reliabilityById = new Map(
826
+ gridMap.elements.map((el) => [el.id, el.bbox_reliability]),
827
+ );
828
+ return gateCorrections(arbitrated.failing || [], (id) =>
829
+ reliabilityById.get(id),
830
+ );
831
+ }
832
+
833
+ /**
834
+ * §HEAL-R6 / T1 "das Kabel" (F-AT-6-08, R9-Determinismus): stabilisiert
835
+ * resolved.sanitize_loss VOR der Emission — lexikografisch nach (tag, reason)
836
+ * sortiert UND dedupliziert. Der Adapter liefert die Liste in DOM-removed-
837
+ * Reihenfolge; zwei strukturell identische Verluste (gleiches tag+reason) sind
838
+ * für den Konsumenten ununterscheidbar. Sortieren + Dedup macht das Verdikt
839
+ * byte-stabil (gleiches lossy-SVG 2× → identische Reihenfolge) und kompakt.
840
+ * Reine Funktion (kein I/O, kein Date/Random). Liefert IMMER ein Array (auch []).
841
+ *
842
+ * @param {Array<{tag:string, reason:string, value?:string}>|undefined} loss
843
+ * @returns {Array<{tag:string, reason:string, value?:string}>}
844
+ */
845
+ function stabilizeSanitizeLoss(loss) {
846
+ if (!Array.isArray(loss)) return [];
847
+ const seen = new Set();
848
+ const out = [];
849
+ for (const l of loss) {
850
+ if (!l || typeof l !== 'object') continue;
851
+ const tag = String(l.tag ?? '');
852
+ const reason = String(l.reason ?? '');
853
+ // §H9 K-05: `value` (der gestrippte Attribut-WERT, z.B. die verlorene
854
+ // Autor-id — vom Adapter seit T2 erfasst) bleibt ERHALTEN statt gedroppt,
855
+ // sonst kann die Prosa nie nennen, WELCHE id betroffen war (stille
856
+ // Mutation). Dedup-/Sort-Schlüssel um value erweitert (Determinismus
857
+ // bleibt; zwei Strips desselben Attributs mit verschiedenen Werten sind
858
+ // verschiedene Wahrheiten).
859
+ const value = l.value !== undefined ? String(l.value) : undefined;
860
+ const key = `${tag}\u0000${reason}\u0000${value ?? ''}`;
861
+ if (seen.has(key)) continue;
862
+ seen.add(key);
863
+ out.push({ tag, reason, ...(value !== undefined ? { value } : {}) });
864
+ }
865
+ const cmp = (x, y) => (x < y ? -1 : x > y ? 1 : 0);
866
+ out.sort(
867
+ (a, b) =>
868
+ cmp(a.tag, b.tag) ||
869
+ cmp(a.reason, b.reason) ||
870
+ cmp(a.value ?? '', b.value ?? ''),
871
+ );
872
+ return out;
873
+ }
874
+
875
+ /**
876
+ * §HEAL-R6 / T1: das classifyCanvas-Verdikt aus resolved.sanitize_loss DIREKT
877
+ * (Anti-LECK-3 — NIE aus gridMap.canvas, das sanitize_loss nicht trägt → wäre
878
+ * immer 'valid' → neue stille Lüge). viewBoxValidity LIEFERT der Renderer seit
879
+ * §HEAL-7/A (F-TF-002) am canvas-Objekt: 'degenerate' (viewBox vorhanden, aber
880
+ * parse-fail/NaN/zero/negativ) · 'default_replaced' (keine viewBox + keine
881
+ * width/height → CSS-Default 300×150 griff) · sonst nicht gesetzt.
882
+ * DOMINANZ (honesty.js#classifyCanvas-Vertrag, Z.113ff): lossy > degenerate >
883
+ * default_replaced > valid — ein nicht-leerer sanitizeLoss überstimmt das
884
+ * viewBoxValidity-Signal bewusst (Pessimismus-Präzedenz). canvas-Argument ist
885
+ * resolved.canvas (kann im Error-Pfad fehlen → optional-chaining).
886
+ *
887
+ * @param {{canvas?: {viewBoxValidity?: string}}} resolved
888
+ * @param {Array} sanitizeLoss - bereits stabilisierte Verlust-Liste.
889
+ * @returns {'valid'|'default_replaced'|'degenerate'|'lossy'}
890
+ */
891
+ function deriveCanvasValidity(resolved, sanitizeLoss) {
892
+ return classifyCanvas({
893
+ viewBoxValidity: resolved.canvas?.viewBoxValidity,
894
+ sanitizeLoss,
895
+ });
896
+ }
897
+
898
+ /**
899
+ * §HEAL-R6 / T1 Error-Pfad-Ehrlichkeit (F-AT-6-05): im resolved.error-Fall baut
900
+ * analyze/compare/inspect heute `structured:null` — ein gestrippter-aber-nicht-
901
+ * renderbarer Canvas verliert damit das laute lossy-Signal STILL (tools.js
902
+ * substituiert dann eine neutrale Schema-Error-Response ohne canvas_validity).
903
+ * Diese Helper liefert das ehrliche Error-Resultat: trägt resolved.sanitize_loss
904
+ * einen Verlust (canvas_validity==='lossy'), dann ein SCHEMA-KONFORMES structured
905
+ * mit scene.canvas_validity:'lossy' + die laute Prosa-Zeile; sonst Verhalten
906
+ * UNVERÄNDERT (prose:`Fehler: …`, structured:null → tools.js-Error-Response).
907
+ *
908
+ * SCHEMA-KONFORMITÄT (KRITISCH): tools.js deklariert outputSchema (analyzeOutput
909
+ * / inspectOutput) und die MCP-SDK VALIDIERT structuredContent dagegen (-32602-
910
+ * Reject). Ein non-null structured MUSS daher die jeweilige Pflicht-Form tragen
911
+ * (analyze: status/iteration/scene/corrections/unchecked/diff; inspect:
912
+ * scene.suppressed/elements). Wir bauen die NEUTRALE Error-Form (analyze:
913
+ * analyzeErrorStructured — §H9 P2 DIESELBE Quelle, die tools.js#
914
+ * analyzeErrorResponse konsumiert; inspect: Spiegel von tools.js#
915
+ * inspectErrorResponse) und hängen NUR das additive
916
+ * scene.canvas_validity:'lossy' an. canvas_validity stammt aus
917
+ * resolved.sanitize_loss DIREKT (Anti-LECK-3); gridMap existiert hier nicht.
918
+ *
919
+ * @param {{error:string, message:string, sanitize_loss?:Array, canvas?:object}} resolved
920
+ * @param {'analyze'|'inspect'} kind - bestimmt die Schema-konforme Hülle.
921
+ * @returns {{prose:string, structured:(object|null)}}
922
+ */
923
+ function buildErrorResult(resolved, kind = 'analyze') {
924
+ const sanitizeLoss = stabilizeSanitizeLoss(resolved.sanitize_loss);
925
+ const canvasValidity = deriveCanvasValidity(resolved, sanitizeLoss);
926
+ if (canvasValidity !== 'lossy') {
927
+ // §6 RELAIS Fehler-Kanal: code+hint entstehen HIER (an der pipeline-
928
+ // Quelle, aus resolved.error/message — dem existierenden Wahrheits-Ort).
929
+ // tools.js bettet sie additiv in die Schema-Error-Hülle (error{code,hint},
930
+ // NUR bei isError:true). §P1: der hint trägt den Navigations-Zusatz
931
+ // (renderErrorHint, eine Quelle — Ist + nächster Schritt statt nur Befund);
932
+ // die Prosa trägt denselben hint wortidentisch (Parity per Konstruktion).
933
+ const hint = renderErrorHint(resolved.message);
934
+ return {
935
+ prose: `Fehler: ${hint}`,
936
+ structured: null,
937
+ error: { code: resolved.error, hint },
938
+ };
939
+ }
940
+ // §H9 K-03/K-24/K-05: echte Verlust-Ursachen auch im Error-Pfad.
941
+ const prose = formatErrorWithLoss(resolved.message, sanitizeLoss);
942
+ if (kind === 'inspect') {
943
+ // Spiegel von tools.js#inspectErrorResponse (inspectOutput-konform) + additiv
944
+ // scene.canvas_validity:'lossy'.
945
+ return {
946
+ prose,
947
+ structured: {
948
+ scene: {
949
+ width: 0,
950
+ height: 0,
951
+ grid: '0x0',
952
+ elements: [],
953
+ suppressed: 0,
954
+ canvas_validity: canvasValidity,
955
+ },
956
+ },
957
+ };
958
+ }
959
+ // Spiegel von tools.js#analyzeErrorResponse (analyzeOutput-konform) + additiv
960
+ // scene.canvas_validity:'lossy'. (Hülle aus analyzeErrorStructured — §H9
961
+ // K-13bc: EINE Quelle, wiederverwendet vom compare-No-Baseline-Pfad.)
962
+ const structured = analyzeErrorStructured();
963
+ structured.scene.canvas_validity = canvasValidity;
964
+ return { prose, structured };
965
+ }
966
+
967
+ /**
968
+ * §H9 K-13bc: kanal-neutrale No-Baseline-Hinweis-Prosa — EINE Quelle.
969
+ * 'analyze' ist der gemeinsame Operationsname BEIDER Kanäle (JS-Export
970
+ * analyze, MCP vector_mirror_analyze); der MCP-Dialektname gehört nicht in
971
+ * die kanal-agnostische Kernschicht. Exportiert, damit tools.js den
972
+ * Nutzungsfehler EXAKT erkennt (die isError-Projektion am MCP-Rand bleibt
973
+ * wahr — kein Heuristik-Sniffing auf der Hüllen-Form).
974
+ */
975
+ export const NO_BASELINE_HINT =
976
+ 'Hinweis: Keine Basis zur analysisId gefunden — führe zuerst analyze aus und übergib dessen analysisId.';
977
+
978
+ /**
979
+ * §6 RELAIS Fehler-Kanal: kanal-neutrale Bookmark-Fehler-Prosa — EINE Quelle
980
+ * (Muster NO_BASELINE_HINT; Wortlaut byte-identisch zur bisherigen Inline-
981
+ * Prosa in bookmark()). Exportiert, damit Proben die Parity prüfen können.
982
+ */
983
+ export const BOOKMARK_UNKNOWN_HINT =
984
+ 'Hinweis: analysisId nicht gefunden — zuerst vector_mirror_analyze aufrufen.';
985
+
986
+ /**
987
+ * §6 RELAIS / P1 (Flussbett, internal design rule): Render-Fehler-hint = Ist-Zustand
988
+ * + nächster Schritt, nie nur Befund. Die EINE Quelle für ALLE Render-Error-
989
+ * hints (buildErrorResult non-lossy-Zweig + palette); die Prosa rendert
990
+ * denselben hint wortidentisch (`Fehler: ${hint}`) — Parity per Konstruktion,
991
+ * gepinnt am MCP-Rand (tests/relais_red/probe_mcp_errorchannel.mjs).
992
+ */
993
+ export function renderErrorHint(message) {
994
+ return `${message} — prüfe SVG-Wohlgeformtheit/viewBox; Details: content-Text`;
995
+ }
996
+
997
+ /**
998
+ * §H9 K-13bc/P2: die analyzeOutput-konforme Error-Hülle als EINE Quelle —
999
+ * konsumiert von buildErrorResult (lossy-Zweig), compare (No-Baseline-Pfad)
1000
+ * UND tools.js#analyzeErrorResponse (MCP-Rand-Spiegel; exportiert, damit der
1001
+ * Spiegel keine zweite Hüllen-Kopie trägt). status:'FAIL' + leere scene =
1002
+ * die etablierte Error-Result-Form des Projekts.
1003
+ *
1004
+ * §H9 P2 WAHRE FORM: im Fehlerfall gibt es KEINE Messung — die Hülle darf
1005
+ * daher weder einen Lösungs-Zustand behaupten (das frühere convergence:
1006
+ * 'SOLVED' widersprach isError:true) noch eine Korrelations-ID erfinden (die
1007
+ * frühere randomUUID referenzierte bewusst KEIN Grid — eine als echt
1008
+ * präsentierte Phantom-ID). Beide Felder tragen jetzt null („keine Aussage" /
1009
+ * „keine Analyse gespeichert"); iterationSchema ist nullable nachgezogen.
1010
+ *
1011
+ * @returns {object} analyzeOutput-konformes Error-structured.
1012
+ */
1013
+ export function analyzeErrorStructured() {
1014
+ return {
1015
+ status: 'FAIL',
1016
+ iteration: {
1017
+ sequence: 1,
1018
+ previous_issues: 0,
1019
+ current_issues: 0,
1020
+ total_issues: 0,
1021
+ returned_issues: 0,
1022
+ suppressed: 0,
1023
+ convergence: null,
1024
+ analysisId: null,
1025
+ },
1026
+ scene: { width: 0, height: 0, grid: '0x0', elements: [] },
1027
+ corrections: [],
1028
+ unchecked: [],
1029
+ diff: [],
1030
+ };
1031
+ }
1032
+
1033
+ // ═════════════════════════════════════════════════════════════════════════
1034
+ // §HEAL-4 (F-AT-7-10 + F-AT-7-11) — additiver Viewport-Mess-Detektor
1035
+ // (docs/internal/an internal spec, ADR-001 STAND (4)).
1036
+ //
1037
+ // Der Mess-Schritt läuft NUR in analyze() und inspect(), NACH fireResolve und
1038
+ // VOR mapToGridMap/Formatierung — NIE in resolve() (breaker-getimter Pfad;
1039
+ // Phase-0-Lehre: Mess-Verdrahtung in resolve() zerstörte 330ms → 5153ms).
1040
+ // compare()/palette() bleiben unberührt (Spec-Scope).
1041
+ //
1042
+ // ADDITIV OR-ONLY (F-AT-7-14, use-Shadow-Blindfleck der Messung pixel-bewiesen):
1043
+ // die Messung darf media_dependent NUR HINZUFÜGEN, NIE ein statisches true
1044
+ // wegnehmen — die Statik bleibt load-bearing. Transport über die EXISTENTEN
1045
+ // Schienen (grid.js media_dependent-Durchreichung + warnings[]-Pfad inkl.
1046
+ // truncated_warnings + prose.js mediaNote) — KEIN neuer Kanal, KEIN Schema-Feld.
1047
+ // ═════════════════════════════════════════════════════════════════════════
1048
+
1049
+ /** §HEAL-4 MK3: scene-level Ausfall-Marker (laut, nie still). */
1050
+ const MEDIA_MEASURE_UNAVAILABLE = 'MEDIA_MEASURE_UNAVAILABLE';
1051
+
1052
+ /**
1053
+ * §HEAL-4 Mess-Schritt: misst die Viewport-Divergenz (lean 2-VP-Diff, separate
1054
+ * Page/eigener Mutex — MK5) und projiziert divergente Elemente OR-only auf
1055
+ * resolved.elements (media_dependent:true + MEDIA_DEPENDENT genau 1×).
1056
+ *
1057
+ * PROJEKTION (2 disjunkte Stufen, deterministisch, konservativ):
1058
+ * 1. authorId GESETZT — die Autor-id IST die emittierte Element-id (resolve()
1059
+ * behält explizite ids unverändert, §1.3). PATCH R3 (Codex-Blocker):
1060
+ * DUPLIKAT-Autor-IDs sind nicht attribuierbar (eine last-wins-Map nähme
1061
+ * still das letzte = Falsch-Flag-Richtung, F-TF-003-Klasse) ⇒ skip.
1062
+ * PATCH R2 (Codex-Blocker): authorId gesetzt aber NICHT emittiert (z.B.
1063
+ * divergentes invisibleNow-Element) ⇒ skip, KEIN Geometrie-Fallback —
1064
+ * sonst erbt ein geometrie-deckungsgleiches FREMDES Element das Flag
1065
+ * (Falsch-Flag-Richtung, Suite-Beleg R5c).
1066
+ * 2. authorId NULL (Auto-ID-Fall): EINDEUTIGER (tag, geom@1920)-Match
1067
+ * gegen resolved.elements — resolve() misst am IDENTISCHEN Viewport 1920
1068
+ * mit derselben 4-Punkt-userM-Projektion, die Werte sind deckungsgleich
1069
+ * (Toleranz 0.01 ≫ KB9-Rundung 1e-4). Ambig (0 oder >1 Kandidaten) ⇒
1070
+ * NICHT raten (kein Über-Flag) — die Statik trägt; ehrliches Residuum.
1071
+ *
1072
+ * MK3: JEDER Mess-Ausfall (Timeout/Fehler/Selbsttest) ⇒ { unavailable: true } —
1073
+ * der Aufrufer setzt den scene-Marker; die statischen Flags sind zu diesem
1074
+ * Zeitpunkt bereits vollständig im resolved (additiv ⇒ kein Re-Leak). Wirft NIE.
1075
+ *
1076
+ * @param {string} svgString
1077
+ * @param {{elements?: Array}} resolved — wird IN PLACE additiv erweitert.
1078
+ * @returns {Promise<{unavailable: boolean}>}
1079
+ */
1080
+ async function measureMediaStep(svgString, resolved) {
1081
+ let measured;
1082
+ try {
1083
+ measured = await measureViewportDivergence(svgString);
1084
+ } catch (_) {
1085
+ return { unavailable: true };
1086
+ }
1087
+ if (!measured || measured.error) return { unavailable: true };
1088
+ const els = Array.isArray(resolved.elements) ? resolved.elements : [];
1089
+ // R3: ID-Häufigkeit — Duplikat-Autor-IDs sind nicht attribuierbar (kein Raten).
1090
+ const idCount = new Map();
1091
+ for (const e of els) idCount.set(e.id, (idCount.get(e.id) || 0) + 1);
1092
+ // R8 (Codex-Re-Review): Duplikat-Zählung SYMMETRISCH auch über die Mess-
1093
+ // DESCRIPTOREN — ein nicht-emittierter id-Zwilling im Mess-DOM (z.B. zwei
1094
+ // divergente Elemente mit identischer Autor-ID, nur eines emittiert) würde
1095
+ // sonst auf das emittierte gleichnamige Element attribuiert (Falsch-Flag-
1096
+ // Kante). NICHT fixture-testbar ohne Fault-Injection in den Walk (beide
1097
+ // Zwillinge müssten divergieren UND genau einer emittiert sein — der R5d-
1098
+ // Pfad deckt die emittierte Seite, diese Zählung die Descriptor-Seite).
1099
+ // EHRLICHES REST-RESIDUUM: divergiert NUR der nicht-emittierte Zwilling,
1100
+ // sieht diese Zählung ihn allein (count==1) und attribuiert auf den
1101
+ // emittierten Namensvetter — abgesichert nur, wenn auch der emittierte
1102
+ // selbst divergiert; vollständige Schließung bräuchte die authorId-Zählung
1103
+ // über ALLE Walk-Records (bewusst nicht: Descriptor-Vertrag bleibt lean).
1104
+ const descIdCount = new Map();
1105
+ for (const d of measured.diverged || []) {
1106
+ if (d.authorId)
1107
+ descIdCount.set(d.authorId, (descIdCount.get(d.authorId) || 0) + 1);
1108
+ }
1109
+ const elById = new Map(els.map((e) => [e.id, e]));
1110
+ for (const d of measured.diverged || []) {
1111
+ let el;
1112
+ if (d.authorId) {
1113
+ // R3+R8: mehrdeutige Autor-ID (emittiert ODER descriptor-seitig) ⇒
1114
+ // konservativ skip (kein last-wins-Flag, kein Zwillings-Raten).
1115
+ if (
1116
+ (idCount.get(d.authorId) || 0) > 1 ||
1117
+ (descIdCount.get(d.authorId) || 0) > 1
1118
+ )
1119
+ continue;
1120
+ el = elById.get(d.authorId);
1121
+ // R2: gesetzte, aber nicht emittierte authorId ⇒ konservativ skip —
1122
+ // NIE in den Geometrie-Fallback fallen (Falsch-Flag auf Fremd-Element).
1123
+ if (!el) continue;
1124
+ } else if (Array.isArray(d.geomBase)) {
1125
+ const [x0, y0, x1, y1] = d.geomBase;
1126
+ const close = (a, b) =>
1127
+ Number.isFinite(a) && Number.isFinite(b) && Math.abs(a - b) <= 0.01;
1128
+ const cands = els.filter(
1129
+ (e) =>
1130
+ e.tag === d.tag &&
1131
+ e.bbox &&
1132
+ close(e.bbox.x, x0) &&
1133
+ close(e.bbox.y, y0) &&
1134
+ close(e.bbox.w, x1 - x0) &&
1135
+ close(e.bbox.h, y1 - y0),
1136
+ );
1137
+ if (cands.length === 1) el = cands[0];
1138
+ }
1139
+ // Auto-ID-Fall ohne EINDEUTIGEN Geometrie-Match (0 oder >1 Kandidaten)
1140
+ // → Statik trägt, kein Raten (Suite-Pinning R5a).
1141
+ if (!el) continue;
1142
+ if (el.media_dependent !== true) {
1143
+ el.media_dependent = true; // OR-only: NUR hinzufügen, nie wegnehmen.
1144
+ if (!Array.isArray(el.warnings)) el.warnings = [];
1145
+ // WARNING-INVARIANTE (Spiegel des Produktiv-Walks): GENAU 1× — statisch
1146
+ // geflaggte Elemente tragen die Warning bereits und landen nie hier.
1147
+ if (!el.warnings.includes('MEDIA_DEPENDENT')) {
1148
+ el.warnings.push('MEDIA_DEPENDENT');
1149
+ }
1150
+ }
1151
+ }
1152
+ return { unavailable: false };
1153
+ }
1154
+
1155
+ /**
1156
+ * §HEAL-4 MK3: hängt den lauten Ausfall-Marker an ein fertig formatiertes
1157
+ * Resultat — scene-level im structured + WARNUNG-Zeile in der Prosa. Seit
1158
+ * §HEAL-7/C ist scene.media_measure in BEIDEN registrierten outputSchemas
1159
+ * (schema.js analyzeOutput/inspectOutput) deklariert: der Marker überlebt den
1160
+ * MCP-Boundary-Parse (vorher strippte zod-unknownKeys ihn STILL — der Prosa-
1161
+ * Kanal war die einzige Schiene). Reine Funktion über das Resultat.
1162
+ */
1163
+ function withMeasureUnavailable(prose, structured) {
1164
+ if (structured?.scene && typeof structured.scene === 'object') {
1165
+ structured.scene.media_measure = MEDIA_MEASURE_UNAVAILABLE;
1166
+ }
1167
+ return {
1168
+ prose: `${prose}\nWARNUNG: ${MEDIA_MEASURE_UNAVAILABLE} — 2-Viewport-Messung ausgefallen (Timeout/Fehler/Selbsttest); statische Viewport-Erkennung trägt.`,
1169
+ structured,
1170
+ };
1171
+ }
1172
+
1173
+ /**
1174
+ * Full analysis: resolve SVG, map to grid, check constraints, diff, report.
1175
+ * Returns { prose: string, structured: object } for dual response (MCP OBL-009).
1176
+ */
1177
+ export async function analyze(
1178
+ svgString,
1179
+ constraintStrings = [],
1180
+ previousIssueCount,
1181
+ ) {
1182
+ // §H9 N-1: Fassaden-Guard — spiegelt den bereits deklarierten MCP-Vertrag
1183
+ // (schema.js: previousIssueCount z.number().int().nonnegative().optional())
1184
+ // an der Programm-Fassade. Ohne Guard wird Müll ({t:0.5}, 'banane', -3.7)
1185
+ // zu sequence=2 + fabriziertem DIVERGING-Verdikt (Fortschritts-Lüge).
1186
+ // Fail-fast VOR jeder Messung; legale Abwesenheit (undefined/null) und
1187
+ // valide Integer bleiben byte-identisch.
1188
+ if (
1189
+ previousIssueCount !== undefined &&
1190
+ previousIssueCount !== null &&
1191
+ !(Number.isInteger(previousIssueCount) && previousIssueCount >= 0)
1192
+ ) {
1193
+ // §H9 P5: JSON.stringify kann SELBST werfen (zirkuläres Objekt, BigInt) —
1194
+ // die Diagnose darf den hilfreichen TypeError nie verdrängen. Fallback
1195
+ // String(x): Navigation garantiert, egal welcher Müll ankommt.
1196
+ let ist;
1197
+ if (typeof previousIssueCount === 'number') {
1198
+ ist = String(previousIssueCount);
1199
+ } else {
1200
+ try {
1201
+ ist = JSON.stringify(previousIssueCount) ?? String(previousIssueCount);
1202
+ } catch {
1203
+ ist = String(previousIssueCount);
1204
+ }
1205
+ }
1206
+ throw new TypeError(
1207
+ `analyze: previousIssueCount muss nichtnegativer Integer (oder undefined/null) sein, erhalten: ${ist}`,
1208
+ );
1209
+ }
1210
+ if (!page) await init();
1211
+
1212
+ const resolved = await fireResolve(svgString);
1213
+ // §HEAL-R6 / T1 (F-AT-6-05): Error-Pfad trägt das laute lossy-Signal, wenn
1214
+ // resolved.sanitize_loss einen Verlust meldet (sonst Verhalten unverändert).
1215
+ if (resolved.error) return buildErrorResult(resolved);
1216
+
1217
+ // §HEAL-R6 / T1 "das Kabel" (F-AT-6-08): das classifyCanvas-Verdikt aus
1218
+ // resolved.sanitize_loss DIREKT (Anti-LECK-3 — NIE aus gridMap.canvas).
1219
+ // sanitize_loss VOR der Emission stabil sortiert + dedupliziert (R9).
1220
+ const sanitizeLoss = stabilizeSanitizeLoss(resolved.sanitize_loss);
1221
+ const canvasValidity = deriveCanvasValidity(resolved, sanitizeLoss);
1222
+
1223
+ // §HEAL-4: additiver Viewport-Mess-Schritt — NACH fireResolve (eigenes
1224
+ // Budget, NICHT breaker-getimt), VOR mapToGridMap (die existenten Schienen
1225
+ // reichen die OR-only-Flags durch). Ausfall ⇒ lauter scene-Marker (MK3).
1226
+ const mediaMeasure = await measureMediaStep(svgString, resolved);
1227
+
1228
+ // §1.1 Stateless RPC: kein impliziter Cross-Call-Grid. analyze startet ohne
1229
+ // Vorgaenger; ID-Continuity ist explizit Caller-Aufgabe (analysisId in compare).
1230
+ const gridMap = mapToGridMap(resolved, null);
1231
+ const constraints = parseConstraints(constraintStrings);
1232
+ const constraintResults = checkAllConstraints(constraints, gridMap);
1233
+
1234
+ const arbitrated = arbitrate(constraintResults, []);
1235
+
1236
+ // Schicht 2: Server-generated analysisId (crypto.randomUUID, Node-native, kein Dep).
1237
+ const analysisId = randomUUID();
1238
+
1239
+ // §E1: EINE Pessimismus-Entscheidung pro issue, VOR den Formattern. BEIDE
1240
+ // Formatter bekommen DIESELBE gegatete arbitrated (failing := gegatet) —
1241
+ // symmetrisch, EINE Wahrheitsquelle, kein ungegateter Seitenkanal (R3).
1242
+ const gated = { ...arbitrated, failing: gateFailing(gridMap, arbitrated) };
1243
+ // §H9 K-03/K-24/K-05: die R9-stabilisierte Verlust-Liste in die Prosa-opts —
1244
+ // die ⚠-Zeile nennt die ECHTEN Ursachen statt des Hardcodes "(id/name)".
1245
+ const prose = formatReport(gridMap, gated, { canvasValidity, sanitizeLoss });
1246
+ const structured = formatStructured(gridMap, gated, {
1247
+ previousIssueCount,
1248
+ analysisId,
1249
+ canvasValidity,
1250
+ });
1251
+
1252
+ // Eviction: oldest-key (Map.keys().next().value) BEFORE insert wenn Cap erreicht.
1253
+ if (grids.size >= maxGrids) {
1254
+ const oldest = grids.keys().next().value;
1255
+ grids.delete(oldest);
1256
+ }
1257
+ grids.set(analysisId, gridMap);
1258
+
1259
+ // §HEAL-4 MK3: Mess-Ausfall LAUT melden (scene-Marker + Prosa-WARNUNG) —
1260
+ // nie still; das statische Ergebnis oben ist davon unberührt (additiv).
1261
+ if (mediaMeasure.unavailable)
1262
+ return withMeasureUnavailable(prose, structured);
1263
+ return { prose, structured };
1264
+ }
1265
+
1266
+ /**
1267
+ * Compare: resolve new SVG, diff against a stored analyze-state, report changes.
1268
+ *
1269
+ * @param {string} svgString
1270
+ * @param {string[]} [constraintStrings]
1271
+ * @param {string} analysisId - REQUIRED; explizite ID aus vorherigem analyze.
1272
+ * §1.1 Stateless RPC: kein impliziter Default; Schema (compareInput) erzwingt
1273
+ * das Pflichtfeld am Interface-Layer.
1274
+ * Fehlt das Grid zur uebergebenen analysisId (z.B. evicted) → Hinweis-Pfad.
1275
+ *
1276
+ * Read-only contract: compare mutiert die grids-Map NICHT. Fuer chained compares
1277
+ * muss der Caller analyze erneut aufrufen, um eine neue analysisId zu erhalten.
1278
+ *
1279
+ * Returns { prose: string, structured: (object|null) } for dual response —
1280
+ * §H9 K-13bc: structured ist auch im No-Baseline-Fall non-null (ehrliche
1281
+ * analyzeOutput-konforme Error-Hülle, status:'FAIL'); null NUR im
1282
+ * resolve-Error-Pfad ohne Sanitize-Verlust (buildErrorResult-Sentinel,
1283
+ * den tools.js in eine Error-Response übersetzt).
1284
+ */
1285
+ export async function compare(svgString, constraintStrings = [], analysisId) {
1286
+ if (!page) await init();
1287
+
1288
+ // §1.4 Disjunktion: analysisId kann eine UUID (grids) ODER ein Bookmark-Name
1289
+ // (bookmarks) sein. Keyspaces sind disjunkt (bookmarkInput verbietet UUID-Form).
1290
+ // Bookmark löst zur Quell-UUID auf (KORR-2): formatStructured bekommt IMMER die
1291
+ // UUID, NIE den Namen — hält die §1.3-Server-Garantie (iteration.analysisId=UUID).
1292
+ let previousGrid = null;
1293
+ let baselineId = null;
1294
+ if (analysisId) {
1295
+ if (grids.has(analysisId)) {
1296
+ previousGrid = grids.get(analysisId);
1297
+ baselineId = analysisId;
1298
+ } else if (bookmarks.has(analysisId)) {
1299
+ const b = bookmarks.get(analysisId);
1300
+ previousGrid = b.gridMap;
1301
+ baselineId = b.analysisId;
1302
+ }
1303
+ }
1304
+ if (!previousGrid) {
1305
+ // §H9 K-13bc: ehrliches non-null Fehler-Objekt statt Schema-Bruch via
1306
+ // null — WIEDERVERWENDUNG der bestehenden Error-Hülle (analyzeOutput-
1307
+ // konform, Präzedenz: buildErrorResult lossy-Zweig) + kanal-neutrale
1308
+ // Hinweis-Prosa (NO_BASELINE_HINT, eine Quelle).
1309
+ // §6 RELAIS: derselbe Hint zusätzlich maschinenlesbar als additives
1310
+ // error{code,hint} IN der Hülle (R9a #13/#14 — der Schema-geführte
1311
+ // Konsument sah eine Wand; jetzt trägt structured die Navigation).
1312
+ // Parity per Konstruktion: prose === error.hint === NO_BASELINE_HINT.
1313
+ return {
1314
+ prose: NO_BASELINE_HINT,
1315
+ structured: {
1316
+ ...analyzeErrorStructured(),
1317
+ error: { code: 'NO_BASELINE', hint: NO_BASELINE_HINT },
1318
+ },
1319
+ };
1320
+ }
1321
+
1322
+ const resolved = await fireResolve(svgString);
1323
+ // §HEAL-R6 / T1 (F-AT-6-05): Error-Pfad trägt das laute lossy-Signal.
1324
+ if (resolved.error) return buildErrorResult(resolved);
1325
+
1326
+ // §HEAL-R6 / T1 "das Kabel" (F-AT-6-08): canvas_validity aus
1327
+ // resolved.sanitize_loss DIREKT (Anti-LECK-3), R9-stabilisiert.
1328
+ const sanitizeLoss = stabilizeSanitizeLoss(resolved.sanitize_loss);
1329
+ const canvasValidity = deriveCanvasValidity(resolved, sanitizeLoss);
1330
+
1331
+ const gridMap = mapToGridMap(resolved, previousGrid);
1332
+ const constraints = parseConstraints(constraintStrings);
1333
+ const constraintResults =
1334
+ constraints.length > 0 ? checkAllConstraints(constraints, gridMap) : [];
1335
+
1336
+ const diff = computeDiff(previousGrid, gridMap);
1337
+ const arbitrated = arbitrate(constraintResults, diff);
1338
+
1339
+ // §E1: EINE Pessimismus-Entscheidung pro issue, VOR den Formattern. BEIDE
1340
+ // Formatter konsumieren DIESELBE gegatete arbitrated (R3-Symmetrie).
1341
+ const gated = { ...arbitrated, failing: gateFailing(gridMap, arbitrated) };
1342
+ // §H9 K-03/K-24/K-05: echte Verlust-Ursachen in die ⚠-Zeile (wie analyze).
1343
+ const prose = formatReport(gridMap, gated, { canvasValidity, sanitizeLoss });
1344
+ // analysisId in Output: die aufgelöste Quell-UUID (baselineId), NIE der Name.
1345
+ // §1.4 KORR-2: hält die §1.3-Server-Garantie (iteration.analysisId ist UUID).
1346
+ const structured = formatStructured(gridMap, gated, {
1347
+ analysisId: baselineId,
1348
+ canvasValidity,
1349
+ });
1350
+
1351
+ // §1.1 Stateless RPC: compare ist read-only, mutiert grids/bookmarks nicht.
1352
+ return { prose, structured };
1353
+ }
1354
+
1355
+ /**
1356
+ * §1.4 Bookmark: speichert eine bestehende analyze-GridMap unter einem Namen
1357
+ * als langlebige Baseline für den Sniper-Loop (analyze → bookmark → edit →
1358
+ * compare(name)). Referenziert eine EXPLIZITE analysisId (stateless-konsistent
1359
+ * zu §1.1; kein 'most-recent'). LRU-Eviction bei Cap (insertion-order, re-set =
1360
+ * newest). Speichert {gridMap, analysisId} damit compare(name) zur Quell-UUID
1361
+ * auflösen kann (KORR-2 — hält §1.3-Server-Garantie).
1362
+ *
1363
+ * @param {string} name - Bookmark-Name (kein UUID-Format, Schema erzwingt es).
1364
+ * @param {string} analysisId - UUID einer bestehenden analyze-GridMap.
1365
+ * Returns { prose, structured }. structured===null wenn analysisId unbekannt.
1366
+ */
1367
+ export function bookmark(name, analysisId) {
1368
+ const gridMap = grids.get(analysisId);
1369
+ if (!gridMap) {
1370
+ // §6 RELAIS: code+hint an der pipeline-Quelle (BOOKMARK_UNKNOWN_HINT,
1371
+ // eine Quelle — Prosa byte-identisch zu vorher); tools.js bettet sie in
1372
+ // die bookmarkOutput-Error-Hülle (error{code,hint}, NUR bei isError).
1373
+ return {
1374
+ prose: BOOKMARK_UNKNOWN_HINT,
1375
+ structured: null,
1376
+ error: { code: 'ANALYSIS_NOT_FOUND', hint: BOOKMARK_UNKNOWN_HINT },
1377
+ };
1378
+ }
1379
+
1380
+ // LRU: re-set eines bestehenden Namens macht ihn zum jüngsten Eintrag.
1381
+ bookmarks.delete(name);
1382
+ if (bookmarks.size >= maxBookmarks) {
1383
+ const oldest = bookmarks.keys().next().value;
1384
+ bookmarks.delete(oldest);
1385
+ }
1386
+ bookmarks.set(name, { gridMap, analysisId });
1387
+
1388
+ return {
1389
+ prose: `Bookmark '${name}' gespeichert (${bookmarks.size}/${maxBookmarks}).`,
1390
+ structured: {
1391
+ name,
1392
+ analysisId,
1393
+ stored: true,
1394
+ bookmarkCount: bookmarks.size,
1395
+ },
1396
+ };
1397
+ }
1398
+
1399
+ /**
1400
+ * Inspect: resolve SVG and return grid map without constraints.
1401
+ * Returns { prose: string, structured: object }.
1402
+ */
1403
+ export async function inspect(svgString) {
1404
+ if (!page) await init();
1405
+
1406
+ const resolved = await fireResolve(svgString);
1407
+ // §HEAL-R6 / T1 (F-AT-6-05): Error-Pfad trägt das laute lossy-Signal
1408
+ // (inspectOutput-konforme Error-Hülle, daher kind='inspect').
1409
+ if (resolved.error) return buildErrorResult(resolved, 'inspect');
1410
+
1411
+ // §HEAL-R6 / T1 "das Kabel" (F-AT-6-08): canvas_validity aus
1412
+ // resolved.sanitize_loss DIREKT (Anti-LECK-3), R9-stabilisiert.
1413
+ const sanitizeLoss = stabilizeSanitizeLoss(resolved.sanitize_loss);
1414
+ const canvasValidity = deriveCanvasValidity(resolved, sanitizeLoss);
1415
+
1416
+ // §HEAL-4: additiver Viewport-Mess-Schritt — identische Verdrahtung wie in
1417
+ // analyze() (NACH fireResolve, VOR mapToGridMap; Ausfall ⇒ MK3-Marker).
1418
+ const mediaMeasure = await measureMediaStep(svgString, resolved);
1419
+
1420
+ // §1.1 Stateless RPC: inspect ist eine isolierte Probe ohne Cross-Call-Grid.
1421
+ const gridMap = mapToGridMap(resolved, null);
1422
+ const arbitrated = arbitrate([], []);
1423
+
1424
+ // §E1 Identitaetsfall: inspect hat keine constraints → failing===[] →
1425
+ // gateCorrections([], …)===[]. Die gegatete (leere) Liste geht als
1426
+ // arbitrated.failing rein (R3-Symmetrie); fail-closed-Vertrag trivial erfuellt.
1427
+ const gated = { ...arbitrated, failing: gateFailing(gridMap, arbitrated) };
1428
+ // §H9 K-03/K-24/K-05: echte Verlust-Ursachen in die ⚠-Zeile (wie analyze).
1429
+ const prose = formatReport(gridMap, gated, { canvasValidity, sanitizeLoss });
1430
+ const structured = formatInspectStructured(gridMap, { canvasValidity });
1431
+
1432
+ // §HEAL-4 MK3: Mess-Ausfall LAUT melden (scene-Marker + Prosa-WARNUNG).
1433
+ if (mediaMeasure.unavailable)
1434
+ return withMeasureUnavailable(prose, structured);
1435
+ return { prose, structured };
1436
+ }
1437
+
1438
+ /**
1439
+ * Palette: resolve SVG and return color information.
1440
+ * Returns { prose: string, structured: object }.
1441
+ */
1442
+ export async function palette(svgString) {
1443
+ if (!page) await init();
1444
+
1445
+ const resolved = await fireResolve(svgString);
1446
+ if (resolved.error) {
1447
+ // §6 RELAIS: code+hint aus resolved (pipeline-Quelle, Muster
1448
+ // buildErrorResult); §P1 Navigations-Zusatz via renderErrorHint (eine
1449
+ // Quelle), Prosa trägt den hint wortidentisch (Parity per Konstruktion).
1450
+ const hint = renderErrorHint(resolved.message);
1451
+ return {
1452
+ prose: `Fehler: ${hint}`,
1453
+ structured: null,
1454
+ error: { code: resolved.error, hint },
1455
+ };
1456
+ }
1457
+
1458
+ // §1.1 Stateless RPC: palette ist eine isolierte Probe ohne Cross-Call-Grid.
1459
+ const gridMap = mapToGridMap(resolved, null);
1460
+
1461
+ const lines = [`PALETTE: ${gridMap.elements.length} Elemente`];
1462
+ for (const el of gridMap.elements.slice(0, 7)) {
1463
+ const stroke =
1464
+ el.stroke && el.stroke !== 'transparent' ? ` [Rand: ${el.stroke}]` : '';
1465
+ lines.push(` ${el.tag}#${el.id}: ${el.color}${stroke}`);
1466
+ }
1467
+ if (gridMap.elements.length > 7)
1468
+ lines.push(` (${gridMap.elements.length - 7} weitere)`);
1469
+
1470
+ return {
1471
+ prose: lines.join('\n'),
1472
+ structured: formatPaletteStructured(gridMap.elements),
1473
+ };
1474
+ }
1475
+
1476
+ /**
1477
+ * Meta: list available constraint types with syntax.
1478
+ * Returns { prose: string, structured: object }.
1479
+ */
1480
+ export function getConstraintTypes() {
1481
+ const types = listConstraints();
1482
+ const syntaxMap = {
1483
+ 'CENTERED-IN': '#subject CENTERED-IN #reference',
1484
+ 'NO-OVERLAP': '#subject NO-OVERLAP #reference',
1485
+ INSIDE: '#subject INSIDE #reference',
1486
+ 'ALIGNED-LEFT': '#subject ALIGNED-LEFT #reference',
1487
+ 'ALIGNED-TOP': '#subject ALIGNED-TOP #reference',
1488
+ 'LEFT-OF': '#subject LEFT-OF #reference',
1489
+ ABOVE: '#subject ABOVE #reference',
1490
+ 'SAME-SIZE': '#subject SAME-SIZE #reference',
1491
+ 'DISTANCE-FROM': '#subject DISTANCE-FROM #reference N',
1492
+ COLOR: '#subject COLOR farbname',
1493
+ FILL: '#subject FILL canvas',
1494
+ };
1495
+
1496
+ const lines = [`CONSTRAINTS: ${types.length} Typen verfuegbar`];
1497
+ for (const t of types) {
1498
+ const syntax = syntaxMap[t.type] || `#subject ${t.type} #reference`;
1499
+ const arrange = t.hasArrange ? ' [+arrange]' : '';
1500
+ lines.push(` ${t.type}: ${syntax}${arrange}`);
1501
+ }
1502
+
1503
+ return {
1504
+ prose: lines.join('\n'),
1505
+ structured: {
1506
+ types: types.map((t) => ({
1507
+ type: t.type,
1508
+ syntax: syntaxMap[t.type] || `#subject ${t.type} #reference`,
1509
+ hasArrange: t.hasArrange,
1510
+ })),
1511
+ },
1512
+ };
1513
+ }
1514
+
1515
+ /**
1516
+ * Meta: server status.
1517
+ * Returns { prose: string, structured: object }.
1518
+ */
1519
+ export function getStatus() {
1520
+ const types = listConstraints();
1521
+ const browserRunning = page !== null;
1522
+ const breakerStats = renderBreaker ? getBreakerStats(renderBreaker) : null;
1523
+ const breakerSummary = breakerStats
1524
+ ? ` | Breaker: ${breakerStats.state} (fires=${breakerStats.fires}, fails=${breakerStats.failures})`
1525
+ : '';
1526
+
1527
+ // §1.9: Kalibrierungs-Stand surfacen (PENDING bis Auto-Selftest fertig).
1528
+ const calibSummary = lastCalibration
1529
+ ? ` | Kalibrierung: ${lastCalibration.status} (${lastCalibration.calibrated}/${lastCalibration.total})`
1530
+ : '';
1531
+
1532
+ return {
1533
+ prose: `Vector Mirror v2.0.0 | Browser: ${browserRunning ? 'running' : 'stopped'} | Letzter Stand: ${grids.size > 0 ? 'ja' : 'nein'} | ${types.length} Constraint-Typen${breakerSummary}${calibSummary}`,
1534
+ structured: {
1535
+ version: '2.0.0',
1536
+ browser: browserRunning ? 'running' : 'stopped',
1537
+ lastAnalysis: grids.size > 0,
1538
+ constraintTypes: types.length,
1539
+ breaker: breakerStats,
1540
+ // §1.9: nullable/optional — kein Bruch bestehender E2E-Roundtrip-Asserts.
1541
+ calibration: lastCalibration,
1542
+ },
1543
+ };
1544
+ }
1545
+
1546
+ function cloneArrangeState(state) {
1547
+ return new Map(
1548
+ Array.from(state.entries(), ([id, entry]) => [
1549
+ id,
1550
+ {
1551
+ ...entry,
1552
+ bbox: { ...entry.bbox },
1553
+ },
1554
+ ]),
1555
+ );
1556
+ }
1557
+
1558
+ /**
1559
+ * Arrange: compute SVG attributes from canvas + elements + constraints (pure math).
1560
+ * Inverse of the analysis pipeline — no browser needed.
1561
+ * Sequential constraint processing: each constraint sees the updated state from the previous one.
1562
+ * Returns { prose: string, structured: object }.
1563
+ */
1564
+ export function arrange(canvas, elements, constraintStrings) {
1565
+ const constraints = parseConstraints(constraintStrings);
1566
+ const warnings = [];
1567
+
1568
+ // Build mutable state map with initial BBox from element properties
1569
+ const state = new Map();
1570
+ for (const el of elements) {
1571
+ if (state.has(el.id)) {
1572
+ warnings.push(
1573
+ `Element-ID #${el.id} ist mehrfach vorhanden; spaeterer Eintrag wird ignoriert`,
1574
+ );
1575
+ continue;
1576
+ }
1577
+ const w = el.width ?? (el.r ? el.r * 2 : 0);
1578
+ const h = el.height ?? (el.r ? el.r * 2 : 0);
1579
+ state.set(el.id, {
1580
+ id: el.id,
1581
+ tag: el.tag,
1582
+ bbox: { x: el.x ?? 0, y: el.y ?? 0, w, h },
1583
+ content: el.content ?? null,
1584
+ });
1585
+ }
1586
+ const originalState = cloneArrangeState(state);
1587
+
1588
+ // Result map accumulates all attribute patches per element
1589
+ const results = new Map();
1590
+
1591
+ // §H10 R11-11 (a) ABHÄNGIGKEITS-FLAG (O2): flaches Set der Subjekte, deren
1592
+ // Platzierungs-Constraint verweigert wurde und die daher an der AUSGANGSLAGE
1593
+ // stehen. Spätere Constraints, die so ein Subjekt referenzieren, rechnen WIE
1594
+ // HEUTE (die Messung gegen die Ausgangslage ist wahr) — tragen aber das
1595
+ // ehrliche Flag auf der warnings-Schiene (Flag statt Wert-Eingriff). Eine
1596
+ // spätere erfolgreiche Platzierung löscht den Eintrag (Aussage bleibt wahr).
1597
+ const unplacedRefused = new Set();
1598
+
1599
+ // Process constraints sequentially — order matters
1600
+ for (const c of constraints) {
1601
+ // §H10 R11-21: Parse-Verweigerung auf der arrange-eigenen warnings-Schiene
1602
+ // (gleicher Wortlaut wie der analyze-Pfad — eine Quelle: unparseableDetail).
1603
+ if (c.type === 'CONSTRAINT_UNPARSEABLE') {
1604
+ warnings.push(`${unparseableDetail(c)}. ${CONSTRAINT_GRAMMAR_HINT}`);
1605
+ continue;
1606
+ }
1607
+ const subjState = state.get(c.subject);
1608
+ if (!subjState) {
1609
+ warnings.push(`Element #${c.subject} nicht gefunden`);
1610
+ continue;
1611
+ }
1612
+
1613
+ // §H10 R11-11 (b) IDENTITÄTS-WACHE (O2): Subjekt === Referenz ⇒ der Patch
1614
+ // wäre die eigene Ist-Lage (leeres Echo) — verweigern statt „platzieren"
1615
+ // (kein attributes-Eintrag), symmetrisch zur checkAllConstraints-Wache.
1616
+ if (c.reference != null && c.reference === c.subject) {
1617
+ warnings.push(
1618
+ `Selbst-Referenz: Subjekt und Referenz sind dasselbe Element (#${c.subject}) — ${c.type} wirkungslos, verweigert`,
1619
+ );
1620
+ unplacedRefused.add(c.subject);
1621
+ continue;
1622
+ }
1623
+
1624
+ const refState = c.reference ? state.get(c.reference) : null;
1625
+ if (c.reference && !refState) {
1626
+ warnings.push(`Referenz #${c.reference} nicht gefunden`);
1627
+ unplacedRefused.add(c.subject);
1628
+ continue;
1629
+ }
1630
+ // Guard: constraints requiring a reference but missing one (e.g. '#a DISTANCE-FROM' without ref)
1631
+ // FINDING-2-Fix (E6 Re-Review-2): EINE Wahrheit — registry-getriebenes
1632
+ // requiresReference() statt zweiter hartkodierter noRefTypes-Liste. Verhalten
1633
+ // fuer COLOR/FILL (requiresReference:false) UNVERAENDERT; unbekannte Typen
1634
+ // bleiben fail-closed ref-pflichtig (requiresReference(unbekannt)=true) wie
1635
+ // zuvor. Ein kuenftiger custom ref-freier Constraint wird hier NICHT mehr
1636
+ // faelschlich blockiert (er markiert requiresReference:false bei der
1637
+ // Registrierung — analyze- und arrange-Pfad teilen dieselbe SSOT).
1638
+ if (!refState && requiresReference(c.type)) {
1639
+ warnings.push(`${c.type} benoetigt eine Referenz`);
1640
+ unplacedRefused.add(c.subject);
1641
+ continue;
1642
+ }
1643
+
1644
+ // §H10 R11-11 (a): die Referenz steht (verweigert) an der Ausgangslage —
1645
+ // Wert unangetastet rechnen, Vorbehalt aussprechen (ehrliches Flag).
1646
+ if (c.reference && unplacedRefused.has(c.reference)) {
1647
+ warnings.push(
1648
+ `#${c.subject} ${c.type} #${c.reference}: Referenz #${c.reference} wurde nicht platziert (eigene Constraint verweigert) — Platzierung basiert auf der Ausgangslage`,
1649
+ );
1650
+ }
1651
+
1652
+ const ctx = { canvas };
1653
+ if (c.value !== undefined) ctx.value = c.value;
1654
+
1655
+ const patch = arrangeConstraint(c.type, subjState, refState, ctx);
1656
+ if (patch === null) {
1657
+ if (c.type !== 'COLOR') {
1658
+ warnings.push(`${c.type} hat keine arrange-Funktion`);
1659
+ unplacedRefused.add(c.subject);
1660
+ }
1661
+ continue;
1662
+ }
1663
+
1664
+ // Merge patch into results
1665
+ if (!results.has(c.subject)) results.set(c.subject, {});
1666
+ const current = results.get(c.subject);
1667
+ Object.assign(current, patch);
1668
+ // §H10 R11-11 (a): erfolgreich platziert ⇒ Flag-Aussage wäre falsch.
1669
+ unplacedRefused.delete(c.subject);
1670
+
1671
+ // Update BBox in state so next constraint sees new position/size
1672
+ if (patch.x !== undefined) subjState.bbox.x = patch.x;
1673
+ if (patch.y !== undefined) subjState.bbox.y = patch.y;
1674
+ if (patch.cx !== undefined)
1675
+ subjState.bbox.x = patch.cx - subjState.bbox.w / 2;
1676
+ if (patch.cy !== undefined)
1677
+ subjState.bbox.y = patch.cy - subjState.bbox.h / 2;
1678
+ if (patch.width !== undefined) subjState.bbox.w = patch.width;
1679
+ if (patch.height !== undefined) subjState.bbox.h = patch.height;
1680
+ if (patch.r !== undefined) {
1681
+ subjState.bbox.w = patch.r * 2;
1682
+ subjState.bbox.h = patch.r * 2;
1683
+ }
1684
+ }
1685
+
1686
+ // Enrich: convert constraint outputs to valid SVG attributes per tag
1687
+ const attributes = {};
1688
+ for (const [id, attrs] of results) {
1689
+ const el = elements.find((e) => e.id === id);
1690
+ if (!el) continue;
1691
+
1692
+ const stateEntry = state.get(id);
1693
+ const originalEntry = originalState.get(id);
1694
+ const enriched = {};
1695
+ const hadPositionPatch =
1696
+ attrs.x !== undefined ||
1697
+ attrs.y !== undefined ||
1698
+ attrs.cx !== undefined ||
1699
+ attrs.cy !== undefined;
1700
+
1701
+ if (attrs.r !== undefined) {
1702
+ enriched.r = attrs.r;
1703
+ }
1704
+ if (
1705
+ el.tag === 'circle' &&
1706
+ (attrs.width !== undefined || attrs.height !== undefined)
1707
+ ) {
1708
+ enriched.r = Math.min(stateEntry.bbox.w, stateEntry.bbox.h) / 2;
1709
+ } else {
1710
+ if (attrs.width !== undefined) enriched.width = attrs.width;
1711
+ if (attrs.height !== undefined) enriched.height = attrs.height;
1712
+ }
1713
+
1714
+ if (hadPositionPatch) {
1715
+ const dx =
1716
+ Math.round((stateEntry.bbox.x - originalEntry.bbox.x) * 10) / 10;
1717
+ const dy =
1718
+ Math.round((stateEntry.bbox.y - originalEntry.bbox.y) * 10) / 10;
1719
+ if (dx !== 0 || dy !== 0 || hasTranslateTransform(el.transform)) {
1720
+ enriched.transform = buildTransform(el.transform, dx, dy);
1721
+ }
1722
+ }
1723
+
1724
+ if (enriched.transform === undefined) {
1725
+ if (attrs.x !== undefined) enriched.x = attrs.x;
1726
+ if (attrs.y !== undefined) enriched.y = attrs.y;
1727
+ }
1728
+ attributes[id] = enriched;
1729
+ }
1730
+
1731
+ const prose = formatArrangeReport(attributes, warnings);
1732
+ return { prose, structured: { attributes, warnings } };
1733
+ }
1734
+
1735
+ // =============================================================================
1736
+ // §1.9 EICHKÖRPER-SELFTEST (Kalibrierung, anti-zirkulär REGEL-2)
1737
+ // =============================================================================
1738
+ //
1739
+ // Der Selftest ist ein ehrliches MESSWERK (REGEL-3/9): er MISST nur, ändert
1740
+ // keine Grid/Spotter/Sniper-Logik. Er lädt die 5 golden Eichkörper (EK-1..5),
1741
+ // ruft das jeweilige Tool und PARTIAL-matcht das Ergebnis gegen die UNABHÄNGIG
1742
+ // aus den Spec-Formeln abgeleiteten expected-Felder (EK-*.expected.json). Der
1743
+ // PARTIAL-Match (nur anti-zirk-Felder) ist Pflicht: ein voller bytewise-Vergleich
1744
+ // gegen expected.json wäre zirkulär (man kopiert Tool-Output rein). Die expected-
1745
+ // Werte sind die SPEC-WAHRHEIT (Grid/Farbe/Reliability-Formeln), nicht Tool-Output.
1746
+
1747
+ const __selftestDir = join(
1748
+ dirname(fileURLToPath(import.meta.url)),
1749
+ '..',
1750
+ 'tests',
1751
+ 'fixtures',
1752
+ 'golden',
1753
+ );
1754
+
1755
+ function __loadEk(name) {
1756
+ const svg = readFileSync(join(__selftestDir, `${name}.svg`), 'utf8');
1757
+ const expected = JSON.parse(
1758
+ readFileSync(join(__selftestDir, `${name}.expected.json`), 'utf8'),
1759
+ );
1760
+ return { svg, expected };
1761
+ }
1762
+
1763
+ /** Findet ein scene.elements-Element per id. */
1764
+ function __findEl(scene, id) {
1765
+ return (scene?.elements || []).find((e) => e.id === id);
1766
+ }
1767
+
1768
+ /**
1769
+ * §1.9 PARTIAL-Match eines EK gegen seine anti-zirk-expected-Felder. Liefert
1770
+ * eine Liste von Mismatch-Strings (leer === PASS). Asserted NUR die im
1771
+ * expected.json hinterlegten Spec-Felder (grid, cell, color, status,
1772
+ * total_issues, convergence, reliability, warnings, suppression), NIE den
1773
+ * vollen Output (Anti-Zirkularität).
1774
+ */
1775
+ async function __checkEk(name) {
1776
+ const { svg, expected } = __loadEk(name);
1777
+ const mism = [];
1778
+ const tool = expected.tool;
1779
+ const cons = expected.constraints || [];
1780
+
1781
+ if (tool === 'palette') {
1782
+ const r = await palette(svg);
1783
+ const got = r.structured?.colors || [];
1784
+ for (const want of expected.expected.colors) {
1785
+ const g = got.find((c) => c.id === want.id);
1786
+ if (!g) mism.push(`${name}: color id '${want.id}' fehlt`);
1787
+ else if (g.fill !== want.fill)
1788
+ mism.push(
1789
+ `${name}: ${want.id}.fill '${g.fill}' !== spec '${want.fill}'`,
1790
+ );
1791
+ }
1792
+ } else if (tool === 'inspect') {
1793
+ const r = await inspect(svg);
1794
+ const scene = r.structured?.scene;
1795
+ const exp = expected.expected.scene;
1796
+ if (exp.grid !== undefined && scene?.grid !== exp.grid)
1797
+ mism.push(`${name}: grid '${scene?.grid}' !== spec '${exp.grid}'`);
1798
+ if (exp.width !== undefined && scene?.width !== exp.width)
1799
+ mism.push(`${name}: width ${scene?.width} !== spec ${exp.width}`);
1800
+ if (exp.height !== undefined && scene?.height !== exp.height)
1801
+ mism.push(`${name}: height ${scene?.height} !== spec ${exp.height}`);
1802
+ const want = exp.elements || exp.elements_contains || [];
1803
+ for (const w of want) {
1804
+ const g = __findEl(scene, w.id);
1805
+ if (!g) {
1806
+ mism.push(`${name}: element '${w.id}' fehlt in scene`);
1807
+ continue;
1808
+ }
1809
+ for (const key of ['tag', 'cell', 'color', 'bbox_reliability']) {
1810
+ if (w[key] !== undefined && g[key] !== w[key])
1811
+ mism.push(`${name}: ${w.id}.${key} '${g[key]}' !== spec '${w[key]}'`);
1812
+ }
1813
+ if (w.warnings !== undefined) {
1814
+ const gw = JSON.stringify(g.warnings || []);
1815
+ const ww = JSON.stringify(w.warnings);
1816
+ if (gw !== ww)
1817
+ mism.push(`${name}: ${w.id}.warnings ${gw} !== spec ${ww}`);
1818
+ }
1819
+ }
1820
+ } else if (tool === 'analyze') {
1821
+ const r = await analyze(svg, cons);
1822
+ const s = r.structured;
1823
+ const exp = expected.expected;
1824
+ if (exp.status !== undefined && s?.status !== exp.status)
1825
+ mism.push(`${name}: status '${s?.status}' !== spec '${exp.status}'`);
1826
+ if (exp.iteration) {
1827
+ for (const key of Object.keys(exp.iteration)) {
1828
+ if (s?.iteration?.[key] !== exp.iteration[key])
1829
+ mism.push(
1830
+ `${name}: iteration.${key} ${s?.iteration?.[key]} !== spec ${exp.iteration[key]}`,
1831
+ );
1832
+ }
1833
+ }
1834
+ for (const want of exp.corrections_contains || []) {
1835
+ const hit = (s?.corrections || []).some(
1836
+ (c) => c.element === want.element && c.constraint === want.constraint,
1837
+ );
1838
+ if (!hit)
1839
+ mism.push(
1840
+ `${name}: correction ${want.element}:${want.constraint} fehlt`,
1841
+ );
1842
+ }
1843
+ }
1844
+
1845
+ // §S4/D2 Frozen-Clock-Assert (REGEL-2 + Residual #1 Mitigation): NUR für den
1846
+ // Zeit-EK (EK-5). Belegt die Frame-vs-Clock-KOPPLUNG mit einem GEOMETRIE-
1847
+ // Doppelbeleg — nicht nur getCurrentTime()===0 (das wäre F-TF-019-Klasse:
1848
+ // belegt die Uhr, nicht dass getBBox den t=0-Frame sieht). #anim cx from=30
1849
+ // to=170 dur=2s: Center-x MUSS bei t=0 ≈ 30 UND bei t=1.0 ≈ 100 (30+(170-30)
1850
+ // *0.5) sein. WICHTIG (REGEL-2): die expected-WERTE bleiben bit-identisch;
1851
+ // weicht hier etwas ab → echter seek/Reflow-Bug = Honest-Red, NICHT die
1852
+ // expected.json nachziehen.
1853
+ if (expected.frozen_clock_assert) {
1854
+ const g0 = await __probeFrozenGeometryAt(svg, 'anim', 0);
1855
+ const g1 = await __probeFrozenGeometryAt(svg, 'anim', 1.0);
1856
+ if (g0.error)
1857
+ mism.push(`${name}: frozen_clock t=0-Probe-Fehler ${g0.error}`);
1858
+ else if (Math.abs(g0.cx - 30) >= 1.0)
1859
+ mism.push(
1860
+ `${name}: frozen_clock cx@t0 ${g0.cx?.toFixed?.(3)} !== 30 (±1.0) — Clock NICHT bei t=0 eingefroren`,
1861
+ );
1862
+ if (g1.error)
1863
+ mism.push(`${name}: frozen_clock t=1.0-Probe-Fehler ${g1.error}`);
1864
+ else if (Math.abs(g1.cx - 100) >= 2.0)
1865
+ mism.push(
1866
+ `${name}: frozen_clock cx@t1.0 ${g1.cx?.toFixed?.(3)} !== 100 (±2.0) — seek bewegt die Geometrie NICHT (Frame-vs-Clock-Entkopplung)`,
1867
+ );
1868
+ if (!g0.error && !g1.error && Math.abs(g1.cx - g0.cx - 70) >= 3.0)
1869
+ mism.push(
1870
+ `${name}: frozen_clock KOPPLUNG-Delta ${(g1.cx - g0.cx)?.toFixed?.(3)} !== 70 (±3.0)`,
1871
+ );
1872
+ }
1873
+
1874
+ // §1.9 EK-4 Suppression (REGEL-3 Spotter-Anti-Lüge): analyze-correction für
1875
+ // ein not_measurable-Element DARF KEINE Pixel-Deltas/fix tragen.
1876
+ if (expected.expected.analyze_suppression) {
1877
+ const sup = expected.expected.analyze_suppression;
1878
+ const r = await analyze(svg, sup.constraints);
1879
+ const corr = (r.structured?.corrections || []).find(
1880
+ (c) => c.element === sup.element,
1881
+ );
1882
+ if (!corr) {
1883
+ mism.push(`${name}: suppression-correction ${sup.element} fehlt`);
1884
+ } else {
1885
+ for (const key of sup.forbidden_keys) {
1886
+ if (corr[key] !== undefined)
1887
+ mism.push(
1888
+ `${name}: suppression-Bruch — ${sup.element}.${key} ist gesetzt (REGEL-3)`,
1889
+ );
1890
+ }
1891
+ }
1892
+ }
1893
+
1894
+ return mism;
1895
+ }
1896
+
1897
+ const __EK_NAMES = [
1898
+ 'EK-1_color',
1899
+ 'EK-2_position',
1900
+ 'EK-3_constraint',
1901
+ 'EK-4_3d',
1902
+ 'EK-5_animation',
1903
+ ];
1904
+
1905
+ /**
1906
+ * §1.9 Eichkörper-Selftest: läuft die 5 EK-Fixtures, PARTIAL-matcht jede gegen
1907
+ * ihre anti-zirk-expected-Felder (Spec-Wahrheit). full=true → zusätzlich ein
1908
+ * N=10-Mini-Determinismus-Check (gleicher EK 10× via inspect, gestrippte
1909
+ * Bytewise-Gleichheit der scene). Server-Start-tauglich (schnell, ein Browser).
1910
+ *
1911
+ * Setzt den Modul-State lastCalibration (status.calibration lesbar). Returns
1912
+ * { status:'PASS'|'FAIL', calibrated, total, failures, structured, prose }.
1913
+ */
1914
+ export async function runSelftest(full = false) {
1915
+ if (!page) await init();
1916
+
1917
+ const failures = [];
1918
+ let calibrated = 0;
1919
+ for (const name of __EK_NAMES) {
1920
+ let mism;
1921
+ try {
1922
+ mism = await __checkEk(name);
1923
+ } catch (err) {
1924
+ mism = [`${name}: Ausnahme — ${err.message}`];
1925
+ }
1926
+ if (mism.length === 0) calibrated++;
1927
+ else for (const m of mism) failures.push({ ek: name, reason: m });
1928
+ }
1929
+
1930
+ // full=true: schneller N=10-Determinismus-Mini-Check (inspect EK-2, gestrippt).
1931
+ if (full) {
1932
+ const { svg } = __loadEk('EK-2_position');
1933
+ const seen = new Set();
1934
+ for (let i = 0; i < 10; i++) {
1935
+ const r = await inspect(svg);
1936
+ seen.add(JSON.stringify(r.structured?.scene));
1937
+ }
1938
+ if (seen.size !== 1) {
1939
+ failures.push({
1940
+ ek: 'EK-2_position',
1941
+ reason: `full-Determinismus: ${seen.size}/10 unique (erwartet 1)`,
1942
+ });
1943
+ }
1944
+ }
1945
+
1946
+ const status = failures.length === 0 ? 'PASS' : 'FAIL';
1947
+ const total = __EK_NAMES.length;
1948
+ lastCalibration = {
1949
+ status,
1950
+ calibrated,
1951
+ total,
1952
+ timestamp: new Date().toISOString(),
1953
+ };
1954
+
1955
+ const prose =
1956
+ status === 'PASS'
1957
+ ? `Selftest PASS: ${calibrated}/${total} Eichkörper kalibriert (anti-zirk Spec-Match).`
1958
+ : `Selftest FAIL: ${calibrated}/${total} kalibriert, ${failures.length} Abweichung(en):\n` +
1959
+ failures.map((f) => ` - ${f.ek}: ${f.reason}`).join('\n');
1960
+
1961
+ return {
1962
+ status,
1963
+ calibrated,
1964
+ total,
1965
+ failures,
1966
+ prose,
1967
+ structured: { status, calibrated, total, failures },
1968
+ };
1969
+ }
1970
+
1971
+ /**
1972
+ * §1.9 Server-Start-Hook: markiert die Kalibrierung als PENDING, BEVOR der
1973
+ * fire-and-forget runSelftest() nach connect läuft (status.calibration zeigt
1974
+ * dann PENDING bis der Selftest fertig ist). Idempotent.
1975
+ */
1976
+ export function markCalibrationPending() {
1977
+ lastCalibration = {
1978
+ status: 'PENDING',
1979
+ calibrated: 0,
1980
+ total: __EK_NAMES.length,
1981
+ timestamp: new Date().toISOString(),
1982
+ };
1983
+ }