linked-rolls 0.71.0 → 0.72.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -0
- package/lib/filiation/adoption.d.ts +47 -0
- package/lib/filiation/adoption.js +190 -0
- package/lib/filiation/changes.d.ts +33 -0
- package/lib/filiation/changes.js +38 -0
- package/lib/filiation/index.d.ts +12 -0
- package/lib/filiation/index.js +12 -0
- package/lib/filiation/presence.d.ts +50 -0
- package/lib/filiation/presence.js +122 -0
- package/lib/filiation/proposal.d.ts +132 -0
- package/lib/filiation/proposal.js +376 -0
- package/lib/filiation/readings.d.ts +108 -0
- package/lib/filiation/readings.js +281 -0
- package/lib/filiation/search.d.ts +113 -0
- package/lib/filiation/search.js +268 -0
- package/lib/filiation/systems.d.ts +21 -0
- package/lib/filiation/systems.js +39 -0
- package/lib/filiation/trees.d.ts +20 -0
- package/lib/filiation/trees.js +69 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -97,6 +97,42 @@ const midi = midiOf(events, welteT100System.name, welteT100System.defaultOptions
|
|
|
97
97
|
The systems are `linked-rolls/welte-t100` (red Welte),
|
|
98
98
|
`linked-rolls/welte-t98` (green Welte) and `linked-rolls/welte-licensee`.
|
|
99
99
|
|
|
100
|
+
## Proposing a stemma
|
|
101
|
+
|
|
102
|
+
Which versions there are, lost ones included, how they derive from each
|
|
103
|
+
other and which edits belong to which can be proposed from the copies
|
|
104
|
+
alone. `proposeStemma` collates the transcribed copies all at once,
|
|
105
|
+
notes which copies have, lack or cannot show each reading, and scores
|
|
106
|
+
every stemma the copies admit, up to seven copies. A reading arising
|
|
107
|
+
costs ten times a reading lost. Losses count for less where the
|
|
108
|
+
editorial principles of an edition say they are weak evidence: a
|
|
109
|
+
redundancy cleaned up, or what a re-cut for another system lacks. On
|
|
110
|
+
the best stemma, each reading arises in the latest version all copies
|
|
111
|
+
carrying it descend from.
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
import { produce } from 'immer'
|
|
115
|
+
import { adoptStemma, proposeStemma, stemmaChanges } from 'linked-rolls'
|
|
116
|
+
|
|
117
|
+
const proposal = proposeStemma(edition)
|
|
118
|
+
proposal.notation // '*(St1(*(Ne1,St2(Bo1,Ch1))),Wi1)' for WM 225
|
|
119
|
+
proposal.supports // by how much the copies decide each grouping
|
|
120
|
+
|
|
121
|
+
const adopted = produce(edition, adoptStemma(proposal))
|
|
122
|
+
const changes = stemmaChanges(edition, adopted)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Adopting keeps what the edition holds wherever the proposal stands for
|
|
126
|
+
it: the ids of versions and symbols, a derivation's belief, and the
|
|
127
|
+
edits the editor wrote, which hold their versions to what they insert
|
|
128
|
+
and delete. Motivations, beliefs and versions known only from a
|
|
129
|
+
recording remain the editor's to state.
|
|
130
|
+
|
|
131
|
+
The costs are those with which the stemma of WM 225 comes out of its
|
|
132
|
+
six copies. They were found by searching for such values, so they show
|
|
133
|
+
that the stemma is consistent with these principles, not that the
|
|
134
|
+
principles confirm it.
|
|
135
|
+
|
|
100
136
|
## Format
|
|
101
137
|
|
|
102
138
|
The export uses the Roll Edition Ontology (REO), namespace
|
|
@@ -129,3 +165,53 @@ the day as `date-released`, commit, and push a tag `v<version>`. The
|
|
|
129
165
|
`publish.yml` workflow builds, tests and publishes to npm, then makes a
|
|
130
166
|
GitHub release of the tag, which Zenodo archives with a DOI. A test fails
|
|
131
167
|
while the two versions differ.
|
|
168
|
+
|
|
169
|
+
## About
|
|
170
|
+
|
|
171
|
+
linked-rolls grew out of the dissertation *Grünfelds Geist.
|
|
172
|
+
Rekonstruktion einer Welte-Mignon-Klavierrollenaufnahme* (Niels
|
|
173
|
+
Pfeffer, Eberhard Karls Universität Tübingen, Musikwissenschaftliches
|
|
174
|
+
Institut, 2026), which edits Alfred Grünfeld's 1905 recording of
|
|
175
|
+
Schumann's *Träumerei*, Welte-Mignon roll 225, from its surviving
|
|
176
|
+
copies. That edition is published at https://welte225.org/ and can be
|
|
177
|
+
read in the [Roll Desk](https://rolldesk.welte225.org/).
|
|
178
|
+
|
|
179
|
+
Its aim is a scholarly edition of a piano roll that treats the roll the
|
|
180
|
+
way a critical edition treats a text: every copy is a witness, the
|
|
181
|
+
differences between copies are collated into versions, and every
|
|
182
|
+
editorial decision is recorded together with the evidence and the
|
|
183
|
+
beliefs behind it, so that a reader can retrace and contest it. The
|
|
184
|
+
data are meant to be reused outside the library, which is why the
|
|
185
|
+
format is linked data built on CIDOC CRM rather than a file format of
|
|
186
|
+
its own.
|
|
187
|
+
|
|
188
|
+
### Related formats
|
|
189
|
+
|
|
190
|
+
Several formats have been developed or used to represent piano rolls at
|
|
191
|
+
different stages of processing:
|
|
192
|
+
|
|
193
|
+
- **Scan images:** CIS and MRS, often accompanied by ANN metadata.
|
|
194
|
+
- **Event lists:** Wayne Stahnke's RAW, WEB and BAR, PRF, SCN, ROL, and
|
|
195
|
+
the e-roll MIDI files of pneumatic readers.
|
|
196
|
+
- **Symbolic descriptions:** the Roll Analysis files of the Stanford
|
|
197
|
+
University Piano Roll Archive, serialised as ATON.
|
|
198
|
+
|
|
199
|
+
None of them is concerned with what a critical edition needs: an
|
|
200
|
+
apparatus of the variants between copies, the traces a copy carries
|
|
201
|
+
(handwritten notes, patches, perforations added later), a record of how
|
|
202
|
+
and on what assumptions each reading came about, and identifiers by
|
|
203
|
+
which all of this can be referred to and annotated. The dissertation
|
|
204
|
+
surveys these formats in its chapter on variants.
|
|
205
|
+
|
|
206
|
+
Editions of texts and of notated music are mostly encoded in TEI and
|
|
207
|
+
MEI. A roll edition, however, is less a nested text document than a
|
|
208
|
+
flat structure of roll events described in numbers, and its data should
|
|
209
|
+
link to other domains, which is why the Roll Edition Format is JSON-LD
|
|
210
|
+
built on CIDOC CRM and LRMoo.
|
|
211
|
+
|
|
212
|
+
### Maintenance
|
|
213
|
+
|
|
214
|
+
The library is maintained by Niels Pfeffer
|
|
215
|
+
([ORCID 0000-0002-6210-7255](https://orcid.org/0000-0002-6210-7255)). It
|
|
216
|
+
is released under the MIT licence, the ontology and its documentation
|
|
217
|
+
under CC BY 4.0.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Edition } from "../model/Edition.js";
|
|
2
|
+
import { Version } from "../model/Version.js";
|
|
3
|
+
import { EditionOp } from "../ops/draft.js";
|
|
4
|
+
import { StemmaProposal, Unpinned } from "./proposal.js";
|
|
5
|
+
/**
|
|
6
|
+
* What adopting a proposal makes of the stemma: the versions it puts in
|
|
7
|
+
* place of the edition's, and what of the edition it leaves out.
|
|
8
|
+
* @category Filiation
|
|
9
|
+
*/
|
|
10
|
+
export interface Adoption {
|
|
11
|
+
versions: Version[];
|
|
12
|
+
/** By version of the proposal, the id of the version it becomes. */
|
|
13
|
+
ids: Record<string, string>;
|
|
14
|
+
/** The versions of the edition the adoption leaves out. */
|
|
15
|
+
removed: string[];
|
|
16
|
+
/** The edits the editor wrote that the adoption leaves out, and why. */
|
|
17
|
+
dropped: Unpinned[];
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* What adopting the proposal makes of the edition's stemma, worked out
|
|
21
|
+
* without changing it, so that it can be looked at first.
|
|
22
|
+
*
|
|
23
|
+
* A version of the proposal that stands for a version of the edition
|
|
24
|
+
* keeps its id, system, creation and motivations, the derivation it
|
|
25
|
+
* states where it still derives from the same version, and its other
|
|
26
|
+
* derivations, the hypotheses, as long as they name a version that is
|
|
27
|
+
* still there and does not derive from it. Its edits are the proposal's:
|
|
28
|
+
* the edits the editor wrote that the proposal keeps, as they are, and
|
|
29
|
+
* one edit for each further reading it inserts or deletes, or for each
|
|
30
|
+
* recoding. An edit the version stated before over the very same
|
|
31
|
+
* readings is taken over with its id and what was written on it; the
|
|
32
|
+
* others are new and marked unchecked, as a collation marks its own.
|
|
33
|
+
*
|
|
34
|
+
* A reading that stands for a symbol of the edition keeps that symbol's
|
|
35
|
+
* id and whatever it states beside its carriers. A version of the edition
|
|
36
|
+
* nothing stands for goes, unless it states no edits and derives from a
|
|
37
|
+
* version that stays, as a version known only from a recording does.
|
|
38
|
+
* @category Filiation
|
|
39
|
+
*/
|
|
40
|
+
export declare const adoptionOf: (edition: Edition, proposal: StemmaProposal) => Adoption;
|
|
41
|
+
/**
|
|
42
|
+
* Puts the proposed stemma in place of the edition's, as `adoptionOf`
|
|
43
|
+
* describes, and takes back a copy's statement that it carries a version
|
|
44
|
+
* that goes. One operation, and so one undo step.
|
|
45
|
+
* @category Operations
|
|
46
|
+
*/
|
|
47
|
+
export declare const adoptStemma: (proposal: StemmaProposal) => EditionOp;
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/** Taking a proposed stemma into the edition, as one operation. */
|
|
2
|
+
import { v5 } from "uuid";
|
|
3
|
+
import { assignReference, idOf } from "../model/Assumption.js";
|
|
4
|
+
import { derivesFrom, editsOf, insertedBy, principalDerivationOf } from "../model/Version.js";
|
|
5
|
+
import { systemOf } from "../systems/TrackerBar.js";
|
|
6
|
+
import { unchecked } from "../collation/Collation.js";
|
|
7
|
+
import { versionIn } from "../lookup/lookup.js";
|
|
8
|
+
import { declaring, reading } from "../ops/draft.js";
|
|
9
|
+
import { dropStatements } from "../ops/copies.js";
|
|
10
|
+
import { barNamed } from "./systems.js";
|
|
11
|
+
/** The namespace the names of adopted versions and edits are minted in, so that adopting again gives them again. */
|
|
12
|
+
const ADOPTED = '6f1d2c8e-93a4-4f0b-b8a1-2f4c5d7e9a31';
|
|
13
|
+
const exchangeKey = (inserted, deleted) => `${[...inserted].sort().join(',')}/${[...deleted].sort().join(',')}`;
|
|
14
|
+
/** The carriers of the reading, keeping what the edition states of those it held already. */
|
|
15
|
+
const carriersOf = (reading, stated) => {
|
|
16
|
+
const known = new Map(stated.map(carrier => [idOf(carrier), carrier]));
|
|
17
|
+
return reading.carriers.map(id => known.get(id) ?? assignReference(id));
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* What adopting the proposal makes of the edition's stemma, worked out
|
|
21
|
+
* without changing it, so that it can be looked at first.
|
|
22
|
+
*
|
|
23
|
+
* A version of the proposal that stands for a version of the edition
|
|
24
|
+
* keeps its id, system, creation and motivations, the derivation it
|
|
25
|
+
* states where it still derives from the same version, and its other
|
|
26
|
+
* derivations, the hypotheses, as long as they name a version that is
|
|
27
|
+
* still there and does not derive from it. Its edits are the proposal's:
|
|
28
|
+
* the edits the editor wrote that the proposal keeps, as they are, and
|
|
29
|
+
* one edit for each further reading it inserts or deletes, or for each
|
|
30
|
+
* recoding. An edit the version stated before over the very same
|
|
31
|
+
* readings is taken over with its id and what was written on it; the
|
|
32
|
+
* others are new and marked unchecked, as a collation marks its own.
|
|
33
|
+
*
|
|
34
|
+
* A reading that stands for a symbol of the edition keeps that symbol's
|
|
35
|
+
* id and whatever it states beside its carriers. A version of the edition
|
|
36
|
+
* nothing stands for goes, unless it states no edits and derives from a
|
|
37
|
+
* version that stays, as a version known only from a recording does.
|
|
38
|
+
* @category Filiation
|
|
39
|
+
*/
|
|
40
|
+
export const adoptionOf = (edition, proposal) => {
|
|
41
|
+
const readingOf = new Map(proposal.collation.readings.map(reading => [reading.id, reading]));
|
|
42
|
+
const symbolIdOf = (reading) => proposal.symbols[reading] ?? reading;
|
|
43
|
+
const existing = new Map(edition.versions.flatMap(insertedBy).map(symbol => [symbol.id, symbol]));
|
|
44
|
+
// A reading the stemma lets arise more than once is one symbol for each time it arises, each
|
|
45
|
+
// carried by the copies below where it arose: the edition inserts a symbol in one version only.
|
|
46
|
+
const parentOf = new Map(proposal.versions.map(proposed => [proposed.id, proposed.parent]));
|
|
47
|
+
const copiesBelow = new Map(proposal.versions.map(proposed => [proposed.id, new Set()]));
|
|
48
|
+
for (const proposed of proposal.versions) {
|
|
49
|
+
for (let at = proposed.id; at !== undefined; at = parentOf.get(at)) {
|
|
50
|
+
if (proposed.copy)
|
|
51
|
+
copiesBelow.get(at).add(proposed.copy);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const arising = new Map();
|
|
55
|
+
for (const proposed of proposal.versions) {
|
|
56
|
+
for (const reading of proposed.inserts)
|
|
57
|
+
arising.set(reading, [...(arising.get(reading) ?? []), proposed.id]);
|
|
58
|
+
}
|
|
59
|
+
/** The symbol a reading is where it arose in the version given. */
|
|
60
|
+
const symbolIdAt = (reading, at) => {
|
|
61
|
+
const places = arising.get(reading) ?? [];
|
|
62
|
+
return places.indexOf(at) > 0 ? `symbol_${v5(`${reading}|${at}`, ADOPTED)}` : symbolIdOf(reading);
|
|
63
|
+
};
|
|
64
|
+
/** The symbol a version inherits of a reading: the one that arose nearest above it. */
|
|
65
|
+
const inheritedAt = (reading, at) => {
|
|
66
|
+
const places = arising.get(reading) ?? [];
|
|
67
|
+
for (let above = parentOf.get(at); above !== undefined; above = parentOf.get(above)) {
|
|
68
|
+
if (places.includes(above))
|
|
69
|
+
return symbolIdAt(reading, above);
|
|
70
|
+
}
|
|
71
|
+
return symbolIdOf(reading);
|
|
72
|
+
};
|
|
73
|
+
const symbolOf = (id, at) => {
|
|
74
|
+
const reading = readingOf.get(id);
|
|
75
|
+
const twice = (arising.get(id)?.length ?? 0) > 1;
|
|
76
|
+
const below = copiesBelow.get(at);
|
|
77
|
+
const own = twice
|
|
78
|
+
? { ...reading, carriers: reading.carriers.filter((_, index) => below.has(reading.copies[index])) }
|
|
79
|
+
: reading;
|
|
80
|
+
const symbol = symbolIdAt(id, at);
|
|
81
|
+
const stated = symbol === proposal.symbols[id] ? existing.get(symbol) : undefined;
|
|
82
|
+
const carriers = carriersOf(own, stated?.carriers ?? []);
|
|
83
|
+
if (stated)
|
|
84
|
+
return { ...stated, carriers };
|
|
85
|
+
return reading.meaning.type === 'note'
|
|
86
|
+
? { type: 'note', id: symbol, pitch: reading.meaning.pitch, carriers }
|
|
87
|
+
: { type: 'expression', id: symbol, expressionType: reading.meaning.expressionType, scope: reading.meaning.scope, carriers };
|
|
88
|
+
};
|
|
89
|
+
const standingFor = new Map(Object.entries(proposal.symbols).map(([reading, symbol]) => [symbol, reading]));
|
|
90
|
+
/** The symbol as an edit the editor wrote inserts it, with the carriers of the reading it stands for. */
|
|
91
|
+
const restated = (symbol) => {
|
|
92
|
+
const reading = standingFor.get(symbol.id);
|
|
93
|
+
return reading ? { ...symbol, carriers: carriersOf(readingOf.get(reading), symbol.carriers) } : symbol;
|
|
94
|
+
};
|
|
95
|
+
const idFor = (proposed) => proposed.version ?? v5(`version|${proposed.copy ?? ''}|${[...proposed.inserts].sort().join(',')}`, ADOPTED);
|
|
96
|
+
const ids = new Map(proposal.versions.map(proposed => [proposed.id, idFor(proposed)]));
|
|
97
|
+
const staying = new Set(ids.values());
|
|
98
|
+
const versions = proposal.versions.map((proposed) => {
|
|
99
|
+
const id = ids.get(proposed.id);
|
|
100
|
+
const before = proposed.version ? versionIn(edition, proposed.version) : undefined;
|
|
101
|
+
const pinned = new Set(proposed.pinned);
|
|
102
|
+
const stated = before ? editsOf(before) : [];
|
|
103
|
+
const kept = stated
|
|
104
|
+
.filter(edit => pinned.has(edit.id))
|
|
105
|
+
.map(edit => edit.insert ? { ...edit, insert: edit.insert.map(restated) } : edit);
|
|
106
|
+
const spoken = new Set(kept.flatMap(edit => [...(edit.insert ?? []).map(symbol => symbol.id), ...(edit.delete ?? [])]));
|
|
107
|
+
const statedBefore = new Map(stated.map(edit => [exchangeKey((edit.insert ?? []).map(symbol => symbol.id), edit.delete ?? []), edit]));
|
|
108
|
+
const made = (inserts, deletes, recoding) => {
|
|
109
|
+
const inserted = inserts.map(reading => symbolIdAt(reading, proposed.id));
|
|
110
|
+
const deleted = deletes.map(reading => inheritedAt(reading, proposed.id));
|
|
111
|
+
if ([...inserted, ...deleted].some(symbol => spoken.has(symbol)))
|
|
112
|
+
return undefined;
|
|
113
|
+
const key = exchangeKey(inserted, deleted);
|
|
114
|
+
const same = statedBefore.get(key);
|
|
115
|
+
const insert = inserts.map(reading => symbolOf(reading, proposed.id));
|
|
116
|
+
if (same)
|
|
117
|
+
return { ...same, ...(insert.length > 0 && { insert }), ...(deleted.length > 0 && { delete: deleted }) };
|
|
118
|
+
return {
|
|
119
|
+
type: 'edit',
|
|
120
|
+
id: v5(`edit|${id}|${key}`, ADOPTED),
|
|
121
|
+
...(recoding && { editType: 'recoding' }),
|
|
122
|
+
motivation: unchecked,
|
|
123
|
+
...(insert.length > 0 && { insert }),
|
|
124
|
+
...(deleted.length > 0 && { delete: deleted })
|
|
125
|
+
};
|
|
126
|
+
};
|
|
127
|
+
// A recoding an edit of the editor's cuts into is stated reading by reading.
|
|
128
|
+
const whole = proposed.recodings.filter(recoding => ![...recoding.by.map(reading => symbolIdAt(reading, proposed.id)), ...recoding.replaced.map(reading => inheritedAt(reading, proposed.id))]
|
|
129
|
+
.some(symbol => spoken.has(symbol)));
|
|
130
|
+
const recoded = new Set(whole.flatMap(recoding => [...recoding.by, ...recoding.replaced]));
|
|
131
|
+
const edits = [
|
|
132
|
+
...kept,
|
|
133
|
+
...whole.map(recoding => made(recoding.by, recoding.replaced, true)),
|
|
134
|
+
...proposed.inserts.filter(reading => !recoded.has(reading)).map(reading => made([reading], [], false)),
|
|
135
|
+
...proposed.deletes.filter(reading => !recoded.has(reading)).map(reading => made([], [reading], false))
|
|
136
|
+
].filter((edit) => edit !== undefined);
|
|
137
|
+
const parent = proposed.parent ? ids.get(proposed.parent) : undefined;
|
|
138
|
+
const principal = before && principalDerivationOf(before);
|
|
139
|
+
const derivation = parent === undefined
|
|
140
|
+
? undefined
|
|
141
|
+
: principal && idOf(principal) === parent ? principal : assignReference(parent);
|
|
142
|
+
const hypotheses = (before?.basedOn ?? [])
|
|
143
|
+
.filter(other => other !== principal && staying.has(idOf(other)) && idOf(other) !== parent);
|
|
144
|
+
const basedOn = [...(derivation ? [derivation] : []), ...hypotheses];
|
|
145
|
+
const copy = proposed.copy ? edition.copies.find(copy => copy.id === proposed.copy) : undefined;
|
|
146
|
+
const bar = barNamed(proposed.system);
|
|
147
|
+
const system = copy?.production?.system ?? before?.system ?? (bar ? systemOf(bar) : { id: proposed.system, name: proposed.system, sameAs: [] });
|
|
148
|
+
const { basedOn: _, edits: __, ...rest } = before ?? {};
|
|
149
|
+
return {
|
|
150
|
+
...rest,
|
|
151
|
+
id,
|
|
152
|
+
system,
|
|
153
|
+
...(basedOn.length > 0 && { basedOn }),
|
|
154
|
+
edits,
|
|
155
|
+
motivations: declaring(before?.motivations ?? [], edits)
|
|
156
|
+
};
|
|
157
|
+
});
|
|
158
|
+
// A hypothesis may not name a version that now derives from the one stating it.
|
|
159
|
+
const pruned = versions.map(version => ({
|
|
160
|
+
...version,
|
|
161
|
+
...(version.basedOn && {
|
|
162
|
+
basedOn: version.basedOn.filter((derivation, index) => index === 0 || !derivesFrom(versions, idOf(derivation), version.id))
|
|
163
|
+
})
|
|
164
|
+
}));
|
|
165
|
+
const statedOnly = edition.versions.filter(version => {
|
|
166
|
+
if (staying.has(version.id) || editsOf(version).length > 0)
|
|
167
|
+
return false;
|
|
168
|
+
const principal = principalDerivationOf(version);
|
|
169
|
+
return principal !== undefined && staying.has(idOf(principal));
|
|
170
|
+
});
|
|
171
|
+
const removed = edition.versions.filter(version => !staying.has(version.id) && !statedOnly.includes(version)).map(version => version.id);
|
|
172
|
+
// The edition's order where a version stays, the new ones after it.
|
|
173
|
+
const rank = new Map(edition.versions.map((version, index) => [version.id, index]));
|
|
174
|
+
const ordered = [...pruned, ...statedOnly].sort((a, b) => (rank.get(a.id) ?? Infinity) - (rank.get(b.id) ?? Infinity));
|
|
175
|
+
return { versions: ordered, ids: Object.fromEntries(ids), removed, dropped: proposal.unpinned };
|
|
176
|
+
};
|
|
177
|
+
/**
|
|
178
|
+
* Puts the proposed stemma in place of the edition's, as `adoptionOf`
|
|
179
|
+
* describes, and takes back a copy's statement that it carries a version
|
|
180
|
+
* that goes. One operation, and so one undo step.
|
|
181
|
+
* @category Operations
|
|
182
|
+
*/
|
|
183
|
+
export const adoptStemma = (proposal) => reading(edition => {
|
|
184
|
+
const { versions, removed } = adoptionOf(edition, proposal);
|
|
185
|
+
const gone = new Set(removed);
|
|
186
|
+
return draft => {
|
|
187
|
+
draft.versions = versions;
|
|
188
|
+
draft.copies.forEach(copy => dropStatements(copy, statement => gone.has(idOf(statement))));
|
|
189
|
+
};
|
|
190
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** What tells two states of a stemma apart, version by version. */
|
|
2
|
+
import { Edition } from "../model/Edition.js";
|
|
3
|
+
/**
|
|
4
|
+
* How a version differs between two states of the edition: whether it
|
|
5
|
+
* is in either, what it derives from in each, and which symbols it
|
|
6
|
+
* inserts and deletes in one and not the other.
|
|
7
|
+
* @category Filiation
|
|
8
|
+
*/
|
|
9
|
+
export interface VersionChange {
|
|
10
|
+
version: string;
|
|
11
|
+
/** The version it derives from in the state before, where it is in that state. */
|
|
12
|
+
parentBefore?: string | null;
|
|
13
|
+
/** The version it derives from in the state after, where it is in that state. */
|
|
14
|
+
parentAfter?: string | null;
|
|
15
|
+
inserted: {
|
|
16
|
+
kept: number;
|
|
17
|
+
gone: string[];
|
|
18
|
+
added: string[];
|
|
19
|
+
};
|
|
20
|
+
deleted: {
|
|
21
|
+
kept: number;
|
|
22
|
+
gone: string[];
|
|
23
|
+
added: string[];
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Version by version, how the stemma of one state of the edition differs
|
|
28
|
+
* from that of another, as when a proposed stemma is set against the one
|
|
29
|
+
* the edition holds. The versions of the state before come first, in
|
|
30
|
+
* their order, then those only the state after holds.
|
|
31
|
+
* @category Filiation
|
|
32
|
+
*/
|
|
33
|
+
export declare const stemmaChanges: (before: Pick<Edition, 'versions'>, after: Pick<Edition, 'versions'>) => VersionChange[];
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { idOf } from "../model/Assumption.js";
|
|
2
|
+
import { deletedBy, insertedBy, principalDerivationOf } from "../model/Version.js";
|
|
3
|
+
const compared = (before, after) => {
|
|
4
|
+
const was = new Set(before);
|
|
5
|
+
const is = new Set(after);
|
|
6
|
+
return {
|
|
7
|
+
kept: after.filter(id => was.has(id)).length,
|
|
8
|
+
gone: before.filter(id => !is.has(id)),
|
|
9
|
+
added: after.filter(id => !was.has(id))
|
|
10
|
+
};
|
|
11
|
+
};
|
|
12
|
+
const parentOf = (version) => {
|
|
13
|
+
const principal = principalDerivationOf(version);
|
|
14
|
+
return principal ? idOf(principal) : null;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Version by version, how the stemma of one state of the edition differs
|
|
18
|
+
* from that of another, as when a proposed stemma is set against the one
|
|
19
|
+
* the edition holds. The versions of the state before come first, in
|
|
20
|
+
* their order, then those only the state after holds.
|
|
21
|
+
* @category Filiation
|
|
22
|
+
*/
|
|
23
|
+
export const stemmaChanges = (before, after) => {
|
|
24
|
+
const was = new Map(before.versions.map(version => [version.id, version]));
|
|
25
|
+
const is = new Map(after.versions.map(version => [version.id, version]));
|
|
26
|
+
const ids = [...new Set([...was.keys(), ...is.keys()])];
|
|
27
|
+
return ids.map(id => {
|
|
28
|
+
const one = was.get(id);
|
|
29
|
+
const other = is.get(id);
|
|
30
|
+
return {
|
|
31
|
+
version: id,
|
|
32
|
+
...(one && { parentBefore: parentOf(one) }),
|
|
33
|
+
...(other && { parentAfter: parentOf(other) }),
|
|
34
|
+
inserted: compared(one ? insertedBy(one).map(symbol => symbol.id) : [], other ? insertedBy(other).map(symbol => symbol.id) : []),
|
|
35
|
+
deleted: compared(one ? deletedBy(one) : [], other ? deletedBy(other) : [])
|
|
36
|
+
};
|
|
37
|
+
});
|
|
38
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filiation: proposing a stemma from the copies alone, by the editorial
|
|
3
|
+
* principles the rules of an edition state, and taking it into the
|
|
4
|
+
* edition.
|
|
5
|
+
*/
|
|
6
|
+
export { collateCopies, readingsOfCopy, type CopyCollation, type CollatedReading, type CopyReading, type PairWindow, type Recoding } from "./readings.js";
|
|
7
|
+
export { presenceIn, ineffectiveAmong, defaultMargin, type Presence, type PresenceMatrix } from "./presence.js";
|
|
8
|
+
export { defaultCosts, type Costs, type Scored, type Support } from "./search.js";
|
|
9
|
+
export { transfersOf, systemAbove } from "./systems.js";
|
|
10
|
+
export { proposeStemma, defaultMovedWithin, type FiliationOptions, type ProposedVersion, type StemmaProposal, type Unpinned } from "./proposal.js";
|
|
11
|
+
export { adoptionOf, adoptStemma, type Adoption } from "./adoption.js";
|
|
12
|
+
export { stemmaChanges, type VersionChange } from "./changes.js";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filiation: proposing a stemma from the copies alone, by the editorial
|
|
3
|
+
* principles the rules of an edition state, and taking it into the
|
|
4
|
+
* edition.
|
|
5
|
+
*/
|
|
6
|
+
export { collateCopies, readingsOfCopy } from "./readings.js";
|
|
7
|
+
export { presenceIn, ineffectiveAmong, defaultMargin } from "./presence.js";
|
|
8
|
+
export { defaultCosts } from "./search.js";
|
|
9
|
+
export { transfersOf, systemAbove } from "./systems.js";
|
|
10
|
+
export { proposeStemma, defaultMovedWithin } from "./proposal.js";
|
|
11
|
+
export { adoptionOf, adoptStemma } from "./adoption.js";
|
|
12
|
+
export { stemmaChanges } from "./changes.js";
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Which copies carry which collated reading, which lack it, and where a copy cannot say. */
|
|
2
|
+
import { Edition } from "../model/Edition.js";
|
|
3
|
+
import { HorizontalSpan } from "../model/Feature.js";
|
|
4
|
+
import { Millimeters } from "../model/Quantity.js";
|
|
5
|
+
import { TrackerBar, TrackMeaning } from "../systems/TrackerBar.js";
|
|
6
|
+
import { CopyCollation } from "./readings.js";
|
|
7
|
+
/**
|
|
8
|
+
* What a copy says of a reading: it carries it, it lacks it, or it
|
|
9
|
+
* cannot tell, because it is torn there, its text does not reach so
|
|
10
|
+
* far, or its system has no word for the reading.
|
|
11
|
+
* @category Filiation
|
|
12
|
+
*/
|
|
13
|
+
export type Presence = 'present' | 'absent' | 'unknown';
|
|
14
|
+
/**
|
|
15
|
+
* The presence of every collated reading in every copy, and the readings
|
|
16
|
+
* that are ineffective in a copy carrying them.
|
|
17
|
+
* @category Filiation
|
|
18
|
+
*/
|
|
19
|
+
export interface PresenceMatrix {
|
|
20
|
+
copies: string[];
|
|
21
|
+
/** By reading, by copy. */
|
|
22
|
+
presence: Record<string, Record<string, Presence>>;
|
|
23
|
+
/** The readings that are ineffective where a copy carries them: a latched function set again, or cancelled again. */
|
|
24
|
+
redundant: string[];
|
|
25
|
+
}
|
|
26
|
+
/** How far beyond the end of its text, or the edge of a tear, a copy cannot say what it lacks. */
|
|
27
|
+
export declare const defaultMargin: import("../index.js").Quantity<"mm">;
|
|
28
|
+
/**
|
|
29
|
+
* The readings a latched function makes no use of in the text given: a
|
|
30
|
+
* command setting what is set already, or cancelling what is cancelled.
|
|
31
|
+
* Commands at one place are taken cancel first, as the valves take them.
|
|
32
|
+
* @category Filiation
|
|
33
|
+
*/
|
|
34
|
+
export declare const ineffectiveAmong: (bar: TrackerBar, readings: readonly {
|
|
35
|
+
id: string;
|
|
36
|
+
meaning: TrackMeaning;
|
|
37
|
+
place: HorizontalSpan;
|
|
38
|
+
}[]) => Set<string>;
|
|
39
|
+
/**
|
|
40
|
+
* The presence of every reading in every copy of the collation.
|
|
41
|
+
*
|
|
42
|
+
* A copy lacking a reading is taken to lack it only where it could show
|
|
43
|
+
* it: inside its text, away from its tears, and in its own system's
|
|
44
|
+
* words. A copy re-cut for a system that holds what another latches
|
|
45
|
+
* carries a latched pair where one of its held commands stands for it;
|
|
46
|
+
* where it holds the same function nearby without standing for the pair,
|
|
47
|
+
* or where the pair was ineffective anyway, it cannot say.
|
|
48
|
+
* @category Filiation
|
|
49
|
+
*/
|
|
50
|
+
export declare const presenceIn: (edition: Pick<Edition, 'copies'>, collation: CopyCollation, margin?: Millimeters) => PresenceMatrix;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { barOf, tearsOf } from "../model/RollCopy.js";
|
|
2
|
+
import { mm } from "../model/Quantity.js";
|
|
3
|
+
import { reads } from "./readings.js";
|
|
4
|
+
/** How far beyond the end of its text, or the edge of a tear, a copy cannot say what it lacks. */
|
|
5
|
+
export const defaultMargin = mm(3);
|
|
6
|
+
/** How far from a held command of the same function a copy cannot say whether it holds a latched pair. */
|
|
7
|
+
const HELD_REACH = mm(5);
|
|
8
|
+
/** The function a latching command sets or cancels, and whether it sets it, or nothing for any other. */
|
|
9
|
+
const latchOf = (bar, meaning) => {
|
|
10
|
+
if (meaning.type !== 'expression')
|
|
11
|
+
return undefined;
|
|
12
|
+
const operation = bar.operationOf(meaning.expressionType);
|
|
13
|
+
if (!operation || operation.spelling === 'held')
|
|
14
|
+
return undefined;
|
|
15
|
+
return {
|
|
16
|
+
latch: operation.sided ? `${operation.operates} ${meaning.scope}` : operation.operates,
|
|
17
|
+
on: operation.spelling === 'on'
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
/** The function a command operates, the side included where it counts, whatever its spelling. */
|
|
21
|
+
const functionOf = (bar, meaning) => {
|
|
22
|
+
if (meaning.type !== 'expression')
|
|
23
|
+
return undefined;
|
|
24
|
+
const operation = bar.operationOf(meaning.expressionType);
|
|
25
|
+
return operation && (operation.sided ? `${operation.operates} ${meaning.scope}` : operation.operates);
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* The readings a latched function makes no use of in the text given: a
|
|
29
|
+
* command setting what is set already, or cancelling what is cancelled.
|
|
30
|
+
* Commands at one place are taken cancel first, as the valves take them.
|
|
31
|
+
* @category Filiation
|
|
32
|
+
*/
|
|
33
|
+
export const ineffectiveAmong = (bar, readings) => {
|
|
34
|
+
const byLatch = new Map();
|
|
35
|
+
for (const reading of readings) {
|
|
36
|
+
const latch = latchOf(bar, reading.meaning);
|
|
37
|
+
if (!latch)
|
|
38
|
+
continue;
|
|
39
|
+
const commands = byLatch.get(latch.latch) ?? [];
|
|
40
|
+
commands.push({ id: reading.id, at: reading.place.from, on: latch.on });
|
|
41
|
+
byLatch.set(latch.latch, commands);
|
|
42
|
+
}
|
|
43
|
+
const ineffective = new Set();
|
|
44
|
+
for (const commands of byLatch.values()) {
|
|
45
|
+
commands.sort((a, b) => a.at - b.at || Number(a.on) - Number(b.on));
|
|
46
|
+
let on = false;
|
|
47
|
+
for (const command of commands) {
|
|
48
|
+
if (command.on === on)
|
|
49
|
+
ineffective.add(command.id);
|
|
50
|
+
on = command.on;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return ineffective;
|
|
54
|
+
};
|
|
55
|
+
const within = (at, span, margin) => at >= span.from - margin && at <= span.to + margin;
|
|
56
|
+
/**
|
|
57
|
+
* The presence of every reading in every copy of the collation.
|
|
58
|
+
*
|
|
59
|
+
* A copy lacking a reading is taken to lack it only where it could show
|
|
60
|
+
* it: inside its text, away from its tears, and in its own system's
|
|
61
|
+
* words. A copy re-cut for a system that holds what another latches
|
|
62
|
+
* carries a latched pair where one of its held commands stands for it;
|
|
63
|
+
* where it holds the same function nearby without standing for the pair,
|
|
64
|
+
* or where the pair was ineffective anyway, it cannot say.
|
|
65
|
+
* @category Filiation
|
|
66
|
+
*/
|
|
67
|
+
export const presenceIn = (edition, collation, margin = defaultMargin) => {
|
|
68
|
+
const copyOf = new Map(edition.copies.map(copy => [copy.id, copy]));
|
|
69
|
+
const barFor = new Map(collation.copies.map(id => [id, barOf(copyOf.get(id))]));
|
|
70
|
+
const placeOf = new Map();
|
|
71
|
+
for (const id of collation.copies) {
|
|
72
|
+
for (const feature of copyOf.get(id).production?.produced ?? []) {
|
|
73
|
+
if ('horizontal' in feature && feature.horizontal)
|
|
74
|
+
placeOf.set(feature.id, feature.horizontal);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const redundant = new Set();
|
|
78
|
+
for (const copy of collation.copies) {
|
|
79
|
+
const carried = collation.readings.flatMap(reading => {
|
|
80
|
+
const index = reading.copies.indexOf(copy);
|
|
81
|
+
if (index < 0)
|
|
82
|
+
return [];
|
|
83
|
+
return [{ id: reading.id, meaning: reading.meaning, place: placeOf.get(reading.carriers[index]) ?? reading.place }];
|
|
84
|
+
});
|
|
85
|
+
ineffectiveAmong(barFor.get(copy), carried).forEach(id => redundant.add(id));
|
|
86
|
+
}
|
|
87
|
+
const standsFor = new Map();
|
|
88
|
+
for (const recoding of collation.recodings) {
|
|
89
|
+
for (const replaced of recoding.replaced)
|
|
90
|
+
standsFor.set(replaced, [...(standsFor.get(replaced) ?? []), ...recoding.by]);
|
|
91
|
+
}
|
|
92
|
+
const copiesOf = new Map(collation.readings.map(reading => [reading.id, new Set(reading.copies)]));
|
|
93
|
+
const heldBy = new Map(collation.copies.map(copy => [copy, collation.readings.filter(reading => reading.meaning.type === 'expression' && copiesOf.get(reading.id).has(copy))]));
|
|
94
|
+
const tears = new Map(collation.copies.map(copy => [copy, tearsOf(copyOf.get(copy)).map(tear => tear.horizontal)]));
|
|
95
|
+
const presenceOf = (reading, copy) => {
|
|
96
|
+
if (copiesOf.get(reading.id).has(copy))
|
|
97
|
+
return 'present';
|
|
98
|
+
const at = reading.place.from;
|
|
99
|
+
if (!within(at, collation.extent[copy], margin))
|
|
100
|
+
return 'unknown';
|
|
101
|
+
if (tears.get(copy).some(tear => within(at, tear, margin)))
|
|
102
|
+
return 'unknown';
|
|
103
|
+
const bar = barFor.get(copy);
|
|
104
|
+
if (reads(bar, reading.meaning))
|
|
105
|
+
return 'absent';
|
|
106
|
+
if (reading.meaning.type !== 'expression')
|
|
107
|
+
return 'unknown';
|
|
108
|
+
if ((standsFor.get(reading.id) ?? []).some(by => copiesOf.get(by)?.has(copy)))
|
|
109
|
+
return 'present';
|
|
110
|
+
if (redundant.has(reading.id))
|
|
111
|
+
return 'unknown';
|
|
112
|
+
const wanted = functionOf(barFor.get(reading.copies[0]), reading.meaning);
|
|
113
|
+
const held = heldBy.get(copy).some(other => functionOf(bar, other.meaning) === wanted
|
|
114
|
+
&& at >= other.place.from - HELD_REACH && at <= other.place.to + HELD_REACH);
|
|
115
|
+
return held ? 'unknown' : 'absent';
|
|
116
|
+
};
|
|
117
|
+
const presence = {};
|
|
118
|
+
for (const reading of collation.readings) {
|
|
119
|
+
presence[reading.id] = Object.fromEntries(collation.copies.map(copy => [copy, presenceOf(reading, copy)]));
|
|
120
|
+
}
|
|
121
|
+
return { copies: [...collation.copies], presence, redundant: [...redundant].sort() };
|
|
122
|
+
};
|