@glossarist/concept-browser 0.7.86 → 0.7.88

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.
@@ -13,6 +13,14 @@ function extractPartitiveRelations(
13
13
  const relations: PartitiveRelationWire[] = [];
14
14
  const sourceUri = concept['@id'];
15
15
 
16
+ const LEGACY_MULTIPLICITY_MAP: Record<string, { presence: string; count: string }> = {
17
+ compulsory: { presence: 'required', count: 'exactly_one' },
18
+ optional: { presence: 'optional', count: 'exactly_one' },
19
+ compulsory_multiple: { presence: 'required', count: 'multiple' },
20
+ optional_multiple: { presence: 'optional', count: 'multiple' },
21
+ compulsory_at_least_one: { presence: 'required', count: 'at_least_one' },
22
+ };
23
+
16
24
  const resolveConceptUri = (ref: any): string | null => {
17
25
  const source = ref?.['gl:source'] || ref?.source;
18
26
  const id = ref?.['gl:id'] || ref?.id;
@@ -21,6 +29,29 @@ function extractPartitiveRelations(
21
29
  return `${uriBase}/${reg}/concept/${id}`;
22
30
  };
23
31
 
32
+ const resolvePresence = (member: any): 'required' | 'optional' => {
33
+ const p = member['gl:presence'] ?? member.presence;
34
+ if (p === 'required' || p === 'optional') return p as 'required' | 'optional';
35
+ const multiplicity = member['gl:multiplicity'] ?? member.multiplicity;
36
+ if (typeof multiplicity === 'string' && LEGACY_MULTIPLICITY_MAP[multiplicity]) {
37
+ return LEGACY_MULTIPLICITY_MAP[multiplicity].presence as 'required' | 'optional';
38
+ }
39
+ const certainty = member['gl:certainty'] ?? member.certainty;
40
+ return certainty === 'possible' ? 'optional' : 'required';
41
+ };
42
+
43
+ const resolveCount = (member: any): 'exactly_one' | 'at_least_one' | 'multiple' => {
44
+ const c = member['gl:count'] ?? member.count;
45
+ if (c === 'exactly_one' || c === 'at_least_one' || c === 'multiple') {
46
+ return c as 'exactly_one' | 'at_least_one' | 'multiple';
47
+ }
48
+ const multiplicity = member['gl:multiplicity'] ?? member.multiplicity;
49
+ if (typeof multiplicity === 'string' && LEGACY_MULTIPLICITY_MAP[multiplicity]) {
50
+ return LEGACY_MULTIPLICITY_MAP[multiplicity].count as 'exactly_one' | 'at_least_one' | 'multiple';
51
+ }
52
+ return 'exactly_one';
53
+ };
54
+
24
55
  for (const rel of concept['gl:partitiveRelations'] || []) {
25
56
  const comprehensive = resolveConceptUri(rel['gl:comprehensive']);
26
57
  if (!comprehensive) continue;
@@ -30,16 +61,14 @@ function extractPartitiveRelations(
30
61
  const memberRef = member['gl:ref'] || member;
31
62
  const uri = resolveConceptUri(memberRef);
32
63
  if (!uri || uri === sourceUri) return null;
33
- const multiplicity = member['gl:multiplicity'] || 'compulsory';
34
- const isDelimiting = member['gl:isDelimiting'] === true;
35
- if (!isValidMultiplicity(multiplicity)) {
36
- throw new Error(
37
- `Invalid partitive member multiplicity: "${multiplicity}". Allowed: compulsory, optional, compulsory_multiple, optional_multiple, compulsory_at_least_one`,
38
- );
39
- }
40
- return { uri, multiplicity, isDelimiting };
64
+ return {
65
+ uri,
66
+ presence: resolvePresence(member),
67
+ count: resolveCount(member),
68
+ isDelimiting: member['gl:isDelimiting'] === true || member.isDelimiting === true,
69
+ };
41
70
  })
42
- .filter((p: any): p is { uri: string; multiplicity: 'compulsory' | 'optional' | 'compulsory_multiple' | 'optional_multiple' | 'compulsory_at_least_one'; isDelimiting: boolean } => p !== null);
71
+ .filter((p: any): p is { uri: string; presence: 'required' | 'optional'; count: 'exactly_one' | 'at_least_one' | 'multiple'; isDelimiting: boolean } => p !== null);
43
72
 
44
73
  if (partitives.length === 0) continue;
45
74
 
@@ -61,17 +90,13 @@ function extractPartitiveRelations(
61
90
  return relations;
62
91
  }
63
92
 
64
- function isValidMultiplicity(value: unknown): value is 'compulsory' | 'optional' | 'compulsory_multiple' | 'optional_multiple' | 'compulsory_at_least_one' {
65
- return ['compulsory', 'optional', 'compulsory_multiple', 'optional_multiple', 'compulsory_at_least_one'].includes(value as string);
66
- }
67
-
68
- describe('extractPartitiveRelations', () => {
93
+ describe('extractPartitiveRelations (MECE presence × count)', () => {
69
94
  const uriBase = 'https://example.org';
70
95
  const urnMap = new Map([['urn:vim:pub:v:2:2012', 'vim-2012']]);
71
96
  const registerId = 'vim-2012';
72
97
  const sourceUri = `${uriBase}/${registerId}/concept/1.3`;
73
98
 
74
- it('extracts a complete relation with two compulsory members', () => {
99
+ it('extracts a complete relation with two required/exactly_one members', () => {
75
100
  const concept = {
76
101
  '@id': sourceUri,
77
102
  'gl:partitiveRelations': [
@@ -90,8 +115,8 @@ describe('extractPartitiveRelations', () => {
90
115
  expect(result).toHaveLength(1);
91
116
  expect(result[0].comprehensive).toBe(`${uriBase}/${registerId}/concept/1.3`);
92
117
  expect(result[0].partitives).toEqual([
93
- { uri: `${uriBase}/${registerId}/concept/1.4`, multiplicity: 'compulsory', isDelimiting: false },
94
- { uri: `${uriBase}/${registerId}/concept/1.5`, multiplicity: 'compulsory', isDelimiting: false },
118
+ { uri: `${uriBase}/${registerId}/concept/1.4`, presence: 'required', count: 'exactly_one', isDelimiting: false },
119
+ { uri: `${uriBase}/${registerId}/concept/1.5`, presence: 'required', count: 'exactly_one', isDelimiting: false },
95
120
  ]);
96
121
  expect(result[0].completeness).toBe('complete');
97
122
  });
@@ -129,7 +154,7 @@ describe('extractPartitiveRelations', () => {
129
154
  expect(rel.completeness).toBe('partial');
130
155
  });
131
156
 
132
- it('reads per-member multiplicity + isDelimiting', () => {
157
+ it('reads per-member presence + count + isDelimiting (v3 native)', () => {
133
158
  const concept = {
134
159
  '@id': sourceUri,
135
160
  'gl:partitiveRelations': [
@@ -137,51 +162,56 @@ describe('extractPartitiveRelations', () => {
137
162
  'gl:comprehensive': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.3' },
138
163
  'gl:hasPartitive': [
139
164
  { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.4' } },
140
- { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.5' }, 'gl:multiplicity': 'optional' },
141
- { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.6' }, 'gl:multiplicity': 'compulsory_multiple' },
142
- { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.7' }, 'gl:multiplicity': 'compulsory_at_least_one', 'gl:isDelimiting': true },
165
+ { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.5' }, 'gl:presence': 'optional' },
166
+ { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.6' }, 'gl:count': 'multiple' },
167
+ { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.7' }, 'gl:count': 'at_least_one', 'gl:isDelimiting': true },
143
168
  ],
144
169
  },
145
170
  ],
146
171
  };
147
172
  const [rel] = extractPartitiveRelations(concept, registerId, uriBase, urnMap);
148
173
  expect(rel.partitives).toEqual([
149
- { uri: `${uriBase}/${registerId}/concept/1.4`, multiplicity: 'compulsory', isDelimiting: false },
150
- { uri: `${uriBase}/${registerId}/concept/1.5`, multiplicity: 'optional', isDelimiting: false },
151
- { uri: `${uriBase}/${registerId}/concept/1.6`, multiplicity: 'compulsory_multiple', isDelimiting: false },
152
- { uri: `${uriBase}/${registerId}/concept/1.7`, multiplicity: 'compulsory_at_least_one', isDelimiting: true },
174
+ { uri: `${uriBase}/${registerId}/concept/1.4`, presence: 'required', count: 'exactly_one', isDelimiting: false },
175
+ { uri: `${uriBase}/${registerId}/concept/1.5`, presence: 'optional', count: 'exactly_one', isDelimiting: false },
176
+ { uri: `${uriBase}/${registerId}/concept/1.6`, presence: 'required', count: 'multiple', isDelimiting: false },
177
+ { uri: `${uriBase}/${registerId}/concept/1.7`, presence: 'required', count: 'at_least_one', isDelimiting: true },
153
178
  ]);
154
179
  });
155
180
 
156
- it('throws on invalid completeness', () => {
181
+ it('migrates legacy gl:multiplicity into presence + count (one-release compat)', () => {
157
182
  const concept = {
158
183
  '@id': sourceUri,
159
184
  'gl:partitiveRelations': [
160
185
  {
161
186
  'gl:comprehensive': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.3' },
162
187
  'gl:hasPartitive': [
163
- { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.4' } },
188
+ { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.4' }, 'gl:multiplicity': 'optional_multiple' },
189
+ { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.5' }, 'gl:multiplicity': 'compulsory_at_least_one' },
164
190
  ],
165
- 'gl:completeness': 'closed',
166
191
  },
167
192
  ],
168
193
  };
169
- expect(() => extractPartitiveRelations(concept, registerId, uriBase, urnMap)).toThrow(/Invalid.*completeness.*closed/);
194
+ const [rel] = extractPartitiveRelations(concept, registerId, uriBase, urnMap);
195
+ expect(rel.partitives).toEqual([
196
+ { uri: `${uriBase}/${registerId}/concept/1.4`, presence: 'optional', count: 'multiple', isDelimiting: false },
197
+ { uri: `${uriBase}/${registerId}/concept/1.5`, presence: 'required', count: 'at_least_one', isDelimiting: false },
198
+ ]);
170
199
  });
171
200
 
172
- it('throws on invalid multiplicity', () => {
201
+ it('throws on invalid completeness', () => {
173
202
  const concept = {
174
203
  '@id': sourceUri,
175
204
  'gl:partitiveRelations': [
176
205
  {
177
206
  'gl:comprehensive': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.3' },
178
207
  'gl:hasPartitive': [
179
- { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.4' }, 'gl:multiplicity': 'maybe' },
208
+ { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.4' } },
180
209
  ],
210
+ 'gl:completeness': 'closed',
181
211
  },
182
212
  ],
183
213
  };
184
- expect(() => extractPartitiveRelations(concept, registerId, uriBase, urnMap)).toThrow(/Invalid.*multiplicity.*maybe/);
214
+ expect(() => extractPartitiveRelations(concept, registerId, uriBase, urnMap)).toThrow(/Invalid.*completeness.*closed/);
185
215
  });
186
216
 
187
217
  it('skips a relation whose comprehensive cannot be resolved', () => {
@@ -103,4 +103,33 @@ describe('UriRouter', () => {
103
103
  expect(UriRouter.parseUri('https://example.com/other')).toBeNull();
104
104
  });
105
105
  });
106
+
107
+ describe('SSOT: UriRouter.buildConceptUri is the single concept-URI constructor', () => {
108
+ // Per AUDIT-2026-07-29 P1-1: ConceptIdentity, the conceptUri()
109
+ // bridge, and UriRouter.buildConceptUri must all funnel through
110
+ // UriRouter.buildConceptUri. No inline template literals for
111
+ // `${uriBase}/${registerId}/concept/${id}` outside UriRouter.
112
+
113
+ it('UriRouter.buildConceptUri constructs the canonical URI', () => {
114
+ expect(UriRouter.buildConceptUri('https://x.org', 'vim', '1.3'))
115
+ .toBe('https://x.org/vim/concept/1.3');
116
+ });
117
+
118
+ it('UriRouter.buildDomainUri constructs the canonical domain URI', () => {
119
+ expect(UriRouter.buildDomainUri('https://x.org', 'vim', 'physics'))
120
+ .toBe('https://x.org/vim/domain/physics');
121
+ });
122
+
123
+ it('ConceptIdentity.uri delegates to UriRouter.buildConceptUri', async () => {
124
+ const { ConceptIdentity } = await import('../adapters/concept-identity');
125
+ const id = new ConceptIdentity('1.3', 'vim', 'https://x.org');
126
+ expect(id.uri).toBe(UriRouter.buildConceptUri('https://x.org', 'vim', '1.3'));
127
+ });
128
+
129
+ it('ConceptIdentity.domainUri delegates to UriRouter.buildDomainUri', async () => {
130
+ const { ConceptIdentity } = await import('../adapters/concept-identity');
131
+ const id = new ConceptIdentity('1.3', 'vim', 'https://x.org');
132
+ expect(id.domainUri('physics')).toBe(UriRouter.buildDomainUri('https://x.org', 'vim', 'physics'));
133
+ });
134
+ });
106
135
  });
@@ -1,113 +1,137 @@
1
+ /**
2
+ * MECE refactor of partitive-multiplicity (ISO 704:2022).
3
+ *
4
+ * Types + enums come from glossarist-js (the SSOT for the model).
5
+ * The render-ready stroke style is the only browser-specific concern;
6
+ * its truth table is pinned here so future rendering edits stay
7
+ * aligned with the spec.
8
+ */
1
9
  import { describe, it, expect } from 'vitest';
2
10
  import {
3
- PARTITIVE_MULTIPLICITY,
4
- PARTITIVE_MULTIPLICITY_VALUES,
5
- isPartitiveMultiplicity,
6
- multiplicityDefinition,
11
+ PARTITIVE_PRESENCE,
12
+ PARTITIVE_COUNT,
13
+ isPartitivePresence,
14
+ isPartitiveCount,
15
+ presenceLabel,
16
+ countLabel,
7
17
  rakeStrokeStyle,
8
- multiplicityFromCertainty,
18
+ partitiveMultiplicityName,
9
19
  NORMAL_STROKE_WIDTH,
10
20
  DELIMITING_STROKE_WIDTH,
11
- type PartitiveMultiplicity,
21
+ type PartitivePresence,
22
+ type PartitiveCount,
12
23
  } from '../../utils/partitive-multiplicity';
13
24
 
14
- describe('partitive-multiplicity registry', () => {
15
- it('has exactly 5 ISO 704:2022 multiplicity values', () => {
16
- expect(PARTITIVE_MULTIPLICITY_VALUES).toEqual([
17
- 'compulsory',
18
- 'optional',
19
- 'compulsory_multiple',
20
- 'optional_multiple',
21
- 'compulsory_at_least_one',
22
- ]);
25
+ describe('presence axis (re-exported from glossarist)', () => {
26
+ it('has exactly 2 values: required, optional', () => {
27
+ expect([...PARTITIVE_PRESENCE.VALUES]).toEqual(['required', 'optional']);
23
28
  });
24
29
 
25
- it('compulsory: 1 line, solid', () => {
26
- expect(multiplicityDefinition('compulsory')).toEqual({
27
- lines: 1, pattern: 'solid', label: 'Compulsory',
28
- });
30
+ it('isPartitivePresence narrows known values', () => {
31
+ expect(isPartitivePresence('required')).toBe(true);
32
+ expect(isPartitivePresence('optional')).toBe(true);
33
+ expect(isPartitivePresence('unknown')).toBe(false);
34
+ expect(isPartitivePresence(null)).toBe(false);
35
+ expect(isPartitivePresence(42)).toBe(false);
29
36
  });
30
37
 
31
- it('optional: 1 line, dashed', () => {
32
- expect(multiplicityDefinition('optional')).toEqual({
33
- lines: 1, pattern: 'dashed', label: 'Optional',
34
- });
38
+ it('presenceLabel returns human-readable labels', () => {
39
+ expect(presenceLabel('required')).toBe('Required');
40
+ expect(presenceLabel('optional')).toBe('Optional');
41
+ });
42
+ });
43
+
44
+ describe('count axis (re-exported from glossarist)', () => {
45
+ it('has exactly 3 values: exactly_one, at_least_one, multiple', () => {
46
+ expect([...PARTITIVE_COUNT.VALUES]).toEqual(['exactly_one', 'at_least_one', 'multiple']);
35
47
  });
36
48
 
37
- it('compulsory_multiple: 2 lines, solid', () => {
38
- expect(multiplicityDefinition('compulsory_multiple')).toEqual({
39
- lines: 2, pattern: 'solid', label: 'Compulsory multiple',
40
- });
49
+ it('isPartitiveCount narrows known values', () => {
50
+ expect(isPartitiveCount('exactly_one')).toBe(true);
51
+ expect(isPartitiveCount('at_least_one')).toBe(true);
52
+ expect(isPartitiveCount('multiple')).toBe(true);
53
+ expect(isPartitiveCount('unknown')).toBe(false);
54
+ expect(isPartitiveCount(null)).toBe(false);
41
55
  });
42
56
 
43
- it('optional_multiple: 2 lines, dashed', () => {
44
- expect(multiplicityDefinition('optional_multiple')).toEqual({
45
- lines: 2, pattern: 'dashed', label: 'Optional multiple',
46
- });
57
+ it('countLabel returns human-readable labels', () => {
58
+ expect(countLabel('exactly_one')).toBe('Exactly one');
59
+ expect(countLabel('at_least_one')).toBe('At least one');
60
+ expect(countLabel('multiple')).toBe('Multiple');
47
61
  });
62
+ });
48
63
 
49
- it('compulsory_at_least_one: 2 lines, mixed (one solid + one dashed)', () => {
50
- expect(multiplicityDefinition('compulsory_at_least_one')).toEqual({
51
- lines: 2, pattern: 'mixed', label: 'At least one',
52
- });
64
+ describe('partitiveMultiplicityName (glossarist SSOT)', () => {
65
+ it('maps the 5 valid combinations to ISO 704 names', () => {
66
+ expect(partitiveMultiplicityName('required', 'exactly_one')).toBe('compulsory');
67
+ expect(partitiveMultiplicityName('optional', 'exactly_one')).toBe('optional');
68
+ expect(partitiveMultiplicityName('required', 'multiple')).toBe('compulsory_multiple');
69
+ expect(partitiveMultiplicityName('optional', 'multiple')).toBe('optional_multiple');
70
+ expect(partitiveMultiplicityName('required', 'at_least_one')).toBe('compulsory_at_least_one');
53
71
  });
54
72
 
55
- it('isPartitiveMultiplicity narrows known values', () => {
56
- expect(isPartitiveMultiplicity('compulsory')).toBe(true);
57
- expect(isPartitiveMultiplicity('compulsory_at_least_one')).toBe(true);
58
- expect(isPartitiveMultiplicity('unknown')).toBe(false);
59
- expect(isPartitiveMultiplicity(null)).toBe(false);
60
- expect(isPartitiveMultiplicity(42)).toBe(false);
73
+ it('rejects the vacuous (optional, at_least_one) combination', () => {
74
+ expect(() => partitiveMultiplicityName('optional', 'at_least_one'))
75
+ .toThrow(/collapses to optional \+ multiple/);
61
76
  });
62
77
  });
63
78
 
64
- describe('rakeStrokeStyle', () => {
65
- const cases: Array<{
66
- name: string;
67
- multiplicity: PartitiveMultiplicity;
68
- isDelimiting: boolean;
69
- expected: {
70
- lineCount: 1 | 2;
71
- primaryDashed: boolean;
72
- secondaryDashed: boolean;
73
- strokeWidth: number;
74
- };
75
- }> = [
76
- // 5 multiplicity × 2 delimiting = 10 variants
77
- { name: 'compulsory / non-delimiting', multiplicity: 'compulsory', isDelimiting: false, expected: { lineCount: 1, primaryDashed: false, secondaryDashed: false, strokeWidth: NORMAL_STROKE_WIDTH } },
78
- { name: 'compulsory / delimiting', multiplicity: 'compulsory', isDelimiting: true, expected: { lineCount: 1, primaryDashed: false, secondaryDashed: false, strokeWidth: DELIMITING_STROKE_WIDTH } },
79
- { name: 'optional / non-delimiting', multiplicity: 'optional', isDelimiting: false, expected: { lineCount: 1, primaryDashed: true, secondaryDashed: true, strokeWidth: NORMAL_STROKE_WIDTH } },
80
- { name: 'optional / delimiting', multiplicity: 'optional', isDelimiting: true, expected: { lineCount: 1, primaryDashed: true, secondaryDashed: true, strokeWidth: DELIMITING_STROKE_WIDTH } },
81
- { name: 'compulsory_multiple / non-delimiting', multiplicity: 'compulsory_multiple', isDelimiting: false, expected: { lineCount: 2, primaryDashed: false, secondaryDashed: false, strokeWidth: NORMAL_STROKE_WIDTH } },
82
- { name: 'compulsory_multiple / delimiting', multiplicity: 'compulsory_multiple', isDelimiting: true, expected: { lineCount: 2, primaryDashed: false, secondaryDashed: false, strokeWidth: DELIMITING_STROKE_WIDTH } },
83
- { name: 'optional_multiple / non-delimiting', multiplicity: 'optional_multiple', isDelimiting: false, expected: { lineCount: 2, primaryDashed: true, secondaryDashed: true, strokeWidth: NORMAL_STROKE_WIDTH } },
84
- { name: 'optional_multiple / delimiting', multiplicity: 'optional_multiple', isDelimiting: true, expected: { lineCount: 2, primaryDashed: true, secondaryDashed: true, strokeWidth: DELIMITING_STROKE_WIDTH } },
85
- { name: 'compulsory_at_least_one / non-delimiting', multiplicity: 'compulsory_at_least_one', isDelimiting: false, expected: { lineCount: 2, primaryDashed: false, secondaryDashed: true, strokeWidth: NORMAL_STROKE_WIDTH } },
86
- { name: 'compulsory_at_least_one / delimiting', multiplicity: 'compulsory_at_least_one', isDelimiting: true, expected: { lineCount: 2, primaryDashed: false, secondaryDashed: true, strokeWidth: DELIMITING_STROKE_WIDTH } },
87
- ];
79
+ describe('rakeStrokeStyle (MECE rendering truth table)', () => {
80
+ const presenceCases: PartitivePresence[] = ['required', 'optional'];
81
+ const countCases: PartitiveCount[] = ['exactly_one', 'at_least_one', 'multiple'];
82
+ const delimitingCases = [false, true];
88
83
 
89
- for (const c of cases) {
90
- it(`${c.name} → ${JSON.stringify(c.expected)}`, () => {
91
- expect(rakeStrokeStyle(c.multiplicity, c.isDelimiting)).toEqual(c.expected);
92
- });
84
+ for (const presence of presenceCases) {
85
+ for (const count of countCases) {
86
+ for (const isDelimiting of delimitingCases) {
87
+ const expectedWidth = isDelimiting ? DELIMITING_STROKE_WIDTH : NORMAL_STROKE_WIDTH;
88
+ const expectedPrimaryDashed = presence === 'optional';
89
+ const expectedLineCount: 1 | 2 = count === 'exactly_one' ? 1 : 2;
90
+ // When lineCount=1, secondaryDashed is unused (no second line).
91
+ // Implementation choice: set it to false as the default.
92
+ // When lineCount=2:
93
+ // - at_least_one: secondary always dashed ("possibly more")
94
+ // - multiple: secondary mirrors primary
95
+ const expectedSecondaryDashed = expectedLineCount === 1
96
+ ? false
97
+ : count === 'at_least_one'
98
+ ? true
99
+ : expectedPrimaryDashed;
100
+
101
+ it(`${presence} × ${count}${isDelimiting ? ' / delimiting' : ''}`, () => {
102
+ expect(rakeStrokeStyle(presence, count, isDelimiting)).toEqual({
103
+ lineCount: expectedLineCount,
104
+ primaryDashed: expectedPrimaryDashed,
105
+ secondaryDashed: expectedSecondaryDashed,
106
+ strokeWidth: expectedWidth,
107
+ });
108
+ });
109
+ }
110
+ }
93
111
  }
94
112
 
95
- it('delimiting strokeWidth is exactly 3× normal (per ISO 704)', () => {
113
+ it('delimiting strokeWidth is 3× normal (per ISO 704 convention)', () => {
96
114
  expect(DELIMITING_STROKE_WIDTH / NORMAL_STROKE_WIDTH).toBe(3);
97
115
  });
98
- });
99
-
100
- describe('multiplicityFromCertainty (glossarist 0.4.20 migration)', () => {
101
- it('confirmed → compulsory', () => {
102
- expect(multiplicityFromCertainty('confirmed')).toBe('compulsory');
103
- });
104
116
 
105
- it('possible → optional', () => {
106
- expect(multiplicityFromCertainty('possible')).toBe('optional');
117
+ it('exactly_one always produces a single line, regardless of presence', () => {
118
+ for (const presence of presenceCases) {
119
+ const style = rakeStrokeStyle(presence, 'exactly_one', false);
120
+ expect(style.lineCount).toBe(1);
121
+ expect(style.primaryDashed).toBe(presence === 'optional');
122
+ }
107
123
  });
108
124
 
109
- it('null / undefined → compulsory (default)', () => {
110
- expect(multiplicityFromCertainty(null)).toBe('compulsory');
111
- expect(multiplicityFromCertainty(undefined)).toBe('compulsory');
125
+ it('at_least_one differs from multiple by secondary line dash pattern', () => {
126
+ for (const presence of presenceCases) {
127
+ const atleastOne = rakeStrokeStyle(presence, 'at_least_one', false);
128
+ const multiple = rakeStrokeStyle(presence, 'multiple', false);
129
+ expect(atleastOne.lineCount).toBe(2);
130
+ expect(multiple.lineCount).toBe(2);
131
+ // at_least_one: secondary always dashed (possibly more)
132
+ expect(atleastOne.secondaryDashed).toBe(true);
133
+ // multiple: secondary matches primary
134
+ expect(multiple.secondaryDashed).toBe(multiple.primaryDashed);
135
+ }
112
136
  });
113
137
  });
@@ -137,7 +137,7 @@ export class GraphDataSource {
137
137
  if (lc?.domain) {
138
138
  edges.push({
139
139
  source: sourceUri,
140
- target: `${this.uriBase}/${this.registerId}/domain/${slugify(lc.domain)}`,
140
+ target: UriRouter.buildDomainUri(this.uriBase, this.registerId, slugify(lc.domain)),
141
141
  type: 'domain',
142
142
  label: lc.domain,
143
143
  register: this.registerId,
@@ -1,5 +1,4 @@
1
1
  import type { Manifest } from './types';
2
- import { ConceptIdentity } from './concept-identity';
3
2
 
4
3
  // ── URI pattern matching ────────────────────────────────────────────────────
5
4
 
@@ -106,11 +105,16 @@ export class UriRouter {
106
105
  return `${uriBase}/${registerId}/concept/${conceptId}`;
107
106
  }
108
107
 
109
- /** Construct a canonical URI for a concept. */
108
+ /** Construct a canonical domain URI from components. */
109
+ static buildDomainUri(uriBase: string, registerId: string, domainSlug: string): string {
110
+ return `${uriBase}/${registerId}/domain/${domainSlug}`;
111
+ }
112
+
113
+ /** Construct a canonical URI for a concept from its register-id + concept-id. */
110
114
  buildUri(registerId: string, conceptId: string): string {
111
115
  const info = this.registerMap.get(registerId);
112
116
  const uriBase = info?.uriBase ?? '';
113
- return new ConceptIdentity(conceptId, registerId, uriBase).uri;
117
+ return UriRouter.buildConceptUri(uriBase, registerId, conceptId);
114
118
  }
115
119
 
116
120
  getRegisteredIds(): string[] {
@@ -1,4 +1,5 @@
1
1
  import { InvalidConceptIdentityError, InvalidConceptUriError } from '../errors';
2
+ import { UriRouter } from './UriRouter';
2
3
 
3
4
  export interface ConceptIdentityParts {
4
5
  readonly localId: string;
@@ -24,7 +25,10 @@ export class ConceptIdentity implements ConceptIdentityParts {
24
25
  { localId, registerId, uriBase },
25
26
  );
26
27
  }
27
- this._uri = `${uriBase}/${registerId}/concept/${localId}`;
28
+ // Single SSOT for concept URI construction: UriRouter.buildConceptUri.
29
+ // ConceptIdentity, the conceptUri() bridge, and UriRouter.buildConceptUri
30
+ // all funnel here — no inline template literals scattered across files.
31
+ this._uri = UriRouter.buildConceptUri(uriBase, registerId, localId);
28
32
  this._slug = localId;
29
33
  this._path = `${registerId}/concepts/${localId}`;
30
34
  }
@@ -52,7 +56,7 @@ export class ConceptIdentity implements ConceptIdentityParts {
52
56
  }
53
57
 
54
58
  domainUri(domainSlug: string): string {
55
- return `${this.uriBase}/${this.registerId}/domain/${domainSlug}`;
59
+ return UriRouter.buildDomainUri(this.uriBase, this.registerId, domainSlug);
56
60
  }
57
61
 
58
62
  static fromUri(uri: string): ConceptIdentity {
@@ -25,6 +25,10 @@ import {
25
25
  DATE_TYPES,
26
26
  ConceptRef,
27
27
  } from 'glossarist';
28
+ import {
29
+ isPartitivePresence,
30
+ isPartitiveCount,
31
+ } from '../utils/partitive-multiplicity';
28
32
  import {
29
33
  LetterSymbol,
30
34
  GrammarInfo,
@@ -166,11 +170,15 @@ interface JsonLdLocalizedConcept {
166
170
 
167
171
  interface JsonLdPartitiveMember {
168
172
  'gl:ref'?: JsonLdRef;
169
- /** ISO 704:2022 v3 field (native in glossarist 0.4.24). */
170
- 'gl:multiplicity'?: string;
171
- /** ISO 704:2022 v3 field (native in glossarist 0.4.24). */
173
+ /** ISO 704:2022 MECE: presence (required | optional) */
174
+ 'gl:presence'?: string;
175
+ /** ISO 704:2022 MECE: count (exactly_one | at_least_one | multiple) */
176
+ 'gl:count'?: string;
177
+ /** ISO 704:2022: delimiting part (3× stroke width in diagrams) */
172
178
  'gl:isDelimiting'?: boolean;
173
- /** Legacy v2 field — still read for backward compat with older datasets. */
179
+ /** Legacy v3 field — one-string multiplicity, split into presence+count */
180
+ 'gl:multiplicity'?: string;
181
+ /** Legacy v2 field — migrated to presence+count */
174
182
  'gl:certainty'?: string;
175
183
  }
176
184
 
@@ -590,16 +598,23 @@ function mapPartitiveRelationFromJsonLd(r: JsonLdPartitiveRelation): Record<stri
590
598
  const ref = m['gl:ref'] ? mapRefFromJsonLd(m['gl:ref']) : null;
591
599
  if (!ref) return null;
592
600
  const out: Record<string, unknown> = { ref };
593
- // ISO 704:2022 multiplicity + is_delimiting (v3). Fall back to
594
- // legacy gl:certainty for backward compat.
595
- if (m['gl:multiplicity']) {
596
- out.multiplicity = m['gl:multiplicity'];
597
- } else if (m['gl:certainty']) {
598
- out.multiplicity = m['gl:certainty'] === 'possible' ? 'optional' : 'compulsory';
599
- }
600
- if (m['gl:isDelimiting'] === true) {
601
- out.is_delimiting = true;
601
+ // ISO 704:2022 MECE: prefer presence × count from JSON-LD. Fall back
602
+ // to migrating the legacy one-string `multiplicity` or v2 `certainty`
603
+ // so data in transit from older glossarist versions still loads.
604
+ const presence = m['gl:presence'];
605
+ const count = m['gl:count'];
606
+ if (isPartitivePresence(presence) && isPartitiveCount(count)) {
607
+ out.presence = presence;
608
+ out.count = count;
609
+ } else {
610
+ const raw = m['gl:multiplicity'] ?? splitLegacyCertainty(m['gl:certainty']);
611
+ if (raw) {
612
+ const parts = splitMultiplicity(raw);
613
+ out.presence = parts.presence;
614
+ out.count = parts.count;
615
+ }
602
616
  }
617
+ if (m['gl:isDelimiting'] === true) out.is_delimiting = true;
603
618
  return out;
604
619
  })
605
620
  .filter((m): m is Record<string, unknown> => m !== null);
@@ -624,6 +639,24 @@ function mapPartitiveRelationFromJsonLd(r: JsonLdPartitiveRelation): Record<stri
624
639
  return out;
625
640
  }
626
641
 
642
+ /** Migrate legacy v2 certainty → old v3 multiplicity string. */
643
+ function splitLegacyCertainty(certainty: string | undefined): string | null {
644
+ if (!certainty) return null;
645
+ return certainty === 'possible' ? 'optional' : 'compulsory';
646
+ }
647
+
648
+ /** Split the legacy one-string multiplicity into MECE presence × count. */
649
+ function splitMultiplicity(m: string): { presence: 'required' | 'optional'; count: 'exactly_one' | 'at_least_one' | 'multiple' } {
650
+ const LEGACY_MAP: Record<string, { presence: 'required' | 'optional'; count: 'exactly_one' | 'at_least_one' | 'multiple' }> = {
651
+ compulsory: { presence: 'required', count: 'exactly_one' },
652
+ optional: { presence: 'optional', count: 'exactly_one' },
653
+ compulsory_multiple: { presence: 'required', count: 'multiple' },
654
+ optional_multiple: { presence: 'optional', count: 'multiple' },
655
+ compulsory_at_least_one: { presence: 'required', count: 'at_least_one' },
656
+ };
657
+ return LEGACY_MAP[m] ?? { presence: 'required', count: 'exactly_one' };
658
+ }
659
+
627
660
  function conceptFromJsonLd(doc: JsonLdConcept): Concept {
628
661
  const id = String(doc['gl:identifier'] ?? doc['@id']?.split('/').pop() ?? '');
629
662
  const localizations: Record<string, unknown> = {};