All files / src/analysis/run peaks.ts

93.12% Statements 528/567
85.15% Branches 195/229
100% Functions 21/21
93.12% Lines 528/567

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831                                                                        2x 2x 2x 2x 2x                 2x   2x                                   2x                 2x     2x     2x     2x 2x   2x 19x 19x 1x 1x 1x 19x 19x       1x 1x   1x 1x 1x 19x 19x   19x 19x 19x 19x 19x 19x   19x 19x 19x 19x 19x 19x 1x 1x 1x     1x 1x 1x     16x 16x 16x 16x       19x 19x 1x 1x 1x 14x 19x 17x 1x 1x   1x 1x 1x 17x 2x 2x     2x 2x 17x     13x 13x 19x 19x                     13x 13x 19x 19x 19x 19x 8x   5x 19x     19x 19x 19x 19x 19x 3x 1x 1x 1x   1x 2x 2x 2x 2x 2x 1x 1x   1x 1x 2x 2x 2x       377x 377x 377x 377x 2x 2x           2x 2x 2x 2x 2x 2x 19x           10x 10x 2294x 1x 1x 1x 10x 1x 1x   1x 1x 1x 1x 10x 10x     10x 10x     13x 13x 19x     13x 13x 3922x 3906x 13x   13x 13x 13x 19x 3935x 3935x 3935x 3935x 3935x 3935x 3935x 3935x 3935x     3935x     3935x 3935x 3935x 3935x 3935x 19x             19x             13x 13x 13x 13x         13x 13x 13x 10x 3x 13x 13x       13x 13x 13x 13x 13x 13x 13x 13x 19x 19x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 13x 13x 13x                                               16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 34x 55x 55x 34x 34x 32x 32x 34x 22616x 11127x 11127x 11127x 32x 32x 32x 32x 40x 32x 32x 14x 14x                               58x 58x 58x 58x 58x 58x 58x 58x 58x 58x 58x 58x 58x 58x 58x 58x 96x 96x 96x 96x 96x 133x 2x 2x 2x     131x 133x 130x     133x 133x 133x 95x 20x 20x 31x 20x 20x 20x 20x     20x 20x 20x 20x 20x 95x 95x 95x 95x 95x 58x 1x       56x 58x 58x     17x 17x 17x                     2x                           2x 6x 6x 6x 6x 6x 6x 6x 6x     24x 24x 6x 22x 6x 6x 6x 6x 4x 4x       4x                     13x 13x 13x 13x 13x 13x 13x   13x 13x 13x 13x 13x 13x 16x 16x 16x 9206x     9206x 9206x 2361x 16x       16x 10x 2294x 2294x 10x 10x 16x 16x 16x 16x 16x 16x 13x 13x 13x 13x 13x 13x 13x 13x 13x 13x 13x 13x 13x 13x         13x 13x   13x 13x     27x 27x 27x 27x       27x 27x 27x 27x 27x 27x 27x 27x 27x     13x 13x 13x 13x 13x 13x 13x 13x 13x 13x 13x 13x 13x 13x 13x 13x 13x 13x 13x 13x 13x 30x 30x 30x 30x 30x 30x 30x 30x 30x 30x 13x 13x 13x     2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 12x 12x 12x 12x 12x 12x 12x 12x 12x 2x 2x 2x                       5x                       5x 5x 5x 5x 3632x 3632x 3632x 3632x 3632x 3632x 3632x 3632x 7083x 3632x 3632x 3451x 3451x 3451x 3451x 3632x 5x   5x 3x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 7x 7x 7x 7x 5x 5x 2x 2x 2x 2x 2x 3x 5x 5x 5x 5x           85x 85x 85x   8x 8x 8x 8x 8x   2x 2x 2x 2x 2x     4x 4x 4x 4x 4x 4x     13x 13x 13x 13x 13x 13x 13x 13x     7214x 7214x 7213x 7213x     2x 2x        
/**
 * Calling peaks, run — in the annotations worker, beside the tracks.
 *
 * yapc's pipeline for one condition (`analysis/yapc.ts`, `analysis/idr.ts`), in
 * its order: the mean of the chosen tracks, its smoothed second derivative, the
 * stretches where that is negative, each track's score for every one of them,
 * the narrow ones dropped, the strongest kept, and — with a replicate — IDR over
 * the two rankings and the peaks under the threshold.
 *
 * ## Read a window at a time
 *
 * Every base of every ticked sequence is read, at the file's own resolution,
 * which on a mammalian genome is gigabytes of bigWig. So nothing is held whole:
 * each sequence is read in windows of {@link WINDOW} bases plus the 228 each
 * side the convolution reaches, each window is convolved and scanned and let
 * go, and the thread is handed back before the next — which is what keeps the
 * lanes this worker also draws moving, and what lets a Stop be read at all.
 * `ConcaveScan` carries what crosses a seam. The answer is bit for bit the one a
 * whole sequence in memory gives; the golden checks it both ways.
 *
 * Where the engine has WebAssembly with SIMD, a window's whole scan — pieces,
 * pooling, both convolutions, the concave mask and the regions — is one call
 * into yapc's kernel (`yapcKernel.ts`), three times the speed of the same
 * TypeScript and the same numbers to the last bit; the TypeScript below is what
 * runs where it cannot, and what the kernel is tested against.
 *
 * ## What comes out
 *
 * The peaks as an interval set — each with its summit as the thick part, which
 * is where yapc's own `_peaksall.bed` puts it — and a figure of the curvature
 * of every candidate, split by whether it made the set. On a real ATAC track
 * that figure is two populations, and with one track and no test it is how a
 * reader chooses a cut. A table per sequence is always there; one per peak is a
 * toggle, as FRiP's is, since on a mammalian genome it is a hundred thousand rows.
 */
 
import { yieldToEventLoop } from '../../async'
import { FeatureBuilder } from '../../annotations/featureset'
import { DETAIL_SEP, type TrackSamples } from '../../annotations/types'
import { IDR_BOX, IdrError, fitIdr, globalIdr, idrRanks, localIdr, scaledIdr, type IdrFit } from '../idr'
import {
  boolParam,
  idParam,
  numParam,
  strParam,
  tickedSequences,
  type TrackJobContext,
  type TrackTaskRunner,
} from '../job'
import { DEFAULT_IDR_THRESHOLD, hasReplicate } from '../tasks/peaks'
import type { CurveOutput, CurveSeries, IntervalsOutput, TableOutput, TaskOutput } from '../types'
import {
  ConcaveScan,
  YAPC_DEFAULTS,
  YAPC_IDR_THRESHOLDS,
  concaveMask,
  convolutionReach,
  convolveSame,
  peakBounds,
  pooledRuns,
  runsOver,
  scaledThreshold,
  StrongestRegions,
  yapcKernel,
  zeroRuns,
  type ConcaveRegions,
  type StrongestRegionsHeld,
  type YapcKernel,
} from '../yapc'
import { KernelMemoryError, KernelScan } from '../yapcKernel'
 
/**
 * Bases computed per read.
 *
 * A quarter of a megabase: small enough that one window's arithmetic is a few
 * tens of milliseconds, so the worker is never away from its lanes for longer,
 * and large enough that the 456-base halo read twice is under half a per cent.
 */
const WINDOW = 1 << 18
 
/** IDR's own floor: its command refuses to fit fewer peaks than this. */
const IDR_MIN_PEAKS = 20
 
/** The most values a violin is drawn from; beyond it they are thinned evenly by rank. */
const DRAWN = 20_000
 
/** Called and not: the colours a candidate is drawn in, by what became of it. */
const KEPT_COLOURS = ['#0072b2', '#009e73']
const CUT_COLOUR = '#999999'
 
export const runPeaks: TrackTaskRunner = async (job) => {
  const signal = idParam(job.params, 'signal')
  if (signal === null) {
    job.note('No track was chosen. Load a bigWig and pick it under Track.')
    return []
  }
  const replicate = hasReplicate(job.params) ? idParam(job.params, 'replicate') : null
  if (replicate === signal) {
    // Refused rather than run: IDR on a track and itself is two identical
    // rankings, which the model reads as perfect reproducibility — every peak
    // would pass, and the table would say so with a straight face.
    job.note(
      'The replicate chosen is the track being called, and a track cannot test itself. ' +
        'Choose a second track, or none.',
    )
    return []
  }
  const entries = replicate === null ? [signal] : [signal, replicate]
  const nameOf = (id: string): string => job.inputs.labels?.[id] ?? id
 
  const width = clampInt(numParam(job.params, 'width', YAPC_DEFAULTS.width), 1, 2000)
  const times = clampInt(numParam(job.params, 'times', YAPC_DEFAULTS.times), 0, 10)
  const minWidth = clampInt(numParam(job.params, 'minWidth', YAPC_DEFAULTS.minWidth), 1, 1e9)
  const halfWidth = clampInt(numParam(job.params, 'halfWidth', 0), 0, 1e9)
  const truncate = clampInt(numParam(job.params, 'truncate', YAPC_DEFAULTS.truncate), 1, 1e9)
  const threshold = thresholdOf(job)
 
  const lengths = job.inputs.contigLengths ?? {}
  const ticked = tickedSequences(job.params, 'sequences')
  const names = Object.keys(lengths).filter(
    (name) => lengths[name] > 0 && (ticked === null || ticked.includes(name)),
  )
  if (names.length === 0) {
    job.note(
      ticked !== null && ticked.length === 0
        ? 'No sequence is ticked, so there is nothing to call peaks on.'
        : 'This session knows no sequence lengths, so there is nothing to scan. Load a ' +
            'chrom.sizes, or the map itself.',
    )
    return []
  }
 
  // ---- the scan --------------------------------------------------------------
  const kernel = yapcKernel(width, times)
  const scan = (prune: boolean): Promise<Scanned | string> =>
    scanAll(job, entries, names, lengths, kernel, { minWidth, truncate, below: replicate === null, prune })
  let scanned = await scan(true)
  // A `NaN` score after a region was let go — only an infinite value in a track
  // gives one — leaves no order a subset of the regions can reproduce
  // (`StrongestRegions`), so the scan is run again holding every region.
  if (typeof scanned !== 'string' && !scanned.held.exact) scanned = await scan(false)
  if (typeof scanned === 'string') {
    job.note(scanned)
    return []
  }
  const { held, found, n, perSequence, absentOn } = scanned
  for (let t = 0; t < entries.length; t++) {
    if (absentOn[t].length === names.length) {
      job.note(
        `${nameOf(entries[t])} has none of the ${names.length === 1 ? 'sequence' : 'sequences'} ` +
          'ticked. Check that it and the assembly name their sequences the same way.',
      )
      return []
    }
    if (absentOn[t].length > 0) {
      job.note(
        `${nameOf(entries[t])} has no ${listOf(absentOn[t])}. yapc reads a track that lacks a ` +
          'sequence as zeros there, so it lowers the mean the regions are called on, and ranks ' +
          'every region there last by it.',
      )
    }
  }
 
  // ---- regions to candidates --------------------------------------------------
  const readAs =
    `${kernel.values.length}-tap kernel: a ${width.toLocaleString()} bp window, ` +
    `${times} ${times === 1 ? 'pass' : 'passes'}`
  if (n === 0) {
    job.note(
      `No concave region at least ${minWidth.toLocaleString()} bp wide on ` +
        `${names.length === 1 ? names[0] : `${names.length} sequences`} (${readAs}). A wider ` +
        'smoothing window finds broader shapes; a narrower minimum keeps smaller ones.',
    )
    return []
  }
  // Every candidate that could be among the strongest, and — in yapc's IDR order,
  // by sequence name, then position — the strongest. Everything below is indexed
  // through `tested`.
  const { contig, starts, ends, modes, flat: flats, scores, tested } = held
  job.note(
    `${found.toLocaleString()} concave ${found === 1 ? 'region' : 'regions'} on ` +
      `${names.length.toLocaleString()} ${names.length === 1 ? 'sequence' : 'sequences'} ` +
      `(${readAs}), ${n.toLocaleString()} at least ${minWidth.toLocaleString()} bp wide` +
      (tested.length < n
        ? `, of which the ${tested.length.toLocaleString()} strongest by each track’s curvature ` +
          'rank were kept.'
        : '.'),
  )
 
  // ---- IDR ----------------------------------------------------------------
  let fit: IdrFit | null = null
  let local: Float64Array | null = null
  let global: Float64Array | null = null
  const passes = new Uint8Array(tested.length).fill(1)
  if (replicate !== null) {
    if (tested.length < IDR_MIN_PEAKS) {
      job.note(
        `IDR needs at least ${IDR_MIN_PEAKS} peaks to fit — its own command refuses fewer — and ` +
          `there ${tested.length === 1 ? 'is 1' : `are ${tested.length}`}. They are reported ` +
          'untested, ranked by curvature.',
      )
    } else {
      const r1 = idrRanks(pick(scores[0], tested))
      const r2 = idrRanks(pick(scores[1], tested))
      const ties = tiesIn(pick(scores[0], tested)) + tiesIn(pick(scores[1], tested))
      if (ties > 0) {
        job.note(
          `${ties.toLocaleString()} ${ties === 1 ? 'score ties' : 'scores tie'} with another: ` +
            'ranked by position, where yapc breaks a tie at random.',
        )
      }
      try {
        fit = await fitIdr(r1, r2, {
          between: async (rounds) => {
            // No total, because IDR's fit has none: it ends when it converges.
            // A bar filling towards a limit of three thousand rounds that it
            // reaches a tenth of would be a picture of nothing.
            job.progress(0, 0, `IDR, round ${rounds}`)
            await yieldToEventLoop()
            job.throwIfCancelled()
          },
        })
      } catch (error) {
        if (error instanceof IdrError) {
          throw new Error(`IDR could not fit these two tracks, as idr itself could not: ${error.message}`)
        }
        throw error
      }
      local = localIdr(fit.theta, r1, r2)
      global = globalIdr(local)
      const cut = scaledThreshold(threshold)
      for (let k = 0; k < tested.length; k++) passes[k] = scaledIdr(global[k]) >= cut ? 1 : 0
      job.note(fitNote(fit, tested.length, countOf(passes), threshold))
    }
  } else {
    // A stretch of constant signal is concave by yapc's test — `d2 < 10⁻¹⁰`, and
    // flat is 0 or −10⁻¹¹ — and scores next to nothing. yapc never reports one:
    // IDR ranks it last in both replicates and drops it. With no IDR that cut
    // has to be made here, and the region's own shape makes it — `ConcaveScan`'s
    // `flat` — rather than a threshold on a score.
    let flat = 0
    for (let k = 0; k < tested.length; k++) {
      if (flats[tested[k]] === 0) continue
      passes[k] = 0
      flat++
    }
    if (flat > 0) {
      job.note(
        `${flat.toLocaleString()} of the candidates ${flat === 1 ? 'is' : 'are'} flat — the ` +
          'signal constant across a whole kernel’s width inside, which yapc’s test counts as ' +
          `concave — and ${flat === 1 ? 'is' : 'are'} left out, as IDR would leave ` +
          `${flat === 1 ? 'it' : 'them'} out of two replicates.`,
      )
    }
    job.note(
      'One track, so no test of significance: yapc’s cut is IDR between two replicates. These ' +
        'are the strongest concave regions by curvature — choose a replicate to test them, or ' +
        'lower “keep the strongest” to the upper of the two populations in the figure.',
    )
  }
 
  // ---- outputs ------------------------------------------------------------
  const order = new Map(names.map((name, i) => [name, i]))
  const called: number[] = []
  for (let k = 0; k < tested.length; k++) if (passes[k] === 1) called.push(k)
  // The lane in the assembly's own order, which is the order a BED of it is
  // written in; `tested` is in yapc's, by name.
  called.sort(
    (a, b) =>
      (order.get(contig[tested[a]]) ?? 0) - (order.get(contig[tested[b]]) ?? 0) ||
      starts[tested[a]] - starts[tested[b]],
  )
 
  const build = new FeatureBuilder()
  let clamped = 0
  let named = 0
  for (const k of called) {
    const i = tested[k]
    const [from, to] = peakBounds(starts[i], ends[i], modes[i], halfWidth, lengths[contig[i]])
    if (halfWidth > 0 && (from !== modes[i] - halfWidth || to !== modes[i] + halfWidth + 1)) clamped++
    const detail = [`summit ${(modes[i] + 1).toLocaleString('en-US')}`]
    detail.push(
      `curvature ${entries.map((_, t) => significant(scores[t][i])).join(' / ')}`,
    )
    if (global !== null) detail.push(`IDR ${significant(global[k])}`)
    build.add(contig[i], from, to, {
      // An id in the name column, in genomic order, as a peak caller's BED
      // carries one — what a reader cites a peak by and what a click shows.
      name: `peak_${++named}`,
      // What a peak's strength *is* in each mode: its IDR where there was a
      // test, as yapc's BEDs carry it, and its curvature where there was not.
      score: global !== null ? scaledIdr(global[k]) : scores[0][i],
      cds: [modes[i], modes[i] + 1],
      detail: detail.join(DETAIL_SEP),
    })
  }
  if (clamped > 0) {
    job.note(
      `${clamped.toLocaleString()} ${clamped === 1 ? 'peak reaches' : 'peaks reach'} past a ` +
        'sequence end at this half-width and ' +
        `${clamped === 1 ? 'is' : 'are'} cut at it; yapc writes the coordinate past the end.`,
    )
  }
  if (called.length === 0) {
    job.note(
      `No peak passes IDR ≤ ${threshold}. A looser threshold keeps more; two tracks that are not ` +
        'replicates of one experiment will pass few at any.',
    )
  }
 
  const outputs: TaskOutput[] = []
  if (called.length > 0) {
    outputs.push({
      kind: 'intervals',
      // Per track, so a call on another track is another row rather than this
      // one replaced — the workspace keeps one row per output id — and spelled
      // safely, since the shell names its files after it and there an entry is a
      // path.
      id: `peaks-${entries.map((entry) => entry.replace(/[^A-Za-z0-9_]+/g, '_')).join('-')}`,
      label:
        replicate === null
          ? `Peaks · ${nameOf(signal)} · no IDR`
          : `Peaks · ${nameOf(signal)} + ${nameOf(replicate)} · IDR ≤ ${threshold}`,
      payload: { kind: 'features', data: build.finish() },
      noun: { one: 'peak', many: 'peaks' },
      // A track directly under the coverage it was called on, which is where a
      // peak is checked: box against bump (§18.7). On the map a peak is a place
      // — narrower than a bin of any map it is drawn on.
      lane: { under: { entry: signal } },
      display: 'lane',
      look: 'point',
    } satisfies IntervalsOutput)
  }
  const figure = curvatureFigure(entries.map(nameOf), scores, tested, passes, replicate !== null, flats, held.below)
  if (figure !== null) outputs.push(figure)
  outputs.push(sequenceTable(names, lengths, perSequence, called.map((k) => contig[tested[k]])))
  if (fit !== null && global !== null) outputs.push(thresholdTable(global, threshold))
  if (boolParam(job.params, 'perPeak', false)) {
    outputs.push(
      peakTable({
        names: entries.map(nameOf),
        contig,
        starts,
        ends,
        modes,
        scores,
        tested,
        passes,
        local,
        global,
        replicate: replicate !== null,
      }),
    )
  }
  job.progress(1, 1, 'done')
  return outputs
}
 
/** Every sequence scanned, and what of its regions the cut can use. */
interface Scanned {
  /** The candidates that could be among the strongest, and the strongest. */
  held: StrongestRegionsHeld
  /** Concave regions found, and those at least the minimum wide. */
  found: number
  n: number
  perSequence: Map<string, { regions: number; wide: number }>
  /** Per track, the sequences it does not have. */
  absentOn: string[][]
}
 
/**
 * Every ticked sequence, scanned in turn, its regions counted and those wide
 * enough handed to the cut as soon as it is done.
 *
 * Only the regions that can still be among the strongest are held from one
 * sequence to the next (`StrongestRegions`): every region of the genome is
 * scored and ranked as yapc ranks it, but a mouse genome's 6.5 million of them
 * are never in memory at once — which they were, and which was most of the
 * memory a genome-wide call took (§18.8).
 */
async function scanAll(
  job: TrackJobContext,
  entries: readonly string[],
  names: readonly string[],
  lengths: Readonly<Record<string, number>>,
  kernel: YapcKernel,
  options: { minWidth: number; truncate: number; below: boolean; prune: boolean },
): Promise<Scanned | string> {
  const totalBases = names.reduce((sum, name) => sum + lengths[name], 0)
  let doneBases = 0
  const strongest = new StrongestRegions(entries.length, options.truncate, {
    prune: options.prune,
    below: options.below,
  })
  const absentOn: string[][] = entries.map(() => [])
  const perSequence = new Map<string, { regions: number; wide: number }>()
  let found = 0
  let n = 0
  for (const name of names) {
    const scanned = await scanSequence(job, entries, name, lengths[name], kernel, (bases) => {
      doneBases += bases
      job.progress(doneBases, totalBases, name)
    })
    if (typeof scanned === 'string') return scanned
    const { regions } = scanned
    let wide = 0
    for (let i = 0; i < regions.starts.length; i++) {
      if (regions.ends[i] - regions.starts[i] < options.minWidth) continue
      wide++
      strongest.add(regions, i)
    }
    found += regions.starts.length
    n += wide
    perSequence.set(name, { regions: regions.starts.length, wide })
    scanned.absent.forEach((gone, t) => {
      if (gone) absentOn[t].push(name)
    })
  }
  return { held: strongest.finish(), found, n, perSequence, absentOn }
}
 
/**
 * One sequence, read and scanned a window at a time.
 *
 * Returns the sequence's concave regions and which tracks do not have it at
 * all, or a sentence where a track refused to be read here — which only a
 * track kind with a limit on what it will answer can do, and which is then the
 * whole run's answer rather than a hole in it.
 *
 * Each window is scanned in yapc's WebAssembly kernel where this engine runs it
 * (`yapcKernel.ts`), and in the TypeScript below where it does not, or where
 * `engine` asks for it — the two give the same regions to the last bit, which is
 * what the tests ask both for. A kernel that runs out of memory part-way leaves
 * the sequence to the TypeScript, from its first base.
 */
export async function scanSequence(
  job: TrackJobContext,
  entries: readonly string[],
  name: string,
  length: number,
  kernel: YapcKernel,
  report: (bases: number) => void,
  engine: 'auto' | 'js' = 'auto',
): Promise<{ regions: ConcaveRegions; absent: boolean[] } | string> {
  const { left, right } = convolutionReach(kernel, length)
  const absent = entries.map(() => false)
  const fast = engine === 'js' ? null : KernelScan.open(kernel, name, length, entries.length, WINDOW)
  let scan: ConcaveScan | null = null
  let done = 0
  try {
    for (let from = 0; from < length; from += WINDOW) {
      const to = Math.min(length, from + WINDOW)
      const lo = Math.max(0, from - left)
      const hi = Math.min(length, to + right)
      const answers: Array<TrackSamples | null> = []
      for (let t = 0; t < entries.length; t++) {
        if (absent[t]) {
          answers.push(null)
          continue
        }
        // Base resolution: one base per pixel is what selects the file's own
        // intervals rather than a zoom level's means (§8.2).
        const set = await job.sample(entries[t], [{ contig: name, start: lo, end: hi }], 1)
        if (set.note !== undefined) return set.note
        const answer = set.regions[0]
        // Asked about a sequence the track does not name: it has none of it,
        // which yapc reads as zeros (`zeroRuns`).
        if (answer === undefined) absent[t] = true
        answers.push(answer ?? null)
      }
      if (fast !== null) fast.window(from, to, lo, hi, answers)
      else {
        const runs = answers.map((answer) =>
          answer === null ? zeroRuns(lo, hi) : runsOver(answer.starts, answer.ends, answer.values, lo, hi),
        )
        scan ??= new ConcaveScan(name, length, absent)
        const flat = new Uint8Array(to - from)
        const pooled = convolveSame(pooledRuns(runs, lo, hi), kernel, length, from, to, flat)
        // Each track's own second derivative is read only inside a concave region
        // of the pooled one, to score it — half the bases — so it is summed only there.
        const concave = entries.length === 1 ? null : concaveMask(pooled)
        const own =
          concave === null ? [pooled] : runs.map((r) => convolveSame(r, kernel, length, from, to, undefined, concave))
        scan.feed(from, pooled, own, flat)
      }
      report(to - from)
      done += to - from
      await yieldToEventLoop()
      job.throwIfCancelled()
    }
  } catch (error) {
    if (!(error instanceof KernelMemoryError)) throw error
    report(-done)
    return scanSequence(job, entries, name, length, kernel, report, 'js')
  }
  if (fast !== null) return { regions: fast.finish(), absent }
  return { regions: (scan ?? new ConcaveScan(name, length, absent)).finish(), absent }
}
 
/** The IDR threshold asked for, as one of yapc's six; the default otherwise, and said. */
function thresholdOf(job: TrackJobContext): number {
  const asked = Number(strParam(job.params, 'idr', DEFAULT_IDR_THRESHOLD))
  if (YAPC_IDR_THRESHOLDS.includes(asked)) return asked
  if (hasReplicate(job.params)) {
    job.note(
      `An IDR threshold of ${strParam(job.params, 'idr', '')} is not one yapc writes; ` +
        `${DEFAULT_IDR_THRESHOLD} was used.`,
    )
  }
  return Number(DEFAULT_IDR_THRESHOLD)
}
 
/** IDR's four parameters, in its order, as a note names them. */
const PARAMETERS = ['mean', 'spread', 'correlation', 'reproducible share'] as const
 
/**
 * What the fit found, in a sentence — and, where it ran into the edge of the
 * box IDR keeps its parameters in, what that means.
 *
 * Two tracks that are not replicates of one experiment drive the EM out of the
 * box, and the coordinate ascent it falls back on then parks one parameter or
 * more against it — the correlation at its floor, the reproducible share at its
 * ceiling, the mean at zero — and every edge reached is named. The IDRs that
 * come out are IDR's answer and are reported, but they are not a statement
 * about reproducibility, and a reader who chose an ATAC track and a ChIP track
 * has to be told so rather than handed a peak set.
 */
export function fitNote(fit: IdrFit, tested: number, passed: number, threshold: number): string {
  const [mu, sigma, rho, p] = fit.theta
  const said =
    `IDR fitted over ${tested.toLocaleString()} peaks in ${fit.rounds.toLocaleString()} rounds: ` +
    `μ ${mu.toFixed(2)}, σ ${sigma.toFixed(2)}, ρ ${rho.toFixed(2)}, reproducible share ` +
    `${p.toFixed(2)}; ${passed.toLocaleString()} pass IDR ≤ ${threshold}.`
  const edges: string[] = []
  const bound = (value: number): string => (Number.isInteger(value) ? String(value) : value.toFixed(2))
  fit.theta.forEach((value, j) => {
    // Within what the coordinate ascent's line search resolves, scaled to the
    // bound, since a σ of 19.9999 has hit a ceiling of 20.
    const [low, high] = [IDR_BOX.min[j], IDR_BOX.max[j]]
    if (value <= low + 1e-4 * Math.max(1, low)) {
      edges.push(`the ${PARAMETERS[j]} at its floor of ${bound(low)}`)
    } else if (value >= high - 1e-4 * Math.max(1, high)) {
      edges.push(`the ${PARAMETERS[j]} at its ceiling of ${bound(high)}`)
    }
  })
  if (edges.length === 0) return said
  return (
    `${said} The fit ended at the edge of IDR’s parameter box — ${edges.join(', ')} — ` +
    'which is where two tracks that do not rank their peaks like replicates of one ' +
    'experiment put it. Read these IDRs as that, not as reproducibility.'
  )
}
 
/**
 * The curvature of every candidate, as violins split by what became of it.
 *
 * On a log axis, because the score spans six decades and the question — are
 * there two populations, and where does the cut fall between them — is a
 * question about its logarithm. A candidate whose score is not positive (a flat
 * stretch scores about 10⁻¹¹, and a track missing the sequence −∞) cannot sit on
 * that axis and is left out of the picture, not the peak set.
 */
function curvatureFigure(
  names: readonly string[],
  scores: readonly Float64Array[],
  tested: Int32Array,
  passes: Uint8Array,
  withIdr: boolean,
  flats: Uint8Array,
  /** Track 0's curvature of every candidate the cut let go before the end, where there is no IDR. */
  below: Float64Array,
): CurveOutput | null {
  const series: CurveSeries[] = []
  const inTest = new Uint8Array(flats.length)
  for (const i of tested) inTest[i] = 1
  for (let t = 0; t < scores.length; t++) {
    const kept: number[] = []
    const cut: number[] = []
    tested.forEach((i, k) => {
      const value = scores[t][i]
      // A flat region is not a candidate where there is no IDR to drop it, and
      // its score of 10⁻⁷ would give the axis five decades of nothing.
      if (!(value > 0) || (!withIdr && flats[i] === 1)) return
      if (passes[k] === 1) kept.push(value)
      else cut.push(value)
    })
    // Below the truncation: candidates that never reached a test — those held to
    // the end, and those let go on the way. A violin is drawn from its values
    // sorted, so which came first does not matter.
    if (!withIdr) {
      for (let i = 0; i < flats.length; i++) {
        if (inTest[i] === 0 && scores[t][i] > 0 && flats[i] === 0) cut.push(scores[t][i])
      }
      for (const value of below) cut.push(value)
    }
    const prefix = scores.length === 1 ? '' : `${names[t]} · `
    const keptName = withIdr ? `${prefix}passes IDR` : `${prefix}peaks`
    const cutName = withIdr ? `${prefix}fails IDR` : `${prefix}below the cut`
    if (kept.length > 0) series.push(violin(keptName, kept, KEPT_COLOURS[t] ?? KEPT_COLOURS[0]))
    if (cut.length > 0) series.push(violin(cutName, cut, CUT_COLOUR))
  }
  if (series.length === 0) return null
  const all = series.flatMap((entry) => Array.from(entry.y))
  const low = quantile(all, 0.01)
  let high = Number.NEGATIVE_INFINITY
  for (const value of all) if (value > high) high = value
  return {
    kind: 'curve',
    id: 'peaks-curvature',
    label: 'Curvature of every candidate',
    series,
    xLabel: '',
    yLabel: 'Curvature (yapc score)',
    xScale: 'linear',
    yScale: 'log',
    // Opened on the candidates, from the decade below their first percentile to
    // the decade above the strongest — whole decades, since a log axis whose
    // end reads 8.25623 is an axis labelled by accident — rather than on the odd
    // score far below the rest; zoomed out, those are there.
    ...(Number.isFinite(low) && low > 0 && high > low
      ? { yDomain: [10 ** Math.floor(Math.log10(low)), 10 ** Math.ceil(Math.log10(high))] as [number, number] }
      : {}),
  }
}
 
/** One violin, thinned evenly by rank past {@link DRAWN}. */
function violin(name: string, values: number[], colour: string): CurveSeries {
  const sorted = Float64Array.from(values).sort()
  let drawn = sorted
  if (sorted.length > DRAWN) {
    drawn = new Float64Array(DRAWN)
    for (let i = 0; i < DRAWN; i++) drawn[i] = sorted[Math.round((i * (sorted.length - 1)) / (DRAWN - 1))]
  }
  return {
    name,
    x: Float64Array.from(drawn, (_, i) => i),
    y: drawn,
    colour,
    n: values.length,
    mark: 'violin',
  }
}
 
/** Per sequence: how much it had in it, and how many peaks came out. */
function sequenceTable(
  names: readonly string[],
  lengths: Readonly<Record<string, number>>,
  perSequence: ReadonlyMap<string, { regions: number; wide: number }>,
  calledOn: readonly string[],
): TableOutput {
  const peaks = new Map<string, number>()
  for (const name of calledOn) peaks.set(name, (peaks.get(name) ?? 0) + 1)
  return {
    kind: 'table',
    id: 'peaks-sequences',
    label: 'Per sequence',
    columns: [
      { key: 'sequence', label: 'Sequence', kind: 'text' },
      { key: 'length', label: 'Length', kind: 'number' },
      { key: 'regions', label: 'Concave regions', kind: 'number' },
      { key: 'wide', label: 'Wide enough', kind: 'number' },
      { key: 'peaks', label: 'Peaks', kind: 'number' },
      { key: 'density', label: 'Peaks per Mb', kind: 'number', digits: 1 },
    ],
    rows: names.map((name) => {
      const counted = perSequence.get(name) ?? { regions: 0, wide: 0 }
      const called = peaks.get(name) ?? 0
      return {
        sequence: name,
        length: lengths[name],
        regions: counted.regions,
        wide: counted.wide,
        peaks: called,
        density: (called / lengths[name]) * 1e6,
      }
    }),
  }
}
 
/** How many peaks each of yapc's six thresholds keeps — its six BED files, as rows. */
function thresholdTable(global: Float64Array, drawn: number): TableOutput {
  const tested = global.length
  return {
    kind: 'table',
    id: 'peaks-idr',
    label: 'IDR thresholds',
    columns: [
      { key: 'threshold', label: 'IDR ≤', kind: 'text' },
      { key: 'peaks', label: 'Peaks', kind: 'number' },
      { key: 'share', label: 'Share of tested', kind: 'number', digits: 3 },
      { key: 'drawn', label: 'Drawn', kind: 'text' },
    ],
    rows: YAPC_IDR_THRESHOLDS.map((threshold) => {
      const cut = scaledThreshold(threshold)
      let passing = 0
      for (let k = 0; k < tested; k++) if (scaledIdr(global[k]) >= cut) passing++
      return {
        threshold: String(threshold),
        peaks: passing,
        share: passing / tested,
        drawn: threshold === drawn ? 'yes' : '',
      }
    }),
  }
}
 
/**
 * One row per peak tested: yapc's `.tsv`.
 *
 * Starts and summits are held **0-based**, as yapc writes them and as the lane
 * and a BED saved from it keep them, and printed 1-based like every coordinate
 * a reader reads (`TableColumn.coordinate`, §26.1). They used to be held
 * 1-based, which printed right and saved a TSV whose sequence, start and end
 * this app's own reader takes as BED's (§8.3) — every peak dropped back on a
 * map a base to the right of where it was called.
 */
function peakTable(options: {
  names: readonly string[]
  contig: readonly string[]
  starts: Int32Array
  ends: Int32Array
  modes: Int32Array
  scores: readonly Float64Array[]
  tested: Int32Array
  passes: Uint8Array
  local: Float64Array | null
  global: Float64Array | null
  replicate: boolean
}): TableOutput {
  const { names, contig, starts, ends, modes, scores, tested, passes, local, global } = options
  const rows: Array<Record<string, string | number>> = []
  tested.forEach((i, k) => {
    const row: Record<string, string | number> = {
      sequence: contig[i],
      start: starts[i],
      end: ends[i],
      summit: modes[i],
      width: ends[i] - starts[i],
    }
    scores.forEach((column, t) => {
      row[`curvature${t}`] = column[i]
    })
    if (local !== null && global !== null) {
      row.local = local[k]
      row.idr = global[k]
      row.passes = passes[k] === 1 ? 'yes' : 'no'
    }
    rows.push(row)
  })
  // Strongest first: by IDR where there was a test, by curvature where not.
  if (global !== null) rows.sort((a, b) => Number(a.idr) - Number(b.idr))
  else rows.sort((a, b) => Number(b.curvature0) - Number(a.curvature0))
  return {
    kind: 'table',
    id: 'peaks-list',
    label: options.replicate ? 'Every peak tested' : 'Every peak',
    columns: [
      { key: 'sequence', label: 'Sequence', kind: 'text' },
      { key: 'start', label: 'Start', kind: 'number', coordinate: 'start' },
      { key: 'end', label: 'End', kind: 'number', coordinate: 'end' },
      { key: 'summit', label: 'Summit', kind: 'number', coordinate: 'start', hint: 'The most concave base.' },
      { key: 'width', label: 'Width', kind: 'number' },
      ...names.map((name, t) => ({
        key: `curvature${t}`,
        label: names.length === 1 ? 'Curvature' : `Curvature · ${name}`,
        kind: 'number' as const,
        digits: 2,
      })),
      ...(local !== null
        ? [
            { key: 'local', label: 'Local IDR', kind: 'number' as const, digits: 4, scientific: true },
            { key: 'idr', label: 'IDR', kind: 'number' as const, digits: 4, scientific: true },
            { key: 'passes', label: 'Passes', kind: 'text' as const },
          ]
        : []),
    ],
    rows,
  }
}
 
// ---------------------------------------------------------------------------
// Small things
// ---------------------------------------------------------------------------
 
function clampInt(value: number, min: number, max: number): number {
  return Math.min(max, Math.max(min, Math.round(value)))
}
 
function pick(values: Float64Array, at: Int32Array): Float64Array {
  const out = new Float64Array(at.length)
  for (let k = 0; k < at.length; k++) out[k] = values[at[k]]
  return out
}
 
function countOf(flags: Uint8Array): number {
  let total = 0
  for (const flag of flags) total += flag
  return total
}
 
/** How many values equal another one, which IDR's ranking has to order somehow. */
function tiesIn(values: Float64Array): number {
  const sorted = Float64Array.from(values).sort()
  let ties = 0
  for (let i = 1; i < sorted.length; i++) if (sorted[i] === sorted[i - 1]) ties++
  return ties
}
 
/** The `p`-quantile of some values, linearly interpolated; `NaN` for none. */
function quantile(values: readonly number[], p: number): number {
  if (values.length === 0) return Number.NaN
  const sorted = Float64Array.from(values).sort()
  const at = p * (sorted.length - 1)
  const lower = Math.floor(at)
  const upper = Math.min(sorted.length - 1, lower + 1)
  return sorted[lower] + (at - lower) * (sorted[upper] - sorted[lower])
}
 
/** Four significant figures, grouped: `1,235`, `0.001234`. */
function significant(value: number): string {
  if (!Number.isFinite(value)) return value > 0 ? '∞' : value < 0 ? '−∞' : '—'
  return Number(value.toPrecision(4)).toLocaleString('en-US', { maximumSignificantDigits: 4 })
}
 
/** `I, II and III`, or the first three and a count. */
function listOf(names: readonly string[]): string {
  if (names.length === 1) return names[0]
  if (names.length <= 3) return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`
  return `${names.slice(0, 3).join(', ')} and ${(names.length - 3).toLocaleString()} more`
}