linked-rolls 0.64.0 → 0.66.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
@@ -55,6 +55,14 @@ between the two. What the alignments say about the paper of the copies,
55
55
  how far each has stretched and how the papers of the systems relate, is
56
56
  worked out by `paperOf`.
57
57
 
58
+ What is worked out is not stated. Where the edition holds to the ratio a
59
+ re-cut gave the paper of its version, the creation of the version states
60
+ it (`lengthRatio`, as `lengthRatioOf` gives it), so that others can take
61
+ it as a premise. A measured value, such as that ratio, a copy's
62
+ dimensions and hole separation, or the setting of its perforator, is
63
+ held on the strength of a measurement, which names what it was taken
64
+ on, the program and its version, and the day.
65
+
58
66
  Documents written by earlier releases are brought up to date on import.
59
67
  To check a document against the schema:
60
68
 
@@ -80,20 +80,19 @@ const typesNotOnTheBar = (version, snapshot) => {
80
80
  * out says 59. Collation across systems consults only the place, so
81
81
  * without this nothing checks the tracks at all.
82
82
  */
83
- const carriersOffTheirMeaning = (edition, version, commands) => {
84
- const misread = (carrier, symbol) => {
83
+ const carriedOffItsMeaning = (edition) => (symbol) => {
84
+ const key = keyOf(symbol);
85
+ return placedCarriersOf(edition, symbol).some(carrier => {
85
86
  const copy = copyOfFeature(edition, carrier.id);
86
- if (!copy)
87
- return false;
88
87
  // A carrier lying across several positions reads as several commands,
89
88
  // and carries the symbol as long as one of them is the symbol's.
90
- return !barOf(copy).meaningsOf(carrier.vertical)
91
- .some(meaning => keyOf(meaning) === keyOf(symbol));
92
- };
93
- return commands
94
- .filter(symbol => placedCarriersOf(edition, symbol).some(carrier => misread(carrier, symbol)))
95
- .map(symbol => ({ version, symbol: symbol.id, problem: 'carrier-on-another-track' }));
89
+ return copy !== undefined && !barOf(copy).meaningsOf(carrier.vertical).some(meaning => keyOf(meaning) === key);
90
+ });
96
91
  };
92
+ /** The commands the version shows among those carried off their meaning. */
93
+ const carriersOffTheirMeaning = (misread, version, commands) => commands
94
+ .filter(symbol => misread.has(symbol))
95
+ .map(symbol => ({ version, symbol: symbol.id, problem: 'carrier-on-another-track' }));
97
96
  /**
98
97
  * Where a copy of the version's own system strays from the others in
99
98
  * its paper by more than paper does.
@@ -104,13 +103,9 @@ const carriersOffTheirMeaning = (edition, version, commands) => {
104
103
  * speed, and averaging it in would hide both that and the fact that the
105
104
  * playback rests on a guess.
106
105
  */
107
- const paperDisagreed = (edition, version) => {
108
- const own = new Set(paperOfVersion(edition, version)?.copies ?? []);
109
- return alignmentProblems(edition)
110
- .some(({ copy, problem }) => problem === 'paper-beyond-its-spread' && own.has(copy))
111
- ? [{ version: version.id, symbol: version.id, problem: 'copies-disagree-on-the-paper' }]
112
- : [];
113
- };
106
+ const paperDisagreed = (edition, strays, version) => paperOfVersion(edition, version)?.copies.some(copy => strays.has(copy))
107
+ ? [{ version: version.id, symbol: version.id, problem: 'copies-disagree-on-the-paper' }]
108
+ : [];
114
109
  /**
115
110
  * A version's strikes that take nothing out of its text: each deleted
116
111
  * symbol has to be one the parent hands down, or one the version put
@@ -143,14 +138,22 @@ const strikesBitingNothing = (edition, version) => {
143
138
  * track that does not say what its symbol says, and a strike that takes
144
139
  * nothing out.
145
140
  */
146
- export const constraintProblems = (edition) => edition.versions.flatMap(version => {
147
- const snapshot = snapshotOf(edition, version.id);
148
- const commands = snapshot.filter(isCommand);
149
- return [
150
- ...problemsIn(version.id, commands),
151
- ...typesNotOnTheBar(version, snapshot),
152
- ...carriersOffTheirMeaning(edition, version.id, commands),
153
- ...paperDisagreed(edition, version),
154
- ...strikesBitingNothing(edition, version)
155
- ];
156
- });
141
+ export const constraintProblems = (edition) => {
142
+ // What a symbol's carriers and the copies' paper say holds whichever
143
+ // version shows them, so it is asked once for the edition.
144
+ const misread = new Set(edition.versions.flatMap(insertedBy).filter(isCommand).filter(carriedOffItsMeaning(edition)));
145
+ const strays = new Set(alignmentProblems(edition)
146
+ .filter(({ problem }) => problem === 'paper-beyond-its-spread')
147
+ .map(({ copy }) => copy));
148
+ return edition.versions.flatMap(version => {
149
+ const snapshot = snapshotOf(edition, version.id);
150
+ const commands = snapshot.filter(isCommand);
151
+ return [
152
+ ...problemsIn(version.id, commands),
153
+ ...typesNotOnTheBar(version, snapshot),
154
+ ...carriersOffTheirMeaning(misread, version.id, commands),
155
+ ...paperDisagreed(edition, strays, version),
156
+ ...strikesBitingNothing(edition, version)
157
+ ];
158
+ });
159
+ };
@@ -1,5 +1,6 @@
1
1
  import { Edition } from "../model/Edition.js";
2
2
  import { Strain } from "../model/RollCopy.js";
3
+ import { LengthRatio } from "../model/Version.js";
3
4
  import { Percent } from "../model/Quantity.js";
4
5
  /**
5
6
  * How far the paper of one copy is taken to have stretched or shrunk
@@ -127,3 +128,19 @@ export type AlignmentProblem = {
127
128
  * is `alignmentFor`'s to say.
128
129
  */
129
130
  export declare const alignmentProblems: (edition: Pick<Edition, 'copies' | 'referenceCopy'>) => AlignmentProblem[];
131
+ /**
132
+ * How long the version's paper runs for a length of the paper of the
133
+ * version it derives from, as the alignments give it: the ratio of the
134
+ * two versions' systems' papers, with its uncertainty. It is what the
135
+ * creation of the version states where the edition holds to it
136
+ * (`VersionCreation.lengthRatio`).
137
+ *
138
+ * The reading pools the copies of a system, so the ratio is only given
139
+ * for a version that one of those copies bears witness to: a second
140
+ * re-cut for the same system, whose copies measure no paper, is not
141
+ * credited with the first one's ratio. Where the reading has copies of
142
+ * two versions re-cut for one system, the ratio is theirs together.
143
+ * Nothing is given for a version that derives from none, or from one on
144
+ * its own system, whose paper it keeps.
145
+ */
146
+ export declare const lengthRatioOf: (edition: Edition, versionId: string) => LengthRatio | undefined;
@@ -1,7 +1,11 @@
1
1
  import { referenceCopyOf } from "../model/Edition.js";
2
2
  import { barOf, featuresOf, paperStretchOf } from "../model/RollCopy.js";
3
- import { certaintyOf, isAsserted } from "../model/Assumption.js";
3
+ import { certaintyOf, idOf, isAsserted } from "../model/Assumption.js";
4
+ import { principalDerivationOf } from "../model/Version.js";
5
+ import { versionIn } from "../lookup/lookup.js";
6
+ import { witnessesOf } from "./witnesses.js";
4
7
  import { inMetersPerMinute, percent } from "../model/Quantity.js";
8
+ import { trackerBarOf } from "../systems/index.js";
5
9
  /**
6
10
  * How far the paper of one copy is taken to have stretched or shrunk
7
11
  * along the roll, as a standard deviation, where the edition has too few
@@ -258,3 +262,36 @@ export const alignmentProblems = (edition) => {
258
262
  });
259
263
  return [...unaligned, ...elsewhere, ...beyond, ...speeds];
260
264
  };
265
+ /**
266
+ * How long the version's paper runs for a length of the paper of the
267
+ * version it derives from, as the alignments give it: the ratio of the
268
+ * two versions' systems' papers, with its uncertainty. It is what the
269
+ * creation of the version states where the edition holds to it
270
+ * (`VersionCreation.lengthRatio`).
271
+ *
272
+ * The reading pools the copies of a system, so the ratio is only given
273
+ * for a version that one of those copies bears witness to: a second
274
+ * re-cut for the same system, whose copies measure no paper, is not
275
+ * credited with the first one's ratio. Where the reading has copies of
276
+ * two versions re-cut for one system, the ratio is theirs together.
277
+ * Nothing is given for a version that derives from none, or from one on
278
+ * its own system, whose paper it keeps.
279
+ */
280
+ export const lengthRatioOf = (edition, versionId) => {
281
+ const version = versionIn(edition, versionId);
282
+ const derivation = version && principalDerivationOf(version);
283
+ const base = derivation && versionIn(edition, idOf(derivation));
284
+ const reading = paperOf(edition);
285
+ if (!version || !base || !reading)
286
+ return undefined;
287
+ const systemOf = (of) => reading.systems.find(paper => paper.system === trackerBarOf(of.system)?.id);
288
+ const own = systemOf(version);
289
+ const theirs = systemOf(base);
290
+ if (!own || !theirs || own.system === theirs.system)
291
+ return undefined;
292
+ const witnesses = new Set(witnessesOf(edition, versionId).map(witness => witness.copy));
293
+ if (!own.copies.some(copy => witnesses.has(copy)))
294
+ return undefined;
295
+ const value = own.ratio / theirs.ratio;
296
+ return { value, uncertainty: value * Math.hypot(own.ratioError / own.ratio, theirs.ratioError / theirs.ratio) };
297
+ };
@@ -1,5 +1,5 @@
1
1
  import { bearsPhysicalEvidence, isMeasured, sourceLabels } from "../model/FeatureSource.js";
2
- import { calibrationOf, featuresOf } from "../model/RollCopy.js";
2
+ import { calibrationOf, featuresOf, measuringSoftwareOf } from "../model/RollCopy.js";
3
3
  import { trackerBarOf } from "../systems/index.js";
4
4
  import { witnessesOf } from "./witnesses.js";
5
5
  export const reservationTypes = [
@@ -49,7 +49,7 @@ const physicalEvidence = copy => copy.readFrom && !bearsPhysicalEvidence(copy.re
49
49
  type: 'no-physical-evidence',
50
50
  note: 'Punch diameter, hole separation, punching pattern, marks and writings cannot be observed on this source.'
51
51
  } : undefined;
52
- const measurementDocumented = copy => !copy.measurements.measuredBy && copy.readFrom?.kind !== 'roll' ? {
52
+ const measurementDocumented = copy => measuringSoftwareOf(copy).length === 0 && copy.readFrom?.kind !== 'roll' ? {
53
53
  type: 'measurement-undocumented',
54
54
  note: 'No measuring software is recorded for this copy.'
55
55
  } : undefined;
@@ -2,6 +2,7 @@ import { certaintyOf, idOf, idsOf } from "../model/Assumption.js";
2
2
  import { insertedBy } from "../model/Version.js";
3
3
  import { lineageOf } from "./stemma.js";
4
4
  import { copyOfFeature, versionIn } from "../lookup/lookup.js";
5
+ import { perState } from "../lookup/perState.js";
5
6
  /** The copies whose features carry any of the symbols. */
6
7
  const copiesCarrying = (edition, symbols) => new Set(symbols
7
8
  .flatMap(symbol => idsOf(symbol.carriers))
@@ -17,19 +18,21 @@ const carriagesIn = (edition) => edition.versions.flatMap(version => {
17
18
  .filter(copy => carrying.has(copy.id))
18
19
  .map((copy) => ({ copy: copy.id, version: version.id, depth }));
19
20
  });
20
- const carriersIn = (edition) => {
21
+ /** What the copies' features attest, worked out once for each state of the edition. */
22
+ const carriersIn = perState((edition) => {
21
23
  const carriages = carriagesIn(edition);
24
+ const byVersion = Map.groupBy(carriages, carriage => carriage.version);
22
25
  return {
23
26
  copiesOf: new Map(edition.versions.map(version => [
24
27
  version.id,
25
- carriages.filter(carriage => carriage.version === version.id).map(carriage => carriage.copy)
28
+ (byVersion.get(version.id) ?? []).map(carriage => carriage.copy)
26
29
  ])),
27
30
  // Written in order of depth, so a copy's latest carriage is set last and stands.
28
31
  latestOf: new Map([...carriages]
29
32
  .sort((one, other) => one.depth - other.depth)
30
33
  .map(carriage => [carriage.copy, carriage.version]))
31
34
  };
32
- };
35
+ });
33
36
  /**
34
37
  * The versions a copy's features carry at first hand: for every copy,
35
38
  * the latest state it bears. A version left out is reached only through
@@ -1,6 +1,7 @@
1
1
  import { featuresOf, isTear, tearsOf } from "../model/RollCopy.js";
2
2
  import { defaultTrackerBar } from "../systems/index.js";
3
3
  import { add, mm, subtract, track } from "../model/Quantity.js";
4
+ import { partitionPoint } from "../shared/sorted.js";
4
5
  /** A place on the copy's own paper, on the edition's axis. */
5
6
  export const toAxis = (alignment) => (place) => alignment ? mm((place + alignment.shift.horizontal) * alignment.scale) : place;
6
7
  /** A place on the edition's axis, on the copy's own paper. */
@@ -217,12 +218,41 @@ const noteOnsets = (features, bar) => features
217
218
  .map(meaning => ({ pitch: meaning.pitch, at: feature.horizontal.from }));
218
219
  })
219
220
  .sort(byPlace);
221
+ /**
222
+ * The middle one of the values, the upper of the two where their number
223
+ * is even. It is selected rather than the values sorted (Hoare's FIND,
224
+ * as Wirth writes it): a long roll gives over a million slopes, and only
225
+ * the one in the middle is wanted. The values are reordered where they
226
+ * lie.
227
+ */
220
228
  const median = (values) => {
221
- const sorted = [...values].sort((x, y) => x - y);
222
- return sorted[Math.floor(sorted.length / 2)] ?? 0;
229
+ const middle = values.length >> 1;
230
+ let low = 0;
231
+ let high = values.length - 1;
232
+ while (low < high) {
233
+ const pivot = values[middle];
234
+ let i = low;
235
+ let j = high;
236
+ while (i <= j) {
237
+ while (values[i] < pivot)
238
+ i++;
239
+ while (pivot < values[j])
240
+ j--;
241
+ if (i <= j) {
242
+ const swapped = values[i];
243
+ values[i] = values[j];
244
+ values[j] = swapped;
245
+ i++;
246
+ j--;
247
+ }
248
+ }
249
+ if (j < middle)
250
+ low = i;
251
+ if (middle < i)
252
+ high = j;
253
+ }
254
+ return values[middle] ?? 0;
223
255
  };
224
- /** The slope of the line through two matches, or nothing where they share a place on the copy. */
225
- const slopeBetween = (m, n) => n.a === m.a ? [] : [(n.b - m.b) / (n.a - m.a)];
226
256
  /** Matches beyond this are thinned before the slopes are taken, whose number grows with the square. */
227
257
  const SLOPE_SAMPLE = 1500;
228
258
  /**
@@ -234,25 +264,27 @@ const SLOPE_SAMPLE = 1500;
234
264
  const robustLine = (matches) => {
235
265
  const step = Math.max(1, Math.ceil(matches.length / SLOPE_SAMPLE));
236
266
  const sample = matches.filter((_, i) => i % step === 0);
237
- const slopes = sample.flatMap((m, i) => sample.slice(i + 1).flatMap(n => slopeBetween(m, n)));
238
- if (slopes.length === 0)
267
+ const slopes = new Float64Array(sample.length * (sample.length - 1) / 2);
268
+ let count = 0;
269
+ sample.forEach((m, i) => {
270
+ for (let j = i + 1; j < sample.length; j++) {
271
+ const n = sample[j];
272
+ // Two matches at one place on the copy give no slope.
273
+ if (n.a !== m.a)
274
+ slopes[count++] = (n.b - m.b) / (n.a - m.a);
275
+ }
276
+ });
277
+ if (count === 0)
239
278
  return undefined;
240
- const slope = median(slopes);
241
- return { slope, intercept: median(matches.map(m => m.b - slope * m.a)) };
279
+ const slope = median(slopes.subarray(0, count));
280
+ return { slope, intercept: median(Float64Array.from(matches, m => m.b - slope * m.a)) };
242
281
  };
243
282
  const placed = (line, a) => line.slope * a + line.intercept;
244
283
  const residualOf = (line, match) => match.b - placed(line, match.a);
245
284
  /** Pitches in a row that pin down a place on a roll, provided the row occurs once. */
246
285
  const ANCHOR_LENGTH = 6;
247
286
  /** Every run of `length` pitches, keyed by the run, with the onsets it starts at. */
248
- const runsOf = (onsets, length) => {
249
- const runs = new Map();
250
- onsets.slice(0, Math.max(0, onsets.length - length + 1)).forEach((start, i) => {
251
- const key = onsets.slice(i, i + length).map(onset => onset.pitch).join(',');
252
- runs.set(key, [...(runs.get(key) ?? []), start]);
253
- });
254
- return runs;
255
- };
287
+ const runsOf = (onsets, length) => Map.groupBy(onsets.slice(0, Math.max(0, onsets.length - length + 1)), (_, i) => onsets.slice(i, i + length).map(onset => onset.pitch).join(','));
256
288
  /** The places a run of pitches found once on each roll ties together. */
257
289
  const anchors = (a, b) => {
258
290
  const runsB = runsOf(b, ANCHOR_LENGTH);
@@ -277,13 +309,16 @@ const pairedNearestFirst = (candidates) => {
277
309
  }
278
310
  return matches;
279
311
  };
312
+ /** Widens the window by a hair before it is searched, so that rounding in its bounds cannot leave out what the distance admits. */
313
+ const WINDOW_SLACK = 1e-9;
280
314
  /** Each onset of A paired with the nearest onset of B of its pitch within the window around where the line puts it. */
281
315
  const nearestMatches = (a, b, line, window) => {
282
- const bByPitch = new Map();
283
- b.forEach(onset => bByPitch.set(onset.pitch, [...(bByPitch.get(onset.pitch) ?? []), onset]));
284
316
  const candidates = a.flatMap(onset => {
285
317
  const expected = placed(line, onset.at);
286
- return (bByPitch.get(onset.pitch) ?? [])
318
+ const ofPitch = b.get(onset.pitch) ?? [];
319
+ const first = partitionPoint(ofPitch, other => other.at < expected - window - WINDOW_SLACK);
320
+ const end = partitionPoint(ofPitch, other => other.at <= expected + window + WINDOW_SLACK);
321
+ return ofPitch.slice(first, end)
287
322
  .map(other => ({ distance: Math.abs(other.at - expected), a: onset, b: other }))
288
323
  .filter(candidate => candidate.distance <= window);
289
324
  });
@@ -341,5 +376,5 @@ export function alignFeatures(rollA, rollB, barA = defaultTrackerBar, barB = bar
341
376
  const coarse = robustLine(anchors(a, b));
342
377
  if (!coarse)
343
378
  return undefined;
344
- return resultOf(WINDOWS.reduce(settled(a, b), { line: coarse, matches: [] }));
379
+ return resultOf(WINDOWS.reduce(settled(a, Map.groupBy(b, onset => onset.pitch)), { line: coarse, matches: [] }));
345
380
  }
@@ -66,7 +66,7 @@ export interface ScatterOptions {
66
66
  groupOf?: Grouping;
67
67
  /** The width of the histogram's bins. */
68
68
  binWidth?: Millimeters;
69
- /** The tolerance the edition states at present, against which each departure is reported as still admitted or not. */
69
+ /** The tolerance the derivation states at present, against which each departure is reported as still admitted or not. */
70
70
  stated?: CollationTolerance;
71
71
  }
72
72
  /** A reading the calculated tolerance does not admit. */
@@ -90,7 +90,7 @@ export interface Departure {
90
90
  */
91
91
  separatedBy: BothEnds<boolean>;
92
92
  /**
93
- * Whether the tolerance the edition states at present still admits
93
+ * Whether the tolerance the derivation states at present still admits
94
94
  * it. Absent where none was given to compare against.
95
95
  *
96
96
  * This sees one direction only. A departure is by definition a
@@ -80,23 +80,19 @@ const changing = (copyId) => (act) => {
80
80
  return { ...act, diminished: { '@id': copyId } };
81
81
  return act;
82
82
  };
83
+ /** The copy with what each of its acts changed stated. */
84
+ const withChangesOf = (copy) => isRecord(copy) && Array.isArray(copy.modifications)
85
+ ? { ...copy, modifications: copy.modifications.map(changing(copy['@id'])) }
86
+ : copy;
83
87
  /**
84
88
  * The document with what each act changed stated. Which of P110
85
89
  * augmented and P112 diminished applies depends on the class of the
86
90
  * act, which no property chain in OWL 2 RL can test, so the export
87
91
  * states it. What a copy or a patch bears is left to a reasoner (see
88
- * the entailments in reo.ttl).
92
+ * the entailments in reo.ttl). The copies are the only nodes that are
93
+ * modified, and the edition holds them in its list of copies alone.
89
94
  */
90
- const withChanges = (value) => {
91
- if (Array.isArray(value))
92
- return value.map(withChanges);
93
- if (!isRecord(value))
94
- return value;
95
- const node = Object.fromEntries(Object.entries(value).map(([key, child]) => [key, withChanges(child)]));
96
- return node['@type'] === 'RollCopy' && Array.isArray(node.modifications)
97
- ? { ...node, modifications: node.modifications.map(changing(node['@id'])) }
98
- : node;
99
- };
95
+ const withChanges = (edition) => Array.isArray(edition.copies) ? { ...edition, copies: edition.copies.map(withChangesOf) } : edition;
100
96
  /** Terms the context sets to null, which say nothing when the edition is read as RDF. */
101
97
  const silentTerms = new Set(Object.entries(context['@context'])
102
98
  .filter(([, definition]) => definition === null)
@@ -120,21 +116,24 @@ const quote = (subject, key, reference, listed) => {
120
116
  const { '@annotation': { '@id': annotation, ...about }, ...object } = reference;
121
117
  return { '@id': { '@id': subject, [key]: listed ? [object] : object }, annotation, ...about };
122
118
  };
123
- /** The value a node states under a key, less the doubted references, and those references quoted. */
124
- const quotingValue = (subject, key, value) => {
119
+ /** The value a node states under a key, less the doubted references, which go to the quoted. */
120
+ const unquoted = (subject, key, value, quoted) => {
125
121
  if (Array.isArray(value)) {
126
- return {
127
- node: value.filter(item => !isDoubtedReference(item)),
128
- quoted: value.filter(isDoubtedReference).map(item => quote(subject, key, item, true))
129
- };
122
+ return value.filter(item => {
123
+ if (!isDoubtedReference(item))
124
+ return true;
125
+ quoted.push(quote(subject, key, item, true));
126
+ return false;
127
+ });
130
128
  }
131
- return isDoubtedReference(value)
132
- ? { node: undefined, quoted: [quote(subject, key, value, false)] }
133
- : { node: value, quoted: [] };
129
+ if (!isDoubtedReference(value))
130
+ return value;
131
+ quoted.push(quote(subject, key, value, false));
132
+ return undefined;
134
133
  };
135
134
  /**
136
135
  * The document with every doubted reference taken off the node that
137
- * states it, and quoted instead.
136
+ * states it, and quoted instead, in the order the document states them.
138
137
  *
139
138
  * An `@annotation` in JSON-LD-star states the triple it annotates and
140
139
  * then says something about it, so a statement the edition holds
@@ -142,32 +141,28 @@ const quotingValue = (subject, key, value) => {
142
141
  * between nodes are quoted, since only they can be put back where they
143
142
  * stood; a doubted date or attribution stays annotated in place.
144
143
  */
145
- const withDoubtedReferencesQuoted = (value) => {
146
- if (Array.isArray(value)) {
147
- const quotings = value.map(withDoubtedReferencesQuoted);
148
- return { node: quotings.map(({ node }) => node), quoted: quotings.flatMap(({ quoted }) => quoted) };
149
- }
144
+ const withDoubtedReferencesQuoted = (value, quoted) => {
145
+ if (Array.isArray(value))
146
+ return value.map(item => withDoubtedReferencesQuoted(item, quoted));
150
147
  if (!isRecord(value))
151
- return { node: value, quoted: [] };
148
+ return value;
152
149
  const subject = value['@id'];
153
- const entries = Object.entries(value).map(([key, child]) => {
154
- const own = typeof subject === 'string' && !key.startsWith('@')
155
- ? quotingValue(subject, key, child)
156
- : { node: child, quoted: [] };
157
- const below = withDoubtedReferencesQuoted(own.node);
158
- return { key, node: below.node, quoted: [...own.quoted, ...below.quoted] };
150
+ const node = {};
151
+ Object.entries(value).forEach(([key, child]) => {
152
+ const stated = typeof subject === 'string' && !key.startsWith('@') ? unquoted(subject, key, child, quoted) : child;
153
+ const below = withDoubtedReferencesQuoted(stated, quoted);
154
+ if (below !== undefined)
155
+ node[key] = below;
159
156
  });
160
- return {
161
- node: Object.fromEntries(entries.filter(({ node }) => node !== undefined).map(({ key, node }) => [key, node])),
162
- quoted: entries.flatMap(({ quoted }) => quoted)
163
- };
157
+ return node;
164
158
  };
165
159
  /** The edition with each copy's features and tears at the copy's own places, as a document holds them. */
166
160
  const atOwnPlacesAll = (edition) => edition.copies.some(copy => copy.measurements.alignment)
167
161
  ? { ...edition, copies: edition.copies.map(atOwnPlaces) }
168
162
  : edition;
169
163
  export const asJsonLd = (edition) => {
170
- const { node, quoted } = withDoubtedReferencesQuoted(withSystemContexts(withCreations(withChanges(asJsonLdEntity(atOwnPlacesAll(edition))))));
164
+ const quoted = [];
165
+ const node = withDoubtedReferencesQuoted(withSystemContexts(withCreations(withChanges(asJsonLdEntity(atOwnPlacesAll(edition))))), quoted);
171
166
  // The context is the export's own; one carried in from an import must not override it.
172
167
  const { base, '@context': carried, ...rest } = node;
173
168
  return {
package/lib/io/migrate.js CHANGED
@@ -1,7 +1,9 @@
1
+ import { v4 } from "uuid";
1
2
  import { systemIdIn, systemOf, translationBetween } from "../systems/TrackerBar.js";
2
3
  import { trackerBars } from "../systems/index.js";
3
4
  import { welteT100 } from "../systems/welteT100/bar.js";
4
5
  import { isDateString } from "../shared/utils.js";
6
+ import { mapped, withValues } from "../ops/immutable.js";
5
7
  const retiredVersionTypes = new Set(['edition', 'unicum']);
6
8
  /**
7
9
  * The vocabulary the steps below were written against, as it stood in
@@ -37,10 +39,10 @@ const referenceKeys = ['alignedWith', 'pairedWith', 'basedOn'];
37
39
  * The five type terms that were capitalised while the rest of the
38
40
  * vocabulary was not, under the keys that carried them then.
39
41
  */
40
- const lowerCasedTerms = {
42
+ const lowerCasedTerms = Object.entries({
41
43
  method: { Print: 'print', Handwriting: 'handwriting', Stamp: 'stamp' },
42
44
  material: { Paper: 'paper', Tape: 'tape' }
43
- };
45
+ });
44
46
  const named = (name) => ({ name, sameAs: [] });
45
47
  const withRenamedKeys = (node) => Object.keys(node).some(key => Object.hasOwn(renamedKeys, key))
46
48
  ? Object.fromEntries(Object.entries(node).map(([key, value]) => [renamedKeys[key] ?? key, value]))
@@ -82,7 +84,7 @@ const withoutVersionSiglum = (node) => {
82
84
  const { siglum: _labelled, ...rest } = node;
83
85
  return rest;
84
86
  };
85
- const withLowerCaseTerms = (node) => Object.entries(lowerCasedTerms).reduce((result, [key, terms]) => {
87
+ const withLowerCaseTerms = (node) => lowerCasedTerms.reduce((result, [key, terms]) => {
86
88
  const lowered = terms[result[key]];
87
89
  return lowered ? { ...result, [key]: lowered } : result;
88
90
  }, node);
@@ -450,26 +452,68 @@ const withTimeSpanDates = (node) => {
450
452
  const { '@value': within, '@type': _typed, ...rest } = node;
451
453
  return { ...rest, within };
452
454
  };
453
- const migrateNode = (node) => [withRenamedKeys, withRenamedEditType, withTypology, withRenamedType, withoutVersionType, withoutVersionSiglum, withLowerCaseTerms, withSplitMethod, withReferences, withKeeper, withoutEmptyKeeper,
454
- withPerforator, withProductionNodes, withTypedPerforator, withScale, withoutOps, withDerivationList, withReadingKind, withTimeSpanDates,
455
- withoutFeatureKind, withBorneFeaturesNamed, withFeaturesInActs, withOwnPlaces, withDriveOfStaggering, withoutPattern, withoutBearings, withoutEntailedType]
456
- .reduce((result, step) => step(result), node);
457
- /** The items each walked, or the very same list where the walk changed none. */
458
- const walked = (items) => {
459
- const result = items.map(walk);
460
- return result.every((item, i) => item === items[i]) ? items : result;
461
- };
462
- /** The node with each child walked, or the very same node where the walk changed none. */
463
- const withWalkedChildren = (node) => {
464
- const entries = Object.entries(node);
465
- const result = entries.map(([key, child]) => [key, walk(child)]);
466
- return result.every(([, child], i) => child === entries[i][1]) ? node : Object.fromEntries(result);
455
+ /** The values a copy's one `measuredBy` stood for, of those the edition exports: what the analysis it named had given. */
456
+ const measuredByCovered = ['dimensions', 'holeSeparation'];
457
+ /** A day written as a date, of a date written with or without its time. */
458
+ const dayOf = (date) => typeof date === 'string' && /^\d{4}-\d{2}-\d{2}/.test(date) ? date.slice(0, 10) : undefined;
459
+ /**
460
+ * A copy once named one program for all its measurements, beside values
461
+ * that could not say what took them. Each value the edition exports now
462
+ * carries the measurement it rests on, so the program, its version and
463
+ * the day go into the reasons of the dimensions and the hole separation,
464
+ * which the analysis it named had given. The scan resolution is read
465
+ * off the file rather than measured, and the margins are not exported;
466
+ * nothing is said of them. The ids are the copy's, so that migrating a
467
+ * file twice writes the same document.
468
+ */
469
+ const withMeasuredByInReasons = (node) => {
470
+ const measurements = node?.measurements;
471
+ if (!measurements || typeof measurements !== 'object' || !Object.hasOwn(measurements, 'measuredBy'))
472
+ return node;
473
+ const { measuredBy, ...rest } = measurements;
474
+ const day = dayOf(measuredBy?.date);
475
+ const reason = measuredBy?.software && {
476
+ '@type': 'measurement',
477
+ software: [{ name: measuredBy.software, ...(measuredBy.version && { version: measuredBy.version }) }],
478
+ ...(day && { date: { within: day } })
479
+ };
480
+ const owner = typeof node['@id'] === 'string' ? plainCopyId(node['@id']) : v4();
481
+ const withReason = (key, value) => {
482
+ if (!reason || !value || typeof value !== 'object')
483
+ return value;
484
+ const annotation = value['@annotation'];
485
+ if (annotation?.belief) {
486
+ return { ...value, '@annotation': { ...annotation, belief: { ...annotation.belief, reasons: [...(annotation.belief.reasons ?? []), reason] } } };
487
+ }
488
+ return {
489
+ ...value,
490
+ '@annotation': {
491
+ '@id': `${owner}-${key}-annotation`,
492
+ belief: { '@type': 'belief', '@id': `${owner}-${key}-belief`, certainty: 'true', reasons: [reason] }
493
+ }
494
+ };
495
+ };
496
+ return {
497
+ ...node,
498
+ measurements: {
499
+ ...rest,
500
+ ...Object.fromEntries(measuredByCovered
501
+ .filter(key => Object.hasOwn(rest, key))
502
+ .map(key => [key, withReason(key, rest[key])]))
503
+ }
504
+ };
467
505
  };
506
+ const nodeSteps = [withRenamedKeys, withRenamedEditType, withTypology, withRenamedType, withoutVersionType, withoutVersionSiglum, withLowerCaseTerms, withSplitMethod, withReferences, withKeeper, withoutEmptyKeeper,
507
+ withPerforator, withProductionNodes, withTypedPerforator, withScale, withoutOps, withDerivationList, withReadingKind, withTimeSpanDates,
508
+ withoutFeatureKind, withBorneFeaturesNamed, withFeaturesInActs, withOwnPlaces, withDriveOfStaggering, withoutPattern, withoutBearings, withoutEntailedType,
509
+ withMeasuredByInReasons];
510
+ const migrateNode = (node) => nodeSteps.reduce((result, step) => step(result), node);
511
+ /** The value with every node in it migrated, a node before its children; the very same value where nothing changed. */
468
512
  const walk = (value) => {
469
513
  if (Array.isArray(value))
470
- return walked(value);
514
+ return mapped(value, walk);
471
515
  if (value && typeof value === 'object')
472
- return withWalkedChildren(migrateNode(value));
516
+ return withValues(migrateNode(value), walk);
473
517
  return value;
474
518
  };
475
519
  /** The production of a copy, under whichever of its two names it carries. */
@@ -576,18 +620,21 @@ const statesNoTolerance = (version) => isSingleDerivation(version.basedOn) && !v
576
620
  * The collation tolerance was the edition's before it was stated on
577
621
  * each derivation. An edition written then collated every version at
578
622
  * that one value, so it is written onto every derivation that gives
579
- * none of its own.
623
+ * none of its own, and the edition states it no longer.
580
624
  */
581
625
  const withDerivationTolerance = (edition) => {
582
- const collationTolerance = edition.creation?.collationTolerance;
583
- const versions = Array.isArray(edition.versions) ? edition.versions : [];
584
- if (!collationTolerance || !versions.some(statesNoTolerance))
626
+ if (!edition.creation || !Object.hasOwn(edition.creation, 'collationTolerance'))
585
627
  return edition;
628
+ const { collationTolerance, ...creation } = edition.creation;
629
+ const versions = listAt(edition, 'versions');
586
630
  return {
587
631
  ...edition,
588
- versions: versions.map(version => statesNoTolerance(version)
589
- ? { ...version, basedOn: { ...version.basedOn, collationTolerance } }
590
- : version)
632
+ creation,
633
+ ...(collationTolerance && versions && {
634
+ versions: versions.map(version => statesNoTolerance(version)
635
+ ? { ...version, basedOn: { ...version.basedOn, collationTolerance } }
636
+ : version)
637
+ })
591
638
  };
592
639
  };
593
640
  /** Release 0.58.0 stated a revision of the format, which the format does not state. */
@@ -78,4 +78,21 @@ export interface Place extends Named {
78
78
  * @see crm:E55 Type
79
79
  */
80
80
  export type Concept = (WithId & Partial<Named>) | (Named & Partial<WithId>);
81
+ /**
82
+ * Software that captured a copy or measured it: the transcriber that
83
+ * read a recording into notes, the emulator that wrote a MIDI file, the
84
+ * program that found the holes on a scan or aligned two copies.
85
+ * @see crmdig:D14 Software
86
+ */
87
+ export interface Software {
88
+ /**
89
+ * @see rdfs:label
90
+ */
91
+ name: string;
92
+ /**
93
+ * The release, the commit, or the model checkpoint of a transcriber.
94
+ * @see owl:versionInfo
95
+ */
96
+ version?: string;
97
+ }
81
98
  export {};