@sigloch/contracts 6.0.0 → 6.3.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/dist/se/rules.js CHANGED
@@ -6,6 +6,7 @@
6
6
  import { z } from 'zod/v4';
7
7
  import { ElementType, TraceType, TestRefsSchema, RealRefSchema } from './ontology.js';
8
8
  import { isValidTrace } from './meta-model.js';
9
+ import { indexOf } from './graph-index.js';
9
10
  export const RuleSeverity = z.enum(['error', 'warning', 'info']);
10
11
  /** Candidate target for resolving a violation (e.g. a REQ to satisfy, a TEST to link). */
11
12
  export const ViolationCandidate = z.object({
@@ -37,13 +38,32 @@ export const RuleViolation = z.object({
37
38
  // Graph lookup helpers (shared across rules)
38
39
  // ---------------------------------------------------------------------------
39
40
  /** Find the MOD a FUNC/SCHEMA is allocated to. */
41
+ /**
42
+ * CR-SM-256: die EINE Definition von "zerlegt". Ein FUNC mit FUNC-Kindern ist ein Rollup
43
+ * seiner Kinder — ein Blackbox-Block, durch den nichts fliesst und der keine Anforderung
44
+ * erfuellt, die nicht schon ein Blatt erfuellt. Die Pflichten liegen vollstaendig auf den
45
+ * Blaettern (so schon R-30 seit CR-SM-249, so R-20 seit CR-210).
46
+ *
47
+ * Bewusst EIN Helper statt drei Kopien: eine zweite Definition von "Blatt" waere ein
48
+ * zweiter Weg zu derselben Aussage, und der billigere gewinnt immer. CR-SM-263 haengt als
49
+ * vierter Nutzer daran (MT-02, `metric-rules.ts`) — deshalb exportiert.
50
+ */
51
+ export function decomposedFuncs(graph) {
52
+ const idx = indexOf(graph);
53
+ const funcIds = new Set(idx.elementsOfType('FUNC').map(f => f.id));
54
+ return new Set(graph.traces
55
+ .filter(t => t.type === 'compose' && funcIds.has(t.source) && funcIds.has(t.target))
56
+ .map(t => t.source));
57
+ }
40
58
  function findParentModule(graph, elementId) {
41
- const trace = graph.traces.find(t => t.source === elementId && t.type === 'allocate');
59
+ const idx = indexOf(graph);
60
+ const trace = idx.out(elementId, 'allocate')[0];
42
61
  return trace?.target;
43
62
  }
44
63
  /** Get all outgoing traces of a given type from an element. */
45
64
  function outgoingTraces(graph, elementId, traceType) {
46
- return graph.traces.filter(t => t.source === elementId && (!traceType || t.type === traceType));
65
+ const idx = indexOf(graph);
66
+ return traceType === undefined ? idx.outAll(elementId) : idx.out(elementId, traceType);
47
67
  }
48
68
  /** Tokenize id/name/description into a lowercase token set (drops tokens < 3 chars). */
49
69
  function overlapTokens(...parts) {
@@ -54,35 +74,69 @@ function overlapTokens(...parts) {
54
74
  .split(/[^a-z0-9]+/)
55
75
  .filter(t => t.length >= 3));
56
76
  }
77
+ /**
78
+ * CR-SM-261: Tokenmenge je Element, einmal je Prozess statt einmal je Vergleich.
79
+ *
80
+ * `toCandidates` rief `overlapTokens` frueher INNERHALB des Sort-Komparators auf. Ein Sort
81
+ * ueber k Kandidaten macht rund k·log k Vergleiche, jeder Vergleich hat beide Seiten neu
82
+ * tokenisiert (join + toLowerCase + Regex-Split) — und das je meldendem Element. Bei R-31 mit
83
+ * 33 Befunden gegen 46 FLOWs sind das ueber 11 000 Tokenisierungen fuer 46 verschiedene
84
+ * Elemente. Das Profil aus CR-SM-260 hat R-31 deshalb mit 25,9 % und n^2,10 gemessen, obwohl
85
+ * die Regel selbst nur Mengen aufbaut: die Kosten lagen vollstaendig in der Kandidatenliste.
86
+ *
87
+ * Der Schluessel ist das Element-OBJEKT, nicht die id — zwei Graphen im selben Prozess (der
88
+ * Probegraph aus `applyRule` neben dem Original) teilen sich ids, aber nicht die Objekte.
89
+ * `WeakMap` gibt die Eintraege frei, sobald der Graph es tut; es entsteht kein Cache, den
90
+ * jemand invalidieren muesste, und damit auch keine zweite Wahrheit ueber ein Element.
91
+ */
92
+ const TOKEN_CACHE = new WeakMap();
93
+ function tokensOf(e) {
94
+ let t = TOKEN_CACHE.get(e);
95
+ if (t === undefined) {
96
+ t = overlapTokens(e.id, e.name, e.description);
97
+ TOKEN_CACHE.set(e, t);
98
+ }
99
+ return t;
100
+ }
57
101
  /**
58
102
  * Map elements to ViolationCandidate format. When `ref` is given, rank candidates
59
103
  * by id/name/description token overlap with `ref` (descending) so the most relevant
60
104
  * target is first — e.g. REQ-bootstrap → TEST-bootstrap (CR-GC-203 item 3). Ranking
61
105
  * is a hint, not an auto-link; semantic confirmation stays with the caller.
62
106
  */
63
- function toCandidates(elements, ref) {
107
+ function toCandidates(
108
+ // CR-SM-264: `readonly`, weil die Kandidaten jetzt aus `idx.elementsOfType()` kommen. Seit
109
+ // CR-SM-261 sortiert diese Funktion ohnehin eine eigene Liste (decorate-sort-undecorate) und
110
+ // fasst die Eingabe nicht mehr an — der mutable Typ war schon vorher zu weit.
111
+ elements, ref) {
64
112
  if (!ref)
65
113
  return elements.map(e => ({ id: e.id, type: e.type, name: e.name }));
66
- const refTokens = overlapTokens(ref.id, ref.name, ref.description);
114
+ const refTokens = tokensOf(ref);
67
115
  const score = (e) => {
68
116
  let n = 0;
69
- for (const tok of overlapTokens(e.id, e.name, e.description))
117
+ for (const tok of tokensOf(e))
70
118
  if (refTokens.has(tok))
71
119
  n++;
72
120
  return n;
73
121
  };
74
- return [...elements]
75
- .sort((a, b) => score(b) - score(a))
76
- .map(e => ({ id: e.id, type: e.type, name: e.name }));
122
+ // CR-SM-261: EINMAL bewerten, dann sortieren. Vorher stand `score` im Komparator und lief
123
+ // damit rund 2·k·log k mal statt k mal. Der Index ist der Gleichstands-Tiebreak und haelt die
124
+ // Ausgabe byte-identisch: `Array.prototype.sort` ist seit ES2019 stabil, die alte Fassung hat
125
+ // bei gleichem Score also die Eingangsreihenfolge behalten — diese tut es explizit.
126
+ return elements
127
+ .map((e, i) => ({ e, i, s: score(e) }))
128
+ .sort((a, b) => b.s - a.s || a.i - b.i)
129
+ .map(({ e }) => ({ id: e.id, type: e.type, name: e.name }));
77
130
  }
78
131
  // ---------------------------------------------------------------------------
79
132
  // R-01: Every REQ must have at least one verify trace
80
133
  // ---------------------------------------------------------------------------
81
134
  function reqMustHaveVerification(graph) {
82
- const reqs = graph.elements.filter(e => e.type === 'REQ');
83
- const tests = graph.elements.filter(e => e.type === 'TEST');
135
+ const idx = indexOf(graph);
136
+ const reqs = idx.elementsOfType('REQ');
137
+ const tests = idx.elementsOfType('TEST');
84
138
  return reqs
85
- .filter(req => !graph.traces.some(t => t.target === req.id && t.type === 'verify'))
139
+ .filter(req => idx.in(req.id, 'verify').length === 0)
86
140
  .map(req => ({
87
141
  rule_id: 'R-01',
88
142
  severity: 'error',
@@ -101,8 +155,15 @@ function reqMustHaveVerification(graph) {
101
155
  // R-02: Every FUNC must satisfy at least one REQ
102
156
  // ---------------------------------------------------------------------------
103
157
  function funcMustSatisfyReq(graph) {
104
- const funcs = graph.elements.filter(e => e.type === 'FUNC');
105
- const allReqs = graph.elements.filter(e => e.type === 'REQ');
158
+ const idx = indexOf(graph);
159
+ // CR-SM-256: Grundgesamtheit sind die BLAETTER. Ein zerlegter FUNC erfuellt keine REQ,
160
+ // die nicht schon eine seiner Blatt-Funktionen erfuellt — wer ihn befriedigen will,
161
+ // muss eine satisfy-Kante erfinden, die dieselbe REQ ein zweites Mal beansprucht.
162
+ // Am graphcode-Selbstmodell (graphVersion 171) melden 12 der 13 Bloecke R-02 UND R-31,
163
+ // also dieselbe Ursache zweimal gezaehlt — die Klasse CR-SM-235/-242.
164
+ const decomposed = decomposedFuncs(graph);
165
+ const funcs = idx.elementsOfType('FUNC').filter(e => !decomposed.has(e.id));
166
+ const allReqs = idx.elementsOfType('REQ');
106
167
  // CR-GC-366: der Filter prueft den ZIELTYP. Vorher war er zieltyp-blind (`type === 'satisfy'`),
107
168
  // womit ein `FUNC -satisfy-> UC` die Regel stumm schaltete, obwohl kein Requirement erfuellt war —
108
169
  // der Regelname versprach mehr als der Code pruefte. Am graphcode-Selbstmodell verdeckte das 6 von
@@ -111,15 +172,15 @@ function funcMustSatisfyReq(graph) {
111
172
  // Ziele das Meta-Modell gerade zulaesst.
112
173
  const reqIds = new Set(allReqs.map(r => r.id));
113
174
  return funcs
114
- .filter(fn => !graph.traces.some(t => t.source === fn.id && t.type === 'satisfy' && reqIds.has(t.target)))
175
+ .filter(fn => !idx.out(fn.id, 'satisfy').some(t => reqIds.has(t.target)))
115
176
  .map(fn => {
116
177
  const modId = findParentModule(graph, fn.id);
117
178
  // Candidate REQs: same module's other FUNCs satisfy these, or constraints referencing this func
118
179
  const siblingReqs = modId
119
180
  ? graph.traces
120
181
  .filter(t => t.type === 'allocate' && t.target === modId && t.source !== fn.id)
121
- .flatMap(t => graph.traces.filter(st => st.source === t.source && st.type === 'satisfy'))
122
- .map(st => graph.elements.find(e => e.id === st.target))
182
+ .flatMap(t => idx.out(t.source, 'satisfy'))
183
+ .map(st => idx.byId.get(st.target))
123
184
  .filter((e) => !!e)
124
185
  : [];
125
186
  // Also include constraints whose description mentions this func's name
@@ -146,12 +207,13 @@ function funcMustSatisfyReq(graph) {
146
207
  // R-03: ASIL isolation
147
208
  // ---------------------------------------------------------------------------
148
209
  function asilIsolation(graph) {
210
+ const idx = indexOf(graph);
149
211
  const violations = [];
150
- const modules = graph.elements.filter(e => e.type === 'MOD');
212
+ const modules = idx.elementsOfType('MOD');
151
213
  for (const mod of modules) {
152
214
  const allocated = graph.traces
153
215
  .filter(t => t.target === mod.id && t.type === 'allocate')
154
- .map(t => graph.elements.find(e => e.id === t.source))
216
+ .map(t => idx.byId.get(t.source))
155
217
  .filter((e) => !!e);
156
218
  const hasD = allocated.some(e => e.asil === 'D');
157
219
  const hasQM = allocated.some(e => e.asil === 'QM');
@@ -185,22 +247,22 @@ function asilIsolation(graph) {
185
247
  // `null` → messen, nicht urteilen: die Regel schweigt.
186
248
  // ---------------------------------------------------------------------------
187
249
  function maxModuleSize(graph, policy) {
250
+ const idx = indexOf(graph);
188
251
  const violations = [];
189
252
  const steps = policy.moduleSize;
190
253
  if (steps === null)
191
254
  return violations;
192
- const modules = graph.elements.filter(e => e.type === 'MOD');
255
+ const modules = idx.elementsOfType('MOD');
193
256
  for (const mod of modules) {
194
- const allocatedIds = graph.traces.filter(t => t.target === mod.id && t.type === 'allocate').map(t => t.source);
195
- const allocated = allocatedIds.map(id => graph.elements.find(e => e.id === id)).filter((e) => !!e);
257
+ const allocatedIds = idx.in(mod.id, 'allocate').map(t => t.source);
258
+ const allocated = allocatedIds.map(id => idx.byId.get(id)).filter((e) => !!e);
196
259
  const funcCount = allocated.length;
197
260
  if (funcCount <= steps.coupled)
198
261
  continue;
199
262
  // Count crossing flows: io paths from FUNCs in this module to FUNCs in other modules
200
263
  const funcIds = new Set(allocatedIds);
201
- const crossings = graph.traces.filter(t => {
202
- if (t.type !== 'io')
203
- return false;
264
+ // CR-SM-264: nur die io-Kanten, nicht alle Traces je MOD.
265
+ const crossings = idx.tracesOfType('io').filter(t => {
204
266
  const srcIn = funcIds.has(t.source);
205
267
  const tgtIn = funcIds.has(t.target);
206
268
  return (srcIn && !tgtIn) || (!srcIn && tgtIn);
@@ -242,10 +304,11 @@ function maxModuleSize(graph, policy) {
242
304
  // R-05: Every TEST must verify at least one REQ
243
305
  // ---------------------------------------------------------------------------
244
306
  function testMustVerifyReq(graph) {
245
- const tests = graph.elements.filter(e => e.type === 'TEST');
246
- const allReqs = graph.elements.filter(e => e.type === 'REQ');
307
+ const idx = indexOf(graph);
308
+ const tests = idx.elementsOfType('TEST');
309
+ const allReqs = idx.elementsOfType('REQ');
247
310
  return tests
248
- .filter(test => !graph.traces.some(t => t.source === test.id && t.type === 'verify'))
311
+ .filter(test => idx.out(test.id, 'verify').length === 0)
249
312
  .map(test => ({
250
313
  rule_id: 'R-05',
251
314
  severity: 'warning',
@@ -269,11 +332,12 @@ function testMustVerifyReq(graph) {
269
332
  // R-08: Bidirectional trace consistency
270
333
  // ---------------------------------------------------------------------------
271
334
  function traceConsistency(graph) {
335
+ const idx = indexOf(graph);
272
336
  return graph.traces
273
337
  .filter(t => t.category !== 'audit')
274
338
  .filter(t => {
275
- const sourceExists = graph.elements.some(e => e.id === t.source);
276
- const targetExists = graph.elements.some(e => e.id === t.target);
339
+ const sourceExists = idx.byId.has(t.source);
340
+ const targetExists = idx.byId.has(t.target);
277
341
  return !sourceExists || !targetExists;
278
342
  })
279
343
  .map(t => ({
@@ -294,11 +358,12 @@ function traceConsistency(graph) {
294
358
  // concern, stricter check — no parallel path); up to two warnings per FLOW.
295
359
  // ---------------------------------------------------------------------------
296
360
  function flowCompleteness(graph) {
297
- const flows = graph.elements.filter(e => e.type === 'FLOW');
361
+ const idx = indexOf(graph);
362
+ const flows = idx.elementsOfType('FLOW');
298
363
  // Producers/consumers of a FLOW are FUNCs or ACTORs (io endpoints on the
299
364
  // upstream/downstream side); UC may consume too. Offer both as candidates.
300
- const sources = graph.elements.filter(e => e.type === 'FUNC' || e.type === 'ACTOR');
301
- const io = graph.traces.filter(t => t.type === 'io');
365
+ const sources = graph.elements.filter(e => e.type === 'FUNC' || e.type === 'ACTOR'); // Mehrtyp: ein Durchlauf ist hier billiger als vier Index-Listen zu mischen (Reihenfolge!)
366
+ const io = idx.tracesOfType('io');
302
367
  const violations = [];
303
368
  for (const f of flows) {
304
369
  const ctx = { element_type: f.type, element_name: f.name, candidate_targets: toCandidates(sources) };
@@ -340,6 +405,7 @@ function flowCompleteness(graph) {
340
405
  /** Trace types on which a direct 2-cycle is a real finding (CR-GC-315). */
341
406
  const CIRCULAR_TRACE_TYPES = new Set(['compose', 'allocate', 'relation']);
342
407
  function noDirectCircular(graph) {
408
+ const idx = indexOf(graph);
343
409
  const violations = [];
344
410
  // Direction-independent key: A↔B is ONE cycle, reported once. Keying on the
345
411
  // message (CR-GC-315 predecessor) never collapsed anything — the two
@@ -348,7 +414,7 @@ function noDirectCircular(graph) {
348
414
  for (const t of graph.traces) {
349
415
  if (!CIRCULAR_TRACE_TYPES.has(t.type))
350
416
  continue;
351
- if (!graph.traces.some(other => other.source === t.target && other.target === t.source && other.type === t.type))
417
+ if (!idx.out(t.target, t.type).some(other => other.target === t.source))
352
418
  continue;
353
419
  const key = `${t.type}|${[t.source, t.target].sort().join('|')}`;
354
420
  if (seen.has(key))
@@ -370,12 +436,12 @@ function noDirectCircular(graph) {
370
436
  // RD-01: Unresolved requirement (leaf REQ without satisfy)
371
437
  // ---------------------------------------------------------------------------
372
438
  function unresolvedRequirement(graph) {
373
- const reqs = graph.elements.filter(e => e.type === 'REQ');
439
+ const idx = indexOf(graph);
440
+ const reqs = idx.elementsOfType('REQ');
374
441
  // Leaf REQs: no compose→REQ children
375
- const leafReqs = reqs.filter(req => !graph.traces.some(t => t.source === req.id && t.type === 'compose' &&
376
- graph.elements.some(e => e.id === t.target && e.type === 'REQ')));
442
+ const leafReqs = reqs.filter(req => !idx.out(req.id, 'compose').some(t => idx.typeOf(t.target) === 'REQ'));
377
443
  return leafReqs
378
- .filter(req => !graph.traces.some(t => t.target === req.id && t.type === 'satisfy'))
444
+ .filter(req => idx.in(req.id, 'satisfy').length === 0)
379
445
  .map(req => ({
380
446
  rule_id: 'RD-01',
381
447
  severity: 'warning',
@@ -394,12 +460,11 @@ function unresolvedRequirement(graph) {
394
460
  // RD-02: Decomposition consistency (parent REQ should not have FUNC satisfy)
395
461
  // ---------------------------------------------------------------------------
396
462
  function decompositionConsistency(graph) {
397
- const reqs = graph.elements.filter(e => e.type === 'REQ');
398
- const parentReqs = reqs.filter(req => graph.traces.some(t => t.source === req.id && t.type === 'compose' &&
399
- graph.elements.some(e => e.id === t.target && e.type === 'REQ')));
463
+ const idx = indexOf(graph);
464
+ const reqs = idx.elementsOfType('REQ');
465
+ const parentReqs = reqs.filter(req => idx.out(req.id, 'compose').some(t => idx.typeOf(t.target) === 'REQ'));
400
466
  return parentReqs
401
- .filter(req => graph.traces.some(t => t.target === req.id && t.type === 'satisfy' &&
402
- graph.elements.some(e => e.id === t.source && e.type === 'FUNC')))
467
+ .filter(req => idx.in(req.id, 'satisfy').some(t => idx.typeOf(t.source) === 'FUNC'))
403
468
  .map(req => ({
404
469
  rule_id: 'RD-02',
405
470
  severity: 'warning',
@@ -413,18 +478,18 @@ function decompositionConsistency(graph) {
413
478
  // RD-03: No premature decomposition (all children same satisfy target)
414
479
  // ---------------------------------------------------------------------------
415
480
  function noPrematureDecomposition(graph) {
416
- const reqs = graph.elements.filter(e => e.type === 'REQ');
417
- const parentReqs = reqs.filter(req => graph.traces.some(t => t.source === req.id && t.type === 'compose' &&
418
- graph.elements.some(e => e.id === t.target && e.type === 'REQ')));
481
+ const idx = indexOf(graph);
482
+ const reqs = idx.elementsOfType('REQ');
483
+ const parentReqs = reqs.filter(req => idx.out(req.id, 'compose').some(t => idx.typeOf(t.target) === 'REQ'));
419
484
  return parentReqs
420
485
  .filter(parent => {
421
486
  const childIds = graph.traces
422
487
  .filter(t => t.source === parent.id && t.type === 'compose' &&
423
- graph.elements.some(e => e.id === t.target && e.type === 'REQ'))
488
+ idx.typeOf(t.target) === 'REQ')
424
489
  .map(t => t.target);
425
490
  if (childIds.length < 2)
426
491
  return false;
427
- const satisfyTargets = childIds.map(cid => graph.traces.filter(t => t.source !== cid && t.target === cid && t.type === 'satisfy').map(t => t.source));
492
+ const satisfyTargets = childIds.map(cid => idx.in(cid, 'satisfy').filter(t => t.source !== cid).map(t => t.source));
428
493
  // All children satisfied by the same single source
429
494
  const allSame = satisfyTargets.every(targets => targets.length === 1 && targets[0] === satisfyTargets[0]?.[0]);
430
495
  return allSame && satisfyTargets[0]?.length === 1;
@@ -452,8 +517,10 @@ function noPrematureDecomposition(graph) {
452
517
  */
453
518
  const DECOMPOSITION_BREADTH_MAX = 11;
454
519
  function decompositionBreadth(graph) {
455
- const typeOf = new Map(graph.elements.map(e => [e.id, e.type]));
456
- const byId = new Map(graph.elements.map(e => [e.id, e]));
520
+ const idx = indexOf(graph);
521
+ // CR-SM-264: `typeOf` und `byId` baute diese Regel je Aufruf selbst — der Index hat beide.
522
+ const typeOf = idx;
523
+ const byId = idx.byId;
457
524
  const counts = new Map();
458
525
  const bump = (parentId, kind) => {
459
526
  const key = `${parentId}\u0000${kind}`;
@@ -464,8 +531,8 @@ function decompositionBreadth(graph) {
464
531
  counts.set(key, { parentId, kind, n: 1 });
465
532
  };
466
533
  for (const t of graph.traces) {
467
- const src = typeOf.get(t.source);
468
- const tgt = typeOf.get(t.target);
534
+ const src = typeOf.typeOf(t.source);
535
+ const tgt = typeOf.typeOf(t.target);
469
536
  if (t.type === 'compose' && src === 'FUNC' && tgt === 'FUNC') {
470
537
  bump(t.source, 'sub-FUNC');
471
538
  }
@@ -494,9 +561,10 @@ function decompositionBreadth(graph) {
494
561
  // R-14: UC must have at least 1 compose trace (→ FCHAIN or REQ) (CR-117)
495
562
  // ---------------------------------------------------------------------------
496
563
  function ucMustHaveCompose(graph) {
497
- const ucs = graph.elements.filter(e => e.type === 'UC');
564
+ const idx = indexOf(graph);
565
+ const ucs = idx.elementsOfType('UC');
498
566
  return ucs
499
- .filter(uc => !graph.traces.some(t => t.source === uc.id && t.type === 'compose'))
567
+ .filter(uc => idx.out(uc.id, 'compose').length === 0)
500
568
  .map(uc => ({
501
569
  rule_id: 'R-14',
502
570
  severity: 'warning',
@@ -528,14 +596,15 @@ function ucMustHaveCompose(graph) {
528
596
  // einen einzigen Befund ueber beide Selbstmodelle.
529
597
  // ---------------------------------------------------------------------------
530
598
  function fchainCompleteness(graph) {
531
- const fchains = graph.elements.filter(e => e.type === 'FCHAIN');
532
- const funcs = graph.elements.filter(e => e.type === 'FUNC');
533
- const ucs = graph.elements.filter(e => e.type === 'UC');
599
+ const idx = indexOf(graph);
600
+ const fchains = idx.elementsOfType('FCHAIN');
601
+ const funcs = idx.elementsOfType('FUNC');
602
+ const ucs = idx.elementsOfType('UC');
534
603
  const ucIds = new Set(ucs.map(u => u.id));
535
604
  const violations = [];
536
605
  for (const fc of fchains) {
537
606
  const ctx = { element_type: fc.type, element_name: fc.name };
538
- if (!graph.traces.some(t => t.source === fc.id && t.type === 'compose')) {
607
+ if (idx.out(fc.id, 'compose').length === 0) {
539
608
  violations.push({
540
609
  rule_id: 'R-15',
541
610
  severity: 'warning',
@@ -545,7 +614,7 @@ function fchainCompleteness(graph) {
545
614
  context: { ...ctx, candidate_targets: toCandidates(funcs) },
546
615
  });
547
616
  }
548
- if (!graph.traces.some(t => t.target === fc.id && t.type === 'compose' && ucIds.has(t.source))) {
617
+ if (!idx.in(fc.id, 'compose').some(t => ucIds.has(t.source))) {
549
618
  violations.push({
550
619
  rule_id: 'R-15',
551
620
  severity: 'warning',
@@ -562,9 +631,10 @@ function fchainCompleteness(graph) {
562
631
  // R-16: ACTOR must have at least 1 io trace (CR-117)
563
632
  // ---------------------------------------------------------------------------
564
633
  function actorMustHaveTrace(graph) {
565
- const actors = graph.elements.filter(e => e.type === 'ACTOR');
634
+ const idx = indexOf(graph);
635
+ const actors = idx.elementsOfType('ACTOR');
566
636
  return actors
567
- .filter(a => !graph.traces.some(t => (t.source === a.id || t.target === a.id) && t.type === 'io'))
637
+ .filter(a => idx.out(a.id, 'io').length === 0 && idx.in(a.id, 'io').length === 0)
568
638
  .map(a => ({
569
639
  rule_id: 'R-16',
570
640
  severity: 'warning',
@@ -581,9 +651,10 @@ function actorMustHaveTrace(graph) {
581
651
  // R-17: SYS must have at least 1 compose trace (CR-117)
582
652
  // ---------------------------------------------------------------------------
583
653
  function sysMustHaveCompose(graph) {
584
- const systems = graph.elements.filter(e => e.type === 'SYS');
654
+ const idx = indexOf(graph);
655
+ const systems = idx.elementsOfType('SYS');
585
656
  return systems
586
- .filter(sys => !graph.traces.some(t => t.source === sys.id && t.type === 'compose'))
657
+ .filter(sys => idx.out(sys.id, 'compose').length === 0)
587
658
  .map(sys => ({
588
659
  rule_id: 'R-17',
589
660
  severity: 'warning',
@@ -608,10 +679,10 @@ function sysMustHaveCompose(graph) {
608
679
  // zusaetzlich compose) — genau deshalb ist jetzt der Zeitpunkt.
609
680
  // ---------------------------------------------------------------------------
610
681
  function msEmptyScope(graph) {
611
- const crIds = new Set(graph.elements.filter(e => e.type === 'CR').map(e => e.id));
612
- return graph.elements
613
- .filter(e => e.type === 'MS')
614
- .filter(ms => !graph.traces.some(t => t.type === 'relation' && t.target === ms.id && crIds.has(t.source)))
682
+ const idx = indexOf(graph);
683
+ const crIds = new Set(idx.elementsOfType('CR').map(e => e.id));
684
+ return idx.elementsOfType('MS')
685
+ .filter(ms => !idx.in(ms.id, 'relation').some(t => crIds.has(t.source)))
615
686
  .map(ms => ({
616
687
  rule_id: 'MS-01',
617
688
  severity: 'warning',
@@ -625,7 +696,8 @@ function msEmptyScope(graph) {
625
696
  // MS-02: Milestone depends-on dangling target
626
697
  // ---------------------------------------------------------------------------
627
698
  function msDanglingDependency(graph) {
628
- const msIds = new Set(graph.elements.filter(e => e.type === 'MS').map(e => e.id));
699
+ const idx = indexOf(graph);
700
+ const msIds = new Set(idx.elementsOfType('MS').map(e => e.id));
629
701
  return graph.traces
630
702
  .filter(t => t.type === 'relation' && t.label === 'depends-on' && msIds.has(t.source))
631
703
  .filter(t => !msIds.has(t.target))
@@ -646,7 +718,9 @@ function msDanglingDependency(graph) {
646
718
  // here we only judge pairs whose endpoints both resolve. Audit traces are exempt.
647
719
  // ---------------------------------------------------------------------------
648
720
  function validTracePattern(graph) {
649
- const typeById = new Map(graph.elements.map(e => [e.id, e.type]));
721
+ const idx = indexOf(graph);
722
+ // CR-SM-264: die Typ-Map kommt aus dem Index statt je Aufruf neu gebaut zu werden.
723
+ const typeById = { get: (id) => idx.typeOf(id) };
650
724
  return graph.traces
651
725
  .filter(t => t.category !== 'audit')
652
726
  .filter(t => {
@@ -676,8 +750,8 @@ function validTracePattern(graph) {
676
750
  // only and surfaces an unbound runnable TEST in rules_evaluate / readiness.
677
751
  // ---------------------------------------------------------------------------
678
752
  function testMustHaveRunnableBinding(graph) {
679
- return graph.elements
680
- .filter(e => e.type === 'TEST')
753
+ const idx = indexOf(graph);
754
+ return idx.elementsOfType('TEST')
681
755
  .filter(e => e.attributes?.concept !== true) // concept-only TESTs are exempt
682
756
  // CR-SM-231: `testRefs` (Array, min 1) statt `testRef` (Objekt). Semantik unveraendert —
683
757
  // Praesenz und Form, keine Datei-Existenz; die pruefst RC-02.
@@ -708,6 +782,7 @@ function testMustHaveRunnableBinding(graph) {
708
782
  // zu sein.
709
783
  // ---------------------------------------------------------------------------
710
784
  function testFileExclusivity(graph) {
785
+ const idx = indexOf(graph);
711
786
  const claimedBy = new Map();
712
787
  for (const el of graph.elements) {
713
788
  if (el.type !== 'TEST')
@@ -737,7 +812,7 @@ function testFileExclusivity(graph) {
737
812
  element_id: owner,
738
813
  message: `${owner} claims test file '${file}', which is also claimed by ${sorted.filter(o => o !== owner).join(', ')}`,
739
814
  fix_hint: 'A test file belongs to at most one TEST. Split the file, or drop the entry from all but the one acceptance it really evidences',
740
- context: { element_type: 'TEST', element_name: graph.elements.find(e => e.id === owner)?.name },
815
+ context: { element_type: 'TEST', element_name: idx.byId.get(owner)?.name },
741
816
  });
742
817
  }
743
818
  }
@@ -757,7 +832,8 @@ function testFileExclusivity(graph) {
757
832
  // RC-01's job); this guards presence/shape only. Cycle-safe.
758
833
  // ---------------------------------------------------------------------------
759
834
  function funcMustHaveCodeBinding(graph) {
760
- const funcById = new Map(graph.elements.filter(e => e.type === 'FUNC').map(e => [e.id, e]));
835
+ const idx = indexOf(graph);
836
+ const funcById = new Map(idx.elementsOfType('FUNC').map(e => [e.id, e]));
761
837
  const composeFuncChildren = (id) => graph.traces
762
838
  .filter(t => t.source === id && t.type === 'compose' && funcById.has(t.target))
763
839
  .map(t => t.target);
@@ -848,9 +924,10 @@ function funcMustHaveCodeBinding(graph) {
848
924
  // choice. The FCHAIN is the modelled claim; the test is owed on the claim.
849
925
  // ---------------------------------------------------------------------------
850
926
  function fchainMustHaveIntegrationTest(graph) {
927
+ const idx = indexOf(graph);
851
928
  const typeOf = new Map(graph.elements.map(e => [e.id, e.type]));
852
929
  const isFunc = (id) => typeOf.get(id) === 'FUNC';
853
- const io = graph.traces.filter(t => t.type === 'io');
930
+ const io = idx.tracesOfType('io');
854
931
  // FUNC↔FUNC connections via the FLOW hop: (producer FUNC) → FLOW → (consumer FUNC).
855
932
  const connections = [];
856
933
  for (const e of graph.elements) {
@@ -873,7 +950,7 @@ function fchainMustHaveIntegrationTest(graph) {
873
950
  }
874
951
  }
875
952
  // FCHAINs whose satisfy-REQ is verified by a TEST = chains with an integration test.
876
- const verifiedReqs = new Set(graph.traces.filter(t => t.type === 'verify').map(t => t.target));
953
+ const verifiedReqs = new Set(idx.tracesOfType('verify').map(t => t.target));
877
954
  const testedChains = new Set();
878
955
  for (const t of graph.traces) {
879
956
  if (t.type === 'satisfy' && typeOf.get(t.source) === 'FCHAIN' && verifiedReqs.has(t.target)) {
@@ -897,7 +974,7 @@ function fchainMustHaveIntegrationTest(graph) {
897
974
  if (seen.has(key))
898
975
  continue;
899
976
  seen.add(key);
900
- const anchorEl = graph.elements.find(e => e.id === anchor);
977
+ const anchorEl = idx.byId.get(anchor);
901
978
  violations.push({
902
979
  rule_id: 'R-21',
903
980
  severity: 'warning',
@@ -919,11 +996,12 @@ function fchainMustHaveIntegrationTest(graph) {
919
996
  // deliberately not checked (CR-GVE-173/174).
920
997
  // ---------------------------------------------------------------------------
921
998
  function funcMustBeAllocated(graph) {
922
- const funcs = graph.elements.filter(e => e.type === 'FUNC');
923
- const mods = graph.elements.filter(e => e.type === 'MOD');
999
+ const idx = indexOf(graph);
1000
+ const funcs = idx.elementsOfType('FUNC');
1001
+ const mods = idx.elementsOfType('MOD');
924
1002
  const modIds = new Set(mods.map(m => m.id));
925
1003
  return funcs
926
- .filter(fn => !graph.traces.some(t => t.source === fn.id && t.type === 'allocate' && modIds.has(t.target)))
1004
+ .filter(fn => !idx.out(fn.id, 'allocate').some(t => modIds.has(t.target)))
927
1005
  .map(fn => ({
928
1006
  rule_id: 'R-22',
929
1007
  severity: 'warning',
@@ -946,13 +1024,13 @@ function funcMustBeAllocated(graph) {
946
1024
  // R-22: one rule per element perspective (like R-01 REQ-side beside R-02/R-05).
947
1025
  // ---------------------------------------------------------------------------
948
1026
  function modMustHaveAllocatedFunc(graph) {
949
- const mods = graph.elements.filter(e => e.type === 'MOD');
950
- const funcs = graph.elements.filter(e => e.type === 'FUNC');
1027
+ const idx = indexOf(graph);
1028
+ const mods = idx.elementsOfType('MOD');
1029
+ const funcs = idx.elementsOfType('FUNC');
951
1030
  const modIds = new Set(mods.map(m => m.id));
952
- const unallocatedFuncs = funcs.filter(fn => !graph.traces.some(t => t.source === fn.id && t.type === 'allocate' && modIds.has(t.target)));
1031
+ const unallocatedFuncs = funcs.filter(fn => !idx.out(fn.id, 'allocate').some(t => modIds.has(t.target)));
953
1032
  return mods
954
- .filter(mod => !graph.traces.some(t => t.target === mod.id && t.type === 'allocate' &&
955
- graph.elements.some(e => e.id === t.source && e.type === 'FUNC')))
1033
+ .filter(mod => !idx.in(mod.id, 'allocate').some(t => idx.typeOf(t.source) === 'FUNC'))
956
1034
  .map(mod => ({
957
1035
  rule_id: 'R-23',
958
1036
  severity: 'warning',
@@ -980,8 +1058,8 @@ function modMustHaveAllocatedFunc(graph) {
980
1058
  // out of scope here (pure, no I/O — that is RC-03's job).
981
1059
  // ---------------------------------------------------------------------------
982
1060
  function schemaMustHaveSchemaRef(graph) {
983
- return graph.elements
984
- .filter(e => e.type === 'SCHEMA')
1061
+ const idx = indexOf(graph);
1062
+ return idx.elementsOfType('SCHEMA')
985
1063
  .filter(e => e.attributes?.concept !== true && e.attributes?.external !== true)
986
1064
  .filter(e => !RealRefSchema.safeParse(e.attributes?.realRef).success)
987
1065
  .map(sc => ({
@@ -1004,8 +1082,9 @@ function schemaMustHaveSchemaRef(graph) {
1004
1082
  // realRef with a file. RESOLUTION (file on disk) is a consumer/RC concern.
1005
1083
  // ---------------------------------------------------------------------------
1006
1084
  function physicalModMustHaveRealRef(graph) {
1007
- return graph.elements
1008
- .filter(e => e.type === 'MOD' && e.attributes?.kind === 'physical')
1085
+ const idx = indexOf(graph);
1086
+ return idx.elementsOfType('MOD')
1087
+ .filter(e => e.attributes?.kind === 'physical')
1009
1088
  .filter(e => e.attributes?.concept !== true && e.attributes?.external !== true)
1010
1089
  .filter(e => !RealRefSchema.safeParse(e.attributes?.realRef).success)
1011
1090
  .map(mod => ({
@@ -1056,13 +1135,13 @@ function physicalModMustHaveRealRef(graph) {
1056
1135
  // waere Neubau-Zwang statt Steuerung.
1057
1136
  // ---------------------------------------------------------------------------
1058
1137
  function funcMustBeInEffectChain(graph) {
1059
- const funcs = graph.elements.filter(e => e.type === 'FUNC');
1060
- const fchains = graph.elements.filter(e => e.type === 'FCHAIN');
1138
+ const idx = indexOf(graph);
1139
+ const funcs = idx.elementsOfType('FUNC');
1140
+ const fchains = idx.elementsOfType('FCHAIN');
1061
1141
  const funcIds = new Set(funcs.map(f => f.id));
1062
1142
  const fchainIds = new Set(fchains.map(fc => fc.id));
1063
- const decomposed = new Set(graph.traces
1064
- .filter(t => t.type === 'compose' && funcIds.has(t.source) && funcIds.has(t.target))
1065
- .map(t => t.source));
1143
+ // CR-SM-256: dieselbe Definition wie R-02 und R-31, nicht mehr die lokale Kopie.
1144
+ const decomposed = decomposedFuncs(graph);
1066
1145
  const inChain = new Set(graph.traces
1067
1146
  .filter(t => t.type === 'compose' && fchainIds.has(t.source) && funcIds.has(t.target))
1068
1147
  .map(t => t.target));
@@ -1092,9 +1171,16 @@ function funcMustBeInEffectChain(graph) {
1092
1171
  // eigenen Nenner-Beitrag, was CR-SM-242 fuer IO-01 als Fehlmessung nachgewiesen hat.
1093
1172
  // ---------------------------------------------------------------------------
1094
1173
  function funcMustBeWired(graph) {
1095
- const funcs = graph.elements.filter(e => e.type === 'FUNC');
1096
- const flows = graph.elements.filter(e => e.type === 'FLOW');
1097
- const io = graph.traces.filter(t => t.type === 'io');
1174
+ const idx = indexOf(graph);
1175
+ // CR-SM-256: Grundgesamtheit sind die BLAETTER, wie bei R-02 und R-30. Durch einen
1176
+ // Blackbox-Block fliessen keine Daten die io-Kanten haengen an seinen Kindern.
1177
+ // CR-GC-375 hat das fuer R-31 ausdruecklich offengelassen ("eine eigene Frage, hier
1178
+ // bewusst nicht"); die Messung liegt jetzt vor: 12 von 13 Bloecken am
1179
+ // graphcode-Selbstmodell melden nur deshalb, und keiner aus einem anderen Grund.
1180
+ const decomposed = decomposedFuncs(graph);
1181
+ const funcs = idx.elementsOfType('FUNC').filter(e => !decomposed.has(e.id));
1182
+ const flows = idx.elementsOfType('FLOW');
1183
+ const io = idx.tracesOfType('io');
1098
1184
  const hasInput = new Set(io.map(t => t.target)); // FLOW -io-> FUNC
1099
1185
  const hasOutput = new Set(io.map(t => t.source)); // FUNC -io-> FLOW
1100
1186
  return funcs