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
@@ -328,11 +328,11 @@ export class PartitiveHyperedge extends GlossaristModel {
328
328
  static fromJSON(data: Record<string, unknown>): PartitiveHyperedge;
329
329
  }
330
330
 
331
- // v2 PartitiveRelation + GenericRelation (MECE per-member model).
331
+ // v2 PartitiveHyperedge + GenericHyperedge (MECE per-member model).
332
332
  // These supersede the v1 PartitiveHyperedge model above. New code
333
333
  // should use v2 exclusively; the v1 model is kept for backward-compat
334
334
  // input.
335
- export class ConceptSystemMember extends GlossaristModel {
335
+ export class HyperedgeMember extends GlossaristModel {
336
336
  readonly ref: ConceptRef;
337
337
  readonly presence: 'required' | 'optional';
338
338
  readonly count: 'exactly_one' | 'at_least_one' | 'multiple';
@@ -342,17 +342,17 @@ export class ConceptSystemMember extends GlossaristModel {
342
342
  delimiting(): boolean;
343
343
  identity(): string;
344
344
  toJSON(): Record<string, unknown>;
345
- static fromJSON(data: Record<string, unknown>): ConceptSystemMember;
345
+ static fromJSON(data: Record<string, unknown>): HyperedgeMember;
346
346
  static identityOf(value: unknown): string;
347
347
  }
348
348
 
349
- export class PartitiveMember extends ConceptSystemMember {}
349
+ export class PartitiveMember extends HyperedgeMember {}
350
350
 
351
- export class GenericMember extends ConceptSystemMember {}
351
+ export class GenericMember extends HyperedgeMember {}
352
352
 
353
- export class AbstractNaryRelation extends GlossaristModel {
353
+ export class AbstractHyperedge extends GlossaristModel {
354
354
  readonly comprehensive: ConceptRef;
355
- readonly members: ConceptSystemMember[];
355
+ readonly members: HyperedgeMember[];
356
356
  readonly completeness: 'complete' | 'partial';
357
357
  readonly criterion: Record<string, string> | null;
358
358
  readonly sources: ConceptSource[];
@@ -362,19 +362,19 @@ export class AbstractNaryRelation extends GlossaristModel {
362
362
  readonly isPartial: boolean;
363
363
  readonly isCoordinate: boolean;
364
364
  toJSON(): Record<string, unknown>;
365
- static fromJSON(data: Record<string, unknown>): AbstractNaryRelation;
365
+ static fromJSON(data: Record<string, unknown>): AbstractHyperedge;
366
366
  static identityOf(value: unknown): string;
367
367
  }
368
368
 
369
- export class PartitiveRelation extends AbstractNaryRelation {
369
+ export class PartitiveHyperedge extends AbstractHyperedge {
370
370
  /** @deprecated use {@link members} — kept for v1/v2 wire compat. */
371
- readonly partitives: ConceptSystemMember[];
372
- static fromJSON(data: Record<string, unknown>): PartitiveRelation;
371
+ readonly partitives: HyperedgeMember[];
372
+ static fromJSON(data: Record<string, unknown>): PartitiveHyperedge;
373
373
  static identityOf(value: unknown): string;
374
374
  }
375
375
 
376
- export class GenericRelation extends AbstractNaryRelation {
377
- static fromJSON(data: Record<string, unknown>): GenericRelation;
376
+ export class GenericHyperedge extends AbstractHyperedge {
377
+ static fromJSON(data: Record<string, unknown>): GenericHyperedge;
378
378
  static identityOf(value: unknown): string;
379
379
  }
380
380
 
@@ -383,14 +383,14 @@ export const DEFINITION_TYPE: Readonly<Record<string, DefinitionType>>;
383
383
  export const DEFAULT_DEFINITION_TYPE: DefinitionType;
384
384
  export const DEFINITION_TYPE_VALUES: readonly DefinitionType[];
385
385
 
386
- export const RELATION_TYPE_TO_CLASS: Readonly<Record<string, typeof AbstractNaryRelation>>;
386
+ export const RELATION_TYPE_TO_CLASS: Readonly<Record<string, typeof AbstractHyperedge>>;
387
387
  export class RelationLoadError extends Error {
388
388
  readonly path: string;
389
389
  constructor(path: string, message: string);
390
390
  }
391
391
  export const RelationLoader: {
392
- loadAll(relationsDir: string): Map<string, AbstractNaryRelation[]>;
393
- loadOne(path: string): AbstractNaryRelation | null;
392
+ loadAll(relationsDir: string): Map<string, AbstractHyperedge[]>;
393
+ loadOne(path: string): AbstractHyperedge | null;
394
394
  };
395
395
 
396
396
  export const DESIGNATION_RELATIONSHIP_TYPES: readonly string[];
@@ -21,7 +21,6 @@ export { Citation } from './citation.js';
21
21
  export { ConceptRef } from './concept-ref.js';
22
22
  export { ConceptSource } from './concept-source.js';
23
23
  export { RelatedConcept, RELATIONSHIP_TYPES } from './related-concept.js';
24
- export { PartitiveHyperedge } from './partitive-hyperedge.js';
25
24
  export {
26
25
  PARTITIVE_ENUMERATION,
27
26
  PARTITIVE_ENUMERATION_VALUES,
@@ -33,17 +32,17 @@ export {
33
32
  isValidPluralityMarker,
34
33
  } from './plurality-marker.js';
35
34
 
36
- // v2 PartitiveRelation models (TODO.partitive-relation-v2). These
35
+ // v2 PartitiveHyperedge models (TODO.partitive-relation-v2). These
37
36
  // supersede the v1 hyperedge models above; the v1 models are kept for
38
37
  // backward-compat input. New code should use v2 exclusively.
39
- export { PartitiveRelation } from './partitive-relation.js';
38
+ export { PartitiveHyperedge } from './partitive-hyperedge.js';
40
39
  export { PartitiveMember } from './partitive-member.js';
41
40
 
42
- // v2 GenericRelation models (per-file relation storage). Mirrors
43
- // PartitiveRelation shape; uses the same MECE member shape.
44
- export { AbstractNaryRelation } from './abstract-nary-relation.js';
45
- export { ConceptSystemMember } from './concept-system-member.js';
46
- export { GenericRelation } from './generic-relation.js';
41
+ // v2 GenericHyperedge models (per-file relation storage). Mirrors
42
+ // PartitiveHyperedge shape; uses the same MECE member shape.
43
+ export { AbstractHyperedge } from './abstract-hyperedge.js';
44
+ export { HyperedgeMember } from './hyperedge-member.js';
45
+ export { GenericHyperedge } from './generic-hyperedge.js';
47
46
  export { GenericMember } from './generic-member.js';
48
47
  export {
49
48
  DEFINITION_TYPE,
@@ -51,9 +50,13 @@ export {
51
50
  } from './definition-type.js';
52
51
 
53
52
  // Per-file relation loader (Node-side runtime; not browser-bundled).
54
- // Walks a relations/ directory and dispatches to PartitiveRelation /
55
- // GenericRelation by file type.
53
+ // Walks a relations/ directory and dispatches to PartitiveHyperedge /
54
+ // GenericHyperedge by file type.
56
55
  export { RelationLoader, RelationLoadError, TYPE_TO_CLASS as RELATION_TYPE_TO_CLASS } from './relation-loader.js';
56
+
57
+ // Directional index over hyperedge sets. Built on demand from a flat
58
+ // hyperedge list (typically concept.relations or a dataset union).
59
+ export { HyperedgeIndex, buildDatasetIndex } from './hyperedge-index.js';
57
60
  export {
58
61
  PARTITIVE_PRESENCE,
59
62
  PARTITIVE_PRESENCE_VALUES,
@@ -53,6 +53,17 @@ export function multiplicityFromPair(presence, count) {
53
53
  return name;
54
54
  }
55
55
 
56
+ // Canonical error message for the invalid (presence, count) combination.
57
+ // Single source of truth — hyperedge-member.js delegates to this so the
58
+ // error string doesn't drift across the two files. Phase 8 of
59
+ // TODO.abstract-hyperedge.
60
+ export function invalidCombinationError(presence, count) {
61
+ return new Error(
62
+ `Invalid multiplicity combination: presence=${presence}, count=${count}. ` +
63
+ `(optional + at_least_one collapses to optional + multiple — use count=multiple.)`,
64
+ );
65
+ }
66
+
56
67
  export function pairFromMultiplicity(name) {
57
68
  const pair = PAIR_BY_NAME[name];
58
69
  if (pair == null) {
@@ -92,9 +103,3 @@ export function resolveMultiplicity(member) {
92
103
  return DEFAULT_MULTIPLICITY;
93
104
  }
94
105
 
95
- function invalidCombinationError(presence, count) {
96
- return new Error(
97
- `Invalid multiplicity combination: presence=${presence}, count=${count}. ` +
98
- `(optional + at_least_one collapses to optional + multiple — use count=multiple.)`,
99
- );
100
- }
@@ -1,175 +1,91 @@
1
- // PartitiveHyperedge — a one-to-many partitive decomposition.
1
+ // PartitiveHyperedge — an ISO 704:2022 / ISO 1087-1 / ISO 12620
2
+ // partitive relation connecting a comprehensive concept
3
+ // (superordinate concept partitive) to two or more partitive
4
+ // concepts (subordinate concepts partitive) which fitted together
5
+ // constitute the comprehensive.
2
6
  //
3
- // One comprehensive concept (the whole) is related to one or more
4
- // parts as a SINGLE relationship. Captures invariants that binary
5
- // RelatedConcept edges cannot:
7
+ // Extends AbstractHyperedge; the leaf-specific concern is the
8
+ // PartitiveMember type for `members`. The wire-level field name
9
+ // stays `partitives` for v1/v2 backward compat.
6
10
  //
7
- // - which comprehensive owns which parts (set membership)
8
- // - plurality markers from the source diagram (double / dashed)
9
- // - enumeration completeness (closed: all parts listed; open:
10
- // other parts may exist)
11
+ // ISO 704:2022 rake diagram semantics:
11
12
  //
12
- // See concept-model/TODO.hyperedge/00-design-overview.md for the
13
- // design rationale.
13
+ // comprehensive concept
14
+ // ─────────────────────
15
+ // │ partitive (one or more) with per-member multiplicity + optional delimiting
16
+ // ─────────────────────
14
17
  //
15
- // Hyperedges coexist with binary `broader_partitive` /
16
- // `narrower_partitive` edges. Use a binary edge for pairwise
17
- // relationships without diagram metadata; use a hyperedge when the
18
- // relationship is one-to-many with enumeration/marker metadata.
19
-
20
- import { GlossaristModel } from './base.js';
21
- import { ConceptRef } from './concept-ref.js';
22
- import {
23
- PARTITIVE_ENUMERATION,
24
- PARTITIVE_ENUMERATION_VALUES,
25
- isValidPartitiveEnumeration,
26
- } from './partitive-enumeration.js';
27
- import {
28
- PLURALITY_MARKER_VALUES,
29
- isValidPluralityMarker,
30
- } from './plurality-marker.js';
31
-
32
- const DEFAULT_ENUMERATION = PARTITIVE_ENUMERATION.CLOSED;
18
+ // All partitives within one relation are coordinate concepts
19
+ // (ISO 12620): they share the comprehensive AND share the
20
+ // criterion of subdivision.
21
+ //
22
+ // "Delimiting" parts (bold/3x-width line in source diagrams) behave
23
+ // like delimiting characteristics — they distinguish the comprehensive
24
+ // from coordinate concepts. Whether a part is delimiting depends on
25
+ // the concept system, the coordinate concepts, the inheritance
26
+ // principle, and the criterion of subdivision used
27
+ // (ISO 704:2022 §5.5.4.2.1).
28
+ //
29
+ // Distinguished from binary `has_part` / `is_part_of` edges, which
30
+ // express pairwise part-of assertions without completeness,
31
+ // multiplicity, or criterion claims. A single partitive should be
32
+ // expressed as a binary edge, not a PartitiveHyperedge (ISO requires
33
+ // "two or more").
34
+
35
+ import { AbstractHyperedge } from './abstract-hyperedge.js';
36
+ import { PartitiveMember } from './partitive-member.js';
37
+ import { HyperedgeRegistry } from './hyperedge-registry.js';
38
+
39
+ export class PartitiveHyperedge extends AbstractHyperedge {
40
+ // Per-class metadata block — the OCP contract. External systems
41
+ // (parser, serializer, diff, RDF emitter, validators) read from
42
+ // these statics and the HyperedgeRegistry. Adding a new hyperedge
43
+ // type = declaring these fields on a new subclass + registering
44
+ // it. Nothing else in the codebase changes.
45
+ static wireKey = 'partitive_relations';
46
+ static typeTag = 'partitive_relation';
47
+ static rdfType = 'gloss:PartitiveRelation';
48
+ static memberClass = PartitiveMember;
49
+ static v1WireKeys = ['partitive_hyperedges'];
50
+ static kindLabel = 'PART';
33
51
 
34
- export class PartitiveHyperedge extends GlossaristModel {
35
52
  constructor(data = {}) {
36
- super();
37
- this.comprehensive = ensureConceptRef(data.comprehensive, 'comprehensive');
38
- this.parts = ensureConceptRefArray(data.parts, 'parts');
39
- this.enumeration = resolveEnumeration(data.enumeration);
40
- this.markers = validateMarkers(data.markers ?? []);
41
- this.content = normalizeContent(data.content);
42
-
43
- assertNonEmptyParts(this.parts);
44
- assertNoSelfLoop(this.comprehensive, this.parts);
45
- }
46
-
47
- get isClosed() { return this.enumeration === PARTITIVE_ENUMERATION.CLOSED; }
48
- get isOpen() { return this.enumeration === PARTITIVE_ENUMERATION.OPEN; }
49
- get isMarked() { return this.markers.length > 0; }
50
-
51
- hasMarker(value) {
52
- return this.markers.includes(value);
53
+ const members = data?.members ?? data?.partitives;
54
+ super({ ...data, members });
55
+ this.members = members == null
56
+ ? this.members
57
+ : (Array.isArray(members)
58
+ ? members.map(m =>
59
+ m instanceof PartitiveMember ? m : new PartitiveMember(m))
60
+ : this.members);
53
61
  }
54
62
 
55
- hasEnumeration(value) {
56
- return this.enumeration === value;
57
- }
58
-
59
- get contentString() {
60
- if (!this.content) return null;
61
- const values = Object.values(this.content);
62
- return values.length > 0 ? values[0] : null;
63
- }
63
+ get partitives() { return this.members; }
64
64
 
65
65
  toJSON() {
66
- const obj = {
67
- comprehensive: this.comprehensive.toJSON(),
68
- parts: this.parts.map(p => p.toJSON()),
69
- enumeration: this.enumeration,
70
- };
71
- if (this.markers.length > 0) obj.markers = [...this.markers];
72
- if (this.content != null) obj.content = this.content;
73
- return obj;
66
+ const base = super.toJSON();
67
+ const { members, ...rest } = base;
68
+ return { ...rest, partitives: members };
74
69
  }
75
70
 
76
71
  static identityOf(value) {
77
72
  const v = value ?? {};
78
73
  const c = v.comprehensive ?? {};
79
- const parts = Array.isArray(v.parts) ? v.parts : [];
80
- const partsStr = parts
81
- .map(p => `${p?.source ?? ''}:${p?.id ?? ''}`)
82
- .sort();
83
- return `${c.source ?? ''}:${c.id ?? ''}|${partsStr.join('|')}`;
84
- }
85
-
86
- identity() {
87
- return PartitiveHyperedge.identityOf(this);
74
+ const partKeys = Array.isArray(v.partitives ?? v.members)
75
+ ? (v.partitives ?? v.members).map(p => {
76
+ const r = p?.ref ?? p ?? {};
77
+ return `${r.source ?? ''}:${r.id ?? ''}`;
78
+ }).sort()
79
+ : [];
80
+ return `${c.source ?? ''}:${c.id ?? ''}|${partKeys.join('|')}`;
88
81
  }
89
82
 
90
83
  static fromJSON(data) {
91
- return new PartitiveHyperedge(data);
92
- }
93
- }
94
-
95
- function resolveEnumeration(value) {
96
- if (value == null) return DEFAULT_ENUMERATION;
97
- if (!isValidPartitiveEnumeration(value)) {
98
- throw new Error(
99
- `invalid enumeration ${JSON.stringify(value)}; ` +
100
- `must be one of ${PARTITIVE_ENUMERATION_VALUES.join(', ')}`,
101
- );
84
+ const normalized = { ...(data ?? {}), members: data?.partitives ?? data?.members };
85
+ return new PartitiveHyperedge(normalized);
102
86
  }
103
- return value;
104
87
  }
105
88
 
106
- function validateMarkers(values) {
107
- const arr = Array.isArray(values) ? values : [values];
108
- const seen = new Set();
109
- for (const m of arr) {
110
- if (!isValidPluralityMarker(m)) {
111
- throw new Error(
112
- `invalid plurality marker ${JSON.stringify(m)}; ` +
113
- `must be one of ${PLURALITY_MARKER_VALUES.join(', ')}`,
114
- );
115
- }
116
- if (seen.has(m)) {
117
- throw new Error(
118
- `duplicate plurality marker ${JSON.stringify(m)} in partitive_hyperedge`,
119
- );
120
- }
121
- seen.add(m);
122
- }
123
- return arr;
124
- }
125
-
126
- function ensureConceptRef(value, fieldName) {
127
- const ref = value instanceof ConceptRef ? value : new ConceptRef(value ?? {});
128
- if (!ref.source && !ref.id) {
129
- throw new Error(
130
- `PartitiveHyperedge#${fieldName} must be a non-empty ConceptRef ` +
131
- `(source or id required)`,
132
- );
133
- }
134
- return ref;
135
- }
136
-
137
- function ensureConceptRefArray(values, fieldName) {
138
- if (!Array.isArray(values)) values = values == null ? [] : [values];
139
- return values.map((v, i) => ensureConceptRef(v, `${fieldName}[${i}]`));
140
- }
141
-
142
- function assertNonEmptyParts(parts) {
143
- if (!parts || parts.length === 0) {
144
- throw new Error('PartitiveHyperedge requires at least one part');
145
- }
146
- }
147
-
148
- function assertNoSelfLoop(comprehensive, parts) {
149
- const cKey = `${comprehensive.source ?? ''}:${comprehensive.id ?? ''}`;
150
- for (const p of parts) {
151
- const pKey = `${p.source ?? ''}:${p.id ?? ''}`;
152
- if (pKey === cKey) {
153
- throw new Error('PartitiveHyperedge#parts cannot include the comprehensive');
154
- }
155
- }
156
- }
89
+ export default PartitiveHyperedge;
157
90
 
158
- // Content is a localized string (language -> text). Plain strings on
159
- // input are normalized to `{ default: '...' }` so callers always see
160
- // the same shape. `null` is preserved.
161
- //
162
- // Cross-repo symmetry: glossarist-ruby's `PartitiveHyperedge#content=`
163
- // (lib/glossarist/v3/partitive_hyperedge.rb) applies the same
164
- // normalization since commit c917846 (was previously a plain string;
165
- // aligned with invariant I7 in TODO.hyperedges round 1, item 29).
166
- // Datasets now round-trip identically across repos.
167
- function normalizeContent(value) {
168
- if (value == null) return null;
169
- if (typeof value === 'string') return { default: value };
170
- if (typeof value === 'object') {
171
- const entries = Object.entries(value).filter(([, v]) => typeof v === 'string');
172
- return entries.length > 0 ? Object.fromEntries(entries) : null;
173
- }
174
- return null;
175
- }
91
+ HyperedgeRegistry.register(PartitiveHyperedge);
@@ -1,6 +1,6 @@
1
- // PartitiveMember — one member of a PartitiveRelation. Inherits the
2
- // ISO 704:2022 MECE shape from ConceptSystemMember; the `comprehensive`
3
- // of its parent PartitiveRelation denotes the whole concept.
1
+ // PartitiveMember — one member of a PartitiveHyperedge. Inherits the
2
+ // ISO 704:2022 MECE shape from HyperedgeMember; the `comprehensive`
3
+ // of its parent PartitiveHyperedge denotes the whole concept.
4
4
  //
5
5
  // Distinct leaf class for type safety and to leave room for
6
6
  // Partitive-specific extensions.
@@ -15,10 +15,10 @@
15
15
  // optional, the at-least-one constraint is vacuous and collapses to
16
16
  // (optional, multiple). Rejected at construction.
17
17
 
18
- import { ConceptSystemMember } from './concept-system-member.js';
18
+ import { HyperedgeMember } from './hyperedge-member.js';
19
19
 
20
- export class PartitiveMember extends ConceptSystemMember {
21
- // Inherits all attributes and validations from ConceptSystemMember.
20
+ export class PartitiveMember extends HyperedgeMember {
21
+ // Inherits all attributes and validations from HyperedgeMember.
22
22
  // Declared as a distinct class for type safety and to leave room
23
23
  // for future partitive-specific extensions.
24
24
  }
@@ -1,33 +1,38 @@
1
- // RelationLoader — runtime loader for per-file n-ary relation YAMLs.
1
+ // HyperedgeLoader — runtime loader for per-file hyperedge YAMLs.
2
2
  //
3
3
  // Walks a `relations/<comprehensive-id>/<slug>.yaml` directory and
4
- // dispatches each file to the appropriate class (PartitiveRelation
5
- // or GenericRelation) based on its `type` field.
4
+ // dispatches each file to the appropriate class (PartitiveHyperedge,
5
+ // GenericHyperedge, future leaves) based on its `type` field, via
6
+ // HyperedgeRegistry.
6
7
  //
7
8
  // The wire-shape contract is owned by the JSON Schema at
8
9
  // `schemas/v3/relation.yaml` (concept-model repo) — this loader
9
10
  // produces the corresponding JS model instances. See
10
11
  // docs/design/relations-as-files.md (concept-model).
11
12
  //
12
- // This is the runtime equivalent of concept-model's
13
- // CheckPartitiveRelationCoherence#each_relation /
14
- // CheckGenericRelationCoherence#each_relation validators.
15
- // Both readers and validators must agree on the type-dispatch
16
- // table (TYPE_TO_CLASS). Keep them in sync via the JSON Schema.
13
+ // Adding a new hyperedge type means: declare the leaf class with the
14
+ // metadata block, register it. The loader picks it up automatically —
15
+ // no edits here.
17
16
 
18
17
  import { readdirSync, readFileSync, existsSync } from 'node:fs';
19
18
  import * as yaml from 'js-yaml';
20
- import { PartitiveRelation } from './partitive-relation.js';
21
- import { GenericRelation } from './generic-relation.js';
19
+ import { HyperedgeRegistry } from './hyperedge-registry.js';
22
20
 
23
- /**
24
- * Per-type class dispatch. Keys are the `type` field of the relation
25
- * file. Adding a new relation type (e.g. AssociativeRelation) means
26
- * one entry here plus one new class.
27
- */
28
- export const TYPE_TO_CLASS = Object.freeze({
29
- partitive_relation: PartitiveRelation,
30
- generic_relation: GenericRelation,
21
+ // Backward-compat: snapshot of registered classes keyed by typeTag.
22
+ // Prefer HyperedgeRegistry.forTypeTag() for new code — this snapshot
23
+ // is frozen at module load and won't pick up runtime registrations
24
+ // from tests (the OCP test in Phase 11 registers a mock type after
25
+ // import).
26
+ export const TYPE_TO_CLASS = new Proxy({}, {
27
+ get(_target, prop) {
28
+ return HyperedgeRegistry.forTypeTag(prop);
29
+ },
30
+ ownKeys() {
31
+ return HyperedgeRegistry.allTypeTags();
32
+ },
33
+ getOwnPropertyDescriptor() {
34
+ return { enumerable: true, configurable: true };
35
+ },
31
36
  });
32
37
 
33
38
  /**
@@ -50,10 +55,9 @@ export class RelationLoadError extends Error {
50
55
  * @param {string} relationsDir — path to a `relations/` directory
51
56
  * containing `<comprehensive-id>/<slug>.yaml` files (per the
52
57
  * schema in schemas/v3/relation.yaml).
53
- * @returns {Map<string, Array<AbstractNaryRelation>>} — for each
58
+ * @returns {Map<string, Array<AbstractHyperedge>>} — for each
54
59
  * comprehensive id, the list of relations that have it as their
55
- * comprehensive. Type-discriminated (PartitiveRelation or
56
- * GenericRelation).
60
+ * comprehensive.
57
61
  */
58
62
  export function loadAll(relationsDir) {
59
63
  const result = new Map();
@@ -84,7 +88,7 @@ export function loadAll(relationsDir) {
84
88
  * `RelationLoader.loadAll(dir)`. The individual functions are also
85
89
  * exported directly for tree-shakeable single-call sites.
86
90
  */
87
- export const RelationLoader = Object.freeze({ loadAll });
91
+ export const RelationLoader = Object.freeze({ loadAll, loadOne });
88
92
 
89
93
  /**
90
94
  * Load a single relation file. Returns null if the file is missing,
@@ -101,7 +105,7 @@ export function loadOne(path) {
101
105
  }
102
106
  if (!doc || typeof doc !== 'object') return null;
103
107
  if (!doc.type) return null;
104
- const Cls = TYPE_TO_CLASS[doc.type];
108
+ const Cls = HyperedgeRegistry.forTypeTag(doc.type);
105
109
  if (!Cls) return null; // Unknown type — let the validator report it
106
110
  return Cls.fromJSON(doc);
107
111
  }
@@ -8,9 +8,29 @@ import { WELL_KNOWN } from './prefixes.js';
8
8
  import { localizedConceptToQuads } from './gloss-localized-concept.js';
9
9
  import { conceptSourceToQuads } from './gloss-source.js';
10
10
  import { partitiveRelationToQuads, partitiveRelationSubjectUri } from './gloss-partitive-relation.js';
11
- import { hyperedgeToQuads, hyperedgeSubjectUri } from './gloss-hyperedge.js';
11
+ import { genericHyperedgeToQuads, genericHyperedgeSubjectUri, GENERIC_HYPEREDGE_LINK_PREDICATE } from './gloss-generic-hyperedge.js';
12
+ import { PartitiveHyperedge } from '../models/partitive-hyperedge.js';
13
+ import { GenericHyperedge } from '../models/generic-hyperedge.js';
12
14
  import { namedNode, literal, quad } from './terms.js';
13
15
 
16
+ // Per-type RDF emitter dispatch. Keyed by class. Adding a new hyperedge
17
+ // type with its own emitter means: (1) write the emitter module,
18
+ // (2) add one entry here. Per TODO Phase 6e this should be a
19
+ // self-registering registry; for the two current types, the dispatch
20
+ // table is simpler and keeps the dependency graph acyclic.
21
+ const HYPEREDGE_EMITTERS = new Map([
22
+ [PartitiveHyperedge, {
23
+ linkPredicate: PRED.gloss.hasPartitiveRelation,
24
+ subjectUri: partitiveRelationSubjectUri,
25
+ toQuads: partitiveRelationToQuads,
26
+ }],
27
+ [GenericHyperedge, {
28
+ linkPredicate: GENERIC_HYPEREDGE_LINK_PREDICATE,
29
+ subjectUri: genericHyperedgeSubjectUri,
30
+ toQuads: genericHyperedgeToQuads,
31
+ }],
32
+ ]);
33
+
14
34
  // Kind tag → URI segment. Each NonVerbalReference subtype has a
15
35
  // predictable URL path so the same entity is reachable from any
16
36
  // concept that references it.
@@ -72,27 +92,23 @@ export function* conceptToQuads(concept, options) {
72
92
  srcIndex += 1;
73
93
  }
74
94
 
75
- // v2 canonical: concept.partitiveRelations. v1 alias
76
- // (concept.partitiveHyperedges) points at the same array — both
77
- // work here.
78
- const relations = concept.partitiveRelations ?? concept.partitiveHyperedges ?? [];
95
+ // Iterate the unified relations array. Dispatch via per-type
96
+ // emitter table.
79
97
  let relIdx = 0;
80
- for (const relation of relations) {
81
- // Skip v1 PartitiveHyperedge instances — they have their own
82
- // emitter (gloss-hyperedge.js) and would double-emit. The parser
83
- // always produces v2 PartitiveRelation instances; only direct
84
- // callers using the v1 model would see this branch.
85
- const isV2 = !relation.enumeration && !relation.markers && !relation.parts
86
- || (relation.completeness != null || relation.partitives != null);
87
- if (!isV2) {
88
- const heSubject = hyperedgeSubjectUri(subjectUri, relation, relIdx);
89
- yield quad(s, namedNode(PRED.gloss.hasPartitiveHyperedge), namedNode(heSubject));
90
- yield* hyperedgeToQuads(relation, { parentUri: subjectUri, index: relIdx });
91
- } else {
92
- const relSubject = partitiveRelationSubjectUri(subjectUri, relation, relIdx);
93
- yield quad(s, namedNode(PRED.gloss.hasPartitiveRelation), namedNode(relSubject));
94
- yield* partitiveRelationToQuads(relation, { parentUri: subjectUri, index: relIdx });
98
+ for (const relation of concept.relations ?? []) {
99
+ const emitter = HYPEREDGE_EMITTERS.get(relation.constructor);
100
+ if (!emitter) {
101
+ // Unknown hyperedge type — skip rather than crash. The OCP
102
+ // contract (Phase 11) tests that new types register an emitter;
103
+ // skipping here gives external consumers a soft-failure path
104
+ // when they pass around abstract hyperedges the JS layer doesn't
105
+ // recognize yet.
106
+ relIdx += 1;
107
+ continue;
95
108
  }
109
+ const relSubject = emitter.subjectUri(subjectUri, relation, relIdx);
110
+ yield quad(s, namedNode(emitter.linkPredicate), namedNode(relSubject));
111
+ yield* emitter.toQuads(relation, { parentUri: subjectUri, index: relIdx });
96
112
  relIdx += 1;
97
113
  }
98
114
  }