@sigloch/graph-api-core 0.4.1 → 2.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.
@@ -8,16 +8,36 @@ export declare class FormatECodec {
8
8
  constructor(ontology: OntologyDescriptor);
9
9
  /** Extract a ```format-e block from LLM output. Returns null if not found. */
10
10
  extractFromLlm(llmOutput: string): string | null;
11
- /** Parse Format E text into validated operations. */
12
- parse(input: string): FormatEDiff;
11
+ /**
12
+ * Parse Format E text into validated operations.
13
+ *
14
+ * `options.resolveType` types uids this text does not declare — a mutation diff that
15
+ * only adds edges between existing nodes carries no `## Nodes` block. Callers bind it
16
+ * to their store. Without it, such a diff produces errors, never a silent skip.
17
+ */
18
+ parse(input: string, options?: {
19
+ resolveType?: (uid: string) => string | undefined;
20
+ }): FormatEDiff;
13
21
  /** Serialize a Graph to Format E text. */
14
22
  serialize(graph: Graph): string;
15
- private looksLikeNode;
16
23
  private parseNodeLine;
17
24
  private parseEdgeLine;
18
25
  private parseMerge;
19
- private extractNodeType;
20
26
  private parseInlineAttrs;
27
+ /**
28
+ * CR-SM-215: fan-out serialization — edges sharing `(sourceId, edgeType)` collapse
29
+ * onto one line `A -x-> B, C, D`. The source UID is written once instead of once per
30
+ * edge; on the graphcode SSOT graph that is 318 lines instead of 751.
31
+ *
32
+ * Two invariants:
33
+ * - **Edges carrying attributes stay single-line.** `serializeAttrs` binds to one
34
+ * edge; a group would either drop `cardinality`/`constraint`/`notes` or wrongly
35
+ * share one edge's attributes with its siblings.
36
+ * - **Deterministic order** (graphcode `REQ-deterministic-serialization`): entries
37
+ * sorted by source, then edge type, then first target — code-unit order, not
38
+ * `localeCompare`, which is locale-dependent.
39
+ */
40
+ private serializeEdges;
21
41
  private serializeAttrs;
22
42
  private edgeTypeToArrow;
23
43
  }
@@ -1,6 +1,11 @@
1
1
  /**
2
2
  * FormatECodec — ontology-agnostic Format E parser/serializer.
3
3
  * Domain-specific node types, edge types, and arrows come from OntologyDescriptor.
4
+ *
5
+ * CR-SM-216 (Format-E v2): a node's type comes from the `### <TYPE>` section it is
6
+ * declared under, never from the spelling of its uid. Id conventions differ across the
7
+ * family (`TYPE-slug`, `Name.TypeAbbr.Counter`, `cand_<hex>`); typing by spelling made
8
+ * every foreign convention fail silently instead of loudly.
4
9
  */
5
10
  import { isValidTrace, tracePatternsOf } from './types.js';
6
11
  // ---------------------------------------------------------------------------
@@ -42,11 +47,21 @@ export class FormatECodec {
42
47
  const m = FORMAT_E_FENCE.exec(llmOutput);
43
48
  return m ? m[1].trim() : null;
44
49
  }
45
- /** Parse Format E text into validated operations. */
46
- parse(input) {
50
+ /**
51
+ * Parse Format E text into validated operations.
52
+ *
53
+ * `options.resolveType` types uids this text does not declare — a mutation diff that
54
+ * only adds edges between existing nodes carries no `## Nodes` block. Callers bind it
55
+ * to their store. Without it, such a diff produces errors, never a silent skip.
56
+ */
57
+ parse(input, options = {}) {
47
58
  const operations = [];
48
59
  const errors = [];
49
60
  let section = null;
61
+ let currentType = null;
62
+ /** uid → type, declared by this text's `### <TYPE>` sections. */
63
+ const declared = new Map();
64
+ const typeOf = (uid) => declared.get(uid) ?? options.resolveType?.(uid);
50
65
  for (const rawLine of input.split('\n')) {
51
66
  const line = rawLine.trim();
52
67
  if (!line || line.startsWith('//') || line.startsWith('#!'))
@@ -68,14 +83,29 @@ export class FormatECodec {
68
83
  // Section headers
69
84
  if (/^##\s*nodes?\s*$/i.test(line)) {
70
85
  section = 'nodes';
86
+ currentType = null;
71
87
  continue;
72
88
  }
73
89
  if (/^##\s*edges?\s*$/i.test(line)) {
74
90
  section = 'edges';
91
+ currentType = null;
75
92
  continue;
76
93
  }
77
94
  if (/^##\s*merges?\s*$/i.test(line)) {
78
95
  section = 'merges';
96
+ currentType = null;
97
+ continue;
98
+ }
99
+ // CR-SM-216: `### <TYPE>` carries the type for the nodes below it.
100
+ const typeSection = /^###\s+(\S+)\s*$/.exec(line);
101
+ if (typeSection && section === 'nodes') {
102
+ if (this.validNodeTypes.has(typeSection[1])) {
103
+ currentType = typeSection[1];
104
+ }
105
+ else {
106
+ currentType = null;
107
+ errors.push(`Unknown node type section: "### ${typeSection[1]}"`);
108
+ }
79
109
  continue;
80
110
  }
81
111
  if (line.startsWith('#'))
@@ -96,12 +126,17 @@ export class FormatECodec {
96
126
  // '->' (e.g. "FUNC->FUNC compose"); the arrow lives before any '|', edges
97
127
  // carry no pipe — so splitting on '|' cleanly disambiguates (CR-GC-247).
98
128
  if (line.split('|', 1)[0].includes('->')) {
99
- this.parseEdgeLine(line, operations, errors);
129
+ this.parseEdgeLine(line, typeOf, operations, errors);
100
130
  continue;
101
131
  }
102
- // Node
103
- if (section === 'nodes' || this.looksLikeNode(line)) {
104
- this.parseNodeLine(line, operations, errors);
132
+ // Node — the `### <TYPE>` section supplies the type (CR-SM-216)
133
+ if (section === 'nodes') {
134
+ if (!currentType) {
135
+ errors.push(`Node line outside a "### <TYPE>" section: "${line}"`);
136
+ }
137
+ else {
138
+ this.parseNodeLine(line, currentType, declared, operations, errors);
139
+ }
105
140
  continue;
106
141
  }
107
142
  if (line.length > 0)
@@ -114,32 +149,37 @@ export class FormatECodec {
114
149
  const lines = [];
115
150
  if (graph.nodes.length > 0) {
116
151
  lines.push('## Nodes');
152
+ // CR-SM-216: one `### <TYPE>` header per type instead of a type suffix on every
153
+ // uid. Measured on the graphcode SSOT graph: 12 headers ≈ 48 tokens, where a
154
+ // per-node type attribute would have cost ≈ 1476.
155
+ const byType = new Map();
117
156
  for (const node of graph.nodes) {
118
- const descr = node.description ? `|${node.description}` : '';
119
- const attrs = this.serializeAttrs(node.attributes);
120
- lines.push(`+ ${node.uid}${descr}${attrs}`);
157
+ const group = byType.get(node.type);
158
+ if (group)
159
+ group.push(node);
160
+ else
161
+ byType.set(node.type, [node]);
162
+ }
163
+ for (const type of [...byType.keys()].sort()) {
164
+ lines.push(`### ${type}`);
165
+ for (const node of byType.get(type) ?? []) {
166
+ const descr = node.description ? `|${node.description}` : '';
167
+ const attrs = this.serializeAttrs(node.attributes);
168
+ lines.push(`+ ${node.uid}${descr}${attrs}`);
169
+ }
121
170
  }
122
171
  }
123
172
  if (graph.edges.length > 0) {
124
173
  lines.push('');
125
174
  lines.push('## Edges');
126
- for (const edge of graph.edges) {
127
- const arrow = this.edgeTypeToArrow(edge.edgeType);
128
- const attrs = this.serializeAttrs(edge.attributes);
129
- lines.push(`+ ${edge.sourceId} -${arrow}-> ${edge.targetId}${attrs}`);
130
- }
175
+ lines.push(...this.serializeEdges(graph.edges));
131
176
  }
132
177
  return lines.join('\n');
133
178
  }
134
179
  // ---------------------------------------------------------------------------
135
180
  // Private
136
181
  // ---------------------------------------------------------------------------
137
- looksLikeNode(line) {
138
- const stripped = line.replace(/^[+\-~!]\s*/, '');
139
- const dotParts = stripped.split('.');
140
- return dotParts.length >= 2 && this.validNodeTypes.has(dotParts[dotParts.length - 2] ?? '');
141
- }
142
- parseNodeLine(line, ops, errors) {
182
+ parseNodeLine(line, nodeType, declared, ops, errors) {
143
183
  const opChar = OP_PREFIX[line[0]] ? line[0] : '+';
144
184
  const rest = OP_PREFIX[line[0]] ? line.slice(1).trim() : line.trim();
145
185
  const action = OP_PREFIX[opChar] ?? 'add';
@@ -155,30 +195,35 @@ export class FormatECodec {
155
195
  const pipeIdx = mainPart.indexOf('|');
156
196
  const uid = pipeIdx >= 0 ? mainPart.slice(0, pipeIdx).trim() : mainPart.trim();
157
197
  const description = pipeIdx >= 0 ? mainPart.slice(pipeIdx + 1).trim() : undefined;
158
- // Validate node type from uid
159
- const nodeType = this.extractNodeType(uid);
160
- if (!nodeType) {
161
- errors.push(`Cannot extract node type from uid: "${uid}"`);
198
+ if (!uid) {
199
+ errors.push(`Node line without a uid: "${line}"`);
162
200
  return;
163
201
  }
164
- if (!this.validNodeTypes.has(nodeType)) {
165
- errors.push(`Unknown node type "${nodeType}" in: "${uid}"`);
202
+ // CR-SM-217: with `TYPE-slug` as the family canon, the prefix restores the
203
+ // redundancy CR-SM-216 removed — for free, since `REQ-safety` costs fewer tokens
204
+ // than `REQ-safety.REQ`. A node under the wrong section is caught here. Uids with
205
+ // no recognisable prefix (legacy ids, graphify candidates) are not an error, only
206
+ // unchecked — the prefix is a cross-check, never a type source.
207
+ const prefix = uid.split('-', 1)[0];
208
+ if (prefix !== uid && this.validNodeTypes.has(prefix) && prefix !== nodeType) {
209
+ errors.push(`Uid prefix contradicts its section: "${uid}" under "### ${nodeType}"`);
166
210
  return;
167
211
  }
212
+ declared.set(uid, nodeType);
168
213
  if (action === 'remove') {
169
214
  ops.push({ type: 'remove_node', semanticId: uid });
170
215
  }
171
216
  else if (action === 'update') {
172
- ops.push({ type: 'update_node', semanticId: uid, description, attributes: inlineAttrs });
217
+ ops.push({ type: 'update_node', semanticId: uid, elementType: nodeType, description, attributes: inlineAttrs });
173
218
  }
174
219
  else if (action === 'strict_add') {
175
- ops.push({ type: 'strict_add_node', semanticId: uid, description, attributes: inlineAttrs });
220
+ ops.push({ type: 'strict_add_node', semanticId: uid, elementType: nodeType, description, attributes: inlineAttrs });
176
221
  }
177
222
  else {
178
- ops.push({ type: 'add_node', semanticId: uid, description, attributes: inlineAttrs });
223
+ ops.push({ type: 'add_node', semanticId: uid, elementType: nodeType, description, attributes: inlineAttrs });
179
224
  }
180
225
  }
181
- parseEdgeLine(line, ops, errors) {
226
+ parseEdgeLine(line, typeOf, ops, errors) {
182
227
  const opChar = OP_PREFIX[line[0]] ? line[0] : '+';
183
228
  const rest = OP_PREFIX[line[0]] ? line.slice(1).trim() : line.trim();
184
229
  const action = OP_PREFIX[opChar] ?? 'add';
@@ -205,19 +250,19 @@ export class FormatECodec {
205
250
  errors.push(`Unknown edge arrow type "${arrowType}" in: "${line}"`);
206
251
  return;
207
252
  }
208
- // Validate source node type
209
- const srcType = this.extractNodeType(sourceId);
253
+ // CR-SM-216: endpoint types come from the declared sections or the caller's
254
+ // resolver — never from the uid's spelling.
255
+ const srcType = typeOf(sourceId);
210
256
  if (!srcType) {
211
- errors.push(`Cannot extract node type from source: "${sourceId}"`);
257
+ errors.push(`Cannot resolve type of source "${sourceId}" — not declared under a "### <TYPE>" section and no resolveType provided`);
212
258
  return;
213
259
  }
214
260
  // Handle 1:N targets
215
261
  const targets = targetsPart.split(',').map(t => t.trim()).filter(Boolean);
216
262
  for (const targetId of targets) {
217
- // Validate target node type
218
- const tgtType = this.extractNodeType(targetId);
263
+ const tgtType = typeOf(targetId);
219
264
  if (!tgtType) {
220
- errors.push(`Cannot extract node type from target: "${targetId}"`);
265
+ errors.push(`Cannot resolve type of target "${targetId}" — not declared under a "### <TYPE>" section and no resolveType provided`);
221
266
  continue;
222
267
  }
223
268
  // Meta-model validation (CR-GC-247: single checker, patterns SSOT — honors
@@ -255,22 +300,6 @@ export class FormatECodec {
255
300
  sourceIds: parts,
256
301
  });
257
302
  }
258
- extractNodeType(uid) {
259
- const parts = uid.split('.');
260
- // Format: Name.TYPE or Name.TYPE.Counter
261
- if (parts.length >= 2) {
262
- // Type is the last segment that matches a known type, or second-to-last
263
- for (let i = parts.length - 1; i >= 1; i--) {
264
- if (this.validNodeTypes.has(parts[i]))
265
- return parts[i];
266
- }
267
- // Fallback: second segment for 3-part IDs
268
- if (parts.length >= 3)
269
- return parts[parts.length - 2];
270
- return parts[1];
271
- }
272
- return null;
273
- }
274
303
  parseInlineAttrs(raw) {
275
304
  const attrs = {};
276
305
  for (const pair of raw.split(',')) {
@@ -281,6 +310,48 @@ export class FormatECodec {
281
310
  }
282
311
  return attrs;
283
312
  }
313
+ /**
314
+ * CR-SM-215: fan-out serialization — edges sharing `(sourceId, edgeType)` collapse
315
+ * onto one line `A -x-> B, C, D`. The source UID is written once instead of once per
316
+ * edge; on the graphcode SSOT graph that is 318 lines instead of 751.
317
+ *
318
+ * Two invariants:
319
+ * - **Edges carrying attributes stay single-line.** `serializeAttrs` binds to one
320
+ * edge; a group would either drop `cardinality`/`constraint`/`notes` or wrongly
321
+ * share one edge's attributes with its siblings.
322
+ * - **Deterministic order** (graphcode `REQ-deterministic-serialization`): entries
323
+ * sorted by source, then edge type, then first target — code-unit order, not
324
+ * `localeCompare`, which is locale-dependent.
325
+ */
326
+ serializeEdges(edges) {
327
+ const entries = [];
328
+ const groups = new Map();
329
+ for (const edge of edges) {
330
+ const attrs = this.serializeAttrs(edge.attributes);
331
+ if (attrs) {
332
+ entries.push({ sourceId: edge.sourceId, edgeType: edge.edgeType, targets: [edge.targetId], attrs });
333
+ continue;
334
+ }
335
+ const key = `${edge.sourceId}${edge.edgeType}`;
336
+ const group = groups.get(key);
337
+ if (group) {
338
+ group.targets.push(edge.targetId);
339
+ }
340
+ else {
341
+ const entry = { sourceId: edge.sourceId, edgeType: edge.edgeType, targets: [edge.targetId], attrs: '' };
342
+ groups.set(key, entry);
343
+ entries.push(entry);
344
+ }
345
+ }
346
+ const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
347
+ for (const entry of entries)
348
+ entry.targets.sort(cmp);
349
+ entries.sort((a, b) => cmp(a.sourceId, b.sourceId)
350
+ || cmp(a.edgeType, b.edgeType)
351
+ || cmp(a.targets[0], b.targets[0])
352
+ || cmp(a.attrs, b.attrs));
353
+ return entries.map(e => `+ ${e.sourceId} -${this.edgeTypeToArrow(e.edgeType)}-> ${e.targets.join(', ')}${e.attrs}`);
354
+ }
284
355
  serializeAttrs(attrs) {
285
356
  const entries = Object.entries(attrs).filter(([, v]) => v != null && v !== '');
286
357
  if (entries.length === 0)
@@ -8,8 +8,18 @@ import { isValidTrace, tracePatternsOf } from './types.js';
8
8
  import { FormatECodec } from './format-e-codec.js';
9
9
  import { DefaultRuleEngine } from './rule-engine.js';
10
10
  import { InMemoryAuditLog } from './audit.js';
11
- import { extractFromSemanticId } from '@sigloch/contracts/se';
12
11
  import { updateEdge, mergeNodes } from './edge-ops.js';
12
+ /**
13
+ * CR-SM-216: a display name for a typed node, derived from the uid's *shape* only.
14
+ * `MOD-harness` → "harness", `Mower.PR.01` → "Mower", anything else → the uid itself.
15
+ * Purely cosmetic — the type never comes from here.
16
+ */
17
+ function nameFromUid(uid, type) {
18
+ if (uid.startsWith(`${type}-`))
19
+ return uid.slice(type.length + 1);
20
+ const dot = uid.indexOf('.');
21
+ return dot > 0 ? uid.slice(0, dot) : uid;
22
+ }
13
23
  export class GraphService {
14
24
  ontology;
15
25
  codec;
@@ -461,15 +471,16 @@ export class GraphService {
461
471
  async applyNodeCreate(op, isSESchema) {
462
472
  let type;
463
473
  let name;
464
- if (isSESchema) {
465
- try {
466
- const { type: extractedType, name: extractedName } = extractFromSemanticId(op.semanticId);
467
- type = extractedType;
468
- name = extractedName;
469
- }
470
- catch {
471
- throw new Error(`Invalid semantic ID: ${op.semanticId}`);
472
- }
474
+ if (op.elementType) {
475
+ // CR-SM-216: the type travels with the operation (Format-E `### <TYPE>` section).
476
+ type = op.elementType;
477
+ name = nameFromUid(op.semanticId, type);
478
+ }
479
+ else if (isSESchema) {
480
+ // CR-SM-217: no id-derived typing for SE any more. A hand-built SE operation must
481
+ // name its type — guessing it from the uid is the CR-230 failure mode, and with
482
+ // `TYPE-slug` as the canon there is no counter to parse out of the id either.
483
+ throw new Error(`Node operation for "${op.semanticId}" carries no elementType — SE nodes must declare their type (Format-E: "### <TYPE>" section)`);
473
484
  }
474
485
  else {
475
486
  const parts = op.semanticId.split('.');
@@ -18,7 +18,7 @@ import type { Graph, OntologyDescriptor } from './types.js';
18
18
  */
19
19
  export declare function projectToOntologyGraph(graph: Graph): OntologyGraph;
20
20
  /**
21
- * Canonical SE OntologyDescriptor (ontology + 17 rules), version-pinned to
22
- * contracts/se ONTOLOGY_VERSION. Plug into GraphService / FormatECodec.
21
+ * Canonical SE OntologyDescriptor (ontology + V3 rules + MT metrics), version-pinned
22
+ * to contracts/se ONTOLOGY_VERSION. Plug into GraphService / FormatECodec.
23
23
  */
24
24
  export declare const SE_DESCRIPTOR: OntologyDescriptor;
@@ -9,7 +9,7 @@
9
9
  * - Graph (nodes/edges) ⟷ OntologyGraph (elements/traces)
10
10
  * - RuleViolation (ruleId/…) ⟷ contracts RuleViolation (rule_id/…)
11
11
  */
12
- import { ElementType, TraceType, TRACE_PATTERNS, V3_RULES, ONTOLOGY_VERSION, } from '@sigloch/contracts/se';
12
+ import { ElementType, TraceType, TRACE_PATTERNS, V3_RULES, MT_RULES, ONTOLOGY_VERSION, } from '@sigloch/contracts/se';
13
13
  /**
14
14
  * Project an ontology-agnostic Graph (nodes/edges) onto the SE OntologyGraph
15
15
  * (elements/traces) that contracts/se rules evaluate against. Type-specific
@@ -42,8 +42,16 @@ export function projectToOntologyGraph(graph) {
42
42
  }));
43
43
  return { elements, traces };
44
44
  }
45
- /** The 17 contracts/se V3_RULES, adapted to graph-api-core's Rule shape. */
46
- const SE_RULES = V3_RULES.map((def) => ({
45
+ /**
46
+ * The contracts/se rule catalog, adapted to graph-api-core's Rule shape.
47
+ *
48
+ * V3_RULES + MT_RULES (CR-SM-222): the architecture metrics lived in contracts since
49
+ * K2 but ran only in aimpro's `evaluateAllRules`, so the governed repos (graphcode,
50
+ * graph-view-edit) never saw them. Same adapter, no fork — they inherit by
51
+ * consuming this descriptor. MT severities are warning/info, so they stay advisory
52
+ * in graphcode's gate.
53
+ */
54
+ const SE_RULES = [...V3_RULES, ...MT_RULES].map((def) => ({
47
55
  id: def.id,
48
56
  name: def.name,
49
57
  severity: def.severity,
@@ -69,8 +77,8 @@ const edgeTypes = Object.fromEntries(TraceType.options.map((tt) => {
69
77
  return [tt, { arrows: [tt], validPairs: pairs.length ? pairs : [['*', '*']] }];
70
78
  }));
71
79
  /**
72
- * Canonical SE OntologyDescriptor (ontology + 17 rules), version-pinned to
73
- * contracts/se ONTOLOGY_VERSION. Plug into GraphService / FormatECodec.
80
+ * Canonical SE OntologyDescriptor (ontology + V3 rules + MT metrics), version-pinned
81
+ * to contracts/se ONTOLOGY_VERSION. Plug into GraphService / FormatECodec.
74
82
  */
75
83
  export const SE_DESCRIPTOR = {
76
84
  name: 'se',
package/dist/types.d.ts CHANGED
@@ -103,8 +103,21 @@ export interface FormatEOperation {
103
103
  type: 'add_node' | 'remove_node' | 'update_node' | 'add_edge' | 'remove_edge' | 'update_edge' | 'strict_add_node' | 'strict_add_edge' | 'merge_nodes';
104
104
  /** Node uid or edge key (source->target). */
105
105
  semanticId: string;
106
+ /**
107
+ * CR-SM-216: the node type, taken from the `### <TYPE>` section the node was declared
108
+ * under. Set on every node-creating operation. Consumers must read this instead of
109
+ * re-deriving a type from the uid — uid conventions differ per repo, and guessing
110
+ * from the spelling fails silently on the ones it does not recognise.
111
+ */
112
+ elementType?: string;
106
113
  description?: string;
107
- attributes?: Record<string, string>;
114
+ /**
115
+ * BOK-CR-026: `unknown`, not `string` — object-valued bindings (`realRef`,
116
+ * `testRef`) must survive a Format-E mutation as objects, or the element reads as
117
+ * unbound. GraphService already handled them as unknown internally; this is the
118
+ * public type catching up (a plain `@key value` line still yields a string).
119
+ */
120
+ attributes?: Record<string, unknown>;
108
121
  sourceId?: string;
109
122
  targetId?: string;
110
123
  edgeType?: string;
@@ -114,7 +127,7 @@ export interface FormatEOperation {
114
127
  set?: {
115
128
  edgeType?: string;
116
129
  flip?: boolean;
117
- attributes?: Record<string, string>;
130
+ attributes?: Record<string, unknown>;
118
131
  };
119
132
  }
120
133
  export interface FormatEDiff {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sigloch/graph-api-core",
3
- "version": "0.4.1",
3
+ "version": "2.0.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -21,7 +21,7 @@
21
21
  "prepublishOnly": "npm run build && npm run test"
22
22
  },
23
23
  "dependencies": {
24
- "@sigloch/contracts": "^0.8.0",
24
+ "@sigloch/contracts": "^2.0.0",
25
25
  "zod": "^4.3.6"
26
26
  },
27
27
  "license": "MIT",
@@ -4,19 +4,31 @@ Exercises the reserved inline attributes from CR-007:
4
4
  - Nodes: `notes`
5
5
  - Edges: `cardinality`, `constraint`, `notes`
6
6
 
7
+ Plus both serializer branches from CR-SM-215: attributed edges stay one per line,
8
+ attribute-free edges of the same `(source, type)` collapse into one fan-out line.
9
+
7
10
  Uses the minimal `TEST_ONTOLOGY` shipped from `@sigloch/graph-api-core/test-fixtures`.
8
11
 
12
+ Format-E **v2** (CR-SM-216): Typ aus dem `### <TYPE>`-Sektionsheader.
13
+
9
14
  ```format-e
10
15
  ## Nodes
16
+ ### PR
11
17
  + Mower.PR.01|Riding mower R15 [notes:Phase-2-Vertical-Slice-Wurzel]
18
+ ### C
12
19
  + Engine.C.01|Combustion engine [power:gas]
13
20
  + Gearbox.C.02|Mowing-deck gearbox family [kind:family,notes:drei Varianten geplant]
21
+ + Blade.C.03|Mowing blade
22
+ + Deck.C.04|Cutting deck shell
23
+ ### FR
14
24
  + DFMEA-Engine-001.FR.01|Not-Stop-Funktion am Motor [external_id:DFMEA-Engine-001]
25
+ ### CTRL
15
26
  + EmergencyBrake.CTRL.01|Mechanische Bremse Stillstandszeit max 5 sec
16
27
 
17
28
  ## Edges
18
29
  + Mower.PR.01 -compose-> Engine.C.01 [cardinality:1..1]
19
30
  + Mower.PR.01 -compose-> Gearbox.C.02 [cardinality:1..n,constraint:mindestens eine Variante je Schnittbreite]
31
+ + Gearbox.C.02 -compose-> Blade.C.03, Deck.C.04
20
32
  + Engine.C.01 -addressed_by-> DFMEA-Engine-001.FR.01 [cardinality:1..n,notes:DFMEA-Hotspot]
21
33
  + DFMEA-Engine-001.FR.01 -addressed_by-> EmergencyBrake.CTRL.01 [cardinality:1..1,constraint:Stillstandszeit <= 5 sec]
22
34
  ```
@@ -7,24 +7,39 @@ sigloch-weit nutzbare Variante für CI-Smokes.
7
7
 
8
8
  Validiert gegen `TEST_ONTOLOGY` aus `@sigloch/graph-api-core/test-fixtures`.
9
9
 
10
+ Format-E **v2** (CR-SM-216): der Typ steht im `### <TYPE>`-Sektionsheader, nicht in der UID.
11
+ Die UIDs bleiben unverändert — sie werden nur nicht mehr als Typquelle gelesen.
12
+
10
13
  ```format-e
11
14
  ## Nodes
15
+ ### OU
12
16
  + Konstruktion.OU.01|Konstruktion
13
17
  + Tooling.OU.02|Tooling
18
+ ### R
14
19
  + DesignLead.R.01|Design Lead
15
20
  + Tooling.R.02|Tooling
21
+ ### PR
16
22
  + R15.PR.01|Rasentraktor R15 [code:R15]
23
+ ### VAR
17
24
  + R15-1000.VAR.01|R15 mit 1000 mm Schnittbreite [attribute_key:Schnittbreite,attribute_value:1000mm]
18
25
  + R15-1500.VAR.02|R15 mit 1500 mm Schnittbreite [attribute_key:Schnittbreite,attribute_value:1500mm]
26
+ ### C
19
27
  + MaehwerkGetriebe.C.01|Maehwerk-Getriebe Familie [kind:family]
20
28
  + Getriebe-1000.C.02|Getriebe 1000 [kind:variant,part_no:MG-1000]
21
29
  + Getriebe-1500.C.03|Getriebe 1500 [kind:variant,part_no:MG-1500]
30
+ ### F
22
31
  + UnterschiedlicheMaehbreiten.F.01|Unterschiedliche Maehbreiten [customer_priority:hoch]
32
+ ### FR
23
33
  + DFMEA-MG-001.FR.01|Not-Stop-Funktion [external_id:DFMEA-MG-001,status:verifiziert]
34
+ ### CTRL
24
35
  + MechBremse.CTRL.01|Mechanische Bremse Stillstandszeit max 5 sec
36
+ ### TC
25
37
  + Stillstandszeitmessung.TC.01|Stillstandszeit-Messung nach Not-Aus [criterion:t_stop <= 5 sec]
38
+ ### TL
26
39
  + WerkzeugSetGehaeuse.TL.01|Werkzeug-Set Gehaeuse Druckguss [material:Aluminium-Druckguss]
40
+ ### MT
27
41
  + M3.MT.01|GO Milestone [sequence_no:3]
42
+ ### SL
28
43
  + PDM.SL.01|PDM-System [type:PLM]
29
44
 
30
45
  ## Edges