linked-rolls 0.71.0 → 0.73.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -97,6 +97,46 @@ 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. 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
111
+ costs ten times a reading lost. Losses count for less where the
112
+ editorial principles of an edition say they are weak evidence: a
113
+ redundancy cleaned up, or what a re-cut for another system lacks. On
114
+ the best stemma, each reading arises in the latest version all copies
115
+ carrying it descend from.
116
+
117
+ ```ts
118
+ import { produce } from 'immer'
119
+ import { adoptStemma, proposeStemma, stemmaChanges } from 'linked-rolls'
120
+
121
+ const proposal = proposeStemma(edition)
122
+ proposal.notation // '*(St1(*(Ne1,St2(Bo1,Ch1))),Wi1)' for WM 225
123
+ proposal.supports // by how much the copies decide each grouping
124
+
125
+ const adopted = produce(edition, adoptStemma(proposal))
126
+ const changes = stemmaChanges(edition, adopted)
127
+ ```
128
+
129
+ Adopting keeps what the edition holds wherever the proposal stands for
130
+ it: the ids of versions and symbols, a derivation's belief, and the
131
+ edits the editor wrote, which hold their versions to what they insert
132
+ and delete. Motivations, beliefs and versions known only from a
133
+ recording remain the editor's to state.
134
+
135
+ The costs are those with which the stemma of WM 225 comes out of its
136
+ six copies. They were found by searching for such values, so they show
137
+ that the stemma is consistent with these principles, not that the
138
+ principles confirm it.
139
+
100
140
  ## Format
101
141
 
102
142
  The export uses the Roll Edition Ontology (REO), namespace
@@ -129,3 +169,53 @@ the day as `date-released`, commit, and push a tag `v<version>`. The
129
169
  `publish.yml` workflow builds, tests and publishes to npm, then makes a
130
170
  GitHub release of the tag, which Zenodo archives with a DOI. A test fails
131
171
  while the two versions differ.
172
+
173
+ ## About
174
+
175
+ linked-rolls grew out of the dissertation *Grünfelds Geist.
176
+ Rekonstruktion einer Welte-Mignon-Klavierrollenaufnahme* (Niels
177
+ Pfeffer, Eberhard Karls Universität Tübingen, Musikwissenschaftliches
178
+ Institut, 2026), which edits Alfred Grünfeld's 1905 recording of
179
+ Schumann's *Träumerei*, Welte-Mignon roll 225, from its surviving
180
+ copies. That edition is published at https://welte225.org/ and can be
181
+ read in the [Roll Desk](https://rolldesk.welte225.org/).
182
+
183
+ Its aim is a scholarly edition of a piano roll that treats the roll the
184
+ way a critical edition treats a text: every copy is a witness, the
185
+ differences between copies are collated into versions, and every
186
+ editorial decision is recorded together with the evidence and the
187
+ beliefs behind it, so that a reader can retrace and contest it. The
188
+ data are meant to be reused outside the library, which is why the
189
+ format is linked data built on CIDOC CRM rather than a file format of
190
+ its own.
191
+
192
+ ### Related formats
193
+
194
+ Several formats have been developed or used to represent piano rolls at
195
+ different stages of processing:
196
+
197
+ - **Scan images:** CIS and MRS, often accompanied by ANN metadata.
198
+ - **Event lists:** Wayne Stahnke's RAW, WEB and BAR, PRF, SCN, ROL, and
199
+ the e-roll MIDI files of pneumatic readers.
200
+ - **Symbolic descriptions:** the Roll Analysis files of the Stanford
201
+ University Piano Roll Archive, serialised as ATON.
202
+
203
+ None of them is concerned with what a critical edition needs: an
204
+ apparatus of the variants between copies, the traces a copy carries
205
+ (handwritten notes, patches, perforations added later), a record of how
206
+ and on what assumptions each reading came about, and identifiers by
207
+ which all of this can be referred to and annotated. The dissertation
208
+ surveys these formats in its chapter on variants.
209
+
210
+ Editions of texts and of notated music are mostly encoded in TEI and
211
+ MEI. A roll edition, however, is less a nested text document than a
212
+ flat structure of roll events described in numbers, and its data should
213
+ link to other domains, which is why the Roll Edition Format is JSON-LD
214
+ built on CIDOC CRM and LRMoo.
215
+
216
+ ### Maintenance
217
+
218
+ The library is maintained by Niels Pfeffer
219
+ ([ORCID 0000-0002-6210-7255](https://orcid.org/0000-0002-6210-7255)). It
220
+ is released under the MIT licence, the ontology and its documentation
221
+ 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,189 @@
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
+ proposed.copies.forEach(copy => copiesBelow.get(at).add(copy));
51
+ }
52
+ }
53
+ const arising = new Map();
54
+ for (const proposed of proposal.versions) {
55
+ for (const reading of proposed.inserts)
56
+ arising.set(reading, [...(arising.get(reading) ?? []), proposed.id]);
57
+ }
58
+ /** The symbol a reading is where it arose in the version given. */
59
+ const symbolIdAt = (reading, at) => {
60
+ const places = arising.get(reading) ?? [];
61
+ return places.indexOf(at) > 0 ? `symbol_${v5(`${reading}|${at}`, ADOPTED)}` : symbolIdOf(reading);
62
+ };
63
+ /** The symbol a version inherits of a reading: the one that arose nearest above it. */
64
+ const inheritedAt = (reading, at) => {
65
+ const places = arising.get(reading) ?? [];
66
+ for (let above = parentOf.get(at); above !== undefined; above = parentOf.get(above)) {
67
+ if (places.includes(above))
68
+ return symbolIdAt(reading, above);
69
+ }
70
+ return symbolIdOf(reading);
71
+ };
72
+ const symbolOf = (id, at) => {
73
+ const reading = readingOf.get(id);
74
+ const twice = (arising.get(id)?.length ?? 0) > 1;
75
+ const below = copiesBelow.get(at);
76
+ const own = twice
77
+ ? { ...reading, carriers: reading.carriers.filter((_, index) => below.has(reading.copies[index])) }
78
+ : reading;
79
+ const symbol = symbolIdAt(id, at);
80
+ const stated = symbol === proposal.symbols[id] ? existing.get(symbol) : undefined;
81
+ const carriers = carriersOf(own, stated?.carriers ?? []);
82
+ if (stated)
83
+ return { ...stated, carriers };
84
+ return reading.meaning.type === 'note'
85
+ ? { type: 'note', id: symbol, pitch: reading.meaning.pitch, carriers }
86
+ : { type: 'expression', id: symbol, expressionType: reading.meaning.expressionType, scope: reading.meaning.scope, carriers };
87
+ };
88
+ const standingFor = new Map(Object.entries(proposal.symbols).map(([reading, symbol]) => [symbol, reading]));
89
+ /** The symbol as an edit the editor wrote inserts it, with the carriers of the reading it stands for. */
90
+ const restated = (symbol) => {
91
+ const reading = standingFor.get(symbol.id);
92
+ return reading ? { ...symbol, carriers: carriersOf(readingOf.get(reading), symbol.carriers) } : symbol;
93
+ };
94
+ const idFor = (proposed) => proposed.version ?? v5(`version|${proposed.copies.join(',')}|${[...proposed.inserts].sort().join(',')}`, ADOPTED);
95
+ const ids = new Map(proposal.versions.map(proposed => [proposed.id, idFor(proposed)]));
96
+ const staying = new Set(ids.values());
97
+ const versions = proposal.versions.map((proposed) => {
98
+ const id = ids.get(proposed.id);
99
+ const before = proposed.version ? versionIn(edition, proposed.version) : undefined;
100
+ const pinned = new Set(proposed.pinned);
101
+ const stated = before ? editsOf(before) : [];
102
+ const kept = stated
103
+ .filter(edit => pinned.has(edit.id))
104
+ .map(edit => edit.insert ? { ...edit, insert: edit.insert.map(restated) } : edit);
105
+ const spoken = new Set(kept.flatMap(edit => [...(edit.insert ?? []).map(symbol => symbol.id), ...(edit.delete ?? [])]));
106
+ const statedBefore = new Map(stated.map(edit => [exchangeKey((edit.insert ?? []).map(symbol => symbol.id), edit.delete ?? []), edit]));
107
+ const made = (inserts, deletes, recoding) => {
108
+ const inserted = inserts.map(reading => symbolIdAt(reading, proposed.id));
109
+ const deleted = deletes.map(reading => inheritedAt(reading, proposed.id));
110
+ if ([...inserted, ...deleted].some(symbol => spoken.has(symbol)))
111
+ return undefined;
112
+ const key = exchangeKey(inserted, deleted);
113
+ const same = statedBefore.get(key);
114
+ const insert = inserts.map(reading => symbolOf(reading, proposed.id));
115
+ if (same)
116
+ return { ...same, ...(insert.length > 0 && { insert }), ...(deleted.length > 0 && { delete: deleted }) };
117
+ return {
118
+ type: 'edit',
119
+ id: v5(`edit|${id}|${key}`, ADOPTED),
120
+ ...(recoding && { editType: 'recoding' }),
121
+ motivation: unchecked,
122
+ ...(insert.length > 0 && { insert }),
123
+ ...(deleted.length > 0 && { delete: deleted })
124
+ };
125
+ };
126
+ // A recoding an edit of the editor's cuts into is stated reading by reading.
127
+ const whole = proposed.recodings.filter(recoding => ![...recoding.by.map(reading => symbolIdAt(reading, proposed.id)), ...recoding.replaced.map(reading => inheritedAt(reading, proposed.id))]
128
+ .some(symbol => spoken.has(symbol)));
129
+ const recoded = new Set(whole.flatMap(recoding => [...recoding.by, ...recoding.replaced]));
130
+ const edits = [
131
+ ...kept,
132
+ ...whole.map(recoding => made(recoding.by, recoding.replaced, true)),
133
+ ...proposed.inserts.filter(reading => !recoded.has(reading)).map(reading => made([reading], [], false)),
134
+ ...proposed.deletes.filter(reading => !recoded.has(reading)).map(reading => made([], [reading], false))
135
+ ].filter((edit) => edit !== undefined);
136
+ const parent = proposed.parent ? ids.get(proposed.parent) : undefined;
137
+ const principal = before && principalDerivationOf(before);
138
+ const derivation = parent === undefined
139
+ ? undefined
140
+ : principal && idOf(principal) === parent ? principal : assignReference(parent);
141
+ const hypotheses = (before?.basedOn ?? [])
142
+ .filter(other => other !== principal && staying.has(idOf(other)) && idOf(other) !== parent);
143
+ const basedOn = [...(derivation ? [derivation] : []), ...hypotheses];
144
+ const copy = edition.copies.find(copy => copy.id === proposed.copies[0]);
145
+ const bar = barNamed(proposed.system);
146
+ const system = copy?.production?.system ?? before?.system ?? (bar ? systemOf(bar) : { id: proposed.system, name: proposed.system, sameAs: [] });
147
+ const { basedOn: _, edits: __, ...rest } = before ?? {};
148
+ return {
149
+ ...rest,
150
+ id,
151
+ system,
152
+ ...(basedOn.length > 0 && { basedOn }),
153
+ edits,
154
+ motivations: declaring(before?.motivations ?? [], edits)
155
+ };
156
+ });
157
+ // A hypothesis may not name a version that now derives from the one stating it.
158
+ const pruned = versions.map(version => ({
159
+ ...version,
160
+ ...(version.basedOn && {
161
+ basedOn: version.basedOn.filter((derivation, index) => index === 0 || !derivesFrom(versions, idOf(derivation), version.id))
162
+ })
163
+ }));
164
+ const statedOnly = edition.versions.filter(version => {
165
+ if (staying.has(version.id) || editsOf(version).length > 0)
166
+ return false;
167
+ const principal = principalDerivationOf(version);
168
+ return principal !== undefined && staying.has(idOf(principal));
169
+ });
170
+ const removed = edition.versions.filter(version => !staying.has(version.id) && !statedOnly.includes(version)).map(version => version.id);
171
+ // The edition's order where a version stays, the new ones after it.
172
+ const rank = new Map(edition.versions.map((version, index) => [version.id, index]));
173
+ const ordered = [...pruned, ...statedOnly].sort((a, b) => (rank.get(a.id) ?? Infinity) - (rank.get(b.id) ?? Infinity));
174
+ return { versions: ordered, ids: Object.fromEntries(ids), removed, dropped: proposal.unpinned };
175
+ };
176
+ /**
177
+ * Puts the proposed stemma in place of the edition's, as `adoptionOf`
178
+ * describes, and takes back a copy's statement that it carries a version
179
+ * that goes. One operation, and so one undo step.
180
+ * @category Operations
181
+ */
182
+ export const adoptStemma = (proposal) => reading(edition => {
183
+ const { versions, removed } = adoptionOf(edition, proposal);
184
+ const gone = new Set(removed);
185
+ return draft => {
186
+ draft.versions = versions;
187
+ draft.copies.forEach(copy => dropStatements(copy, statement => gone.has(idOf(statement))));
188
+ };
189
+ });
@@ -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,13 @@
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";
13
+ export { sharedTexts, differencesBetween, defaultSameText, type SharedText, type KeptApart } from "./texts.js";
@@ -0,0 +1,13 @@
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";
13
+ export { sharedTexts, differencesBetween, defaultSameText } from "./texts.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
+ };