vector-mirror 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,266 @@
1
+ /**
2
+ * arbitrate.js — Tri-State Validator (P1-04, VM-LC-001)
3
+ * Vector Mirror v2.5
4
+ *
5
+ * Splits constraint results into three buckets — `passing`, `failing`,
6
+ * `unchecked` — and passes the scene `diff` through severity-sorted.
7
+ * No silent cap: max=3 capping has moved to the presentation layer
8
+ * (`adapters/emitter/prose.js`); structured output ships the full set.
9
+ *
10
+ * Tri-state semantics:
11
+ * pass === true → passing[] (constraint evaluated, satisfied)
12
+ * pass === false → failing[] (constraint evaluated, violated, with deltas)
13
+ * pass === null → unchecked[] (could not evaluate, with reasonCode)
14
+ *
15
+ * Each `unchecked` entry carries a structured reason so the LLM (or a
16
+ * downstream emitter) can act on it instead of seeing a phantom "PASS":
17
+ * - reasonCategory: "SPECIFICATION" | "MODEL"
18
+ * - reasonCode: enum (see REASON_CODES)
19
+ * - hint: human-readable line
20
+ * - suggestedCorrection?: string (only when we can offer one)
21
+ *
22
+ * Source-of-truth references:
23
+ * - FIX_PLAN_2026-04-18 §1.2 P1-04
24
+ * - KATALOG VM-LC-001 (Silent-PASS bei pass:null)
25
+ * - KATALOG VM-LC-003 (max=3-Kappung verletzt ADR-024)
26
+ * - RB-01 §6.1, RB-03 §6.1
27
+ *
28
+ * Core module: NO imports from adapters/ or interface/.
29
+ */
30
+
31
+ /**
32
+ * Severity ordering for diff entries (lower = more important).
33
+ * `failing` issues are pinned at severity 0; diff types follow.
34
+ */
35
+ const DIFF_PRIO = {
36
+ VERSCHOBEN: 1,
37
+ FARBÄNDERUNG: 2,
38
+ NEU: 3,
39
+ ENTFERNT: 4,
40
+ };
41
+
42
+ /**
43
+ * Allowed reasonCode values for unchecked entries.
44
+ * @type {readonly string[]}
45
+ */
46
+ export const REASON_CODES = Object.freeze([
47
+ 'CONSTRAINT_TYPE_UNKNOWN', // unbekannter / falsch geschriebener Typ
48
+ 'SUBJECT_NOT_FOUND', // subjekt-id existiert nicht im scene
49
+ 'REFERENCE_NOT_FOUND', // referenz-id existiert nicht im scene
50
+ 'REFERENCE_DEGENERATE', // referenz-bbox 0×0 oder ungueltig
51
+ 'MEASUREMENT_AMBIGUOUS', // kein klares messverfahren (z.b. fill auf <g>)
52
+ 'INVALID_MEASUREMENT', // mess-vorgabe selbst ungueltig (z.b. negativer ziel-abstand) (D-003)
53
+ 'SEMANTIC_SUSPICIOUS', // syntaktisch ok, aber semantisch verdaechtig
54
+ 'SCOPE_MISMATCH', // constraint passt nicht zu element-typ
55
+ // §HEAL-5 Verdikt-Wache (pipeline.js#classifySubjectHonesty, F-AT-2-005):
56
+ 'SUBJECT_NOT_PAINTED', // subjekt malt 0 pixel (paint_visible:false) — pass:true degradiert
57
+ 'SUBJECT_TIME_VARIANT', // subjekt-geometrie zeit-variant (motion_dependent) — geprueft @t0
58
+ // §H10 R11-07 dritte Wache-Klasse (pipeline.js#classifySubjectHonesty):
59
+ 'SUBJECT_NOT_MEASURABLE', // subjekt-bbox not_measurable (3d/non-smil-motion) — kein gruenes verdikt ueber misstrauter zahl
60
+ // §H10 R11-21 Parse-Verweigerung (pipeline.js#parseConstraints/checkAllConstraints):
61
+ 'CONSTRAINT_UNPARSEABLE', // constraint-string nicht (vollstaendig) gegen die grammatik parsebar — verweigert statt interpretiert
62
+ // §H10 R11-01 Existenz-Register (pipeline.js#checkAllConstraints):
63
+ 'SUBJECT_HIDDEN', // subjekt-id existiert im markup, ist aber css-unsichtbar @t0 — nicht gemessen
64
+ 'REFERENCE_HIDDEN', // referenz-id existiert im markup, ist aber css-unsichtbar @t0 — symmetrie zu SUBJECT_HIDDEN (P1)
65
+ ]);
66
+
67
+ /**
68
+ * Constraint types currently registered in `core/constraints/loader.js`.
69
+ * Used as the dictionary for typo-suggestions in unchecked entries.
70
+ * Mirrors ADR-029 Tier-A (11 types). When loader.js gains new types,
71
+ * extend this list.
72
+ */
73
+ const KNOWN_CONSTRAINT_TYPES = Object.freeze([
74
+ 'ALIGNED-LEFT',
75
+ 'ALIGNED-TOP',
76
+ 'CENTERED-IN',
77
+ 'COLOR',
78
+ 'DISTANCE-FROM',
79
+ 'FILL',
80
+ 'INSIDE',
81
+ 'NO-OVERLAP',
82
+ 'LEFT-OF',
83
+ 'ABOVE',
84
+ 'SAME-SIZE',
85
+ ]);
86
+
87
+ /**
88
+ * Levenshtein edit distance — small helper, used for typo suggestions.
89
+ * O(m·n) time, O(min(m,n)) space.
90
+ *
91
+ * @param {string} a
92
+ * @param {string} b
93
+ * @returns {number}
94
+ */
95
+ function levenshtein(a, b) {
96
+ if (a === b) return 0;
97
+ if (!a) return b.length;
98
+ if (!b) return a.length;
99
+ let prev = new Array(b.length + 1);
100
+ let curr = new Array(b.length + 1);
101
+ for (let j = 0; j <= b.length; j++) prev[j] = j;
102
+ for (let i = 1; i <= a.length; i++) {
103
+ curr[0] = i;
104
+ for (let j = 1; j <= b.length; j++) {
105
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
106
+ curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
107
+ }
108
+ [prev, curr] = [curr, prev];
109
+ }
110
+ return prev[b.length];
111
+ }
112
+
113
+ /**
114
+ * Returns the closest known constraint type within Levenshtein ≤ 2,
115
+ * or `undefined` if no candidate is close enough.
116
+ *
117
+ * @param {string} type
118
+ * @returns {string|undefined}
119
+ */
120
+ function closestKnownType(type) {
121
+ if (!type) return undefined;
122
+ let best;
123
+ let bestDist = 3; // strictly less than this wins; ≤2 by FIX_PLAN
124
+ for (const known of KNOWN_CONSTRAINT_TYPES) {
125
+ const d = levenshtein(type.toUpperCase(), known);
126
+ if (d < bestDist) {
127
+ bestDist = d;
128
+ best = known;
129
+ }
130
+ }
131
+ return best;
132
+ }
133
+
134
+ /**
135
+ * Builds an `unchecked` entry. If the constraint result already carries
136
+ * a reasonCode (provided by registry / P1-08), passes it through; otherwise
137
+ * applies a heuristic fallback so existing producers keep working.
138
+ *
139
+ * @param {object} cr raw constraint result (`pass: null` shape)
140
+ * @returns {{
141
+ * id?: string,
142
+ * constraintType?: string,
143
+ * reasonCategory: 'SPECIFICATION'|'MODEL',
144
+ * reasonCode: string,
145
+ * hint: string,
146
+ * suggestedCorrection?: string,
147
+ * detail?: string
148
+ * }}
149
+ */
150
+ function buildUnchecked(cr) {
151
+ const entry = {};
152
+ if (cr.id !== undefined) entry.id = cr.id;
153
+ if (cr.constraintType !== undefined) entry.constraintType = cr.constraintType;
154
+ if (cr.detail !== undefined) entry.detail = cr.detail;
155
+
156
+ // Honor upstream reasonCode (registry / P1-08) if present and valid.
157
+ if (cr.reasonCode && REASON_CODES.includes(cr.reasonCode)) {
158
+ entry.reasonCategory =
159
+ cr.reasonCategory === 'MODEL' ? 'MODEL' : 'SPECIFICATION';
160
+ entry.reasonCode = cr.reasonCode;
161
+ entry.hint = cr.hint || cr.detail || 'Constraint nicht ausgewertet.';
162
+ if (cr.suggestedCorrection)
163
+ entry.suggestedCorrection = cr.suggestedCorrection;
164
+ return entry;
165
+ }
166
+
167
+ // Fallback heuristic: attempt typo-detection on constraintType.
168
+ if (
169
+ cr.constraintType &&
170
+ !KNOWN_CONSTRAINT_TYPES.includes(cr.constraintType)
171
+ ) {
172
+ const suggestion = closestKnownType(cr.constraintType);
173
+ entry.reasonCategory = 'SPECIFICATION';
174
+ entry.reasonCode = 'CONSTRAINT_TYPE_UNKNOWN';
175
+ entry.hint = `Constraint-Typ '${cr.constraintType}' ist nicht registriert.`;
176
+ if (suggestion) entry.suggestedCorrection = suggestion;
177
+ return entry;
178
+ }
179
+
180
+ // Generic catch-all: known type but check returned pass:null.
181
+ entry.reasonCategory = 'MODEL';
182
+ entry.reasonCode = 'MEASUREMENT_AMBIGUOUS';
183
+ entry.hint = cr.detail || 'Constraint-Auswertung nicht moeglich.';
184
+ return entry;
185
+ }
186
+
187
+ /**
188
+ * Tri-state validation pass over constraint results + scene diff.
189
+ *
190
+ * @param {Array<{pass: boolean|null, [k:string]: any}>} constraintResults
191
+ * @param {Array<{type: string, [k:string]: any}>} diff
192
+ * @returns {{
193
+ * passing: Array<{id?: string, constraintType?: string}>,
194
+ * failing: Array<{type: 'CONSTRAINT_FAIL', severity: 0, [k:string]: any}>,
195
+ * unchecked: Array<{reasonCategory: string, reasonCode: string, hint: string, [k:string]: any}>,
196
+ * diff: Array<{type: string, severity: number, [k:string]: any}>,
197
+ * totals: {
198
+ * total: number,
199
+ * passing_count: number,
200
+ * failing_count: number,
201
+ * unchecked_count: number,
202
+ * diff_count: number
203
+ * }
204
+ * }}
205
+ */
206
+ /**
207
+ * Copies any defined `keys` from `src` onto `dst`. Helper to keep
208
+ * arbitrate() free of long if-chains.
209
+ */
210
+ function copyDefined(src, dst, keys) {
211
+ for (const k of keys) if (src[k] !== undefined) dst[k] = src[k];
212
+ return dst;
213
+ }
214
+
215
+ const PASSING_KEYS = ['id', 'constraintType'];
216
+ const FAILING_KEYS = [
217
+ 'detail',
218
+ 'id',
219
+ 'constraintType',
220
+ 'reference',
221
+ 'dx',
222
+ 'dy',
223
+ 'dw',
224
+ 'dh',
225
+ ];
226
+
227
+ export function arbitrate(constraintResults, diff) {
228
+ const passing = [];
229
+ const failing = [];
230
+ const unchecked = [];
231
+
232
+ for (const cr of constraintResults) {
233
+ if (cr.pass === true) {
234
+ passing.push(copyDefined(cr, {}, PASSING_KEYS));
235
+ } else if (cr.pass === false) {
236
+ failing.push(
237
+ copyDefined(cr, { type: 'CONSTRAINT_FAIL', severity: 0 }, FAILING_KEYS),
238
+ );
239
+ } else {
240
+ unchecked.push(buildUnchecked(cr));
241
+ }
242
+ }
243
+
244
+ const diffSorted = diff
245
+ .map((d) => ({ ...d, severity: DIFF_PRIO[d.type] ?? 5 }))
246
+ .sort((a, b) => a.severity - b.severity);
247
+
248
+ const passing_count = passing.length;
249
+ const failing_count = failing.length;
250
+ const unchecked_count = unchecked.length;
251
+ const diff_count = diffSorted.length;
252
+
253
+ return {
254
+ passing,
255
+ failing,
256
+ unchecked,
257
+ diff: diffSorted,
258
+ totals: {
259
+ total: failing_count + unchecked_count + diff_count,
260
+ passing_count,
261
+ failing_count,
262
+ unchecked_count,
263
+ diff_count,
264
+ },
265
+ };
266
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * _schema.js — Constraint Output Contract (REGEL-3 Caller-Boundary Gate)
3
+ *
4
+ *
5
+ * Zweck:
6
+ * REGEL-3 "Spotter-Anti-Luege" (CONSTANTS.md Z.116) maschinen-mässig
7
+ * erzwingen. Jeder Constraint, dessen check() ein fail-Verdict liefert,
8
+ * MUSS strukturierte Pixel-Korrekturen (dx oder dy) zurueckgeben — nicht
9
+ * nur Prosa. Bis Sprint-β3 war REGEL-3 textuelle Konvention; jetzt ist
10
+ * sie via Zod-Schema kodiert und durch loader.validatedCheckConstraint
11
+ * testbar.
12
+ *
13
+ * Vertrag:
14
+ * - pass=true → keine Pixel-Korrektur erwartet (detail darf null sein)
15
+ * - pass=false → MUSS mindestens dx ODER dy enthalten (oder beide)
16
+ *
17
+ * REGEL-4 Hexagonal:
18
+ * - Modul ist Layer "core/" — darf NUR aus core/ oder Pure-Libraries
19
+ * importieren. Hier: ausschliesslich `zod` (Pure-Library, transport-
20
+ * agnostisch). KEIN Import aus adapters/ oder interface/.
21
+ *
22
+ * Adressiert Audit-Welle-β:
23
+ * WELLE-β-004 (DISTANCE-FROM ohne dx/dy — REGEL-3-Asymmetrie zu
24
+ * kanonischen Constraints CENTERED-IN/ALIGNED-LEFT).
25
+ *
26
+ * DEPENDS: zod
27
+ */
28
+ import { z } from 'zod';
29
+
30
+ /**
31
+ * constraintCheckSchema — Zod-Output-Vertrag fuer alle Constraint .check()-Resultate.
32
+ *
33
+ * Aufbau:
34
+ * - Basis (z.object): pass:boolean, detail:string|null.
35
+ * - .and(z.union(...)): diskriminiert nach pass-Wert.
36
+ * Branch 1 (pass=true): KEINE Pflicht zu dx/dy.
37
+ * Branch 2 (pass=false): refine erzwingt dx ODER dy (optional beide,
38
+ * aber mindestens eins).
39
+ *
40
+ * REGEL-3-Pflicht bei pass=false (TYP-EHRLICH, maintainer decision S2b
41
+ * Step-0, D-007):
42
+ * - spatial-Constraints (CENTERED-IN, ALIGNED-*, DISTANCE-FROM, …):
43
+ * mindestens `dx` ODER `dy`.
44
+ * - Resize-Constraints (SAME-SIZE): erfuellen REGEL-3 mit `dw` ODER `dh`
45
+ * (Groessen-Delta ist ihre kanonische strukturierte Korrektur).
46
+ * - non-spatial-Constraints (COLOR): haben KEINEN Pixel-Delta. Sie tragen
47
+ * einen expliziten `non_spatial: true`-Marker und sind damit von der
48
+ * Delta-Pflicht ausgenommen. Der Marker ist self-describing (lokal vom
49
+ * Constraint deklariert, kein zentraler Typ-Katalog im Schema → keine
50
+ * Drift). OHNE Marker bleibt die Delta-Pflicht streng: ein spatial/resize-
51
+ * Constraint, der weder Delta noch Marker liefert, wirft weiterhin
52
+ * (keine Prosa-only-Antwort fuer mess-bare Constraints).
53
+ */
54
+ export const constraintCheckSchema = z
55
+ .object({
56
+ pass: z.boolean(),
57
+ detail: z.string().nullable(),
58
+ })
59
+ .and(
60
+ z.union([
61
+ // pass=true: keine Korrektur-Daten erwartet
62
+ z.object({ pass: z.literal(true) }),
63
+ // pass=false: REGEL-3 verlangt strukturierte Korrektur — (dx|dy) ODER
64
+ // (dw|dh) — ausser der Constraint markiert sich als non_spatial.
65
+ z
66
+ .object({
67
+ pass: z.literal(false),
68
+ dx: z.number().optional(),
69
+ dy: z.number().optional(),
70
+ dw: z.number().optional(),
71
+ dh: z.number().optional(),
72
+ // non-spatial-Marker (COLOR): nimmt den Constraint von der
73
+ // Pixel-Delta-Pflicht aus. Muss explizit `true` sein.
74
+ non_spatial: z.literal(true).optional(),
75
+ })
76
+ .refine(
77
+ (v) =>
78
+ v.non_spatial === true ||
79
+ v.dx !== undefined ||
80
+ v.dy !== undefined ||
81
+ v.dw !== undefined ||
82
+ v.dh !== undefined,
83
+ {
84
+ message:
85
+ 'REGEL-3: fail-Verdict MUSS strukturierte Korrektur liefern — dx/dy (spatial) ODER dw/dh (resize) ODER non_spatial:true (z.B. COLOR). Keine Prosa-only-Antwort fuer mess-bare Constraints.',
86
+ },
87
+ ),
88
+ ]),
89
+ );
@@ -0,0 +1,42 @@
1
+ /**
2
+ * aligned.js - ALIGNED-LEFT + ALIGNED-TOP Constraints
3
+ * Migrated from mirror.js:63-71
4
+ * Vector Mirror v2.0
5
+ */
6
+ import { registerConstraint } from './registry.js';
7
+
8
+ registerConstraint('ALIGNED-LEFT', {
9
+ // Uniform dispatch signature (registry.js): check(subj, ref, ctx).
10
+ check(subj, ref, _ctx) {
11
+ const dx = subj.bbox.x - ref.bbox.x;
12
+ const pass = Math.abs(dx) <= 2; // Hard 2px spirit level
13
+ if (pass) return { pass, detail: null };
14
+ return {
15
+ pass,
16
+ detail: `Nicht bündig. Korrektur: dx=${Math.round(-dx)}px`,
17
+ dx: Math.round(-dx),
18
+ };
19
+ },
20
+ // Uniform dispatch signature (registry.js): arrange(subj, ref, ctx).
21
+ arrange(_subj, ref, _ctx) {
22
+ return { x: ref.bbox.x };
23
+ },
24
+ });
25
+
26
+ registerConstraint('ALIGNED-TOP', {
27
+ // Uniform dispatch signature (registry.js): check(subj, ref, ctx).
28
+ check(subj, ref, _ctx) {
29
+ const dy = subj.bbox.y - ref.bbox.y;
30
+ const pass = Math.abs(dy) <= 2;
31
+ if (pass) return { pass, detail: null };
32
+ return {
33
+ pass,
34
+ detail: `Nicht bündig. Korrektur: dy=${Math.round(-dy)}px`,
35
+ dy: Math.round(-dy),
36
+ };
37
+ },
38
+ // Uniform dispatch signature (registry.js): arrange(subj, ref, ctx).
39
+ arrange(_subj, ref, _ctx) {
40
+ return { y: ref.bbox.y };
41
+ },
42
+ });
@@ -0,0 +1,29 @@
1
+ /**
2
+ * centered-in.js - CENTERED-IN Constraint
3
+ * Migrated from mirror.js:36-41
4
+ * Vector Mirror v2.0
5
+ */
6
+
7
+ import { getTolX, getTolY } from '../tolerance.js';
8
+ import { registerConstraint } from './registry.js';
9
+
10
+ registerConstraint('CENTERED-IN', {
11
+ check(subj, ref, { grid }) {
12
+ const dx = subj.bbox.x + subj.bbox.w / 2 - (ref.bbox.x + ref.bbox.w / 2);
13
+ const dy = subj.bbox.y + subj.bbox.h / 2 - (ref.bbox.y + ref.bbox.h / 2);
14
+ const pass =
15
+ Math.abs(dx) <= getTolX(ref.bbox.w, grid) &&
16
+ Math.abs(dy) <= getTolY(ref.bbox.h, grid);
17
+ if (pass) return { pass, detail: null };
18
+ return {
19
+ pass,
20
+ detail: `Verfehlt Zentrum. Korrektur: dx=${Math.round(-dx)}px, dy=${Math.round(-dy)}px`,
21
+ dx: Math.round(-dx),
22
+ dy: Math.round(-dy),
23
+ };
24
+ },
25
+ // Uniform dispatch signature (registry.js): arrange(subj, ref, ctx).
26
+ arrange(_subj, ref, _ctx) {
27
+ return { cx: ref.bbox.x + ref.bbox.w / 2, cy: ref.bbox.y + ref.bbox.h / 2 };
28
+ },
29
+ });
@@ -0,0 +1,63 @@
1
+ /**
2
+ * color.js - COLOR Constraint
3
+ * Migrated from mirror.js:104-107
4
+ * Vector Mirror v2.0
5
+ * Uses ctx.value (target color name)
6
+ */
7
+ import { registerConstraint } from './registry.js';
8
+ import { parseColor } from '../../lib/palette.js';
9
+
10
+ registerConstraint('COLOR', {
11
+ // COLOR braucht keine Referenz (non-spatial, Ziel-Farbe steckt in ctx.value).
12
+ // Ohne diesen Marker wuerde die pipeline-Wache (fail-closed-Default) COLOR
13
+ // faelschlich als referenz-pflichtig behandeln.
14
+ requiresReference: false,
15
+ // Uniform dispatch signature (registry.js): check(subj, ref, ctx).
16
+ check(subj, _ref, { value }) {
17
+ // COLOR ist non-spatial (D-007): kein Pixel-Delta. Der non_spatial-Marker
18
+ // nimmt fail-Verdicts von der REGEL-3-Delta-Pflicht aus (_schema.js).
19
+ // Der spaetere COLOR-attribute_fix ist C-04/L-003-Arbeit, NICHT hier.
20
+ if (value === undefined || value === null) {
21
+ return {
22
+ pass: false,
23
+ detail: `Kein Farbwert angegeben fuer #${subj.id}.`,
24
+ non_spatial: true,
25
+ };
26
+ }
27
+ // §HEAL3b: eine nicht messbare Farbe (use-Instanz → 'indeterminate', §HEAL3b
28
+ // im Renderer) kann nicht GEGEN ein Ziel geprüft werden. Ein pass:false meldete
29
+ // eine FALSCHE Verletzung und triggerte eine SCHÄDLICHE Korrektur auf eine
30
+ // womöglich schon korrekte Farbe (Blind-Trust-Gesetz). Unmessbar → UNCHECKED.
31
+ if (subj.color === 'indeterminate') {
32
+ return {
33
+ pass: null,
34
+ reasonCode: 'MEASUREMENT_AMBIGUOUS',
35
+ reasonCategory: 'MODEL',
36
+ detail: `Farbe von #${subj.id} ist nicht messbar (use-Instanz) — COLOR ${value} nicht prüfbar.`,
37
+ };
38
+ }
39
+ // §H9 K-08a: subj.color ist IMMER der quantisierte Name (grid.js →
40
+ // parseColor/CIELAB). Der Soll-Token MUSS durch DENSELBEN Quantisierer,
41
+ // sonst vergleicht der Check zwei Repräsentationen derselben Farbe
42
+ // (#ff0000 vs red = False Negative). Unbekannte Token fallen in
43
+ // parseColor unverändert durch → ehrlicher FAIL bleibt.
44
+ //
45
+ // §H9 P3 MESS-GRANULARITÄT (ausgesprochen, bewusst): `COLOR <hex>`
46
+ // bedeutet nearest-named-color im Mess-Farbraum (140 W3C-Namen, CIELAB —
47
+ // parseColor/rgbToColorName). Zwei Hexes, die in DIESELBE Palette-Zelle
48
+ // quantisieren (z.B. #ff0000 und #ff0001 → beide 'red'), gelten als
49
+ // GLEICH — das ist die Granularität der Messung, kein Bug. Hexes mit
50
+ // VERSCHIEDENEM quantisierten Namen (#ff0000 'red' vs #ff6347 'tomato')
51
+ // bleiben verschieden → FAIL (Pin: tests/h9_red/test_h9_k08a.mjs).
52
+ // Feinere Unterscheidung (Hex-exakter Kanal) wäre eine Design-Runde,
53
+ // keine stille Verfeinerung hier.
54
+ const pass = subj.color === parseColor(value);
55
+ if (pass) return { pass, detail: null };
56
+ return {
57
+ pass,
58
+ detail: `Farbe ist ${subj.color}, soll ${value} sein.`,
59
+ non_spatial: true,
60
+ };
61
+ },
62
+ // No arrange — color cannot be spatially computed
63
+ });