@glossarist/concept-browser 0.7.78 → 0.7.80

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 (55) hide show
  1. package/package.json +2 -2
  2. package/scripts/build-edges.js +109 -39
  3. package/scripts/generate-data.mjs +63 -27
  4. package/src/__tests__/build-edges-redundancy.test.ts +101 -0
  5. package/src/__tests__/composables/use-concept-diff.test.ts +240 -0
  6. package/src/__tests__/composables/use-concept-edges-v2.test.ts +132 -0
  7. package/src/__tests__/composables/use-dataset-stats.test.ts +96 -0
  8. package/src/__tests__/config/page-types.test.ts +82 -0
  9. package/src/__tests__/model-bridge-bridges.test.ts +3 -2
  10. package/src/__tests__/model-bridge-citation.test.ts +1 -1
  11. package/src/__tests__/ontology-registry.test.ts +2 -2
  12. package/src/__tests__/partitive-relation-styling.test.ts +82 -0
  13. package/src/__tests__/partitive-relation.test.ts +285 -0
  14. package/src/adapters/GraphDataSource.ts +16 -6
  15. package/src/adapters/dataset-stats.ts +13 -0
  16. package/src/adapters/non-verbal/glossarist-augment.d.ts +155 -4
  17. package/src/adapters/types.ts +27 -17
  18. package/src/components/AppSidebar.vue +14 -57
  19. package/src/components/ConceptDetail.vue +29 -0
  20. package/src/components/ConceptDiffModal.vue +120 -0
  21. package/src/components/ConceptDiffView.vue +192 -0
  22. package/src/components/ConceptEditionRail.vue +61 -36
  23. package/src/components/PartitiveRelationList.vue +139 -0
  24. package/src/components/groups/DatasetGroupRenderer.vue +14 -4
  25. package/src/components/groups/DefaultGroupSidebar.vue +28 -11
  26. package/src/components/groups/LineageGroupSidebar.vue +17 -29
  27. package/src/composables/use-concept-diff.ts +231 -0
  28. package/src/composables/use-concept-edges.ts +66 -45
  29. package/src/composables/use-dataset-stats.ts +52 -0
  30. package/src/config/page-types.ts +67 -0
  31. package/src/config/types.ts +6 -1
  32. package/src/config/use-site-config.ts +13 -29
  33. package/src/data/taxonomies.json +24 -1
  34. package/src/i18n/locales/eng.yml +6 -0
  35. package/src/utils/partitive-relation-styling.ts +62 -0
  36. package/src/views/DatasetView.vue +5 -3
  37. package/src/views/StatsView.vue +9 -22
  38. package/src/__tests__/hyperedge-styling.test.ts +0 -57
  39. package/src/__tests__/partitive-hyperedge.test.ts +0 -199
  40. package/src/components/PartitiveHyperedgeList.vue +0 -112
  41. package/src/islands/BibliographyIsland.vue +0 -26
  42. package/src/islands/ConceptDetailIsland.vue +0 -27
  43. package/src/islands/ConceptDiffIsland.vue +0 -214
  44. package/src/islands/ConceptIsland.vue +0 -102
  45. package/src/islands/GraphIsland.vue +0 -15
  46. package/src/islands/GroupRendererIsland.vue +0 -15
  47. package/src/islands/HeaderIsland.vue +0 -10
  48. package/src/islands/NonVerbalIsland.vue +0 -75
  49. package/src/islands/RelationSphereIsland.vue +0 -20
  50. package/src/islands/SearchIsland.vue +0 -23
  51. package/src/islands/SeriesCardIsland.vue +0 -12
  52. package/src/islands/ShortcutsIsland.vue +0 -13
  53. package/src/islands/SidebarIsland.vue +0 -15
  54. package/src/islands/SupersessionDiffIsland.vue +0 -267
  55. package/src/utils/hyperedge-styling.ts +0 -48
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glossarist/concept-browser",
3
- "version": "0.7.78",
3
+ "version": "0.7.80",
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.17",
40
+ "glossarist": "^0.4.20",
41
41
  "js-yaml": "^4.1.0",
42
42
  "jszip": "^3.10.1",
43
43
  "pinia": "^2.3.1",
@@ -2,20 +2,30 @@ function buildConceptUri(uriBase, registerId, conceptId) {
2
2
  return `${uriBase}/${registerId}/concept/${conceptId}`;
3
3
  }
4
4
 
5
- const VALID_MARKERS = new Set(['double', 'dashed']);
5
+ const VALID_COMPLETENESS = new Set(['complete', 'partial']);
6
+ const VALID_CERTAINTY = new Set(['confirmed', 'possible']);
7
+
8
+ function validateCompleteness(value) {
9
+ if (value == null) return 'complete';
10
+ if (!VALID_COMPLETENESS.has(value)) {
11
+ throw new Error(
12
+ `Invalid partitive relation completeness: "${value}". Allowed: complete, partial`,
13
+ );
14
+ }
15
+ return value;
16
+ }
6
17
 
7
- function validateHyperedgeMarkers(markers) {
8
- const out = [];
9
- for (const m of markers) {
10
- if (!VALID_MARKERS.has(m)) {
11
- throw new Error(`Invalid partitive hyperedge marker: "${m}". Allowed: double, dashed`);
12
- }
13
- out.push(m);
18
+ function validateCertainty(value) {
19
+ if (value == null) return 'confirmed';
20
+ if (!VALID_CERTAINTY.has(value)) {
21
+ throw new Error(
22
+ `Invalid partitive member certainty: "${value}". Allowed: confirmed, possible`,
23
+ );
14
24
  }
15
- return out;
25
+ return value;
16
26
  }
17
27
 
18
- function normalizeHyperedgeContent(content) {
28
+ function normalizeLocalizedString(content) {
19
29
  if (content == null) return undefined;
20
30
  if (typeof content === 'string') return { default: content };
21
31
  return content;
@@ -146,41 +156,68 @@ function extractRelated(concept, registerId, uriBase, urnMap) {
146
156
  }
147
157
 
148
158
  /**
149
- * Extracts partitive hyperedges from a concept's gl:partitiveHyperedges
150
- * array. Returns hyperedge objects (NOT binary GraphEdges) so the UI
159
+ * Extracts partitive relations from a concept's gl:partitiveRelations
160
+ * array. Returns relation objects (NOT binary GraphEdges) so the UI
151
161
  * can render them with their full set-based semantics: comprehensive +
152
- * parts together, enumeration completeness (open vs closed), and
153
- * diagram plurality markers (double / dashed).
162
+ * partitives (each with certainty), completeness, plurality, and
163
+ * criterion.
154
164
  *
155
- * Hyperedges are written to a separate hyperedges.json file, keeping
165
+ * Relations are written to partitive_relations.json, keeping
156
166
  * edges.json binary-only.
167
+ *
168
+ * v2 shape per concept-model/TODO.partitive-relation-v2.
157
169
  */
158
- function extractPartitiveHyperedges(concept, registerId, uriBase, urnMap) {
159
- const hyperedges = [];
170
+ function extractPartitiveRelations(concept, registerId, uriBase, urnMap) {
171
+ const relations = [];
160
172
  const sourceUri = concept['@id'];
161
- for (const he of concept['gl:partitiveHyperedges'] || []) {
162
- const compRef = he['gl:comprehensive'];
173
+ for (const rel of concept['gl:partitiveRelations'] || []) {
174
+ const compRef = rel['gl:comprehensive'];
163
175
  if (!compRef) continue;
164
176
  const comprehensive = resolveConceptUri(compRef, uriBase, urnMap);
165
177
  if (!comprehensive) continue;
166
178
 
167
- const parts = (he['gl:hasPart'] || [])
168
- .map(p => resolveConceptUri(p, uriBase, urnMap))
169
- .filter(p => p && p !== sourceUri);
179
+ const partitives = (rel['gl:hasPartitive'] || [])
180
+ .map(p => {
181
+ const memberRef = p['gl:ref'] || p;
182
+ const uri = resolveConceptUri(memberRef, uriBase, urnMap);
183
+ if (!uri || uri === sourceUri) return null;
184
+ return {
185
+ uri,
186
+ certainty: validateCertainty(p['gl:certainty']),
187
+ };
188
+ })
189
+ .filter(p => p !== null);
190
+
191
+ if (partitives.length === 0) continue;
170
192
 
171
- if (parts.length === 0) continue;
193
+ const plurality = resolvePlurality(rel['gl:hasPlurality']);
172
194
 
173
- hyperedges.push({
195
+ relations.push({
174
196
  source: sourceUri,
175
197
  comprehensive,
176
- parts,
177
- enumeration: he['gl:enumeration'] || 'closed',
178
- markers: validateHyperedgeMarkers(he['gl:hasPluralityMarker'] || []),
179
- label: normalizeHyperedgeContent(he['gl:content']),
198
+ partitives,
199
+ completeness: validateCompleteness(rel['gl:completeness']),
200
+ plurality,
201
+ criterion: normalizeLocalizedString(rel['gl:criterion']),
180
202
  register: registerId,
181
203
  });
182
204
  }
183
- return hyperedges;
205
+ return relations;
206
+ }
207
+
208
+ function resolvePlurality(plurality) {
209
+ if (!plurality) return null;
210
+ const isShared = plurality['gl:isShared'] ?? plurality.is_shared;
211
+ if (typeof isShared !== 'boolean') return null;
212
+ const isUncertain = plurality['gl:isUncertain'] ?? plurality.is_uncertain ?? false;
213
+ const sharedTypeRef = plurality['gl:sharedType'] ?? plurality.shared_type;
214
+ let sharedType = null;
215
+ if (sharedTypeRef) {
216
+ const source = sharedTypeRef['gl:source'] ?? sharedTypeRef.source;
217
+ const id = sharedTypeRef['gl:id'] ?? sharedTypeRef.id;
218
+ if (source && id) sharedType = `${source}:${id}`;
219
+ }
220
+ return { isShared, isUncertain, sharedType };
184
221
  }
185
222
 
186
223
  function resolveConceptUri(ref, uriBase, urnMap) {
@@ -197,6 +234,37 @@ function extractAllEdges(concept, registerId, uriBase, urnMap) {
197
234
  return EXTRACTORS.flatMap(fn => fn(concept, registerId, uriBase, urnMap));
198
235
  }
199
236
 
237
+ /**
238
+ * Per concept-model TODO.partitive-relation-v2 item 14:
239
+ * a PartitiveRelation A → {b, c, d} subsumes binary has_part edges
240
+ * for the same pairs. Warn (don't fail) when both encodings exist.
241
+ *
242
+ * Binary edge types considered partitive: has_part, is_part_of,
243
+ * broader_partitive, narrower_partitive.
244
+ */
245
+ const BINARY_PARTITIVE_TYPES = new Set(['has_part', 'is_part_of', 'broader_partitive', 'narrower_partitive']);
246
+
247
+ function warnBinaryPartitiveRedundancy(edges, relations, registerId) {
248
+ if (!relations?.length) return;
249
+ const partitivePairs = new Set();
250
+ for (const rel of relations) {
251
+ for (const member of rel.partitives ?? []) {
252
+ const memberUri = member.uri ?? member;
253
+ partitivePairs.add(`${rel.comprehensive}|${memberUri}`);
254
+ partitivePairs.add(`${memberUri}|${rel.comprehensive}`);
255
+ }
256
+ }
257
+ const redundant = edges.filter(e =>
258
+ BINARY_PARTITIVE_TYPES.has(e.type)
259
+ && partitivePairs.has(`${e.source}|${e.target}`),
260
+ );
261
+ if (redundant.length > 0) {
262
+ console.warn(
263
+ ` ⚠ ${registerId}: ${redundant.length} binary has_part/is_part_of edge(s) duplicate a PartitiveRelation member — prefer the PartitiveRelation encoding (concept-model TODO item 14).`,
264
+ );
265
+ }
266
+ }
267
+
200
268
  // --- Build ---
201
269
 
202
270
  function buildEdgesForDataset(datasetDir, registerId, uriBase, urnMap, manifest) {
@@ -210,7 +278,7 @@ function buildEdgesForDataset(datasetDir, registerId, uriBase, urnMap, manifest)
210
278
  console.log(` Processing ${files.length} concepts...`);
211
279
 
212
280
  const allEdges = [];
213
- const allHyperedges = [];
281
+ const allPartitiveRelations = [];
214
282
  const allSourceRefs = [];
215
283
  const domainConceptCount = new Map();
216
284
  let processed = 0;
@@ -219,8 +287,10 @@ function buildEdgesForDataset(datasetDir, registerId, uriBase, urnMap, manifest)
219
287
  try {
220
288
  const data = JSON.parse(readFileSync(join(conceptsDir, file), 'utf-8'));
221
289
  const edges = extractAllEdges(data, registerId, uriBase, urnMap);
290
+ const relations = extractPartitiveRelations(data, registerId, uriBase, urnMap);
291
+ warnBinaryPartitiveRedundancy(edges, relations, registerId);
222
292
  allEdges.push(...edges);
223
- allHyperedges.push(...extractPartitiveHyperedges(data, registerId, uriBase, urnMap));
293
+ allPartitiveRelations.push(...relations);
224
294
  allSourceRefs.push(...extractSourceRefs(data, registerId));
225
295
 
226
296
  for (const edge of edges) {
@@ -258,16 +328,16 @@ function buildEdgesForDataset(datasetDir, registerId, uriBase, urnMap, manifest)
258
328
  writeFileSync(outputPath, JSON.stringify(output));
259
329
  console.log(` Written ${deduped.length} edges to edges.json (${(JSON.stringify(output).length / 1024).toFixed(1)} KB)`);
260
330
 
261
- // Write hyperedges.json (parallel to edges.json) for one-to-many
262
- // partitive decompositions. See TODO.hyperedge/00-design-overview.md.
263
- const hyperedgesOutput = {
331
+ // Write partitive_relations.json (parallel to edges.json) for one-to-many
332
+ // partitive decompositions. See TODO.partitive-relation-v2.
333
+ const relationsOutput = {
264
334
  registerId,
265
- hyperedgeCount: allHyperedges.length,
266
- hyperedges: allHyperedges,
335
+ relationCount: allPartitiveRelations.length,
336
+ relations: allPartitiveRelations,
267
337
  };
268
- const hyperedgesPath = join(datasetDir, 'hyperedges.json');
269
- writeFileSync(hyperedgesPath, JSON.stringify(hyperedgesOutput));
270
- console.log(` Written ${allHyperedges.length} hyperedges to hyperedges.json (${(JSON.stringify(hyperedgesOutput).length / 1024).toFixed(1)} KB)`);
338
+ const relationsPath = join(datasetDir, 'partitive_relations.json');
339
+ writeFileSync(relationsPath, JSON.stringify(relationsOutput));
340
+ console.log(` Written ${allPartitiveRelations.length} partitive relations to partitive_relations.json (${(JSON.stringify(relationsOutput).length / 1024).toFixed(1)} KB)`);
271
341
 
272
342
  // Build domain-nodes.json from manifest sections (authoritative source)
273
343
  const manifestSections = manifest.sections;
@@ -65,7 +65,7 @@ function loadConceptFile(filePath) {
65
65
 
66
66
  // Managed concept-level fields
67
67
  if (mc.related) result._related = mc.related;
68
- if (mc.partitive_hyperedges) result._partitiveHyperedges = mc.partitive_hyperedges;
68
+ if (mc.partitive_relations) result._partitiveRelations = mc.partitive_relations;
69
69
  if (mc.data.domains) result._domains = mc.data.domains;
70
70
  if (mc.dates) result._dates = mc.dates;
71
71
  if (mc.sources) result._sources = mc.sources;
@@ -679,23 +679,45 @@ function yamlToJsonLd(conceptYaml, register, refMaps) {
679
679
  });
680
680
  }
681
681
 
682
- if (conceptYaml._partitiveHyperedges?.length > 0) {
683
- doc['gl:partitiveHyperedges'] = conceptYaml._partitiveHyperedges.map(he => {
684
- const out = { '@type': 'gl:PartitiveHyperedge' };
685
- if (he.comprehensive) {
686
- out['gl:comprehensive'] = refToJsonLd(he.comprehensive, 'gl:ConceptRef');
682
+ if (conceptYaml._partitiveRelations?.length > 0) {
683
+ doc['gl:partitiveRelations'] = conceptYaml._partitiveRelations.map(rel => {
684
+ const out = { '@type': 'gl:PartitiveRelation' };
685
+ if (rel.comprehensive) {
686
+ out['gl:comprehensive'] = refToJsonLd(rel.comprehensive, 'gl:ConceptRef');
687
687
  }
688
- if (Array.isArray(he.parts) && he.parts.length > 0) {
689
- out['gl:hasPart'] = he.parts.map(p => refToJsonLd(p, 'gl:ConceptRef'));
688
+ if (Array.isArray(rel.partitives) && rel.partitives.length > 0) {
689
+ out['gl:hasPartitive'] = rel.partitives.map(member => {
690
+ const m = { '@type': 'gl:PartitiveMember' };
691
+ if (member.ref) {
692
+ m['gl:ref'] = refToJsonLd(member.ref, 'gl:ConceptRef');
693
+ } else {
694
+ m['gl:ref'] = refToJsonLd(member, 'gl:ConceptRef');
695
+ }
696
+ if (member.certainty) m['gl:certainty'] = member.certainty;
697
+ return m;
698
+ });
690
699
  }
691
- if (he.enumeration) {
692
- out['gl:enumeration'] = he.enumeration;
700
+ if (rel.completeness) {
701
+ out['gl:completeness'] = rel.completeness;
693
702
  }
694
- if (Array.isArray(he.markers) && he.markers.length > 0) {
695
- out['gl:hasPluralityMarker'] = [...he.markers];
703
+ if (rel.plurality) {
704
+ const pl = { '@type': 'gl:TypeSharedPlurality' };
705
+ pl['gl:isShared'] = rel.plurality.is_shared ?? rel.plurality.isShared ?? false;
706
+ if (rel.plurality.is_uncertain ?? rel.plurality.isUncertain) {
707
+ pl['gl:isUncertain'] = rel.plurality.is_uncertain ?? rel.plurality.isUncertain;
708
+ }
709
+ if (rel.plurality.shared_type ?? rel.plurality.sharedType) {
710
+ pl['gl:sharedType'] = refToJsonLd(
711
+ rel.plurality.shared_type ?? rel.plurality.sharedType,
712
+ 'gl:ConceptRef',
713
+ );
714
+ }
715
+ out['gl:hasPlurality'] = pl;
696
716
  }
697
- if (he.content) {
698
- out['gl:content'] = typeof he.content === 'string' ? { default: he.content } : he.content;
717
+ if (rel.criterion) {
718
+ out['gl:criterion'] = typeof rel.criterion === 'string'
719
+ ? { default: rel.criterion }
720
+ : rel.criterion;
699
721
  }
700
722
  return out;
701
723
  });
@@ -952,7 +974,14 @@ async function processDataset(dir, register, opts) {
952
974
  const stats = {
953
975
  sourceMap: new Map(),
954
976
  relTypeCounts: {},
955
- partitiveHyperedges: { count: 0, byEnumeration: { closed: 0, open: 0 }, byMarker: { double: 0, dashed: 0, none: 0 } },
977
+ partitiveRelations: {
978
+ count: 0,
979
+ byCompleteness: { complete: 0, partial: 0 },
980
+ byMemberCertainty: { confirmed: 0, possible: 0 },
981
+ withCriterion: 0,
982
+ withoutCriterion: 0,
983
+ withPlurality: 0,
984
+ },
956
985
  };
957
986
 
958
987
  const STATS_PROCESSORS = [
@@ -976,18 +1005,25 @@ async function processDataset(dir, register, opts) {
976
1005
  s.relTypeCounts[type] = (s.relTypeCounts[type] || 0) + 1;
977
1006
  }
978
1007
  },
979
- function countHyperedges(cy, _termid, s) {
980
- for (const he of cy._partitiveHyperedges || []) {
981
- s.partitiveHyperedges.count += 1;
982
- const e = he.enumeration || 'closed';
983
- s.partitiveHyperedges.byEnumeration[e] = (s.partitiveHyperedges.byEnumeration[e] || 0) + 1;
984
- const markers = he.markers || [];
985
- if (markers.length === 0) {
986
- s.partitiveHyperedges.byMarker.none += 1;
1008
+ function countPartitiveRelations(cy, _termid, s) {
1009
+ for (const rel of cy._partitiveRelations || []) {
1010
+ s.partitiveRelations.count += 1;
1011
+ const completeness = rel.completeness || 'complete';
1012
+ s.partitiveRelations.byCompleteness[completeness] =
1013
+ (s.partitiveRelations.byCompleteness[completeness] || 0) + 1;
1014
+ const partitives = rel.partitives || [];
1015
+ for (const member of partitives) {
1016
+ const certainty = member.certainty || 'confirmed';
1017
+ s.partitiveRelations.byMemberCertainty[certainty] =
1018
+ (s.partitiveRelations.byMemberCertainty[certainty] || 0) + 1;
1019
+ }
1020
+ if (rel.criterion) {
1021
+ s.partitiveRelations.withCriterion += 1;
987
1022
  } else {
988
- for (const m of markers) {
989
- s.partitiveHyperedges.byMarker[m] = (s.partitiveHyperedges.byMarker[m] || 0) + 1;
990
- }
1023
+ s.partitiveRelations.withoutCriterion += 1;
1024
+ }
1025
+ if (rel.plurality) {
1026
+ s.partitiveRelations.withPlurality += 1;
991
1027
  }
992
1028
  }
993
1029
  },
@@ -1159,7 +1195,7 @@ async function processDataset(dir, register, opts) {
1159
1195
  sources: sourceStats,
1160
1196
  relationshipCount: totalRelationships,
1161
1197
  relationshipTypes: stats.relTypeCounts,
1162
- partitiveHyperedges: stats.partitiveHyperedges,
1198
+ partitiveRelations: stats.partitiveRelations,
1163
1199
  });
1164
1200
 
1165
1201
  const manifest = {
@@ -0,0 +1,101 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+
3
+ /**
4
+ * Tests for warnBinaryPartitiveRedundancy.
5
+ *
6
+ * The function lives inside scripts/build-edges.js (not exported). To
7
+ * test it in isolation we re-implement the same logic here and verify
8
+ * the behavior. The production function uses console.warn — we check
9
+ * the contract by inspecting what gets warned.
10
+ */
11
+ const BINARY_PARTITIVE_TYPES = new Set(['has_part', 'is_part_of', 'broader_partitive', 'narrower_partitive']);
12
+
13
+ interface TestEdge {
14
+ source: string;
15
+ target: string;
16
+ type: string;
17
+ }
18
+
19
+ interface TestRelation {
20
+ comprehensive: string;
21
+ partitives?: Array<{ uri?: string } | string>;
22
+ }
23
+
24
+ function findRedundantBinaries(edges: TestEdge[], relations: TestRelation[]): TestEdge[] {
25
+ if (!relations?.length) return [];
26
+ const partitivePairs = new Set<string>();
27
+ for (const rel of relations) {
28
+ const members = rel.partitives ?? [];
29
+ for (const member of members) {
30
+ const memberUri = typeof member === 'string' ? member : (member.uri ?? '');
31
+ partitivePairs.add(`${rel.comprehensive}|${memberUri}`);
32
+ partitivePairs.add(`${memberUri}|${rel.comprehensive}`);
33
+ }
34
+ }
35
+ return edges.filter((e: TestEdge) =>
36
+ BINARY_PARTITIVE_TYPES.has(e.type)
37
+ && partitivePairs.has(`${e.source}|${e.target}`),
38
+ );
39
+ }
40
+
41
+ describe('binary has_part vs PartitiveRelation redundancy (TODO item 14)', () => {
42
+ beforeEach(() => {
43
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
44
+ });
45
+
46
+ afterEach(() => {
47
+ vi.restoreAllMocks();
48
+ });
49
+
50
+ it('returns no redundancies when PartitiveRelation has no members', () => {
51
+ const edges = [{ source: 'A', target: 'B', type: 'has_part' }];
52
+ const relations = [{ comprehensive: 'A', partitives: [] }];
53
+ expect(findRedundantBinaries(edges, relations)).toEqual([]);
54
+ });
55
+
56
+ it('returns no redundancies when there are no binary has_part edges', () => {
57
+ const edges = [{ source: 'A', target: 'B', type: 'related_concept' }];
58
+ const relations = [{ comprehensive: 'A', partitives: [{ uri: 'B' }, { uri: 'C' }] }];
59
+ expect(findRedundantBinaries(edges, relations)).toEqual([]);
60
+ });
61
+
62
+ it('flags binary has_part that duplicates a PartitiveRelation member', () => {
63
+ const edges = [
64
+ { source: 'A', target: 'B', type: 'has_part' },
65
+ { source: 'A', target: 'C', type: 'has_part' },
66
+ ];
67
+ const relations = [{ comprehensive: 'A', partitives: [{ uri: 'B' }, { uri: 'C' }, { uri: 'D' }] }];
68
+ const flagged = findRedundantBinaries(edges, relations);
69
+ expect(flagged).toHaveLength(2);
70
+ });
71
+
72
+ it('flags inverse direction (is_part_of)', () => {
73
+ const edges = [{ source: 'B', target: 'A', type: 'is_part_of' }];
74
+ const relations = [{ comprehensive: 'A', partitives: [{ uri: 'B' }] }];
75
+ expect(findRedundantBinaries(edges, relations)).toHaveLength(1);
76
+ });
77
+
78
+ it('does not flag non-partitive binary types', () => {
79
+ const edges = [
80
+ { source: 'A', target: 'B', type: 'broader_generic' },
81
+ { source: 'A', target: 'B', type: 'related_concept' },
82
+ ];
83
+ const relations = [{ comprehensive: 'A', partitives: [{ uri: 'B' }] }];
84
+ expect(findRedundantBinaries(edges, relations)).toEqual([]);
85
+ });
86
+
87
+ it('handles broader_partitive / narrower_partitive', () => {
88
+ const edges = [
89
+ { source: 'A', target: 'B', type: 'broader_partitive' },
90
+ { source: 'B', target: 'A', type: 'narrower_partitive' },
91
+ ];
92
+ const relations = [{ comprehensive: 'A', partitives: [{ uri: 'B' }, { uri: 'C' }] }];
93
+ expect(findRedundantBinaries(edges, relations)).toHaveLength(2);
94
+ });
95
+
96
+ it('ignores legacy v1 parts field (no partitives key present)', () => {
97
+ const edges: TestEdge[] = [{ source: 'A', target: 'B', type: 'has_part' }];
98
+ const relations = [{ comprehensive: 'A' }] as TestRelation[];
99
+ expect(findRedundantBinaries(edges, relations)).toEqual([]);
100
+ });
101
+ });