glossarist 0.4.30 → 0.4.32

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glossarist",
3
- "version": "0.4.30",
3
+ "version": "0.4.32",
4
4
  "description": "JavaScript SDK for Glossarist GCR packages — read, write, validate, and manage terminology concepts",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -5,13 +5,16 @@ import { migrateHyperedgeToRelation } from './migration/partitive-relation-migra
5
5
  import { InvalidInputError, YamlParseError } from './errors.js';
6
6
 
7
7
  // Structural keys are reserved at the concept level and excluded
8
- // from language localization discovery. Updated for v2:
9
- // `partitive_relations` is the v2 wire name; `partitive_hyperedges`
10
- // is accepted as v1 backward-compat input.
8
+ // from language localization discovery. The unified `relations`
9
+ // key, the v2 typed keys (`partitive_relations`, `generic_relations`),
10
+ // and the v1 backward-compat key (`partitive_hyperedges`) are all
11
+ // reserved.
11
12
  const STRUCTURAL_KEYS = new Set([
12
13
  'termid', 'term', 'figures', 'tables', 'formulas',
14
+ 'relations',
13
15
  'partitive_hyperedges', 'partitiveHyperedges',
14
16
  'partitive_relations', 'partitiveRelations',
17
+ 'generic_relations', 'genericRelations',
15
18
  ]);
16
19
 
17
20
  export class ConceptParser {
@@ -67,7 +70,7 @@ export class ConceptParser {
67
70
  figures: doc.figures,
68
71
  tables: doc.tables,
69
72
  formulas: doc.formulas,
70
- partitiveRelations: _resolvePartitiveData(doc),
73
+ relations: _resolveNaryData(doc),
71
74
  raw: doc,
72
75
  });
73
76
  }
@@ -84,14 +87,20 @@ export class ConceptParser {
84
87
  localizations[lang] = lcData;
85
88
  }
86
89
 
87
- assertConceptLevelOnly(mc, ['related', 'partitive_relations', 'partitive_hyperedges']);
90
+ assertConceptLevelOnly(mc, [
91
+ 'related',
92
+ 'relations',
93
+ 'partitive_relations',
94
+ 'partitive_hyperedges',
95
+ 'generic_relations',
96
+ ]);
88
97
 
89
98
  return new Concept({
90
99
  id: String(mc.data.identifier),
91
100
  term: null,
92
101
  localizations,
93
102
  related: _normalizeRelated(mc.related),
94
- partitiveRelations: _resolvePartitiveData(mc),
103
+ relations: _resolveNaryData(mc),
95
104
  domains: mc.data.domains,
96
105
  groups: mc.data.groups,
97
106
  dates: mc.dates ?? mc.data?.dates,
@@ -121,26 +130,63 @@ function assertConceptLevelOnly(mc, keys) {
121
130
  }
122
131
  }
123
132
 
124
- // Resolves partitive relation data from any of three input shapes:
125
- // v2 wire: { partitive_relations: [...] }
126
- // v2 camel: { partitiveRelations: [...] }
127
- // v1 wire: { partitive_hyperedges: [...] } ← migrated to v2
128
- // v1 camel: { partitiveHyperedges: [...] } ← migrated to v2
133
+ // Resolve n-ary relation data from any input shape into a single
134
+ // unified array. Accepts:
129
135
  //
130
- // Returns null when no partitive data is present, so Concept's
131
- // `_resolvePartitiveRelations` falls back to the empty-array default.
132
- function _resolvePartitiveData(container) {
133
- const v2 = container?.partitive_relations ?? container?.partitiveRelations;
134
- if (Array.isArray(v2)) return v2;
136
+ // unified wire: { relations: [...] } (hashes with `type`)
137
+ // v2 partitive: { partitive_relations: [...] } (hashes)
138
+ // v2 generic: { generic_relations: [...] } (hashes)
139
+ // v1 partitive: { partitive_hyperedges: [...] } ← migrated
140
+ //
141
+ // Returns null when no relation data is present, so Concept's
142
+ // `_resolveNaryRelations` falls back to the empty-array default.
143
+ function _resolveNaryData(container) {
144
+ if (!container) return null;
145
+
146
+ if (Array.isArray(container.relations)) {
147
+ return container.relations;
148
+ }
149
+
150
+ const out = [];
151
+
152
+ const v2Partitive = container.partitive_relations ?? container.partitiveRelations;
153
+ if (Array.isArray(v2Partitive)) {
154
+ for (const r of v2Partitive) {
155
+ const tagged = _addTypeIfMissing(r, 'partitive_relation');
156
+ if (tagged != null) out.push(tagged);
157
+ }
158
+ }
159
+
160
+ const v2Generic = container.generic_relations ?? container.genericRelations;
161
+ if (Array.isArray(v2Generic)) {
162
+ for (const r of v2Generic) {
163
+ const tagged = _addTypeIfMissing(r, 'generic_relation');
164
+ if (tagged != null) out.push(tagged);
165
+ }
166
+ }
135
167
 
136
- const v1 = container?.partitive_hyperedges ?? container?.partitiveHyperedges;
168
+ const v1 = container.partitive_hyperedges ?? container.partitiveHyperedges;
137
169
  if (Array.isArray(v1)) {
138
- return v1
139
- .map(h => (h?.toJSON && typeof h.toJSON === 'function') ? h.toJSON() : h)
140
- .map(migrateHyperedgeToRelation)
141
- .filter(h => h != null);
170
+ for (const h of v1) {
171
+ const hash = h?.toJSON && typeof h.toJSON === 'function' ? h.toJSON() : h;
172
+ const migrated = migrateHyperedgeToRelation(hash);
173
+ if (migrated) out.push({ ...migrated, type: 'partitive_relation' });
174
+ }
175
+ }
176
+
177
+ return out.length > 0 ? out : null;
178
+ }
179
+
180
+ function _addTypeIfMissing(value, type) {
181
+ if (value == null) return null;
182
+ if (typeof value.toJSON === 'function') {
183
+ const hash = value.toJSON();
184
+ return hash.type ? hash : { ...hash, type };
185
+ }
186
+ if (typeof value === 'object') {
187
+ return value.type ? value : { ...value, type };
142
188
  }
143
- return null;
189
+ return value;
144
190
  }
145
191
 
146
192
  function _normalizeRelated(arr) {
@@ -1,18 +1,30 @@
1
1
  import * as yaml from 'js-yaml';
2
+ import { PartitiveRelation } from './models/partitive-relation.js';
3
+ import { GenericRelation } from './models/generic-relation.js';
2
4
 
3
5
  const DUMP_OPTS = { lineWidth: -1, noRefs: true, sortKeys: false, skipInvalid: true };
4
6
 
7
+ // Partition the unified relations array by concrete type and emit
8
+ // each type under its own wire key. Concept.relations holds mixed
9
+ // types; the wire format stays split for backward compat with
10
+ // existing consumers and the JSON Schema in concept-model.
11
+ function _emitTypedRelations(concept) {
12
+ const partitiveRels = concept.relations.filter(r => r instanceof PartitiveRelation);
13
+ const genericRels = concept.relations.filter(r => r instanceof GenericRelation);
14
+ return {
15
+ partitive: partitiveRels.map(r => r.toJSON()),
16
+ generic: genericRels.map(r => r.toJSON()),
17
+ };
18
+ }
19
+
5
20
  export class ConceptSerializer {
6
21
  toCanonicalYaml(concept) {
7
22
  const doc = { termid: concept.id };
8
23
  if (concept.term) doc.term = concept.term;
9
24
 
10
- // v2: emit partitive_relations (was partitive_hyperedges in v1).
11
- // Concept.partitiveRelations is the canonical v2 property; the
12
- // v1 alias `partitiveHyperedges` points at the same array.
13
- if (concept.partitiveRelations && concept.partitiveRelations.length > 0) {
14
- doc.partitive_relations = concept.partitiveRelations.map(r => r.toJSON());
15
- }
25
+ const { partitive, generic } = _emitTypedRelations(concept);
26
+ if (partitive.length > 0) doc.partitive_relations = partitive;
27
+ if (generic.length > 0) doc.generic_relations = generic;
16
28
 
17
29
  for (const lang of concept.languages) {
18
30
  const lc = concept.localization(lang);
@@ -53,10 +65,9 @@ export class ConceptSerializer {
53
65
  if (concept.relatedConcepts.length > 0) {
54
66
  mainDoc.related = concept.relatedConcepts.map(rc => rc.toJSON());
55
67
  }
56
- // v2 wire name. Concept loads both v1 and v2 input but emits only v2.
57
- if (concept.partitiveRelations && concept.partitiveRelations.length > 0) {
58
- mainDoc.partitive_relations = concept.partitiveRelations.map(r => r.toJSON());
59
- }
68
+ const { partitive, generic } = _emitTypedRelations(concept);
69
+ if (partitive.length > 0) mainDoc.partitive_relations = partitive;
70
+ if (generic.length > 0) mainDoc.generic_relations = generic;
60
71
  if (concept.sources.length > 0) {
61
72
  mainDoc.sources = concept.sources.map(s => s.toJSON());
62
73
  }
@@ -1,4 +1,6 @@
1
1
  import { GlossaristModel } from '../models/base.js';
2
+ import { PartitiveRelation } from '../models/partitive-relation.js';
3
+ import { GenericRelation } from '../models/generic-relation.js';
2
4
  import { Added, Removed, Changed } from './change.js';
3
5
  import { ListDiff, diffList, diffSet } from './list-diff.js';
4
6
  import { identityOf } from './identity.js';
@@ -99,11 +101,17 @@ export class ConceptLevelDiff extends GlossaristModel {
99
101
  this._sources = wrapListDiff(data.sources);
100
102
  this._dates = wrapListDiff(data.dates);
101
103
  this._relatedConcepts = wrapListDiff(data.relatedConcepts ?? data.related_concepts);
102
- // v2 canonical name is partitiveRelations. Accept v1 names
103
- // (partitiveHyperedges / partitive_hyperedges) for backward compat
104
- // with diffs serialized by older glossarist-js versions.
105
- this._partitiveRelations = wrapListDiff(
106
- data.partitiveRelations
104
+ // Unified n-ary relations diff. Accepts:
105
+ // - data.relations (new unified shape)
106
+ // - data.partitiveRelations /
107
+ // data.partitive_relations /
108
+ // data.partitiveHyperedges /
109
+ // data.partitive_hyperedges (legacy partitive-only shape)
110
+ // Old diffs only had partitive, so the legacy fallback treats them
111
+ // as a unified list with one type present.
112
+ this._relations = wrapListDiff(
113
+ data.relations
114
+ ?? data.partitiveRelations
107
115
  ?? data.partitive_relations
108
116
  ?? data.partitiveHyperedges
109
117
  ?? data.partitive_hyperedges,
@@ -119,8 +127,25 @@ export class ConceptLevelDiff extends GlossaristModel {
119
127
  get sources() { return this._sources; }
120
128
  get dates() { return this._dates; }
121
129
  get relatedConcepts() { return this._relatedConcepts; }
122
- get partitiveRelations() { return this._partitiveRelations; }
123
- /** @deprecated use partitiveRelations */ get partitiveHyperedges() { return this._partitiveRelations; }
130
+ /** Unified n-ary relations diff (PartitiveRelation + GenericRelation). */
131
+ get relations() { return this._relations; }
132
+ /**
133
+ * Filtered view of `relations` containing only PartitiveRelation
134
+ * entries. Backward compat for callers that read .partitiveRelations
135
+ * directly on a ConceptLevelDiff.
136
+ */
137
+ get partitiveRelations() {
138
+ return filterListDiffByType(this._relations, PartitiveRelation);
139
+ }
140
+ /** @deprecated use .relations */ get partitiveHyperedges() { return this.partitiveRelations; }
141
+ /**
142
+ * Filtered view of `relations` containing only GenericRelation
143
+ * entries. Backward compat for callers that read .genericRelations
144
+ * directly on a ConceptLevelDiff.
145
+ */
146
+ get genericRelations() {
147
+ return filterListDiffByType(this._relations, GenericRelation);
148
+ }
124
149
  get groups() { return this._groups; }
125
150
  get sections() { return this._sections; }
126
151
  get tags() { return this._tags; }
@@ -130,7 +155,7 @@ export class ConceptLevelDiff extends GlossaristModel {
130
155
  return this._sources.hasChanges
131
156
  || this._dates.hasChanges
132
157
  || this._relatedConcepts.hasChanges
133
- || this._partitiveRelations.hasChanges
158
+ || this._relations.hasChanges
134
159
  || this._groups.hasChanges
135
160
  || this._sections.hasChanges
136
161
  || this._tags.hasChanges
@@ -141,7 +166,7 @@ export class ConceptLevelDiff extends GlossaristModel {
141
166
  yield* walkList('concept.sources', this._sources);
142
167
  yield* walkList('concept.dates', this._dates);
143
168
  yield* walkList('concept.relatedConcepts', this._relatedConcepts);
144
- yield* walkList('concept.partitiveRelations', this._partitiveRelations);
169
+ yield* walkList('concept.relations', this._relations);
145
170
  yield* walkList('concept.groups', this._groups);
146
171
  yield* walkList('concept.sections', this._sections);
147
172
  yield* walkList('concept.tags', this._tags);
@@ -153,7 +178,7 @@ export class ConceptLevelDiff extends GlossaristModel {
153
178
  sources: this._sources.toJSON(),
154
179
  dates: this._dates.toJSON(),
155
180
  related_concepts: this._relatedConcepts.toJSON(),
156
- partitive_relations: this._partitiveRelations.toJSON(),
181
+ relations: this._relations.toJSON(),
157
182
  groups: this._groups.toJSON(),
158
183
  sections: this._sections.toJSON(),
159
184
  tags: this._tags.toJSON(),
@@ -428,7 +453,7 @@ function diffConceptLevel(oldConcept, newConcept) {
428
453
  sources: fullListDiff(c.sources ?? [], Direction),
429
454
  dates: fullListDiff(c.dates ?? [], Direction),
430
455
  relatedConcepts: fullListDiff(c.relatedConcepts ?? [], Direction),
431
- partitiveRelations: fullListDiff(c.partitiveRelations ?? c.partitiveHyperedges ?? [], Direction),
456
+ relations: fullListDiff(c.relations ?? [], Direction),
432
457
  groups: fullListDiff(c.groups ?? [], Direction),
433
458
  sections: fullListDiff(c.sections ?? [], Direction),
434
459
  tags: fullListDiff(c.tags ?? [], Direction),
@@ -440,9 +465,9 @@ function diffConceptLevel(oldConcept, newConcept) {
440
465
  sources: diffSources(oldConcept.sources ?? [], newConcept.sources ?? []),
441
466
  dates: diffDates(oldConcept.dates ?? [], newConcept.dates ?? []),
442
467
  relatedConcepts: diffRelatedConcepts(oldConcept.relatedConcepts ?? [], newConcept.relatedConcepts ?? []),
443
- partitiveRelations: diffPartitiveRelations(
444
- oldConcept.partitiveRelations ?? oldConcept.partitiveHyperedges ?? [],
445
- newConcept.partitiveRelations ?? newConcept.partitiveHyperedges ?? [],
468
+ relations: diffNaryRelations(
469
+ oldConcept.relations ?? [],
470
+ newConcept.relations ?? [],
446
471
  ),
447
472
  groups: diffStringSet(oldConcept.groups ?? [], newConcept.groups ?? []),
448
473
  sections: diffStringSet(oldConcept.sections ?? [], newConcept.sections ?? []),
@@ -540,13 +565,40 @@ function diffRelatedConcepts(oldRC, newRC) {
540
565
  });
541
566
  }
542
567
 
543
- function diffPartitiveRelations(oldR, newR) {
568
+ // Diff two unified n-ary relations arrays. Identity is polymorphic —
569
+ // dispatches via value.constructor.identityOf so PartitiveRelation
570
+ // and GenericRelation (and any future n-ary subclass) coexist in
571
+ // the same list. Cross-type entries with matching identity are still
572
+ // treated as different (because their constructor differs and they
573
+ // live under different wire keys).
574
+ function diffNaryRelations(oldR, newR) {
544
575
  return diffSet(oldR, newR, {
545
- identityKey: identityOf,
576
+ identityKey: relationIdentityOf,
546
577
  textKey: relationText,
547
578
  });
548
579
  }
549
580
 
581
+ function relationIdentityOf(value) {
582
+ if (value == null) return '';
583
+ const Cls = value.constructor;
584
+ if (typeof Cls?.identityOf === 'function') {
585
+ const typed = Cls.identityOf(value);
586
+ return `${Cls.name}::${typed}`;
587
+ }
588
+ return identityOf(value);
589
+ }
590
+
591
+ function filterListDiffByType(listDiff, Cls) {
592
+ if (!listDiff) return listDiff;
593
+ return new ListDiff({
594
+ added: listDiff.added.filter(e => e.value instanceof Cls),
595
+ removed: listDiff.removed.filter(e => e.value instanceof Cls),
596
+ changed: listDiff.changed.filter(e =>
597
+ (e.oldValue == null || e.oldValue instanceof Cls) &&
598
+ (e.newValue == null || e.newValue instanceof Cls)),
599
+ });
600
+ }
601
+
550
602
  function relationText(r) {
551
603
  if (!r) return '';
552
604
  return canonicalJson(typeof r.toJSON === 'function' ? r.toJSON() : r);
@@ -598,8 +650,8 @@ function countConceptItems(oldConcept, newConcept, langs) {
598
650
  count += maxOf(oldConcept?.dates?.length, newConcept?.dates?.length);
599
651
  count += maxOf(oldConcept?.relatedConcepts?.length, newConcept?.relatedConcepts?.length);
600
652
  count += maxOf(
601
- oldConcept?.partitiveRelations?.length ?? oldConcept?.partitiveHyperedges?.length,
602
- newConcept?.partitiveRelations?.length ?? newConcept?.partitiveHyperedges?.length,
653
+ oldConcept?.relations?.length,
654
+ newConcept?.relations?.length,
603
655
  );
604
656
  count += maxOf(oldConcept?.groups?.length, newConcept?.groups?.length);
605
657
  count += maxOf(oldConcept?.sections?.length, newConcept?.sections?.length);
@@ -5,6 +5,7 @@ import { RelatedConcept } from '../models/related-concept.js';
5
5
  import { Designation } from '../models/designation.js';
6
6
  import { DetailedDefinition } from '../models/detailed-definition.js';
7
7
  import { PartitiveRelation } from '../models/partitive-relation.js';
8
+ import { GenericRelation } from '../models/generic-relation.js';
8
9
  import { Added, Removed, Changed } from './change.js';
9
10
  import { ListDiff } from './list-diff.js';
10
11
  import { TextDiff, TextHunk } from './text-diff.js';
@@ -67,9 +68,7 @@ export function reverseDiff(diff) {
67
68
  sources: reverseListDiff(diff.concept.sources),
68
69
  dates: reverseListDiff(diff.concept.dates),
69
70
  relatedConcepts: reverseListDiff(diff.concept.relatedConcepts),
70
- partitiveRelations: reverseListDiff(
71
- diff.concept.partitiveRelations ?? diff.concept.partitiveHyperedges,
72
- ),
71
+ relations: reverseListDiff(diff.concept.relations),
73
72
  groups: reverseListDiff(diff.concept.groups),
74
73
  sections: reverseListDiff(diff.concept.sections),
75
74
  tags: reverseListDiff(diff.concept.tags),
@@ -108,15 +107,32 @@ function applyConceptLevelPatch(json, conceptDiff) {
108
107
  json.sources = applyListPatch(json.sources ?? [], conceptDiff.sources, ConceptSource.identityOf);
109
108
  json.dates = applyListPatch(json.dates ?? [], conceptDiff.dates, ConceptDate.identityOf);
110
109
  json.related = applyListPatch(json.related ?? [], conceptDiff.relatedConcepts, RelatedConcept.identityOf);
111
- json.partitive_relations = applyListPatch(
112
- json.partitive_relations ?? json.partitive_hyperedges ?? [],
113
- conceptDiff.partitiveRelations ?? conceptDiff.partitiveHyperedges,
114
- PartitiveRelation.identityOf,
115
- );
116
- // v1 wire key is removed once v2 has replaced it; otherwise leave
117
- // v1 data in place for backward-compat readers.
118
- if (json.partitive_relations != null && json.partitive_hyperedges != null) {
119
- delete json.partitive_hyperedges;
110
+
111
+ // Unified relations diff → typed wire keys. Partition the unified
112
+ // diff by concrete class and apply each half to its own wire key.
113
+ // The wire format stays split (partitive_relations + generic_relations)
114
+ // for backward compat with consumers; the in-memory model is unified.
115
+ const partitiveDiff = partitionListDiff(conceptDiff.relations, PartitiveRelation);
116
+ const genericDiff = partitionListDiff(conceptDiff.relations, GenericRelation);
117
+
118
+ if (partitiveDiff.hasChanges) {
119
+ json.partitive_relations = applyListPatch(
120
+ json.partitive_relations ?? json.partitive_hyperedges ?? [],
121
+ partitiveDiff,
122
+ PartitiveRelation.identityOf,
123
+ );
124
+ // v1 wire key is removed once v2 has replaced it; otherwise leave
125
+ // v1 data in place for backward-compat readers.
126
+ if (json.partitive_relations != null && json.partitive_hyperedges != null) {
127
+ delete json.partitive_hyperedges;
128
+ }
129
+ }
130
+ if (genericDiff.hasChanges) {
131
+ json.generic_relations = applyListPatch(
132
+ json.generic_relations ?? [],
133
+ genericDiff,
134
+ GenericRelation.identityOf,
135
+ );
120
136
  }
121
137
 
122
138
  json.tags = applyListPatch(json.tags ?? [], conceptDiff.tags);
@@ -233,3 +249,19 @@ function toJsonValue(value) {
233
249
  if (typeof value.toJSON === 'function') return value.toJSON();
234
250
  return value;
235
251
  }
252
+
253
+ // Project a unified relations ListDiff onto a single concrete class.
254
+ // Entries whose value (old or new) is an instance of `Cls` survive;
255
+ // everything else is filtered out. Cross-type changed entries (e.g.
256
+ // old was PartitiveRelation, new is GenericRelation) are not yet
257
+ // supported — those would need to surface as a remove + add pair.
258
+ function partitionListDiff(listDiff, Cls) {
259
+ if (!listDiff) return new ListDiff({ added: [], removed: [], changed: [] });
260
+ return new ListDiff({
261
+ added: listDiff.added.filter(e => e.value instanceof Cls),
262
+ removed: listDiff.removed.filter(e => e.value instanceof Cls),
263
+ changed: listDiff.changed.filter(e =>
264
+ (e.oldValue == null || e.oldValue instanceof Cls) &&
265
+ (e.newValue == null || e.newValue instanceof Cls)),
266
+ });
267
+ }
@@ -125,7 +125,10 @@ function renderConceptLevel(diff, colors) {
125
125
  lines.push(...renderListDiff('Sources', diff.sources, colors, itemLabel));
126
126
  lines.push(...renderListDiff('Dates', diff.dates, colors, itemLabel));
127
127
  lines.push(...renderListDiff('Related', diff.relatedConcepts, colors, itemLabel));
128
- lines.push(...renderListDiff('Partitive relations', diff.partitiveRelations ?? diff.partitiveHyperedges, colors, relationLabel));
128
+ // Unified n-ary relations section. Replaces the per-type sections
129
+ // (Partitive relations, Generic relations) — the underlying diff
130
+ // is a single ListDiff over the unified .relations array.
131
+ lines.push(...renderListDiff('N-ary relations', diff.relations, colors, relationLabel));
129
132
  lines.push(...renderListDiff('Groups', diff.groups, colors, itemLabel));
130
133
  lines.push(...renderListDiff('Sections', diff.sections, colors, itemLabel));
131
134
  lines.push(...renderListDiff('Tags', diff.tags, colors, itemLabel));
@@ -203,8 +206,11 @@ function relationLabel(r) {
203
206
  if (!r) return '?';
204
207
  const c = r.comprehensive;
205
208
  const head = c?.id ?? c?.source ?? c?.text ?? '?';
206
- const partitives = Array.isArray(r.partitives)
207
- ? r.partitives.map(m => {
209
+ // PartitiveRelation exposes .partitives as the wire alias for
210
+ // .members; GenericRelation uses .members directly. Read both.
211
+ const members = Array.isArray(r.partitives) ? r.partitives : r.members;
212
+ const memberText = Array.isArray(members)
213
+ ? members.map(m => {
208
214
  const ref = m?.ref ?? m ?? {};
209
215
  let tail = '';
210
216
  const mult = resolveMultiplicity(m);
@@ -217,7 +223,8 @@ function relationLabel(r) {
217
223
  const criterion = r.criterion
218
224
  ? ` / ${Object.values(r.criterion)[0] ?? ''}`
219
225
  : '';
220
- return `${head} → {${partitives}}${completeness}${criterion}`;
226
+ const typeTag = r?.constructor?.name === 'GenericRelation' ? 'GEN ' : '';
227
+ return `${typeTag}${head} → {${memberText}}${completeness}${criterion}`;
221
228
  }
222
229
 
223
230
  // Aggregate multiplicity + delimiting stats across a concept's
@@ -3,6 +3,9 @@ import { LocalizedConcept } from './localized-concept.js';
3
3
  import { RelatedConcept } from './related-concept.js';
4
4
  import { PartitiveHyperedge } from './partitive-hyperedge.js';
5
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';
6
9
  import { migrateHyperedgeToRelation } from '../migration/partitive-relation-migrator.js';
7
10
  import { ConceptReference } from './concept-reference.js';
8
11
  import { ConceptDate } from './concept-date.js';
@@ -34,12 +37,18 @@ export class Concept extends GlossaristModel {
34
37
  this._cache = {};
35
38
 
36
39
  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;
40
+
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.
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);
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,17 @@ export class Concept extends GlossaristModel {
63
72
 
64
73
  get termid() { return this.id; }
65
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; }
85
+
66
86
  get languages() {
67
87
  return Object.keys(this._rawLocalizations);
68
88
  }
@@ -202,8 +222,16 @@ export class Concept extends GlossaristModel {
202
222
  if (this.relatedConcepts.length > 0) {
203
223
  obj.related = this.relatedConcepts.map(rc => rc.toJSON());
204
224
  }
205
- if (this.partitiveRelations.length > 0) {
206
- obj.partitive_relations = this.partitiveRelations.map(r => r.toJSON());
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());
207
235
  }
208
236
  if (this.domains.length > 0) {
209
237
  obj.domains = this.domains.map(d => d.toJSON());
@@ -234,31 +262,72 @@ function _mapInstances(arr, Cls) {
234
262
  return arr.map(item => item instanceof Cls ? item : new Cls(item));
235
263
  }
236
264
 
237
- // Resolve partitive relations from any of three input shapes:
238
- // 1. data.partitiveRelations (v2, preferred)
239
- // 2. data.partitive_relations (v2 wire name)
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)
240
271
  // 3. data.partitiveHyperedges / data.partitive_hyperedges (v1, migrated)
241
272
  //
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));
273
+ // Hash dispatch uses TYPE_TO_CLASS from relation-type-registry.js —
274
+ // the single source of truth shared with the per-file loader.
275
+ //
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) {
280
+ if (Array.isArray(data.relations)) {
281
+ return data.relations
282
+ .map(r => _coerceRelation(r))
283
+ .filter(r => r != null);
284
+ }
285
+
286
+ const out = [];
287
+
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);
292
+ if (coerced) out.push(coerced);
293
+ }
249
294
  }
250
- if (Array.isArray(data.partitive_relations)) {
251
- return data.partitive_relations.map(r => new PartitiveRelation(r));
295
+
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);
301
+ }
252
302
  }
303
+
253
304
  const v1 = data.partitiveHyperedges ?? data.partitive_hyperedges;
254
305
  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));
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));
310
+ }
260
311
  }
261
- return [];
312
+
313
+ return out;
314
+ }
315
+
316
+ function _coerceRelation(value, fallbackClass) {
317
+ if (value == null) return null;
318
+ if (value instanceof AbstractNaryRelation) return value;
319
+
320
+ 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);
324
+ }
325
+
326
+ function _classForHash(hash) {
327
+ if (!hash || typeof hash !== 'object') return null;
328
+ const type = hash.type;
329
+ if (typeof type !== 'string') return null;
330
+ return TYPE_TO_CLASS[type] ?? null;
262
331
  }
263
332
 
264
333
  const CONCEPT_WIRE_NAMES = Object.freeze({
@@ -17,18 +17,16 @@
17
17
 
18
18
  import { readdirSync, readFileSync, existsSync } from 'node:fs';
19
19
  import * as yaml from 'js-yaml';
20
- import { PartitiveRelation } from './partitive-relation.js';
21
- import { GenericRelation } from './generic-relation.js';
20
+ import { TYPE_TO_CLASS } from './relation-type-registry.js';
22
21
 
23
22
  /**
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.
23
+ * Per-type class dispatch. Re-exported from the registry so the
24
+ * loader's old `TYPE_TO_CLASS` import path keeps working. The
25
+ * registry is the single source of truth — adding a relation type
26
+ * there makes it available to both the loader and Concept's
27
+ * unified relations array.
27
28
  */
28
- export const TYPE_TO_CLASS = Object.freeze({
29
- partitive_relation: PartitiveRelation,
30
- generic_relation: GenericRelation,
31
- });
29
+ export { TYPE_TO_CLASS };
32
30
 
33
31
  /**
34
32
  * Thrown by `loadAll` when a relation file has an unknown `type`,
@@ -0,0 +1,25 @@
1
+ // N-ary relation type registry. Single source of truth for the
2
+ // `type` → class dispatch used by the parser, the per-file loader,
3
+ // and Concept's unified relations resolver.
4
+ //
5
+ // Adding a new n-ary relation type (Sequential, Associative, …)
6
+ // = one entry here plus one new class. Nothing else changes:
7
+ // not Concept, not parser, not serializer, not diff, not patch,
8
+ // not renderer.
9
+ //
10
+ // Lives in its own module (not relation-loader.js) because the loader
11
+ // pulls in node:fs, which is off-limits on the browser-gcr path that
12
+ // transitively imports this registry via Concept.
13
+
14
+ import { PartitiveRelation } from './partitive-relation.js';
15
+ import { GenericRelation } from './generic-relation.js';
16
+
17
+ export const TYPE_TO_CLASS = Object.freeze({
18
+ partitive_relation: PartitiveRelation,
19
+ generic_relation: GenericRelation,
20
+ });
21
+
22
+ export function classForRelationType(type) {
23
+ if (typeof type !== 'string') return null;
24
+ return TYPE_TO_CLASS[type] ?? null;
25
+ }