@jbrowse/modifications-utils 5.0.0-beta.1 → 5.0.0-beta.10

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
@@ -25,9 +25,15 @@ cytosines in `context` are considered (default CpG); plants also use CHG/CHH.
25
25
 
26
26
  Parse MM tag to extract modification positions on the read sequence.
27
27
 
28
+ Only the calls placed inside `[readStart, readEnd)` are kept, and each entry's
29
+ `probStart` moves past the calls dropped ahead of them, so ML indexing is
30
+ unchanged. The deltas count from the read's 5' end, which is the END of `fseq`
31
+ on a reverse read: the bases between that end and the window are still counted,
32
+ but only as a tally, and nothing past the far side of the window is walked.
33
+
28
34
  ```js
29
35
  // type signature
30
- (mm: string, fseq: string, fstrand: number) => ModWithPositions[]
36
+ (mm: string, fseq: string, fstrand: number, readStart?: number, readEnd?: number) => ModWithPositions[]
31
37
  ```
32
38
 
33
39
  [Source code](https://github.com/GMOD/jbrowse-components/blob/main/packages/modifications-utils/src/getModPositions.ts)
@@ -63,6 +69,20 @@ survive. A caller that needs the numbers themselves still wants
63
69
 
64
70
  [Source code](https://github.com/GMOD/jbrowse-components/blob/main/packages/modifications-utils/src/getModProbabilities.ts)
65
71
 
72
+ ### getModTag
73
+
74
+ The read's MM tag, or undefined when its MN tag says MM was computed on a
75
+ sequence of a different length than SEQ. Per SAMtags, MN catches a hard clip or
76
+ trim that shifted the bases MM's deltas count, so on a mismatch every position
77
+ MM would place is wrong.
78
+
79
+ ```js
80
+ // type signature
81
+ (feature: Feature) => string | undefined
82
+ ```
83
+
84
+ [Source code](https://github.com/GMOD/jbrowse-components/blob/main/packages/modifications-utils/src/getModTag.ts)
85
+
66
86
  ### getModTypes
67
87
 
68
88
  The modification types an MM tag declares, from its headers alone.
@@ -1,2 +1,3 @@
1
1
  import type { ModWithPositions } from './getModPositions.ts';
2
- export declare function forEachModRefPos(modifications: readonly ModWithPositions[], probabilities: number[] | undefined, cigarOps: ArrayLike<number>, isReverse: boolean, cb: (mod: ModWithPositions, ref: number, idx: number, prob: number) => void): void;
2
+ import type { CigarCursor } from '@jbrowse/cigar-utils';
3
+ export declare function forEachModRefPos(modifications: readonly ModWithPositions[], probabilities: number[] | undefined, cigarOps: ArrayLike<number>, isReverse: boolean, cb: (mod: ModWithPositions, ref: number, idx: number, prob: number) => void, from?: CigarCursor): void;
@@ -3,13 +3,14 @@ import { modProbAt } from './getModProbabilities.js';
3
3
  // Walk every modification's read-sequence positions, mapping each to its
4
4
  // reference position via the CIGAR and recovering its ML probability. The
5
5
  // ML-array offset (probStart/probStride) is precomputed per modification by
6
- // getModPositions, so this no longer tracks a running index by hand.
7
- export function forEachModRefPos(modifications, probabilities, cigarOps, isReverse, cb) {
6
+ // getModPositions, so this no longer tracks a running index by hand. `from`
7
+ // starts every walk at a `refWindowToRead` cursor, for positions cut to it.
8
+ export function forEachModRefPos(modifications, probabilities, cigarOps, isReverse, cb, from) {
8
9
  for (const mod of modifications) {
9
10
  const { positions, probStart, probStride } = mod;
10
11
  getNextRefPos(cigarOps, positions, (ref, idx) => {
11
12
  const prob = modProbAt(probabilities, probStart, probStride, isReverse, idx, positions.length);
12
13
  cb(mod, ref, idx, prob);
13
- });
14
+ }, from);
14
15
  }
15
16
  }
@@ -1,5 +1,6 @@
1
1
  import type { CytosineContext } from './cytosineContext.ts';
2
2
  import type { ModWithPositions } from './getModPositions.ts';
3
+ import type { ReadWindow } from '@jbrowse/cigar-utils';
3
4
  export interface ParsedModData {
4
5
  modifications: ModWithPositions[];
5
6
  probabilities: number[] | undefined;
@@ -7,6 +8,8 @@ export interface ParsedModData {
7
8
  seq: string;
8
9
  fstrand: -1 | 0 | 1;
9
10
  flen: number;
11
+ /** the window `modifications` was cut to, and the only one binned */
12
+ window?: ReadWindow;
10
13
  }
11
14
  /**
12
15
  * #api
@@ -26,7 +29,7 @@ export declare function isMethylationFillType(type: string): type is "h" | "m";
26
29
  * positions, returning typed arrays for methylated/unmethylated calls. Only
27
30
  * cytosines in `context` are considered (default CpG); plants also use CHG/CHH.
28
31
  */
29
- export declare function getMethBins({ modifications, probabilities, cigarOps, seq, fstrand, flen }: ParsedModData, context?: CytosineContext): {
32
+ export declare function getMethBins({ modifications, probabilities, cigarOps, seq, fstrand, flen, window, }: ParsedModData, context?: CytosineContext): {
30
33
  methBins: number[];
31
34
  hydroxyMethBins: number[];
32
35
  methProbs: number[];
@@ -21,7 +21,7 @@ export function isMethylationFillType(type) {
21
21
  * positions, returning typed arrays for methylated/unmethylated calls. Only
22
22
  * cytosines in `context` are considered (default CpG); plants also use CHG/CHH.
23
23
  */
24
- export function getMethBins({ modifications, probabilities, cigarOps, seq, fstrand, flen }, context = 'CG') {
24
+ export function getMethBins({ modifications, probabilities, cigarOps, seq, fstrand, flen, window, }, context = 'CG') {
25
25
  const isReverse = fstrand === -1;
26
26
  const methBins = [];
27
27
  const hydroxyMethBins = [];
@@ -42,7 +42,7 @@ export function getMethBins({ modifications, probabilities, cigarOps, seq, fstra
42
42
  hydroxyMethProbs[ref] = prob;
43
43
  }
44
44
  }
45
- });
45
+ }, window);
46
46
  // Only fill undetected sites as unmethylated when the read actually carries a
47
47
  // 5mC ('m') call. A read with only e.g. 6mA modifications never assayed
48
48
  // methylation, so inventing unmethylated cytosines for it would be wrong.
@@ -55,12 +55,14 @@ export function getMethBins({ modifications, probabilities, cigarOps, seq, fstra
55
55
  // both-strand modBAM ('C+m;G-m') has its uncalled sites in both directions.
56
56
  const fillForward = methMods.some(m => isReverse === (m.strand === '-'));
57
57
  const fillReverse = methMods.some(m => isReverse !== (m.strand === '-'));
58
- // Scan the full read sequence for every cytosine in `context` and mark any not
59
- // already detected from the MM tag as unmethylated (prob=0).
58
+ // Scan the window's read sequence for every cytosine in `context` and mark any
59
+ // not already detected from the MM tag as unmethylated (prob=0).
60
60
  if (fillUnmethylated) {
61
- let readPos = 0;
62
- let refPos = 0;
63
- for (let i = 0, l = cigarOps.length; i < l; i++) {
61
+ const lo = Math.max(0, window?.refStart ?? 0);
62
+ const hi = Math.min(flen, window?.refEnd ?? flen);
63
+ let readPos = window?.opRead ?? 0;
64
+ let refPos = window?.opRef ?? 0;
65
+ for (let i = window?.op ?? 0, l = cigarOps.length; i < l && refPos < hi; i++) {
64
66
  const packed = cigarOps[i];
65
67
  const len = packed >>> 4;
66
68
  const op = packed & 0xf;
@@ -74,8 +76,8 @@ export function getMethBins({ modifications, probabilities, cigarOps, seq, fstra
74
76
  for (let j = 0; j < len; j++) {
75
77
  const rp = readPos + j;
76
78
  const rf = refPos + j;
77
- if (rf >= 0 &&
78
- rf < flen &&
79
+ if (rf >= lo &&
80
+ rf < hi &&
79
81
  !methBins[rf] &&
80
82
  ((fillForward && matchesCytosineContext(seq, rp, false, context)) ||
81
83
  (fillReverse && matchesCytosineContext(seq, rp, true, context)))) {
@@ -11,9 +11,18 @@ export interface ModWithPositions {
11
11
  * #api
12
12
  * Parse MM tag to extract modification positions on the read sequence.
13
13
  *
14
+ * Only the calls placed inside `[readStart, readEnd)` are kept, and each
15
+ * entry's `probStart` moves past the calls dropped ahead of them, so ML
16
+ * indexing is unchanged. The deltas count from the read's 5' end, which is the
17
+ * END of `fseq` on a reverse read: the bases between that end and the window
18
+ * are still counted, but only as a tally, and nothing past the far side of the
19
+ * window is walked.
20
+ *
14
21
  * @param mm - MM tag string (e.g., "C+m,2,2,1;A+a,0,3")
15
22
  * @param fseq - Read sequence
16
23
  * @param fstrand - Read strand (-1, 0, or 1)
24
+ * @param readStart - First read offset to keep
25
+ * @param readEnd - Read offset past the last one to keep
17
26
  * @returns Array of modification objects with positions
18
27
  */
19
- export declare function getModPositions(mm: string, fseq: string, fstrand: number): ModWithPositions[];
28
+ export declare function getModPositions(mm: string, fseq: string, fstrand: number, readStart?: number, readEnd?: number): ModWithPositions[];
@@ -7,19 +7,209 @@ const COMPLEMENT_CODE = {
7
7
  71: 67, // G->C
8
8
  78: 78, // N->N
9
9
  };
10
+ const COMMA = 44;
11
+ const encoder = new TextEncoder();
12
+ const scratch = new Uint8Array(1 << 16);
13
+ const scratchWords = new Uint32Array(scratch.buffer, scratch.byteOffset, scratch.byteLength / 4);
14
+ // Occurrences of `code` in text[start, end), four characters per step over the
15
+ // text's bytes: a byte of `word ^ pattern` is zero exactly where the character
16
+ // matches, and the SWAR zero-byte test finds all four at once.
17
+ function countCode(text, code, start, end) {
18
+ const pattern = Math.imul(code, 0x01010101);
19
+ let n = 0;
20
+ for (let from = start; from < end; from += scratch.length) {
21
+ const to = Math.min(end, from + scratch.length);
22
+ const len = to - from;
23
+ const { read, written } = encoder.encodeInto(text.substring(from, to), scratch);
24
+ if (read !== len || written !== len) {
25
+ for (let i = from; i < to; i++) {
26
+ n += +(text.charCodeAt(i) === code);
27
+ }
28
+ continue;
29
+ }
30
+ const nWords = len >> 2;
31
+ for (let i = 0; i < nWords; i++) {
32
+ const x = scratchWords[i] ^ pattern;
33
+ const zero = ~(((x & 0x7f7f7f7f) + 0x7f7f7f7f) | x | 0x7f7f7f7f);
34
+ n += Math.imul(zero >>> 7, 0x01010101) >>> 24;
35
+ }
36
+ for (let i = nWords << 2; i < len; i++) {
37
+ n += +(scratch[i] === code);
38
+ }
39
+ }
40
+ return n;
41
+ }
42
+ // One group's calls inside [windowStart, windowEnd), ascending, and how many
43
+ // calls came before them in MM order. `deltas` is the group after its header,
44
+ // `,2,2,1`.
45
+ //
46
+ // An MM tag may declare more calls of a base than the read has left. When that
47
+ // happens the walk has nowhere to put them, and every emitted value still has
48
+ // to be a valid index into the read — `getMethBins` indexes the sequence with
49
+ // these, and the CIGAR walk requires them ascending, so a position outside the
50
+ // read is read as a real one somewhere wrong rather than dropped. So the
51
+ // exhausted case clamps to the nearest valid index and stays there, which on an
52
+ // empty read is 0 for every call.
53
+ function walkGroup(deltas, nPositions, base, fseq, isRev, windowStart, windowEnd) {
54
+ const seqLength = fseq.length;
55
+ const positions = [];
56
+ if (seqLength === 0) {
57
+ for (let i = 0; i < nPositions; i++) {
58
+ positions.push(0);
59
+ }
60
+ return { positions, skipped: 0 };
61
+ }
62
+ if (windowStart >= windowEnd || nPositions === 0) {
63
+ return { positions, skipped: 0 };
64
+ }
65
+ const isN = base === 'N';
66
+ const baseCode = base.charCodeAt(0);
67
+ // Reverse reads are walked from the back of fseq, matching the complement,
68
+ // rather than reverse-complementing the read.
69
+ const code = isRev ? (COMPLEMENT_CODE[baseCode] ?? baseCode) : baseCode;
70
+ // `+field` for each delta in turn, read in place rather than split out: the
71
+ // digits are summed as they are passed, and a field holding anything else
72
+ // (a sign, a space, a fraction) goes to `+` itself, so a malformed tag reads
73
+ // as it always has. Against `split(',')` on this same windowed walk it
74
+ // measured 1.24x at a 1 kb view and 1.03x over whole reads
75
+ // (`benches/modWindow.bench.ts`); `mmParseShape.bench.ts` had declined it
76
+ // at 1.06x when every read was walked end to end.
77
+ const deltasLength = deltas.length;
78
+ let cursor = 0;
79
+ const next = () => {
80
+ const from = cursor + 1;
81
+ let to = from;
82
+ let value = 0;
83
+ let digits = true;
84
+ for (; to < deltasLength; to++) {
85
+ const d = deltas.charCodeAt(to) - 48;
86
+ if (d === COMMA - 48) {
87
+ break;
88
+ }
89
+ if (d < 0 || d > 9) {
90
+ digits = false;
91
+ }
92
+ value = value * 10 + d;
93
+ }
94
+ cursor = to;
95
+ return digits && to - from < 16 ? value : +deltas.slice(from, to);
96
+ };
97
+ // The calls on the 5' side of the window are passed over by counting their
98
+ // base there once and subtracting whole deltas from the count. `carry` is
99
+ // what is left: occurrences the first kept call has already used up. A NaN
100
+ // delta fails the test and is handed to the walk, which places it as it
101
+ // always has.
102
+ const tallyStart = isRev ? windowEnd : 0;
103
+ const tallyEnd = isRev ? seqLength : windowStart;
104
+ let carry = isN
105
+ ? tallyEnd - tallyStart
106
+ : countCode(fseq, code, tallyStart, tallyEnd);
107
+ let first = 1;
108
+ let delta = next();
109
+ while (delta + 1 <= carry && first < nPositions) {
110
+ carry -= delta + 1;
111
+ first++;
112
+ delta = next();
113
+ }
114
+ if (delta + 1 <= carry) {
115
+ return { positions, skipped: nPositions };
116
+ }
117
+ if (isRev) {
118
+ let currPos = seqLength - windowEnd;
119
+ for (let i = first; i <= nPositions; i++) {
120
+ if (i > first) {
121
+ delta = next();
122
+ }
123
+ let at = 0;
124
+ if (currPos < seqLength) {
125
+ let remaining = delta - carry;
126
+ carry = 0;
127
+ do {
128
+ if (isN || fseq.charCodeAt(seqLength - 1 - currPos) === code) {
129
+ remaining--;
130
+ }
131
+ currPos++;
132
+ } while (remaining >= 0 && currPos < seqLength);
133
+ at = seqLength - currPos;
134
+ }
135
+ if (at < windowStart) {
136
+ break;
137
+ }
138
+ positions.push(at);
139
+ }
140
+ positions.reverse();
141
+ }
142
+ else {
143
+ // **Forward jumps, reverse steps, and the asymmetry is measured rather
144
+ // than assumed.** `indexOf` for a single character is a native scan, so
145
+ // finding the (delta+1)-th occurrence is delta+1 searches instead of one
146
+ // step per base — 1.560x on the sparse fixture and 1.247x on the dense
147
+ // one, parse phase, in `benches/mmDeltaJump.bench.ts`. `lastIndexOf` is
148
+ // NOT the mirror image: the same change on reverse reads measures
149
+ // **0.786x**.
150
+ const endClamp = seqLength - 1;
151
+ let currPos = windowStart;
152
+ for (let i = first; i <= nPositions; i++) {
153
+ if (i > first) {
154
+ delta = next();
155
+ }
156
+ const remaining = delta - carry;
157
+ carry = 0;
158
+ let at = -1;
159
+ if (isN) {
160
+ // 'N' matches every base, so the (delta+1)-th is delta ahead and
161
+ // there is nothing to search for.
162
+ at = currPos + remaining;
163
+ if (at >= seqLength) {
164
+ at = -1;
165
+ }
166
+ }
167
+ else {
168
+ for (let k = 0; k <= remaining; k++) {
169
+ at = fseq.indexOf(base, currPos);
170
+ if (at < 0) {
171
+ break;
172
+ }
173
+ currPos = at + 1;
174
+ }
175
+ }
176
+ if (at < 0) {
177
+ currPos = seqLength;
178
+ at = endClamp;
179
+ }
180
+ else {
181
+ currPos = at + 1;
182
+ }
183
+ if (at >= windowEnd) {
184
+ break;
185
+ }
186
+ positions.push(at);
187
+ }
188
+ }
189
+ return { positions, skipped: first - 1 };
190
+ }
10
191
  /**
11
192
  * #api
12
193
  * Parse MM tag to extract modification positions on the read sequence.
13
194
  *
195
+ * Only the calls placed inside `[readStart, readEnd)` are kept, and each
196
+ * entry's `probStart` moves past the calls dropped ahead of them, so ML
197
+ * indexing is unchanged. The deltas count from the read's 5' end, which is the
198
+ * END of `fseq` on a reverse read: the bases between that end and the window
199
+ * are still counted, but only as a tally, and nothing past the far side of the
200
+ * window is walked.
201
+ *
14
202
  * @param mm - MM tag string (e.g., "C+m,2,2,1;A+a,0,3")
15
203
  * @param fseq - Read sequence
16
204
  * @param fstrand - Read strand (-1, 0, or 1)
205
+ * @param readStart - First read offset to keep
206
+ * @param readEnd - Read offset past the last one to keep
17
207
  * @returns Array of modification objects with positions
18
208
  */
19
- export function getModPositions(mm, fseq, fstrand) {
20
- const seqLength = fseq.length;
209
+ export function getModPositions(mm, fseq, fstrand, readStart = 0, readEnd = fseq.length) {
21
210
  const isRev = fstrand === -1;
22
- const mods = mm.split(';');
211
+ const windowStart = Math.max(0, readStart);
212
+ const windowEnd = Math.min(fseq.length, readEnd);
23
213
  const result = [];
24
214
  // Running offset into the flat ML probabilities array. Each group consumes
25
215
  // (numPositions * numTypes) values, interleaved per position.
@@ -29,27 +219,26 @@ export function getModPositions(mm, fseq, fstrand) {
29
219
  // model emits `C+h?;C+m?` — two groups, one canonical base, identical delta
30
220
  // lists — so on real ONT output this drops one of every three sequence walks.
31
221
  //
32
- // The key is what the walk below READS: the canonical base and the delta list
33
- // (`fseq` and `fstrand` are per read, not per group). It also carries the MM
34
- // strand, which the walk does not currently read — deliberately stronger than
35
- // needed, so the test cannot go quietly wrong if the walk ever becomes
36
- // strand-aware.
222
+ // The key is what the walk READS: the canonical base and the delta list
223
+ // (`fseq`, `fstrand` and the window are per read, not per group). It also
224
+ // carries the MM strand, which the walk does not currently read —
225
+ // deliberately stronger than needed, so the test cannot go quietly wrong if
226
+ // the walk ever becomes strand-aware.
37
227
  //
38
- // Parallel arrays rather than a Map: a read carries one to four groups, so a
39
- // linear scan of char compares beats hashing a multi-kilobyte delta string,
40
- // and they are allocated on the first walk so a single-group read builds none.
228
+ // A list rather than a Map: a read carries one to four groups, so a linear
229
+ // scan of char compares beats hashing a multi-kilobyte delta string, and it
230
+ // is allocated on the first walk so a single-group read builds none.
41
231
  // `benches/sameBaseMerge.bench.ts` prices the whole test at inside the control
42
232
  // on every fixture where it cannot fire, including one where every compare is
43
- // forced to run to its final byte and then fail.
44
- let seenKeys;
45
- let seenDeltas;
46
- let seenPositions;
47
- for (const mod of mods) {
233
+ // forced to run to its final byte and then fail. A group that matches is not
234
+ // even split.
235
+ let seen;
236
+ for (const mod of mm.split(';')) {
48
237
  if (mod === '') {
49
238
  continue;
50
239
  }
51
- const split = mod.split(',');
52
- const basemod = split[0];
240
+ const comma = mod.indexOf(',');
241
+ const basemod = comma < 0 ? mod : mod.slice(0, comma);
53
242
  const { base, strand, typestr, mod: skipFlag, } = parseModHeader(basemod, mod);
54
243
  const unknownSkip = skipFlag === '?';
55
244
  // typestr can be multi-char lowercase e.g. 'mh' (5mC + 5hmC at same
@@ -59,7 +248,7 @@ export function getModPositions(mm, fseq, fstrand) {
59
248
  const isSingleType = isSingleModType(typestr);
60
249
  const nTypes = isSingleType ? 1 : typestr.length;
61
250
  // ONE walk per GROUP, not per type. Every type in a combined code is called
62
- // at the same positions — only probStart differs — so the walk below and the
251
+ // at the same positions — only probStart differs — so the walk and the
63
252
  // array it fills are shared, and the entries pushed after it point at the
64
253
  // same array. `C+mh` used to walk the read sequence twice and allocate two
65
254
  // identical arrays; `benches/modCombinedCode.bench.ts` (in the alignments
@@ -67,115 +256,30 @@ export function getModPositions(mm, fseq, fstrand) {
67
256
  // `C+mh`, and at **1.16x** even on a single-type tag, where nothing is
68
257
  // deduplicated and the win is the per-group closure this loop replaced.
69
258
  //
70
- // A combined code is the RARER of the two shapes this deduplicates, though.
71
- // The reuse test below is the common one — see `seenKeys`.
72
- //
73
259
  // this logic based on parse_mm.pl from hts-specs
74
- const splitLength = split.length;
75
- const nPositions = splitLength - 1;
260
+ //
76
261
  // Everything after the header — `,2,2,1`. V8 slices a long string in O(1),
77
262
  // and the compare rejects on length before it reads a byte.
78
263
  const deltas = mod.slice(basemod.length);
79
264
  const key = base + strand;
80
- let positions;
81
- if (seenKeys !== undefined) {
82
- for (let s = 0, n = seenKeys.length; s < n; s++) {
83
- if (seenKeys[s] === key && seenDeltas[s] === deltas) {
84
- positions = seenPositions[s];
85
- break;
86
- }
87
- }
88
- }
89
- if (positions === undefined) {
90
- const isN = base === 'N';
91
- let currPos = 0;
92
- // An MM tag may declare more calls of a base than the read has left. When
93
- // that happens the walk has nowhere to put them, and every emitted value
94
- // still has to be a valid index into the read — `getMethBins` indexes the
95
- // sequence with these, and the CIGAR walk requires them ascending, so a
96
- // position outside the read is read as a real one somewhere wrong rather
97
- // than dropped. So the exhausted case clamps to the nearest valid index
98
- // and stays there.
99
- const endClamp = isRev ? 0 : Math.max(0, seqLength - 1);
100
- // Pre-allocate and fill backwards on reverse strand to avoid a final
101
- // reverse(). Forward stays a growing literal on purpose: filling
102
- // `new Array(n)` leaves holey elements, and this array is read in the
103
- // CIGAR walk's inner loop.
104
- positions = isRev ? new Array(nPositions) : [];
105
- let writeIndex = isRev ? nPositions - 1 : 0;
106
- if (isRev) {
107
- // Avoid revcom(fseq) by reading fseq from the back and complementing the
108
- // expected char-code.
109
- const baseCode = base.charCodeAt(0);
110
- const targetCode = COMPLEMENT_CODE[baseCode] ?? baseCode;
111
- for (let i = 1; i < splitLength; i++) {
112
- if (currPos >= seqLength) {
113
- positions[writeIndex--] = endClamp;
114
- continue;
115
- }
116
- let delta = +split[i];
117
- do {
118
- if (isN ||
119
- fseq.charCodeAt(seqLength - 1 - currPos) === targetCode) {
120
- delta--;
121
- }
122
- currPos++;
123
- } while (delta >= 0 && currPos < seqLength);
124
- positions[writeIndex--] = seqLength - currPos;
125
- }
126
- }
127
- else {
128
- // **Forward jumps, reverse steps, and the asymmetry is measured rather
129
- // than assumed.** `indexOf` for a single character is a native scan, so
130
- // finding the (delta+1)-th occurrence is delta+1 searches instead of one
131
- // step per base — 1.560x on the sparse fixture and 1.247x on the dense
132
- // one, parse phase, in `benches/mmDeltaJump.bench.ts`.
133
- //
134
- // `lastIndexOf` is NOT the mirror image: the same change on reverse
135
- // reads measures **0.786x**, i.e. materially slower than stepping, so
136
- // reverse keeps the loop above. Applying it to both, which is the
137
- // obvious version, nets 1.054x where branching on strand nets 1.263x.
138
- for (let i = 1; i < splitLength; i++) {
139
- const delta = +split[i];
140
- let at = -1;
141
- if (isN) {
142
- // 'N' matches every base, so the (delta+1)-th is delta ahead and
143
- // there is nothing to search for.
144
- at = currPos + delta;
145
- if (at >= seqLength) {
146
- at = -1;
147
- }
148
- }
149
- else {
150
- for (let k = 0; k <= delta; k++) {
151
- at = fseq.indexOf(base, currPos);
152
- if (at < 0) {
153
- break;
154
- }
155
- currPos = at + 1;
156
- }
157
- }
158
- if (at < 0) {
159
- currPos = seqLength;
160
- positions[writeIndex++] = endClamp;
161
- }
162
- else {
163
- currPos = at + 1;
164
- positions[writeIndex++] = at;
165
- }
166
- }
167
- }
168
- if (seenKeys === undefined) {
169
- seenKeys = [key];
170
- seenDeltas = [deltas];
171
- seenPositions = [positions];
265
+ let walked = seen?.find(w => w.key === key && w.deltas === deltas);
266
+ if (walked === undefined) {
267
+ const nPositions = countCode(deltas, COMMA, 0, deltas.length);
268
+ walked = {
269
+ key,
270
+ deltas,
271
+ nPositions,
272
+ ...walkGroup(deltas, nPositions, base, fseq, isRev, windowStart, windowEnd),
273
+ };
274
+ if (seen === undefined) {
275
+ seen = [walked];
172
276
  }
173
277
  else {
174
- seenKeys.push(key);
175
- seenDeltas.push(deltas);
176
- seenPositions.push(positions);
278
+ seen.push(walked);
177
279
  }
178
280
  }
281
+ const { positions, skipped, nPositions } = walked;
282
+ const probStart = mlBase + skipped * nTypes;
179
283
  if (isSingleType) {
180
284
  result.push({
181
285
  type: typestr,
@@ -183,7 +287,7 @@ export function getModPositions(mm, fseq, fstrand) {
183
287
  strand,
184
288
  unknownSkip,
185
289
  positions,
186
- probStart: mlBase,
290
+ probStart,
187
291
  probStride: 1,
188
292
  });
189
293
  }
@@ -196,7 +300,7 @@ export function getModPositions(mm, fseq, fstrand) {
196
300
  strand,
197
301
  unknownSkip,
198
302
  positions,
199
- probStart: mlBase + j,
303
+ probStart: probStart + j,
200
304
  probStride: nTypes,
201
305
  });
202
306
  }
@@ -0,0 +1,9 @@
1
+ import type { Feature } from '@jbrowse/core/util';
2
+ /**
3
+ * #api
4
+ * The read's MM tag, or undefined when its MN tag says MM was computed on a
5
+ * sequence of a different length than SEQ. Per SAMtags, MN catches a hard clip
6
+ * or trim that shifted the bases MM's deltas count, so on a mismatch every
7
+ * position MM would place is wrong.
8
+ */
9
+ export declare function getModTag(feature: Feature): string | undefined;
@@ -0,0 +1,22 @@
1
+ import { getTag, getTagAlt } from './getTagAlt.js';
2
+ function seqLength(feature) {
3
+ const len = feature.get('seq_length');
4
+ return typeof len === 'number'
5
+ ? len
6
+ : feature.get('seq')?.length;
7
+ }
8
+ /**
9
+ * #api
10
+ * The read's MM tag, or undefined when its MN tag says MM was computed on a
11
+ * sequence of a different length than SEQ. Per SAMtags, MN catches a hard clip
12
+ * or trim that shifted the bases MM's deltas count, so on a mismatch every
13
+ * position MM would place is wrong.
14
+ */
15
+ export function getModTag(feature) {
16
+ const mm = getTagAlt(feature, 'MM', 'Mm');
17
+ if (!mm) {
18
+ return undefined;
19
+ }
20
+ const mn = getTag(feature, 'MN');
21
+ return mn === undefined || Number(mn) === seqLength(feature) ? mm : undefined;
22
+ }
package/esm/index.d.ts CHANGED
@@ -6,6 +6,7 @@ export { forEachModRefPos } from './forEachModRefPos.ts';
6
6
  export { getMethBins, isMethylationFillType } from './getMethBins.ts';
7
7
  export type { ParsedModData } from './getMethBins.ts';
8
8
  export { getModPositions } from './getModPositions.ts';
9
+ export { getModTag } from './getModTag.ts';
9
10
  export type { ModWithPositions } from './getModPositions.ts';
10
11
  export { getModTypes, isSingleModType } from './getModTypes.ts';
11
12
  export type { ModTypeHeader } from './getModTypes.ts';
package/esm/index.js CHANGED
@@ -4,6 +4,7 @@ export { detectSimplexModifications } from './detectSimplexModifications.js';
4
4
  export { forEachModRefPos } from './forEachModRefPos.js';
5
5
  export { getMethBins, isMethylationFillType } from './getMethBins.js';
6
6
  export { getModPositions } from './getModPositions.js';
7
+ export { getModTag } from './getModTag.js';
7
8
  export { getModTypes, isSingleModType } from './getModTypes.js';
8
9
  export { getModProbabilities, getModProbabilityBytes, modProbAt, } from './getModProbabilities.js';
9
10
  export { getTag, getTagAlt } from './getTagAlt.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jbrowse/modifications-utils",
3
- "version": "5.0.0-beta.1",
3
+ "version": "5.0.0-beta.10",
4
4
  "type": "module",
5
5
  "description": "Pure MM/ML base-modification tag parsers (methylation, etc.)",
6
6
  "keywords": [
@@ -25,8 +25,8 @@
25
25
  "esm"
26
26
  ],
27
27
  "dependencies": {
28
- "@jbrowse/cigar-utils": "^5.0.0-beta.1",
29
- "@jbrowse/core": "^5.0.0-beta.1"
28
+ "@jbrowse/cigar-utils": "^5.0.0-beta.10",
29
+ "@jbrowse/core": "^5.0.0-beta.10"
30
30
  },
31
31
  "publishConfig": {
32
32
  "access": "public"