@gmod/gbz-base 2.2.0 → 2.4.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/src/subgraph.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  isReverse,
12
12
  nodeId,
13
13
  nodeOrientation,
14
+ pathEndsAreCanonical,
14
15
  pathIsCanonical,
15
16
  } from './gbwt/node.ts'
16
17
  import { gfaHeaderLines, sha256Hex, subgraphName } from './graphName.ts'
@@ -54,7 +55,7 @@ export interface PathIdentity {
54
55
 
55
56
  interface PathInfo {
56
57
  path: number[]
57
- positions: Pos[]
58
+ offsets: number[]
58
59
  len: number
59
60
  weight: number | undefined
60
61
  identity: PathIdentity | undefined
@@ -114,6 +115,24 @@ export interface SubgraphOptions {
114
115
  signal?: AbortSignal | undefined
115
116
  }
116
117
 
118
+ export class SubgraphLimitError extends Error {
119
+ override name = 'SubgraphLimitError'
120
+ readonly limit: number
121
+ readonly windowBp: number | undefined
122
+ readonly walkedBp: number | undefined
123
+
124
+ constructor(limit: number, walk?: { windowBp: number; walkedBp: number }) {
125
+ super(
126
+ walk === undefined
127
+ ? `Subgraph size limit of ${limit} nodes exceeded`
128
+ : `Subgraph size limit of ${limit} nodes exceeded ${walk.walkedBp} bp into a ${walk.windowBp} bp window`,
129
+ )
130
+ this.limit = limit
131
+ this.windowBp = walk?.windowBp
132
+ this.walkedBp = walk?.walkedBp
133
+ }
134
+ }
135
+
117
136
  function sideBefore(
118
137
  a: [number, number, NodeSide],
119
138
  b: [number, number, NodeSide],
@@ -178,26 +197,198 @@ function posKey(pos: Pos) {
178
197
  return `${pos.node}:${pos.offset}`
179
198
  }
180
199
 
200
+ interface FragmentAlignment {
201
+ strand: '+' | '-'
202
+ refStart: number
203
+ refEnd: number
204
+ edits: Edit[]
205
+ weight: number | undefined
206
+ path: number[]
207
+ start: Pos
208
+ identity:
209
+ | {
210
+ pathHandle: number
211
+ name: PathName
212
+ hapStart: number
213
+ hapEnd: number
214
+ walkForward: boolean
215
+ }
216
+ | undefined
217
+ }
218
+
219
+ function joinable(a: FragmentAlignment, b: FragmentAlignment) {
220
+ const insertion = b.identity!.hapStart - a.identity!.hapEnd
221
+ const deletion =
222
+ a.strand === '+' ? b.refStart - a.refEnd : a.refStart - b.refEnd
223
+ return a.strand === b.strand && insertion >= 0 && deletion >= 0
224
+ ? { insertion, deletion }
225
+ : undefined
226
+ }
227
+
228
+ function joinPair(
229
+ a: FragmentAlignment,
230
+ b: FragmentAlignment,
231
+ gap: { insertion: number; deletion: number },
232
+ ): FragmentAlignment {
233
+ const [left, right] = a.strand === '+' ? [a, b] : [b, a]
234
+ const edits = left.edits.map(([op, len]): Edit => [op, len])
235
+ appendGap(edits, gap.insertion, gap.deletion)
236
+ for (const [op, len] of right.edits) {
237
+ appendEdit(edits, op, len)
238
+ }
239
+ const walkForward = a.identity!.walkForward
240
+ const [first, second] = walkForward ? [a, b] : [b, a]
241
+ return {
242
+ strand: a.strand,
243
+ refStart: left.refStart,
244
+ refEnd: right.refEnd,
245
+ edits,
246
+ weight: undefined,
247
+ path: [...first.path, ...second.path],
248
+ start: first.start,
249
+ identity: {
250
+ pathHandle: a.identity!.pathHandle,
251
+ name: a.identity!.name,
252
+ hapStart: a.identity!.hapStart,
253
+ hapEnd: b.identity!.hapEnd,
254
+ walkForward,
255
+ },
256
+ }
257
+ }
258
+
259
+ function joinSiblings(fragments: FragmentAlignment[]) {
260
+ if (fragments.some(f => f.weight !== undefined)) {
261
+ return fragments
262
+ }
263
+ const byPath = new Map<number, number[]>()
264
+ fragments.forEach((fragment, index) => {
265
+ if (fragment.identity) {
266
+ const siblings = byPath.get(fragment.identity.pathHandle)
267
+ if (siblings) {
268
+ siblings.push(index)
269
+ } else {
270
+ byPath.set(fragment.identity.pathHandle, [index])
271
+ }
272
+ }
273
+ })
274
+ const joined = new Map<number, FragmentAlignment>()
275
+ const consumed = new Set<number>()
276
+ for (const siblings of byPath.values()) {
277
+ siblings.sort(
278
+ (x, y) =>
279
+ fragments[x]!.identity!.hapStart - fragments[y]!.identity!.hapStart,
280
+ )
281
+ let head = siblings[0]!
282
+ let current = fragments[head]!
283
+ for (const index of siblings.slice(1)) {
284
+ const next = fragments[index]!
285
+ const gap = joinable(current, next)
286
+ if (gap) {
287
+ current = joinPair(current, next, gap)
288
+ consumed.add(index)
289
+ } else {
290
+ joined.set(head, current)
291
+ head = index
292
+ current = next
293
+ }
294
+ }
295
+ joined.set(head, current)
296
+ }
297
+ return fragments.flatMap((fragment, index) =>
298
+ consumed.has(index) ? [] : [joined.get(index) ?? fragment],
299
+ )
300
+ }
301
+
302
+ function pathPosition(info: PathInfo, k: number): Pos {
303
+ return { node: info.path[k]!, offset: info.offsets[k]! }
304
+ }
305
+
306
+ const SCAN_GAP = 4096
307
+
308
+ function handleRuns(sortedHandles: number[]) {
309
+ const runs: [number, number][] = []
310
+ for (const handle of sortedHandles) {
311
+ const last = runs[runs.length - 1]
312
+ if (last && handle - last[1] <= SCAN_GAP) {
313
+ last[1] = handle
314
+ } else {
315
+ runs.push([handle, handle])
316
+ }
317
+ }
318
+ return runs
319
+ }
320
+
181
321
  interface Anchor {
182
322
  pathHandle: number
183
323
  orientation: Orientation
184
324
  base: number
185
325
  }
186
326
 
327
+ export type ChainEnd =
328
+ | 'in-fragment sample'
329
+ | 'identified sibling'
330
+ | 'out-of-window sample'
331
+ | 'bound'
332
+ | 'endmarker'
333
+ | 'cycle'
334
+
335
+ export interface ChainRecord {
336
+ fragments: number
337
+ steps: number
338
+ seeks: number
339
+ reentries: number
340
+ twinLandings: number
341
+ end: ChainEnd
342
+ pathHandle: number | undefined
343
+ }
344
+
345
+ export interface IdentificationStats {
346
+ interval: number
347
+ scans: [number, number][]
348
+ windowSamples: number
349
+ fragmentLengths: number[]
350
+ companionSeeks: number
351
+ companionMisses: number
352
+ graphLookups: number
353
+ graphFetches: number
354
+ chains: ChainRecord[]
355
+ }
356
+
357
+ interface SubgraphStats {
358
+ orderedAlignments: number
359
+ lcsAlignments: number
360
+ identificationSteps: number
361
+ identificationFetches: number
362
+ identification: IdentificationStats
363
+ }
364
+
187
365
  export class Subgraph {
188
366
  private records = new Map<number, GbzRecord>()
189
367
  private paths: PathInfo[] = []
368
+ private twinStarts = new Set<string>()
190
369
  private refId: number | undefined
191
370
  private refPath: PathName | undefined
192
371
  private refHandle: number | undefined
193
372
  private refInterval: [number, number] | undefined
194
373
  private refIndexCache: Map<number, number[]> | undefined
195
374
  private refPrefixCache: number[] | undefined
196
- readonly stats = {
375
+ private walkedBp: number | undefined
376
+ readonly stats: SubgraphStats = {
197
377
  orderedAlignments: 0,
198
378
  lcsAlignments: 0,
199
379
  identificationSteps: 0,
200
380
  identificationFetches: 0,
381
+ identification: {
382
+ interval: 0,
383
+ scans: [],
384
+ windowSamples: 0,
385
+ fragmentLengths: [],
386
+ companionSeeks: 0,
387
+ companionMisses: 0,
388
+ graphLookups: 0,
389
+ graphFetches: 0,
390
+ chains: [],
391
+ },
201
392
  }
202
393
 
203
394
  private db: GBZBase
@@ -251,7 +442,7 @@ export class Subgraph {
251
442
  private async addNode(id: number) {
252
443
  this.signal?.throwIfAborted()
253
444
  if (this.limit !== undefined && this.nodeCount >= this.limit) {
254
- throw new Error(`Subgraph size limit of ${this.limit} nodes exceeded`)
445
+ throw new SubgraphLimitError(this.limit)
255
446
  }
256
447
  const forward = await this.db.getRecord(encodeNode(id, 'forward'))
257
448
  const reverse = await this.db.getRecord(encodeNode(id, 'reverse'))
@@ -270,6 +461,7 @@ export class Subgraph {
270
461
 
271
462
  private clearPaths() {
272
463
  this.paths = []
464
+ this.twinStarts.clear()
273
465
  this.refId = undefined
274
466
  this.refPath = undefined
275
467
  this.refHandle = undefined
@@ -350,10 +542,44 @@ export class Subgraph {
350
542
  return this.insertContext(active, context)
351
543
  }
352
544
 
545
+ async prefetchReferenceWalk(reference: ReferencePath, len: number) {
546
+ const last = await this.db.indexedPosition(
547
+ reference.handle,
548
+ reference.position.seqOffset + len,
549
+ )
550
+ if (last) {
551
+ const a = reference.position.handle
552
+ const b = last.pos.node
553
+ await this.db.prefetchRecords(Math.min(a, b), Math.max(a, b) + 1)
554
+ }
555
+ }
556
+
353
557
  async aroundInterval(start: PathPosition, len: number, context: number) {
354
558
  if (len === 0) {
355
559
  throw new Error('Interval length must be greater than 0')
356
560
  }
561
+ this.walkedBp = 0
562
+ try {
563
+ return await this.walkInterval(start, len, context)
564
+ } catch (error) {
565
+ throw error instanceof SubgraphLimitError && error.windowBp === undefined
566
+ ? new SubgraphLimitError(error.limit, {
567
+ windowBp: len,
568
+ walkedBp: this.walkedBp,
569
+ })
570
+ : error
571
+ }
572
+ }
573
+
574
+ get referenceWalkedBp() {
575
+ return this.walkedBp
576
+ }
577
+
578
+ private async walkInterval(
579
+ start: PathPosition,
580
+ len: number,
581
+ context: number,
582
+ ) {
357
583
  let pos: Pos = { node: start.handle, offset: start.gbwtOffset }
358
584
  let offset = start.nodeOffset
359
585
  let remaining = len
@@ -361,6 +587,7 @@ export class Subgraph {
361
587
  for (;;) {
362
588
  const id = nodeId(pos.node)
363
589
  const orientation = nodeOrientation(pos.node)
590
+ this.walkedBp = len - remaining
364
591
  await this.ensureNode(id)
365
592
  const record = this.record(pos.node)
366
593
  if (offset >= record.sequenceLen) {
@@ -389,6 +616,7 @@ export class Subgraph {
389
616
  offset = 0
390
617
  remaining -= distanceToNext
391
618
  }
619
+ this.walkedBp = len
392
620
  return this.insertContext(active, context)
393
621
  }
394
622
 
@@ -602,69 +830,133 @@ export class Subgraph {
602
830
  this.refPath = reference?.name
603
831
  this.refHandle = reference?.handle
604
832
  const handles = this.sortedHandles()
605
- const successors = new Map<
606
- number,
607
- { nodes: Int32Array; offsets: Int32Array; hasPredecessor: Uint8Array }
608
- >()
609
- for (const handle of handles) {
610
- const { nodes, offsets } = this.record(handle).gbwt().decompressArrays()
611
- successors.set(handle, {
612
- nodes,
833
+ const count = handles.length
834
+ const indexOf = new Map<number, number>()
835
+ handles.forEach((handle, i) => indexOf.set(handle, i))
836
+ const nextIndex: Int32Array[] = []
837
+ const nextOffset: Int32Array[] = []
838
+ const hasPredecessor: Uint8Array[] = []
839
+ const seqLen = new Int32Array(count)
840
+ for (let i = 0; i < count; i++) {
841
+ const record = this.record(handles[i]!)
842
+ const { nodes, offsets } = record.gbwt().decompressArrays()
843
+ seqLen[i] = record.sequenceLen
844
+ nextIndex.push(nodes)
845
+ nextOffset.push(offsets)
846
+ hasPredecessor.push(new Uint8Array(nodes.length))
847
+ }
848
+ for (let i = 0; i < count; i++) {
849
+ const nodes = nextIndex[i]!
850
+ const offsets = nextOffset[i]!
851
+ for (let k = 0; k < nodes.length; k++) {
852
+ const j = indexOf.get(nodes[k]!)
853
+ if (j === undefined) {
854
+ nodes[k] = -1
855
+ } else {
856
+ nodes[k] = j
857
+ hasPredecessor[j]![offsets[k]!] = 1
858
+ }
859
+ }
860
+ }
861
+ const refIndex =
862
+ refPos === undefined ? undefined : indexOf.get(refPos.handle)
863
+ const refGbwtOffset = refPos?.gbwtOffset
864
+ let refOffset: number | undefined
865
+ const walk = (i: number, offset: number, path: number[] | undefined) => {
866
+ const offsets: number[] = []
867
+ let steps = 0
868
+ let len = 0
869
+ let refAt = -1
870
+ let cur = i
871
+ let off = offset
872
+ for (;;) {
873
+ if (cur === refIndex && off === refGbwtOffset) {
874
+ refAt = steps
875
+ }
876
+ if (path) {
877
+ path.push(handles[cur]!)
878
+ offsets.push(off)
879
+ }
880
+ steps += 1
881
+ len += seqLen[cur]!
882
+ const next = nextIndex[cur]![off]!
883
+ if (next < 0) {
884
+ break
885
+ }
886
+ off = nextOffset[cur]![off]!
887
+ cur = next
888
+ }
889
+ return { offsets, len, refAt, last: cur }
890
+ }
891
+ const keep = (
892
+ path: number[],
893
+ offsets: number[],
894
+ len: number,
895
+ refAt: number,
896
+ ) => {
897
+ if (refAt >= 0) {
898
+ this.refId = this.paths.length
899
+ refOffset = refAt
900
+ }
901
+ this.paths.push({
902
+ path,
613
903
  offsets,
614
- hasPredecessor: new Uint8Array(nodes.length),
904
+ len,
905
+ weight: undefined,
906
+ identity: undefined,
615
907
  })
616
908
  }
617
- for (const handle of handles) {
618
- const { nodes, offsets } = successors.get(handle)!
619
- for (let i = 0; i < nodes.length; i++) {
620
- const entry = successors.get(nodes[i]!)
621
- if (entry) {
622
- entry.hasPredecessor[offsets[i]!] = 1
909
+ const twinsExpected = new Int32Array(count)
910
+ const refMayStartReversed =
911
+ refIndex !== undefined && isReverse(handles[refIndex]!)
912
+ for (let i = 0; i < count; i++) {
913
+ const first = handles[i]!
914
+ if (!isReverse(first)) {
915
+ const starts = hasPredecessor[i]!
916
+ for (let offset = 0; offset < starts.length; offset++) {
917
+ if (starts[offset] === 0) {
918
+ const path: number[] = []
919
+ const { offsets, len, refAt, last } = walk(i, offset, path)
920
+ const lastHandle = handles[last]!
921
+ if (refAt >= 0 || pathEndsAreCanonical(first, lastHandle)) {
922
+ keep(path, offsets, len, refAt)
923
+ if (!isReverse(lastHandle)) {
924
+ const twinRecord = indexOf.get(flipNode(lastHandle))!
925
+ twinsExpected[twinRecord] = twinsExpected[twinRecord]! + 1
926
+ }
927
+ } else {
928
+ this.twinStarts.add(posKey({ node: first, offset }))
929
+ }
930
+ }
623
931
  }
624
932
  }
625
933
  }
626
- let refOffset: number | undefined
627
- for (const handle of handles) {
628
- const entries = successors.get(handle)!
629
- for (let offset = 0; offset < entries.nodes.length; offset++) {
630
- if (entries.hasPredecessor[offset] === 0) {
631
- let currNode: number | undefined = handle
632
- let currOffset = offset
633
- let isRef = false
634
- const path: number[] = []
635
- const positions: Pos[] = []
636
- let len = 0
637
- while (currNode !== undefined) {
934
+ for (let i = 0; i < count; i++) {
935
+ const first = handles[i]!
936
+ if (isReverse(first)) {
937
+ const starts = hasPredecessor[i]!
938
+ let startCount = 0
939
+ for (const flag of starts) {
940
+ if (flag === 0) {
941
+ startCount += 1
942
+ }
943
+ }
944
+ const allTwins = !refMayStartReversed && startCount === twinsExpected[i]
945
+ for (let offset = 0; offset < starts.length; offset++) {
946
+ if (starts[offset] === 0) {
947
+ const bare = allTwins ? undefined : walk(i, offset, undefined)
638
948
  if (
639
- currNode === refPos?.handle &&
640
- currOffset === refPos.gbwtOffset
949
+ bare &&
950
+ (bare.refAt >= 0 ||
951
+ pathEndsAreCanonical(first, handles[bare.last]))
641
952
  ) {
642
- this.refId = this.paths.length
643
- refOffset = path.length
644
- isRef = true
645
- }
646
- path.push(currNode)
647
- positions.push({ node: currNode, offset: currOffset })
648
- len += this.record(currNode).sequenceLen
649
- const step = successors.get(currNode)!
650
- const nextNode: number = step.nodes[currOffset]!
651
- const nextOffset: number = step.offsets[currOffset]!
652
- if (nextNode !== ENDMARKER && successors.has(nextNode)) {
653
- currNode = nextNode
654
- currOffset = nextOffset
953
+ const path: number[] = []
954
+ const { offsets, len, refAt } = walk(i, offset, path)
955
+ keep(path, offsets, len, refAt)
655
956
  } else {
656
- currNode = undefined
957
+ this.twinStarts.add(posKey({ node: first, offset }))
657
958
  }
658
959
  }
659
- if (isRef || pathIsCanonical(path)) {
660
- this.paths.push({
661
- path,
662
- positions,
663
- len,
664
- weight: undefined,
665
- identity: undefined,
666
- })
667
- }
668
960
  }
669
961
  }
670
962
  }
@@ -720,6 +1012,33 @@ export class Subgraph {
720
1012
  this.refId = refId
721
1013
  }
722
1014
 
1015
+ // Narrows an identified subgraph to the reference walk and the named walks
1016
+ // `wanted` accepts, and drops every node only the discarded walks visited,
1017
+ // so a cut for a chosen set draws that set's private sequence and nothing
1018
+ // else's. Unresolved walks are discarded with the rest.
1019
+ keepHaplotypes(wanted: (name: PathName) => boolean) {
1020
+ const refInfo =
1021
+ this.refId === undefined ? undefined : this.paths[this.refId]
1022
+ const kept = this.paths.filter(
1023
+ (info, index) =>
1024
+ index === this.refId ||
1025
+ (info.identity !== undefined && wanted(info.identity.name)),
1026
+ )
1027
+ const visited = new Set<number>()
1028
+ for (const info of kept) {
1029
+ for (const handle of info.path) {
1030
+ visited.add(nodeId(handle))
1031
+ }
1032
+ }
1033
+ for (const handle of [...this.records.keys()]) {
1034
+ if (!visited.has(nodeId(handle))) {
1035
+ this.records.delete(handle)
1036
+ }
1037
+ }
1038
+ this.paths = kept
1039
+ this.refId = refInfo === undefined ? undefined : kept.indexOf(refInfo)
1040
+ }
1041
+
723
1042
  async identifyPaths() {
724
1043
  if (!this.db.hasHaplotypeIndex) {
725
1044
  throw new Error(
@@ -727,35 +1046,65 @@ export class Subgraph {
727
1046
  )
728
1047
  }
729
1048
  const interval = (await this.db.haplotypeSampleInterval()) ?? 4096
730
- const handles = this.sortedHandles()
731
- const minHandle = handles[0]
732
- const maxHandle = handles[handles.length - 1]
733
- if (minHandle === undefined || maxHandle === undefined) {
1049
+ const runs = handleRuns(this.sortedHandles())
1050
+ if (runs.length === 0) {
734
1051
  return
735
1052
  }
736
1053
  const samples = new Map<string, HaplotypeSample>()
737
- for (const sample of await this.db.haplotypeSamplesInRange(
738
- minHandle,
739
- maxHandle,
740
- )) {
741
- samples.set(posKey(sample), sample)
1054
+ for (const [first, last] of runs) {
1055
+ for (const sample of await this.db.haplotypeSamplesInRange(first, last)) {
1056
+ samples.set(posKey(sample), sample)
1057
+ }
1058
+ }
1059
+ const scanned = (handle: number) => {
1060
+ let lo = 0
1061
+ let hi = runs.length - 1
1062
+ while (lo < hi) {
1063
+ const mid = (lo + hi + 1) >> 1
1064
+ if (runs[mid]![0] <= handle) {
1065
+ lo = mid
1066
+ } else {
1067
+ hi = mid - 1
1068
+ }
1069
+ }
1070
+ const run = runs[lo]!
1071
+ return run[0] <= handle && handle <= run[1]
742
1072
  }
743
1073
  const starts = new Map<string, number>()
744
1074
  this.paths.forEach((info, index) => {
745
- const first = info.positions[0]
746
- if (first && index !== this.refId) {
747
- starts.set(posKey(first), index)
1075
+ if (info.path.length > 0 && index !== this.refId) {
1076
+ starts.set(posKey(pathPosition(info, 0)), index)
748
1077
  }
749
1078
  })
750
- const recordAt = this.recordReader(() => {
1079
+ const identification = this.stats.identification
1080
+ identification.interval = interval
1081
+ identification.scans = runs
1082
+ identification.windowSamples = samples.size
1083
+ this.paths.forEach((info, index) => {
1084
+ if (index !== this.refId) {
1085
+ identification.fragmentLengths.push(info.len)
1086
+ }
1087
+ })
1088
+ const readRecord = this.recordReader(() => {
751
1089
  this.stats.identificationFetches += 1
1090
+ identification.graphFetches += 1
752
1091
  })
753
- const sampleAt = async (pos: Pos) => {
754
- if (pos.node >= minHandle && pos.node <= maxHandle) {
1092
+ const recordAt = (handle: number) => {
1093
+ identification.graphLookups += 1
1094
+ return readRecord(handle)
1095
+ }
1096
+ const sampleAt = async (pos: Pos, chain: ChainRecord) => {
1097
+ if (scanned(pos.node)) {
755
1098
  return samples.get(posKey(pos))
756
1099
  }
757
1100
  this.stats.identificationFetches += 1
758
- return this.db.haplotypeSampleAt(pos.node, pos.offset)
1101
+ identification.companionSeeks += 1
1102
+ chain.seeks += 1
1103
+ const sample = await this.db.haplotypeSampleAt(pos.node, pos.offset)
1104
+ if (!sample) {
1105
+ identification.companionMisses += 1
1106
+ }
1107
+ return sample
759
1108
  }
760
1109
  const names = new Map<number, PathName>()
761
1110
  const nameOf = async (pathHandle: number) => {
@@ -809,6 +1158,16 @@ export class Subgraph {
809
1158
  }
810
1159
  const chain: { index: number; startBp: number }[] = []
811
1160
  const visited = new Set<number>()
1161
+ const record: ChainRecord = {
1162
+ fragments: 0,
1163
+ steps: 0,
1164
+ seeks: 0,
1165
+ reentries: 0,
1166
+ twinLandings: 0,
1167
+ end: 'endmarker',
1168
+ pathHandle: undefined,
1169
+ }
1170
+ identification.chains.push(record)
812
1171
  let anchor: Anchor | undefined
813
1172
  let counter = 0
814
1173
  let current: number | undefined = start
@@ -817,13 +1176,16 @@ export class Subgraph {
817
1176
  this.signal?.throwIfAborted()
818
1177
  if (current !== undefined) {
819
1178
  if (visited.has(current)) {
1179
+ record.end = 'cycle'
820
1180
  break
821
1181
  }
822
1182
  visited.add(current)
823
1183
  const info = this.paths[current]!
824
1184
  chain.push({ index: current, startBp: counter })
1185
+ record.fragments += 1
825
1186
  let bp = counter
826
- for (const position of info.positions) {
1187
+ for (let k = 0; k < info.path.length; k++) {
1188
+ const position = pathPosition(info, k)
827
1189
  const sample = samples.get(posKey(position))
828
1190
  const nodeLen = this.record(position.node).sequenceLen
829
1191
  if (sample) {
@@ -834,42 +1196,56 @@ export class Subgraph {
834
1196
  }
835
1197
  counter += info.len
836
1198
  if (anchor) {
1199
+ record.end = 'in-fragment sample'
837
1200
  break
838
1201
  }
839
- const last = info.positions[info.positions.length - 1]!
1202
+ const last = pathPosition(info, info.path.length - 1)
840
1203
  pos = this.record(last.node).gbwt().lf(last.offset)
841
1204
  current = undefined
842
1205
  }
843
1206
  if (pos === undefined || pos.node === ENDMARKER) {
1207
+ record.end = 'endmarker'
844
1208
  break
845
1209
  }
846
- const known = starts.get(posKey(pos))
1210
+ const key = posKey(pos)
1211
+ const known = starts.get(key)
847
1212
  if (known !== undefined) {
848
1213
  const identity = this.paths[known]!.identity
849
1214
  if (identity) {
850
1215
  anchor = anchorFromIdentity(identity, counter)
1216
+ record.end = 'identified sibling'
851
1217
  break
852
1218
  }
853
1219
  current = known
854
1220
  continue
855
1221
  }
856
- const sample = await sampleAt(pos)
857
- const record = await recordAt(pos.node)
1222
+ if (this.records.has(pos.node)) {
1223
+ record.reentries += 1
1224
+ }
1225
+ if (this.twinStarts.has(key)) {
1226
+ record.twinLandings += 1
1227
+ }
1228
+ const sample = await sampleAt(pos, record)
1229
+ const node = await recordAt(pos.node)
858
1230
  if (sample) {
859
- anchor = anchorFromSample(sample, counter, record.sequenceLen)
1231
+ anchor = anchorFromSample(sample, counter, node.sequenceLen)
1232
+ record.end = 'out-of-window sample'
860
1233
  break
861
1234
  }
862
1235
  this.stats.identificationSteps += 1
1236
+ record.steps += 1
863
1237
  if (
864
1238
  counter - (chain[chain.length - 1] as { startBp: number }).startBp >
865
- 4 * interval + 4 * record.sequenceLen
1239
+ 4 * interval + 4 * node.sequenceLen
866
1240
  ) {
1241
+ record.end = 'bound'
867
1242
  break
868
1243
  }
869
- counter += record.sequenceLen
870
- pos = record.gbwt().lf(pos.offset)
1244
+ counter += node.sequenceLen
1245
+ pos = node.gbwt().lf(pos.offset)
871
1246
  }
872
1247
  if (anchor) {
1248
+ record.pathHandle = anchor.pathHandle
873
1249
  const name = await nameOf(anchor.pathHandle)
874
1250
  for (const { index, startBp } of chain) {
875
1251
  const info = this.paths[index]!
@@ -1020,28 +1396,7 @@ export class Subgraph {
1020
1396
  suffix = refLen - prefix
1021
1397
  }
1022
1398
  appendEdit(edits, 'M', prefix)
1023
- const pathMiddle = pathLen - prefix - suffix
1024
- const refMiddle = refLen - prefix - suffix
1025
- if (pathMiddle === 0) {
1026
- appendEdit(edits, 'D', refMiddle)
1027
- } else if (refMiddle === 0) {
1028
- appendEdit(edits, 'I', pathMiddle)
1029
- } else {
1030
- const mismatch = Math.min(pathMiddle, refMiddle)
1031
- const mismatchIndel =
1032
- 4 * mismatch +
1033
- gapPenalty(pathMiddle - mismatch) +
1034
- gapPenalty(refMiddle - mismatch)
1035
- const insertionDeletion = gapPenalty(pathMiddle) + gapPenalty(refMiddle)
1036
- if (mismatchIndel <= insertionDeletion) {
1037
- appendEdit(edits, 'M', mismatch)
1038
- appendEdit(edits, 'I', pathMiddle - mismatch)
1039
- appendEdit(edits, 'D', refMiddle - mismatch)
1040
- } else {
1041
- appendEdit(edits, 'I', pathMiddle)
1042
- appendEdit(edits, 'D', refMiddle)
1043
- }
1044
- }
1399
+ appendGap(edits, pathLen - prefix - suffix, refLen - prefix - suffix)
1045
1400
  appendEdit(edits, 'M', suffix)
1046
1401
  }
1047
1402
 
@@ -1067,22 +1422,37 @@ export class Subgraph {
1067
1422
  ordered ??
1068
1423
  weightedLcs(path, ref, handle => this.record(handle).sequenceLen)[0]
1069
1424
  const edits: Edit[] = []
1425
+ const refPrefix = this.refPrefix(ref)
1426
+ const alignGap = (
1427
+ pathFrom: number,
1428
+ pathTo: number,
1429
+ refFrom: number,
1430
+ refTo: number,
1431
+ ) => {
1432
+ if (pathFrom === pathTo) {
1433
+ appendEdit(edits, 'D', refPrefix[refTo]! - refPrefix[refFrom]!)
1434
+ } else if (refFrom === refTo) {
1435
+ appendEdit(edits, 'I', this.pathLen(path.slice(pathFrom, pathTo)))
1436
+ } else {
1437
+ this.align(
1438
+ path.slice(pathFrom, pathTo),
1439
+ ref.slice(refFrom, refTo),
1440
+ edits,
1441
+ )
1442
+ }
1443
+ }
1070
1444
  let matched = 0
1071
1445
  let pathOffset = 0
1072
1446
  let refOffset = 0
1073
1447
  for (const [nextPath, nextRef] of lcs) {
1074
- this.align(
1075
- path.slice(pathOffset, nextPath),
1076
- ref.slice(refOffset, nextRef),
1077
- edits,
1078
- )
1448
+ alignGap(pathOffset, nextPath, refOffset, nextRef)
1079
1449
  const nodeLen = this.record(path[nextPath]!).sequenceLen
1080
1450
  appendEdit(edits, 'M', nodeLen)
1081
1451
  matched += nodeLen
1082
1452
  pathOffset = nextPath + 1
1083
1453
  refOffset = nextRef + 1
1084
1454
  }
1085
- this.align(path.slice(pathOffset), ref.slice(refOffset), edits)
1455
+ alignGap(pathOffset, path.length, refOffset, ref.length)
1086
1456
  return { edits, matched }
1087
1457
  }
1088
1458
 
@@ -1132,7 +1502,7 @@ export class Subgraph {
1132
1502
  }
1133
1503
  const ref = this.paths[this.refId]!.path
1134
1504
  const refTotal = this.refPrefix(ref)[ref.length]!
1135
- const result: HaplotypeAlignment[] = []
1505
+ const fragments: FragmentAlignment[] = []
1136
1506
  this.paths.forEach((info, index) => {
1137
1507
  if (index === this.refId) {
1138
1508
  return
@@ -1154,38 +1524,44 @@ export class Subgraph {
1154
1524
  const alongReference = identity
1155
1525
  ? (identity.orientation === 'forward') !== flipped
1156
1526
  : !flipped
1157
- const span: AlignmentSpan = {
1527
+ fragments.push({
1158
1528
  strand: alongReference ? '+' : '-',
1159
1529
  refStart: reference.start + leading,
1160
1530
  refEnd: reference.start + refTotal - trailing,
1161
- cigar: edits
1162
- .slice(first, last)
1163
- .map(([op, len]) => `${len}${op}`)
1164
- .join(''),
1531
+ edits: edits.slice(first, last),
1165
1532
  weight: info.weight,
1166
1533
  path: info.path,
1167
- start: info.positions[0]!,
1168
- }
1169
- if (identity) {
1170
- const hapStart = identity.name.fragment + identity.hapStart
1171
- const hapEnd = identity.name.fragment + identity.hapEnd
1172
- result.push({
1173
- ...span,
1174
- resolved: true,
1175
- name: identity.name,
1176
- label: formatPathName(
1177
- { ...identity.name, fragment: hapStart },
1178
- hapEnd,
1179
- ),
1180
- pathHandle: identity.pathHandle,
1181
- hapStart,
1182
- hapEnd,
1183
- })
1184
- } else {
1185
- result.push({ ...span, resolved: false })
1186
- }
1534
+ start: pathPosition(info, 0),
1535
+ identity:
1536
+ identity === undefined
1537
+ ? undefined
1538
+ : {
1539
+ pathHandle: identity.pathHandle,
1540
+ name: identity.name,
1541
+ hapStart: identity.name.fragment + identity.hapStart,
1542
+ hapEnd: identity.name.fragment + identity.hapEnd,
1543
+ walkForward: identity.orientation === 'forward',
1544
+ },
1545
+ })
1546
+ })
1547
+ return joinSiblings(fragments).map(fragment => {
1548
+ const { edits, identity, ...rest } = fragment
1549
+ const span: AlignmentSpan = { ...rest, cigar: cigarOf(edits) }
1550
+ return identity
1551
+ ? {
1552
+ ...span,
1553
+ resolved: true,
1554
+ name: identity.name,
1555
+ label: formatPathName(
1556
+ { ...identity.name, fragment: identity.hapStart },
1557
+ identity.hapEnd,
1558
+ ),
1559
+ pathHandle: identity.pathHandle,
1560
+ hapStart: identity.hapStart,
1561
+ hapEnd: identity.hapEnd,
1562
+ }
1563
+ : { ...span, resolved: false }
1187
1564
  })
1188
- return result
1189
1565
  }
1190
1566
 
1191
1567
  private canonicalEdges(id: number) {
@@ -1254,11 +1630,12 @@ export class Subgraph {
1254
1630
  }
1255
1631
  const walk = (
1256
1632
  info: PathInfo,
1633
+ identity: PathIdentity | undefined,
1257
1634
  name: PathName,
1258
1635
  end: number,
1259
1636
  cigarString: string | undefined,
1260
1637
  ) => {
1261
- const steps = info.path
1638
+ const steps = haplotypeOrderedPath(info, identity)
1262
1639
  .map(handle => `${isReverse(handle) ? '<' : '>'}${nodeId(handle)}`)
1263
1640
  .join('')
1264
1641
  const weight = info.weight === undefined ? '' : `\tWT:i:${info.weight}`
@@ -1270,6 +1647,7 @@ export class Subgraph {
1270
1647
  lines.push(
1271
1648
  walk(
1272
1649
  this.paths[this.refId]!,
1650
+ undefined,
1273
1651
  {
1274
1652
  ...this.refPath,
1275
1653
  fragment: this.refPath.fragment + this.refInterval[0],
@@ -1288,6 +1666,7 @@ export class Subgraph {
1288
1666
  resolved
1289
1667
  ? walk(
1290
1668
  info,
1669
+ resolved,
1291
1670
  {
1292
1671
  ...resolved.name,
1293
1672
  fragment: resolved.name.fragment + resolved.hapStart,
@@ -1297,6 +1676,7 @@ export class Subgraph {
1297
1676
  )
1298
1677
  : walk(
1299
1678
  info,
1679
+ undefined,
1300
1680
  { sample: 'unknown', contig, haplotype, fragment: 0 },
1301
1681
  info.len,
1302
1682
  cigarString,
@@ -1341,6 +1721,7 @@ export class Subgraph {
1341
1721
  paths.push(
1342
1722
  jsonPath(
1343
1723
  info,
1724
+ undefined,
1344
1725
  formatPathName(name, this.refPath.fragment + this.refInterval[1]),
1345
1726
  undefined,
1346
1727
  ),
@@ -1365,7 +1746,12 @@ export class Subgraph {
1365
1746
  info.len,
1366
1747
  )
1367
1748
  paths.push(
1368
- jsonPath(info, name, cigar ? this.alignToRef(index) : undefined),
1749
+ jsonPath(
1750
+ info,
1751
+ resolved,
1752
+ name,
1753
+ cigar ? this.alignToRef(index) : undefined,
1754
+ ),
1369
1755
  )
1370
1756
  haplotype += 1
1371
1757
  })
@@ -1373,8 +1759,23 @@ export class Subgraph {
1373
1759
  }
1374
1760
  }
1375
1761
 
1762
+ // A named walk lists its steps in the haplotype's own direction, as the W line
1763
+ // spec and the start..end coordinates beside it require. extractPaths keeps
1764
+ // whichever twin of a walk is canonical, which is a property of the handles
1765
+ // and not of the haplotype, so the kept walk runs against the haplotype for
1766
+ // about half of them; identification records which.
1767
+ function haplotypeOrderedPath(
1768
+ info: PathInfo,
1769
+ identity: PathIdentity | undefined,
1770
+ ) {
1771
+ return identity?.orientation === 'reverse'
1772
+ ? info.path.map(handle => flipNode(handle)).reverse()
1773
+ : info.path
1774
+ }
1775
+
1376
1776
  function jsonPath(
1377
1777
  info: PathInfo,
1778
+ identity: PathIdentity | undefined,
1378
1779
  name: string,
1379
1780
  cigar: string | undefined,
1380
1781
  ): SubgraphPath {
@@ -1382,7 +1783,7 @@ function jsonPath(
1382
1783
  name,
1383
1784
  ...(info.weight === undefined ? {} : { weight: info.weight }),
1384
1785
  ...(cigar === undefined ? {} : { cigar }),
1385
- path: info.path.map(handle => ({
1786
+ path: haplotypeOrderedPath(info, identity).map(handle => ({
1386
1787
  id: String(nodeId(handle)),
1387
1788
  is_reverse: isReverse(handle),
1388
1789
  })),
@@ -1416,3 +1817,30 @@ function appendEdit(edits: Edit[], op: EditOp, len: number) {
1416
1817
  function gapPenalty(len: number) {
1417
1818
  return len === 0 ? 0 : 6 + (len - 1)
1418
1819
  }
1820
+
1821
+ function appendGap(edits: Edit[], pathMiddle: number, refMiddle: number) {
1822
+ if (pathMiddle === 0) {
1823
+ appendEdit(edits, 'D', refMiddle)
1824
+ } else if (refMiddle === 0) {
1825
+ appendEdit(edits, 'I', pathMiddle)
1826
+ } else {
1827
+ const mismatch = Math.min(pathMiddle, refMiddle)
1828
+ const mismatchIndel =
1829
+ 4 * mismatch +
1830
+ gapPenalty(pathMiddle - mismatch) +
1831
+ gapPenalty(refMiddle - mismatch)
1832
+ const insertionDeletion = gapPenalty(pathMiddle) + gapPenalty(refMiddle)
1833
+ if (mismatchIndel <= insertionDeletion) {
1834
+ appendEdit(edits, 'M', mismatch)
1835
+ appendEdit(edits, 'I', pathMiddle - mismatch)
1836
+ appendEdit(edits, 'D', refMiddle - mismatch)
1837
+ } else {
1838
+ appendEdit(edits, 'I', pathMiddle)
1839
+ appendEdit(edits, 'D', refMiddle)
1840
+ }
1841
+ }
1842
+ }
1843
+
1844
+ function cigarOf(edits: Edit[]) {
1845
+ return edits.map(([op, len]) => `${len}${op}`).join('')
1846
+ }