@genome-spy/core 0.43.1 → 0.43.3

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.
@@ -92,12 +92,24 @@ export default class BigBedSource extends SingleAxisWindowedSource {
92
92
  }
93
93
 
94
94
  /**
95
- * A specific optimization for Hautaniemi Lab's Methylation project, where
96
- * we have hundreds of columns having small integers (0-100).
97
- * This parser avoids generating piles of garbage to be collected by the GC.
98
- * We don't split the line into an array of strings, but instead parse the
99
- * integer fields directly from the original string.
100
- * This parser doesn't support arrays, etc. at the moment.
95
+ * An optimized parser for Hautaniemi Lab's Methylation project, where
96
+ * we have hundreds of columns having small integers (0-100). This is over 5x
97
+ * faster than @gmod/bed's parser.
98
+ *
99
+ * Techniques used:
100
+ *
101
+ * 1. Avoid generating garbage by parsing integers directly from the string,
102
+ * i.e., without splitting the line into an array of strings.
103
+ * 2. Use a template object to avoid hidden class changes after each property
104
+ * assignment. Avoids garbage generation.
105
+ * 3. Generate and compile code that uses constants to access object properties,
106
+ * avoiding Map lookups during assignment.
107
+ * 4. Input chrom, startPos, and endPos as parameters so that @gmod/bbi's
108
+ * output doesn't first need to be converted to a string just to be parsed
109
+ * again.
110
+ *
111
+ * This parser doesn't support arrays, etc. at the moment. This could, however,
112
+ * be extended into a fully-featured parser.
101
113
  *
102
114
  * @param {import("@gmod/bed").default} bed
103
115
  */
@@ -151,26 +163,69 @@ function makeFastParser(bed) {
151
163
  return value * sign;
152
164
  }
153
165
 
166
+ const templateFields = fields.map(
167
+ (field) =>
168
+ `${JSON.stringify(field.name)}: ${
169
+ field.isNumeric ? "0" : "emptyString"
170
+ }`
171
+ );
172
+
173
+ /**
174
+ * Make a template object with all fields to avoid the JavaScript VM's
175
+ * hidden class to be changed after each property assignment. Transitions
176
+ * between hidden classes generate plenty of garbage to be collected.
177
+ *
178
+ * Ideally, the parsed values would be assigned directly in this function,
179
+ * but for some reason, it results in abysmally slow performance on Chrome,
180
+ * but not on Firefox, where it would be super fast.
181
+ */
182
+ const makeTemplate = new Function(`
183
+ const emptyString = "";
184
+ return function makeTemplate(chrom, chromStart, chromEnd) {
185
+ return {
186
+ chrom,
187
+ chromStart,
188
+ chromEnd,
189
+ ${templateFields.join(",\n")}
190
+ }
191
+ };`)();
192
+
193
+ /*
194
+ * Generate setter code that uses constant field names to access the
195
+ * object's properties. This avoids Map lookups and allows for efficient
196
+ * machine code to be generated by the VM.
197
+ */
154
198
  const fieldParsers = fields.map((field) => {
155
- const { name, type } = field;
199
+ const type = field.type;
200
+ const name = JSON.stringify(field.name);
156
201
 
157
202
  if (["ubyte", "int", "uint"].includes(type)) {
158
- return () => {
159
- currentObject[name] = parseInt();
160
- };
203
+ return `d[${name}] = parseInt();`;
161
204
  } else if (field.isNumeric) {
162
- return () => {
163
- currentObject[name] = Number(parseString());
164
- };
205
+ return `d[${name}] = Number(parseString());`;
165
206
  } else if (["char", "string", "lstring"].includes(type)) {
166
- return () => {
167
- currentObject[name] = parseString();
168
- };
207
+ return `d[${name}] = parseString();`;
169
208
  } else {
170
209
  throw new Error("Unsupported type: " + type);
210
+ // TODO: Implement them
171
211
  }
172
212
  });
173
213
 
214
+ /*
215
+ * Split the field parsers into chunks to avoid creating so large
216
+ * functions that the JavaScript VM would decline to optimize it.
217
+ * Not sure if this is really necessary, but the added cost is minimal.
218
+ */
219
+ const chunckedFieldParsers = chunk(fieldParsers, 50).map((chunk, i) =>
220
+ Function(
221
+ "parseInt",
222
+ "parseString",
223
+ `return function parseFieldChunk${i}(d) {
224
+ ${chunk.join("\n")}
225
+ }`
226
+ )(parseInt, parseString)
227
+ );
228
+
174
229
  /**
175
230
  * @param {string} line
176
231
  */
@@ -189,14 +244,10 @@ function makeFastParser(bed) {
189
244
  function parseLine(chrom, chromStart, chromEnd, rest) {
190
245
  setLine(rest);
191
246
 
192
- currentObject = {
193
- chrom,
194
- chromStart,
195
- chromEnd,
196
- };
247
+ currentObject = makeTemplate(chrom, chromStart, chromEnd);
197
248
 
198
- for (let j = 0, n = fieldParsers.length; j < n; j++) {
199
- fieldParsers[j]();
249
+ for (const parser of chunckedFieldParsers) {
250
+ parser(currentObject);
200
251
  }
201
252
 
202
253
  return currentObject;
@@ -204,3 +255,15 @@ function makeFastParser(bed) {
204
255
 
205
256
  return parseLine;
206
257
  }
258
+
259
+ /**
260
+ * @param {T[]} arr
261
+ * @param {number} size
262
+ * @template T
263
+ */
264
+ function chunk(arr, size) {
265
+ // https://www.30secondsofcode.org/js/s/split-array-into-chunks/
266
+ return Array.from({ length: Math.ceil(arr.length / size) }, (_v, i) =>
267
+ arr.slice(i * size, i * size + size)
268
+ );
269
+ }
@@ -2,7 +2,10 @@
2
2
  /**
3
3
  * Computes coverage for sorted segments
4
4
  *
5
- * TODO: Binned coverage
5
+ * TODO: Binned coverage, e.g., don't emit a new segment for every
6
+ * coverage change, but only every n bases or so. The most straightforward
7
+ * way to implement it is a separate transform that bins the coverage
8
+ * segments and calculates weighted averages.
6
9
  */
7
10
  export default class CoverageTransform extends FlowNode {
8
11
  /**
@@ -23,7 +26,12 @@ export default class CoverageTransform extends FlowNode {
23
26
  chrom: string;
24
27
  };
25
28
  createSegment: Function;
26
- ends: FlatQueue<any>;
29
+ /**
30
+ * End pos as priority, weight as value
31
+ *
32
+ * @type {FlatQueue<number>}
33
+ */
34
+ ends: FlatQueue<number>;
27
35
  }
28
36
  import FlowNode from "../flowNode.js";
29
37
  import FlatQueue from "flatqueue";
@@ -1 +1 @@
1
- {"version":3,"file":"coverage.d.ts","sourceRoot":"","sources":["../../../../src/data/transforms/coverage.js"],"names":[],"mappings":";AAKA;;;;GAIG;AACH;IAKI;;OAEG;IACH,oBAFW,OAAO,yBAAyB,EAAE,cAAc,EA4C1D;IAxCG,yDAAoB;IAEpB,mDAAwC;IACxC,iDAAoC;IAEpC,mCAAmC;IACnC,sBADoB,GAAG,KAAE,MAAM,CAGT;IACtB,mCAAmC;IACnC,uBADoB,GAAG,KAAE,MAAM,CACsC;IAErE;;;;;MAKC;IAGD,wBAgBC;IAGD,qBAA2B;CA2HlC;qBAnLyC,gBAAgB;sBAHpC,WAAW"}
1
+ {"version":3,"file":"coverage.d.ts","sourceRoot":"","sources":["../../../../src/data/transforms/coverage.js"],"names":[],"mappings":";AAKA;;;;;;;GAOG;AACH;IAKI;;OAEG;IACH,oBAFW,OAAO,yBAAyB,EAAE,cAAc,EAgD1D;IA5CG,yDAAoB;IAEpB,mDAAwC;IACxC,iDAAoC;IAEpC,mCAAmC;IACnC,sBADoB,GAAG,KAAE,MAAM,CAGT;IACtB,mCAAmC;IACnC,uBADoB,GAAG,KAAE,MAAM,CACsC;IAErE;;;;;MAKC;IAGD,wBAgBC;IAED;;;;OAIG;IACH,MAFU,UAAU,MAAM,CAAC,CAEA;CAiIlC;qBAhMyC,gBAAgB;sBAHpC,WAAW"}
@@ -6,7 +6,10 @@ import FlowNode, { BEHAVIOR_CLONES } from "../flowNode.js";
6
6
  /**
7
7
  * Computes coverage for sorted segments
8
8
  *
9
- * TODO: Binned coverage
9
+ * TODO: Binned coverage, e.g., don't emit a new segment for every
10
+ * coverage change, but only every n bases or so. The most straightforward
11
+ * way to implement it is a separate transform that bins the coverage
12
+ * segments and calculates weighted averages.
10
13
  */
11
14
  export default class CoverageTransform extends FlowNode {
12
15
  get behavior() {
@@ -56,7 +59,11 @@ export default class CoverageTransform extends FlowNode {
56
59
  )
57
60
  );
58
61
 
59
- // End pos as priority, weight as value
62
+ /**
63
+ * End pos as priority, weight as value
64
+ *
65
+ * @type {FlatQueue<number>}
66
+ */
60
67
  this.ends = new FlatQueue();
61
68
  }
62
69
 
@@ -75,7 +82,7 @@ export default class CoverageTransform extends FlowNode {
75
82
  const chromAccessor = this.chromAccessor;
76
83
  const weightAccessor = this.weightAccessor;
77
84
 
78
- /** @type {Record<string, number|string>} used for merging adjacent segment */
85
+ /** @type {import("../flowNode.js").Datum} used for merging adjacent segment */
79
86
  let bufferedSegment;
80
87
 
81
88
  /** @type {string} */
@@ -91,7 +98,7 @@ export default class CoverageTransform extends FlowNode {
91
98
  /** @type {number} */
92
99
  let prevEdge;
93
100
 
94
- // End pos as priority, weight as value
101
+ /** End pos as priority, weight as value */
95
102
  const ends = this.ends;
96
103
  ends.clear();
97
104
 
@@ -127,9 +134,7 @@ export default class CoverageTransform extends FlowNode {
127
134
  };
128
135
 
129
136
  const flushQueue = () => {
130
- // Flush queue
131
- /** @type {number} */
132
- let edge;
137
+ let edge = 0;
133
138
  while ((edge = ends.peekValue()) !== undefined) {
134
139
  pushSegment(prevEdge, edge, coverage);
135
140
  prevEdge = edge;
@@ -147,8 +152,7 @@ export default class CoverageTransform extends FlowNode {
147
152
  this.handle = (datum) => {
148
153
  const start = startAccessor(datum);
149
154
 
150
- /** @type {number} */
151
- let edge;
155
+ let edge = 0;
152
156
  while ((edge = ends.peekValue()) !== undefined && edge < start) {
153
157
  pushSegment(prevEdge, edge, coverage);
154
158
  prevEdge = edge;
@@ -179,5 +183,14 @@ export default class CoverageTransform extends FlowNode {
179
183
  flushQueue();
180
184
  super.complete();
181
185
  };
186
+
187
+ /**
188
+ * @param {import("../../types/flowBatch.js").FlowBatch} flowBatch
189
+ */
190
+ this.beginBatch = (flowBatch) => {
191
+ flushQueue();
192
+ prevChrom = undefined;
193
+ super.beginBatch(flowBatch);
194
+ };
182
195
  }
183
196
  }
@@ -1,4 +1,4 @@
1
- import { expect, test } from "vitest";
1
+ import { describe, expect, test } from "vitest";
2
2
  import CoverageTransform from "./coverage.js";
3
3
  import { processData } from "../flowTestUtils.js";
4
4
 
@@ -16,108 +16,223 @@ function transform(params, data) {
16
16
  return processData(t, data);
17
17
  }
18
18
 
19
- test("Coverage transform produces correct coverage segments", () => {
20
- const reads = [
21
- [0, 4],
22
- [1, 3],
23
- [2, 6],
24
- [4, 8],
25
- [8, 10],
26
- [11, 14],
27
- [11, 13],
28
- [11, 12],
29
- [15, 18],
30
- [16, 18],
31
- [17, 18],
32
- ].map((d) => ({
33
- start: d[0],
34
- end: d[1],
35
- }));
36
-
37
- const coverageSegments = [
38
- [0, 1, 1],
39
- [1, 2, 2],
40
- [2, 3, 3],
41
- [3, 6, 2],
42
- [6, 10, 1],
43
- [11, 12, 3],
44
- [12, 13, 2],
45
- [13, 14, 1],
46
- [15, 16, 1],
47
- [16, 17, 2],
48
- [17, 18, 3],
49
- ].map((d) => ({
50
- start: d[0],
51
- end: d[1],
52
- coverage: d[2],
53
- }));
54
-
19
+ /**
20
+ *
21
+ * @param {[number, number][]} reads Start, end
22
+ * @param {[number, number, number][]} coverageSegments Start, end, coverage
23
+ */
24
+ function testSimpleCoverage(reads, coverageSegments) {
55
25
  /** @type {CoverageParams} */
56
26
  const coverageConfig = {
57
27
  type: "coverage",
58
28
  start: "start",
59
29
  end: "end",
60
30
  };
61
- expect(transform(coverageConfig, reads)).toEqual(coverageSegments);
62
- });
63
-
64
- test("Coverage transform handles chromosomes", () => {
65
- const reads = [
66
- { chrom: "chr1", start: 0, end: 1 },
67
- { chrom: "chr2", start: 0, end: 1 },
68
- { chrom: "chr3", start: 1, end: 3 },
69
- ];
70
-
71
- const coverageSegments = [
72
- { chrom: "chr1", start: 0, end: 1, coverage: 1 },
73
- { chrom: "chr2", start: 0, end: 1, coverage: 1 },
74
- { chrom: "chr3", start: 1, end: 3, coverage: 1 },
75
- ];
31
+ expect(
32
+ transform(
33
+ coverageConfig,
34
+ reads.map((d) => ({
35
+ start: d[0],
36
+ end: d[1],
37
+ }))
38
+ )
39
+ ).toEqual(
40
+ coverageSegments.map((d) => ({
41
+ start: d[0],
42
+ end: d[1],
43
+ coverage: d[2],
44
+ }))
45
+ );
46
+ }
76
47
 
48
+ /**
49
+ *
50
+ * @param {[number, number, number][]} reads Start, end, weight
51
+ * @param {[number, number, number][]} coverageSemgments Start, end, coverage
52
+ */
53
+ function testWeightedCoverage(reads, coverageSegments) {
77
54
  /** @type {CoverageParams} */
78
55
  const coverageConfig = {
79
56
  type: "coverage",
80
- chrom: "chrom",
81
57
  start: "start",
82
58
  end: "end",
59
+ weight: "weight",
83
60
  };
61
+ expect(
62
+ transform(
63
+ coverageConfig,
64
+ reads.map((d) => ({
65
+ start: d[0],
66
+ end: d[1],
67
+ weight: d[2],
68
+ }))
69
+ )
70
+ ).toEqual(
71
+ coverageSegments.map((d) => ({
72
+ start: d[0],
73
+ end: d[1],
74
+ coverage: d[2],
75
+ }))
76
+ );
77
+ }
84
78
 
85
- expect(transform(coverageConfig, reads)).toEqual(coverageSegments);
86
- });
79
+ describe("Coverage transform", () => {
80
+ test("Typical case", () =>
81
+ testSimpleCoverage(
82
+ [
83
+ [0, 4],
84
+ [1, 3],
85
+ [2, 6],
86
+ [4, 8],
87
+ [8, 10],
88
+ [11, 14],
89
+ [11, 13],
90
+ [11, 12],
91
+ [15, 18],
92
+ [16, 18],
93
+ [17, 18],
94
+ ],
95
+ [
96
+ [0, 1, 1],
97
+ [1, 2, 2],
98
+ [2, 3, 3],
99
+ [3, 6, 2],
100
+ [6, 10, 1],
101
+ [11, 12, 3],
102
+ [12, 13, 2],
103
+ [13, 14, 1],
104
+ [15, 16, 1],
105
+ [16, 17, 2],
106
+ [17, 18, 3],
107
+ ]
108
+ ));
87
109
 
88
- test("Coverage transform handles weights", () => {
89
- const reads = [
90
- [0, 4, 1],
91
- [1, 3, 2],
92
- [2, 6, 3],
93
- [8, 10, -1],
94
- ].map((d) => ({
95
- start: d[0],
96
- end: d[1],
97
- weight: d[2],
98
- }));
110
+ test("Multiple identical overlapping segments", () =>
111
+ testSimpleCoverage(
112
+ [
113
+ [1, 2],
114
+ [3, 4],
115
+ [3, 4],
116
+ [5, 6],
117
+ [5, 6],
118
+ [5, 6],
119
+ ],
120
+ [
121
+ [1, 2, 1],
122
+ [3, 4, 2],
123
+ [5, 6, 3],
124
+ ]
125
+ ));
99
126
 
100
- const coverageSegments = [
101
- [0, 1, 1],
102
- [1, 2, 3],
103
- [2, 3, 6],
104
- [3, 4, 4],
105
- [4, 6, 3],
106
- [8, 10, -1],
107
- ].map((d) => ({
108
- start: d[0],
109
- end: d[1],
110
- coverage: d[2],
111
- }));
127
+ test("Adjacent segments with equal coverage are merged", () =>
128
+ testSimpleCoverage(
129
+ [
130
+ [1, 2],
131
+ [2, 3],
132
+ [3, 4],
133
+ [5, 6],
134
+ [6, 7],
135
+ [7, 8],
136
+ ],
137
+ [
138
+ [1, 4, 1],
139
+ [5, 8, 1],
140
+ ]
141
+ ));
112
142
 
113
- /** @type {CoverageParams} */
114
- const coverageConfig = {
115
- type: "coverage",
116
- chrom: "chrom",
117
- start: "start",
118
- end: "end",
119
- weight: "weight",
120
- };
143
+ test("Chromosomes pass through", () => {
144
+ const reads = [
145
+ { chrom: "chr1", start: 0, end: 1 },
146
+ { chrom: "chr2", start: 0, end: 1 },
147
+ { chrom: "chr3", start: 1, end: 3 },
148
+ ];
149
+
150
+ const coverageSegments = [
151
+ { chrom: "chr1", start: 0, end: 1, coverage: 1 },
152
+ { chrom: "chr2", start: 0, end: 1, coverage: 1 },
153
+ { chrom: "chr3", start: 1, end: 3, coverage: 1 },
154
+ ];
155
+
156
+ /** @type {CoverageParams} */
157
+ const coverageConfig = {
158
+ type: "coverage",
159
+ chrom: "chrom",
160
+ start: "start",
161
+ end: "end",
162
+ };
163
+
164
+ expect(transform(coverageConfig, reads)).toEqual(coverageSegments);
165
+ });
166
+
167
+ test("Typical weighted coverage", () =>
168
+ testWeightedCoverage(
169
+ [
170
+ [0, 4, 1],
171
+ [1, 3, 2],
172
+ [2, 6, 3],
173
+ [8, 10, -1],
174
+ ],
175
+ [
176
+ [0, 1, 1],
177
+ [1, 2, 3],
178
+ [2, 3, 6],
179
+ [3, 4, 4],
180
+ [4, 6, 3],
181
+ [8, 10, -1],
182
+ ]
183
+ ));
184
+
185
+ test("Multiple weights at a single locus", () =>
186
+ testWeightedCoverage(
187
+ [
188
+ // -- Locus 1
189
+ [1, 2, 1],
190
+ [1, 2, 2],
191
+ [1, 2, 3],
192
+ [1, 2, 4],
193
+ [1, 2, 5],
194
+ // -- Locus 2
195
+ [2, 3, 2],
196
+ [2, 3, 3],
197
+ [2, 3, 4],
198
+ [2, 3, 5],
199
+ [2, 3, 6],
200
+ ],
201
+ [
202
+ // -- Locus 1
203
+ [1, 2, 15],
204
+ // -- Locus 2
205
+ [2, 3, 20],
206
+ ]
207
+ ));
121
208
 
122
- expect(transform(coverageConfig, reads)).toEqual(coverageSegments);
209
+ test("Adjacent segments with different weights produce separated segments", () =>
210
+ testWeightedCoverage(
211
+ [
212
+ // -- Cluster 1
213
+ [1, 2, 2],
214
+ [2, 3, 1],
215
+ [3, 4, 1],
216
+ // -- Cluster 2
217
+ [5, 6, 1],
218
+ [6, 7, 2],
219
+ [7, 8, 1],
220
+ // -- Cluster 3
221
+ [9, 10, 1],
222
+ [10, 11, 1],
223
+ [11, 12, 2],
224
+ ],
225
+ [
226
+ // -- Cluster 1
227
+ [1, 2, 2],
228
+ [2, 4, 1],
229
+ // -- Cluster 2
230
+ [5, 6, 1],
231
+ [6, 7, 2],
232
+ [7, 8, 1],
233
+ // -- Cluster 3
234
+ [9, 11, 1],
235
+ [11, 12, 2],
236
+ ]
237
+ ));
123
238
  });
@@ -1,9 +1,10 @@
1
1
  /**
2
- * Returns an iterator that merges multiple sorted arrays.
2
+ * Merges multiple sorted arrays.
3
3
  *
4
4
  * @param {T[][]} arrays
5
+ * @param {function(T):void} handler a function that will be called for each element
5
6
  * @param {function(T):number} [accessor]
6
7
  * @template T
7
8
  */
8
- export default function kWayMerge<T>(arrays: T[][], accessor?: (arg0: T) => number): Generator<T, void, unknown>;
9
+ export default function kWayMerge<T>(arrays: T[][], handler: (arg0: T) => void, accessor?: (arg0: T) => number): void;
9
10
  //# sourceMappingURL=kWayMerge.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"kWayMerge.d.ts","sourceRoot":"","sources":["../../../src/utils/kWayMerge.js"],"names":[],"mappings":"AAEA;;;;;;GAMG;AACH,4EAHuB,MAAM,+BAmC5B"}
1
+ {"version":3,"file":"kWayMerge.d.ts","sourceRoot":"","sources":["../../../src/utils/kWayMerge.js"],"names":[],"mappings":"AAEA;;;;;;;GAOG;AACH,0EAJuB,IAAI,0BACJ,MAAM,QAmC5B"}
@@ -1,13 +1,14 @@
1
1
  import FlatQueue from "flatqueue";
2
2
 
3
3
  /**
4
- * Returns an iterator that merges multiple sorted arrays.
4
+ * Merges multiple sorted arrays.
5
5
  *
6
6
  * @param {T[][]} arrays
7
+ * @param {function(T):void} handler a function that will be called for each element
7
8
  * @param {function(T):number} [accessor]
8
9
  * @template T
9
10
  */
10
- export default function* kWayMerge(arrays, accessor = (x) => +x) {
11
+ export default function kWayMerge(arrays, handler, accessor = (x) => +x) {
11
12
  // https://www.wikiwand.com/en/K-way_merge_algorithm
12
13
 
13
14
  // This could be optimized by implementing a tournament tree or
@@ -31,7 +32,7 @@ export default function* kWayMerge(arrays, accessor = (x) => +x) {
31
32
  let pointer = pointers[i];
32
33
  const element = array[pointer++];
33
34
 
34
- yield element;
35
+ handler(element);
35
36
 
36
37
  if (pointer < array.length) {
37
38
  const newValue = accessor(array[pointer]);
@@ -22,5 +22,9 @@ test("k-way merge merges multiple sorted arrays", () => {
22
22
  /** @type {function(any):number} */
23
23
  const accessor = (d) => d.a;
24
24
 
25
- expect([...kWayMerge(arrays, accessor)]).toEqual(sorted);
25
+ /** @type {{a: number}[]} */
26
+ const result = [];
27
+ kWayMerge(arrays, (d) => result.push(d), accessor);
28
+
29
+ expect(result).toEqual(sorted);
26
30
  });
package/package.json CHANGED
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "contributors": [],
9
9
  "license": "MIT",
10
- "version": "0.43.1",
10
+ "version": "0.43.3",
11
11
  "jsdelivr": "dist/bundle/index.js",
12
12
  "unpkg": "dist/bundle/index.js",
13
13
  "browser": "dist/bundle/index.js",
@@ -65,5 +65,5 @@
65
65
  "vega-scale": "^7.1.1",
66
66
  "vega-util": "^1.16.0"
67
67
  },
68
- "gitHead": "e47791753d5467ab83b5ef073976eb2f2484000b"
68
+ "gitHead": "0c6e0418a7461b40da98896bfaf5e05732384e85"
69
69
  }