glossarist 0.4.32 → 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 (39) hide show
  1. package/package.json +1 -1
  2. package/src/concept-parser.js +66 -44
  3. package/src/concept-reader.js +59 -3
  4. package/src/concept-serializer.js +4 -4
  5. package/src/diff/concept-diff.js +9 -9
  6. package/src/diff/diff-patch.js +19 -27
  7. package/src/diff/diff-renderer.js +8 -3
  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 +144 -71
  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 +28 -22
  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/models/relation-type-registry.js +0 -25
  37. package/src/rdf/gloss-hyperedge.js +0 -90
  38. package/src/validators/partitive-drift-rule.js +0 -51
  39. package/src/validators/partitive-hyperedge-rule.js +0 -75
@@ -2,10 +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';
6
- import { GenericRelation } from './generic-relation.js';
7
- import { AbstractNaryRelation } from './abstract-nary-relation.js';
8
- import { TYPE_TO_CLASS } from './relation-type-registry.js';
5
+ import { AbstractHyperedge } from './abstract-hyperedge.js';
6
+ import { HyperedgeRegistry, groupHyperedgesByWireKey } from './hyperedge-registry.js';
9
7
  import { migrateHyperedgeToRelation } from '../migration/partitive-relation-migrator.js';
10
8
  import { ConceptReference } from './concept-reference.js';
11
9
  import { ConceptDate } from './concept-date.js';
@@ -38,16 +36,18 @@ export class Concept extends GlossaristModel {
38
36
 
39
37
  this.relatedConcepts = _mapInstances(data.relatedConcepts ?? data.related ?? data.related_concepts ?? [], RelatedConcept);
40
38
 
41
- // Unified n-ary relations array. Both PartitiveRelation and
42
- // GenericRelation live here. Adding a new n-ary type (Sequential,
43
- // Associative) = adding to _resolveNaryRelations, not editing
44
- // Concept, parser, serializer, diff, patch, or renderer.
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
45
  //
46
- // Backward-compat getters (.partitiveRelations, .partitiveHyperedges,
47
- // .genericRelations) filter this single array by type. Callers that
48
- // use the typed getters see the same behavior; new callers should
49
- // iterate .relations directly and dispatch via instanceof.
50
- this.relations = _resolveNaryRelations(data);
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
51
 
52
52
  this.domains = _normalizeDomains(data.domains, data.groups);
53
53
  this.groups = Array.isArray(data.groups)
@@ -72,16 +72,17 @@ export class Concept extends GlossaristModel {
72
72
 
73
73
  get termid() { return this.id; }
74
74
 
75
- // Typed getters that filter the unified relations array. Backward
76
- // compat for callers that read .partitiveRelations directly.
77
- get partitiveRelations() {
78
- return this.relations.filter(r => r instanceof PartitiveRelation);
79
- }
80
- get genericRelations() {
81
- return this.relations.filter(r => r instanceof GenericRelation);
82
- }
83
- // v1 alias
84
- get partitiveHyperedges() { return this.partitiveRelations; }
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)
85
86
 
86
87
  get languages() {
87
88
  return Object.keys(this._rawLocalizations);
@@ -222,16 +223,13 @@ export class Concept extends GlossaristModel {
222
223
  if (this.relatedConcepts.length > 0) {
223
224
  obj.related = this.relatedConcepts.map(rc => rc.toJSON());
224
225
  }
225
- // Emit typed wire keys from the unified relations array.
226
- // Each relation type gets its own wire key so existing consumers
227
- // see no wire-shape change.
228
- const partitiveRels = this.relations.filter(r => r instanceof PartitiveRelation);
229
- const genericRels = this.relations.filter(r => r instanceof GenericRelation);
230
- if (partitiveRels.length > 0) {
231
- obj.partitive_relations = partitiveRels.map(r => r.toJSON());
232
- }
233
- if (genericRels.length > 0) {
234
- obj.generic_relations = genericRels.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());
235
233
  }
236
234
  if (this.domains.length > 0) {
237
235
  obj.domains = this.domains.map(d => d.toJSON());
@@ -262,72 +260,147 @@ function _mapInstances(arr, Cls) {
262
260
  return arr.map(item => item instanceof Cls ? item : new Cls(item));
263
261
  }
264
262
 
265
- // Resolve the unified n-ary relations array from any input shape.
266
- // Order of precedence:
267
- // 1. data.relations (preferred unified shape — already typed instances
268
- // or hashes carrying a `type` discriminator)
269
- // 2. data.partitiveRelations / data.partitive_relations (v2 typed)
270
- // + data.genericRelations / data.generic_relations (v2 typed)
271
- // 3. data.partitiveHyperedges / data.partitive_hyperedges (v1, migrated)
263
+ // Resolve the unified hyperedge array from any input shape.
272
264
  //
273
- // Hash dispatch uses TYPE_TO_CLASS from relation-type-registry.js —
274
- // the single source of truth shared with the per-file loader.
265
+ // Three input categories are accepted; mixing them throws (audit C4 —
266
+ // silent drop was a footgun):
275
267
  //
276
- // Adding a new n-ary relation type (Sequential, Associative, …) means
277
- // one entry in TYPE_TO_CLASS plus one new class — Concept, parser,
278
- // serializer, diff, patch, and renderer do not change.
279
- function _resolveNaryRelations(data) {
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).
277
+ //
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
+
280
284
  if (Array.isArray(data.relations)) {
281
- return data.relations
282
- .map(r => _coerceRelation(r))
283
- .filter(r => r != null);
285
+ return _dedupeOrThrow(data.relations.map(r => _coerceHyperedge(r)).filter(r => r != null));
284
286
  }
285
287
 
286
288
  const out = [];
287
289
 
288
- const v2Partitive = data.partitiveRelations ?? data.partitive_relations;
289
- if (Array.isArray(v2Partitive)) {
290
- for (const r of v2Partitive) {
291
- const coerced = _coerceRelation(r, PartitiveRelation);
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);
292
295
  if (coerced) out.push(coerced);
293
296
  }
294
297
  }
295
298
 
296
- const v2Generic = data.genericRelations ?? data.generic_relations;
297
- if (Array.isArray(v2Generic)) {
298
- for (const r of v2Generic) {
299
- const coerced = _coerceRelation(r, GenericRelation);
300
- if (coerced) out.push(coerced);
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
+ }
309
+ }
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);
301
330
  }
302
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
+ }
303
341
 
304
- const v1 = data.partitiveHyperedges ?? data.partitive_hyperedges;
305
- if (Array.isArray(v1)) {
306
- for (const h of v1) {
307
- const hash = h instanceof PartitiveHyperedge ? h.toJSON() : h;
308
- const migrated = migrateHyperedgeToRelation(hash);
309
- if (migrated) out.push(new PartitiveRelation(migrated));
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
+ );
310
356
  }
357
+ seen.set(id, r);
311
358
  }
359
+ return relations;
360
+ }
312
361
 
313
- return out;
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);
314
369
  }
315
370
 
316
- function _coerceRelation(value, fallbackClass) {
371
+ function _coerceHyperedge(value, fallbackClass) {
317
372
  if (value == null) return null;
318
- if (value instanceof AbstractNaryRelation) return value;
373
+ if (value instanceof AbstractHyperedge) return value;
319
374
 
320
375
  const hash = typeof value.toJSON === 'function' ? value.toJSON() : value;
321
- const Cls = _classForHash(hash) ?? fallbackClass;
322
- if (!Cls) return null;
323
- return new Cls(hash);
376
+ const cls = _classForHash(hash) ?? fallbackClass;
377
+ if (!cls) return null;
378
+ return new cls(hash);
324
379
  }
325
380
 
326
381
  function _classForHash(hash) {
327
382
  if (!hash || typeof hash !== 'object') return null;
328
383
  const type = hash.type;
329
384
  if (typeof type !== 'string') return null;
330
- return TYPE_TO_CLASS[type] ?? 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());
331
404
  }
332
405
 
333
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 };