glossarist 0.4.19 → 0.4.22

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 (54) hide show
  1. package/data/concept-model/shapes/glossarist.shacl.ttl +151 -1
  2. package/package.json +1 -1
  3. package/src/concept-diff.js +13 -0
  4. package/src/concept-parser.js +52 -2
  5. package/src/concept-serializer.js +11 -0
  6. package/src/diff/canonical-json.js +31 -0
  7. package/src/diff/change.js +28 -0
  8. package/src/diff/collection-diff.js +57 -25
  9. package/src/diff/concept-diff.js +100 -60
  10. package/src/diff/diff-patch.js +49 -48
  11. package/src/diff/diff-renderer.js +34 -0
  12. package/src/diff/identity.js +22 -0
  13. package/src/diff/index.d.ts +64 -1
  14. package/src/diff/index.js +2 -0
  15. package/src/diff/similarity.js +24 -0
  16. package/src/index.d.ts +2 -0
  17. package/src/index.js +2 -0
  18. package/src/migration/partitive-relation-migrator.js +99 -0
  19. package/src/models/completeness.js +24 -0
  20. package/src/models/concept-date.js +8 -0
  21. package/src/models/concept-ref.js +4 -0
  22. package/src/models/concept-source.js +21 -2
  23. package/src/models/concept.js +55 -0
  24. package/src/models/designation.js +15 -0
  25. package/src/models/detailed-definition.js +11 -0
  26. package/src/models/enum.js +42 -0
  27. package/src/models/hyperedge-colors.js +71 -0
  28. package/src/models/index.d.ts +61 -0
  29. package/src/models/index.js +38 -0
  30. package/src/models/localized-concept.js +29 -0
  31. package/src/models/multiplicity.js +24 -0
  32. package/src/models/partitive-enumeration.js +23 -0
  33. package/src/models/partitive-hyperedge.js +175 -0
  34. package/src/models/partitive-member.js +96 -0
  35. package/src/models/partitive-relation.js +149 -0
  36. package/src/models/plurality-marker.js +24 -0
  37. package/src/models/related-concept.js +5 -0
  38. package/src/models/relation-categories.js +6 -0
  39. package/src/rdf/gloss-concept.js +26 -0
  40. package/src/rdf/gloss-hyperedge.js +90 -0
  41. package/src/rdf/gloss-partitive-relation.js +88 -0
  42. package/src/rdf/gloss-source.js +1 -1
  43. package/src/rdf/index.d.ts +37 -0
  44. package/src/rdf/index.js +2 -0
  45. package/src/rdf/predicates.d.ts +46 -0
  46. package/src/rdf/predicates.js +23 -0
  47. package/src/reference-resolver.js +49 -0
  48. package/src/validators/binary-has-part-redundancy-rule.js +74 -0
  49. package/src/validators/external-concept-shape-rule.js +60 -0
  50. package/src/validators/index.js +19 -1
  51. package/src/validators/partitive-drift-rule.js +51 -0
  52. package/src/validators/partitive-hyperedge-rule.js +75 -0
  53. package/src/validators/partitive-relation-coherence-rule.js +98 -0
  54. package/src/validators/sourced-from-locality-rule.js +53 -0
@@ -1,28 +1,34 @@
1
1
  import { GlossaristModel } from '../models/base.js';
2
2
  import { Added, Removed, Changed } from './change.js';
3
3
  import { ListDiff, diffList, diffSet } from './list-diff.js';
4
-
4
+ import { identityOf } from './identity.js';
5
+ import { canonicalJson } from './canonical-json.js';
6
+ import { computeSimilarity } from './similarity.js';
7
+
8
+ // Metadata field lists for the diff layer.
9
+ //
10
+ // Concept and LocalizedConcept also expose static DIFF_FIELDS that
11
+ // mirror these arrays. The duplication is intentional: concept-diff.js
12
+ // cannot import Concept (Concept imports diffConcepts from this module,
13
+ // creating a cycle). Instead, a spec (test/diff/field-sync.test.js)
14
+ // asserts the two stay in sync. Adding a new scalar metadata field
15
+ // requires editing both sites; forgetting one fails the test loudly.
16
+ //
17
+ // (Invariant N2 — TODO.hyperedges-v2/07.)
5
18
  const CONCEPT_METADATA_FIELDS = Object.freeze([
6
19
  'status', 'term', 'uri', 'schemaVersion',
7
20
  ]);
8
21
 
9
22
  const LOCALIZATION_METADATA_FIELDS = Object.freeze([
10
- 'entryStatus',
11
- 'classification',
12
- 'reviewType',
13
- 'domain',
14
- 'release',
15
- 'lineageSourceSimilarity',
16
- 'script',
17
- 'system',
18
- 'reviewDate',
19
- 'reviewDecisionDate',
20
- 'reviewDecisionEvent',
21
- 'reviewStatus',
22
- 'reviewDecision',
23
- 'reviewDecisionNotes',
23
+ 'entryStatus', 'classification', 'reviewType', 'domain', 'release',
24
+ 'lineageSourceSimilarity', 'script', 'system',
25
+ 'reviewDate', 'reviewDecisionDate', 'reviewDecisionEvent',
26
+ 'reviewStatus', 'reviewDecision', 'reviewDecisionNotes',
24
27
  ]);
25
28
 
29
+ // Metadata field lists are sourced from the models per invariant N2.
30
+ // (See file-top comment about why they're duplicated rather than imported.)
31
+
26
32
  export class DiffStats extends GlossaristModel {
27
33
  constructor(data = {}) {
28
34
  super();
@@ -93,6 +99,15 @@ export class ConceptLevelDiff extends GlossaristModel {
93
99
  this._sources = wrapListDiff(data.sources);
94
100
  this._dates = wrapListDiff(data.dates);
95
101
  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
107
+ ?? data.partitive_relations
108
+ ?? data.partitiveHyperedges
109
+ ?? data.partitive_hyperedges,
110
+ );
96
111
  this._groups = wrapListDiff(data.groups);
97
112
  this._sections = wrapListDiff(data.sections);
98
113
  this._tags = wrapListDiff(data.tags);
@@ -104,6 +119,8 @@ export class ConceptLevelDiff extends GlossaristModel {
104
119
  get sources() { return this._sources; }
105
120
  get dates() { return this._dates; }
106
121
  get relatedConcepts() { return this._relatedConcepts; }
122
+ get partitiveRelations() { return this._partitiveRelations; }
123
+ /** @deprecated use partitiveRelations */ get partitiveHyperedges() { return this._partitiveRelations; }
107
124
  get groups() { return this._groups; }
108
125
  get sections() { return this._sections; }
109
126
  get tags() { return this._tags; }
@@ -113,6 +130,7 @@ export class ConceptLevelDiff extends GlossaristModel {
113
130
  return this._sources.hasChanges
114
131
  || this._dates.hasChanges
115
132
  || this._relatedConcepts.hasChanges
133
+ || this._partitiveRelations.hasChanges
116
134
  || this._groups.hasChanges
117
135
  || this._sections.hasChanges
118
136
  || this._tags.hasChanges
@@ -123,6 +141,7 @@ export class ConceptLevelDiff extends GlossaristModel {
123
141
  yield* walkList('concept.sources', this._sources);
124
142
  yield* walkList('concept.dates', this._dates);
125
143
  yield* walkList('concept.relatedConcepts', this._relatedConcepts);
144
+ yield* walkList('concept.partitiveRelations', this._partitiveRelations);
126
145
  yield* walkList('concept.groups', this._groups);
127
146
  yield* walkList('concept.sections', this._sections);
128
147
  yield* walkList('concept.tags', this._tags);
@@ -134,6 +153,7 @@ export class ConceptLevelDiff extends GlossaristModel {
134
153
  sources: this._sources.toJSON(),
135
154
  dates: this._dates.toJSON(),
136
155
  related_concepts: this._relatedConcepts.toJSON(),
156
+ partitive_relations: this._partitiveRelations.toJSON(),
137
157
  groups: this._groups.toJSON(),
138
158
  sections: this._sections.toJSON(),
139
159
  tags: this._tags.toJSON(),
@@ -161,6 +181,8 @@ export class LocalizedConceptDiff extends GlossaristModel {
161
181
  ? data.metadata
162
182
  : MetadataDiff.fromJSON(data.metadata ?? {});
163
183
  this._totalItems = data.totalItems ?? data.total_items ?? 0;
184
+ this._statsCache = undefined;
185
+ this._similarityCache = undefined;
164
186
  }
165
187
 
166
188
  get designations() { return this._designations; }
@@ -171,6 +193,7 @@ export class LocalizedConceptDiff extends GlossaristModel {
171
193
  get dates() { return this._dates; }
172
194
  get related() { return this._related; }
173
195
  get metadata() { return this._metadata; }
196
+ get totalItems() { return this._totalItems; }
174
197
 
175
198
  get hasChanges() {
176
199
  return this._designations.hasChanges
@@ -184,11 +207,17 @@ export class LocalizedConceptDiff extends GlossaristModel {
184
207
  }
185
208
 
186
209
  get stats() {
187
- return collectStats(this.walk());
210
+ if (this._statsCache === undefined) {
211
+ this._statsCache = collectStats(this.walk());
212
+ }
213
+ return this._statsCache;
188
214
  }
189
215
 
190
216
  get similarity() {
191
- return computeSimilarity(this.stats.total, this._totalItems);
217
+ if (this._similarityCache === undefined) {
218
+ this._similarityCache = computeSimilarity(this.stats.total, this._totalItems);
219
+ }
220
+ return this._similarityCache;
192
221
  }
193
222
 
194
223
  *walk(prefix) {
@@ -226,8 +255,8 @@ export class LocalizedConceptDiff extends GlossaristModel {
226
255
  export class ConceptDiff extends GlossaristModel {
227
256
  constructor(data = {}) {
228
257
  super();
229
- this.oldId = data.oldId ?? data.old_id ?? null;
230
- this.newId = data.newId ?? data.new_id ?? null;
258
+ this._oldId = data.oldId ?? data.old_id ?? null;
259
+ this._newId = data.newId ?? data.new_id ?? null;
231
260
  this._concept = data.concept instanceof ConceptLevelDiff
232
261
  ? data.concept
233
262
  : ConceptLevelDiff.fromJSON(data.concept ?? {});
@@ -240,6 +269,8 @@ export class ConceptDiff extends GlossaristModel {
240
269
  : LocalizedConceptDiff.fromJSON(lcDiff);
241
270
  }
242
271
  this._totalItems = data.totalItems ?? data.total_items ?? 0;
272
+ this._statsCache = undefined;
273
+ this._similarityCache = undefined;
243
274
  }
244
275
 
245
276
  get oldId() { return this._oldId; }
@@ -247,9 +278,7 @@ export class ConceptDiff extends GlossaristModel {
247
278
  get concept() { return this._concept; }
248
279
  get languages() { return this._languages; }
249
280
  get localizations() { return this._localizations; }
250
-
251
- set oldId(v) { this._oldId = v; }
252
- set newId(v) { this._newId = v; }
281
+ get totalItems() { return this._totalItems; }
253
282
 
254
283
  get localizationLanguages() {
255
284
  return Object.keys(this._localizations);
@@ -266,11 +295,17 @@ export class ConceptDiff extends GlossaristModel {
266
295
  }
267
296
 
268
297
  get stats() {
269
- return collectStats(this.walk());
298
+ if (this._statsCache === undefined) {
299
+ this._statsCache = collectStats(this.walk());
300
+ }
301
+ return this._statsCache;
270
302
  }
271
303
 
272
304
  get similarity() {
273
- return computeSimilarity(this.stats.total, this._totalItems);
305
+ if (this._similarityCache === undefined) {
306
+ this._similarityCache = computeSimilarity(this.stats.total, this._totalItems);
307
+ }
308
+ return this._similarityCache;
274
309
  }
275
310
 
276
311
  *walk() {
@@ -302,7 +337,7 @@ export class ConceptDiff extends GlossaristModel {
302
337
  }
303
338
  }
304
339
 
305
- export function diffConcepts(oldConcept, newConcept, language = 'eng') {
340
+ export function diffConcepts(oldConcept, newConcept, language) {
306
341
  if (!oldConcept && !newConcept) {
307
342
  return new ConceptDiff({});
308
343
  }
@@ -315,12 +350,19 @@ export function diffConcepts(oldConcept, newConcept, language = 'eng') {
315
350
  let langs;
316
351
  if (language === 'all') {
317
352
  langs = union(oldLangs, newLangs);
318
- } else if (oldLangs.includes(language) || newLangs.includes(language)) {
353
+ } else if (language) {
354
+ if (!oldLangs.includes(language) && !newLangs.includes(language)) {
355
+ throw new Error(
356
+ `diffConcepts: language '${language}' not present in either concept ` +
357
+ `(available: ${union(oldLangs, newLangs).join(', ') || 'none'})`,
358
+ );
359
+ }
319
360
  langs = [language];
320
- } else if (oldLangs.length === 0 && newLangs.length === 0) {
321
- langs = [];
322
361
  } else {
323
- langs = ['eng'];
362
+ // No language specified: diff everything that exists. Avoids the
363
+ // silent 'eng' fallback that masked changes in concepts without an
364
+ // English localization.
365
+ langs = union(oldLangs, newLangs);
324
366
  }
325
367
 
326
368
  const conceptDiff = diffConceptLevel(oldConcept ?? null, newConcept ?? null);
@@ -330,7 +372,6 @@ export function diffConcepts(oldConcept, newConcept, language = 'eng') {
330
372
  const oldLoc = oldConcept?.localization(lang) ?? null;
331
373
  const newLoc = newConcept?.localization(lang) ?? null;
332
374
  const lcDiff = diffLocalizedConcepts(oldLoc, newLoc);
333
- lcDiff._totalItems = countLocalizedItems(oldLoc, newLoc);
334
375
  localizations[lang] = lcDiff;
335
376
  }
336
377
 
@@ -370,6 +411,7 @@ export function diffLocalizedConcepts(oldLoc, newLoc) {
370
411
  dates: diffDates(oldLoc.dates ?? [], newLoc.dates ?? []),
371
412
  related: diffRelated(oldLoc.related ?? [], newLoc.related ?? []),
372
413
  metadata: diffMetadata(oldLoc, newLoc, LOCALIZATION_METADATA_FIELDS),
414
+ totalItems: countLocalizedItems(oldLoc, newLoc),
373
415
  });
374
416
  }
375
417
 
@@ -386,6 +428,7 @@ function diffConceptLevel(oldConcept, newConcept) {
386
428
  sources: fullListDiff(c.sources ?? [], Direction),
387
429
  dates: fullListDiff(c.dates ?? [], Direction),
388
430
  relatedConcepts: fullListDiff(c.relatedConcepts ?? [], Direction),
431
+ partitiveRelations: fullListDiff(c.partitiveRelations ?? c.partitiveHyperedges ?? [], Direction),
389
432
  groups: fullListDiff(c.groups ?? [], Direction),
390
433
  sections: fullListDiff(c.sections ?? [], Direction),
391
434
  tags: fullListDiff(c.tags ?? [], Direction),
@@ -397,6 +440,10 @@ function diffConceptLevel(oldConcept, newConcept) {
397
440
  sources: diffSources(oldConcept.sources ?? [], newConcept.sources ?? []),
398
441
  dates: diffDates(oldConcept.dates ?? [], newConcept.dates ?? []),
399
442
  relatedConcepts: diffRelatedConcepts(oldConcept.relatedConcepts ?? [], newConcept.relatedConcepts ?? []),
443
+ partitiveRelations: diffPartitiveRelations(
444
+ oldConcept.partitiveRelations ?? oldConcept.partitiveHyperedges ?? [],
445
+ newConcept.partitiveRelations ?? newConcept.partitiveHyperedges ?? [],
446
+ ),
400
447
  groups: diffStringSet(oldConcept.groups ?? [], newConcept.groups ?? []),
401
448
  sections: diffStringSet(oldConcept.sections ?? [], newConcept.sections ?? []),
402
449
  tags: diffStringSet(oldConcept.tags ?? [], newConcept.tags ?? []),
@@ -421,7 +468,7 @@ function diffLanguageSets(oldLangs, newLangs) {
421
468
  function fullyDiff(loc, lang, direction) {
422
469
  const ChangeClass = direction === 'added' ? Added : Removed;
423
470
  const key = direction;
424
- return new LocalizedConceptDiff({
471
+ const lc = new LocalizedConceptDiff({
425
472
  languageCode: lang,
426
473
  designations: new ListDiff({ [key]: (loc.terms ?? []).map(v => new ChangeClass({ value: v })) }),
427
474
  definitions: new ListDiff({ [key]: (loc.definitions ?? []).map(v => new ChangeClass({ value: v })) }),
@@ -431,7 +478,9 @@ function fullyDiff(loc, lang, direction) {
431
478
  dates: new ListDiff({ [key]: (loc.dates ?? []).map(v => new ChangeClass({ value: v })) }),
432
479
  related: new ListDiff({ [key]: (loc.related ?? []).map(v => new ChangeClass({ value: v })) }),
433
480
  metadata: fullMetadataDiff(loc, LOCALIZATION_METADATA_FIELDS, direction),
481
+ totalItems: countLocalizedItems(direction === 'added' ? null : loc, direction === 'added' ? loc : null),
434
482
  });
483
+ return lc;
435
484
  }
436
485
 
437
486
  function fullListDiff(items, direction) {
@@ -455,17 +504,11 @@ function fullMetadataDiff(obj, fields, direction) {
455
504
 
456
505
  function diffDesignations(oldTerms, newTerms) {
457
506
  return diffSet(oldTerms, newTerms, {
458
- identityKey: designationIdentity,
507
+ identityKey: identityOf,
459
508
  textKey: d => d?.designation ?? '',
460
509
  });
461
510
  }
462
511
 
463
- function designationIdentity(d) {
464
- const type = d?.type ?? 'expression';
465
- const text = String(d?.designation ?? '').toLowerCase().trim();
466
- return `${type}|${text}`;
467
- }
468
-
469
512
  function diffTextList(oldItems, newItems) {
470
513
  return diffList(oldItems, newItems, {
471
514
  textKey: d => d?.content ?? '',
@@ -474,45 +517,44 @@ function diffTextList(oldItems, newItems) {
474
517
 
475
518
  function diffSources(oldSources, newSources) {
476
519
  return diffSet(oldSources, newSources, {
477
- identityKey: sourceIdentity,
520
+ identityKey: identityOf,
478
521
  });
479
522
  }
480
523
 
481
- function sourceIdentity(s) {
482
- const ref = s?.origin?.ref;
483
- const source = ref?.source ?? '';
484
- const id = ref?.id ?? '';
485
- const type = s?.type ?? '';
486
- return `${type}|${source}|${id}`;
487
- }
488
-
489
524
  function diffDates(oldDates, newDates) {
490
525
  return diffSet(oldDates, newDates, {
491
- identityKey: d => d?.type ?? '',
526
+ identityKey: identityOf,
492
527
  textKey: d => d?.date ?? '',
493
528
  });
494
529
  }
495
530
 
496
531
  function diffRelated(oldRelated, newRelated) {
497
532
  return diffSet(oldRelated, newRelated, {
498
- identityKey: relatedIdentity,
533
+ identityKey: identityOf,
499
534
  });
500
535
  }
501
536
 
502
537
  function diffRelatedConcepts(oldRC, newRC) {
503
538
  return diffSet(oldRC, newRC, {
504
- identityKey: relatedIdentity,
539
+ identityKey: identityOf,
505
540
  });
506
541
  }
507
542
 
508
- function relatedIdentity(r) {
509
- const ref = r?.ref;
510
- return `${r?.type ?? ''}|${ref?.source ?? ''}|${ref?.id ?? ''}`;
543
+ function diffPartitiveRelations(oldR, newR) {
544
+ return diffSet(oldR, newR, {
545
+ identityKey: identityOf,
546
+ textKey: relationText,
547
+ });
548
+ }
549
+
550
+ function relationText(r) {
551
+ if (!r) return '';
552
+ return canonicalJson(typeof r.toJSON === 'function' ? r.toJSON() : r);
511
553
  }
512
554
 
513
555
  function diffStringSet(oldStrings, newStrings) {
514
556
  return diffSet(oldStrings, newStrings, {
515
- identityKey: s => String(s),
557
+ identityKey: identityOf,
516
558
  });
517
559
  }
518
560
 
@@ -545,12 +587,6 @@ function collectStats(walker) {
545
587
  return new DiffStats({ added, removed, changed });
546
588
  }
547
589
 
548
- function computeSimilarity(changeCount, totalItems) {
549
- if (totalItems <= 0) return 1.0;
550
- const ratio = Math.min(changeCount / totalItems, 1);
551
- return Math.round((1 - ratio) * 10000) / 10000;
552
- }
553
-
554
590
  function maxOf(a, b) {
555
591
  return Math.max(a ?? 0, b ?? 0);
556
592
  }
@@ -561,6 +597,10 @@ function countConceptItems(oldConcept, newConcept, langs) {
561
597
  count += maxOf(oldConcept?.sources?.length, newConcept?.sources?.length);
562
598
  count += maxOf(oldConcept?.dates?.length, newConcept?.dates?.length);
563
599
  count += maxOf(oldConcept?.relatedConcepts?.length, newConcept?.relatedConcepts?.length);
600
+ count += maxOf(
601
+ oldConcept?.partitiveRelations?.length ?? oldConcept?.partitiveHyperedges?.length,
602
+ newConcept?.partitiveRelations?.length ?? newConcept?.partitiveHyperedges?.length,
603
+ );
564
604
  count += maxOf(oldConcept?.groups?.length, newConcept?.groups?.length);
565
605
  count += maxOf(oldConcept?.sections?.length, newConcept?.sections?.length);
566
606
  count += maxOf(oldConcept?.tags?.length, newConcept?.tags?.length);
@@ -1,7 +1,14 @@
1
1
  import { Concept } from '../models/concept.js';
2
+ import { ConceptSource } from '../models/concept-source.js';
3
+ import { ConceptDate } from '../models/concept-date.js';
4
+ import { RelatedConcept } from '../models/related-concept.js';
5
+ import { Designation } from '../models/designation.js';
6
+ import { DetailedDefinition } from '../models/detailed-definition.js';
7
+ import { PartitiveRelation } from '../models/partitive-relation.js';
2
8
  import { Added, Removed, Changed } from './change.js';
3
9
  import { ListDiff } from './list-diff.js';
4
10
  import { TextDiff, TextHunk } from './text-diff.js';
11
+ import { identityOf } from './identity.js';
5
12
  import {
6
13
  ConceptDiff,
7
14
  ConceptLevelDiff,
@@ -9,14 +16,23 @@ import {
9
16
  MetadataDiff,
10
17
  } from './concept-diff.js';
11
18
 
12
- const CONCEPT_METADATA_JSON_KEYS = {
19
+ // Wire-name (camelCase JS field → snake_case JSON key) for metadata
20
+ // fields patched onto Concept and LocalizedConcept.
21
+ //
22
+ // These mirror `Concept.wireNameFor` / `LocalizedConcept.wireNameFor`.
23
+ // The duplication is intentional: diff-patch.js cannot import those
24
+ // models lazily without making applyDiff async (a breaking API change),
25
+ // and the field set is small and stable. A spec
26
+ // (test/diff/field-sync.test.js) asserts the two stay in sync.
27
+
28
+ const CONCEPT_METADATA_JSON_KEYS = Object.freeze({
13
29
  status: 'status',
14
30
  term: 'term',
15
31
  uri: 'uri',
16
32
  schemaVersion: 'schema_version',
17
- };
33
+ });
18
34
 
19
- const LOC_METADATA_JSON_KEYS = {
35
+ const LOC_METADATA_JSON_KEYS = Object.freeze({
20
36
  entryStatus: 'entry_status',
21
37
  classification: 'classification',
22
38
  reviewType: 'review_type',
@@ -31,7 +47,7 @@ const LOC_METADATA_JSON_KEYS = {
31
47
  reviewStatus: 'review_status',
32
48
  reviewDecision: 'review_decision',
33
49
  reviewDecisionNotes: 'review_decision_notes',
34
- };
50
+ });
35
51
 
36
52
  export function applyDiff(oldConcept, diff) {
37
53
  const json = oldConcept.toJSON();
@@ -51,6 +67,9 @@ export function reverseDiff(diff) {
51
67
  sources: reverseListDiff(diff.concept.sources),
52
68
  dates: reverseListDiff(diff.concept.dates),
53
69
  relatedConcepts: reverseListDiff(diff.concept.relatedConcepts),
70
+ partitiveRelations: reverseListDiff(
71
+ diff.concept.partitiveRelations ?? diff.concept.partitiveHyperedges,
72
+ ),
54
73
  groups: reverseListDiff(diff.concept.groups),
55
74
  sections: reverseListDiff(diff.concept.sections),
56
75
  tags: reverseListDiff(diff.concept.tags),
@@ -86,11 +105,21 @@ export function reverseDiff(diff) {
86
105
  }
87
106
 
88
107
  function applyConceptLevelPatch(json, conceptDiff) {
89
- json.sources = applyListPatch(json.sources ?? [], conceptDiff.sources, sourceIdentity);
90
- json.dates = applyListPatch(json.dates ?? [], conceptDiff.dates, dateIdentity);
91
- json.related = applyListPatch(json.related ?? [], conceptDiff.relatedConcepts, relatedIdentity);
108
+ json.sources = applyListPatch(json.sources ?? [], conceptDiff.sources, ConceptSource.identityOf);
109
+ json.dates = applyListPatch(json.dates ?? [], conceptDiff.dates, ConceptDate.identityOf);
110
+ 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;
120
+ }
92
121
 
93
- json.tags = applyListPatch(json.tags ?? [], conceptDiff.tags, s => String(s));
122
+ json.tags = applyListPatch(json.tags ?? [], conceptDiff.tags);
94
123
 
95
124
  applyMetadataPatch(json, conceptDiff.metadata, CONCEPT_METADATA_JSON_KEYS);
96
125
  }
@@ -114,18 +143,19 @@ function applyLocalizedPatch(json, lang, lcDiff) {
114
143
  }
115
144
  const loc = json.localizations[lang];
116
145
 
117
- loc.terms = applyListPatch(loc.terms ?? [], lcDiff.designations, designationIdentity);
118
- loc.definition = applyListPatch(loc.definition ?? [], lcDiff.definitions, definitionIdentity);
119
- loc.notes = applyListPatch(loc.notes ?? [], lcDiff.notes, definitionIdentity);
120
- loc.examples = applyListPatch(loc.examples ?? [], lcDiff.examples, definitionIdentity);
121
- loc.sources = applyListPatch(loc.sources ?? [], lcDiff.sources, sourceIdentity);
122
- loc.dates = applyListPatch(loc.dates ?? [], lcDiff.dates, dateIdentity);
123
- loc.related = applyListPatch(loc.related ?? [], lcDiff.related, relatedIdentity);
146
+ loc.terms = applyListPatch(loc.terms ?? [], lcDiff.designations, Designation.identityOf);
147
+ loc.definition = applyListPatch(loc.definition ?? [], lcDiff.definitions, DetailedDefinition.identityOf);
148
+ loc.notes = applyListPatch(loc.notes ?? [], lcDiff.notes, DetailedDefinition.identityOf);
149
+ loc.examples = applyListPatch(loc.examples ?? [], lcDiff.examples, DetailedDefinition.identityOf);
150
+ loc.sources = applyListPatch(loc.sources ?? [], lcDiff.sources, ConceptSource.identityOf);
151
+ loc.dates = applyListPatch(loc.dates ?? [], lcDiff.dates, ConceptDate.identityOf);
152
+ loc.related = applyListPatch(loc.related ?? [], lcDiff.related, RelatedConcept.identityOf);
124
153
 
125
154
  applyMetadataPatch(loc, lcDiff.metadata, LOC_METADATA_JSON_KEYS);
126
155
  }
127
156
 
128
157
  function applyListPatch(existingItems, listDiff, identityFn) {
158
+ const fn = identityFn ?? identityOf;
129
159
  let result = [...existingItems];
130
160
 
131
161
  for (const entry of listDiff.added) {
@@ -133,14 +163,14 @@ function applyListPatch(existingItems, listDiff, identityFn) {
133
163
  }
134
164
 
135
165
  for (const entry of listDiff.removed) {
136
- const targetKey = identityFn(entry.value);
137
- result = result.filter(item => identityFn(item) !== targetKey);
166
+ const targetKey = fn(entry.value);
167
+ result = result.filter(item => fn(item) !== targetKey);
138
168
  }
139
169
 
140
170
  for (const entry of listDiff.changed) {
141
- const oldKey = identityFn(entry.oldValue);
171
+ const oldKey = fn(entry.oldValue);
142
172
  result = result.map(item =>
143
- identityFn(item) === oldKey ? toJsonValue(entry.newValue) : item,
173
+ fn(item) === oldKey ? toJsonValue(entry.newValue) : item,
144
174
  );
145
175
  }
146
176
 
@@ -203,32 +233,3 @@ function toJsonValue(value) {
203
233
  if (typeof value.toJSON === 'function') return value.toJSON();
204
234
  return value;
205
235
  }
206
-
207
- function designationIdentity(d) {
208
- const data = typeof d?.toJSON === 'function' ? d.toJSON() : d ?? {};
209
- const type = data.type ?? 'expression';
210
- const text = String(data.designation ?? '').toLowerCase().trim();
211
- return `${type}|${text}`;
212
- }
213
-
214
- function definitionIdentity(d) {
215
- const data = typeof d?.toJSON === 'function' ? d.toJSON() : d ?? {};
216
- return JSON.stringify(data);
217
- }
218
-
219
- function sourceIdentity(s) {
220
- const data = typeof s?.toJSON === 'function' ? s.toJSON() : s ?? {};
221
- const ref = data.origin?.ref;
222
- return `${data.type ?? ''}|${ref?.source ?? ''}|${ref?.id ?? ''}`;
223
- }
224
-
225
- function dateIdentity(d) {
226
- const data = typeof d?.toJSON === 'function' ? d.toJSON() : d ?? {};
227
- return data.type ?? '';
228
- }
229
-
230
- function relatedIdentity(r) {
231
- const data = typeof r?.toJSON === 'function' ? r.toJSON() : r ?? {};
232
- const ref = data.ref;
233
- return `${data.type ?? ''}|${ref?.source ?? ''}|${ref?.id ?? ''}`;
234
- }
@@ -124,6 +124,7 @@ function renderConceptLevel(diff, colors) {
124
124
  lines.push(...renderListDiff('Sources', diff.sources, colors, itemLabel));
125
125
  lines.push(...renderListDiff('Dates', diff.dates, colors, itemLabel));
126
126
  lines.push(...renderListDiff('Related', diff.relatedConcepts, colors, itemLabel));
127
+ lines.push(...renderListDiff('Partitive relations', diff.partitiveRelations ?? diff.partitiveHyperedges, colors, relationLabel));
127
128
  lines.push(...renderListDiff('Groups', diff.groups, colors, itemLabel));
128
129
  lines.push(...renderListDiff('Sections', diff.sections, colors, itemLabel));
129
130
  lines.push(...renderListDiff('Tags', diff.tags, colors, itemLabel));
@@ -197,6 +198,39 @@ function definitionLabel(d) {
197
198
  return d.content ?? '?';
198
199
  }
199
200
 
201
+ function relationLabel(r) {
202
+ if (!r) return '?';
203
+ const c = r.comprehensive;
204
+ const head = c?.id ?? c?.source ?? c?.text ?? '?';
205
+ const partitives = Array.isArray(r.partitives)
206
+ ? r.partitives.map(m => {
207
+ const ref = m?.ref ?? m ?? {};
208
+ const tail = m?.certainty === 'possible' ? '?' : '';
209
+ return `${ref.id ?? ref.source ?? ref.text ?? '?'}${tail}`;
210
+ }).join(', ')
211
+ : '';
212
+ const completeness = r.completeness ? ` (${r.completeness})` : '';
213
+ const criterion = r.criterion
214
+ ? ` / ${Object.values(r.criterion)[0] ?? ''}`
215
+ : '';
216
+ const plurality = r.plurality
217
+ ? ` [${_pluralityLabel(r.plurality)}]`
218
+ : '';
219
+ return `${head} → {${partitives}}${completeness}${criterion}${plurality}`;
220
+ }
221
+
222
+ function _pluralityLabel(p) {
223
+ if (!p) return '';
224
+ const flags = [];
225
+ if (p.is_shared) flags.push('shared');
226
+ if (p.is_uncertain) flags.push('uncertain');
227
+ if (p.shared_type) {
228
+ const t = p.shared_type;
229
+ flags.push(`type=${t.id ?? t.source ?? '?'}`);
230
+ }
231
+ return flags.join(',');
232
+ }
233
+
200
234
  function itemLabel(item) {
201
235
  if (item == null) return '?';
202
236
  if (typeof item === 'string') return item;
@@ -0,0 +1,22 @@
1
+ // Shared identity helper for the diff layer.
2
+ //
3
+ // Per invariant N1 (TODO.hyperedges-v2/00-master-plan.md), identity is a
4
+ // model concern. Models expose `identity()` returning a stable string.
5
+ // This helper dispatches: if the value is a model with `identity()`,
6
+ // call it; otherwise fall back to canonical JSON of `toJSON()`.
7
+ //
8
+ // Both `concept-diff.js` (compute side) and `diff-patch.js` (apply side)
9
+ // import this — closing the duplication that previously allowed the two
10
+ // sides to drift.
11
+
12
+ import { canonicalJson } from './canonical-json.js';
13
+
14
+ export function identityOf(item) {
15
+ if (item == null) return '';
16
+ if (typeof item.identity === 'function') return item.identity();
17
+ if (typeof item === 'string') return item;
18
+ if (typeof item === 'number') return String(item);
19
+ if (typeof item === 'boolean') return String(item);
20
+ if (typeof item.toJSON === 'function') return canonicalJson(item.toJSON());
21
+ return canonicalJson(item);
22
+ }