linked-rolls 0.72.0 → 0.74.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 +40 -1
- package/lib/filiation/adoption.js +3 -4
- package/lib/filiation/edits.d.ts +134 -0
- package/lib/filiation/edits.js +520 -0
- package/lib/filiation/index.d.ts +4 -2
- package/lib/filiation/index.js +4 -2
- package/lib/filiation/proposal.d.ts +24 -5
- package/lib/filiation/proposal.js +39 -24
- package/lib/filiation/search.d.ts +12 -7
- package/lib/filiation/search.js +9 -8
- package/lib/filiation/texts.d.ts +54 -0
- package/lib/filiation/texts.js +96 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -103,7 +103,11 @@ Which versions there are, lost ones included, how they derive from each
|
|
|
103
103
|
other and which edits belong to which can be proposed from the copies
|
|
104
104
|
alone. `proposeStemma` collates the transcribed copies all at once,
|
|
105
105
|
notes which copies have, lack or cannot show each reading, and scores
|
|
106
|
-
every stemma the copies admit
|
|
106
|
+
every stemma the copies admit. Copies of one system that differ in no
|
|
107
|
+
more than a few readings, or that the edition gives one version, are
|
|
108
|
+
taken to carry one text, and so one version, as pressings of one master
|
|
109
|
+
do; what one of them lacks is a deviation of that copy. The search
|
|
110
|
+
covers up to seven such texts, however many copies carry them. A reading arising
|
|
107
111
|
costs ten times a reading lost. Losses count for less where the
|
|
108
112
|
editorial principles of an edition say they are weak evidence: a
|
|
109
113
|
redundancy cleaned up, or what a re-cut for another system lacks. On
|
|
@@ -133,6 +137,41 @@ six copies. They were found by searching for such values, so they show
|
|
|
133
137
|
that the stemma is consistent with these principles, not that the
|
|
134
138
|
principles confirm it.
|
|
135
139
|
|
|
140
|
+
## Proposing the edits
|
|
141
|
+
|
|
142
|
+
What a version inserts and deletes can be grouped into edits and typed
|
|
143
|
+
by their shape alone. `proposeEdits` reads a version's changes line by
|
|
144
|
+
line, a latched function, a held one or a pitch, and never across a
|
|
145
|
+
command both texts keep, so that an edit stays at one place. A function
|
|
146
|
+
turned on and off again is one edit, typed an additional accent where
|
|
147
|
+
the bar spells an accent so; a perforation put elsewhere is one move
|
|
148
|
+
with the one it replaces, typed a shift, or shortened or prolonged
|
|
149
|
+
where it begins about where the other did; overlapping notes are one
|
|
150
|
+
edit, a note struck again or two tied; a held command standing for a
|
|
151
|
+
latched pair is one recoding; a command the text makes no use of is a
|
|
152
|
+
redundancy added or removed; cancels punched across lines at one place
|
|
153
|
+
are one row. `adoptEdits` puts the proposal in place of the edits a
|
|
154
|
+
collation wrote and nobody has motivated, and keeps the rest.
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
import { adoptEdits, adoptStemma, proposeEdits, proposeStemma } from 'linked-rolls'
|
|
158
|
+
|
|
159
|
+
proposeEdits(edition, versionId) // [{ insert, delete, editType, grouping }, …]
|
|
160
|
+
|
|
161
|
+
// The stemma and the edits of its versions, as one undo step
|
|
162
|
+
const adopted = produce(edition, draft => {
|
|
163
|
+
adoptStemma(proposeStemma(edition))(draft)
|
|
164
|
+
adoptEdits()(draft)
|
|
165
|
+
})
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Why an edit was made is not read off its shape, and neither is whether
|
|
169
|
+
a shift corrects an error: what is left to the editor is the
|
|
170
|
+
motivation. On WM 225 the proposal reproduces 370 of the 414 edits the
|
|
171
|
+
editor wrote, from what each version changes, and all of their types
|
|
172
|
+
but the shifts the editor took for corrected errors. From the copies
|
|
173
|
+
alone, through the proposed stemma, it reproduces 326.
|
|
174
|
+
|
|
136
175
|
## Format
|
|
137
176
|
|
|
138
177
|
The export uses the Roll Edition Ontology (REO), namespace
|
|
@@ -47,8 +47,7 @@ export const adoptionOf = (edition, proposal) => {
|
|
|
47
47
|
const copiesBelow = new Map(proposal.versions.map(proposed => [proposed.id, new Set()]));
|
|
48
48
|
for (const proposed of proposal.versions) {
|
|
49
49
|
for (let at = proposed.id; at !== undefined; at = parentOf.get(at)) {
|
|
50
|
-
|
|
51
|
-
copiesBelow.get(at).add(proposed.copy);
|
|
50
|
+
proposed.copies.forEach(copy => copiesBelow.get(at).add(copy));
|
|
52
51
|
}
|
|
53
52
|
}
|
|
54
53
|
const arising = new Map();
|
|
@@ -92,7 +91,7 @@ export const adoptionOf = (edition, proposal) => {
|
|
|
92
91
|
const reading = standingFor.get(symbol.id);
|
|
93
92
|
return reading ? { ...symbol, carriers: carriersOf(readingOf.get(reading), symbol.carriers) } : symbol;
|
|
94
93
|
};
|
|
95
|
-
const idFor = (proposed) => proposed.version ?? v5(`version|${proposed.
|
|
94
|
+
const idFor = (proposed) => proposed.version ?? v5(`version|${proposed.copies.join(',')}|${[...proposed.inserts].sort().join(',')}`, ADOPTED);
|
|
96
95
|
const ids = new Map(proposal.versions.map(proposed => [proposed.id, idFor(proposed)]));
|
|
97
96
|
const staying = new Set(ids.values());
|
|
98
97
|
const versions = proposal.versions.map((proposed) => {
|
|
@@ -142,7 +141,7 @@ export const adoptionOf = (edition, proposal) => {
|
|
|
142
141
|
const hypotheses = (before?.basedOn ?? [])
|
|
143
142
|
.filter(other => other !== principal && staying.has(idOf(other)) && idOf(other) !== parent);
|
|
144
143
|
const basedOn = [...(derivation ? [derivation] : []), ...hypotheses];
|
|
145
|
-
const copy =
|
|
144
|
+
const copy = edition.copies.find(copy => copy.id === proposed.copies[0]);
|
|
146
145
|
const bar = barNamed(proposed.system);
|
|
147
146
|
const system = copy?.production?.system ?? before?.system ?? (bar ? systemOf(bar) : { id: proposed.system, name: proposed.system, sameAs: [] });
|
|
148
147
|
const { basedOn: _, edits: __, ...rest } = before ?? {};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { Edition } from "../model/Edition.js";
|
|
2
|
+
import { Edit, EditType } from "../model/Edit.js";
|
|
3
|
+
import { Millimeters } from "../model/Quantity.js";
|
|
4
|
+
import { EditionOp } from "../ops/draft.js";
|
|
5
|
+
/**
|
|
6
|
+
* How far a perforation may be put from where it stood for the two to be
|
|
7
|
+
* one move. WM 225 moves commands by up to about 60 mm.
|
|
8
|
+
* @category Filiation
|
|
9
|
+
*/
|
|
10
|
+
export declare const defaultShiftWithin: import("../index.js").Quantity<"mm">;
|
|
11
|
+
/**
|
|
12
|
+
* How far an Off may follow an On for the two to be one function turned
|
|
13
|
+
* on and off again, as an accent is. WM 225 has crescendo accents of up
|
|
14
|
+
* to about 55 mm.
|
|
15
|
+
* @category Filiation
|
|
16
|
+
*/
|
|
17
|
+
export declare const defaultPairWithin: import("../index.js").Quantity<"mm">;
|
|
18
|
+
/**
|
|
19
|
+
* How near the onsets of a perforation and the one put in its place lie
|
|
20
|
+
* for the one to be a longer or shorter version of the other rather than
|
|
21
|
+
* a moved one. The figure `mergeEdits` guesses with.
|
|
22
|
+
* @category Filiation
|
|
23
|
+
*/
|
|
24
|
+
export declare const defaultResizeWithin: import("../index.js").Quantity<"mm">;
|
|
25
|
+
/**
|
|
26
|
+
* How near two perforations lie to stand at one place: the commands of a
|
|
27
|
+
* cancel row, or an accent's On and the note set off for it.
|
|
28
|
+
* @category Filiation
|
|
29
|
+
*/
|
|
30
|
+
export declare const defaultSamePlaceWithin: import("../index.js").Quantity<"mm">;
|
|
31
|
+
/**
|
|
32
|
+
* What a proposal of edits is computed with. Every value has a default,
|
|
33
|
+
* the one the edits of WM 225 are reproduced with.
|
|
34
|
+
* @category Filiation
|
|
35
|
+
*/
|
|
36
|
+
export interface EditOptions {
|
|
37
|
+
/** `defaultShiftWithin` */
|
|
38
|
+
shiftWithin?: Millimeters;
|
|
39
|
+
/** `defaultPairWithin` */
|
|
40
|
+
pairWithin?: Millimeters;
|
|
41
|
+
/** `defaultResizeWithin` */
|
|
42
|
+
resizeWithin?: Millimeters;
|
|
43
|
+
/** `defaultSamePlaceWithin` */
|
|
44
|
+
samePlaceWithin?: Millimeters;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Why changes were taken for one edit.
|
|
48
|
+
* @category Filiation
|
|
49
|
+
*/
|
|
50
|
+
export type EditGrouping =
|
|
51
|
+
/** A change nothing else on its line goes with. */
|
|
52
|
+
'single'
|
|
53
|
+
/** A latched function turned on and off again shortly after, as an accent is, or off and on again, added or withdrawn. */
|
|
54
|
+
| 'pair'
|
|
55
|
+
/** Notes of one pitch, or held commands of one function, that overlap: a note struck again, two tied into one, one made longer. */
|
|
56
|
+
| 'overlap'
|
|
57
|
+
/** What the version deletes on a line, and what it puts nearby in its place. */
|
|
58
|
+
| 'move'
|
|
59
|
+
/** A held command of the version standing for a latched pair of the parent's. */
|
|
60
|
+
| 'substitution'
|
|
61
|
+
/** Cancels punched across several lines at one place. */
|
|
62
|
+
| 'row';
|
|
63
|
+
/**
|
|
64
|
+
* An edit proposed from the changes of a version.
|
|
65
|
+
* @category Filiation
|
|
66
|
+
*/
|
|
67
|
+
export interface ProposedEdit {
|
|
68
|
+
/** The ids of the symbols it inserts, in the order of their places. */
|
|
69
|
+
insert: string[];
|
|
70
|
+
/** The ids of the symbols it deletes, in the order of their places. */
|
|
71
|
+
delete: string[];
|
|
72
|
+
/** The kind of change, where its shape says. */
|
|
73
|
+
editType?: EditType;
|
|
74
|
+
grouping: EditGrouping;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The edits of the version, proposed from what its edits insert and
|
|
78
|
+
* delete, however they group it now. The proposal depends on the
|
|
79
|
+
* version's changes and the text of its parent alone, so it is the same
|
|
80
|
+
* whatever order the changes come in.
|
|
81
|
+
*
|
|
82
|
+
* Changes are read line by line: a latched function on its side of the
|
|
83
|
+
* keyboard, a held one, or a pitch. A command or a note both texts keep
|
|
84
|
+
* divides its line, and no edit runs across one, so that every edit
|
|
85
|
+
* stays at one place. On a latched line, a function turned on and off
|
|
86
|
+
* again shortly after is one edit, as an accent is; a pair withdrawn and
|
|
87
|
+
* put elsewhere whole is one move, and so is a command put elsewhere on
|
|
88
|
+
* its own, the nearest first; an Off with an On right after it breaks
|
|
89
|
+
* the function off and takes it up again. Notes, and commands held for
|
|
90
|
+
* as long as they last, are one edit where they overlap, and one move
|
|
91
|
+
* where a note is put nearby. Where the version's system has no word for
|
|
92
|
+
* a command of the parent's, a held command standing for a latched pair
|
|
93
|
+
* is one edit with it; what has no counterpart stands alone. Cancels
|
|
94
|
+
* punched across three lines or more at one place are one edit, as a
|
|
95
|
+
* cancel row is.
|
|
96
|
+
*
|
|
97
|
+
* The type is read off the shape: a pair the bar spells an accent with is
|
|
98
|
+
* an additional accent, as is a note set off from its chord by a few
|
|
99
|
+
* millimetres where the version puts an accent's On; a perforation put
|
|
100
|
+
* elsewhere is a shift, or where it begins about where the other did,
|
|
101
|
+
* shortened or prolonged; a note struck again is shortened, two tied
|
|
102
|
+
* prolonged; a change the text makes no use of is a redundancy added or
|
|
103
|
+
* removed; and a change the other system has no word for is a recoding.
|
|
104
|
+
* Whether a shift corrects an error, and why any change was made, is
|
|
105
|
+
* not read off the shape.
|
|
106
|
+
*
|
|
107
|
+
* A version that derives from none states its text, not a change of
|
|
108
|
+
* another, and each of its symbols stands alone.
|
|
109
|
+
* @category Filiation
|
|
110
|
+
*/
|
|
111
|
+
export declare const proposeEdits: (edition: Edition, versionId: string, options?: EditOptions) => ProposedEdit[];
|
|
112
|
+
/**
|
|
113
|
+
* Whether the edit is left for a proposal to group again: one a
|
|
114
|
+
* collation or an adoption wrote, that nobody has given a motivation.
|
|
115
|
+
* @category Filiation
|
|
116
|
+
*/
|
|
117
|
+
export declare const isRegroupable: (edit: Readonly<Edit>) => boolean;
|
|
118
|
+
/**
|
|
119
|
+
* Puts the proposed edits (`proposeEdits`) in place of those of the
|
|
120
|
+
* versions that a collation or an adoption wrote and nobody has given a
|
|
121
|
+
* motivation, every version but the root where none are named. An edit
|
|
122
|
+
* someone motivated, typed beyond what a collation types, or held under a
|
|
123
|
+
* belief stays as it is, and what a proposed edit shares with it stands
|
|
124
|
+
* symbol by symbol beside it. An edit stated already over the very same
|
|
125
|
+
* symbols as a proposed one is kept with its id and what was written on
|
|
126
|
+
* it, the proposed type filled in where it states none; the others are
|
|
127
|
+
* new, with ids that adopting again gives again, and marked unchecked,
|
|
128
|
+
* so that what is left to the editor is to say why.
|
|
129
|
+
*
|
|
130
|
+
* Applied on the same draft after `adoptStemma`, it proposes the edits of
|
|
131
|
+
* the stemma adopted, as one undo step.
|
|
132
|
+
* @category Operations
|
|
133
|
+
*/
|
|
134
|
+
export declare const adoptEdits: (versionIds?: readonly string[], options?: EditOptions) => EditionOp;
|
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The edits of a version, proposed from what it inserts and deletes:
|
|
3
|
+
* which of its changes belong together, and what kind of change each
|
|
4
|
+
* group is. Why a change was made is left to the editor.
|
|
5
|
+
*/
|
|
6
|
+
import { v5 } from "uuid";
|
|
7
|
+
import { deletedBy, editsOf, insertedBy } from "../model/Version.js";
|
|
8
|
+
import { distance, mm, subtract } from "../model/Quantity.js";
|
|
9
|
+
import { trackerBarOf } from "../systems/index.js";
|
|
10
|
+
import { operationOf, substitutionsBetween } from "../collation/substitution.js";
|
|
11
|
+
import { isCollationsOwn, unchecked } from "../collation/Collation.js";
|
|
12
|
+
import { predecessorOf } from "../analysis/stemma.js";
|
|
13
|
+
import { placeOf, snapshotOf } from "../analysis/text.js";
|
|
14
|
+
import { symbolsIn, versionIn } from "../lookup/lookup.js";
|
|
15
|
+
import { groupBy } from "../shared/utils.js";
|
|
16
|
+
import { declaring, reading } from "../ops/draft.js";
|
|
17
|
+
import { ineffectiveAmong } from "./presence.js";
|
|
18
|
+
import { reads } from "./readings.js";
|
|
19
|
+
/**
|
|
20
|
+
* How far a perforation may be put from where it stood for the two to be
|
|
21
|
+
* one move. WM 225 moves commands by up to about 60 mm.
|
|
22
|
+
* @category Filiation
|
|
23
|
+
*/
|
|
24
|
+
export const defaultShiftWithin = mm(60);
|
|
25
|
+
/**
|
|
26
|
+
* How far an Off may follow an On for the two to be one function turned
|
|
27
|
+
* on and off again, as an accent is. WM 225 has crescendo accents of up
|
|
28
|
+
* to about 55 mm.
|
|
29
|
+
* @category Filiation
|
|
30
|
+
*/
|
|
31
|
+
export const defaultPairWithin = mm(60);
|
|
32
|
+
/**
|
|
33
|
+
* How near the onsets of a perforation and the one put in its place lie
|
|
34
|
+
* for the one to be a longer or shorter version of the other rather than
|
|
35
|
+
* a moved one. The figure `mergeEdits` guesses with.
|
|
36
|
+
* @category Filiation
|
|
37
|
+
*/
|
|
38
|
+
export const defaultResizeWithin = mm(5);
|
|
39
|
+
/**
|
|
40
|
+
* How near two perforations lie to stand at one place: the commands of a
|
|
41
|
+
* cancel row, or an accent's On and the note set off for it.
|
|
42
|
+
* @category Filiation
|
|
43
|
+
*/
|
|
44
|
+
export const defaultSamePlaceWithin = mm(2);
|
|
45
|
+
const isExpression = (symbol) => symbol.type === 'expression';
|
|
46
|
+
/** The line a command stands on: a latched function, a held one, a command no other system shares, or a pitch. */
|
|
47
|
+
const lineOf = (symbol) => {
|
|
48
|
+
if (symbol.type === 'note')
|
|
49
|
+
return { key: `note ${symbol.pitch}`, latched: false };
|
|
50
|
+
const operation = operationOf(symbol.expressionType);
|
|
51
|
+
if (!operation)
|
|
52
|
+
return { key: `expression ${symbol.scope} ${symbol.expressionType}`, latched: false };
|
|
53
|
+
const operates = operation.sided ? `${operation.operates} ${symbol.scope}` : operation.operates;
|
|
54
|
+
return operation.spelling === 'held'
|
|
55
|
+
? { key: `held ${operates}`, latched: false }
|
|
56
|
+
: { key: operates, latched: true };
|
|
57
|
+
};
|
|
58
|
+
const spellingOf = (symbol) => isExpression(symbol) ? operationOf(symbol.expressionType)?.spelling : undefined;
|
|
59
|
+
const isOn = (symbol) => spellingOf(symbol) === 'on';
|
|
60
|
+
const meaningOf = (symbol) => symbol.type === 'note'
|
|
61
|
+
? { type: 'note', pitch: symbol.pitch }
|
|
62
|
+
: { type: 'expression', expressionType: symbol.expressionType, scope: symbol.scope };
|
|
63
|
+
/** The ids of the commands of the text that its latched functions make no use of. */
|
|
64
|
+
const ineffectiveIn = (bar, text, placed) => bar
|
|
65
|
+
? ineffectiveAmong(bar, text.flatMap(symbol => {
|
|
66
|
+
const place = placed(symbol);
|
|
67
|
+
return symbol.type !== 'text' && place ? [{ id: symbol.id, meaning: meaningOf(symbol), place }] : [];
|
|
68
|
+
}))
|
|
69
|
+
: new Set();
|
|
70
|
+
const lengthOf = (span) => subtract(span.to, span.from);
|
|
71
|
+
const ofSide = (changes, side) => changes.filter(change => change.side === side);
|
|
72
|
+
const byPlace = (a, b) => a.place.from - b.place.from || (a.symbol.id < b.symbol.id ? -1 : a.symbol.id > b.symbol.id ? 1 : 0);
|
|
73
|
+
const inOrder = (changes) => [...changes].sort(byPlace);
|
|
74
|
+
const typesOf = (changes) => inOrder(changes).map(change => change.symbol.type === 'expression' ? change.symbol.expressionType : change.symbol.type);
|
|
75
|
+
const sameSequence = (a, b) => a.length === b.length && a.every((value, i) => value === b[i]);
|
|
76
|
+
const onsetOf = (changes) => Math.min(...changes.map(change => change.place.from));
|
|
77
|
+
/**
|
|
78
|
+
* The commands of one text that turn its function on and off again: an
|
|
79
|
+
* Off with the Ons right before it within the window. Ons that follow
|
|
80
|
+
* each other are one punched where the other stands, so an accent whose
|
|
81
|
+
* On moved forward keeps the one it left behind. The changes are those
|
|
82
|
+
* of one text on one line, in order, between two commands both texts keep.
|
|
83
|
+
*/
|
|
84
|
+
const pairsIn = (changes, within) => changes.flatMap((change, index) => {
|
|
85
|
+
if (isOn(change.symbol))
|
|
86
|
+
return [];
|
|
87
|
+
const ons = [];
|
|
88
|
+
for (let i = index - 1; i >= 0 && isOn(changes[i].symbol) && change.place.from - changes[i].place.from <= within; i--) {
|
|
89
|
+
ons.unshift(changes[i]);
|
|
90
|
+
}
|
|
91
|
+
return ons.length > 0 ? [[...ons, change]] : [];
|
|
92
|
+
});
|
|
93
|
+
/**
|
|
94
|
+
* Of the changes left, an On with the Off right before it in the same
|
|
95
|
+
* text within the window, both of use to it: a function broken off and
|
|
96
|
+
* taken up again.
|
|
97
|
+
*/
|
|
98
|
+
const breaksIn = (changes, left, within) => {
|
|
99
|
+
const breaks = [];
|
|
100
|
+
changes.forEach((change, index) => {
|
|
101
|
+
const before = changes[index - 1];
|
|
102
|
+
if (!before || !left.has(change) || !left.has(before) || breaks.some(pair => pair.includes(before)))
|
|
103
|
+
return;
|
|
104
|
+
if (before.redundant || change.redundant || !isOn(change.symbol) || isOn(before.symbol))
|
|
105
|
+
return;
|
|
106
|
+
if (change.place.from - before.place.from <= within)
|
|
107
|
+
breaks.push([before, change]);
|
|
108
|
+
});
|
|
109
|
+
return breaks;
|
|
110
|
+
};
|
|
111
|
+
/** Whether the one unit puts the perforations of the other elsewhere, near enough and each moved by about as much. */
|
|
112
|
+
const movesRigidly = (deleted, inserted, options) => {
|
|
113
|
+
const before = inOrder(deleted);
|
|
114
|
+
const after = inOrder(inserted);
|
|
115
|
+
if (!sameSequence(typesOf(before), typesOf(after)))
|
|
116
|
+
return false;
|
|
117
|
+
const shifts = after.map((change, index) => change.place.from - before[index].place.from);
|
|
118
|
+
return Math.abs(shifts[0]) <= options.shiftWithin
|
|
119
|
+
&& Math.max(...shifts) - Math.min(...shifts) <= options.resizeWithin;
|
|
120
|
+
};
|
|
121
|
+
/** What the version deletes, each with what it puts in its place: the nearest candidate first, each taken once. */
|
|
122
|
+
const nearestFirst = (deleted, inserted, fits) => {
|
|
123
|
+
const candidates = deleted.flatMap(one => inserted
|
|
124
|
+
.filter(other => fits(one, other))
|
|
125
|
+
.map(other => ({ one, other, gap: Math.abs(onsetOf(other) - onsetOf(one)) })));
|
|
126
|
+
candidates.sort((a, b) => a.gap - b.gap || byPlace(inOrder(a.one)[0], inOrder(b.one)[0]) || byPlace(inOrder(a.other)[0], inOrder(b.other)[0]));
|
|
127
|
+
const moves = new Map();
|
|
128
|
+
const taken = new Set();
|
|
129
|
+
for (const { one, other } of candidates) {
|
|
130
|
+
if (moves.has(one) || taken.has(other))
|
|
131
|
+
continue;
|
|
132
|
+
moves.set(one, other);
|
|
133
|
+
taken.add(other);
|
|
134
|
+
}
|
|
135
|
+
return moves;
|
|
136
|
+
};
|
|
137
|
+
/** The units as groups, a deleted one together with what it moved to. */
|
|
138
|
+
const asGroups = (units, moves) => {
|
|
139
|
+
const partners = new Set(moves.values());
|
|
140
|
+
return units.flatMap((unit) => {
|
|
141
|
+
if (partners.has(unit))
|
|
142
|
+
return [];
|
|
143
|
+
const partner = moves.get(unit);
|
|
144
|
+
if (partner)
|
|
145
|
+
return [{ changes: [...unit, ...partner], grouping: 'move' }];
|
|
146
|
+
return [{ changes: unit, grouping: unit.length === 1 ? 'single' : 'overlap' }];
|
|
147
|
+
});
|
|
148
|
+
};
|
|
149
|
+
/**
|
|
150
|
+
* The changes on a latched line. A command both texts keep sets the
|
|
151
|
+
* function alike in both, so the line falls into segments between such
|
|
152
|
+
* commands, and no edit runs across one.
|
|
153
|
+
*
|
|
154
|
+
* Within a segment, a pair one text turns the function on and off again
|
|
155
|
+
* with is a unit. A pair the version withdraws and one it adds that puts
|
|
156
|
+
* each command elsewhere by about as much is one move. Otherwise each
|
|
157
|
+
* command the version deletes goes with the nearest of the same type it
|
|
158
|
+
* puts nearby, a pair broken up only where every command of it moves.
|
|
159
|
+
* What is left stands alone, but for an Off and an On right after it,
|
|
160
|
+
* which break the function off and take it up again.
|
|
161
|
+
*/
|
|
162
|
+
const latchedGroups = (changes, kept, options) => {
|
|
163
|
+
const entries = [
|
|
164
|
+
...kept.map(({ symbol, place }) => ({ at: place.from, on: isOn(symbol), id: symbol.id })),
|
|
165
|
+
...changes.map(change => ({ at: change.place.from, on: isOn(change.symbol), id: change.symbol.id, change }))
|
|
166
|
+
];
|
|
167
|
+
// At one place a cancel comes first, as the valves take it.
|
|
168
|
+
entries.sort((a, b) => a.at - b.at || Number(a.on) - Number(b.on) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
169
|
+
const segments = [[]];
|
|
170
|
+
for (const entry of entries) {
|
|
171
|
+
if (entry.change)
|
|
172
|
+
segments[segments.length - 1].push(entry.change);
|
|
173
|
+
else if (segments[segments.length - 1].length > 0)
|
|
174
|
+
segments.push([]);
|
|
175
|
+
}
|
|
176
|
+
return segments.flatMap(segment => {
|
|
177
|
+
const added = pairsIn(ofSide(segment, 'insert'), options.pairWithin);
|
|
178
|
+
const withdrawn = pairsIn(ofSide(segment, 'delete'), options.pairWithin);
|
|
179
|
+
const paired = new Set([...added, ...withdrawn].flat());
|
|
180
|
+
const singles = segment.filter(change => !paired.has(change));
|
|
181
|
+
const pairMoves = nearestFirst(withdrawn, added, (one, other) => movesRigidly(one, other, options));
|
|
182
|
+
const movedPairs = new Set([...pairMoves.keys(), ...pairMoves.values()]);
|
|
183
|
+
let open = [...added, ...withdrawn].filter(pair => !movedPairs.has(pair));
|
|
184
|
+
let units = [];
|
|
185
|
+
let moves = new Map();
|
|
186
|
+
for (;;) {
|
|
187
|
+
units = [...singles, ...open.flat()].map(change => [change]);
|
|
188
|
+
moves = nearestFirst(units.filter(unit => unit[0].side === 'delete'), units.filter(unit => unit[0].side === 'insert'), (one, other) => movesRigidly(one, other, options));
|
|
189
|
+
const moved = new Set([...moves.keys(), ...moves.values()].flat());
|
|
190
|
+
const intact = open.filter(pair => !pair.every(change => moved.has(change)));
|
|
191
|
+
if (intact.length === 0)
|
|
192
|
+
break;
|
|
193
|
+
open = open.filter(pair => !intact.includes(pair));
|
|
194
|
+
}
|
|
195
|
+
const whole = [...added, ...withdrawn].filter(pair => !movedPairs.has(pair) && !open.includes(pair));
|
|
196
|
+
const moved = new Set([...moves.keys(), ...moves.values()].flat());
|
|
197
|
+
const left = new Set(units.flat().filter(change => !moved.has(change)));
|
|
198
|
+
const breaks = [
|
|
199
|
+
...breaksIn(ofSide(segment, 'insert'), left, options.pairWithin),
|
|
200
|
+
...breaksIn(ofSide(segment, 'delete'), left, options.pairWithin)
|
|
201
|
+
];
|
|
202
|
+
const broken = new Set(breaks.flat());
|
|
203
|
+
return [
|
|
204
|
+
...asGroups([...pairMoves.keys(), ...pairMoves.values()], pairMoves),
|
|
205
|
+
...[...whole, ...breaks].map((pair) => ({ changes: pair, grouping: 'pair' })),
|
|
206
|
+
...asGroups(units.filter(unit => !broken.has(unit[0])), moves)
|
|
207
|
+
];
|
|
208
|
+
});
|
|
209
|
+
};
|
|
210
|
+
/**
|
|
211
|
+
* The changes on a line of perforations that last as long as they
|
|
212
|
+
* sound: a pitch, or a held function. A perforation both texts keep
|
|
213
|
+
* divides the line. Changes that overlap are a unit, a note struck again
|
|
214
|
+
* or two tied into one, except where as many are deleted as inserted:
|
|
215
|
+
* then each deleted one is a unit with the inserted one in its place, in
|
|
216
|
+
* order. A unit the version deletes and one of the same shape it puts
|
|
217
|
+
* nearby are one move.
|
|
218
|
+
*/
|
|
219
|
+
const spanGroups = (changes, kept, options) => {
|
|
220
|
+
const entries = [
|
|
221
|
+
...kept.map(({ place }) => ({ from: place.from, to: place.to })),
|
|
222
|
+
...inOrder(changes).map(change => ({ from: change.place.from, to: change.place.to, change }))
|
|
223
|
+
].sort((a, b) => a.from - b.from);
|
|
224
|
+
const units = [];
|
|
225
|
+
const segmentOf = new Map();
|
|
226
|
+
let segment = 0;
|
|
227
|
+
let open = [];
|
|
228
|
+
let reach = -Infinity;
|
|
229
|
+
const close = () => {
|
|
230
|
+
const inserted = ofSide(open, 'insert');
|
|
231
|
+
const deleted = ofSide(open, 'delete');
|
|
232
|
+
const closed = inserted.length > 1 && inserted.length === deleted.length
|
|
233
|
+
? inserted.map((change, index) => [deleted[index], change])
|
|
234
|
+
: open.length > 0 ? [open] : [];
|
|
235
|
+
closed.forEach(unit => segmentOf.set(unit, segment));
|
|
236
|
+
units.push(...closed);
|
|
237
|
+
open = [];
|
|
238
|
+
reach = -Infinity;
|
|
239
|
+
};
|
|
240
|
+
for (const entry of entries) {
|
|
241
|
+
if (!entry.change) {
|
|
242
|
+
close();
|
|
243
|
+
segment++;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (entry.from > reach)
|
|
247
|
+
close();
|
|
248
|
+
open.push(entry.change);
|
|
249
|
+
reach = Math.max(reach, entry.to);
|
|
250
|
+
}
|
|
251
|
+
close();
|
|
252
|
+
const pure = units.filter(unit => unit.every(change => change.side === unit[0].side));
|
|
253
|
+
const moves = nearestFirst(pure.filter(unit => unit[0].side === 'delete'), pure.filter(unit => unit[0].side === 'insert'), (one, other) => segmentOf.get(one) === segmentOf.get(other) && movesRigidly(one, other, options));
|
|
254
|
+
return asGroups(units, moves);
|
|
255
|
+
};
|
|
256
|
+
/**
|
|
257
|
+
* Cancels the version punches across three lines or more at one place,
|
|
258
|
+
* none of them closing a pair or moved: a cancel row, as a roll has
|
|
259
|
+
* before its first note.
|
|
260
|
+
*/
|
|
261
|
+
const rowsAmong = (groups, within) => {
|
|
262
|
+
const cancels = groups
|
|
263
|
+
.filter(group => group.grouping === 'single')
|
|
264
|
+
.flatMap(group => group.changes)
|
|
265
|
+
.filter(change => change.side === 'insert' && spellingOf(change.symbol) === 'off')
|
|
266
|
+
.sort(byPlace);
|
|
267
|
+
const rows = [];
|
|
268
|
+
for (let i = 0; i < cancels.length;) {
|
|
269
|
+
let j = i + 1;
|
|
270
|
+
while (j < cancels.length && cancels[j].place.from - cancels[i].place.from <= within)
|
|
271
|
+
j++;
|
|
272
|
+
const row = cancels.slice(i, j);
|
|
273
|
+
if (new Set(row.map(change => lineOf(change.symbol).key)).size >= 3)
|
|
274
|
+
rows.push({ changes: row, grouping: 'row' });
|
|
275
|
+
i = j;
|
|
276
|
+
}
|
|
277
|
+
return rows;
|
|
278
|
+
};
|
|
279
|
+
/** Whether the bar spells an accent with exactly these commands. */
|
|
280
|
+
const spellsAccent = (bar, types) => (bar?.accents ?? []).some(accent => sameSequence(types, accent));
|
|
281
|
+
/** What kind of change a group is, where its shape says. */
|
|
282
|
+
const typeOf = (group, bar, accentsAt, options) => {
|
|
283
|
+
const inserted = inOrder(ofSide(group.changes, 'insert'));
|
|
284
|
+
const deleted = inOrder(ofSide(group.changes, 'delete'));
|
|
285
|
+
if (group.grouping === 'substitution' || group.changes.some(change => change.foreign))
|
|
286
|
+
return 'recoding';
|
|
287
|
+
if (group.changes.every(change => change.redundant)) {
|
|
288
|
+
if (deleted.length === 0)
|
|
289
|
+
return 'add-redundancy';
|
|
290
|
+
if (inserted.length === 0)
|
|
291
|
+
return 'remove-redundancy';
|
|
292
|
+
}
|
|
293
|
+
if (group.grouping === 'pair') {
|
|
294
|
+
return deleted.length === 0 && spellsAccent(bar, typesOf(inserted.slice(-2))) ? 'additional-accent' : undefined;
|
|
295
|
+
}
|
|
296
|
+
if (inserted.length === 0 || deleted.length === 0)
|
|
297
|
+
return undefined;
|
|
298
|
+
if (inserted.length === 1 && deleted.length === 1) {
|
|
299
|
+
const [after, before] = [inserted[0].place, deleted[0].place];
|
|
300
|
+
if (distance(after.from, before.from) >= options.resizeWithin)
|
|
301
|
+
return 'shift';
|
|
302
|
+
// A note set off from its chord where the version puts an accent serves the accent.
|
|
303
|
+
const [early, late] = [Math.min(after.from, before.from), Math.max(after.from, before.from)];
|
|
304
|
+
const setOff = inserted[0].symbol.type === 'note' && late - early >= options.samePlaceWithin
|
|
305
|
+
&& accentsAt.some(at => at >= early - options.samePlaceWithin && at <= late + options.samePlaceWithin);
|
|
306
|
+
if (setOff)
|
|
307
|
+
return 'additional-accent';
|
|
308
|
+
return lengthOf(after) < lengthOf(before) ? 'shorten' : 'prolong';
|
|
309
|
+
}
|
|
310
|
+
const notes = group.changes.every(change => change.symbol.type === 'note');
|
|
311
|
+
if (notes && deleted.length === 1)
|
|
312
|
+
return 'shorten';
|
|
313
|
+
if (notes && inserted.length === 1)
|
|
314
|
+
return 'prolong';
|
|
315
|
+
if (sameSequence(typesOf(inserted), typesOf(deleted)))
|
|
316
|
+
return 'shift';
|
|
317
|
+
return undefined;
|
|
318
|
+
};
|
|
319
|
+
const settingsOf = (options) => ({
|
|
320
|
+
shiftWithin: options.shiftWithin ?? defaultShiftWithin,
|
|
321
|
+
pairWithin: options.pairWithin ?? defaultPairWithin,
|
|
322
|
+
resizeWithin: options.resizeWithin ?? defaultResizeWithin,
|
|
323
|
+
samePlaceWithin: options.samePlaceWithin ?? defaultSamePlaceWithin
|
|
324
|
+
});
|
|
325
|
+
/**
|
|
326
|
+
* The edits of the version, proposed from what its edits insert and
|
|
327
|
+
* delete, however they group it now. The proposal depends on the
|
|
328
|
+
* version's changes and the text of its parent alone, so it is the same
|
|
329
|
+
* whatever order the changes come in.
|
|
330
|
+
*
|
|
331
|
+
* Changes are read line by line: a latched function on its side of the
|
|
332
|
+
* keyboard, a held one, or a pitch. A command or a note both texts keep
|
|
333
|
+
* divides its line, and no edit runs across one, so that every edit
|
|
334
|
+
* stays at one place. On a latched line, a function turned on and off
|
|
335
|
+
* again shortly after is one edit, as an accent is; a pair withdrawn and
|
|
336
|
+
* put elsewhere whole is one move, and so is a command put elsewhere on
|
|
337
|
+
* its own, the nearest first; an Off with an On right after it breaks
|
|
338
|
+
* the function off and takes it up again. Notes, and commands held for
|
|
339
|
+
* as long as they last, are one edit where they overlap, and one move
|
|
340
|
+
* where a note is put nearby. Where the version's system has no word for
|
|
341
|
+
* a command of the parent's, a held command standing for a latched pair
|
|
342
|
+
* is one edit with it; what has no counterpart stands alone. Cancels
|
|
343
|
+
* punched across three lines or more at one place are one edit, as a
|
|
344
|
+
* cancel row is.
|
|
345
|
+
*
|
|
346
|
+
* The type is read off the shape: a pair the bar spells an accent with is
|
|
347
|
+
* an additional accent, as is a note set off from its chord by a few
|
|
348
|
+
* millimetres where the version puts an accent's On; a perforation put
|
|
349
|
+
* elsewhere is a shift, or where it begins about where the other did,
|
|
350
|
+
* shortened or prolonged; a note struck again is shortened, two tied
|
|
351
|
+
* prolonged; a change the text makes no use of is a redundancy added or
|
|
352
|
+
* removed; and a change the other system has no word for is a recoding.
|
|
353
|
+
* Whether a shift corrects an error, and why any change was made, is
|
|
354
|
+
* not read off the shape.
|
|
355
|
+
*
|
|
356
|
+
* A version that derives from none states its text, not a change of
|
|
357
|
+
* another, and each of its symbols stands alone.
|
|
358
|
+
* @category Filiation
|
|
359
|
+
*/
|
|
360
|
+
export const proposeEdits = (edition, versionId, options = {}) => {
|
|
361
|
+
const settings = settingsOf(options);
|
|
362
|
+
const version = versionIn(edition, versionId);
|
|
363
|
+
if (!version)
|
|
364
|
+
return [];
|
|
365
|
+
const parent = predecessorOf(edition, versionId);
|
|
366
|
+
if (!parent) {
|
|
367
|
+
return insertedBy(version).map(symbol => ({ insert: [symbol.id], delete: [], grouping: 'single' }));
|
|
368
|
+
}
|
|
369
|
+
const bar = trackerBarOf(version.system);
|
|
370
|
+
const parentBar = trackerBarOf(parent.system) ?? bar;
|
|
371
|
+
const placed = (symbol) => placeOf(edition, symbol);
|
|
372
|
+
const parentText = snapshotOf(edition, parent.id);
|
|
373
|
+
const ineffectiveBefore = ineffectiveIn(parentBar, parentText, placed);
|
|
374
|
+
const ineffectiveAfter = ineffectiveIn(bar, snapshotOf(edition, versionId), placed);
|
|
375
|
+
const unplaced = [];
|
|
376
|
+
const changes = [];
|
|
377
|
+
const add = (symbol, side) => {
|
|
378
|
+
const place = placed(symbol);
|
|
379
|
+
if (!place || symbol.type === 'text') {
|
|
380
|
+
unplaced.push({ insert: side === 'insert' ? [symbol.id] : [], delete: side === 'delete' ? [symbol.id] : [], grouping: 'single' });
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
const other = side === 'insert' ? parentBar : bar;
|
|
384
|
+
changes.push({
|
|
385
|
+
symbol,
|
|
386
|
+
side,
|
|
387
|
+
place,
|
|
388
|
+
redundant: (side === 'insert' ? ineffectiveAfter : ineffectiveBefore).has(symbol.id),
|
|
389
|
+
foreign: isExpression(symbol) && !!other && !reads(other, meaningOf(symbol))
|
|
390
|
+
});
|
|
391
|
+
};
|
|
392
|
+
insertedBy(version).forEach(symbol => add(symbol, 'insert'));
|
|
393
|
+
symbolsIn(edition, deletedBy(version)).forEach(symbol => add(symbol, 'delete'));
|
|
394
|
+
changes.sort(byPlace);
|
|
395
|
+
// A held command of the version's system stands for a latched pair of the parent's.
|
|
396
|
+
const foreign = changes.filter(change => change.foreign);
|
|
397
|
+
const substituted = substitutionsBetween(ofSide(foreign, 'insert').map(change => change.symbol), ofSide(foreign, 'delete').map(change => change.symbol), placed).map(({ replaced, by }) => {
|
|
398
|
+
const ids = new Set([...replaced, ...by].map(symbol => symbol.id));
|
|
399
|
+
return { changes: foreign.filter(change => ids.has(change.symbol.id)), grouping: 'substitution' };
|
|
400
|
+
});
|
|
401
|
+
const substitutedIds = new Set(substituted.flatMap(group => group.changes.map(change => change.symbol.id)));
|
|
402
|
+
const recoded = foreign
|
|
403
|
+
.filter(change => !substitutedIds.has(change.symbol.id))
|
|
404
|
+
.map((change) => ({ changes: [change], grouping: 'single' }));
|
|
405
|
+
const changed = new Set(changes.map(change => change.symbol.id));
|
|
406
|
+
const keptOn = new Map();
|
|
407
|
+
for (const symbol of parentText) {
|
|
408
|
+
const place = placed(symbol);
|
|
409
|
+
if (changed.has(symbol.id) || symbol.type === 'text' || !place)
|
|
410
|
+
continue;
|
|
411
|
+
const key = lineOf(symbol).key;
|
|
412
|
+
keptOn.set(key, [...(keptOn.get(key) ?? []), { symbol, place }]);
|
|
413
|
+
}
|
|
414
|
+
const groupLines = (among) => [...groupBy(among, change => lineOf(change.symbol).key)].flatMap(([key, onLine]) => lineOf(onLine[0].symbol).latched
|
|
415
|
+
? latchedGroups(onLine, keptOn.get(key) ?? [], settings)
|
|
416
|
+
: spanGroups(onLine, keptOn.get(key) ?? [], settings));
|
|
417
|
+
const native = changes.filter(change => !change.foreign);
|
|
418
|
+
const first = groupLines(native);
|
|
419
|
+
const rows = rowsAmong(first, settings.samePlaceWithin);
|
|
420
|
+
const inRow = new Set(rows.flatMap(row => row.changes));
|
|
421
|
+
const lines = inRow.size > 0 ? groupLines(native.filter(change => !inRow.has(change))) : first;
|
|
422
|
+
// Where the version puts the On of one of its bar's accents.
|
|
423
|
+
const accentOns = new Set((bar?.accents ?? []).map(accent => accent[0]));
|
|
424
|
+
const accentsAt = ofSide(native, 'insert')
|
|
425
|
+
.filter(change => isExpression(change.symbol) && accentOns.has(change.symbol.expressionType))
|
|
426
|
+
.map(change => change.place.from);
|
|
427
|
+
const proposed = [...substituted, ...recoded, ...lines, ...rows]
|
|
428
|
+
.sort((a, b) => byPlace(inOrder(a.changes)[0], inOrder(b.changes)[0]))
|
|
429
|
+
.map((group) => {
|
|
430
|
+
const editType = typeOf(group, bar, accentsAt, settings);
|
|
431
|
+
return {
|
|
432
|
+
insert: inOrder(ofSide(group.changes, 'insert')).map(change => change.symbol.id),
|
|
433
|
+
delete: inOrder(ofSide(group.changes, 'delete')).map(change => change.symbol.id),
|
|
434
|
+
...(editType && { editType }),
|
|
435
|
+
grouping: group.grouping
|
|
436
|
+
};
|
|
437
|
+
});
|
|
438
|
+
return [...proposed, ...unplaced];
|
|
439
|
+
};
|
|
440
|
+
/** The namespace the ids of adopted edits are minted in, so that adopting again gives them again. */
|
|
441
|
+
const EDITS = '3c9e0f62-1d4b-4a7e-9b55-8e2f7a6d1c04';
|
|
442
|
+
const exchangeKey = (inserted, deleted) => `${[...inserted].sort().join(',')}/${[...deleted].sort().join(',')}`;
|
|
443
|
+
/**
|
|
444
|
+
* Whether the edit is left for a proposal to group again: one a
|
|
445
|
+
* collation or an adoption wrote, that nobody has given a motivation.
|
|
446
|
+
* @category Filiation
|
|
447
|
+
*/
|
|
448
|
+
export const isRegroupable = (edit) => isCollationsOwn(edit) && (edit.motivation === undefined || edit.motivation === unchecked);
|
|
449
|
+
/**
|
|
450
|
+
* The edits the version comes to state when the proposal is adopted:
|
|
451
|
+
* those it keeps, and those put in place of the rest.
|
|
452
|
+
*/
|
|
453
|
+
const adoptedEditsOf = (edition, version, options) => {
|
|
454
|
+
const edits = editsOf(version);
|
|
455
|
+
const held = edits.filter(edit => !isRegroupable(edit));
|
|
456
|
+
const spoken = new Set(held.flatMap(edit => [...(edit.insert ?? []).map(symbol => symbol.id), ...(edit.delete ?? [])]));
|
|
457
|
+
const symbolOf = new Map(insertedBy(version).map(symbol => [symbol.id, symbol]));
|
|
458
|
+
const stated = new Map(edits.map(edit => [exchangeKey((edit.insert ?? []).map(symbol => symbol.id), edit.delete ?? []), edit]));
|
|
459
|
+
const made = (inserted, deleted, editType) => {
|
|
460
|
+
const key = exchangeKey(inserted, deleted);
|
|
461
|
+
const same = stated.get(key);
|
|
462
|
+
if (same)
|
|
463
|
+
return same.editType || !editType ? same : { ...same, editType };
|
|
464
|
+
return {
|
|
465
|
+
type: 'edit',
|
|
466
|
+
id: v5(`edit|${version.id}|${key}`, EDITS),
|
|
467
|
+
...(editType && { editType }),
|
|
468
|
+
motivation: unchecked,
|
|
469
|
+
...(inserted.length > 0 && { insert: inserted.map(id => symbolOf.get(id)) }),
|
|
470
|
+
...(deleted.length > 0 && { delete: [...deleted] })
|
|
471
|
+
};
|
|
472
|
+
};
|
|
473
|
+
const proposed = proposeEdits(edition, version.id, options).flatMap(edit => {
|
|
474
|
+
const free = (ids) => ids.filter(id => !spoken.has(id));
|
|
475
|
+
const [inserted, deleted] = [free(edit.insert), free(edit.delete)];
|
|
476
|
+
if (inserted.length === edit.insert.length && deleted.length === edit.delete.length) {
|
|
477
|
+
return [made(inserted, deleted, edit.editType)];
|
|
478
|
+
}
|
|
479
|
+
// What an edit that stays leaves of a proposed one stands symbol by symbol.
|
|
480
|
+
return [...inserted.map(id => made([id], [])), ...deleted.map(id => made([], [id]))];
|
|
481
|
+
});
|
|
482
|
+
return [...held, ...proposed];
|
|
483
|
+
};
|
|
484
|
+
/**
|
|
485
|
+
* Puts the proposed edits (`proposeEdits`) in place of those of the
|
|
486
|
+
* versions that a collation or an adoption wrote and nobody has given a
|
|
487
|
+
* motivation, every version but the root where none are named. An edit
|
|
488
|
+
* someone motivated, typed beyond what a collation types, or held under a
|
|
489
|
+
* belief stays as it is, and what a proposed edit shares with it stands
|
|
490
|
+
* symbol by symbol beside it. An edit stated already over the very same
|
|
491
|
+
* symbols as a proposed one is kept with its id and what was written on
|
|
492
|
+
* it, the proposed type filled in where it states none; the others are
|
|
493
|
+
* new, with ids that adopting again gives again, and marked unchecked,
|
|
494
|
+
* so that what is left to the editor is to say why.
|
|
495
|
+
*
|
|
496
|
+
* Applied on the same draft after `adoptStemma`, it proposes the edits of
|
|
497
|
+
* the stemma adopted, as one undo step.
|
|
498
|
+
* @category Operations
|
|
499
|
+
*/
|
|
500
|
+
export const adoptEdits = (versionIds, options = {}) => reading(edition => {
|
|
501
|
+
const targets = (versionIds ?? edition.versions.map(version => version.id))
|
|
502
|
+
.map(id => versionIn(edition, id))
|
|
503
|
+
.filter((version) => version !== undefined && predecessorOf(edition, version.id) !== undefined);
|
|
504
|
+
const adopted = new Map(targets.flatMap(version => {
|
|
505
|
+
const edits = adoptedEditsOf(edition, version, options);
|
|
506
|
+
const stated = editsOf(version);
|
|
507
|
+
if (edits.length === stated.length && edits.every((edit, index) => edit === stated[index]))
|
|
508
|
+
return [];
|
|
509
|
+
return [[version.id, { ...version, edits, motivations: declaring(version.motivations, edits) }]];
|
|
510
|
+
}));
|
|
511
|
+
// Each version is put in place whole: one that an operation before put
|
|
512
|
+
// on the same draft, as adopting a stemma does, is no draft to write to.
|
|
513
|
+
return draft => {
|
|
514
|
+
draft.versions.forEach((version, index) => {
|
|
515
|
+
const changed = adopted.get(version.id);
|
|
516
|
+
if (changed)
|
|
517
|
+
draft.versions[index] = changed;
|
|
518
|
+
});
|
|
519
|
+
};
|
|
520
|
+
});
|
package/lib/filiation/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Filiation: proposing a stemma from the copies alone, by the editorial
|
|
3
|
-
* principles the rules of an edition state, and
|
|
4
|
-
* edition.
|
|
3
|
+
* principles the rules of an edition state, and the edits of each of its
|
|
4
|
+
* versions, and taking them into the edition.
|
|
5
5
|
*/
|
|
6
6
|
export { collateCopies, readingsOfCopy, type CopyCollation, type CollatedReading, type CopyReading, type PairWindow, type Recoding } from "./readings.js";
|
|
7
7
|
export { presenceIn, ineffectiveAmong, defaultMargin, type Presence, type PresenceMatrix } from "./presence.js";
|
|
@@ -10,3 +10,5 @@ export { transfersOf, systemAbove } from "./systems.js";
|
|
|
10
10
|
export { proposeStemma, defaultMovedWithin, type FiliationOptions, type ProposedVersion, type StemmaProposal, type Unpinned } from "./proposal.js";
|
|
11
11
|
export { adoptionOf, adoptStemma, type Adoption } from "./adoption.js";
|
|
12
12
|
export { stemmaChanges, type VersionChange } from "./changes.js";
|
|
13
|
+
export { sharedTexts, differencesBetween, defaultSameText, type SharedText, type KeptApart } from "./texts.js";
|
|
14
|
+
export { proposeEdits, adoptEdits, isRegroupable, defaultShiftWithin, defaultPairWithin, defaultResizeWithin, defaultSamePlaceWithin, type EditOptions, type EditGrouping, type ProposedEdit } from "./edits.js";
|
package/lib/filiation/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Filiation: proposing a stemma from the copies alone, by the editorial
|
|
3
|
-
* principles the rules of an edition state, and
|
|
4
|
-
* edition.
|
|
3
|
+
* principles the rules of an edition state, and the edits of each of its
|
|
4
|
+
* versions, and taking them into the edition.
|
|
5
5
|
*/
|
|
6
6
|
export { collateCopies, readingsOfCopy } from "./readings.js";
|
|
7
7
|
export { presenceIn, ineffectiveAmong, defaultMargin } from "./presence.js";
|
|
@@ -10,3 +10,5 @@ export { transfersOf, systemAbove } from "./systems.js";
|
|
|
10
10
|
export { proposeStemma, defaultMovedWithin } from "./proposal.js";
|
|
11
11
|
export { adoptionOf, adoptStemma } from "./adoption.js";
|
|
12
12
|
export { stemmaChanges } from "./changes.js";
|
|
13
|
+
export { sharedTexts, differencesBetween, defaultSameText } from "./texts.js";
|
|
14
|
+
export { proposeEdits, adoptEdits, isRegroupable, defaultShiftWithin, defaultPairWithin, defaultResizeWithin, defaultSamePlaceWithin } from "./edits.js";
|
|
@@ -8,6 +8,7 @@ import { Millimeters } from "../model/Quantity.js";
|
|
|
8
8
|
import { CopyCollation, Recoding } from "./readings.js";
|
|
9
9
|
import { PresenceMatrix } from "./presence.js";
|
|
10
10
|
import { Costs, Scored, Support } from "./search.js";
|
|
11
|
+
import { KeptApart, SharedText } from "./texts.js";
|
|
11
12
|
/**
|
|
12
13
|
* How near a reading of the same kind on the other side of the root
|
|
13
14
|
* makes a reading on the less refined side a moved one, where nothing
|
|
@@ -34,8 +35,18 @@ export interface FiliationOptions {
|
|
|
34
35
|
movedWithin?: Millimeters;
|
|
35
36
|
/** How far beyond its text, or the edge of a tear, a copy cannot say what it lacks (3 mm). */
|
|
36
37
|
margin?: Millimeters;
|
|
37
|
-
/**
|
|
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
|
+
*/
|
|
38
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;
|
|
39
50
|
/** How many runners-up to report (5). */
|
|
40
51
|
alternatives?: number;
|
|
41
52
|
}
|
|
@@ -46,8 +57,8 @@ export interface FiliationOptions {
|
|
|
46
57
|
export interface ProposedVersion {
|
|
47
58
|
/** A name within the proposal: the versions are numbered from the root down. */
|
|
48
59
|
id: string;
|
|
49
|
-
/** The
|
|
50
|
-
|
|
60
|
+
/** The copies carrying the version at first hand; none for a lost state. */
|
|
61
|
+
copies: string[];
|
|
51
62
|
/** The system the version is coded for, by its identifier. */
|
|
52
63
|
system: string;
|
|
53
64
|
/** The version it derives from, by its name within the proposal. */
|
|
@@ -60,8 +71,11 @@ export interface ProposedVersion {
|
|
|
60
71
|
deletes: string[];
|
|
61
72
|
/** The held commands that stand for a latched pair, among what the version inserts and deletes. */
|
|
62
73
|
recodings: Recoding[];
|
|
63
|
-
/** The readings the version holds that
|
|
64
|
-
deviations:
|
|
74
|
+
/** The readings the version holds that a copy carrying it lacks. */
|
|
75
|
+
deviations: {
|
|
76
|
+
copy: string;
|
|
77
|
+
reading: string;
|
|
78
|
+
}[];
|
|
65
79
|
/** The edits the editor wrote on `version` that the proposal keeps as they are. */
|
|
66
80
|
pinned: string[];
|
|
67
81
|
}
|
|
@@ -83,6 +97,11 @@ export type Unpinned = {
|
|
|
83
97
|
*/
|
|
84
98
|
export interface StemmaProposal {
|
|
85
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;
|
|
86
105
|
costs: Costs;
|
|
87
106
|
movedWithin: Millimeters;
|
|
88
107
|
margin: Millimeters;
|
|
@@ -10,6 +10,7 @@ import { defaultMargin, presenceIn } from "./presence.js";
|
|
|
10
10
|
import { defaultCosts, searchStemmata, statesOf } from "./search.js";
|
|
11
11
|
import { barNamed, transfersOf } from "./systems.js";
|
|
12
12
|
import { MAX_COPIES } from "./trees.js";
|
|
13
|
+
import { defaultSameText, sharedTexts } from "./texts.js";
|
|
13
14
|
/**
|
|
14
15
|
* How near a reading of the same kind on the other side of the root
|
|
15
16
|
* makes a reading on the less refined side a moved one, where nothing
|
|
@@ -101,16 +102,16 @@ const deleting = (tree, states, v) => !states[v] && tree.parent[v] >= 0 && state
|
|
|
101
102
|
* a reading of the same kind within the window and this branch does not
|
|
102
103
|
* (moved), or where it is ineffective (cleaned up, as redundancies are).
|
|
103
104
|
*/
|
|
104
|
-
const baseTextOf = (tree, readings, matrix, placement, held, within, systems) => {
|
|
105
|
+
const baseTextOf = (tree, readings, matrix, placement, held, within, texts, systems) => {
|
|
105
106
|
const root = tree.copy.length - 1;
|
|
106
107
|
const children = childrenOf(tree)[root];
|
|
107
108
|
if (tree.copy[root] >= 0 || children.length !== 2)
|
|
108
109
|
return [];
|
|
109
|
-
const copiesBelow = (v) =>
|
|
110
|
+
const copiesBelow = (v) => texts.filter((_, t) => tree.below[v] & (1 << t)).flat();
|
|
110
111
|
const rootSystem = tree.system[root];
|
|
111
|
-
/** How many
|
|
112
|
+
/** How many expression perforations the copies of the root's system below the node carry, on average. */
|
|
112
113
|
const densityOf = (v) => {
|
|
113
|
-
const copies =
|
|
114
|
+
const copies = texts.filter((_, t) => (tree.below[v] & (1 << t)) && systems[t] === rootSystem).flat();
|
|
114
115
|
if (copies.length === 0)
|
|
115
116
|
return Infinity;
|
|
116
117
|
const counts = copies.map(copy => readings.filter(reading => reading.meaning.type === 'expression' && matrix.presence[reading.id][copy] === 'present').length);
|
|
@@ -185,23 +186,36 @@ export const proposeStemma = (edition, options = {}) => {
|
|
|
185
186
|
const copies = transcribed(edition).filter(id => !options.copies || options.copies.includes(id));
|
|
186
187
|
if (copies.length < 2)
|
|
187
188
|
throw new Error('A stemma needs at least two copies with features');
|
|
188
|
-
if (copies.length > MAX_COPIES)
|
|
189
|
-
throw new Error(`The exhaustive search covers at most ${MAX_COPIES} copies`);
|
|
190
189
|
const costs = { ...defaultCosts, ...options.costs };
|
|
191
190
|
const movedWithin = options.movedWithin ?? defaultMovedWithin;
|
|
192
191
|
const margin = options.margin ?? defaultMargin;
|
|
192
|
+
const sameText = options.sameText ?? defaultSameText;
|
|
193
193
|
const copyOf = new Map(edition.copies.map(copy => [copy.id, copy]));
|
|
194
|
-
const
|
|
195
|
-
const systems = copies.map(id => barOf(copyOf.get(id)).id);
|
|
194
|
+
const systemOfCopy = new Map(copies.map(id => [id, barOf(copyOf.get(id)).id]));
|
|
196
195
|
const transfers = transfersOf();
|
|
196
|
+
const firstHandOf = (copy) => versionsWitnessedBy(edition, copy)
|
|
197
|
+
.find(witness => witness.by === 'carriers' && witness.through === undefined)?.version;
|
|
197
198
|
const collation = collateCopies(edition, copies);
|
|
198
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]));
|
|
199
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
|
+
};
|
|
200
214
|
const rows = collation.readings.map(reading => ({
|
|
201
|
-
presence:
|
|
215
|
+
presence: texts.map(text => textPresence(reading.id, text)),
|
|
202
216
|
redundant: redundant.has(reading.id)
|
|
203
217
|
}));
|
|
204
|
-
const search = searchStemmata(
|
|
218
|
+
const search = searchStemmata(texts, names, systems, rows, costs, transfers, options.alternatives ?? 5);
|
|
205
219
|
const tree = search.best;
|
|
206
220
|
const size = tree.copy.length;
|
|
207
221
|
const place = (fixed) => {
|
|
@@ -226,14 +240,13 @@ export const proposeStemma = (edition, options = {}) => {
|
|
|
226
240
|
const standsFor = new Map();
|
|
227
241
|
const claimed = new Set();
|
|
228
242
|
for (let v = 0; v < size; v++) {
|
|
229
|
-
const
|
|
230
|
-
if (
|
|
243
|
+
const text = tree.copy[v];
|
|
244
|
+
if (text < 0)
|
|
231
245
|
continue;
|
|
232
|
-
const firstHand =
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
claimed.add(firstHand.version);
|
|
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);
|
|
237
250
|
}
|
|
238
251
|
}
|
|
239
252
|
const insertsAt = (placement, v) => collation.readings.filter(reading => inserting(tree, placement.get(reading.id), v)).map(reading => symbols[reading.id] ?? reading.id);
|
|
@@ -306,7 +319,7 @@ export const proposeStemma = (edition, options = {}) => {
|
|
|
306
319
|
}
|
|
307
320
|
const { placement, failed } = place(fixed);
|
|
308
321
|
const held = new Set([...fixed.keys()].filter(reading => !failed.has(reading)));
|
|
309
|
-
const baseText = baseTextOf(tree, collation.readings, matrix, placement, held, movedWithin, systems);
|
|
322
|
+
const baseText = baseTextOf(tree, collation.readings, matrix, placement, held, movedWithin, texts, systems);
|
|
310
323
|
recode(tree, collation.readings, placement);
|
|
311
324
|
for (const [v, edits] of pins) {
|
|
312
325
|
for (const { edit, readings } of edits) {
|
|
@@ -334,14 +347,13 @@ export const proposeStemma = (edition, options = {}) => {
|
|
|
334
347
|
return fits;
|
|
335
348
|
})
|
|
336
349
|
: [];
|
|
337
|
-
const
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
: [];
|
|
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 })));
|
|
342
354
|
return {
|
|
343
355
|
id: nameOf.get(v),
|
|
344
|
-
|
|
356
|
+
copies: [...carrying],
|
|
345
357
|
system: tree.system[v],
|
|
346
358
|
...(tree.parent[v] >= 0 && { parent: nameOf.get(tree.parent[v]) }),
|
|
347
359
|
...(standsFor.has(v) && { version: standsFor.get(v) }),
|
|
@@ -357,6 +369,9 @@ export const proposeStemma = (edition, options = {}) => {
|
|
|
357
369
|
.filter(({ versions }) => versions.length > 1);
|
|
358
370
|
return {
|
|
359
371
|
copies,
|
|
372
|
+
texts: shared.texts,
|
|
373
|
+
apart: shared.apart,
|
|
374
|
+
sameText,
|
|
360
375
|
costs,
|
|
361
376
|
movedWithin,
|
|
362
377
|
margin,
|
|
@@ -79,8 +79,12 @@ export interface Scored {
|
|
|
79
79
|
export interface Support {
|
|
80
80
|
/** The copies the grouping holds, as the copies of a node and those below it. */
|
|
81
81
|
copies: string[];
|
|
82
|
-
/**
|
|
83
|
-
|
|
82
|
+
/**
|
|
83
|
+
* Where it is the grouping of a version carried by copies and lying
|
|
84
|
+
* above others, rather than of the copies below a node: the copies
|
|
85
|
+
* carrying that version.
|
|
86
|
+
*/
|
|
87
|
+
ancestor?: string[];
|
|
84
88
|
margin: number;
|
|
85
89
|
}
|
|
86
90
|
/** The outcome of the search. */
|
|
@@ -92,12 +96,13 @@ export interface Search {
|
|
|
92
96
|
supports: Support[];
|
|
93
97
|
}
|
|
94
98
|
/**
|
|
95
|
-
* Scores every stemma of the
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
* depends on nothing but the
|
|
99
|
+
* Scores every stemma of the texts, each carried by one copy or by
|
|
100
|
+
* several, and keeps the best, the runners-up and, for each grouping of
|
|
101
|
+
* the best, the best stemma without it. Ties go to fewer lost states,
|
|
102
|
+
* then to the notation, so that the outcome depends on nothing but the
|
|
103
|
+
* copies and the costs.
|
|
99
104
|
*/
|
|
100
|
-
export declare const searchStemmata: (
|
|
105
|
+
export declare const searchStemmata: (texts: readonly (readonly string[])[], names: readonly string[], systems: readonly string[], rows: readonly {
|
|
101
106
|
presence: readonly Presence[];
|
|
102
107
|
redundant: boolean;
|
|
103
108
|
}[], costs: Costs, transfers: ReadonlyMap<string, ReadonlySet<string>>, keep: number) => Search;
|
package/lib/filiation/search.js
CHANGED
|
@@ -138,13 +138,14 @@ const scoreOf = (tree, classes, costs, absent, present) => {
|
|
|
138
138
|
};
|
|
139
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
140
|
/**
|
|
141
|
-
* Scores every stemma of the
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
* depends on nothing but the
|
|
141
|
+
* Scores every stemma of the texts, each carried by one copy or by
|
|
142
|
+
* several, and keeps the best, the runners-up and, for each grouping of
|
|
143
|
+
* the best, the best stemma without it. Ties go to fewer lost states,
|
|
144
|
+
* then to the notation, so that the outcome depends on nothing but the
|
|
145
|
+
* copies and the costs.
|
|
145
146
|
*/
|
|
146
|
-
export const searchStemmata = (
|
|
147
|
-
const n =
|
|
147
|
+
export const searchStemmata = (texts, names, systems, rows, costs, transfers, keep) => {
|
|
148
|
+
const n = texts.length;
|
|
148
149
|
const classes = classesOf(rows, n, costs);
|
|
149
150
|
const pool = 2 * n;
|
|
150
151
|
const absent = Array.from({ length: pool }, () => new Float64Array(classes.weight.length));
|
|
@@ -189,7 +190,7 @@ export const searchStemmata = (copies, names, systems, rows, costs, transfers, k
|
|
|
189
190
|
throw new Error('No stemma of these copies is admissible');
|
|
190
191
|
const best = top[0];
|
|
191
192
|
const supports = [];
|
|
192
|
-
const named = (mask) =>
|
|
193
|
+
const named = (mask) => texts.filter((_, c) => mask & (1 << c)).flat();
|
|
193
194
|
const seen = new Set();
|
|
194
195
|
for (let v = 0; v < best.tree.copy.length; v++) {
|
|
195
196
|
const mask = best.tree.below[v];
|
|
@@ -203,7 +204,7 @@ export const searchStemmata = (copies, names, systems, rows, costs, transfers, k
|
|
|
203
204
|
supports.push({ copies: named(mask), margin: without[mask] - best.cost });
|
|
204
205
|
}
|
|
205
206
|
if (copy >= 0)
|
|
206
|
-
supports.push({ copies: named(mask), ancestor:
|
|
207
|
+
supports.push({ copies: named(mask), ancestor: [...texts[copy]], margin: notAncestor[copy] - best.cost });
|
|
207
208
|
}
|
|
208
209
|
return {
|
|
209
210
|
best: best.tree,
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** Which copies carry the same text, and so one version between them. */
|
|
2
|
+
import { PresenceMatrix } from "./presence.js";
|
|
3
|
+
/**
|
|
4
|
+
* Copies taken to carry one text: copies of one system, punched from one
|
|
5
|
+
* master, that differ in no more readings than a transcription and the
|
|
6
|
+
* wear of a copy explain.
|
|
7
|
+
* @category Filiation
|
|
8
|
+
*/
|
|
9
|
+
export interface SharedText {
|
|
10
|
+
/** The copies, in the order the proposal was given them. */
|
|
11
|
+
copies: string[];
|
|
12
|
+
/** The most readings any two of them differ in, where both can tell. */
|
|
13
|
+
differences: number;
|
|
14
|
+
/** Whether the edition says so: the copies carry one version at first hand there. */
|
|
15
|
+
stated: boolean;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Two copies of one system the proposal keeps apart, and how many
|
|
19
|
+
* readings they differ in, for the closest pairs: what was near being
|
|
20
|
+
* taken for one text.
|
|
21
|
+
* @category Filiation
|
|
22
|
+
*/
|
|
23
|
+
export interface KeptApart {
|
|
24
|
+
copies: [string, string];
|
|
25
|
+
differences: number;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* How many readings two copies of one system may differ in and still be
|
|
29
|
+
* taken to carry the same text, where nothing else is said. A version
|
|
30
|
+
* can differ from the one it derives from by a handful of edits: WM 225's
|
|
31
|
+
* L2 differs from R4 in seven readings. Copies punched from one master,
|
|
32
|
+
* transcribed with care, differ in fewer.
|
|
33
|
+
* @category Filiation
|
|
34
|
+
*/
|
|
35
|
+
export declare const defaultSameText = 3;
|
|
36
|
+
/** In how many readings the two copies differ where both can tell: one has it, the other lacks it. */
|
|
37
|
+
export declare const differencesBetween: (matrix: PresenceMatrix, one: string, other: string) => number;
|
|
38
|
+
/**
|
|
39
|
+
* The copies in groups that carry the same text.
|
|
40
|
+
*
|
|
41
|
+
* Copies of different systems never carry one text, since a version is a
|
|
42
|
+
* reading in one system's words. Copies the edition gives one version at
|
|
43
|
+
* first hand stay together (`stated`). Beyond that, the two groups whose
|
|
44
|
+
* copies differ least are joined first, as long as every copy of the one
|
|
45
|
+
* differs from every copy of the other in no more than `within` readings
|
|
46
|
+
* where both can tell, so that a chain of small differences never joins
|
|
47
|
+
* copies far apart. Ties go to the order the copies were given in.
|
|
48
|
+
* `within` below zero joins only what the edition states.
|
|
49
|
+
* @category Filiation
|
|
50
|
+
*/
|
|
51
|
+
export declare const sharedTexts: (matrix: PresenceMatrix, systems: readonly string[], within: number, stated?: readonly (readonly string[])[]) => {
|
|
52
|
+
texts: SharedText[];
|
|
53
|
+
apart: KeptApart[];
|
|
54
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How many readings two copies of one system may differ in and still be
|
|
3
|
+
* taken to carry the same text, where nothing else is said. A version
|
|
4
|
+
* can differ from the one it derives from by a handful of edits: WM 225's
|
|
5
|
+
* L2 differs from R4 in seven readings. Copies punched from one master,
|
|
6
|
+
* transcribed with care, differ in fewer.
|
|
7
|
+
* @category Filiation
|
|
8
|
+
*/
|
|
9
|
+
export const defaultSameText = 3;
|
|
10
|
+
/** In how many readings the two copies differ where both can tell: one has it, the other lacks it. */
|
|
11
|
+
export const differencesBetween = (matrix, one, other) => {
|
|
12
|
+
let count = 0;
|
|
13
|
+
for (const presence of Object.values(matrix.presence)) {
|
|
14
|
+
const a = presence[one];
|
|
15
|
+
const b = presence[other];
|
|
16
|
+
if ((a === 'present' && b === 'absent') || (a === 'absent' && b === 'present'))
|
|
17
|
+
count++;
|
|
18
|
+
}
|
|
19
|
+
return count;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* The copies in groups that carry the same text.
|
|
23
|
+
*
|
|
24
|
+
* Copies of different systems never carry one text, since a version is a
|
|
25
|
+
* reading in one system's words. Copies the edition gives one version at
|
|
26
|
+
* first hand stay together (`stated`). Beyond that, the two groups whose
|
|
27
|
+
* copies differ least are joined first, as long as every copy of the one
|
|
28
|
+
* differs from every copy of the other in no more than `within` readings
|
|
29
|
+
* where both can tell, so that a chain of small differences never joins
|
|
30
|
+
* copies far apart. Ties go to the order the copies were given in.
|
|
31
|
+
* `within` below zero joins only what the edition states.
|
|
32
|
+
* @category Filiation
|
|
33
|
+
*/
|
|
34
|
+
export const sharedTexts = (matrix, systems, within, stated = []) => {
|
|
35
|
+
const copies = matrix.copies;
|
|
36
|
+
const index = new Map(copies.map((copy, i) => [copy, i]));
|
|
37
|
+
const distance = copies.map((one, i) => copies.map((other, j) => i === j ? 0 : j < i ? NaN : differencesBetween(matrix, one, other)));
|
|
38
|
+
const between = (i, j) => i < j ? distance[i][j] : distance[j][i];
|
|
39
|
+
const groupOf = copies.map((_, i) => i);
|
|
40
|
+
const members = new Map(copies.map((_, i) => [i, [i]]));
|
|
41
|
+
const statedGroups = new Set();
|
|
42
|
+
const join = (a, b) => {
|
|
43
|
+
const [keep, drop] = a < b ? [a, b] : [b, a];
|
|
44
|
+
for (const i of members.get(drop))
|
|
45
|
+
groupOf[i] = keep;
|
|
46
|
+
members.set(keep, [...members.get(keep), ...members.get(drop)].sort((x, y) => x - y));
|
|
47
|
+
members.delete(drop);
|
|
48
|
+
};
|
|
49
|
+
for (const group of stated) {
|
|
50
|
+
const known = group.map(copy => index.get(copy)).filter((i) => i !== undefined);
|
|
51
|
+
for (const i of known.slice(1)) {
|
|
52
|
+
if (systems[i] !== systems[known[0]] || groupOf[i] === groupOf[known[0]])
|
|
53
|
+
continue;
|
|
54
|
+
join(groupOf[i], groupOf[known[0]]);
|
|
55
|
+
}
|
|
56
|
+
if (known.length > 1)
|
|
57
|
+
statedGroups.add(groupOf[known[0]]);
|
|
58
|
+
}
|
|
59
|
+
const pairs = [];
|
|
60
|
+
for (let i = 0; i < copies.length; i++) {
|
|
61
|
+
for (let j = i + 1; j < copies.length; j++) {
|
|
62
|
+
if (systems[i] === systems[j])
|
|
63
|
+
pairs.push({ i, j, d: between(i, j) });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
pairs.sort((a, b) => a.d - b.d || a.i - b.i || a.j - b.j);
|
|
67
|
+
if (within >= 0) {
|
|
68
|
+
for (const { i, j, d } of pairs) {
|
|
69
|
+
if (d > within)
|
|
70
|
+
break;
|
|
71
|
+
const a = groupOf[i];
|
|
72
|
+
const b = groupOf[j];
|
|
73
|
+
if (a === b)
|
|
74
|
+
continue;
|
|
75
|
+
const close = members.get(a).every(x => members.get(b).every(y => between(x, y) <= within));
|
|
76
|
+
if (!close)
|
|
77
|
+
continue;
|
|
78
|
+
const wasStated = statedGroups.has(a) || statedGroups.has(b);
|
|
79
|
+
join(a, b);
|
|
80
|
+
if (wasStated)
|
|
81
|
+
statedGroups.add(Math.min(a, b));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const texts = [...members.entries()]
|
|
85
|
+
.sort(([a], [b]) => a - b)
|
|
86
|
+
.map(([group, list]) => ({
|
|
87
|
+
copies: list.map(i => copies[i]),
|
|
88
|
+
differences: Math.max(0, ...list.flatMap(x => list.filter(y => y > x).map(y => between(x, y)))),
|
|
89
|
+
stated: statedGroups.has(group)
|
|
90
|
+
}));
|
|
91
|
+
const apart = pairs
|
|
92
|
+
.filter(({ i, j }) => groupOf[i] !== groupOf[j])
|
|
93
|
+
.slice(0, 5)
|
|
94
|
+
.map(({ i, j, d }) => ({ copies: [copies[i], copies[j]], differences: d }));
|
|
95
|
+
return { texts, apart };
|
|
96
|
+
};
|
package/package.json
CHANGED