@glossarist/concept-browser 0.7.90 → 0.7.92

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/concept-browser",
3
- "version": "0.7.90",
3
+ "version": "0.7.92",
4
4
  "description": "Vue SPA for browsing Glossarist terminology datasets with cross-reference resolution, graph visualization, and multi-language support",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,7 +37,7 @@
37
37
  "astro": "^7.0.7",
38
38
  "d3": "^7.9.0",
39
39
  "favicons": "^7.2.0",
40
- "glossarist": "^0.4.26",
40
+ "glossarist": "^0.4.33",
41
41
  "js-yaml": "^4.1.0",
42
42
  "jszip": "^3.10.1",
43
43
  "pinia": "^2.3.1",
@@ -0,0 +1,147 @@
1
+ /**
2
+ * GenericRelationList mount test — pins the wire-type contract so
3
+ * the 5 type bugs found in audit (entry.titles, member.ref,
4
+ * is_delimiting casing, comprehensive.source, missing props)
5
+ * can't regress.
6
+ */
7
+ import { describe, it, expect, vi } from 'vitest';
8
+ import { mount } from '@vue/test-utils';
9
+ import GenericRelationList from '../../components/GenericRelationList.vue';
10
+ import type { GenericRelationWire } from '../../adapters/types';
11
+
12
+ // Stub the composables the component depends on
13
+ vi.mock('../../stores/vocabulary', () => ({
14
+ useVocabularyStore: () => ({
15
+ graph: { getNode: () => null },
16
+ datasets: new Map(),
17
+ }),
18
+ }));
19
+
20
+ vi.mock('../../adapters/factory', () => ({
21
+ getFactory: () => ({
22
+ resolve: (uri: string) => ({ type: 'unresolved' as const, uri }),
23
+ }),
24
+ }));
25
+
26
+ vi.mock('../../i18n', () => ({
27
+ useI18n: () => ({ t: (key: string) => key }),
28
+ locale: { value: 'eng' },
29
+ }));
30
+
31
+ vi.mock('vue-router', () => ({
32
+ useRouter: () => ({ push: vi.fn() }),
33
+ }));
34
+
35
+ const manifest = {
36
+ id: 'test',
37
+ datasetUri: 'https://example.org/test',
38
+ title: 'Test',
39
+ description: '',
40
+ owner: '',
41
+ baseUrl: '',
42
+ languages: ['eng'],
43
+ conceptCount: 0,
44
+ conceptUrlTemplate: '',
45
+ indexUrl: '',
46
+ contextUrl: '',
47
+ uriBase: 'https://example.org',
48
+ status: 'valid',
49
+ schemaVersion: '3',
50
+ tags: [],
51
+ lastUpdated: '',
52
+ sourceRepo: '',
53
+ chunkSize: 100,
54
+ } as any;
55
+
56
+ function makeRelation(overrides: Partial<GenericRelationWire> = {}): GenericRelationWire {
57
+ return {
58
+ source: 'https://example.org/test/concept/5-1',
59
+ comprehensive: 'https://example.org/test/concept/5-1',
60
+ members: [
61
+ { uri: 'https://example.org/test/concept/5-4', presence: 'required', count: 'exactly_one', isDelimiting: false },
62
+ { uri: 'https://example.org/test/concept/5-5', presence: 'required', count: 'exactly_one', isDelimiting: false },
63
+ ],
64
+ completeness: 'complete',
65
+ register: 'test',
66
+ ...overrides,
67
+ };
68
+ }
69
+
70
+ describe('GenericRelationList — wire-type contract mount test', () => {
71
+ it('mounts without errors for a well-formed relation', () => {
72
+ const w = mount(GenericRelationList, {
73
+ props: {
74
+ relations: [makeRelation()],
75
+ manifest,
76
+ registerId: 'test',
77
+ },
78
+ });
79
+ expect(w.html()).toContain('generic-relation-card');
80
+ });
81
+
82
+ it('renders completeness badge', () => {
83
+ const w = mount(GenericRelationList, {
84
+ props: {
85
+ relations: [makeRelation({ completeness: 'partial' })],
86
+ manifest,
87
+ registerId: 'test',
88
+ },
89
+ });
90
+ expect(w.html()).toContain('completeness-partial');
91
+ });
92
+
93
+ it('renders multiple relations', () => {
94
+ const w = mount(GenericRelationList, {
95
+ props: {
96
+ relations: [
97
+ makeRelation({ comprehensive: 'https://example.org/test/concept/5-1' }),
98
+ makeRelation({ comprehensive: 'https://example.org/test/concept/5-2' }),
99
+ ],
100
+ manifest,
101
+ registerId: 'test',
102
+ },
103
+ });
104
+ expect(w.findAll('.generic-relation-card')).toHaveLength(2);
105
+ });
106
+
107
+ it('renders criterion when present', () => {
108
+ const w = mount(GenericRelationList, {
109
+ props: {
110
+ relations: [makeRelation({ criterion: { eng: 'by realization medium' } })],
111
+ manifest,
112
+ registerId: 'test',
113
+ },
114
+ });
115
+ expect(w.html()).toContain('by realization medium');
116
+ });
117
+
118
+ it('renders member presence/count badges for non-default values', () => {
119
+ const w = mount(GenericRelationList, {
120
+ props: {
121
+ relations: [makeRelation({
122
+ members: [
123
+ { uri: 'https://example.org/test/concept/5-4', presence: 'optional', count: 'multiple', isDelimiting: true },
124
+ { uri: 'https://example.org/test/concept/5-5', presence: 'required', count: 'exactly_one', isDelimiting: false },
125
+ ],
126
+ })],
127
+ manifest,
128
+ registerId: 'test',
129
+ },
130
+ });
131
+ const html = w.html();
132
+ expect(html).toContain('Optional');
133
+ expect(html).toContain('Multiple');
134
+ });
135
+
136
+ it('renders empty section when relations array is empty', () => {
137
+ const w = mount(GenericRelationList, {
138
+ props: {
139
+ relations: [],
140
+ manifest,
141
+ registerId: 'test',
142
+ },
143
+ });
144
+ expect(w.find('section.generic-relations').exists()).toBe(true);
145
+ expect(w.findAll('.generic-relation-card')).toHaveLength(0);
146
+ });
147
+ });
@@ -1,14 +1,13 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- // glossarist-js 0.4.26's top-level d.ts doesn't re-export the MECE
3
- // PartitiveRelation/PartitiveMember classes; import from glossarist/models
4
- // (extended by our local augmentation) so the types are correct.
2
+ // glossarist 0.4.33 renamed PartitiveRelation → PartitiveHyperedge.
3
+ // Import from glossarist/models (extended by our local augmentation).
5
4
  import type {
6
- PartitiveRelation,
5
+ PartitiveHyperedge,
7
6
  PartitiveMember,
8
7
  ConceptRef,
9
8
  } from 'glossarist/models';
10
9
  import {
11
- PartitiveRelation as PartitiveRelationModel,
10
+ PartitiveHyperedge as PartitiveHyperedgeModel,
12
11
  PartitiveMember as PartitiveMemberModel,
13
12
  ConceptRef as ConceptRefModel,
14
13
  } from 'glossarist/models';
@@ -48,8 +47,8 @@ function makeRelation(opts: {
48
47
  partitives: PartitiveMember[];
49
48
  completeness?: 'complete' | 'partial';
50
49
  criterion?: Record<string, string>;
51
- }): PartitiveRelation {
52
- return PartitiveRelationModel.fromJSON(opts as unknown as Record<string, unknown>) as PartitiveRelation;
50
+ }): PartitiveHyperedge {
51
+ return PartitiveHyperedgeModel.fromJSON(opts as unknown as Record<string, unknown>) as PartitiveHyperedge;
53
52
  }
54
53
 
55
54
  describe('useConceptEdges — MECE PartitiveRelation (glossarist 0.4.26)', () => {
@@ -57,8 +56,8 @@ describe('useConceptEdges — MECE PartitiveRelation (glossarist 0.4.26)', () =>
57
56
  it('presence defaults to required, count defaults to exactly_one', () => {
58
57
  const m = makePartitive('test', '1.1');
59
58
  expect(m.presence).toBe('required');
60
- expect(m.isRequired).toBe(true);
61
- expect(m.isOptional).toBe(false);
59
+ expect(m.presence === "required").toBe(true);
60
+ expect(m.presence === "optional").toBe(false);
62
61
  });
63
62
 
64
63
  it('count defaults to exactly_one', () => {
@@ -69,8 +68,8 @@ describe('useConceptEdges — MECE PartitiveRelation (glossarist 0.4.26)', () =>
69
68
  it('presence=optional is preserved', () => {
70
69
  const m = makePartitive('test', '1.1', { presence: 'optional' });
71
70
  expect(m.presence).toBe('optional');
72
- expect(m.isOptional).toBe(true);
73
- expect(m.isRequired).toBe(false);
71
+ expect(m.presence === "optional").toBe(true);
72
+ expect(m.presence === "required").toBe(false);
74
73
  });
75
74
 
76
75
  it('count=multiple is preserved', () => {
@@ -86,13 +85,13 @@ describe('useConceptEdges — MECE PartitiveRelation (glossarist 0.4.26)', () =>
86
85
  it('is_delimiting defaults to false', () => {
87
86
  const m = makePartitive('test', '1.1');
88
87
  expect(m.is_delimiting).toBe(false);
89
- expect(m.isDelimiting).toBe(false);
88
+ expect(m.is_delimiting).toBe(false);
90
89
  });
91
90
 
92
91
  it('is_delimiting is preserved when set to true', () => {
93
92
  const m = makePartitive('test', '1.1', { isDelimiting: true });
94
93
  expect(m.is_delimiting).toBe(true);
95
- expect(m.isDelimiting).toBe(true);
94
+ expect(m.is_delimiting).toBe(true);
96
95
  });
97
96
 
98
97
  it('rejects the vacuous (presence=optional, count=at_least_one)', () => {
@@ -108,10 +107,10 @@ describe('useConceptEdges — MECE PartitiveRelation (glossarist 0.4.26)', () =>
108
107
  comprehensive: makeRef('test', '1.3'),
109
108
  partitives: [makePartitive('test', '1.4'), makePartitive('test', '1.5')],
110
109
  });
111
- expect(r.partitives).toHaveLength(2);
112
- expect(r.completeness).toBe('complete');
113
- expect(r.isComplete).toBe(true);
114
- expect(r.isPartial).toBe(false);
110
+ expect((r as any).members).toHaveLength(2);
111
+ expect((r as any).completeness).toBe('complete');
112
+ expect((r as any).isComplete).toBe(true);
113
+ expect((r as any).isPartial).toBe(false);
115
114
  });
116
115
 
117
116
  it('completeness=partial is preserved', () => {
@@ -120,8 +119,8 @@ describe('useConceptEdges — MECE PartitiveRelation (glossarist 0.4.26)', () =>
120
119
  partitives: [makePartitive('test', '1.4'), makePartitive('test', '1.5')],
121
120
  completeness: 'partial',
122
121
  });
123
- expect(r.completeness).toBe('partial');
124
- expect(r.isPartial).toBe(true);
122
+ expect((r as any).completeness).toBe('partial');
123
+ expect((r as any).isPartial).toBe(true);
125
124
  });
126
125
 
127
126
  it('isCoordinate true when ≥2 partitives', () => {
@@ -129,7 +128,7 @@ describe('useConceptEdges — MECE PartitiveRelation (glossarist 0.4.26)', () =>
129
128
  comprehensive: makeRef('test', '1.3'),
130
129
  partitives: [makePartitive('test', '1.4'), makePartitive('test', '1.5')],
131
130
  });
132
- expect(r.isCoordinate).toBe(true);
131
+ expect((r as any).isCoordinate).toBe(true);
133
132
  });
134
133
 
135
134
  it('rejects < 2 partitives (ISO 704 requirement)', () => {
@@ -0,0 +1,166 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ // glossarist 0.4.33 renamed PartitiveRelation → PartitiveHyperedge.
3
+ // Import from glossarist/models (extended by our local augmentation).
4
+ import type {
5
+ PartitiveHyperedge,
6
+ PartitiveMember,
7
+ ConceptRef,
8
+ } from 'glossarist/models';
9
+ import {
10
+ PartitiveHyperedge as PartitiveHyperedgeModel,
11
+ PartitiveMember as PartitiveMemberModel,
12
+ ConceptRef as ConceptRefModel,
13
+ } from 'glossarist/models';
14
+
15
+ /**
16
+ * Verifies glossarist-js 0.4.26's MECE-native PartitiveRelation model
17
+ * (ISO 704:2022 presence × count + is_delimiting).
18
+ *
19
+ * The concept-browser consumes these fields directly (no migration
20
+ * bridge). These specs pin the runtime contract so a regression in
21
+ * upstream is caught here.
22
+ */
23
+
24
+ function makeRef(source: string, id: string): ConceptRef {
25
+ return ConceptRefModel.fromJSON({ source, id }) as ConceptRef;
26
+ }
27
+
28
+ function makePartitive(
29
+ source: string,
30
+ id: string,
31
+ fields: { presence?: 'required' | 'optional'; count?: 'exactly_one' | 'at_least_one' | 'multiple'; isDelimiting?: boolean } = {},
32
+ ): PartitiveMember {
33
+ const data: {
34
+ ref: ConceptRef;
35
+ presence?: 'required' | 'optional';
36
+ count?: 'exactly_one' | 'at_least_one' | 'multiple';
37
+ is_delimiting?: boolean;
38
+ } = { ref: makeRef(source, id) };
39
+ if (fields.presence) data.presence = fields.presence;
40
+ if (fields.count) data.count = fields.count;
41
+ if (fields.isDelimiting) data.is_delimiting = fields.isDelimiting;
42
+ return PartitiveMemberModel.fromJSON(data as unknown as Record<string, unknown>) as PartitiveMember;
43
+ }
44
+
45
+ function makeRelation(opts: {
46
+ comprehensive: ConceptRef;
47
+ partitives: PartitiveMember[];
48
+ completeness?: 'complete' | 'partial';
49
+ criterion?: Record<string, string>;
50
+ }): PartitiveHyperedge {
51
+ return PartitiveHyperedgeModel.fromJSON(opts as unknown as Record<string, unknown>) as PartitiveHyperedge;
52
+ }
53
+
54
+ describe('useConceptEdges — MECE PartitiveRelation (glossarist 0.4.26)', () => {
55
+ describe('PartitiveMember (ISO 704:2022 presence × count + delimiting)', () => {
56
+ it('presence defaults to required, count defaults to exactly_one', () => {
57
+ const m = makePartitive('test', '1.1');
58
+ expect(m.presence).toBe('required');
59
+ expect(m.presence === "required").toBe(true);
60
+ expect(m.presence === "optional").toBe(false);
61
+ });
62
+
63
+ it('count defaults to exactly_one', () => {
64
+ const m = makePartitive('test', '1.1');
65
+ expect(m.count).toBe('exactly_one');
66
+ });
67
+
68
+ it('presence=optional is preserved', () => {
69
+ const m = makePartitive('test', '1.1', { presence: 'optional' });
70
+ expect(m.presence).toBe('optional');
71
+ expect(m.presence === "optional").toBe(true);
72
+ expect(m.presence === "required").toBe(false);
73
+ });
74
+
75
+ it('count=multiple is preserved', () => {
76
+ const m = makePartitive('test', '1.1', { count: 'multiple' });
77
+ expect(m.count).toBe('multiple');
78
+ });
79
+
80
+ it('count=at_least_one is preserved', () => {
81
+ const m = makePartitive('test', '1.1', { count: 'at_least_one' });
82
+ expect(m.count).toBe('at_least_one');
83
+ });
84
+
85
+ it('is_delimiting defaults to false', () => {
86
+ const m = makePartitive('test', '1.1');
87
+ expect(m.is_delimiting).toBe(false);
88
+ expect(m.is_delimiting).toBe(false);
89
+ });
90
+
91
+ it('is_delimiting is preserved when set to true', () => {
92
+ const m = makePartitive('test', '1.1', { isDelimiting: true });
93
+ expect(m.is_delimiting).toBe(true);
94
+ expect(m.is_delimiting).toBe(true);
95
+ });
96
+
97
+ it('rejects the vacuous (presence=optional, count=at_least_one)', () => {
98
+ expect(() =>
99
+ makePartitive('test', '1.1', { presence: 'optional', count: 'at_least_one' }),
100
+ ).toThrow(/collapses to optional \+ multiple/);
101
+ });
102
+ });
103
+
104
+ describe('PartitiveRelation', () => {
105
+ it('accepts 2 members with default axes', () => {
106
+ const r = makeRelation({
107
+ comprehensive: makeRef('test', '1.3'),
108
+ partitives: [makePartitive('test', '1.4'), makePartitive('test', '1.5')],
109
+ });
110
+ expect(r.members).toHaveLength(2);
111
+ expect((r as any).completeness).toBe('complete');
112
+ expect((r as any).isComplete).toBe(true);
113
+ expect((r as any).isPartial).toBe(false);
114
+ });
115
+
116
+ it('completeness=partial is preserved', () => {
117
+ const r = makeRelation({
118
+ comprehensive: makeRef('test', '1.3'),
119
+ partitives: [makePartitive('test', '1.4'), makePartitive('test', '1.5')],
120
+ completeness: 'partial',
121
+ });
122
+ expect((r as any).completeness).toBe('partial');
123
+ expect((r as any).isPartial).toBe(true);
124
+ });
125
+
126
+ it('isCoordinate true when ≥2 partitives', () => {
127
+ const r = makeRelation({
128
+ comprehensive: makeRef('test', '1.3'),
129
+ partitives: [makePartitive('test', '1.4'), makePartitive('test', '1.5')],
130
+ });
131
+ expect((r as any).isCoordinate).toBe(true);
132
+ });
133
+
134
+ it('rejects < 2 partitives (ISO 704 requirement)', () => {
135
+ expect(() => makeRelation({
136
+ comprehensive: makeRef('test', '1.3'),
137
+ partitives: [makePartitive('test', '1.4')],
138
+ })).toThrow(/≥2 partitives/);
139
+ });
140
+
141
+ it('round-trips through toJSON with MECE fields', () => {
142
+ const r = makeRelation({
143
+ comprehensive: makeRef('test', '1.3'),
144
+ partitives: [
145
+ makePartitive('test', '1.4', { presence: 'required', count: 'multiple' }),
146
+ makePartitive('test', '1.5', { presence: 'optional', isDelimiting: true }),
147
+ ],
148
+ completeness: 'partial',
149
+ criterion: { eng: 'physical structure' },
150
+ });
151
+ const json = r.toJSON() as {
152
+ comprehensive: unknown;
153
+ partitives: Array<{ presence?: string; count?: string; is_delimiting?: boolean; ref: unknown }>;
154
+ completeness: string;
155
+ criterion?: Record<string, string>;
156
+ };
157
+ expect(json.comprehensive).toBeDefined();
158
+ expect(json.partitives).toHaveLength(2);
159
+ expect(json.partitives[0].count).toBe('multiple');
160
+ expect(json.partitives[1].presence).toBe('optional');
161
+ expect(json.partitives[1].is_delimiting).toBe(true);
162
+ expect(json.completeness).toBe('partial');
163
+ expect(json.criterion).toEqual({ eng: 'physical structure' });
164
+ });
165
+ });
166
+ });
@@ -23,9 +23,9 @@ interface PartitiveMemberLike {
23
23
 
24
24
  function loadPartitives(doc: Record<string, unknown>): PartitiveMemberLike[] {
25
25
  const concept = conceptFromJson(doc as any);
26
- const relations = (concept as any).partitiveRelations ?? [];
26
+ const relations = (concept as any).relations ?? [];
27
27
  return relations.flatMap((r: any) =>
28
- (r.partitives ?? []).map((m: any) => ({
28
+ ((r.members ?? r.partitives) ?? []).map((m: any) => ({
29
29
  presence: m.presence,
30
30
  count: m.count,
31
31
  is_delimiting: m.is_delimiting,
@@ -60,6 +60,6 @@ describe('GL wire-key SSOT', () => {
60
60
  // The point of this test: when adding a key, you remember to update
61
61
  // the count; when removing, you notice the test fails. Catches
62
62
  // accidental drift in either direction.
63
- expect(Object.keys(GL).length).toBe(87);
63
+ expect(Object.keys(GL).length).toBe(90);
64
64
  });
65
65
  });
@@ -10,7 +10,7 @@ import type { ConceptSummary } from '../types';
10
10
  import { ConceptIdentity } from '../concept-identity';
11
11
  import { GL } from '../wire-keys';
12
12
  import { isJsonLd, mapLocalizedFromJsonLd, mapRelatedFromJsonLd } from './mappers';
13
- import { mapPartitiveRelationFromJsonLd } from './partitive';
13
+ import { mapHyperedgeFromJsonLd } from './hyperedge';
14
14
  import { normalizeEntityRefs } from './entity-refs';
15
15
  import { attachBridges, attachSourcedFromToSources } from './bridges';
16
16
  import type { JsonLdConcept } from './jsonld-types';
@@ -27,9 +27,18 @@ function conceptFromJsonLd(doc: JsonLdConcept): Concept {
27
27
  }
28
28
 
29
29
  const related = (doc[GL.RELATED] ?? []).map(mapRelatedFromJsonLd);
30
+
31
+ // Unified hyperedge mapping: both partitive and generic relations
32
+ // share the same JSON-LD wire shape. glossarist-js dispatches by
33
+ // wire key (`partitive_relations` / `generic_relations`).
30
34
  const partitiveRelations = (doc[GL.PARTITIVE_RELATIONS] ?? [])
31
- .map(mapPartitiveRelationFromJsonLd)
35
+ .map((r: any) => mapHyperedgeFromJsonLd(r, 'gl:hasPartitive'))
36
+ .filter((r): r is Record<string, unknown> => r !== null);
37
+
38
+ const genericRelations = (doc[GL.GENERIC_RELATIONS] ?? [])
39
+ .map((r: any) => mapHyperedgeFromJsonLd(r, 'gl:hasGeneric'))
32
40
  .filter((r): r is Record<string, unknown> => r !== null);
41
+
33
42
  const tagsArr = doc[GL.TAGS];
34
43
  const tags = Array.isArray(tagsArr) ? [...tagsArr] : [];
35
44
 
@@ -40,6 +49,7 @@ function conceptFromJsonLd(doc: JsonLdConcept): Concept {
40
49
  localizations,
41
50
  related,
42
51
  partitive_relations: partitiveRelations,
52
+ generic_relations: genericRelations,
43
53
  tags,
44
54
  figures: normalizeEntityRefs(doc[GL.FIGURE_REF]),
45
55
  tables: normalizeEntityRefs(doc[GL.TABLE_REF]),
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Hyperedge (n-ary relation) mapping: JSON-LD → glossarist-js model.
3
+ *
4
+ * Handles ISO 704:2022 n-ary relations — both PartitiveHyperedge and
5
+ * GenericHyperedge share the same JSON-LD wire shape. The mapper is
6
+ * identical for both; the glossarist-js Concept constructor dispatches
7
+ * by wire key (`partitive_relations` vs `generic_relations`).
8
+ *
9
+ * MECE migration: reads presence × count directly (ISO 704:2022 v3).
10
+ * Falls back to migrating the legacy 5-value `multiplicity` or v2
11
+ * `certainty` field.
12
+ */
13
+ import { GL } from '../wire-keys';
14
+ import { isPartitivePresence, isPartitiveCount } from '../../utils/partitive-multiplicity';
15
+ import { mapRefFromJsonLd } from './mappers';
16
+ import type { JsonLdPartitiveRelation } from './jsonld-types';
17
+
18
+ /**
19
+ * Map a JSON-LD relation entry to the raw dict shape that
20
+ * glossarist-js's AbstractHyperedge subclasses accept.
21
+ *
22
+ * Works for both PartitiveHyperedge and GenericHyperedge — the only
23
+ * difference at the wire level is which wire key the parent dict
24
+ * uses (`partitive_relations` vs `generic_relations`).
25
+ *
26
+ * Reads `gl:hasPartitive`, `gl:hasGeneric`, or `gl:members` for
27
+ * the member array (in order of preference).
28
+ */
29
+ export function mapHyperedgeFromJsonLd(
30
+ r: JsonLdPartitiveRelation,
31
+ memberKey: 'gl:hasPartitive' | 'gl:hasGeneric' | 'gl:members' = 'gl:hasPartitive',
32
+ ): Record<string, unknown> | null {
33
+ const comprehensive = r[GL.COMPREHENSIVE] ? mapRefFromJsonLd(r[GL.COMPREHENSIVE]) : null;
34
+ if (!comprehensive) return null;
35
+
36
+ const rawMembers = r[GL.HAS_PARTITIVE] ?? r[GL.HAS_GENERIC] ?? r[GL.HAS_MEMBER] ?? r[memberKey] ?? [];
37
+ const partitives = (rawMembers as any[])
38
+ .map((m): Record<string, unknown> | null => {
39
+ const ref = m[GL.REF] ? mapRefFromJsonLd(m[GL.REF]) : null;
40
+ if (!ref) return null;
41
+ const out: Record<string, unknown> = { ref };
42
+ const presence = m[GL.PRESENCE];
43
+ const count = m[GL.COUNT];
44
+ if (isPartitivePresence(presence) && isPartitiveCount(count)) {
45
+ out.presence = presence;
46
+ out.count = count;
47
+ } else {
48
+ const raw = m[GL.MULTIPLICITY] ?? splitLegacyCertainty(m[GL.CERTAINTY]);
49
+ if (raw) {
50
+ const parts = splitMultiplicity(raw);
51
+ out.presence = parts.presence;
52
+ out.count = parts.count;
53
+ }
54
+ }
55
+ if (m[GL.IS_DELIMITING] === true) out.is_delimiting = true;
56
+ return out;
57
+ })
58
+ .filter((m): m is Record<string, unknown> => m !== null);
59
+
60
+ if (partitives.length < 2) return null;
61
+
62
+ const out: Record<string, unknown> = {
63
+ comprehensive,
64
+ members: partitives,
65
+ };
66
+
67
+ if (r[GL.COMPLETENESS]) out.completeness = r[GL.COMPLETENESS];
68
+
69
+ if (r[GL.CRITERION]) {
70
+ out.criterion = typeof r[GL.CRITERION] === 'string'
71
+ ? { default: r[GL.CRITERION] }
72
+ : r[GL.CRITERION];
73
+ }
74
+
75
+ return out;
76
+ }
77
+
78
+ /** Migrate legacy v2 certainty → old v3 multiplicity string. */
79
+ function splitLegacyCertainty(certainty: string | undefined): string | null {
80
+ if (!certainty) return null;
81
+ return certainty === 'possible' ? 'optional' : 'compulsory';
82
+ }
83
+
84
+ /** Split the legacy one-string multiplicity into MECE presence × count. */
85
+ function splitMultiplicity(m: string): { presence: 'required' | 'optional'; count: 'exactly_one' | 'at_least_one' | 'multiple' } {
86
+ const LEGACY_MAP: Record<string, { presence: 'required' | 'optional'; count: 'exactly_one' | 'at_least_one' | 'multiple' }> = {
87
+ compulsory: { presence: 'required', count: 'exactly_one' },
88
+ optional: { presence: 'optional', count: 'exactly_one' },
89
+ compulsory_multiple: { presence: 'required', count: 'multiple' },
90
+ optional_multiple: { presence: 'optional', count: 'multiple' },
91
+ compulsory_at_least_one: { presence: 'required', count: 'at_least_one' },
92
+ };
93
+ return LEGACY_MAP[m] ?? { presence: 'required', count: 'exactly_one' };
94
+ }
@@ -150,6 +150,8 @@ export interface JsonLdPartitiveMember {
150
150
  export interface JsonLdPartitiveRelation {
151
151
  'gl:comprehensive'?: JsonLdRef;
152
152
  'gl:hasPartitive'?: JsonLdPartitiveMember[];
153
+ 'gl:hasGeneric'?: JsonLdPartitiveMember[];
154
+ 'gl:members'?: JsonLdPartitiveMember[];
153
155
  'gl:completeness'?: string;
154
156
  'gl:criterion'?: Record<string, string> | string;
155
157
  }
@@ -162,6 +164,7 @@ export interface JsonLdConcept {
162
164
  'gl:localizedConcept'?: Record<string, JsonLdLocalizedConcept>;
163
165
  'gl:related'?: JsonLdRelated[];
164
166
  'gl:partitiveRelations'?: JsonLdPartitiveRelation[];
167
+ 'gl:genericRelations'?: JsonLdPartitiveRelation[];
165
168
  'gl:tags'?: string[];
166
169
  'gl:figureRef'?: unknown[];
167
170
  'gl:tableRef'?: unknown[];