glossarist 0.4.32 → 0.4.34

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 (40) hide show
  1. package/package.json +1 -1
  2. package/src/concept-parser.js +67 -45
  3. package/src/concept-reader.js +59 -3
  4. package/src/concept-serializer.js +4 -4
  5. package/src/diff/concept-diff.js +13 -13
  6. package/src/diff/diff-patch.js +19 -27
  7. package/src/diff/diff-renderer.js +19 -6
  8. package/src/index.js +2 -1
  9. package/src/migration/{partitive-relation-migrator.js → hyperedge-migrator.js} +3 -3
  10. package/src/models/{abstract-nary-relation.js → abstract-hyperedge.js} +5 -5
  11. package/src/models/completeness.js +1 -1
  12. package/src/models/concept.js +145 -72
  13. package/src/models/{generic-relation.js → generic-hyperedge.js} +18 -6
  14. package/src/models/generic-member.js +76 -9
  15. package/src/models/hyperedge-index.js +87 -0
  16. package/src/models/{concept-system-member.js → hyperedge-member.js} +26 -43
  17. package/src/models/hyperedge-registry.js +115 -0
  18. package/src/models/index.d.ts +16 -16
  19. package/src/models/index.js +16 -13
  20. package/src/models/multiplicity.js +11 -6
  21. package/src/models/partitive-hyperedge.js +69 -153
  22. package/src/models/partitive-member.js +40 -11
  23. package/src/models/relation-loader.js +28 -22
  24. package/src/rdf/gloss-concept.js +36 -20
  25. package/src/rdf/gloss-generic-hyperedge.js +92 -0
  26. package/src/rdf/gloss-partitive-relation.js +4 -4
  27. package/src/rdf/index.d.ts +4 -4
  28. package/src/rdf/index.js +0 -1
  29. package/src/reference-resolver.js +14 -12
  30. package/src/validators/binary-has-part-redundancy-rule.js +6 -6
  31. package/src/validators/delimiting-coherence-rule.js +2 -2
  32. package/src/validators/hyperedge-coherence-rule.js +98 -0
  33. package/src/validators/index.js +3 -9
  34. package/src/validators/partitive-relation-coherence-rule.js +6 -79
  35. package/src/validators/ref-keys.js +2 -2
  36. package/src/models/partitive-relation.js +0 -76
  37. package/src/models/relation-type-registry.js +0 -25
  38. package/src/rdf/gloss-hyperedge.js +0 -90
  39. package/src/validators/partitive-drift-rule.js +0 -51
  40. package/src/validators/partitive-hyperedge-rule.js +0 -75
@@ -1,32 +1,39 @@
1
- // RelationLoader — runtime loader for per-file n-ary relation YAMLs.
1
+ // HyperedgeLoader — runtime loader for per-file hyperedge YAMLs.
2
2
  //
3
3
  // Walks a `relations/<comprehensive-id>/<slug>.yaml` directory and
4
- // dispatches each file to the appropriate class (PartitiveRelation
5
- // or GenericRelation) based on its `type` field.
4
+ // dispatches each file to the appropriate class (PartitiveHyperedge,
5
+ // GenericHyperedge, future leaves) based on its `type` field, via
6
+ // HyperedgeRegistry.
6
7
  //
7
8
  // The wire-shape contract is owned by the JSON Schema at
8
9
  // `schemas/v3/relation.yaml` (concept-model repo) — this loader
9
10
  // produces the corresponding JS model instances. See
10
11
  // docs/design/relations-as-files.md (concept-model).
11
12
  //
12
- // This is the runtime equivalent of concept-model's
13
- // CheckPartitiveRelationCoherence#each_relation /
14
- // CheckGenericRelationCoherence#each_relation validators.
15
- // Both readers and validators must agree on the type-dispatch
16
- // table (TYPE_TO_CLASS). Keep them in sync via the JSON Schema.
13
+ // Adding a new hyperedge type means: declare the leaf class with the
14
+ // metadata block, register it. The loader picks it up automatically —
15
+ // no edits here.
17
16
 
18
17
  import { readdirSync, readFileSync, existsSync } from 'node:fs';
19
18
  import * as yaml from 'js-yaml';
20
- import { TYPE_TO_CLASS } from './relation-type-registry.js';
19
+ import { HyperedgeRegistry } from './hyperedge-registry.js';
21
20
 
22
- /**
23
- * Per-type class dispatch. Re-exported from the registry so the
24
- * loader's old `TYPE_TO_CLASS` import path keeps working. The
25
- * registry is the single source of truth — adding a relation type
26
- * there makes it available to both the loader and Concept's
27
- * unified relations array.
28
- */
29
- export { TYPE_TO_CLASS };
21
+ // Backward-compat: snapshot of registered classes keyed by typeTag.
22
+ // Prefer HyperedgeRegistry.forTypeTag() for new code — this snapshot
23
+ // is frozen at module load and won't pick up runtime registrations
24
+ // from tests (the OCP test in Phase 11 registers a mock type after
25
+ // import).
26
+ export const TYPE_TO_CLASS = new Proxy({}, {
27
+ get(_target, prop) {
28
+ return HyperedgeRegistry.forTypeTag(prop);
29
+ },
30
+ ownKeys() {
31
+ return HyperedgeRegistry.allTypeTags();
32
+ },
33
+ getOwnPropertyDescriptor() {
34
+ return { enumerable: true, configurable: true };
35
+ },
36
+ });
30
37
 
31
38
  /**
32
39
  * Thrown by `loadAll` when a relation file has an unknown `type`,
@@ -48,10 +55,9 @@ export class RelationLoadError extends Error {
48
55
  * @param {string} relationsDir — path to a `relations/` directory
49
56
  * containing `<comprehensive-id>/<slug>.yaml` files (per the
50
57
  * schema in schemas/v3/relation.yaml).
51
- * @returns {Map<string, Array<AbstractNaryRelation>>} — for each
58
+ * @returns {Map<string, Array<AbstractHyperedge>>} — for each
52
59
  * comprehensive id, the list of relations that have it as their
53
- * comprehensive. Type-discriminated (PartitiveRelation or
54
- * GenericRelation).
60
+ * comprehensive.
55
61
  */
56
62
  export function loadAll(relationsDir) {
57
63
  const result = new Map();
@@ -82,7 +88,7 @@ export function loadAll(relationsDir) {
82
88
  * `RelationLoader.loadAll(dir)`. The individual functions are also
83
89
  * exported directly for tree-shakeable single-call sites.
84
90
  */
85
- export const RelationLoader = Object.freeze({ loadAll });
91
+ export const RelationLoader = Object.freeze({ loadAll, loadOne });
86
92
 
87
93
  /**
88
94
  * Load a single relation file. Returns null if the file is missing,
@@ -99,7 +105,7 @@ export function loadOne(path) {
99
105
  }
100
106
  if (!doc || typeof doc !== 'object') return null;
101
107
  if (!doc.type) return null;
102
- const Cls = TYPE_TO_CLASS[doc.type];
108
+ const Cls = HyperedgeRegistry.forTypeTag(doc.type);
103
109
  if (!Cls) return null; // Unknown type — let the validator report it
104
110
  return Cls.fromJSON(doc);
105
111
  }
@@ -8,9 +8,29 @@ import { WELL_KNOWN } from './prefixes.js';
8
8
  import { localizedConceptToQuads } from './gloss-localized-concept.js';
9
9
  import { conceptSourceToQuads } from './gloss-source.js';
10
10
  import { partitiveRelationToQuads, partitiveRelationSubjectUri } from './gloss-partitive-relation.js';
11
- import { hyperedgeToQuads, hyperedgeSubjectUri } from './gloss-hyperedge.js';
11
+ import { genericHyperedgeToQuads, genericHyperedgeSubjectUri, GENERIC_HYPEREDGE_LINK_PREDICATE } from './gloss-generic-hyperedge.js';
12
+ import { PartitiveHyperedge } from '../models/partitive-hyperedge.js';
13
+ import { GenericHyperedge } from '../models/generic-hyperedge.js';
12
14
  import { namedNode, literal, quad } from './terms.js';
13
15
 
16
+ // Per-type RDF emitter dispatch. Keyed by class. Adding a new hyperedge
17
+ // type with its own emitter means: (1) write the emitter module,
18
+ // (2) add one entry here. Per TODO Phase 6e this should be a
19
+ // self-registering registry; for the two current types, the dispatch
20
+ // table is simpler and keeps the dependency graph acyclic.
21
+ const HYPEREDGE_EMITTERS = new Map([
22
+ [PartitiveHyperedge, {
23
+ linkPredicate: PRED.gloss.hasPartitiveRelation,
24
+ subjectUri: partitiveRelationSubjectUri,
25
+ toQuads: partitiveRelationToQuads,
26
+ }],
27
+ [GenericHyperedge, {
28
+ linkPredicate: GENERIC_HYPEREDGE_LINK_PREDICATE,
29
+ subjectUri: genericHyperedgeSubjectUri,
30
+ toQuads: genericHyperedgeToQuads,
31
+ }],
32
+ ]);
33
+
14
34
  // Kind tag → URI segment. Each NonVerbalReference subtype has a
15
35
  // predictable URL path so the same entity is reachable from any
16
36
  // concept that references it.
@@ -72,27 +92,23 @@ export function* conceptToQuads(concept, options) {
72
92
  srcIndex += 1;
73
93
  }
74
94
 
75
- // v2 canonical: concept.partitiveRelations. v1 alias
76
- // (concept.partitiveHyperedges) points at the same array — both
77
- // work here.
78
- const relations = concept.partitiveRelations ?? concept.partitiveHyperedges ?? [];
95
+ // Iterate the unified relations array. Dispatch via per-type
96
+ // emitter table.
79
97
  let relIdx = 0;
80
- for (const relation of relations) {
81
- // Skip v1 PartitiveHyperedge instances — they have their own
82
- // emitter (gloss-hyperedge.js) and would double-emit. The parser
83
- // always produces v2 PartitiveRelation instances; only direct
84
- // callers using the v1 model would see this branch.
85
- const isV2 = !relation.enumeration && !relation.markers && !relation.parts
86
- || (relation.completeness != null || relation.partitives != null);
87
- if (!isV2) {
88
- const heSubject = hyperedgeSubjectUri(subjectUri, relation, relIdx);
89
- yield quad(s, namedNode(PRED.gloss.hasPartitiveHyperedge), namedNode(heSubject));
90
- yield* hyperedgeToQuads(relation, { parentUri: subjectUri, index: relIdx });
91
- } else {
92
- const relSubject = partitiveRelationSubjectUri(subjectUri, relation, relIdx);
93
- yield quad(s, namedNode(PRED.gloss.hasPartitiveRelation), namedNode(relSubject));
94
- yield* partitiveRelationToQuads(relation, { parentUri: subjectUri, index: relIdx });
98
+ for (const relation of concept.relations ?? []) {
99
+ const emitter = HYPEREDGE_EMITTERS.get(relation.constructor);
100
+ if (!emitter) {
101
+ // Unknown hyperedge type — skip rather than crash. The OCP
102
+ // contract (Phase 11) tests that new types register an emitter;
103
+ // skipping here gives external consumers a soft-failure path
104
+ // when they pass around abstract hyperedges the JS layer doesn't
105
+ // recognize yet.
106
+ relIdx += 1;
107
+ continue;
95
108
  }
109
+ const relSubject = emitter.subjectUri(subjectUri, relation, relIdx);
110
+ yield quad(s, namedNode(emitter.linkPredicate), namedNode(relSubject));
111
+ yield* emitter.toQuads(relation, { parentUri: subjectUri, index: relIdx });
96
112
  relIdx += 1;
97
113
  }
98
114
  }
@@ -0,0 +1,92 @@
1
+ // GenericHyperedge → RDF quads.
2
+ //
3
+ // Shape (mirrors gloss-partitive-relation.js, swapping partitive
4
+ // predicates for generic predicates per the concept-model ontology
5
+ // extension that introduces gloss:GenericRelation):
6
+ //
7
+ // <base>/generic-relation/<carrying-id>/<comprehensive-id>
8
+ // rdf:type gloss:GenericRelation
9
+ // gloss:comprehensive <base>/concept/<comprehensive-id> (named node)
10
+ // gloss:hasGeneric <base>/concept/<member-id> (named node, one per member)
11
+ // gloss:completeness <.../completeness/complete|partial> (named node, SKOS)
12
+ // gloss:criterion "..."@lang (literal, one per language)
13
+ //
14
+ // Per-member multiplicity + is_delimiting reuse the same shape as
15
+ // partitive members (HyperedgeMember is the shared base). Emitted via
16
+ // `partitiveMemberToQuads` — the per-member shape is type-blind.
17
+ //
18
+ // The carrying concept links via `gloss:hasGenericRelation`.
19
+ //
20
+ // Per TODO Phase 6e. When the concept-model ontology publishes the
21
+ // generic-relation predicates, this file is the single place to update
22
+ // the predicate strings; nothing else in the RDF layer changes.
23
+
24
+ import { PRED } from './predicates.js';
25
+ import { WELL_KNOWN } from './prefixes.js';
26
+ import { partitiveMemberToQuads } from './gloss-partitive-member.js';
27
+ import { namedNode, literal, quad } from './terms.js';
28
+
29
+ const COMPLETENESS_NS = 'https://www.glossarist.org/ontologies/completeness/';
30
+
31
+ // Predicate IRIs. concept-model ontology v3.2.0 does not yet publish
32
+ // GenericRelation predicates; we mint them under the same gloss:
33
+ // namespace following the PartitiveRelation naming convention. Update
34
+ // to match concept-model once finalized.
35
+ const HAS_GENERIC_RELATION = 'https://www.glossarist.org/ontologies/hasGenericRelation';
36
+ const HAS_GENERIC = 'https://www.glossarist.org/ontologies/hasGeneric';
37
+ const GENERIC_RELATION_TYPE = 'https://www.glossarist.org/ontologies/GenericRelation';
38
+
39
+ export function* genericHyperedgeToQuads(relation, { parentUri, index }) {
40
+ const subjectUri = genericHyperedgeSubjectUri(parentUri, relation, index);
41
+ const s = namedNode(subjectUri);
42
+
43
+ yield quad(s, namedNode(WELL_KNOWN.rdfType), namedNode(GENERIC_RELATION_TYPE));
44
+
45
+ const comp = relation.comprehensive;
46
+ if (comp && (comp.source || comp.id)) {
47
+ yield quad(s, namedNode(PRED.gloss.comprehensive),
48
+ namedNode(conceptRefUri(comp, parentUri)));
49
+ }
50
+
51
+ const members = Array.isArray(relation.members) ? relation.members : [];
52
+ for (const member of members) {
53
+ const ref = member?.ref ?? member;
54
+ if (!ref || (!ref.source && !ref.id)) continue;
55
+ const memberUri = conceptRefUri(ref, parentUri);
56
+ yield quad(s, namedNode(HAS_GENERIC), namedNode(memberUri));
57
+ yield* partitiveMemberToQuads(member, { memberUri });
58
+ }
59
+
60
+ if (relation.completeness) {
61
+ yield quad(s, namedNode(PRED.gloss.completeness),
62
+ namedNode(COMPLETENESS_NS + relation.completeness));
63
+ }
64
+
65
+ if (relation.criterion && typeof relation.criterion === 'object') {
66
+ for (const [lang, text] of Object.entries(relation.criterion)) {
67
+ if (typeof text !== 'string') continue;
68
+ yield quad(s, namedNode(PRED.gloss.criterion), literal(text, lang));
69
+ }
70
+ }
71
+ }
72
+
73
+ // Subject URI scheme: `<base>/generic-relation/<carrying-id>/<comprehensive-id>`.
74
+ // Mirrors the partitive scheme so consumers can predict either path.
75
+ export function genericHyperedgeSubjectUri(parentUri, relation, index) {
76
+ const base = parentUri.replace(/\/concept\/[^/]+$/, '');
77
+ const carrierId = parentUri.split('/concept/')[1] ?? `carrier-${index}`;
78
+ const compId = relation?.comprehensive?.id ?? `comp-${index}`;
79
+ return `${base}/generic-relation/${carrierId}/${compId}`;
80
+ }
81
+
82
+ // Exported for gloss-concept.js to use when linking the carrying concept.
83
+ export const GENERIC_HYPEREDGE_LINK_PREDICATE = HAS_GENERIC_RELATION;
84
+
85
+ function conceptRefUri(ref, parentUri) {
86
+ const id = ref?.id;
87
+ if (id) {
88
+ const base = parentUri.split('/concept/')[0];
89
+ return `${base}/concept/${id}`;
90
+ }
91
+ return `urn:glossarist:${ref?.source ?? 'unknown'}:`;
92
+ }
@@ -1,9 +1,9 @@
1
- // PartitiveRelation → RDF quads.
1
+ // PartitiveHyperedge → RDF quads.
2
2
  //
3
3
  // Shape (per concept-model ontology, v3.2.0):
4
4
  //
5
5
  // <base>/partitive-relation/<carrying-id>/<comprehensive-id>
6
- // rdf:type gloss:PartitiveRelation
6
+ // rdf:type gloss:PartitiveHyperedge
7
7
  // gloss:comprehensive <base>/concept/<comprehensive-id> (named node)
8
8
  // gloss:hasPartitive <base>/concept/<partitive-id> (named node, one per member)
9
9
  // gloss:completeness <.../completeness/complete|partial> (named node, SKOS)
@@ -14,7 +14,7 @@
14
14
  //
15
15
  // The carrying concept links via `gloss:hasPartitiveRelation`.
16
16
  //
17
- // Replaces gloss-hyperedge.js (the v1 emitter) per
17
+ // Per
18
18
  // TODO.partitive-relation-v2 item 01.
19
19
  // Cross-repo alignment with glossarist-ruby's Rdf::GlossPartitiveRelation.
20
20
  //
@@ -35,7 +35,7 @@ export function* partitiveRelationToQuads(relation, { parentUri, index }) {
35
35
  const subjectUri = partitiveRelationSubjectUri(parentUri, relation, index);
36
36
  const s = namedNode(subjectUri);
37
37
 
38
- yield quad(s, namedNode(WELL_KNOWN.rdfType), namedNode(PRED.gloss.PartitiveRelation));
38
+ yield quad(s, namedNode(WELL_KNOWN.rdfType), namedNode(PRED.gloss.PartitiveHyperedge));
39
39
 
40
40
  const comp = relation.comprehensive;
41
41
  if (comp && (comp.source || comp.id)) {
@@ -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, PartitiveMember } from '../models/index';
14
+ import type { PartitiveHyperedge, PartitiveHyperedge, PartitiveMember } from '../models/index';
15
15
 
16
16
  export { PRED, PREFIXES } from './predicates';
17
17
  export type PredicateMap = typeof PRED;
@@ -96,7 +96,7 @@ export declare function hyperedgeSubjectUri(
96
96
  index: number,
97
97
  ): string;
98
98
 
99
- // ── PartitiveRelation emitter (v2) ────────────────────────────────────
99
+ // ── PartitiveHyperedge emitter (v2) ────────────────────────────────────
100
100
 
101
101
  export interface PartitiveRelationEmitOptions {
102
102
  parentUri: string;
@@ -104,13 +104,13 @@ export interface PartitiveRelationEmitOptions {
104
104
  }
105
105
 
106
106
  export declare function partitiveRelationToQuads(
107
- relation: PartitiveRelation,
107
+ relation: PartitiveHyperedge,
108
108
  options: PartitiveRelationEmitOptions,
109
109
  ): Generator<Quad, void, unknown>;
110
110
 
111
111
  export declare function partitiveRelationSubjectUri(
112
112
  parentUri: string,
113
- relation: PartitiveRelation,
113
+ relation: PartitiveHyperedge,
114
114
  index: number,
115
115
  ): string;
116
116
 
package/src/rdf/index.js CHANGED
@@ -26,7 +26,6 @@ export {
26
26
  } from './gloss-designation.js';
27
27
  export { detailedDefinitionToQuads } from './gloss-detailed-definition.js';
28
28
  export { conceptSourceToQuads } from './gloss-source.js';
29
- export { hyperedgeToQuads, hyperedgeSubjectUri } from './gloss-hyperedge.js';
30
29
  export { partitiveMemberToQuads, MULTIPLICITY_NS, PRESENCE_NS, COUNT_NS } from './gloss-partitive-member.js';
31
30
  export { partitiveRelationToQuads, partitiveRelationSubjectUri } from './gloss-partitive-relation.js';
32
31
  export {
@@ -84,29 +84,31 @@ export class ReferenceResolver {
84
84
  }
85
85
  }
86
86
 
87
- // v2 model: concept.partitiveRelations is canonical; v1 alias
88
- // concept.partitiveHyperedges points at the same array. Each
89
- // relation has a comprehensive ConceptRef and 2+ partitive members
90
- // each carrying a ConceptRef.
91
- const relations = concept.partitiveRelations ?? concept.partitiveHyperedges ?? [];
87
+ // All hyperedges (Partitive + Generic + future types) live in
88
+ // concept.relations. Each has a comprehensive ConceptRef and 2+
89
+ // members each carrying a ConceptRef. The reference type is the
90
+ // relation's typeTag (partitive_relation, generic_relation, …).
91
+ const relations = concept.relations ?? [];
92
92
  for (let i = 0; i < relations.length; i++) {
93
93
  const rel = relations[i];
94
- const relPath = `partitiveRelations[${i}]`;
94
+ const relType = rel?.constructor?.typeTag ?? 'hyperedge';
95
+ const relPath = `relations[${i}]`;
95
96
 
96
97
  const compTarget = hyperedgeRefTarget(rel.comprehensive);
97
98
  if (compTarget) {
98
99
  refs.push(new Reference(
99
100
  'concept',
100
101
  compTarget,
101
- 'partitive_relation',
102
+ relType,
102
103
  `${relPath}.comprehensive`,
103
104
  { lookupKey: { id: compTarget } },
104
105
  ));
105
106
  }
106
107
 
107
- // v2 shape: rel.partitives is [{ ref, certainty }, ...].
108
- // v1 shape: rel.parts is [ref, ...]. Accept both.
109
- const members = rel.partitives ?? rel.parts ?? [];
108
+ // Member shape: members is [{ ref, presence, count, is_delimiting }, ...]
109
+ // (HyperedgeMember / PartitiveMember / GenericMember). The wire
110
+ // alias .partitives is also handled for backward compat with v2 wire consumers.
111
+ const members = rel.members ?? rel.partitives ?? rel.parts ?? [];
110
112
  for (let j = 0; j < members.length; j++) {
111
113
  const m = members[j];
112
114
  const memberRef = m?.ref ?? m;
@@ -115,8 +117,8 @@ export class ReferenceResolver {
115
117
  refs.push(new Reference(
116
118
  'concept',
117
119
  pTarget,
118
- 'partitive_relation',
119
- `${relPath}.partitives[${j}]`,
120
+ relType,
121
+ `${relPath}.members[${j}]`,
120
122
  { lookupKey: { id: pTarget } },
121
123
  ));
122
124
  }
@@ -1,6 +1,6 @@
1
1
  // Warns when binary `has_part` / `is_part_of` edges duplicate
2
- // PartitiveRelation members, or when a concept has many binary
3
- // has_part edges that should be consolidated into a PartitiveRelation.
2
+ // PartitiveHyperedge members, or when a concept has many binary
3
+ // has_part edges that should be consolidated into a PartitiveHyperedge.
4
4
  //
5
5
  // Per TODO.partitive-relation-v2 item 14, binary edges and
6
6
  // PartitiveRelations coexist by design. The asymmetry is pragmatic:
@@ -20,7 +20,7 @@ export class BinaryHasPartRedundancyRule extends ValidationRule {
20
20
  constructor() { super('binary-has-part-redundancy', 'warning'); }
21
21
 
22
22
  validate(concept, path, result) {
23
- const relations = concept.partitiveRelations ?? concept.partitiveHyperedges ?? [];
23
+ const relations = concept.relations ?? [];
24
24
 
25
25
  // Collect partitive refs from PartitiveRelations.
26
26
  const relationTargets = new Set();
@@ -49,17 +49,17 @@ export class BinaryHasPartRedundancyRule extends ValidationRule {
49
49
  this.addIssue(result,
50
50
  `${path}relatedConcepts`,
51
51
  `binary has_part edge for (${target}) is redundant — already in a ` +
52
- `PartitiveRelation member; pick one encoding to avoid divergence`,
52
+ `PartitiveHyperedge member; pick one encoding to avoid divergence`,
53
53
  );
54
54
  }
55
55
  }
56
56
 
57
- // Cluster: 3+ binary has_part edges suggests PartitiveRelation territory.
57
+ // Cluster: 3+ binary has_part edges suggests PartitiveHyperedge territory.
58
58
  if (binaryTargets.size >= 3) {
59
59
  this.addIssue(result,
60
60
  `${path}relatedConcepts`,
61
61
  `${binaryTargets.size} binary has_part edges on this concept; ` +
62
- `consider converting to a PartitiveRelation (which can carry ` +
62
+ `consider converting to a PartitiveHyperedge (which can carry ` +
63
63
  `completeness, multiplicity, and criterion metadata)`,
64
64
  );
65
65
  }
@@ -9,7 +9,7 @@
9
9
  // This rule surfaces three categories of issues that the model
10
10
  // constructor cannot catch (they're cross-relation, not per-instance):
11
11
  //
12
- // 1. WARNING: A PartitiveRelation where every partitive is
12
+ // 1. WARNING: A PartitiveHyperedge where every partitive is
13
13
  // is_delimiting=true overuses delimiting. Typically only some
14
14
  // parts distinguish the comprehensive from coordinate concepts.
15
15
  // 2. ERROR: Two PartitiveRelations on the same concept with the
@@ -30,7 +30,7 @@ export class DelimitingCoherenceRule extends ValidationRule {
30
30
  constructor() { super('delimiting-coherence'); }
31
31
 
32
32
  validate(concept, path, result) {
33
- const relations = concept.partitiveRelations ?? concept.partitiveHyperedges ?? [];
33
+ const relations = concept.relations ?? [];
34
34
  if (relations.length === 0) return;
35
35
 
36
36
  _checkOveruse(relations, path, result);
@@ -0,0 +1,98 @@
1
+ // Validates hyperedge coherence per ISO 704 / ISO 12620.
2
+ //
3
+ // Applies to every AbstractHyperedge subclass (PartitiveHyperedge,
4
+ // GenericHyperedge, future TemporalHyperedge / AssociativeHyperedge /
5
+ // SequentialHyperedge). The ISO 704 / ISO 12620 invariants are
6
+ // type-blind:
7
+ //
8
+ // 1. ERROR: each hyperedge has ≥2 members (ISO 704 "two or more")
9
+ // 2. ERROR: no two hyperedges of the same type on the same concept
10
+ // share the same comprehensive + same criterion (duplicate
11
+ // decomposition — violates ISO 12620 coordinate-concept coherence)
12
+ // 3. WARNING: a concept with multiple hyperedges of the same type
13
+ // where any lacks criterion (cannot verify distinctness from
14
+ // siblings)
15
+ //
16
+ // Lives in the validator framework, not the model constructor, per
17
+ // invariant I8 (TODO.hyperedges-v2/00). Constructors enforce type-shape;
18
+ // validators enforce semantics.
19
+ //
20
+ // Per TODO Phase 7: rule renamed from PartitiveRelationCoherenceRule
21
+ // to HyperedgeCoherenceRule. The rule applies to all hyperedges; the
22
+ // duplicate check is scoped per type so a Partitive and a Generic with
23
+ // the same comprehensive+criterion don't false-positive.
24
+
25
+ import { ValidationRule } from './validation-rule.js';
26
+ import { refKey, criterionKey } from './ref-keys.js';
27
+ import { AbstractHyperedge } from '../models/abstract-hyperedge.js';
28
+
29
+ export class HyperedgeCoherenceRule extends ValidationRule {
30
+ constructor() { super('hyperedge-coherence'); }
31
+
32
+ validate(concept, path, result) {
33
+ const hyperedges = (concept.relations ?? []).filter(r => r instanceof AbstractHyperedge);
34
+ if (hyperedges.length === 0) return;
35
+
36
+ // Check 1: ≥2 members per hyperedge. (The constructor also
37
+ // rejects this, but a warning here surfaces it in the result
38
+ // bundle for CI visibility.)
39
+ for (let i = 0; i < hyperedges.length; i++) {
40
+ const memberCount = (hyperedges[i]?.members ?? []).length;
41
+ if (memberCount < 2) {
42
+ this.addIssue(result,
43
+ `${path}relations[${i}].members`,
44
+ `hyperedge ${i + 1} has ${memberCount} member(s); ` +
45
+ `ISO 704 requires ≥2 (use a binary edge for pairwise facts)`,
46
+ );
47
+ }
48
+ }
49
+
50
+ // Check 2: no duplicate (type + comprehensive + criterion) on the same concept.
51
+ // Scope by type so a Partitive and a Generic with the same comp+criterion
52
+ // don't false-positive — they're different decompositions.
53
+ const seen = new Map();
54
+ for (let i = 0; i < hyperedges.length; i++) {
55
+ const rel = hyperedges[i];
56
+ const typeTag = rel?.constructor?.typeTag ?? '';
57
+ const compKey = refKey(rel?.comprehensive);
58
+ const critKey = criterionKey(rel?.criterion);
59
+ if (compKey == null || critKey == null) continue;
60
+ const key = `${typeTag}|${compKey}|${critKey}`;
61
+ if (seen.has(key)) {
62
+ this.addIssue(result,
63
+ `${path}relations[${i}]`,
64
+ `duplicate ${rel.constructor.name} (same comprehensive + same criterion ` +
65
+ `as relation ${seen.get(key) + 1}); violates ISO 12620 coordinate-concept coherence`,
66
+ );
67
+ } else {
68
+ seen.set(key, i);
69
+ }
70
+ }
71
+
72
+ // Check 3: criterion documentation (warning) when concept has multiple
73
+ // hyperedges of the same type. Same-type-scope rationale as Check 2.
74
+ const byType = new Map();
75
+ for (const rel of hyperedges) {
76
+ const typeTag = rel?.constructor?.typeTag ?? '';
77
+ if (!byType.has(typeTag)) byType.set(typeTag, []);
78
+ byType.get(typeTag).push(rel);
79
+ }
80
+ for (const [typeTag, rels] of byType) {
81
+ if (rels.length <= 1) continue;
82
+ for (const rel of rels) {
83
+ if (!rel.criterion) {
84
+ const idx = hyperedges.indexOf(rel);
85
+ result.addWarning(
86
+ `${path}relations[${idx}].criterion`,
87
+ `concept has multiple ${typeTag} hyperedges but this one ` +
88
+ `has no criterion; cannot verify distinctness from siblings`,
89
+ );
90
+ }
91
+ }
92
+ }
93
+ }
94
+ }
95
+
96
+ // Backward-compat alias. Existing imports of PartitiveRelationCoherenceRule
97
+ // continue to work; the rule is now type-blind.
98
+ export const PartitiveRelationCoherenceRule = HyperedgeCoherenceRule;
@@ -17,10 +17,8 @@ export {
17
17
  NonVerbalRefIntegrityRule,
18
18
  OrphanedImagesRule,
19
19
  } from './v3-rules.js';
20
- export { PartitiveHyperedgeShapeRule } from './partitive-hyperedge-rule.js';
21
- export { PartitiveDriftRule } from './partitive-drift-rule.js';
22
20
  export { SourcedFromLocalityRule } from './sourced-from-locality-rule.js';
23
- export { PartitiveRelationCoherenceRule } from './partitive-relation-coherence-rule.js';
21
+ export { PartitiveRelationCoherenceRule, HyperedgeCoherenceRule } from './hyperedge-coherence-rule.js';
24
22
  export { ExternalConceptShapeRule } from './external-concept-shape-rule.js';
25
23
  export { BinaryHasPartRedundancyRule } from './binary-has-part-redundancy-rule.js';
26
24
  export { DelimitingCoherenceRule } from './delimiting-coherence-rule.js';
@@ -40,10 +38,8 @@ import {
40
38
  CiteRefIntegrityRule,
41
39
  NonVerbalRefIntegrityRule,
42
40
  } from './v3-rules.js';
43
- import { PartitiveHyperedgeShapeRule } from './partitive-hyperedge-rule.js';
44
- import { PartitiveDriftRule } from './partitive-drift-rule.js';
45
41
  import { SourcedFromLocalityRule } from './sourced-from-locality-rule.js';
46
- import { PartitiveRelationCoherenceRule } from './partitive-relation-coherence-rule.js';
42
+ import { HyperedgeCoherenceRule } from './hyperedge-coherence-rule.js';
47
43
  import { ExternalConceptShapeRule } from './external-concept-shape-rule.js';
48
44
  import { BinaryHasPartRedundancyRule } from './binary-has-part-redundancy-rule.js';
49
45
  import { DelimitingCoherenceRule } from './delimiting-coherence-rule.js';
@@ -62,10 +58,8 @@ const _default = new ConceptValidator()
62
58
  .addRule(new RelationshipTypeRule())
63
59
  .addRule(new CiteRefIntegrityRule())
64
60
  .addRule(new NonVerbalRefIntegrityRule())
65
- .addRule(new PartitiveHyperedgeShapeRule())
66
- .addRule(new PartitiveDriftRule())
67
61
  .addRule(new SourcedFromLocalityRule())
68
- .addRule(new PartitiveRelationCoherenceRule())
62
+ .addRule(new HyperedgeCoherenceRule())
69
63
  .addRule(new ExternalConceptShapeRule())
70
64
  .addRule(new BinaryHasPartRedundancyRule())
71
65
  .addRule(new DelimitingCoherenceRule());