glossarist 0.4.19 → 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 +5 -0
- 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
package/src/diff/index.d.ts
CHANGED
|
@@ -29,8 +29,13 @@ export function diffText(oldText: string | null | undefined, newText: string | n
|
|
|
29
29
|
export const CHANGE_ADDED: 'added';
|
|
30
30
|
export const CHANGE_REMOVED: 'removed';
|
|
31
31
|
export const CHANGE_CHANGED: 'changed';
|
|
32
|
+
export const CHANGE_MATCHED: 'matched';
|
|
32
33
|
|
|
33
|
-
export type ChangeType =
|
|
34
|
+
export type ChangeType =
|
|
35
|
+
| typeof CHANGE_ADDED
|
|
36
|
+
| typeof CHANGE_REMOVED
|
|
37
|
+
| typeof CHANGE_CHANGED
|
|
38
|
+
| typeof CHANGE_MATCHED;
|
|
34
39
|
|
|
35
40
|
export class Change extends GlossaristModel {
|
|
36
41
|
readonly type: ChangeType;
|
|
@@ -66,6 +71,13 @@ export class Changed extends Change {
|
|
|
66
71
|
static fromJSON(data: Record<string, unknown>): Changed;
|
|
67
72
|
}
|
|
68
73
|
|
|
74
|
+
export class Matched extends Change {
|
|
75
|
+
readonly type: 'matched';
|
|
76
|
+
readonly value: unknown;
|
|
77
|
+
toJSON(): { type: 'matched'; path?: string; value: unknown };
|
|
78
|
+
static fromJSON(data: Record<string, unknown>): Matched;
|
|
79
|
+
}
|
|
80
|
+
|
|
69
81
|
export function deserializeChange(data: Record<string, unknown>): Change;
|
|
70
82
|
|
|
71
83
|
// ── List diff ──────────────────────────────────────────────────────────
|
|
@@ -126,6 +138,7 @@ export class ConceptLevelDiff extends GlossaristModel {
|
|
|
126
138
|
readonly sources: ListDiff;
|
|
127
139
|
readonly dates: ListDiff;
|
|
128
140
|
readonly relatedConcepts: ListDiff;
|
|
141
|
+
readonly partitiveHyperedges: ListDiff;
|
|
129
142
|
readonly groups: ListDiff;
|
|
130
143
|
readonly sections: ListDiff;
|
|
131
144
|
readonly tags: ListDiff;
|
|
@@ -147,6 +160,9 @@ export class LocalizedConceptDiff extends GlossaristModel {
|
|
|
147
160
|
readonly related: ListDiff;
|
|
148
161
|
readonly metadata: MetadataDiff;
|
|
149
162
|
readonly hasChanges: boolean;
|
|
163
|
+
readonly stats: DiffStats;
|
|
164
|
+
readonly similarity: number;
|
|
165
|
+
readonly totalItems: number;
|
|
150
166
|
walk(prefix?: string): Generator<DiffWalkEntry, void, unknown>;
|
|
151
167
|
toJSON(): Record<string, unknown>;
|
|
152
168
|
static fromJSON(data: Record<string, unknown>): LocalizedConceptDiff;
|
|
@@ -161,6 +177,8 @@ export class ConceptDiff extends GlossaristModel {
|
|
|
161
177
|
readonly hasChanges: boolean;
|
|
162
178
|
readonly localizationLanguages: string[];
|
|
163
179
|
readonly stats: DiffStats;
|
|
180
|
+
readonly similarity: number;
|
|
181
|
+
readonly totalItems: number;
|
|
164
182
|
localization(lang: string): LocalizedConceptDiff | null;
|
|
165
183
|
walk(): Generator<ConceptDiffWalkEntry, void, unknown>;
|
|
166
184
|
toJSON(): Record<string, unknown>;
|
|
@@ -177,3 +195,48 @@ export function diffLocalizedConcepts(
|
|
|
177
195
|
oldLoc: any | null,
|
|
178
196
|
newLoc: any | null,
|
|
179
197
|
): LocalizedConceptDiff;
|
|
198
|
+
|
|
199
|
+
// ── Collection diff ────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
export class ConceptCollectionDiff extends GlossaristModel {
|
|
202
|
+
readonly oldCount: number;
|
|
203
|
+
readonly newCount: number;
|
|
204
|
+
readonly matched: Matched[];
|
|
205
|
+
readonly added: Added[];
|
|
206
|
+
readonly removed: Removed[];
|
|
207
|
+
readonly conceptDiffs: Record<string, ConceptDiff>;
|
|
208
|
+
readonly changedIds: string[];
|
|
209
|
+
readonly hasChanges: boolean;
|
|
210
|
+
readonly stats: DiffStats;
|
|
211
|
+
readonly similarity: number;
|
|
212
|
+
conceptDiff(id: string): ConceptDiff | null;
|
|
213
|
+
walk(): Generator<ConceptDiffWalkEntry, void, unknown>;
|
|
214
|
+
toJSON(): Record<string, unknown>;
|
|
215
|
+
static fromJSON(data: Record<string, unknown>): ConceptCollectionDiff;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function diffConceptCollections(
|
|
219
|
+
oldCollection: any | null,
|
|
220
|
+
newCollection: any | null,
|
|
221
|
+
options?: { language?: string; skipUnchanged?: boolean },
|
|
222
|
+
): ConceptCollectionDiff;
|
|
223
|
+
|
|
224
|
+
// ── Rendering ──────────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
export interface RenderOptions {
|
|
227
|
+
colors?: boolean;
|
|
228
|
+
showUnchanged?: boolean;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function renderConceptDiff(diff: ConceptDiff, options?: RenderOptions): string;
|
|
232
|
+
export function renderCollectionDiff(diff: ConceptCollectionDiff, options?: RenderOptions): string;
|
|
233
|
+
export function renderTextDiff(textDiff: TextDiff | Record<string, unknown>, options?: RenderOptions): string;
|
|
234
|
+
|
|
235
|
+
// ── Patching ───────────────────────────────────────────────────────────
|
|
236
|
+
|
|
237
|
+
export function applyDiff<T extends { toJSON(): Record<string, unknown> }>(
|
|
238
|
+
oldConcept: T,
|
|
239
|
+
diff: ConceptDiff,
|
|
240
|
+
): T;
|
|
241
|
+
|
|
242
|
+
export function reverseDiff(diff: ConceptDiff): ConceptDiff;
|
package/src/diff/index.js
CHANGED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Similarity scoring for the diff layer.
|
|
2
|
+
//
|
|
3
|
+
// `computeSimilarity(changeCount, totalItems)` returns a value in
|
|
4
|
+
// [0, 1] rounded to SIMILARITY_PRECISION_DECIMALS decimal places. The
|
|
5
|
+
// rounding matches what glossarist-ruby's diff layer produces, so
|
|
6
|
+
// similarity scores are stable across repos.
|
|
7
|
+
//
|
|
8
|
+
// `averageSimilarity(values)` averages an array of per-item similarity
|
|
9
|
+
// scores (used by ConceptCollectionDiff to roll up matched concepts).
|
|
10
|
+
|
|
11
|
+
export const SIMILARITY_PRECISION_DECIMALS = 4;
|
|
12
|
+
const _SCALE = 10 ** SIMILARITY_PRECISION_DECIMALS;
|
|
13
|
+
|
|
14
|
+
export function computeSimilarity(changeCount, totalItems) {
|
|
15
|
+
if (totalItems <= 0) return 1.0;
|
|
16
|
+
const ratio = Math.min(changeCount / totalItems, 1);
|
|
17
|
+
return Math.round((1 - ratio) * _SCALE) / _SCALE;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function averageSimilarity(values) {
|
|
21
|
+
if (values.length === 0) return 1.0;
|
|
22
|
+
const sum = values.reduce((a, b) => a + b, 0);
|
|
23
|
+
return Math.round((sum / values.length) * _SCALE) / _SCALE;
|
|
24
|
+
}
|
package/src/index.d.ts
CHANGED
|
@@ -13,6 +13,8 @@ export {
|
|
|
13
13
|
BibliographyEntry, BibliographyData,
|
|
14
14
|
GcrMetadata, GcrStatistics,
|
|
15
15
|
RELATIONSHIP_TYPES, DESIGNATION_RELATIONSHIP_TYPES, DATE_TYPES,
|
|
16
|
+
PartitiveHyperedge, PARTITIVE_ENUMERATION, PARTITIVE_ENUMERATION_VALUES,
|
|
17
|
+
PLURALITY_MARKER, PLURALITY_MARKER_VALUES,
|
|
16
18
|
} from './models/index';
|
|
17
19
|
|
|
18
20
|
// Concept diff
|
package/src/index.js
CHANGED
|
@@ -63,6 +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,
|
|
67
|
+
PLURALITY_MARKER, PLURALITY_MARKER_VALUES,
|
|
66
68
|
} from './models/index.js';
|
|
67
69
|
|
|
68
70
|
export { AssetIndex } from './validators/asset-index.js';
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// v1 → v2 PartitiveRelation migration.
|
|
2
|
+
//
|
|
3
|
+
// Pure function: takes a v1 PartitiveHyperedge hash, returns a v2
|
|
4
|
+
// PartitiveRelation hash. Idempotent. Used by the parser when loading
|
|
5
|
+
// legacy YAML; available as a public API for one-shot dataset migrations.
|
|
6
|
+
//
|
|
7
|
+
// Field mapping (per TODO.partitive-relation-v2 items 01–05):
|
|
8
|
+
//
|
|
9
|
+
// comprehensive → comprehensive (unchanged shape)
|
|
10
|
+
// parts: [ref, ...] → partitives: [{ ref, certainty: 'confirmed' }, ...]
|
|
11
|
+
// enumeration: open → completeness: partial
|
|
12
|
+
// enumeration: closed→ completeness: complete
|
|
13
|
+
// markers: [double] ┐
|
|
14
|
+
// markers: [dashed] ├→ plurality: { is_shared, is_uncertain }
|
|
15
|
+
// markers: [double, dashed] ┘
|
|
16
|
+
// content: "..." → (dropped — structural edges carry no prose)
|
|
17
|
+
//
|
|
18
|
+
// Cardinality check: v1 allowed [1..*] parts; v2 requires [2..*].
|
|
19
|
+
// A v1 hyperedge with exactly 1 part cannot migrate to v2 and is
|
|
20
|
+
// returned with a `migrationWarning` field set so callers can decide
|
|
21
|
+
// whether to surface it (the parser drops these with a warning).
|
|
22
|
+
|
|
23
|
+
const MARKER_DOUBLE = 'double';
|
|
24
|
+
const MARKER_DASHED = 'dashed';
|
|
25
|
+
|
|
26
|
+
export function migrateHyperedgeToRelation(v1Hash) {
|
|
27
|
+
if (v1Hash == null) return null;
|
|
28
|
+
if (Array.isArray(v1Hash)) {
|
|
29
|
+
return v1Hash.map(migrateHyperedgeToRelation);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const out = {
|
|
33
|
+
comprehensive: { ...(v1Hash.comprehensive ?? {}) },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const parts = Array.isArray(v1Hash.parts) ? v1Hash.parts : [];
|
|
37
|
+
out.partitives = parts.map(ref => ({
|
|
38
|
+
ref: { ...ref },
|
|
39
|
+
certainty: 'confirmed',
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
out.completeness = _migrateEnumeration(v1Hash.enumeration);
|
|
43
|
+
|
|
44
|
+
const plurality = _migrateMarkers(v1Hash.markers);
|
|
45
|
+
if (plurality != null) out.plurality = plurality;
|
|
46
|
+
|
|
47
|
+
// v1 content field is dropped per TODO.partitive-relation-v2 item 04.
|
|
48
|
+
// Caller (parser) may surface this as a warning if the text matters.
|
|
49
|
+
|
|
50
|
+
if (parts.length < 2) {
|
|
51
|
+
out.migrationWarning =
|
|
52
|
+
`v1 PartitiveHyperedge had ${parts.length} part(s); v2 requires ≥2. ` +
|
|
53
|
+
`Either add more partitives or convert to a binary has_part edge.`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (v1Hash.content != null) {
|
|
57
|
+
out.migrationWarning = (out.migrationWarning ?? '') +
|
|
58
|
+
` v1 'content' field was dropped (v2 PartitiveRelation carries no prose; ` +
|
|
59
|
+
`move valuable text to the comprehensive concept's notes).`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Inverse — v2 → v1 — exists only for tooling that must round-trip
|
|
66
|
+
// through v1 consumers. NOT used by the parser; the serializer emits
|
|
67
|
+
// v2 only.
|
|
68
|
+
export function downgradeRelationToHyperedge(v2Hash) {
|
|
69
|
+
if (v2Hash == null) return null;
|
|
70
|
+
if (Array.isArray(v2Hash)) {
|
|
71
|
+
return v2Hash.map(downgradeRelationToHyperedge);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const out = {
|
|
75
|
+
comprehensive: { ...(v2Hash.comprehensive ?? {}) },
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
out.parts = (v2Hash.partitives ?? []).map(m => ({ ...(m.ref ?? {}) }));
|
|
79
|
+
out.enumeration = _downgradeCompleteness(v2Hash.completeness);
|
|
80
|
+
|
|
81
|
+
const markers = _downgradePlurality(v2Hash.plurality);
|
|
82
|
+
if (markers.length > 0) out.markers = markers;
|
|
83
|
+
|
|
84
|
+
// criterion has no v1 equivalent — dropped silently
|
|
85
|
+
// per-member certainty has no v1 equivalent — dropped silently
|
|
86
|
+
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function _migrateEnumeration(value) {
|
|
91
|
+
if (value === 'open') return 'partial';
|
|
92
|
+
if (value === 'closed') return 'complete';
|
|
93
|
+
if (value == null) return 'complete';
|
|
94
|
+
// Unknown values default to complete (safe default — v2's safe default).
|
|
95
|
+
return 'complete';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function _downgradeCompleteness(value) {
|
|
99
|
+
if (value === 'partial') return 'open';
|
|
100
|
+
return 'closed';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function _migrateMarkers(markers) {
|
|
104
|
+
if (!Array.isArray(markers) || markers.length === 0) return null;
|
|
105
|
+
|
|
106
|
+
const isShared = markers.includes(MARKER_DOUBLE);
|
|
107
|
+
const isUncertain = markers.includes(MARKER_DASHED);
|
|
108
|
+
|
|
109
|
+
// Per TODO.partitive-relation-v2 item 03 migration table:
|
|
110
|
+
// [dashed] alone is semantically odd (broken line qualifies
|
|
111
|
+
// plurality; without double, what's being qualified?). We
|
|
112
|
+
// migrate it to is_shared=false, is_uncertain=true and rely on
|
|
113
|
+
// the PartitiveRelationCoherenceRule to warn.
|
|
114
|
+
if (!isShared && !isUncertain) return null;
|
|
115
|
+
|
|
116
|
+
return { is_shared: isShared, is_uncertain: isUncertain };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function _downgradePlurality(plurality) {
|
|
120
|
+
if (plurality == null) return [];
|
|
121
|
+
const markers = [];
|
|
122
|
+
if (plurality.is_shared) markers.push(MARKER_DOUBLE);
|
|
123
|
+
if (plurality.is_uncertain) markers.push(MARKER_DASHED);
|
|
124
|
+
return markers;
|
|
125
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Completeness — whether a PartitiveRelation's encoded partitives
|
|
2
|
+
// constitute the whole comprehensive (complete) or only some of it
|
|
3
|
+
// (partial).
|
|
4
|
+
//
|
|
5
|
+
// ISO 704 depicts this as the rake's backline: a backline ending with
|
|
6
|
+
// a tooth is complete; a backline continuing without a tooth is
|
|
7
|
+
// partial.
|
|
8
|
+
//
|
|
9
|
+
// Renamed from the v1 `PartitiveEnumeration` (closed|open) per
|
|
10
|
+
// TODO.partitive-relation-v2 item 02. The semantic claim being
|
|
11
|
+
// expressed is completeness of the decomposition, not "enumeration"
|
|
12
|
+
// (which is a neutral-sounding word that doesn't convey the claim).
|
|
13
|
+
|
|
14
|
+
import { makeEnum } from './enum.js';
|
|
15
|
+
|
|
16
|
+
export const COMPLETENESS = makeEnum('COMPLETENESS', {
|
|
17
|
+
COMPLETE: 'complete',
|
|
18
|
+
PARTIAL: 'partial',
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export const COMPLETENESS_VALUES = COMPLETENESS.VALUES;
|
|
22
|
+
export const isValidCompleteness = COMPLETENESS.isValid;
|
|
23
|
+
|
|
24
|
+
export const DEFAULT_COMPLETENESS = COMPLETENESS.COMPLETE;
|
|
@@ -15,6 +15,14 @@ export class ConceptDate extends GlossaristModel {
|
|
|
15
15
|
return this.date ? new Date(this.date) : null;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
static identityOf(value) {
|
|
19
|
+
return value?.type ?? '';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
identity() {
|
|
23
|
+
return ConceptDate.identityOf(this);
|
|
24
|
+
}
|
|
25
|
+
|
|
18
26
|
toJSON() {
|
|
19
27
|
const obj = {};
|
|
20
28
|
if (this.date != null) obj.date = this.date;
|
|
@@ -11,11 +11,18 @@ export class ConceptSource extends GlossaristModel {
|
|
|
11
11
|
? (data.origin instanceof Citation ? data.origin : new Citation(data.origin))
|
|
12
12
|
: null;
|
|
13
13
|
this.modification = data.modification ?? null;
|
|
14
|
-
this.
|
|
14
|
+
this.sourcedFrom = (data.sourcedFrom ?? data.sourced_from ?? []).map(
|
|
15
15
|
c => c instanceof Citation ? c : new Citation(c)
|
|
16
16
|
);
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
// Backward-compat alias for callers using the snake_case JS field name.
|
|
20
|
+
// Prefer `.sourcedFrom` going forward; the wire name remains
|
|
21
|
+
// `sourced_from` (see toJSON).
|
|
22
|
+
get sourced_from() {
|
|
23
|
+
return this.sourcedFrom;
|
|
24
|
+
}
|
|
25
|
+
|
|
19
26
|
toJSON() {
|
|
20
27
|
const obj = {};
|
|
21
28
|
if (this.id != null) obj.id = this.id;
|
|
@@ -23,10 +30,22 @@ export class ConceptSource extends GlossaristModel {
|
|
|
23
30
|
if (this.type != null) obj.type = this.type;
|
|
24
31
|
if (this.origin != null) obj.origin = this.origin.toJSON();
|
|
25
32
|
if (this.modification != null) obj.modification = this.modification;
|
|
26
|
-
if (this.
|
|
33
|
+
if (this.sourcedFrom.length > 0) {
|
|
34
|
+
obj.sourced_from = this.sourcedFrom.map(c => c.toJSON());
|
|
35
|
+
}
|
|
27
36
|
return obj;
|
|
28
37
|
}
|
|
29
38
|
|
|
39
|
+
static identityOf(value) {
|
|
40
|
+
const v = value ?? {};
|
|
41
|
+
const ref = v?.origin?.ref ?? (v?.origin instanceof Citation ? v.origin.ref : null);
|
|
42
|
+
return `${v.type ?? ''}|${ref?.source ?? ''}|${ref?.id ?? ''}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
identity() {
|
|
46
|
+
return ConceptSource.identityOf(this);
|
|
47
|
+
}
|
|
48
|
+
|
|
30
49
|
static fromJSON(data) {
|
|
31
50
|
return new ConceptSource(data);
|
|
32
51
|
}
|
package/src/models/concept.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { GlossaristModel } from './base.js';
|
|
2
2
|
import { LocalizedConcept } from './localized-concept.js';
|
|
3
3
|
import { RelatedConcept } from './related-concept.js';
|
|
4
|
+
import { PartitiveHyperedge } from './partitive-hyperedge.js';
|
|
5
|
+
import { PartitiveRelation } from './partitive-relation.js';
|
|
6
|
+
import { migrateHyperedgeToRelation } from '../migration/partitive-relation-migrator.js';
|
|
4
7
|
import { ConceptReference } from './concept-reference.js';
|
|
5
8
|
import { ConceptDate } from './concept-date.js';
|
|
6
9
|
import { ConceptSource } from './concept-source.js';
|
|
@@ -10,6 +13,18 @@ import { FormulaReference } from './non-verbal-references.js';
|
|
|
10
13
|
import { diffConcepts } from '../diff/concept-diff.js';
|
|
11
14
|
|
|
12
15
|
export class Concept extends GlossaristModel {
|
|
16
|
+
// Scalar fields the diff layer tracks at the concept level. Adding
|
|
17
|
+
// a new scalar metadata field requires only appending its name here;
|
|
18
|
+
// diff detection, patch application, and similarity scoring all pick
|
|
19
|
+
// it up automatically. (Invariant N2 — TODO.hyperedges-v2/07.)
|
|
20
|
+
static get DIFF_FIELDS() {
|
|
21
|
+
return Object.freeze(['status', 'term', 'uri', 'schemaVersion']);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
static wireNameFor(field) {
|
|
25
|
+
return CONCEPT_WIRE_NAMES[field] ?? field;
|
|
26
|
+
}
|
|
27
|
+
|
|
13
28
|
constructor(data = {}) {
|
|
14
29
|
super();
|
|
15
30
|
this.id = String(data.id ?? data.termid ?? '');
|
|
@@ -19,6 +34,12 @@ export class Concept extends GlossaristModel {
|
|
|
19
34
|
this._cache = {};
|
|
20
35
|
|
|
21
36
|
this.relatedConcepts = _mapInstances(data.relatedConcepts ?? data.related ?? data.related_concepts ?? [], RelatedConcept);
|
|
37
|
+
this.partitiveRelations = _resolvePartitiveRelations(data);
|
|
38
|
+
// Backward-compat alias: v1 callers reading .partitiveHyperedges
|
|
39
|
+
// get the v2 relations (the v1 model is preserved at
|
|
40
|
+
// ../partitive-hyperedge.js for direct instantiation, but Concept
|
|
41
|
+
// exposes only v2).
|
|
42
|
+
this.partitiveHyperedges = this.partitiveRelations;
|
|
22
43
|
this.domains = _normalizeDomains(data.domains, data.groups);
|
|
23
44
|
this.groups = Array.isArray(data.groups)
|
|
24
45
|
? data.groups.map(g => typeof g === 'string' ? g : (g?.id ?? g?.sectionId ?? null)).filter(Boolean)
|
|
@@ -181,6 +202,9 @@ export class Concept extends GlossaristModel {
|
|
|
181
202
|
if (this.relatedConcepts.length > 0) {
|
|
182
203
|
obj.related = this.relatedConcepts.map(rc => rc.toJSON());
|
|
183
204
|
}
|
|
205
|
+
if (this.partitiveRelations.length > 0) {
|
|
206
|
+
obj.partitive_relations = this.partitiveRelations.map(r => r.toJSON());
|
|
207
|
+
}
|
|
184
208
|
if (this.domains.length > 0) {
|
|
185
209
|
obj.domains = this.domains.map(d => d.toJSON());
|
|
186
210
|
}
|
|
@@ -210,6 +234,37 @@ function _mapInstances(arr, Cls) {
|
|
|
210
234
|
return arr.map(item => item instanceof Cls ? item : new Cls(item));
|
|
211
235
|
}
|
|
212
236
|
|
|
237
|
+
// Resolve partitive relations from any of three input shapes:
|
|
238
|
+
// 1. data.partitiveRelations (v2, preferred)
|
|
239
|
+
// 2. data.partitive_relations (v2 wire name)
|
|
240
|
+
// 3. data.partitiveHyperedges / data.partitive_hyperedges (v1, migrated)
|
|
241
|
+
//
|
|
242
|
+
// v1 input is auto-migrated to v2 via the migrator. v1 instances
|
|
243
|
+
// already constructed (rare — only direct callers) are migrated field
|
|
244
|
+
// by field. The parser produces plain hashes so the common path is (3).
|
|
245
|
+
function _resolvePartitiveRelations(data) {
|
|
246
|
+
if (Array.isArray(data.partitiveRelations)) {
|
|
247
|
+
return data.partitiveRelations.map(r =>
|
|
248
|
+
r instanceof PartitiveRelation ? r : new PartitiveRelation(r));
|
|
249
|
+
}
|
|
250
|
+
if (Array.isArray(data.partitive_relations)) {
|
|
251
|
+
return data.partitive_relations.map(r => new PartitiveRelation(r));
|
|
252
|
+
}
|
|
253
|
+
const v1 = data.partitiveHyperedges ?? data.partitive_hyperedges;
|
|
254
|
+
if (Array.isArray(v1)) {
|
|
255
|
+
return v1
|
|
256
|
+
.map(h => h instanceof PartitiveHyperedge ? h.toJSON() : h)
|
|
257
|
+
.map(migrateHyperedgeToRelation)
|
|
258
|
+
.filter(h => h != null)
|
|
259
|
+
.map(h => new PartitiveRelation(h));
|
|
260
|
+
}
|
|
261
|
+
return [];
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const CONCEPT_WIRE_NAMES = Object.freeze({
|
|
265
|
+
schemaVersion: 'schema_version',
|
|
266
|
+
});
|
|
267
|
+
|
|
213
268
|
function _normalizeDomains(domains, groups) {
|
|
214
269
|
if (domains) {
|
|
215
270
|
return domains.map(d => d instanceof ConceptReference ? d : new ConceptReference(d));
|
|
@@ -91,6 +91,21 @@ export class Designation extends RegistrableModel {
|
|
|
91
91
|
return SKOS_LABEL_BY_NORMATIVE_STATUS[status] ?? 'altLabel';
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
// Static identity works on a model instance OR its plain JSON form.
|
|
95
|
+
// The diff layer uses this so that patch-side identity (computed on
|
|
96
|
+
// plain JSON from toJSON) matches diff-side identity (computed on
|
|
97
|
+
// model instances).
|
|
98
|
+
static identityOf(value) {
|
|
99
|
+
const v = value ?? {};
|
|
100
|
+
const type = v.type ?? 'expression';
|
|
101
|
+
const text = String(v.designation ?? '').toLowerCase().trim();
|
|
102
|
+
return `${type}|${text}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
identity() {
|
|
106
|
+
return Designation.identityOf(this);
|
|
107
|
+
}
|
|
108
|
+
|
|
94
109
|
static fromJSON(data) {
|
|
95
110
|
return Designation.fromData(data);
|
|
96
111
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { GlossaristModel } from './base.js';
|
|
2
2
|
import { Citation } from './citation.js';
|
|
3
|
+
import { canonicalJson } from '../diff/canonical-json.js';
|
|
3
4
|
|
|
4
5
|
export class DetailedDefinition extends GlossaristModel {
|
|
5
6
|
constructor(data = {}) {
|
|
@@ -42,6 +43,16 @@ export class DetailedDefinition extends GlossaristModel {
|
|
|
42
43
|
}
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
static identityOf(value) {
|
|
47
|
+
if (value == null) return '';
|
|
48
|
+
if (typeof value.toJSON === 'function') return canonicalJson(value.toJSON());
|
|
49
|
+
return canonicalJson(value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
identity() {
|
|
53
|
+
return DetailedDefinition.identityOf(this);
|
|
54
|
+
}
|
|
55
|
+
|
|
45
56
|
static fromJSON(data) {
|
|
46
57
|
return new DetailedDefinition(data);
|
|
47
58
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Enum factory — produces a frozen KEY/VALUES bundle, a frozen values
|
|
2
|
+
// array, and an isValid* predicate from a single `makeEnum` call.
|
|
3
|
+
//
|
|
4
|
+
// Adding a new enum means one line:
|
|
5
|
+
//
|
|
6
|
+
// export const COLOR = makeEnum('COLOR', { RED: 'red', BLUE: 'blue' });
|
|
7
|
+
//
|
|
8
|
+
// The factory returns:
|
|
9
|
+
// {
|
|
10
|
+
// CLOSED: 'closed', // exposed by destructuring
|
|
11
|
+
// OPEN: 'open',
|
|
12
|
+
// VALUES: ['closed', 'open'], // frozen array
|
|
13
|
+
// isValid: (v) => boolean, // predicate
|
|
14
|
+
// }
|
|
15
|
+
//
|
|
16
|
+
// We attach the bundle via Object.assign on a frozen object so the
|
|
17
|
+
// bundle is itself frozen and exposes both the per-value constants
|
|
18
|
+
// (CLOSED, OPEN, ...) and the VALUES list + isValid predicate.
|
|
19
|
+
|
|
20
|
+
export function makeEnum(name, values) {
|
|
21
|
+
if (typeof name !== 'string') {
|
|
22
|
+
throw new Error(`makeEnum: name must be a string, got ${typeof name}`);
|
|
23
|
+
}
|
|
24
|
+
if (typeof values !== 'object' || values === null) {
|
|
25
|
+
throw new Error(`makeEnum: values must be an object, got ${typeof values}`);
|
|
26
|
+
}
|
|
27
|
+
for (const [k, v] of Object.entries(values)) {
|
|
28
|
+
if (typeof v !== 'string') {
|
|
29
|
+
throw new Error(`makeEnum(${name}): value for ${k} must be a string, got ${typeof v}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const frozen = Object.freeze({ ...values });
|
|
34
|
+
const valuesArr = Object.freeze(Object.values(frozen));
|
|
35
|
+
const isValid = (v) => valuesArr.includes(v);
|
|
36
|
+
|
|
37
|
+
return Object.assign(Object.create(null), frozen, {
|
|
38
|
+
name,
|
|
39
|
+
VALUES: valuesArr,
|
|
40
|
+
isValid,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Hyperedge color resolver — resolves display colors for a
|
|
2
|
+
// PartitiveHyperedge based on its enumeration and the carrying
|
|
3
|
+
// concept's category color.
|
|
4
|
+
//
|
|
5
|
+
// Closed hyperedges render at full intensity (the relationship is
|
|
6
|
+
// definitive — these are ALL the parts). Open hyperedges render at
|
|
7
|
+
// reduced intensity (the relationship is partial — other parts may
|
|
8
|
+
// exist).
|
|
9
|
+
//
|
|
10
|
+
// Marker-based styling (double = thicker stroke, dashed = dashed
|
|
11
|
+
// pattern) is the caller's responsibility; this resolver returns
|
|
12
|
+
// the color only, plus a `pattern` hint for marker-based styling.
|
|
13
|
+
|
|
14
|
+
import { categoryColorPair } from './relation-colors.js';
|
|
15
|
+
|
|
16
|
+
const ENUMERATION_INTENSITY = Object.freeze({
|
|
17
|
+
closed: 1.0,
|
|
18
|
+
open: 0.55,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const DEFAULT_CATEGORY = 'hierarchical';
|
|
22
|
+
|
|
23
|
+
export function resolveHyperedgeColor(hyperedge, options = {}) {
|
|
24
|
+
if (!hyperedge) return null;
|
|
25
|
+
|
|
26
|
+
const category = options.category ?? DEFAULT_CATEGORY;
|
|
27
|
+
const base = categoryColorPair(category, options.overrides);
|
|
28
|
+
if (!base) return null;
|
|
29
|
+
|
|
30
|
+
const intensity = ENUMERATION_INTENSITY[hyperedge.enumeration] ?? 1.0;
|
|
31
|
+
const pattern = derivePattern(hyperedge.markers);
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
light: tint(base.light, intensity),
|
|
35
|
+
dark: tint(base.dark, intensity),
|
|
36
|
+
pattern,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function derivePattern(markers) {
|
|
41
|
+
const arr = markers ?? [];
|
|
42
|
+
if (arr.includes('dashed')) return 'dashed';
|
|
43
|
+
if (arr.includes('double')) return 'double';
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Linear interpolation between the base color and white (light mode)
|
|
48
|
+
// or between the base and a neutral (dark mode). Pure-string inputs
|
|
49
|
+
// return the original color when intensity === 1.0.
|
|
50
|
+
function tint(hex, ratio) {
|
|
51
|
+
if (ratio >= 1.0) return hex;
|
|
52
|
+
const rgb = parseHex(hex);
|
|
53
|
+
if (!rgb) return hex;
|
|
54
|
+
|
|
55
|
+
const target = [255, 255, 255];
|
|
56
|
+
const mixed = rgb.map((c, i) =>
|
|
57
|
+
Math.round(c + (target[i] - c) * (1 - ratio)));
|
|
58
|
+
return '#' + mixed.map(c => c.toString(16).padStart(2, '0')).join('');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parseHex(s) {
|
|
62
|
+
if (typeof s !== 'string') return null;
|
|
63
|
+
const m = s.match(/^#?([0-9a-f]{6})$/i);
|
|
64
|
+
if (!m) return null;
|
|
65
|
+
const n = m[1];
|
|
66
|
+
return [
|
|
67
|
+
parseInt(n.slice(0, 2), 16),
|
|
68
|
+
parseInt(n.slice(2, 4), 16),
|
|
69
|
+
parseInt(n.slice(4, 6), 16),
|
|
70
|
+
];
|
|
71
|
+
}
|