archaeopteryx 3.16.0 → 3.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/forester.js CHANGED
@@ -20,7 +20,7 @@
20
20
  *
21
21
  */
22
22
 
23
- // v 3.16.0
23
+ // v 3.18.0
24
24
  // 2026-09-17
25
25
  //
26
26
  // forester.js is a general suite for dealing with phylogenetic trees.
@@ -225,17 +225,89 @@
225
225
  throw ("this should never have happened, child node index = " + cni);
226
226
  }
227
227
  let x = p.children[0];
228
- let nbl = undefined;
229
- if (x.branch_length || p.branch_length) {
230
- nbl = (x.branch_length > 0 ? x.branch_length : 0) + (p.branch_length > 0 ? p.branch_length : 0);
228
+ let stated = function (v) {
229
+ return typeof v === 'number' && isFinite(v);
230
+ };
231
+ // What the merged branch is made of, for a clock-model tree's
232
+ // divergence: each piece's span in time and the rate its node
233
+ // stated, kept on x (see divergenceOfPieces). A removed node's
234
+ // rate goes on scaling its own piece, as the desktop keeps it.
235
+ let showsDiv = statesDivergenceUnit(phy);
236
+ let root = forester.getTreeRoot(phy);
237
+ let pieceOf = function (n) {
238
+ if (Array.isArray(n._pieces)) {
239
+ return n._pieces;
240
+ }
241
+ let span = null;
242
+ if (stated(n._timeLength)) {
243
+ span = n._timeLength;
244
+ } else if (showsDiv) {
245
+ let d = auspiceNodeDate(n);
246
+ let pd = auspiceNodeDate(n.parent);
247
+ span = (d !== null && pd !== null) ? timeGapOf(root)(d, pd) : null;
248
+ } else if (stated(n.branch_length)) {
249
+ span = n.branch_length;
250
+ }
251
+ return span === null ? null : [{span: span, rate: clockRateOf(n)}];
252
+ };
253
+ let xs = pieceOf(x);
254
+ let ps = pieceOf(p);
255
+ let pieces = (xs && ps) ? xs.concat(ps) : null;
256
+ // The two lengths add up, sign and all: a stated 0 is a length (a
257
+ // truthiness test once turned two zeros into no length at all),
258
+ // and a negative span stays a negative span, so that a node keeps
259
+ // its date. One length stated and one not: the one stated.
260
+ let nbl = (stated(x.branch_length) && stated(p.branch_length)) ? (x.branch_length + p.branch_length)
261
+ : (stated(x.branch_length) ? x.branch_length : (stated(p.branch_length) ? p.branch_length : undefined));
262
+ // The length kept for the Time | Div switch goes with the branch
263
+ // the same way: x now spans, in time, what it and the node removed
264
+ // above it spanned. Left as it was, x came back from Div short by
265
+ // its parent's length (0.65 years on a real Nextstrain build). A
266
+ // length not kept for either leaves x with none.
267
+ if (Object.prototype.hasOwnProperty.call(x, '_timeLength')
268
+ || Object.prototype.hasOwnProperty.call(p, '_timeLength')) {
269
+ x._timeLength = (stated(x._timeLength) && stated(p._timeLength))
270
+ ? (x._timeLength + p._timeLength) : undefined;
231
271
  }
232
272
  x.parent = pp;
233
273
  pp.children[cni] = x;
234
274
  x.branch_length = nbl;
275
+ if (pieces) {
276
+ x._pieces = pieces;
277
+ } else {
278
+ delete x._pieces;
279
+ }
280
+ // On a captured clock-model tree the cumulative divergence of x,
281
+ // and of everything under it, moves by what the merged branch's
282
+ // divergence is now against what x's was.
283
+ if (pieces && stated(x._divergence) && stated(pp._divergence)) {
284
+ let own = clockRateOf(x);
285
+ if (own !== null) {
286
+ let shift = (pp._divergence + divergenceOfPieces(pieces, own)) - x._divergence;
287
+ if (shift !== 0) {
288
+ forester.preOrderTraversalAll(x, function (n) {
289
+ n._divergence += shift;
290
+ });
291
+ }
292
+ }
293
+ }
235
294
  }
236
295
 
237
296
  };
238
297
 
298
+ // The divergence along a branch made of pieces (see forester.deleteSubtree):
299
+ // each piece's span, where it is positive, times the rate its node stated,
300
+ // or the surviving node's where it stated none. Clamped piece by piece,
301
+ // as each was when it was a branch of its own, so that deleting a node
302
+ // moves nobody in Div.
303
+ function divergenceOfPieces(pieces, ownRate) {
304
+ let sum = 0;
305
+ for (let i = 0; i < pieces.length; ++i) {
306
+ let r = (pieces[i].rate === null || pieces[i].rate === undefined) ? ownRate : pieces[i].rate;
307
+ sum += Math.max(0, pieces[i].span) * r;
308
+ }
309
+ return sum;
310
+ }
239
311
 
240
312
  /**
241
313
  * To re-root a tree object.
@@ -6388,13 +6460,22 @@
6388
6460
  if (typeof title === 'string' && title.trim().length > 0) {
6389
6461
  phy.name = title.trim();
6390
6462
  }
6391
- if (auspiceHasAnyDate(root)) {
6392
- setDeltaBranchLengths(root, null, auspiceNodeDate); // default view = time
6463
+ // A build has no branch lengths of its own, so it opens in the metric
6464
+ // it states COMPLETELY: time when every node states num_date, else
6465
+ // divergence when every node states div, else as it always did (time
6466
+ // if any node is dated, divergence if none is). Joint with the
6467
+ // desktop, 2026-09-29. A num_date is a calendar year, so the gap is
6468
+ // child minus parent, and it keeps its sign.
6469
+ let opensInTime = everyNodeStates(root, auspiceNodeDate)
6470
+ || (!everyNodeStates(root, recordedDiv) && auspiceHasAnyDate(root));
6471
+ if (opensInTime) {
6472
+ setDeltaBranchLengths(root, null, auspiceNodeDate, calendarGap);
6393
6473
  } else {
6394
- // a divergence-only build carries no num_date anywhere; div deltas
6395
- // keep the layout meaningful instead of a cladogram
6396
- setDeltaBranchLengths(root, null, auspiceNodeDiv);
6474
+ setDeltaBranchLengths(root, null, recordedDiv, divergenceGap);
6397
6475
  }
6476
+ // ...and says which, as the desktop's reader does: a saved copy then
6477
+ // states what its branch lengths measure
6478
+ phy.branch_length_unit = opensInTime ? CALENDAR_LENGTH_UNIT : DIVERGENCE_LENGTH_UNIT;
6398
6479
  // A tip keeps its date INTERVAL: on a Nextstrain build it is the
6399
6480
  // sampling-date uncertainty of a sample dated only to its month or
6400
6481
  // year, which is data. (It was dropped here until 2026-09-17 because
@@ -6409,38 +6490,57 @@
6409
6490
  ? node.date.value : null;
6410
6491
  }
6411
6492
 
6412
- // A node's CUMULATIVE divergence from the root. Auspice states it outright
6413
- // as nextstrain:div; every other tree states it as branch lengths, which
6414
- // are the same thing in difference form, so it is summed at load and kept
6415
- // on the node (see forester.captureDivergence). The property wins where it
6416
- // exists, so an Auspice tree behaves exactly as it always did.
6417
- function auspiceNodeDiv(node) {
6493
+ // The divergence a node RECORDS (nextstrain:div), or null. A plain decimal
6494
+ // number and nothing else (parseBeastNumber), as a clock rate is: every
6495
+ // number the switch reads is read one way, on both programs. parseFloat
6496
+ // read one out of the front of "0.005abc", "1,5" and "0x10" -- which the
6497
+ // Nexus reader had never let through, so the same value was a divergence
6498
+ // in a phyloXML file and none in a Nexus one. A NEGATIVE one is a value;
6499
+ // the branch along which it falls is drawn at 0 (divergenceGap).
6500
+ // Christian, 2026-09-29.
6501
+ function recordedDiv(node) {
6418
6502
  if (node.properties) {
6419
6503
  for (let i = 0; i < node.properties.length; ++i) {
6420
6504
  if (node.properties[i].ref === NEXTSTRAIN_PREFIX + 'div') {
6421
- let d = parseFloat(node.properties[i].value);
6422
- return isFinite(d) ? d : null;
6505
+ return parseBeastNumber(node.properties[i].value);
6423
6506
  }
6424
6507
  }
6425
6508
  }
6426
- return (typeof node._divergence === 'number' && isFinite(node._divergence))
6427
- ? node._divergence : null;
6509
+ return null;
6428
6510
  }
6429
6511
 
6430
- function auspiceHasAnyDate(node) {
6512
+ function recordsDivergence(root) {
6431
6513
  let found = false;
6432
- forester.preOrderTraversalAll(node, function (n) {
6433
- if (auspiceNodeDate(n) !== null) {
6514
+ forester.preOrderTraversalAll(root, function (n) {
6515
+ if (!found && recordedDiv(n) !== null) {
6434
6516
  found = true;
6435
6517
  }
6436
6518
  });
6437
6519
  return found;
6438
6520
  }
6439
6521
 
6440
- function auspiceHasAnyDiv(node) {
6522
+ // A node's CUMULATIVE divergence from the root. Auspice states it outright
6523
+ // as nextstrain:div; a BEAST clock-model tree states it as branch length
6524
+ // x rate, summed at load and kept on the node (see forester.
6525
+ // captureDivergence). A tree states it ONE way: where any node records a
6526
+ // divergence nothing is captured, so a node that records none has none.
6527
+ // Until 2026-09-29 such a node fell back to the sum of the loaded lengths,
6528
+ // which on an Auspice tree are YEARS -- a tip whose div of 0.0008 was
6529
+ // missing drew its branch at 0.2794, where the file's own numbers said
6530
+ // 0.0002.
6531
+ function auspiceNodeDiv(node) {
6532
+ let d = recordedDiv(node);
6533
+ if (d !== null) {
6534
+ return d;
6535
+ }
6536
+ return (typeof node._divergence === 'number' && isFinite(node._divergence))
6537
+ ? node._divergence : null;
6538
+ }
6539
+
6540
+ function auspiceHasAnyDate(node) {
6441
6541
  let found = false;
6442
6542
  forester.preOrderTraversalAll(node, function (n) {
6443
- if (auspiceNodeDiv(n) !== null) {
6543
+ if (auspiceNodeDate(n) !== null) {
6444
6544
  found = true;
6445
6545
  }
6446
6546
  });
@@ -6451,42 +6551,209 @@
6451
6551
  // (num_date -> the time view; nextstrain:div -> the divergence view).
6452
6552
  // The root's length is 0, and a node missing the metric (or whose parent
6453
6553
  // misses it) gets 0 -- so a time<->divergence toggle can never leave a
6454
- // stale cross-scale length behind. A (spurious) negative delta clamps to 0.
6455
- function setDeltaBranchLengths(node, parentValue, metricOf) {
6554
+ // stale cross-scale length behind.
6555
+ function setDeltaBranchLengths(node, parentValue, metricOf, gapOf) {
6456
6556
  let v = metricOf(node);
6457
- node.branch_length = (parentValue !== null && v !== null)
6458
- ? Math.max(0, v - parentValue) : 0;
6557
+ node.branch_length = (parentValue !== null && v !== null) ? gapOf(v, parentValue) : 0;
6459
6558
  let children = node.children;
6460
6559
  if (children) {
6461
6560
  for (let i = 0; i < children.length; ++i) {
6462
- setDeltaBranchLengths(children[i], v, metricOf);
6561
+ setDeltaBranchLengths(children[i], v, metricOf, gapOf);
6463
6562
  }
6464
6563
  }
6465
6564
  }
6466
6565
 
6566
+ // A TIME gap keeps its sign. A child dated before its parent is what the
6567
+ // file states -- real Nextstrain builds state it (42 branches in 4 of 11,
6568
+ // one of them by 4.09 years) -- so the length is negative and every node
6569
+ // keeps its own date. Clamped at 0 until 2026-09-29, which also made the
6570
+ // switch lossy: a time tree stating negative lengths came back from Div
6571
+ // with 0 in their place. (A negative length is DRAWN at 0, as it always
6572
+ // was and as the desktop draws it; this is about the value.)
6573
+ function calendarGap(v, parentValue) {
6574
+ return v - parentValue;
6575
+ }
6576
+
6577
+ function ageGap(v, parentValue) {
6578
+ return parentValue - v;
6579
+ }
6580
+
6581
+ // In the direction the tree's dates run: calendar dates increase toward
6582
+ // the tips, ages (a height) toward the root. As the desktop measures it.
6583
+ function timeGapOf(root) {
6584
+ return timeIncreasesTowardTips(root) ? calendarGap : ageGap;
6585
+ }
6586
+
6587
+ // Divergence accumulates: a node recording less than its parent is noise,
6588
+ // not a branch running backwards, and draws at 0.
6589
+ function divergenceGap(v, parentValue) {
6590
+ return Math.max(0, v - parentValue);
6591
+ }
6592
+
6467
6593
  // The time<->divergence plumbing: both metrics are RETAINED on a parsed
6468
6594
  // Auspice tree (the date values + the nextstrain:div properties), so a
6469
6595
  // future display toggle can rewrite the branch lengths from EITHER at any
6470
6596
  // time -- lossless and reversible, and reusing the exact recompute the
6471
6597
  // parser itself used, so the toggle can never drift from the loaded view.
6472
6598
 
6599
+ // Both leave the tree exactly as it was, and answer false, when their
6600
+ // layout cannot state every branch: a branch drawn at 0 because a datum is
6601
+ // MISSING says "nothing happened along this branch", which the file never
6602
+ // said. A datum that is stated is stated, whether zero or negative.
6603
+
6604
+ // What a tree's branch lengths MEASURE is stated on the tree (phyloXML's
6605
+ // branch_length_unit), and each layout stamps its own: the scale bar, the
6606
+ // tree's properties and a saved file then all say the same. Divergence is
6607
+ // in substitutions per site; time is in the unit of the tree's own dates,
6608
+ // or plainly "time" where they state none (BEAST heights). The desktop's
6609
+ // words, byte for byte (BranchLengthLayout.distanceUnit).
6610
+ //
6611
+ // It is also how a clock-model tree saved from the Div view is known for
6612
+ // what it is when it is opened again (see captureDivergence).
6613
+ const DIVERGENCE_LENGTH_UNIT = 'subs/site';
6614
+ const CALENDAR_LENGTH_UNIT = 'year';
6615
+ const PLAIN_TIME_LENGTH_UNIT = 'time';
6616
+
6617
+ // The unit of the first dated node that states one, in preorder, children
6618
+ // in file order; null where none does.
6619
+ function dateUnitOf(root) {
6620
+ let unit = null;
6621
+ (function walk(n) {
6622
+ if (unit !== null) {
6623
+ return;
6624
+ }
6625
+ if (n.date && typeof n.date.unit === 'string' && n.date.unit.trim().length > 0) {
6626
+ unit = n.date.unit.trim();
6627
+ return;
6628
+ }
6629
+ let children = n.children;
6630
+ if (children) {
6631
+ for (let i = 0; i < children.length; ++i) {
6632
+ walk(children[i]);
6633
+ }
6634
+ }
6635
+ }(root));
6636
+ return unit;
6637
+ }
6638
+
6639
+ // `phy` may be the tree or its root; only the tree carries a unit.
6640
+ function stampLengthUnit(phy, root, unit) {
6641
+ if (phy && phy !== root && typeof phy === 'object') {
6642
+ phy.branch_length_unit = unit;
6643
+ }
6644
+ }
6645
+
6646
+ function statesDivergenceUnit(phy) {
6647
+ return !!phy && typeof phy.branch_length_unit === 'string'
6648
+ && phy.branch_length_unit.trim() === DIVERGENCE_LENGTH_UNIT;
6649
+ }
6650
+
6651
+ // Back in TIME a tree gets the lengths it had in time: the ones it
6652
+ // arrived with, kept at load (see captureDivergence). So a file's own
6653
+ // lengths are what is shown, to the digit, where they and its node dates
6654
+ // disagree (carnivore.tree: 1.407 stated on a branch whose two heights are
6655
+ // 1.098 apart). Joint with the desktop (Christian, 2026-09-29). A tree that
6656
+ // kept none -- it did not arrive in time -- is laid out by its date gaps.
6473
6657
  forester.applyTimeBranchLengths = function (phy) {
6474
- setDeltaBranchLengths(forester.getTreeRoot(phy), null, auspiceNodeDate);
6658
+ let root = forester.getTreeRoot(phy);
6659
+ if (root && root._timeLengthsKept === true) {
6660
+ if (!everyLengthWasStated(root)) {
6661
+ return false; // a branch with no kept length: nothing to give back
6662
+ }
6663
+ forester.preOrderTraversalAll(root, function (n) {
6664
+ if (n !== root) { // the root has no branch, and no layout touches what it states
6665
+ n.branch_length = n._timeLength;
6666
+ }
6667
+ });
6668
+ stampLengthUnit(phy, root, dateUnitOf(root) || PLAIN_TIME_LENGTH_UNIT);
6669
+ return true;
6670
+ }
6671
+ if (!root || !everyNodeStates(root, auspiceNodeDate)) {
6672
+ return false;
6673
+ }
6674
+ layOutBelowRoot(root, auspiceNodeDate, timeGapOf(root));
6675
+ stampLengthUnit(phy, root, dateUnitOf(root) || PLAIN_TIME_LENGTH_UNIT);
6676
+ return true;
6475
6677
  };
6476
6678
 
6477
6679
  forester.applyDivergenceBranchLengths = function (phy) {
6478
- setDeltaBranchLengths(forester.getTreeRoot(phy), null, auspiceNodeDiv);
6680
+ let root = forester.getTreeRoot(phy);
6681
+ if (!root || !divergenceStatesEveryBranch(root)) {
6682
+ return false;
6683
+ }
6684
+ layOutBelowRoot(root, auspiceNodeDiv, divergenceGap);
6685
+ stampLengthUnit(phy, root, DIVERGENCE_LENGTH_UNIT);
6686
+ return true;
6479
6687
  };
6480
6688
 
6689
+ // The root has no branch to lay out: it keeps the length it has, or none.
6690
+ // Given a length of 0 by the switch, a tree saved from Div stated a root
6691
+ // branch its file never had, and the desktop's copy of it did not.
6692
+ function layOutBelowRoot(root, metricOf, gapOf) {
6693
+ let had = Object.prototype.hasOwnProperty.call(root, 'branch_length');
6694
+ let length = root.branch_length;
6695
+ setDeltaBranchLengths(root, null, metricOf, gapOf);
6696
+ if (had) {
6697
+ root.branch_length = length;
6698
+ } else {
6699
+ delete root.branch_length;
6700
+ }
6701
+ }
6702
+
6703
+ function everyNodeStates(root, metricOf) {
6704
+ let every = true;
6705
+ forester.preOrderTraversalAll(root, function (n) {
6706
+ if (every && metricOf(n) === null) {
6707
+ every = false;
6708
+ }
6709
+ });
6710
+ return every;
6711
+ }
6712
+
6713
+ // Recorded on every node (the root too: it is the parent its children are
6714
+ // measured from), or derived from a clock rate on every branch and a
6715
+ // length every one of which the file stated. A tree with neither has no
6716
+ // divergence to lay out: its branch lengths are what they are.
6717
+ function divergenceStatesEveryBranch(root) {
6718
+ if (recordsDivergence(root)) {
6719
+ return everyNodeStates(root, recordedDiv);
6720
+ }
6721
+ return root._divergenceFromRates === true && root._everyLengthStated === true;
6722
+ }
6723
+
6724
+ // Asked at load where it was (captureDivergence): once the switch has been
6725
+ // pressed every branch has a length, whatever the file stated.
6726
+ function everyLengthWasStated(root) {
6727
+ return (root._everyLengthStated === undefined) ? everyBranchStatesLength(root)
6728
+ : (root._everyLengthStated === true);
6729
+ }
6730
+
6481
6731
  /**
6482
- * Records each node's cumulative divergence from the root, summed from the
6483
- * branch lengths AS LOADED, so the divergence view survives the time view
6484
- * overwriting `branch_length`. Kept in `_divergence`, which is private:
6485
- * no writer emits it, and a re-read of our own output re-derives it.
6732
+ * Keeps, at load, what the Time | Div switch needs to be lossless:
6733
+ *
6734
+ * - the branch lengths the tree ARRIVED with, where they are its time
6735
+ * (`_timeLength`): on a BEAST clock-model tree always, and on a tree
6736
+ * recording its divergence when its lengths are its date gaps. Back in
6737
+ * Time the tree gets exactly these;
6738
+ * - on a clock-model tree -- a clock rate on every branch -- each node's
6739
+ * cumulative divergence from the root (`_divergence`), summed from
6740
+ * those lengths times each branch's rate.
6741
+ *
6742
+ * Both are private: no writer emits them, and a re-read of our own output
6743
+ * re-derives them. Nothing is kept on a tree with neither a recorded
6744
+ * divergence nor rates: it has no second layout, and the switch is never
6745
+ * offered on it.
6486
6746
  *
6487
- * A tree whose branch lengths ARE its dates (an Auspice time tree, say)
6488
- * gets the snapshot too; it is `hasTimeAndDivergence` that decides whether
6489
- * the two views differ enough to be worth offering.
6747
+ * A tree may ARRIVE showing divergence: it was saved while Div was on
6748
+ * screen. One that records its divergence is known by its lengths, which
6749
+ * are not its date gaps. A clock-model tree is known by what it states --
6750
+ * the unit of its branch lengths is the one the divergence layout stamps
6751
+ * -- since its lengths are not its date gaps even in time. Such a tree
6752
+ * keeps no time lengths (it carries none): its time is laid out from its
6753
+ * dates, and its divergence is each rate times that. Until 2026-09-29 a
6754
+ * clock-model tree's lengths were taken for its time whatever they were,
6755
+ * and one saved from Div came back labelled Time, with a Div of rate x
6756
+ * divergence. Joint with the desktop.
6490
6757
  *
6491
6758
  * Call once per tree at load, before anything rewrites a branch length.
6492
6759
  *
@@ -6497,77 +6764,152 @@
6497
6764
  if (!root) {
6498
6765
  return;
6499
6766
  }
6767
+ root._divergenceFromRates = false;
6768
+ root._timeLengthsKept = false;
6769
+ root._everyLengthStated = everyBranchStatesLength(root);
6770
+ // A tree that RECORDS its divergence states it that way and no other:
6771
+ // none is derived beside it, so a node that records none has none (see
6772
+ // auspiceNodeDiv).
6773
+ let recorded = recordsDivergence(root);
6774
+ // A BEAST clock-model tree states its branch lengths in TIME and each
6775
+ // branch's clock rate beside it, so its divergence is length x rate
6776
+ // (substitutions per site). Only when EVERY branch states a rate: a
6777
+ // partial one would mix substitutions with years along a path.
6778
+ let rates = recorded ? null : everyBranchClockRate(root);
6779
+ if (!recorded && rates === null) {
6780
+ return;
6781
+ }
6782
+ let showsDivergence = rates !== null && statesDivergenceUnit(phy);
6783
+ let keep = (rates !== null && !showsDivergence) || (recorded && branchLengthsAreTime(root));
6784
+ let gapOf = showsDivergence ? timeGapOf(root) : null;
6500
6785
  (function walk(node, cumulative) {
6501
- node._divergence = cumulative;
6786
+ if (keep) {
6787
+ node._timeLength = node.branch_length;
6788
+ }
6789
+ if (rates) {
6790
+ node._divergence = cumulative;
6791
+ }
6502
6792
  let children = node.children;
6503
6793
  if (children) {
6504
6794
  for (let i = 0; i < children.length; ++i) {
6505
- let bl = children[i].branch_length;
6506
- let step = (typeof bl === 'number' && isFinite(bl) && bl > 0) ? bl : 0;
6795
+ // the branch's time: the length it arrived with, or, on
6796
+ // a tree that arrived showing divergence, its date gap
6797
+ let span = children[i].branch_length;
6798
+ if (gapOf) {
6799
+ let d = auspiceNodeDate(children[i]);
6800
+ let pd = auspiceNodeDate(node);
6801
+ span = (d !== null && pd !== null) ? gapOf(d, pd) : 0;
6802
+ }
6803
+ // a span of 0, or a negative one, adds no divergence; a
6804
+ // branch made of pieces (a node deleted above it) adds
6805
+ // each piece's
6806
+ let step = 0;
6807
+ if (rates && Array.isArray(children[i]._pieces)) {
6808
+ step = divergenceOfPieces(children[i]._pieces, rates.get(children[i]));
6809
+ } else if (rates && typeof span === 'number' && isFinite(span) && span > 0) {
6810
+ step = span * rates.get(children[i]);
6811
+ }
6507
6812
  walk(children[i], cumulative + step);
6508
6813
  }
6509
6814
  }
6510
6815
  }(root, 0));
6816
+ root._divergenceFromRates = rates !== null;
6817
+ root._timeLengthsKept = keep;
6511
6818
  };
6512
6819
 
6513
- // How much the tree's SHAPE would change between the two metrics, as a
6514
- // fraction of its width: each tip's distance from the root under each
6515
- // metric, normalised by the deepest tip, and the largest disagreement.
6820
+ // A length of 0, or a negative one, is a length the file stated; one that
6821
+ // is not there is not.
6822
+ function everyBranchStatesLength(root) {
6823
+ let every = true;
6824
+ forester.preOrderTraversalAll(root, function (n) {
6825
+ if (every && n !== root && !(typeof n.branch_length === 'number' && isFinite(n.branch_length))) {
6826
+ every = false;
6827
+ }
6828
+ });
6829
+ return every;
6830
+ }
6831
+
6832
+ // A node's BEAST clock rate (the `rate` annotation) as everyBranchClockRate
6833
+ // reads it: a plain decimal, not negative; else null.
6834
+ function clockRateOf(n) {
6835
+ if (n.properties) {
6836
+ for (let i = 0; i < n.properties.length; ++i) {
6837
+ if (n.properties[i].ref === BEAST_PREFIX + 'rate') {
6838
+ let r = parseBeastNumber(n.properties[i].value);
6839
+ return (r === null || r < 0) ? null : r;
6840
+ }
6841
+ }
6842
+ }
6843
+ return null;
6844
+ }
6845
+
6846
+ // Each non-root node's BEAST clock rate (the `rate` annotation), or null
6847
+ // unless every branch states a finite, non-negative one. A rate is a plain
6848
+ // decimal number and nothing else (parseBeastNumber): parseFloat reads a
6849
+ // number out of the front of "0.01abc" or "1,5", and a rate nobody wrote
6850
+ // would then scale a branch.
6851
+ function everyBranchClockRate(root) {
6852
+ let rates = new Map();
6853
+ let ok = true;
6854
+ let branches = 0;
6855
+ forester.preOrderTraversalAll(root, function (n) {
6856
+ if (!ok || n === root) {
6857
+ return;
6858
+ }
6859
+ ++branches;
6860
+ let r = null;
6861
+ if (n.properties) {
6862
+ for (let i = 0; i < n.properties.length; ++i) {
6863
+ if (n.properties[i].ref === BEAST_PREFIX + 'rate') {
6864
+ r = parseBeastNumber(n.properties[i].value);
6865
+ break;
6866
+ }
6867
+ }
6868
+ }
6869
+ if (r === null || r < 0) {
6870
+ ok = false;
6871
+ return;
6872
+ }
6873
+ rates.set(n, r);
6874
+ });
6875
+ return (ok && branches > 0) ? rates : null;
6876
+ }
6877
+
6878
+ // Whether each picture has any DEPTH: some tip away from the root, in
6879
+ // time and in divergence. A tree whose tips all carry the root's date has
6880
+ // no time picture, and one whose divergence is 0 on every branch (every
6881
+ // rate 0, say) has no divergence picture: a switch to a tree drawn as a
6882
+ // point is not offered. Joint with the desktop (Christian, 2026-09-29:
6883
+ // "Always offer; refuse only zero").
6516
6884
  //
6517
- // Comparing branch lengths PAIR BY PAIR is the wrong question and gave the
6518
- // wrong answer: on influenza.tree the branch lengths differ from the date
6519
- // gaps by a median of 7% per branch, which looks like a separate measure,
6520
- // but the differences cancel along every path and all 687 tips land within
6521
- // 0.3% of where the other metric puts them. Its branch lengths ARE time --
6522
- // a BEAST time tree states time -- so a toggle would redraw one picture
6523
- // twice. A real Nextstrain build, where divergence is genuinely a
6524
- // different measurement, moves tips by 24.8% of the tree's width.
6885
+ // Until then the switch was offered only where the two pictures differed
6886
+ // by more than 2% of the tree's width at some tip, which refused a strict
6887
+ // clock, whose divergence is its time rescaled. One rule for both
6888
+ // programs replaced a threshold both would have had to justify.
6525
6889
  //
6526
6890
  // Both metrics are read WITHOUT touching branch_length, so this can be
6527
- // asked at load without disturbing the tree.
6528
- function layoutShiftBetweenMetrics(root) {
6891
+ // asked at load without disturbing the tree. Depth is taken at the tips,
6892
+ // summed along the path as the layout would draw it.
6893
+ function bothPicturesHaveDepth(root) {
6529
6894
  let rootDate = auspiceNodeDate(root);
6530
- let time = [];
6531
- let div = [];
6532
- let ok = true;
6533
- (function walk(n, t, d) {
6534
- let nt = auspiceNodeDate(n);
6895
+ let timeDepth = 0;
6896
+ let divDepth = 0;
6897
+ (function walk(n, parentDiv, d) {
6535
6898
  let nd = auspiceNodeDiv(n);
6536
- let tHere = (nt !== null && rootDate !== null) ? Math.abs(nt - rootDate) : t;
6537
- let dHere = (nd !== null) ? nd : d;
6899
+ let dHere = (n === root || nd === null || parentDiv === null) ? d : (d + divergenceGap(nd, parentDiv));
6538
6900
  if (!n.children || n.children.length === 0) {
6539
- if (nt === null || nd === null) {
6540
- ok = false;
6901
+ let nt = auspiceNodeDate(n);
6902
+ if (nt !== null && rootDate !== null) {
6903
+ timeDepth = Math.max(timeDepth, Math.abs(nt - rootDate));
6541
6904
  }
6542
- time.push(tHere);
6543
- div.push(dHere);
6905
+ divDepth = Math.max(divDepth, dHere);
6544
6906
  return;
6545
6907
  }
6546
6908
  for (let i = 0; i < n.children.length; ++i) {
6547
- walk(n.children[i], tHere, dHere);
6909
+ walk(n.children[i], nd, dHere);
6548
6910
  }
6549
- }(root, 0, 0));
6550
- if (!ok || time.length === 0) {
6551
- return 0;
6552
- }
6553
- let tMax = Math.max.apply(null, time.length > 50000 ? time.slice(0, 50000) : time);
6554
- let dMax = Math.max.apply(null, div.length > 50000 ? div.slice(0, 50000) : div);
6555
- if (!(tMax > 0) || !(dMax > 0)) {
6556
- return 0;
6557
- }
6558
- let worst = 0;
6559
- for (let i = 0; i < time.length; ++i) {
6560
- worst = Math.max(worst, Math.abs((time[i] / tMax) - (div[i] / dMax)));
6561
- }
6562
- return worst;
6563
- }
6564
-
6565
- // Below this the two views are the same picture and the switch is noise.
6566
- // influenza.tree measures 0.003, nextstrain-ncov.json 0.248.
6567
- const BRANCH_METRIC_SHIFT_MIN = 0.02;
6568
-
6569
- function divergenceDiffersFromTime(root) {
6570
- return layoutShiftBetweenMetrics(root) > BRANCH_METRIC_SHIFT_MIN;
6911
+ }(root, null, 0));
6912
+ return timeDepth > 0 && divDepth > 0;
6571
6913
  }
6572
6914
 
6573
6915
  // Which metric the CURRENT branch lengths hold -- a different question
@@ -6578,6 +6920,7 @@
6578
6920
  function branchLengthsAreTime(root) {
6579
6921
  let pairs = 0;
6580
6922
  let same = 0;
6923
+ let gapOf = timeGapOf(root);
6581
6924
  forester.preOrderTraversalAll(root, function (n) {
6582
6925
  if (!n.children) {
6583
6926
  return;
@@ -6594,8 +6937,11 @@
6594
6937
  continue;
6595
6938
  }
6596
6939
  ++pairs;
6597
- let scale = Math.max(Math.abs(bl), Math.abs(cv - pv), 1e-9);
6598
- if (Math.abs(Math.abs(cv - pv) - bl) <= (scale * 1e-6)) {
6940
+ // signed, as the time view writes it: a length the file
6941
+ // states negative is its date gap too
6942
+ let gap = gapOf(cv, pv);
6943
+ let scale = Math.max(Math.abs(bl), Math.abs(gap), 1e-9);
6944
+ if (Math.abs(gap - bl) <= (scale * 1e-6)) {
6599
6945
  ++same;
6600
6946
  }
6601
6947
  }
@@ -6603,13 +6949,14 @@
6603
6949
  return pairs > 0 && (same * 20) >= (pairs * 19);
6604
6950
  }
6605
6951
 
6606
- // Whether the dates run the way the time view needs them to: a CALENDAR
6607
- // date increases toward the tips, and setDeltaBranchLengths takes
6608
- // child - parent and clamps at 0. A BEAST height runs the other way -- it
6609
- // is an age, largest at the root -- so a tree still stating heights would
6610
- // get a branch length of 0 everywhere and collapse to a point. Measured,
6611
- // not assumed: forester.convertHeightsToDates turns heights into calendar
6612
- // dates, and a tree it REFUSED still states ages.
6952
+ // Which way the tree's dates run: a CALENDAR date increases toward the
6953
+ // tips; a BEAST height runs the other way -- it is an age, largest at the
6954
+ // root. Measured, not assumed, by the majority of the parent-child pairs
6955
+ // that differ (a tie, or none, reads as ages): forester.
6956
+ // convertHeightsToDates turns heights into calendar dates, and a tree it
6957
+ // REFUSED still states ages. The time view takes its gaps in this
6958
+ // direction (timeGapOf), so the few pairs that run AGAINST it come out
6959
+ // negative.
6613
6960
  function timeIncreasesTowardTips(root) {
6614
6961
  let up = 0;
6615
6962
  let pairs = 0;
@@ -6647,31 +6994,52 @@
6647
6994
  */
6648
6995
  forester.branchLengthScale = function (phy) {
6649
6996
  let root = forester.getTreeRoot(phy);
6650
- return (root && branchLengthsAreTime(root)) ? 'time' : 'divergence';
6997
+ if (!root) {
6998
+ return 'divergence';
6999
+ }
7000
+ // A tree that kept the lengths it arrived with is in time exactly
7001
+ // while they are the ones on it (the switch back restores them).
7002
+ if (root._timeLengthsKept === true) {
7003
+ let kept = true;
7004
+ forester.preOrderTraversalAll(root, function (n) {
7005
+ if (n !== root && n.branch_length !== n._timeLength) {
7006
+ kept = false;
7007
+ }
7008
+ });
7009
+ return kept ? 'time' : 'divergence';
7010
+ }
7011
+ return branchLengthsAreTime(root) ? 'time' : 'divergence';
6651
7012
  };
6652
7013
 
6653
7014
  /**
6654
- * True when the tree carries BOTH a time signal (dated nodes, running the
6655
- * calendar way) and a divergence signal that says something different, so
6656
- * a time <-> divergence toggle is meaningful. Auspice states divergence as
6657
- * nextstrain:div; a BEAST tree states it as its branch lengths, which on a
6658
- * real one differ from the dates on nearly every branch (influenza.tree:
6659
- * 1344 of 1372).
7015
+ * True when the tree can be laid out by TIME and by DIVERGENCE. The rule
7016
+ * is joint with the desktop (Christian, 2026-09-29):
7017
+ *
7018
+ * - it states a divergence: recorded on its nodes (nextstrain:div), or a
7019
+ * clock rate on every branch (BEAST; divergence is length x rate, and
7020
+ * the lengths are its time). A tree with neither is never offered the
7021
+ * switch, whatever its dates say;
7022
+ * - BOTH layouts can state EVERY branch: every node is dated, every
7023
+ * branch states a length, and every node records its divergence (the
7024
+ * root too) or every branch states a rate. A value that is stated is
7025
+ * stated, whether zero or negative; only an absent one is missing. A
7026
+ * branch is never drawn at 0 because a datum is missing;
7027
+ * - each picture has some depth (bothPicturesHaveDepth).
7028
+ *
7029
+ * Otherwise the switch is not offered and the tree stays in the layout it
7030
+ * arrived in. Whether the two pictures DIFFER is not asked, nor which way
7031
+ * the dates run.
6660
7032
  *
6661
7033
  * @param phy the tree
6662
7034
  * @returns {boolean}
6663
7035
  */
6664
7036
  forester.hasTimeAndDivergence = function (phy) {
6665
7037
  let root = forester.getTreeRoot(phy);
6666
- if (!root || !auspiceHasAnyDate(root) || !timeIncreasesTowardTips(root)) {
7038
+ if (!root || !everyNodeStates(root, auspiceNodeDate) || !everyLengthWasStated(root)
7039
+ || !divergenceStatesEveryBranch(root)) {
6667
7040
  return false;
6668
7041
  }
6669
- // auspiceHasAnyDiv is true for every tree once the divergence has been
6670
- // captured, so it cannot decide this on its own -- it only says a
6671
- // divergence measure EXISTS. Whether it says anything different is the
6672
- // shift test, and that is the question worth asking of both kinds of
6673
- // tree.
6674
- return auspiceHasAnyDiv(root) && divergenceDiffersFromTime(root);
7042
+ return bothPicturesHaveDepth(root);
6675
7043
  };
6676
7044
 
6677
7045
  // A number that is not NaN. It used to test only for null, undefined and