@sigloch/contracts 6.1.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({
@@ -44,21 +45,25 @@ export const RuleViolation = z.object({
44
45
  * Blaettern (so schon R-30 seit CR-SM-249, so R-20 seit CR-210).
45
46
  *
46
47
  * Bewusst EIN Helper statt drei Kopien: eine zweite Definition von "Blatt" waere ein
47
- * zweiter Weg zu derselben Aussage, und der billigere gewinnt immer.
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.
48
50
  */
49
- function decomposedFuncs(graph) {
50
- const funcIds = new Set(graph.elements.filter(e => e.type === 'FUNC').map(f => f.id));
51
+ export function decomposedFuncs(graph) {
52
+ const idx = indexOf(graph);
53
+ const funcIds = new Set(idx.elementsOfType('FUNC').map(f => f.id));
51
54
  return new Set(graph.traces
52
55
  .filter(t => t.type === 'compose' && funcIds.has(t.source) && funcIds.has(t.target))
53
56
  .map(t => t.source));
54
57
  }
55
58
  function findParentModule(graph, elementId) {
56
- 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];
57
61
  return trace?.target;
58
62
  }
59
63
  /** Get all outgoing traces of a given type from an element. */
60
64
  function outgoingTraces(graph, elementId, traceType) {
61
- 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);
62
67
  }
63
68
  /** Tokenize id/name/description into a lowercase token set (drops tokens < 3 chars). */
64
69
  function overlapTokens(...parts) {
@@ -69,35 +74,69 @@ function overlapTokens(...parts) {
69
74
  .split(/[^a-z0-9]+/)
70
75
  .filter(t => t.length >= 3));
71
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
+ }
72
101
  /**
73
102
  * Map elements to ViolationCandidate format. When `ref` is given, rank candidates
74
103
  * by id/name/description token overlap with `ref` (descending) so the most relevant
75
104
  * target is first — e.g. REQ-bootstrap → TEST-bootstrap (CR-GC-203 item 3). Ranking
76
105
  * is a hint, not an auto-link; semantic confirmation stays with the caller.
77
106
  */
78
- 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) {
79
112
  if (!ref)
80
113
  return elements.map(e => ({ id: e.id, type: e.type, name: e.name }));
81
- const refTokens = overlapTokens(ref.id, ref.name, ref.description);
114
+ const refTokens = tokensOf(ref);
82
115
  const score = (e) => {
83
116
  let n = 0;
84
- for (const tok of overlapTokens(e.id, e.name, e.description))
117
+ for (const tok of tokensOf(e))
85
118
  if (refTokens.has(tok))
86
119
  n++;
87
120
  return n;
88
121
  };
89
- return [...elements]
90
- .sort((a, b) => score(b) - score(a))
91
- .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 }));
92
130
  }
93
131
  // ---------------------------------------------------------------------------
94
132
  // R-01: Every REQ must have at least one verify trace
95
133
  // ---------------------------------------------------------------------------
96
134
  function reqMustHaveVerification(graph) {
97
- const reqs = graph.elements.filter(e => e.type === 'REQ');
98
- 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');
99
138
  return reqs
100
- .filter(req => !graph.traces.some(t => t.target === req.id && t.type === 'verify'))
139
+ .filter(req => idx.in(req.id, 'verify').length === 0)
101
140
  .map(req => ({
102
141
  rule_id: 'R-01',
103
142
  severity: 'error',
@@ -116,14 +155,15 @@ function reqMustHaveVerification(graph) {
116
155
  // R-02: Every FUNC must satisfy at least one REQ
117
156
  // ---------------------------------------------------------------------------
118
157
  function funcMustSatisfyReq(graph) {
158
+ const idx = indexOf(graph);
119
159
  // CR-SM-256: Grundgesamtheit sind die BLAETTER. Ein zerlegter FUNC erfuellt keine REQ,
120
160
  // die nicht schon eine seiner Blatt-Funktionen erfuellt — wer ihn befriedigen will,
121
161
  // muss eine satisfy-Kante erfinden, die dieselbe REQ ein zweites Mal beansprucht.
122
162
  // Am graphcode-Selbstmodell (graphVersion 171) melden 12 der 13 Bloecke R-02 UND R-31,
123
163
  // also dieselbe Ursache zweimal gezaehlt — die Klasse CR-SM-235/-242.
124
164
  const decomposed = decomposedFuncs(graph);
125
- const funcs = graph.elements.filter(e => e.type === 'FUNC' && !decomposed.has(e.id));
126
- const allReqs = graph.elements.filter(e => e.type === 'REQ');
165
+ const funcs = idx.elementsOfType('FUNC').filter(e => !decomposed.has(e.id));
166
+ const allReqs = idx.elementsOfType('REQ');
127
167
  // CR-GC-366: der Filter prueft den ZIELTYP. Vorher war er zieltyp-blind (`type === 'satisfy'`),
128
168
  // womit ein `FUNC -satisfy-> UC` die Regel stumm schaltete, obwohl kein Requirement erfuellt war —
129
169
  // der Regelname versprach mehr als der Code pruefte. Am graphcode-Selbstmodell verdeckte das 6 von
@@ -132,15 +172,15 @@ function funcMustSatisfyReq(graph) {
132
172
  // Ziele das Meta-Modell gerade zulaesst.
133
173
  const reqIds = new Set(allReqs.map(r => r.id));
134
174
  return funcs
135
- .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)))
136
176
  .map(fn => {
137
177
  const modId = findParentModule(graph, fn.id);
138
178
  // Candidate REQs: same module's other FUNCs satisfy these, or constraints referencing this func
139
179
  const siblingReqs = modId
140
180
  ? graph.traces
141
181
  .filter(t => t.type === 'allocate' && t.target === modId && t.source !== fn.id)
142
- .flatMap(t => graph.traces.filter(st => st.source === t.source && st.type === 'satisfy'))
143
- .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))
144
184
  .filter((e) => !!e)
145
185
  : [];
146
186
  // Also include constraints whose description mentions this func's name
@@ -167,12 +207,13 @@ function funcMustSatisfyReq(graph) {
167
207
  // R-03: ASIL isolation
168
208
  // ---------------------------------------------------------------------------
169
209
  function asilIsolation(graph) {
210
+ const idx = indexOf(graph);
170
211
  const violations = [];
171
- const modules = graph.elements.filter(e => e.type === 'MOD');
212
+ const modules = idx.elementsOfType('MOD');
172
213
  for (const mod of modules) {
173
214
  const allocated = graph.traces
174
215
  .filter(t => t.target === mod.id && t.type === 'allocate')
175
- .map(t => graph.elements.find(e => e.id === t.source))
216
+ .map(t => idx.byId.get(t.source))
176
217
  .filter((e) => !!e);
177
218
  const hasD = allocated.some(e => e.asil === 'D');
178
219
  const hasQM = allocated.some(e => e.asil === 'QM');
@@ -206,22 +247,22 @@ function asilIsolation(graph) {
206
247
  // `null` → messen, nicht urteilen: die Regel schweigt.
207
248
  // ---------------------------------------------------------------------------
208
249
  function maxModuleSize(graph, policy) {
250
+ const idx = indexOf(graph);
209
251
  const violations = [];
210
252
  const steps = policy.moduleSize;
211
253
  if (steps === null)
212
254
  return violations;
213
- const modules = graph.elements.filter(e => e.type === 'MOD');
255
+ const modules = idx.elementsOfType('MOD');
214
256
  for (const mod of modules) {
215
- const allocatedIds = graph.traces.filter(t => t.target === mod.id && t.type === 'allocate').map(t => t.source);
216
- 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);
217
259
  const funcCount = allocated.length;
218
260
  if (funcCount <= steps.coupled)
219
261
  continue;
220
262
  // Count crossing flows: io paths from FUNCs in this module to FUNCs in other modules
221
263
  const funcIds = new Set(allocatedIds);
222
- const crossings = graph.traces.filter(t => {
223
- if (t.type !== 'io')
224
- return false;
264
+ // CR-SM-264: nur die io-Kanten, nicht alle Traces je MOD.
265
+ const crossings = idx.tracesOfType('io').filter(t => {
225
266
  const srcIn = funcIds.has(t.source);
226
267
  const tgtIn = funcIds.has(t.target);
227
268
  return (srcIn && !tgtIn) || (!srcIn && tgtIn);
@@ -263,10 +304,11 @@ function maxModuleSize(graph, policy) {
263
304
  // R-05: Every TEST must verify at least one REQ
264
305
  // ---------------------------------------------------------------------------
265
306
  function testMustVerifyReq(graph) {
266
- const tests = graph.elements.filter(e => e.type === 'TEST');
267
- 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');
268
310
  return tests
269
- .filter(test => !graph.traces.some(t => t.source === test.id && t.type === 'verify'))
311
+ .filter(test => idx.out(test.id, 'verify').length === 0)
270
312
  .map(test => ({
271
313
  rule_id: 'R-05',
272
314
  severity: 'warning',
@@ -290,11 +332,12 @@ function testMustVerifyReq(graph) {
290
332
  // R-08: Bidirectional trace consistency
291
333
  // ---------------------------------------------------------------------------
292
334
  function traceConsistency(graph) {
335
+ const idx = indexOf(graph);
293
336
  return graph.traces
294
337
  .filter(t => t.category !== 'audit')
295
338
  .filter(t => {
296
- const sourceExists = graph.elements.some(e => e.id === t.source);
297
- 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);
298
341
  return !sourceExists || !targetExists;
299
342
  })
300
343
  .map(t => ({
@@ -315,11 +358,12 @@ function traceConsistency(graph) {
315
358
  // concern, stricter check — no parallel path); up to two warnings per FLOW.
316
359
  // ---------------------------------------------------------------------------
317
360
  function flowCompleteness(graph) {
318
- const flows = graph.elements.filter(e => e.type === 'FLOW');
361
+ const idx = indexOf(graph);
362
+ const flows = idx.elementsOfType('FLOW');
319
363
  // Producers/consumers of a FLOW are FUNCs or ACTORs (io endpoints on the
320
364
  // upstream/downstream side); UC may consume too. Offer both as candidates.
321
- const sources = graph.elements.filter(e => e.type === 'FUNC' || e.type === 'ACTOR');
322
- 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');
323
367
  const violations = [];
324
368
  for (const f of flows) {
325
369
  const ctx = { element_type: f.type, element_name: f.name, candidate_targets: toCandidates(sources) };
@@ -361,6 +405,7 @@ function flowCompleteness(graph) {
361
405
  /** Trace types on which a direct 2-cycle is a real finding (CR-GC-315). */
362
406
  const CIRCULAR_TRACE_TYPES = new Set(['compose', 'allocate', 'relation']);
363
407
  function noDirectCircular(graph) {
408
+ const idx = indexOf(graph);
364
409
  const violations = [];
365
410
  // Direction-independent key: A↔B is ONE cycle, reported once. Keying on the
366
411
  // message (CR-GC-315 predecessor) never collapsed anything — the two
@@ -369,7 +414,7 @@ function noDirectCircular(graph) {
369
414
  for (const t of graph.traces) {
370
415
  if (!CIRCULAR_TRACE_TYPES.has(t.type))
371
416
  continue;
372
- 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))
373
418
  continue;
374
419
  const key = `${t.type}|${[t.source, t.target].sort().join('|')}`;
375
420
  if (seen.has(key))
@@ -391,12 +436,12 @@ function noDirectCircular(graph) {
391
436
  // RD-01: Unresolved requirement (leaf REQ without satisfy)
392
437
  // ---------------------------------------------------------------------------
393
438
  function unresolvedRequirement(graph) {
394
- const reqs = graph.elements.filter(e => e.type === 'REQ');
439
+ const idx = indexOf(graph);
440
+ const reqs = idx.elementsOfType('REQ');
395
441
  // Leaf REQs: no compose→REQ children
396
- const leafReqs = reqs.filter(req => !graph.traces.some(t => t.source === req.id && t.type === 'compose' &&
397
- 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'));
398
443
  return leafReqs
399
- .filter(req => !graph.traces.some(t => t.target === req.id && t.type === 'satisfy'))
444
+ .filter(req => idx.in(req.id, 'satisfy').length === 0)
400
445
  .map(req => ({
401
446
  rule_id: 'RD-01',
402
447
  severity: 'warning',
@@ -415,12 +460,11 @@ function unresolvedRequirement(graph) {
415
460
  // RD-02: Decomposition consistency (parent REQ should not have FUNC satisfy)
416
461
  // ---------------------------------------------------------------------------
417
462
  function decompositionConsistency(graph) {
418
- const reqs = graph.elements.filter(e => e.type === 'REQ');
419
- const parentReqs = reqs.filter(req => graph.traces.some(t => t.source === req.id && t.type === 'compose' &&
420
- 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'));
421
466
  return parentReqs
422
- .filter(req => graph.traces.some(t => t.target === req.id && t.type === 'satisfy' &&
423
- 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'))
424
468
  .map(req => ({
425
469
  rule_id: 'RD-02',
426
470
  severity: 'warning',
@@ -434,18 +478,18 @@ function decompositionConsistency(graph) {
434
478
  // RD-03: No premature decomposition (all children same satisfy target)
435
479
  // ---------------------------------------------------------------------------
436
480
  function noPrematureDecomposition(graph) {
437
- const reqs = graph.elements.filter(e => e.type === 'REQ');
438
- const parentReqs = reqs.filter(req => graph.traces.some(t => t.source === req.id && t.type === 'compose' &&
439
- 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'));
440
484
  return parentReqs
441
485
  .filter(parent => {
442
486
  const childIds = graph.traces
443
487
  .filter(t => t.source === parent.id && t.type === 'compose' &&
444
- graph.elements.some(e => e.id === t.target && e.type === 'REQ'))
488
+ idx.typeOf(t.target) === 'REQ')
445
489
  .map(t => t.target);
446
490
  if (childIds.length < 2)
447
491
  return false;
448
- 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));
449
493
  // All children satisfied by the same single source
450
494
  const allSame = satisfyTargets.every(targets => targets.length === 1 && targets[0] === satisfyTargets[0]?.[0]);
451
495
  return allSame && satisfyTargets[0]?.length === 1;
@@ -473,8 +517,10 @@ function noPrematureDecomposition(graph) {
473
517
  */
474
518
  const DECOMPOSITION_BREADTH_MAX = 11;
475
519
  function decompositionBreadth(graph) {
476
- const typeOf = new Map(graph.elements.map(e => [e.id, e.type]));
477
- 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;
478
524
  const counts = new Map();
479
525
  const bump = (parentId, kind) => {
480
526
  const key = `${parentId}\u0000${kind}`;
@@ -485,8 +531,8 @@ function decompositionBreadth(graph) {
485
531
  counts.set(key, { parentId, kind, n: 1 });
486
532
  };
487
533
  for (const t of graph.traces) {
488
- const src = typeOf.get(t.source);
489
- const tgt = typeOf.get(t.target);
534
+ const src = typeOf.typeOf(t.source);
535
+ const tgt = typeOf.typeOf(t.target);
490
536
  if (t.type === 'compose' && src === 'FUNC' && tgt === 'FUNC') {
491
537
  bump(t.source, 'sub-FUNC');
492
538
  }
@@ -515,9 +561,10 @@ function decompositionBreadth(graph) {
515
561
  // R-14: UC must have at least 1 compose trace (→ FCHAIN or REQ) (CR-117)
516
562
  // ---------------------------------------------------------------------------
517
563
  function ucMustHaveCompose(graph) {
518
- const ucs = graph.elements.filter(e => e.type === 'UC');
564
+ const idx = indexOf(graph);
565
+ const ucs = idx.elementsOfType('UC');
519
566
  return ucs
520
- .filter(uc => !graph.traces.some(t => t.source === uc.id && t.type === 'compose'))
567
+ .filter(uc => idx.out(uc.id, 'compose').length === 0)
521
568
  .map(uc => ({
522
569
  rule_id: 'R-14',
523
570
  severity: 'warning',
@@ -549,14 +596,15 @@ function ucMustHaveCompose(graph) {
549
596
  // einen einzigen Befund ueber beide Selbstmodelle.
550
597
  // ---------------------------------------------------------------------------
551
598
  function fchainCompleteness(graph) {
552
- const fchains = graph.elements.filter(e => e.type === 'FCHAIN');
553
- const funcs = graph.elements.filter(e => e.type === 'FUNC');
554
- 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');
555
603
  const ucIds = new Set(ucs.map(u => u.id));
556
604
  const violations = [];
557
605
  for (const fc of fchains) {
558
606
  const ctx = { element_type: fc.type, element_name: fc.name };
559
- if (!graph.traces.some(t => t.source === fc.id && t.type === 'compose')) {
607
+ if (idx.out(fc.id, 'compose').length === 0) {
560
608
  violations.push({
561
609
  rule_id: 'R-15',
562
610
  severity: 'warning',
@@ -566,7 +614,7 @@ function fchainCompleteness(graph) {
566
614
  context: { ...ctx, candidate_targets: toCandidates(funcs) },
567
615
  });
568
616
  }
569
- 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))) {
570
618
  violations.push({
571
619
  rule_id: 'R-15',
572
620
  severity: 'warning',
@@ -583,9 +631,10 @@ function fchainCompleteness(graph) {
583
631
  // R-16: ACTOR must have at least 1 io trace (CR-117)
584
632
  // ---------------------------------------------------------------------------
585
633
  function actorMustHaveTrace(graph) {
586
- const actors = graph.elements.filter(e => e.type === 'ACTOR');
634
+ const idx = indexOf(graph);
635
+ const actors = idx.elementsOfType('ACTOR');
587
636
  return actors
588
- .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)
589
638
  .map(a => ({
590
639
  rule_id: 'R-16',
591
640
  severity: 'warning',
@@ -602,9 +651,10 @@ function actorMustHaveTrace(graph) {
602
651
  // R-17: SYS must have at least 1 compose trace (CR-117)
603
652
  // ---------------------------------------------------------------------------
604
653
  function sysMustHaveCompose(graph) {
605
- const systems = graph.elements.filter(e => e.type === 'SYS');
654
+ const idx = indexOf(graph);
655
+ const systems = idx.elementsOfType('SYS');
606
656
  return systems
607
- .filter(sys => !graph.traces.some(t => t.source === sys.id && t.type === 'compose'))
657
+ .filter(sys => idx.out(sys.id, 'compose').length === 0)
608
658
  .map(sys => ({
609
659
  rule_id: 'R-17',
610
660
  severity: 'warning',
@@ -629,10 +679,10 @@ function sysMustHaveCompose(graph) {
629
679
  // zusaetzlich compose) — genau deshalb ist jetzt der Zeitpunkt.
630
680
  // ---------------------------------------------------------------------------
631
681
  function msEmptyScope(graph) {
632
- const crIds = new Set(graph.elements.filter(e => e.type === 'CR').map(e => e.id));
633
- return graph.elements
634
- .filter(e => e.type === 'MS')
635
- .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)))
636
686
  .map(ms => ({
637
687
  rule_id: 'MS-01',
638
688
  severity: 'warning',
@@ -646,7 +696,8 @@ function msEmptyScope(graph) {
646
696
  // MS-02: Milestone depends-on dangling target
647
697
  // ---------------------------------------------------------------------------
648
698
  function msDanglingDependency(graph) {
649
- 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));
650
701
  return graph.traces
651
702
  .filter(t => t.type === 'relation' && t.label === 'depends-on' && msIds.has(t.source))
652
703
  .filter(t => !msIds.has(t.target))
@@ -667,7 +718,9 @@ function msDanglingDependency(graph) {
667
718
  // here we only judge pairs whose endpoints both resolve. Audit traces are exempt.
668
719
  // ---------------------------------------------------------------------------
669
720
  function validTracePattern(graph) {
670
- 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) };
671
724
  return graph.traces
672
725
  .filter(t => t.category !== 'audit')
673
726
  .filter(t => {
@@ -697,8 +750,8 @@ function validTracePattern(graph) {
697
750
  // only and surfaces an unbound runnable TEST in rules_evaluate / readiness.
698
751
  // ---------------------------------------------------------------------------
699
752
  function testMustHaveRunnableBinding(graph) {
700
- return graph.elements
701
- .filter(e => e.type === 'TEST')
753
+ const idx = indexOf(graph);
754
+ return idx.elementsOfType('TEST')
702
755
  .filter(e => e.attributes?.concept !== true) // concept-only TESTs are exempt
703
756
  // CR-SM-231: `testRefs` (Array, min 1) statt `testRef` (Objekt). Semantik unveraendert —
704
757
  // Praesenz und Form, keine Datei-Existenz; die pruefst RC-02.
@@ -729,6 +782,7 @@ function testMustHaveRunnableBinding(graph) {
729
782
  // zu sein.
730
783
  // ---------------------------------------------------------------------------
731
784
  function testFileExclusivity(graph) {
785
+ const idx = indexOf(graph);
732
786
  const claimedBy = new Map();
733
787
  for (const el of graph.elements) {
734
788
  if (el.type !== 'TEST')
@@ -758,7 +812,7 @@ function testFileExclusivity(graph) {
758
812
  element_id: owner,
759
813
  message: `${owner} claims test file '${file}', which is also claimed by ${sorted.filter(o => o !== owner).join(', ')}`,
760
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',
761
- 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 },
762
816
  });
763
817
  }
764
818
  }
@@ -778,7 +832,8 @@ function testFileExclusivity(graph) {
778
832
  // RC-01's job); this guards presence/shape only. Cycle-safe.
779
833
  // ---------------------------------------------------------------------------
780
834
  function funcMustHaveCodeBinding(graph) {
781
- 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]));
782
837
  const composeFuncChildren = (id) => graph.traces
783
838
  .filter(t => t.source === id && t.type === 'compose' && funcById.has(t.target))
784
839
  .map(t => t.target);
@@ -869,9 +924,10 @@ function funcMustHaveCodeBinding(graph) {
869
924
  // choice. The FCHAIN is the modelled claim; the test is owed on the claim.
870
925
  // ---------------------------------------------------------------------------
871
926
  function fchainMustHaveIntegrationTest(graph) {
927
+ const idx = indexOf(graph);
872
928
  const typeOf = new Map(graph.elements.map(e => [e.id, e.type]));
873
929
  const isFunc = (id) => typeOf.get(id) === 'FUNC';
874
- const io = graph.traces.filter(t => t.type === 'io');
930
+ const io = idx.tracesOfType('io');
875
931
  // FUNC↔FUNC connections via the FLOW hop: (producer FUNC) → FLOW → (consumer FUNC).
876
932
  const connections = [];
877
933
  for (const e of graph.elements) {
@@ -894,7 +950,7 @@ function fchainMustHaveIntegrationTest(graph) {
894
950
  }
895
951
  }
896
952
  // FCHAINs whose satisfy-REQ is verified by a TEST = chains with an integration test.
897
- 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));
898
954
  const testedChains = new Set();
899
955
  for (const t of graph.traces) {
900
956
  if (t.type === 'satisfy' && typeOf.get(t.source) === 'FCHAIN' && verifiedReqs.has(t.target)) {
@@ -918,7 +974,7 @@ function fchainMustHaveIntegrationTest(graph) {
918
974
  if (seen.has(key))
919
975
  continue;
920
976
  seen.add(key);
921
- const anchorEl = graph.elements.find(e => e.id === anchor);
977
+ const anchorEl = idx.byId.get(anchor);
922
978
  violations.push({
923
979
  rule_id: 'R-21',
924
980
  severity: 'warning',
@@ -940,11 +996,12 @@ function fchainMustHaveIntegrationTest(graph) {
940
996
  // deliberately not checked (CR-GVE-173/174).
941
997
  // ---------------------------------------------------------------------------
942
998
  function funcMustBeAllocated(graph) {
943
- const funcs = graph.elements.filter(e => e.type === 'FUNC');
944
- 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');
945
1002
  const modIds = new Set(mods.map(m => m.id));
946
1003
  return funcs
947
- .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)))
948
1005
  .map(fn => ({
949
1006
  rule_id: 'R-22',
950
1007
  severity: 'warning',
@@ -967,13 +1024,13 @@ function funcMustBeAllocated(graph) {
967
1024
  // R-22: one rule per element perspective (like R-01 REQ-side beside R-02/R-05).
968
1025
  // ---------------------------------------------------------------------------
969
1026
  function modMustHaveAllocatedFunc(graph) {
970
- const mods = graph.elements.filter(e => e.type === 'MOD');
971
- 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');
972
1030
  const modIds = new Set(mods.map(m => m.id));
973
- 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)));
974
1032
  return mods
975
- .filter(mod => !graph.traces.some(t => t.target === mod.id && t.type === 'allocate' &&
976
- 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'))
977
1034
  .map(mod => ({
978
1035
  rule_id: 'R-23',
979
1036
  severity: 'warning',
@@ -1001,8 +1058,8 @@ function modMustHaveAllocatedFunc(graph) {
1001
1058
  // out of scope here (pure, no I/O — that is RC-03's job).
1002
1059
  // ---------------------------------------------------------------------------
1003
1060
  function schemaMustHaveSchemaRef(graph) {
1004
- return graph.elements
1005
- .filter(e => e.type === 'SCHEMA')
1061
+ const idx = indexOf(graph);
1062
+ return idx.elementsOfType('SCHEMA')
1006
1063
  .filter(e => e.attributes?.concept !== true && e.attributes?.external !== true)
1007
1064
  .filter(e => !RealRefSchema.safeParse(e.attributes?.realRef).success)
1008
1065
  .map(sc => ({
@@ -1025,8 +1082,9 @@ function schemaMustHaveSchemaRef(graph) {
1025
1082
  // realRef with a file. RESOLUTION (file on disk) is a consumer/RC concern.
1026
1083
  // ---------------------------------------------------------------------------
1027
1084
  function physicalModMustHaveRealRef(graph) {
1028
- return graph.elements
1029
- .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')
1030
1088
  .filter(e => e.attributes?.concept !== true && e.attributes?.external !== true)
1031
1089
  .filter(e => !RealRefSchema.safeParse(e.attributes?.realRef).success)
1032
1090
  .map(mod => ({
@@ -1077,8 +1135,9 @@ function physicalModMustHaveRealRef(graph) {
1077
1135
  // waere Neubau-Zwang statt Steuerung.
1078
1136
  // ---------------------------------------------------------------------------
1079
1137
  function funcMustBeInEffectChain(graph) {
1080
- const funcs = graph.elements.filter(e => e.type === 'FUNC');
1081
- 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');
1082
1141
  const funcIds = new Set(funcs.map(f => f.id));
1083
1142
  const fchainIds = new Set(fchains.map(fc => fc.id));
1084
1143
  // CR-SM-256: dieselbe Definition wie R-02 und R-31, nicht mehr die lokale Kopie.
@@ -1112,15 +1171,16 @@ function funcMustBeInEffectChain(graph) {
1112
1171
  // eigenen Nenner-Beitrag, was CR-SM-242 fuer IO-01 als Fehlmessung nachgewiesen hat.
1113
1172
  // ---------------------------------------------------------------------------
1114
1173
  function funcMustBeWired(graph) {
1174
+ const idx = indexOf(graph);
1115
1175
  // CR-SM-256: Grundgesamtheit sind die BLAETTER, wie bei R-02 und R-30. Durch einen
1116
1176
  // Blackbox-Block fliessen keine Daten — die io-Kanten haengen an seinen Kindern.
1117
1177
  // CR-GC-375 hat das fuer R-31 ausdruecklich offengelassen ("eine eigene Frage, hier
1118
1178
  // bewusst nicht"); die Messung liegt jetzt vor: 12 von 13 Bloecken am
1119
1179
  // graphcode-Selbstmodell melden nur deshalb, und keiner aus einem anderen Grund.
1120
1180
  const decomposed = decomposedFuncs(graph);
1121
- const funcs = graph.elements.filter(e => e.type === 'FUNC' && !decomposed.has(e.id));
1122
- const flows = graph.elements.filter(e => e.type === 'FLOW');
1123
- const io = graph.traces.filter(t => t.type === 'io');
1181
+ const funcs = idx.elementsOfType('FUNC').filter(e => !decomposed.has(e.id));
1182
+ const flows = idx.elementsOfType('FLOW');
1183
+ const io = idx.tracesOfType('io');
1124
1184
  const hasInput = new Set(io.map(t => t.target)); // FLOW -io-> FUNC
1125
1185
  const hasOutput = new Set(io.map(t => t.source)); // FUNC -io-> FLOW
1126
1186
  return funcs