glossarist 0.4.31 → 0.4.33

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.
Files changed (38) hide show
  1. package/package.json +1 -1
  2. package/src/concept-parser.js +95 -27
  3. package/src/concept-reader.js +59 -3
  4. package/src/concept-serializer.js +21 -10
  5. package/src/diff/concept-diff.js +70 -18
  6. package/src/diff/diff-patch.js +37 -13
  7. package/src/diff/diff-renderer.js +16 -4
  8. package/src/index.js +2 -1
  9. package/src/migration/partitive-relation-migrator.js +3 -3
  10. package/src/models/{abstract-nary-relation.js → abstract-hyperedge.js} +4 -4
  11. package/src/models/completeness.js +1 -1
  12. package/src/models/concept.js +172 -30
  13. package/src/models/{generic-relation.js → generic-hyperedge.js} +18 -6
  14. package/src/models/generic-member.js +5 -5
  15. package/src/models/hyperedge-index.js +87 -0
  16. package/src/models/{concept-system-member.js → hyperedge-member.js} +12 -24
  17. package/src/models/hyperedge-registry.js +115 -0
  18. package/src/models/index.d.ts +16 -16
  19. package/src/models/index.js +13 -10
  20. package/src/models/multiplicity.js +11 -6
  21. package/src/models/partitive-hyperedge.js +69 -153
  22. package/src/models/partitive-member.js +6 -6
  23. package/src/models/relation-loader.js +27 -23
  24. package/src/rdf/gloss-concept.js +36 -20
  25. package/src/rdf/gloss-generic-hyperedge.js +92 -0
  26. package/src/rdf/gloss-partitive-relation.js +3 -3
  27. package/src/rdf/index.d.ts +4 -4
  28. package/src/rdf/index.js +0 -1
  29. package/src/reference-resolver.js +14 -12
  30. package/src/validators/binary-has-part-redundancy-rule.js +6 -6
  31. package/src/validators/delimiting-coherence-rule.js +2 -2
  32. package/src/validators/hyperedge-coherence-rule.js +98 -0
  33. package/src/validators/index.js +3 -9
  34. package/src/validators/partitive-relation-coherence-rule.js +6 -79
  35. package/src/models/partitive-relation.js +0 -76
  36. package/src/rdf/gloss-hyperedge.js +0 -90
  37. package/src/validators/partitive-drift-rule.js +0 -51
  38. package/src/validators/partitive-hyperedge-rule.js +0 -75
@@ -1,4 +1,4 @@
1
- // Completeness — whether a PartitiveRelation's encoded partitives
1
+ // Completeness — whether a PartitiveHyperedge's encoded partitives
2
2
  // constitute the whole comprehensive (complete) or only some of it
3
3
  // (partial).
4
4
  //
@@ -2,7 +2,8 @@ import { GlossaristModel } from './base.js';
2
2
  import { LocalizedConcept } from './localized-concept.js';
3
3
  import { RelatedConcept } from './related-concept.js';
4
4
  import { PartitiveHyperedge } from './partitive-hyperedge.js';
5
- import { PartitiveRelation } from './partitive-relation.js';
5
+ import { AbstractHyperedge } from './abstract-hyperedge.js';
6
+ import { HyperedgeRegistry, groupHyperedgesByWireKey } from './hyperedge-registry.js';
6
7
  import { migrateHyperedgeToRelation } from '../migration/partitive-relation-migrator.js';
7
8
  import { ConceptReference } from './concept-reference.js';
8
9
  import { ConceptDate } from './concept-date.js';
@@ -34,12 +35,20 @@ export class Concept extends GlossaristModel {
34
35
  this._cache = {};
35
36
 
36
37
  this.relatedConcepts = _mapInstances(data.relatedConcepts ?? data.related ?? data.related_concepts ?? [], RelatedConcept);
37
- this.partitiveRelations = _resolvePartitiveRelations(data);
38
- // Backward-compat alias: v1 callers reading .partitiveHyperedges
39
- // get the v2 relations (the v1 model is preserved at
40
- // ../partitive-hyperedge.js for direct instantiation, but Concept
41
- // exposes only v2).
42
- this.partitiveHyperedges = this.partitiveRelations;
38
+
39
+ // Unified hyperedge array. Every n-ary relation (PartitiveHyperedge,
40
+ // GenericHyperedge, future TemporalHyperedge / AssociativeHyperedge /
41
+ // SequentialHyperedge) lives here. The single public API is
42
+ // `.relations`; external systems that need to filter by type use
43
+ // `concept.relations.filter(r => r instanceof X)` or dispatch via
44
+ // HyperedgeRegistry (preferred — see Phases 6 and 11).
45
+ //
46
+ // Throws on ambiguous input (caller passed both `relations` and any
47
+ // typed wire key) and on duplicate relations (same identity twice).
48
+ // Silent drop / silent dedupe are footguns the audit (C4, C6)
49
+ // flagged; explicit throws make corruption loud.
50
+ this.relations = _resolveHyperedges(data);
51
+
43
52
  this.domains = _normalizeDomains(data.domains, data.groups);
44
53
  this.groups = Array.isArray(data.groups)
45
54
  ? data.groups.map(g => typeof g === 'string' ? g : (g?.id ?? g?.sectionId ?? null)).filter(Boolean)
@@ -63,6 +72,18 @@ export class Concept extends GlossaristModel {
63
72
 
64
73
  get termid() { return this.id; }
65
74
 
75
+ // No typed projections. Use concept.relations directly and filter
76
+ // by instanceof, or dispatch via HyperedgeRegistry. The old
77
+ // .partitiveRelations / .genericRelations / .partitiveHyperedges
78
+ // getters were removed per TODO Phase 4 — they were footguns
79
+ // (audit C3: .partitiveHyperedges returned v2 instances but callers
80
+ // expected v1 shape; multiple validators silently no-op'd).
81
+ //
82
+ // Snippet migration guide:
83
+ // concept.partitiveRelations → concept.relations.filter(r => r instanceof PartitiveHyperedge)
84
+ // concept.genericRelations → concept.relations.filter(r => r instanceof GenericHyperedge)
85
+ // concept.partitiveHyperedges → concept.relations.filter(r => r instanceof PartitiveHyperedge)
86
+
66
87
  get languages() {
67
88
  return Object.keys(this._rawLocalizations);
68
89
  }
@@ -202,8 +223,13 @@ export class Concept extends GlossaristModel {
202
223
  if (this.relatedConcepts.length > 0) {
203
224
  obj.related = this.relatedConcepts.map(rc => rc.toJSON());
204
225
  }
205
- if (this.partitiveRelations.length > 0) {
206
- obj.partitive_relations = this.partitiveRelations.map(r => r.toJSON());
226
+ // Emit per-type wire keys from the unified relations array,
227
+ // partitioned via HyperedgeRegistry. Adding a new hyperedge type
228
+ // means its wire key appears here automatically — no edits to
229
+ // Concept.toJSON.
230
+ const grouped = groupHyperedgesByWireKey(this.relations);
231
+ for (const [wireKey, rels] of Object.entries(grouped)) {
232
+ if (rels.length > 0) obj[wireKey] = rels.map(r => r.toJSON());
207
233
  }
208
234
  if (this.domains.length > 0) {
209
235
  obj.domains = this.domains.map(d => d.toJSON());
@@ -234,31 +260,147 @@ function _mapInstances(arr, Cls) {
234
260
  return arr.map(item => item instanceof Cls ? item : new Cls(item));
235
261
  }
236
262
 
237
- // Resolve partitive relations from any of three input shapes:
238
- // 1. data.partitiveRelations (v2, preferred)
239
- // 2. data.partitive_relations (v2 wire name)
240
- // 3. data.partitiveHyperedges / data.partitive_hyperedges (v1, migrated)
263
+ // Resolve the unified hyperedge array from any input shape.
264
+ //
265
+ // Three input categories are accepted; mixing them throws (audit C4 —
266
+ // silent drop was a footgun):
267
+ //
268
+ // 1. data.relations — preferred unified shape. Entries
269
+ // are typed instances or hashes
270
+ // carrying a `type` discriminator
271
+ // (looked up via HyperedgeRegistry).
272
+ // 2. Per-type v2 wire keys — one of HyperedgeRegistry.allWireKeys()
273
+ // (partitive_relations, generic_relations).
274
+ // 3. Per-type v1 legacy keys — declared on each class via
275
+ // `static v1WireKeys` (e.g.
276
+ // partitive_hyperedges → migrated).
241
277
  //
242
- // v1 input is auto-migrated to v2 via the migrator. v1 instances
243
- // already constructed (rare — only direct callers) are migrated field
244
- // by field. The parser produces plain hashes so the common path is (3).
245
- function _resolvePartitiveRelations(data) {
246
- if (Array.isArray(data.partitiveRelations)) {
247
- return data.partitiveRelations.map(r =>
248
- r instanceof PartitiveRelation ? r : new PartitiveRelation(r));
278
+ // Adding a new hyperedge type means: declare the leaf class with its
279
+ // metadata block, register it. Concept, parser, serializer, diff,
280
+ // patch, renderer, RDF emitter do not change. (Phase 11 OCP contract.)
281
+ function _resolveHyperedges(data) {
282
+ _assertNoAmbiguousInput(data);
283
+
284
+ if (Array.isArray(data.relations)) {
285
+ return _dedupeOrThrow(data.relations.map(r => _coerceHyperedge(r)).filter(r => r != null));
249
286
  }
250
- if (Array.isArray(data.partitive_relations)) {
251
- return data.partitive_relations.map(r => new PartitiveRelation(r));
287
+
288
+ const out = [];
289
+
290
+ for (const cls of HyperedgeRegistry.allClasses()) {
291
+ const arr = data[cls.wireKey] ?? data[_camelCase(cls.wireKey)];
292
+ if (!Array.isArray(arr)) continue;
293
+ for (const r of arr) {
294
+ const coerced = _coerceHyperedge(r, cls);
295
+ if (coerced) out.push(coerced);
296
+ }
252
297
  }
253
- const v1 = data.partitiveHyperedges ?? data.partitive_hyperedges;
254
- if (Array.isArray(v1)) {
255
- return v1
256
- .map(h => h instanceof PartitiveHyperedge ? h.toJSON() : h)
257
- .map(migrateHyperedgeToRelation)
258
- .filter(h => h != null)
259
- .map(h => new PartitiveRelation(h));
298
+
299
+ for (const cls of HyperedgeRegistry.allClasses()) {
300
+ for (const v1Key of cls.v1WireKeys ?? []) {
301
+ const camelV1 = _camelCase(v1Key);
302
+ const arr = data[v1Key] ?? data[camelV1];
303
+ if (!Array.isArray(arr)) continue;
304
+ for (const h of arr) {
305
+ const migrated = _migrateV1Hash(cls, v1Key, h);
306
+ if (migrated) out.push(new cls(migrated));
307
+ }
308
+ }
260
309
  }
261
- return [];
310
+
311
+ return _dedupeOrThrow(out);
312
+ }
313
+
314
+ // Audit C4: silent drop on mixed input is a footgun. Throw instead.
315
+ // Allows `relations` alone, or any combination of typed keys alone —
316
+ // but not both at once.
317
+ function _assertNoAmbiguousInput(data) {
318
+ if (!Array.isArray(data.relations)) return;
319
+ const typedKeys = [];
320
+ for (const cls of HyperedgeRegistry.allClasses()) {
321
+ if (Array.isArray(data[cls.wireKey])) typedKeys.push(cls.wireKey);
322
+ const camelWire = _camelCase(cls.wireKey);
323
+ if (camelWire !== cls.wireKey && Array.isArray(data[camelWire])) {
324
+ typedKeys.push(camelWire);
325
+ }
326
+ for (const v1 of cls.v1WireKeys ?? []) {
327
+ if (Array.isArray(data[v1])) typedKeys.push(v1);
328
+ const camelV1 = _camelCase(v1);
329
+ if (camelV1 !== v1 && Array.isArray(data[camelV1])) typedKeys.push(camelV1);
330
+ }
331
+ }
332
+ if (typedKeys.length > 0) {
333
+ throw new Error(
334
+ `Concept input is ambiguous: pass either 'relations' (unified) ` +
335
+ `or typed wire keys (${typedKeys.join(', ')}), not both. ` +
336
+ ` Mixing them silently drops the typed keys, which has caused ` +
337
+ ` data loss in production (see audit C4).`,
338
+ );
339
+ }
340
+ }
341
+
342
+ // Audit C6: do NOT silently dedupe. Duplicate relations indicate a bug
343
+ // in the caller; surface it. The identity function delegates to each
344
+ // class's static identityOf (polymorphic — handles mixed types).
345
+ function _dedupeOrThrow(relations) {
346
+ const seen = new Map();
347
+ for (const r of relations) {
348
+ const id = _hyperedgeIdentity(r);
349
+ if (seen.has(id)) {
350
+ throw new Error(
351
+ `Concept input has duplicate hyperedge (identity=${id}). ` +
352
+ `Passing the same relation twice is a bug — the parser and ` +
353
+ `serializer would silently emit it twice on disk. ` +
354
+ `Audit C6 requires we surface this loudly instead.`,
355
+ );
356
+ }
357
+ seen.set(id, r);
358
+ }
359
+ return relations;
360
+ }
361
+
362
+ function _hyperedgeIdentity(r) {
363
+ if (r == null) return '';
364
+ const Cls = r.constructor;
365
+ if (typeof Cls?.identityOf === 'function') {
366
+ return `${Cls.name}::${Cls.identityOf(r)}`;
367
+ }
368
+ return String(r);
369
+ }
370
+
371
+ function _coerceHyperedge(value, fallbackClass) {
372
+ if (value == null) return null;
373
+ if (value instanceof AbstractHyperedge) return value;
374
+
375
+ const hash = typeof value.toJSON === 'function' ? value.toJSON() : value;
376
+ const cls = _classForHash(hash) ?? fallbackClass;
377
+ if (!cls) return null;
378
+ return new cls(hash);
379
+ }
380
+
381
+ function _classForHash(hash) {
382
+ if (!hash || typeof hash !== 'object') return null;
383
+ const type = hash.type;
384
+ if (typeof type !== 'string') return null;
385
+ return HyperedgeRegistry.forTypeTag(type);
386
+ }
387
+
388
+ // v1 → v2 migration for hashes from legacy wire keys. Currently only
389
+ // PartitiveHyperedge has v1 wire keys (partitive_hyperedges). Future
390
+ // types extend this dispatch — but it lives here in the migration
391
+ // helper, not in the model classes, since v1 is a legacy concern.
392
+ function _migrateV1Hash(cls, v1Key, hash) {
393
+ if (cls === PartitiveHyperedge && v1Key === 'partitive_hyperedges') {
394
+ const normalized = hash;
395
+ return migrateHyperedgeToRelation(normalized);
396
+ }
397
+ return null;
398
+ }
399
+
400
+ // snake_case → camelCase
401
+ function _camelCase(s) {
402
+ if (typeof s !== 'string' || !s.includes('_')) return s;
403
+ return s.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
262
404
  }
263
405
 
264
406
  const CONCEPT_WIRE_NAMES = Object.freeze({
@@ -1,18 +1,28 @@
1
- // GenericRelation — an ISO 704 / ISO 1087-1 / ISO 12620 generic
1
+ // GenericHyperedge — an ISO 704 / ISO 1087-1 / ISO 12620 generic
2
2
  // relation connecting a comprehensive concept (the genus) to two
3
3
  // or more specific concepts (the species) which together constitute
4
4
  // a decomposition by some criterion of subdivision.
5
5
  //
6
- // Mirror of PartitiveRelation. Multiple GenericRelations on the same
6
+ // Mirror of PartitiveHyperedge. Multiple GenericRelations on the same
7
7
  // comprehensive distinguished by `criterion` is the OIML pattern
8
8
  // (5.1 measurement standard has 6 criterion groups).
9
9
  //
10
10
  // See docs/design/generic-relation.md (concept-model repo).
11
11
 
12
- import { AbstractNaryRelation } from './abstract-nary-relation.js';
12
+ import { AbstractHyperedge } from './abstract-hyperedge.js';
13
13
  import { GenericMember } from './generic-member.js';
14
+ import { HyperedgeRegistry } from './hyperedge-registry.js';
15
+
16
+ export class GenericHyperedge extends AbstractHyperedge {
17
+ // Per-class metadata block — the OCP contract. See PartitiveHyperedge
18
+ // for field documentation.
19
+ static wireKey = 'generic_relations';
20
+ static typeTag = 'generic_relation';
21
+ static rdfType = 'gloss:GenericRelation';
22
+ static memberClass = GenericMember;
23
+ static v1WireKeys = [];
24
+ static kindLabel = 'GEN';
14
25
 
15
- export class GenericRelation extends AbstractNaryRelation {
16
26
  constructor(data = {}) {
17
27
  super(data);
18
28
  this.members = data?.members == null
@@ -36,8 +46,10 @@ export class GenericRelation extends AbstractNaryRelation {
36
46
  }
37
47
 
38
48
  static fromJSON(data) {
39
- return new GenericRelation(data);
49
+ return new GenericHyperedge(data);
40
50
  }
41
51
  }
42
52
 
43
- export default GenericRelation;
53
+ export default GenericHyperedge;
54
+
55
+ HyperedgeRegistry.register(GenericHyperedge);
@@ -1,15 +1,15 @@
1
- // GenericMember — one member of a GenericRelation. Inherits the
2
- // ISO 704:2022 MECE shape from ConceptSystemMember. Declared as a
1
+ // GenericMember — one member of a GenericHyperedge. Inherits the
2
+ // ISO 704:2022 MECE shape from HyperedgeMember. Declared as a
3
3
  // distinct leaf class for type safety and to leave room for
4
4
  // generic-specific extensions.
5
5
  //
6
6
  // See docs/design/abstract-nary-relation.md and
7
7
  // docs/design/generic-relation.md (concept-model repo).
8
8
 
9
- import { ConceptSystemMember } from './concept-system-member.js';
9
+ import { HyperedgeMember } from './hyperedge-member.js';
10
10
 
11
- export class GenericMember extends ConceptSystemMember {
12
- // Inherits all attributes and validations from ConceptSystemMember.
11
+ export class GenericMember extends HyperedgeMember {
12
+ // Inherits all attributes and validations from HyperedgeMember.
13
13
  }
14
14
 
15
15
  export default GenericMember;
@@ -0,0 +1,87 @@
1
+ // HyperedgeIndex — directional index over a hyperedge set.
2
+ //
3
+ // A hyperedge is 1→N (one comprehensive, many members). The forward
4
+ // query ("which hyperedges have X as comprehensive?") and the reverse
5
+ // query ("which hyperedges have Y as a member?") are both useful. The
6
+ // reverse query is the load-bearing one: OIML V 2-200:2010 concept
7
+ // 5.13 is a member of one of 5.1's hyperedges AND the comprehensive
8
+ // of its own 2 hyperedges — without an index, every "find hyperedges
9
+ // mentioning X" turns into an O(N) walk over every hyperedge.
10
+ //
11
+ // The index is built on demand from a flat hyperedge list (typically
12
+ // Concept.relations or the union of all concepts' relations in a
13
+ // collection). It does not store state on Concept — Concept stays a
14
+ // simple value object.
15
+ //
16
+ // Identity key matches HyperedgeRegistry / ConceptRef conventions:
17
+ // `${source}:${id}` (empty source or id is rejected)
18
+ //
19
+ // See TODO Phase 9.
20
+
21
+ export class HyperedgeIndex {
22
+ constructor(hyperedges = []) {
23
+ this._byComprehensive = new Map();
24
+ this._byMember = new Map();
25
+
26
+ for (const h of hyperedges) {
27
+ const cKey = _qualifiedId(h?.comprehensive);
28
+ if (cKey) _addToMapList(this._byComprehensive, cKey, h);
29
+ const members = Array.isArray(h?.members) ? h.members : [];
30
+ for (const m of members) {
31
+ const mKey = _qualifiedId(m?.ref ?? m);
32
+ if (mKey) _addToMapList(this._byMember, mKey, h);
33
+ }
34
+ }
35
+ }
36
+
37
+ // Hyperedges where the given concept is the comprehensive (the "1" side).
38
+ forComprehensive(qualifiedId) {
39
+ return this._byComprehensive.get(qualifiedId) ?? [];
40
+ }
41
+
42
+ // Hyperedges where the given concept appears as a member (the "many" side).
43
+ forMember(qualifiedId) {
44
+ return this._byMember.get(qualifiedId) ?? [];
45
+ }
46
+
47
+ // All concepts that appear as a comprehensive in any indexed hyperedge.
48
+ comprehensives() {
49
+ return [...this._byComprehensive.keys()];
50
+ }
51
+
52
+ // All concepts that appear as a member in any indexed hyperedge.
53
+ members() {
54
+ return [...this._byMember.keys()];
55
+ }
56
+
57
+ get size() {
58
+ return this._byComprehensive.size;
59
+ }
60
+ }
61
+
62
+ // Build a HyperedgeIndex across multiple Concepts' relations arrays
63
+ // (a dataset-wide view). Each Concept contributes its own .relations.
64
+ // Helper for the most common pattern: index once at collection load,
65
+ // query many times.
66
+ export function buildDatasetIndex(concepts = []) {
67
+ const all = [];
68
+ for (const concept of concepts) {
69
+ for (const h of concept.relations ?? []) {
70
+ all.push(h);
71
+ }
72
+ }
73
+ return new HyperedgeIndex(all);
74
+ }
75
+
76
+ function _qualifiedId(ref) {
77
+ if (!ref) return null;
78
+ const source = ref.source ?? '';
79
+ const id = ref.id ?? '';
80
+ if (!source && !id) return null;
81
+ return `${source}:${id}`;
82
+ }
83
+
84
+ function _addToMapList(map, key, value) {
85
+ if (!map.has(key)) map.set(key, []);
86
+ map.get(key).push(value);
87
+ }
@@ -1,4 +1,4 @@
1
- // ConceptSystemMember — abstract base shape for members of any
1
+ // HyperedgeMember — abstract base shape for members of any
2
2
  // n-ary concept-system relation (PartitiveMember, GenericMember,
3
3
  // future AssociativeMember, SequentialMember).
4
4
  //
@@ -18,20 +18,13 @@ import {
18
18
  isValidPresence,
19
19
  } from './partitive-presence.js';
20
20
  import {
21
- PARTITIVE_COUNT,
22
21
  PARTITIVE_COUNT_VALUES,
23
22
  DEFAULT_COUNT,
24
23
  isValidCount,
25
24
  } from './partitive-count.js';
25
+ import { multiplicityFromPair } from './multiplicity.js';
26
26
 
27
- // Module-private (not exported). Single source of truth for the
28
- // invalid (presence, count) combination rejected at construction.
29
- const INVALID_PRESENCE_COUNT_COMBO = {
30
- presence: PARTITIVE_PRESENCE.OPTIONAL,
31
- count: PARTITIVE_COUNT.AT_LEAST_ONE,
32
- };
33
-
34
- export class ConceptSystemMember extends GlossaristModel {
27
+ export class HyperedgeMember extends GlossaristModel {
35
28
  constructor(data = {}) {
36
29
  super();
37
30
  this.ref = _ensureRef(data.ref);
@@ -47,7 +40,7 @@ export class ConceptSystemMember extends GlossaristModel {
47
40
  get isDelimiting() { return this.is_delimiting === true; }
48
41
 
49
42
  identity() {
50
- return ConceptSystemMember.identityOf(this);
43
+ return HyperedgeMember.identityOf(this);
51
44
  }
52
45
 
53
46
  toJSON() {
@@ -65,7 +58,7 @@ export class ConceptSystemMember extends GlossaristModel {
65
58
  }
66
59
 
67
60
  static fromJSON(data) {
68
- return new ConceptSystemMember(data);
61
+ return new HyperedgeMember(data);
69
62
  }
70
63
  }
71
64
 
@@ -73,7 +66,7 @@ function _ensureRef(value) {
73
66
  const ref = value instanceof ConceptRef ? value : new ConceptRef(value ?? {});
74
67
  if (!ref.source && !ref.id && !ref.text) {
75
68
  throw new Error(
76
- 'ConceptSystemMember.ref must be a non-empty ConceptRef ' +
69
+ 'HyperedgeMember.ref must be a non-empty ConceptRef ' +
77
70
  '(source, id, or text required)',
78
71
  );
79
72
  }
@@ -106,21 +99,16 @@ function _resolveDelimiting(value) {
106
99
  if (value == null) return false;
107
100
  if (typeof value !== 'boolean') {
108
101
  throw new Error(
109
- `ConceptSystemMember.is_delimiting must be boolean; got ${typeof value}`,
102
+ `HyperedgeMember.is_delimiting must be boolean; got ${typeof value}`,
110
103
  );
111
104
  }
112
105
  return value;
113
106
  }
114
107
 
115
108
  function _assertValidPair(presence, count) {
116
- if (
117
- presence === INVALID_PRESENCE_COUNT_COMBO.presence &&
118
- count === INVALID_PRESENCE_COUNT_COMBO.count
119
- ) {
120
- throw new Error(
121
- 'ConceptSystemMember presence=optional + count=at_least_one is invalid — ' +
122
- 'it collapses to optional + multiple (zero or more). ' +
123
- 'Use presence: optional, count: multiple instead.',
124
- );
125
- }
109
+ // Delegate to the multiplicity SSOT (Phase 8 of TODO.abstract-hyperedge).
110
+ // The canonical error string lives in one place: multiplicity.js.
111
+ // Calling multiplicityFromPair throws on the invalid combination;
112
+ // we don't need to duplicate the error message here.
113
+ multiplicityFromPair(presence, count);
126
114
  }
@@ -0,0 +1,115 @@
1
+ // HyperedgeRegistry — single source of truth for hyperedge type dispatch.
2
+ //
3
+ // Each hyperedge leaf class (PartitiveHyperedge, GenericHyperedge, future
4
+ // TemporalHyperedge, AssociativeHyperedge, etc.) registers itself once at
5
+ // module load. External systems (parser, serializer, diff, RDF emitter,
6
+ // validators) read from this registry instead of branching on instanceof
7
+ // or hardcoding per-type tables.
8
+ //
9
+ // Adding a new hyperedge type:
10
+ // 1. Declare the leaf class with the 6-field metadata block
11
+ // (wireKey, typeTag, rdfType, memberClass, v1WireKeys, kindLabel).
12
+ // 2. Call HyperedgeRegistry.register(MyClass) at the bottom of the file.
13
+ //
14
+ // Nothing else in the codebase changes. That is the OCP contract.
15
+ //
16
+ // Three indexes support three lookup patterns:
17
+ // byWireKey — parser/serializer/diff-patch dispatch by YAML/JSON key
18
+ // byTypeTag — per-file loader dispatch by `type: ...` discriminator
19
+ // byRdfType — RDF emitter dispatch by ontology class IRI
20
+ //
21
+ // The abstract base (AbstractHyperedge) has no wireKey and is skipped
22
+ // by register() — it cannot be instantiated through the registry.
23
+
24
+ class HyperedgeRegistry {
25
+ static _byWireKey = new Map();
26
+ static _byTypeTag = new Map();
27
+ static _byRdfType = new Map();
28
+
29
+ static register(cls) {
30
+ if (!cls || !cls.wireKey || !cls.typeTag || !cls.rdfType) return false;
31
+ if (this._byWireKey.has(cls.wireKey)) {
32
+ throw new Error(
33
+ `HyperedgeRegistry: duplicate wireKey '${cls.wireKey}' ` +
34
+ `(trying to register ${cls.name}; already registered to ` +
35
+ `${this._byWireKey.get(cls.wireKey).name})`,
36
+ );
37
+ }
38
+ if (this._byTypeTag.has(cls.typeTag)) {
39
+ throw new Error(
40
+ `HyperedgeRegistry: duplicate typeTag '${cls.typeTag}' ` +
41
+ `(trying to register ${cls.name})`,
42
+ );
43
+ }
44
+ if (this._byRdfType.has(cls.rdfType)) {
45
+ throw new Error(
46
+ `HyperedgeRegistry: duplicate rdfType '${cls.rdfType}' ` +
47
+ `(trying to register ${cls.name})`,
48
+ );
49
+ }
50
+ this._byWireKey.set(cls.wireKey, cls);
51
+ this._byTypeTag.set(cls.typeTag, cls);
52
+ this._byRdfType.set(cls.rdfType, cls);
53
+ return true;
54
+ }
55
+
56
+ // Test-only. Removes a class from all three indexes. Used by the OCP
57
+ // spec to clean up mock types it adds during a test run.
58
+ static unregister(cls) {
59
+ if (!cls) return false;
60
+ const r1 = this._byWireKey.delete(cls.wireKey);
61
+ const r2 = this._byTypeTag.delete(cls.typeTag);
62
+ const r3 = this._byRdfType.delete(cls.rdfType);
63
+ return r1 || r2 || r3;
64
+ }
65
+
66
+ static forWireKey(key) { return this._byWireKey.get(key) ?? null; }
67
+ static forTypeTag(tag) { return this._byTypeTag.get(tag) ?? null; }
68
+ static forRdfType(type) { return this._byRdfType.get(type) ?? null; }
69
+
70
+ static allClasses() {
71
+ // Dedup in case the same class is reachable via multiple indexes.
72
+ const seen = new Set();
73
+ const out = [];
74
+ for (const cls of this._byTypeTag.values()) {
75
+ if (!seen.has(cls)) {
76
+ seen.add(cls);
77
+ out.push(cls);
78
+ }
79
+ }
80
+ return out;
81
+ }
82
+
83
+ static allWireKeys() { return [...this._byWireKey.keys()]; }
84
+ static allTypeTags() { return [...this._byTypeTag.keys()]; }
85
+
86
+ // Snapshot of every registered wire-shape identifier (v2 wireKey plus
87
+ // any v1 legacy keys). Used by the parser to reserve STRUCTURAL_KEYS
88
+ // and to know which top-level YAML keys to walk for hyperedge data.
89
+ static allWireAndLegacyKeys() {
90
+ const out = [];
91
+ for (const cls of this.allClasses()) {
92
+ out.push(cls.wireKey);
93
+ for (const k of cls.v1WireKeys ?? []) out.push(k);
94
+ }
95
+ return out;
96
+ }
97
+ }
98
+
99
+ // Helper: partition a unified hyperedge array by wire key. Used by
100
+ // Concept.toJSON, ConceptSerializer, and diff-patch to emit per-type
101
+ // wire keys without per-type branching in those files. Adding a new
102
+ // hyperedge type "just works" because the group is keyed by the
103
+ // class's static wireKey.
104
+ export function groupHyperedgesByWireKey(hyperedges) {
105
+ const out = {};
106
+ for (const h of hyperedges ?? []) {
107
+ const key = h?.constructor?.wireKey;
108
+ if (!key) continue;
109
+ if (!out[key]) out[key] = [];
110
+ out[key].push(h);
111
+ }
112
+ return out;
113
+ }
114
+
115
+ export { HyperedgeRegistry };