@rsconcept/domain 2.2.0 → 2.3.0
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/dist/{diagnostics-B1OfMra1.js → diagnostics-CljEzVBV.js} +354 -5
- package/dist/diagnostics-CljEzVBV.js.map +1 -0
- package/dist/{diagnostics-Db1F7ruF.d.ts → diagnostics-Dac5sJhO.d.ts} +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/library/diagnostics.d.ts +1 -1
- package/dist/library/diagnostics.js +1 -1
- package/dist/library/index.d.ts +2 -2
- package/dist/library/index.js +1 -1
- package/dist/library/oss-api.d.ts +1 -1
- package/dist/library/oss-api.js +1 -1
- package/dist/library/rsengine.d.ts +1 -1
- package/dist/library/rsengine.js +2 -2
- package/dist/library/rsform-api/index.d.ts +172 -19
- package/dist/library/rsform-api/index.js +2 -2
- package/dist/library/rsform.d.ts +2 -2
- package/dist/library/rsform.js.map +1 -1
- package/dist/library/rsmodel-api.d.ts +1 -1
- package/dist/library/rsmodel-api.js +2 -2
- package/dist/library/rsmodel.d.ts +1 -1
- package/dist/library/structure-planner.d.ts +1 -1
- package/dist/library/structure-planner.js +2 -2
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/library/index.ts +1 -0
- package/src/library/rsform-api/alias.ts +57 -1
- package/src/library/rsform-api/attribution.ts +87 -0
- package/src/library/rsform-api/index.ts +23 -2
- package/src/library/rsform-api/substitution.ts +82 -0
- package/src/library/rsform-api/synthesis.test.ts +126 -0
- package/src/library/rsform-api/synthesis.ts +142 -3
- package/src/library/rsform-api/text-resolution.test.ts +86 -0
- package/src/library/rsform-api/text-resolution.ts +152 -0
- package/src/library/rsform-api/transforms.test.ts +161 -0
- package/src/library/rsform-api/transforms.ts +42 -0
- package/src/library/rsform-api/types.ts +19 -2
- package/src/library/rsform.ts +1 -1
- package/dist/diagnostics-B1OfMra1.js.map +0 -1
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attribution remapping helpers for substitution and schema embedding.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { type Attribution } from '../rsform';
|
|
6
|
+
|
|
7
|
+
function attributionKey(attr: Attribution): string {
|
|
8
|
+
return `${attr.container}:${attr.attribute}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Remap attributions when constituents are substituted (original → replacement).
|
|
13
|
+
* Drops self-loops and duplicates. Matches sandbox / backend substitute attribution rewrite.
|
|
14
|
+
*/
|
|
15
|
+
export function remapAttributionsUnderSubstitution(
|
|
16
|
+
attributions: readonly Attribution[],
|
|
17
|
+
origToSub: ReadonlyMap<number, number>
|
|
18
|
+
): Attribution[] {
|
|
19
|
+
const result: Attribution[] = [];
|
|
20
|
+
const seen = new Set<string>();
|
|
21
|
+
|
|
22
|
+
for (const attr of attributions) {
|
|
23
|
+
if (!origToSub.has(attr.container) && !origToSub.has(attr.attribute)) {
|
|
24
|
+
const key = attributionKey(attr);
|
|
25
|
+
if (!seen.has(key)) {
|
|
26
|
+
seen.add(key);
|
|
27
|
+
result.push({ ...attr });
|
|
28
|
+
}
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const container = origToSub.get(attr.container) ?? attr.container;
|
|
33
|
+
const attribute = origToSub.get(attr.attribute) ?? attr.attribute;
|
|
34
|
+
if (container === attribute) {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const key = `${container}:${attribute}`;
|
|
38
|
+
if (seen.has(key)) {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
seen.add(key);
|
|
42
|
+
result.push({ container, attribute });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Copy attributions whose both endpoints are in {@link sourceIdSet}, remapping ids via {@link idMap}.
|
|
50
|
+
* Appends onto a clone of {@link existing} with dedupe. Used by schema embedding / insert.
|
|
51
|
+
*/
|
|
52
|
+
export function importAttributions(
|
|
53
|
+
sourceAttributions: readonly Attribution[],
|
|
54
|
+
sourceIdSet: ReadonlySet<number>,
|
|
55
|
+
idMap: Readonly<Record<number, number>>,
|
|
56
|
+
existing: readonly Attribution[] = []
|
|
57
|
+
): Attribution[] {
|
|
58
|
+
const result = existing.map(attr => ({ ...attr }));
|
|
59
|
+
const seen = new Set(result.map(attributionKey));
|
|
60
|
+
|
|
61
|
+
for (const attr of sourceAttributions) {
|
|
62
|
+
if (!sourceIdSet.has(attr.container) || !sourceIdSet.has(attr.attribute)) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const container = idMap[attr.container];
|
|
66
|
+
const attribute = idMap[attr.attribute];
|
|
67
|
+
if (container === undefined || attribute === undefined || container === attribute) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const key = `${container}:${attribute}`;
|
|
71
|
+
if (seen.has(key)) {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
seen.add(key);
|
|
75
|
+
result.push({ container, attribute });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Drop attributions that reference any id in {@link deletedIds}. */
|
|
82
|
+
export function filterAttributions(
|
|
83
|
+
attributions: readonly Attribution[],
|
|
84
|
+
deletedIds: ReadonlySet<number>
|
|
85
|
+
): Attribution[] {
|
|
86
|
+
return attributions.filter(attr => !deletedIds.has(attr.container) && !deletedIds.has(attr.attribute));
|
|
87
|
+
}
|
|
@@ -4,14 +4,19 @@
|
|
|
4
4
|
|
|
5
5
|
export {
|
|
6
6
|
addAliasReference,
|
|
7
|
+
allocateImportAliases,
|
|
7
8
|
applyMappingToConstituents,
|
|
8
9
|
argumentValuesToMapping,
|
|
10
|
+
buildSequentialAliasMapping,
|
|
11
|
+
type ConstituentaMappableFields,
|
|
9
12
|
generateAlias,
|
|
13
|
+
maxAliasIndex,
|
|
10
14
|
removeAliasReference,
|
|
11
15
|
validateAliasFormat,
|
|
12
16
|
validateNewAlias
|
|
13
17
|
} from './alias';
|
|
14
18
|
export { getAnalysisFor, refineAnalysisForDependencyCycles } from './analysis';
|
|
19
|
+
export { filterAttributions, importAttributions, remapAttributionsUnderSubstitution } from './attribution';
|
|
15
20
|
export {
|
|
16
21
|
canHaveManualTypification,
|
|
17
22
|
canProduceStructure,
|
|
@@ -26,7 +31,23 @@ export {
|
|
|
26
31
|
typeClassForCstType
|
|
27
32
|
} from './cst-type';
|
|
28
33
|
export { applyFilterCategory, inferStatus, inferTemplate, inferTemplatedType, normalizeExpression } from './expression';
|
|
29
|
-
export { restoreConstituentOrder } from './formal-graph';
|
|
34
|
+
export { buildFormalDependencyGraph, restoreConstituentOrder } from './formal-graph';
|
|
30
35
|
export { calculateSchemaStats, isSchemaIssue } from './stats';
|
|
31
36
|
export { findCstByStructure, getStructureName, inferNewSpawnPosition } from './structure';
|
|
32
|
-
export {
|
|
37
|
+
export { applyConstituentSubstitutions, remapSubstitutionsAfterImport, type SubstitutionResult } from './substitution';
|
|
38
|
+
export {
|
|
39
|
+
inlineSynthesis,
|
|
40
|
+
type InlineSynthesisInput,
|
|
41
|
+
type InlineSynthesisResult,
|
|
42
|
+
sortItemsForInlineSynthesis,
|
|
43
|
+
type SynthesizableConstituenta
|
|
44
|
+
} from './synthesis';
|
|
45
|
+
export {
|
|
46
|
+
buildDefinitionReferenceGraph,
|
|
47
|
+
buildTermReferenceGraph,
|
|
48
|
+
resolveAllConstituentTexts,
|
|
49
|
+
resolveConstituentTextChange,
|
|
50
|
+
type TextChangeOptions
|
|
51
|
+
} from './text-resolution';
|
|
52
|
+
export { insertItemAfter, moveIdsInOrder, reorderItemsByIds } from './transforms';
|
|
53
|
+
export type { AliasTypedConstituenta, OrderableConstituenta, ResolvableConstituenta, SemanticRelations } from './types';
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Constituenta substitution and post-import substitution remapping.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { type AliasMapping } from '../../rslang/api';
|
|
6
|
+
import { type Attribution, type Substitution } from '../rsform';
|
|
7
|
+
|
|
8
|
+
import { applyMappingToConstituents, type ConstituentaMappableFields } from './alias';
|
|
9
|
+
import { remapAttributionsUnderSubstitution } from './attribution';
|
|
10
|
+
|
|
11
|
+
/** Result of applying substitutions to a schema fragment. */
|
|
12
|
+
export interface SubstitutionResult<T> {
|
|
13
|
+
items: T[];
|
|
14
|
+
attributions: Attribution[];
|
|
15
|
+
deletedIds: Set<number>;
|
|
16
|
+
/** Replacement constituent ids (survivors referenced by substitutions). */
|
|
17
|
+
replacementIds: number[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Apply constituenta substitutions: remap attributions, drop originals, rewrite aliases in survivors.
|
|
22
|
+
* Does not resolve texts — call {@link resolveAllConstituentTexts} afterwards.
|
|
23
|
+
*/
|
|
24
|
+
export function applyConstituentSubstitutions<T extends ConstituentaMappableFields & { id: number; alias: string }>(
|
|
25
|
+
items: readonly T[],
|
|
26
|
+
attributions: readonly Attribution[],
|
|
27
|
+
substitutions: readonly Substitution[]
|
|
28
|
+
): SubstitutionResult<T> {
|
|
29
|
+
if (substitutions.length === 0) {
|
|
30
|
+
return {
|
|
31
|
+
items: [...items],
|
|
32
|
+
attributions: attributions.map(attr => ({ ...attr })),
|
|
33
|
+
deletedIds: new Set(),
|
|
34
|
+
replacementIds: []
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const byId = new Map(items.map(cst => [cst.id, cst]));
|
|
39
|
+
const mapping: AliasMapping = {};
|
|
40
|
+
const deletedIds = new Set<number>();
|
|
41
|
+
const origToSub = new Map<number, number>();
|
|
42
|
+
const replacementIds: number[] = [];
|
|
43
|
+
|
|
44
|
+
for (const { original, substitution } of substitutions) {
|
|
45
|
+
const originalCst = byId.get(original);
|
|
46
|
+
const substitutionCst = byId.get(substitution);
|
|
47
|
+
if (!originalCst || !substitutionCst) {
|
|
48
|
+
throw new Error(`applyConstituentSubstitutions: unknown constituenta ${original} -> ${substitution}`);
|
|
49
|
+
}
|
|
50
|
+
mapping[originalCst.alias] = substitutionCst.alias;
|
|
51
|
+
deletedIds.add(original);
|
|
52
|
+
origToSub.set(original, substitution);
|
|
53
|
+
replacementIds.push(substitution);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const nextItems = items.filter(cst => !deletedIds.has(cst.id));
|
|
57
|
+
applyMappingToConstituents(nextItems, mapping, false);
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
items: nextItems,
|
|
61
|
+
attributions: remapAttributionsUnderSubstitution(attributions, origToSub),
|
|
62
|
+
deletedIds,
|
|
63
|
+
replacementIds
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Remap substitution endpoints after source constituents were inserted under new ids.
|
|
69
|
+
* If {@link original} was imported, map it; otherwise map {@link substitution}.
|
|
70
|
+
*/
|
|
71
|
+
export function remapSubstitutionsAfterImport(
|
|
72
|
+
substitutions: readonly Substitution[],
|
|
73
|
+
sourceIdSet: ReadonlySet<number>,
|
|
74
|
+
idMap: Readonly<Record<number, number>>
|
|
75
|
+
): Substitution[] {
|
|
76
|
+
return substitutions.map(sub => {
|
|
77
|
+
if (sourceIdSet.has(sub.original)) {
|
|
78
|
+
return { original: idMap[sub.original], substitution: sub.substitution };
|
|
79
|
+
}
|
|
80
|
+
return { original: sub.original, substitution: idMap[sub.substitution] };
|
|
81
|
+
});
|
|
82
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { CstType } from '../rsform';
|
|
4
|
+
|
|
5
|
+
import { inlineSynthesis } from './synthesis';
|
|
6
|
+
|
|
7
|
+
function item(partial: {
|
|
8
|
+
id: number;
|
|
9
|
+
alias: string;
|
|
10
|
+
cst_type: CstType;
|
|
11
|
+
definition_formal?: string;
|
|
12
|
+
term_raw?: string;
|
|
13
|
+
definition_raw?: string;
|
|
14
|
+
}) {
|
|
15
|
+
return {
|
|
16
|
+
id: partial.id,
|
|
17
|
+
alias: partial.alias,
|
|
18
|
+
cst_type: partial.cst_type,
|
|
19
|
+
definition_formal: partial.definition_formal ?? '',
|
|
20
|
+
typification_manual: '',
|
|
21
|
+
term_raw: partial.term_raw ?? '',
|
|
22
|
+
term_resolved: partial.term_raw ?? '',
|
|
23
|
+
term_forms: [] as { text: string; tags: string }[],
|
|
24
|
+
definition_raw: partial.definition_raw ?? '',
|
|
25
|
+
definition_resolved: partial.definition_raw ?? ''
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('inlineSynthesis', () => {
|
|
30
|
+
it('keeps source aliases when receiver is empty', () => {
|
|
31
|
+
const result = inlineSynthesis({
|
|
32
|
+
receiverItems: [],
|
|
33
|
+
sourceItems: [item({ id: 10, alias: 'X9', cst_type: CstType.BASE, term_raw: 'person' })],
|
|
34
|
+
nextId: 1
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
expect(result.items).toHaveLength(1);
|
|
38
|
+
expect(result.items[0].alias).toBe('X9');
|
|
39
|
+
expect(result.items[0].id).toBe(1);
|
|
40
|
+
expect(result.idMap).toEqual({ 10: 1 });
|
|
41
|
+
expect(result.nextId).toBe(2);
|
|
42
|
+
expect(result.aliasMapping).toEqual({});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('allocates fresh aliases when receiver is non-empty', () => {
|
|
46
|
+
const result = inlineSynthesis({
|
|
47
|
+
receiverItems: [item({ id: 1, alias: 'X2', cst_type: CstType.BASE })],
|
|
48
|
+
sourceItems: [item({ id: 10, alias: 'X9', cst_type: CstType.BASE, term_raw: 'source' })],
|
|
49
|
+
nextId: 100
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
expect(result.items).toHaveLength(2);
|
|
53
|
+
expect(result.items[1].alias).toBe('X3');
|
|
54
|
+
expect(result.items[1].id).toBe(100);
|
|
55
|
+
expect(result.aliasMapping).toEqual({ X9: 'X3' });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('applies substitutions after import (source original → receiver survivor)', () => {
|
|
59
|
+
const result = inlineSynthesis({
|
|
60
|
+
receiverItems: [item({ id: 1, alias: 'X1', cst_type: CstType.BASE, term_raw: 'keep' })],
|
|
61
|
+
sourceItems: [
|
|
62
|
+
item({
|
|
63
|
+
id: 20,
|
|
64
|
+
alias: 'X1',
|
|
65
|
+
cst_type: CstType.BASE,
|
|
66
|
+
term_raw: 'drop',
|
|
67
|
+
definition_formal: ''
|
|
68
|
+
})
|
|
69
|
+
],
|
|
70
|
+
substitutions: [{ original: 20, substitution: 1 }],
|
|
71
|
+
nextId: 50
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
expect(result.items).toHaveLength(1);
|
|
75
|
+
expect(result.items[0].id).toBe(1);
|
|
76
|
+
expect(result.items[0].term_raw).toBe('keep');
|
|
77
|
+
expect(result.deletedIds).toEqual(new Set([50]));
|
|
78
|
+
expect(result.replacementIds).toEqual([1]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('remaps formal references when importing into non-empty receiver', () => {
|
|
82
|
+
const result = inlineSynthesis({
|
|
83
|
+
receiverItems: [item({ id: 1, alias: 'X1', cst_type: CstType.BASE })],
|
|
84
|
+
sourceItems: [
|
|
85
|
+
item({ id: 10, alias: 'X1', cst_type: CstType.BASE }),
|
|
86
|
+
item({
|
|
87
|
+
id: 11,
|
|
88
|
+
alias: 'S1',
|
|
89
|
+
cst_type: CstType.STRUCTURED,
|
|
90
|
+
definition_formal: 'ℬ(X1)'
|
|
91
|
+
})
|
|
92
|
+
],
|
|
93
|
+
nextId: 2
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const structured = result.items.find(cst => cst.cst_type === CstType.STRUCTURED)!;
|
|
97
|
+
expect(structured.alias).toBe('S1');
|
|
98
|
+
expect(structured.definition_formal).toBe('ℬ(X2)');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('imports attributions with id remapping', () => {
|
|
102
|
+
const result = inlineSynthesis({
|
|
103
|
+
receiverItems: [item({ id: 1, alias: 'X1', cst_type: CstType.BASE })],
|
|
104
|
+
receiverAttributions: [],
|
|
105
|
+
sourceItems: [
|
|
106
|
+
item({ id: 10, alias: 'X1', cst_type: CstType.BASE }),
|
|
107
|
+
item({ id: 11, alias: 'S1', cst_type: CstType.STRUCTURED })
|
|
108
|
+
],
|
|
109
|
+
sourceAttributions: [{ container: 10, attribute: 11 }],
|
|
110
|
+
nextId: 2
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
expect(result.attributions).toEqual([{ container: 2, attribute: 3 }]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('returns a clone when source is empty', () => {
|
|
117
|
+
const receiver = [item({ id: 1, alias: 'X1', cst_type: CstType.BASE })];
|
|
118
|
+
const result = inlineSynthesis({
|
|
119
|
+
receiverItems: receiver,
|
|
120
|
+
sourceItems: [],
|
|
121
|
+
nextId: 2
|
|
122
|
+
});
|
|
123
|
+
expect(result.items).toEqual(receiver);
|
|
124
|
+
expect(result.items).not.toBe(receiver);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -1,11 +1,150 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Schema embedding (synthesis): insert constituents from a source schema into a receiver,
|
|
3
|
+
* then apply an identification (substitution) table.
|
|
4
|
+
*
|
|
5
|
+
* Mirrors backend `inline_synthesis` / `insert_from` + `substitute`
|
|
6
|
+
* (Portal UI: «Встраивание схемы»).
|
|
3
7
|
*/
|
|
4
8
|
|
|
9
|
+
import { type AliasMapping } from '../../rslang/api';
|
|
5
10
|
import { type LibraryItem } from '../library';
|
|
6
|
-
import { type RSForm } from '../rsform';
|
|
11
|
+
import { type Attribution, type CstType, type RSForm, type Substitution } from '../rsform';
|
|
7
12
|
|
|
8
|
-
|
|
13
|
+
import { allocateImportAliases, applyMappingToConstituents, type ConstituentaMappableFields } from './alias';
|
|
14
|
+
import { importAttributions } from './attribution';
|
|
15
|
+
import { applyConstituentSubstitutions, remapSubstitutionsAfterImport } from './substitution';
|
|
16
|
+
import { resolveAllConstituentTexts } from './text-resolution';
|
|
17
|
+
import { type AliasTypedConstituenta, type ResolvableConstituenta } from './types';
|
|
18
|
+
|
|
19
|
+
/** Constituent shape required for embedding / synthesis transforms. */
|
|
20
|
+
export type SynthesizableConstituenta = ConstituentaMappableFields &
|
|
21
|
+
ResolvableConstituenta &
|
|
22
|
+
AliasTypedConstituenta & {
|
|
23
|
+
id: number;
|
|
24
|
+
alias: string;
|
|
25
|
+
cst_type: CstType;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Input for {@link inlineSynthesis}. */
|
|
29
|
+
export interface InlineSynthesisInput<T extends SynthesizableConstituenta> {
|
|
30
|
+
/** Receiver schema constituents (order preserved; imports append). */
|
|
31
|
+
receiverItems: readonly T[];
|
|
32
|
+
/** Receiver attributions (default empty). */
|
|
33
|
+
receiverAttributions?: readonly Attribution[];
|
|
34
|
+
/**
|
|
35
|
+
* Source constituents to insert (already filtered to the desired subset).
|
|
36
|
+
* Cloned; originals are not mutated.
|
|
37
|
+
*/
|
|
38
|
+
sourceItems: readonly T[];
|
|
39
|
+
/** Source attributions whose both endpoints are among {@link sourceItems}. */
|
|
40
|
+
sourceAttributions?: readonly Attribution[];
|
|
41
|
+
/**
|
|
42
|
+
* Identification table using pre-import ids:
|
|
43
|
+
* one endpoint is a source id, the other a receiver id
|
|
44
|
+
* (same rules as backend embedding / InlineSynthesis).
|
|
45
|
+
*/
|
|
46
|
+
substitutions?: readonly Substitution[];
|
|
47
|
+
/** Next free numeric id for inserted constituents. */
|
|
48
|
+
nextId: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Result of {@link inlineSynthesis}. */
|
|
52
|
+
export interface InlineSynthesisResult<T extends SynthesizableConstituenta> {
|
|
53
|
+
items: T[];
|
|
54
|
+
attributions: Attribution[];
|
|
55
|
+
/** Updated next-id after inserts. */
|
|
56
|
+
nextId: number;
|
|
57
|
+
/** Source id → inserted id. */
|
|
58
|
+
idMap: Record<number, number>;
|
|
59
|
+
/** Alias remapping applied to imported constituents (empty when receiver was empty). */
|
|
60
|
+
aliasMapping: AliasMapping;
|
|
61
|
+
/** Ids removed by substitutions. */
|
|
62
|
+
deletedIds: Set<number>;
|
|
63
|
+
/** Ids of newly inserted constituents (before any were deleted by substitutions). */
|
|
64
|
+
insertedIds: number[];
|
|
65
|
+
/** Survivor ids referenced by applied substitutions. */
|
|
66
|
+
replacementIds: number[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Embed {@link InlineSynthesisInput.sourceItems} into the receiver and apply substitutions.
|
|
71
|
+
* Resolves term/definition entity references on the result.
|
|
72
|
+
* Does not mutate inputs.
|
|
73
|
+
*/
|
|
74
|
+
export function inlineSynthesis<T extends SynthesizableConstituenta>(
|
|
75
|
+
input: InlineSynthesisInput<T>
|
|
76
|
+
): InlineSynthesisResult<T> {
|
|
77
|
+
const sourceItems = input.sourceItems;
|
|
78
|
+
if (sourceItems.length === 0) {
|
|
79
|
+
return {
|
|
80
|
+
items: input.receiverItems.map(item => structuredClone(item)),
|
|
81
|
+
attributions: (input.receiverAttributions ?? []).map(attr => ({ ...attr })),
|
|
82
|
+
nextId: input.nextId,
|
|
83
|
+
idMap: {},
|
|
84
|
+
aliasMapping: {},
|
|
85
|
+
deletedIds: new Set(),
|
|
86
|
+
insertedIds: [],
|
|
87
|
+
replacementIds: []
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const receiverItems = input.receiverItems.map(item => structuredClone(item));
|
|
92
|
+
let attributions = (input.receiverAttributions ?? []).map(attr => ({ ...attr }));
|
|
93
|
+
const aliasMapping = allocateImportAliases(receiverItems, sourceItems);
|
|
94
|
+
const idMap: Record<number, number> = {};
|
|
95
|
+
const inserted: T[] = [];
|
|
96
|
+
const sourceIdSet = new Set(sourceItems.map(cst => cst.id));
|
|
97
|
+
let nextId = input.nextId;
|
|
98
|
+
|
|
99
|
+
for (const cst of sourceItems) {
|
|
100
|
+
const newId = nextId;
|
|
101
|
+
nextId += 1;
|
|
102
|
+
idMap[cst.id] = newId;
|
|
103
|
+
|
|
104
|
+
const cloned = structuredClone(cst);
|
|
105
|
+
cloned.id = newId;
|
|
106
|
+
if (Object.keys(aliasMapping).length > 0) {
|
|
107
|
+
cloned.alias = aliasMapping[cst.alias];
|
|
108
|
+
applyMappingToConstituents([cloned], aliasMapping, false);
|
|
109
|
+
}
|
|
110
|
+
inserted.push(cloned);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
attributions = importAttributions(input.sourceAttributions ?? [], sourceIdSet, idMap, attributions);
|
|
114
|
+
const items = receiverItems.concat(inserted);
|
|
115
|
+
resolveAllConstituentTexts(items);
|
|
116
|
+
|
|
117
|
+
const substitutions = input.substitutions ?? [];
|
|
118
|
+
if (substitutions.length === 0) {
|
|
119
|
+
return {
|
|
120
|
+
items,
|
|
121
|
+
attributions,
|
|
122
|
+
nextId,
|
|
123
|
+
idMap,
|
|
124
|
+
aliasMapping,
|
|
125
|
+
deletedIds: new Set(),
|
|
126
|
+
insertedIds: inserted.map(cst => cst.id),
|
|
127
|
+
replacementIds: []
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const remapped = remapSubstitutionsAfterImport(substitutions, sourceIdSet, idMap);
|
|
132
|
+
const substituted = applyConstituentSubstitutions(items, attributions, remapped);
|
|
133
|
+
resolveAllConstituentTexts(substituted.items);
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
items: substituted.items,
|
|
137
|
+
attributions: substituted.attributions,
|
|
138
|
+
nextId,
|
|
139
|
+
idMap,
|
|
140
|
+
aliasMapping,
|
|
141
|
+
deletedIds: substituted.deletedIds,
|
|
142
|
+
insertedIds: inserted.map(cst => cst.id),
|
|
143
|
+
replacementIds: substituted.replacementIds
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Sorts library items relevant for schema embedding with specified {@link RSForm}. */
|
|
9
148
|
export function sortItemsForInlineSynthesis(receiver: RSForm, items: readonly LibraryItem[]): LibraryItem[] {
|
|
10
149
|
const result = items.filter(item => item.location === receiver.location);
|
|
11
150
|
for (const item of items) {
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { resolveConstituentTextChange } from './text-resolution';
|
|
4
|
+
import { type ResolvableConstituenta } from './types';
|
|
5
|
+
|
|
6
|
+
function row(
|
|
7
|
+
data: Partial<ResolvableConstituenta> & Pick<ResolvableConstituenta, 'alias' | 'id'>
|
|
8
|
+
): ResolvableConstituenta {
|
|
9
|
+
return {
|
|
10
|
+
id: data.id,
|
|
11
|
+
alias: data.alias,
|
|
12
|
+
definition_raw: data.definition_raw ?? '',
|
|
13
|
+
definition_resolved: data.definition_resolved ?? '',
|
|
14
|
+
term_raw: data.term_raw ?? data.alias,
|
|
15
|
+
term_resolved: data.term_resolved ?? data.term_raw ?? data.alias,
|
|
16
|
+
term_forms: data.term_forms ?? []
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('resolveConstituentTextChange', () => {
|
|
21
|
+
it('cascades term changes into dependent terms and definitions', () => {
|
|
22
|
+
const items = [
|
|
23
|
+
row({ id: 1, alias: 'T1', term_raw: 'base concept', term_resolved: 'base concept' }),
|
|
24
|
+
row({
|
|
25
|
+
id: 2,
|
|
26
|
+
alias: 'T2',
|
|
27
|
+
term_raw: '@{T1|nomn,sing}',
|
|
28
|
+
term_resolved: 'base concept',
|
|
29
|
+
definition_raw: '@{T1|nomn,sing}',
|
|
30
|
+
definition_resolved: 'base concept',
|
|
31
|
+
term_forms: [{ text: 'stale manual form', tags: 'gent,sing' }]
|
|
32
|
+
}),
|
|
33
|
+
row({ id: 3, alias: 'T3', definition_raw: '@{T2|nomn,sing}', definition_resolved: 'base concept' })
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
items[0].term_raw = 'updated concept';
|
|
37
|
+
resolveConstituentTextChange(items, 1, {
|
|
38
|
+
termChanged: true,
|
|
39
|
+
termRawChanged: true,
|
|
40
|
+
definitionRawChanged: false,
|
|
41
|
+
clearTargetForms: true
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
expect(items[0].term_resolved).toBe('updated concept');
|
|
45
|
+
expect(items[1].term_resolved).toBe('updated concept');
|
|
46
|
+
expect(items[1].term_forms).toStrictEqual([]);
|
|
47
|
+
expect(items[1].definition_resolved).toBe('updated concept');
|
|
48
|
+
expect(items[2].definition_resolved).toBe('updated concept');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('uses updated manual forms as a term change trigger', () => {
|
|
52
|
+
const items = [
|
|
53
|
+
row({ id: 1, alias: 'T1', term_raw: 'base concept', term_resolved: 'base concept' }),
|
|
54
|
+
row({ id: 2, alias: 'T2', term_raw: '@{T1|gent,sing}', term_resolved: 'base concept' })
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
items[0].term_forms = [{ text: 'manual genitive singular', tags: 'gent,sing' }];
|
|
58
|
+
resolveConstituentTextChange(items, 1, {
|
|
59
|
+
termChanged: true,
|
|
60
|
+
termRawChanged: false,
|
|
61
|
+
definitionRawChanged: false,
|
|
62
|
+
clearTargetForms: false
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
expect(items[1].term_resolved).toBe('manual genitive singular');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('does not cascade definition-only edits', () => {
|
|
69
|
+
const items = [
|
|
70
|
+
row({ id: 1, alias: 'T1', term_raw: 'base concept', term_resolved: 'base concept' }),
|
|
71
|
+
row({ id: 2, alias: 'T2', definition_raw: '@{T1|nomn,sing}', definition_resolved: 'base concept' }),
|
|
72
|
+
row({ id: 3, alias: 'T3', definition_raw: '@{T2|nomn,sing}', definition_resolved: 'old T2' })
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
items[1].definition_raw = 'Only @{T1|nomn,sing}';
|
|
76
|
+
resolveConstituentTextChange(items, 2, {
|
|
77
|
+
termChanged: false,
|
|
78
|
+
termRawChanged: false,
|
|
79
|
+
definitionRawChanged: true,
|
|
80
|
+
clearTargetForms: false
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(items[1].definition_resolved).toBe('Only base concept');
|
|
84
|
+
expect(items[2].definition_resolved).toBe('old T2');
|
|
85
|
+
});
|
|
86
|
+
});
|