glossarist 0.4.18 → 0.4.20

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 (55) 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 +125 -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 +39 -0
  30. package/src/models/localized-concept.js +29 -0
  31. package/src/models/member-certainty.js +21 -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 +79 -0
  35. package/src/models/partitive-relation.js +158 -0
  36. package/src/models/plurality-marker.js +24 -0
  37. package/src/models/related-concept.js +38 -1
  38. package/src/models/relation-categories.js +6 -0
  39. package/src/models/type-shared-plurality.js +71 -0
  40. package/src/rdf/gloss-concept.js +26 -0
  41. package/src/rdf/gloss-hyperedge.js +90 -0
  42. package/src/rdf/gloss-partitive-relation.js +109 -0
  43. package/src/rdf/gloss-source.js +1 -1
  44. package/src/rdf/index.d.ts +37 -0
  45. package/src/rdf/index.js +2 -0
  46. package/src/rdf/predicates.d.ts +54 -0
  47. package/src/rdf/predicates.js +27 -0
  48. package/src/reference-resolver.js +49 -0
  49. package/src/validators/binary-has-part-redundancy-rule.js +74 -0
  50. package/src/validators/external-concept-shape-rule.js +60 -0
  51. package/src/validators/index.js +19 -1
  52. package/src/validators/partitive-drift-rule.js +51 -0
  53. package/src/validators/partitive-hyperedge-rule.js +75 -0
  54. package/src/validators/partitive-relation-coherence-rule.js +107 -0
  55. package/src/validators/sourced-from-locality-rule.js +53 -0
@@ -43,13 +43,17 @@ gloss:ConceptShape a sh:NodeShape ;
43
43
  sh:property [
44
44
  sh:path gloss:hasStatus ;
45
45
  sh:class skos:Concept ;
46
- sh:valuesFrom <https://www.glossarist.org/ontologies/conceptstatus> ;
46
+ sh:valuesFrom <https://www.glossarist.org/ontologies/status> ;
47
47
  sh:maxCount 1 ;
48
48
  ] ;
49
49
  sh:property [
50
50
  sh:path gloss:hasRelatedConcept ;
51
51
  sh:class gloss:RelatedConcept ;
52
52
  ] ;
53
+ sh:property [
54
+ sh:path gloss:hasPartitiveHyperedge ;
55
+ sh:class gloss:PartitiveHyperedge ;
56
+ ] ;
53
57
  sh:property [
54
58
  sh:path gloss:hasDate ;
55
59
  sh:class gloss:ConceptDate ;
@@ -717,6 +721,152 @@ gloss:RelatedConceptShape a sh:NodeShape ;
717
721
  sh:maxCount 1 ;
718
722
  ] .
719
723
 
724
+ # ══════════════════════════════════════════════════════════════════════════
725
+ # Partitive Hyperedge shapes
726
+ # ══════════════════════════════════════════════════════════════════════════
727
+
728
+ gloss:PartitiveHyperedgeShape a sh:NodeShape ;
729
+ sh:targetClass gloss:PartitiveHyperedge ;
730
+ sh:property [
731
+ sh:path gloss:comprehensive ;
732
+ sh:class gloss:ConceptRef ;
733
+ sh:minCount 1 ;
734
+ sh:maxCount 1 ;
735
+ ] ;
736
+ sh:property [
737
+ sh:path gloss:hasPart ;
738
+ sh:class gloss:ConceptRef ;
739
+ sh:minCount 1 ;
740
+ ] ;
741
+ sh:property [
742
+ sh:path gloss:enumeration ;
743
+ sh:class skos:Concept ;
744
+ sh:valuesFrom <https://www.glossarist.org/ontologies/partitiveEnumeration> ;
745
+ sh:maxCount 1 ;
746
+ ] ;
747
+ sh:property [
748
+ sh:path gloss:hasPluralityMarker ;
749
+ sh:class skos:Concept ;
750
+ sh:valuesFrom <https://www.glossarist.org/ontologies/pluralityMarker> ;
751
+ ] ;
752
+ sh:property [
753
+ sh:path gloss:hyperedgeContent ;
754
+ sh:datatype xsd:string ;
755
+ sh:maxCount 1 ;
756
+ ] .
757
+
758
+ # Ownership invariant — soft (warning severity). A PartitiveHyperedge
759
+ # is *conventionally* carried by its comprehensive's own concept entry:
760
+ # <concept/X> gloss:hasPartitiveHyperedge [
761
+ # gloss:comprehensive [ a gloss:ConceptRef ; gloss:ref-id "X" ]
762
+ # ] .
763
+ #
764
+ # Datasets in the wild may legitimately cross-reference (a concept
765
+ # carries a hyperedge whose comprehensive points elsewhere — e.g. for
766
+ # indexing). We do not forbid this; we warn so reviewers can confirm
767
+ # the cross-reference is intentional.
768
+ #
769
+ # The SPARQL constraint matches when the carrying concept's URI-ending
770
+ # identifier differs from the comprehensive ConceptRef's `gloss:id`
771
+ # literal.
772
+ gloss:PartitiveHyperedgeOwnershipShape a sh:NodeShape ;
773
+ sh:targetClass gloss:PartitiveHyperedge ;
774
+ sh:severity sh:Warning ;
775
+ sh:message "partitive_hyperedge is carried by a concept that is not its comprehensive — confirm the cross-reference is intentional."@en ;
776
+ sh:sparql [
777
+ a sh:SPARQLConstraint ;
778
+ sh:prefixes [
779
+ sh:declare [
780
+ sh:prefix "gloss" ;
781
+ sh:namespace "https://www.glossarist.org/ontologies/"^^xsd:anyURI ;
782
+ ] ;
783
+ sh:declare [
784
+ sh:prefix "xsd" ;
785
+ sh:namespace "http://www.w3.org/2001/XMLSchema#"^^xsd:anyURI ;
786
+ ] ;
787
+ ] ;
788
+ sh:select """
789
+ SELECT $this ?carryingConcept ?compRef
790
+ WHERE {
791
+ ?carryingConcept gloss:hasPartitiveHyperedge $this .
792
+ $this gloss:comprehensive ?compRef .
793
+ # Two encoding shapes exist:
794
+ # (a) ?compRef is a gloss:ConceptRef node with gloss:conceptRefId
795
+ # (b) ?compRef is a direct URI ending in /<concept_id>
796
+ # We bind ?compKey to whichever form is present, then compare
797
+ # against the URI suffix of the carrying concept.
798
+ OPTIONAL { ?compRef gloss:conceptRefId ?literalId . }
799
+ BIND(COALESCE(?literalId, STRAFTER(STR(?compRef), CONCAT(STR(?carryingConcept), "/"))) AS ?compKey)
800
+ FILTER (BOUND(?compKey) && !STRENDS(STR(?carryingConcept), CONCAT("/", ?compKey)))
801
+ }
802
+ """ ;
803
+ ] .
804
+
805
+ # ── Enum scheme shapes ───────────────────────────────────────────────────
806
+ # PartitiveEnumeration and PluralityMarker are modelled as owl:Class
807
+ # but functionally are SKOS ConceptSchemes. The shape asserts the
808
+ # scheme contract: every instance must be a skos:ConceptScheme.
809
+
810
+ gloss:PartitiveEnumerationShape a sh:NodeShape ;
811
+ sh:targetClass gloss:PartitiveEnumeration ;
812
+ sh:class skos:ConceptScheme .
813
+
814
+ gloss:PluralityMarkerShape a sh:NodeShape ;
815
+ sh:targetClass gloss:PluralityMarker ;
816
+ sh:class skos:ConceptScheme .
817
+
818
+ # ── Non-verbal entity hierarchy ──────────────────────────────────────────
819
+ # Abstract base + shared dataset-wide entity. Concrete shapes
820
+ # (Figure, Table, Formula) live elsewhere in this file.
821
+
822
+ gloss:NonVerbalEntityShape a sh:NodeShape ;
823
+ sh:targetClass gloss:NonVerbalEntity ;
824
+ sh:property [
825
+ sh:path gloss:caption ;
826
+ sh:datatype xsd:string ;
827
+ sh:maxCount 1 ;
828
+ ] ;
829
+ sh:property [
830
+ sh:path gloss:altText ;
831
+ sh:datatype xsd:string ;
832
+ sh:maxCount 1 ;
833
+ ] ;
834
+ sh:property [
835
+ sh:path gloss:description ;
836
+ sh:datatype xsd:string ;
837
+ ] ;
838
+ sh:property [
839
+ sh:path gloss:hasSource ;
840
+ sh:class gloss:ConceptSource ;
841
+ ] .
842
+
843
+ gloss:SharedNonVerbalEntityShape a sh:NodeShape ;
844
+ sh:targetClass gloss:SharedNonVerbalEntity ;
845
+ sh:class gloss:NonVerbalEntity ;
846
+ sh:property [
847
+ sh:path gloss:role ;
848
+ sh:datatype xsd:string ;
849
+ sh:maxCount 1 ;
850
+ ] .
851
+
852
+ gloss:FigureImageShape a sh:NodeShape ;
853
+ sh:targetClass gloss:FigureImage ;
854
+ sh:property [
855
+ sh:path gloss:src ;
856
+ sh:datatype xsd:string ;
857
+ sh:minCount 1 ;
858
+ sh:maxCount 1 ;
859
+ ] ;
860
+ sh:property [
861
+ sh:path gloss:format ;
862
+ sh:datatype xsd:string ;
863
+ sh:maxCount 1 ;
864
+ ] ;
865
+ sh:property [
866
+ sh:path gloss:hasSubfigure ;
867
+ sh:class gloss:FigureImage ;
868
+ ] .
869
+
720
870
  gloss:GrammarInfoShape a sh:NodeShape ;
721
871
  sh:targetClass gloss:GrammarInfo ;
722
872
  sh:property [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glossarist",
3
- "version": "0.4.18",
3
+ "version": "0.4.20",
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,3 +1,15 @@
1
+ /**
2
+ * @deprecated Import from `glossarist/diff` (i.e. `src/diff/index.js`)
3
+ * instead. This file is a backwards-compatibility shim left over from
4
+ * the pre-`eb6e61c` layout. It will be removed in the next major
5
+ * release.
6
+ *
7
+ * Migration:
8
+ * - `import { diffConcepts } from 'glossarist/concept-diff'`
9
+ * + `import { diffConcepts } from 'glossarist/diff'`
10
+ *
11
+ * See TODO.hyperedges-v2/12 for the deprecation rationale.
12
+ */
1
13
  export {
2
14
  diffConcepts,
3
15
  diffLocalizedConcepts,
@@ -16,4 +28,5 @@ export {
16
28
  Added,
17
29
  Removed,
18
30
  Changed,
31
+ Matched,
19
32
  } from './diff/index.js';
@@ -1,9 +1,18 @@
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 { migrateHyperedgeToRelation } from './migration/partitive-relation-migrator.js';
4
5
  import { InvalidInputError, YamlParseError } from './errors.js';
5
6
 
6
- const STRUCTURAL_KEYS = new Set(['termid', 'term', 'figures', 'tables', 'formulas']);
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.
11
+ const STRUCTURAL_KEYS = new Set([
12
+ 'termid', 'term', 'figures', 'tables', 'formulas',
13
+ 'partitive_hyperedges', 'partitiveHyperedges',
14
+ 'partitive_relations', 'partitiveRelations',
15
+ ]);
7
16
 
8
17
  export class ConceptParser {
9
18
  parse(raw, context) {
@@ -58,6 +67,7 @@ export class ConceptParser {
58
67
  figures: doc.figures,
59
68
  tables: doc.tables,
60
69
  formulas: doc.formulas,
70
+ partitiveRelations: _resolvePartitiveData(doc),
61
71
  raw: doc,
62
72
  });
63
73
  }
@@ -74,11 +84,14 @@ export class ConceptParser {
74
84
  localizations[lang] = lcData;
75
85
  }
76
86
 
87
+ assertConceptLevelOnly(mc, ['related', 'partitive_relations', 'partitive_hyperedges']);
88
+
77
89
  return new Concept({
78
90
  id: String(mc.data.identifier),
79
91
  term: null,
80
92
  localizations,
81
- related: _normalizeRelated(mc.related ?? mc.data?.related),
93
+ related: _normalizeRelated(mc.related),
94
+ partitiveRelations: _resolvePartitiveData(mc),
82
95
  domains: mc.data.domains,
83
96
  groups: mc.data.groups,
84
97
  dates: mc.dates ?? mc.data?.dates,
@@ -93,6 +106,43 @@ export class ConceptParser {
93
106
  }
94
107
  }
95
108
 
109
+ function assertConceptLevelOnly(mc, keys) {
110
+ const conceptId = mc?.data?.identifier ?? '<unknown>';
111
+ for (const key of keys) {
112
+ const camelKey = key.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
113
+ if (mc?.data?.[key] != null && mc[key] == null && mc[camelKey] == null) {
114
+ throw new InvalidInputError(
115
+ `'${key}' must live at concept level (top-level of the managed ` +
116
+ `concept document), not under data:. Found data.${key} in concept ` +
117
+ `${conceptId}. Move the key out of the data: block.`,
118
+ 'concept-level ' + key,
119
+ );
120
+ }
121
+ }
122
+ }
123
+
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
129
+ //
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);
142
+ }
143
+ return null;
144
+ }
145
+
96
146
  function _normalizeRelated(arr) {
97
147
  if (!arr || !Array.isArray(arr)) return [];
98
148
  return arr.map(r => {
@@ -7,6 +7,13 @@ export class ConceptSerializer {
7
7
  const doc = { termid: concept.id };
8
8
  if (concept.term) doc.term = concept.term;
9
9
 
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
+ }
16
+
10
17
  for (const lang of concept.languages) {
11
18
  const lc = concept.localization(lang);
12
19
  if (lc) {
@@ -46,6 +53,10 @@ export class ConceptSerializer {
46
53
  if (concept.relatedConcepts.length > 0) {
47
54
  mainDoc.related = concept.relatedConcepts.map(rc => rc.toJSON());
48
55
  }
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
+ }
49
60
  if (concept.sources.length > 0) {
50
61
  mainDoc.sources = concept.sources.map(s => s.toJSON());
51
62
  }
@@ -0,0 +1,31 @@
1
+ // Canonical JSON for equality comparison.
2
+ //
3
+ // Sorts object keys recursively so two semantically-equal values produce
4
+ // identical strings regardless of insertion order. Arrays preserve order
5
+ // (they're ordered in domain semantics). Undefined values are dropped.
6
+ //
7
+ // Used by the diff layer to compare items whose `toJSON()` may emit keys
8
+ // in different orders depending on conditional inclusion (e.g.,
9
+ // PartitiveHyperedge#toJSON omits `markers` when empty).
10
+
11
+ export function canonicalJson(value) {
12
+ return JSON.stringify(toCanonical(value));
13
+ }
14
+
15
+ export function deepEqualCanonical(a, b) {
16
+ return canonicalJson(a) === canonicalJson(b);
17
+ }
18
+
19
+ function toCanonical(value) {
20
+ if (value == null) return null;
21
+ if (Array.isArray(value)) return value.map(toCanonical);
22
+ if (typeof value === 'object') {
23
+ const out = {};
24
+ for (const k of Object.keys(value).sort()) {
25
+ const v = toCanonical(value[k]);
26
+ if (v !== undefined) out[k] = v;
27
+ }
28
+ return out;
29
+ }
30
+ return value;
31
+ }
@@ -4,6 +4,7 @@ import { TextDiff } from './text-diff.js';
4
4
  export const CHANGE_ADDED = 'added';
5
5
  export const CHANGE_REMOVED = 'removed';
6
6
  export const CHANGE_CHANGED = 'changed';
7
+ export const CHANGE_MATCHED = 'matched';
7
8
 
8
9
  function serializeValue(v) {
9
10
  if (v == null) return null;
@@ -100,11 +101,38 @@ export class Changed extends Change {
100
101
  }
101
102
  }
102
103
 
104
+ // Matched is for set-membership records that exist in both sides of a
105
+ // comparison but have no directionality — used by ConceptCollectionDiff
106
+ // to track which concepts were paired by id, distinct from those that
107
+ // were genuinely Added or Removed. Distinct from Added because the data
108
+ // has a different semantic meaning; conflating them caused type drift
109
+ // in round 1 (TODO.hyperedges-v2/08).
110
+ export class Matched extends Change {
111
+ constructor(data = {}) {
112
+ super(data);
113
+ this.value = data.value ?? null;
114
+ }
115
+
116
+ get type() { return CHANGE_MATCHED; }
117
+
118
+ toJSON() {
119
+ const obj = { type: CHANGE_MATCHED };
120
+ if (this.path != null) obj.path = this.path;
121
+ obj.value = serializeValue(this.value);
122
+ return obj;
123
+ }
124
+
125
+ static fromJSON(data) {
126
+ return new Matched(data);
127
+ }
128
+ }
129
+
103
130
  export function deserializeChange(data) {
104
131
  switch (data?.type) {
105
132
  case CHANGE_ADDED: return Added.fromJSON(data);
106
133
  case CHANGE_REMOVED: return Removed.fromJSON(data);
107
134
  case CHANGE_CHANGED: return Changed.fromJSON(data);
135
+ case CHANGE_MATCHED: return Matched.fromJSON(data);
108
136
  default:
109
137
  throw new Error(`Unknown change type: ${data?.type}`);
110
138
  }
@@ -1,6 +1,7 @@
1
1
  import { GlossaristModel } from '../models/base.js';
2
- import { Added, Removed } from './change.js';
2
+ import { Added, Removed, Matched, deserializeChange } from './change.js';
3
3
  import { ConceptDiff, DiffStats, diffConcepts } from './concept-diff.js';
4
+ import { averageSimilarity } from './similarity.js';
4
5
 
5
6
  export class ConceptCollectionDiff extends GlossaristModel {
6
7
  constructor(data = {}) {
@@ -8,9 +9,9 @@ export class ConceptCollectionDiff extends GlossaristModel {
8
9
  this.oldCount = data.oldCount ?? data.old_count ?? 0;
9
10
  this.newCount = data.newCount ?? data.new_count ?? 0;
10
11
 
11
- this._matched = wrapIdList(data.matched);
12
- this._added = wrapIdList(data.added);
13
- this._removed = wrapIdList(data.removed);
12
+ this._matched = wrapIdList(data.matched, Matched);
13
+ this._added = wrapIdList(data.added, Added);
14
+ this._removed = wrapIdList(data.removed, Removed);
14
15
 
15
16
  this._conceptDiffs = {};
16
17
  const raw = data.conceptDiffs ?? data.concept_diffs ?? {};
@@ -19,6 +20,8 @@ export class ConceptCollectionDiff extends GlossaristModel {
19
20
  ? diff
20
21
  : ConceptDiff.fromJSON(diff);
21
22
  }
23
+ this._statsCache = undefined;
24
+ this._similarityCache = undefined;
22
25
  }
23
26
 
24
27
  get matched() { return this._matched; }
@@ -41,29 +44,35 @@ export class ConceptCollectionDiff extends GlossaristModel {
41
44
  }
42
45
 
43
46
  get stats() {
44
- let added = this._added.length;
45
- let removed = this._removed.length;
46
- let changed = 0;
47
- for (const diff of Object.values(this._conceptDiffs)) {
48
- const s = diff.stats;
49
- added += s.added;
50
- removed += s.removed;
51
- changed += s.changed;
47
+ if (this._statsCache === undefined) {
48
+ let added = this._added.length;
49
+ let removed = this._removed.length;
50
+ let changed = 0;
51
+ for (const diff of Object.values(this._conceptDiffs)) {
52
+ const s = diff.stats;
53
+ added += s.added;
54
+ removed += s.removed;
55
+ changed += s.changed;
56
+ }
57
+ this._statsCache = new DiffStats({ added, removed, changed });
52
58
  }
53
- return new DiffStats({ added, removed, changed });
59
+ return this._statsCache;
54
60
  }
55
61
 
56
62
  get similarity() {
57
- if (this._matched.length === 0) return 0;
58
- let sum = 0;
59
- let n = 0;
60
- for (const entry of this._matched) {
61
- const id = entry.value;
62
- const diff = this._conceptDiffs[id];
63
- sum += diff ? diff.similarity : 1.0;
64
- n++;
63
+ if (this._similarityCache === undefined) {
64
+ if (this._matched.length === 0) {
65
+ this._similarityCache = 0;
66
+ } else {
67
+ const values = this._matched.map(entry => {
68
+ const id = entry.value;
69
+ const diff = this._conceptDiffs[id];
70
+ return diff ? diff.similarity : 1.0;
71
+ });
72
+ this._similarityCache = averageSimilarity(values);
73
+ }
65
74
  }
66
- return n > 0 ? Math.round((sum / n) * 10000) / 10000 : 1.0;
75
+ return this._similarityCache;
67
76
  }
68
77
 
69
78
  *walk() {
@@ -134,7 +143,7 @@ export function diffConceptCollections(oldCollection, newCollection, options = {
134
143
  return new ConceptCollectionDiff({
135
144
  oldCount: oldConcepts.length,
136
145
  newCount: newConcepts.length,
137
- matched: matchedIds.map(id => new Added({ value: id })),
146
+ matched: matchedIds.map(id => new Matched({ value: id })),
138
147
  added: addedIds.map(id => new Added({ value: id })),
139
148
  removed: removedIds.map(id => new Removed({ value: id })),
140
149
  conceptDiffs,
@@ -158,7 +167,30 @@ function conceptsEqual(a, b) {
158
167
  return JSON.stringify(a?.toJSON?.()) === JSON.stringify(b?.toJSON?.());
159
168
  }
160
169
 
161
- function wrapIdList(data) {
170
+ // `wrapIdList` deserializes each entry via deserializeChange, which
171
+ // dispatches on `type`. The `expectedClass` parameter is a defensive
172
+ // check: if the entry's deserialized type doesn't match the slot it's
173
+ // being loaded into, throw. Prevents the round-1 bug where removed
174
+ // entries silently became Added after a JSON round-trip.
175
+ function wrapIdList(data, expectedClass) {
162
176
  if (!data) return [];
163
- return data.map(c => c instanceof Added || c instanceof Removed ? c : Added.fromJSON(c));
177
+ return data.map(c => {
178
+ if (c instanceof Added || c instanceof Removed || c instanceof Matched) {
179
+ if (!(c instanceof expectedClass)) {
180
+ throw new Error(
181
+ `ConceptCollectionDiff: entry type ${c.constructor.name} ` +
182
+ `does not match slot ${expectedClass.name}`,
183
+ );
184
+ }
185
+ return c;
186
+ }
187
+ const deserialized = deserializeChange(c);
188
+ if (!(deserialized instanceof expectedClass)) {
189
+ throw new Error(
190
+ `ConceptCollectionDiff: deserialized entry type ` +
191
+ `${deserialized.constructor.name} does not match slot ${expectedClass.name}`,
192
+ );
193
+ }
194
+ return deserialized;
195
+ });
164
196
  }