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
@@ -100,6 +100,7 @@ export class Concept extends GlossaristModel {
100
100
  readonly localizations: Record<string, any>;
101
101
  readonly raw: Record<string, unknown> | null;
102
102
  readonly relatedConcepts: RelatedConcept[];
103
+ readonly partitiveHyperedges: PartitiveHyperedge[];
103
104
  readonly domains: ConceptReference[];
104
105
  readonly tags: string[];
105
106
  readonly dates: ConceptDate[];
@@ -257,6 +258,8 @@ export class ConceptSource extends GlossaristModel {
257
258
  readonly type: string | null;
258
259
  readonly origin: Citation | null;
259
260
  readonly modification: string | null;
261
+ readonly sourcedFrom: Citation[];
262
+ /** @deprecated use sourcedFrom */
260
263
  readonly sourced_from: Citation[];
261
264
  }
262
265
 
@@ -267,6 +270,64 @@ export class RelatedConcept extends GlossaristModel {
267
270
  readonly ref: ConceptRef | null;
268
271
  }
269
272
 
273
+ export interface LocalizedContent {
274
+ readonly [language: string]: string;
275
+ }
276
+
277
+ export const PARTITIVE_ENUMERATION: Readonly<{
278
+ readonly CLOSED: 'closed';
279
+ readonly OPEN: 'open';
280
+ }>;
281
+ export const PARTITIVE_ENUMERATION_VALUES: readonly ['closed', 'open'];
282
+ export function isValidPartitiveEnumeration(value: unknown): value is 'closed' | 'open';
283
+
284
+ export const PLURALITY_MARKER: Readonly<{
285
+ readonly DOUBLE: 'double';
286
+ readonly DASHED: 'dashed';
287
+ }>;
288
+ export const PLURALITY_MARKER_VALUES: readonly ['double', 'dashed'];
289
+ export function isValidPluralityMarker(value: unknown): value is 'double' | 'dashed';
290
+
291
+ export function makeEnum<T extends string>(
292
+ name: string,
293
+ values: Record<string, T>,
294
+ ): Readonly<Record<string, T>> & {
295
+ readonly name: string;
296
+ readonly VALUES: readonly T[];
297
+ readonly isValid: (value: unknown) => value is T;
298
+ };
299
+
300
+ export interface HyperedgeColorPair {
301
+ readonly light: string;
302
+ readonly dark: string;
303
+ readonly pattern: 'dashed' | 'double' | null;
304
+ }
305
+
306
+ export function resolveHyperedgeColor(
307
+ hyperedge: { enumeration?: string; markers?: string[] } | null,
308
+ options?: {
309
+ category?: string;
310
+ overrides?: { byCategory?: Record<string, unknown> };
311
+ },
312
+ ): HyperedgeColorPair | null;
313
+
314
+ export class PartitiveHyperedge extends GlossaristModel {
315
+ readonly comprehensive: ConceptRef;
316
+ readonly parts: ConceptRef[];
317
+ readonly enumeration: 'closed' | 'open';
318
+ readonly markers: ('double' | 'dashed')[];
319
+ readonly content: LocalizedContent | null;
320
+
321
+ readonly isMarked: boolean;
322
+ readonly contentString: string | null;
323
+
324
+ hasMarker(value: string): boolean;
325
+ hasEnumeration(value: string): boolean;
326
+ identity(): string;
327
+ toJSON(): Record<string, unknown>;
328
+ static fromJSON(data: Record<string, unknown>): PartitiveHyperedge;
329
+ }
330
+
270
331
  export const DESIGNATION_RELATIONSHIP_TYPES: readonly string[];
271
332
  export class DesignationRelationship extends GlossaristModel {
272
333
  readonly type: string | null;
@@ -21,6 +21,44 @@ export { Citation } from './citation.js';
21
21
  export { ConceptRef } from './concept-ref.js';
22
22
  export { ConceptSource } from './concept-source.js';
23
23
  export { RelatedConcept, RELATIONSHIP_TYPES } from './related-concept.js';
24
+ export { PartitiveHyperedge } from './partitive-hyperedge.js';
25
+ export {
26
+ PARTITIVE_ENUMERATION,
27
+ PARTITIVE_ENUMERATION_VALUES,
28
+ isValidPartitiveEnumeration,
29
+ } from './partitive-enumeration.js';
30
+ export {
31
+ PLURALITY_MARKER,
32
+ PLURALITY_MARKER_VALUES,
33
+ isValidPluralityMarker,
34
+ } from './plurality-marker.js';
35
+
36
+ // v2 PartitiveRelation models (TODO.partitive-relation-v2). These
37
+ // supersede the v1 hyperedge models above; the v1 models are kept for
38
+ // backward-compat input. New code should use v2 exclusively.
39
+ export { PartitiveRelation } from './partitive-relation.js';
40
+ export { PartitiveMember } from './partitive-member.js';
41
+ export {
42
+ COMPLETENESS,
43
+ COMPLETENESS_VALUES,
44
+ DEFAULT_COMPLETENESS,
45
+ isValidCompleteness,
46
+ } from './completeness.js';
47
+ export {
48
+ MULTIPLICITY,
49
+ MULTIPLICITY_VALUES,
50
+ DEFAULT_MULTIPLICITY,
51
+ isValidMultiplicity,
52
+ } from './multiplicity.js';
53
+
54
+ // v1 → v2 migration helper. Pure function; idempotent.
55
+ export {
56
+ migrateHyperedgeToRelation,
57
+ downgradeRelationToHyperedge,
58
+ } from '../migration/partitive-relation-migrator.js';
59
+
60
+ export { makeEnum } from './enum.js';
61
+ export { resolveHyperedgeColor } from './hyperedge-colors.js';
24
62
  export { DesignationRelationship, DESIGNATION_RELATIONSHIP_TYPES } from './designation-relationship.js';
25
63
  export { ConceptReference } from './concept-reference.js';
26
64
  export { ConceptDate, DATE_TYPES } from './concept-date.js';
@@ -10,7 +10,36 @@ function wrapAs(Cls) {
10
10
  return item => item instanceof Cls ? item : new Cls(item);
11
11
  }
12
12
 
13
+ const LOC_WIRE_NAMES = Object.freeze({
14
+ entryStatus: 'entry_status',
15
+ reviewType: 'review_type',
16
+ lineageSourceSimilarity: 'lineage_source_similarity',
17
+ reviewDate: 'review_date',
18
+ reviewDecisionDate: 'review_decision_date',
19
+ reviewDecisionEvent: 'review_decision_event',
20
+ reviewStatus: 'review_status',
21
+ reviewDecision: 'review_decision',
22
+ reviewDecisionNotes: 'review_decision_notes',
23
+ });
24
+
13
25
  export class LocalizedConcept extends GlossaristModel {
26
+ // Scalar metadata fields the diff layer tracks per localization.
27
+ // Adding a new scalar field requires only appending its name here;
28
+ // diff/patch/similarity pick it up automatically.
29
+ // (Invariant N2 — TODO.hyperedges-v2/07.)
30
+ static get DIFF_FIELDS() {
31
+ return Object.freeze([
32
+ 'entryStatus', 'classification', 'reviewType', 'domain', 'release',
33
+ 'lineageSourceSimilarity', 'script', 'system',
34
+ 'reviewDate', 'reviewDecisionDate', 'reviewDecisionEvent',
35
+ 'reviewStatus', 'reviewDecision', 'reviewDecisionNotes',
36
+ ]);
37
+ }
38
+
39
+ static wireNameFor(field) {
40
+ return LOC_WIRE_NAMES[field] ?? field;
41
+ }
42
+
14
43
  constructor(data = {}) {
15
44
  super();
16
45
  this.languageCode = data.language_code ?? data.languageCode ?? null;
@@ -0,0 +1,24 @@
1
+ // Multiplicity — ISO 704:2022 partitive member multiplicity.
2
+ //
3
+ // Encodes the diagram line notation as structured data:
4
+ // compulsory — 1 solid line (must exist in every instance)
5
+ // optional — 1 dashed line (exists in some instances only)
6
+ // compulsory_multiple — 2 solid lines (multiple must exist)
7
+ // optional_multiple — 2 dashed lines (multiple may exist)
8
+ // at_least_one — 1 solid + 1 dashed line (at least one must exist)
9
+ //
10
+ // Defaults to `compulsory` when omitted.
11
+
12
+ import { makeEnum } from './enum.js';
13
+
14
+ export const MULTIPLICITY = makeEnum('MULTIPLICITY', {
15
+ COMPULSORY: 'compulsory',
16
+ OPTIONAL: 'optional',
17
+ COMPULSORY_MULTIPLE: 'compulsory_multiple',
18
+ OPTIONAL_MULTIPLE: 'optional_multiple',
19
+ AT_LEAST_ONE: 'at_least_one',
20
+ });
21
+
22
+ export const MULTIPLICITY_VALUES = MULTIPLICITY.VALUES;
23
+ export const DEFAULT_MULTIPLICITY = MULTIPLICITY.COMPULSORY;
24
+ export const isValidMultiplicity = MULTIPLICITY.isValid;
@@ -0,0 +1,23 @@
1
+ // PartitiveEnumeration — allowed values for PartitiveHyperedge#enumeration.
2
+ //
3
+ // closed — the encoded parts are ALL the parts of the comprehensive.
4
+ // Adding or removing a part would change the relationship.
5
+ // open — the encoded parts are SOME of the parts; others may exist
6
+ // but are not encoded.
7
+ //
8
+ // See concept-model/TODO.hyperedge/00-design-overview.md for the design
9
+ // rationale and the open/closed distinction.
10
+
11
+ export const PARTITIVE_ENUMERATION = Object.freeze({
12
+ CLOSED: 'closed',
13
+ OPEN: 'open',
14
+ });
15
+
16
+ export const PARTITIVE_ENUMERATION_VALUES = Object.freeze([
17
+ PARTITIVE_ENUMERATION.CLOSED,
18
+ PARTITIVE_ENUMERATION.OPEN,
19
+ ]);
20
+
21
+ export function isValidPartitiveEnumeration(value) {
22
+ return PARTITIVE_ENUMERATION_VALUES.includes(value);
23
+ }
@@ -0,0 +1,175 @@
1
+ // PartitiveHyperedge — a one-to-many partitive decomposition.
2
+ //
3
+ // One comprehensive concept (the whole) is related to one or more
4
+ // parts as a SINGLE relationship. Captures invariants that binary
5
+ // RelatedConcept edges cannot:
6
+ //
7
+ // - which comprehensive owns which parts (set membership)
8
+ // - plurality markers from the source diagram (double / dashed)
9
+ // - enumeration completeness (closed: all parts listed; open:
10
+ // other parts may exist)
11
+ //
12
+ // See concept-model/TODO.hyperedge/00-design-overview.md for the
13
+ // design rationale.
14
+ //
15
+ // Hyperedges coexist with binary `broader_partitive` /
16
+ // `narrower_partitive` edges. Use a binary edge for pairwise
17
+ // relationships without diagram metadata; use a hyperedge when the
18
+ // relationship is one-to-many with enumeration/marker metadata.
19
+
20
+ import { GlossaristModel } from './base.js';
21
+ import { ConceptRef } from './concept-ref.js';
22
+ import {
23
+ PARTITIVE_ENUMERATION,
24
+ PARTITIVE_ENUMERATION_VALUES,
25
+ isValidPartitiveEnumeration,
26
+ } from './partitive-enumeration.js';
27
+ import {
28
+ PLURALITY_MARKER_VALUES,
29
+ isValidPluralityMarker,
30
+ } from './plurality-marker.js';
31
+
32
+ const DEFAULT_ENUMERATION = PARTITIVE_ENUMERATION.CLOSED;
33
+
34
+ export class PartitiveHyperedge extends GlossaristModel {
35
+ constructor(data = {}) {
36
+ super();
37
+ this.comprehensive = ensureConceptRef(data.comprehensive, 'comprehensive');
38
+ this.parts = ensureConceptRefArray(data.parts, 'parts');
39
+ this.enumeration = resolveEnumeration(data.enumeration);
40
+ this.markers = validateMarkers(data.markers ?? []);
41
+ this.content = normalizeContent(data.content);
42
+
43
+ assertNonEmptyParts(this.parts);
44
+ assertNoSelfLoop(this.comprehensive, this.parts);
45
+ }
46
+
47
+ get isClosed() { return this.enumeration === PARTITIVE_ENUMERATION.CLOSED; }
48
+ get isOpen() { return this.enumeration === PARTITIVE_ENUMERATION.OPEN; }
49
+ get isMarked() { return this.markers.length > 0; }
50
+
51
+ hasMarker(value) {
52
+ return this.markers.includes(value);
53
+ }
54
+
55
+ hasEnumeration(value) {
56
+ return this.enumeration === value;
57
+ }
58
+
59
+ get contentString() {
60
+ if (!this.content) return null;
61
+ const values = Object.values(this.content);
62
+ return values.length > 0 ? values[0] : null;
63
+ }
64
+
65
+ toJSON() {
66
+ const obj = {
67
+ comprehensive: this.comprehensive.toJSON(),
68
+ parts: this.parts.map(p => p.toJSON()),
69
+ enumeration: this.enumeration,
70
+ };
71
+ if (this.markers.length > 0) obj.markers = [...this.markers];
72
+ if (this.content != null) obj.content = this.content;
73
+ return obj;
74
+ }
75
+
76
+ static identityOf(value) {
77
+ const v = value ?? {};
78
+ const c = v.comprehensive ?? {};
79
+ const parts = Array.isArray(v.parts) ? v.parts : [];
80
+ const partsStr = parts
81
+ .map(p => `${p?.source ?? ''}:${p?.id ?? ''}`)
82
+ .sort();
83
+ return `${c.source ?? ''}:${c.id ?? ''}|${partsStr.join('|')}`;
84
+ }
85
+
86
+ identity() {
87
+ return PartitiveHyperedge.identityOf(this);
88
+ }
89
+
90
+ static fromJSON(data) {
91
+ return new PartitiveHyperedge(data);
92
+ }
93
+ }
94
+
95
+ function resolveEnumeration(value) {
96
+ if (value == null) return DEFAULT_ENUMERATION;
97
+ if (!isValidPartitiveEnumeration(value)) {
98
+ throw new Error(
99
+ `invalid enumeration ${JSON.stringify(value)}; ` +
100
+ `must be one of ${PARTITIVE_ENUMERATION_VALUES.join(', ')}`,
101
+ );
102
+ }
103
+ return value;
104
+ }
105
+
106
+ function validateMarkers(values) {
107
+ const arr = Array.isArray(values) ? values : [values];
108
+ const seen = new Set();
109
+ for (const m of arr) {
110
+ if (!isValidPluralityMarker(m)) {
111
+ throw new Error(
112
+ `invalid plurality marker ${JSON.stringify(m)}; ` +
113
+ `must be one of ${PLURALITY_MARKER_VALUES.join(', ')}`,
114
+ );
115
+ }
116
+ if (seen.has(m)) {
117
+ throw new Error(
118
+ `duplicate plurality marker ${JSON.stringify(m)} in partitive_hyperedge`,
119
+ );
120
+ }
121
+ seen.add(m);
122
+ }
123
+ return arr;
124
+ }
125
+
126
+ function ensureConceptRef(value, fieldName) {
127
+ const ref = value instanceof ConceptRef ? value : new ConceptRef(value ?? {});
128
+ if (!ref.source && !ref.id) {
129
+ throw new Error(
130
+ `PartitiveHyperedge#${fieldName} must be a non-empty ConceptRef ` +
131
+ `(source or id required)`,
132
+ );
133
+ }
134
+ return ref;
135
+ }
136
+
137
+ function ensureConceptRefArray(values, fieldName) {
138
+ if (!Array.isArray(values)) values = values == null ? [] : [values];
139
+ return values.map((v, i) => ensureConceptRef(v, `${fieldName}[${i}]`));
140
+ }
141
+
142
+ function assertNonEmptyParts(parts) {
143
+ if (!parts || parts.length === 0) {
144
+ throw new Error('PartitiveHyperedge requires at least one part');
145
+ }
146
+ }
147
+
148
+ function assertNoSelfLoop(comprehensive, parts) {
149
+ const cKey = `${comprehensive.source ?? ''}:${comprehensive.id ?? ''}`;
150
+ for (const p of parts) {
151
+ const pKey = `${p.source ?? ''}:${p.id ?? ''}`;
152
+ if (pKey === cKey) {
153
+ throw new Error('PartitiveHyperedge#parts cannot include the comprehensive');
154
+ }
155
+ }
156
+ }
157
+
158
+ // Content is a localized string (language -> text). Plain strings on
159
+ // input are normalized to `{ default: '...' }` so callers always see
160
+ // the same shape. `null` is preserved.
161
+ //
162
+ // Cross-repo symmetry: glossarist-ruby's `PartitiveHyperedge#content=`
163
+ // (lib/glossarist/v3/partitive_hyperedge.rb) applies the same
164
+ // normalization since commit c917846 (was previously a plain string;
165
+ // aligned with invariant I7 in TODO.hyperedges round 1, item 29).
166
+ // Datasets now round-trip identically across repos.
167
+ function normalizeContent(value) {
168
+ if (value == null) return null;
169
+ if (typeof value === 'string') return { default: value };
170
+ if (typeof value === 'object') {
171
+ const entries = Object.entries(value).filter(([, v]) => typeof v === 'string');
172
+ return entries.length > 0 ? Object.fromEntries(entries) : null;
173
+ }
174
+ return null;
175
+ }
@@ -0,0 +1,96 @@
1
+ // PartitiveMember — one member of a PartitiveRelation.
2
+ //
3
+ // Carries a ConceptRef (the subordinate concept partitive) plus
4
+ // ISO 704:2022 multiplicity and delimiting metadata.
5
+ //
6
+ // ISO 704:2022 partitive member notation:
7
+ //
8
+ // multiplicity (diagram line notation):
9
+ // compulsory — 1 solid line (must exist in every instance)
10
+ // optional — 1 dashed line (exists in some instances only)
11
+ // compulsory_multiple — 2 solid lines (multiple must exist)
12
+ // optional_multiple — 2 dashed lines (multiple may exist)
13
+ // at_least_one — 1 solid + 1 dashed line (≥1 must exist)
14
+ //
15
+ // is_delimiting (orthogonal, bold 3x-width line in diagram):
16
+ // A delimiting part behaves like a delimiting characteristic:
17
+ // it distinguishes the comprehensive from coordinate concepts.
18
+
19
+ import { GlossaristModel } from './base.js';
20
+ import { ConceptRef } from './concept-ref.js';
21
+ import {
22
+ MULTIPLICITY,
23
+ DEFAULT_MULTIPLICITY,
24
+ MULTIPLICITY_VALUES,
25
+ isValidMultiplicity,
26
+ } from './multiplicity.js';
27
+
28
+ export class PartitiveMember extends GlossaristModel {
29
+ constructor(data = {}) {
30
+ super();
31
+ this.ref = data.ref instanceof ConceptRef
32
+ ? data.ref
33
+ : new ConceptRef(data.ref ?? {});
34
+ this.multiplicity = _resolveMultiplicity(data.multiplicity);
35
+ this.is_delimiting = data.is_delimiting === true;
36
+
37
+ _assertNonEmptyRef(this.ref);
38
+ }
39
+
40
+ get isCompulsory() {
41
+ return this.multiplicity === MULTIPLICITY.COMPULSORY;
42
+ }
43
+
44
+ get isOptional() {
45
+ return this.multiplicity === MULTIPLICITY.OPTIONAL;
46
+ }
47
+
48
+ get isDelimiting() {
49
+ return this.is_delimiting === true;
50
+ }
51
+
52
+ identity() {
53
+ return PartitiveMember.identityOf(this);
54
+ }
55
+
56
+ toJSON() {
57
+ const obj = { ref: this.ref.toJSON() };
58
+ if (this.multiplicity !== DEFAULT_MULTIPLICITY) {
59
+ obj.multiplicity = this.multiplicity;
60
+ }
61
+ if (this.is_delimiting === true) {
62
+ obj.is_delimiting = true;
63
+ }
64
+ return obj;
65
+ }
66
+
67
+ static identityOf(value) {
68
+ const v = value ?? {};
69
+ const ref = v.ref ?? {};
70
+ return `${ref.source ?? ''}:${ref.id ?? ''}`;
71
+ }
72
+
73
+ static fromJSON(data) {
74
+ return new PartitiveMember(data);
75
+ }
76
+ }
77
+
78
+ function _resolveMultiplicity(value) {
79
+ if (value == null) return DEFAULT_MULTIPLICITY;
80
+ if (!isValidMultiplicity(value)) {
81
+ throw new Error(
82
+ `invalid multiplicity ${JSON.stringify(value)}; ` +
83
+ `must be one of ${MULTIPLICITY_VALUES.join(', ')}`,
84
+ );
85
+ }
86
+ return value;
87
+ }
88
+
89
+ function _assertNonEmptyRef(ref) {
90
+ if (!ref.source && !ref.id && !ref.text) {
91
+ throw new Error(
92
+ 'PartitiveMember#ref must be a non-empty ConceptRef ' +
93
+ '(source, id, or text required)',
94
+ );
95
+ }
96
+ }
@@ -0,0 +1,149 @@
1
+ // PartitiveRelation — an ISO 704 / ISO 1087-1 / ISO 12620
2
+ // partitive relation connecting a comprehensive concept
3
+ // (superordinate concept partitive) to two or more partitive
4
+ // concepts (subordinate concepts partitive) which fitted together
5
+ // constitute the comprehensive.
6
+ //
7
+ // Field renames (v1 → v2):
8
+ // parts [1..*] → partitives [2..*] (as PartitiveMember)
9
+ // enumeration: closed|open → completeness: complete|partial
10
+ // content: LocalizedString → (dropped — structural edges carry no prose)
11
+ //
12
+ // Per-member metadata (ISO 704:2022):
13
+ // multiplicity (5 values) + is_delimiting (boolean)
14
+ //
15
+ // New field:
16
+ // criterion: LocalizedString [0..1] (ISO 12620 coordinate-concept coherence)
17
+ //
18
+ // Distinguished from binary `has_part` / `is_part_of` edges, which
19
+ // express pairwise part-of assertions without completeness,
20
+ // multiplicity, or criterion claims. A single partitive should be
21
+ // expressed as a binary edge, not a PartitiveRelation (ISO requires
22
+ // "two or more").
23
+
24
+ import { GlossaristModel } from './base.js';
25
+ import { ConceptRef } from './concept-ref.js';
26
+ import { PartitiveMember } from './partitive-member.js';
27
+ import {
28
+ COMPLETENESS,
29
+ COMPLETENESS_VALUES,
30
+ DEFAULT_COMPLETENESS,
31
+ isValidCompleteness,
32
+ } from './completeness.js';
33
+
34
+ export class PartitiveRelation extends GlossaristModel {
35
+ constructor(data = {}) {
36
+ super();
37
+ this.comprehensive = _ensureComprehensive(data.comprehensive);
38
+ this.partitives = _ensurePartitives(data.partitives);
39
+ this.completeness = _resolveCompleteness(data.completeness);
40
+ this.criterion = _normalizeCriterion(data.criterion);
41
+
42
+ _assertNoSelfLoop(this.comprehensive, this.partitives);
43
+ }
44
+
45
+ get isComplete() { return this.completeness === COMPLETENESS.COMPLETE; }
46
+ get isPartial() { return this.completeness === COMPLETENESS.PARTIAL; }
47
+
48
+ // ISO 12620 coordinate-concept predicate: a relation is "coordinate"
49
+ // when it has ≥2 partitives (all partitives within one relation are
50
+ // coordinate concepts by definition).
51
+ get isCoordinate() { return this.partitives.length >= 2; }
52
+
53
+ hasCriterion() { return this.criterion != null; }
54
+
55
+ identity() {
56
+ return PartitiveRelation.identityOf(this);
57
+ }
58
+
59
+ toJSON() {
60
+ const obj = {
61
+ comprehensive: this.comprehensive.toJSON(),
62
+ partitives: this.partitives.map(p => p.toJSON()),
63
+ completeness: this.completeness,
64
+ };
65
+ if (this.criterion != null) obj.criterion = { ...this.criterion };
66
+ return obj;
67
+ }
68
+
69
+ // Stable identity for diffing: same comprehensive + same partitive
70
+ // set (by ref identity) = same relation. Changes to completeness,
71
+ // multiplicity, is_delimiting, or criterion produce "changed"
72
+ // entries rather than add+remove.
73
+ static identityOf(value) {
74
+ const v = value ?? {};
75
+ const c = v.comprehensive ?? {};
76
+ const partitiveKeys = Array.isArray(v.partitives)
77
+ ? v.partitives.map(p => {
78
+ const r = p?.ref ?? p ?? {};
79
+ return `${r.source ?? ''}:${r.id ?? ''}`;
80
+ }).sort()
81
+ : [];
82
+ return `${c.source ?? ''}:${c.id ?? ''}|${partitiveKeys.join('|')}`;
83
+ }
84
+
85
+ static fromJSON(data) {
86
+ return new PartitiveRelation(data);
87
+ }
88
+ }
89
+
90
+ function _ensureComprehensive(value) {
91
+ const ref = value instanceof ConceptRef ? value : new ConceptRef(value ?? {});
92
+ if (!ref.source && !ref.id && !ref.text) {
93
+ throw new Error(
94
+ 'PartitiveRelation.comprehensive must be a non-empty ConceptReference ' +
95
+ '(source, id, or text required)',
96
+ );
97
+ }
98
+ return ref;
99
+ }
100
+
101
+ function _ensurePartitives(values) {
102
+ if (!Array.isArray(values)) {
103
+ throw new Error(
104
+ `PartitiveRelation.partitives must be an array (got ${typeof values})`,
105
+ );
106
+ }
107
+ if (values.length < 2) {
108
+ throw new Error(
109
+ `PartitiveRelation requires ≥2 partitives (ISO 704); got ${values.length}`,
110
+ );
111
+ }
112
+ return values.map(v => v instanceof PartitiveMember ? v : new PartitiveMember(v));
113
+ }
114
+
115
+ function _resolveCompleteness(value) {
116
+ const c = value ?? DEFAULT_COMPLETENESS;
117
+ if (!isValidCompleteness(c)) {
118
+ throw new Error(
119
+ `PartitiveRelation.completeness has invalid value ${JSON.stringify(value)}; ` +
120
+ `must be one of: ${COMPLETENESS_VALUES.join(', ')}`,
121
+ );
122
+ }
123
+ return c;
124
+ }
125
+
126
+ function _normalizeCriterion(value) {
127
+ if (value == null) return null;
128
+ if (typeof value === 'string') {
129
+ return value.length > 0 ? { default: value } : null;
130
+ }
131
+ if (typeof value === 'object') {
132
+ const entries = Object.entries(value).filter(([, v]) => typeof v === 'string');
133
+ return entries.length > 0 ? Object.fromEntries(entries) : null;
134
+ }
135
+ return null;
136
+ }
137
+
138
+ function _assertNoSelfLoop(comprehensive, partitives) {
139
+ const cKey = `${comprehensive.source ?? ''}:${comprehensive.id ?? ''}`;
140
+ for (const p of partitives) {
141
+ const ref = p.ref;
142
+ const pKey = `${ref.source ?? ''}:${ref.id ?? ''}`;
143
+ if (pKey === cKey && (ref.source || ref.id)) {
144
+ throw new Error(
145
+ 'PartitiveRelation.partitives cannot include the comprehensive (no self-loops)',
146
+ );
147
+ }
148
+ }
149
+ }
@@ -0,0 +1,24 @@
1
+ // PluralityMarker — diagram notation flags from VIM concept-relationship
2
+ // diagrams. Travels with a PartitiveHyperedge so the rendering layer
3
+ // can reproduce the source diagram's notation.
4
+ //
5
+ // double — close-set double line at the receiver end. Several
6
+ // partitive concepts of the same type are involved.
7
+ // dashed — broken/dashed line. Plurality is uncertain.
8
+ //
9
+ // Orthogonal to PartitiveEnumeration — both may be set on the same
10
+ // hyperedge.
11
+
12
+ export const PLURALITY_MARKER = Object.freeze({
13
+ DOUBLE: 'double',
14
+ DASHED: 'dashed',
15
+ });
16
+
17
+ export const PLURALITY_MARKER_VALUES = Object.freeze([
18
+ PLURALITY_MARKER.DOUBLE,
19
+ PLURALITY_MARKER.DASHED,
20
+ ]);
21
+
22
+ export function isValidPluralityMarker(value) {
23
+ return PLURALITY_MARKER_VALUES.includes(value);
24
+ }
@@ -25,6 +25,11 @@ export const RELATIONSHIP_TYPES = Object.freeze([
25
25
  // Associative (ISO 10241-1 / ISO 25964)
26
26
  'see', 'related_concept', 'related_concept_broader', 'related_concept_narrower',
27
27
  'references',
28
+ // ExternalConcept resolution (Glossarist extension — v2 partitive-relation)
29
+ // Direction: real concept `provides` substance for an external concept;
30
+ // external concept `provided_by` real concept. Stored on the
31
+ // ExternalConcept. See TODO.partitive-relation-v2 item 07.
32
+ 'provides', 'provided_by',
28
33
  // Comparative (ISO 10241-1)
29
34
  'compare', 'contrast',
30
35
  // Spatiotemporal (ISO 25964 / TBX)
@@ -96,6 +96,11 @@ export const RELATION_CATEGORIES = Object.freeze({
96
96
  // (a version IS a definitional variant of the same concept identity).
97
97
  // Renaming aligns glossarist-js with the canonical taxonomy at
98
98
  // concept-browser:src/data/taxonomies.json.
99
+ //
100
+ // `provides` / `provided_by` (Glossarist v2 extension) live here too:
101
+ // they record ExternalConcept resolution — a definitional linkage
102
+ // between a placeholder and the concept that fills its substance.
103
+ // See TODO.partitive-relation-v2 item 07.
99
104
  definitional: Object.freeze({
100
105
  label: 'Definitional',
101
106
  description: 'ISO 19135 concept-to-concept relations: definition, part, instance, inheritance, versioning.',
@@ -107,6 +112,7 @@ export const RELATION_CATEGORIES = Object.freeze({
107
112
  'inherits', 'inherited_by',
108
113
  'has_version', 'version_of',
109
114
  'current_version', 'current_version_of',
115
+ 'provides', 'provided_by',
110
116
  ]),
111
117
  }),
112
118
  });