glossarist 0.4.24 → 0.4.25

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.24",
3
+ "version": "0.4.25",
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",
@@ -205,7 +205,10 @@ function relationLabel(r) {
205
205
  const partitives = Array.isArray(r.partitives)
206
206
  ? r.partitives.map(m => {
207
207
  const ref = m?.ref ?? m ?? {};
208
- const tail = m?.certainty === 'possible' ? '?' : '';
208
+ let tail = '';
209
+ const mult = m?.multiplicity;
210
+ if (mult && mult !== 'compulsory') tail = ` (${mult})`;
211
+ if (m?.is_delimiting === true) tail += ' ⊘';
209
212
  return `${ref.id ?? ref.source ?? ref.text ?? '?'}${tail}`;
210
213
  }).join(', ')
211
214
  : '';
@@ -213,22 +216,25 @@ function relationLabel(r) {
213
216
  const criterion = r.criterion
214
217
  ? ` / ${Object.values(r.criterion)[0] ?? ''}`
215
218
  : '';
216
- const plurality = r.plurality
217
- ? ` [${_pluralityLabel(r.plurality)}]`
218
- : '';
219
- return `${head} → {${partitives}}${completeness}${criterion}${plurality}`;
219
+ return `${head} → {${partitives}}${completeness}${criterion}`;
220
220
  }
221
221
 
222
- function _pluralityLabel(p) {
223
- if (!p) return '';
224
- const flags = [];
225
- if (p.is_shared) flags.push('shared');
226
- if (p.is_uncertain) flags.push('uncertain');
227
- if (p.shared_type) {
228
- const t = p.shared_type;
229
- flags.push(`type=${t.id ?? t.source ?? '?'}`);
222
+ // Aggregate multiplicity + delimiting stats across a concept's
223
+ // PartitiveRelations. Used by dataset-level summaries, not by the
224
+ // per-relation diff label.
225
+ export function multiplicityStats(relations) {
226
+ const byMultiplicity = {};
227
+ let delimitingCount = 0;
228
+ let total = 0;
229
+ for (const rel of relations ?? []) {
230
+ for (const m of rel?.partitives ?? []) {
231
+ total += 1;
232
+ const k = m?.multiplicity ?? 'compulsory';
233
+ byMultiplicity[k] = (byMultiplicity[k] ?? 0) + 1;
234
+ if (m?.is_delimiting === true) delimitingCount += 1;
235
+ }
230
236
  }
231
- return flags.join(',');
237
+ return { total, byMultiplicity, delimiting: delimitingCount };
232
238
  }
233
239
 
234
240
  function itemLabel(item) {
@@ -21,6 +21,25 @@
21
21
  // A v1 hyperedge with exactly 1 part cannot migrate to v2 and is
22
22
  // returned with a `migrationWarning` field set so callers can decide
23
23
  // whether to surface it (the parser drops these with a warning).
24
+ //
25
+ // ─── Migration decision table (v1 markers → v2 multiplicity) ──────
26
+ //
27
+ // v1 markers encoded per-relation plurality claims. v2 multiplicity
28
+ // is per-member. The mapping is lossy; reviewers must set per-member
29
+ // multiplicity individually after migration.
30
+ //
31
+ // | v1 markers | Likely multiplicity for each partitive | Notes |
32
+ // |-------------------------|------------------------------------------------|--------------------------------------------------------|
33
+ // | (none) | `compulsory` | Default; reviewer confirms |
34
+ // | `[double]` | `compulsory_multiple` or `compulsory_at_least_one` | Reviewer distinguishes "all required" from "≥1 required" |
35
+ // | `[dashed]` | `optional` or `optional_multiple` | Reviewer distinguishes single vs multiple |
36
+ // | `[double, dashed]` | `optional_multiple` or `compulsory_at_least_one` | Plurality claim; reviewer distinguishes uncertain vs required |
37
+ // | (single-part v1 edge) | (cannot migrate — needs ≥2 partitives) | Convert to a binary `has_part` edge |
38
+ //
39
+ // `is_delimiting` is NEVER set by migration. ISO 704:2022 says
40
+ // delimiting depends on the concept system and coordinate concepts,
41
+ // which migration cannot infer. Reviewer sets it based on domain
42
+ // knowledge (see DelimitingCoherenceRule for cross-relation checks).
24
43
 
25
44
  export function migrateHyperedgeToRelation(v1Hash) {
26
45
  if (v1Hash == null) return null;
@@ -6,15 +6,19 @@
6
6
  // ISO 704:2022 partitive member notation:
7
7
  //
8
8
  // multiplicity (diagram line notation):
9
- // compulsory — 1 solid line (must exist in every instance)
10
- // optional — 1 dashed line (exists in some instances only)
11
- // compulsory_multiple — 2 solid lines (multiple must exist)
12
- // optional_multiple — 2 dashed lines (multiple may exist)
13
- // compulsory_at_least_one — 1 solid + 1 dashed line (≥1 must exist)
9
+ // compulsory — 1 solid line (must exist in every instance)
10
+ // optional — 1 dashed line (exists in some instances only)
11
+ // compulsory_multiple — 2 solid lines (multiple must exist)
12
+ // optional_multiple — 2 dashed lines (multiple may exist)
13
+ // compulsory_at_least_one — 1 solid + 1 dashed (≥1 must exist)
14
14
  //
15
15
  // is_delimiting (orthogonal, bold 3x-width line in diagram):
16
16
  // A delimiting part behaves like a delimiting characteristic:
17
17
  // it distinguishes the comprehensive from coordinate concepts.
18
+ //
19
+ // Whether a part is delimiting depends on the concept system, the
20
+ // coordinate concepts, the inheritance principle, and the criterion
21
+ // of subdivision used (ISO 704:2022 §5.5.4.2.1).
18
22
 
19
23
  import { GlossaristModel } from './base.js';
20
24
  import { ConceptRef } from './concept-ref.js';
@@ -32,11 +36,14 @@ export class PartitiveMember extends GlossaristModel {
32
36
  ? data.ref
33
37
  : new ConceptRef(data.ref ?? {});
34
38
  this.multiplicity = _resolveMultiplicity(data.multiplicity);
35
- this.is_delimiting = data.is_delimiting === true;
39
+ this.is_delimiting = _resolveBoolean(data.is_delimiting, 'is_delimiting');
36
40
 
37
41
  _assertNonEmptyRef(this.ref);
38
42
  }
39
43
 
44
+ // Per-value multiplicity predicates. Each new enum value gets one
45
+ // line — OCP-friendly: the predicates grow with the enum without
46
+ // touching unrelated code. (TODO.partitive-relation-v3/05.)
40
47
  get isCompulsory() {
41
48
  return this.multiplicity === MULTIPLICITY.COMPULSORY;
42
49
  }
@@ -45,6 +52,18 @@ export class PartitiveMember extends GlossaristModel {
45
52
  return this.multiplicity === MULTIPLICITY.OPTIONAL;
46
53
  }
47
54
 
55
+ get isCompulsoryMultiple() {
56
+ return this.multiplicity === MULTIPLICITY.COMPULSORY_MULTIPLE;
57
+ }
58
+
59
+ get isOptionalMultiple() {
60
+ return this.multiplicity === MULTIPLICITY.OPTIONAL_MULTIPLE;
61
+ }
62
+
63
+ get isCompulsoryAtLeastOne() {
64
+ return this.multiplicity === MULTIPLICITY.COMPULSORY_AT_LEAST_ONE;
65
+ }
66
+
48
67
  get isDelimiting() {
49
68
  return this.is_delimiting === true;
50
69
  }
@@ -86,6 +105,17 @@ function _resolveMultiplicity(value) {
86
105
  return value;
87
106
  }
88
107
 
108
+ // Boolean coercion with strict type check. Rejects strings, numbers,
109
+ // objects (silent coercion was the v3 bug TODO.partitive-relation-v3/02).
110
+ function _resolveBoolean(value, fieldName) {
111
+ if (value == null) return false;
112
+ if (typeof value === 'boolean') return value;
113
+ throw new Error(
114
+ `PartitiveMember.${fieldName} must be a boolean; got ${typeof value} ` +
115
+ `${JSON.stringify(value)}`,
116
+ );
117
+ }
118
+
89
119
  function _assertNonEmptyRef(ref) {
90
120
  if (!ref.source && !ref.id && !ref.text) {
91
121
  throw new Error(
@@ -1,19 +1,33 @@
1
- // PartitiveRelation — an ISO 704 / ISO 1087-1 / ISO 12620
1
+ // PartitiveRelation — an ISO 704:2022 / ISO 1087-1 / ISO 12620
2
2
  // partitive relation connecting a comprehensive concept
3
3
  // (superordinate concept partitive) to two or more partitive
4
4
  // concepts (subordinate concepts partitive) which fitted together
5
5
  // constitute the comprehensive.
6
6
  //
7
+ // ISO 704:2022 rake diagram semantics:
8
+ //
9
+ // comprehensive concept
10
+ // ─────────────────────
11
+ // │ partitive (one or more) with per-member multiplicity + optional delimiting
12
+ // ─────────────────────
13
+ //
14
+ // All partitives within one relation are coordinate concepts
15
+ // (ISO 12620): they share the comprehensive AND share the
16
+ // criterion of subdivision.
17
+ //
18
+ // "Delimiting" parts (bold/3x-width line in source diagrams) behave
19
+ // like delimiting characteristics — they distinguish the comprehensive
20
+ // from coordinate concepts. Whether a part is delimiting depends on
21
+ // the concept system, the coordinate concepts, the inheritance
22
+ // principle, and the criterion of subdivision used
23
+ // (ISO 704:2022 §5.5.4.2.1).
24
+ //
7
25
  // Field renames (v1 → v2):
8
26
  // parts [1..*] → partitives [2..*] (as PartitiveMember)
9
27
  // enumeration: closed|open → completeness: complete|partial
28
+ // markers: [double, dashed] → (per-member multiplicity + is_delimiting)
10
29
  // content: LocalizedString → (dropped — structural edges carry no prose)
11
- //
12
- // Per-member metadata (ISO 704:2022):
13
- // multiplicity (5 values) + is_delimiting (boolean)
14
- //
15
- // New field:
16
- // criterion: LocalizedString [0..1] (ISO 12620 coordinate-concept coherence)
30
+ // plural-positive is_shared / is_uncertain → multiplicity (ISO 704:2022)
17
31
  //
18
32
  // Distinguished from binary `has_part` / `is_part_of` edges, which
19
33
  // express pairwise part-of assertions without completeness,
@@ -0,0 +1,39 @@
1
+ // PartitiveMember → RDF quads.
2
+ //
3
+ // Emits per-member predicates (multiplicity, is_delimiting) onto the
4
+ // member's concept URI. The member URI is constructed by the caller
5
+ // (typically `gloss-partitive-relation.js`).
6
+ //
7
+ // Extracted from `gloss-partitive-relation.js` per
8
+ // TODO.partitive-relation-v3/04 so future emitters (a standalone
9
+ // PartitiveMember listing, a stats aggregator) can reuse this
10
+ // emission without modifying the relation emitter (OCP).
11
+
12
+ import { PRED } from './predicates.js';
13
+ import { namedNode, literal, quad } from './terms.js';
14
+
15
+ // SKOS taxonomy namespace for multiplicity values. concept-model will
16
+ // ship the corresponding taxonomy; until then, the URIs are forward-
17
+ // compatible and resolve once the taxonomy lands.
18
+ //
19
+ // Per the JSON-LD context (`"@type": "@id"`), multiplicity objects
20
+ // MUST be URI references, not string literals. Emitted shape:
21
+ // <member-uri> gloss:multiplicity <.../multiplicity/compulsory>
22
+ export const MULTIPLICITY_NS = 'https://www.glossarist.org/ontologies/multiplicity/';
23
+
24
+ export function* partitiveMemberToQuads(member, { memberUri }) {
25
+ if (member?.multiplicity) {
26
+ yield quad(
27
+ namedNode(memberUri),
28
+ namedNode(PRED.gloss.multiplicity),
29
+ namedNode(MULTIPLICITY_NS + member.multiplicity),
30
+ );
31
+ }
32
+ if (member?.is_delimiting === true) {
33
+ yield quad(
34
+ namedNode(memberUri),
35
+ namedNode(PRED.gloss.isDelimiting),
36
+ literal('true', 'xsd:boolean'),
37
+ );
38
+ }
39
+ }
@@ -6,22 +6,31 @@
6
6
  // rdf:type gloss:PartitiveRelation
7
7
  // gloss:comprehensive <base>/concept/<comprehensive-id> (named node)
8
8
  // gloss:hasPartitive <base>/concept/<partitive-id> (named node, one per member)
9
- // gloss:completeness "complete"|"partial" (literal)
10
- // gloss:criterion "..."@lang (one per language, when present)
9
+ // gloss:completeness <.../completeness/complete|partial> (named node, SKOS)
10
+ // gloss:criterion "..."@lang (literal, one per language)
11
11
  //
12
- // Per-member multiplicity + is_delimiting are emitted as part of
13
- // each member's named-node object via the partitiveMemberToQuads
14
- // helper (separate file).
12
+ // Per-member multiplicity + is_delimiting are emitted by the
13
+ // `partitiveMemberToQuads` helper in `gloss-partitive-member.js`.
15
14
  //
16
15
  // The carrying concept links via `gloss:hasPartitiveRelation`.
17
16
  //
18
- // Replaces gloss-hyperedge.js (the v1 emitter) per TODO.partitive-relation-v2 item 01.
17
+ // Replaces gloss-hyperedge.js (the v1 emitter) per
18
+ // TODO.partitive-relation-v2 item 01.
19
19
  // Cross-repo alignment with glossarist-ruby's Rdf::GlossPartitiveRelation.
20
+ //
21
+ // JSON-LD `@type: @id` invariant: `multiplicity` and `completeness`
22
+ // are URI references into SKOS taxonomies (per
23
+ // `data/concept-model/glossarist.context.jsonld`), not literals.
24
+ // `criterion` is `@container: @language` (one literal per language).
25
+ // `isDelimiting` is `xsd:boolean` literal.
20
26
 
21
27
  import { PRED } from './predicates.js';
22
28
  import { WELL_KNOWN } from './prefixes.js';
29
+ import { partitiveMemberToQuads } from './gloss-partitive-member.js';
23
30
  import { namedNode, literal, quad } from './terms.js';
24
31
 
32
+ const COMPLETENESS_NS = 'https://www.glossarist.org/ontologies/completeness/';
33
+
25
34
  export function* partitiveRelationToQuads(relation, { parentUri, index }) {
26
35
  const subjectUri = partitiveRelationSubjectUri(parentUri, relation, index);
27
36
  const s = namedNode(subjectUri);
@@ -40,20 +49,12 @@ export function* partitiveRelationToQuads(relation, { parentUri, index }) {
40
49
  if (!ref || (!ref.source && !ref.id)) continue;
41
50
  const memberUri = conceptRefUri(ref, parentUri);
42
51
  yield quad(s, namedNode(PRED.gloss.hasPartitive), namedNode(memberUri));
43
-
44
- // Per-member multiplicity (ISO 704:2022) and is_delimiting.
45
- if (member?.multiplicity) {
46
- yield quad(namedNode(memberUri), namedNode(PRED.gloss.multiplicity),
47
- literal(member.multiplicity));
48
- }
49
- if (member?.is_delimiting === true) {
50
- yield quad(namedNode(memberUri), namedNode(PRED.gloss.isDelimiting),
51
- literal('true', 'xsd:boolean'));
52
- }
52
+ yield* partitiveMemberToQuads(member, { memberUri });
53
53
  }
54
54
 
55
55
  if (relation.completeness) {
56
- yield quad(s, namedNode(PRED.gloss.completeness), literal(relation.completeness));
56
+ yield quad(s, namedNode(PRED.gloss.completeness),
57
+ namedNode(COMPLETENESS_NS + relation.completeness));
57
58
  }
58
59
 
59
60
  if (relation.criterion && typeof relation.criterion === 'object') {
@@ -11,7 +11,7 @@ import type { Designation } from '../models/index';
11
11
  import type { DetailedDefinition } from '../models/index';
12
12
  import type { ConceptSource } from '../models/index';
13
13
  import type { NonVerbRep, Figure, Table, Formula } from '../models/index';
14
- import type { PartitiveHyperedge, PartitiveRelation } from '../models/index';
14
+ import type { PartitiveHyperedge, PartitiveRelation, PartitiveMember } from '../models/index';
15
15
 
16
16
  export { PRED, PREFIXES } from './predicates';
17
17
  export type PredicateMap = typeof PRED;
@@ -114,6 +114,19 @@ export declare function partitiveRelationSubjectUri(
114
114
  index: number,
115
115
  ): string;
116
116
 
117
+ // ── PartitiveMember emitter (per-member multiplicity + is_delimiting) ──
118
+
119
+ export interface PartitiveMemberEmitOptions {
120
+ memberUri: string;
121
+ }
122
+
123
+ export declare const MULTIPLICITY_NS: string;
124
+
125
+ export declare function partitiveMemberToQuads(
126
+ member: PartitiveMember,
127
+ options: PartitiveMemberEmitOptions,
128
+ ): Generator<Quad, void, unknown>;
129
+
117
130
  // ── Non-verbal representation emitters ─────────────────────────────────
118
131
 
119
132
  export interface NonVerbalRepEmitOptions {
package/src/rdf/index.js CHANGED
@@ -27,6 +27,7 @@ export {
27
27
  export { detailedDefinitionToQuads } from './gloss-detailed-definition.js';
28
28
  export { conceptSourceToQuads } from './gloss-source.js';
29
29
  export { hyperedgeToQuads, hyperedgeSubjectUri } from './gloss-hyperedge.js';
30
+ export { partitiveMemberToQuads, MULTIPLICITY_NS } from './gloss-partitive-member.js';
30
31
  export { partitiveRelationToQuads, partitiveRelationSubjectUri } from './gloss-partitive-relation.js';
31
32
  export {
32
33
  nonVerbalRepToQuads,
@@ -12,6 +12,7 @@
12
12
  // Severity: warning. Never blocks save.
13
13
 
14
14
  import { ValidationRule } from './validation-rule.js';
15
+ import { refKey } from './ref-keys.js';
15
16
 
16
17
  const BINARY_HAS_PART_TYPES = new Set(['has_part', 'is_part_of']);
17
18
 
@@ -24,11 +25,11 @@ export class BinaryHasPartRedundancyRule extends ValidationRule {
24
25
  // Collect partitive refs from PartitiveRelations.
25
26
  const relationTargets = new Set();
26
27
  for (const rel of relations) {
27
- const compKey = _refKey(rel?.comprehensive);
28
+ const compKey = refKey(rel?.comprehensive);
28
29
  const members = rel?.partitives ?? rel?.parts ?? [];
29
30
  for (const member of members) {
30
31
  const ref = member?.ref ?? member;
31
- const key = _refKey(ref);
32
+ const key = refKey(ref);
32
33
  if (key && key !== compKey) relationTargets.add(key);
33
34
  }
34
35
  }
@@ -38,7 +39,7 @@ export class BinaryHasPartRedundancyRule extends ValidationRule {
38
39
  const related = concept.relatedConcepts ?? [];
39
40
  for (const rc of related) {
40
41
  if (!BINARY_HAS_PART_TYPES.has(rc.type)) continue;
41
- const key = _refKey(rc?.ref);
42
+ const key = refKey(rc?.ref);
42
43
  if (key) binaryTargets.add(key);
43
44
  }
44
45
 
@@ -59,16 +60,8 @@ export class BinaryHasPartRedundancyRule extends ValidationRule {
59
60
  `${path}relatedConcepts`,
60
61
  `${binaryTargets.size} binary has_part edges on this concept; ` +
61
62
  `consider converting to a PartitiveRelation (which can carry ` +
62
- `completeness, plurality, and criterion metadata)`,
63
+ `completeness, multiplicity, and criterion metadata)`,
63
64
  );
64
65
  }
65
66
  }
66
67
  }
67
-
68
- function _refKey(ref) {
69
- if (!ref) return null;
70
- const source = ref.source ?? '';
71
- const id = ref.id ?? '';
72
- if (!source && !id) return null;
73
- return `${source}:${id}`;
74
- }
@@ -0,0 +1,105 @@
1
+ // Validates delimiting coherence across a concept's PartitiveRelations
2
+ // per ISO 704:2022.
3
+ //
4
+ // ISO 704:2022 (§5.5.4.2.1, rake diagram semantics):
5
+ // "Whether a part is delimiting depends on the concept system, the
6
+ // coordinate concepts, the inheritance principle, and the criterion
7
+ // of subdivision used."
8
+ //
9
+ // This rule surfaces three categories of issues that the model
10
+ // constructor cannot catch (they're cross-relation, not per-instance):
11
+ //
12
+ // 1. WARNING: A PartitiveRelation where every partitive is
13
+ // is_delimiting=true overuses delimiting. Typically only some
14
+ // parts distinguish the comprehensive from coordinate concepts.
15
+ // 2. ERROR: Two PartitiveRelations on the same concept with the
16
+ // same criterion but different is_delimiting for the same
17
+ // partitive ref — inconsistent delimiting claim about the same
18
+ // part. ISO 704 requires delimiting claims about a given part to
19
+ // be consistent across decompositions sharing a criterion.
20
+ // 3. WARNING: A partitive marked is_delimiting=true on a relation
21
+ // with no criterion set. Delimiting is meaningful only relative
22
+ // to a subdivision criterion.
23
+ //
24
+ // Per TODO.partitive-relation-v3/06.
25
+
26
+ import { ValidationRule } from './validation-rule.js';
27
+ import { refKey, criterionKey } from './ref-keys.js';
28
+
29
+ export class DelimitingCoherenceRule extends ValidationRule {
30
+ constructor() { super('delimiting-coherence'); }
31
+
32
+ validate(concept, path, result) {
33
+ const relations = concept.partitiveRelations ?? concept.partitiveHyperedges ?? [];
34
+ if (relations.length === 0) return;
35
+
36
+ _checkOveruse(relations, path, result);
37
+ _checkInconsistentAcrossRelations(relations, path, result, this);
38
+ _checkDelimitingWithoutCriterion(relations, path, result);
39
+ }
40
+ }
41
+
42
+ function _checkOveruse(relations, path, result) {
43
+ for (let i = 0; i < relations.length; i++) {
44
+ const rel = relations[i];
45
+ const members = rel?.partitives ?? [];
46
+ if (members.length < 2) continue;
47
+ const delimitingCount = members.filter(m => m?.is_delimiting === true).length;
48
+ if (delimitingCount === members.length) {
49
+ result.addWarning(
50
+ `${path}partitiveRelations[${i}]`,
51
+ `every partitive is marked is_delimiting=true; typically only ` +
52
+ `some parts distinguish the comprehensive from coordinate concepts`,
53
+ );
54
+ }
55
+ }
56
+ }
57
+
58
+ function _checkInconsistentAcrossRelations(relations, path, result, rule) {
59
+ const byCriterion = new Map();
60
+ for (let i = 0; i < relations.length; i++) {
61
+ const rel = relations[i];
62
+ const critKey = criterionKey(rel?.criterion);
63
+ if (critKey == null) continue;
64
+ if (!byCriterion.has(critKey)) byCriterion.set(critKey, []);
65
+ byCriterion.get(critKey).push({ rel, i });
66
+ }
67
+
68
+ for (const [, group] of byCriterion) {
69
+ const refDelimiting = new Map();
70
+ for (const { rel, i } of group) {
71
+ for (const m of rel?.partitives ?? []) {
72
+ const key = refKey(m?.ref);
73
+ if (key == null) continue;
74
+ const delimiting = m?.is_delimiting === true;
75
+ const existing = refDelimiting.get(key);
76
+ if (existing != null && existing.delimiting !== delimiting) {
77
+ rule.addIssue(result,
78
+ `${path}partitiveRelations[${i}].partitives`,
79
+ `inconsistent is_delimiting for ref (${key}) across ` +
80
+ `relations ${existing.relIdx} and ${i} with the same ` +
81
+ `criterion; ISO 704 requires delimiting claims about the ` +
82
+ `same part to be consistent`,
83
+ );
84
+ } else if (existing == null) {
85
+ refDelimiting.set(key, { delimiting, relIdx: i });
86
+ }
87
+ }
88
+ }
89
+ }
90
+ }
91
+
92
+ function _checkDelimitingWithoutCriterion(relations, path, result) {
93
+ for (let i = 0; i < relations.length; i++) {
94
+ const rel = relations[i];
95
+ if (rel?.criterion) continue;
96
+ const anyDelimiting = (rel?.partitives ?? []).some(m => m?.is_delimiting === true);
97
+ if (anyDelimiting) {
98
+ result.addWarning(
99
+ `${path}partitiveRelations[${i}].criterion`,
100
+ `relation has is_delimiting=true on partitives but no criterion; ` +
101
+ `delimiting is meaningful only relative to a subdivision criterion`,
102
+ );
103
+ }
104
+ }
105
+ }
@@ -23,6 +23,7 @@ export { SourcedFromLocalityRule } from './sourced-from-locality-rule.js';
23
23
  export { PartitiveRelationCoherenceRule } from './partitive-relation-coherence-rule.js';
24
24
  export { ExternalConceptShapeRule } from './external-concept-shape-rule.js';
25
25
  export { BinaryHasPartRedundancyRule } from './binary-has-part-redundancy-rule.js';
26
+ export { DelimitingCoherenceRule } from './delimiting-coherence-rule.js';
26
27
 
27
28
  import { ConceptValidator, LanguageCodeRule, DesignationTypeRule, EntryStatusRule } from './concept-validator.js';
28
29
  import { RegisterValidator } from './register-validator.js';
@@ -45,6 +46,7 @@ import { SourcedFromLocalityRule } from './sourced-from-locality-rule.js';
45
46
  import { PartitiveRelationCoherenceRule } from './partitive-relation-coherence-rule.js';
46
47
  import { ExternalConceptShapeRule } from './external-concept-shape-rule.js';
47
48
  import { BinaryHasPartRedundancyRule } from './binary-has-part-redundancy-rule.js';
49
+ import { DelimitingCoherenceRule } from './delimiting-coherence-rule.js';
48
50
 
49
51
  const _default = new ConceptValidator()
50
52
  .addRule(new LanguageCodeRule())
@@ -65,7 +67,8 @@ const _default = new ConceptValidator()
65
67
  .addRule(new SourcedFromLocalityRule())
66
68
  .addRule(new PartitiveRelationCoherenceRule())
67
69
  .addRule(new ExternalConceptShapeRule())
68
- .addRule(new BinaryHasPartRedundancyRule());
70
+ .addRule(new BinaryHasPartRedundancyRule())
71
+ .addRule(new DelimitingCoherenceRule());
69
72
 
70
73
  export function validateConcept(concept) {
71
74
  return _default.validate(concept);
@@ -17,6 +17,7 @@
17
17
  // validators enforce semantics.
18
18
 
19
19
  import { ValidationRule } from './validation-rule.js';
20
+ import { refKey, criterionKey } from './ref-keys.js';
20
21
 
21
22
  export class PartitiveRelationCoherenceRule extends ValidationRule {
22
23
  constructor() { super('partitive-relation-coherence'); }
@@ -43,8 +44,8 @@ export class PartitiveRelationCoherenceRule extends ValidationRule {
43
44
  const seen = new Map();
44
45
  for (let i = 0; i < relations.length; i++) {
45
46
  const rel = relations[i];
46
- const compKey = _conceptRefKey(rel?.comprehensive);
47
- const critKey = _criterionKey(rel?.criterion);
47
+ const compKey = refKey(rel?.comprehensive);
48
+ const critKey = criterionKey(rel?.criterion);
48
49
  if (compKey == null || critKey == null) continue;
49
50
  const key = `${compKey}|${critKey}`;
50
51
  if (seen.has(key)) {
@@ -77,22 +78,3 @@ export class PartitiveRelationCoherenceRule extends ValidationRule {
77
78
  // at construction by PartitiveMember itself.)
78
79
  }
79
80
  }
80
-
81
- function _conceptRefKey(ref) {
82
- if (!ref) return null;
83
- const source = ref.source ?? '';
84
- const id = ref.id ?? '';
85
- if (!source && !id) return null;
86
- return `${source}:${id}`;
87
- }
88
-
89
- function _criterionKey(criterion) {
90
- if (!criterion || typeof criterion !== 'object') return null;
91
- // Sort values for stable comparison regardless of language key order.
92
- const values = Object.values(criterion)
93
- .filter(v => typeof v === 'string')
94
- .map(s => s.trim())
95
- .filter(s => s.length > 0)
96
- .sort();
97
- return values.length > 0 ? values.join('|') : null;
98
- }
@@ -0,0 +1,31 @@
1
+ // Shared identity-key helpers for cross-relation validators.
2
+ //
3
+ // Extracted from PartitiveRelationCoherenceRule and
4
+ // BinaryHasPartRedundancyRule per TODO.partitive-relation-v3/06.
5
+ // Eliminates drift: both rules now use the same notion of "same ref"
6
+ // and "same criterion" instead of each defining its own _refKey and
7
+ // _criterionKey with subtly different behaviors.
8
+
9
+ export function refKey(ref) {
10
+ if (!ref) return null;
11
+ const source = ref.source ?? '';
12
+ const id = ref.id ?? '';
13
+ if (!source && !id) return null;
14
+ return `${source}:${id}`;
15
+ }
16
+
17
+ // Hashable key for a criterion (lang-keyed hash). Returns null when
18
+ // no criterion is set OR when the criterion contains no string values.
19
+ // Values are sorted so two criteria with the same strings in different
20
+ // language-key order produce the same key (ISO 12620 coordinate-
21
+ // concept coherence cares about the criterion's content, not the
22
+ // language label order).
23
+ export function criterionKey(criterion) {
24
+ if (!criterion || typeof criterion !== 'object') return null;
25
+ const values = Object.values(criterion)
26
+ .filter(v => typeof v === 'string')
27
+ .map(s => s.trim())
28
+ .filter(s => s.length > 0)
29
+ .sort();
30
+ return values.length > 0 ? values.join('|') : null;
31
+ }