@keepsnappy/agent 0.1.0-alpha.18 → 0.1.0-alpha.20

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.
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // apps/agent/src/cli.ts
4
- import { resolve as resolve8 } from "node:path";
4
+ import { resolve as resolve9 } from "node:path";
5
5
  import { parseArgs as parseArgs4 } from "node:util";
6
6
 
7
7
  // apps/agent/src/config.ts
@@ -1953,6 +1953,16 @@ var MEASURES = {
1953
1953
  decimals: 1,
1954
1954
  what: "The part of the slowest screen update that React spent outside every component's own render, in its own bookkeeping"
1955
1955
  },
1956
+ jank_ms: {
1957
+ unit: "ms",
1958
+ worse: "higher",
1959
+ at: ["screen", "launch", "press", "drag", "step", "other", "background"],
1960
+ producer: "tracesift",
1961
+ label: "Jank time",
1962
+ short: "jank time",
1963
+ decimals: 1,
1964
+ what: "The time the window's screen updates spent past one 60 Hz frame, added up, read from the full React export"
1965
+ },
1956
1966
  commits_over_2_frames: {
1957
1967
  unit: "count",
1958
1968
  worse: "higher",
@@ -3598,6 +3608,7 @@ function occurrence(windows, budgetMs, idleNeutral = false, frameMs, upper = fal
3598
3608
  ...upper ? { timerCarriedMs: carried, renderMsUpper: renderMs === undefined || carried === undefined ? undefined : renderMs + carried } : {}
3599
3609
  };
3600
3610
  }
3611
+ var SAME_COMMIT_MS = 0.01;
3601
3612
  var UPDATERS_KEPT = 5;
3602
3613
  var CPU_FUNCTIONS_KEPT = 10;
3603
3614
  var UNTIMED_COMPONENTS_KEPT = 10;
@@ -3736,7 +3747,9 @@ function profileMetrics(windows, steps, options = {}) {
3736
3747
  const selected = middleBy((active.length ? active : occ).filter((o) => o.window), (o) => o.commitMaxMs, (o) => num2(o.window.raw.id) ?? 0, (o) => num2(o.window.raw.t0) ?? o.window.startWall);
3737
3748
  const identity = options.commitIdentity && metrics.has(`${key}.commit_max_ms`) && COMMIT_IDENTITY_KINDS.includes(kind) && selected?.slowestCommit;
3738
3749
  const openedBy = selected?.window ? options.openedBy?.get(selected.window) : undefined;
3739
- profiles.push({ ...profile, ...identity ? { slowestCommit: identity } : {}, ...openedBy ? { openedBy } : {} });
3750
+ const streamed = identity ? options.commitTops?.get(identity.seq) : undefined;
3751
+ const tops = identity && streamed && Math.abs(streamed.ms - identity.ms) <= SAME_COMMIT_MS ? streamed : undefined;
3752
+ profiles.push({ ...profile, ...identity ? { slowestCommit: { ...identity, ...tops?.top ? { top: tops.top } : {}, ...tops?.updater ? { updater: tops.updater } : {} } } : {}, ...openedBy ? { openedBy } : {} });
3740
3753
  }
3741
3754
  return { metrics, profiles };
3742
3755
  }
@@ -5012,7 +5025,7 @@ function samplerFrameBudgetMs(samples) {
5012
5025
  }
5013
5026
  return hz > 1 ? frameBudgetMs(Math.round(hz)) : undefined;
5014
5027
  }
5015
- function profileOptions(windows, version, processWindows = windows) {
5028
+ function profileOptions(windows, version, processWindows = windows, commitStreams = []) {
5016
5029
  if (version < 26)
5017
5030
  return {};
5018
5031
  const processes = new Set(processWindows.map((w) => w.raw.react?.causes?.process).filter((p) => typeof p === "string"));
@@ -5026,7 +5039,9 @@ function profileOptions(windows, version, processWindows = windows) {
5026
5039
  if (opener)
5027
5040
  openedBy.set(screen, opener.name);
5028
5041
  }
5029
- return { fullRowMs: FULL_ROW_MS, commitIdentity: processes.size <= 1, openedBy };
5042
+ const [only] = processes.size === 1 ? processes : [];
5043
+ const commitTops = new Map(commitStreams.filter((s) => s.process === only).flatMap((s) => s.commits.map((c) => [c.seq, c])));
5044
+ return { fullRowMs: FULL_ROW_MS, commitIdentity: processes.size <= 1, ...commitTops.size ? { commitTops } : {}, openedBy };
5030
5045
  }
5031
5046
  function deriveRun(input) {
5032
5047
  const { run, markers = [], ri, clockOffsetMs, clockErrorMs, samples = [], memWindow } = input;
@@ -5049,7 +5064,7 @@ function deriveRun(input) {
5049
5064
  return { rows, steps, run: final, profiles: [], derivationVersion: version };
5050
5065
  const budget = input.frameBudgetMs ?? (version >= 6 ? samplerFrameBudgetMs(samplesInRun(samples, final.t0, final.t1)) : undefined);
5051
5066
  const compareMs = version >= 20 ? LIMIT_FRAME_MS : budget ?? frameBudgetMs();
5052
- const allComponents = profileOptions(windows, version, input.windows);
5067
+ const allComponents = profileOptions(windows, version, input.windows, input.commitStreams);
5053
5068
  const prof = profileMetrics(windows, steps, {
5054
5069
  ...allComponents,
5055
5070
  frameBudgetMs: version >= 11 ? compareMs : Number(compareMs.toFixed(1)),
@@ -5203,6 +5218,26 @@ var markerEnv = (url, runId) => ({
5203
5218
 
5204
5219
  // packages/present/dist/src/capital.js
5205
5220
  var upperFirst = (s) => s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
5221
+ var lowerFirst = (s) => s ? s.charAt(0).toLowerCase() + s.slice(1) : s;
5222
+
5223
+ // packages/present/dist/src/names.js
5224
+ var textPart = (text) => ({ text });
5225
+ var componentPart = (text) => ({ text, component: true });
5226
+ var namePartsText = (parts) => parts.map((p) => p.text).join("");
5227
+ var plainParts = (text) => [textPart(text)];
5228
+ function nameParts(parts) {
5229
+ const out = [];
5230
+ for (const p of parts) {
5231
+ if (!p.text)
5232
+ continue;
5233
+ const last = out.at(-1);
5234
+ if (last && !last.component && !p.component)
5235
+ out[out.length - 1] = textPart(last.text + p.text);
5236
+ else
5237
+ out.push(p);
5238
+ }
5239
+ return out;
5240
+ }
5206
5241
 
5207
5242
  // packages/present/dist/src/placeWords.js
5208
5243
  var GENERIC_GROUPS = new Set(["app", "tabs", "root"]);
@@ -5239,6 +5274,7 @@ var STATE_WORDS = {
5239
5274
  "no-change": "No change",
5240
5275
  "cant-tell": "Can’t tell"
5241
5276
  };
5277
+ var STATE_ORDER = ["worse", "warning", "cant-tell", "better", "no-change"];
5242
5278
  var INFO_WORDS = { heat: "Phone heated up" };
5243
5279
  var isWorseState = (state) => state === "worse";
5244
5280
  var NOT_BLOCKING = "not blocking";
@@ -5247,6 +5283,7 @@ var notBlockingBadge = (word, blocking) => word === STATE_WORDS.worse && blockin
5247
5283
  var BLOCKED_WORD = "Blocked";
5248
5284
  var NOT_COMPUTED_WORD = "Verdict not computed yet (computed on demand)";
5249
5285
  var NO_BASELINE_WORD = "Waiting for main";
5286
+ var NOT_COMPARED_WORD = "Not compared with main";
5250
5287
  var NOT_JUDGED_WORD = "Not judged";
5251
5288
 
5252
5289
  // packages/present/dist/src/values.js
@@ -5310,6 +5347,13 @@ function fieldValueText(field, v) {
5310
5347
  const def = fieldDef(field.split(KEY_SEPARATOR)[0]);
5311
5348
  return format(def?.unit, def?.decimals ?? FALLBACK_DECIMALS, v);
5312
5349
  }
5350
+ function timeMove(field, a, b) {
5351
+ if (a === null || b === null || !(a > 0))
5352
+ return null;
5353
+ const breakdown = breakdownMeasure(field);
5354
+ const unit = breakdown ? BREAKDOWN_MEASURES[breakdown].unit : fieldDef(field.split(KEY_SEPARATOR)[0])?.unit;
5355
+ return unit === "ms" ? Math.abs(b - a) / a : null;
5356
+ }
5313
5357
 
5314
5358
  // packages/present/dist/src/rowWords.js
5315
5359
  var GONE_TAILS = {
@@ -5364,16 +5408,19 @@ function leadBody(subject, measure, main, pr) {
5364
5408
  var screenLeadText = (tier, subject, measure, main, pr) => `${STATE_WORDS[TIER_STATE[tier]]}: ${leadBody(subject, measure, main, pr)}`;
5365
5409
  var FOR_INFORMATION = "For information";
5366
5410
  var informationLeadText = (subject, measure, main, pr) => `${FOR_INFORMATION}: ${leadBody(subject, measure, main, pr)}`;
5367
- function foldedText(n, rows, page) {
5368
- return `${n} ${GONE_TAILS[page][rows === "components" ? "removed" : "notInRun"]}`;
5369
- }
5370
5411
  function placeShownName(p, o = {}) {
5371
- const target = p.target ? componentShownName(p.target.name, p.target.file) : libraryViewName(p.kind, p.host ?? "");
5372
- const named = p.target && p.parent ? `${target} in ${componentShownName(p.parent.name, p.parent.file)}${p.k ? `, ${ordinal(p.k)}` : ""}` : target;
5373
- return o.onScreen ? `${named} on ${o.onScreen}` : named;
5412
+ return namePartsText(placeShownNameParts(p, o));
5413
+ }
5414
+ function placeShownNameParts(p, o = {}) {
5415
+ const target = p.target ? componentNameParts(p.target.name, p.target.file) : plainParts(libraryViewName(p.kind, p.host ?? ""));
5416
+ const named = p.target && p.parent ? [...target, textPart(" in "), ...componentNameParts(p.parent.name, p.parent.file), textPart(p.k ? `, ${ordinal(p.k)}` : "")] : target;
5417
+ const screen = typeof o.onScreen === "string" ? plainParts(o.onScreen) : o.onScreen;
5418
+ return nameParts(screen?.length && namePartsText(screen) ? [...named, textPart(" on "), ...screen] : named);
5374
5419
  }
5375
5420
  var libraryViewName = (kind, hostType) => `${kind === "drag" ? "Scroll" : "Tap"} outside app components (${hostType})`;
5376
- function componentShownName(name, file) {
5421
+ var componentShownName = (name, file) => componentFileName(name, file);
5422
+ var componentNameParts = (name, file) => [componentPart(componentFileName(name, file))];
5423
+ function componentFileName(name, file) {
5377
5424
  if (name !== "default" || !file)
5378
5425
  return name;
5379
5426
  const path = file.replace(/\.[^.\/]+$/, "");
@@ -5427,21 +5474,49 @@ var placeOpenWord = (id) => {
5427
5474
  const open = placeOpen(id);
5428
5475
  return open ? OPEN_WORDS[open] : null;
5429
5476
  };
5430
- function placeIdShownName(id, o = {}) {
5477
+ var TWIN2 = /\+3s$/;
5478
+ function foldTwins(rows, placeOf, also = () => "") {
5479
+ const seen = new Set;
5480
+ const out = rows.filter((r) => {
5481
+ const key = `${placeOf(r).replace(TWIN2, "")}\x00${also(r)}`;
5482
+ if (seen.has(key))
5483
+ return false;
5484
+ seen.add(key);
5485
+ return true;
5486
+ });
5487
+ return { rows: out, folded: rows.length - out.length };
5488
+ }
5489
+ var foldedTwinsText = (n) => `${n} “${PLACE_KIND_WORDS.after3s}” ${n === 1 ? "twin" : "twins"} folded into ${n === 1 ? "its place" : "their places"}`;
5490
+ function placeIdShownNameParts(id, o = {}) {
5431
5491
  const p = placeParts(id);
5432
5492
  if (!p)
5433
5493
  return null;
5434
5494
  const component = (part) => ({ name: part.name, file: part.file ?? "" });
5435
- const screen = p.screen.name === "(no screen)" ? "" : p.screen.name === "(outside app components)" ? screenName(p.screen.file ?? "") : componentShownName(p.screen.name, p.screen.file ?? "");
5495
+ const screen = p.screen.name === "(no screen)" ? [] : p.screen.name === "(outside app components)" ? [textPart(screenName(p.screen.file ?? ""))] : componentNameParts(p.screen.name, p.screen.file ?? "");
5436
5496
  if (p.kind === "screen" || p.kind === "launch")
5437
- return screen + (p.kind === "screen" && typeof p.visit === "number" && p.visit > 1 ? ` (${ordinal(p.visit)} visit)` : "");
5438
- return placeShownName({ kind: p.kind, target: p.outsideHost === null ? component(p.target) : null, parent: p.parent ? component(p.parent) : null, k: p.k, ...p.outsideHost === null ? {} : { host: p.outsideHost } }, { onScreen: o.onScreen === false ? undefined : screen });
5497
+ return nameParts([...screen, textPart(p.kind === "screen" && typeof p.visit === "number" && p.visit > 1 ? ` (${ordinal(p.visit)} visit)` : "")]);
5498
+ return placeShownNameParts({ kind: p.kind, target: p.outsideHost === null ? component(p.target) : null, parent: p.parent ? component(p.parent) : null, k: p.k, ...p.outsideHost === null ? {} : { host: p.outsideHost } }, { onScreen: o.onScreen === false ? undefined : screen });
5439
5499
  }
5440
5500
  var isLibraryPlace = (id) => placeParts(id)?.outsideHost != null;
5441
- function stemName(stem, kind) {
5442
- const shown = placeIdShownName(stem);
5501
+ function dropTail(parts, n) {
5502
+ const out = [];
5503
+ for (const p of [...parts].reverse()) {
5504
+ if (n >= p.text.length) {
5505
+ n -= p.text.length;
5506
+ continue;
5507
+ }
5508
+ out.unshift({ ...p, text: p.text.slice(0, p.text.length - n) });
5509
+ n = 0;
5510
+ }
5511
+ return out;
5512
+ }
5513
+ function stemNameParts(stem, kind) {
5514
+ const shown = placeIdShownNameParts(stem);
5443
5515
  if (shown !== null)
5444
- return isLibraryPlace(stem) ? shown : `${KIND_WORD[kind]}: ${shown}`;
5516
+ return nameParts(isLibraryPlace(stem) ? shown : [textPart(`${KIND_WORD[kind]}: `), ...shown]);
5517
+ return plainParts(stemText(stem, kind));
5518
+ }
5519
+ function stemText(stem, kind) {
5445
5520
  if (kind === "journey")
5446
5521
  return PLACE_KIND_WORDS.journey;
5447
5522
  if (kind === "other")
@@ -5457,12 +5532,13 @@ function stemName(stem, kind) {
5457
5532
  const visit = visitMark ? Number(visitMark[2]) : 1;
5458
5533
  return `${PLACE_KIND_WORDS.screen}: ${screenName(visitMark ? visitMark[1] : rest)}${visit > 1 ? ` (${ordinal(visit)} visit)` : ""}`;
5459
5534
  }
5460
- function windowName(id) {
5535
+ var windowName = (id) => namePartsText(windowNameParts(id));
5536
+ function windowNameParts(id) {
5461
5537
  if (id === "flow")
5462
- return PLACE_KIND_WORDS.journey;
5538
+ return plainParts(PLACE_KIND_WORDS.journey);
5463
5539
  const ref = placeRef(id);
5464
- const name = stemName(ref.stem, ref.kind);
5465
- return ref.segment === "after3s" ? `${name} · ${PLACE_KIND_WORDS.after3s}` : name;
5540
+ const name = stemNameParts(ref.stem, ref.kind);
5541
+ return ref.segment === "after3s" ? nameParts([...name, textPart(` · ${PLACE_KIND_WORDS.after3s}`)]) : name;
5466
5542
  }
5467
5543
  function placeKindWord(id) {
5468
5544
  if (id === "flow")
@@ -5470,31 +5546,33 @@ function placeKindWord(id) {
5470
5546
  const { kind } = placeRef(id);
5471
5547
  return kind === "other" ? "Window" : KIND_WORD[kind];
5472
5548
  }
5473
- function windowQualified(id) {
5549
+ var windowQualified = (id) => namePartsText(windowQualifiedParts(id));
5550
+ function windowQualifiedParts(id) {
5474
5551
  const word = placeOpenWord(id);
5475
- const label = placeLabel(id);
5552
+ const label = placeLabelParts(id);
5476
5553
  if (!word)
5477
5554
  return label;
5478
5555
  const after = placeRef(id).segment === "after3s" ? ` · ${PLACE_KIND_WORDS.after3s}` : "";
5479
- return `${after ? label.slice(0, -after.length) : label} (${word})${after}`;
5556
+ return nameParts([...after ? dropTail(label, after.length) : label, textPart(` (${word})${after}`)]);
5480
5557
  }
5481
5558
  var ELEMENT_SEPARATORS = /(?<!\d)\.|\.(?!\d)|[_-]+/g;
5482
5559
  var elementWords = (testId) => testId.replace(ELEMENT_SEPARATORS, " ").replace(/\s+/g, " ").trim();
5483
- function placeLabel(id, screen) {
5560
+ var placeLabel = (id, screen) => namePartsText(placeLabelParts(id, screen));
5561
+ function placeLabelParts(id, screen) {
5484
5562
  if (id === "flow")
5485
- return PLACE_KIND_WORDS.journey;
5563
+ return plainParts(PLACE_KIND_WORDS.journey);
5486
5564
  const ref = placeRef(id);
5487
5565
  const after = ref.segment === "after3s" ? ` · ${PLACE_KIND_WORDS.after3s}` : "";
5488
- const shown = placeIdShownName(ref.stem);
5566
+ const shown = placeIdShownNameParts(ref.stem);
5489
5567
  if (shown !== null)
5490
- return isLibraryPlace(ref.stem) ? `${shown}${after}` : `${KIND_WORD[ref.kind]} · ${shown}${after}`;
5568
+ return nameParts([...isLibraryPlace(ref.stem) ? [] : [textPart(`${KIND_WORD[ref.kind]} · `)], ...shown, textPart(after)]);
5491
5569
  if (ref.kind === "press" || ref.kind === "drag" || ref.kind === "step") {
5492
5570
  const element = elementWords(ref.stem.slice(ref.stem.indexOf(":") + 1).replace(OPEN_MARK, ""));
5493
- return `${KIND_WORD[ref.kind]} · “${element}”${screen ? ` · ${screen}` : ""}${after}`;
5571
+ return plainParts(`${KIND_WORD[ref.kind]} · “${element}”${screen ? ` · ${screen}` : ""}${after}`);
5494
5572
  }
5495
5573
  const name = windowName(ref.stem);
5496
5574
  const colon = name.indexOf(": ");
5497
- return `${colon > 0 && ref.kind !== "other" ? `${name.slice(0, colon)} · ${name.slice(colon + 2)}` : name}${after}`;
5575
+ return plainParts(`${colon > 0 && ref.kind !== "other" ? `${name.slice(0, colon)} · ${name.slice(colon + 2)}` : name}${after}`);
5498
5576
  }
5499
5577
  // packages/present/dist/src/placeNames.js
5500
5578
  function shortestPaths(files) {
@@ -5513,6 +5591,28 @@ function shortestPaths(files) {
5513
5591
  }
5514
5592
  return out;
5515
5593
  }
5594
+ // packages/present/dist/src/words.js
5595
+ var REASON_WORDS = {
5596
+ "no-baseline": "there are no main runs on this phone and journey yet",
5597
+ "too-few-runs": "there are too few valid runs on a side",
5598
+ "judged-measures-missing": "the judged measures are missing on a side",
5599
+ "measured-differently": "the sides were measured differently",
5600
+ "runs-unknown": "a change that would count has no value in some runs, so it cannot be confirmed in every run",
5601
+ "facts-version-mismatch": "the sides were derived with different versions",
5602
+ "not-judged-on-simulators": "timing is not judged on simulators",
5603
+ "platforms-differ": "the sides ran on different platforms (iOS and Android are never compared)",
5604
+ "not-enough-main": "main does not have enough sessions on this phone and journey yet"
5605
+ };
5606
+ var waitingWords = (runs) => `${runs} main ${runs === 1 ? "run waits" : "runs wait"} for a reprocess`;
5607
+ var AT_MOST = "≤ ";
5608
+ var atMost = (text, isBound) => isBound ? `${AT_MOST}${text}` : text;
5609
+ var AT_LEAST = "≥ ";
5610
+ var atLeast = (text, isBound) => isBound ? `${AT_LEAST}${text}` : text;
5611
+
5612
+ // packages/present/dist/src/shown.js
5613
+ var feltGainText = (ms) => ms && ms > 0 ? `felt gain −${fieldValueText("jank_ms", ms)}` : null;
5614
+ var feltCostText = (ms) => ms && ms > 0 ? `felt cost +${fieldValueText("jank_ms", ms)}` : null;
5615
+
5516
5616
  // packages/present/dist/src/stability.js
5517
5617
  var UNSTABLE_WORDS = {
5518
5618
  short: "main varies too much here",
@@ -5619,8 +5719,6 @@ function resultContextLines(c, verbose = false, short = false) {
5619
5719
  for (const [screen, rows] of screens) {
5620
5720
  const line = notInRunRowsText({ screens: rows.filter((p) => p.kind === "screen").length, places: rows.filter((p) => p.kind !== "screen").length }, c.page);
5621
5721
  out.push(`${screen ? `${screen}: ` : ""}${line.lead}${line.rest}`);
5622
- if (rows.length > 3)
5623
- out.push(` ${foldedText(rows.length, "places", c.page)}`);
5624
5722
  if (!short || rows.length <= 3)
5625
5723
  out.push(...rows.map((p) => ` ${windowQualified(p.place)}`));
5626
5724
  }
@@ -5630,8 +5728,6 @@ function resultContextLines(c, verbose = false, short = false) {
5630
5728
  for (const [place, rows] of removed) {
5631
5729
  const line = removedRowsText(rows.length, c.page);
5632
5730
  out.push(`${windowQualified(place)}: ${line.lead}${line.rest}`);
5633
- if (rows.length > 3)
5634
- out.push(` ${foldedText(rows.length, "components", c.page)}`);
5635
5731
  if (!short || rows.length <= 3)
5636
5732
  out.push(...rows.map((r) => ` ${r.ref.kind === "breakdown" ? componentReadName({ key: r.ref.key, name: r.ref.name }) : measureWords(r.ref.measure)}`));
5637
5733
  }
@@ -5663,7 +5759,7 @@ var READ_USAGE = {
5663
5759
  page: "--page must be a whole number from 1; use it only with places or compare.",
5664
5760
  all: "--all is for places or compare; do not combine it with --page, --offset or --short.",
5665
5761
  detailFlag: " --detail (places: include recorded run detail; also included by --verbose or component paging)",
5666
- detail: "Include recorded per-run component and window detail.",
5762
+ detail: "Include recorded per-run component and window detail: an opt-in diagnostic that reads the runs' recorded profiles (slower); ranking places never needs it.",
5667
5763
  compareTop: "--top-components must be a whole number from 1 to 200 for compare.",
5668
5764
  top: "--top-components must be a whole number from 0 to 20, or from 1 to 200 with --page or --all.",
5669
5765
  paging: "The API did not advance the component page; stopped without claiming the list is complete.",
@@ -5673,23 +5769,119 @@ var READ_USAGE = {
5673
5769
  var componentListsText = (totals, shown) => `Stored component rows: ${shown.rows} of ${totals.rows} compared; ${shown.added} of ${totals.added} ${READ_WORDS.onlyB.toLowerCase()}; ${shown.removed} of ${totals.removed} ${READ_WORDS.onlyA.toLowerCase()}.`;
5674
5770
  var notMeasuredText = (side) => `${READ_WORDS.notMeasured} in run ${side.toUpperCase()}`;
5675
5771
 
5676
- // packages/present/dist/src/words.js
5677
- var REASON_WORDS = {
5678
- "no-baseline": "there are no main runs on this phone and journey yet",
5679
- "too-few-runs": "there are too few valid runs on a side",
5680
- "judged-measures-missing": "the judged measures are missing on a side",
5681
- "measured-differently": "the sides were measured differently",
5682
- "runs-unknown": "a change that would count has no value in some runs, so it cannot be confirmed in every run",
5683
- "facts-version-mismatch": "the sides were derived with different versions",
5684
- "not-judged-on-simulators": "timing is not judged on simulators",
5685
- "platforms-differ": "the sides ran on different platforms (iOS and Android are never compared)",
5686
- "not-enough-main": "main does not have enough sessions on this phone and journey yet"
5772
+ // packages/present/dist/src/problems.js
5773
+ var FAR_OVER_BUDGET_WORD = "Far over budget";
5774
+ var heaviestWords = (name, value) => [textPart(`${HEAVIEST_WORD}: `), componentPart(name), ...value ? [textPart(` ${value}`)] : []];
5775
+ var HEAVIEST_WORD = "heaviest";
5776
+ var PROBLEM_TAG_WORDS = { new: NEW_IN_PR_CHIP, main: "Also on main", none: NOT_COMPARED_WORD };
5777
+ var PROBLEM_TAG_LINE_WORDS = { new: "new in this PR", main: "also on main", none: "not compared with main" };
5778
+ var problems = (n) => `${n.toLocaleString("en-US")} ${n === 1 ? "problem" : "problems"}`;
5779
+ var places = (n) => `${n.toLocaleString("en-US")} ${n === 1 ? "place" : "places"}`;
5780
+ var problemsAtPlaces = (n, at) => `${problems(n)} at ${places(at)}`;
5781
+ function problemCountsText(c, own) {
5782
+ const tags = c.tags;
5783
+ const split = tags ? `: ${[`${tags.new} ${PROBLEM_TAG_LINE_WORDS.new}`, ...["main", "none"].filter((t) => tags[t]).map((t) => `${tags[t]} ${PROBLEM_TAG_LINE_WORDS[t]}`)].join(", ")}` : "";
5784
+ return `${problemsAtPlaces(c.count, c.places)} in ${own} own runs${split}${c.far_count ? `; ${c.far_count} of them ${FAR_OVER_BUDGET_WORD.toLowerCase()}` : ""}`;
5785
+ }
5786
+ var newProblems = (n) => `${n.toLocaleString("en-US")} new ${n === 1 ? "problem" : "problems"}`;
5787
+ var PROBLEMS_BLOCK_WORDS = {
5788
+ branch: {
5789
+ heading: "New problems in this branch",
5790
+ sub: "too slow on their own, whatever main does; they don’t block the merge",
5791
+ seeAll: (n) => `See all ${newProblems(n)}`,
5792
+ alsoLine: (n, at) => `${PROBLEM_TAG_WORDS.main}: ${problemsAtPlaces(n, at)}`,
5793
+ notComparedLine: (n, at) => `${PROBLEM_TAG_WORDS.none}: ${problemsAtPlaces(n, at)}`,
5794
+ choices: { new: PROBLEM_TAG_WORDS.new, also: PROBLEM_TAG_WORDS.main, not_compared: PROBLEM_TAG_WORDS.none },
5795
+ panelTitle: "Problems in this branch",
5796
+ panelNote: "Each one is too slow in this branch’s own runs, whatever main does. Problems never block.",
5797
+ filter: "Which problems"
5798
+ },
5799
+ main: {
5800
+ heading: "New problems in this session",
5801
+ sub: "too slow on their own, whatever earlier sessions did",
5802
+ seeAll: (n) => `See all ${newProblems(n)}`,
5803
+ alsoLine: (n, at) => `Also in earlier sessions: ${problemsAtPlaces(n, at)}`,
5804
+ notComparedLine: (n, at) => `Not compared with earlier sessions: ${problemsAtPlaces(n, at)}`,
5805
+ choices: { new: "New in this session", also: "Also in earlier sessions", not_compared: "Not compared with earlier sessions" },
5806
+ panelTitle: "Problems in this session",
5807
+ panelNote: "Each one is too slow in this session’s own runs, whatever earlier sessions did. Problems never block.",
5808
+ filter: "Which problems"
5809
+ }
5687
5810
  };
5688
- var waitingWords = (runs) => `${runs} main ${runs === 1 ? "run waits" : "runs wait"} for a reprocess`;
5689
- var AT_MOST = "≤ ";
5690
- var atMost = (text, isBound) => isBound ? `${AT_MOST}${text}` : text;
5691
- var AT_LEAST = "≥ ";
5692
- var atLeast = (text, isBound) => isBound ? `${AT_LEAST}${text}` : text;
5811
+ // packages/present/dist/src/place-tier.js
5812
+ var slowerCountsText = (n, sep = " · ") => [n.worse ? `${n.worse} ${STATE_WORDS.worse.toLowerCase()}` : "", n.slight ? `${n.slight} ${STATE_WORDS.warning.toLowerCase()}` : ""].filter(Boolean).join(sep);
5813
+ // packages/present/dist/src/verdict-section.js
5814
+ var places2 = (n) => `${n.toLocaleString("en-US")} ${n === 1 ? "place" : "places"}`;
5815
+ var of = (n, total) => `${n.toLocaleString("en-US")} of ${places2(total)}`;
5816
+ var runs = (n) => `${n} valid ${n === 1 ? "run" : "runs"}`;
5817
+ var VERDICT_SECTION_WORDS = {
5818
+ slower: (n, total, slight) => `${slight ? "A little slower" : "Slower"} at ${of(n, total)}`,
5819
+ blocksMerge: "blocks the merge",
5820
+ faster: (n, total) => `Faster at ${of(n, total)}, slower at none`,
5821
+ mixed: (slower, faster, total) => `Slower at ${of(slower, total)}, faster at ${faster.toLocaleString("en-US")}`,
5822
+ unpublishedSdk: "ran an unpublished SDK build",
5823
+ noneMoved: (total, judged = total) => judged < total ? `No place moved out of its normal range (${judged.toLocaleString("en-US")} of ${total.toLocaleString("en-US")} judged)` : `No place moved out of its normal range (${places2(total)})`,
5824
+ noneMovedMeasured: (measured, same) => same ? `Nothing moved at ${of(same, same + measured)}; ${measured} measured differently` : `Nothing moved; ${measured} ${measured === 1 ? "place was" : "places were"} measured differently`,
5825
+ tooFewRuns: (valid, needed) => `Not compared: ${runs(valid)} of ${needed} needed`,
5826
+ tooFewMainRuns: (valid, needed) => `Not compared: main has ${runs(valid)} of ${needed} needed`,
5827
+ tooFewBefore: (valid) => `Not compared: the sessions before have only ${runs(valid)}`,
5828
+ noBaseline: { branch: "Nothing to compare with: no main runs on this phone yet", main: "Nothing to compare with: no earlier sessions on this phone" },
5829
+ noisy: "Not judged: main’s own runs vary too much at every place here",
5830
+ versionMismatch: "Not compared: the sides were computed with different analysis versions",
5831
+ unconfirmed: (n) => `Can’t confirm ${places2(n)}: a run has no value there`,
5832
+ notCompared: (reason) => `Not compared: ${reason}`,
5833
+ notJudgedYet: (why) => `Not judged yet: ${why} on this phone`,
5834
+ collecting: (collected, baseline, after) => after ? `Main is collecting a new baseline after ${after}: ${collected} of ${baseline} sessions` : `Main is collecting its baseline: ${collected} of ${baseline} sessions`,
5835
+ doesNotBlock: "doesn’t block",
5836
+ fixes: {
5837
+ branch: {
5838
+ "no-baseline": "Add this journey on this phone to main’s nightly runs.",
5839
+ "too-few-runs": "Run this branch again on this phone.",
5840
+ "judged-measures-missing": "Record the judged measures, then run again.",
5841
+ "runs-unknown": "Run this branch again; a full set of runs decides it.",
5842
+ "facts-version-mismatch": "Recompute both sides in Setup; no new runs are needed.",
5843
+ "platforms-differ": "Add this phone’s platform to main’s nightly runs."
5844
+ },
5845
+ main: {
5846
+ "no-baseline": "Nothing to do: main’s next 3 nightly sessions make a result possible.",
5847
+ "too-few-runs": "Main’s next nightly session decides it.",
5848
+ "runs-unknown": "Main’s next nightly session decides it.",
5849
+ "judged-measures-missing": "Record the judged measures on main; its next nightly session is compared.",
5850
+ "facts-version-mismatch": "Recompute both sides in Setup; no new runs are needed."
5851
+ }
5852
+ },
5853
+ waitForMain: { branch: "Nothing to do on this branch; main’s next nightly session decides it.", main: "Main’s next nightly session decides it." },
5854
+ sides: {
5855
+ branch: { left: "Main", right: "This branch", rightShort: "Branch" },
5856
+ main: { left: "Sessions before", right: "Newest session", rightShort: "Newest" },
5857
+ runSet: { left: "Main", right: "This run set", rightShort: "Run set" }
5858
+ },
5859
+ branchSides: (name) => ({ left: "Main", right: name || "This branch", rightShort: "Branch" }),
5860
+ inProgress: (arrived, expected) => `${arrived} of ${expected} runs arrived; the result comes with the last one`,
5861
+ whatChangedLabel: "What changed",
5862
+ moreSlower: (n) => `+${n.toLocaleString("en-US")} more slower`,
5863
+ slowerPanel: (n) => `${places2(n)} slower`,
5864
+ forInformation: "for information",
5865
+ allFilter: "All",
5866
+ filter: {
5867
+ label: "Show places",
5868
+ choice: (tierWord) => upperFirst(tierWord),
5869
+ showing: (choice, n) => `Showing ${choice.toLowerCase()}: ${n}`
5870
+ },
5871
+ countsLabel: "Places by result",
5872
+ links: {
5873
+ openChart: "Open chart",
5874
+ seeAll: (n) => `See all ${places2(n)}`,
5875
+ whichPlaces: "Which places",
5876
+ whichChanges: "Which changes",
5877
+ howToFix: "How to fix",
5878
+ whatDiffers: "What differs",
5879
+ whatChanged: "What changed",
5880
+ openSetup: "Open setup",
5881
+ openScreen: "Open screen"
5882
+ }
5883
+ };
5884
+
5693
5885
  // packages/present/dist/src/main-history.js
5694
5886
  var MAIN_TIER_WORDS = {
5695
5887
  same: "In norm",
@@ -5706,11 +5898,13 @@ var MAIN_TIER_RESULT_WORDS = {
5706
5898
  worse: STATE_WORDS.worse
5707
5899
  };
5708
5900
  var MAIN_TIER_RANK = { worse: 0, slight: 1, cant: 2, better: 3, same: 4 };
5901
+ var earlierWords = (sessions, since, config) => `main has ${sessions}${config.baseline.sessions === undefined ? "" : ` of ${config.baseline.sessions}`} earlier sessions${since ? ` on ${since}` : ""}`;
5709
5902
  function mainCantWords(reason, config) {
5710
5903
  if (!reason)
5711
5904
  return "";
5712
- return reason.kind === "noisy_main" ? "main’s runs vary too much there" : `main has ${reason.earlier_sessions}${config.baseline.sessions === undefined ? "" : ` of ${config.baseline.sessions}`} earlier sessions${reason.since_label ? ` on ${reason.since_label}` : ""}`;
5905
+ return reason.kind === "noisy_main" ? "main’s runs vary too much there" : earlierWords(String(reason.earlier_sessions), reason.since_label, config);
5713
5906
  }
5907
+ var mainCantRangeWords = (r, config) => earlierWords(`${r.min}–${r.max}`, r.since_label, config);
5714
5908
  var waitingSessionsWords = (sessions) => `${sessions} older main ${sessions === 1 ? "session waits" : "sessions wait"} for a reprocess`;
5715
5909
  function mainPlaceReasonWords(reason, config) {
5716
5910
  switch (reason.kind) {
@@ -5740,9 +5934,11 @@ var SHORT_ASPECT_WORDS = {
5740
5934
  driverVersion: "Driver",
5741
5935
  journeyVersion: "Journey"
5742
5936
  };
5937
+ var cantAt = (places) => `${STATE_WORDS["cant-tell"]} at ${places} place${places === 1 ? "" : "s"}`;
5743
5938
  function mainSessionCantWords(places, reason, config) {
5744
- return `${STATE_WORDS["cant-tell"]} at ${places} place${places === 1 ? "" : "s"}: ${mainCantWords(reason, config)}`;
5939
+ return `${cantAt(places)}: ${mainCantWords(reason, config)}`;
5745
5940
  }
5941
+ var mainSessionCantRangeWords = (places, r, config) => `${cantAt(places)}: ${mainCantRangeWords(r, config)}`;
5746
5942
  var KIND_PLURAL = {
5747
5943
  screen: "screens",
5748
5944
  press: "taps",
@@ -5767,56 +5963,12 @@ function mainCutWords(v) {
5767
5963
  return parts.length ? parts.join(" ") : "Measures alike; no baseline cut.";
5768
5964
  }
5769
5965
  var BY_RANK = Object.keys(MAIN_TIER_RANK).sort((a, b) => MAIN_TIER_RANK[a] - MAIN_TIER_RANK[b]);
5770
- // packages/present/dist/src/verdict-section.js
5771
- var places = (n) => `${n.toLocaleString("en-US")} ${n === 1 ? "place" : "places"}`;
5772
- var of = (n, total) => `${n.toLocaleString("en-US")} of ${places(total)}`;
5773
- var runs = (n) => `${n} valid ${n === 1 ? "run" : "runs"}`;
5774
- var VERDICT_SECTION_WORDS = {
5775
- slower: (n, total, slight) => `${slight ? "A little slower" : "Slower"} at ${of(n, total)}`,
5776
- blocksMerge: "blocks the merge",
5777
- faster: (n, total) => `Faster at ${of(n, total)}, slower at none`,
5778
- mixed: (slower, faster, total) => `Slower at ${of(slower, total)}, faster at ${faster.toLocaleString("en-US")}`,
5779
- unpublishedSdk: "ran an unpublished SDK build",
5780
- noneMoved: (total, judged = total) => judged < total ? `No place moved out of its normal range (${judged.toLocaleString("en-US")} of ${total.toLocaleString("en-US")} judged)` : `No place moved out of its normal range (${places(total)})`,
5781
- noneMovedMeasured: (measured, same) => same ? `Nothing moved at ${of(same, same + measured)}; ${measured} measured differently` : `Nothing moved; ${measured} ${measured === 1 ? "place was" : "places were"} measured differently`,
5782
- tooFewRuns: (valid, needed) => `Not compared: ${runs(valid)} of ${needed} needed`,
5783
- tooFewMainRuns: (valid, needed) => `Not compared: main has ${runs(valid)} of ${needed} needed`,
5784
- tooFewBefore: (valid) => `Not compared: the sessions before have only ${runs(valid)}`,
5785
- noBaseline: { branch: "Nothing to compare with: no main runs on this phone yet", main: "Nothing to compare with: no earlier sessions on this phone" },
5786
- noisy: "Not judged: main’s own runs vary too much at every place here",
5787
- versionMismatch: "Not compared: the sides were computed with different analysis versions",
5788
- unconfirmed: (n) => `Can’t confirm ${places(n)}: a run has no value there`,
5789
- notCompared: (reason) => `Not compared: ${reason}`,
5790
- notJudgedYet: (why) => `Not judged yet: ${why} on this phone`,
5791
- collecting: (collected, baseline, after) => after ? `Main is collecting a new baseline after ${after}: ${collected} of ${baseline} sessions` : `Main is collecting its baseline: ${collected} of ${baseline} sessions`,
5792
- doesNotBlock: "doesn’t block",
5793
- sides: {
5794
- branch: { left: "Main", right: "This branch", rightShort: "Branch" },
5795
- main: { left: "Sessions before", right: "Newest session", rightShort: "Newest" },
5796
- runSet: { left: "Main", right: "This run set", rightShort: "Run set" }
5797
- },
5798
- branchSides: (name) => ({ left: "Main", right: name || "This branch", rightShort: "Branch" }),
5799
- inProgress: (arrived, expected) => `${arrived} of ${expected} runs arrived; the result comes with the last one`,
5800
- whatChangedLabel: "What changed",
5801
- forInformation: "for information",
5802
- allFilter: "All",
5803
- filter: {
5804
- label: "Show places",
5805
- choice: (tierWord) => upperFirst(tierWord),
5806
- showing: (choice, n) => `Showing ${choice.toLowerCase()}: ${n}`
5807
- },
5808
- countsLabel: "Places by result",
5809
- links: {
5810
- openChart: "Open chart",
5811
- seeAll: (n) => `See all ${places(n)}`,
5812
- whichPlaces: "Which places",
5813
- whichChanges: "Which changes",
5814
- howToFix: "How to fix",
5815
- whatDiffers: "What differs",
5816
- whatChanged: "What changed",
5817
- openSetup: "Open setup",
5818
- openScreen: "Open screen"
5819
- }
5966
+ var MEMORY_HELD_WORDS = { head: "Memory", rest: "still held after the journey", text: "Memory still held after the journey" };
5967
+ var MAIN_CHART_TITLES = {
5968
+ renders: "Places vs their normal range",
5969
+ rendersTip: (baseline) => `Share of the journey’s places per session, by result against main’s ${baseline} sessions before it. Dashed: not judged yet, while main collects ${baseline} sessions on the setup (after main’s start or a change).`,
5970
+ memory: `${MEMORY_HELD_WORDS.text} · MB`,
5971
+ memoryTip: "Memory kept by the journey: what it still holds after the clean-up at its end. Line: the median of a session; band: the spread inside it."
5820
5972
  };
5821
5973
  // packages/present/dist/src/summary-message.js
5822
5974
  var SUMMARY_WORDS = {
@@ -5832,39 +5984,62 @@ var SUMMARY_WORDS = {
5832
5984
  fewRuns: "few runs",
5833
5985
  measuredDifferently: "measured differently",
5834
5986
  namesChanged: (sdk, mainSessions, needed) => `Main: ${mainSessions} of ${needed} sessions on SDK ${sdkTag(sdk)}`,
5987
+ morePlaces: (n) => `+${n} more ${n === 1 ? "place" : "places"} changed`,
5988
+ slowerPlaces: (n, split) => `${n} ${n === 1 ? "place" : "places"} slower${split && slowerCountsText(split) ? `: ${slowerCountsText(split, ", ")}` : ""}`,
5989
+ noPlaceSlower: "no place slower",
5990
+ toCheck: (n) => `${n} ${n === 1 ? "component" : "components"} to check`,
5991
+ compared: {
5992
+ branch: (sessions, days) => `vs main’s last ${sessions}${days}`,
5993
+ main: (sessions, days) => `newest session vs main’s ${sessions} before${days}`,
5994
+ mainSlower: (sessions, days) => `newest session slower than main’s ${sessions} before${days}`
5995
+ },
5996
+ sessions: (n) => `${n} ${n === 1 ? "session" : "sessions"}`,
5997
+ days: (days) => days ? ` (${days})` : "",
5998
+ runs: (runs, each) => each ? `${each} ${each === 1 ? "run" : "runs"} each` : `${runs} ${runs === 1 ? "run" : "runs"}`,
5999
+ unblock: (why, fix) => `${why} → ${fix}`,
5835
6000
  link: {
5836
- lane: (journey, phone) => `Open ${journey} on ${phone}`,
5837
- phone: (phone) => `Open on ${phone}`,
6001
+ comparison: "See the comparison",
6002
+ session: "See the session",
5838
6003
  branch: (branch) => `Open ${branch}`,
5839
6004
  runSet: "Open the run set"
5840
6005
  }
5841
6006
  };
5842
- var ORDER = ["worse", "warning", "cant-tell", "not-computed", "better", "no-change"];
6007
+ var ORDER = [...STATE_ORDER.slice(0, 3), "not-computed", ...STATE_ORDER.slice(3)];
5843
6008
  var rank = (l) => ORDER.indexOf(l.state);
5844
6009
  var WORD = { ...STATE_WORDS, "not-computed": SUMMARY_WORDS.notComputed };
5845
6010
  var shownLane = (lanes) => [...lanes].sort((a, b) => rank(a) - rank(b) || b.places - a.places)[0];
5846
6011
  var clip = (s, n) => s.length > n ? `${s.slice(0, n - 1)}…` : s;
5847
- var NAME = { name: 32, beside: 18, place: 16, link: 60, button: 30 };
5848
- var DETAIL = 64;
6012
+ var NAME = { name: 32, button: 30 };
6013
+ var TOP2 = 3;
6014
+ var MAX_LINES = 5;
5849
6015
  var upper = (s) => s ? `${s[0].toUpperCase()}${s.slice(1)}` : s;
5850
6016
  var fromTo = (a, b) => {
5851
6017
  const unit = (v) => / [^\d\s]+$/.exec(v)?.[0];
5852
6018
  return unit(a) && unit(a) === unit(b) ? `${a.slice(0, -unit(a).length)} → ${b}` : `${a} → ${b}`;
5853
6019
  };
5854
- function clipPlace(place, n) {
5855
- if (place.length <= n)
5856
- return place;
5857
- let cut = place.slice(0, n);
5858
- if (place[n] !== " " && cut.includes(" "))
5859
- cut = cut.slice(0, cut.lastIndexOf(" "));
5860
- cut = cut.trimEnd();
5861
- const open = (cut.match(/“/g) ?? []).length > (cut.match(/”/g) ?? []).length;
5862
- return `${cut}…${open ? "”" : ""}`;
5863
- }
5864
- var changeText2 = (c, room) => {
5865
- const what = `${c.measure} ${c.main !== null && c.now !== null ? fromTo(c.main, c.now) : c.change}`;
5866
- return c.place ? `${clipPlace(c.place, Math.max(NAME.place, room - what.length - 3))} · ${what}` : upper(what);
6020
+ var days = (labels) => {
6021
+ const a = labels[0], b = labels.at(-1);
6022
+ if (!a || !b || a === b)
6023
+ return a ?? "";
6024
+ const [ma] = a.split(" ");
6025
+ const [mb, db] = b.split(" ");
6026
+ return ma === mb ? `${a}–${db}` : `${a}–${b}`;
5867
6027
  };
6028
+ var changeLine = (c, tier) => {
6029
+ const values = c.main !== null && c.now !== null ? `${fromTo(c.main, c.now)}${c.change ? ` (${c.change})` : ""}` : c.change;
6030
+ return [c.place ?? upper(c.measure), ...c.place ? [c.measure] : [], values, WORD[c.tier ?? tier]].filter(Boolean).join(" · ");
6031
+ };
6032
+ function tierSplit(l) {
6033
+ const worse = new Set, all = new Set;
6034
+ for (const c of l.changes) {
6035
+ const key = c.key ?? c.place ?? c.measure;
6036
+ all.add(key);
6037
+ if ((c.tier ?? l.state) === "worse")
6038
+ worse.add(key);
6039
+ }
6040
+ const split = { worse: worse.size, slight: all.size - worse.size };
6041
+ return l.state === "worse" && !split.slight || l.state === "warning" && !split.worse ? undefined : split;
6042
+ }
5868
6043
  var wordReason = (word) => word?.includes(": ") ? word.slice(word.indexOf(": ") + 2) : null;
5869
6044
  var notJudgedWhy = (l) => l.state === "not-computed" && l.word?.startsWith(`${NOT_JUDGED_WORD}: `) ? upper(l.word.slice(NOT_JUDGED_WORD.length + 2)) : null;
5870
6045
  function why(l) {
@@ -5880,19 +6055,30 @@ function why(l) {
5880
6055
  return upper(SUMMARY_WORDS.placesOnPage);
5881
6056
  return upper(wordReason(l.word) ?? (l.word === NO_BASELINE_WORD ? l.word : REASON_WORDS["judged-measures-missing"]));
5882
6057
  }
6058
+ function unblock(l, pr) {
6059
+ const page = pr ? "branch" : "main";
6060
+ const reason = l.namesChanged ? "not-enough-main" : l.reason;
6061
+ if (!reason)
6062
+ return null;
6063
+ const fix = reason === "not-enough-main" ? VERDICT_SECTION_WORDS.waitForMain[page] : VERDICT_SECTION_WORDS.fixes[page][reason];
6064
+ return fix ? lowerFirst(fix.replace(/\.$/, "")) : null;
6065
+ }
5883
6066
  var noneMoved = VERDICT_SECTION_WORDS.noneMoved(1).replace(/ \(.*\)$/, "");
5884
6067
  function conditions(l, pr) {
5885
6068
  const moved = l.state === "worse" || l.state === "warning";
5886
- const heat = l.heatedRuns ? SUMMARY_WORDS.heated(l.heatedRuns, l.runs) : "";
5887
- const heatIsNews = l.state === "no-change" && !!heat;
5888
- return {
5889
- always: [pr && l.state === "worse" ? l.blocking ? VERDICT_SECTION_WORDS.blocksMerge : NOT_BLOCKING : "", heatIsNews ? heat : ""].filter(Boolean),
5890
- optional: [
5891
- moved && l.measuredDifferently ? SUMMARY_WORDS.measuredDifferently : "",
5892
- (moved || l.state === "better") && l.fewRuns ? SUMMARY_WORDS.fewRuns : "",
5893
- heatIsNews ? "" : heat
5894
- ].filter(Boolean)
5895
- };
6069
+ return [
6070
+ pr && l.state === "worse" ? l.blocking ? VERDICT_SECTION_WORDS.blocksMerge : NOT_BLOCKING : "",
6071
+ moved && l.measuredDifferently ? SUMMARY_WORDS.measuredDifferently : "",
6072
+ (moved || l.state === "better") && l.fewRuns ? SUMMARY_WORDS.fewRuns : ""
6073
+ ].filter(Boolean);
6074
+ }
6075
+ function compared(l, pr, slower) {
6076
+ const judged = l.state === "worse" || l.state === "warning" || l.state === "better" || l.state === "no-change";
6077
+ if (!judged || !l.main?.sessions)
6078
+ return null;
6079
+ const args = [SUMMARY_WORDS.sessions(l.main.sessions), SUMMARY_WORDS.days(days(l.main.labels))];
6080
+ const what = pr ? SUMMARY_WORDS.compared.branch(...args) : slower ? SUMMARY_WORDS.compared.mainSlower(...args) : SUMMARY_WORDS.compared.main(...args);
6081
+ return `${what} · ${SUMMARY_WORDS.runs(l.main.runs, l.main.each)}`;
5896
6082
  }
5897
6083
  function summaryMessage(input) {
5898
6084
  const l = shownLane(input.lanes);
@@ -5901,37 +6087,84 @@ function summaryMessage(input) {
5901
6087
  state: "not-computed",
5902
6088
  newestSlower: false,
5903
6089
  lead: `${SUMMARY_WORDS.notComputed} · ${clip(input.branch, NAME.name)}`,
5904
- detail: upper(SUMMARY_WORDS.notComputedWhy),
6090
+ ...withLines([upper(SUMMARY_WORDS.notComputedWhy)]),
5905
6091
  link: { label: SUMMARY_WORDS.link.branch(clip(input.branch, NAME.button - 5)), url: input.url }
5906
6092
  };
5907
6093
  }
5908
6094
  const journeys = new Set(input.lanes.map((x) => x.journey ?? "-")).size;
6095
+ const phones = new Set(input.lanes.map((x) => x.phone ?? "-")).size;
5909
6096
  const single = input.lanes.length === 1;
5910
- const phone = l.phone ?? "unknown phone";
5911
- const several = journeys === 1 ? SUMMARY_WORDS.phones(new Set(input.lanes.map((x) => x.phone ?? "-")).size) : SUMMARY_WORDS.journeys(journeys);
5912
6097
  const newestSlower = !input.pr && (l.state === "worse" || l.state === "warning");
5913
- const named = (subject, rest) => [clip(subject, rest.length ? NAME.beside : NAME.name), ...rest];
5914
- const what = input.pr ? named(input.branch, single ? [] : [several]) : single || journeys === 1 ? named(l.journey ?? "journey", single ? [] : [several]) : [several];
5915
- const lead = newestSlower ? [SUMMARY_WORDS.main, ...what, SUMMARY_WORDS.newestSlower].join(" · ") : [notJudgedWhy(l) ? NOT_JUDGED_WORD : WORD[l.state], ...what].join(" · ");
5916
- const top = l.state === "worse" || l.state === "warning" || l.state === "better" ? l.changes[0] : undefined;
5917
- const { always, optional } = conditions(l, input.pr);
5918
- const answer = top ? null : always.includes(SUMMARY_WORDS.heated(l.heatedRuns, l.runs)) ? noneMoved : why(l);
5919
- let extra = [...always];
5920
- for (const c of optional)
5921
- if ((answer ?? changeText2(top, 0)).length + `; ${[...extra, c].join(", ")}`.length <= DETAIL)
5922
- extra = [...extra, c];
5923
- const tail = extra.length ? `; ${extra.join(", ")}` : "";
5924
- const detail = `${top ? changeText2(top, DETAIL - tail.length) : answer}${tail}`;
5925
- const journey = clip(l.journey ?? "journey", NAME.link);
5926
- const short = (full, fallback) => full.length <= NAME.button ? full : fallback;
5927
- const label = single ? short(SUMMARY_WORDS.link.lane(journey, phone), SUMMARY_WORDS.link.phone(phone)) : input.pr ? SUMMARY_WORDS.link.branch(clip(input.branch, NAME.button - 5)) : journeys === 1 ? short(SUMMARY_WORDS.link.lane(journey, several), SUMMARY_WORDS.link.phone(several)) : SUMMARY_WORDS.link.runSet;
5928
- return { state: l.state, newestSlower, lead, detail, link: { label, url: (single ? l.url : null) ?? input.url } };
6098
+ const lead = [
6099
+ notJudgedWhy(l) ? NOT_JUDGED_WORD : WORD[l.state],
6100
+ ...input.app ? [input.app] : [],
6101
+ journeys === 1 ? l.journey ?? "journey" : SUMMARY_WORDS.journeys(journeys),
6102
+ input.pr && input.prNumber ? `${input.branch} (#${input.prNumber})` : input.branch,
6103
+ phones === 1 ? l.phone ?? "unknown phone" : SUMMARY_WORDS.phones(phones)
6104
+ ].join(" · ");
6105
+ const label = input.pr ? SUMMARY_WORDS.link.comparison : SUMMARY_WORDS.link.session;
6106
+ const link = { label, url: (single ? l.url : null) ?? input.url };
6107
+ if (!input.slack)
6108
+ return { state: l.state, newestSlower, lead, ...withLines(changeLines(l, input.pr, newestSlower)), link };
6109
+ const lines = [];
6110
+ if (l.state === "worse" || l.state === "warning") {
6111
+ const n = l.places || l.changes.length;
6112
+ lines.push(upper([...n ? [SUMMARY_WORDS.slowerPlaces(n, tierSplit(l))] : [], feltCostText(l.feltCostMs)].filter(Boolean).join(" · ") || why(l)));
6113
+ } else if (l.state === "no-change")
6114
+ lines.push(upper(SUMMARY_WORDS.noPlaceSlower));
6115
+ else if (l.state === "cant-tell") {
6116
+ const fix = unblock(l, input.pr);
6117
+ lines.push(fix ? SUMMARY_WORDS.unblock(why(l), fix) : why(l));
6118
+ } else if (l.state === "better")
6119
+ lines.push([why(l), feltGainText(l.feltGainMs)].filter(Boolean).join(" · "));
6120
+ else
6121
+ lines.push(why(l));
6122
+ if (l.toCheckCount)
6123
+ lines.push(upper(SUMMARY_WORDS.toCheck(l.toCheckCount)));
6124
+ const basis = compared(l, input.pr, false);
6125
+ const extra = conditions(l, input.pr).join(", ");
6126
+ const heat = l.heatedRuns ? SUMMARY_WORDS.heated(l.heatedRuns, l.runs) : null;
6127
+ const context = [basis ? `${basis}${extra ? `; ${extra}` : ""}` : extra, heat].filter(Boolean).join(" · ");
6128
+ if (context)
6129
+ lines.push(basis ? context : upper(context));
6130
+ return { state: l.state, newestSlower, lead, ...withLines(lines), link };
6131
+ }
6132
+ function changeLines(l, pr, newestSlower) {
6133
+ const lines = [];
6134
+ const basis = compared(l, pr, newestSlower);
6135
+ const extra = conditions(l, pr).join(", ");
6136
+ const heat = l.heatedRuns ? upper(SUMMARY_WORDS.heated(l.heatedRuns, l.runs)) : null;
6137
+ const felt = l.state === "worse" || l.state === "warning" ? feltCostText(l.feltCostMs) : l.state === "better" ? feltGainText(l.feltGainMs) : null;
6138
+ const sums = upper([felt, l.toCheckCount ? SUMMARY_WORDS.toCheck(l.toCheckCount) : null].filter(Boolean).join(" · "));
6139
+ const tail = [...sums ? [sums] : [], ...basis || extra ? [basis ? `${basis}${extra ? `; ${extra}` : ""}` : upper(extra)] : [], ...heat ? [heat] : []];
6140
+ if (l.state === "worse" || l.state === "warning" || l.state === "better") {
6141
+ const seen = new Set;
6142
+ const places = l.changes.filter((c) => {
6143
+ const key = c.key ?? c.place ?? c.measure;
6144
+ return seen.has(key) ? false : (seen.add(key), true);
6145
+ });
6146
+ const room = MAX_LINES - tail.length;
6147
+ const top = places.length > room ? places.slice(0, Math.min(TOP2, room - 1)) : places.slice(0, TOP2);
6148
+ lines.push(...top.map((c) => changeLine(c, l.state)));
6149
+ if (!top.length)
6150
+ lines.push(why(l));
6151
+ const more = l.places - top.length;
6152
+ if (top.length && more > 0)
6153
+ lines.push(SUMMARY_WORDS.morePlaces(more));
6154
+ } else {
6155
+ const fix = l.state === "cant-tell" ? unblock(l, pr) : null;
6156
+ lines.push(fix ? SUMMARY_WORDS.unblock(why(l), fix) : why(l));
6157
+ }
6158
+ lines.push(...tail);
6159
+ return lines;
5929
6160
  }
6161
+ var withLines = (lines) => ({ lines, detail: lines.join(`
6162
+ `) });
5930
6163
  function pendingMessage(branch, url) {
5931
- return { state: "not-computed", newestSlower: false, lead: `${SUMMARY_WORDS.notComputed} · ${clip(branch, NAME.name)}`, detail: upper(SUMMARY_WORDS.notComputedWhy), link: { label: SUMMARY_WORDS.link.runSet, url } };
6164
+ return { state: "not-computed", newestSlower: false, lead: `${SUMMARY_WORDS.notComputed} · ${clip(branch, NAME.name)}`, ...withLines([upper(SUMMARY_WORDS.notComputedWhy)]), link: { label: SUMMARY_WORDS.link.runSet, url } };
5932
6165
  }
5933
6166
  function noResultMessage(branch, reason, url) {
5934
- return { state: "not-computed", newestSlower: false, lead: `${SUMMARY_WORDS.noResult} · ${clip(branch, NAME.name)}`, detail: reason.replace(/\.$/, ""), link: url ? { label: SUMMARY_WORDS.link.runSet, url } : null };
6167
+ return { state: "not-computed", newestSlower: false, lead: `${SUMMARY_WORDS.noResult} · ${clip(branch, NAME.name)}`, ...withLines([reason.replace(/\.$/, "")]), link: url ? { label: SUMMARY_WORDS.link.runSet, url } : null };
5935
6168
  }
5936
6169
  // packages/present/dist/src/run-set-message.js
5937
6170
  var midLine = (label, metric) => !metric.startsWith("component:") && /^[A-Z][a-z]*(\s|$)/.test(label) ? `${label[0].toLowerCase()}${label.slice(1)}` : label;
@@ -6006,6 +6239,8 @@ function laneOf(v, sc, set) {
6006
6239
  changes: shown.map((i) => ({
6007
6240
  place: placeOf(i.step),
6008
6241
  measure: measureWord(i),
6242
+ key: i.finding ?? placeRef(i.step ?? "*").stem,
6243
+ tier: i.state === "better" ? "better" : i.grade === "warn" ? "warning" : "worse",
6009
6244
  main: i.baseline === null ? null : atMost(fieldValueText(i.metric, i.baseline), i.baseline_is_bound),
6010
6245
  now: i.candidate === null ? null : fieldValueText(i.metric, i.candidate),
6011
6246
  change: changeWords(i)
@@ -6017,17 +6252,23 @@ function laneOf(v, sc, set) {
6017
6252
  heatedRuns: sc.heated_runs ?? 0,
6018
6253
  runs: sc.runs.valid + sc.runs.invalid,
6019
6254
  namesChanged: sc.context?.namesChanged ?? null,
6020
- url: linkOf(sc, set)
6255
+ url: linkOf(sc, set),
6256
+ main: sc.main ?? null,
6257
+ reason: sc.headline?.reason ?? sc.headline?.reasons?.[0] ?? null,
6258
+ feltCostMs: sc.headline?.felt_cost_ms ?? null,
6259
+ feltGainMs: sc.headline?.felt_gain_ms ?? null,
6260
+ toCheckCount: sc.headline?.to_check_count ?? null
6021
6261
  };
6022
6262
  }
6023
6263
  var isPending = (s) => lanesOf(s).every((l) => l.state === "not-computed" && !l.word?.startsWith(`${NOT_JUDGED_WORD}: `));
6024
6264
  var lanesOf = (s) => s.verdicts ? s.verdicts.scenarios.map((sc) => laneOf(s.verdicts, sc, s)) : [];
6025
6265
  var isPr = (s) => !!s.verdicts?.scenarios.some((sc) => sc.role === "comparison");
6026
- var runSetMessage = (s) => summaryMessage({ branch: s.branch, pr: isPr(s), lanes: lanesOf(s), url: linkOf(s) ?? s.dashboard_url });
6266
+ var runSetMessage = (s, o = {}) => summaryMessage({ branch: s.branch, pr: isPr(s), lanes: lanesOf(s), url: linkOf(s) ?? s.dashboard_url, app: s.app ?? null, prNumber: s.pull_request?.number ?? null, slack: o.slack });
6027
6267
  var MESSAGE_COLORS = { error: "#D6334A", warn: "#C47A00", better: "#238359", none: "#7C8492" };
6028
6268
  function messageTone(s) {
6029
6269
  const lanes = s ? lanesOf(s) : [];
6030
- if (lanes.some((l) => l.state === "worse" && l.blocking))
6270
+ const pr = !!s && isPr(s);
6271
+ if (lanes.some((l) => l.state === "worse" && (l.blocking || !pr)))
6031
6272
  return { mark: "\uD83D\uDD34", color: MESSAGE_COLORS.error };
6032
6273
  if (lanes.some((l) => l.state === "warning"))
6033
6274
  return { mark: "\uD83D\uDFE1", color: MESSAGE_COLORS.warn };
@@ -6062,25 +6303,50 @@ function resultSlackPayload(s, o = {}) {
6062
6303
  const moved = lanes.some((l) => l.state === "worse" || l.state === "warning");
6063
6304
  const heat = lanes.some((l) => l.heatedRuns > 0);
6064
6305
  const improved = lanes.some((l) => l.state === "better") || s.verdicts.state === "better";
6065
- if (isPr(s) ? !moved && !heat : !moved && !improved && !heat && !o.always)
6306
+ if (!isPr(s) && !moved && !improved && !heat && !o.always)
6066
6307
  return null;
6067
- return slackPayload(runSetMessage(s), messageTone(s));
6308
+ return slackPayload(runSetMessage(s, { slack: true }), messageTone(s));
6068
6309
  }
6069
6310
  var noResultSlackPayload = (branch, reason, url) => slackPayload(noResultMessage(branch, reason, url), { mark: "⚪", color: MESSAGE_COLORS.none });
6070
6311
  var markdown = (m, mark) => `### ${mark} ${m.lead}
6071
6312
 
6072
- ${m.detail}
6313
+ ${m.lines.join(`
6314
+ `)}
6073
6315
  ${m.link ? `
6074
6316
  [${m.link.label} ›](${m.link.url})
6075
6317
  ` : ""}`;
6076
6318
  var resultMarkdown = (s) => markdown(runSetMessage(s), messageTone(s).mark);
6077
6319
  var noResultMarkdown = (branch, reason, url) => markdown(noResultMessage(branch, reason, url), "⚪");
6078
6320
  var pendingMarkdown = (branch, url) => markdown(pendingMessage(branch, url), "⚪");
6321
+ // packages/present/dist/src/stale.js
6322
+ function staleNoteText(why) {
6323
+ const newer = why.includes("newer-run-set");
6324
+ const rules = why.some((w) => w === "core-version" || w === "config");
6325
+ if (newer && rules)
6326
+ return "Result from an older run set and older rules.";
6327
+ if (newer)
6328
+ return "Result from an older run set; a newer one has arrived.";
6329
+ if (rules)
6330
+ return "Result from older rules; it is not recomputed yet.";
6331
+ return "Result from older inputs.";
6332
+ }
6333
+ var staleFlagText = (why) => {
6334
+ const note = staleNoteText(why).replace(/\.$/, "");
6335
+ return `${note.charAt(0).toLowerCase()}${note.slice(1)}`;
6336
+ };
6337
+ var STALE_MARK = "stale result";
6079
6338
  // apps/agent/src/upload.ts
6080
6339
  import { join as join4 } from "node:path";
6081
6340
  var MAX_RATE_RETRIES = 5;
6082
6341
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
6342
+ var pause = (ms) => sleep(ms);
6343
+
6083
6344
  class UploadError extends Error {
6345
+ status;
6346
+ constructor(message, status) {
6347
+ super(message);
6348
+ this.status = status;
6349
+ }
6084
6350
  }
6085
6351
  async function call(f, base, path, headers, body, method = "POST") {
6086
6352
  let res;
@@ -6092,7 +6358,7 @@ async function call(f, base, path, headers, body, method = "POST") {
6092
6358
  ...method === "POST" ? { body: JSON.stringify(body ?? {}) } : {}
6093
6359
  });
6094
6360
  } catch (e) {
6095
- throw new UploadError(`${method} ${path}: ${e.message}`);
6361
+ throw new UploadError(`${method} ${path}: ${e.message}`, 0);
6096
6362
  }
6097
6363
  if (res.status !== 429 || attempt >= MAX_RATE_RETRIES)
6098
6364
  break;
@@ -6102,7 +6368,7 @@ async function call(f, base, path, headers, body, method = "POST") {
6102
6368
  }
6103
6369
  const text = await res.text();
6104
6370
  if (res.status < 200 || res.status >= 300)
6105
- throw new UploadError(`${method} ${path} -> ${res.status} ${text.slice(0, 200)}`);
6371
+ throw new UploadError(`${method} ${path} -> ${res.status} ${text.slice(0, 200)}`, res.status);
6106
6372
  try {
6107
6373
  return text ? JSON.parse(text) : {};
6108
6374
  } catch {
@@ -6239,13 +6505,35 @@ async function createRunSet(t, body) {
6239
6505
  throw new UploadError("POST /v1/run-sets: response has no run_set_id (is the URL the KeepSnappy API?)");
6240
6506
  return { run_set_id: r.run_set_id, dashboard_url: String(r.dashboard_url ?? "") };
6241
6507
  }
6508
+ var UNKNOWN_OUTCOME = new Set([0, 502, 503, 504]);
6509
+ var CONFIRM_READS = 6;
6510
+ var CONFIRM_WAIT_MS = 1e4;
6242
6511
  async function completeRunSet(t, id, verdicts) {
6243
6512
  const path = `/v1/run-sets/${encodeURIComponent(id)}/complete`;
6244
- const r = await call(t.fetch ?? fetch, t.url, path, keyHeader(t), { verdicts });
6513
+ let r;
6514
+ try {
6515
+ r = await call(t.fetch ?? fetch, t.url, path, keyHeader(t), { verdicts });
6516
+ } catch (e) {
6517
+ const closed = e instanceof UploadError && UNKNOWN_OUTCOME.has(e.status ?? -1) ? await confirmedClose(t, id) : null;
6518
+ if (closed)
6519
+ return closed;
6520
+ throw e;
6521
+ }
6245
6522
  if (!r.run_set_id)
6246
6523
  throw new UploadError(`POST ${path}: response has no run_set_id`);
6247
6524
  return r;
6248
6525
  }
6526
+ async function confirmedClose(t, id) {
6527
+ const path = `/v1/run-sets/${encodeURIComponent(id)}`;
6528
+ for (let i = 0;i < CONFIRM_READS; i++) {
6529
+ if (i)
6530
+ await pause(CONFIRM_WAIT_MS);
6531
+ const s = await call(t.fetch ?? fetch, t.url, path, keyHeader(t), undefined, "GET").catch(() => null);
6532
+ if (s?.run_set_id && (s.status === "processed" || s.status === "failed"))
6533
+ return s;
6534
+ }
6535
+ return null;
6536
+ }
6249
6537
  async function createIngestRun(t, body) {
6250
6538
  const r = await call(t.fetch ?? fetch, t.url, "/v1/runs", keyHeader(t), body);
6251
6539
  if (!r.run_id || !r.run_token)
@@ -10188,8 +10476,14 @@ ${outcome.summary}`);
10188
10476
  const info = { device, ...git.branch ? { branch: git.branch } : {}, ...git.commit ? { commit: git.commit } : {} };
10189
10477
  await deps.fs.writeFile(join9(out, RUN_INFO), `${JSON.stringify({ ...info, ...runSet ? { run_set_id: runSet.run_set_id } : {} }, null, 2)}
10190
10478
  `);
10191
- if (!api)
10479
+ if (!api) {
10480
+ if (!runs.some((run) => run.valid)) {
10481
+ const reasons = [...new Set(runs.map((run) => run.invalid_reason).filter(Boolean))];
10482
+ rt.print(`No valid run was recorded: ${runs.length === 0 ? "no runs were attempted" : `${runs.length} ${runs.length === 1 ? "run" : "runs"} invalid${reasons.length ? ` (${reasons.join(", ")})` : ""}`}.`);
10483
+ return EXIT.infra;
10484
+ }
10192
10485
  return EXIT.ok;
10486
+ }
10193
10487
  const up = await uploadRuns(deps, out, runs, {
10194
10488
  ingestUrl: api.url,
10195
10489
  key: api.key,
@@ -10410,7 +10704,7 @@ async function aa(deps, outDir, opts = {}) {
10410
10704
  }
10411
10705
 
10412
10706
  // apps/agent/src/farm.ts
10413
- import { dirname as dirname3, join as join13, resolve as resolve5 } from "node:path";
10707
+ import { dirname as dirname4, join as join13, resolve as resolve6 } from "node:path";
10414
10708
 
10415
10709
  // packages/appium-driver/dist/src/links.js
10416
10710
  var ALERT_POLL_MS = 250;
@@ -10714,7 +11008,7 @@ import { existsSync, readFileSync, statSync } from "node:fs";
10714
11008
  import { dirname as dirname2, join as join11, relative as relative2, resolve as resolve4 } from "node:path";
10715
11009
  import { fileURLToPath } from "node:url";
10716
11010
  function driverVersion() {
10717
- const built = "0.1.0-alpha.18";
11011
+ const built = "0.1.0-alpha.20";
10718
11012
  if (built)
10719
11013
  return built;
10720
11014
  let dir = dirname2(fileURLToPath(import.meta.url));
@@ -10769,6 +11063,11 @@ var SDK_FLUSH_WORST_CASE_MS = 315000;
10769
11063
  var FLUSH_MARGIN_MS = 30000;
10770
11064
  var FLUSH_WAIT_MS = SDK_FLUSH_WORST_CASE_MS + FLUSH_MARGIN_MS;
10771
11065
  var FLUSH_STATUSES = new Set(["ok", "error", "off"]);
11066
+ // packages/verdict-core/dist/src/version.generated.js
11067
+ var CORE_FINGERPRINT = "1c8a7ed82956dae0";
11068
+
11069
+ // packages/verdict-core/dist/src/version.js
11070
+ var CORE_VERSION = parseInt(CORE_FINGERPRINT.slice(0, 7), 16);
10772
11071
  // packages/verdict-core/dist/src/window-count.js
10773
11072
  var WINDOW_DEPENDENT_JOURNEY_MEASURES = ["sampler.cpu_ms", "sampler.frames.longest_gap_ms"];
10774
11073
  var OFF = new WeakSet;
@@ -10795,6 +11094,9 @@ var CLAUSES = {
10795
11094
  commit: { c: 0.8326, k95: 1.7675, k99: 2.8108 },
10796
11095
  component: { c: 0.6481, k95: 1.8075, k99: 2.6648 }
10797
11096
  }),
11097
+ "T-NOISE-JANK": row("T-NOISE-JANK", "The judged `jank` rule: the no-change noise curve of jank time, as T-WARN-NOISE; at a main median of 0, Slightly worse past zeroWarn and Worse past zeroError.", "ms", ">", "W8 option A, Arek 2026-10-26 [ctl-arek-jank-time]; fitted by the 2026-10-16 method (dedup-nomi, PR 3x3 vs main 3x3, every split) on pack v3 aa-ni (S23 TapIn, 10 sessions, 4200 splits, 19 places with a median above 0, R² -0.27; the 10-16 S23 and iPhone sessions are no longer stored); zeroWarn = the largest d99 at a median of 0 over the A/A pools with an export (farm16 Pixel 7), zeroError = zeroWarn x k99 / k95", true, {
11098
+ jank: { c: 0.9159, k95: 2.5757, k99: 3.2072, zeroWarn: 10.38, zeroError: 12.92 }
11099
+ }),
10798
11100
  "T-REL": row("T-REL", "The relative part of both tiers when a rule has no noise curve; the deciding config's tiers cap it at the error ms, so no input reaches it.", "share", ">", "REQ-MET-26, controller 2026-10-16", false, { rel: 0.3 }),
10799
11101
  "J-MEMORY-RETAINED": row("J-MEMORY-RETAINED", "Memory the journey kept after the end GC: Slightly worse past max(warn MB, warn % of main), Worse past max(error MB, error % of main), on every device.", "MB", ">", "REQ-MET-4, Arek 2026-10-20 ~08:00, pair B (standing-rulings.md); provisional", true, { warn: { abs: 20, rel: 0.1 }, error: { abs: 150, rel: 0.3 } }),
10800
11102
  "E-REACT-COUNTS": row("E-REACT-COUNTS", "Commit and render counts are evidence past max(floor, +50 % of main).", "count", ">", "REQ-MET-13, Arek 2026-10-20 (morning rulings, Layer 2); provisional", true, { commits: { rel: 0.5, abs: 2 }, renders: { rel: 0.5, abs: 10 } }),
@@ -10802,6 +11104,7 @@ var CLAUSES = {
10802
11104
  "E-BACKGROUND": row("E-BACKGROUND", "Background places are evidence past max(floor, % of main).", "ms", ">", "REQ-MEAS-32; the number has no ruling", true, { background_commit: FLOOR, background_render: FLOOR, background_component: FLOOR }),
10803
11105
  "E-LAUNCH": row("E-LAUNCH", "The launch screen is evidence past max(5 ms, +30 % of main).", "ms", ">", "REQ-VER-15; floor adopted 2026-10-21 from config.ts F-13, [ctl-stab-noold-1625]", true, { launch_commit: FLOOR, launch_render: FLOOR, launch_component: FLOOR }),
10804
11106
  "E-STEP": row("E-STEP", "A declared step is evidence past max(5 ms, +30 % of main).", "ms", ">", "REQ-MET-14; floor adopted 2026-10-21 from config.ts F-13, [ctl-stab-noold-1625]", true, { step_commit: FLOOR, step_render: FLOOR, step_component: FLOOR }),
11107
+ "E-COMMIT-MAX": row("E-COMMIT-MAX", "The slowest commit at a judged window is evidence past max(5 ms, +30 % of main) while jank time is judged.", "ms", ">", "W8 option A, Arek 2026-10-26 [ctl-arek-jank-time]: commit_max_ms stays shown as evidence", true, { commit_max: FLOOR }),
10805
11108
  "E-TOUCH": row("E-TOUCH", "Touch response is evidence past max(10 ms, +20 % of main).", "ms", ">", 'ruling "touch response is not judged" (REQ-MET-24)', true, { touch: { rel: 0.2, abs: 10 } }),
10806
11109
  "E-FREEZE": row("E-FREEZE", "The longest freeze (window, journey) is evidence past max(17 ms, +50 % of main).", "ms", ">", NO_RULING, true, { freeze: { rel: 0.5, abs: 17 }, freeze_journey: { rel: 0.5, abs: 17 } }),
10807
11110
  "E-MEMORY-PLACE": row("E-MEMORY-PLACE", "Memory added at a place is evidence past this many MB (screens have their own), on every device.", "MB", ">", 'REQ-MET-4 amendment 2026-10-18 ~21:10 and ~22:20 ("abs 2 MB, screens 10 MB")', true, { memory_added: { abs: 2 }, memory_added_screen: { abs: 10 } }),
@@ -10857,7 +11160,8 @@ var REACT_MEASURES = [
10857
11160
  "self_ms_top",
10858
11161
  "component_ms",
10859
11162
  "dimension:component",
10860
- "dimension:updater"
11163
+ "dimension:updater",
11164
+ "jank_ms"
10861
11165
  ];
10862
11166
  var WINDOW_MODEL_AFFECTS = [
10863
11167
  "commits",
@@ -10882,7 +11186,8 @@ var WINDOW_MODEL_AFFECTS = [
10882
11186
  "memory_left_mb",
10883
11187
  "retained_mb_per_cycle",
10884
11188
  "dimension:component",
10885
- "dimension:updater"
11189
+ "dimension:updater",
11190
+ "jank_ms"
10886
11191
  ];
10887
11192
  var MEASURES_AT = (kind) => [...Object.keys(MEASURES).filter((id) => MEASURES[id].at.includes(kind)), "dimension:component", "dimension:updater"];
10888
11193
  var SPLIT_AFFECTS = [
@@ -11040,7 +11345,7 @@ var DRIVER_RELEASES = [
11040
11345
  { affects: LOADED_MEMORY }
11041
11346
  ],
11042
11347
  uncalibrated: false,
11043
- source: "Drag end points move above the platform bottom system-gesture zone (fix/driver-gesture-zone); start points unchanged; on iOS a shortened drag keeps its duration (slower); on Android it keeps its speed (ends sooner). A drag's end, and the duration of a step whose scroll steps contain such drags, can change. W1 (#306): farm sessions record no video or device logs by default (`--capture off`), so the phone carries less load and every memory measure (mem.*, memory_left_mb, sampler.memory.*, ri.memory.*) is measured differently from capture-on alpha.16 sessions (Elastyczność 2026-10-26 15:45 B1-DIFF; controller Stab 4). The declaration only separates alpha.16 from alpha.17: two alpha.17 sessions with capture on and off still compare unflagged until the run's setup.capture (#344) is judged (follow-up). Screens and taps stay compared (REQ-FARM-35)."
11348
+ source: "Drag end points move above the platform bottom system-gesture zone (fix/driver-gesture-zone); start points unchanged; on iOS a shortened drag keeps its duration (slower); on Android it keeps its speed (ends sooner). A drag's end, and the duration of a step whose scroll steps contain such drags, can change. W1 (#306): farm sessions record no video or device logs by default (`--capture off`), so the phone carries less load and every memory measure (mem.*, memory_left_mb, sampler.memory.*, ri.memory.*) is measured differently from capture-on alpha.16 sessions (Elastyczność 2026-10-26 15:45 B1-DIFF; controller Stab 4). The declaration only separates alpha.16 from alpha.17: two alpha.17 sessions with capture on and off are told apart by the `capture` measured-with aspect (run setup.capture, #344, #426). Screens and taps stay compared (REQ-FARM-35)."
11044
11349
  },
11045
11350
  {
11046
11351
  version: "0.1.0-alpha.18",
@@ -11048,8 +11353,31 @@ var DRIVER_RELEASES = [
11048
11353
  declares: [{ affects: DRAG_TIME_AND_CPU, places: ["drag"], platforms: ["ios"] }],
11049
11354
  uncalibrated: false,
11050
11355
  source: "R-SCROLL-BUILD (#355 one scroll primitive and the counted gesture retry, #356 the scroll fault matrix). Android declares nothing: on a phone that takes every gesture the driver sends the same requests, bodies and waits as alpha.17 (the whole command stream is pinned). iOS: each checked drag (every to-top drag of flingUntilShown/passChecked and the first drag of each scrollToEnd pass) gains two frame reads of the held row, one right before and one right after it, so a drag window's time and CPU can change; its React render, commits and memory stay compared. A phone that drops a gesture (BrowserStack Galaxy S23, S25 Ultra) gets that gesture once more, as a counted harness defect (REQ-FARM-35)."
11356
+ },
11357
+ {
11358
+ version: "0.1.0-alpha.19",
11359
+ previous: "0.1.0-alpha.18",
11360
+ declares: [
11361
+ { affects: MEASURES_AT("drag"), places: ["drag"], platforms: ["ios"] },
11362
+ { affects: MEASURES_AT("step"), places: ["step"], platforms: ["ios"] }
11363
+ ],
11364
+ uncalibrated: false,
11365
+ source: "#393 (fix/keepsnappy-clamped-row-drag-travel, appium-driver CHANGELOG 2026-10-27): on iOS a row-to-top drag whose end safeBottomY clamps (chat history on every iPhone with a home indicator) starts 33 pt higher, so it travels 33 pt further in the same 400 ms (a faster release, a longer coast), with one list lookup and two frame reads right before it. Every measure of that drag window can move, React work included (more rows scroll by), and so can the step whose scroll steps contain it. Android and unclamped drags send the same requests as alpha.18 (pinned): declares nothing there (REQ-FARM-35)."
11366
+ },
11367
+ {
11368
+ version: "0.1.0-alpha.20",
11369
+ previous: "0.1.0-alpha.19",
11370
+ declares: [
11371
+ { affects: MEASURES_AT("drag"), places: ["drag"] },
11372
+ { affects: MEASURES_AT("step"), places: ["step"] },
11373
+ { affects: MEASURES_AT("journey"), places: ["journey"] }
11374
+ ],
11375
+ uncalibrated: false,
11376
+ source: '#417 (plan 84 step 2.1, D1, appium-driver CHANGELOG "release alpha.20", 2026-10-27): one gesture definition for every list and screen scroll on both platforms, one touch held 150 ms over 60 % of the list frame (at least 250 dp or pt). Android list and screen flings 12 000 / 3000 dp/s -> that contact (a shorter coast, about 1000-1600 dp in place of about 7000, so fewer momentum frames and possibly one more gesture per page pass); iOS list drags 75 % (end) / 65 % (top) -> 60 %, iOS drawn-row (inverted list) drags 400 -> 150 ms; touches reaching a system zone move whole. Every other request, body and wait is pinned unchanged against alpha.19. The CHANGELOG states "changes Android and iOS list/scroll places; compare only within alpha.20 on both platforms", so both platforms are declared at drag, step and journey places (REQ-FARM-35): the journey holds the whole-run measures the judge reads (memory kept, memory at launch, end and peak, whole-run CPU, the longest freeze), and a shorter coast and one more gesture per page pass move them too.'
11051
11377
  }
11052
11378
  ];
11379
+ var JOURNEY_RELEASES = {};
11380
+ var CAPTURE_MEASURED_WITH = { aspect: "capture", affects: LOADED_MEMORY, marks: true };
11053
11381
  var UNSTABLE_NOISY = {
11054
11382
  reason: "noisy",
11055
11383
  spread: "sessions",
@@ -11063,7 +11391,6 @@ var UNSTABLE_NOISY = {
11063
11391
  uncalibrated: false,
11064
11392
  source: 'Arek 2026-10-18 ~12:00, ~12:20 (noisy on main = unstable, main only, per comparison, hysteresis), 12:50 (within-session spread only: a regression that lands on main moves whole sessions and must stay judged), 13:20 ("zrób na razie wg rekomendacji"; threshold provisional). ' + "Ratio = z99 x the predicted no-change difference SD from the pooled within-session MAD of main (Var(median) = (pi/2) sigma^2 / n) / the smallest warning step at main's median. " + "Replay (noisy/REPORT.md, derivation 22, 1 PR vs 3 main): S23 night 2 -> 1 false worse z 60 (MAD rule 0), sim 0, NI pool 95 -> 64 warnings z 840 (MAD 92); 3 vs 3 0 everywhere; flagged per split S23 17.8, NI 4.9, sim 0.7; judged per split S23 64.5 -> 46.8; flips S23 8 (MAD 21); planted main steps flagged noisy 0 (MAD 5-29 %, with the between-session part 56-99 %). " + "Curve: kept for the judged-rule lookup; the ratio uses the warning step"
11065
11393
  };
11066
-
11067
11394
  class ConfigError2 extends Error {
11068
11395
  problems;
11069
11396
  constructor(problems) {
@@ -11075,7 +11402,7 @@ class ConfigError2 extends Error {
11075
11402
  var noRow = (id) => {
11076
11403
  throw new ConfigError2([`${id}: no row in the clause table`]);
11077
11404
  };
11078
- var NOISE_FITS = CLAUSES["T-WARN-NOISE"].value;
11405
+ var NOISE_FITS = { ...CLAUSES["T-WARN-NOISE"].value, ...CLAUSES["T-NOISE-JANK"].value };
11079
11406
  var TIER_REL = 0.3;
11080
11407
  var TIER_UPDATE_MS = { warnMs: 5, errorMs: 15 };
11081
11408
  var TIER_WORK_MS = { warnMs: 10, errorMs: 30 };
@@ -11107,17 +11434,16 @@ function withClauseValues(base) {
11107
11434
  throw new ConfigError2(["better: missing from base config"]);
11108
11435
  const warnMs = CLAUSES["T-WARN-MS"].value;
11109
11436
  const errorMs = CLAUSES["TIER-ERROR"].value;
11110
- const noise = CLAUSES["T-WARN-NOISE"];
11111
11437
  const memory = CLAUSES["J-MEMORY-RETAINED"];
11112
11438
  const judgedByNoise = (r) => {
11113
11439
  const fit = NOISE_FITS[r.id] ?? noRow(r.id);
11114
- const n = { a: 0, b: fit.c, power: 0.5, lift: fit.k95 };
11440
+ const n = { a: 0, b: fit.c, power: 0.5, lift: fit.k95, ...fit.zeroWarn !== undefined ? { atZero: fit.zeroWarn } : {} };
11115
11441
  return {
11116
11442
  ...r,
11117
11443
  threshold: [{ abs: warnMs.update, noise: n }],
11118
11444
  thresholdByWork: { entry: [{ abs: warnMs.work, noise: n }], mount: [{ abs: warnMs.work, noise: n }] },
11119
- errorNoise: { ...n, lift: fit.k99 },
11120
- uncalibrated: noise.uncalibrated
11445
+ errorNoise: { a: 0, b: fit.c, power: 0.5, lift: fit.k99, ...fit.zeroError !== undefined ? { atZero: fit.zeroError } : {} },
11446
+ uncalibrated: (r.id in CLAUSES["T-NOISE-JANK"].value ? CLAUSES["T-NOISE-JANK"] : CLAUSES["T-WARN-NOISE"]).uncalibrated
11121
11447
  };
11122
11448
  };
11123
11449
  const evidence = (r) => {
@@ -11125,11 +11451,18 @@ function withClauseValues(base) {
11125
11451
  const at = r.thresholdAt && Object.fromEntries(Object.keys(r.thresholdAt).map((kind) => [kind, [part((EVIDENCE_BARS.get(`${r.id}_${kind}`) ?? noRow(`${r.id}_${kind}`)).bar)]]));
11126
11452
  return { ...r, threshold: [part(e.bar)], ...at ? { thresholdAt: at } : {}, uncalibrated: e.uncalibrated };
11127
11453
  };
11128
- const rules = base.rules.map((r) => {
11454
+ const valued = (r) => {
11129
11455
  if (r.id === "memory_retained")
11130
11456
  return { ...r, threshold: [part(memory.value.warn)], uncalibrated: memory.uncalibrated };
11131
- return r.role === "judged" ? judgedByNoise(r) : evidence(r);
11132
- });
11457
+ if (r.role === "judged")
11458
+ return judgedByNoise(r);
11459
+ if (r.id in NOISE_FITS) {
11460
+ const { errorNoise: _error, ...shown } = judgedByNoise(r);
11461
+ return shown;
11462
+ }
11463
+ return evidence(r);
11464
+ };
11465
+ const rules = base.rules.map(valued);
11133
11466
  const frames = (id) => ({ above: { frames: CLAUSES[id].value.frames }, uncalibrated: CLAUSES[id].uncalibrated });
11134
11467
  const entry = CLAUSES["P-HEAVY-ENTRY"];
11135
11468
  const unstable = CLAUSES["U-NOISY"];
@@ -11171,12 +11504,38 @@ function withClauseValues(base) {
11171
11504
  minMainRuns: unstable.value.minMainRuns,
11172
11505
  minSessions: unstable.value.minSessions,
11173
11506
  uncalibrated: unstable.uncalibrated,
11174
- curve: Object.fromEntries(Object.keys(u.curve).map((id) => [id, (NOISE_FITS[id] ?? noRow(`unstable curve ${id}`)).c]))
11507
+ curve: Object.fromEntries(Object.keys(u.curve).map((id) => [id, (NOISE_FITS[id] ?? noRow(`unstable curve ${id}`)).c])),
11508
+ ...u.reads ? { reads: u.reads.map(valued) } : {}
11175
11509
  }))
11176
11510
  };
11177
11511
  }
11512
+ var COMMIT_MAX_JUDGED = noiseJudged("commit", { measure: "commit_max_ms" });
11513
+ var RENDER_JUDGED = noiseJudged("render", { measure: "render_ms" });
11514
+ var COMPONENT_JUDGED = noiseJudged("component", { dimension: "component", measure: "self_ms" });
11515
+ var MEMORY_RETAINED = {
11516
+ id: "memory_retained",
11517
+ role: "judged",
11518
+ on: { measure: "mem.retained_growth_mb" },
11519
+ places: JOURNEY,
11520
+ devices: ANY_DEVICE,
11521
+ threshold: [{ rel: 0.1, abs: 20 }],
11522
+ uncalibrated: true,
11523
+ source: MEMORY_PAIR_SOURCE
11524
+ };
11525
+ var FELT_SOURCE = 'Arek 2026-10-27 ~08:00 ("Ok", [ctl-arek-felt-cost], amends decision 0005 / Q-5): component self time explains, never decides; every component past its noise is listed "to check", also on a green verdict';
11526
+ var JANK_SOURCE = `W8 option A, Arek 2026-10-26 ("Opcja A jest git", [ctl-arek-jank-time]): jank time (the window's commit times past one 60 Hz frame, added up) is judged in place of the slowest commit, so one spike no longer decides and jitter below a frame counts 0; curve T-NOISE-JANK; deciding since simple-4.26 after the pack v3 replay (A/A Slightly worse 58 -> 46 of 840 on aa-ni, doses 54 of 54)`;
11527
+ var NOISY_WIDEN = {
11528
+ noisyFrom: 1,
11529
+ lineMultiplier: 1,
11530
+ sd: "aa-loo",
11531
+ excludeAbove: null,
11532
+ excludeReturnBelow: null,
11533
+ excludeAboveMs: null,
11534
+ uncalibrated: false,
11535
+ source: `Arek 2026-10-27 ~19:45 [ctl-arek-noisy-widen]: noisy places judged with a widened line, tunable down to the old exclusion (excludeAbove 1.0 / 0.75 with the within-session spread); amendment ~19:40 ("Szum z 3 sesji main powinniśmy liczyć tak samo jak A/A"): one main session against the others, between-session shift included, plus the PR's run-to-run spread; ctl 19:45: ×1, k never raised to pass; start position: never left out (ctl 22:20, the Pixel 7 no longer counts). Pack v4 replay against simple-4.27 without the Pixel 7: A/A false Worse 1 / 1 of 1070 splits, doses caught Reference night 166 / 166, Reference PR 93 / 93, farm iPhone ms 4863 -> 5012 of 6580 (Worse 3107 -> 3167), places left out 9.8 % -> 0 %. Footnote Pixel 7 (21 splits): A/A Worse 0 -> 4`
11536
+ };
11178
11537
  var SHAPE = {
11179
- id: "simple-4.25+noise95-99",
11538
+ id: "simple-4.28+noise95-99",
11180
11539
  minRuns: 2,
11181
11540
  baseline: { sessions: 3, ...GUESS("Q4 / REQ-VER-33: main's newest 3 sessions against one PR session (the quieter setting of the plan 58 simulation)") },
11182
11541
  repeats: { kind: "every-pr-beyond-every-main" },
@@ -11198,13 +11557,15 @@ var SHAPE = {
11198
11557
  uncalibrated: false,
11199
11558
  source: 'Arek 2026-10-16 ("Tak, to też ustalaliśmy"): the SDK, the driver and the journey are the instrument (REQ-VER-35); a release declares the measures it affects (REQ-VER-34); a forced GC before each step is a different instrument (REQ-VER-13, docs/plans/gc-before-action.md)',
11200
11559
  driverReleases: DRIVER_RELEASES,
11560
+ ...Object.keys(JOURNEY_RELEASES).length ? { journeyReleases: JOURNEY_RELEASES } : {},
11201
11561
  unknownDiffers: ["journeyVersion"]
11202
11562
  },
11203
11563
  calibrated: { platforms: ["ios", "android"], uncalibrated: false, source: 'REQ-FARM-26: Android moved in by Arek ("Ok", 2026-10-17) after the S23 A/A, 5 sessions × 3 runs: 0 z 25 false worse under the deciding rule (stats-android/aa/aa-report.md)' },
11204
11564
  rules: [
11205
- noiseJudged("commit", { measure: "commit_max_ms" }),
11206
- noiseJudged("render", { measure: "render_ms" }),
11207
- noiseJudged("component", { dimension: "component", measure: "self_ms" }),
11565
+ { ...COMMIT_MAX_JUDGED, id: "jank", on: { measure: "jank_ms" }, source: JANK_SOURCE },
11566
+ evidenceRule("commit_max", { measure: "commit_max_ms" }, JUDGED_WINDOWS, [], "W8 option A: the slowest commit is shown, jank time is judged", { devices: ANY_DEVICE }),
11567
+ RENDER_JUDGED,
11568
+ { ...COMPONENT_JUDGED, role: "evidence", source: FELT_SOURCE },
11208
11569
  { ...evidenceRule("commits", { measure: "commits" }, REACT_PLACES, [{ rel: 0.5, abs: 2 }], "render and commit counts are evidence only (ruling a)"), source: COUNT_SOURCE },
11209
11570
  { ...evidenceRule("renders", { measure: "renders" }, REACT_PLACES, [{ rel: 0.5, abs: 10 }], "render and commit counts are evidence only (ruling a)"), source: COUNT_SOURCE },
11210
11571
  evidenceRule("render_upper", { measure: "render_ms_upper" }, WINDOWS, [{ rel: TIER_REL, abs: TIER_UPDATE_MS.warnMs }], "REQ-MEAS-32 timers, option A (Arek 2026-10-17): judged plus timer-carried render time is evidence with the render threshold; render_ms decides"),
@@ -11230,16 +11591,7 @@ var SHAPE = {
11230
11591
  evidenceRule("gc", { measure: "gc_ms" }, SHOWN2, [{ rel: 0.5, abs: 2 }], "v5.3 warn level"),
11231
11592
  evidenceRule("requests", { measure: "net_requests" }, SHOWN2, [{ rel: 0.5, abs: 1 }], "v5.3 warn level"),
11232
11593
  evidenceRule("js_cpu", { measure: "js_cpu_ms" }, SHOWN2, [{ rel: 0.5, abs: 5 }], "v5.3 warn level"),
11233
- {
11234
- id: "memory_retained",
11235
- role: "judged",
11236
- on: { measure: "mem.retained_growth_mb" },
11237
- places: JOURNEY,
11238
- devices: ANY_DEVICE,
11239
- threshold: [{ rel: 0.1, abs: 20 }],
11240
- uncalibrated: true,
11241
- source: MEMORY_PAIR_SOURCE
11242
- }
11594
+ MEMORY_RETAINED
11243
11595
  ],
11244
11596
  problems: [
11245
11597
  {
@@ -11281,13 +11633,20 @@ var SHAPE = {
11281
11633
  uncalibrated: true,
11282
11634
  source: "ruling d: entry and in-screen mount about 100 ms; an update one frame (16.7 ms on every screen, Arek 2026-10-16)"
11283
11635
  },
11636
+ feltCost: {
11637
+ reads: ["jank", "render"],
11638
+ uncalibrated: false,
11639
+ source: 'Arek 2026-10-27 ~08:00 ("Ok", [ctl-arek-felt-cost]) after giga2-scratch/analysis/2026-10-27-verdict-scale.md option C; B4 replay on the jank pack v3 (676d7453a39c) against simple-4.26: A/A Worse / Slightly worse over 16 pools 2 / 68 -> 1 / 34, doses 52 of 54 (in-screen-mount 1.1x and slow-commit 0.5x lose one case each), golden 10 of 10, corpus 0 moves'
11640
+ },
11284
11641
  freeze: { on: "sampler_longest_gap_ms", uncalibrated: false, source: 'evidence only: the longest freeze covers the whole window, not the transition; "no dropped frames during the transition" (ruling d) needs per-place frame data that is not available' },
11285
11642
  notJudgedLabels: ["launch"],
11286
11643
  unstable: [{
11287
11644
  ...UNSTABLE_NOISY,
11288
11645
  step: "own",
11289
- source: `${UNSTABLE_NOISY.source}; Arek 2026-10-26 ("A wygląda kusząco. Oddaj do impl.", ctl-arek-met29-own-step): the step is the place's own work kind's (entry and in-screen mount 10 ms, update 5 ms), the smallest only when the kind is unknown`
11646
+ reads: [COMMIT_MAX_JUDGED, RENDER_JUDGED, COMPONENT_JUDGED, MEMORY_RETAINED],
11647
+ source: `${UNSTABLE_NOISY.source}; Arek 2026-10-26 ("A wygląda kusząco. Oddaj do impl.", ctl-arek-met29-own-step): the step is the place's own work kind's (entry and in-screen mount 10 ms, update 5 ms), the smallest only when the kind is unknown; ctl-arek-jank-time: noisiness still reads the slowest commit, so a place is unstable exactly when it was before jank time was judged`
11290
11648
  }],
11649
+ noisy: NOISY_WIDEN,
11291
11650
  judgeFrom: { mainSessions: 3, uncalibrated: false, source: 'Arek 2026-10-18 (REQ-VER-41): a phone is judged only when main has at least 3 sessions on that phone and journey; with fewer the result is "not enough main yet", not judged, not blocking, values shown. Replay (agents/release/replay, d22): 1 PR vs 2 main on the S23 night pool gave 13 z 60 false worse+warning, 1 vs 3 gave 0' },
11292
11651
  measuredWith: [
11293
11652
  { aspect: "renderer", affects: REACT_MEASURES },
@@ -11296,7 +11655,8 @@ var SHAPE = {
11296
11655
  { aspect: "screenWindowModel", affects: WINDOW_MODEL_AFFECTS, places: ["screen"], marks: true },
11297
11656
  { aspect: "cpuSource", affects: ["sampler.cpu_ms"] },
11298
11657
  { aspect: "causeSplit", affects: REACT_MEASURES, places: REACT_PLACES, marks: true },
11299
- { aspect: "endGc", affects: MEMORY_MEASURES }
11658
+ { aspect: "endGc", affects: MEMORY_MEASURES },
11659
+ CAPTURE_MEASURED_WITH
11300
11660
  ],
11301
11661
  tiers: {
11302
11662
  rel: TIER_REL,
@@ -11320,15 +11680,6 @@ var SHAPE = {
11320
11680
  }
11321
11681
  };
11322
11682
  var CONFIG = withClauseValues(SHAPE);
11323
- var P2A_SLIGHT = withClauseValues({
11324
- ...CONFIG,
11325
- id: `${CONFIG.id}+p2a-sw`,
11326
- unstable: CONFIG.unstable.map((u) => ({
11327
- ...u,
11328
- judgeAt: { line: "error", tier: "warning" },
11329
- source: `${u.source}; P2a candidate (plan 81, Arek 2026-10-23 ~15:45): judged only past the error line, as Slightly worse, never blocking`
11330
- }))
11331
- });
11332
11683
  var P4_EVEN = {
11333
11684
  ...CONFIG,
11334
11685
  id: `${CONFIG.id}+p4-even`,
@@ -11339,6 +11690,51 @@ var P3_COMMIT = withClauseValues({
11339
11690
  id: `${CONFIG.id}+p3-commit`,
11340
11691
  commitGrouping: { uncalibrated: true, source: "plan 81 P3; Arek 2026-10-26 [ctl-arek-p81-p3p4]: two windows that share one proven commit (the same slowest commit in every PR run) may be one finding; never merged without it" }
11341
11692
  });
11693
+ // apps/agent/src/flow-version.ts
11694
+ import { createHash as createHash2 } from "node:crypto";
11695
+ import { dirname as dirname3, relative as relative3, resolve as resolve5, sep as sep2 } from "node:path";
11696
+ var REFERENCE = /^\s*(?:-\s*)?(?:runFlow|runScript|file)\s*:\s*['"]?([^'"\s#{}$]+\.(?:ya?ml|js))['"]?\s*$/gm;
11697
+ var commonDir = (paths) => {
11698
+ let dir = dirname3(paths[0]);
11699
+ while (!paths.every((p) => p === dir || p.startsWith(dir.endsWith(sep2) ? dir : dir + sep2)))
11700
+ dir = dirname3(dir);
11701
+ return dir;
11702
+ };
11703
+ async function flowsVersion(files, read, skip = []) {
11704
+ if (files.length === 0)
11705
+ return null;
11706
+ const roots = files.map((f) => resolve5(f));
11707
+ const skipped = new Set(skip.map((s) => resolve5(s)));
11708
+ const seen = new Map;
11709
+ const visit = async (file, root) => {
11710
+ if (seen.has(file) || skipped.has(file))
11711
+ return true;
11712
+ const text = await read(file).then((t) => t.replace(/\r\n?/g, `
11713
+ `), () => null);
11714
+ if (text === null)
11715
+ return !root;
11716
+ seen.set(file, text);
11717
+ for (const m of text.matchAll(REFERENCE))
11718
+ await visit(resolve5(dirname3(file), m[1]), false);
11719
+ return true;
11720
+ };
11721
+ for (const r of roots)
11722
+ if (!await visit(r, true))
11723
+ return null;
11724
+ const base = commonDir(roots);
11725
+ const keyed = [...seen].map(([file, text]) => [relative3(base, file).split(sep2).join("/"), text]);
11726
+ const hash = createHash2("sha256");
11727
+ for (const [key, text] of keyed.sort((a, b) => a[0] < b[0] ? -1 : 1))
11728
+ hash.update(key).update("\x00").update(text).update("\x00");
11729
+ return hash.digest("hex").slice(0, 12);
11730
+ }
11731
+ async function maestroCliVersion(deps) {
11732
+ const r = await deps.spawner.run("maestro", ["--version"], { timeoutMs: 30000, killAfterMs: 5000 });
11733
+ if (r.code !== 0)
11734
+ return;
11735
+ return /^\s*v?(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)\s*$/m.exec(r.stdout)?.[1];
11736
+ }
11737
+
11342
11738
  // apps/agent/src/device-model.ts
11343
11739
  import { tmpdir } from "node:os";
11344
11740
  import { join as join12 } from "node:path";
@@ -11481,6 +11877,19 @@ var maestroLinkFields = (sdkArgs = "") => ({ keepsnappyLinkArgs: linkArgs(sdkArg
11481
11877
 
11482
11878
  // apps/agent/src/farm.ts
11483
11879
  var STATE_VERSION = 1;
11880
+ function plantOf(raw) {
11881
+ let v;
11882
+ try {
11883
+ v = JSON.parse(raw);
11884
+ } catch {
11885
+ return null;
11886
+ }
11887
+ const ok = (p) => {
11888
+ const x = p;
11889
+ return !!x && typeof x.place === "string" && !!x.place && typeof x.kind === "string" && !!x.kind && typeof x.factor === "number" && Number.isFinite(x.factor) && x.factor > 0 && (x.unit === "budget" || x.unit === "MB");
11890
+ };
11891
+ return Array.isArray(v) && v.length > 0 && v.length <= 200 && v.every(ok) ? v : null;
11892
+ }
11484
11893
  var COMPLETION_MARKER = "run:complete";
11485
11894
  var SETUP_FLAGS = [
11486
11895
  ["driver-version", "driver_version"],
@@ -11489,19 +11898,26 @@ var SETUP_FLAGS = [
11489
11898
  ["journey-version", "journey_version"],
11490
11899
  ["flag-set", "flag_set"]
11491
11900
  ];
11492
- function instrumentFlags(f) {
11493
- if (!f["journey-module"] && f.driver !== "appium")
11494
- return {};
11901
+ var LOCAL_PROVIDERS = ["local", "usb", "customer", "simulator", "emulator"];
11902
+ var COMMIT_ID = /^[0-9a-f]{40}$/i;
11903
+ function instrumentFlags(f, maestro = {}) {
11904
+ const given = f["journey-version"]?.trim();
11905
+ const named = given && !COMMIT_ID.test(given) ? given : undefined;
11906
+ if (!f["journey-module"] && f.driver !== "appium") {
11907
+ const driver = f["driver-version"] ?? maestro.maestroVersion;
11908
+ const journey = maestro.flowsVersion ?? named;
11909
+ return { ...driver ? { "driver-version": driver } : {}, ...journey ? { "journey-version": journey } : {} };
11910
+ }
11495
11911
  let hash;
11496
11912
  if (f["journey-module"]) {
11497
11913
  try {
11498
- hash = journeyVersion(resolve5(f["journey-module"]));
11914
+ hash = journeyVersion(resolve6(f["journey-module"]));
11499
11915
  } catch {
11500
11916
  hash = undefined;
11501
11917
  }
11502
11918
  }
11503
11919
  const driver = f["driver-version"] ?? driverVersion() ?? undefined;
11504
- const journey = f["journey-version"] ?? hash;
11920
+ const journey = hash ?? named;
11505
11921
  return { ...driver ? { "driver-version": driver } : {}, ...journey ? { "journey-version": journey } : {} };
11506
11922
  }
11507
11923
  var TOKENS_STUB = "// Replaced by `keepsnappy farm open` for one upload. Never commit real tokens.\noutput.keepsnappyTokens = {};\n";
@@ -11562,7 +11978,8 @@ async function farmJourneys(deps, f, rt, journeys) {
11562
11978
  if (!byName.has(j))
11563
11979
  rt.print(`journey ${j}: no flow file named ${j}.yaml in --config/--flows; sent without a title`);
11564
11980
  }
11565
- return journeysFor(journeys.map((j) => ({ name: j, ...byName.get(j) ?? {} })), { readFile: (p) => deps.fs.readFile(p), root: files.length ? await repoRoot(deps) : null });
11981
+ const info = await journeysFor(journeys.map((j) => ({ name: j, ...byName.get(j) ?? {} })), { readFile: (p) => deps.fs.readFile(p), root: files.length ? await repoRoot(deps) : null });
11982
+ return { journeys: info, files: files.map((x) => x.file) };
11566
11983
  }
11567
11984
  async function farmOpen(deps, f, rt) {
11568
11985
  const problems = [];
@@ -11579,7 +11996,7 @@ async function farmOpen(deps, f, rt) {
11579
11996
  provider = "simulator";
11580
11997
  model = model ? withSimulatorWord(model) : simulator;
11581
11998
  }
11582
- if (!model && ["local", "usb", "customer", "simulator", "emulator"].includes(provider)) {
11999
+ if (!model && LOCAL_PROVIDERS.includes(provider)) {
11583
12000
  model = await detectDeviceModel(deps, { platform: f.platform, deviceId: f["device-id"]?.trim() }) ?? undefined;
11584
12001
  if (model)
11585
12002
  rt.print(`device model read from the device: ${model}`);
@@ -11606,18 +12023,29 @@ async function farmOpen(deps, f, rt) {
11606
12023
  problems.push("--tokens-js <path> is required (the file the suite overlay puts at flows/keepsnappy/tokens.js)");
11607
12024
  if (!f.state)
11608
12025
  problems.push("--state <path> is required (read by keepsnappy farm close)");
12026
+ const plant = f.plant === undefined ? undefined : plantOf(f.plant);
12027
+ if (plant === null)
12028
+ problems.push('--plant must be a JSON list of { place, kind, factor > 0, unit: "budget" | "MB" }');
11609
12029
  if (problems.length)
11610
12030
  throw new ConfigError(problems);
11611
- const statePath = resolve5(f.state);
11612
- const tokensPath = resolve5(f["tokens-js"]);
12031
+ const statePath = resolve6(f.state);
12032
+ const tokensPath = resolve6(f["tokens-js"]);
11613
12033
  if (await deps.fs.exists(statePath)) {
11614
12034
  throw new ConfigError([`${statePath} already exists: close that run set first (keepsnappy farm close --state ${f.state}) or remove the file`]);
11615
12035
  }
11616
12036
  const api = apiTarget(url, f, rt);
11617
12037
  const now = rt.now ?? Date.now;
11618
12038
  const device = { model, os: f["device-os"] ?? null, provider };
11619
- const journeyInfo = await farmJourneys(deps, f, rt, journeys);
11620
- const withInstrument = { ...f, ...instrumentFlags(f) };
12039
+ const { journeys: journeyInfo, files: flowFiles } = await farmJourneys(deps, f, rt, journeys);
12040
+ const { "driver-version": _driver, "journey-version": given, ...rest } = f;
12041
+ const maestro = !f["journey-module"] && f.driver !== "appium";
12042
+ const flows = maestro ? await flowsVersion(flowFiles, (p) => deps.fs.readFile(p), [tokensPath]) : null;
12043
+ const asked = maestro && !f["driver-version"] && LOCAL_PROVIDERS.includes(provider) ? await maestroCliVersion(deps) : undefined;
12044
+ const instrument = instrumentFlags(f, { ...flows ? { flowsVersion: flows } : {}, ...asked ? { maestroVersion: asked } : {} });
12045
+ if (given?.trim() && given.trim() !== instrument["journey-version"]) {
12046
+ rt.print(`--journey-version ${given.trim()} ignored: ${instrument["journey-version"] ? `the journey version is the hash of what runs, ${instrument["journey-version"]}` : "a commit id names the checkout, not the journey; the run records no journey version"}`);
12047
+ }
12048
+ const withInstrument = { ...rest, ...instrument };
11621
12049
  const setup = Object.fromEntries(SETUP_FLAGS.flatMap(([flag, key]) => withInstrument[flag]?.trim() ? [[key, withInstrument[flag].trim().slice(0, 200)]] : []));
11622
12050
  const base = await baseCommitFor(deps, rt.env, { explicit: f["base-commit"], branch: git.branch, commit: git.commit }, rt.print);
11623
12051
  const main = await mainMerges(deps, rt.env, { branch: git.branch, commit: git.commit });
@@ -11634,7 +12062,13 @@ async function farmOpen(deps, f, rt) {
11634
12062
  journeys: journeyInfo,
11635
12063
  repetitions: repeat,
11636
12064
  source: "farm",
11637
- ...f["completion-marker"] || Object.keys(setup).length ? { meta: { ...f["completion-marker"] ? { completion_marker: COMPLETION_MARKER } : {}, ...Object.keys(setup).length ? { setup } : {} } } : {}
12065
+ ...f["completion-marker"] || Object.keys(setup).length || plant ? {
12066
+ meta: {
12067
+ ...f["completion-marker"] ? { completion_marker: COMPLETION_MARKER } : {},
12068
+ ...Object.keys(setup).length ? { setup } : {},
12069
+ ...plant ? { plant } : {}
12070
+ }
12071
+ } : {}
11638
12072
  }).catch(infra2(`cannot open a run set at ${api.url}`));
11639
12073
  const prefix = `${git.commit ? git.commit.slice(0, 12) : "farm"}-${rs.run_set_id.slice(0, 8)}`;
11640
12074
  const runs = [];
@@ -11665,7 +12099,7 @@ async function farmOpen(deps, f, rt) {
11665
12099
  if (rt.env.GITHUB_ACTIONS === "true")
11666
12100
  for (const t of Object.values(tokens).flat())
11667
12101
  rt.print(`::add-mask::${t}`);
11668
- await deps.fs.mkdir(dirname3(tokensPath));
12102
+ await deps.fs.mkdir(dirname4(tokensPath));
11669
12103
  await deps.fs.writeFile(tokensPath, tokensJs(api.url, rs.run_set_id, tokens, maestroLinkFields(f["sdk-args"] ?? "")));
11670
12104
  const state = {
11671
12105
  version: STATE_VERSION,
@@ -11685,7 +12119,7 @@ async function farmOpen(deps, f, rt) {
11685
12119
  closed_at: null,
11686
12120
  result: null
11687
12121
  };
11688
- await deps.fs.mkdir(dirname3(statePath));
12122
+ await deps.fs.mkdir(dirname4(statePath));
11689
12123
  await deps.fs.writeFile(statePath, `${JSON.stringify(state, null, 2)}
11690
12124
  `);
11691
12125
  await githubOutputs(deps, rt, {
@@ -11791,7 +12225,7 @@ async function farmClose(deps, f, rt) {
11791
12225
  throw new ConfigError(["--state <path> is required (written by keepsnappy farm open)"]);
11792
12226
  if (f["fail-on-worse"] && !f.wait)
11793
12227
  throw new ConfigError(["--fail-on-worse needs --wait"]);
11794
- const statePath = resolve5(f.state);
12228
+ const statePath = resolve6(f.state);
11795
12229
  let state;
11796
12230
  try {
11797
12231
  state = JSON.parse(await deps.fs.readFile(statePath));
@@ -11818,10 +12252,10 @@ async function closeRunSet(deps, f, rt, state, statePath) {
11818
12252
  const api = apiTarget(f.api ?? f["ingest-url"] ?? state.api, f, rt);
11819
12253
  const now = rt.now ?? Date.now;
11820
12254
  const sleep = rt.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
11821
- const out = resolve5(f.out ?? dirname3(statePath));
12255
+ const out = resolve6(f.out ?? dirname4(statePath));
11822
12256
  let bsText = null;
11823
12257
  if (f["bs-build"])
11824
- bsText = await deps.fs.readFile(resolve5(f["bs-build"])).catch(() => null);
12258
+ bsText = await deps.fs.readFile(resolve6(f["bs-build"])).catch(() => null);
11825
12259
  const session = sessionOutcome(bsText);
11826
12260
  const where = `${state.branch}${state.commit ? ` @ ${state.commit.slice(0, 7)}` : ""}`;
11827
12261
  rt.print(`KeepSnappy run set ${state.run_set_id} (${where}): closing`);
@@ -11906,7 +12340,7 @@ async function closeRunSet(deps, f, rt, state, statePath) {
11906
12340
  // apps/agent/src/farm-run.ts
11907
12341
  import { existsSync as existsSync2 } from "node:fs";
11908
12342
  import { createRequire } from "node:module";
11909
- import { dirname as dirname4, join as join16, resolve as resolve6 } from "node:path";
12343
+ import { dirname as dirname5, join as join16, resolve as resolve7 } from "node:path";
11910
12344
  import { fileURLToPath as fileURLToPath2 } from "node:url";
11911
12345
 
11912
12346
  // apps/agent/src/app-source.ts
@@ -12179,10 +12613,10 @@ ${r.stderr}`;
12179
12613
  // apps/agent/src/farm-run.ts
12180
12614
  function appiumDriverEntry(o = {}) {
12181
12615
  const exists = o.exists ?? existsSync2;
12182
- const bundled = join16(o.selfDir ?? dirname4(fileURLToPath2(import.meta.url)), "appium-driver.js");
12616
+ const bundled = join16(o.selfDir ?? dirname5(fileURLToPath2(import.meta.url)), "appium-driver.js");
12183
12617
  if (exists(bundled))
12184
12618
  return bundled;
12185
- const pkgDir = dirname4(o.packageJson ? o.packageJson() : createRequire(import.meta.url).resolve("@keepsnappy/appium-driver/package.json"));
12619
+ const pkgDir = dirname5(o.packageJson ? o.packageJson() : createRequire(import.meta.url).resolve("@keepsnappy/appium-driver/package.json"));
12186
12620
  const built = join16(pkgDir, "dist", "src", "cli.js");
12187
12621
  return exists(built) ? built : join16(pkgDir, "src", "cli.ts");
12188
12622
  }
@@ -12281,7 +12715,7 @@ function planFarmRun(f) {
12281
12715
  ports,
12282
12716
  env,
12283
12717
  driverArgs: f["driver-arg"] ?? [],
12284
- out: resolve6(f.out ?? "farm-run"),
12718
+ out: resolve7(f.out ?? "farm-run"),
12285
12719
  timeoutMinutes,
12286
12720
  killAfterSeconds,
12287
12721
  pollSeconds
@@ -12330,7 +12764,7 @@ async function runDriver2(deps, rt, f, p, ports, ctx) {
12330
12764
  device: p.device,
12331
12765
  app: ctx.app,
12332
12766
  ...f["custom-id"] ? { customId: f["custom-id"] } : {},
12333
- suiteDir: resolve6(f["suite-dir"]),
12767
+ suiteDir: resolve7(f["suite-dir"]),
12334
12768
  tokensPath: f["tokens-path"] ?? "flows/keepsnappy/tokens.js",
12335
12769
  tokensJs: ctx.tokensJs,
12336
12770
  execute: f.execute,
@@ -12356,7 +12790,7 @@ async function runDriver2(deps, rt, f, p, ports, ctx) {
12356
12790
  throw new Error("no Appium driver entry: pass --driver-script");
12357
12791
  const args = [
12358
12792
  entry,
12359
- ...f["journey-module"] ? ["--journey-module", resolve6(f["journey-module"])] : [],
12793
+ ...f["journey-module"] ? ["--journey-module", resolve7(f["journey-module"])] : [],
12360
12794
  "--platform",
12361
12795
  p.platform,
12362
12796
  "--device",
@@ -12433,7 +12867,8 @@ async function farmRun(deps, f, rt, ports) {
12433
12867
  driver: p.driver,
12434
12868
  "tokens-js": tokensJs,
12435
12869
  state: statePath,
12436
- ...commit ? { commit } : {}
12870
+ ...commit ? { commit } : {},
12871
+ ...p.driver !== "appium" && !f["driver-version"] && f["maestro-version"] ? { "driver-version": f["maestro-version"] } : {}
12437
12872
  }, rt);
12438
12873
  if (openCode !== EXIT.ok)
12439
12874
  return openCode;
@@ -12476,15 +12911,15 @@ async function farmRun(deps, f, rt, ports) {
12476
12911
  }
12477
12912
 
12478
12913
  // apps/agent/src/sourcemaps.ts
12479
- import { createHash as createHash2, randomUUID as randomUUID2 } from "node:crypto";
12914
+ import { createHash as createHash3, randomUUID as randomUUID2 } from "node:crypto";
12480
12915
  import { access, readFile as readFile4, readdir as readdir2, rename, rm, writeFile as writeFile2 } from "node:fs/promises";
12481
- import { basename as basename3, join as join17, resolve as resolve7 } from "node:path";
12916
+ import { basename as basename3, join as join17, resolve as resolve8 } from "node:path";
12482
12917
  import { parseArgs as parseArgs2 } from "node:util";
12483
12918
  import { gzipSync } from "node:zlib";
12484
12919
  var MAX_COMPLETE_ROUNDS = 20;
12485
12920
  var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
12486
12921
  async function prepareComponents(projectRoot, options = {}) {
12487
- const root = resolve7(projectRoot);
12922
+ const root = resolve8(projectRoot);
12488
12923
  const directory = join17(root, ".keepsnappy", "components");
12489
12924
  const configurations = await readdir2(directory, { withFileTypes: true }).catch((error) => {
12490
12925
  if (error.code === "ENOENT")
@@ -12525,7 +12960,7 @@ async function prepareComponents(projectRoot, options = {}) {
12525
12960
  throw new ConfigError([`invalid KeepSnappy component fragment ${file}`]);
12526
12961
  if (!hashes.has(fragment.source)) {
12527
12962
  try {
12528
- hashes.set(fragment.source, createHash2("sha256").update(await readFile4(resolve7(root, fragment.source))).digest("hex"));
12963
+ hashes.set(fragment.source, createHash3("sha256").update(await readFile4(resolve8(root, fragment.source))).digest("hex"));
12529
12964
  } catch (error) {
12530
12965
  if (error.code !== "ENOENT")
12531
12966
  throw error;
@@ -12550,7 +12985,7 @@ async function prepareComponents(projectRoot, options = {}) {
12550
12985
  await rm(temporary, { force: true });
12551
12986
  }
12552
12987
  const gzip = gzipSync(text);
12553
- return { file, count: components.length, bytes: Buffer.byteLength(text), gzip, sha256: createHash2("sha256").update(gzip).digest("hex") };
12988
+ return { file, count: components.length, bytes: Buffer.byteLength(text), gzip, sha256: createHash3("sha256").update(gzip).digest("hex") };
12554
12989
  }
12555
12990
  function stripSourcesContent(map) {
12556
12991
  if (!isObject(map))
@@ -12604,7 +13039,7 @@ async function uploadSourcemaps(opts) {
12604
13039
  const { map, stripped } = stripSourcesContent(parsed);
12605
13040
  const text = Buffer.from(JSON.stringify(map));
12606
13041
  const gz = gzipSync(text);
12607
- const sha256 = createHash2("sha256").update(gz).digest("hex");
13042
+ const sha256 = createHash3("sha256").update(gz).digest("hex");
12608
13043
  const name = basename3(file);
12609
13044
  const announced = await call(f, opts.url, `/v1/builds/${encodeURIComponent(opts.build)}/sourcemaps`, headers, {
12610
13045
  platform: opts.platform,
@@ -12743,6 +13178,7 @@ import { parseArgs as parseArgs3 } from "node:util";
12743
13178
  var SHORT_SESSIONS = 3;
12744
13179
  var SHORT_POINTS = 5;
12745
13180
  var SHORT_PLACES = 5;
13181
+ var SHORT_COMPARE_MEASURES = 3;
12746
13182
  var SHORT_FORM_NOTES = {
12747
13183
  list_findings: "the how-sure lines, the place lines of findings that are not Worse and the lanes that were judged",
12748
13184
  get_finding: "the why, seen and layer lines, the component lines, the memory evidence lines other than the judged measure and the prompt",
@@ -12752,7 +13188,8 @@ var SHORT_FORM_NOTES = {
12752
13188
  place_trend: `the sessions older than the newest ${SHORT_SESSIONS} and the history restarts among them (unknown restarts always stay)`,
12753
13189
  window_history: `the runs older than the newest ${SHORT_POINTS}`,
12754
13190
  pr_status: "the per-journey dashboard links and the heat note",
12755
- list_problems: `the work and component lines, the dashboard links and the problems after the first ${SHORT_PLACES}`
13191
+ list_problems: `the work and component lines, the dashboard links and the problems after the first ${SHORT_PLACES}`,
13192
+ compare_runs: `the component lines and the measures beyond each place's ${SHORT_COMPARE_MEASURES} times that moved most`
12756
13193
  };
12757
13194
  var andList = (xs) => xs.length < 2 ? xs[0] ?? "" : `${xs.slice(0, -1).join(", ")} and ${xs[xs.length - 1]}`;
12758
13195
 
@@ -12855,10 +13292,11 @@ function measuredLines(l, r, opts) {
12855
13292
  }
12856
13293
  const lane = (x) => {
12857
13294
  const runs = x.runs.after !== null && x.runs.before !== null ? x.branch === l.baseline_branch ? `, ${x.runs.after} latest runs vs ${x.runs.before} earlier` : `, ${x.runs.after} branch runs vs ${x.runs.before} on ${l.baseline_branch}` : "";
12858
- return `${x.commit?.sha ? `commit ${x.commit.sha.slice(0, 7)}` : "commit not recorded"} (${when(x.at)}): ${x.word}${runs}${x.waiting_for_reprocess ? `; ${waitingWords(x.waiting_for_reprocess)}` : ""}`;
13295
+ return `${x.commit?.sha ? `commit ${x.commit.sha.slice(0, 7)}` : "commit not recorded"} (${when(x.at)}): ${x.word}${feltWords(x)}${runs}${x.waiting_for_reprocess ? `; ${waitingWords(x.waiting_for_reprocess)}` : ""}`;
12859
13296
  };
12860
13297
  if (m.lanes.length === 1) {
12861
13298
  r.both(`${scope}: newest measured ${f.branch ? "" : `on ${n.branch}, `}${lane(n)}; ${plural2(m.run_sets, "run set")} measured.`);
13299
+ r.both(...(n.comparison_notes ?? []).map((line) => ` ${line}`));
12862
13300
  r.full(...(n.session_speed ?? []).map((line) => ` ${line}`));
12863
13301
  r.both(...laneContextLines(n.context, n.journey, n.phone_name, opts.verbose, opts.form === "short"));
12864
13302
  return [];
@@ -12866,9 +13304,10 @@ function measuredLines(l, r, opts) {
12866
13304
  const shown = m.lanes.slice(0, LANES_SHOWN);
12867
13305
  r.add(shown.some((x) => !x.judged || !!x.context), `${scope}: ${plural2(m.run_sets, "run set")} measured; the newest of each journey × phone:`);
12868
13306
  for (const x of shown) {
12869
- const keep = !x.judged || !!x.context;
13307
+ const keep = !x.judged || !!x.context || !!x.comparison_notes;
12870
13308
  r.add(keep, ` ${x.journey} on ${x.phone_name}${f.branch ? "" : ` (${x.branch})`}: ${lane(x)}`);
12871
13309
  r.add(keep, ...resultContextLines(x.context, opts.verbose, opts.form === "short").map((line) => ` ${line}`));
13310
+ r.add(keep, ...(x.comparison_notes ?? []).map((line) => ` ${line}`));
12872
13311
  r.full(...(x.session_speed ?? []).map((line) => ` ${line}`));
12873
13312
  }
12874
13313
  if (m.lanes.length > shown.length)
@@ -12879,6 +13318,7 @@ function measuredLines(l, r, opts) {
12879
13318
  ...m.lanes.length > shown.length ? [plural2(m.lanes.length - shown.length, "lane") + " beyond the eighth"] : []
12880
13319
  ];
12881
13320
  }
13321
+ var feltWords = (x) => `${x.felt_cost ? ` · ${x.felt_cost}` : ""}${x.to_check ? `; ${SUMMARY_WORDS.toCheck(x.to_check)}` : ""}`;
12882
13322
  function countsLine2(l) {
12883
13323
  const c = l.counts;
12884
13324
  const parts = [
@@ -12932,10 +13372,11 @@ function formatBranchStatus(s, opts = {}) {
12932
13372
  if (s.lanes.length)
12933
13373
  r.both("");
12934
13374
  for (const l of s.lanes) {
12935
- const shown = l.result === "blocks" ? `${l.word} (blocks)` : badgedWord(l.word, l.blocking);
13375
+ const shown = `${l.result === "blocks" ? `${l.word} (blocks)` : badgedWord(l.word, l.blocking)}${l.stale ? ` (${STALE_FLAG})` : ""}`;
12936
13376
  const sha = l.commit?.sha?.slice(0, 7);
12937
- r.both(` ${l.journey_title ?? l.journey} on ${l.phone_name}: ${shown} · ${sha ? `commit ${sha}` : "commit not recorded"} (${when(l.at)})${l.in_newest_run_set ? "" : " · older run set"}${l.waiting_for_reprocess ? `; ${waitingWords(l.waiting_for_reprocess)}` : ""}`);
13377
+ r.both(` ${l.journey_title ?? l.journey} on ${l.phone_name}: ${shown}${feltWords(l)} · ${sha ? `commit ${sha}` : "commit not recorded"} (${when(l.at)})${l.in_newest_run_set ? "" : " · older run set"}${l.waiting_for_reprocess ? `; ${waitingWords(l.waiting_for_reprocess)}` : ""}`);
12938
13378
  r.both(...resultContextLines(l.context, opts.verbose, opts.form === "short"));
13379
+ r.both(...(l.comparison_notes ?? []).map((line) => ` ${line}`));
12939
13380
  r.full(...(l.session_speed ?? []).map((line) => ` ${line}`));
12940
13381
  for (const f of l.worst)
12941
13382
  r.both(` ${f.id} ${f.headline}`);
@@ -12945,6 +13386,7 @@ function formatBranchStatus(s, opts = {}) {
12945
13386
  r.full(` ${l.link}`);
12946
13387
  }
12947
13388
  const links = s.lanes.filter((l) => l.link).length;
13389
+ const staleLanes = s.problems?.stale_lanes ?? 0;
12948
13390
  const heated = s.counts.heat_runs ?? 0;
12949
13391
  const more = [
12950
13392
  [false, heated ? `${INFO_WORDS.heat.toLowerCase()} in ${plural2(heated, "run")} (information, never blocks)` : ""],
@@ -12953,6 +13395,7 @@ function formatBranchStatus(s, opts = {}) {
12953
13395
  s.not_judged_findings ? `${plural2(s.not_judged_findings, "finding")} not judged in the newest run set; they may still be there: keepsnappy findings --branch ${s.branch} --state not-judged` : ""
12954
13396
  ],
12955
13397
  [true, statusProblemsLine(s)],
13398
+ [true, staleLanes ? `${staleWords(staleLanes)}: ${staleLanes === 1 ? "its word and problems are" : "their words and problems are"} of older rules` : ""],
12956
13399
  [true, s.problems?.unread_lanes ? `${unreadWords(s.problems.unread_lanes)} for problems: ${staleResult(s.problems.unread_lanes)}` : ""],
12957
13400
  [true, s.links.overview ? `Dashboard: ${s.links.overview}` : ""],
12958
13401
  [true, s.worst.length ? "One finding with its evidence and a prompt: keepsnappy finding <id> --prompt" : ""]
@@ -12968,9 +13411,11 @@ function statusProblemsLine(s) {
12968
13411
  const p = s.problems;
12969
13412
  if (!p?.count)
12970
13413
  return "";
12971
- return `${plural2(p.places, "place")} over budget in this branch’s own runs (${p.far_count ? `${p.far_count} far over budget; ` : ""}not a regression, never blocks): list_problems, or keepsnappy problems --branch ${s.branch}`;
13414
+ return `${problemCountsText(p, "this branch’s")}. Not a regression, never blocks: list_problems, or keepsnappy problems --branch ${s.branch}`;
12972
13415
  }
12973
13416
  var unreadWords = (n) => `${n} journey × phone not read`;
13417
+ var STALE_FLAG = staleFlagText(["config"]);
13418
+ var staleWords = (n) => `${n} journey × phone from a stale result (${STALE_FLAG})`;
12974
13419
  var staleResult = (n) => `${n === 1 ? "its stored result is" : "their stored results are"} not current`;
12975
13420
  function formatProblemList(l, opts = {}) {
12976
13421
  const r = new Rows;
@@ -12987,7 +13432,7 @@ function formatProblemList(l, opts = {}) {
12987
13432
  return r.render(opts.form);
12988
13433
  }
12989
13434
  if (c.count)
12990
- r.both(`${b}: ${plural2(c.places, "place")} over budget in ${b === l.baseline_branch ? "main’s" : "this branch’s"} own runs${c.far_count ? ` (${c.far_count} far over budget)` : ""}`);
13435
+ r.both(`${b}: ${problemCountsText(c, b === l.baseline_branch ? "main’s" : "this branch’s")}`);
12991
13436
  else if (read)
12992
13437
  r.both(`${b}: no problem over budget in the newest run set (${read} journey × phone read)`);
12993
13438
  else
@@ -12995,13 +13440,15 @@ function formatProblemList(l, opts = {}) {
12995
13440
  r.both(l.note);
12996
13441
  if (read && unread)
12997
13442
  r.both(`${unreadWords(unread)}: ${staleResult(unread)}, so nothing is known about ${unread === 1 ? "its" : "their"} problems.`);
13443
+ if (c.stale_lanes)
13444
+ r.both(`${staleWords(c.stale_lanes)}: ${c.stale_lanes === 1 ? "its" : "their"} problems are listed, each marked “${STALE_MARK}”.`);
12998
13445
  l.problems.forEach((p, i) => {
12999
13446
  const keep = i < SHORT_PLACES;
13000
- r.add(keep, "", `${l.offset + i + 1}. ${p.text}: ${p.place_name}${opts.verbose ? ` (${p.place})` : ""}${p.intermittent ? ` — ${p.note}, not counted` : ""}`);
13001
- r.add(keep, ` ${p.journey_title ?? p.journey} on ${p.phone_name}, ${p.runs_text}${p.tag_word ? `, ${p.tag_word}` : ""}`);
13447
+ r.add(keep, "", `${l.offset + i + 1}. ${p.place_name}${opts.verbose ? ` (${p.place})` : ""}: ${p.text}${p.intermittent ? ` — ${p.note}, not counted` : ""}`);
13448
+ r.add(keep, ` ${p.journey_title ?? p.journey} on ${p.phone_name}, ${p.runs_text}${p.tag_word ? `, ${p.tag_word}` : ""}${p.stale ? `, ${STALE_MARK}` : ""}`);
13002
13449
  const top = p.top_components[0];
13003
13450
  const topName = componentReadNames(p.top_components)[0];
13004
- const detail = [p.work, top ? `heaviest ${topName}${opts.verbose && top.key ? ` (${top.key})` : ""} ${top.self_text}` : null, p.new_component ? NEW_IN_PR_CHIP : null, p.range_text ? `${p.range_text} between runs` : null].filter(Boolean);
13451
+ const detail = [p.work, top ? `${namePartsText(heaviestWords(`${topName}${opts.verbose && top.key ? ` (${top.key})` : ""}`, top.self_ms === null ? null : top.self_text))}` : null, p.new_component ? NEW_IN_PR_CHIP : null, p.range_text ? `${p.range_text} between runs` : null].filter(Boolean);
13005
13452
  if (detail.length)
13006
13453
  r.full(` ${detail.join("; ")}`);
13007
13454
  if (p.url)
@@ -13021,56 +13468,59 @@ function formatProblemList(l, opts = {}) {
13021
13468
  }
13022
13469
  var commitText = (c) => !c ? "commit not recorded" : c.title ? `“${c.title}”${c.author ? ` by ${c.author}` : ""}${c.sha ? ` (${c.sha.slice(0, 7)})` : ""}` : c.sha ? c.sha.slice(0, 7) : "commit not recorded";
13023
13470
  function formatCompare(r, opts = {}) {
13024
- const out = [
13025
- `Run A: ${r.a.external_id} on ${r.a.branch ?? "?"}, ${commitText(r.a.commit ? { sha: r.a.commit_sha, ...r.a.commit } : { sha: r.a.commit_sha, title: null, author: null })}`,
13026
- `Run B: ${r.b.external_id} on ${r.b.branch ?? "?"}, ${commitText(r.b.commit ? { sha: r.b.commit_sha, ...r.b.commit } : { sha: r.b.commit_sha, title: null, author: null })}`,
13027
- `${r.same_journey ? "Same journey" : "Different journeys"}, ${r.same_phone ? "same phone" : "different phones"}. ${r.note}`
13028
- ];
13471
+ const rows = new Rows;
13472
+ rows.both(`Run A: ${r.a.external_id} on ${r.a.branch ?? "?"}, ${commitText(r.a.commit ? { sha: r.a.commit_sha, ...r.a.commit } : { sha: r.a.commit_sha, title: null, author: null })}`, `Run B: ${r.b.external_id} on ${r.b.branch ?? "?"}, ${commitText(r.b.commit ? { sha: r.b.commit_sha, ...r.b.commit } : { sha: r.b.commit_sha, title: null, author: null })}`, `${r.same_journey ? "Same journey" : "Different journeys"}, ${r.same_phone ? "same phone" : "different phones"}. ${r.note}`);
13029
13473
  if (r.setup_differs.length)
13030
- out.push(`Measured differently: ${r.setup_differs.map((d) => `${d.field} ${JSON.stringify(d.a)} → ${JSON.stringify(d.b)}`).join(", ")}`);
13474
+ rows.both(`Measured differently: ${r.setup_differs.map((d) => `${d.field} ${JSON.stringify(d.a)} → ${JSON.stringify(d.b)}`).join(", ")}`);
13031
13475
  for (const w of r.windows) {
13032
13476
  const ms = w.metrics.filter((m) => m.a !== null || m.b !== null);
13033
13477
  const c = w.component_page ?? w.components;
13034
13478
  if (!ms.length && !c)
13035
13479
  continue;
13036
- out.push("", `${w.window_name}${opts.verbose ? ` (${w.window})` : ""}`);
13480
+ rows.both("", `${w.window_name}${opts.verbose ? ` (${w.window})` : ""}`);
13037
13481
  const singles = [...c?.added ?? [], ...c?.removed ?? [], ...w.component_page?.uncompared?.a ?? [], ...w.component_page?.uncompared?.b ?? []];
13038
13482
  const components = [...c?.rows ?? [], ...singles];
13039
13483
  const names = componentReadNames(components.map((row) => ({ key: row.key, name: row.component })));
13040
13484
  const shown = new Map(components.map((row, i) => [row.key, names[i]]));
13041
13485
  for (const row of c?.rows ?? [])
13042
- out.push(` ${shown.get(row.key)}: ${row.self_ms_a === null ? "–" : exactComponentMs(row.self_ms_a)} → ${row.self_ms_b === null ? "–" : exactComponentMs(row.self_ms_b)}${opts.verbose ? ` (${row.key})` : ""}`);
13043
- for (const [label, rows] of [
13486
+ rows.full(` ${shown.get(row.key)}: ${row.self_ms_a === null ? "–" : exactComponentMs(row.self_ms_a)} → ${row.self_ms_b === null ? "–" : exactComponentMs(row.self_ms_b)}${opts.verbose ? ` (${row.key})` : ""}`);
13487
+ for (const [label, list] of [
13044
13488
  [READ_WORDS.onlyB, c?.added ?? []],
13045
13489
  [READ_WORDS.onlyA, c?.removed ?? []]
13046
13490
  ]) {
13047
- for (const row of rows)
13048
- out.push(` ${shown.get(row.key)}: ${label}; ${row.self_ms === null ? "–" : exactComponentMs(row.self_ms)}${opts.verbose ? ` (${row.key})` : ""}`);
13491
+ for (const row of list)
13492
+ rows.full(` ${shown.get(row.key)}: ${label}; ${row.self_ms === null ? "–" : exactComponentMs(row.self_ms)}${opts.verbose ? ` (${row.key})` : ""}`);
13049
13493
  }
13050
13494
  const page = w.component_page;
13051
13495
  if (page) {
13052
13496
  for (const side of ["a", "b"]) {
13053
13497
  if (!page.measured[side])
13054
- out.push(` ${notMeasuredText(side)}`);
13498
+ rows.full(` ${notMeasuredText(side)}`);
13055
13499
  for (const row of page.uncompared?.[side] ?? [])
13056
- out.push(` ${shown.get(row.key)}: ${side === "a" ? READ_WORDS.runA : READ_WORDS.runB} ${row.self_ms === null ? "–" : exactComponentMs(row.self_ms)}${opts.verbose ? ` (${row.key})` : ""}`);
13500
+ rows.full(` ${shown.get(row.key)}: ${side === "a" ? READ_WORDS.runA : READ_WORDS.runB} ${row.self_ms === null ? "–" : exactComponentMs(row.self_ms)}${opts.verbose ? ` (${row.key})` : ""}`);
13057
13501
  }
13058
- out.push(` ${componentPageText(page.page, components.length)}`);
13502
+ rows.full(` ${componentPageText(page.page, components.length)}`);
13059
13503
  } else if (c?.totals)
13060
- out.push(` ${componentListsText(c.totals, { rows: c.rows.length, added: c.added?.length ?? 0, removed: c.removed?.length ?? 0 })}`);
13504
+ rows.full(` ${componentListsText(c.totals, { rows: c.rows.length, added: c.added?.length ?? 0, removed: c.removed?.length ?? 0 })}`);
13061
13505
  if (c?.omissions)
13062
13506
  for (const side of ["a", "b"]) {
13063
13507
  if (page && !page.measured[side])
13064
13508
  continue;
13065
13509
  const o = c.omissions[side];
13066
13510
  if (o.sdkOmitted === null || o.omitted === null || o.sdkOmitted > 0 || o.omitted > 0)
13067
- out.push(` ${omissionText({ run_id: side === "a" ? READ_WORDS.runA : READ_WORDS.runB, ...o })}`);
13511
+ rows.full(` ${omissionText({ run_id: side === "a" ? READ_WORDS.runA : READ_WORDS.runB, ...o })}`);
13068
13512
  }
13513
+ const top = new Set(ms.map((m) => ({ m, by: timeMove(m.metric, m.a, m.b) })).filter((x) => x.by !== null && x.by > 0).sort((x, y) => y.by - x.by).slice(0, SHORT_COMPARE_MEASURES).map((x) => x.m));
13069
13514
  for (const m of ms)
13070
- out.push(` ${m.label.padEnd(28)}${m.a_text.padStart(10)} → ${m.b_text}`);
13515
+ rows.add(top.has(m), ` ${m.label.padEnd(28)}${m.a_text.padStart(10)} → ${m.b_text}`);
13071
13516
  }
13072
- return out.join(`
13073
- `);
13517
+ const p = r.windows_page;
13518
+ if (p) {
13519
+ const order = p.order === "moved" ? ", the ones whose time moved most first" : "";
13520
+ const shownText = r.windows.length ? `Showing places ${p.offset + 1}–${p.offset + r.windows.length} of ${p.total}${order}` : `Showing none of ${p.total} places`;
13521
+ rows.both("", p.next_offset !== null ? `${shownText}; the rest from offset ${p.next_offset} (--offset ${p.next_offset}).` : `${shownText}.`);
13522
+ }
13523
+ return rows.render(opts.form, ["the component lines", `the measures beyond each place's ${SHORT_COMPARE_MEASURES} times that moved most`]);
13074
13524
  }
13075
13525
  function formatHistory(h, opts = {}) {
13076
13526
  const r = new Rows;
@@ -13102,8 +13552,8 @@ function formatHistory(h, opts = {}) {
13102
13552
  }
13103
13553
  function formatCommits(c) {
13104
13554
  return [
13105
- `${plural2(c.commits.length, "measured commit")} (${plural2(c.sessions, "session")}) on ${c.branch}, oldest first:`,
13106
- ...c.commits.map((x) => ` ${when(x.first_measured_at)} ${commitText(x)}${x.pull_request?.number ? `, PR #${x.pull_request.number}` : ""}${x.sessions > 1 ? `; ${x.sessions} sessions, the last ${when(x.last_measured_at)}` : ""}`),
13555
+ `${plural2(c.commits.length, "measured commit")} (${plural2(c.sessions, "session")}) ${c.from_branch ? `from ${c.from_branch} to ${c.branch}` : `on ${c.branch}`}, oldest first:`,
13556
+ ...c.commits.map((x) => ` ${when(x.first_measured_at)} ${commitText(x)}${c.from_branch && x.branch ? ` on ${x.branch}` : ""}${x.pull_request?.number ? `, PR #${x.pull_request.number}` : ""}${x.sessions > 1 ? `; ${x.sessions} sessions, the last ${when(x.last_measured_at)}` : ""}`),
13107
13557
  c.note
13108
13558
  ].join(`
13109
13559
  `);
@@ -13139,7 +13589,7 @@ function formatSessionPlaces(p, opts = {}) {
13139
13589
  return r.both("Stale: its statistics are of another derivation or stats version and are not served until the next recompute rewrites them; nothing is known about its places (not zero).").render(opts.form);
13140
13590
  const by = p.filters.measure;
13141
13591
  const byLabel = p.places.flatMap((x) => x.measures).find((m) => m.measure === by)?.label ?? by;
13142
- r.both(`Stored per-session statistics: no verdict, no comparison. Places by ${byLabel} (${p.filters.sort}), highest first${p.filters.window ? `, matching “${p.filters.window}”` : ""}${p.without_measure ? `; ${p.without_measure} of ${p.total} places ${p.without_measure === 1 ? "has" : "have"} no value for it` : ""}.`, "");
13592
+ r.both(`Stored per-session statistics: no verdict, no comparison. Places by ${byLabel} (${p.filters.sort}), highest first${p.filters.window ? `, matching “${p.filters.window}”` : ""}${p.without_measure ? `; ${p.without_measure} of ${p.total} places ${p.without_measure === 1 ? "has" : "have"} no value for it` : ""}${p.folded_twins ? `; ${foldedTwinsText(p.folded_twins)}` : ""}.`, "");
13143
13593
  let componentLines = 0;
13144
13594
  p.places.forEach((place, i) => {
13145
13595
  const m = place.measures.find((x) => x.measure === by);
@@ -13212,7 +13662,7 @@ function formatMainSessions(h, opts = {}) {
13212
13662
  if (!s.places)
13213
13663
  r.add(k, ` ${MAIN_SUMMARY_PENDING}${s.summary_reason ? ` (${s.summary_reason})` : ""}`);
13214
13664
  else
13215
- r.add(k, ` ${MAIN_TIERS_WORST_FIRST.map((tier) => `${MAIN_TIER_WORDS[tier]}: ${s.places[tier]}`).join("; ")}${s.cant_reason ? `; ${mainSessionCantWords(s.places.cant, s.cant_reason, baseline)}` : ""}`);
13665
+ r.add(k, ` ${MAIN_TIERS_WORST_FIRST.map((tier) => `${MAIN_TIER_WORDS[tier]}: ${s.places[tier]}`).join("; ")}${s.cant_reason ? `; ${mainSessionCantWords(s.places.cant, s.cant_reason, baseline)}` : s.cant_range ? `; ${mainSessionCantRangeWords(s.places.cant, s.cant_range, baseline)}` : ""}`);
13216
13666
  if (s.waiting_sessions)
13217
13667
  r.add(k, ` ${waitingSessionsWords(s.waiting_sessions)}`);
13218
13668
  if (s.memory)
@@ -13254,9 +13704,13 @@ function formatMainSessionPlaces(p, opts = {}) {
13254
13704
  return r.both(`${MAIN_SUMMARY_PENDING}${p.summary_reason ? ` (${p.summary_reason})` : ""}`).render(opts.form);
13255
13705
  const baselineSessions = p.baseline_sessions;
13256
13706
  r.both(`Main session places: tiers are verdicts vs ${p.baseline_sessions === undefined ? "earlier sessions" : `the ${p.baseline_sessions} sessions before`}.`);
13257
- for (const x of p.places)
13258
- r.add(x.tier !== "same", ` ${windowQualified(x.place)}${opts.verbose ? ` (${x.place}; ${x.ref})` : ""} ${storedRefWords(x.ref, p.places.filter((row) => row.place === x.place).map((row) => row.ref))}: ${MAIN_TIER_WORDS[x.tier]}${x.reason ? `; ${mainPlaceReasonWords(x.reason, { baseline: { sessions: baselineSessions } })}` : ""}${x.reason && mainUnstableWords(x.reason) ? `. ${mainUnstableWords(x.reason)}` : ""}`);
13259
- const inNorm = p.places.filter((x) => x.tier === "same").length;
13707
+ const kept = new Set(foldTwins([...p.places].sort((a, b) => MAIN_TIER_RANK[a.tier] - MAIN_TIER_RANK[b.tier]), (x) => x.place, (x) => x.ref).rows);
13708
+ const places = p.places.filter((x) => kept.has(x));
13709
+ for (const x of places)
13710
+ r.add(x.tier !== "same", ` ${windowQualified(x.place)}${opts.verbose ? ` (${x.place}; ${x.ref})` : ""} ${storedRefWords(x.ref, places.filter((row) => row.place === x.place).map((row) => row.ref))}: ${MAIN_TIER_WORDS[x.tier]}${x.reason ? `; ${mainPlaceReasonWords(x.reason, { baseline: { sessions: baselineSessions } })}` : ""}${x.reason && mainUnstableWords(x.reason) ? `. ${mainUnstableWords(x.reason)}` : ""}`);
13711
+ if (places.length < p.places.length)
13712
+ r.both(` ${foldedTwinsText(p.places.length - places.length)}`);
13713
+ const inNorm = places.filter((x) => x.tier === "same").length;
13260
13714
  return r.render(opts.form, inNorm ? [plural2(inNorm, "place") + " in norm"] : []);
13261
13715
  }
13262
13716
  var signed = (n) => `${n > 0 ? "+" : ""}${Number(n.toFixed(3))}`;
@@ -13295,6 +13749,12 @@ function renamedLines(renamed, places) {
13295
13749
  ...component ? [` ${componentNotJoinedText(renamed.sdk)}`] : []
13296
13750
  ];
13297
13751
  }
13752
+ function storedValueText(ref, v, unit) {
13753
+ const rest = ref.slice(2);
13754
+ const field = ref.startsWith("b:") ? `${rest.slice(0, rest.lastIndexOf(":"))}.${rest.slice(rest.lastIndexOf(":") + 1)}` : rest;
13755
+ const text = fieldValueText(field, v);
13756
+ return unit && !/[^\d.,\s-]/.test(text) ? `${text} ${unit}` : text;
13757
+ }
13298
13758
  function formatPlaceTrend(t, opts = {}) {
13299
13759
  const r = new Rows().both(`${t.journey} · ${t.phone.model} · ${t.baseline_branch}: stored per-session statistics`);
13300
13760
  const newest = new Set(t.sessions.slice(-SHORT_SESSIONS).map((s) => s.run_set_id));
@@ -13315,10 +13775,10 @@ function formatPlaceTrend(t, opts = {}) {
13315
13775
  const value = place.sessions.find((s) => s.run_set_id === session.run_set_id);
13316
13776
  if (!value)
13317
13777
  continue;
13318
- const unit = place.unit ? ` ${place.unit}` : "";
13778
+ const value_ = (v) => storedValueText(place.ref, v, place.unit);
13319
13779
  if (!k)
13320
13780
  olderSessions.add(session.run_set_id);
13321
- r.add(k, `${session.completed_at?.slice(0, 10) ?? "date unknown"} ${session.commit.sha?.slice(0, 7) ?? "commit unknown"} · ${windowQualified(place.window)}${opts.verbose ? ` (${value.window ?? place.window}; ${place.ref})` : ""} · ${place.label} (${storedRefWords(place.ref, t.places.filter((row) => row.window === place.window).map((row) => row.ref))}): median ${value.median}${unit}, MAD ${value.mad}${unit}, n=${value.n}`);
13781
+ r.add(k, `${session.completed_at?.slice(0, 10) ?? "date unknown"} ${session.commit.sha?.slice(0, 7) ?? "commit unknown"} · ${windowQualified(place.window)}${opts.verbose ? ` (${value.window ?? place.window}; ${place.ref})` : ""} · ${place.label} (${storedRefWords(place.ref, t.places.filter((row) => row.window === place.window).map((row) => row.ref))}): median ${value_(value.median)}, MAD ${value_(value.mad)}, n=${value.n}`);
13322
13782
  }
13323
13783
  }
13324
13784
  if (!t.sessions.length)
@@ -13375,6 +13835,8 @@ var branchStatus = (t, branch) => get(t, "v1/branches/status", { branch });
13375
13835
  var listProblems = (t, f = {}) => get(t, "v1/problems", { branch: f.branch, main: f.main ? "true" : undefined, journey: f.journey, phone: f.phone, limit: f.limit, offset: f.offset || undefined });
13376
13836
  var getFinding = (t, id) => get(t, `v1/findings/${encodeURIComponent(id)}`);
13377
13837
  var compareRuns = (t, a, b, page = {}) => get(t, "v1/compare", { a, b, ...page });
13838
+ var SHORT_COMPARE_PLACES = 10;
13839
+ var compareQuery = (short, page) => short ? { order: "moved", limit: SHORT_COMPARE_PLACES, ...page } : page;
13378
13840
  var windowHistory = (t, q) => get(t, "v1/history", q);
13379
13841
  var commitsBetween = (t, q) => get(t, "v1/commits", q);
13380
13842
  var layers = (t) => get(t, "v1/layers");
@@ -13409,7 +13871,7 @@ var FINDINGS_USAGE = `keepsnappy findings [--branch NAME | --main] [--journey KE
13409
13871
  problems --branch NAME | --main [--journey KEY] [--phone MODEL] [--limit N] [--offset N] [--short] [--json]
13410
13872
  (the single problems "Over budget" / "Far over budget" in the branch's own runs, whatever main does: not a regression, never blocking)
13411
13873
  finding <id> [--prompt] [--short] [--json] (--prompt: only the hand-off for a coding agent; with --json the whole finding)
13412
- compare <runA> <runB> [--json] (run ids or external ids; one run against one, no statistics)
13874
+ compare <runA> <runB> [--short] [--limit N] [--offset N] [--json] (run ids or external ids; one run against one, no statistics; --short: the places whose time moved most)
13413
13875
  history <journey> <window> [--phone MODEL] [--short] [--json] (exact window id, e.g. "press:live.chat.send")
13414
13876
  commits <from> [<to>] [--branch NAME] [--json] (measured commits between two run sets or commit hashes)
13415
13877
  places [--run-set ID | --journey KEY [--phone MODEL] [--branch NAME]] [--measure ID] [--sort median|p90|mad] [--window TEXT] [--limit N] [--offset N] [--top-components N] [--short] [--json]
@@ -13425,7 +13887,7 @@ ${READ_USAGE.detailFlag}
13425
13887
  --short (findings, status, problems, finding, history, places, main, trend: what decides, without the evidence detail; the same as the MCP tools' form short; --json is unchanged)
13426
13888
  (key: KEEPSNAPPY_KEY or --key-env NAME; API: --api URL or KEEPSNAPPY_API)`;
13427
13889
  var STATES = ["present", "not-judged", "resolved", "all"];
13428
- var SHORT_COMMANDS = new Set(["findings", "status", "problems", "finding", "history", "places", "main", "trend"]);
13890
+ var SHORT_COMMANDS = new Set(["findings", "status", "problems", "finding", "compare", "history", "places", "main", "trend"]);
13429
13891
  var FINDINGS_COMMANDS = new Set(["findings", "status", "problems", "finding", "compare", "history", "commits", "places", "layers", "main", "trend"]);
13430
13892
  async function findingsCommand(cmd, argv, rt) {
13431
13893
  const args = (() => {
@@ -13564,7 +14026,8 @@ async function findingsCommand(cmd, argv, rt) {
13564
14026
  const size = o["top-components"] === undefined ? 12 : Number(o["top-components"]);
13565
14027
  if (!Number.isInteger(size) || size < 1 || size > 200)
13566
14028
  throw new UsageError([READ_USAGE.compareTop]);
13567
- const query = page || o.all || o["top-components"] !== undefined ? { component_offset: ((page ?? 1) - 1) * size, component_limit: size } : {};
14029
+ const components = page || o.all || o["top-components"] !== undefined ? { component_offset: ((page ?? 1) - 1) * size, component_limit: size } : {};
14030
+ const query = compareQuery(o.short === true, { ...components, ...limit ? { limit } : {}, ...offset !== undefined ? { offset } : {} });
13568
14031
  const compared = await compareRuns(t, p[0], p[1], query);
13569
14032
  return out(o.all ? await allCompare(t, compared, size) : compared, formatCompare);
13570
14033
  }
@@ -13728,6 +14191,7 @@ var USAGE = `keepsnappy <command>
13728
14191
  [--config keepsnappy.yml] [--flows <files|dirs|globs,...>] (journey titles: keepsnappy.yml flows.paths, else the flow's name: / top comment)
13729
14192
  [--completion-marker] (the flow posts step run:complete at the end of each iteration; runs without it are invalid)
13730
14193
  [--driver appium --journey-module FILE] [--driver-version V] [--app-build-id ID] [--harness-version V] [--journey-version V] [--flag-set LABEL] (setup fingerprint, plan 51)
14194
+ [--plant JSON] (what the build plants against main, [{ place, kind, factor, unit: budget|MB }]: the run set's meta.plant)
13731
14195
  [--sdk-args k=v&...] (over the run defaults profile=1 diag=1; tokens.js carries them to the flows' activation link)
13732
14196
  farm close --state <file> [--wait [--fail-on-worse]] [--bs-build browserstack.ios.json] [--settle-seconds 60] [--max-wait-seconds 300] [--out DIR]
13733
14197
  (--fail-on-worse as for run: with no verdict computed on the run set it checks nothing)
@@ -13823,6 +14287,7 @@ async function main2(argv) {
13823
14287
  "harness-version": { type: "string" },
13824
14288
  "journey-version": { type: "string" },
13825
14289
  "flag-set": { type: "string" },
14290
+ plant: { type: "string" },
13826
14291
  app: { type: "string" },
13827
14292
  driver: { type: "string" },
13828
14293
  "tunnel-for": { type: "string", multiple: true },
@@ -13873,9 +14338,9 @@ async function main2(argv) {
13873
14338
  if (platform === "ios-device" && !o.device)
13874
14339
  throw new ConfigError(["--device <udid> is required for ios-device"]);
13875
14340
  const arms = parseArmEnv(o["arm-env"], parseArms(o.arms));
13876
- const out = resolve8(o.out ?? `keepsnappy-${new Date().toISOString().replace(/[:.]/g, "-")}`);
14341
+ const out = resolve9(o.out ?? `keepsnappy-${new Date().toISOString().replace(/[:.]/g, "-")}`);
13877
14342
  const udid = o.device ?? "booted";
13878
- const source = platform === "ios-sim" ? new IosSimulatorDataSource(deps, udid) : new IosDeviceDataSource(deps, udid, resolve8(out, ".scratch"));
14343
+ const source = platform === "ios-sim" ? new IosSimulatorDataSource(deps, udid) : new IosDeviceDataSource(deps, udid, resolve9(out, ".scratch"));
13879
14344
  const installer = platform === "ios-sim" ? new IosSimulatorInstaller(deps, udid) : new IosDeviceInstaller(deps, udid);
13880
14345
  const bundleId = await bundleIdOf(deps, arms[0]?.target, o["bundle-id"]);
13881
14346
  let markers;
@@ -13894,7 +14359,7 @@ async function main2(argv) {
13894
14359
  outDir: out,
13895
14360
  testCommand: positionals,
13896
14361
  bundleId,
13897
- ...o["ri-capture"] ? { ri: new FileRiSource(deps, resolve8(o["ri-capture"])) } : {},
14362
+ ...o["ri-capture"] ? { ri: new FileRiSource(deps, resolve9(o["ri-capture"])) } : {},
13898
14363
  ...o["mem-window"] ? { memWindow: o["mem-window"] } : {},
13899
14364
  ...o.timeout ? { testTimeoutMs: Number(o.timeout) * 1000 } : {}
13900
14365
  });
@@ -13906,9 +14371,9 @@ async function main2(argv) {
13906
14371
  const dir = positionals[0];
13907
14372
  if (!dir)
13908
14373
  throw new ConfigError(["extract needs <out-dir>"]);
13909
- const ri = o["ri-capture"] ? new FileRiSource(deps, resolve8(o["ri-capture"])) : undefined;
13910
- const rows = await extract(deps, resolve8(dir), ri, o["mem-window"], o["clock-offset-ms"] ? Number(o["clock-offset-ms"]) : undefined);
13911
- console.log(`${rows.length} rows -> ${resolve8(dir, "metrics.csv")}`);
14374
+ const ri = o["ri-capture"] ? new FileRiSource(deps, resolve9(o["ri-capture"])) : undefined;
14375
+ const rows = await extract(deps, resolve9(dir), ri, o["mem-window"], o["clock-offset-ms"] ? Number(o["clock-offset-ms"]) : undefined);
14376
+ console.log(`${rows.length} rows -> ${resolve9(dir, "metrics.csv")}`);
13912
14377
  return 0;
13913
14378
  }
13914
14379
  if (cmd === "aa") {
@@ -13916,7 +14381,7 @@ async function main2(argv) {
13916
14381
  if (!dir)
13917
14382
  throw new ConfigError(["aa needs <out-dir>"]);
13918
14383
  const config = { ...o.reps ? { reps: Number(o.reps) } : {}, ...o.boot ? { nBoot: Number(o.boot) } : {} };
13919
- console.log((await aa(deps, resolve8(dir), { config })).table);
14384
+ console.log((await aa(deps, resolve9(dir), { config })).table);
13920
14385
  return 0;
13921
14386
  }
13922
14387
  console.error(USAGE);