linked-rolls 0.43.0 → 0.45.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.
Files changed (80) hide show
  1. package/README.md +94 -2
  2. package/lib/Agent.d.ts +1 -1
  3. package/lib/Assumption.d.ts +2 -2
  4. package/lib/Collation.d.ts +84 -7
  5. package/lib/Collation.js +63 -16
  6. package/lib/ConditionState.d.ts +1 -1
  7. package/lib/Edit.d.ts +3 -3
  8. package/lib/Edition.d.ts +14 -8
  9. package/lib/EditionView.d.ts +8 -8
  10. package/lib/EditionView.js +6 -6
  11. package/lib/Emulation.d.ts +4 -4
  12. package/lib/Emulation.js +3 -3
  13. package/lib/Feature.d.ts +5 -5
  14. package/lib/FeatureSource.d.ts +4 -4
  15. package/lib/ReproducingSystem.d.ts +4 -4
  16. package/lib/RollCopy.d.ts +10 -10
  17. package/lib/RollCopy.js +4 -4
  18. package/lib/Symbol.d.ts +2 -2
  19. package/lib/Symbol.js +1 -1
  20. package/lib/TrackCalibration.d.ts +1 -1
  21. package/lib/TrackCalibration.js +1 -1
  22. package/lib/TrackerBar.d.ts +3 -3
  23. package/lib/TrackerBar.js +1 -1
  24. package/lib/Version.d.ts +13 -15
  25. package/lib/Version.js +2 -2
  26. package/lib/alignment.d.ts +4 -4
  27. package/lib/alignment.js +3 -3
  28. package/lib/asJsonLd.d.ts +1 -1
  29. package/lib/asJsonLd.js +3 -3
  30. package/lib/constraints.d.ts +1 -1
  31. package/lib/constraints.js +5 -5
  32. package/lib/context.js +4 -4
  33. package/lib/editionOps.d.ts +42 -10
  34. package/lib/editionOps.js +132 -19
  35. package/lib/importJsonLd.d.ts +1 -1
  36. package/lib/importJsonLd.js +3 -3
  37. package/lib/index.d.ts +42 -40
  38. package/lib/index.js +42 -40
  39. package/lib/migrate.js +18 -7
  40. package/lib/procedures.d.ts +1 -1
  41. package/lib/readers/phillipsEroll.d.ts +19 -4
  42. package/lib/readers/phillipsEroll.js +21 -6
  43. package/lib/readers/spencerBar.d.ts +2 -2
  44. package/lib/readers/spencerBar.js +3 -3
  45. package/lib/readers/stanfordAton.d.ts +3 -3
  46. package/lib/readers/stanfordAton.js +4 -4
  47. package/lib/reservations.d.ts +3 -3
  48. package/lib/reservations.js +4 -4
  49. package/lib/scatter.d.ts +225 -0
  50. package/lib/scatter.js +231 -0
  51. package/lib/schema.json +60 -12
  52. package/lib/sigla.d.ts +1 -1
  53. package/lib/sigla.js +4 -4
  54. package/lib/statistics.d.ts +71 -0
  55. package/lib/statistics.js +121 -0
  56. package/lib/substitution.d.ts +5 -5
  57. package/lib/substitution.js +2 -2
  58. package/lib/systems/index.d.ts +2 -2
  59. package/lib/systems/index.js +4 -4
  60. package/lib/systems/welteLicensee/bar.d.ts +1 -1
  61. package/lib/systems/welteLicensee/bar.js +2 -2
  62. package/lib/systems/welteLicensee/system.d.ts +2 -2
  63. package/lib/systems/welteLicensee/system.js +3 -3
  64. package/lib/systems/welteT100/bar.d.ts +1 -1
  65. package/lib/systems/welteT100/bar.js +2 -2
  66. package/lib/systems/welteT100/system.d.ts +4 -4
  67. package/lib/systems/welteT100/system.js +4 -4
  68. package/lib/systems/welteT98/bar.d.ts +1 -1
  69. package/lib/systems/welteT98/bar.js +2 -2
  70. package/lib/systems/welteT98/system.d.ts +4 -4
  71. package/lib/systems/welteT98/system.js +4 -4
  72. package/lib/utils.d.ts +2 -0
  73. package/lib/utils.js +10 -0
  74. package/lib/validate.d.ts +1 -1
  75. package/lib/validate.js +1 -1
  76. package/lib/vocabulary.d.ts +1 -1
  77. package/lib/vocabulary.js +3 -3
  78. package/lib/witnesses.d.ts +2 -2
  79. package/lib/witnesses.js +2 -2
  80. package/package.json +1 -1
@@ -1,10 +1,10 @@
1
1
  import { read } from "midifile-ts";
2
2
  import { v4 } from "uuid";
3
- import { assignObject } from "../Assumption";
4
- import { systemOf } from "../TrackerBar";
5
- import { welteT100 } from "../systems/welteT100/bar";
6
- import { welteLicensee } from "../systems/welteLicensee/bar";
7
- import { inMetersPerMinute, mm, seconds, track } from "../Quantity";
3
+ import { assignObject } from "../Assumption.js";
4
+ import { systemOf } from "../TrackerBar.js";
5
+ import { welteT100 } from "../systems/welteT100/bar.js";
6
+ import { welteLicensee } from "../systems/welteLicensee/bar.js";
7
+ import { inMetersPerMinute, mm, seconds, track } from "../Quantity.js";
8
8
  /**
9
9
  * Peter Phillips's "e-roll" file, the unprocessed output of his
10
10
  * pneumatic roll reader (thesis pp. 182–196): the roll runs over a
@@ -18,7 +18,22 @@ import { inMetersPerMinute, mm, seconds, track } from "../Quantity";
18
18
  * elapsed time, not paper, and the take-up spool accelerates the paper
19
19
  * as it fills, so `placeAt` has to put the time back onto the paper.
20
20
  * And a switch stays open longer than its perforation is long, so a
21
- * hole read here runs past the punched one.
21
+ * hole read here runs past the punched one: Phillips measures the
22
+ * pneumatic on-time as exceeding the apparent length of a perforation
23
+ * (p. 178), and names what it depends on as the length of the
24
+ * perforation, the height of the tracker bar hole and the paper speed
25
+ * (p. 180).
26
+ *
27
+ * That extension is not subtracted. Doing so would need the absolute
28
+ * hole height of the Welte bar, where he gives only the 0.5 mm by which
29
+ * it exceeds the Licensee's. So the end of a hole on this copy is a
30
+ * pneumatic on-time where the end of a scanned copy's hole is a punched
31
+ * slot, and the two are not the same quantity. It shows in a collation:
32
+ * across welte225.org his notes run about 1.8 mm longer than the other
33
+ * copies read them, while the onsets agree. Until it is corrected, a
34
+ * comparison of hole ends against this copy measures the reader as much
35
+ * as the roll, and `offsetEnd` on the derivation absorbs the systematic
36
+ * part of it.
22
37
  */
23
38
  /**
24
39
  * He numbers his files by two rules at once, which his free samples
@@ -1,5 +1,5 @@
1
- import { PaperSpeed, RollCopy } from "../RollCopy";
2
- import { TrackerBar } from "../TrackerBar";
1
+ import { PaperSpeed, RollCopy } from "../RollCopy.js";
2
+ import { TrackerBar } from "../TrackerBar.js";
3
3
  /**
4
4
  * Rows of the image on an inch of paper. The player reads eight rows a
5
5
  * second per unit of roll tempo (tempo 80 comes with a sample rate of
@@ -1,7 +1,7 @@
1
1
  import { v4 } from "uuid";
2
- import { systemOf } from "../TrackerBar";
3
- import { welteLicensee } from "../systems/welteLicensee/bar";
4
- import { feetPerMinute, inMillimeters, px, track } from "../Quantity";
2
+ import { systemOf } from "../TrackerBar.js";
3
+ import { welteLicensee } from "../systems/welteLicensee/bar.js";
4
+ import { feetPerMinute, inMillimeters, px, track } from "../Quantity.js";
5
5
  /**
6
6
  * Spencer Chase's e-roll file (`.bar`, "eRoll Tracker Bar Image") holds
7
7
  * a roll as a list of events: a distance in rows of the scanned image,
@@ -1,6 +1,6 @@
1
- import { RollCopy } from "../RollCopy";
2
- import { TrackerBar } from "../TrackerBar";
3
- import { Track } from "../Quantity";
1
+ import { RollCopy } from "../RollCopy.js";
2
+ import { TrackerBar } from "../TrackerBar.js";
3
+ import { Track } from "../Quantity.js";
4
4
  export interface StanfordAtonOptions {
5
5
  /**
6
6
  * Added to the scanning software's hole numbering to reach the
@@ -1,8 +1,8 @@
1
1
  import { v4 } from "uuid";
2
- import { AtonParser } from "./AtonParser";
3
- import { systemOf } from "../TrackerBar";
4
- import { welteT100 } from "../systems/welteT100/bar";
5
- import { inMillimeters, mean, mm, pixelsPerInch, px, subtract, track } from "../Quantity";
2
+ import { AtonParser } from "./AtonParser.js";
3
+ import { systemOf } from "../TrackerBar.js";
4
+ import { welteT100 } from "../systems/welteT100/bar.js";
5
+ import { inMillimeters, mean, mm, pixelsPerInch, px, subtract, track } from "../Quantity.js";
6
6
  /** Values in these files carry their unit as a suffix, e.g. "37.7646px". */
7
7
  const readPx = (value) => px(parseFloat(value));
8
8
  /** The parser writes one record as an object and several as an array. */
@@ -1,6 +1,6 @@
1
- import { RollCopy } from "./RollCopy";
2
- import { Version } from "./Version";
3
- import { EditionView } from "./EditionView";
1
+ import { RollCopy } from "./RollCopy.js";
2
+ import { Version } from "./Version.js";
3
+ import { EditionView } from "./EditionView.js";
4
4
  export declare const reservationTypes: readonly ['source-not-stated', 'source-undocumented', 'software-not-named', 'instrument-not-named', 'features-interpreted', 'no-physical-evidence', 'measurement-undocumented', 'not-calibrated', 'system-unknown', 'keeper-unknown'];
5
5
  export type ReservationType = typeof reservationTypes[number];
6
6
  /**
@@ -1,7 +1,7 @@
1
- import { bearsPhysicalEvidence, isMeasured, sourceLabels } from "./FeatureSource";
2
- import { calibrationOf, featuresOf } from "./RollCopy";
3
- import { trackerBarOf } from "./systems";
4
- import { witnessesOf } from "./witnesses";
1
+ import { bearsPhysicalEvidence, isMeasured, sourceLabels } from "./FeatureSource.js";
2
+ import { calibrationOf, featuresOf } from "./RollCopy.js";
3
+ import { trackerBarOf } from "./systems/index.js";
4
+ import { witnessesOf } from "./witnesses.js";
5
5
  export const reservationTypes = [
6
6
  'source-not-stated',
7
7
  'source-undocumented',
@@ -0,0 +1,225 @@
1
+ import { Belief } from "./Assumption.js";
2
+ import { BothEnds, CollationTolerance, Displacement } from "./Collation.js";
3
+ import { EditionView } from "./EditionView.js";
4
+ import { Millimeters } from "./Quantity.js";
5
+ import { Histogram, Spread, Tail } from "./statistics.js";
6
+ import { AnySymbol } from "./Symbol.js";
7
+ /**
8
+ * How far the copies of a roll disagree about where a symbol lies, and
9
+ * the collation tolerance that follows from it.
10
+ *
11
+ * The tolerance decides what counts as a reading at all, and a number
12
+ * someone picks decides it by fiat. The disagreement between copies is
13
+ * measurable, and in the material examined so far it is close to
14
+ * normal, so a displacement an editor put there is recognisable as a
15
+ * departure from that curve rather than as a value above a guess. What
16
+ * the measurement yields is a screen: it says which readings the curve
17
+ * does not account for, and an editor says which of those are acts.
18
+ */
19
+ /** Where one copy puts a symbol, against where the copies read with it put it. */
20
+ export interface Reading {
21
+ symbol: Readonly<AnySymbol>;
22
+ /** The copies under test less the rest, at either end of the symbol. */
23
+ displacement: Displacement;
24
+ }
25
+ /**
26
+ * How far the named copies put each of the symbols from where the
27
+ * remaining copies put it.
28
+ *
29
+ * The comparison is between copies and not between two texts, because
30
+ * a collated symbol is one symbol carrying the features of every copy
31
+ * that reads it: no second symbol is left to match it against. Which
32
+ * copies a version was established from is therefore not something the
33
+ * edition still states once the collation has run, and the caller
34
+ * names them. `sidesOf` says which copies attest each side of a
35
+ * derivation, which is the way to name them rather than by hand.
36
+ *
37
+ * The copies named are one **side** of a comparison and the rest are
38
+ * the other, so the displacement is signed and its sign is the caller's
39
+ * choice. Naming a set that is not a side yields a well formed
40
+ * measurement of something else: one copy's own noise against a mixture
41
+ * of both sides, which is no window any collation used and is wider
42
+ * than the truth.
43
+ *
44
+ * A symbol only one side carries measures nothing and is passed over,
45
+ * which leaves out exactly the insertions and the deletions.
46
+ */
47
+ export declare const readingsOf: (view: EditionView, symbols: readonly Readonly<AnySymbol>[], copies: ReadonlySet<string>) => Reading[];
48
+ /**
49
+ * What sample a reading belongs to. Notes and expressions are taken
50
+ * apart by default, since in the present material they scatter
51
+ * differently.
52
+ *
53
+ * That split is a stopgap and no distinction in the model. What it
54
+ * stands in for is a skew: the offset between two copies runs across
55
+ * the width of the paper, and the expression punches sit at the two
56
+ * margins, where the gradient is largest and of opposite sign, while
57
+ * the notes sit between them. Grouped by where the punch sits rather
58
+ * than by what it says, the expression punches are no noisier than the
59
+ * notes. Once the alignment carries a skew term, one sample will do,
60
+ * which is why this is a parameter of the estimator and not a field
61
+ * anywhere.
62
+ */
63
+ export type Grouping = (symbol: Readonly<AnySymbol>) => string;
64
+ export interface ScatterOptions {
65
+ /** What sample a reading belongs to. By default what the symbol is: a note, an expression, a text. */
66
+ groupOf?: Grouping;
67
+ /** The width of the histogram's bins. */
68
+ binWidth?: Millimeters;
69
+ /** The tolerance the edition states at present, against which each departure is reported as still admitted or not. */
70
+ stated?: CollationTolerance;
71
+ }
72
+ /** A reading the calculated tolerance does not admit. */
73
+ export interface Departure {
74
+ /** The symbol read. */
75
+ symbol: string;
76
+ /** How far the copies under test put it from where the rest put it. */
77
+ displacement: Displacement;
78
+ /** That displacement in units of the scatter, at either end. */
79
+ z: BothEnds<number>;
80
+ /**
81
+ * Which end put the reading outside the window. At least one is
82
+ * true, and a reading far out at both is true twice.
83
+ *
84
+ * It is worth seeing apart. The onset, with the kind, decides
85
+ * whether two copies read one command; the end decides whether that
86
+ * command was lengthened or shortened. A separation the end alone
87
+ * makes is as often a punch measured badly as a punch genuinely
88
+ * prolonged, and an editor cannot tell the two apart without being
89
+ * told which test did it.
90
+ */
91
+ separatedBy: BothEnds<boolean>;
92
+ /** Whether the tolerance the edition states at present still admits it. Absent where none was given to compare against. */
93
+ admittedAsStated?: boolean;
94
+ }
95
+ /** How far a sample departs from the normal shape the tolerance assumes of it. */
96
+ export interface Normality {
97
+ /** How much heavier its tails are than a normal sample's. Nothing for a normal shape. */
98
+ excessKurtosis: number;
99
+ /** What it puts beyond two, three and four times the scatter, against what a normal sample would. */
100
+ tails: Tail[];
101
+ }
102
+ /** A histogram with the curve its counts are held to follow. */
103
+ export interface FittedHistogram extends Histogram<'mm'> {
104
+ /**
105
+ * The normal curve, in the counts' own units, so that an overlay is
106
+ * drawn from these numbers rather than fitted a second time:
107
+ * `area / (sigma * sqrt(2 * PI)) * exp(-(((x - centre) / sigma) ** 2) / 2)`.
108
+ */
109
+ curve: {
110
+ centre: Millimeters;
111
+ sigma: Millimeters;
112
+ area: number;
113
+ };
114
+ }
115
+ /** How far one sample of readings scatters, and the tolerance that follows from it. */
116
+ export interface Scatter {
117
+ /** The sample, as the grouping named it. */
118
+ group: string;
119
+ /** Where the readings sit and how far they scatter, at either end. */
120
+ spread: BothEnds<Spread<'mm'>>;
121
+ /**
122
+ * How many times the scatter a reading must lie out before it is
123
+ * taken for a departure rather than for chance. It is fixed by the
124
+ * size of the sample and not at a round three, so that fewer than
125
+ * one of the sample's own readings is expected to pass it.
126
+ */
127
+ k: number;
128
+ /**
129
+ * The window that follows: `k` times the scatter, centred on the
130
+ * median. Its offsets run in the direction of the sample, the
131
+ * copies named less the rest, so a window measured over the
132
+ * parent's copies carries them negated. `toleranceAcross` is what
133
+ * turns these into the window a derivation stores, and it asks
134
+ * which side was named; storing one of these directly does not.
135
+ */
136
+ tolerance: CollationTolerance;
137
+ /** How far the sample departs from the normal shape, at either end. */
138
+ normality: BothEnds<Normality>;
139
+ /** The sample as counts per bin, with the curve to draw over it. */
140
+ histogram: BothEnds<FittedHistogram>;
141
+ /** The readings the calculated tolerance does not admit, the furthest out first. */
142
+ departures: Departure[];
143
+ }
144
+ /**
145
+ * How far out a reading must lie before it is held a departure: the
146
+ * point beyond which fewer than one reading of a sample of this size is
147
+ * expected to fall by chance. For the samples an edge of the stemma
148
+ * yields, some hundreds of readings, this lands between 3.0 and 3.2.
149
+ */
150
+ export declare const departureThreshold: (n: number) => number;
151
+ /**
152
+ * How the readings scatter, one sample per group. A group whose
153
+ * readings all sit at one place yields nothing, since no scatter can be
154
+ * read off such a sample.
155
+ */
156
+ export declare const scatterOf: (readings: readonly Reading[], options?: ScatterOptions) => Scatter[];
157
+ /** How one copy's readings of a version's text scatter against the readings of the copies it is collated with. */
158
+ export declare const scatterOfCopy: (view: EditionView, versionId: string, copyId: string, options?: ScatterOptions) => Scatter[];
159
+ /**
160
+ * Which side of a derivation a measurement was taken over: the version
161
+ * derived, or the version it is read against.
162
+ */
163
+ export type Side = 'child' | 'parent';
164
+ /**
165
+ * One window admitting what every sample's own window admits, in the
166
+ * direction a collation applies it.
167
+ *
168
+ * A derivation states a single tolerance while the samples differ, and
169
+ * covering them keeps each sample within the budget of chance
170
+ * departures its own threshold was chosen for.
171
+ *
172
+ * `named` says whose copies the scatters were measured over, and it is
173
+ * asked for rather than assumed because nothing else can tell. A window
174
+ * measured over the parent's copies runs the other way, and stored
175
+ * unturned its offsets sit on the wrong side of the readings: the width
176
+ * is unaffected, so nothing in the magnitude looks wrong, and the
177
+ * departures are unaffected too, since they are taken about the
178
+ * sample's own median. Only the collation is wrong, and it is wrong in
179
+ * both directions at once, separating readings that belong together and
180
+ * merging readings that do not. Nothing where there is no sample.
181
+ */
182
+ export declare const toleranceAcross: (scatters: readonly Scatter[], named: Side) => CollationTolerance | undefined;
183
+ /** How many of a side's symbols a copy bears. */
184
+ export interface Attestation {
185
+ copy: string;
186
+ symbols: number;
187
+ }
188
+ /** The copies attesting each side of a derivation, each side in order of how much it bears. */
189
+ export interface Sides {
190
+ /** The copies bearing what the child inserts: the reading the derivation moves to. */
191
+ child: Attestation[];
192
+ /** The copies bearing what the child strikes from the parent: the reading it moves from. */
193
+ parent: Attestation[];
194
+ }
195
+ /**
196
+ * Which copies attest each side of a version's derivation, read off the
197
+ * edits themselves: the copies bearing what it inserts stand for the
198
+ * reading it moves to, and those bearing what it strikes for the
199
+ * reading it moves from.
200
+ *
201
+ * This is the way to name a side. Counting only the copies that bear a
202
+ * version's own text will not do it, because a collation hands a
203
+ * child's carriers up to the parent's symbols, so a descendant's copies
204
+ * come to bear an ancestor's readings and look like its own. The
205
+ * deletions are not exposed to that: what a version strikes is attested
206
+ * by the copies that read it before the version departed from it.
207
+ *
208
+ * A fully collated edge inserts and deletes nothing and so attests
209
+ * neither side, which is the case where the edition has genuinely
210
+ * stopped saying and an editor has to.
211
+ */
212
+ export declare const sidesOf: (view: EditionView, versionId: string) => Sides | undefined;
213
+ /**
214
+ * The belief a calculated tolerance rests on, to annotate the
215
+ * derivation's `collationTolerance` with: an inference from the scatter
216
+ * of the readings, the size of the sample and the rule that fixed the
217
+ * threshold. `used` names what the inference worked on, such as the
218
+ * copies compared.
219
+ *
220
+ * It is held likely rather than true. The tolerance follows from the
221
+ * sample only as far as the sample is normal, and a sample far from
222
+ * that shape makes the number unreliable rather than wrong, which is
223
+ * what `normality` is reported for.
224
+ */
225
+ export declare const inferredTolerance: (scatters: readonly Scatter[], used?: readonly string[]) => Belief;
package/lib/scatter.js ADDED
@@ -0,0 +1,231 @@
1
+ import { v4 } from "uuid";
2
+ import { admits, offsetEndOf, offsetStartOf } from "./Collation.js";
3
+ import { mean, mm } from "./Quantity.js";
4
+ import { excessKurtosisOf, histogramOf, normalQuantile, spreadOf, standardise, tailOf } from "./statistics.js";
5
+ import { groupBy } from "./utils.js";
6
+ import { deletedBy, insertedBy } from "./Version.js";
7
+ const placeOf = (carriers) => ({
8
+ from: mean(carriers.map(carrier => carrier.horizontal.from)),
9
+ to: mean(carriers.map(carrier => carrier.horizontal.to))
10
+ });
11
+ const sitsOn = (view, copies) => (feature) => {
12
+ const copy = view.copyOf(feature.id);
13
+ return copy !== undefined && copies.has(copy.id);
14
+ };
15
+ /**
16
+ * How far the named copies put each of the symbols from where the
17
+ * remaining copies put it.
18
+ *
19
+ * The comparison is between copies and not between two texts, because
20
+ * a collated symbol is one symbol carrying the features of every copy
21
+ * that reads it: no second symbol is left to match it against. Which
22
+ * copies a version was established from is therefore not something the
23
+ * edition still states once the collation has run, and the caller
24
+ * names them. `sidesOf` says which copies attest each side of a
25
+ * derivation, which is the way to name them rather than by hand.
26
+ *
27
+ * The copies named are one **side** of a comparison and the rest are
28
+ * the other, so the displacement is signed and its sign is the caller's
29
+ * choice. Naming a set that is not a side yields a well formed
30
+ * measurement of something else: one copy's own noise against a mixture
31
+ * of both sides, which is no window any collation used and is wider
32
+ * than the truth.
33
+ *
34
+ * A symbol only one side carries measures nothing and is passed over,
35
+ * which leaves out exactly the insertions and the deletions.
36
+ */
37
+ export const readingsOf = (view, symbols, copies) => {
38
+ const tested = sitsOn(view, copies);
39
+ return symbols.flatMap((symbol) => {
40
+ const carriers = view.carriersOf(symbol);
41
+ const here = carriers.filter(tested);
42
+ const there = carriers.filter(carrier => !tested(carrier));
43
+ if (here.length === 0 || there.length === 0)
44
+ return [];
45
+ const ours = placeOf(here);
46
+ const theirs = placeOf(there);
47
+ return [{ symbol, displacement: { from: mm(ours.from - theirs.from), to: mm(ours.to - theirs.to) } }];
48
+ });
49
+ };
50
+ const byWhatItIs = symbol => symbol.type;
51
+ /** The distances from the centre a sample's normality is read at. */
52
+ const TAILS = [2, 3, 4];
53
+ const DEFAULT_BIN_WIDTH = mm(0.25);
54
+ /**
55
+ * How far out a reading must lie before it is held a departure: the
56
+ * point beyond which fewer than one reading of a sample of this size is
57
+ * expected to fall by chance. For the samples an edge of the stemma
58
+ * yields, some hundreds of readings, this lands between 3.0 and 3.2.
59
+ */
60
+ export const departureThreshold = (n) => normalQuantile(1 - 1 / (2 * n));
61
+ const estimate = (values, binWidth) => {
62
+ const spread = spreadOf(values);
63
+ const histogram = histogramOf(values, binWidth);
64
+ if (!spread || !histogram || spread.sigma === 0)
65
+ return undefined;
66
+ const standardised = values.map(value => standardise(value, spread));
67
+ return {
68
+ spread,
69
+ standardised,
70
+ normality: {
71
+ excessKurtosis: excessKurtosisOf(standardised),
72
+ tails: TAILS.map(beyond => tailOf(standardised, beyond))
73
+ },
74
+ histogram: {
75
+ ...histogram,
76
+ curve: { centre: spread.median, sigma: spread.sigma, area: spread.n * binWidth }
77
+ }
78
+ };
79
+ };
80
+ const furthestOut = (departure) => Math.max(Math.abs(departure.z.from), Math.abs(departure.z.to));
81
+ const departuresIn = (sample, standardised, k, stated) => sample
82
+ .map((reading, i) => {
83
+ const z = { from: standardised.from[i], to: standardised.to[i] };
84
+ return {
85
+ symbol: reading.symbol.id,
86
+ displacement: reading.displacement,
87
+ z,
88
+ separatedBy: { from: Math.abs(z.from) > k, to: Math.abs(z.to) > k },
89
+ ...(stated && { admittedAsStated: admits(stated, reading.displacement) })
90
+ };
91
+ })
92
+ .filter(departure => furthestOut(departure) > k)
93
+ .sort((a, b) => furthestOut(b) - furthestOut(a));
94
+ const scatterIn = (group, sample, { binWidth = DEFAULT_BIN_WIDTH, stated }) => {
95
+ const from = estimate(sample.map(reading => reading.displacement.from), binWidth);
96
+ const to = estimate(sample.map(reading => reading.displacement.to), binWidth);
97
+ if (!from || !to)
98
+ return [];
99
+ const k = departureThreshold(sample.length);
100
+ return [{
101
+ group,
102
+ spread: { from: from.spread, to: to.spread },
103
+ k,
104
+ tolerance: {
105
+ offsetStart: from.spread.median,
106
+ offsetEnd: to.spread.median,
107
+ toleranceStart: mm(k * from.spread.sigma),
108
+ toleranceEnd: mm(k * to.spread.sigma)
109
+ },
110
+ normality: { from: from.normality, to: to.normality },
111
+ histogram: { from: from.histogram, to: to.histogram },
112
+ departures: departuresIn(sample, { from: from.standardised, to: to.standardised }, k, stated)
113
+ }];
114
+ };
115
+ /**
116
+ * How the readings scatter, one sample per group. A group whose
117
+ * readings all sit at one place yields nothing, since no scatter can be
118
+ * read off such a sample.
119
+ */
120
+ export const scatterOf = (readings, options = {}) => [...groupBy(readings, reading => (options.groupOf ?? byWhatItIs)(reading.symbol))]
121
+ .flatMap(([group, sample]) => scatterIn(group, sample, options));
122
+ /** How one copy's readings of a version's text scatter against the readings of the copies it is collated with. */
123
+ export const scatterOfCopy = (view, versionId, copyId, options = {}) => scatterOf(readingsOf(view, view.snapshot(versionId), new Set([copyId])), options);
124
+ const covering = (windows) => {
125
+ const lowest = Math.min(...windows.map(window => window.offset - window.tolerance));
126
+ const highest = Math.max(...windows.map(window => window.offset + window.tolerance));
127
+ return { offset: mm((lowest + highest) / 2), tolerance: mm((highest - lowest) / 2) };
128
+ };
129
+ /**
130
+ * One window admitting what every sample's own window admits, in the
131
+ * direction a collation applies it.
132
+ *
133
+ * A derivation states a single tolerance while the samples differ, and
134
+ * covering them keeps each sample within the budget of chance
135
+ * departures its own threshold was chosen for.
136
+ *
137
+ * `named` says whose copies the scatters were measured over, and it is
138
+ * asked for rather than assumed because nothing else can tell. A window
139
+ * measured over the parent's copies runs the other way, and stored
140
+ * unturned its offsets sit on the wrong side of the readings: the width
141
+ * is unaffected, so nothing in the magnitude looks wrong, and the
142
+ * departures are unaffected too, since they are taken about the
143
+ * sample's own median. Only the collation is wrong, and it is wrong in
144
+ * both directions at once, separating readings that belong together and
145
+ * merging readings that do not. Nothing where there is no sample.
146
+ */
147
+ export const toleranceAcross = (scatters, named) => {
148
+ if (scatters.length === 0)
149
+ return undefined;
150
+ const towardsChild = named === 'child' ? 1 : -1;
151
+ const start = covering(scatters.map(({ tolerance }) => ({ offset: offsetStartOf(tolerance), tolerance: tolerance.toleranceStart })));
152
+ const end = covering(scatters.map(({ tolerance }) => ({ offset: offsetEndOf(tolerance), tolerance: tolerance.toleranceEnd })));
153
+ return {
154
+ offsetStart: mm(towardsChild * start.offset),
155
+ toleranceStart: start.tolerance,
156
+ offsetEnd: mm(towardsChild * end.offset),
157
+ toleranceEnd: end.tolerance
158
+ };
159
+ };
160
+ const copiesBearing = (view, symbols) => {
161
+ const tally = symbols.reduce((counts, symbol) => {
162
+ const bearers = new Set(view.carriersOf(symbol).flatMap(carrier => {
163
+ const copy = view.copyOf(carrier.id);
164
+ return copy ? [copy.id] : [];
165
+ }));
166
+ bearers.forEach(copy => counts.set(copy, (counts.get(copy) ?? 0) + 1));
167
+ return counts;
168
+ }, new Map());
169
+ return [...tally]
170
+ .map(([copy, symbols]) => ({ copy, symbols }))
171
+ .sort((a, b) => b.symbols - a.symbols);
172
+ };
173
+ /**
174
+ * Which copies attest each side of a version's derivation, read off the
175
+ * edits themselves: the copies bearing what it inserts stand for the
176
+ * reading it moves to, and those bearing what it strikes for the
177
+ * reading it moves from.
178
+ *
179
+ * This is the way to name a side. Counting only the copies that bear a
180
+ * version's own text will not do it, because a collation hands a
181
+ * child's carriers up to the parent's symbols, so a descendant's copies
182
+ * come to bear an ancestor's readings and look like its own. The
183
+ * deletions are not exposed to that: what a version strikes is attested
184
+ * by the copies that read it before the version departed from it.
185
+ *
186
+ * A fully collated edge inserts and deletes nothing and so attests
187
+ * neither side, which is the case where the edition has genuinely
188
+ * stopped saying and an editor has to.
189
+ */
190
+ export const sidesOf = (view, versionId) => {
191
+ const version = view.get(versionId);
192
+ if (!version)
193
+ return undefined;
194
+ return {
195
+ child: copiesBearing(view, insertedBy(version)),
196
+ parent: copiesBearing(view, view.getAll(deletedBy(version)))
197
+ };
198
+ };
199
+ const inMillimetres = (value) => `${value.toFixed(2)} mm`;
200
+ const sampleDescribed = (scatter) => `${scatter.group}: ${scatter.spread.from.n} readings, scattering by `
201
+ + `${inMillimetres(scatter.spread.from.sigma)} at the start and ${inMillimetres(scatter.spread.to.sigma)} at the end `
202
+ + `about a median of ${inMillimetres(scatter.spread.from.median)} and ${inMillimetres(scatter.spread.to.median)}, `
203
+ + `with a threshold of ${scatter.k.toFixed(2)} times the scatter`;
204
+ const warrantFor = (scatters) => 'The tolerance is calculated from the scatter of the readings themselves, taken as the median absolute '
205
+ + 'deviation of how far the collated copies put each symbol from where the copies read with it put it. '
206
+ + `${scatters.map(sampleDescribed).join('. ')}. `
207
+ + 'Each threshold is the point beyond which fewer than one reading of a sample of that size is expected to '
208
+ + 'fall by chance, and the window is the threshold times the scatter about its median.';
209
+ /**
210
+ * The belief a calculated tolerance rests on, to annotate the
211
+ * derivation's `collationTolerance` with: an inference from the scatter
212
+ * of the readings, the size of the sample and the rule that fixed the
213
+ * threshold. `used` names what the inference worked on, such as the
214
+ * copies compared.
215
+ *
216
+ * It is held likely rather than true. The tolerance follows from the
217
+ * sample only as far as the sample is normal, and a sample far from
218
+ * that shape makes the number unreliable rather than wrong, which is
219
+ * what `normality` is reported for.
220
+ */
221
+ export const inferredTolerance = (scatters, used = []) => ({
222
+ type: 'belief',
223
+ id: v4(),
224
+ certainty: 'likely',
225
+ reasons: [{
226
+ type: 'inference',
227
+ premises: [],
228
+ ...(used.length > 0 && { used: [...used] }),
229
+ note: warrantFor(scatters)
230
+ }]
231
+ });