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.
- package/data/concept-model/shapes/glossarist.shacl.ttl +151 -1
- package/package.json +1 -1
- package/src/concept-diff.js +13 -0
- package/src/concept-parser.js +52 -2
- package/src/concept-serializer.js +11 -0
- package/src/diff/canonical-json.js +31 -0
- package/src/diff/change.js +28 -0
- package/src/diff/collection-diff.js +57 -25
- package/src/diff/concept-diff.js +100 -60
- package/src/diff/diff-patch.js +49 -48
- package/src/diff/diff-renderer.js +34 -0
- package/src/diff/identity.js +22 -0
- package/src/diff/index.d.ts +64 -1
- package/src/diff/index.js +2 -0
- package/src/diff/similarity.js +24 -0
- package/src/index.d.ts +2 -0
- package/src/index.js +2 -0
- package/src/migration/partitive-relation-migrator.js +125 -0
- package/src/models/completeness.js +24 -0
- package/src/models/concept-date.js +8 -0
- package/src/models/concept-ref.js +4 -0
- package/src/models/concept-source.js +21 -2
- package/src/models/concept.js +55 -0
- package/src/models/designation.js +15 -0
- package/src/models/detailed-definition.js +11 -0
- package/src/models/enum.js +42 -0
- package/src/models/hyperedge-colors.js +71 -0
- package/src/models/index.d.ts +61 -0
- package/src/models/index.js +39 -0
- package/src/models/localized-concept.js +29 -0
- package/src/models/member-certainty.js +21 -0
- package/src/models/partitive-enumeration.js +23 -0
- package/src/models/partitive-hyperedge.js +175 -0
- package/src/models/partitive-member.js +79 -0
- package/src/models/partitive-relation.js +158 -0
- package/src/models/plurality-marker.js +24 -0
- package/src/models/related-concept.js +38 -1
- package/src/models/relation-categories.js +6 -0
- package/src/models/type-shared-plurality.js +71 -0
- package/src/rdf/gloss-concept.js +26 -0
- package/src/rdf/gloss-hyperedge.js +90 -0
- package/src/rdf/gloss-partitive-relation.js +109 -0
- package/src/rdf/gloss-source.js +1 -1
- package/src/rdf/index.d.ts +37 -0
- package/src/rdf/index.js +2 -0
- package/src/rdf/predicates.d.ts +54 -0
- package/src/rdf/predicates.js +27 -0
- package/src/reference-resolver.js +49 -0
- package/src/validators/binary-has-part-redundancy-rule.js +74 -0
- package/src/validators/external-concept-shape-rule.js +60 -0
- package/src/validators/index.js +19 -1
- package/src/validators/partitive-drift-rule.js +51 -0
- package/src/validators/partitive-hyperedge-rule.js +75 -0
- package/src/validators/partitive-relation-coherence-rule.js +107 -0
- 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/
|
|
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
package/src/concept-diff.js
CHANGED
|
@@ -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';
|
package/src/concept-parser.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
+
}
|
package/src/diff/change.js
CHANGED
|
@@ -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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
59
|
+
return this._statsCache;
|
|
54
60
|
}
|
|
55
61
|
|
|
56
62
|
get similarity() {
|
|
57
|
-
if (this.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =>
|
|
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
|
}
|