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.
- package/LICENSE +21 -0
- package/README.md +249 -0
- package/package.json +65 -0
- package/src/adapters/emitter/prose.js +689 -0
- package/src/adapters/emitter/structured.js +649 -0
- package/src/adapters/renderer/playwright.js +7345 -0
- package/src/core/arbitrate.js +266 -0
- package/src/core/constraints/_schema.js +89 -0
- package/src/core/constraints/aligned.js +42 -0
- package/src/core/constraints/centered-in.js +29 -0
- package/src/core/constraints/color.js +63 -0
- package/src/core/constraints/distance.js +233 -0
- package/src/core/constraints/fill.js +22 -0
- package/src/core/constraints/inside.js +52 -0
- package/src/core/constraints/loader.js +65 -0
- package/src/core/constraints/no-overlap.js +50 -0
- package/src/core/constraints/positional.js +46 -0
- package/src/core/constraints/registry.js +98 -0
- package/src/core/constraints/same-size.js +35 -0
- package/src/core/diff.js +118 -0
- package/src/core/element_vocabulary.js +241 -0
- package/src/core/grid.js +240 -0
- package/src/core/honesty.js +214 -0
- package/src/core/sanitizer/auto_ids.js +104 -0
- package/src/core/tolerance.js +22 -0
- package/src/core/use_graph.js +541 -0
- package/src/interface/claims.js +439 -0
- package/src/interface/schema.js +626 -0
- package/src/interface/server.js +57 -0
- package/src/interface/tools.js +437 -0
- package/src/lib/bbox.js +17 -0
- package/src/lib/breaker.js +240 -0
- package/src/lib/geom.js +144 -0
- package/src/lib/palette.js +236 -0
- package/src/lib/transforms.js +111 -0
- package/src/pipeline.js +1983 -0
package/src/pipeline.js
ADDED
|
@@ -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
|
+
}
|