linked-rolls 0.71.0 → 0.72.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/README.md +86 -0
- package/lib/filiation/adoption.d.ts +47 -0
- package/lib/filiation/adoption.js +190 -0
- package/lib/filiation/changes.d.ts +33 -0
- package/lib/filiation/changes.js +38 -0
- package/lib/filiation/index.d.ts +12 -0
- package/lib/filiation/index.js +12 -0
- package/lib/filiation/presence.d.ts +50 -0
- package/lib/filiation/presence.js +122 -0
- package/lib/filiation/proposal.d.ts +132 -0
- package/lib/filiation/proposal.js +376 -0
- package/lib/filiation/readings.d.ts +108 -0
- package/lib/filiation/readings.js +281 -0
- package/lib/filiation/search.d.ts +113 -0
- package/lib/filiation/search.js +268 -0
- package/lib/filiation/systems.d.ts +21 -0
- package/lib/filiation/systems.js +39 -0
- package/lib/filiation/trees.d.ts +20 -0
- package/lib/filiation/trees.js +69 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The readings of a set of copies, collated all at once: every copy
|
|
3
|
+
* against every other, so that what counts as one reading does not
|
|
4
|
+
* depend on the order the copies are taken in.
|
|
5
|
+
*/
|
|
6
|
+
import { v5 } from "uuid";
|
|
7
|
+
import { asSymbols, barOf } from "../model/RollCopy.js";
|
|
8
|
+
import { idOf } from "../model/Assumption.js";
|
|
9
|
+
import { mm } from "../model/Quantity.js";
|
|
10
|
+
import { keyOf } from "../systems/TrackerBar.js";
|
|
11
|
+
import { medianOf, spreadOf } from "../collation/statistics.js";
|
|
12
|
+
import { departureThreshold } from "../collation/scatter.js";
|
|
13
|
+
import { substitutionsBetween } from "../collation/substitution.js";
|
|
14
|
+
import { transfersOf } from "./systems.js";
|
|
15
|
+
/** The namespace the names of collated readings are minted in, so that the same carriers always give the same name. */
|
|
16
|
+
const READINGS = '0b7a3f5e-5d0c-4a59-9d55-4b2b6c1f6a70';
|
|
17
|
+
/**
|
|
18
|
+
* How far apart the first estimate takes two readings to be the same:
|
|
19
|
+
* far beyond any scatter, near enough to stay off the next command of
|
|
20
|
+
* the same kind.
|
|
21
|
+
*/
|
|
22
|
+
const PROVISIONAL = mm(8);
|
|
23
|
+
/** The window between a pair too seldom read together for a scatter to be read off. */
|
|
24
|
+
const FALLBACK = { n: 0, toleranceStart: mm(5), toleranceEnd: mm(5) };
|
|
25
|
+
/** Below this many shared readings no scatter is read off a pair. */
|
|
26
|
+
const FEWEST = 10;
|
|
27
|
+
/** Two readings further apart than this are never compared. */
|
|
28
|
+
const REACH = mm(40);
|
|
29
|
+
/** How far a held command may lie from the latched pair it stands for. */
|
|
30
|
+
const RECODING_TOLERANCE = { toleranceStart: mm(5), toleranceEnd: mm(5) };
|
|
31
|
+
const groupOf = (meaning) => meaning.type;
|
|
32
|
+
/**
|
|
33
|
+
* What the copy's own punching put on the paper and its bar reads as
|
|
34
|
+
* text, in order of onset.
|
|
35
|
+
*
|
|
36
|
+
* A later alteration of the copy is not part of what was punched from
|
|
37
|
+
* the version, so only the features of the production count. Where the
|
|
38
|
+
* bar finds the roll's end, reading stops, and the end itself is not
|
|
39
|
+
* read. A command no other scale has a word for (the motor, the rewind,
|
|
40
|
+
* the electric cut-off) runs the instrument rather than the music and
|
|
41
|
+
* is no reading either.
|
|
42
|
+
* @category Filiation
|
|
43
|
+
*/
|
|
44
|
+
export const readingsOfCopy = (copy) => {
|
|
45
|
+
const bar = barOf(copy);
|
|
46
|
+
const chains = (copy.production?.produced ?? []).filter((feature) => feature.type === 'HoleChain');
|
|
47
|
+
const end = bar.endsAt(chains);
|
|
48
|
+
const place = new Map(chains.map(chain => [chain.id, chain.horizontal]));
|
|
49
|
+
return asSymbols(chains, bar)
|
|
50
|
+
.filter(symbol => symbol.type !== 'expression' || bar.operationOf(symbol.expressionType) !== undefined)
|
|
51
|
+
.flatMap((symbol) => {
|
|
52
|
+
if (symbol.type === 'text')
|
|
53
|
+
return [];
|
|
54
|
+
const feature = idOf(symbol.carriers[0]);
|
|
55
|
+
const horizontal = place.get(feature);
|
|
56
|
+
if (end !== undefined && horizontal.from >= end.at)
|
|
57
|
+
return [];
|
|
58
|
+
const meaning = symbol.type === 'note'
|
|
59
|
+
? { type: 'note', pitch: symbol.pitch }
|
|
60
|
+
: { type: 'expression', expressionType: symbol.expressionType, scope: symbol.scope };
|
|
61
|
+
const key = keyOf(meaning);
|
|
62
|
+
return [{ id: `${feature}|${key}`, copy: copy.id, feature, meaning, key, place: { ...horizontal } }];
|
|
63
|
+
})
|
|
64
|
+
.sort((a, b) => a.place.from - b.place.from || compare(a.id, b.id));
|
|
65
|
+
};
|
|
66
|
+
const compare = (a, b) => a < b ? -1 : a > b ? 1 : 0;
|
|
67
|
+
const groupBy = (items, key) => {
|
|
68
|
+
const groups = new Map();
|
|
69
|
+
for (const item of items) {
|
|
70
|
+
const k = key(item);
|
|
71
|
+
const group = groups.get(k);
|
|
72
|
+
if (group)
|
|
73
|
+
group.push(item);
|
|
74
|
+
else
|
|
75
|
+
groups.set(k, [item]);
|
|
76
|
+
}
|
|
77
|
+
return groups;
|
|
78
|
+
};
|
|
79
|
+
const pairKey = (a, b, group) => `${a}|${b}|${group}`;
|
|
80
|
+
/** The reading of the other copy nearest in onset, within the provisional window, ties to the smaller id. */
|
|
81
|
+
const nearestOf = (reading, others) => {
|
|
82
|
+
let best;
|
|
83
|
+
let distance = Infinity;
|
|
84
|
+
for (const other of others) {
|
|
85
|
+
const d = Math.abs(other.place.from - reading.place.from);
|
|
86
|
+
if (d < distance || (d === distance && best && other.id < best.id)) {
|
|
87
|
+
best = other;
|
|
88
|
+
distance = d;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return distance <= PROVISIONAL ? best : undefined;
|
|
92
|
+
};
|
|
93
|
+
/**
|
|
94
|
+
* The window of every pair of copies, read off the readings they share
|
|
95
|
+
* at first sight: for each meaning, the mutually nearest onsets within a
|
|
96
|
+
* wide provisional window. Their displacements, the later copy less the
|
|
97
|
+
* earlier, give the centre (the median) and the scatter about it (the
|
|
98
|
+
* median absolute deviation as a standard deviation), and the window is
|
|
99
|
+
* the scatter times the threshold beyond which fewer than one reading of
|
|
100
|
+
* a sample of that size would fall by chance.
|
|
101
|
+
*/
|
|
102
|
+
const windowsOf = (copies, byKey) => {
|
|
103
|
+
const samples = new Map();
|
|
104
|
+
for (const readings of byKey.values()) {
|
|
105
|
+
const group = groupOf(readings[0].meaning);
|
|
106
|
+
const byCopy = groupBy(readings, reading => reading.copy);
|
|
107
|
+
for (let i = 0; i < copies.length; i++) {
|
|
108
|
+
for (let j = i + 1; j < copies.length; j++) {
|
|
109
|
+
const one = byCopy.get(copies[i]) ?? [];
|
|
110
|
+
const other = byCopy.get(copies[j]) ?? [];
|
|
111
|
+
for (const a of one) {
|
|
112
|
+
const b = nearestOf(a, other);
|
|
113
|
+
if (!b || nearestOf(b, one) !== a)
|
|
114
|
+
continue;
|
|
115
|
+
const key = pairKey(copies[i], copies[j], group);
|
|
116
|
+
const sample = samples.get(key) ?? { from: [], to: [] };
|
|
117
|
+
sample.from.push(mm(b.place.from - a.place.from));
|
|
118
|
+
sample.to.push(mm(b.place.to - a.place.to));
|
|
119
|
+
samples.set(key, sample);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const windows = {};
|
|
125
|
+
for (const [key, sample] of samples) {
|
|
126
|
+
const start = spreadOf(sample.from);
|
|
127
|
+
const end = spreadOf(sample.to);
|
|
128
|
+
if (!start || !end || sample.from.length < FEWEST || start.sigma === 0 || end.sigma === 0)
|
|
129
|
+
continue;
|
|
130
|
+
const k = departureThreshold(sample.from.length);
|
|
131
|
+
windows[key] = {
|
|
132
|
+
n: sample.from.length,
|
|
133
|
+
offsetStart: start.median,
|
|
134
|
+
offsetEnd: end.median,
|
|
135
|
+
toleranceStart: mm(k * start.sigma),
|
|
136
|
+
toleranceEnd: mm(k * end.sigma)
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
return windows;
|
|
140
|
+
};
|
|
141
|
+
/** How far apart two readings lie, in units of the window of their copies; above one they are not the same. */
|
|
142
|
+
const distanceIn = (windows, order, x, y) => {
|
|
143
|
+
const [a, b] = order.get(x.copy) < order.get(y.copy) ? [x, y] : [y, x];
|
|
144
|
+
const window = windows[pairKey(a.copy, b.copy, groupOf(a.meaning))] ?? FALLBACK;
|
|
145
|
+
const start = Math.abs(b.place.from - a.place.from - (window.offsetStart ?? 0)) / window.toleranceStart;
|
|
146
|
+
const end = Math.abs(b.place.to - a.place.to - (window.offsetEnd ?? 0)) / window.toleranceEnd;
|
|
147
|
+
return Math.max(start, end);
|
|
148
|
+
};
|
|
149
|
+
/** The readings in clusters: nearest pairs first, never two readings of one copy in one cluster. */
|
|
150
|
+
const clustered = (readings, edges) => {
|
|
151
|
+
const parent = new Map(readings.map(reading => [reading.id, reading.id]));
|
|
152
|
+
const members = new Map(readings.map(reading => [reading.id, [reading]]));
|
|
153
|
+
const find = (id) => {
|
|
154
|
+
let root = id;
|
|
155
|
+
while (parent.get(root) !== root)
|
|
156
|
+
root = parent.get(root);
|
|
157
|
+
parent.set(id, root);
|
|
158
|
+
return root;
|
|
159
|
+
};
|
|
160
|
+
for (const { x, y } of edges) {
|
|
161
|
+
const a = find(x.id);
|
|
162
|
+
const b = find(y.id);
|
|
163
|
+
if (a === b)
|
|
164
|
+
continue;
|
|
165
|
+
const one = members.get(a);
|
|
166
|
+
const other = members.get(b);
|
|
167
|
+
const copies = new Set(one.map(reading => reading.copy));
|
|
168
|
+
if (other.some(reading => copies.has(reading.copy)))
|
|
169
|
+
continue;
|
|
170
|
+
const [root, child] = a < b ? [a, b] : [b, a];
|
|
171
|
+
parent.set(child, root);
|
|
172
|
+
members.set(root, [...members.get(root), ...members.get(child)]);
|
|
173
|
+
members.delete(child);
|
|
174
|
+
}
|
|
175
|
+
return [...members.values()];
|
|
176
|
+
};
|
|
177
|
+
const readingId = (key, carriers) => `symbol_${v5(`${key}|${[...carriers].sort().join(',')}`, READINGS)}`;
|
|
178
|
+
const asCollated = (members, order) => {
|
|
179
|
+
const sorted = [...members].sort((a, b) => order.get(a.copy) - order.get(b.copy));
|
|
180
|
+
const carriers = sorted.map(reading => reading.feature);
|
|
181
|
+
return {
|
|
182
|
+
id: readingId(sorted[0].key, carriers),
|
|
183
|
+
meaning: sorted[0].meaning,
|
|
184
|
+
key: sorted[0].key,
|
|
185
|
+
carriers,
|
|
186
|
+
copies: sorted.map(reading => reading.copy),
|
|
187
|
+
place: {
|
|
188
|
+
unit: 'mm',
|
|
189
|
+
from: medianOf(sorted.map(reading => reading.place.from)),
|
|
190
|
+
to: medianOf(sorted.map(reading => reading.place.to))
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
};
|
|
194
|
+
/**
|
|
195
|
+
* Where the copies of a system re-cut from another hold what the other
|
|
196
|
+
* latches: for each such pair of copies, the held commands that stand
|
|
197
|
+
* for a latched pair, as `substitutionsBetween` finds them. Each is
|
|
198
|
+
* stated once, however many copies of the older system it was found
|
|
199
|
+
* against.
|
|
200
|
+
*/
|
|
201
|
+
const recodingsOf = (edition, copies, readingsOf, collatedOf) => {
|
|
202
|
+
const systemOf = new Map(copies.map(id => [id, barOf(edition.copies.find(copy => copy.id === id)).id]));
|
|
203
|
+
const transfers = transfersOf();
|
|
204
|
+
const asExpression = (reading) => ({ type: 'expression', id: reading.id, carriers: [], ...reading.meaning });
|
|
205
|
+
const placeOf = new Map([...readingsOf.values()].flat().map(reading => [reading.id, reading.place]));
|
|
206
|
+
const locate = (symbol) => placeOf.get(symbol.id);
|
|
207
|
+
const found = new Map();
|
|
208
|
+
for (const newer of copies) {
|
|
209
|
+
for (const older of copies) {
|
|
210
|
+
if (!transfers.get(systemOf.get(older))?.has(systemOf.get(newer)))
|
|
211
|
+
continue;
|
|
212
|
+
const own = readingsOf.get(newer).filter(reading => reading.meaning.type === 'expression').map(asExpression);
|
|
213
|
+
const inherited = readingsOf.get(older).filter(reading => reading.meaning.type === 'expression').map(asExpression);
|
|
214
|
+
for (const substitution of substitutionsBetween(own, inherited, locate, RECODING_TOLERANCE)) {
|
|
215
|
+
const by = [...new Set(substitution.by.map(symbol => collatedOf.get(symbol.id)))].sort();
|
|
216
|
+
const replaced = [...new Set(substitution.replaced.map(symbol => collatedOf.get(symbol.id)))].sort();
|
|
217
|
+
const key = `${by.join(',')}>${replaced.join(',')}`;
|
|
218
|
+
if (!found.has(key))
|
|
219
|
+
found.set(key, { replaced, by });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return [...found.values()].sort((a, b) => compare(a.by[0], b.by[0]));
|
|
224
|
+
};
|
|
225
|
+
/**
|
|
226
|
+
* Collates the copies all at once. Every reading is compared with every
|
|
227
|
+
* reading of the same meaning on the other copies, and two are taken to
|
|
228
|
+
* be one where they lie within the window of their pair of copies at
|
|
229
|
+
* both ends. The readings are then joined nearest pair first, and never
|
|
230
|
+
* so that one copy would read a joined reading twice.
|
|
231
|
+
*
|
|
232
|
+
* Collating one copy after another against what was collated before, as
|
|
233
|
+
* connecting versions does, depends on the order: six copies collated in
|
|
234
|
+
* their 720 orders give hundreds of different results. Compared all with
|
|
235
|
+
* all, the result is the same whichever order the copies are given in,
|
|
236
|
+
* and only the copies themselves decide it.
|
|
237
|
+
* @category Filiation
|
|
238
|
+
*/
|
|
239
|
+
export const collateCopies = (edition, copyIds) => {
|
|
240
|
+
const copies = copyIds.filter(id => edition.copies.some(copy => copy.id === id));
|
|
241
|
+
const order = new Map(copies.map((id, index) => [id, index]));
|
|
242
|
+
const readingsOf = new Map(copies.map(id => [id, readingsOfCopy(edition.copies.find(copy => copy.id === id))]));
|
|
243
|
+
const all = [...readingsOf.values()].flat();
|
|
244
|
+
const byKey = groupBy(all, reading => reading.key);
|
|
245
|
+
const windows = windowsOf(copies, byKey);
|
|
246
|
+
const edges = [];
|
|
247
|
+
for (const readings of byKey.values()) {
|
|
248
|
+
const sorted = [...readings].sort((a, b) => a.place.from - b.place.from || compare(a.id, b.id));
|
|
249
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
250
|
+
for (let j = i + 1; j < sorted.length && sorted[j].place.from - sorted[i].place.from <= REACH; j++) {
|
|
251
|
+
const x = sorted[i];
|
|
252
|
+
const y = sorted[j];
|
|
253
|
+
if (x.copy === y.copy)
|
|
254
|
+
continue;
|
|
255
|
+
const d = distanceIn(windows, order, x, y);
|
|
256
|
+
if (d > 1 + 1e-9)
|
|
257
|
+
continue;
|
|
258
|
+
const [p, q] = x.id < y.id ? [x, y] : [y, x];
|
|
259
|
+
edges.push({ x: p, y: q, d });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
edges.sort((a, b) => a.d - b.d || compare(a.x.id, b.x.id) || compare(a.y.id, b.y.id));
|
|
264
|
+
const readings = clustered(all, edges)
|
|
265
|
+
.map(members => asCollated(members, order))
|
|
266
|
+
.sort((a, b) => a.place.from - b.place.from || compare(a.key, b.key) || compare(a.id, b.id));
|
|
267
|
+
const collatedOf = new Map();
|
|
268
|
+
for (const reading of readings) {
|
|
269
|
+
reading.carriers.forEach(feature => collatedOf.set(`${feature}|${reading.key}`, reading.id));
|
|
270
|
+
}
|
|
271
|
+
const extent = Object.fromEntries(copies.map(id => {
|
|
272
|
+
const own = readingsOf.get(id);
|
|
273
|
+
return [id, {
|
|
274
|
+
from: mm(Math.min(...own.map(reading => reading.place.from))),
|
|
275
|
+
to: mm(Math.max(...own.map(reading => reading.place.from)))
|
|
276
|
+
}];
|
|
277
|
+
}));
|
|
278
|
+
return { copies, readings, windows, recodings: recodingsOf(edition, copies, readingsOf, collatedOf), extent };
|
|
279
|
+
};
|
|
280
|
+
/** Whether the bar has a word for the meaning at all. */
|
|
281
|
+
export const reads = (bar, meaning) => bar.positionOf(meaning) !== undefined;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stemma that explains the presence of the readings at the least
|
|
3
|
+
* cost, searched among all of them, and what each node of it holds.
|
|
4
|
+
*/
|
|
5
|
+
import { Presence } from "./presence.js";
|
|
6
|
+
/**
|
|
7
|
+
* What the search counts against a stemma. A reading arising costs
|
|
8
|
+
* `gain`, and a reading the base text holds arose too. A reading lost
|
|
9
|
+
* costs 1, or less where the editorial principles say a loss is weak
|
|
10
|
+
* evidence.
|
|
11
|
+
* @category Filiation
|
|
12
|
+
*/
|
|
13
|
+
export interface Costs {
|
|
14
|
+
/**
|
|
15
|
+
* A reading arising. Ten times a loss, since an addition at exactly
|
|
16
|
+
* the same place is very unlikely to be made twice (shared addition),
|
|
17
|
+
* while a perforation is easily struck.
|
|
18
|
+
*/
|
|
19
|
+
gain: number;
|
|
20
|
+
/**
|
|
21
|
+
* A redundant reading lost, for the share of a loss: the removal of
|
|
22
|
+
* redundancies is expected and happens in two branches independently
|
|
23
|
+
* (independent cleanup).
|
|
24
|
+
*/
|
|
25
|
+
redundantLoss: number;
|
|
26
|
+
/**
|
|
27
|
+
* A reading lost on the way into another system, for the share of a
|
|
28
|
+
* loss: a re-cut was an editorial pass of its own, and what it lacks
|
|
29
|
+
* says little about where it was cut from.
|
|
30
|
+
*/
|
|
31
|
+
transferLoss: number;
|
|
32
|
+
/**
|
|
33
|
+
* The copy carrying a version lacking one of its readings, since a
|
|
34
|
+
* copy may fail to show what its version holds. Where it is
|
|
35
|
+
* `Infinity`, a copy carries its version exactly.
|
|
36
|
+
*/
|
|
37
|
+
copyDeviation: number;
|
|
38
|
+
/** A lost state, so that one is posited only where shared additions require it. */
|
|
39
|
+
lostState: number;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The costs the edition of WM 225 is reproduced with: a gain 10, a lost
|
|
43
|
+
* redundancy 0.25, a loss on a transfer 0.25, a deviation of a copy 1
|
|
44
|
+
* and a lost state 4. They were found by searching for values that give
|
|
45
|
+
* that stemma, so they say that it is consistent with such rules, not
|
|
46
|
+
* that the rules confirm it.
|
|
47
|
+
* @category Filiation
|
|
48
|
+
*/
|
|
49
|
+
export declare const defaultCosts: Costs;
|
|
50
|
+
/**
|
|
51
|
+
* A stemma laid out for counting: its nodes children first, the root
|
|
52
|
+
* last, each with the copy it carries, its parent, its system, whether
|
|
53
|
+
* it was re-cut from its parent, and the copies at and below it.
|
|
54
|
+
*/
|
|
55
|
+
export interface LaidOut {
|
|
56
|
+
copy: Int16Array;
|
|
57
|
+
parent: Int16Array;
|
|
58
|
+
transfer: Uint8Array;
|
|
59
|
+
system: string[];
|
|
60
|
+
below: Int32Array;
|
|
61
|
+
lost: number;
|
|
62
|
+
notation: string;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* A stemma the search scored, in a notation naming the copies: a lost
|
|
66
|
+
* state as `*`, the versions derived from a node in brackets after it.
|
|
67
|
+
* @category Filiation
|
|
68
|
+
*/
|
|
69
|
+
export interface Scored {
|
|
70
|
+
notation: string;
|
|
71
|
+
cost: number;
|
|
72
|
+
lost: number;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* How much more the best stemma without one of the best one's groupings
|
|
76
|
+
* costs: the margin by which the copies decide it.
|
|
77
|
+
* @category Filiation
|
|
78
|
+
*/
|
|
79
|
+
export interface Support {
|
|
80
|
+
/** The copies the grouping holds, as the copies of a node and those below it. */
|
|
81
|
+
copies: string[];
|
|
82
|
+
/** Whether it is the grouping of a copy carrying a version above others, rather than of the copies below a node. */
|
|
83
|
+
ancestor?: string;
|
|
84
|
+
margin: number;
|
|
85
|
+
}
|
|
86
|
+
/** The outcome of the search. */
|
|
87
|
+
export interface Search {
|
|
88
|
+
best: LaidOut;
|
|
89
|
+
cost: number;
|
|
90
|
+
scored: number;
|
|
91
|
+
alternatives: Scored[];
|
|
92
|
+
supports: Support[];
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Scores every stemma of the copies and keeps the best, the runners-up
|
|
96
|
+
* and, for each grouping of the best, the best stemma without it. Ties
|
|
97
|
+
* go to fewer lost states, then to the notation, so that the outcome
|
|
98
|
+
* depends on nothing but the copies and the costs.
|
|
99
|
+
*/
|
|
100
|
+
export declare const searchStemmata: (copies: readonly string[], names: readonly string[], systems: readonly string[], rows: readonly {
|
|
101
|
+
presence: readonly Presence[];
|
|
102
|
+
redundant: boolean;
|
|
103
|
+
}[], costs: Costs, transfers: ReadonlyMap<string, ReadonlySet<string>>, keep: number) => Search;
|
|
104
|
+
/**
|
|
105
|
+
* What every node holds of one reading under the least costly account
|
|
106
|
+
* of it: children first, as `LaidOut` orders the nodes. A node may be
|
|
107
|
+
* held to a state (`fixed`), as an edit the editor wrote holds a
|
|
108
|
+
* version to what it inserts or deletes. Where two accounts cost the
|
|
109
|
+
* same, the one with the node lacking the reading is taken, so that a
|
|
110
|
+
* reading arises as late as the copies allow. Nothing where the fixed
|
|
111
|
+
* states cannot be met.
|
|
112
|
+
*/
|
|
113
|
+
export declare const statesOf: (tree: LaidOut, presence: readonly Presence[], redundant: boolean, costs: Costs, fixed?: ReadonlyMap<number, boolean>) => boolean[] | undefined;
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { leadsTo, systemAbove } from "./systems.js";
|
|
2
|
+
import { stemmataOf } from "./trees.js";
|
|
3
|
+
/**
|
|
4
|
+
* The costs the edition of WM 225 is reproduced with: a gain 10, a lost
|
|
5
|
+
* redundancy 0.25, a loss on a transfer 0.25, a deviation of a copy 1
|
|
6
|
+
* and a lost state 4. They were found by searching for values that give
|
|
7
|
+
* that stemma, so they say that it is consistent with such rules, not
|
|
8
|
+
* that the rules confirm it.
|
|
9
|
+
* @category Filiation
|
|
10
|
+
*/
|
|
11
|
+
export const defaultCosts = {
|
|
12
|
+
gain: 10,
|
|
13
|
+
redundantLoss: 0.25,
|
|
14
|
+
transferLoss: 0.25,
|
|
15
|
+
copyDeviation: 1,
|
|
16
|
+
lostState: 4
|
|
17
|
+
};
|
|
18
|
+
const INF = 1e12;
|
|
19
|
+
const lay = (tree, systems, names, transfers) => {
|
|
20
|
+
const copy = [];
|
|
21
|
+
const parent = [];
|
|
22
|
+
const system = [];
|
|
23
|
+
const below = [];
|
|
24
|
+
let lost = 0;
|
|
25
|
+
const visit = (node) => {
|
|
26
|
+
const visited = [];
|
|
27
|
+
for (const child of node.children) {
|
|
28
|
+
const done = visit(child);
|
|
29
|
+
if (!done)
|
|
30
|
+
return undefined;
|
|
31
|
+
visited.push(done);
|
|
32
|
+
}
|
|
33
|
+
const under = new Set(visited.flatMap(child => [...child.systems]));
|
|
34
|
+
let own;
|
|
35
|
+
if (node.copy !== undefined) {
|
|
36
|
+
own = systems[node.copy];
|
|
37
|
+
// A copy carrying a version above others is of a system that leads to theirs.
|
|
38
|
+
if ([...under].some(other => !leadsTo(transfers, own, other)))
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
own = systemAbove(transfers, under);
|
|
43
|
+
if (own === undefined)
|
|
44
|
+
return undefined;
|
|
45
|
+
lost++;
|
|
46
|
+
}
|
|
47
|
+
const index = copy.length;
|
|
48
|
+
copy.push(node.copy ?? -1);
|
|
49
|
+
parent.push(-1);
|
|
50
|
+
system.push(own);
|
|
51
|
+
below.push(visited.reduce((mask, child) => mask | below[child.index], node.copy !== undefined ? 1 << node.copy : 0));
|
|
52
|
+
visited.forEach(child => { parent[child.index] = index; });
|
|
53
|
+
const label = node.copy !== undefined ? names[node.copy] : '*';
|
|
54
|
+
const notation = visited.length === 0 ? label : `${label}(${visited.map(child => child.notation).sort().join(',')})`;
|
|
55
|
+
return { index, systems: new Set([own, ...under]), notation };
|
|
56
|
+
};
|
|
57
|
+
const root = visit(tree);
|
|
58
|
+
if (!root)
|
|
59
|
+
return undefined;
|
|
60
|
+
const transfer = system.map((own, index) => parent[index] >= 0 && system[parent[index]] !== own ? 1 : 0);
|
|
61
|
+
return {
|
|
62
|
+
copy: Int16Array.from(copy),
|
|
63
|
+
parent: Int16Array.from(parent),
|
|
64
|
+
transfer: Uint8Array.from(transfer),
|
|
65
|
+
system,
|
|
66
|
+
below: Int32Array.from(below),
|
|
67
|
+
lost,
|
|
68
|
+
notation: root.notation
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
const classesOf = (rows, copies, costs) => {
|
|
72
|
+
const counts = new Map();
|
|
73
|
+
for (const row of rows) {
|
|
74
|
+
const key = `${row.presence.map(p => p[0]).join('')}${row.redundant ? 'r' : ''}`;
|
|
75
|
+
const known = counts.get(key);
|
|
76
|
+
if (known)
|
|
77
|
+
known.n++;
|
|
78
|
+
else
|
|
79
|
+
counts.set(key, { row, n: 1 });
|
|
80
|
+
}
|
|
81
|
+
const classes = [...counts.values()];
|
|
82
|
+
return {
|
|
83
|
+
weight: Float64Array.from(classes.map(({ n }) => n)),
|
|
84
|
+
loss: Float64Array.from(classes.map(({ row }) => row.redundant ? costs.redundantLoss : 1)),
|
|
85
|
+
forbidsAbsent: Array.from({ length: copies }, (_, c) => Uint8Array.from(classes.map(({ row }) => row.presence[c] === 'present' ? 1 : 0))),
|
|
86
|
+
forbidsPresent: Array.from({ length: copies }, (_, c) => Uint8Array.from(classes.map(({ row }) => row.presence[c] === 'absent' ? 1 : 0))),
|
|
87
|
+
total: rows.length
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* The cost of every class at every node, children first: what the
|
|
92
|
+
* subtree below costs if the node lacks the reading (`absent`) and if
|
|
93
|
+
* it holds it (`present`).
|
|
94
|
+
*/
|
|
95
|
+
const fill = (tree, classes, costs, absent, present) => {
|
|
96
|
+
const k = classes.weight.length;
|
|
97
|
+
for (let v = 0; v < tree.copy.length; v++) {
|
|
98
|
+
const a = absent[v];
|
|
99
|
+
const p = present[v];
|
|
100
|
+
a.fill(0);
|
|
101
|
+
p.fill(0);
|
|
102
|
+
const copy = tree.copy[v];
|
|
103
|
+
if (copy >= 0) {
|
|
104
|
+
const inner = tree.below[v] !== 1 << copy;
|
|
105
|
+
const noAbsent = classes.forbidsAbsent[copy];
|
|
106
|
+
const noPresent = classes.forbidsPresent[copy];
|
|
107
|
+
for (let c = 0; c < k; c++) {
|
|
108
|
+
if (noAbsent[c])
|
|
109
|
+
a[c] = INF;
|
|
110
|
+
if (noPresent[c])
|
|
111
|
+
p[c] = inner ? costs.copyDeviation : INF;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
for (let v = 0; v < tree.copy.length; v++) {
|
|
116
|
+
const u = tree.parent[v];
|
|
117
|
+
if (u < 0)
|
|
118
|
+
continue;
|
|
119
|
+
const factor = tree.transfer[v] ? costs.transferLoss : 1;
|
|
120
|
+
const a = absent[v];
|
|
121
|
+
const p = present[v];
|
|
122
|
+
const pa = absent[u];
|
|
123
|
+
const pp = present[u];
|
|
124
|
+
for (let c = 0; c < k; c++) {
|
|
125
|
+
pa[c] += Math.min(a[c], p[c] + costs.gain);
|
|
126
|
+
pp[c] += Math.min(p[c], a[c] + classes.loss[c] * factor);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
const scoreOf = (tree, classes, costs, absent, present) => {
|
|
131
|
+
fill(tree, classes, costs, absent, present);
|
|
132
|
+
const root = tree.copy.length - 1;
|
|
133
|
+
let sum = 0;
|
|
134
|
+
for (let c = 0; c < classes.weight.length; c++) {
|
|
135
|
+
sum += classes.weight[c] * Math.min(absent[root][c], present[root][c] + costs.gain);
|
|
136
|
+
}
|
|
137
|
+
return sum - costs.gain * classes.total + costs.lostState * tree.lost;
|
|
138
|
+
};
|
|
139
|
+
const before = (a, b) => a.cost < b.cost - 1e-9 || (Math.abs(a.cost - b.cost) <= 1e-9 && (a.lost < b.lost || (a.lost === b.lost && a.notation < b.notation)));
|
|
140
|
+
/**
|
|
141
|
+
* Scores every stemma of the copies and keeps the best, the runners-up
|
|
142
|
+
* and, for each grouping of the best, the best stemma without it. Ties
|
|
143
|
+
* go to fewer lost states, then to the notation, so that the outcome
|
|
144
|
+
* depends on nothing but the copies and the costs.
|
|
145
|
+
*/
|
|
146
|
+
export const searchStemmata = (copies, names, systems, rows, costs, transfers, keep) => {
|
|
147
|
+
const n = copies.length;
|
|
148
|
+
const classes = classesOf(rows, n, costs);
|
|
149
|
+
const pool = 2 * n;
|
|
150
|
+
const absent = Array.from({ length: pool }, () => new Float64Array(classes.weight.length));
|
|
151
|
+
const present = Array.from({ length: pool }, () => new Float64Array(classes.weight.length));
|
|
152
|
+
const masks = 1 << n;
|
|
153
|
+
const without = new Float64Array(masks).fill(Infinity);
|
|
154
|
+
const notAncestor = new Float64Array(n).fill(Infinity);
|
|
155
|
+
const top = [];
|
|
156
|
+
let scored = 0;
|
|
157
|
+
for (const tree of stemmataOf(n)) {
|
|
158
|
+
const laid = lay(tree, systems, names, transfers);
|
|
159
|
+
if (!laid)
|
|
160
|
+
continue;
|
|
161
|
+
scored++;
|
|
162
|
+
const cost = scoreOf(laid, classes, costs, absent, present);
|
|
163
|
+
const entry = { notation: laid.notation, cost, lost: laid.lost, tree: laid };
|
|
164
|
+
if (top.length <= keep || before(entry, top[top.length - 1])) {
|
|
165
|
+
top.push(entry);
|
|
166
|
+
top.sort((a, b) => before(a, b) ? -1 : before(b, a) ? 1 : 0);
|
|
167
|
+
if (top.length > keep + 1)
|
|
168
|
+
top.pop();
|
|
169
|
+
}
|
|
170
|
+
const groupings = new Set();
|
|
171
|
+
let ancestors = 0;
|
|
172
|
+
for (let v = 0; v < laid.copy.length; v++) {
|
|
173
|
+
if (laid.below[v] === (laid.copy[v] >= 0 ? 1 << laid.copy[v] : -1))
|
|
174
|
+
continue;
|
|
175
|
+
groupings.add(laid.below[v]);
|
|
176
|
+
if (laid.copy[v] >= 0)
|
|
177
|
+
ancestors |= 1 << laid.copy[v];
|
|
178
|
+
}
|
|
179
|
+
for (let mask = 1; mask < masks; mask++) {
|
|
180
|
+
if (!groupings.has(mask) && cost < without[mask])
|
|
181
|
+
without[mask] = cost;
|
|
182
|
+
}
|
|
183
|
+
for (let c = 0; c < n; c++) {
|
|
184
|
+
if (!(ancestors & (1 << c)) && cost < notAncestor[c])
|
|
185
|
+
notAncestor[c] = cost;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (top.length === 0)
|
|
189
|
+
throw new Error('No stemma of these copies is admissible');
|
|
190
|
+
const best = top[0];
|
|
191
|
+
const supports = [];
|
|
192
|
+
const named = (mask) => copies.filter((_, c) => mask & (1 << c));
|
|
193
|
+
const seen = new Set();
|
|
194
|
+
for (let v = 0; v < best.tree.copy.length; v++) {
|
|
195
|
+
const mask = best.tree.below[v];
|
|
196
|
+
const copy = best.tree.copy[v];
|
|
197
|
+
if (copy >= 0 && mask === 1 << copy)
|
|
198
|
+
continue;
|
|
199
|
+
if (mask === masks - 1 && copy < 0)
|
|
200
|
+
continue;
|
|
201
|
+
if (!seen.has(mask)) {
|
|
202
|
+
seen.add(mask);
|
|
203
|
+
supports.push({ copies: named(mask), margin: without[mask] - best.cost });
|
|
204
|
+
}
|
|
205
|
+
if (copy >= 0)
|
|
206
|
+
supports.push({ copies: named(mask), ancestor: copies[copy], margin: notAncestor[copy] - best.cost });
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
best: best.tree,
|
|
210
|
+
cost: best.cost,
|
|
211
|
+
scored,
|
|
212
|
+
alternatives: top.slice(1).map(({ notation, cost, lost }) => ({ notation, cost, lost })),
|
|
213
|
+
supports
|
|
214
|
+
};
|
|
215
|
+
};
|
|
216
|
+
/**
|
|
217
|
+
* What every node holds of one reading under the least costly account
|
|
218
|
+
* of it: children first, as `LaidOut` orders the nodes. A node may be
|
|
219
|
+
* held to a state (`fixed`), as an edit the editor wrote holds a
|
|
220
|
+
* version to what it inserts or deletes. Where two accounts cost the
|
|
221
|
+
* same, the one with the node lacking the reading is taken, so that a
|
|
222
|
+
* reading arises as late as the copies allow. Nothing where the fixed
|
|
223
|
+
* states cannot be met.
|
|
224
|
+
*/
|
|
225
|
+
export const statesOf = (tree, presence, redundant, costs, fixed) => {
|
|
226
|
+
const size = tree.copy.length;
|
|
227
|
+
const absent = new Float64Array(size);
|
|
228
|
+
const present = new Float64Array(size);
|
|
229
|
+
const loss = redundant ? costs.redundantLoss : 1;
|
|
230
|
+
for (let v = 0; v < size; v++) {
|
|
231
|
+
const copy = tree.copy[v];
|
|
232
|
+
if (copy >= 0) {
|
|
233
|
+
const inner = tree.below[v] !== 1 << copy;
|
|
234
|
+
if (presence[copy] === 'present')
|
|
235
|
+
absent[v] = INF;
|
|
236
|
+
if (presence[copy] === 'absent')
|
|
237
|
+
present[v] = inner ? costs.copyDeviation : INF;
|
|
238
|
+
}
|
|
239
|
+
const held = fixed?.get(v);
|
|
240
|
+
if (held === true)
|
|
241
|
+
absent[v] = INF;
|
|
242
|
+
if (held === false)
|
|
243
|
+
present[v] = INF;
|
|
244
|
+
}
|
|
245
|
+
for (let v = 0; v < size; v++) {
|
|
246
|
+
const u = tree.parent[v];
|
|
247
|
+
if (u < 0)
|
|
248
|
+
continue;
|
|
249
|
+
const factor = tree.transfer[v] ? costs.transferLoss : 1;
|
|
250
|
+
absent[u] += Math.min(absent[v], present[v] + costs.gain);
|
|
251
|
+
present[u] += Math.min(present[v], absent[v] + loss * factor);
|
|
252
|
+
}
|
|
253
|
+
const states = Array.from({ length: size }, () => false);
|
|
254
|
+
const root = size - 1;
|
|
255
|
+
const rootAbsent = absent[root];
|
|
256
|
+
const rootPresent = present[root] + costs.gain;
|
|
257
|
+
if (Math.min(rootAbsent, rootPresent) >= INF / 2)
|
|
258
|
+
return undefined;
|
|
259
|
+
states[root] = rootPresent < rootAbsent;
|
|
260
|
+
for (let v = size - 2; v >= 0; v--) {
|
|
261
|
+
const u = tree.parent[v];
|
|
262
|
+
const factor = tree.transfer[v] ? costs.transferLoss : 1;
|
|
263
|
+
const a = states[u] ? absent[v] + loss * factor : absent[v];
|
|
264
|
+
const p = states[u] ? present[v] : present[v] + costs.gain;
|
|
265
|
+
states[v] = p < a;
|
|
266
|
+
}
|
|
267
|
+
return states;
|
|
268
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { TrackerBar } from "../systems/TrackerBar.js";
|
|
2
|
+
/**
|
|
3
|
+
* By system, the systems a roll of it was re-cut for, as the procedures
|
|
4
|
+
* of the vocabulary name them: a red roll was re-punched for the
|
|
5
|
+
* Licensee system and for the green one, and never the other way round.
|
|
6
|
+
* @category Filiation
|
|
7
|
+
*/
|
|
8
|
+
export declare const transfersOf: () => ReadonlyMap<string, ReadonlySet<string>>;
|
|
9
|
+
/** Whether a version of the one system can stand above a version of the other: the same system, or one re-cut from it. */
|
|
10
|
+
export declare const leadsTo: (transfers: ReadonlyMap<string, ReadonlySet<string>>, from: string, to: string) => boolean;
|
|
11
|
+
/**
|
|
12
|
+
* The system of a lost version above versions of the given systems: the
|
|
13
|
+
* one they share, or else the one they were all re-cut from. A lost
|
|
14
|
+
* state above a red and a Licensee version is red, since a Licensee
|
|
15
|
+
* version is never re-cut into red. Where no system leads to all of
|
|
16
|
+
* them, nothing.
|
|
17
|
+
* @category Filiation
|
|
18
|
+
*/
|
|
19
|
+
export declare const systemAbove: (transfers: ReadonlyMap<string, ReadonlySet<string>>, systems: ReadonlySet<string>) => string | undefined;
|
|
20
|
+
/** The bar of a system the library knows, by its identifier. */
|
|
21
|
+
export declare const barNamed: (id: string) => TrackerBar | undefined;
|