All files / src/analysis runner.ts

94.38% Statements 655/694
86.11% Branches 310/360
94.54% Functions 52/55
94.38% Lines 655/694

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 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 1436 1437 1438 1439 1440 1441 1442 1443 1444 1445 1446 1447 1448 1449 1450 1451 1452 1453 1454 1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471 1472 1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 1491 1492 1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 1518 1519 1520 1521 1522 1523 1524 1525 1526 1527 1528 1529 1530 1531 1532 1533 1534 1535 1536 1537 1538 1539 1540 1541 1542 1543 1544 1545 1546 1547 1548 1549 1550 1551 1552 1553 1554 1555 1556 1557 1558 1559 1560 1561 1562 1563 1564 1565 1566 1567 1568 1569 1570 1571 1572 1573 1574 1575 1576 1577 1578 1579 1580 1581 1582 1583 1584 1585 1586 1587 1588 1589 1590 1591 1592 1593 1594 1595 1596 1597 1598 1599 1600 1601 1602 1603 1604 1605 1606 1607 1608 1609 1610 1611 1612 1613 1614 1615 1616 1617 1618 1619 1620 1621 1622 1623 1624 1625 1626 1627 1628 1629 1630 1631 1632 1633 1634 1635 1636 1637 1638 1639 1640 1641 1642 1643 1644 1645 1646 1647 1648 1649 1650 1651 1652 1653 1654 1655 1656 1657 1658 1659 1660 1661 1662 1663 1664 1665 1666 1667 1668 1669 1670 1671 1672 1673 1674 1675 1676 1677 1678 1679 1680 1681 1682 1683 1684 1685 1686 1687 1688 1689 1690 1691 1692 1693 1694 1695 1696 1697 1698 1699 1700 1701 1702 1703 1704 1705 1706 1707 1708 1709 1710 1711 1712 1713 1714 1715 1716 1717 1718 1719 1720 1721 1722 1723 1724 1725 1726 1727 1728 1729 1730 1731 1732 1733 1734 1735 1736 1737 1738 1739 1740 1741 1742 1743 1744 1745 1746 1747 1748 1749 1750 1751 1752 1753 1754 1755 1756 1757 1758 1759 1760 1761 1762 1763 1764 1765 1766 1767 1768 1769 1770 1771 1772 1773 1774 1775 1776 1777 1778 1779 1780 1781                                                                            2x 2x     2x 2x 2x                         2x   2x 2x 2x 2x 2x                                                                                                                                                                                                                                                                                           2x 44x 44x   44x 44x 44x 44x 61x 61x 40x 40x               61x 61x 18x 18x 18x 43x 43x   40x 40x 40x 39x 38x 37x 37x 37x 35x 34x 33x 33x   33x     33x       30x       30x 29x   40x   30x 30x 29x 29x 29x   29x 29x 29x 29x 29x 29x 29x                           2x                                                     2x 215x 215x                         2x     3x 3x 3x                   84x 84x 84x 84x 84x 84x 84x 84x 84x 84x       84x     84x 84x 84x       84x     84x 84x                     172x 172x 172x     73x 73x 73x                                                                                                                                                                                                         2x 2x 126x 126x   126x   126x                                         126x                                               126x   126x 126x 126x   126x   126x 126x 126x 126x               2x 126x 126x 126x   2x 4101x 4101x                     2x 1x 1x 1x 1x     2x                     2x 9x 9x 9x                     2x 20x 20x 20x 20x                       2x 18x 18x 18x 18x 18x 18x                   2x 7x 7x 7x 7x 3x 3x 1x 1x 1x       7x 3x 3x 2x 2x 2x 7x 7x   2x 187x 187x 130x 130x 187x     2x 6x 6x                     2x 77x 77x                                                         2x 12x 1x 1x 1x 11x 12x 1x 1x 1x       10x 10x 30x 10x 12x   2x 109x 109x 109x 109x 109x 109x     109x 109x                   109x 109x   109x 109x 5x 5x 5x           104x 90x 14x                 109x 77x 76x 76x 76x 77x         77x 103x 103x 103x 103x 103x 103x 103x 103x 103x 103x 103x 109x                 2x 17x 17x                                 2x 2x 2x                 2x 8x 8x                     2x                               2x 76x 76x 76x 76x 76x       76x             76x 76x   76x       76x   76x       76x 76x 76x                                   2x       16x 16x 2x 2x 2x 2x 16x 16x       11x 11x 11x 5x 16x 16x                 2x 102x 102x 102x 102x 12x 12x 12x 102x       102x 102x 102x 102x 102x           2x 197x 165x 165x 99x 99x 94x 94x 99x 197x                   2x 265x 193x 72x 265x   2x 99x 99x             99x 99x 99x 99x 99x 99x 99x 99x 99x 99x 99x 99x 99x         99x 86x 84x   97x 99x 105x 105x 104x           104x 104x 104x 8x 3x 105x 2x 2x 2x   102x                         105x 79x         79x 105x 18x 18x 18x 18x 79x 79x 104x 73x 73x 73x       73x 73x 73x       73x 73x 73x 73x 73x 73x 73x 73x 73x 73x 73x 73x 105x         6x 6x 6x 105x 98x 21x 21x 17x 4x       21x 21x 21x 17x 4x 99x 94x           94x 94x 99x                               2x 5x 5x 3x 3x 3x 3x 3x 3x 5x                   2x 99x 99x 99x 99x 99x 99x 99x 99x                                                     2x 172x 172x 31x 172x                   2x             86x           86x 2x 2x 2x 2x             84x 84x 84x 63x 63x 63x 63x 63x 63x 63x 63x       63x 63x 63x 63x 63x 86x 86x 86x 86x 86x 86x 63x 86x   2x 102x 102x 102x 102x 102x 102x 102x 102x                               102x 103x 103x 15x 103x 103x 102x 102x 102x 102x 102x 102x 102x 102x   102x 1x 1x 1x 1x 1x 1x 102x 102x 102x       102x 102x 102x 102x   102x 102x                         14x 14x 2x 2x   2x 2x 12x 12x 12x 12x 102x 88x 88x 88x 68x 68x                           80x 80x 80x                           102x 102x 102x 102x 102x 102x   2x 99x 99x 99x                                               2x 99x 99x 99x 99x 107x       107x 107x 107x 9x 9x     9x 9x 8x 8x 107x 107x 107x 107x 99x 99x 99x                           2x 102x 102x 102x 102x 102x 102x 102x 102x       102x   102x 31x 31x 31x 31x 30x 31x 25x 25x 21x 5x 5x 5x                     5x 2x 3x 2x 2x             5x 5x 5x 6x         102x 102x 31x 2x 31x 31x   102x 102x 102x 102x 102x           102x   14x 14x 14x 102x 102x       102x               102x 2x 2x 102x                   102x 3x 3x 3x 9x 6x 6x 6x 6x 9x 9x     9x       9x 9x 9x 3x     3x 3x           102x                                         102x       102x 102x           3x 3x 102x 102x                     2x 102x           102x 102x 102x           2x 488x 488x 487x 488x   2x 487x 1x 1x 1x 487x       487x 487x                 2x 1x 1x       1x 1x     1x 1x 2x                                           2x 24x 1x 1x 23x 24x 24x                     4x 4x 4x                     4x 4x 4x 4x 3x 3x 3x   4x                       4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x         4x     4x 4x 4x 4x  
/**
 * The main thread's half of a job: one queue, one worker, one cache.
 *
 * A job is not a frame — §18's opening paragraph — and everything awkward about
 * this module follows from that one sentence. A frame belongs to the view that
 * drew it and dies with it. A job outlives the view that started it, so this
 * object belongs to the **session** and nothing here may name a mounted view:
 * pan, zoom, open a chromosome, switch tabs and come back, and the scan is still
 * running because the thing that knows about it never moved (§18.3).
 *
 * **One job at a time, queued, FIFO.** Not a limit reluctantly accepted. Two
 * concurrent scans over one byte source thrash it and *both* finish later than
 * they would have in sequence, so the queue is faster than the parallelism it
 * refuses as well as being kinder to the range cache.
 *
 * **The worker is created on the first `run` and never before.** Folding the
 * Analysis panel open to read the list of tasks must cost no thread, no mount
 * and no wasm — the promise `analysis.worker.ts` keeps on its side by leaving
 * `getH5()` out of module scope, and the one a client constructed alongside the
 * session would quietly break here.
 *
 * **A composite is a chain, and the chain is visible.** A task that declares
 * `requires` gets each required result computed first, at that task's own
 * defaults — except for what the leaf's own form asked on the step's behalf
 * (`upstreamParams`), which is laid over them, or a step the leaf says to leave
 * out — and handed down in `inputs.upstream` (§18.2). A required result
 * already in the cache is reused when its parameters *and* its framing match —
 * {@link sameRecipe} is that comparison, and it is exported so a test can hold
 * it to the claim. While the chain runs the progress note names the step, which
 * is the only honest answer to "why is this taking so long".
 *
 * **A result is a record and its framing is a snapshot** taken at the instant
 * Run was pressed (§18.4). The framing therefore travels with the queued job
 * rather than being read again at dispatch: a reader who presses Run and then
 * pans has asked for the picture they were looking at, and gets it.
 */
 
import type { AnnotationPayload, FeatureSet, PairSet } from '../annotations/types'
import { editFingerprint, type ReferenceEdit } from '../genome/edit'
import { isCancellation, type Cancellable } from '../worker/client'
import type { OrientationSummary, PairsBinning, SourceSpec } from '../worker/protocol'
import type { PairStats } from '../pairs/stats'
import { abortError, idParam, listParam, runnerParams } from './job'
import { taskById } from './registry'
import {
  defaultParams,
  readsMap,
  type AnalysisContext,
  type AnalysisFraming,
  type AnalysisTask,
  type EntryParam,
  type Params,
  type ParamValue,
  type Provenance,
  type TaskResult,
  upstreamParamsOf,
} from './types'
import { LEAD_MAP, type MapId } from '../annotations/placement'
import type { AnnotationClient } from '../annotations/worker/client'
import { AnalysisClient } from './worker/client'
import { provisionalProvenance, runDraft } from './record'
import { endAction, logAction, type ActionDraft, type ActionOutcome } from '../state/actions'
import { fileLabel } from '../ui/labels'
import {
  BLOCK_COPY_LIMIT_BYTES,
  type BlockInput,
  type PeerMap,
  type ResampleTarget,
  type TaskInputs,
} from './worker/protocol'
 
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
 
/**
 * Where one task stands, as the panel row and the drawer read it.
 *
 * `cancelled` is a state of its own and not a flavour of `failed`, because a
 * reader who pressed Stop knows what happened and does not need a red box to
 * tell them — the same distinction `isCancellation` already draws one layer
 * down.
 */
export type JobState =
  | { status: 'idle' }
  | { status: 'queued' }
  | {
      status: 'running'
      done: number
      total: number
      note: string
      startedAt: number
      /** `3 of 5 · rep3.cool` while a batch is being worked through. */
      batch?: { at: number; of: number; label: string }
    }
  | { status: 'done'; result: TaskResult; ms: number }
  | { status: 'failed'; message: string }
  | { status: 'cancelled' }
 
/**
 * Everything the runner needs from the session, and nothing it needs from a view.
 *
 * Functions rather than fields, because every one of them is a *reading taken at
 * a moment*: the framing when Run is pressed, the block as it stands at
 * dispatch, the payload of an entry that may have been dropped since the form
 * was filled in. A snapshot handed to the constructor would be stale by the
 * second job.
 */
export interface RunnerHost {
  /**
   * The context a task is priced and framed against: the starred map's, or —
   * asked about a `task` that reads no map — the session's own, whichever map
   * is starred (§18.1). {@link source} and {@link contigLengths} answer alike.
   */
  context(task?: AnalysisTask): AnalysisContext | null
  source(task?: AnalysisTask): {
    uri: string
    source: SourceSpec
    cells?: readonly string[]
    binning?: PairsBinning
    orientation?: OrientationSummary
    /** What `pairtools stats` counts, where the map's import counted it (§3.7). */
    pairsStats?: PairStats
    /** The total the map is drawn to, where it is drawn to one (§6.5). */
    resample?: ResampleTarget
    /** The edit of the reference the map is drawn through, where it is drawn through one (§27.7). */
    edit?: ReferenceEdit
    /** What the reader calls this map (§16.10), which its results are named by. */
    label?: string
  } | null
  /**
   * Which map a plain Run files its results against — the star (§18.2), for a
   * task that reads one; a task that reads only tracks is filed on the lead.
   */
  targetMap?(): MapId
  /** Every map on screen, in reading order, for {@link AnalysisRunner.runAll}. */
  allTargets?(): readonly RunTarget[]
  /** The same, once every map still opening has answered; see {@link AnalysisRunner.readyTargets}. */
  allTargetsReady?(): Promise<readonly RunTarget[]>
  /** How many maps a batch will end up covering, ready or not. */
  targetCount?(): number
  block(): BlockInput | null
  payload(entryId: string): AnnotationPayload | undefined
  vector?(entryId: string): Promise<Float32Array | undefined>
  /**
   * The annotations worker, for tasks whose venue is the other one (§18.3).
   *
   * Optional, and a task routed there without one fails by name rather than
   * running somewhere it cannot read its own inputs. Every file has one, a
   * `.metacool` included (§8.5), so `null` is a session whose annotation stores
   * are not built yet — rare, and exactly why the failure has to be loud: a
   * task that ran anyway would read nothing and report it as a result.
   */
  annotations?(): AnnotationClient | null
  /** The assembly, so a task in the other venue can clamp a window (§18.3). */
  contigLengths?(task?: AnalysisTask): Record<string, number>
  /**
   * What a chosen entry is called, for the outputs that name one.
   *
   * The store's, and the reader's: an entry id is `a1`, and a figure whose axes
   * are ids is a figure that has to be decoded against a panel. Optional
   * because a host that cannot answer leaves the ids, which is what they always
   * were rather than a regression.
   */
  label?(entryId: string): string | undefined
  /**
   * A run produced a result, and the map it was measured from.
   *
   * The map is the third argument rather than something the host reads back off
   * the star, and a batch is why: five results arrive one after another and the
   * star has not moved between them, so a host asking "which map is starred"
   * would file all five on whichever one it is. What a task produces belongs to
   * the picture it read.
   */
  finished(task: AnalysisTask, result: TaskResult, target: RunTarget): void
  /**
   * A descriptor by id, for resolving `requires`.
   *
   * Optional, and `analysis/registry.ts`'s own `taskById` is the fallback, so a
   * host has to supply this only to override the catalogue — which a test does
   * and the application does not. A requirement that neither resolves fails by
   * name rather than running with an input silently missing, which is the one
   * outcome a composite must never have.
   */
  task?(id: string): AnalysisTask | undefined
}
 
// ---------------------------------------------------------------------------
// Recipes
// ---------------------------------------------------------------------------
 
/**
 * Whether a stored result was computed from the recipe now being asked for.
 *
 * The whole of the composite cache. A saddle asks for eigenvectors; if the
 * eigenvectors in hand were computed from these parameters over this framing
 * then they *are* the answer, and recomputing them would be forty seconds spent
 * arriving at the same numbers. If either differs they are a different question
 * wearing the same task id, and reusing them would put a figure on screen whose
 * provenance line describes a computation that did not produce it.
 *
 * `framing.label` is deliberately not compared: it is prose derived from the
 * fields above it, and a change to how a bin size is spelled would otherwise
 * invalidate every cached result in the session without a single number moving.
 */
export function sameRecipe(a: Provenance, b: { params: Params; framing: AnalysisFraming }): boolean {
  return sameParams(a.params, b.params) && sameFraming(a.framing, b.framing)
}
 
function sameParams(a: Params, b: Params): boolean {
  const keys = Object.keys(a)
  if (keys.length !== Object.keys(b).length) return false
  for (const key of keys) {
    if (!(key in b) || !sameValue(a[key], b[key])) return false
  }
  return true
}
 
/**
 * `ParamValue` is a scalar or a list of ids, so equality is one level deep and
 * `JSON.stringify` would be a longer way of saying the same thing — except on
 * the two values it gets wrong, `NaN` and `-0`, which a numeric parameter is
 * entitled to hold.
 */
function sameValue(a: ParamValue | undefined, b: ParamValue | undefined): boolean {
  if (Array.isArray(a) || Array.isArray(b)) {
    if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false
    return a.every((item, at) => item === b[at])
  }
  return Object.is(a, b)
}
 
function sameFraming(a: AnalysisFraming, b: AnalysisFraming): boolean {
  return (
    a.uri === b.uri &&
    a.rows[0] === b.rows[0] &&
    a.rows[1] === b.rows[1] &&
    a.cols[0] === b.cols[0] &&
    a.cols[1] === b.cols[1] &&
    a.binsize === b.binsize &&
    a.binsPerCell === b.binsPerCell &&
    a.balance === b.balance &&
    a.value === b.value &&
    a.level === b.level &&
    // The total the map is drawn to changes every count a task reads (§6.5).
    (a.contacts ?? null) === (b.contacts ?? null) &&
    // And so does the edit it is drawn through (§27.7) — an inversion leaves a
    // whole-genome window's bins where they were and moves every contact in it.
    (a.edit ?? null) === (b.edit ?? null) &&
    // And the resolution the reader added, which the bin size says already —
    // and which is said here too, since a stored 4 kb rung and 1 kb summed to
    // 4 kb are two reads of one file (§3.4).
    (a.coarsen ?? null) === (b.coarsen ?? null) &&
    // What the molecules are declared to be changes the numbers as surely as
    // the window does (§8.15): a decay measured along a line is not the one a
    // circle has, and an arm read from another origin is another arm.
    sameNames(a.circular ?? [], b.circular ?? []) &&
    sameOrigins(a.origins ?? {}, b.origins ?? {})
  )
}
 
function sameNames(a: readonly string[], b: readonly string[]): boolean {
  if (a.length !== b.length) return false
  const held = new Set(a)
  return b.every((name) => held.has(name))
}
 
function sameOrigins(
  a: Readonly<Record<string, number>>,
  b: Readonly<Record<string, number>>,
): boolean {
  const keys = Object.keys(a)
  return keys.length === Object.keys(b).length && keys.every((name) => a[name] === b[name])
}
 
// ---------------------------------------------------------------------------
// The runner
// ---------------------------------------------------------------------------
 
/**
 * How often a running job is allowed to repaint the panel.
 *
 * A pixel scan reports progress thousands of times, and a listener that redraws
 * a row per report spends more of the tab's main thread on the progress bar than
 * the job costs it in total. Ten hertz is faster than a reader can read and two
 * orders of magnitude cheaper.
 */
const PROGRESS_NOTIFY_MS = 100
 
/** One step of a resolved chain: a task, and the parameters it will run with. */
interface PlanStep {
  task: AnalysisTask
  params: Params
}
 
/**
 * The map one job runs against, taken when Run was pressed.
 *
 * Held on the job rather than read from the host at dispatch, which is what
 * makes a **batch** possible at all: five jobs queued against five files are
 * five targets, and a host answering "the starred map" would give all five
 * whichever map the star was on when the last of them reached the worker. It
 * also removes a race the old shape had to fail on by name — *the open file
 * changed while this job was queued* — because there is no longer a moving
 * answer for it to disagree with.
 */
/**
 * The key a result is filed under.
 *
 * A NUL between the two halves rather than a slash or a colon: a `MapId` is a
 * generated identifier and a task id is from a fixed catalogue, but neither is
 * validated against a separator, and a key collision here shows up as one map's
 * numbers under another map's tab.
 */
export function resultKey(map: MapId, taskId: string): string {
  return `${map}\u0000${taskId}`
}
 
/**
 * How many earlier runs of one task on one map are kept beside the current one:
 * the latest and the eight before it.
 *
 * Eight, which is a parameter sweep and not an archive. Each held result is its
 * own typed arrays — a pileup, a saddle, a per-bin profile — so this is a memory
 * budget as much as a convenience, and the failure it guards against is a tab
 * that grows all afternoon and is blamed on the file. Eight is enough for the
 * thing people actually do, which is to change one number two or three times and
 * then want the one before last.
 */
export const RUN_HISTORY = 8
 
/** Where a {@link HeldFile} is kept: a task's parameter, since a key alone repeats across tasks. */
function heldKey(taskId: string, key: string): string {
  return `${taskId}\u0000${key}`
}
 
/**
 * What the analysis worker's mount is identified by.
 *
 * The file, and — for a single-cell container — the cells it is summing. Two
 * runs of the same task over the same scool with different cells ticked are two
 * different measurements, and a mount cached on the URI alone would give the
 * second the first one's answer with nothing on screen to say so.
 */
function mountKey(
  uri: string,
  cells?: readonly string[],
  binning?: PairsBinning,
  resample?: ResampleTarget,
  edit?: ReferenceEdit,
  coarsen?: number,
): string {
  const base =
    cells === undefined || cells.length === 0 ? uri : `${uri}\u0000${cells.join('\u0000')}`
  // The base resolution and the rung count, because an imported map at 1 kb and
  // the same file at 10 kb are two maps behind one uri (§3.7) — and a mount kept
  // for the uri alone would answer a task about the second with the first.
  const binned = binning === undefined ? base : `${base}\u0000${binning.base}:${binning.rungs}`
  // And the total it is drawn to (§6.5): the same file at ten million contacts
  // and at its own depth are two maps as surely as two resolutions are.
  const drawn =
    resample === undefined
      ? binned
      : `${binned}\u0000contacts:${resample.contacts}:${resample.seed ?? ''}:${resample.alpha ?? ''}`
  // And the edit it is drawn through (§27.7), by the steps it applies: two
  // edits of one file are two assemblies, as two totals are two maps.
  const edited = edit === undefined ? drawn : `${drawn}\u0000edit:${editFingerprint(edit)}`
  // And the resolution it is summed to (§3.4): one file at 1 kb and at 3 kb is
  // two maps, as one file at two totals is.
  return coarsen === undefined ? edited : `${edited}\u0000coarsen:${coarsen}`
}
 
/**
 * What a console line calls the map a job ran on.
 *
 * Through `fileLabel` whichever kind of label the target carries: a deck names
 * its maps and a lone map is named by its own URI, and a column of full paths
 * would push the command — the thing the panel is for — off the right of the
 * row. A name that is not a path comes back unchanged, so the deck's own naming
 * (§16.10) is what a reader sees there.
 */
function targetLabel(target: RunTarget): string {
  return fileLabel(target.label)
}
 
/** The half of a draft that an outcome may replace. */
function draftFields(draft: ActionDraft): Pick<ActionOutcome, 'line' | 'notes' | 'detail'> {
  return { line: draft.line, notes: draft.notes ?? [], detail: draft.detail ?? [] }
}
 
export interface RunTarget {
  /** Which map, for filing what the run produces (`annotations/placement.ts`). */
  map: MapId
  uri: string
  /**
   * How the analysis worker would mount this file, where there is one to mount.
   *
   * Absent in a session with no contact map, which is not a broken target: the
   * three track tasks (§18.3) read the loaded tracks in the *other* worker and
   * this one is never opened for them ({@link AnalysisRunner.mount}). A map task
   * that reaches a target without one fails by name, which is the honest answer
   * — and the one this used to give as "nothing is open", to every track task in
   * a 1D browser session.
   */
  source?: SourceSpec
  /**
   * The cells this map is summing, where it is a single-cell container (§3.8).
   *
   * Absent everywhere else, and absent means *all of them*. It is part of the
   * target rather than of the source because it is a property of the *view* —
   * the same file with two cells ticked and with four thousand is two different
   * experiments, and a task measures the one on screen.
   */
  cells?: readonly string[]
  /**
   * How this map was binned, where it was imported from a `.pairs` (§3.7).
   *
   * On the target rather than folded into the source because it is a property of
   * the *map* rather than of the bytes: the same file at two base resolutions is
   * two maps, and a mount keyed on the uri alone would answer the second with
   * the first. See {@link mountKey}.
   */
  binning?: PairsBinning
  /**
   * The orientation curves that map's import counted, where it had strands.
   *
   * Per target for the same reason `binning` is: a deck can hold two imported
   * files, and a curve measured on one is not a fact about the other.
   */
  orientation?: OrientationSummary
  /**
   * What `pairtools stats` counts over the records that map's import read
   * (`pairs/stats.ts`), where it was binned from a `.pairs` — per target for
   * {@link orientation}'s reason, and handed to Pairs QC as the curves are.
   */
  pairsStats?: PairStats
  /**
   * The total this map is drawn to (§6.5), where the reader has asked for one.
   *
   * On the target for {@link binning}'s reason: it is a property of the map on
   * screen, every task reads the drawn map, and a mount keyed without it would
   * answer a task at ten million contacts with the file's own depth.
   */
  resample?: ResampleTarget
  /**
   * The edit of the reference this map is drawn through (§27.7): every task
   * reads the edited assembly, as the view draws it, and a mount keyed without
   * it would answer a task about the edit with the file as it was.
   */
  edit?: ReferenceEdit
  /** What to call it while a batch is being worked through. */
  label: string
}
 
/**
 * A file handed to a task's `file` parameter (§18.8): what the parameter's value
 * is while this is the file, and where its bytes are.
 *
 * Held here rather than in the parameters, which are posted, stored and printed
 * and cannot carry a `File`; the value is the file's name — or, for a row picked
 * out of a connected place, its address — and the run is handed the source only
 * while the value still names it.
 */
export interface HeldFile {
  value: string
  source: SourceSpec
  /**
   * What the drop area calls it, where the value does not say: a Drive file's
   * address ends in an id, and a picked row brings its name with it.
   */
  name?: string
  /** Bytes, where known, for the drop area to say. */
  size?: number
}
 
interface Job {
  task: AnalysisTask
  params: Params
  /** Taken when Run was pressed, not when the worker got to it (§18.4). */
  framing: AnalysisFraming
  target: RunTarget
  /** Where this job sits in a batch, or `null` for a run of one. */
  batch: { at: number; of: number } | null
  /** Set by {@link AnalysisRunner.cancel}; read between chain steps. */
  cancelled: boolean
  /** The step currently on the worker, so a cancel has something to call. */
  running: { id: string; handle: Cancellable<TaskResult> } | null
}
 
export class AnalysisRunner {
  private readonly host: RunnerHost
  private readonly makeClient: () => AnalysisClient
  private client: AnalysisClient | null = null
  /** The uri this worker has mounted, so `open` is sent once per file. */
  private mounted: string | null = null
 
  private readonly states = new Map<string, JobState>()
  /**
   * Finished results, keyed by **map and task** rather than by task alone.
   *
   * Two things depend on the map being in the key, and the second is a defect
   * that was live.
   *
   * A deck's batch (`runAll`) produces one result per panel, and a reader
   * comparing four libraries wants all four — on their own tabs, or overlaid in
   * one figure. Keyed by task alone, the fourth overwrote the third overwrote
   * the second, and what a batch left behind was one plot and four synthetic
   * tracks: the tracks were filed per map and the *figures* were not.
   *
   * And this map is also the composite cache (§18.2): a saddle asks for
   * eigenvectors and takes them from here when the recipe matches. A recipe is
   * params plus framing, and **a batch shares one framing across every target**
   * — it is taken once, when Run is pressed. So the reuse test could not see
   * that the cached eigenvectors were measured on another file, and a saddle run
   * over a deck drew panel B's compartments out of panel A's numbers. Silently,
   * and only for the composites.
   */
  private readonly results = new Map<string, TaskResult>()
  /**
   * The runs **before** the current one, newest first, keyed like the results.
   *
   * A task run twice used to leave one figure: the second result replaced the
   * first in place and the first was gone, along with the parameters that
   * produced it. That is the wrong shape for what people actually do with this
   * catalogue, which is to run a thing, change one number, run it again, and
   * then want the two side by side — or, more often, to discover that the
   * previous answer was the one worth keeping. Nothing about a finished result
   * makes it cheap to recompute: a pileup over forty thousand anchors is a
   * minute of range requests, and a reader who lost it by pressing Run has lost
   * the minute.
   *
   * So the old result is kept rather than dropped. Bounded by
   * {@link RUN_HISTORY}, because these hold typed arrays and an afternoon of
   * parameter sweeps would otherwise be an afternoon of pileups in memory; the
   * oldest falls off the end, which is the one a reader is least likely to want
   * and the one they can most easily produce again.
   *
   * Not persisted, for the reason §18.4 gives about the framing: a result is a
   * record *of a view*, and a history restored against a file that has since
   * been re-binned would be a list of figures that no longer describe anything.
   */
  private readonly history = new Map<string, TaskResult[]>()
  /** The target each stored result was measured on, for labelling the tabs. */
  private readonly resultTargets = new Map<string, RunTarget>()
  private readonly queue: Job[] = []
  private current: Job | null = null
  /** Files handed to a task's `file` parameter, by task and key; see {@link holdFile}. */
  private readonly held = new Map<string, HeldFile>()
 
  private readonly listeners = new Set<() => void>()
  private lastNotifiedAt = 0
  private trailing: ReturnType<typeof setTimeout> | null = null
  private destroyed = false
 
  /**
   * The client factory is a seam rather than a configuration point: the
   * application always wants the real worker, and a test wants one whose
   * `postMessage` it can answer itself. Defaulted so that the documented
   * one-argument construction is the whole of the public shape.
   */
  constructor(host: RunnerHost, makeClient: () => AnalysisClient = () => new AnalysisClient()) {
    this.host = host
    this.makeClient = makeClient
  }
 
  stateOf(taskId: string): JobState {
    return this.states.get(taskId) ?? { status: 'idle' }
  }
 
  /**
   * Keep the file a form was handed for one of a task's parameters, or let it go.
   *
   * Here because the runner outlives the form: the drawer builds a new one
   * whenever the task changes, and a file dropped on P(s) must still be there
   * when the reader comes back to it. Per session, like every run, and never
   * written anywhere — a `File` does not survive a reload, and the form empties
   * a name whose file is not held (`ParamForm`).
   */
  holdFile(taskId: string, key: string, held: HeldFile | null): void {
    const at = heldKey(taskId, key)
    if (held === null) this.held.delete(at)
    else this.held.set(at, held)
  }
 
  /** The file {@link holdFile} kept for this task's parameter, if any. */
  heldFile(taskId: string, key: string): HeldFile | undefined {
    return this.held.get(heldKey(taskId, key))
  }
 
  /**
   * The result for one task on one map; the host's own map when none is named.
   *
   * The default is what every existing caller wants and is why they needed no
   * change: a drawer showing one figure is showing the starred map's — or, for a
   * task that reads no map, the one result it has ({@link homeOf}).
   */
  resultOf(taskId: string, map?: MapId): TaskResult | null {
    const where = map ?? this.homeOf(taskId)
    return this.results.get(resultKey(where, taskId)) ?? null
  }
 
  /**
   * The map a task's results are filed under when nobody names one.
   *
   * The star's, for a task that reads a map: it measured the starred picture.
   * The session's own for a task that reads only the tracks and sequences
   * (`readsMap`), which is where {@link queueOne} files it whichever map is
   * starred — so moving the star neither hides its result nor makes the next
   * Run a second copy of it filed under another map.
   */
  private homeOf(taskId: string): MapId {
    const task = this.host.task?.(taskId) ?? taskById(taskId)
    if (task !== undefined && !readsMap(task)) return LEAD_MAP
    return this.host.targetMap?.() ?? LEAD_MAP
  }
 
  /**
   * Every run of this task on one map, newest first — the current one included.
   *
   * What the drawer's *previous run* stepper walks. The current result leads,
   * because that is what is on screen when the drawer opens and a list that made
   * the reader step *forwards* to reach it would be reading itself backwards.
   *
   * An empty list where there is no result at all, so a caller need not tell
   * "never run" from "run once" before it can count.
   */
  runsOf(taskId: string, map?: MapId): TaskResult[] {
    const where = map ?? this.homeOf(taskId)
    const key = resultKey(where, taskId)
    const current = this.results.get(key)
    const older = this.history.get(key) ?? []
    return current === undefined ? [...older] : [current, ...older]
  }
 
  /**
   * Every map this task has a result for, in the order the deck holds them.
   *
   * What the drawer's tabs are built from, and what the combined figure folds.
   * Ordered by the host's target list rather than by when each finished, so the
   * tabs follow the grid and not the queue — a batch reports in reading order
   * but a re-run of one panel would otherwise jump it to the end.
   */
  resultsFor(taskId: string): Array<{ target: RunTarget; result: TaskResult }> {
    const order = this.host.allTargets?.() ?? []
    const seen = new Set<MapId>()
    const out: Array<{ target: RunTarget; result: TaskResult }> = []
    for (const target of order) {
      const result = this.results.get(resultKey(target.map, taskId))
      if (result === undefined) continue
      seen.add(target.map)
      out.push({ target, result })
    }
    // A result whose map has since left the deck is still a result, and losing
    // it when a panel is closed would throw away a computation the reader is
    // looking at. It goes on the end, labelled by the target it was run against.
    for (const [key, result] of this.results) {
      const target = this.resultTargets.get(key)
      if (target === undefined || seen.has(target.map)) continue
      if (key !== resultKey(target.map, taskId)) continue
      out.push({ target, result })
    }
    return out
  }
 
  subscribe(listener: () => void): () => void {
    this.listeners.add(listener)
    return () => {
      this.listeners.delete(listener)
    }
  }
 
  /** A job is running or waiting to. What the panel's spinner is keyed on. */
  get busy(): boolean {
    return this.current !== null || this.queue.length > 0
  }
 
  /**
   * Queue `task`.
   *
   * The framing is read here rather than at dispatch, which is what makes a
   * result a record of what the reader was looking at when they asked. A view
   * with no map has no framing and there is nothing to run against — said
   * plainly rather than queued and failed a second later, since the reader is
   * still looking at the button they pressed.
   */
  run(task: AnalysisTask, params: Params): void {
    this.queueOne(task, params, null)
  }
 
  /**
   * Run one task against **every map on screen**, one after another.
   *
   * The question a deck raises and the star only half answers: four replicates
   * are open because they are being compared, and the comparison is four
   * insulation tracks rather than one. Pressing Run four times means moving the
   * star four times and remembering which of them has been done — bookkeeping
   * the reader should not be doing for a machine that can count.
   *
   * **One job per map, queued, not four at once.** The worker mounts one file at
   * a time, so the queue is what the deck's own memory budget already assumes;
   * running them in parallel would be four h5wasm instances and four dense
   * blocks live at the moment the tab can least afford it. Each result is filed
   * against the map it was measured from, so what a batch leaves behind is a
   * track per panel, each on its own picture, and each cancellable at the point
   * it has reached.
   *
   * The targets are the caller's, in the order they should run: the session
   * hands the lead first and then the deck in reading order, which is what a
   * reader looking at the grid would expect the progress to follow.
   *
   * **A task that reads no map is one run, however many maps there are.** Its
   * runner reads the session's tracks and sequences and never a matrix, so five
   * jobs against five maps were five computations of one answer, filed as five
   * identical lanes each named after a map it never read. The drawer offers it
   * no batch (§18.1); this is the same rule where a batch is queued.
   */
  runAll(task: AnalysisTask, params: Params, targets: readonly RunTarget[]): void {
    if (!readsMap(task)) {
      this.queueOne(task, params, null)
      return
    }
    if (targets.length === 0) return
    if (targets.length === 1) {
      this.queueOne(task, params, null, targets[0])
      return
    }
    // Cleared first, so a batch does not queue behind the single run of the same
    // task a reader pressed a moment ago and then report *its* progress as the
    // first of five.
    this.cancel(task.id)
    targets.forEach((target, at) => {
      this.queueOne(task, params, { at, of: targets.length }, target)
    })
  }
 
  private queueOne(
    task: AnalysisTask,
    params: Params,
    batch: { at: number; of: number } | null,
    target?: RunTarget,
  ): void {
    if (this.destroyed) return
    // Asked about this task: one that reads no map is framed by the session's
    // own map, whichever is starred, so its sequences and its grid hold still.
    const context = this.host.context(task)
    if (context === null) {
      this.refuse(task, batch, 'nothing is open')
      return
    }
    // A task in the other venue reads the loaded tracks and never touches the
    // matrix, so it runs in a session that has no matrix at all — a 1D browser
    // (§5.8), where the reader has the bigWigs and not the map. It still needs a
    // framing, because a result is a record and a record names what it was of
    // (§18.4); what it names there is the assembly rather than a block.
    // A task that reads no map is a record of none, whatever the view shows.
    const framing =
      task.mapless === true
        ? tracksFraming(context, 'sequences')
        : context.framing ?? (task.venue === 'annotations' ? tracksFraming(context) : null)
    if (framing === null) {
      this.refuse(task, batch, 'this view has no contact map to run against')
      return
    }
    // A task that reads no map is filed with the session's tracks, on the lead,
    // whichever map is starred: what it measures does not move with the star,
    // so a press after the star moved is this same run again — collapsed below,
    // or kept as the run before — rather than a second copy of one lane filed
    // under another map's name.
    const where = readsMap(task)
      ? (target ?? this.hostTarget(framing, task))
      : { ...this.hostTarget(framing, task), map: LEAD_MAP }
    // A second press while the same task is already on the worker is a reader
    // wondering whether the first one registered, not a request for two runs of
    // it. A press while it is still *queued* is a change of mind about the
    // parameters, so the recipe is replaced where it stands — moving it to the
    // back of the queue would punish the correction.
    //
    // **Per map, not per task**, since a batch is one task queued five times:
    // collapsing on the id alone would leave a five-map batch running once.
    if (batch === null) {
      if (this.current?.task.id === task.id && this.current.target.map === where.map) return
      const waiting = this.queue.find(
        (job) => job.task.id === task.id && job.target.map === where.map,
      )
      if (waiting !== undefined) {
        waiting.params = params
        waiting.framing = framing
        return
      }
    }
    this.queue.push({
      task,
      params,
      framing,
      target: where,
      batch,
      cancelled: false,
      running: null,
    })
    this.setState(task.id, { status: 'queued' })
    this.pump()
  }
 
  /**
   * Every map a batch would run on, in the order it would run them.
   *
   * Asked of the host on every call rather than held: a deck gains and loses
   * maps while a drawer is open, and the button that offers to run on all of
   * them has to be counting the ones that are there.
   */
  targets(): readonly RunTarget[] {
    return this.host.allTargets?.() ?? []
  }
 
  /**
   * Every map a batch will run on, once the ones still opening have answered.
   *
   * {@link targets} is the synchronous reading and is what the drawer prices,
   * orders results by and finds the star in. It is the wrong list to *run* from:
   * a companion opens to draw a picture, and the two tables an analysis needs
   * are a further pair of reads it only makes when starred or batched — so on a
   * freshly opened deck the first press of **Run on all maps** found some of
   * them missing and quietly ran on the rest. Pressing again worked, which made
   * a timing question look like a question about the files.
   *
   * Falls back to the synchronous list where a host has no answer, which is
   * every session with no deck: there is one map, it is open, and there is
   * nothing to wait for.
   */
  async readyTargets(): Promise<readonly RunTarget[]> {
    return (await this.host.allTargetsReady?.()) ?? this.targets()
  }
 
  /**
   * How many maps a batch will end up covering — what the button counts.
   *
   * Not `targets().length`: a deck of four with one still opening offered "run
   * on all 3 maps" and then ran four. Counting the grid is the honest number,
   * because {@link readyTargets} waits for every cell in it.
   */
  targetCount(): number {
    return this.host.targetCount?.() ?? this.targets().length
  }
 
  /**
   * Which of {@link targets} a plain Run goes to — the starred map (§18.2).
   *
   * `null` where the session names no such map, which is every session without
   * a deck: there is one map, a plain Run goes to it, and naming it would be
   * telling a reader which of one thing was chosen. The drawer's Run button
   * reads this to say *what it is about to measure*, which on a deck of five is
   * the one thing the word "run" does not say.
   */
  target(): RunTarget | null {
    const map = this.host.targetMap?.()
    if (map === undefined) return null
    return this.targets().find((target) => target.map === map) ?? null
  }
 
  /**
   * The one map a plain Run goes to: whatever the session says is current.
   *
   * A session with nothing to *mount* still has a target, and that is the whole
   * of what a track task needed: the framing is what it is a measurement of, and
   * a genome browser has one of those without having a contact map. Where the
   * host does answer with a file — which is every session with a map, and the
   * starred one in a deck, or the session's own for a task that reads none —
   * that file is what the target names.
   */
  private hostTarget(framing: AnalysisFraming, task: AnalysisTask): RunTarget {
    const source = this.host.source(task)
    return {
      map: this.host.targetMap?.() ?? LEAD_MAP,
      uri: source?.uri ?? framing.uri,
      ...(source === null ? {} : { source: source.source }),
      // Carried, not dropped: this is the target a plain Run builds, so a cell
      // set lost here is lost for every single-map run — which is all of them
      // outside a batch (§3.8).
      ...(source?.cells === undefined ? {} : { cells: source.cells }),
      // And the two facts an imported `.pairs` carries (§3.7), which were being
      // dropped here: the worker's own mount of a contact list is refused
      // without the grid the reader chose, so every plain Run on a map binned
      // from one failed with *choose a bin size*, and P(s) never saw the
      // orientation curves the import had counted. A batch built its targets
      // elsewhere and carried both, which is why *run on all maps* worked.
      ...(source?.binning === undefined ? {} : { binning: source.binning }),
      ...(source?.orientation === undefined ? {} : { orientation: source.orientation }),
      // …and its `pairtools stats`, counted in the same pass, for Pairs QC.
      ...(source?.pairsStats === undefined ? {} : { pairsStats: source.pairsStats }),
      // Drawn as the view draws it (§6.5): a plain Run is the commonest way a
      // task reaches a resampled map, and dropping the total here would measure
      // the file as stored under a header that says otherwise.
      ...(source?.resample === undefined ? {} : { resample: source.resample }),
      // …and through the edit it is drawn through (§27.7), for the same reason.
      ...(source?.edit === undefined ? {} : { edit: source.edit }),
      // The map's name where the host has one — the tab, the lane and every
      // figure that names the map take it from here — and the framing's address
      // where it has none, which is a genome browsed as tracks.
      label: source?.label ?? framing.uri,
    }
  }
 
  /**
   * Call off a queued or running job.
   *
   * Cooperative for the running one: the worker checks between chunks, so the
   * job stops at the next check and its `AbortError` arrives here as a
   * cancellation rather than as a failure. A job that has already finished is
   * left alone — cancelling a result would be deleting it.
   *
   * The **annotations** venue checks the same way, at a read or a yield, and a
   * task there that does neither runs to its end — `runStep` says which — so the
   * guarantee that holds for every step is kept on this side as well: whatever
   * the step goes on to compute, the row ends `cancelled` and nothing it
   * produced is adopted (`execute`, at the check after `runStep`). Both halves
   * are needed. Setting the flag without that check is what let a cancelled run
   * finish as `done` with its lanes on the panel.
   */
  cancel(taskId: string): void {
    // **Every** queued job for this task, not the first: a batch is one task
    // queued once per map, and dropping one of five would stop the map in flight
    // and start the next — a Stop that looks like it did nothing, twice.
    let dropped = 0
    for (let at = this.queue.length - 1; at >= 0; at--) {
      if (this.queue[at].task.id !== taskId) continue
      this.queue.splice(at, 1)
      dropped++
    }
    const job = this.current
    if (job === null || job.task.id !== taskId) {
      // Nothing was running: what was cancelled is what was waiting. A task with
      // neither is a press on a row whose job has already finished, and
      // cancelling a result would be deleting it.
      if (dropped > 0) this.setState(taskId, { status: 'cancelled' })
      return
    }
    job.cancelled = true
    job.running?.handle.cancel()
  }
 
  /**
   * End the session's analysis: the queue, the jobs and the thread.
   *
   * A job does not survive the session (§18.3), and it ends as `cancelled`
   * rather than as `failed` because terminating the worker is this object's own
   * doing and not a fault the reader needs told about.
   */
  destroy(): void {
    this.destroyed = true
    for (const job of this.queue) this.setState(job.task.id, { status: 'cancelled' })
    this.queue.length = 0
    if (this.current !== null) {
      this.current.cancelled = true
      this.setState(this.current.task.id, { status: 'cancelled' })
    }
    if (this.trailing !== null) {
      clearTimeout(this.trailing)
      this.trailing = null
    }
    this.client?.terminate()
    this.client = null
    this.mounted = null
    this.listeners.clear()
  }
 
  // -------------------------------------------------------------------------
  // The queue
  // -------------------------------------------------------------------------
 
  private pump(): void {
    if (this.current !== null || this.destroyed) return
    const job = this.queue.shift()
    if (job === undefined) return
    this.current = job
    void this.execute(job).finally(() => {
      this.current = null
      this.pump()
    })
  }
 
  /**
   * The batch tail of a running state, or nothing for a run of one.
   *
   * Attached to every `running` state rather than to the first, because a batch
   * is only legible while it is *in progress*: a reader who sees "opening the
   * file" with no idea it is the fourth of five has no way to tell a long job
   * from a stuck one.
   */
  private batchOf(job: Job): { batch: { at: number; of: number; label: string } } | object {
    return job.batch === null
      ? {}
      : { batch: { at: job.batch.at, of: job.batch.of, label: job.target.label } }
  }
 
  private async execute(job: Job): Promise<void> {
    const startedAt = performance.now()
    let step: PlanStep | null = null
    // **One console line per job**, written here and not where Run was pressed
    // (§23). That is what makes *run on all maps* six lines rather than one: the
    // button queues six jobs, and this is the place that knows a job from a
    // press. It is also the only place that sees a run that never reached a
    // result — a failure, or a Stop — which is exactly the run whose parameters
    // a reader most wants back.
    const recorded = this.recordStart(job)
    let outcome: ActionOutcome | null = null
    this.setState(job.task.id, {
      status: 'running',
      done: 0,
      total: 0,
      note: 'starting',
      startedAt,
      ...this.batchOf(job),
    })
    try {
      const chain = this.plan(job.task, job.params)
      const client = this.ensureClient()
      // Mounted only for a chain that reads the matrix. A track-only chain has
      // no file for the analysis worker to open — and opening one anyway would
      // be a wasm instance and a range cache for a job that will not ask them a
      // question.
      if (chain.some((step) => step.task.venue !== 'annotations')) {
        await this.mount(job, client, startedAt)
      }
 
      const upstream: Record<string, TaskResult> = {}
      for (let at = 0; at < chain.length; at++) {
        step = chain[at]
        if (job.cancelled) throw abortError()
        const leaf = step.task.id === job.task.id
 
        // A cached upstream whose recipe matches is the answer, and the task
        // below never learns whether it was computed now or ten minutes ago
        // (§18.2). The leaf is never taken from the cache: pressing Run means
        // run it.
        const cached = this.results.get(resultKey(job.target.map, step.task.id))
        if (
          !leaf &&
          cached !== undefined &&
          sameRecipe(cached.provenance, { params: step.params, framing: job.framing })
        ) {
          upstream[step.task.id] = cached
          continue
        }
 
        const result = await this.runStep(job, client, step, chain, at, upstream, startedAt)
        // Stopped *while this step was in flight*, which the check at the top of
        // the loop cannot see: it runs before the step, and a Stop pressed after
        // that is only readable once the step settles. Without this line the
        // leaf branch below ran anyway — `setState('done')` and
        // `this.host.finished(...)` — so a cancelled run adopted its lanes onto
        // the Annotations panel and its row said `running` until the moment it
        // said `done`, never `cancelled`. The whole of what the panel's Stop
        // promises is that the answer is not taken, so the refusal belongs here
        // rather than only in the worker: a task in the **annotations** venue that
        // never hands its thread back never sees the cancel (see `runStep`), and
        // even an `AbortError` can lose the race with the result it was meant to
        // pre-empt.
        if (job.cancelled) throw abortError()
        const key = resultKey(job.target.map, step.task.id)
        // The result this one replaces goes to the front of the history, so
        // *previous* means the run before this one on this map — and a
        // composite's reused upstream step is filed the same way, since it is
        // as much a result as the leaf.
        const previous = this.results.get(key)
        if (previous !== undefined && previous !== result) {
          const kept = this.history.get(key) ?? []
          kept.unshift(previous)
          this.history.set(key, kept.slice(0, RUN_HISTORY))
        }
        this.results.set(key, result)
        this.resultTargets.set(key, job.target)
        if (leaf) {
          this.setState(step.task.id, {
            status: 'done',
            result,
            // Wall clock from dispatch, which for a composite counts the whole
            // chain including the steps that were reused. `result.ms` is the
            // last step's own time, and is what the drawer prints beside it.
            ms: performance.now() - startedAt,
          })
          this.host.finished(job.task, result, job.target)
          // The result's own provenance replaces the one this job was recorded
          // with: the worker wrote it beside the numbers, so it is the
          // authoritative account of what produced them (see `record.ts`).
          outcome = {
            status: 'ok',
            ms: performance.now() - startedAt,
            ...draftFields(
              runDraft({
                task: job.task,
                provenance: this.recordable(job, result.provenance),
                context: this.host.context(job.task),
                where: targetLabel(job.target),
              }),
            ),
          }
        } else {
          // A required result is cached and its row says so, but it is not
          // handed to `finished`: the reader asked for the saddle, and
          // publishing its eigenvectors as a synthetic track (§18.5) would put
          // lanes on the Annotations panel that nobody asked for.
          upstream[step.task.id] = result
          this.setState(step.task.id, { status: 'done', result, ms: result.ms })
        }
      }
    } catch (err) {
      const cancelled = job.cancelled || isCancellation(err)
      const state: JobState = cancelled
        ? { status: 'cancelled' }
        : { status: 'failed', message: err instanceof Error ? err.message : String(err) }
      // The step that was on the worker stops too, and says the same thing the
      // job does; a chain that left `expected` reading "running" for the rest of
      // the session would be a row nobody could clear.
      if (step !== null && step.task.id !== job.task.id) this.setState(step.task.id, state)
      this.setState(job.task.id, state)
      outcome = cancelled
        ? { status: 'cancelled' }
        : { status: 'failed', error: err instanceof Error ? err.message : String(err) }
    } finally {
      job.running = null
      // The fallback covers a chain that ended without reaching its own leaf,
      // which nothing adopts and which is a cancel from where the reader sits.
      // A line left saying `running` would spin for the life of the tab and come
      // back `interrupted` after a reload, which would be this bookkeeping
      // blaming the browser.
      endAction(recorded, outcome ?? { status: 'cancelled' })
    }
  }
 
  /**
   * A press of Run that never became a job.
   *
   * Both refusals above are about the **session** rather than about the target —
   * there is nothing open, or the view has no map — so the console line says so
   * once however many maps the batch was aimed at: five copies of one fact is
   * five lines a reader has to read before finding out they are the same line.
   *
   * Recorded at all because the console's promise is that every press leaves a
   * line (§23). There is no command on this one and there cannot be: a framing
   * is what a command's `--window` is written from, and the absence of one is
   * the refusal. So it is prose, with the reason on it, which is the answer to
   * "why did nothing happen when I pressed run at two o'clock".
   */
  private refuse(task: AnalysisTask, batch: { at: number; of: number } | null, why: string): void {
    this.setState(task.id, { status: 'failed', message: why })
    if (batch !== null && batch.at > 0) return
    logAction({
      kind: 'run',
      line: `ran ${task.title}`,
      status: 'failed',
      error: why,
    })
  }
 
  /**
   * Open the console's line for a job that is about to run.
   *
   * The provenance is composed rather than read, because there is no result yet
   * — see `record.ts`. What it costs if the job then fails is a `created:` stamp
   * naming the moment the work started instead of the moment it stopped, which
   * is the more useful of the two on a run that produced nothing.
   */
  private recordStart(job: Job): string {
    const draft = runDraft({
      task: job.task,
      provenance: this.recordable(job, provisionalProvenance(job.task, job.params, job.framing)),
      context: this.host.context(job.task),
      where: targetLabel(job.target),
    })
    return logAction({ ...draft, status: 'running' })
  }
 
  /**
   * The provenance a console line is written from: the job's, naming the file
   * the job actually ran on.
   *
   * **A batch shares one framing across every target** — it is taken once, when
   * Run is pressed, which is what makes a result a record of what the reader was
   * looking at (§18.4) — and an `AnalysisFraming` carries a `uri`. So the second
   * job of a five-map batch is dispatched against `rep2.cool` and carries a
   * framing that says `rep1.cool`, and the worker echoes that framing into the
   * result it returns.
   *
   * Invisible in the drawer, which prints `framing.label` and never the URI. Not
   * invisible here: this is the one surface that turns a provenance into a
   * **command**, and a command naming the wrong file is a reader running the
   * wrong experiment in a shell. So the line is written from the target's file,
   * and the *whole* line is — the `#` provenance block above the command as well
   * as the command itself — because a row that quoted two files would be worse
   * than either.
   *
   * Deliberately not written back into `job.framing` or into the result. That
   * framing is compared by `sameRecipe` and by the drawer's staleness check,
   * which is about *the view having moved* rather than about which file is on
   * screen, and changing it would mark every companion's result stale whenever
   * the star is elsewhere — a correction to §18.4's machinery, not to a console.
   */
  private recordable(job: Job, provenance: Provenance): Provenance {
    const uri = job.target.uri
    if (uri === '' || uri === provenance.framing.uri) return provenance
    return { ...provenance, framing: { ...provenance.framing, uri } }
  }
 
  /**
   * The mount, opened on the first job that needs one and once per file.
   *
   * The uri is checked against the framing rather than trusted: a queued job
   * whose file was closed and replaced while it waited would otherwise be run
   * against the new one and reported under the old one's provenance, which is
   * the single thing {@link AnalysisFraming.uri} exists to prevent.
   */
  private async mount(job: Job, client: AnalysisClient, startedAt: number): Promise<void> {
    // The job's own target, not the host's answer now: a batch is five jobs
    // against five files, and asking the session which map is current as each
    // reaches the worker would run all five against whichever one it had drifted
    // to. It also retires a failure this used to have to invent — *the open file
    // changed while this job was queued* — since there is no moving answer left
    // for a job to disagree with.
    const source = job.target
    // A target with no file behind it: a session browsing a genome, reached by a
    // task that reads the matrix. Said by name and in the task's own terms,
    // because the reader's way out is to open a cooler — which is a different
    // sentence from the one the queue used to give ("nothing is open") for a
    // session that plainly had a genome and nine tracks in it.
    if (source.source === undefined) {
      throw new Error(
        `${job.task.title} reads a contact map, and this session is browsing a genome without one.`,
      )
    }
    // Keyed on the cell set as well as the file: an scool re-ticked between two
    // runs is the same URI and a different map, and a mount kept for the name
    // alone would answer the second question with the first one's cells.
    // At the resolution the framing names, where the reader added it (§3.4):
    // the framing's rather than the target's, because a batch frames every map
    // by the starred one's picture, and that picture is at this resolution.
    const coarsen = job.framing.coarsen
    const key = mountKey(source.uri, source.cells, source.binning, source.resample, source.edit, coarsen)
    if (this.mounted === key) return
    this.setState(job.task.id, {
      status: 'running',
      done: 0,
      total: 0,
      note: 'opening the file',
      startedAt,
      ...this.batchOf(job),
    })
    // Cleared first: an `open` that rejects halfway leaves the worker holding
    // nothing, and a `mounted` still naming the previous file would make the
    // next job skip the open it now needs.
    this.mounted = null
    await client.request<'open'>({
      kind: 'open',
      uri: source.uri,
      source: source.source,
      ...(source.cells === undefined ? {} : { cells: source.cells }),
      ...(source.binning === undefined ? {} : { binning: source.binning }),
      ...(source.resample === undefined ? {} : { resample: source.resample }),
      ...(source.edit === undefined ? {} : { edit: source.edit }),
      ...(coarsen === undefined ? {} : { coarsen }),
    })
    this.mounted = key
  }
 
  private async runStep(
    job: Job,
    client: AnalysisClient,
    step: PlanStep,
    chain: readonly PlanStep[],
    at: number,
    upstream: Record<string, TaskResult>,
    startedAt: number,
  ): Promise<TaskResult> {
    // **The step's name only where the step is news.**
    //
    // A chain runs several tasks in sequence and the honest answer to "why is
    // this taking so long" is which of them is running, so a composite says
    // *expected (1/3) — reading pixels*. A one-step job has no such question: the
    // drawer's heading is the task's title in the largest text on the panel and
    // the panel row's title is the same words, so `P(s) — reading pixels` spent
    // two thirds of the line repeating what the reader is looking at and pushed
    // the one new word to the end. Alone, the note is the whole line.
    //
    // The bare title is still the answer before a worker has reported anything,
    // because a blank line under the run row reads as a job that has not
    // started. Both consumers have their own fallback for an empty note —
    // *working* in the drawer, the blurb in the panel row — so the title is
    // handed over rather than either of them.
    const label = (note?: string): string => {
      const said = note === undefined || note === '' ? '' : note
      if (chain.length === 1) return said === '' ? step.task.title : said
      const where = `${step.task.title} (${at + 1}/${chain.length})`
      return said === '' ? where : `${where} — ${said}`
    }
    this.setState(job.task.id, {
      status: 'running',
      done: 0,
      total: 0,
      note: label(),
      startedAt,
      ...this.batchOf(job),
    })
 
    const onProgress = (done: number, total: number, note?: string): void => {
      this.setState(
        job.task.id,
        { status: 'running', done, total, note: label(note), startedAt, ...this.batchOf(job) },
        true,
      )
    }
    const payload = {
      kind: 'run' as const,
      task: step.task.id,
      // Every locus in cooler's grammar, which is what the runners read; the
      // reader's own form stays in `step.params` and is what the result records
      // (below, and `runnerParams`).
      params: runnerParams(step.task.params, step.params),
      framing: job.framing,
      inputs: await this.inputsFor(step, job, upstream),
    }
 
    let raw: TaskResult
    if (step.task.venue === 'annotations') {
      // The other venue (§18.3), and cancellable the same way: the id-keyed
      // `cancelled` set the worker keeps for its BAM reads is what the
      // `TrackJobContext` it builds now reads, so a Stop reaches a runner at its
      // next check. That was the missing last hop, and it stayed missing while
      // every task here was bounded; calling peaks reads every base of a genome
      // and fits IDR for minutes, which is a job §18.3's *every job can be
      // cancelled* is about. A check only sees a cancel once the thread has been
      // handed back, which a read does and `yieldToEventLoop` does for
      // arithmetic — so a task that does neither (a motif scan is one pass over
      // the residues) still runs to the end, and the guard after `runStep` in
      // `execute` is what refuses its answer: the row ends `cancelled` and
      // nothing the run produced is adopted.
      const tracks = this.host.annotations?.()
      if (tracks === null || tracks === undefined) {
        throw new Error(
          `${step.task.title} runs against the loaded tracks, and this session has no ` +
            'annotations worker.',
        )
      }
      const handle = tracks.cancellable<'run'>(payload, onProgress)
      job.running = { id: step.task.id, handle }
      raw = await handle.promise
      job.running = null
    } else {
      const handle = client.cancellable<'run'>(payload, onProgress)
      job.running = { id: step.task.id, handle }
      raw = await handle.promise
      job.running = null
    }
 
    // The worker fills `taskVersion` with a placeholder and says in its own
    // comment that this line overwrites it: the descriptor holding the real
    // number is main-thread data, and importing the catalogue into the worker to
    // read one field off it would drag the panel's half of the subtree into that
    // bundle. A result that reaches a reader still carrying `0` means this step
    // was skipped.
    // A normalisation the *view* computes is one no task can reproduce: VC, VC √
    // and ICE are `render/derive.ts`'s arithmetic over a block already read, and
    // nothing in the file carries them. Said here rather than in each of the
    // fifteen runners, because none of them has any business knowing what a
    // score picker is — and said at all because a pileup captioned `ICE` that is
    // really counts is the kind of wrong that ends up in a figure.
    const notes =
      job.framing.normalise === null
        ? raw.notes
        : [
            ...raw.notes,
            `The map is drawn with ${job.framing.normalise.toUpperCase()} normalisation, which is ` +
              'computed from the block after it is read and cannot be asked of the file. These ' +
              'numbers are the underlying counts.',
          ]
    // **The parameters as the reader wrote them**, over the ones the worker was
    // handed. The worker records what arrived, and what arrived had every locus
    // in the machine form (`runnerParams`) — but a provenance is the record of
    // what was *asked for*: it fills the form again (`rememberParams`), it is
    // the `# param` line above every export and the command the console writes,
    // and a replay reads it back as typed. `IV:1000000-1400000` in any of those
    // is a locus a reader would read one base early (§26.1).
    return {
      ...raw,
      notes,
      provenance: { ...raw.provenance, params: step.params, taskVersion: step.task.version },
    }
  }
 
  private ensureClient(): AnalysisClient {
    this.client ??= this.makeClient()
    return this.client
  }
 
  // -------------------------------------------------------------------------
  // Composites
  // -------------------------------------------------------------------------
 
  /**
   * The chain to run, required tasks first and the asked-for task last.
   *
   * Depth first, so a requirement of a requirement is computed before the thing
   * that wants it, and de-duplicated, because two steps of one chain may want
   * the same expected curve and computing it twice is exactly what `requires`
   * exists to stop. A required task runs at **its own defaults**: the reader
   * configured the saddle and not the eigenvectors underneath it, and inventing
   * parameters for a form they never opened would be putting numbers in their
   * mouth.
   *
   * **Except what the leaf's own form asked for it** (`upstreamParams`): Pairs
   * QC asks for a reference and an enzyme, which are the restriction step's
   * questions answered on the leaf's form, so they are laid over that step's
   * defaults — and the cache's `sameRecipe` sees the step's parameters as they
   * will run, so a digest made with DpnII is never reused for HindIII. `null`
   * leaves the step out of the chain altogether, and the leaf runs without it.
   */
  private plan(task: AnalysisTask, params: Params): PlanStep[] {
    const steps: PlanStep[] = []
    const planned = new Set<string>()
    const active = new Set<string>()
    const walk = (node: AnalysisTask, nodeParams: Params): void => {
      if (planned.has(node.id)) return
      // Not merely an infinite loop avoided: a cycle in `requires` is a
      // catalogue mistake, and one that would otherwise present as a tab that
      // stops responding rather than as a sentence naming the task.
      if (active.has(node.id)) throw new Error(`the analysis task ${node.id} requires itself`)
      active.add(node.id)
      for (const id of node.requires ?? []) {
        const required = this.host.task?.(id) ?? taskById(id)
        if (required === undefined) {
          throw new Error(`${node.id} requires the task ${id}, which this session does not offer`)
        }
        const answered = upstreamParamsOf(node, id, nodeParams)
        if (answered === null) continue
        walk(required, { ...defaultParams(required.params), ...answered })
      }
      active.delete(node.id)
      planned.add(node.id)
      steps.push({ task: node, params: nodeParams })
    }
    walk(task, params)
    return steps
  }
 
  // -------------------------------------------------------------------------
  // Inputs
  // -------------------------------------------------------------------------
 
  /**
   * What the job carries with it: the loaded sets it named, and maybe the block.
   *
   * Assembled at dispatch rather than when Run was pressed, because a feature
   * set is megabytes of typed arrays and a queued job holding one would keep it
   * alive after the reader dropped it. The framing is the exception, and it is
   * the exception for the reason §18.4 gives.
   */
  private async inputsFor(
    step: PlanStep,
    job: Job,
    upstream: Record<string, TaskResult>,
  ): Promise<TaskInputs> {
    const framing = job.framing
    const features: Record<string, FeatureSet> = {}
    const pairs: Record<string, PairSet> = {}
    const vectors: Record<string, Float32Array> = {}
    // By **entry id**, and gathered for every entry parameter whatever its kind:
    // what a task wants to write on an axis is the name of the file, and which
    // parameter chose it is not the question.
    const labels: Record<string, string> = {}
 
    for (const spec of step.task.params) {
      if (
        spec.kind !== 'featureSet' &&
        spec.kind !== 'pairSet' &&
        spec.kind !== 'track' &&
        spec.kind !== 'sequence'
      ) {
        continue
      }
      for (const [key, entryId] of chosenEntries(spec, step.params)) {
        const label = this.host.label?.(entryId)
        if (label !== undefined && label !== '') labels[entryId] = label
        if (spec.kind === 'track' || spec.kind === 'sequence') {
          // A **sequence** arrives as a vector and not as bases, and that is the
          // whole of how GC-content phasing crosses the venue boundary (§18.6).
          // The residues live in the annotations worker and are megabytes; what
          // the compartments task needs is one number per bin, which `sample`
          // already computes for the lane it draws. Asking for the vector rather
          // than for a third venue is the small fix that open question 6 named.
          //
          // Only for a task that runs *here*: the annotations venue holds the
          // handles itself, so shipping the same numbers to it would be sending
          // a worker a copy of what it is about to read.
          if (step.task.venue === 'annotations') continue
          const vector = await this.host.vector?.(entryId)
          if (vector !== undefined) vectors[key] = vector
          continue
        }
        // An entry that is gone — dropped after the form was filled in — is
        // left out rather than replaced by an empty set, so the runner sees a
        // missing input and can name it instead of reporting a clean aggregate
        // over nothing.
        const payload = this.host.payload(entryId)
        if (payload === undefined) continue
        if (spec.kind === 'featureSet' && payload.kind === 'features') features[key] = payload.data
        if (spec.kind === 'pairSet' && payload.kind === 'pairs') pairs[key] = payload.data
      }
    }
 
    // The files handed to the step's `file` parameters, while the value still
    // names them: a reader who typed an address over a dropped file meant the
    // address, and the file is not sent.
    const sources: Record<string, SourceSpec> = {}
    for (const spec of step.task.params) {
      if (spec.kind !== 'file') continue
      const held = this.held.get(heldKey(step.task.id, spec.key))
      if (held !== undefined && held.value === step.params[spec.key]) sources[spec.key] = held.source
    }
 
    const inputs: TaskInputs = {}
    if (Object.keys(sources).length > 0) inputs.sources = sources
    if (Object.keys(features).length > 0) inputs.features = features
    if (Object.keys(pairs).length > 0) inputs.pairs = pairs
    if (Object.keys(labels).length > 0) inputs.labels = labels
    // Only for the other venue, which has no assembly of its own: the
    // annotations worker holds handles and parsed sets, and neither knows how
    // long a sequence is. Sent for that venue alone rather than always, because
    // on a metagenome it is four thousand entries the analysis worker would
    // never read.
    if (step.task.venue === 'annotations') {
      // The job's, as its framing is: for a 1D task, the session's own map's.
      const lengths = this.host.contigLengths?.(job.task)
      if (lengths !== undefined) inputs.contigLengths = lengths
    }
    if (Object.keys(vectors).length > 0) inputs.vectors = vectors
    if (Object.keys(upstream).length > 0) inputs.upstream = { ...upstream }
    // The job's **own target's**, never the host's answer now: a batch is five
    // jobs against five files, and a curve counted on one import is not a fact
    // about any of the others (`mount` makes the same argument about the source).
    if (job.target.orientation !== undefined) inputs.orientation = job.target.orientation
    // **The records themselves, where the map was binned from them** (§3.7):
    // the `.pairs` the target was imported from, and what its import counted.
    // A task never read the map's own source before — it reads the mounted
    // pixels — and Pairs QC is the one that has to, because a restriction
    // fragment is a fact about where a read ends and no bin remembers that.
    // The target's own source, for the reason the curves above are the
    // target's: in a batch it is the file this job is about.
    if (job.target.binning !== undefined && job.target.source !== undefined) {
      inputs.mapSource = job.target.source
    }
    if (job.target.pairsStats !== undefined) inputs.pairsStats = job.target.pairsStats
    // **Every other target, for the one task whose unit is a pair of files.**
    // Taken from the host's list rather than from the job, because the job knows
    // the map it is *about* and this is a question about the ones beside it —
    // and filtered against the job's own target, since a map is not its own peer.
    //
    // The list that **waits**, as *Run on all maps* does: a companion is on the
    // list only once it has read the two tables an analysis needs, which it does
    // when starred or batched, so the first run in a fresh deck found none of
    // them and said no second map was open beside the one it was looking at.
    if (step.task.id === 'hicrep') {
      const peers: PeerMap[] = []
      const targets = (await this.host.allTargetsReady?.()) ?? this.host.allTargets?.() ?? []
      for (const target of targets) {
        if (target.map === job.target.map || target.source === undefined) continue
        peers.push({
          label: target.label,
          uri: target.uri,
          source: target.source,
          ...(target.cells === undefined ? {} : { cells: target.cells }),
          ...(target.binning === undefined ? {} : { binning: target.binning }),
          // Each map at the depth it is drawn to: reproducibility between two
          // maps matched to one depth is the comparison §6.5 exists for.
          ...(target.resample === undefined ? {} : { resample: target.resample }),
          // …and at the resolution this one is read at, where the reader added
          // it: a stratum is a separation in bins, and two maps compared stratum
          // by stratum have to be cut on one grid (§3.4).
          ...(job.framing.coarsen === undefined ? {} : { coarsen: job.framing.coarsen }),
        })
      }
      if (peers.length > 0) inputs.peers = peers
      // …and the job's own map, named the way its peers are, so a comparison
      // run on every map names each one the same in every run.
      if (job.target.label !== '') inputs.mapLabel = job.target.label
    }
 
    // **The map itself, by address, for the report that reads its finest rung**
    // (§18.8): the worker mounts one file, and a ladder that starts below the
    // rung on screen has to open the one under it. Not on an import, whose
    // rungs were binned here rather than stored.
    if (step.task.id === 'mapreport' && job.target.source !== undefined && job.target.binning === undefined) {
      inputs.self = {
        label: job.target.label,
        uri: job.target.uri,
        source: job.target.source,
        ...(job.target.cells === undefined ? {} : { cells: job.target.cells }),
        ...(job.target.resample === undefined ? {} : { resample: job.target.resample }),
      }
    }
 
    // **Only to the job that is about the map the block came from.**
    //
    // The block on screen belongs to one panel — the starred one — and
    // {@link blockMatches} cannot tell: it compares extents and a bin size, and
    // in a deck every panel is at the same extents at the same bin size, which
    // is the whole point of a deck. So a batch handed the *same* pixels to all
    // five jobs, and five maps came back with one map's answer. Only on the
    // chromosome that happened to be in view, which is the worst shape a bug of
    // this kind can have: every other region was read per file and correct, so
    // the numbers agreed with themselves everywhere a reader would think to
    // check.
    const block = this.hostBlockFor(job)
    // A block drawn at another total than the job's map now is — read before the
    // number in the contacts box changed, and not yet read again — is not a
    // picture of that map, and the job reads for itself instead (§6.5).
    const drawnAlike = (framing.contacts ?? null) === (job.target.resample?.contacts ?? null)
    if (block !== null && drawnAlike && blockWorthCopying(block) && blockMatches(block, framing)) {
      // **Copied, never transferred**, which is why the size gate above is
      // worth having at all. `Transferable` is the optimisation that looks free
      // here and is the one that blanks the map: the buffer handed away is the
      // array the heatmap draws from, and what is left behind is a neutered view
      // and an empty canvas several frames from its cause (§18.3).
      inputs.block = block
    }
    return inputs
  }
 
  /**
   * The block on screen, when it is a picture of *this job's* map.
   *
   * The host has one block to offer and it is whichever panel the star is on
   * (`app/Session.ts`), so lending it to a job about another map is lending one
   * file's pixels to a measurement of another's. `null` everywhere else, which
   * costs that job a read it would have made anyway before decks existed — the
   * worker's second mount is exactly for this.
   */
  private hostBlockFor(job: Job): BlockInput | null {
    const host = this.host.targetMap?.() ?? LEAD_MAP
    // Never at a resolution the reader added (§3.4): the block on screen is the
    // stored rung's balanced values summed, and the mount is the counts summed
    // and balanced by the coarse bins' weights — two estimates of one picture,
    // and a run that took one for some tasks and the other for the rest would
    // disagree with itself.
    if (job.framing.coarsen !== undefined) return null
    return job.target.map === host ? this.host.block() : null
  }
 
  // -------------------------------------------------------------------------
  // Notification
  // -------------------------------------------------------------------------
 
  private setState(taskId: string, state: JobState, throttled = false): void {
    this.states.set(taskId, state)
    if (throttled) this.notifySoon()
    else this.notifyNow()
  }
 
  private notifyNow(): void {
    if (this.trailing !== null) {
      clearTimeout(this.trailing)
      this.trailing = null
    }
    this.lastNotifiedAt = performance.now()
    // Over a copy: a listener that unsubscribes itself while being called — a
    // drawer closing on the very result it was told about — must not shorten
    // the set being walked.
    for (const listener of [...this.listeners]) listener()
  }
 
  /**
   * A progress report, at no more than {@link PROGRESS_NOTIFY_MS}.
   *
   * With a trailing call rather than a bare rate limit: a job whose last report
   * lands inside the window and then works for a minute without another would
   * otherwise leave the panel showing the second-to-last number for that minute.
   */
  private notifySoon(): void {
    const wait = PROGRESS_NOTIFY_MS - (performance.now() - this.lastNotifiedAt)
    if (wait <= 0) {
      this.notifyNow()
      return
    }
    if (this.trailing !== null) return
    this.trailing = setTimeout(() => {
      this.trailing = null
      this.notifyNow()
    }, wait)
  }
}
 
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
 
/**
 * The entries one parameter chose, each with the key its input is filed under.
 *
 * A single-valued parameter files its input under the parameter key, which is
 * what {@link TaskInputs} means by "keyed by the parameter key that chose them".
 * A `multiple` one cannot — five tracks do not fit in one slot — so each is
 * filed under `key:entryId`, which a runner rebuilds from `listParam(params,
 * key)` because that is the list the keys were built from. Keying multiples by
 * entry id alone would collide the moment one task took two multi-selects.
 *
 * Exported because the *shell* has to file its inputs under the same keys and
 * has no runner to do it: `cli/main.ts` opens the files itself and hands the
 * job a `TaskInputs` directly. Two statements of this convention is how the
 * APA's track aggregates came out empty from a command line that named a track
 * — the runner filed it under `tracks:<id>` and the shell under `tracks`.
 */
export function chosenEntries(spec: EntryParam, params: Params): Array<[string, string]> {
  if (spec.multiple === true) {
    return listParam(params, spec.key).map((id): [string, string] => [`${spec.key}:${id}`, id])
  }
  const id = idParam(params, spec.key)
  return id === null ? [] : [[spec.key, id]]
}
 
/**
 * Whether the block is small enough that cloning it beats reading it again.
 *
 * The host is expected to have applied the same limit before making the copy it
 * hands over — `MatrixPanel.analysisBlock` does — and it is checked again here
 * anyway, because the limit protects the frame that performs the structured
 * clone and a second host getting it wrong would show up as a stalled tab rather
 * than as a slow analysis.
 */
function blockWorthCopying(block: BlockInput): boolean {
  return block.values.byteLength <= BLOCK_COPY_LIMIT_BYTES
}
 
/**
 * Whether the block still describes the framing this job was started with.
 *
 * The framing is a snapshot and the block is not: a job that waited behind
 * another one while the reader panned would otherwise be handed the pixels of a
 * different region under the extents of the one it recorded. Where the two
 * disagree the block is simply left off and the worker reads for itself at the
 * file's own resolution, which is what its second mount is for.
 */
function blockMatches(block: BlockInput, framing: AnalysisFraming): boolean {
  return (
    block.rowExtent[0] === framing.rows[0] &&
    block.rowExtent[1] === framing.rows[1] &&
    block.colExtent[0] === framing.cols[0] &&
    block.colExtent[1] === framing.cols[1] &&
    block.binsPerCell === framing.binsPerCell
  )
}
 
/**
 * The framing a track-only run is a record of — or a run on sequences alone,
 * which names them instead.
 *
 * There is no block, so the extents are empty and the bin size is zero — and
 * that is the honest reading rather than a placeholder: the numbers came from
 * the tracks, over the assembly named here, and no region of any matrix was
 * involved. A task in this venue never reads any of these fields; what reads
 * them is the provenance line above an export, which should say so.
 */
function tracksFraming(context: AnalysisContext, read = 'loaded tracks'): AnalysisFraming {
  return {
    uri: context.uri,
    rows: [0, 0],
    cols: [0, 0],
    binsize: context.binsize,
    binsPerCell: 1,
    balance: false,
    value: 'count',
    normalise: null,
    level: 'bin',
    // The file's name rather than its address: a browser session is routinely
    // opened from a signed S3 URL, and the whole of one in a heading pushes the
    // rest of the line off the drawer and off a console row. What the uri is
    // wanted for is `# file:`, which every provenance block already carries.
    label: `${fileLabel(context.uri)} · ${read}, no contact map`,
    // What the file's molecules are declared to be, which a task on the
    // sequence reads as surely as one on the map (§8.15).
    ...(context.circular === undefined ? {} : { circular: [...context.circular] }),
    ...(context.origins === undefined ? {} : { origins: { ...context.origins } }),
  }
}