glossarist 0.4.31 → 0.4.33
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 +95 -27
- package/src/concept-reader.js +59 -3
- package/src/concept-serializer.js +21 -10
- package/src/diff/concept-diff.js +70 -18
- package/src/diff/diff-patch.js +37 -13
- package/src/diff/diff-renderer.js +16 -4
- package/src/index.js +2 -1
- package/src/migration/partitive-relation-migrator.js +3 -3
- package/src/models/{abstract-nary-relation.js → abstract-hyperedge.js} +4 -4
- package/src/models/completeness.js +1 -1
- package/src/models/concept.js +172 -30
- package/src/models/{generic-relation.js → generic-hyperedge.js} +18 -6
- package/src/models/generic-member.js +5 -5
- package/src/models/hyperedge-index.js +87 -0
- package/src/models/{concept-system-member.js → hyperedge-member.js} +12 -24
- package/src/models/hyperedge-registry.js +115 -0
- package/src/models/index.d.ts +16 -16
- package/src/models/index.js +13 -10
- package/src/models/multiplicity.js +11 -6
- package/src/models/partitive-hyperedge.js +69 -153
- package/src/models/partitive-member.js +6 -6
- package/src/models/relation-loader.js +27 -23
- package/src/rdf/gloss-concept.js +36 -20
- package/src/rdf/gloss-generic-hyperedge.js +92 -0
- package/src/rdf/gloss-partitive-relation.js +3 -3
- 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/models/partitive-relation.js +0 -76
- 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/package.json
CHANGED
package/src/concept-parser.js
CHANGED
|
@@ -1,19 +1,41 @@
|
|
|
1
1
|
import * as yaml from 'js-yaml';
|
|
2
2
|
import { Concept } from './models/concept.js';
|
|
3
3
|
import { RelatedConcept } from './models/related-concept.js';
|
|
4
|
+
import { HyperedgeRegistry } from './models/hyperedge-registry.js';
|
|
4
5
|
import { migrateHyperedgeToRelation } from './migration/partitive-relation-migrator.js';
|
|
5
6
|
import { InvalidInputError, YamlParseError } from './errors.js';
|
|
6
7
|
|
|
7
8
|
// Structural keys are reserved at the concept level and excluded
|
|
8
|
-
// from language localization discovery.
|
|
9
|
-
//
|
|
10
|
-
//
|
|
9
|
+
// from language localization discovery. The set is derived from
|
|
10
|
+
// HyperedgeRegistry — every registered hyperedge class contributes
|
|
11
|
+
// its wireKey and any v1WireKeys to the reserved set. Adding a new
|
|
12
|
+
// hyperedge type means the parser reserves its wire key automatically;
|
|
13
|
+
// no edits here.
|
|
11
14
|
const STRUCTURAL_KEYS = new Set([
|
|
12
15
|
'termid', 'term', 'figures', 'tables', 'formulas',
|
|
13
|
-
'
|
|
14
|
-
'
|
|
16
|
+
'related', 'relatedConcepts',
|
|
17
|
+
'relations',
|
|
18
|
+
..._registryStructuralKeys(),
|
|
15
19
|
]);
|
|
16
20
|
|
|
21
|
+
function _registryStructuralKeys() {
|
|
22
|
+
const out = [];
|
|
23
|
+
for (const cls of HyperedgeRegistry.allClasses()) {
|
|
24
|
+
out.push(cls.wireKey);
|
|
25
|
+
out.push(_camelCase(cls.wireKey));
|
|
26
|
+
for (const k of cls.v1WireKeys ?? []) {
|
|
27
|
+
out.push(k);
|
|
28
|
+
out.push(_camelCase(k));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function _camelCase(s) {
|
|
35
|
+
if (typeof s !== 'string' || !s.includes('_')) return s;
|
|
36
|
+
return s.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
37
|
+
}
|
|
38
|
+
|
|
17
39
|
export class ConceptParser {
|
|
18
40
|
parse(raw, context) {
|
|
19
41
|
const label = context ?? 'concept';
|
|
@@ -67,7 +89,7 @@ export class ConceptParser {
|
|
|
67
89
|
figures: doc.figures,
|
|
68
90
|
tables: doc.tables,
|
|
69
91
|
formulas: doc.formulas,
|
|
70
|
-
|
|
92
|
+
relations: _resolveHyperedgeData(doc),
|
|
71
93
|
raw: doc,
|
|
72
94
|
});
|
|
73
95
|
}
|
|
@@ -84,14 +106,23 @@ export class ConceptParser {
|
|
|
84
106
|
localizations[lang] = lcData;
|
|
85
107
|
}
|
|
86
108
|
|
|
87
|
-
|
|
109
|
+
// The hyperedge wire keys are reserved at concept level (top-level
|
|
110
|
+
// of the managed concept YAML), not under data:. Derive the list
|
|
111
|
+
// from the registry so adding a type doesn't require an edit here.
|
|
112
|
+
const conceptLevelOnlyKeys = [
|
|
113
|
+
'related',
|
|
114
|
+
'relations',
|
|
115
|
+
...HyperedgeRegistry.allWireAndLegacyKeys(),
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
assertConceptLevelOnly(mc, conceptLevelOnlyKeys);
|
|
88
119
|
|
|
89
120
|
return new Concept({
|
|
90
121
|
id: String(mc.data.identifier),
|
|
91
122
|
term: null,
|
|
92
123
|
localizations,
|
|
93
124
|
related: _normalizeRelated(mc.related),
|
|
94
|
-
|
|
125
|
+
relations: _resolveHyperedgeData(mc),
|
|
95
126
|
domains: mc.data.domains,
|
|
96
127
|
groups: mc.data.groups,
|
|
97
128
|
dates: mc.dates ?? mc.data?.dates,
|
|
@@ -109,7 +140,7 @@ export class ConceptParser {
|
|
|
109
140
|
function assertConceptLevelOnly(mc, keys) {
|
|
110
141
|
const conceptId = mc?.data?.identifier ?? '<unknown>';
|
|
111
142
|
for (const key of keys) {
|
|
112
|
-
const camelKey = key
|
|
143
|
+
const camelKey = _camelCase(key);
|
|
113
144
|
if (mc?.data?.[key] != null && mc[key] == null && mc[camelKey] == null) {
|
|
114
145
|
throw new InvalidInputError(
|
|
115
146
|
`'${key}' must live at concept level (top-level of the managed ` +
|
|
@@ -121,26 +152,63 @@ function assertConceptLevelOnly(mc, keys) {
|
|
|
121
152
|
}
|
|
122
153
|
}
|
|
123
154
|
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
155
|
+
// Resolve hyperedge data from any input shape into a single unified
|
|
156
|
+
// array. Iterates HyperedgeRegistry — for each registered class:
|
|
157
|
+
// - read its wireKey from the container (e.g. partitive_relations)
|
|
158
|
+
// - read any v1WireKeys (e.g. partitive_hyperedges) and migrate
|
|
159
|
+
// - tag each entry with the class's typeTag
|
|
160
|
+
//
|
|
161
|
+
// Returns null when no relation data is present, so Concept's
|
|
162
|
+
// `_resolveHyperedges` falls back to the empty-array default.
|
|
129
163
|
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
function
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
164
|
+
// Adding a new hyperedge type means: declare it on a class, register.
|
|
165
|
+
// The parser picks it up automatically — no edits here.
|
|
166
|
+
function _resolveHyperedgeData(container) {
|
|
167
|
+
if (!container) return null;
|
|
168
|
+
|
|
169
|
+
if (Array.isArray(container.relations)) {
|
|
170
|
+
return container.relations;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const out = [];
|
|
174
|
+
|
|
175
|
+
for (const cls of HyperedgeRegistry.allClasses()) {
|
|
176
|
+
// v2 wire key (e.g. partitive_relations)
|
|
177
|
+
const arr = container[cls.wireKey] ?? container[_camelCase(cls.wireKey)];
|
|
178
|
+
if (Array.isArray(arr)) {
|
|
179
|
+
for (const r of arr) {
|
|
180
|
+
const tagged = _addTypeIfMissing(r, cls.typeTag);
|
|
181
|
+
if (tagged != null) out.push(tagged);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// v1 legacy wire keys (e.g. partitive_hyperedges) — migrated.
|
|
186
|
+
// The migration helper produces a v2 hash; we tag it with the
|
|
187
|
+
// typeTag so Concept's registry dispatch finds the right class.
|
|
188
|
+
for (const v1Key of cls.v1WireKeys ?? []) {
|
|
189
|
+
const v1Arr = container[v1Key] ?? container[_camelCase(v1Key)];
|
|
190
|
+
if (!Array.isArray(v1Arr)) continue;
|
|
191
|
+
for (const h of v1Arr) {
|
|
192
|
+
const hash = h?.toJSON && typeof h.toJSON === 'function' ? h.toJSON() : h;
|
|
193
|
+
const migrated = migrateHyperedgeToRelation(hash);
|
|
194
|
+
if (migrated) out.push({ ...migrated, type: cls.typeTag });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return out.length > 0 ? out : null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function _addTypeIfMissing(value, type) {
|
|
203
|
+
if (value == null) return null;
|
|
204
|
+
if (typeof value.toJSON === 'function') {
|
|
205
|
+
const hash = value.toJSON();
|
|
206
|
+
return hash.type ? hash : { ...hash, type };
|
|
207
|
+
}
|
|
208
|
+
if (typeof value === 'object') {
|
|
209
|
+
return value.type ? value : { ...value, type };
|
|
142
210
|
}
|
|
143
|
-
return
|
|
211
|
+
return value;
|
|
144
212
|
}
|
|
145
213
|
|
|
146
214
|
function _normalizeRelated(arr) {
|
package/src/concept-reader.js
CHANGED
|
@@ -2,6 +2,7 @@ import fs from 'fs';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import * as yaml from 'js-yaml';
|
|
4
4
|
import { conceptParser } from './concept-parser.js';
|
|
5
|
+
import { RelationLoader } from './models/relation-loader.js';
|
|
5
6
|
import { naturalSort } from './sort.js';
|
|
6
7
|
import { InvalidInputError } from './errors.js';
|
|
7
8
|
import { Register } from './models/register.js';
|
|
@@ -13,7 +14,15 @@ function assertDir(dir, fnName) {
|
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
|
-
* Read all v2 glossarist concept YAML files from a directory
|
|
17
|
+
* Read all v2 glossarist concept YAML files from a directory, then
|
|
18
|
+
* merge in any per-file hyperedge YAMLs from `<dir>/relations/`.
|
|
19
|
+
*
|
|
20
|
+
* Per-file hyperedge storage (concept-model PR #83): each file at
|
|
21
|
+
* `relations/<comprehensive-id>/<slug>.yaml` is loaded and merged
|
|
22
|
+
* into the corresponding Concept.relations array. If the directory
|
|
23
|
+
* does not exist, no per-file hyperedges are loaded (concepts still
|
|
24
|
+
* get any inline hyperedges from their own YAML).
|
|
25
|
+
*
|
|
17
26
|
* @param {string} dir - path to directory containing concept YAML files
|
|
18
27
|
* @returns {import('./models/concept.js').Concept[]}
|
|
19
28
|
* @throws {InvalidInputError} if dir is missing or empty
|
|
@@ -36,11 +45,16 @@ export function readConcepts(dir) {
|
|
|
36
45
|
concepts.push(concept);
|
|
37
46
|
}
|
|
38
47
|
}
|
|
48
|
+
|
|
49
|
+
_mergePerFileHyperedges(dir, concepts);
|
|
39
50
|
return concepts;
|
|
40
51
|
}
|
|
41
52
|
|
|
42
53
|
/**
|
|
43
|
-
* Read a single concept file by ID from a directory
|
|
54
|
+
* Read a single concept file by ID from a directory, then merge in
|
|
55
|
+
* any per-file hyperedge YAMLs for that concept from
|
|
56
|
+
* `<dir>/relations/<id>/`.
|
|
57
|
+
*
|
|
44
58
|
* @param {string} dir - path to directory containing concept YAML files
|
|
45
59
|
* @param {string} id - concept identifier (filename without .yaml)
|
|
46
60
|
* @returns {import('./models/concept.js').Concept | null}
|
|
@@ -58,7 +72,49 @@ export function readConcept(dir, id) {
|
|
|
58
72
|
const filePath = path.join(dir, `${id}.yaml`);
|
|
59
73
|
if (!fs.existsSync(filePath)) return null;
|
|
60
74
|
const raw = fs.readFileSync(filePath, 'utf8');
|
|
61
|
-
|
|
75
|
+
const concept = conceptParser.parse(raw, `${id}.yaml`);
|
|
76
|
+
_mergePerFileHyperedges(dir, [concept]);
|
|
77
|
+
return concept;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Load `relations/` directory and merge per-file hyperedges into the
|
|
81
|
+
// matching Concept.relations arrays. Concepts are matched by id.
|
|
82
|
+
// Inline hyperedges from the concept YAML are preserved alongside the
|
|
83
|
+
// per-file ones (audit: this is the contract — the two sources are
|
|
84
|
+
// additive, not exclusive).
|
|
85
|
+
//
|
|
86
|
+
// Throws if a per-file hyperedge fails to construct (bad YAML, missing
|
|
87
|
+
// type, invalid members) — surfaces corruption loudly per the
|
|
88
|
+
// RelationLoader contract.
|
|
89
|
+
function _mergePerFileHyperedges(dir, concepts) {
|
|
90
|
+
const relationsDir = path.join(dir, 'relations');
|
|
91
|
+
if (!fs.existsSync(relationsDir)) return;
|
|
92
|
+
|
|
93
|
+
const byComprehensive = RelationLoader.loadAll(relationsDir);
|
|
94
|
+
if (byComprehensive.size === 0) return;
|
|
95
|
+
|
|
96
|
+
for (const concept of concepts) {
|
|
97
|
+
const rels = byComprehensive.get(concept.id);
|
|
98
|
+
if (!rels || rels.length === 0) continue;
|
|
99
|
+
// Merge per-file hyperedges with any inline ones from the concept
|
|
100
|
+
// YAML. Per-file wins on identity collision — they're the newer,
|
|
101
|
+
// more authoritative source.
|
|
102
|
+
const inline = concept.relations;
|
|
103
|
+
const merged = [...inline];
|
|
104
|
+
for (const r of rels) {
|
|
105
|
+
const exists = inline.some(x => _sameHyperedge(x, r));
|
|
106
|
+
if (!exists) merged.push(r);
|
|
107
|
+
}
|
|
108
|
+
concept.relations = merged;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function _sameHyperedge(a, b) {
|
|
113
|
+
const aCls = a?.constructor;
|
|
114
|
+
const bCls = b?.constructor;
|
|
115
|
+
if (aCls !== bCls) return false;
|
|
116
|
+
if (typeof aCls.identityOf !== 'function') return false;
|
|
117
|
+
return aCls.identityOf(a) === bCls.identityOf(b);
|
|
62
118
|
}
|
|
63
119
|
|
|
64
120
|
/**
|
|
@@ -1,18 +1,30 @@
|
|
|
1
1
|
import * as yaml from 'js-yaml';
|
|
2
|
+
import { PartitiveHyperedge } from './models/partitive-hyperedge.js';
|
|
3
|
+
import { GenericHyperedge } from './models/generic-hyperedge.js';
|
|
2
4
|
|
|
3
5
|
const DUMP_OPTS = { lineWidth: -1, noRefs: true, sortKeys: false, skipInvalid: true };
|
|
4
6
|
|
|
7
|
+
// Partition the unified relations array by concrete type and emit
|
|
8
|
+
// each type under its own wire key. Concept.relations holds mixed
|
|
9
|
+
// types; the wire format stays split for backward compat with
|
|
10
|
+
// existing consumers and the JSON Schema in concept-model.
|
|
11
|
+
function _emitTypedRelations(concept) {
|
|
12
|
+
const partitiveRels = concept.relations.filter(r => r instanceof PartitiveHyperedge);
|
|
13
|
+
const genericRels = concept.relations.filter(r => r instanceof GenericHyperedge);
|
|
14
|
+
return {
|
|
15
|
+
partitive: partitiveRels.map(r => r.toJSON()),
|
|
16
|
+
generic: genericRels.map(r => r.toJSON()),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
5
20
|
export class ConceptSerializer {
|
|
6
21
|
toCanonicalYaml(concept) {
|
|
7
22
|
const doc = { termid: concept.id };
|
|
8
23
|
if (concept.term) doc.term = concept.term;
|
|
9
24
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
if (concept.partitiveRelations && concept.partitiveRelations.length > 0) {
|
|
14
|
-
doc.partitive_relations = concept.partitiveRelations.map(r => r.toJSON());
|
|
15
|
-
}
|
|
25
|
+
const { partitive, generic } = _emitTypedRelations(concept);
|
|
26
|
+
if (partitive.length > 0) doc.partitive_relations = partitive;
|
|
27
|
+
if (generic.length > 0) doc.generic_relations = generic;
|
|
16
28
|
|
|
17
29
|
for (const lang of concept.languages) {
|
|
18
30
|
const lc = concept.localization(lang);
|
|
@@ -53,10 +65,9 @@ export class ConceptSerializer {
|
|
|
53
65
|
if (concept.relatedConcepts.length > 0) {
|
|
54
66
|
mainDoc.related = concept.relatedConcepts.map(rc => rc.toJSON());
|
|
55
67
|
}
|
|
56
|
-
|
|
57
|
-
if (
|
|
58
|
-
|
|
59
|
-
}
|
|
68
|
+
const { partitive, generic } = _emitTypedRelations(concept);
|
|
69
|
+
if (partitive.length > 0) mainDoc.partitive_relations = partitive;
|
|
70
|
+
if (generic.length > 0) mainDoc.generic_relations = generic;
|
|
60
71
|
if (concept.sources.length > 0) {
|
|
61
72
|
mainDoc.sources = concept.sources.map(s => s.toJSON());
|
|
62
73
|
}
|
package/src/diff/concept-diff.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { GlossaristModel } from '../models/base.js';
|
|
2
|
+
import { PartitiveHyperedge } from '../models/partitive-hyperedge.js';
|
|
3
|
+
import { GenericHyperedge } from '../models/generic-hyperedge.js';
|
|
2
4
|
import { Added, Removed, Changed } from './change.js';
|
|
3
5
|
import { ListDiff, diffList, diffSet } from './list-diff.js';
|
|
4
6
|
import { identityOf } from './identity.js';
|
|
@@ -99,11 +101,17 @@ export class ConceptLevelDiff extends GlossaristModel {
|
|
|
99
101
|
this._sources = wrapListDiff(data.sources);
|
|
100
102
|
this._dates = wrapListDiff(data.dates);
|
|
101
103
|
this._relatedConcepts = wrapListDiff(data.relatedConcepts ?? data.related_concepts);
|
|
102
|
-
//
|
|
103
|
-
// (
|
|
104
|
-
//
|
|
105
|
-
|
|
106
|
-
|
|
104
|
+
// Unified n-ary relations diff. Accepts:
|
|
105
|
+
// - data.relations (new unified shape)
|
|
106
|
+
// - data.partitiveRelations /
|
|
107
|
+
// data.partitive_relations /
|
|
108
|
+
// data.partitiveHyperedges /
|
|
109
|
+
// data.partitive_hyperedges (legacy partitive-only shape)
|
|
110
|
+
// Old diffs only had partitive, so the legacy fallback treats them
|
|
111
|
+
// as a unified list with one type present.
|
|
112
|
+
this._relations = wrapListDiff(
|
|
113
|
+
data.relations
|
|
114
|
+
?? data.partitiveRelations
|
|
107
115
|
?? data.partitive_relations
|
|
108
116
|
?? data.partitiveHyperedges
|
|
109
117
|
?? data.partitive_hyperedges,
|
|
@@ -119,8 +127,25 @@ export class ConceptLevelDiff extends GlossaristModel {
|
|
|
119
127
|
get sources() { return this._sources; }
|
|
120
128
|
get dates() { return this._dates; }
|
|
121
129
|
get relatedConcepts() { return this._relatedConcepts; }
|
|
122
|
-
|
|
123
|
-
|
|
130
|
+
/** Unified n-ary relations diff (PartitiveHyperedge + GenericHyperedge). */
|
|
131
|
+
get relations() { return this._relations; }
|
|
132
|
+
/**
|
|
133
|
+
* Filtered view of `relations` containing only PartitiveHyperedge
|
|
134
|
+
* entries. Backward compat for callers that read .partitiveRelations
|
|
135
|
+
* directly on a ConceptLevelDiff.
|
|
136
|
+
*/
|
|
137
|
+
get partitiveRelations() {
|
|
138
|
+
return filterListDiffByType(this._relations, PartitiveHyperedge);
|
|
139
|
+
}
|
|
140
|
+
/** @deprecated use .relations */ get partitiveHyperedges() { return this.partitiveRelations; }
|
|
141
|
+
/**
|
|
142
|
+
* Filtered view of `relations` containing only GenericHyperedge
|
|
143
|
+
* entries. Backward compat for callers that read .genericRelations
|
|
144
|
+
* directly on a ConceptLevelDiff.
|
|
145
|
+
*/
|
|
146
|
+
get genericRelations() {
|
|
147
|
+
return filterListDiffByType(this._relations, GenericHyperedge);
|
|
148
|
+
}
|
|
124
149
|
get groups() { return this._groups; }
|
|
125
150
|
get sections() { return this._sections; }
|
|
126
151
|
get tags() { return this._tags; }
|
|
@@ -130,7 +155,7 @@ export class ConceptLevelDiff extends GlossaristModel {
|
|
|
130
155
|
return this._sources.hasChanges
|
|
131
156
|
|| this._dates.hasChanges
|
|
132
157
|
|| this._relatedConcepts.hasChanges
|
|
133
|
-
|| this.
|
|
158
|
+
|| this._relations.hasChanges
|
|
134
159
|
|| this._groups.hasChanges
|
|
135
160
|
|| this._sections.hasChanges
|
|
136
161
|
|| this._tags.hasChanges
|
|
@@ -141,7 +166,7 @@ export class ConceptLevelDiff extends GlossaristModel {
|
|
|
141
166
|
yield* walkList('concept.sources', this._sources);
|
|
142
167
|
yield* walkList('concept.dates', this._dates);
|
|
143
168
|
yield* walkList('concept.relatedConcepts', this._relatedConcepts);
|
|
144
|
-
yield* walkList('concept.
|
|
169
|
+
yield* walkList('concept.relations', this._relations);
|
|
145
170
|
yield* walkList('concept.groups', this._groups);
|
|
146
171
|
yield* walkList('concept.sections', this._sections);
|
|
147
172
|
yield* walkList('concept.tags', this._tags);
|
|
@@ -153,7 +178,7 @@ export class ConceptLevelDiff extends GlossaristModel {
|
|
|
153
178
|
sources: this._sources.toJSON(),
|
|
154
179
|
dates: this._dates.toJSON(),
|
|
155
180
|
related_concepts: this._relatedConcepts.toJSON(),
|
|
156
|
-
|
|
181
|
+
relations: this._relations.toJSON(),
|
|
157
182
|
groups: this._groups.toJSON(),
|
|
158
183
|
sections: this._sections.toJSON(),
|
|
159
184
|
tags: this._tags.toJSON(),
|
|
@@ -428,7 +453,7 @@ function diffConceptLevel(oldConcept, newConcept) {
|
|
|
428
453
|
sources: fullListDiff(c.sources ?? [], Direction),
|
|
429
454
|
dates: fullListDiff(c.dates ?? [], Direction),
|
|
430
455
|
relatedConcepts: fullListDiff(c.relatedConcepts ?? [], Direction),
|
|
431
|
-
|
|
456
|
+
relations: fullListDiff(c.relations ?? [], Direction),
|
|
432
457
|
groups: fullListDiff(c.groups ?? [], Direction),
|
|
433
458
|
sections: fullListDiff(c.sections ?? [], Direction),
|
|
434
459
|
tags: fullListDiff(c.tags ?? [], Direction),
|
|
@@ -440,9 +465,9 @@ function diffConceptLevel(oldConcept, newConcept) {
|
|
|
440
465
|
sources: diffSources(oldConcept.sources ?? [], newConcept.sources ?? []),
|
|
441
466
|
dates: diffDates(oldConcept.dates ?? [], newConcept.dates ?? []),
|
|
442
467
|
relatedConcepts: diffRelatedConcepts(oldConcept.relatedConcepts ?? [], newConcept.relatedConcepts ?? []),
|
|
443
|
-
|
|
444
|
-
oldConcept.
|
|
445
|
-
newConcept.
|
|
468
|
+
relations: diffNaryRelations(
|
|
469
|
+
oldConcept.relations ?? [],
|
|
470
|
+
newConcept.relations ?? [],
|
|
446
471
|
),
|
|
447
472
|
groups: diffStringSet(oldConcept.groups ?? [], newConcept.groups ?? []),
|
|
448
473
|
sections: diffStringSet(oldConcept.sections ?? [], newConcept.sections ?? []),
|
|
@@ -540,13 +565,40 @@ function diffRelatedConcepts(oldRC, newRC) {
|
|
|
540
565
|
});
|
|
541
566
|
}
|
|
542
567
|
|
|
543
|
-
|
|
568
|
+
// Diff two unified n-ary relations arrays. Identity is polymorphic —
|
|
569
|
+
// dispatches via value.constructor.identityOf so PartitiveHyperedge
|
|
570
|
+
// and GenericHyperedge (and any future n-ary subclass) coexist in
|
|
571
|
+
// the same list. Cross-type entries with matching identity are still
|
|
572
|
+
// treated as different (because their constructor differs and they
|
|
573
|
+
// live under different wire keys).
|
|
574
|
+
function diffNaryRelations(oldR, newR) {
|
|
544
575
|
return diffSet(oldR, newR, {
|
|
545
|
-
identityKey:
|
|
576
|
+
identityKey: relationIdentityOf,
|
|
546
577
|
textKey: relationText,
|
|
547
578
|
});
|
|
548
579
|
}
|
|
549
580
|
|
|
581
|
+
function relationIdentityOf(value) {
|
|
582
|
+
if (value == null) return '';
|
|
583
|
+
const Cls = value.constructor;
|
|
584
|
+
if (typeof Cls?.identityOf === 'function') {
|
|
585
|
+
const typed = Cls.identityOf(value);
|
|
586
|
+
return `${Cls.name}::${typed}`;
|
|
587
|
+
}
|
|
588
|
+
return identityOf(value);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function filterListDiffByType(listDiff, Cls) {
|
|
592
|
+
if (!listDiff) return listDiff;
|
|
593
|
+
return new ListDiff({
|
|
594
|
+
added: listDiff.added.filter(e => e.value instanceof Cls),
|
|
595
|
+
removed: listDiff.removed.filter(e => e.value instanceof Cls),
|
|
596
|
+
changed: listDiff.changed.filter(e =>
|
|
597
|
+
(e.oldValue == null || e.oldValue instanceof Cls) &&
|
|
598
|
+
(e.newValue == null || e.newValue instanceof Cls)),
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
|
|
550
602
|
function relationText(r) {
|
|
551
603
|
if (!r) return '';
|
|
552
604
|
return canonicalJson(typeof r.toJSON === 'function' ? r.toJSON() : r);
|
|
@@ -598,8 +650,8 @@ function countConceptItems(oldConcept, newConcept, langs) {
|
|
|
598
650
|
count += maxOf(oldConcept?.dates?.length, newConcept?.dates?.length);
|
|
599
651
|
count += maxOf(oldConcept?.relatedConcepts?.length, newConcept?.relatedConcepts?.length);
|
|
600
652
|
count += maxOf(
|
|
601
|
-
oldConcept?.
|
|
602
|
-
newConcept?.
|
|
653
|
+
oldConcept?.relations?.length,
|
|
654
|
+
newConcept?.relations?.length,
|
|
603
655
|
);
|
|
604
656
|
count += maxOf(oldConcept?.groups?.length, newConcept?.groups?.length);
|
|
605
657
|
count += maxOf(oldConcept?.sections?.length, newConcept?.sections?.length);
|
package/src/diff/diff-patch.js
CHANGED
|
@@ -4,7 +4,7 @@ import { ConceptDate } from '../models/concept-date.js';
|
|
|
4
4
|
import { RelatedConcept } from '../models/related-concept.js';
|
|
5
5
|
import { Designation } from '../models/designation.js';
|
|
6
6
|
import { DetailedDefinition } from '../models/detailed-definition.js';
|
|
7
|
-
import {
|
|
7
|
+
import { HyperedgeRegistry } from '../models/hyperedge-registry.js';
|
|
8
8
|
import { Added, Removed, Changed } from './change.js';
|
|
9
9
|
import { ListDiff } from './list-diff.js';
|
|
10
10
|
import { TextDiff, TextHunk } from './text-diff.js';
|
|
@@ -67,9 +67,7 @@ export function reverseDiff(diff) {
|
|
|
67
67
|
sources: reverseListDiff(diff.concept.sources),
|
|
68
68
|
dates: reverseListDiff(diff.concept.dates),
|
|
69
69
|
relatedConcepts: reverseListDiff(diff.concept.relatedConcepts),
|
|
70
|
-
|
|
71
|
-
diff.concept.partitiveRelations ?? diff.concept.partitiveHyperedges,
|
|
72
|
-
),
|
|
70
|
+
relations: reverseListDiff(diff.concept.relations),
|
|
73
71
|
groups: reverseListDiff(diff.concept.groups),
|
|
74
72
|
sections: reverseListDiff(diff.concept.sections),
|
|
75
73
|
tags: reverseListDiff(diff.concept.tags),
|
|
@@ -108,15 +106,25 @@ function applyConceptLevelPatch(json, conceptDiff) {
|
|
|
108
106
|
json.sources = applyListPatch(json.sources ?? [], conceptDiff.sources, ConceptSource.identityOf);
|
|
109
107
|
json.dates = applyListPatch(json.dates ?? [], conceptDiff.dates, ConceptDate.identityOf);
|
|
110
108
|
json.related = applyListPatch(json.related ?? [], conceptDiff.relatedConcepts, RelatedConcept.identityOf);
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
109
|
+
|
|
110
|
+
// Unified hyperedge diff → typed wire keys. Iterate the registry;
|
|
111
|
+
// for each registered class, partition the unified diff by that
|
|
112
|
+
// class and apply to the class's wireKey. Adding a new hyperedge
|
|
113
|
+
// type means this loop picks it up automatically — no edits here.
|
|
114
|
+
for (const cls of HyperedgeRegistry.allClasses()) {
|
|
115
|
+
const partition = partitionListDiff(conceptDiff.relations, cls);
|
|
116
|
+
if (!partition.hasChanges) continue;
|
|
117
|
+
const wireKey = cls.wireKey;
|
|
118
|
+
// v1 wire keys (partitive_hyperedges) on the input are migrated
|
|
119
|
+
// to v2 wire key here: read from either, write to v2 only.
|
|
120
|
+
const v1ReadKeys = cls.v1WireKeys ?? [];
|
|
121
|
+
const existing = v1ReadKeys
|
|
122
|
+
.map(k => json[k])
|
|
123
|
+
.find(arr => Array.isArray(arr)) ?? json[wireKey] ?? [];
|
|
124
|
+
json[wireKey] = applyListPatch(existing, partition, cls.identityOf);
|
|
125
|
+
for (const k of v1ReadKeys) {
|
|
126
|
+
if (json[k] != null && json[wireKey] != null) delete json[k];
|
|
127
|
+
}
|
|
120
128
|
}
|
|
121
129
|
|
|
122
130
|
json.tags = applyListPatch(json.tags ?? [], conceptDiff.tags);
|
|
@@ -233,3 +241,19 @@ function toJsonValue(value) {
|
|
|
233
241
|
if (typeof value.toJSON === 'function') return value.toJSON();
|
|
234
242
|
return value;
|
|
235
243
|
}
|
|
244
|
+
|
|
245
|
+
// Project a unified relations ListDiff onto a single concrete class.
|
|
246
|
+
// Entries whose value (old or new) is an instance of `Cls` survive;
|
|
247
|
+
// everything else is filtered out. Cross-type changed entries (e.g.
|
|
248
|
+
// old was PartitiveHyperedge, new is GenericHyperedge) are not yet
|
|
249
|
+
// supported — those would need to surface as a remove + add pair.
|
|
250
|
+
function partitionListDiff(listDiff, Cls) {
|
|
251
|
+
if (!listDiff) return new ListDiff({ added: [], removed: [], changed: [] });
|
|
252
|
+
return new ListDiff({
|
|
253
|
+
added: listDiff.added.filter(e => e.value instanceof Cls),
|
|
254
|
+
removed: listDiff.removed.filter(e => e.value instanceof Cls),
|
|
255
|
+
changed: listDiff.changed.filter(e =>
|
|
256
|
+
(e.oldValue == null || e.oldValue instanceof Cls) &&
|
|
257
|
+
(e.newValue == null || e.newValue instanceof Cls)),
|
|
258
|
+
});
|
|
259
|
+
}
|
|
@@ -125,7 +125,10 @@ function renderConceptLevel(diff, colors) {
|
|
|
125
125
|
lines.push(...renderListDiff('Sources', diff.sources, colors, itemLabel));
|
|
126
126
|
lines.push(...renderListDiff('Dates', diff.dates, colors, itemLabel));
|
|
127
127
|
lines.push(...renderListDiff('Related', diff.relatedConcepts, colors, itemLabel));
|
|
128
|
-
|
|
128
|
+
// Unified n-ary relations section. Replaces the per-type sections
|
|
129
|
+
// (Partitive relations, Generic relations) — the underlying diff
|
|
130
|
+
// is a single ListDiff over the unified .relations array.
|
|
131
|
+
lines.push(...renderListDiff('N-ary relations', diff.relations, colors, relationLabel));
|
|
129
132
|
lines.push(...renderListDiff('Groups', diff.groups, colors, itemLabel));
|
|
130
133
|
lines.push(...renderListDiff('Sections', diff.sections, colors, itemLabel));
|
|
131
134
|
lines.push(...renderListDiff('Tags', diff.tags, colors, itemLabel));
|
|
@@ -203,8 +206,11 @@ function relationLabel(r) {
|
|
|
203
206
|
if (!r) return '?';
|
|
204
207
|
const c = r.comprehensive;
|
|
205
208
|
const head = c?.id ?? c?.source ?? c?.text ?? '?';
|
|
206
|
-
|
|
207
|
-
|
|
209
|
+
// PartitiveHyperedge exposes .partitives as the wire alias for
|
|
210
|
+
// .members; GenericHyperedge uses .members directly. Read both.
|
|
211
|
+
const members = Array.isArray(r.partitives) ? r.partitives : r.members;
|
|
212
|
+
const memberText = Array.isArray(members)
|
|
213
|
+
? members.map(m => {
|
|
208
214
|
const ref = m?.ref ?? m ?? {};
|
|
209
215
|
let tail = '';
|
|
210
216
|
const mult = resolveMultiplicity(m);
|
|
@@ -217,7 +223,13 @@ function relationLabel(r) {
|
|
|
217
223
|
const criterion = r.criterion
|
|
218
224
|
? ` / ${Object.values(r.criterion)[0] ?? ''}`
|
|
219
225
|
: '';
|
|
220
|
-
|
|
226
|
+
// Each hyperedge leaf declares a short static kindLabel (PART, GEN, …).
|
|
227
|
+
// Read it via the constructor so adding a new hyperedge type needs no
|
|
228
|
+
// renderer edit. The blank-string default keeps backward-compat with
|
|
229
|
+
// any AbstractHyperedge subclass that doesn't declare kindLabel.
|
|
230
|
+
const kindLabel = r?.constructor?.kindLabel ?? '';
|
|
231
|
+
const typeTag = kindLabel ? `${kindLabel} ` : '';
|
|
232
|
+
return `${typeTag}${head} → {${memberText}}${completeness}${criterion}`;
|
|
221
233
|
}
|
|
222
234
|
|
|
223
235
|
// Aggregate multiplicity + delimiting stats across a concept's
|
package/src/index.js
CHANGED
|
@@ -63,7 +63,8 @@ export {
|
|
|
63
63
|
fetchLocalizedString, localizedStringIsEmpty, localizedStringIsPresent,
|
|
64
64
|
GcrMetadata, GcrStatistics,
|
|
65
65
|
RELATIONSHIP_TYPES, DESIGNATION_RELATIONSHIP_TYPES, DATE_TYPES,
|
|
66
|
-
PartitiveHyperedge,
|
|
66
|
+
PartitiveHyperedge,
|
|
67
|
+
PARTITIVE_ENUMERATION, PARTITIVE_ENUMERATION_VALUES,
|
|
67
68
|
PLURALITY_MARKER, PLURALITY_MARKER_VALUES,
|
|
68
69
|
} from './models/index.js';
|
|
69
70
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// v1 → v2
|
|
1
|
+
// v1 → v2 PartitiveHyperedge migration.
|
|
2
2
|
//
|
|
3
3
|
// Pure function: takes a v1 PartitiveHyperedge hash, returns a v2
|
|
4
|
-
//
|
|
4
|
+
// PartitiveHyperedge hash. Idempotent. Used by the parser when loading
|
|
5
5
|
// legacy YAML; available as a public API for one-shot dataset migrations.
|
|
6
6
|
//
|
|
7
7
|
// Field mapping (per TODO.partitive-relation-v2 items 01–05 and the
|
|
@@ -69,7 +69,7 @@ export function migrateHyperedgeToRelation(v1Hash) {
|
|
|
69
69
|
|
|
70
70
|
if (v1Hash.content != null) {
|
|
71
71
|
out.migrationWarning = (out.migrationWarning ?? '') +
|
|
72
|
-
` v1 'content' field was dropped (v2
|
|
72
|
+
` v1 'content' field was dropped (v2 PartitiveHyperedge carries no prose; ` +
|
|
73
73
|
`move valuable text to the comprehensive concept's notes).`;
|
|
74
74
|
}
|
|
75
75
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
// relations (
|
|
1
|
+
// AbstractHyperedge — shared base for n-ary concept-system
|
|
2
|
+
// relations (PartitiveHyperedge, GenericHyperedge, future
|
|
3
3
|
// SequentialRelation, AssociativeRelation).
|
|
4
4
|
//
|
|
5
5
|
// Carries the common shape:
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
isValidCompleteness,
|
|
21
21
|
} from './completeness.js';
|
|
22
22
|
|
|
23
|
-
export class
|
|
23
|
+
export class AbstractHyperedge extends GlossaristModel {
|
|
24
24
|
constructor(data = {}) {
|
|
25
25
|
super();
|
|
26
26
|
this.comprehensive = _ensureComprehensive(data.comprehensive);
|
|
@@ -62,7 +62,7 @@ function _ensureComprehensive(value) {
|
|
|
62
62
|
const ref = value instanceof ConceptRef ? value : new ConceptRef(value ?? {});
|
|
63
63
|
if (!ref.source && !ref.id && !ref.text) {
|
|
64
64
|
throw new Error(
|
|
65
|
-
'
|
|
65
|
+
'AbstractHyperedge.comprehensive must be a non-empty ConceptReference ' +
|
|
66
66
|
'(source, id, or text required)',
|
|
67
67
|
);
|
|
68
68
|
}
|