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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glossarist",
3
- "version": "0.4.31",
3
+ "version": "0.4.33",
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",
@@ -1,19 +1,41 @@
1
1
  import * as yaml from 'js-yaml';
2
2
  import { Concept } from './models/concept.js';
3
3
  import { RelatedConcept } from './models/related-concept.js';
4
+ import { HyperedgeRegistry } from './models/hyperedge-registry.js';
4
5
  import { migrateHyperedgeToRelation } from './migration/partitive-relation-migrator.js';
5
6
  import { InvalidInputError, YamlParseError } from './errors.js';
6
7
 
7
8
  // 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.
9
+ // from language localization discovery. The set is derived from
10
+ // HyperedgeRegistry — every registered hyperedge class contributes
11
+ // its wireKey and any v1WireKeys to the reserved set. Adding a new
12
+ // hyperedge type means the parser reserves its wire key automatically;
13
+ // no edits here.
11
14
  const STRUCTURAL_KEYS = new Set([
12
15
  'termid', 'term', 'figures', 'tables', 'formulas',
13
- 'partitive_hyperedges', 'partitiveHyperedges',
14
- 'partitive_relations', 'partitiveRelations',
16
+ 'related', 'relatedConcepts',
17
+ 'relations',
18
+ ..._registryStructuralKeys(),
15
19
  ]);
16
20
 
21
+ function _registryStructuralKeys() {
22
+ const out = [];
23
+ for (const cls of HyperedgeRegistry.allClasses()) {
24
+ out.push(cls.wireKey);
25
+ out.push(_camelCase(cls.wireKey));
26
+ for (const k of cls.v1WireKeys ?? []) {
27
+ out.push(k);
28
+ out.push(_camelCase(k));
29
+ }
30
+ }
31
+ return out;
32
+ }
33
+
34
+ function _camelCase(s) {
35
+ if (typeof s !== 'string' || !s.includes('_')) return s;
36
+ return s.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
37
+ }
38
+
17
39
  export class ConceptParser {
18
40
  parse(raw, context) {
19
41
  const label = context ?? 'concept';
@@ -67,7 +89,7 @@ export class ConceptParser {
67
89
  figures: doc.figures,
68
90
  tables: doc.tables,
69
91
  formulas: doc.formulas,
70
- partitiveRelations: _resolvePartitiveData(doc),
92
+ relations: _resolveHyperedgeData(doc),
71
93
  raw: doc,
72
94
  });
73
95
  }
@@ -84,14 +106,23 @@ export class ConceptParser {
84
106
  localizations[lang] = lcData;
85
107
  }
86
108
 
87
- assertConceptLevelOnly(mc, ['related', 'partitive_relations', 'partitive_hyperedges']);
109
+ // The hyperedge wire keys are reserved at concept level (top-level
110
+ // of the managed concept YAML), not under data:. Derive the list
111
+ // from the registry so adding a type doesn't require an edit here.
112
+ const conceptLevelOnlyKeys = [
113
+ 'related',
114
+ 'relations',
115
+ ...HyperedgeRegistry.allWireAndLegacyKeys(),
116
+ ];
117
+
118
+ assertConceptLevelOnly(mc, conceptLevelOnlyKeys);
88
119
 
89
120
  return new Concept({
90
121
  id: String(mc.data.identifier),
91
122
  term: null,
92
123
  localizations,
93
124
  related: _normalizeRelated(mc.related),
94
- partitiveRelations: _resolvePartitiveData(mc),
125
+ relations: _resolveHyperedgeData(mc),
95
126
  domains: mc.data.domains,
96
127
  groups: mc.data.groups,
97
128
  dates: mc.dates ?? mc.data?.dates,
@@ -109,7 +140,7 @@ export class ConceptParser {
109
140
  function assertConceptLevelOnly(mc, keys) {
110
141
  const conceptId = mc?.data?.identifier ?? '<unknown>';
111
142
  for (const key of keys) {
112
- const camelKey = key.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
143
+ const camelKey = _camelCase(key);
113
144
  if (mc?.data?.[key] != null && mc[key] == null && mc[camelKey] == null) {
114
145
  throw new InvalidInputError(
115
146
  `'${key}' must live at concept level (top-level of the managed ` +
@@ -121,26 +152,63 @@ function assertConceptLevelOnly(mc, keys) {
121
152
  }
122
153
  }
123
154
 
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
155
+ // Resolve hyperedge data from any input shape into a single unified
156
+ // array. Iterates HyperedgeRegistry — for each registered class:
157
+ // - read its wireKey from the container (e.g. partitive_relations)
158
+ // - read any v1WireKeys (e.g. partitive_hyperedges) and migrate
159
+ // - tag each entry with the class's typeTag
160
+ //
161
+ // Returns null when no relation data is present, so Concept's
162
+ // `_resolveHyperedges` falls back to the empty-array default.
129
163
  //
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;
135
-
136
- const v1 = container?.partitive_hyperedges ?? container?.partitiveHyperedges;
137
- 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);
164
+ // Adding a new hyperedge type means: declare it on a class, register.
165
+ // The parser picks it up automatically — no edits here.
166
+ function _resolveHyperedgeData(container) {
167
+ if (!container) return null;
168
+
169
+ if (Array.isArray(container.relations)) {
170
+ return container.relations;
171
+ }
172
+
173
+ const out = [];
174
+
175
+ for (const cls of HyperedgeRegistry.allClasses()) {
176
+ // v2 wire key (e.g. partitive_relations)
177
+ const arr = container[cls.wireKey] ?? container[_camelCase(cls.wireKey)];
178
+ if (Array.isArray(arr)) {
179
+ for (const r of arr) {
180
+ const tagged = _addTypeIfMissing(r, cls.typeTag);
181
+ if (tagged != null) out.push(tagged);
182
+ }
183
+ }
184
+
185
+ // v1 legacy wire keys (e.g. partitive_hyperedges) — migrated.
186
+ // The migration helper produces a v2 hash; we tag it with the
187
+ // typeTag so Concept's registry dispatch finds the right class.
188
+ for (const v1Key of cls.v1WireKeys ?? []) {
189
+ const v1Arr = container[v1Key] ?? container[_camelCase(v1Key)];
190
+ if (!Array.isArray(v1Arr)) continue;
191
+ for (const h of v1Arr) {
192
+ const hash = h?.toJSON && typeof h.toJSON === 'function' ? h.toJSON() : h;
193
+ const migrated = migrateHyperedgeToRelation(hash);
194
+ if (migrated) out.push({ ...migrated, type: cls.typeTag });
195
+ }
196
+ }
197
+ }
198
+
199
+ return out.length > 0 ? out : null;
200
+ }
201
+
202
+ function _addTypeIfMissing(value, type) {
203
+ if (value == null) return null;
204
+ if (typeof value.toJSON === 'function') {
205
+ const hash = value.toJSON();
206
+ return hash.type ? hash : { ...hash, type };
207
+ }
208
+ if (typeof value === 'object') {
209
+ return value.type ? value : { ...value, type };
142
210
  }
143
- return null;
211
+ return value;
144
212
  }
145
213
 
146
214
  function _normalizeRelated(arr) {
@@ -2,6 +2,7 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import * as yaml from 'js-yaml';
4
4
  import { conceptParser } from './concept-parser.js';
5
+ import { RelationLoader } from './models/relation-loader.js';
5
6
  import { naturalSort } from './sort.js';
6
7
  import { InvalidInputError } from './errors.js';
7
8
  import { Register } from './models/register.js';
@@ -13,7 +14,15 @@ function assertDir(dir, fnName) {
13
14
  }
14
15
 
15
16
  /**
16
- * Read all v2 glossarist concept YAML files from a directory.
17
+ * Read all v2 glossarist concept YAML files from a directory, then
18
+ * merge in any per-file hyperedge YAMLs from `<dir>/relations/`.
19
+ *
20
+ * Per-file hyperedge storage (concept-model PR #83): each file at
21
+ * `relations/<comprehensive-id>/<slug>.yaml` is loaded and merged
22
+ * into the corresponding Concept.relations array. If the directory
23
+ * does not exist, no per-file hyperedges are loaded (concepts still
24
+ * get any inline hyperedges from their own YAML).
25
+ *
17
26
  * @param {string} dir - path to directory containing concept YAML files
18
27
  * @returns {import('./models/concept.js').Concept[]}
19
28
  * @throws {InvalidInputError} if dir is missing or empty
@@ -36,11 +45,16 @@ export function readConcepts(dir) {
36
45
  concepts.push(concept);
37
46
  }
38
47
  }
48
+
49
+ _mergePerFileHyperedges(dir, concepts);
39
50
  return concepts;
40
51
  }
41
52
 
42
53
  /**
43
- * Read a single concept file by ID from a directory.
54
+ * Read a single concept file by ID from a directory, then merge in
55
+ * any per-file hyperedge YAMLs for that concept from
56
+ * `<dir>/relations/<id>/`.
57
+ *
44
58
  * @param {string} dir - path to directory containing concept YAML files
45
59
  * @param {string} id - concept identifier (filename without .yaml)
46
60
  * @returns {import('./models/concept.js').Concept | null}
@@ -58,7 +72,49 @@ export function readConcept(dir, id) {
58
72
  const filePath = path.join(dir, `${id}.yaml`);
59
73
  if (!fs.existsSync(filePath)) return null;
60
74
  const raw = fs.readFileSync(filePath, 'utf8');
61
- return conceptParser.parse(raw, `${id}.yaml`);
75
+ const concept = conceptParser.parse(raw, `${id}.yaml`);
76
+ _mergePerFileHyperedges(dir, [concept]);
77
+ return concept;
78
+ }
79
+
80
+ // Load `relations/` directory and merge per-file hyperedges into the
81
+ // matching Concept.relations arrays. Concepts are matched by id.
82
+ // Inline hyperedges from the concept YAML are preserved alongside the
83
+ // per-file ones (audit: this is the contract — the two sources are
84
+ // additive, not exclusive).
85
+ //
86
+ // Throws if a per-file hyperedge fails to construct (bad YAML, missing
87
+ // type, invalid members) — surfaces corruption loudly per the
88
+ // RelationLoader contract.
89
+ function _mergePerFileHyperedges(dir, concepts) {
90
+ const relationsDir = path.join(dir, 'relations');
91
+ if (!fs.existsSync(relationsDir)) return;
92
+
93
+ const byComprehensive = RelationLoader.loadAll(relationsDir);
94
+ if (byComprehensive.size === 0) return;
95
+
96
+ for (const concept of concepts) {
97
+ const rels = byComprehensive.get(concept.id);
98
+ if (!rels || rels.length === 0) continue;
99
+ // Merge per-file hyperedges with any inline ones from the concept
100
+ // YAML. Per-file wins on identity collision — they're the newer,
101
+ // more authoritative source.
102
+ const inline = concept.relations;
103
+ const merged = [...inline];
104
+ for (const r of rels) {
105
+ const exists = inline.some(x => _sameHyperedge(x, r));
106
+ if (!exists) merged.push(r);
107
+ }
108
+ concept.relations = merged;
109
+ }
110
+ }
111
+
112
+ function _sameHyperedge(a, b) {
113
+ const aCls = a?.constructor;
114
+ const bCls = b?.constructor;
115
+ if (aCls !== bCls) return false;
116
+ if (typeof aCls.identityOf !== 'function') return false;
117
+ return aCls.identityOf(a) === bCls.identityOf(b);
62
118
  }
63
119
 
64
120
  /**
@@ -1,18 +1,30 @@
1
1
  import * as yaml from 'js-yaml';
2
+ import { PartitiveHyperedge } from './models/partitive-hyperedge.js';
3
+ import { GenericHyperedge } from './models/generic-hyperedge.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 PartitiveHyperedge);
13
+ const genericRels = concept.relations.filter(r => r instanceof GenericHyperedge);
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 { PartitiveHyperedge } from '../models/partitive-hyperedge.js';
3
+ import { GenericHyperedge } from '../models/generic-hyperedge.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 (PartitiveHyperedge + GenericHyperedge). */
131
+ get relations() { return this._relations; }
132
+ /**
133
+ * Filtered view of `relations` containing only PartitiveHyperedge
134
+ * entries. Backward compat for callers that read .partitiveRelations
135
+ * directly on a ConceptLevelDiff.
136
+ */
137
+ get partitiveRelations() {
138
+ return filterListDiffByType(this._relations, PartitiveHyperedge);
139
+ }
140
+ /** @deprecated use .relations */ get partitiveHyperedges() { return this.partitiveRelations; }
141
+ /**
142
+ * Filtered view of `relations` containing only GenericHyperedge
143
+ * entries. Backward compat for callers that read .genericRelations
144
+ * directly on a ConceptLevelDiff.
145
+ */
146
+ get genericRelations() {
147
+ return filterListDiffByType(this._relations, GenericHyperedge);
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 PartitiveHyperedge
570
+ // and GenericHyperedge (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);
@@ -4,7 +4,7 @@ import { ConceptDate } from '../models/concept-date.js';
4
4
  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
- import { PartitiveRelation } from '../models/partitive-relation.js';
7
+ import { HyperedgeRegistry } from '../models/hyperedge-registry.js';
8
8
  import { Added, Removed, Changed } from './change.js';
9
9
  import { ListDiff } from './list-diff.js';
10
10
  import { TextDiff, TextHunk } from './text-diff.js';
@@ -67,9 +67,7 @@ export function reverseDiff(diff) {
67
67
  sources: reverseListDiff(diff.concept.sources),
68
68
  dates: reverseListDiff(diff.concept.dates),
69
69
  relatedConcepts: reverseListDiff(diff.concept.relatedConcepts),
70
- partitiveRelations: reverseListDiff(
71
- diff.concept.partitiveRelations ?? diff.concept.partitiveHyperedges,
72
- ),
70
+ relations: reverseListDiff(diff.concept.relations),
73
71
  groups: reverseListDiff(diff.concept.groups),
74
72
  sections: reverseListDiff(diff.concept.sections),
75
73
  tags: reverseListDiff(diff.concept.tags),
@@ -108,15 +106,25 @@ function applyConceptLevelPatch(json, conceptDiff) {
108
106
  json.sources = applyListPatch(json.sources ?? [], conceptDiff.sources, ConceptSource.identityOf);
109
107
  json.dates = applyListPatch(json.dates ?? [], conceptDiff.dates, ConceptDate.identityOf);
110
108
  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;
109
+
110
+ // Unified hyperedge diff → typed wire keys. Iterate the registry;
111
+ // for each registered class, partition the unified diff by that
112
+ // class and apply to the class's wireKey. Adding a new hyperedge
113
+ // type means this loop picks it up automatically — no edits here.
114
+ for (const cls of HyperedgeRegistry.allClasses()) {
115
+ const partition = partitionListDiff(conceptDiff.relations, cls);
116
+ if (!partition.hasChanges) continue;
117
+ const wireKey = cls.wireKey;
118
+ // v1 wire keys (partitive_hyperedges) on the input are migrated
119
+ // to v2 wire key here: read from either, write to v2 only.
120
+ const v1ReadKeys = cls.v1WireKeys ?? [];
121
+ const existing = v1ReadKeys
122
+ .map(k => json[k])
123
+ .find(arr => Array.isArray(arr)) ?? json[wireKey] ?? [];
124
+ json[wireKey] = applyListPatch(existing, partition, cls.identityOf);
125
+ for (const k of v1ReadKeys) {
126
+ if (json[k] != null && json[wireKey] != null) delete json[k];
127
+ }
120
128
  }
121
129
 
122
130
  json.tags = applyListPatch(json.tags ?? [], conceptDiff.tags);
@@ -233,3 +241,19 @@ function toJsonValue(value) {
233
241
  if (typeof value.toJSON === 'function') return value.toJSON();
234
242
  return value;
235
243
  }
244
+
245
+ // Project a unified relations ListDiff onto a single concrete class.
246
+ // Entries whose value (old or new) is an instance of `Cls` survive;
247
+ // everything else is filtered out. Cross-type changed entries (e.g.
248
+ // old was PartitiveHyperedge, new is GenericHyperedge) are not yet
249
+ // supported — those would need to surface as a remove + add pair.
250
+ function partitionListDiff(listDiff, Cls) {
251
+ if (!listDiff) return new ListDiff({ added: [], removed: [], changed: [] });
252
+ return new ListDiff({
253
+ added: listDiff.added.filter(e => e.value instanceof Cls),
254
+ removed: listDiff.removed.filter(e => e.value instanceof Cls),
255
+ changed: listDiff.changed.filter(e =>
256
+ (e.oldValue == null || e.oldValue instanceof Cls) &&
257
+ (e.newValue == null || e.newValue instanceof Cls)),
258
+ });
259
+ }
@@ -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
+ // PartitiveHyperedge exposes .partitives as the wire alias for
210
+ // .members; GenericHyperedge 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,13 @@ 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
+ // Each hyperedge leaf declares a short static kindLabel (PART, GEN, …).
227
+ // Read it via the constructor so adding a new hyperedge type needs no
228
+ // renderer edit. The blank-string default keeps backward-compat with
229
+ // any AbstractHyperedge subclass that doesn't declare kindLabel.
230
+ const kindLabel = r?.constructor?.kindLabel ?? '';
231
+ const typeTag = kindLabel ? `${kindLabel} ` : '';
232
+ return `${typeTag}${head} → {${memberText}}${completeness}${criterion}`;
221
233
  }
222
234
 
223
235
  // Aggregate multiplicity + delimiting stats across a concept's
package/src/index.js CHANGED
@@ -63,7 +63,8 @@ export {
63
63
  fetchLocalizedString, localizedStringIsEmpty, localizedStringIsPresent,
64
64
  GcrMetadata, GcrStatistics,
65
65
  RELATIONSHIP_TYPES, DESIGNATION_RELATIONSHIP_TYPES, DATE_TYPES,
66
- PartitiveHyperedge, PARTITIVE_ENUMERATION, PARTITIVE_ENUMERATION_VALUES,
66
+ PartitiveHyperedge,
67
+ PARTITIVE_ENUMERATION, PARTITIVE_ENUMERATION_VALUES,
67
68
  PLURALITY_MARKER, PLURALITY_MARKER_VALUES,
68
69
  } from './models/index.js';
69
70
 
@@ -1,7 +1,7 @@
1
- // v1 → v2 PartitiveRelation migration.
1
+ // v1 → v2 PartitiveHyperedge migration.
2
2
  //
3
3
  // Pure function: takes a v1 PartitiveHyperedge hash, returns a v2
4
- // PartitiveRelation hash. Idempotent. Used by the parser when loading
4
+ // PartitiveHyperedge hash. Idempotent. Used by the parser when loading
5
5
  // legacy YAML; available as a public API for one-shot dataset migrations.
6
6
  //
7
7
  // Field mapping (per TODO.partitive-relation-v2 items 01–05 and the
@@ -69,7 +69,7 @@ export function migrateHyperedgeToRelation(v1Hash) {
69
69
 
70
70
  if (v1Hash.content != null) {
71
71
  out.migrationWarning = (out.migrationWarning ?? '') +
72
- ` v1 'content' field was dropped (v2 PartitiveRelation carries no prose; ` +
72
+ ` v1 'content' field was dropped (v2 PartitiveHyperedge carries no prose; ` +
73
73
  `move valuable text to the comprehensive concept's notes).`;
74
74
  }
75
75
 
@@ -1,5 +1,5 @@
1
- // AbstractNaryRelation — shared base for n-ary concept-system
2
- // relations (PartitiveRelation, GenericRelation, future
1
+ // AbstractHyperedge — shared base for n-ary concept-system
2
+ // relations (PartitiveHyperedge, GenericHyperedge, future
3
3
  // SequentialRelation, AssociativeRelation).
4
4
  //
5
5
  // Carries the common shape:
@@ -20,7 +20,7 @@ import {
20
20
  isValidCompleteness,
21
21
  } from './completeness.js';
22
22
 
23
- export class AbstractNaryRelation extends GlossaristModel {
23
+ export class AbstractHyperedge extends GlossaristModel {
24
24
  constructor(data = {}) {
25
25
  super();
26
26
  this.comprehensive = _ensureComprehensive(data.comprehensive);
@@ -62,7 +62,7 @@ function _ensureComprehensive(value) {
62
62
  const ref = value instanceof ConceptRef ? value : new ConceptRef(value ?? {});
63
63
  if (!ref.source && !ref.id && !ref.text) {
64
64
  throw new Error(
65
- 'AbstractNaryRelation.comprehensive must be a non-empty ConceptReference ' +
65
+ 'AbstractHyperedge.comprehensive must be a non-empty ConceptReference ' +
66
66
  '(source, id, or text required)',
67
67
  );
68
68
  }