glossarist 0.4.27 → 0.4.29

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glossarist",
3
- "version": "0.4.27",
3
+ "version": "0.4.29",
4
4
  "description": "JavaScript SDK for Glossarist GCR packages — read, write, validate, and manage terminology concepts",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -1,4 +1,5 @@
1
1
  import { TextDiff } from './text-diff.js';
2
+ import { resolveMultiplicity } from '../models/multiplicity.js';
2
3
 
3
4
  const COLORS = {
4
5
  red: s => `\x1b[31m${s}\x1b[0m`,
@@ -198,16 +199,6 @@ function definitionLabel(d) {
198
199
  return d.content ?? '?';
199
200
  }
200
201
 
201
- import { multiplicityFromPair } from '../models/multiplicity.js';
202
-
203
- function _resolveMultiplicity(m) {
204
- if (m?.presence && m?.count) {
205
- try { return multiplicityFromPair(m.presence, m.count); }
206
- catch { return 'compulsory'; }
207
- }
208
- return m?.multiplicity ?? 'compulsory';
209
- }
210
-
211
202
  function relationLabel(r) {
212
203
  if (!r) return '?';
213
204
  const c = r.comprehensive;
@@ -216,7 +207,7 @@ function relationLabel(r) {
216
207
  ? r.partitives.map(m => {
217
208
  const ref = m?.ref ?? m ?? {};
218
209
  let tail = '';
219
- const mult = _resolveMultiplicity(m);
210
+ const mult = resolveMultiplicity(m);
220
211
  if (mult && mult !== 'compulsory') tail = ` (${mult})`;
221
212
  if (m?.is_delimiting === true) tail += ' ⊘';
222
213
  return `${ref.id ?? ref.source ?? ref.text ?? '?'}${tail}`;
@@ -246,7 +237,7 @@ export function multiplicityStats(relations) {
246
237
  for (const rel of relations ?? []) {
247
238
  for (const m of rel?.partitives ?? []) {
248
239
  total += 1;
249
- const mult = _resolveMultiplicity(m);
240
+ const mult = resolveMultiplicity(m);
250
241
  byMultiplicity[mult] = (byMultiplicity[mult] ?? 0) + 1;
251
242
  const p = m?.presence ?? 'required';
252
243
  const c = m?.count ?? 'exactly_one';
@@ -0,0 +1,119 @@
1
+ // AbstractNaryRelation — shared base for n-ary concept-system
2
+ // relations (PartitiveRelation, GenericRelation, future
3
+ // SequentialRelation, AssociativeRelation).
4
+ //
5
+ // Carries the common shape:
6
+ // - comprehensive: ConceptRef
7
+ // - members: 2+ MECE dimensioned concept refs (≥2 per ISO 704)
8
+ // - completeness: complete | partial
9
+ // - criterion: localized subdivision criterion (ISO 12620)
10
+ // - sources / notes / status: administrative metadata
11
+ //
12
+ // Concrete leaves add type-specific fields if needed (currently none).
13
+ // See docs/design/abstract-nary-relation.md (concept-model repo).
14
+
15
+ import { GlossaristModel } from './base.js';
16
+ import { ConceptRef } from './concept-ref.js';
17
+ import {
18
+ COMPLETENESS,
19
+ DEFAULT_COMPLETENESS,
20
+ isValidCompleteness,
21
+ } from './completeness.js';
22
+
23
+ export class AbstractNaryRelation extends GlossaristModel {
24
+ constructor(data = {}) {
25
+ super();
26
+ this.comprehensive = _ensureComprehensive(data.comprehensive);
27
+ this.members = _ensureMembers(data.members, this.constructor.name);
28
+ this.completeness = _resolveCompleteness(data.completeness);
29
+ this.criterion = _normalizeCriterion(data.criterion);
30
+ this.sources = Array.isArray(data.sources) ? [...data.sources] : [];
31
+ this.notes = data.notes ?? null;
32
+ this.status = data.status ?? null;
33
+
34
+ _assertNoSelfLoop(this.comprehensive, this.members);
35
+ }
36
+
37
+ get isComplete() { return this.completeness === COMPLETENESS.COMPLETE; }
38
+ get isPartial() { return this.completeness === COMPLETENESS.PARTIAL; }
39
+ get isCoordinate() { return this.members.length >= 2; }
40
+
41
+ hasCriterion() { return this.criterion != null; }
42
+
43
+ identity() {
44
+ return this.constructor.identityOf(this);
45
+ }
46
+
47
+ toJSON() {
48
+ const obj = {
49
+ comprehensive: this.comprehensive.toJSON(),
50
+ members: this.members.map(m => m.toJSON()),
51
+ completeness: this.completeness,
52
+ };
53
+ if (this.criterion != null) obj.criterion = { ...this.criterion };
54
+ if (this.sources.length > 0) obj.sources = [...this.sources];
55
+ if (this.notes != null) obj.notes = { ...this.notes };
56
+ if (this.status != null) obj.status = this.status;
57
+ return obj;
58
+ }
59
+ }
60
+
61
+ function _ensureComprehensive(value) {
62
+ const ref = value instanceof ConceptRef ? value : new ConceptRef(value ?? {});
63
+ if (!ref.source && !ref.id && !ref.text) {
64
+ throw new Error(
65
+ 'AbstractNaryRelation.comprehensive must be a non-empty ConceptReference ' +
66
+ '(source, id, or text required)',
67
+ );
68
+ }
69
+ return ref;
70
+ }
71
+
72
+ function _ensureMembers(values, typeName) {
73
+ if (!Array.isArray(values)) {
74
+ throw new Error(
75
+ `${typeName}.members must be an array (got ${typeof values})`,
76
+ );
77
+ }
78
+ if (values.length < 2) {
79
+ throw new Error(
80
+ `${typeName} requires ≥2 partitives (ISO 704); got ${values.length}`,
81
+ );
82
+ }
83
+ return values;
84
+ }
85
+
86
+ function _resolveCompleteness(value) {
87
+ const c = value ?? DEFAULT_COMPLETENESS;
88
+ if (!isValidCompleteness(c)) {
89
+ throw new Error(
90
+ `${c === value ? 'completeness' : 'completeness'} has invalid value ${JSON.stringify(value)}`,
91
+ );
92
+ }
93
+ return c;
94
+ }
95
+
96
+ function _normalizeCriterion(value) {
97
+ if (value == null) return null;
98
+ if (typeof value === 'string') {
99
+ return value.length > 0 ? { default: value } : null;
100
+ }
101
+ if (typeof value === 'object') {
102
+ const entries = Object.entries(value).filter(([, v]) => typeof v === 'string');
103
+ return entries.length > 0 ? Object.fromEntries(entries) : null;
104
+ }
105
+ return null;
106
+ }
107
+
108
+ function _assertNoSelfLoop(comprehensive, members) {
109
+ const cKey = `${comprehensive.source ?? ''}:${comprehensive.id ?? ''}`;
110
+ for (const m of members) {
111
+ const ref = m.ref;
112
+ const pKey = `${ref?.source ?? ''}:${ref?.id ?? ''}`;
113
+ if (pKey === cKey && (ref?.source || ref?.id)) {
114
+ throw new Error(
115
+ 'Relation.members cannot include the comprehensive (no self-loops)',
116
+ );
117
+ }
118
+ }
119
+ }
@@ -0,0 +1,126 @@
1
+ // ConceptSystemMember — abstract base shape for members of any
2
+ // n-ary concept-system relation (PartitiveMember, GenericMember,
3
+ // future AssociativeMember, SequentialMember).
4
+ //
5
+ // Carries the shared ISO 704:2022 MECE dimensions: presence × count,
6
+ // plus the orthogonal is_delimiting flag.
7
+ //
8
+ // Concrete leaf classes extend this with type-specific fields if
9
+ // needed (currently none). See docs/design/abstract-nary-relation.md
10
+ // (concept-model repo).
11
+
12
+ import { GlossaristModel } from './base.js';
13
+ import { ConceptRef } from './concept-ref.js';
14
+ import {
15
+ PARTITIVE_PRESENCE,
16
+ PARTITIVE_PRESENCE_VALUES,
17
+ DEFAULT_PRESENCE,
18
+ isValidPresence,
19
+ } from './partitive-presence.js';
20
+ import {
21
+ PARTITIVE_COUNT,
22
+ PARTITIVE_COUNT_VALUES,
23
+ DEFAULT_COUNT,
24
+ isValidCount,
25
+ } from './partitive-count.js';
26
+
27
+ // Module-private (not exported). Single source of truth for the
28
+ // invalid (presence, count) combination rejected at construction.
29
+ const INVALID_PRESENCE_COUNT_COMBO = {
30
+ presence: PARTITIVE_PRESENCE.OPTIONAL,
31
+ count: PARTITIVE_COUNT.AT_LEAST_ONE,
32
+ };
33
+
34
+ export class ConceptSystemMember extends GlossaristModel {
35
+ constructor(data = {}) {
36
+ super();
37
+ this.ref = _ensureRef(data.ref);
38
+ this.presence = _resolvePresence(data.presence);
39
+ this.count = _resolveCount(data.count);
40
+ this.is_delimiting = _resolveDelimiting(data.is_delimiting);
41
+
42
+ _assertValidPair(this.presence, this.count);
43
+ }
44
+
45
+ get isRequired() { return this.presence === PARTITIVE_PRESENCE.REQUIRED; }
46
+ get isOptional() { return this.presence === PARTITIVE_PRESENCE.OPTIONAL; }
47
+ get isDelimiting() { return this.is_delimiting === true; }
48
+
49
+ identity() {
50
+ return ConceptSystemMember.identityOf(this);
51
+ }
52
+
53
+ toJSON() {
54
+ const obj = { ref: this.ref.toJSON() };
55
+ if (this.presence !== DEFAULT_PRESENCE) obj.presence = this.presence;
56
+ if (this.count !== DEFAULT_COUNT) obj.count = this.count;
57
+ if (this.is_delimiting) obj.is_delimiting = true;
58
+ return obj;
59
+ }
60
+
61
+ static identityOf(value) {
62
+ const v = value ?? {};
63
+ const ref = v.ref ?? {};
64
+ return `${ref.source ?? ''}:${ref.id ?? ''}`;
65
+ }
66
+
67
+ static fromJSON(data) {
68
+ return new ConceptSystemMember(data);
69
+ }
70
+ }
71
+
72
+ function _ensureRef(value) {
73
+ const ref = value instanceof ConceptRef ? value : new ConceptRef(value ?? {});
74
+ if (!ref.source && !ref.id && !ref.text) {
75
+ throw new Error(
76
+ 'ConceptSystemMember.ref must be a non-empty ConceptRef ' +
77
+ '(source, id, or text required)',
78
+ );
79
+ }
80
+ return ref;
81
+ }
82
+
83
+ function _resolvePresence(value) {
84
+ const p = value ?? DEFAULT_PRESENCE;
85
+ if (!isValidPresence(p)) {
86
+ throw new Error(
87
+ `invalid presence ${JSON.stringify(value)}; ` +
88
+ `must be one of: ${PARTITIVE_PRESENCE_VALUES.join(', ')}`,
89
+ );
90
+ }
91
+ return p;
92
+ }
93
+
94
+ function _resolveCount(value) {
95
+ const c = value ?? DEFAULT_COUNT;
96
+ if (!isValidCount(c)) {
97
+ throw new Error(
98
+ `invalid count ${JSON.stringify(value)}; ` +
99
+ `must be one of: ${PARTITIVE_COUNT_VALUES.join(', ')}`,
100
+ );
101
+ }
102
+ return c;
103
+ }
104
+
105
+ function _resolveDelimiting(value) {
106
+ if (value == null) return false;
107
+ if (typeof value !== 'boolean') {
108
+ throw new Error(
109
+ `ConceptSystemMember.is_delimiting must be boolean; got ${typeof value}`,
110
+ );
111
+ }
112
+ return value;
113
+ }
114
+
115
+ function _assertValidPair(presence, count) {
116
+ if (
117
+ presence === INVALID_PRESENCE_COUNT_COMBO.presence &&
118
+ count === INVALID_PRESENCE_COUNT_COMBO.count
119
+ ) {
120
+ throw new Error(
121
+ 'ConceptSystemMember presence=optional + count=at_least_one is invalid — ' +
122
+ 'it collapses to optional + multiple (zero or more). ' +
123
+ 'Use presence: optional, count: multiple instead.',
124
+ );
125
+ }
126
+ }
@@ -0,0 +1,17 @@
1
+ // DefinitionType — ISO 704:2022 §5.3 definition strategy enum.
2
+ //
3
+ // intensional (default): genus + differentiae
4
+ // extensional: enumerate instances or species
5
+ // partitive: describe composition
6
+ // translated: translated from another language
7
+
8
+ import { makeEnum } from './enum.js';
9
+
10
+ export const DEFINITION_TYPE = makeEnum('DEFINITION_TYPE', {
11
+ INTENSIONAL: 'intensional',
12
+ EXTENSIONAL: 'extensional',
13
+ PARTITIVE: 'partitive',
14
+ TRANSLATED: 'translated',
15
+ });
16
+
17
+ export const DEFAULT_DEFINITION_TYPE = DEFINITION_TYPE.INTENSIONAL;
@@ -0,0 +1,15 @@
1
+ // GenericMember — one member of a GenericRelation. Inherits the
2
+ // ISO 704:2022 MECE shape from ConceptSystemMember. Declared as a
3
+ // distinct leaf class for type safety and to leave room for
4
+ // generic-specific extensions.
5
+ //
6
+ // See docs/design/abstract-nary-relation.md and
7
+ // docs/design/generic-relation.md (concept-model repo).
8
+
9
+ import { ConceptSystemMember } from './concept-system-member.js';
10
+
11
+ export class GenericMember extends ConceptSystemMember {
12
+ // Inherits all attributes and validations from ConceptSystemMember.
13
+ }
14
+
15
+ export default GenericMember;
@@ -0,0 +1,43 @@
1
+ // GenericRelation — an ISO 704 / ISO 1087-1 / ISO 12620 generic
2
+ // relation connecting a comprehensive concept (the genus) to two
3
+ // or more specific concepts (the species) which together constitute
4
+ // a decomposition by some criterion of subdivision.
5
+ //
6
+ // Mirror of PartitiveRelation. Multiple GenericRelations on the same
7
+ // comprehensive distinguished by `criterion` is the OIML pattern
8
+ // (5.1 measurement standard has 6 criterion groups).
9
+ //
10
+ // See docs/design/generic-relation.md (concept-model repo).
11
+
12
+ import { AbstractNaryRelation } from './abstract-nary-relation.js';
13
+ import { GenericMember } from './generic-member.js';
14
+
15
+ export class GenericRelation extends AbstractNaryRelation {
16
+ constructor(data = {}) {
17
+ super(data);
18
+ this.members = data?.members == null
19
+ ? this.members
20
+ : (Array.isArray(data.members)
21
+ ? data.members.map(m =>
22
+ m instanceof GenericMember ? m : new GenericMember(m))
23
+ : this.members);
24
+ }
25
+
26
+ static identityOf(value) {
27
+ const v = value ?? {};
28
+ const c = v.comprehensive ?? {};
29
+ const memberKeys = Array.isArray(v.members)
30
+ ? v.members.map(m => {
31
+ const r = m?.ref ?? m ?? {};
32
+ return `${r.source ?? ''}:${r.id ?? ''}`;
33
+ }).sort()
34
+ : [];
35
+ return `${c.source ?? ''}:${c.id ?? ''}|${memberKeys.join('|')}`;
36
+ }
37
+
38
+ static fromJSON(data) {
39
+ return new GenericRelation(data);
40
+ }
41
+ }
42
+
43
+ export default GenericRelation;
@@ -328,6 +328,71 @@ export class PartitiveHyperedge extends GlossaristModel {
328
328
  static fromJSON(data: Record<string, unknown>): PartitiveHyperedge;
329
329
  }
330
330
 
331
+ // v2 PartitiveRelation + GenericRelation (MECE per-member model).
332
+ // These supersede the v1 PartitiveHyperedge model above. New code
333
+ // should use v2 exclusively; the v1 model is kept for backward-compat
334
+ // input.
335
+ export class ConceptSystemMember extends GlossaristModel {
336
+ readonly ref: ConceptRef;
337
+ readonly presence: 'required' | 'optional';
338
+ readonly count: 'exactly_one' | 'at_least_one' | 'multiple';
339
+ readonly is_delimiting: boolean;
340
+ required(): boolean;
341
+ optional(): boolean;
342
+ delimiting(): boolean;
343
+ identity(): string;
344
+ toJSON(): Record<string, unknown>;
345
+ static fromJSON(data: Record<string, unknown>): ConceptSystemMember;
346
+ static identityOf(value: unknown): string;
347
+ }
348
+
349
+ export class PartitiveMember extends ConceptSystemMember {}
350
+
351
+ export class GenericMember extends ConceptSystemMember {}
352
+
353
+ export class AbstractNaryRelation extends GlossaristModel {
354
+ readonly comprehensive: ConceptRef;
355
+ readonly members: ConceptSystemMember[];
356
+ readonly completeness: 'complete' | 'partial';
357
+ readonly criterion: Record<string, string> | null;
358
+ readonly sources: ConceptSource[];
359
+ readonly notes: Record<string, string> | null;
360
+ readonly status: string | null;
361
+ readonly isComplete: boolean;
362
+ readonly isPartial: boolean;
363
+ readonly isCoordinate: boolean;
364
+ toJSON(): Record<string, unknown>;
365
+ static fromJSON(data: Record<string, unknown>): AbstractNaryRelation;
366
+ static identityOf(value: unknown): string;
367
+ }
368
+
369
+ export class PartitiveRelation extends AbstractNaryRelation {
370
+ /** @deprecated use {@link members} — kept for v1/v2 wire compat. */
371
+ readonly partitives: ConceptSystemMember[];
372
+ static fromJSON(data: Record<string, unknown>): PartitiveRelation;
373
+ static identityOf(value: unknown): string;
374
+ }
375
+
376
+ export class GenericRelation extends AbstractNaryRelation {
377
+ static fromJSON(data: Record<string, unknown>): GenericRelation;
378
+ static identityOf(value: unknown): string;
379
+ }
380
+
381
+ export type DefinitionType = 'intensional' | 'extensional' | 'partitive' | 'translated';
382
+ export const DEFINITION_TYPE: Readonly<Record<string, DefinitionType>>;
383
+ export const DEFAULT_DEFINITION_TYPE: DefinitionType;
384
+ export const DEFINITION_TYPE_VALUES: readonly DefinitionType[];
385
+
386
+ export const RELATION_TYPE_TO_CLASS: Readonly<Record<string, typeof AbstractNaryRelation>>;
387
+ export class RelationLoadError extends Error {
388
+ readonly path: string;
389
+ constructor(path: string, message: string);
390
+ }
391
+ export const RelationLoader: {
392
+ loadAll(relationsDir: string): Map<string, AbstractNaryRelation[]>;
393
+ loadOne(path: string): AbstractNaryRelation | null;
394
+ };
395
+
331
396
  export const DESIGNATION_RELATIONSHIP_TYPES: readonly string[];
332
397
  export class DesignationRelationship extends GlossaristModel {
333
398
  readonly type: string | null;
@@ -38,6 +38,22 @@ export {
38
38
  // backward-compat input. New code should use v2 exclusively.
39
39
  export { PartitiveRelation } from './partitive-relation.js';
40
40
  export { PartitiveMember } from './partitive-member.js';
41
+
42
+ // v2 GenericRelation models (per-file relation storage). Mirrors
43
+ // PartitiveRelation shape; uses the same MECE member shape.
44
+ export { AbstractNaryRelation } from './abstract-nary-relation.js';
45
+ export { ConceptSystemMember } from './concept-system-member.js';
46
+ export { GenericRelation } from './generic-relation.js';
47
+ export { GenericMember } from './generic-member.js';
48
+ export {
49
+ DEFINITION_TYPE,
50
+ DEFAULT_DEFINITION_TYPE,
51
+ } from './definition-type.js';
52
+
53
+ // Per-file relation loader (Node-side runtime; not browser-bundled).
54
+ // Walks a relations/ directory and dispatches to PartitiveRelation /
55
+ // GenericRelation by file type.
56
+ export { RelationLoader, RelationLoadError, TYPE_TO_CLASS as RELATION_TYPE_TO_CLASS } from './relation-loader.js';
41
57
  export {
42
58
  PARTITIVE_PRESENCE,
43
59
  PARTITIVE_PRESENCE_VALUES,
@@ -63,6 +79,7 @@ export {
63
79
  isValidMultiplicity,
64
80
  multiplicityFromPair,
65
81
  pairFromMultiplicity,
82
+ resolveMultiplicity,
66
83
  } from './multiplicity.js';
67
84
 
68
85
  // v1 → v2 migration helper. Pure function; idempotent.
@@ -66,6 +66,32 @@ export function isValidMultiplicity(name) {
66
66
  return Object.prototype.hasOwnProperty.call(PAIR_BY_NAME, name);
67
67
  }
68
68
 
69
+ /**
70
+ * Resolve the ISO 704 multiplicity name from any object carrying
71
+ * `presence` + `count`, OR a legacy object that still has a literal
72
+ * `multiplicity` field (pre-v4 JSON).
73
+ *
74
+ * For v4 `PartitiveMember` instances, `presence` + `count` are always
75
+ * set (defaults applied at construction), so the legacy branch never
76
+ * fires on a real instance — it exists purely to round-trip raw JSON
77
+ * that bypassed the model constructor.
78
+ *
79
+ * Throws on the invalid `(optional, at_least_one)` combination — the
80
+ * model already rejects this at construction, so reaching the throw
81
+ * here means the caller bypassed the model. Let the error propagate
82
+ * rather than silently returning 'compulsory' (which would mask the
83
+ * data corruption).
84
+ */
85
+ export function resolveMultiplicity(member) {
86
+ if (member?.presence && member?.count) {
87
+ return multiplicityFromPair(member.presence, member.count);
88
+ }
89
+ if (typeof member?.multiplicity === 'string') {
90
+ return member.multiplicity;
91
+ }
92
+ return DEFAULT_MULTIPLICITY;
93
+ }
94
+
69
95
  function invalidCombinationError(presence, count) {
70
96
  return new Error(
71
97
  `Invalid multiplicity combination: presence=${presence}, count=${count}. ` +
@@ -1,7 +1,9 @@
1
- // PartitiveMember — one member of a PartitiveRelation.
1
+ // PartitiveMember — one member of a PartitiveRelation. Inherits the
2
+ // ISO 704:2022 MECE shape from ConceptSystemMember; the `comprehensive`
3
+ // of its parent PartitiveRelation denotes the whole concept.
2
4
  //
3
- // Carries a ConceptRef (the subordinate concept partitive) plus
4
- // ISO 704:2022 presence + count + is_delimiting.
5
+ // Distinct leaf class for type safety and to leave room for
6
+ // Partitive-specific extensions.
5
7
  //
6
8
  // Matches concept-model/models/concepts/PartitiveMember.lutaml exactly:
7
9
  // +ref — ConceptRef [1]
@@ -12,94 +14,13 @@
12
14
  // The combination (optional, at_least_one) is invalid: if a part is
13
15
  // optional, the at-least-one constraint is vacuous and collapses to
14
16
  // (optional, multiple). Rejected at construction.
15
- //
16
- // ISO 704 "name" strings (compulsory, optional, etc.) are NOT part of
17
- // the model — they're a display concern handled by multiplicityFromPair
18
- // in the renderer/viewer layer. The model is purely presence + count.
19
-
20
- import { GlossaristModel } from './base.js';
21
- import { ConceptRef } from './concept-ref.js';
22
- import {
23
- PARTITIVE_PRESENCE,
24
- DEFAULT_PRESENCE,
25
- PARTITIVE_PRESENCE_VALUES,
26
- isValidPresence,
27
- } from './partitive-presence.js';
28
- import {
29
- DEFAULT_COUNT,
30
- PARTITIVE_COUNT_VALUES,
31
- isValidCount,
32
- } from './partitive-count.js';
33
-
34
- export class PartitiveMember extends GlossaristModel {
35
- constructor(data = {}) {
36
- super();
37
- this.ref = data.ref instanceof ConceptRef
38
- ? data.ref
39
- : new ConceptRef(data.ref ?? {});
40
- this.presence = _resolvePresence(data.presence);
41
- this.count = _resolveCount(data.count);
42
- this.is_delimiting = data.is_delimiting === true;
43
-
44
- _assertNonEmptyRef(this.ref);
45
- _assertValidCombination(this.presence, this.count);
46
- }
47
-
48
- get isRequired() { return this.presence === PARTITIVE_PRESENCE.REQUIRED; }
49
- get isOptional() { return this.presence === PARTITIVE_PRESENCE.OPTIONAL; }
50
- get isDelimiting() { return this.is_delimiting === true; }
51
-
52
- identity() {
53
- return PartitiveMember.identityOf(this);
54
- }
55
-
56
- toJSON() {
57
- const obj = { ref: this.ref.toJSON() };
58
- if (this.presence !== DEFAULT_PRESENCE) obj.presence = this.presence;
59
- if (this.count !== DEFAULT_COUNT) obj.count = this.count;
60
- if (this.is_delimiting === true) obj.is_delimiting = true;
61
- return obj;
62
- }
63
17
 
64
- static identityOf(value) {
65
- const v = value ?? {};
66
- const ref = v.ref ?? {};
67
- return `${ref.source ?? ''}:${ref.id ?? ''}`;
68
- }
18
+ import { ConceptSystemMember } from './concept-system-member.js';
69
19
 
70
- static fromJSON(data) {
71
- return new PartitiveMember(data);
72
- }
20
+ export class PartitiveMember extends ConceptSystemMember {
21
+ // Inherits all attributes and validations from ConceptSystemMember.
22
+ // Declared as a distinct class for type safety and to leave room
23
+ // for future partitive-specific extensions.
73
24
  }
74
25
 
75
- function _resolvePresence(value) {
76
- if (value == null) return DEFAULT_PRESENCE;
77
- if (!isValidPresence(value)) {
78
- throw new Error(`invalid presence ${JSON.stringify(value)}; must be one of ${PARTITIVE_PRESENCE_VALUES.join(', ')}`);
79
- }
80
- return value;
81
- }
82
-
83
- function _resolveCount(value) {
84
- if (value == null) return DEFAULT_COUNT;
85
- if (!isValidCount(value)) {
86
- throw new Error(`invalid count ${JSON.stringify(value)}; must be one of ${PARTITIVE_COUNT_VALUES.join(', ')}`);
87
- }
88
- return value;
89
- }
90
-
91
- function _assertNonEmptyRef(ref) {
92
- if (!ref.source && !ref.id && !ref.text) {
93
- throw new Error('PartitiveMember#ref must be a non-empty ConceptRef (source, id, or text required)');
94
- }
95
- }
96
-
97
- function _assertValidCombination(presence, count) {
98
- if (presence === 'optional' && count === 'at_least_one') {
99
- throw new Error(
100
- 'PartitiveMember presence=optional + count=at_least_one is invalid — ' +
101
- 'it collapses to optional + multiple (zero or more). ' +
102
- 'Use presence: optional, count: multiple instead.',
103
- );
104
- }
105
- }
26
+ export default PartitiveMember;
@@ -4,6 +4,10 @@
4
4
  // concepts (subordinate concepts partitive) which fitted together
5
5
  // constitute the comprehensive.
6
6
  //
7
+ // Extends AbstractNaryRelation; the leaf-specific concern is the
8
+ // PartitiveMember type for `members`. The wire-level field name
9
+ // stays `partitives` for v1/v2 backward compat.
10
+ //
7
11
  // ISO 704:2022 rake diagram semantics:
8
12
  //
9
13
  // comprehensive concept
@@ -22,142 +26,51 @@
22
26
  // principle, and the criterion of subdivision used
23
27
  // (ISO 704:2022 §5.5.4.2.1).
24
28
  //
25
- // Field renames (v1 → v2):
26
- // parts [1..*] → partitives [2..*] (as PartitiveMember)
27
- // enumeration: closed|open → completeness: complete|partial
28
- // markers: [double, dashed] → (per-member multiplicity + is_delimiting)
29
- // content: LocalizedString → (dropped — structural edges carry no prose)
30
- // plural-positive is_shared / is_uncertain → multiplicity (ISO 704:2022)
31
- //
32
29
  // Distinguished from binary `has_part` / `is_part_of` edges, which
33
30
  // express pairwise part-of assertions without completeness,
34
31
  // multiplicity, or criterion claims. A single partitive should be
35
32
  // expressed as a binary edge, not a PartitiveRelation (ISO requires
36
33
  // "two or more").
37
34
 
38
- import { GlossaristModel } from './base.js';
39
- import { ConceptRef } from './concept-ref.js';
35
+ import { AbstractNaryRelation } from './abstract-nary-relation.js';
40
36
  import { PartitiveMember } from './partitive-member.js';
41
- import {
42
- COMPLETENESS,
43
- COMPLETENESS_VALUES,
44
- DEFAULT_COMPLETENESS,
45
- isValidCompleteness,
46
- } from './completeness.js';
47
37
 
48
- export class PartitiveRelation extends GlossaristModel {
38
+ export class PartitiveRelation extends AbstractNaryRelation {
49
39
  constructor(data = {}) {
50
- super();
51
- this.comprehensive = _ensureComprehensive(data.comprehensive);
52
- this.partitives = _ensurePartitives(data.partitives);
53
- this.completeness = _resolveCompleteness(data.completeness);
54
- this.criterion = _normalizeCriterion(data.criterion);
55
-
56
- _assertNoSelfLoop(this.comprehensive, this.partitives);
40
+ const members = data?.members ?? data?.partitives;
41
+ super({ ...data, members });
42
+ this.members = members == null
43
+ ? this.members
44
+ : (Array.isArray(members)
45
+ ? members.map(m =>
46
+ m instanceof PartitiveMember ? m : new PartitiveMember(m))
47
+ : this.members);
57
48
  }
58
49
 
59
- get isComplete() { return this.completeness === COMPLETENESS.COMPLETE; }
60
- get isPartial() { return this.completeness === COMPLETENESS.PARTIAL; }
61
-
62
- // ISO 12620 coordinate-concept predicate: a relation is "coordinate"
63
- // when it has ≥2 partitives (all partitives within one relation are
64
- // coordinate concepts by definition).
65
- get isCoordinate() { return this.partitives.length >= 2; }
66
-
67
- hasCriterion() { return this.criterion != null; }
68
-
69
- identity() {
70
- return PartitiveRelation.identityOf(this);
71
- }
50
+ get partitives() { return this.members; }
72
51
 
73
52
  toJSON() {
74
- const obj = {
75
- comprehensive: this.comprehensive.toJSON(),
76
- partitives: this.partitives.map(p => p.toJSON()),
77
- completeness: this.completeness,
78
- };
79
- if (this.criterion != null) obj.criterion = { ...this.criterion };
80
- return obj;
53
+ const base = super.toJSON();
54
+ const { members, ...rest } = base;
55
+ return { ...rest, partitives: members };
81
56
  }
82
57
 
83
- // Stable identity for diffing: same comprehensive + same partitive
84
- // set (by ref identity) = same relation. Changes to completeness,
85
- // multiplicity, is_delimiting, or criterion produce "changed"
86
- // entries rather than add+remove.
87
58
  static identityOf(value) {
88
59
  const v = value ?? {};
89
60
  const c = v.comprehensive ?? {};
90
- const partitiveKeys = Array.isArray(v.partitives)
91
- ? v.partitives.map(p => {
61
+ const partKeys = Array.isArray(v.partitives ?? v.members)
62
+ ? (v.partitives ?? v.members).map(p => {
92
63
  const r = p?.ref ?? p ?? {};
93
64
  return `${r.source ?? ''}:${r.id ?? ''}`;
94
65
  }).sort()
95
66
  : [];
96
- return `${c.source ?? ''}:${c.id ?? ''}|${partitiveKeys.join('|')}`;
67
+ return `${c.source ?? ''}:${c.id ?? ''}|${partKeys.join('|')}`;
97
68
  }
98
69
 
99
70
  static fromJSON(data) {
100
- return new PartitiveRelation(data);
101
- }
102
- }
103
-
104
- function _ensureComprehensive(value) {
105
- const ref = value instanceof ConceptRef ? value : new ConceptRef(value ?? {});
106
- if (!ref.source && !ref.id && !ref.text) {
107
- throw new Error(
108
- 'PartitiveRelation.comprehensive must be a non-empty ConceptReference ' +
109
- '(source, id, or text required)',
110
- );
71
+ const normalized = { ...(data ?? {}), members: data?.partitives ?? data?.members };
72
+ return new PartitiveRelation(normalized);
111
73
  }
112
- return ref;
113
74
  }
114
75
 
115
- function _ensurePartitives(values) {
116
- if (!Array.isArray(values)) {
117
- throw new Error(
118
- `PartitiveRelation.partitives must be an array (got ${typeof values})`,
119
- );
120
- }
121
- if (values.length < 2) {
122
- throw new Error(
123
- `PartitiveRelation requires ≥2 partitives (ISO 704); got ${values.length}`,
124
- );
125
- }
126
- return values.map(v => v instanceof PartitiveMember ? v : new PartitiveMember(v));
127
- }
128
-
129
- function _resolveCompleteness(value) {
130
- const c = value ?? DEFAULT_COMPLETENESS;
131
- if (!isValidCompleteness(c)) {
132
- throw new Error(
133
- `PartitiveRelation.completeness has invalid value ${JSON.stringify(value)}; ` +
134
- `must be one of: ${COMPLETENESS_VALUES.join(', ')}`,
135
- );
136
- }
137
- return c;
138
- }
139
-
140
- function _normalizeCriterion(value) {
141
- if (value == null) return null;
142
- if (typeof value === 'string') {
143
- return value.length > 0 ? { default: value } : null;
144
- }
145
- if (typeof value === 'object') {
146
- const entries = Object.entries(value).filter(([, v]) => typeof v === 'string');
147
- return entries.length > 0 ? Object.fromEntries(entries) : null;
148
- }
149
- return null;
150
- }
151
-
152
- function _assertNoSelfLoop(comprehensive, partitives) {
153
- const cKey = `${comprehensive.source ?? ''}:${comprehensive.id ?? ''}`;
154
- for (const p of partitives) {
155
- const ref = p.ref;
156
- const pKey = `${ref.source ?? ''}:${ref.id ?? ''}`;
157
- if (pKey === cKey && (ref.source || ref.id)) {
158
- throw new Error(
159
- 'PartitiveRelation.partitives cannot include the comprehensive (no self-loops)',
160
- );
161
- }
162
- }
163
- }
76
+ export default PartitiveRelation;
@@ -0,0 +1,107 @@
1
+ // RelationLoader — runtime loader for per-file n-ary relation YAMLs.
2
+ //
3
+ // Walks a `relations/<comprehensive-id>/<slug>.yaml` directory and
4
+ // dispatches each file to the appropriate class (PartitiveRelation
5
+ // or GenericRelation) based on its `type` field.
6
+ //
7
+ // The wire-shape contract is owned by the JSON Schema at
8
+ // `schemas/v3/relation.yaml` (concept-model repo) — this loader
9
+ // produces the corresponding JS model instances. See
10
+ // docs/design/relations-as-files.md (concept-model).
11
+ //
12
+ // This is the runtime equivalent of concept-model's
13
+ // CheckPartitiveRelationCoherence#each_relation /
14
+ // CheckGenericRelationCoherence#each_relation validators.
15
+ // Both readers and validators must agree on the type-dispatch
16
+ // table (TYPE_TO_CLASS). Keep them in sync via the JSON Schema.
17
+
18
+ import { readdirSync, readFileSync, existsSync } from 'node:fs';
19
+ import * as yaml from 'js-yaml';
20
+ import { PartitiveRelation } from './partitive-relation.js';
21
+ import { GenericRelation } from './generic-relation.js';
22
+
23
+ /**
24
+ * Per-type class dispatch. Keys are the `type` field of the relation
25
+ * file. Adding a new relation type (e.g. AssociativeRelation) means
26
+ * one entry here plus one new class.
27
+ */
28
+ export const TYPE_TO_CLASS = Object.freeze({
29
+ partitive_relation: PartitiveRelation,
30
+ generic_relation: GenericRelation,
31
+ });
32
+
33
+ /**
34
+ * Thrown by `loadAll` when a relation file has an unknown `type`,
35
+ * malformed structure, or a class that fails to instantiate.
36
+ */
37
+ export class RelationLoadError extends Error {
38
+ constructor(path, message) {
39
+ super(`relation-loader: ${path}: ${message}`);
40
+ this.path = path;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Walk a `relations/` directory and load every relation file,
46
+ * returning a Map keyed by comprehensive-id.
47
+ *
48
+ * Returns an empty Map if `relationsDir` doesn't exist or is empty.
49
+ *
50
+ * @param {string} relationsDir — path to a `relations/` directory
51
+ * containing `<comprehensive-id>/<slug>.yaml` files (per the
52
+ * schema in schemas/v3/relation.yaml).
53
+ * @returns {Map<string, Array<AbstractNaryRelation>>} — for each
54
+ * comprehensive id, the list of relations that have it as their
55
+ * comprehensive. Type-discriminated (PartitiveRelation or
56
+ * GenericRelation).
57
+ */
58
+ export function loadAll(relationsDir) {
59
+ const result = new Map();
60
+ if (!relationsDir) return result;
61
+ if (!existsSync(relationsDir)) return result;
62
+
63
+ const entries = readdirSync(relationsDir, { withFileTypes: true });
64
+ for (const compEntry of entries) {
65
+ if (!compEntry.isDirectory()) continue;
66
+ const compId = compEntry.name;
67
+ const compDir = `${relationsDir}/${compId}`;
68
+ if (!result.has(compId)) result.set(compId, []);
69
+ const files = readdirSync(compDir)
70
+ .filter(f => f.endsWith('.yaml') || f.endsWith('.yml'))
71
+ .sort();
72
+ for (const file of files) {
73
+ const path = `${compDir}/${file}`;
74
+ const rel = loadOne(path);
75
+ if (rel) result.get(compId).push(rel);
76
+ }
77
+ }
78
+ return result;
79
+ }
80
+
81
+ /**
82
+ * Namespace bundle for the loader — callers import it as
83
+ * `import { RelationLoader } from './relation-loader.js'` and call
84
+ * `RelationLoader.loadAll(dir)`. The individual functions are also
85
+ * exported directly for tree-shakeable single-call sites.
86
+ */
87
+ export const RelationLoader = Object.freeze({ loadAll });
88
+
89
+ /**
90
+ * Load a single relation file. Returns null if the file is missing,
91
+ * unreadable, or doesn't have a recognized `type` field. Throws
92
+ * `RelationLoadError` on malformed content (e.g., invalid `members`
93
+ * count, wrong comprehensive shape, YAML parse error).
94
+ */
95
+ export function loadOne(path) {
96
+ let doc;
97
+ try {
98
+ doc = yaml.load(readFileSync(path, 'utf8'));
99
+ } catch (e) {
100
+ throw new RelationLoadError(path, `YAML parse failed: ${e.message}`);
101
+ }
102
+ if (!doc || typeof doc !== 'object') return null;
103
+ if (!doc.type) return null;
104
+ const Cls = TYPE_TO_CLASS[doc.type];
105
+ if (!Cls) return null; // Unknown type — let the validator report it
106
+ return Cls.fromJSON(doc);
107
+ }
@@ -21,7 +21,7 @@
21
21
 
22
22
  import { PRED } from './predicates.js';
23
23
  import { namedNode, literal, quad } from './terms.js';
24
- import { multiplicityFromPair } from '../models/multiplicity.js';
24
+ import { resolveMultiplicity } from '../models/multiplicity.js';
25
25
 
26
26
  // SKOS taxonomy namespaces (concept-model will ship these taxonomies).
27
27
  // Until then, the URIs are forward-compatible and resolve once the
@@ -46,16 +46,15 @@ export function* partitiveMemberToQuads(member, { memberUri }) {
46
46
  );
47
47
  }
48
48
  // Derived multiplicity URI for v3 RDF consumer backward compat.
49
- // v4 PartitiveMember no longer exposes .multiplicity — compute from
50
- // presence + count via multiplicityFromPair. Accept raw JSON that
51
- // still carries a literal multiplicity field for pre-v4 data.
52
- let mult = null;
53
- if (member?.presence && member?.count) {
54
- try { mult = multiplicityFromPair(member.presence, member.count); }
55
- catch { /* invalid combination — skip derived emission */ }
56
- } else if (typeof member?.multiplicity === 'string') {
57
- mult = member.multiplicity;
58
- }
49
+ // v4 PartitiveMember carries presence + count natively; the legacy
50
+ // multiplicity name is derived via the SSOT resolveMultiplicity().
51
+ // For raw pre-v4 JSON that bypassed the model constructor and still
52
+ // carries a literal multiplicity field, the resolver falls back to
53
+ // that field. The invalid (optional, at_least_one) combination is
54
+ // rejected at model construction — if it reaches the throw here,
55
+ // the caller bypassed the model and we want the error to surface
56
+ // rather than silently producing wrong RDF.
57
+ const mult = resolveMultiplicity(member);
59
58
  if (mult) {
60
59
  yield quad(
61
60
  namedNode(memberUri),