glossarist 0.4.32 → 0.4.34

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 (40) hide show
  1. package/package.json +1 -1
  2. package/src/concept-parser.js +67 -45
  3. package/src/concept-reader.js +59 -3
  4. package/src/concept-serializer.js +4 -4
  5. package/src/diff/concept-diff.js +13 -13
  6. package/src/diff/diff-patch.js +19 -27
  7. package/src/diff/diff-renderer.js +19 -6
  8. package/src/index.js +2 -1
  9. package/src/migration/{partitive-relation-migrator.js → hyperedge-migrator.js} +3 -3
  10. package/src/models/{abstract-nary-relation.js → abstract-hyperedge.js} +5 -5
  11. package/src/models/completeness.js +1 -1
  12. package/src/models/concept.js +145 -72
  13. package/src/models/{generic-relation.js → generic-hyperedge.js} +18 -6
  14. package/src/models/generic-member.js +76 -9
  15. package/src/models/hyperedge-index.js +87 -0
  16. package/src/models/{concept-system-member.js → hyperedge-member.js} +26 -43
  17. package/src/models/hyperedge-registry.js +115 -0
  18. package/src/models/index.d.ts +16 -16
  19. package/src/models/index.js +16 -13
  20. package/src/models/multiplicity.js +11 -6
  21. package/src/models/partitive-hyperedge.js +69 -153
  22. package/src/models/partitive-member.js +40 -11
  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 +4 -4
  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/validators/ref-keys.js +2 -2
  36. package/src/models/partitive-relation.js +0 -76
  37. package/src/models/relation-type-registry.js +0 -25
  38. package/src/rdf/gloss-hyperedge.js +0 -90
  39. package/src/validators/partitive-drift-rule.js +0 -51
  40. package/src/validators/partitive-hyperedge-rule.js +0 -75
@@ -2,11 +2,9 @@ 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';
9
- import { migrateHyperedgeToRelation } from '../migration/partitive-relation-migrator.js';
5
+ import { AbstractHyperedge } from './abstract-hyperedge.js';
6
+ import { HyperedgeRegistry, groupHyperedgesByWireKey } from './hyperedge-registry.js';
7
+ import { migrateHyperedgeToRelation } from '../migration/hyperedge-migrator.js';
10
8
  import { ConceptReference } from './concept-reference.js';
11
9
  import { ConceptDate } from './concept-date.js';
12
10
  import { ConceptSource } from './concept-source.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 hyperedge (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,82 @@
1
- // GenericMember — one member of a GenericRelation. Inherits the
2
- // ISO 704:2022 MECE shape from ConceptSystemMember. Declared as a
3
- // distinct leaf class for type safety and to leave room for
4
- // generic-specific extensions.
1
+ // GenericMember — one member of a GenericHyperedge.
5
2
  //
6
- // See docs/design/abstract-nary-relation.md and
7
- // docs/design/generic-relation.md (concept-model repo).
3
+ // Per ISO 704:2022 §5.5.4.2.1 (Generic relations and general concepts):
4
+ //
5
+ // "In a generic relation, the intension of the subordinate concept
6
+ // includes the intension of the superordinate concept plus at least
7
+ // one additional delimiting characteristic."
8
+ //
9
+ // In hyperedge terms: when a GenericHyperedge has comprehensive=computer mouse
10
+ // and criterion=means of movement detection, each member (mechanical mouse,
11
+ // optomechanical mouse, optical mouse) carries a delimitingCharacteristic —
12
+ // the value of the criterion for that specific concept:
13
+ //
14
+ // mechanical mouse → 'detecting movement by means of rollers'
15
+ // optomechanical mouse → 'detecting movement by means of rollers and light sensors'
16
+ // optical mouse → 'detecting movement by means of light sensors'
17
+ //
18
+ // Multidimensionality (§5.6.3): the same comprehensive can be the
19
+ // '1' side of multiple generic hyperedges simultaneously, each by a
20
+ // different criterion. computer mouse is also comprehensive of
21
+ // {wired mouse, wireless mouse} under criterion=computer connection.
22
+ // That second hyperedge's members carry their own delimitingCharacteristic
23
+ // values ('using a corded electrical connection', 'using a cordless
24
+ // light or sound connection').
25
+ //
26
+ // delimitingCharacteristic is REQUIRED on GenericMember. Without it,
27
+ // the member is just a coordinate concept with no semantic content
28
+ // tying it to the criterion — ISO 704 says the relation is meaningless
29
+ // without the delimiting characteristic.
30
+ //
31
+ // LocalizedString shape (mirrors criterion on the parent hyperedge):
32
+ // { eng: 'detecting movement by means of light sensors' }
33
+ // { eng: '...', fra: '...' } — multiple languages supported
34
+
35
+ import { HyperedgeMember } from './hyperedge-member.js';
8
36
 
9
- import { ConceptSystemMember } from './concept-system-member.js';
37
+ export class GenericMember extends HyperedgeMember {
38
+ constructor(data = {}) {
39
+ super(data);
40
+ this.delimitingCharacteristic = _resolveDelimitingCharacteristic(data.delimitingCharacteristic);
41
+ }
42
+
43
+ toJSON() {
44
+ const base = super.toJSON();
45
+ base.delimitingCharacteristic = { ...this.delimitingCharacteristic };
46
+ return base;
47
+ }
48
+ }
10
49
 
11
- export class GenericMember extends ConceptSystemMember {
12
- // Inherits all attributes and validations from ConceptSystemMember.
50
+ function _resolveDelimitingCharacteristic(value) {
51
+ if (value == null) {
52
+ throw new Error(
53
+ 'GenericMember.delimitingCharacteristic is required per ISO 704:2022 §5.5.4.2.1 ' +
54
+ '(the intension of a subordinate concept must include at least one additional ' +
55
+ 'delimiting characteristic beyond the superordinate).',
56
+ );
57
+ }
58
+ if (typeof value === 'string') {
59
+ if (value.length === 0) {
60
+ throw new Error(
61
+ 'GenericMember.delimitingCharacteristic cannot be empty (ISO 704:2022 §5.5.4.2.1)',
62
+ );
63
+ }
64
+ return { default: value };
65
+ }
66
+ if (typeof value === 'object') {
67
+ const entries = Object.entries(value).filter(([, v]) => typeof v === 'string' && v.length > 0);
68
+ if (entries.length === 0) {
69
+ throw new Error(
70
+ 'GenericMember.delimitingCharacteristic must have at least one non-empty string ' +
71
+ 'value (ISO 704:2022 §5.5.4.2.1)',
72
+ );
73
+ }
74
+ return Object.fromEntries(entries);
75
+ }
76
+ throw new Error(
77
+ `GenericMember.delimitingCharacteristic must be a string or a language-keyed object; ` +
78
+ `got ${typeof value}`,
79
+ );
13
80
  }
14
81
 
15
82
  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,13 +1,21 @@
1
- // ConceptSystemMember — abstract base shape for members of any
2
- // n-ary concept-system relation (PartitiveMember, GenericMember,
3
- // future AssociativeMember, SequentialMember).
1
+ // HyperedgeMember — abstract base shape for the members of any directed
2
+ // 1→N hyperedge (PartitiveMember, GenericMember, future AssociativeMember,
3
+ // SequentialMember).
4
4
  //
5
- // Carries the shared ISO 704:2022 MECE dimensions: presence × count,
6
- // plus the orthogonal is_delimiting flag.
5
+ // Carries ONLY the dimensions shared by every hyperedge member type:
6
+ // ref — ConceptRef [1] (the member concept)
7
+ // presence — 'required' | 'optional' (default: required)
8
+ // count — 'exactly_one' | 'at_least_one' | 'multiple' (default: exactly_one)
7
9
  //
8
- // Concrete leaf classes extend this with type-specific fields if
9
- // needed (currently none). See docs/design/abstract-nary-relation.md
10
- // (concept-model repo).
10
+ // These three form the ISO 704:2022 MECE pair (presence × count). The
11
+ // pair is the shared invariant across all hyperedge types — that's
12
+ // why it lives on the base.
13
+ //
14
+ // Type-specific extensions live on the leaves:
15
+ // PartitiveMember — is_delimiting (rake-diagram semantics, ISO 704 §5.5.4.2.2)
16
+ // GenericMember — delimitingCharacteristic (intension difference, ISO 704 §5.5.4.2.1)
17
+ //
18
+ // See docs/design/abstract-hyperedge.md (concept-model repo).
11
19
 
12
20
  import { GlossaristModel } from './base.js';
13
21
  import { ConceptRef } from './concept-ref.js';
@@ -18,43 +26,33 @@ import {
18
26
  isValidPresence,
19
27
  } from './partitive-presence.js';
20
28
  import {
21
- PARTITIVE_COUNT,
22
29
  PARTITIVE_COUNT_VALUES,
23
30
  DEFAULT_COUNT,
24
31
  isValidCount,
25
32
  } from './partitive-count.js';
33
+ import { multiplicityFromPair } from './multiplicity.js';
26
34
 
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 {
35
+ export class HyperedgeMember extends GlossaristModel {
35
36
  constructor(data = {}) {
36
37
  super();
37
38
  this.ref = _ensureRef(data.ref);
38
39
  this.presence = _resolvePresence(data.presence);
39
40
  this.count = _resolveCount(data.count);
40
- this.is_delimiting = _resolveDelimiting(data.is_delimiting);
41
41
 
42
42
  _assertValidPair(this.presence, this.count);
43
43
  }
44
44
 
45
45
  get isRequired() { return this.presence === PARTITIVE_PRESENCE.REQUIRED; }
46
46
  get isOptional() { return this.presence === PARTITIVE_PRESENCE.OPTIONAL; }
47
- get isDelimiting() { return this.is_delimiting === true; }
48
47
 
49
48
  identity() {
50
- return ConceptSystemMember.identityOf(this);
49
+ return HyperedgeMember.identityOf(this);
51
50
  }
52
51
 
53
52
  toJSON() {
54
53
  const obj = { ref: this.ref.toJSON() };
55
54
  if (this.presence !== DEFAULT_PRESENCE) obj.presence = this.presence;
56
55
  if (this.count !== DEFAULT_COUNT) obj.count = this.count;
57
- if (this.is_delimiting) obj.is_delimiting = true;
58
56
  return obj;
59
57
  }
60
58
 
@@ -65,7 +63,7 @@ export class ConceptSystemMember extends GlossaristModel {
65
63
  }
66
64
 
67
65
  static fromJSON(data) {
68
- return new ConceptSystemMember(data);
66
+ return new HyperedgeMember(data);
69
67
  }
70
68
  }
71
69
 
@@ -73,7 +71,7 @@ function _ensureRef(value) {
73
71
  const ref = value instanceof ConceptRef ? value : new ConceptRef(value ?? {});
74
72
  if (!ref.source && !ref.id && !ref.text) {
75
73
  throw new Error(
76
- 'ConceptSystemMember.ref must be a non-empty ConceptRef ' +
74
+ 'HyperedgeMember.ref must be a non-empty ConceptRef ' +
77
75
  '(source, id, or text required)',
78
76
  );
79
77
  }
@@ -102,25 +100,10 @@ function _resolveCount(value) {
102
100
  return c;
103
101
  }
104
102
 
105
- function _resolveDelimiting(value) {
106
- if (value == null) return false;
107
- if (typeof value !== 'boolean') {
108
- throw new Error(
109
- `ConceptSystemMember.is_delimiting must be boolean; got ${typeof value}`,
110
- );
111
- }
112
- return value;
113
- }
114
-
115
103
  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
- }
104
+ // Delegate to the multiplicity SSOT. The canonical error string
105
+ // lives in one place: multiplicity.js. Calling multiplicityFromPair
106
+ // throws on the invalid combination; we don't need to duplicate the
107
+ // error message here.
108
+ multiplicityFromPair(presence, count);
126
109
  }