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
@@ -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 = typeof CHANGE_ADDED | typeof CHANGE_REMOVED | typeof CHANGE_CHANGED;
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
@@ -9,9 +9,11 @@ export {
9
9
  Added,
10
10
  Removed,
11
11
  Changed,
12
+ Matched,
12
13
  CHANGE_ADDED,
13
14
  CHANGE_REMOVED,
14
15
  CHANGE_CHANGED,
16
+ CHANGE_MATCHED,
15
17
  deserializeChange,
16
18
  } from './change.js';
17
19
 
@@ -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,99 @@
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 and the
8
+ // ISO 704:2022 correction):
9
+ //
10
+ // comprehensive → comprehensive (unchanged shape)
11
+ // parts: [ref, ...] → partitives: [{ ref, multiplicity: 'compulsory' }, ...]
12
+ // enumeration: open → completeness: partial
13
+ // enumeration: closed→ completeness: complete
14
+ // markers: [...] → (dropped — v2 uses per-member multiplicity
15
+ // + is_delimiting; v1 markers encoded the
16
+ // same information imprecisely and cannot
17
+ // round-trip losslessly)
18
+ // content: "..." → (dropped — structural edges carry no prose)
19
+ //
20
+ // Cardinality check: v1 allowed [1..*] parts; v2 requires [2..*].
21
+ // A v1 hyperedge with exactly 1 part cannot migrate to v2 and is
22
+ // returned with a `migrationWarning` field set so callers can decide
23
+ // whether to surface it (the parser drops these with a warning).
24
+
25
+ export function migrateHyperedgeToRelation(v1Hash) {
26
+ if (v1Hash == null) return null;
27
+ if (Array.isArray(v1Hash)) {
28
+ return v1Hash.map(migrateHyperedgeToRelation);
29
+ }
30
+
31
+ const out = {
32
+ comprehensive: { ...(v1Hash.comprehensive ?? {}) },
33
+ };
34
+
35
+ const parts = Array.isArray(v1Hash.parts) ? v1Hash.parts : [];
36
+ out.partitives = parts.map(ref => ({
37
+ ref: { ...ref },
38
+ multiplicity: 'compulsory',
39
+ }));
40
+
41
+ out.completeness = _migrateEnumeration(v1Hash.enumeration);
42
+
43
+ // v1 content + markers fields are dropped per TODO.partitive-relation-v2.
44
+ if (parts.length < 2) {
45
+ out.migrationWarning =
46
+ `v1 PartitiveHyperedge had ${parts.length} part(s); v2 requires ≥2. ` +
47
+ `Either add more partitives or convert to a binary has_part edge.`;
48
+ }
49
+
50
+ if (v1Hash.content != null) {
51
+ out.migrationWarning = (out.migrationWarning ?? '') +
52
+ ` v1 'content' field was dropped (v2 PartitiveRelation carries no prose; ` +
53
+ `move valuable text to the comprehensive concept's notes).`;
54
+ }
55
+
56
+ if (Array.isArray(v1Hash.markers) && v1Hash.markers.length > 0) {
57
+ out.migrationWarning = (out.migrationWarning ?? '') +
58
+ ` v1 'markers' field (${v1Hash.markers.join(', ')}) was dropped; ` +
59
+ `v2 uses per-member 'multiplicity' + 'is_delimiting'. Reviewer ` +
60
+ `should set these per partitive based on ISO 704:2022 notation.`;
61
+ }
62
+
63
+ return out;
64
+ }
65
+
66
+ // Inverse — v2 → v1 — exists only for tooling that must round-trip
67
+ // through v1 consumers. NOT used by the parser; the serializer emits
68
+ // v2 only. Lossy: drops criterion, multiplicity, is_delimiting.
69
+ export function downgradeRelationToHyperedge(v2Hash) {
70
+ if (v2Hash == null) return null;
71
+ if (Array.isArray(v2Hash)) {
72
+ return v2Hash.map(downgradeRelationToHyperedge);
73
+ }
74
+
75
+ const out = {
76
+ comprehensive: { ...(v2Hash.comprehensive ?? {}) },
77
+ };
78
+
79
+ out.parts = (v2Hash.partitives ?? []).map(m => ({ ...(m.ref ?? {}) }));
80
+ out.enumeration = _downgradeCompleteness(v2Hash.completeness);
81
+
82
+ // criterion, multiplicity, is_delimiting have no v1 equivalent —
83
+ // dropped silently.
84
+
85
+ return out;
86
+ }
87
+
88
+ function _migrateEnumeration(value) {
89
+ if (value === 'open') return 'partial';
90
+ if (value === 'closed') return 'complete';
91
+ if (value == null) return 'complete';
92
+ // Unknown values default to complete (safe default — v2's safe default).
93
+ return 'complete';
94
+ }
95
+
96
+ function _downgradeCompleteness(value) {
97
+ if (value === 'partial') return 'open';
98
+ return 'closed';
99
+ }
@@ -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;
@@ -26,6 +26,10 @@ export class ConceptRef extends GlossaristModel {
26
26
  return obj;
27
27
  }
28
28
 
29
+ identity() {
30
+ return `${this.source ?? ''}|${this.id ?? ''}`;
31
+ }
32
+
29
33
  static fromJSON(data) {
30
34
  return new ConceptRef(data);
31
35
  }
@@ -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.sourced_from = (data.sourced_from ?? []).map(
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.sourced_from.length > 0) obj.sourced_from = this.sourced_from.map(c => c.toJSON());
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
  }
@@ -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
+ }