linked-rolls 0.72.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
@@ -103,7 +103,11 @@ Which versions there are, lost ones included, how they derive from each
103
103
  other and which edits belong to which can be proposed from the copies
104
104
  alone. `proposeStemma` collates the transcribed copies all at once,
105
105
  notes which copies have, lack or cannot show each reading, and scores
106
- every stemma the copies admit, up to seven copies. A reading arising
106
+ every stemma the copies admit. Copies of one system that differ in no
107
+ more than a few readings, or that the edition gives one version, are
108
+ taken to carry one text, and so one version, as pressings of one master
109
+ do; what one of them lacks is a deviation of that copy. The search
110
+ covers up to seven such texts, however many copies carry them. A reading arising
107
111
  costs ten times a reading lost. Losses count for less where the
108
112
  editorial principles of an edition say they are weak evidence: a
109
113
  redundancy cleaned up, or what a re-cut for another system lacks. On
@@ -47,8 +47,7 @@ export const adoptionOf = (edition, proposal) => {
47
47
  const copiesBelow = new Map(proposal.versions.map(proposed => [proposed.id, new Set()]));
48
48
  for (const proposed of proposal.versions) {
49
49
  for (let at = proposed.id; at !== undefined; at = parentOf.get(at)) {
50
- if (proposed.copy)
51
- copiesBelow.get(at).add(proposed.copy);
50
+ proposed.copies.forEach(copy => copiesBelow.get(at).add(copy));
52
51
  }
53
52
  }
54
53
  const arising = new Map();
@@ -92,7 +91,7 @@ export const adoptionOf = (edition, proposal) => {
92
91
  const reading = standingFor.get(symbol.id);
93
92
  return reading ? { ...symbol, carriers: carriersOf(readingOf.get(reading), symbol.carriers) } : symbol;
94
93
  };
95
- const idFor = (proposed) => proposed.version ?? v5(`version|${proposed.copy ?? ''}|${[...proposed.inserts].sort().join(',')}`, ADOPTED);
94
+ const idFor = (proposed) => proposed.version ?? v5(`version|${proposed.copies.join(',')}|${[...proposed.inserts].sort().join(',')}`, ADOPTED);
96
95
  const ids = new Map(proposal.versions.map(proposed => [proposed.id, idFor(proposed)]));
97
96
  const staying = new Set(ids.values());
98
97
  const versions = proposal.versions.map((proposed) => {
@@ -142,7 +141,7 @@ export const adoptionOf = (edition, proposal) => {
142
141
  const hypotheses = (before?.basedOn ?? [])
143
142
  .filter(other => other !== principal && staying.has(idOf(other)) && idOf(other) !== parent);
144
143
  const basedOn = [...(derivation ? [derivation] : []), ...hypotheses];
145
- const copy = proposed.copy ? edition.copies.find(copy => copy.id === proposed.copy) : undefined;
144
+ const copy = edition.copies.find(copy => copy.id === proposed.copies[0]);
146
145
  const bar = barNamed(proposed.system);
147
146
  const system = copy?.production?.system ?? before?.system ?? (bar ? systemOf(bar) : { id: proposed.system, name: proposed.system, sameAs: [] });
148
147
  const { basedOn: _, edits: __, ...rest } = before ?? {};
@@ -10,3 +10,4 @@ export { transfersOf, systemAbove } from "./systems.js";
10
10
  export { proposeStemma, defaultMovedWithin, type FiliationOptions, type ProposedVersion, type StemmaProposal, type Unpinned } from "./proposal.js";
11
11
  export { adoptionOf, adoptStemma, type Adoption } from "./adoption.js";
12
12
  export { stemmaChanges, type VersionChange } from "./changes.js";
13
+ export { sharedTexts, differencesBetween, defaultSameText, type SharedText, type KeptApart } from "./texts.js";
@@ -10,3 +10,4 @@ export { transfersOf, systemAbove } from "./systems.js";
10
10
  export { proposeStemma, defaultMovedWithin } from "./proposal.js";
11
11
  export { adoptionOf, adoptStemma } from "./adoption.js";
12
12
  export { stemmaChanges } from "./changes.js";
13
+ export { sharedTexts, differencesBetween, defaultSameText } from "./texts.js";
@@ -8,6 +8,7 @@ import { Millimeters } from "../model/Quantity.js";
8
8
  import { CopyCollation, Recoding } from "./readings.js";
9
9
  import { PresenceMatrix } from "./presence.js";
10
10
  import { Costs, Scored, Support } from "./search.js";
11
+ import { KeptApart, SharedText } from "./texts.js";
11
12
  /**
12
13
  * How near a reading of the same kind on the other side of the root
13
14
  * makes a reading on the less refined side a moved one, where nothing
@@ -34,8 +35,18 @@ export interface FiliationOptions {
34
35
  movedWithin?: Millimeters;
35
36
  /** How far beyond its text, or the edge of a tear, a copy cannot say what it lacks (3 mm). */
36
37
  margin?: Millimeters;
37
- /** Whether the edits the editor wrote hold their versions to what they insert and delete (yes). */
38
+ /**
39
+ * Whether the editor's decisions hold (yes): the edits the editor
40
+ * wrote hold their versions to what they insert and delete, and
41
+ * copies the edition gives one version carry one text.
42
+ */
38
43
  pin?: boolean;
44
+ /**
45
+ * How many readings two copies of one system may differ in and still
46
+ * be taken to carry the same text (`defaultSameText`). Below zero,
47
+ * only what the edition states is taken for one text.
48
+ */
49
+ sameText?: number;
39
50
  /** How many runners-up to report (5). */
40
51
  alternatives?: number;
41
52
  }
@@ -46,8 +57,8 @@ export interface FiliationOptions {
46
57
  export interface ProposedVersion {
47
58
  /** A name within the proposal: the versions are numbered from the root down. */
48
59
  id: string;
49
- /** The copy carrying the version at first hand; none for a lost state. */
50
- copy?: string;
60
+ /** The copies carrying the version at first hand; none for a lost state. */
61
+ copies: string[];
51
62
  /** The system the version is coded for, by its identifier. */
52
63
  system: string;
53
64
  /** The version it derives from, by its name within the proposal. */
@@ -60,8 +71,11 @@ export interface ProposedVersion {
60
71
  deletes: string[];
61
72
  /** The held commands that stand for a latched pair, among what the version inserts and deletes. */
62
73
  recodings: Recoding[];
63
- /** The readings the version holds that the copy carrying it lacks. */
64
- deviations: string[];
74
+ /** The readings the version holds that a copy carrying it lacks. */
75
+ deviations: {
76
+ copy: string;
77
+ reading: string;
78
+ }[];
65
79
  /** The edits the editor wrote on `version` that the proposal keeps as they are. */
66
80
  pinned: string[];
67
81
  }
@@ -83,6 +97,11 @@ export type Unpinned = {
83
97
  */
84
98
  export interface StemmaProposal {
85
99
  copies: string[];
100
+ /** The copies grouped by the text they carry, each group one version of the stemma. */
101
+ texts: SharedText[];
102
+ /** The closest copies of one system that were kept apart, closest first. */
103
+ apart: KeptApart[];
104
+ sameText: number;
86
105
  costs: Costs;
87
106
  movedWithin: Millimeters;
88
107
  margin: Millimeters;
@@ -10,6 +10,7 @@ import { defaultMargin, presenceIn } from "./presence.js";
10
10
  import { defaultCosts, searchStemmata, statesOf } from "./search.js";
11
11
  import { barNamed, transfersOf } from "./systems.js";
12
12
  import { MAX_COPIES } from "./trees.js";
13
+ import { defaultSameText, sharedTexts } from "./texts.js";
13
14
  /**
14
15
  * How near a reading of the same kind on the other side of the root
15
16
  * makes a reading on the less refined side a moved one, where nothing
@@ -101,16 +102,16 @@ const deleting = (tree, states, v) => !states[v] && tree.parent[v] >= 0 && state
101
102
  * a reading of the same kind within the window and this branch does not
102
103
  * (moved), or where it is ineffective (cleaned up, as redundancies are).
103
104
  */
104
- const baseTextOf = (tree, readings, matrix, placement, held, within, systems) => {
105
+ const baseTextOf = (tree, readings, matrix, placement, held, within, texts, systems) => {
105
106
  const root = tree.copy.length - 1;
106
107
  const children = childrenOf(tree)[root];
107
108
  if (tree.copy[root] >= 0 || children.length !== 2)
108
109
  return [];
109
- const copiesBelow = (v) => matrix.copies.filter((_, c) => tree.below[v] & (1 << c));
110
+ const copiesBelow = (v) => texts.filter((_, t) => tree.below[v] & (1 << t)).flat();
110
111
  const rootSystem = tree.system[root];
111
- /** How many dynamic perforations the copies of the root's system below the node carry, on average. */
112
+ /** How many expression perforations the copies of the root's system below the node carry, on average. */
112
113
  const densityOf = (v) => {
113
- const copies = matrix.copies.filter((_, c) => (tree.below[v] & (1 << c)) && systems[c] === rootSystem);
114
+ const copies = texts.filter((_, t) => (tree.below[v] & (1 << t)) && systems[t] === rootSystem).flat();
114
115
  if (copies.length === 0)
115
116
  return Infinity;
116
117
  const counts = copies.map(copy => readings.filter(reading => reading.meaning.type === 'expression' && matrix.presence[reading.id][copy] === 'present').length);
@@ -185,23 +186,36 @@ export const proposeStemma = (edition, options = {}) => {
185
186
  const copies = transcribed(edition).filter(id => !options.copies || options.copies.includes(id));
186
187
  if (copies.length < 2)
187
188
  throw new Error('A stemma needs at least two copies with features');
188
- if (copies.length > MAX_COPIES)
189
- throw new Error(`The exhaustive search covers at most ${MAX_COPIES} copies`);
190
189
  const costs = { ...defaultCosts, ...options.costs };
191
190
  const movedWithin = options.movedWithin ?? defaultMovedWithin;
192
191
  const margin = options.margin ?? defaultMargin;
192
+ const sameText = options.sameText ?? defaultSameText;
193
193
  const copyOf = new Map(edition.copies.map(copy => [copy.id, copy]));
194
- const names = copies.map(id => copyOf.get(id).siglum ?? id);
195
- const systems = copies.map(id => barOf(copyOf.get(id)).id);
194
+ const systemOfCopy = new Map(copies.map(id => [id, barOf(copyOf.get(id)).id]));
196
195
  const transfers = transfersOf();
196
+ const firstHandOf = (copy) => versionsWitnessedBy(edition, copy)
197
+ .find(witness => witness.by === 'carriers' && witness.through === undefined)?.version;
197
198
  const collation = collateCopies(edition, copies);
198
199
  const matrix = presenceIn(edition, collation, margin);
200
+ // Copies carrying one text are one version, and the search is over texts.
201
+ const stated = options.pin === false ? [] : [...Map.groupBy(copies.flatMap(copy => { const version = firstHandOf(copy); return version ? [{ copy, version }] : []; }), ({ version }) => version).values()].map(group => group.map(({ copy }) => copy));
202
+ const shared = sharedTexts(matrix, copies.map(copy => systemOfCopy.get(copy)), sameText, stated);
203
+ const texts = shared.texts.map(text => text.copies);
204
+ if (texts.length > MAX_COPIES) {
205
+ throw new Error(`The exhaustive search covers at most ${MAX_COPIES} different texts, and these copies carry ${texts.length}`);
206
+ }
207
+ const names = texts.map(text => text.map(id => copyOf.get(id).siglum ?? id).join('+'));
208
+ const systems = texts.map(text => systemOfCopy.get(text[0]));
199
209
  const redundant = new Set(matrix.redundant);
210
+ const textPresence = (reading, text) => {
211
+ const each = text.map(copy => matrix.presence[reading][copy]);
212
+ return each.includes('present') ? 'present' : each.includes('absent') ? 'absent' : 'unknown';
213
+ };
200
214
  const rows = collation.readings.map(reading => ({
201
- presence: copies.map(copy => matrix.presence[reading.id][copy]),
215
+ presence: texts.map(text => textPresence(reading.id, text)),
202
216
  redundant: redundant.has(reading.id)
203
217
  }));
204
- const search = searchStemmata(copies, names, systems, rows, costs, transfers, options.alternatives ?? 5);
218
+ const search = searchStemmata(texts, names, systems, rows, costs, transfers, options.alternatives ?? 5);
205
219
  const tree = search.best;
206
220
  const size = tree.copy.length;
207
221
  const place = (fixed) => {
@@ -226,14 +240,13 @@ export const proposeStemma = (edition, options = {}) => {
226
240
  const standsFor = new Map();
227
241
  const claimed = new Set();
228
242
  for (let v = 0; v < size; v++) {
229
- const copy = tree.copy[v];
230
- if (copy < 0)
243
+ const text = tree.copy[v];
244
+ if (text < 0)
231
245
  continue;
232
- const firstHand = versionsWitnessedBy(edition, copies[copy])
233
- .find(witness => witness.by === 'carriers' && witness.through === undefined);
234
- if (firstHand && !claimed.has(firstHand.version)) {
235
- standsFor.set(v, firstHand.version);
236
- claimed.add(firstHand.version);
246
+ const firstHand = texts[text].map(firstHandOf).find(version => version !== undefined && !claimed.has(version));
247
+ if (firstHand) {
248
+ standsFor.set(v, firstHand);
249
+ claimed.add(firstHand);
237
250
  }
238
251
  }
239
252
  const insertsAt = (placement, v) => collation.readings.filter(reading => inserting(tree, placement.get(reading.id), v)).map(reading => symbols[reading.id] ?? reading.id);
@@ -306,7 +319,7 @@ export const proposeStemma = (edition, options = {}) => {
306
319
  }
307
320
  const { placement, failed } = place(fixed);
308
321
  const held = new Set([...fixed.keys()].filter(reading => !failed.has(reading)));
309
- const baseText = baseTextOf(tree, collation.readings, matrix, placement, held, movedWithin, systems);
322
+ const baseText = baseTextOf(tree, collation.readings, matrix, placement, held, movedWithin, texts, systems);
310
323
  recode(tree, collation.readings, placement);
311
324
  for (const [v, edits] of pins) {
312
325
  for (const { edit, readings } of edits) {
@@ -334,14 +347,13 @@ export const proposeStemma = (edition, options = {}) => {
334
347
  return fits;
335
348
  })
336
349
  : [];
337
- const copy = tree.copy[v];
338
- const inner = copy >= 0 && tree.below[v] !== 1 << copy;
339
- const deviations = inner
340
- ? collation.readings.filter(reading => placement.get(reading.id)[v] && matrix.presence[reading.id][copies[copy]] === 'absent').map(reading => reading.id)
341
- : [];
350
+ const carrying = tree.copy[v] >= 0 ? texts[tree.copy[v]] : [];
351
+ const deviations = carrying.flatMap(copy => collation.readings
352
+ .filter(reading => placement.get(reading.id)[v] && matrix.presence[reading.id][copy] === 'absent')
353
+ .map(reading => ({ copy, reading: reading.id })));
342
354
  return {
343
355
  id: nameOf.get(v),
344
- ...(copy >= 0 && { copy: copies[copy] }),
356
+ copies: [...carrying],
345
357
  system: tree.system[v],
346
358
  ...(tree.parent[v] >= 0 && { parent: nameOf.get(tree.parent[v]) }),
347
359
  ...(standsFor.has(v) && { version: standsFor.get(v) }),
@@ -357,6 +369,9 @@ export const proposeStemma = (edition, options = {}) => {
357
369
  .filter(({ versions }) => versions.length > 1);
358
370
  return {
359
371
  copies,
372
+ texts: shared.texts,
373
+ apart: shared.apart,
374
+ sameText,
360
375
  costs,
361
376
  movedWithin,
362
377
  margin,
@@ -79,8 +79,12 @@ export interface Scored {
79
79
  export interface Support {
80
80
  /** The copies the grouping holds, as the copies of a node and those below it. */
81
81
  copies: string[];
82
- /** Whether it is the grouping of a copy carrying a version above others, rather than of the copies below a node. */
83
- ancestor?: string;
82
+ /**
83
+ * Where it is the grouping of a version carried by copies and lying
84
+ * above others, rather than of the copies below a node: the copies
85
+ * carrying that version.
86
+ */
87
+ ancestor?: string[];
84
88
  margin: number;
85
89
  }
86
90
  /** The outcome of the search. */
@@ -92,12 +96,13 @@ export interface Search {
92
96
  supports: Support[];
93
97
  }
94
98
  /**
95
- * Scores every stemma of the copies and keeps the best, the runners-up
96
- * and, for each grouping of the best, the best stemma without it. Ties
97
- * go to fewer lost states, then to the notation, so that the outcome
98
- * depends on nothing but the copies and the costs.
99
+ * Scores every stemma of the texts, each carried by one copy or by
100
+ * several, and keeps the best, the runners-up and, for each grouping of
101
+ * the best, the best stemma without it. Ties go to fewer lost states,
102
+ * then to the notation, so that the outcome depends on nothing but the
103
+ * copies and the costs.
99
104
  */
100
- export declare const searchStemmata: (copies: readonly string[], names: readonly string[], systems: readonly string[], rows: readonly {
105
+ export declare const searchStemmata: (texts: readonly (readonly string[])[], names: readonly string[], systems: readonly string[], rows: readonly {
101
106
  presence: readonly Presence[];
102
107
  redundant: boolean;
103
108
  }[], costs: Costs, transfers: ReadonlyMap<string, ReadonlySet<string>>, keep: number) => Search;
@@ -138,13 +138,14 @@ const scoreOf = (tree, classes, costs, absent, present) => {
138
138
  };
139
139
  const before = (a, b) => a.cost < b.cost - 1e-9 || (Math.abs(a.cost - b.cost) <= 1e-9 && (a.lost < b.lost || (a.lost === b.lost && a.notation < b.notation)));
140
140
  /**
141
- * Scores every stemma of the copies and keeps the best, the runners-up
142
- * and, for each grouping of the best, the best stemma without it. Ties
143
- * go to fewer lost states, then to the notation, so that the outcome
144
- * depends on nothing but the copies and the costs.
141
+ * Scores every stemma of the texts, each carried by one copy or by
142
+ * several, and keeps the best, the runners-up and, for each grouping of
143
+ * the best, the best stemma without it. Ties go to fewer lost states,
144
+ * then to the notation, so that the outcome depends on nothing but the
145
+ * copies and the costs.
145
146
  */
146
- export const searchStemmata = (copies, names, systems, rows, costs, transfers, keep) => {
147
- const n = copies.length;
147
+ export const searchStemmata = (texts, names, systems, rows, costs, transfers, keep) => {
148
+ const n = texts.length;
148
149
  const classes = classesOf(rows, n, costs);
149
150
  const pool = 2 * n;
150
151
  const absent = Array.from({ length: pool }, () => new Float64Array(classes.weight.length));
@@ -189,7 +190,7 @@ export const searchStemmata = (copies, names, systems, rows, costs, transfers, k
189
190
  throw new Error('No stemma of these copies is admissible');
190
191
  const best = top[0];
191
192
  const supports = [];
192
- const named = (mask) => copies.filter((_, c) => mask & (1 << c));
193
+ const named = (mask) => texts.filter((_, c) => mask & (1 << c)).flat();
193
194
  const seen = new Set();
194
195
  for (let v = 0; v < best.tree.copy.length; v++) {
195
196
  const mask = best.tree.below[v];
@@ -203,7 +204,7 @@ export const searchStemmata = (copies, names, systems, rows, costs, transfers, k
203
204
  supports.push({ copies: named(mask), margin: without[mask] - best.cost });
204
205
  }
205
206
  if (copy >= 0)
206
- supports.push({ copies: named(mask), ancestor: copies[copy], margin: notAncestor[copy] - best.cost });
207
+ supports.push({ copies: named(mask), ancestor: [...texts[copy]], margin: notAncestor[copy] - best.cost });
207
208
  }
208
209
  return {
209
210
  best: best.tree,
@@ -0,0 +1,54 @@
1
+ /** Which copies carry the same text, and so one version between them. */
2
+ import { PresenceMatrix } from "./presence.js";
3
+ /**
4
+ * Copies taken to carry one text: copies of one system, punched from one
5
+ * master, that differ in no more readings than a transcription and the
6
+ * wear of a copy explain.
7
+ * @category Filiation
8
+ */
9
+ export interface SharedText {
10
+ /** The copies, in the order the proposal was given them. */
11
+ copies: string[];
12
+ /** The most readings any two of them differ in, where both can tell. */
13
+ differences: number;
14
+ /** Whether the edition says so: the copies carry one version at first hand there. */
15
+ stated: boolean;
16
+ }
17
+ /**
18
+ * Two copies of one system the proposal keeps apart, and how many
19
+ * readings they differ in, for the closest pairs: what was near being
20
+ * taken for one text.
21
+ * @category Filiation
22
+ */
23
+ export interface KeptApart {
24
+ copies: [string, string];
25
+ differences: number;
26
+ }
27
+ /**
28
+ * How many readings two copies of one system may differ in and still be
29
+ * taken to carry the same text, where nothing else is said. A version
30
+ * can differ from the one it derives from by a handful of edits: WM 225's
31
+ * L2 differs from R4 in seven readings. Copies punched from one master,
32
+ * transcribed with care, differ in fewer.
33
+ * @category Filiation
34
+ */
35
+ export declare const defaultSameText = 3;
36
+ /** In how many readings the two copies differ where both can tell: one has it, the other lacks it. */
37
+ export declare const differencesBetween: (matrix: PresenceMatrix, one: string, other: string) => number;
38
+ /**
39
+ * The copies in groups that carry the same text.
40
+ *
41
+ * Copies of different systems never carry one text, since a version is a
42
+ * reading in one system's words. Copies the edition gives one version at
43
+ * first hand stay together (`stated`). Beyond that, the two groups whose
44
+ * copies differ least are joined first, as long as every copy of the one
45
+ * differs from every copy of the other in no more than `within` readings
46
+ * where both can tell, so that a chain of small differences never joins
47
+ * copies far apart. Ties go to the order the copies were given in.
48
+ * `within` below zero joins only what the edition states.
49
+ * @category Filiation
50
+ */
51
+ export declare const sharedTexts: (matrix: PresenceMatrix, systems: readonly string[], within: number, stated?: readonly (readonly string[])[]) => {
52
+ texts: SharedText[];
53
+ apart: KeptApart[];
54
+ };
@@ -0,0 +1,96 @@
1
+ /**
2
+ * How many readings two copies of one system may differ in and still be
3
+ * taken to carry the same text, where nothing else is said. A version
4
+ * can differ from the one it derives from by a handful of edits: WM 225's
5
+ * L2 differs from R4 in seven readings. Copies punched from one master,
6
+ * transcribed with care, differ in fewer.
7
+ * @category Filiation
8
+ */
9
+ export const defaultSameText = 3;
10
+ /** In how many readings the two copies differ where both can tell: one has it, the other lacks it. */
11
+ export const differencesBetween = (matrix, one, other) => {
12
+ let count = 0;
13
+ for (const presence of Object.values(matrix.presence)) {
14
+ const a = presence[one];
15
+ const b = presence[other];
16
+ if ((a === 'present' && b === 'absent') || (a === 'absent' && b === 'present'))
17
+ count++;
18
+ }
19
+ return count;
20
+ };
21
+ /**
22
+ * The copies in groups that carry the same text.
23
+ *
24
+ * Copies of different systems never carry one text, since a version is a
25
+ * reading in one system's words. Copies the edition gives one version at
26
+ * first hand stay together (`stated`). Beyond that, the two groups whose
27
+ * copies differ least are joined first, as long as every copy of the one
28
+ * differs from every copy of the other in no more than `within` readings
29
+ * where both can tell, so that a chain of small differences never joins
30
+ * copies far apart. Ties go to the order the copies were given in.
31
+ * `within` below zero joins only what the edition states.
32
+ * @category Filiation
33
+ */
34
+ export const sharedTexts = (matrix, systems, within, stated = []) => {
35
+ const copies = matrix.copies;
36
+ const index = new Map(copies.map((copy, i) => [copy, i]));
37
+ const distance = copies.map((one, i) => copies.map((other, j) => i === j ? 0 : j < i ? NaN : differencesBetween(matrix, one, other)));
38
+ const between = (i, j) => i < j ? distance[i][j] : distance[j][i];
39
+ const groupOf = copies.map((_, i) => i);
40
+ const members = new Map(copies.map((_, i) => [i, [i]]));
41
+ const statedGroups = new Set();
42
+ const join = (a, b) => {
43
+ const [keep, drop] = a < b ? [a, b] : [b, a];
44
+ for (const i of members.get(drop))
45
+ groupOf[i] = keep;
46
+ members.set(keep, [...members.get(keep), ...members.get(drop)].sort((x, y) => x - y));
47
+ members.delete(drop);
48
+ };
49
+ for (const group of stated) {
50
+ const known = group.map(copy => index.get(copy)).filter((i) => i !== undefined);
51
+ for (const i of known.slice(1)) {
52
+ if (systems[i] !== systems[known[0]] || groupOf[i] === groupOf[known[0]])
53
+ continue;
54
+ join(groupOf[i], groupOf[known[0]]);
55
+ }
56
+ if (known.length > 1)
57
+ statedGroups.add(groupOf[known[0]]);
58
+ }
59
+ const pairs = [];
60
+ for (let i = 0; i < copies.length; i++) {
61
+ for (let j = i + 1; j < copies.length; j++) {
62
+ if (systems[i] === systems[j])
63
+ pairs.push({ i, j, d: between(i, j) });
64
+ }
65
+ }
66
+ pairs.sort((a, b) => a.d - b.d || a.i - b.i || a.j - b.j);
67
+ if (within >= 0) {
68
+ for (const { i, j, d } of pairs) {
69
+ if (d > within)
70
+ break;
71
+ const a = groupOf[i];
72
+ const b = groupOf[j];
73
+ if (a === b)
74
+ continue;
75
+ const close = members.get(a).every(x => members.get(b).every(y => between(x, y) <= within));
76
+ if (!close)
77
+ continue;
78
+ const wasStated = statedGroups.has(a) || statedGroups.has(b);
79
+ join(a, b);
80
+ if (wasStated)
81
+ statedGroups.add(Math.min(a, b));
82
+ }
83
+ }
84
+ const texts = [...members.entries()]
85
+ .sort(([a], [b]) => a - b)
86
+ .map(([group, list]) => ({
87
+ copies: list.map(i => copies[i]),
88
+ differences: Math.max(0, ...list.flatMap(x => list.filter(y => y > x).map(y => between(x, y)))),
89
+ stated: statedGroups.has(group)
90
+ }));
91
+ const apart = pairs
92
+ .filter(({ i, j }) => groupOf[i] !== groupOf[j])
93
+ .slice(0, 5)
94
+ .map(({ i, j, d }) => ({ copies: [copies[i], copies[j]], differences: d }));
95
+ return { texts, apart };
96
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linked-rolls",
3
- "version": "0.72.0",
3
+ "version": "0.73.0",
4
4
  "description": "Digital editions of piano rolls: import, collation, editorial assumptions, JSON-LD export, and emulation through a reproducing system",
5
5
  "license": "MIT",
6
6
  "repository": {