linked-rolls 0.71.0 → 0.73.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 +90 -0
- package/lib/filiation/adoption.d.ts +47 -0
- package/lib/filiation/adoption.js +189 -0
- package/lib/filiation/changes.d.ts +33 -0
- package/lib/filiation/changes.js +38 -0
- package/lib/filiation/index.d.ts +13 -0
- package/lib/filiation/index.js +13 -0
- package/lib/filiation/presence.d.ts +50 -0
- package/lib/filiation/presence.js +122 -0
- package/lib/filiation/proposal.d.ts +151 -0
- package/lib/filiation/proposal.js +391 -0
- package/lib/filiation/readings.d.ts +108 -0
- package/lib/filiation/readings.js +281 -0
- package/lib/filiation/search.d.ts +118 -0
- package/lib/filiation/search.js +269 -0
- package/lib/filiation/systems.d.ts +21 -0
- package/lib/filiation/systems.js +39 -0
- package/lib/filiation/texts.d.ts +54 -0
- package/lib/filiation/texts.js +96 -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,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A stemma proposed from the copies alone: which versions there are,
|
|
3
|
+
* lost ones included, how they derive from each other, and which
|
|
4
|
+
* readings each inserts and deletes.
|
|
5
|
+
*/
|
|
6
|
+
import { Edition } from "../model/Edition.js";
|
|
7
|
+
import { Millimeters } from "../model/Quantity.js";
|
|
8
|
+
import { CopyCollation, Recoding } from "./readings.js";
|
|
9
|
+
import { PresenceMatrix } from "./presence.js";
|
|
10
|
+
import { Costs, Scored, Support } from "./search.js";
|
|
11
|
+
import { KeptApart, SharedText } from "./texts.js";
|
|
12
|
+
/**
|
|
13
|
+
* How near a reading of the same kind on the other side of the root
|
|
14
|
+
* makes a reading on the less refined side a moved one, where nothing
|
|
15
|
+
* else is said. Hits up to about this distance are more than chance; a
|
|
16
|
+
* wider window agrees with the edition of WM 225 more often, but largely
|
|
17
|
+
* by convention.
|
|
18
|
+
* @category Filiation
|
|
19
|
+
*/
|
|
20
|
+
export declare const defaultMovedWithin: import("../index.js").Quantity<"mm">;
|
|
21
|
+
/**
|
|
22
|
+
* What a proposal is computed with. Every value has a default, the one
|
|
23
|
+
* the edition of WM 225 is reproduced with.
|
|
24
|
+
* @category Filiation
|
|
25
|
+
*/
|
|
26
|
+
export interface FiliationOptions {
|
|
27
|
+
/** The copies whose features are collated; by default every copy with features of its punching. */
|
|
28
|
+
copies?: readonly string[];
|
|
29
|
+
/** What the search counts against a stemma (`defaultCosts`). */
|
|
30
|
+
costs?: Partial<Costs>;
|
|
31
|
+
/**
|
|
32
|
+
* How near a reading of the same kind on the other side of the root
|
|
33
|
+
* makes a reading on the less refined side a moved one (`defaultMovedWithin`).
|
|
34
|
+
*/
|
|
35
|
+
movedWithin?: Millimeters;
|
|
36
|
+
/** How far beyond its text, or the edge of a tear, a copy cannot say what it lacks (3 mm). */
|
|
37
|
+
margin?: Millimeters;
|
|
38
|
+
/**
|
|
39
|
+
* Whether the editor's decisions hold (yes): the edits the editor
|
|
40
|
+
* wrote hold their versions to what they insert and delete, and
|
|
41
|
+
* copies the edition gives one version carry one text.
|
|
42
|
+
*/
|
|
43
|
+
pin?: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* How many readings two copies of one system may differ in and still
|
|
46
|
+
* be taken to carry the same text (`defaultSameText`). Below zero,
|
|
47
|
+
* only what the edition states is taken for one text.
|
|
48
|
+
*/
|
|
49
|
+
sameText?: number;
|
|
50
|
+
/** How many runners-up to report (5). */
|
|
51
|
+
alternatives?: number;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* A version the proposal posits.
|
|
55
|
+
* @category Filiation
|
|
56
|
+
*/
|
|
57
|
+
export interface ProposedVersion {
|
|
58
|
+
/** A name within the proposal: the versions are numbered from the root down. */
|
|
59
|
+
id: string;
|
|
60
|
+
/** The copies carrying the version at first hand; none for a lost state. */
|
|
61
|
+
copies: string[];
|
|
62
|
+
/** The system the version is coded for, by its identifier. */
|
|
63
|
+
system: string;
|
|
64
|
+
/** The version it derives from, by its name within the proposal. */
|
|
65
|
+
parent?: string;
|
|
66
|
+
/** The version of the edition it stands for, where one does. */
|
|
67
|
+
version?: string;
|
|
68
|
+
/** The collated readings the version inserts. */
|
|
69
|
+
inserts: string[];
|
|
70
|
+
/** The collated readings the version deletes. */
|
|
71
|
+
deletes: string[];
|
|
72
|
+
/** The held commands that stand for a latched pair, among what the version inserts and deletes. */
|
|
73
|
+
recodings: Recoding[];
|
|
74
|
+
/** The readings the version holds that a copy carrying it lacks. */
|
|
75
|
+
deviations: {
|
|
76
|
+
copy: string;
|
|
77
|
+
reading: string;
|
|
78
|
+
}[];
|
|
79
|
+
/** The edits the editor wrote on `version` that the proposal keeps as they are. */
|
|
80
|
+
pinned: string[];
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Why an edit the editor wrote is not kept.
|
|
84
|
+
* @category Filiation
|
|
85
|
+
*/
|
|
86
|
+
export type Unpinned = {
|
|
87
|
+
edit: string;
|
|
88
|
+
version: string;
|
|
89
|
+
reason: 'version-gone' | 'reading-gone' | 'contradicts-copies';
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* A stemma proposed from the copies, with what it rests on: the
|
|
93
|
+
* collation, the presence of every reading in every copy, the cost of
|
|
94
|
+
* the best stemma and of the runners-up, and the margin by which the
|
|
95
|
+
* copies decide each of its groupings.
|
|
96
|
+
* @category Filiation
|
|
97
|
+
*/
|
|
98
|
+
export interface StemmaProposal {
|
|
99
|
+
copies: string[];
|
|
100
|
+
/** The copies grouped by the text they carry, each group one version of the stemma. */
|
|
101
|
+
texts: SharedText[];
|
|
102
|
+
/** The closest copies of one system that were kept apart, closest first. */
|
|
103
|
+
apart: KeptApart[];
|
|
104
|
+
sameText: number;
|
|
105
|
+
costs: Costs;
|
|
106
|
+
movedWithin: Millimeters;
|
|
107
|
+
margin: Millimeters;
|
|
108
|
+
collation: CopyCollation;
|
|
109
|
+
matrix: PresenceMatrix;
|
|
110
|
+
/** From the root down. */
|
|
111
|
+
versions: ProposedVersion[];
|
|
112
|
+
/** The stemma in the notation `Scored` uses, the copies by siglum. */
|
|
113
|
+
notation: string;
|
|
114
|
+
cost: number;
|
|
115
|
+
/** How many admissible stemmata were scored. */
|
|
116
|
+
scored: number;
|
|
117
|
+
alternatives: Scored[];
|
|
118
|
+
supports: Support[];
|
|
119
|
+
/** The readings the stemma lets arise more than once, with the versions inserting them. */
|
|
120
|
+
conflicts: {
|
|
121
|
+
reading: string;
|
|
122
|
+
versions: string[];
|
|
123
|
+
}[];
|
|
124
|
+
/** The readings on the less refined side of the root taken for the base text, as moved or cleaned up by the other side. */
|
|
125
|
+
baseText: string[];
|
|
126
|
+
/** By collated reading, the symbol of the edition it stands for, where one does. */
|
|
127
|
+
symbols: Record<string, string>;
|
|
128
|
+
unpinned: Unpinned[];
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Proposes a stemma from the copies.
|
|
132
|
+
*
|
|
133
|
+
* The copies are collated all at once (`collateCopies`), and every
|
|
134
|
+
* reading becomes a character that each copy has, lacks or cannot speak
|
|
135
|
+
* to (`presenceIn`). Every stemma of the copies that the systems admit,
|
|
136
|
+
* up to seven copies, is scored by what it has to let arise and be lost
|
|
137
|
+
* (`Costs`), and the least costly is taken, ties going to fewer lost
|
|
138
|
+
* states. On it, each reading arises in the latest version all copies
|
|
139
|
+
* carrying it descend from, and is deleted where a branch lacks it. A
|
|
140
|
+
* reading only the less refined side of the root carries is taken for
|
|
141
|
+
* the base text where the other side has it moved or cleaned up.
|
|
142
|
+
*
|
|
143
|
+
* Where the edition has versions already, a version of the proposal
|
|
144
|
+
* that a copy carries at first hand stands for the version that copy
|
|
145
|
+
* carries in the edition, and a lost one for the version inserting most
|
|
146
|
+
* of the same readings. An edit the editor wrote on such a version, one
|
|
147
|
+
* a collation would not have written, holds the version to what it
|
|
148
|
+
* inserts and deletes, unless the copies cannot be read so.
|
|
149
|
+
* @category Filiation
|
|
150
|
+
*/
|
|
151
|
+
export declare const proposeStemma: (edition: Edition, options?: FiliationOptions) => StemmaProposal;
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import { barOf } from "../model/RollCopy.js";
|
|
2
|
+
import { idOf } from "../model/Assumption.js";
|
|
3
|
+
import { editsOf, insertedBy } from "../model/Version.js";
|
|
4
|
+
import { mm } from "../model/Quantity.js";
|
|
5
|
+
import { keyOf } from "../systems/TrackerBar.js";
|
|
6
|
+
import { isCollationsOwn } from "../collation/Collation.js";
|
|
7
|
+
import { versionsWitnessedBy } from "../analysis/witnesses.js";
|
|
8
|
+
import { collateCopies, reads } from "./readings.js";
|
|
9
|
+
import { defaultMargin, presenceIn } from "./presence.js";
|
|
10
|
+
import { defaultCosts, searchStemmata, statesOf } from "./search.js";
|
|
11
|
+
import { barNamed, transfersOf } from "./systems.js";
|
|
12
|
+
import { MAX_COPIES } from "./trees.js";
|
|
13
|
+
import { defaultSameText, sharedTexts } from "./texts.js";
|
|
14
|
+
/**
|
|
15
|
+
* How near a reading of the same kind on the other side of the root
|
|
16
|
+
* makes a reading on the less refined side a moved one, where nothing
|
|
17
|
+
* else is said. Hits up to about this distance are more than chance; a
|
|
18
|
+
* wider window agrees with the edition of WM 225 more often, but largely
|
|
19
|
+
* by convention.
|
|
20
|
+
* @category Filiation
|
|
21
|
+
*/
|
|
22
|
+
export const defaultMovedWithin = mm(25);
|
|
23
|
+
/** The copies with features of their own punching, in the order the edition lists them. */
|
|
24
|
+
const transcribed = (edition) => edition.copies
|
|
25
|
+
.filter(copy => (copy.production?.produced ?? []).some(feature => feature.type === 'HoleChain'))
|
|
26
|
+
.map(copy => copy.id);
|
|
27
|
+
const keyOfSymbol = (symbol) => symbol.type === 'text' ? undefined : keyOf(symbol);
|
|
28
|
+
/**
|
|
29
|
+
* By collated reading, the symbol of the edition it stands for: the one
|
|
30
|
+
* of the same meaning sharing most of its carriers, each symbol given to
|
|
31
|
+
* one reading at most. A reading whose carriers grew or shrank keeps the
|
|
32
|
+
* symbol's name, so that a collation repeated does not rename what it
|
|
33
|
+
* finds again.
|
|
34
|
+
*/
|
|
35
|
+
const symbolsFor = (edition, readings) => {
|
|
36
|
+
const byCarrier = new Map();
|
|
37
|
+
for (const reading of readings) {
|
|
38
|
+
for (const carrier of reading.carriers)
|
|
39
|
+
byCarrier.set(carrier, [...(byCarrier.get(carrier) ?? []), reading]);
|
|
40
|
+
}
|
|
41
|
+
const candidates = [];
|
|
42
|
+
let order = 0;
|
|
43
|
+
for (const version of edition.versions) {
|
|
44
|
+
for (const symbol of insertedBy(version)) {
|
|
45
|
+
order++;
|
|
46
|
+
const key = keyOfSymbol(symbol);
|
|
47
|
+
if (!key)
|
|
48
|
+
continue;
|
|
49
|
+
const carriers = symbol.carriers.map(idOf);
|
|
50
|
+
const counts = new Map();
|
|
51
|
+
for (const carrier of carriers) {
|
|
52
|
+
for (const reading of byCarrier.get(carrier) ?? []) {
|
|
53
|
+
if (reading.key === key)
|
|
54
|
+
counts.set(reading, (counts.get(reading) ?? 0) + 1);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
for (const [reading, shared] of counts) {
|
|
58
|
+
const exact = shared === carriers.length && shared === reading.carriers.length;
|
|
59
|
+
candidates.push({ symbol: symbol.id, reading: reading.id, shared, exact, order });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
candidates.sort((a, b) => Number(b.exact) - Number(a.exact) || b.shared - a.shared || a.order - b.order || (a.reading < b.reading ? -1 : 1));
|
|
64
|
+
const symbols = {};
|
|
65
|
+
const taken = new Set();
|
|
66
|
+
for (const { symbol, reading } of candidates) {
|
|
67
|
+
if (taken.has(symbol) || symbols[reading])
|
|
68
|
+
continue;
|
|
69
|
+
symbols[reading] = symbol;
|
|
70
|
+
taken.add(symbol);
|
|
71
|
+
}
|
|
72
|
+
return symbols;
|
|
73
|
+
};
|
|
74
|
+
const childrenOf = (tree) => {
|
|
75
|
+
const children = Array.from({ length: tree.copy.length }, () => []);
|
|
76
|
+
tree.parent.forEach((parent, child) => { if (parent >= 0)
|
|
77
|
+
children[parent].push(child); });
|
|
78
|
+
return children;
|
|
79
|
+
};
|
|
80
|
+
/** The nodes from the root down, each before the nodes derived from it. */
|
|
81
|
+
const fromRoot = (tree) => {
|
|
82
|
+
const children = childrenOf(tree);
|
|
83
|
+
const order = [];
|
|
84
|
+
const visit = (v) => {
|
|
85
|
+
order.push(v);
|
|
86
|
+
children[v].forEach(visit);
|
|
87
|
+
};
|
|
88
|
+
visit(tree.copy.length - 1);
|
|
89
|
+
return order;
|
|
90
|
+
};
|
|
91
|
+
const inserting = (tree, states, v) => states[v] && (tree.parent[v] < 0 || !states[tree.parent[v]]);
|
|
92
|
+
const deleting = (tree, states, v) => !states[v] && tree.parent[v] >= 0 && states[tree.parent[v]];
|
|
93
|
+
/**
|
|
94
|
+
* The readings on the less refined side of the root that the other side
|
|
95
|
+
* moved or cleaned up, rather than that this side added.
|
|
96
|
+
*
|
|
97
|
+
* Where the root has two branches, presence alone cannot tell a reading
|
|
98
|
+
* one branch added from a reading of the base text the other changed.
|
|
99
|
+
* Refinement orients the root: the branch whose copies carry fewer
|
|
100
|
+
* dynamic perforations is the earlier text. A reading only that branch
|
|
101
|
+
* carries is then taken for the base text where the other branch carries
|
|
102
|
+
* a reading of the same kind within the window and this branch does not
|
|
103
|
+
* (moved), or where it is ineffective (cleaned up, as redundancies are).
|
|
104
|
+
*/
|
|
105
|
+
const baseTextOf = (tree, readings, matrix, placement, held, within, texts, systems) => {
|
|
106
|
+
const root = tree.copy.length - 1;
|
|
107
|
+
const children = childrenOf(tree)[root];
|
|
108
|
+
if (tree.copy[root] >= 0 || children.length !== 2)
|
|
109
|
+
return [];
|
|
110
|
+
const copiesBelow = (v) => texts.filter((_, t) => tree.below[v] & (1 << t)).flat();
|
|
111
|
+
const rootSystem = tree.system[root];
|
|
112
|
+
/** How many expression perforations the copies of the root's system below the node carry, on average. */
|
|
113
|
+
const densityOf = (v) => {
|
|
114
|
+
const copies = texts.filter((_, t) => (tree.below[v] & (1 << t)) && systems[t] === rootSystem).flat();
|
|
115
|
+
if (copies.length === 0)
|
|
116
|
+
return Infinity;
|
|
117
|
+
const counts = copies.map(copy => readings.filter(reading => reading.meaning.type === 'expression' && matrix.presence[reading.id][copy] === 'present').length);
|
|
118
|
+
return counts.reduce((sum, count) => sum + count, 0) / counts.length;
|
|
119
|
+
};
|
|
120
|
+
const [one, other] = children;
|
|
121
|
+
const [side, refined] = densityOf(one) < densityOf(other) ? [one, other] : densityOf(other) < densityOf(one) ? [other, one] : [-1, -1];
|
|
122
|
+
if (side < 0)
|
|
123
|
+
return [];
|
|
124
|
+
const sideCopies = copiesBelow(side);
|
|
125
|
+
const refinedCopies = copiesBelow(refined);
|
|
126
|
+
const redundant = new Set(matrix.redundant);
|
|
127
|
+
const byKey = new Map();
|
|
128
|
+
for (const reading of readings)
|
|
129
|
+
byKey.set(reading.key, [...(byKey.get(reading.key) ?? []), reading]);
|
|
130
|
+
const moved = [];
|
|
131
|
+
for (const reading of readings) {
|
|
132
|
+
if (held.has(reading.id))
|
|
133
|
+
continue;
|
|
134
|
+
const states = placement.get(reading.id);
|
|
135
|
+
if (!inserting(tree, states, side))
|
|
136
|
+
continue;
|
|
137
|
+
const near = (byKey.get(reading.key) ?? []).some(other => other.id !== reading.id
|
|
138
|
+
&& Math.abs(other.place.from - reading.place.from) <= within
|
|
139
|
+
&& refinedCopies.some(copy => matrix.presence[other.id][copy] === 'present')
|
|
140
|
+
&& !sideCopies.some(copy => matrix.presence[other.id][copy] === 'present'));
|
|
141
|
+
if (!near && !redundant.has(reading.id))
|
|
142
|
+
continue;
|
|
143
|
+
states[root] = true;
|
|
144
|
+
moved.push(reading.id);
|
|
145
|
+
}
|
|
146
|
+
return moved;
|
|
147
|
+
};
|
|
148
|
+
/**
|
|
149
|
+
* Where a version is coded for a system whose bar has no word for a
|
|
150
|
+
* reading it inherits, the reading does not go on: the re-cut deletes
|
|
151
|
+
* it, and what stands for it there is a reading of its own.
|
|
152
|
+
*/
|
|
153
|
+
const recode = (tree, readings, placement) => {
|
|
154
|
+
const order = fromRoot(tree);
|
|
155
|
+
for (const reading of readings) {
|
|
156
|
+
const states = placement.get(reading.id);
|
|
157
|
+
for (const v of order) {
|
|
158
|
+
const bar = barNamed(tree.system[v]);
|
|
159
|
+
if (states[v] && bar && !reads(bar, reading.meaning))
|
|
160
|
+
states[v] = false;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
/**
|
|
165
|
+
* Proposes a stemma from the copies.
|
|
166
|
+
*
|
|
167
|
+
* The copies are collated all at once (`collateCopies`), and every
|
|
168
|
+
* reading becomes a character that each copy has, lacks or cannot speak
|
|
169
|
+
* to (`presenceIn`). Every stemma of the copies that the systems admit,
|
|
170
|
+
* up to seven copies, is scored by what it has to let arise and be lost
|
|
171
|
+
* (`Costs`), and the least costly is taken, ties going to fewer lost
|
|
172
|
+
* states. On it, each reading arises in the latest version all copies
|
|
173
|
+
* carrying it descend from, and is deleted where a branch lacks it. A
|
|
174
|
+
* reading only the less refined side of the root carries is taken for
|
|
175
|
+
* the base text where the other side has it moved or cleaned up.
|
|
176
|
+
*
|
|
177
|
+
* Where the edition has versions already, a version of the proposal
|
|
178
|
+
* that a copy carries at first hand stands for the version that copy
|
|
179
|
+
* carries in the edition, and a lost one for the version inserting most
|
|
180
|
+
* of the same readings. An edit the editor wrote on such a version, one
|
|
181
|
+
* a collation would not have written, holds the version to what it
|
|
182
|
+
* inserts and deletes, unless the copies cannot be read so.
|
|
183
|
+
* @category Filiation
|
|
184
|
+
*/
|
|
185
|
+
export const proposeStemma = (edition, options = {}) => {
|
|
186
|
+
const copies = transcribed(edition).filter(id => !options.copies || options.copies.includes(id));
|
|
187
|
+
if (copies.length < 2)
|
|
188
|
+
throw new Error('A stemma needs at least two copies with features');
|
|
189
|
+
const costs = { ...defaultCosts, ...options.costs };
|
|
190
|
+
const movedWithin = options.movedWithin ?? defaultMovedWithin;
|
|
191
|
+
const margin = options.margin ?? defaultMargin;
|
|
192
|
+
const sameText = options.sameText ?? defaultSameText;
|
|
193
|
+
const copyOf = new Map(edition.copies.map(copy => [copy.id, copy]));
|
|
194
|
+
const systemOfCopy = new Map(copies.map(id => [id, barOf(copyOf.get(id)).id]));
|
|
195
|
+
const transfers = transfersOf();
|
|
196
|
+
const firstHandOf = (copy) => versionsWitnessedBy(edition, copy)
|
|
197
|
+
.find(witness => witness.by === 'carriers' && witness.through === undefined)?.version;
|
|
198
|
+
const collation = collateCopies(edition, copies);
|
|
199
|
+
const matrix = presenceIn(edition, collation, margin);
|
|
200
|
+
// Copies carrying one text are one version, and the search is over texts.
|
|
201
|
+
const stated = options.pin === false ? [] : [...Map.groupBy(copies.flatMap(copy => { const version = firstHandOf(copy); return version ? [{ copy, version }] : []; }), ({ version }) => version).values()].map(group => group.map(({ copy }) => copy));
|
|
202
|
+
const shared = sharedTexts(matrix, copies.map(copy => systemOfCopy.get(copy)), sameText, stated);
|
|
203
|
+
const texts = shared.texts.map(text => text.copies);
|
|
204
|
+
if (texts.length > MAX_COPIES) {
|
|
205
|
+
throw new Error(`The exhaustive search covers at most ${MAX_COPIES} different texts, and these copies carry ${texts.length}`);
|
|
206
|
+
}
|
|
207
|
+
const names = texts.map(text => text.map(id => copyOf.get(id).siglum ?? id).join('+'));
|
|
208
|
+
const systems = texts.map(text => systemOfCopy.get(text[0]));
|
|
209
|
+
const redundant = new Set(matrix.redundant);
|
|
210
|
+
const textPresence = (reading, text) => {
|
|
211
|
+
const each = text.map(copy => matrix.presence[reading][copy]);
|
|
212
|
+
return each.includes('present') ? 'present' : each.includes('absent') ? 'absent' : 'unknown';
|
|
213
|
+
};
|
|
214
|
+
const rows = collation.readings.map(reading => ({
|
|
215
|
+
presence: texts.map(text => textPresence(reading.id, text)),
|
|
216
|
+
redundant: redundant.has(reading.id)
|
|
217
|
+
}));
|
|
218
|
+
const search = searchStemmata(texts, names, systems, rows, costs, transfers, options.alternatives ?? 5);
|
|
219
|
+
const tree = search.best;
|
|
220
|
+
const size = tree.copy.length;
|
|
221
|
+
const place = (fixed) => {
|
|
222
|
+
const placement = new Map();
|
|
223
|
+
const failed = new Set();
|
|
224
|
+
collation.readings.forEach((reading, index) => {
|
|
225
|
+
const pinned = fixed.get(reading.id);
|
|
226
|
+
let states = statesOf(tree, rows[index].presence, rows[index].redundant, costs, pinned);
|
|
227
|
+
if (!states) {
|
|
228
|
+
failed.add(reading.id);
|
|
229
|
+
states = statesOf(tree, rows[index].presence, rows[index].redundant, costs);
|
|
230
|
+
}
|
|
231
|
+
placement.set(reading.id, states);
|
|
232
|
+
});
|
|
233
|
+
return { placement, failed };
|
|
234
|
+
};
|
|
235
|
+
const symbols = symbolsFor(edition, collation.readings);
|
|
236
|
+
const readingOf = new Map(Object.entries(symbols).map(([reading, symbol]) => [symbol, reading]));
|
|
237
|
+
// A first placement, free of the editor's edits, tells which version a lost node stands for.
|
|
238
|
+
const free = place(new Map());
|
|
239
|
+
recode(tree, collation.readings, free.placement);
|
|
240
|
+
const standsFor = new Map();
|
|
241
|
+
const claimed = new Set();
|
|
242
|
+
for (let v = 0; v < size; v++) {
|
|
243
|
+
const text = tree.copy[v];
|
|
244
|
+
if (text < 0)
|
|
245
|
+
continue;
|
|
246
|
+
const firstHand = texts[text].map(firstHandOf).find(version => version !== undefined && !claimed.has(version));
|
|
247
|
+
if (firstHand) {
|
|
248
|
+
standsFor.set(v, firstHand);
|
|
249
|
+
claimed.add(firstHand);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
const insertsAt = (placement, v) => collation.readings.filter(reading => inserting(tree, placement.get(reading.id), v)).map(reading => symbols[reading.id] ?? reading.id);
|
|
253
|
+
const lostMatches = [];
|
|
254
|
+
for (let v = 0; v < size; v++) {
|
|
255
|
+
if (tree.copy[v] >= 0)
|
|
256
|
+
continue;
|
|
257
|
+
const proposed = new Set(insertsAt(free.placement, v));
|
|
258
|
+
for (const version of edition.versions) {
|
|
259
|
+
if (claimed.has(version.id))
|
|
260
|
+
continue;
|
|
261
|
+
const stated = insertedBy(version).map(symbol => symbol.id);
|
|
262
|
+
const shared = stated.filter(id => proposed.has(id)).length;
|
|
263
|
+
const union = new Set([...stated, ...proposed]).size;
|
|
264
|
+
if (shared > 0)
|
|
265
|
+
lostMatches.push({ v, version: version.id, overlap: shared / union });
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
lostMatches.sort((a, b) => b.overlap - a.overlap || a.v - b.v);
|
|
269
|
+
for (const { v, version } of lostMatches) {
|
|
270
|
+
if (standsFor.has(v) || claimed.has(version))
|
|
271
|
+
continue;
|
|
272
|
+
standsFor.set(v, version);
|
|
273
|
+
claimed.add(version);
|
|
274
|
+
}
|
|
275
|
+
// The editor's edits hold their versions, as far as the copies allow.
|
|
276
|
+
const unpinned = [];
|
|
277
|
+
const pins = new Map();
|
|
278
|
+
const fixed = new Map();
|
|
279
|
+
const nodeOf = new Map([...standsFor].map(([v, version]) => [version, v]));
|
|
280
|
+
const meaningOf = new Map(collation.readings.map(reading => [reading.id, reading.meaning]));
|
|
281
|
+
if (options.pin !== false) {
|
|
282
|
+
for (const version of edition.versions) {
|
|
283
|
+
const established = editsOf(version).filter(edit => !isCollationsOwn(edit));
|
|
284
|
+
const v = nodeOf.get(version.id);
|
|
285
|
+
for (const edit of established) {
|
|
286
|
+
if (v === undefined) {
|
|
287
|
+
unpinned.push({ edit: edit.id, version: version.id, reason: 'version-gone' });
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
const inserted = (edit.insert ?? []).map(symbol => readingOf.get(symbol.id));
|
|
291
|
+
const deleted = (edit.delete ?? []).map(id => readingOf.get(id));
|
|
292
|
+
if ([...inserted, ...deleted].some(reading => reading === undefined)) {
|
|
293
|
+
unpinned.push({ edit: edit.id, version: version.id, reason: 'reading-gone' });
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
const bar = barNamed(tree.system[v]);
|
|
297
|
+
const hold = (reading, node, state) => {
|
|
298
|
+
const states = fixed.get(reading) ?? new Map();
|
|
299
|
+
states.set(node, state);
|
|
300
|
+
fixed.set(reading, states);
|
|
301
|
+
};
|
|
302
|
+
for (const reading of inserted) {
|
|
303
|
+
if (bar && !reads(bar, meaningOf.get(reading)))
|
|
304
|
+
continue;
|
|
305
|
+
hold(reading, v, true);
|
|
306
|
+
if (tree.parent[v] >= 0)
|
|
307
|
+
hold(reading, tree.parent[v], false);
|
|
308
|
+
}
|
|
309
|
+
for (const reading of deleted) {
|
|
310
|
+
if (bar && !reads(bar, meaningOf.get(reading)))
|
|
311
|
+
continue;
|
|
312
|
+
hold(reading, v, false);
|
|
313
|
+
if (tree.parent[v] >= 0)
|
|
314
|
+
hold(reading, tree.parent[v], true);
|
|
315
|
+
}
|
|
316
|
+
pins.set(v, [...(pins.get(v) ?? []), { edit: edit.id, readings: [...inserted, ...deleted] }]);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
const { placement, failed } = place(fixed);
|
|
321
|
+
const held = new Set([...fixed.keys()].filter(reading => !failed.has(reading)));
|
|
322
|
+
const baseText = baseTextOf(tree, collation.readings, matrix, placement, held, movedWithin, texts, systems);
|
|
323
|
+
recode(tree, collation.readings, placement);
|
|
324
|
+
for (const [v, edits] of pins) {
|
|
325
|
+
for (const { edit, readings } of edits) {
|
|
326
|
+
if (readings.some(reading => failed.has(reading))) {
|
|
327
|
+
unpinned.push({ edit, version: standsFor.get(v), reason: 'contradicts-copies' });
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const released = new Set(unpinned.map(({ edit }) => edit));
|
|
332
|
+
// Pre-order numbering, so that the root is p0.
|
|
333
|
+
const order = fromRoot(tree);
|
|
334
|
+
const nameOf = new Map(order.map((v, index) => [v, `p${index}`]));
|
|
335
|
+
const versions = order.map((v) => {
|
|
336
|
+
const inserts = collation.readings.filter(reading => inserting(tree, placement.get(reading.id), v)).map(reading => reading.id);
|
|
337
|
+
const deletes = collation.readings.filter(reading => deleting(tree, placement.get(reading.id), v)).map(reading => reading.id);
|
|
338
|
+
const inserted = new Set(inserts);
|
|
339
|
+
const deleted = new Set(deletes);
|
|
340
|
+
const used = new Set();
|
|
341
|
+
const recodings = tree.parent[v] >= 0 && tree.transfer[v]
|
|
342
|
+
? collation.recodings.filter(recoding => {
|
|
343
|
+
const fits = recoding.by.every(id => inserted.has(id) && !used.has(id))
|
|
344
|
+
&& recoding.replaced.every(id => deleted.has(id) && !used.has(id));
|
|
345
|
+
if (fits)
|
|
346
|
+
[...recoding.by, ...recoding.replaced].forEach(id => used.add(id));
|
|
347
|
+
return fits;
|
|
348
|
+
})
|
|
349
|
+
: [];
|
|
350
|
+
const carrying = tree.copy[v] >= 0 ? texts[tree.copy[v]] : [];
|
|
351
|
+
const deviations = carrying.flatMap(copy => collation.readings
|
|
352
|
+
.filter(reading => placement.get(reading.id)[v] && matrix.presence[reading.id][copy] === 'absent')
|
|
353
|
+
.map(reading => ({ copy, reading: reading.id })));
|
|
354
|
+
return {
|
|
355
|
+
id: nameOf.get(v),
|
|
356
|
+
copies: [...carrying],
|
|
357
|
+
system: tree.system[v],
|
|
358
|
+
...(tree.parent[v] >= 0 && { parent: nameOf.get(tree.parent[v]) }),
|
|
359
|
+
...(standsFor.has(v) && { version: standsFor.get(v) }),
|
|
360
|
+
inserts,
|
|
361
|
+
deletes,
|
|
362
|
+
recodings,
|
|
363
|
+
deviations,
|
|
364
|
+
pinned: (pins.get(v) ?? []).map(({ edit }) => edit).filter(edit => !released.has(edit))
|
|
365
|
+
};
|
|
366
|
+
});
|
|
367
|
+
const conflicts = collation.readings
|
|
368
|
+
.map(reading => ({ reading: reading.id, versions: versions.filter(version => version.inserts.includes(reading.id)).map(version => version.id) }))
|
|
369
|
+
.filter(({ versions }) => versions.length > 1);
|
|
370
|
+
return {
|
|
371
|
+
copies,
|
|
372
|
+
texts: shared.texts,
|
|
373
|
+
apart: shared.apart,
|
|
374
|
+
sameText,
|
|
375
|
+
costs,
|
|
376
|
+
movedWithin,
|
|
377
|
+
margin,
|
|
378
|
+
collation,
|
|
379
|
+
matrix,
|
|
380
|
+
versions,
|
|
381
|
+
notation: tree.notation,
|
|
382
|
+
cost: search.cost,
|
|
383
|
+
scored: search.scored,
|
|
384
|
+
alternatives: search.alternatives,
|
|
385
|
+
supports: search.supports,
|
|
386
|
+
conflicts,
|
|
387
|
+
baseText,
|
|
388
|
+
symbols,
|
|
389
|
+
unpinned
|
|
390
|
+
};
|
|
391
|
+
};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { Edition } from "../model/Edition.js";
|
|
2
|
+
import { HorizontalSpan } from "../model/Feature.js";
|
|
3
|
+
import { RollCopy } from "../model/RollCopy.js";
|
|
4
|
+
import { Millimeters } from "../model/Quantity.js";
|
|
5
|
+
import { TrackerBar, TrackMeaning } from "../systems/TrackerBar.js";
|
|
6
|
+
import { CollationTolerance } from "../collation/Collation.js";
|
|
7
|
+
/**
|
|
8
|
+
* One copy's reading of one feature: what the copy's bar reads off it,
|
|
9
|
+
* and where.
|
|
10
|
+
* @category Filiation
|
|
11
|
+
*/
|
|
12
|
+
export interface CopyReading {
|
|
13
|
+
/** The feature and what it is read as, since a feature across two positions reads as two commands. */
|
|
14
|
+
id: string;
|
|
15
|
+
copy: string;
|
|
16
|
+
feature: string;
|
|
17
|
+
meaning: TrackMeaning;
|
|
18
|
+
key: string;
|
|
19
|
+
place: HorizontalSpan;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* A reading the copies share: the readings of one meaning that lie at
|
|
23
|
+
* about the same place, at most one of each copy.
|
|
24
|
+
* @category Filiation
|
|
25
|
+
*/
|
|
26
|
+
export interface CollatedReading {
|
|
27
|
+
/**
|
|
28
|
+
* A name for the reading, the same for the same meaning on the same
|
|
29
|
+
* features, so that collating again gives it again.
|
|
30
|
+
*/
|
|
31
|
+
id: string;
|
|
32
|
+
meaning: TrackMeaning;
|
|
33
|
+
key: string;
|
|
34
|
+
/** The features carrying it, each on another copy. */
|
|
35
|
+
carriers: string[];
|
|
36
|
+
/** The copies carrying it, in the order the collation was given them. */
|
|
37
|
+
copies: string[];
|
|
38
|
+
/** Where it lies: the median of its carriers at either end. */
|
|
39
|
+
place: HorizontalSpan;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* How far two copies may put one reading apart, and where that window
|
|
43
|
+
* is centred: the systematic difference between the two and the scatter
|
|
44
|
+
* about it, as `scatter.ts` measures a derivation's.
|
|
45
|
+
* @category Filiation
|
|
46
|
+
*/
|
|
47
|
+
export interface PairWindow extends CollationTolerance {
|
|
48
|
+
/** How many readings the window was measured on; none where it fell back on the default. */
|
|
49
|
+
n: number;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* A command one copy holds where another latches, as a transfer into a
|
|
53
|
+
* system with held commands spells it: the readings of the one standing
|
|
54
|
+
* for the readings of the other.
|
|
55
|
+
* @category Filiation
|
|
56
|
+
*/
|
|
57
|
+
export interface Recoding {
|
|
58
|
+
/** The collated readings of the latched pair. */
|
|
59
|
+
replaced: string[];
|
|
60
|
+
/** The collated readings of the held command. */
|
|
61
|
+
by: string[];
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The collation of a set of copies.
|
|
65
|
+
* @category Filiation
|
|
66
|
+
*/
|
|
67
|
+
export interface CopyCollation {
|
|
68
|
+
copies: string[];
|
|
69
|
+
readings: CollatedReading[];
|
|
70
|
+
/** By pair of copies (`a|b|group`, the pair in the order the copies were given), the window used. */
|
|
71
|
+
windows: Record<string, PairWindow>;
|
|
72
|
+
recodings: Recoding[];
|
|
73
|
+
/** By copy, where its text begins and ends. */
|
|
74
|
+
extent: Record<string, {
|
|
75
|
+
from: Millimeters;
|
|
76
|
+
to: Millimeters;
|
|
77
|
+
}>;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* What the copy's own punching put on the paper and its bar reads as
|
|
81
|
+
* text, in order of onset.
|
|
82
|
+
*
|
|
83
|
+
* A later alteration of the copy is not part of what was punched from
|
|
84
|
+
* the version, so only the features of the production count. Where the
|
|
85
|
+
* bar finds the roll's end, reading stops, and the end itself is not
|
|
86
|
+
* read. A command no other scale has a word for (the motor, the rewind,
|
|
87
|
+
* the electric cut-off) runs the instrument rather than the music and
|
|
88
|
+
* is no reading either.
|
|
89
|
+
* @category Filiation
|
|
90
|
+
*/
|
|
91
|
+
export declare const readingsOfCopy: (copy: Readonly<RollCopy>) => CopyReading[];
|
|
92
|
+
/**
|
|
93
|
+
* Collates the copies all at once. Every reading is compared with every
|
|
94
|
+
* reading of the same meaning on the other copies, and two are taken to
|
|
95
|
+
* be one where they lie within the window of their pair of copies at
|
|
96
|
+
* both ends. The readings are then joined nearest pair first, and never
|
|
97
|
+
* so that one copy would read a joined reading twice.
|
|
98
|
+
*
|
|
99
|
+
* Collating one copy after another against what was collated before, as
|
|
100
|
+
* connecting versions does, depends on the order: six copies collated in
|
|
101
|
+
* their 720 orders give hundreds of different results. Compared all with
|
|
102
|
+
* all, the result is the same whichever order the copies are given in,
|
|
103
|
+
* and only the copies themselves decide it.
|
|
104
|
+
* @category Filiation
|
|
105
|
+
*/
|
|
106
|
+
export declare const collateCopies: (edition: Pick<Edition, 'copies'>, copyIds: readonly string[]) => CopyCollation;
|
|
107
|
+
/** Whether the bar has a word for the meaning at all. */
|
|
108
|
+
export declare const reads: (bar: TrackerBar, meaning: TrackMeaning) => boolean;
|