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.
- package/package.json +1 -1
- package/src/concept-parser.js +67 -45
- package/src/concept-reader.js +59 -3
- package/src/concept-serializer.js +4 -4
- package/src/diff/concept-diff.js +13 -13
- package/src/diff/diff-patch.js +19 -27
- package/src/diff/diff-renderer.js +19 -6
- package/src/index.js +2 -1
- package/src/migration/{partitive-relation-migrator.js → hyperedge-migrator.js} +3 -3
- package/src/models/{abstract-nary-relation.js → abstract-hyperedge.js} +5 -5
- package/src/models/completeness.js +1 -1
- package/src/models/concept.js +145 -72
- package/src/models/{generic-relation.js → generic-hyperedge.js} +18 -6
- package/src/models/generic-member.js +76 -9
- package/src/models/hyperedge-index.js +87 -0
- package/src/models/{concept-system-member.js → hyperedge-member.js} +26 -43
- package/src/models/hyperedge-registry.js +115 -0
- package/src/models/index.d.ts +16 -16
- package/src/models/index.js +16 -13
- package/src/models/multiplicity.js +11 -6
- package/src/models/partitive-hyperedge.js +69 -153
- package/src/models/partitive-member.js +40 -11
- package/src/models/relation-loader.js +28 -22
- package/src/rdf/gloss-concept.js +36 -20
- package/src/rdf/gloss-generic-hyperedge.js +92 -0
- package/src/rdf/gloss-partitive-relation.js +4 -4
- package/src/rdf/index.d.ts +4 -4
- package/src/rdf/index.js +0 -1
- package/src/reference-resolver.js +14 -12
- package/src/validators/binary-has-part-redundancy-rule.js +6 -6
- package/src/validators/delimiting-coherence-rule.js +2 -2
- package/src/validators/hyperedge-coherence-rule.js +98 -0
- package/src/validators/index.js +3 -9
- package/src/validators/partitive-relation-coherence-rule.js +6 -79
- package/src/validators/ref-keys.js +2 -2
- package/src/models/partitive-relation.js +0 -76
- package/src/models/relation-type-registry.js +0 -25
- package/src/rdf/gloss-hyperedge.js +0 -90
- package/src/validators/partitive-drift-rule.js +0 -51
- package/src/validators/partitive-hyperedge-rule.js +0 -75
package/src/models/concept.js
CHANGED
|
@@ -2,11 +2,9 @@ import { GlossaristModel } from './base.js';
|
|
|
2
2
|
import { LocalizedConcept } from './localized-concept.js';
|
|
3
3
|
import { RelatedConcept } from './related-concept.js';
|
|
4
4
|
import { PartitiveHyperedge } from './partitive-hyperedge.js';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { TYPE_TO_CLASS } from './relation-type-registry.js';
|
|
9
|
-
import { migrateHyperedgeToRelation } from '../migration/partitive-relation-migrator.js';
|
|
5
|
+
import { AbstractHyperedge } from './abstract-hyperedge.js';
|
|
6
|
+
import { HyperedgeRegistry, groupHyperedgesByWireKey } from './hyperedge-registry.js';
|
|
7
|
+
import { migrateHyperedgeToRelation } from '../migration/hyperedge-migrator.js';
|
|
10
8
|
import { ConceptReference } from './concept-reference.js';
|
|
11
9
|
import { ConceptDate } from './concept-date.js';
|
|
12
10
|
import { ConceptSource } from './concept-source.js';
|
|
@@ -38,16 +36,18 @@ export class Concept extends GlossaristModel {
|
|
|
38
36
|
|
|
39
37
|
this.relatedConcepts = _mapInstances(data.relatedConcepts ?? data.related ?? data.related_concepts ?? [], RelatedConcept);
|
|
40
38
|
|
|
41
|
-
// Unified
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
39
|
+
// Unified hyperedge array. Every hyperedge (PartitiveHyperedge,
|
|
40
|
+
// GenericHyperedge, future TemporalHyperedge / AssociativeHyperedge /
|
|
41
|
+
// SequentialHyperedge) lives here. The single public API is
|
|
42
|
+
// `.relations`; external systems that need to filter by type use
|
|
43
|
+
// `concept.relations.filter(r => r instanceof X)` or dispatch via
|
|
44
|
+
// HyperedgeRegistry (preferred — see Phases 6 and 11).
|
|
45
45
|
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
this.relations =
|
|
46
|
+
// Throws on ambiguous input (caller passed both `relations` and any
|
|
47
|
+
// typed wire key) and on duplicate relations (same identity twice).
|
|
48
|
+
// Silent drop / silent dedupe are footguns the audit (C4, C6)
|
|
49
|
+
// flagged; explicit throws make corruption loud.
|
|
50
|
+
this.relations = _resolveHyperedges(data);
|
|
51
51
|
|
|
52
52
|
this.domains = _normalizeDomains(data.domains, data.groups);
|
|
53
53
|
this.groups = Array.isArray(data.groups)
|
|
@@ -72,16 +72,17 @@ export class Concept extends GlossaristModel {
|
|
|
72
72
|
|
|
73
73
|
get termid() { return this.id; }
|
|
74
74
|
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
//
|
|
84
|
-
|
|
75
|
+
// No typed projections. Use concept.relations directly and filter
|
|
76
|
+
// by instanceof, or dispatch via HyperedgeRegistry. The old
|
|
77
|
+
// .partitiveRelations / .genericRelations / .partitiveHyperedges
|
|
78
|
+
// getters were removed per TODO Phase 4 — they were footguns
|
|
79
|
+
// (audit C3: .partitiveHyperedges returned v2 instances but callers
|
|
80
|
+
// expected v1 shape; multiple validators silently no-op'd).
|
|
81
|
+
//
|
|
82
|
+
// Snippet migration guide:
|
|
83
|
+
// concept.partitiveRelations → concept.relations.filter(r => r instanceof PartitiveHyperedge)
|
|
84
|
+
// concept.genericRelations → concept.relations.filter(r => r instanceof GenericHyperedge)
|
|
85
|
+
// concept.partitiveHyperedges → concept.relations.filter(r => r instanceof PartitiveHyperedge)
|
|
85
86
|
|
|
86
87
|
get languages() {
|
|
87
88
|
return Object.keys(this._rawLocalizations);
|
|
@@ -222,16 +223,13 @@ export class Concept extends GlossaristModel {
|
|
|
222
223
|
if (this.relatedConcepts.length > 0) {
|
|
223
224
|
obj.related = this.relatedConcepts.map(rc => rc.toJSON());
|
|
224
225
|
}
|
|
225
|
-
// Emit
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
}
|
|
233
|
-
if (genericRels.length > 0) {
|
|
234
|
-
obj.generic_relations = genericRels.map(r => r.toJSON());
|
|
226
|
+
// Emit per-type wire keys from the unified relations array,
|
|
227
|
+
// partitioned via HyperedgeRegistry. Adding a new hyperedge type
|
|
228
|
+
// means its wire key appears here automatically — no edits to
|
|
229
|
+
// Concept.toJSON.
|
|
230
|
+
const grouped = groupHyperedgesByWireKey(this.relations);
|
|
231
|
+
for (const [wireKey, rels] of Object.entries(grouped)) {
|
|
232
|
+
if (rels.length > 0) obj[wireKey] = rels.map(r => r.toJSON());
|
|
235
233
|
}
|
|
236
234
|
if (this.domains.length > 0) {
|
|
237
235
|
obj.domains = this.domains.map(d => d.toJSON());
|
|
@@ -262,72 +260,147 @@ function _mapInstances(arr, Cls) {
|
|
|
262
260
|
return arr.map(item => item instanceof Cls ? item : new Cls(item));
|
|
263
261
|
}
|
|
264
262
|
|
|
265
|
-
// Resolve the unified
|
|
266
|
-
// Order of precedence:
|
|
267
|
-
// 1. data.relations (preferred unified shape — already typed instances
|
|
268
|
-
// or hashes carrying a `type` discriminator)
|
|
269
|
-
// 2. data.partitiveRelations / data.partitive_relations (v2 typed)
|
|
270
|
-
// + data.genericRelations / data.generic_relations (v2 typed)
|
|
271
|
-
// 3. data.partitiveHyperedges / data.partitive_hyperedges (v1, migrated)
|
|
263
|
+
// Resolve the unified hyperedge array from any input shape.
|
|
272
264
|
//
|
|
273
|
-
//
|
|
274
|
-
//
|
|
265
|
+
// Three input categories are accepted; mixing them throws (audit C4 —
|
|
266
|
+
// silent drop was a footgun):
|
|
275
267
|
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
|
|
268
|
+
// 1. data.relations — preferred unified shape. Entries
|
|
269
|
+
// are typed instances or hashes
|
|
270
|
+
// carrying a `type` discriminator
|
|
271
|
+
// (looked up via HyperedgeRegistry).
|
|
272
|
+
// 2. Per-type v2 wire keys — one of HyperedgeRegistry.allWireKeys()
|
|
273
|
+
// (partitive_relations, generic_relations).
|
|
274
|
+
// 3. Per-type v1 legacy keys — declared on each class via
|
|
275
|
+
// `static v1WireKeys` (e.g.
|
|
276
|
+
// partitive_hyperedges → migrated).
|
|
277
|
+
//
|
|
278
|
+
// Adding a new hyperedge type means: declare the leaf class with its
|
|
279
|
+
// metadata block, register it. Concept, parser, serializer, diff,
|
|
280
|
+
// patch, renderer, RDF emitter do not change. (Phase 11 OCP contract.)
|
|
281
|
+
function _resolveHyperedges(data) {
|
|
282
|
+
_assertNoAmbiguousInput(data);
|
|
283
|
+
|
|
280
284
|
if (Array.isArray(data.relations)) {
|
|
281
|
-
return data.relations
|
|
282
|
-
.map(r => _coerceRelation(r))
|
|
283
|
-
.filter(r => r != null);
|
|
285
|
+
return _dedupeOrThrow(data.relations.map(r => _coerceHyperedge(r)).filter(r => r != null));
|
|
284
286
|
}
|
|
285
287
|
|
|
286
288
|
const out = [];
|
|
287
289
|
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
290
|
+
for (const cls of HyperedgeRegistry.allClasses()) {
|
|
291
|
+
const arr = data[cls.wireKey] ?? data[_camelCase(cls.wireKey)];
|
|
292
|
+
if (!Array.isArray(arr)) continue;
|
|
293
|
+
for (const r of arr) {
|
|
294
|
+
const coerced = _coerceHyperedge(r, cls);
|
|
292
295
|
if (coerced) out.push(coerced);
|
|
293
296
|
}
|
|
294
297
|
}
|
|
295
298
|
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
const
|
|
300
|
-
if (
|
|
299
|
+
for (const cls of HyperedgeRegistry.allClasses()) {
|
|
300
|
+
for (const v1Key of cls.v1WireKeys ?? []) {
|
|
301
|
+
const camelV1 = _camelCase(v1Key);
|
|
302
|
+
const arr = data[v1Key] ?? data[camelV1];
|
|
303
|
+
if (!Array.isArray(arr)) continue;
|
|
304
|
+
for (const h of arr) {
|
|
305
|
+
const migrated = _migrateV1Hash(cls, v1Key, h);
|
|
306
|
+
if (migrated) out.push(new cls(migrated));
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return _dedupeOrThrow(out);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Audit C4: silent drop on mixed input is a footgun. Throw instead.
|
|
315
|
+
// Allows `relations` alone, or any combination of typed keys alone —
|
|
316
|
+
// but not both at once.
|
|
317
|
+
function _assertNoAmbiguousInput(data) {
|
|
318
|
+
if (!Array.isArray(data.relations)) return;
|
|
319
|
+
const typedKeys = [];
|
|
320
|
+
for (const cls of HyperedgeRegistry.allClasses()) {
|
|
321
|
+
if (Array.isArray(data[cls.wireKey])) typedKeys.push(cls.wireKey);
|
|
322
|
+
const camelWire = _camelCase(cls.wireKey);
|
|
323
|
+
if (camelWire !== cls.wireKey && Array.isArray(data[camelWire])) {
|
|
324
|
+
typedKeys.push(camelWire);
|
|
325
|
+
}
|
|
326
|
+
for (const v1 of cls.v1WireKeys ?? []) {
|
|
327
|
+
if (Array.isArray(data[v1])) typedKeys.push(v1);
|
|
328
|
+
const camelV1 = _camelCase(v1);
|
|
329
|
+
if (camelV1 !== v1 && Array.isArray(data[camelV1])) typedKeys.push(camelV1);
|
|
301
330
|
}
|
|
302
331
|
}
|
|
332
|
+
if (typedKeys.length > 0) {
|
|
333
|
+
throw new Error(
|
|
334
|
+
`Concept input is ambiguous: pass either 'relations' (unified) ` +
|
|
335
|
+
`or typed wire keys (${typedKeys.join(', ')}), not both. ` +
|
|
336
|
+
` Mixing them silently drops the typed keys, which has caused ` +
|
|
337
|
+
` data loss in production (see audit C4).`,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
303
341
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
342
|
+
// Audit C6: do NOT silently dedupe. Duplicate relations indicate a bug
|
|
343
|
+
// in the caller; surface it. The identity function delegates to each
|
|
344
|
+
// class's static identityOf (polymorphic — handles mixed types).
|
|
345
|
+
function _dedupeOrThrow(relations) {
|
|
346
|
+
const seen = new Map();
|
|
347
|
+
for (const r of relations) {
|
|
348
|
+
const id = _hyperedgeIdentity(r);
|
|
349
|
+
if (seen.has(id)) {
|
|
350
|
+
throw new Error(
|
|
351
|
+
`Concept input has duplicate hyperedge (identity=${id}). ` +
|
|
352
|
+
`Passing the same relation twice is a bug — the parser and ` +
|
|
353
|
+
`serializer would silently emit it twice on disk. ` +
|
|
354
|
+
`Audit C6 requires we surface this loudly instead.`,
|
|
355
|
+
);
|
|
310
356
|
}
|
|
357
|
+
seen.set(id, r);
|
|
311
358
|
}
|
|
359
|
+
return relations;
|
|
360
|
+
}
|
|
312
361
|
|
|
313
|
-
|
|
362
|
+
function _hyperedgeIdentity(r) {
|
|
363
|
+
if (r == null) return '';
|
|
364
|
+
const Cls = r.constructor;
|
|
365
|
+
if (typeof Cls?.identityOf === 'function') {
|
|
366
|
+
return `${Cls.name}::${Cls.identityOf(r)}`;
|
|
367
|
+
}
|
|
368
|
+
return String(r);
|
|
314
369
|
}
|
|
315
370
|
|
|
316
|
-
function
|
|
371
|
+
function _coerceHyperedge(value, fallbackClass) {
|
|
317
372
|
if (value == null) return null;
|
|
318
|
-
if (value instanceof
|
|
373
|
+
if (value instanceof AbstractHyperedge) return value;
|
|
319
374
|
|
|
320
375
|
const hash = typeof value.toJSON === 'function' ? value.toJSON() : value;
|
|
321
|
-
const
|
|
322
|
-
if (!
|
|
323
|
-
return new
|
|
376
|
+
const cls = _classForHash(hash) ?? fallbackClass;
|
|
377
|
+
if (!cls) return null;
|
|
378
|
+
return new cls(hash);
|
|
324
379
|
}
|
|
325
380
|
|
|
326
381
|
function _classForHash(hash) {
|
|
327
382
|
if (!hash || typeof hash !== 'object') return null;
|
|
328
383
|
const type = hash.type;
|
|
329
384
|
if (typeof type !== 'string') return null;
|
|
330
|
-
return
|
|
385
|
+
return HyperedgeRegistry.forTypeTag(type);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// v1 → v2 migration for hashes from legacy wire keys. Currently only
|
|
389
|
+
// PartitiveHyperedge has v1 wire keys (partitive_hyperedges). Future
|
|
390
|
+
// types extend this dispatch — but it lives here in the migration
|
|
391
|
+
// helper, not in the model classes, since v1 is a legacy concern.
|
|
392
|
+
function _migrateV1Hash(cls, v1Key, hash) {
|
|
393
|
+
if (cls === PartitiveHyperedge && v1Key === 'partitive_hyperedges') {
|
|
394
|
+
const normalized = hash;
|
|
395
|
+
return migrateHyperedgeToRelation(normalized);
|
|
396
|
+
}
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// snake_case → camelCase
|
|
401
|
+
function _camelCase(s) {
|
|
402
|
+
if (typeof s !== 'string' || !s.includes('_')) return s;
|
|
403
|
+
return s.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
331
404
|
}
|
|
332
405
|
|
|
333
406
|
const CONCEPT_WIRE_NAMES = Object.freeze({
|
|
@@ -1,18 +1,28 @@
|
|
|
1
|
-
//
|
|
1
|
+
// GenericHyperedge — an ISO 704 / ISO 1087-1 / ISO 12620 generic
|
|
2
2
|
// relation connecting a comprehensive concept (the genus) to two
|
|
3
3
|
// or more specific concepts (the species) which together constitute
|
|
4
4
|
// a decomposition by some criterion of subdivision.
|
|
5
5
|
//
|
|
6
|
-
// Mirror of
|
|
6
|
+
// Mirror of PartitiveHyperedge. Multiple GenericRelations on the same
|
|
7
7
|
// comprehensive distinguished by `criterion` is the OIML pattern
|
|
8
8
|
// (5.1 measurement standard has 6 criterion groups).
|
|
9
9
|
//
|
|
10
10
|
// See docs/design/generic-relation.md (concept-model repo).
|
|
11
11
|
|
|
12
|
-
import {
|
|
12
|
+
import { AbstractHyperedge } from './abstract-hyperedge.js';
|
|
13
13
|
import { GenericMember } from './generic-member.js';
|
|
14
|
+
import { HyperedgeRegistry } from './hyperedge-registry.js';
|
|
15
|
+
|
|
16
|
+
export class GenericHyperedge extends AbstractHyperedge {
|
|
17
|
+
// Per-class metadata block — the OCP contract. See PartitiveHyperedge
|
|
18
|
+
// for field documentation.
|
|
19
|
+
static wireKey = 'generic_relations';
|
|
20
|
+
static typeTag = 'generic_relation';
|
|
21
|
+
static rdfType = 'gloss:GenericRelation';
|
|
22
|
+
static memberClass = GenericMember;
|
|
23
|
+
static v1WireKeys = [];
|
|
24
|
+
static kindLabel = 'GEN';
|
|
14
25
|
|
|
15
|
-
export class GenericRelation extends AbstractNaryRelation {
|
|
16
26
|
constructor(data = {}) {
|
|
17
27
|
super(data);
|
|
18
28
|
this.members = data?.members == null
|
|
@@ -36,8 +46,10 @@ export class GenericRelation extends AbstractNaryRelation {
|
|
|
36
46
|
}
|
|
37
47
|
|
|
38
48
|
static fromJSON(data) {
|
|
39
|
-
return new
|
|
49
|
+
return new GenericHyperedge(data);
|
|
40
50
|
}
|
|
41
51
|
}
|
|
42
52
|
|
|
43
|
-
export default
|
|
53
|
+
export default GenericHyperedge;
|
|
54
|
+
|
|
55
|
+
HyperedgeRegistry.register(GenericHyperedge);
|
|
@@ -1,15 +1,82 @@
|
|
|
1
|
-
// GenericMember — one member of a
|
|
2
|
-
// ISO 704:2022 MECE shape from ConceptSystemMember. Declared as a
|
|
3
|
-
// distinct leaf class for type safety and to leave room for
|
|
4
|
-
// generic-specific extensions.
|
|
1
|
+
// GenericMember — one member of a GenericHyperedge.
|
|
5
2
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
3
|
+
// Per ISO 704:2022 §5.5.4.2.1 (Generic relations and general concepts):
|
|
4
|
+
//
|
|
5
|
+
// "In a generic relation, the intension of the subordinate concept
|
|
6
|
+
// includes the intension of the superordinate concept plus at least
|
|
7
|
+
// one additional delimiting characteristic."
|
|
8
|
+
//
|
|
9
|
+
// In hyperedge terms: when a GenericHyperedge has comprehensive=computer mouse
|
|
10
|
+
// and criterion=means of movement detection, each member (mechanical mouse,
|
|
11
|
+
// optomechanical mouse, optical mouse) carries a delimitingCharacteristic —
|
|
12
|
+
// the value of the criterion for that specific concept:
|
|
13
|
+
//
|
|
14
|
+
// mechanical mouse → 'detecting movement by means of rollers'
|
|
15
|
+
// optomechanical mouse → 'detecting movement by means of rollers and light sensors'
|
|
16
|
+
// optical mouse → 'detecting movement by means of light sensors'
|
|
17
|
+
//
|
|
18
|
+
// Multidimensionality (§5.6.3): the same comprehensive can be the
|
|
19
|
+
// '1' side of multiple generic hyperedges simultaneously, each by a
|
|
20
|
+
// different criterion. computer mouse is also comprehensive of
|
|
21
|
+
// {wired mouse, wireless mouse} under criterion=computer connection.
|
|
22
|
+
// That second hyperedge's members carry their own delimitingCharacteristic
|
|
23
|
+
// values ('using a corded electrical connection', 'using a cordless
|
|
24
|
+
// light or sound connection').
|
|
25
|
+
//
|
|
26
|
+
// delimitingCharacteristic is REQUIRED on GenericMember. Without it,
|
|
27
|
+
// the member is just a coordinate concept with no semantic content
|
|
28
|
+
// tying it to the criterion — ISO 704 says the relation is meaningless
|
|
29
|
+
// without the delimiting characteristic.
|
|
30
|
+
//
|
|
31
|
+
// LocalizedString shape (mirrors criterion on the parent hyperedge):
|
|
32
|
+
// { eng: 'detecting movement by means of light sensors' }
|
|
33
|
+
// { eng: '...', fra: '...' } — multiple languages supported
|
|
34
|
+
|
|
35
|
+
import { HyperedgeMember } from './hyperedge-member.js';
|
|
8
36
|
|
|
9
|
-
|
|
37
|
+
export class GenericMember extends HyperedgeMember {
|
|
38
|
+
constructor(data = {}) {
|
|
39
|
+
super(data);
|
|
40
|
+
this.delimitingCharacteristic = _resolveDelimitingCharacteristic(data.delimitingCharacteristic);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
toJSON() {
|
|
44
|
+
const base = super.toJSON();
|
|
45
|
+
base.delimitingCharacteristic = { ...this.delimitingCharacteristic };
|
|
46
|
+
return base;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
10
49
|
|
|
11
|
-
|
|
12
|
-
|
|
50
|
+
function _resolveDelimitingCharacteristic(value) {
|
|
51
|
+
if (value == null) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
'GenericMember.delimitingCharacteristic is required per ISO 704:2022 §5.5.4.2.1 ' +
|
|
54
|
+
'(the intension of a subordinate concept must include at least one additional ' +
|
|
55
|
+
'delimiting characteristic beyond the superordinate).',
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
if (typeof value === 'string') {
|
|
59
|
+
if (value.length === 0) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
'GenericMember.delimitingCharacteristic cannot be empty (ISO 704:2022 §5.5.4.2.1)',
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
return { default: value };
|
|
65
|
+
}
|
|
66
|
+
if (typeof value === 'object') {
|
|
67
|
+
const entries = Object.entries(value).filter(([, v]) => typeof v === 'string' && v.length > 0);
|
|
68
|
+
if (entries.length === 0) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
'GenericMember.delimitingCharacteristic must have at least one non-empty string ' +
|
|
71
|
+
'value (ISO 704:2022 §5.5.4.2.1)',
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
return Object.fromEntries(entries);
|
|
75
|
+
}
|
|
76
|
+
throw new Error(
|
|
77
|
+
`GenericMember.delimitingCharacteristic must be a string or a language-keyed object; ` +
|
|
78
|
+
`got ${typeof value}`,
|
|
79
|
+
);
|
|
13
80
|
}
|
|
14
81
|
|
|
15
82
|
export default GenericMember;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// HyperedgeIndex — directional index over a hyperedge set.
|
|
2
|
+
//
|
|
3
|
+
// A hyperedge is 1→N (one comprehensive, many members). The forward
|
|
4
|
+
// query ("which hyperedges have X as comprehensive?") and the reverse
|
|
5
|
+
// query ("which hyperedges have Y as a member?") are both useful. The
|
|
6
|
+
// reverse query is the load-bearing one: OIML V 2-200:2010 concept
|
|
7
|
+
// 5.13 is a member of one of 5.1's hyperedges AND the comprehensive
|
|
8
|
+
// of its own 2 hyperedges — without an index, every "find hyperedges
|
|
9
|
+
// mentioning X" turns into an O(N) walk over every hyperedge.
|
|
10
|
+
//
|
|
11
|
+
// The index is built on demand from a flat hyperedge list (typically
|
|
12
|
+
// Concept.relations or the union of all concepts' relations in a
|
|
13
|
+
// collection). It does not store state on Concept — Concept stays a
|
|
14
|
+
// simple value object.
|
|
15
|
+
//
|
|
16
|
+
// Identity key matches HyperedgeRegistry / ConceptRef conventions:
|
|
17
|
+
// `${source}:${id}` (empty source or id is rejected)
|
|
18
|
+
//
|
|
19
|
+
// See TODO Phase 9.
|
|
20
|
+
|
|
21
|
+
export class HyperedgeIndex {
|
|
22
|
+
constructor(hyperedges = []) {
|
|
23
|
+
this._byComprehensive = new Map();
|
|
24
|
+
this._byMember = new Map();
|
|
25
|
+
|
|
26
|
+
for (const h of hyperedges) {
|
|
27
|
+
const cKey = _qualifiedId(h?.comprehensive);
|
|
28
|
+
if (cKey) _addToMapList(this._byComprehensive, cKey, h);
|
|
29
|
+
const members = Array.isArray(h?.members) ? h.members : [];
|
|
30
|
+
for (const m of members) {
|
|
31
|
+
const mKey = _qualifiedId(m?.ref ?? m);
|
|
32
|
+
if (mKey) _addToMapList(this._byMember, mKey, h);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Hyperedges where the given concept is the comprehensive (the "1" side).
|
|
38
|
+
forComprehensive(qualifiedId) {
|
|
39
|
+
return this._byComprehensive.get(qualifiedId) ?? [];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Hyperedges where the given concept appears as a member (the "many" side).
|
|
43
|
+
forMember(qualifiedId) {
|
|
44
|
+
return this._byMember.get(qualifiedId) ?? [];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// All concepts that appear as a comprehensive in any indexed hyperedge.
|
|
48
|
+
comprehensives() {
|
|
49
|
+
return [...this._byComprehensive.keys()];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// All concepts that appear as a member in any indexed hyperedge.
|
|
53
|
+
members() {
|
|
54
|
+
return [...this._byMember.keys()];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
get size() {
|
|
58
|
+
return this._byComprehensive.size;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Build a HyperedgeIndex across multiple Concepts' relations arrays
|
|
63
|
+
// (a dataset-wide view). Each Concept contributes its own .relations.
|
|
64
|
+
// Helper for the most common pattern: index once at collection load,
|
|
65
|
+
// query many times.
|
|
66
|
+
export function buildDatasetIndex(concepts = []) {
|
|
67
|
+
const all = [];
|
|
68
|
+
for (const concept of concepts) {
|
|
69
|
+
for (const h of concept.relations ?? []) {
|
|
70
|
+
all.push(h);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return new HyperedgeIndex(all);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function _qualifiedId(ref) {
|
|
77
|
+
if (!ref) return null;
|
|
78
|
+
const source = ref.source ?? '';
|
|
79
|
+
const id = ref.id ?? '';
|
|
80
|
+
if (!source && !id) return null;
|
|
81
|
+
return `${source}:${id}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function _addToMapList(map, key, value) {
|
|
85
|
+
if (!map.has(key)) map.set(key, []);
|
|
86
|
+
map.get(key).push(value);
|
|
87
|
+
}
|
|
@@ -1,13 +1,21 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// HyperedgeMember — abstract base shape for the members of any directed
|
|
2
|
+
// 1→N hyperedge (PartitiveMember, GenericMember, future AssociativeMember,
|
|
3
|
+
// SequentialMember).
|
|
4
4
|
//
|
|
5
|
-
// Carries the shared
|
|
6
|
-
//
|
|
5
|
+
// Carries ONLY the dimensions shared by every hyperedge member type:
|
|
6
|
+
// ref — ConceptRef [1] (the member concept)
|
|
7
|
+
// presence — 'required' | 'optional' (default: required)
|
|
8
|
+
// count — 'exactly_one' | 'at_least_one' | 'multiple' (default: exactly_one)
|
|
7
9
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
10
|
+
// These three form the ISO 704:2022 MECE pair (presence × count). The
|
|
11
|
+
// pair is the shared invariant across all hyperedge types — that's
|
|
12
|
+
// why it lives on the base.
|
|
13
|
+
//
|
|
14
|
+
// Type-specific extensions live on the leaves:
|
|
15
|
+
// PartitiveMember — is_delimiting (rake-diagram semantics, ISO 704 §5.5.4.2.2)
|
|
16
|
+
// GenericMember — delimitingCharacteristic (intension difference, ISO 704 §5.5.4.2.1)
|
|
17
|
+
//
|
|
18
|
+
// See docs/design/abstract-hyperedge.md (concept-model repo).
|
|
11
19
|
|
|
12
20
|
import { GlossaristModel } from './base.js';
|
|
13
21
|
import { ConceptRef } from './concept-ref.js';
|
|
@@ -18,43 +26,33 @@ import {
|
|
|
18
26
|
isValidPresence,
|
|
19
27
|
} from './partitive-presence.js';
|
|
20
28
|
import {
|
|
21
|
-
PARTITIVE_COUNT,
|
|
22
29
|
PARTITIVE_COUNT_VALUES,
|
|
23
30
|
DEFAULT_COUNT,
|
|
24
31
|
isValidCount,
|
|
25
32
|
} from './partitive-count.js';
|
|
33
|
+
import { multiplicityFromPair } from './multiplicity.js';
|
|
26
34
|
|
|
27
|
-
|
|
28
|
-
// invalid (presence, count) combination rejected at construction.
|
|
29
|
-
const INVALID_PRESENCE_COUNT_COMBO = {
|
|
30
|
-
presence: PARTITIVE_PRESENCE.OPTIONAL,
|
|
31
|
-
count: PARTITIVE_COUNT.AT_LEAST_ONE,
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
export class ConceptSystemMember extends GlossaristModel {
|
|
35
|
+
export class HyperedgeMember extends GlossaristModel {
|
|
35
36
|
constructor(data = {}) {
|
|
36
37
|
super();
|
|
37
38
|
this.ref = _ensureRef(data.ref);
|
|
38
39
|
this.presence = _resolvePresence(data.presence);
|
|
39
40
|
this.count = _resolveCount(data.count);
|
|
40
|
-
this.is_delimiting = _resolveDelimiting(data.is_delimiting);
|
|
41
41
|
|
|
42
42
|
_assertValidPair(this.presence, this.count);
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
get isRequired() { return this.presence === PARTITIVE_PRESENCE.REQUIRED; }
|
|
46
46
|
get isOptional() { return this.presence === PARTITIVE_PRESENCE.OPTIONAL; }
|
|
47
|
-
get isDelimiting() { return this.is_delimiting === true; }
|
|
48
47
|
|
|
49
48
|
identity() {
|
|
50
|
-
return
|
|
49
|
+
return HyperedgeMember.identityOf(this);
|
|
51
50
|
}
|
|
52
51
|
|
|
53
52
|
toJSON() {
|
|
54
53
|
const obj = { ref: this.ref.toJSON() };
|
|
55
54
|
if (this.presence !== DEFAULT_PRESENCE) obj.presence = this.presence;
|
|
56
55
|
if (this.count !== DEFAULT_COUNT) obj.count = this.count;
|
|
57
|
-
if (this.is_delimiting) obj.is_delimiting = true;
|
|
58
56
|
return obj;
|
|
59
57
|
}
|
|
60
58
|
|
|
@@ -65,7 +63,7 @@ export class ConceptSystemMember extends GlossaristModel {
|
|
|
65
63
|
}
|
|
66
64
|
|
|
67
65
|
static fromJSON(data) {
|
|
68
|
-
return new
|
|
66
|
+
return new HyperedgeMember(data);
|
|
69
67
|
}
|
|
70
68
|
}
|
|
71
69
|
|
|
@@ -73,7 +71,7 @@ function _ensureRef(value) {
|
|
|
73
71
|
const ref = value instanceof ConceptRef ? value : new ConceptRef(value ?? {});
|
|
74
72
|
if (!ref.source && !ref.id && !ref.text) {
|
|
75
73
|
throw new Error(
|
|
76
|
-
'
|
|
74
|
+
'HyperedgeMember.ref must be a non-empty ConceptRef ' +
|
|
77
75
|
'(source, id, or text required)',
|
|
78
76
|
);
|
|
79
77
|
}
|
|
@@ -102,25 +100,10 @@ function _resolveCount(value) {
|
|
|
102
100
|
return c;
|
|
103
101
|
}
|
|
104
102
|
|
|
105
|
-
function _resolveDelimiting(value) {
|
|
106
|
-
if (value == null) return false;
|
|
107
|
-
if (typeof value !== 'boolean') {
|
|
108
|
-
throw new Error(
|
|
109
|
-
`ConceptSystemMember.is_delimiting must be boolean; got ${typeof value}`,
|
|
110
|
-
);
|
|
111
|
-
}
|
|
112
|
-
return value;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
103
|
function _assertValidPair(presence, count) {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
'ConceptSystemMember presence=optional + count=at_least_one is invalid — ' +
|
|
122
|
-
'it collapses to optional + multiple (zero or more). ' +
|
|
123
|
-
'Use presence: optional, count: multiple instead.',
|
|
124
|
-
);
|
|
125
|
-
}
|
|
104
|
+
// Delegate to the multiplicity SSOT. The canonical error string
|
|
105
|
+
// lives in one place: multiplicity.js. Calling multiplicityFromPair
|
|
106
|
+
// throws on the invalid combination; we don't need to duplicate the
|
|
107
|
+
// error message here.
|
|
108
|
+
multiplicityFromPair(presence, count);
|
|
126
109
|
}
|