@keepsnappy/agent 0.1.0-alpha.17 → 0.1.0-alpha.19

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.
@@ -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",
@@ -2198,6 +2208,7 @@ function isComponentConfiguration(value) {
2198
2208
  }
2199
2209
 
2200
2210
  // packages/facts/dist/src/places.js
2211
+ var COMMIT_IDENTITY_KINDS = ["screen", "press", "drag", "other"];
2201
2212
  var placeWithoutVisit = (id) => placeRef(id).stem.replace(/#(\d+|repeat)$/, "");
2202
2213
  var AFTER_3S = "+3s";
2203
2214
  var kindOf = (stem) => {
@@ -2323,7 +2334,7 @@ function withLaunchNames(windows) {
2323
2334
  }
2324
2335
 
2325
2336
  // packages/derive/dist/src/model.js
2326
- var DERIVATION_VERSION = 27;
2337
+ var DERIVATION_VERSION = 28;
2327
2338
  // packages/stats/dist/src/core/rng.js
2328
2339
  function splitmix32(seed) {
2329
2340
  let s = seed >>> 0;
@@ -3180,6 +3191,44 @@ function estimateClockOffset(markers, windows, byOrder = false) {
3180
3191
  const negatives = gaps.filter((g) => g < 0);
3181
3192
  return negatives.length * 2 > starts.length ? Math.min(...negatives) : 0;
3182
3193
  }
3194
+ var ulp = (x) => x === 0 ? Number.MIN_VALUE : 2 ** (Math.floor(Math.log2(Math.abs(x))) - 52);
3195
+ function fitClockOffset(markers, windows, offset, errorMs) {
3196
+ if (!(errorMs > 0))
3197
+ return offset;
3198
+ const inputs = windows.map(inputWall).filter((x) => x !== undefined).sort((a, b) => a - b);
3199
+ const ss = spans(markers, 0).sort((a, b) => a.markerStart - b.markerStart);
3200
+ const score = (o) => {
3201
+ let started = 0;
3202
+ let inside = 0;
3203
+ for (const [i, s] of ss.entries()) {
3204
+ const next = ss[i + 1]?.markerStart;
3205
+ const t = inputs.find((x) => x >= s.markerStart + o);
3206
+ if (t === undefined || next !== undefined && t >= next + o)
3207
+ continue;
3208
+ started += 1;
3209
+ const end = Math.min(s.markerEnd ?? Infinity, next ?? Infinity) + o;
3210
+ inside += inputs.filter((x) => x >= s.markerStart + o && x < end).length;
3211
+ }
3212
+ return [started, inside];
3213
+ };
3214
+ let best = offset;
3215
+ let top = score(offset);
3216
+ for (const [i, s] of ss.entries()) {
3217
+ const prevEnd = ss[i - 1]?.markerEnd;
3218
+ for (const t of inputs) {
3219
+ if (prevEnd !== undefined && t >= prevEnd + offset && t - (prevEnd + offset) <= s.markerStart + offset - t)
3220
+ continue;
3221
+ const o = t - s.markerStart - ulp(t);
3222
+ if (o >= offset || o < offset - errorMs)
3223
+ continue;
3224
+ const sc = score(o);
3225
+ const better = sc[0] > top[0] || sc[0] === top[0] && (sc[1] > top[1] || sc[1] === top[1] && o > best);
3226
+ if (better)
3227
+ [best, top] = [o, sc];
3228
+ }
3229
+ }
3230
+ return best;
3231
+ }
3183
3232
  function spans(markers, offset) {
3184
3233
  const sorted = [...markers].sort((a, b) => a.t_host - b.t_host);
3185
3234
  const out = [];
@@ -3206,7 +3255,8 @@ function ownWindowEnd(windows, t) {
3206
3255
  }
3207
3256
  function alignSteps(markers, windows, opts = {}) {
3208
3257
  const byOrder = opts.byOrder === true;
3209
- const offset = opts.clockOffsetMs ?? estimateClockOffset(markers, windows, byOrder);
3258
+ const given = opts.clockOffsetMs;
3259
+ const offset = given === undefined ? estimateClockOffset(markers, windows, byOrder) : byOrder && opts.clockErrorMs ? fitClockOffset(markers, windows, given, opts.clockErrorMs) : given;
3210
3260
  const ss = spans(markers, offset).sort((a, b) => a.markerStart - b.markerStart);
3211
3261
  const inputs = windows.map(inputWall).filter((x) => x !== undefined).sort((a, b) => a - b);
3212
3262
  const starts = ss.map((s, i) => {
@@ -3256,7 +3306,7 @@ function clusters(windows) {
3256
3306
  }
3257
3307
  return out;
3258
3308
  }
3259
- function onAppClock(windows, version) {
3309
+ function processSegments(windows, version) {
3260
3310
  const order = windows.map((w, i) => ({ w, i })).sort((a, b) => a.w.endWall - b.w.endWall || a.i - b.i);
3261
3311
  const segment = new Map;
3262
3312
  let seg = 0;
@@ -3274,24 +3324,35 @@ function onAppClock(windows, version) {
3274
3324
  prevId = id;
3275
3325
  segment.set(w, seg);
3276
3326
  }
3327
+ return segment;
3328
+ }
3329
+ function onAppClock(windows, version) {
3330
+ const process2 = processSegments(windows, version);
3331
+ const step = (w) => version >= 28 ? w.clockStep : undefined;
3277
3332
  const offset = new Map;
3278
- for (const [w, s] of segment) {
3333
+ for (const [w, seg] of process2) {
3279
3334
  const t1 = num(w.raw.t1);
3280
3335
  if (t1 === undefined)
3281
3336
  continue;
3282
- const o = w.endWall - t1;
3283
- if (!offset.has(s) || o < offset.get(s))
3284
- offset.set(s, o);
3337
+ const st = step(w);
3338
+ if (st && w.endWall - t1 < st.floor)
3339
+ continue;
3340
+ const o = w.endWall - t1 - (st?.end ?? 0);
3341
+ if (!offset.has(seg) || o < offset.get(seg))
3342
+ offset.set(seg, o);
3285
3343
  }
3286
3344
  return windows.map((w) => {
3287
- const s = segment.get(w);
3288
- const o = s === undefined ? undefined : offset.get(s);
3289
- const t0 = num(w.raw.t0);
3345
+ const seg = process2.get(w);
3290
3346
  const t1 = num(w.raw.t1);
3347
+ const own = seg === undefined || t1 === undefined ? undefined : w.endWall - t1 - (step(w)?.end ?? 0);
3348
+ const o = seg === undefined ? undefined : offset.get(seg) ?? own;
3349
+ const t0 = num(w.raw.t0);
3291
3350
  if (o === undefined || t0 === undefined || t1 === undefined)
3292
3351
  return w;
3352
+ const st = step(w);
3353
+ const [atStart, atEnd] = [st?.start ?? 0, st?.end ?? 0];
3293
3354
  const touch = version >= 23 ? num(obj2(w.raw.interaction).touchStartT) : undefined;
3294
- return { ...w, startWall: t0 + o, endWall: t1 + o, ...touch === undefined ? {} : { touchWall: touch + o } };
3355
+ return { ...w, startWall: t0 + o + atStart, endWall: t1 + o + atEnd, ...touch === undefined ? {} : { touchWall: touch + o + atStart } };
3295
3356
  });
3296
3357
  }
3297
3358
 
@@ -3683,7 +3744,7 @@ function profileMetrics(windows, steps, options = {}) {
3683
3744
  const kind = placeRef(key).kind;
3684
3745
  const active = occ.filter((o) => !o.idle);
3685
3746
  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);
3686
- const identity = options.commitIdentity && metrics.has(`${key}.commit_max_ms`) && ["screen", "press", "drag", "other"].includes(kind) && selected?.slowestCommit;
3747
+ const identity = options.commitIdentity && metrics.has(`${key}.commit_max_ms`) && COMMIT_IDENTITY_KINDS.includes(kind) && selected?.slowestCommit;
3687
3748
  const openedBy = selected?.window ? options.openedBy?.get(selected.window) : undefined;
3688
3749
  profiles.push({ ...profile, ...identity ? { slowestCommit: identity } : {}, ...openedBy ? { openedBy } : {} });
3689
3750
  }
@@ -3829,7 +3890,7 @@ function withActionWindows(windows, steps = [], version) {
3829
3890
  }
3830
3891
  const durMs = w.raw.endReason === "timeout" ? undefined : num3(w.raw.durMs);
3831
3892
  const raw = { ...rest, name: w.name, kind: w.raw.kind, t0: w.raw.t0, t1: w.raw.t1, ...durMs === undefined ? {} : { durMs }, endReason: a.endReason, sdkEndReason: w.raw.endReason, actionSpanMs: a.spanMs, actionUncoveredMs: a.uncoveredMs };
3832
- return { name: w.name, startWall: w.startWall, endWall: w.endWall, raw };
3893
+ return { name: w.name, startWall: w.startWall, endWall: w.endWall, raw, ...w.clockStep === undefined ? {} : { clockStep: w.clockStep } };
3833
3894
  });
3834
3895
  }
3835
3896
  function actionMetrics(windows, steps = [], version) {
@@ -4942,7 +5003,7 @@ function keyFirstOpens(windows) {
4942
5003
  }
4943
5004
 
4944
5005
  // packages/derive/dist/src/derive.js
4945
- var DERIVATION_VERSIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 26, 27];
5006
+ var DERIVATION_VERSIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 26, 27, 28];
4946
5007
  var windowsForVersion = (windows, version = DERIVATION_VERSION) => version >= 22 ? keyFirstOpens(windows) : windows;
4947
5008
  function stepWindowsFor(windows, version = DERIVATION_VERSION) {
4948
5009
  return windowsForVersion(version >= 16 ? withLaunchNames(windows).filter((w) => !isBackgroundWindow(w)) : windows, version);
@@ -4978,7 +5039,7 @@ function profileOptions(windows, version, processWindows = windows) {
4978
5039
  return { fullRowMs: FULL_ROW_MS, commitIdentity: processes.size <= 1, openedBy };
4979
5040
  }
4980
5041
  function deriveRun(input) {
4981
- const { run, markers = [], ri, clockOffsetMs, samples = [], memWindow } = input;
5042
+ const { run, markers = [], ri, clockOffsetMs, clockErrorMs, samples = [], memWindow } = input;
4982
5043
  const version = input.derivationVersion ?? DERIVATION_VERSION;
4983
5044
  if (!DERIVATION_VERSIONS.includes(version))
4984
5045
  throw new Error(`unknown derivation version ${String(version)}`);
@@ -4987,7 +5048,7 @@ function deriveRun(input) {
4987
5048
  const cpu = input.cpu && renamed.size ? new Map([...input.cpu].map(([k, v]) => [renamed.get(k) ?? k, v])) : input.cpu;
4988
5049
  const background = version >= 16 ? named.filter(isBackgroundWindow) : [];
4989
5050
  const sdkWindows = stepWindowsFor(input.windows, version);
4990
- const off = clockOffsetMs === undefined ? {} : { clockOffsetMs };
5051
+ const off = { ...clockOffsetMs === undefined ? {} : { clockOffsetMs }, ...version >= 28 && clockErrorMs !== undefined ? { clockErrorMs } : {} };
4991
5052
  const preSteps = version >= 11 && markers.length ? alignRunSteps(markers, sdkWindows, off, version) : [];
4992
5053
  const windows = version >= 11 ? withActionWindows(sdkWindows, preSteps, version) : sdkWindows;
4993
5054
  const steps = version >= 11 ? preSteps : alignRunSteps(markers, windows, clockOffsetMs === undefined ? {} : { clockOffsetMs }, version);
@@ -5150,102 +5211,9 @@ var markerEnv = (url, runId) => ({
5150
5211
  MAESTRO_KEEPSNAPPY_RUN_ID: runId
5151
5212
  });
5152
5213
 
5153
- // apps/agent/src/upload.ts
5154
- import { join as join4 } from "node:path";
5155
- var MAX_RATE_RETRIES = 5;
5156
- var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
5157
- class UploadError extends Error {
5158
- }
5159
- async function call(f, base, path, headers, body, method = "POST") {
5160
- let res;
5161
- for (let attempt = 0;; attempt++) {
5162
- try {
5163
- res = await f(`${base.replace(/\/+$/, "")}${path}`, {
5164
- method,
5165
- headers: method === "POST" ? { "content-type": "application/json", ...headers } : headers,
5166
- ...method === "POST" ? { body: JSON.stringify(body ?? {}) } : {}
5167
- });
5168
- } catch (e) {
5169
- throw new UploadError(`${method} ${path}: ${e.message}`);
5170
- }
5171
- if (res.status !== 429 || attempt >= MAX_RATE_RETRIES)
5172
- break;
5173
- const wait = Number(res.headers.get("retry-after") ?? "1");
5174
- await res.text().catch(() => "");
5175
- await sleep(Math.min(Math.max(Number.isFinite(wait) ? wait : 1, 1), 60) * 1000);
5176
- }
5177
- const text = await res.text();
5178
- if (res.status < 200 || res.status >= 300)
5179
- throw new UploadError(`${method} ${path} -> ${res.status} ${text.slice(0, 200)}`);
5180
- try {
5181
- return text ? JSON.parse(text) : {};
5182
- } catch {
5183
- throw new UploadError(`${method} ${path} -> ${res.status}, the answer is not JSON (is the URL the KeepSnappy API?): ${text.slice(0, 200)}`);
5184
- }
5185
- }
5186
- async function uploadRuns(deps, outDir, runs, opts) {
5187
- const f = opts.fetch ?? fetch;
5188
- const size = opts.batchSize ?? 200;
5189
- const skipped = [];
5190
- let uploaded = 0;
5191
- for (const r of runs) {
5192
- if (!r.valid) {
5193
- skipped.push({ run_id: r.run_id, why: r.invalid_reason || "invalid" });
5194
- continue;
5195
- }
5196
- const log = await deps.fs.readFile(join4(outDir, r.run_id, "keepsnappy.log"));
5197
- const windows = windowsInRun(parseWindows(log), r.t0, r.t1);
5198
- if (windows.length === 0) {
5199
- skipped.push({ run_id: r.run_id, why: "no-windows" });
5200
- continue;
5201
- }
5202
- const externalId = `${opts.externalPrefix}-${r.run_id}`;
5203
- const created = await call(f, opts.ingestUrl, "/v1/runs", { "x-keepsnappy-key": opts.key }, {
5204
- external_id: externalId,
5205
- started_at: r.t0,
5206
- ended_at: r.t1,
5207
- device: { ...opts.device?.model ? { model: opts.device.model } : {}, ...opts.device?.os ? { os: opts.device.os } : {}, ...opts.device?.id ? { id: opts.device.id } : {}, exclusive: true },
5208
- meta: {
5209
- arm: r.arm,
5210
- driver: opts.driver ?? "keepsnappy-run",
5211
- ...opts.meta,
5212
- ...r.scenario ? { scenario: r.scenario } : {},
5213
- ...opts.runSetId ? { run_set_id: opts.runSetId } : {}
5214
- }
5215
- });
5216
- const runId = String(created.run_id ?? "");
5217
- const token = String(created.run_token ?? "");
5218
- if (!runId || !token)
5219
- throw new UploadError("POST /v1/runs: response has no run_id/run_token");
5220
- const items = windows.map((w) => `${w.endWall} KEEPSNAPPY window ${JSON.stringify(w.raw)}`);
5221
- for (let i = 0;i * size < items.length; i++) {
5222
- await call(f, opts.ingestUrl, "/v1/windows", { "x-keepsnappy-key": opts.key, "x-keepsnappy-run-token": token }, {
5223
- batch_id: `${externalId}-${String(i + 1).padStart(4, "0")}`,
5224
- items: items.slice(i * size, (i + 1) * size)
5225
- });
5226
- }
5227
- const samples = samplesInRun(parseSamples(log), r.t0, r.t1).map((x) => `${x.wall} KEEPSNAPPY sample ${JSON.stringify(x.raw)}`);
5228
- for (let i = 0;i * size < samples.length; i++) {
5229
- await call(f, opts.ingestUrl, "/v1/windows", { "x-keepsnappy-key": opts.key, "x-keepsnappy-run-token": token }, {
5230
- batch_id: `${externalId}-s${String(i + 1).padStart(4, "0")}`,
5231
- items: samples.slice(i * size, (i + 1) * size)
5232
- });
5233
- }
5234
- let markers = [];
5235
- try {
5236
- markers = parseMarkersNdjson(await deps.fs.readFile(join4(outDir, r.run_id, "markers.ndjson")));
5237
- } catch {}
5238
- for (const m of markers) {
5239
- await call(f, opts.ingestUrl, "/v1/markers", { authorization: `Bearer ${token}` }, { step: m.step, phase: m.phase, t_host: m.t_host });
5240
- }
5241
- await call(f, opts.ingestUrl, `/v1/runs/${runId}/finish`, { "x-keepsnappy-key": opts.key }, {});
5242
- uploaded++;
5243
- }
5244
- return { uploaded, skipped };
5245
- }
5246
-
5247
5214
  // packages/present/dist/src/capital.js
5248
5215
  var upperFirst = (s) => s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
5216
+ var lowerFirst = (s) => s ? s.charAt(0).toLowerCase() + s.slice(1) : s;
5249
5217
 
5250
5218
  // packages/present/dist/src/placeWords.js
5251
5219
  var GENERIC_GROUPS = new Set(["app", "tabs", "root"]);
@@ -5285,7 +5253,13 @@ var STATE_WORDS = {
5285
5253
  var INFO_WORDS = { heat: "Phone heated up" };
5286
5254
  var isWorseState = (state) => state === "worse";
5287
5255
  var NOT_BLOCKING = "not blocking";
5288
- var badgedWord = (word, blocking) => word === STATE_WORDS.worse && blocking === false ? `${word} (${NOT_BLOCKING})` : word;
5256
+ var badgedWord = (word, blocking) => `${word}${notBlockingBadge(word, blocking)}`;
5257
+ var notBlockingBadge = (word, blocking) => word === STATE_WORDS.worse && blocking === false ? ` (${NOT_BLOCKING})` : "";
5258
+ var BLOCKED_WORD = "Blocked";
5259
+ var NOT_COMPUTED_WORD = "Verdict not computed yet (computed on demand)";
5260
+ var NO_BASELINE_WORD = "Waiting for main";
5261
+ var NOT_COMPARED_WORD = "Not compared with main";
5262
+ var NOT_JUDGED_WORD = "Not judged";
5289
5263
 
5290
5264
  // packages/present/dist/src/values.js
5291
5265
  var NO_VALUE = "–";
@@ -5314,17 +5288,23 @@ function roundTo(v, decimals) {
5314
5288
  return Number(magnitude.toFixed(decimals)) * Math.sign(v);
5315
5289
  return Number(`${Math.round(scaled)}e-${decimals}`) * Math.sign(v);
5316
5290
  }
5291
+ function written(unit, decimals, v) {
5292
+ if (unit === "ms" && Math.abs(v) >= LONG_MS && Math.abs(v) < 1e9)
5293
+ return { n: roundTo(v / 1000, 1) + 0, seconds: true };
5294
+ return { n: roundTo(v * shownUnit(unit).scale, decimals) + 0, seconds: false };
5295
+ }
5317
5296
  function format(unit, decimals, v) {
5318
5297
  if (v === null || v === undefined || !Number.isFinite(v))
5319
5298
  return NO_VALUE;
5299
+ const { n, seconds } = written(unit, decimals, v);
5300
+ if (seconds)
5301
+ return `${String(n)} s`;
5320
5302
  const shown = shownUnit(unit);
5321
- if (unit === "ms" && Math.abs(v) >= LONG_MS && Math.abs(v) < 1e9)
5322
- return `${String(roundTo(v / 1000, 1) + 0)} s`;
5323
- const text = String(roundTo(v * shown.scale, decimals) + 0);
5324
- return shown.unit ? `${text} ${shown.unit}` : text;
5303
+ return shown.unit ? `${String(n)} ${shown.unit}` : String(n);
5325
5304
  }
5305
+ var defOf = (measure) => isMeasureId(measure) ? MEASURES[measure] : BREAKDOWN_MEASURES[measure];
5326
5306
  function valueText(measure, v) {
5327
- const def = isMeasureId(measure) ? MEASURES[measure] : BREAKDOWN_MEASURES[measure];
5307
+ const def = defOf(measure);
5328
5308
  return format(def.unit, def.decimals, v);
5329
5309
  }
5330
5310
  var KEY_SEPARATOR = ":";
@@ -5342,6 +5322,13 @@ function fieldValueText(field, v) {
5342
5322
  const def = fieldDef(field.split(KEY_SEPARATOR)[0]);
5343
5323
  return format(def?.unit, def?.decimals ?? FALLBACK_DECIMALS, v);
5344
5324
  }
5325
+ function timeMove(field, a, b) {
5326
+ if (a === null || b === null || !(a > 0))
5327
+ return null;
5328
+ const breakdown = breakdownMeasure(field);
5329
+ const unit = breakdown ? BREAKDOWN_MEASURES[breakdown].unit : fieldDef(field.split(KEY_SEPARATOR)[0])?.unit;
5330
+ return unit === "ms" ? Math.abs(b - a) / a : null;
5331
+ }
5345
5332
 
5346
5333
  // packages/present/dist/src/rowWords.js
5347
5334
  var GONE_TAILS = {
@@ -5370,6 +5357,14 @@ function notInRunRowsText(c, page) {
5370
5357
  const them = c.screens + c.places === 1 ? "it" : "them";
5371
5358
  return { lead: `${what} ${GONE_TAILS[page].notInRun}:`, rest: ` the ${page === "journey" ? "newest session" : "journey"} did not reach ${them}; not counted.` };
5372
5359
  }
5360
+ var sdkTag = (version) => version.replace(/^\d+\.\d+\.\d+-/, "");
5361
+ var placeChartMarker = (tag) => `Place names changed with SDK ${tag}`;
5362
+ var placeNamesChangedOmitted = (tag) => `the line where place names changed (SDK ${tag})`;
5363
+ var componentChartMarker = (tag) => `SDK ${tag}: names changed, library time now counted`;
5364
+ var ownTimeOnlyBefore = (version) => `own time only, before SDK ${version}`;
5365
+ var placeWasText = (oldKey) => `before it this place was ${oldKey}`;
5366
+ var PLACE_NOT_JOINED_TEXT = "no recorded old name, its history starts here";
5367
+ var componentNotJoinedText = (sdk) => `${componentChartMarker(sdk)}; values before it were ${ownTimeOnlyBefore(sdk)}, not joined`;
5373
5368
  var OUTSIDE_APP_NAME = "Outside app components";
5374
5369
  var OTHER_RENDER_WORK_NAME = "Other render work";
5375
5370
  var plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
@@ -5388,9 +5383,6 @@ function leadBody(subject, measure, main, pr) {
5388
5383
  var screenLeadText = (tier, subject, measure, main, pr) => `${STATE_WORDS[TIER_STATE[tier]]}: ${leadBody(subject, measure, main, pr)}`;
5389
5384
  var FOR_INFORMATION = "For information";
5390
5385
  var informationLeadText = (subject, measure, main, pr) => `${FOR_INFORMATION}: ${leadBody(subject, measure, main, pr)}`;
5391
- function foldedText(n, rows, page) {
5392
- return `${n} ${GONE_TAILS[page][rows === "components" ? "removed" : "notInRun"]}`;
5393
- }
5394
5386
  function placeShownName(p, o = {}) {
5395
5387
  const target = p.target ? componentShownName(p.target.name, p.target.file) : libraryViewName(p.kind, p.host ?? "");
5396
5388
  const named = p.target && p.parent ? `${target} in ${componentShownName(p.parent.name, p.parent.file)}${p.k ? `, ${ordinal(p.k)}` : ""}` : target;
@@ -5451,6 +5443,19 @@ var placeOpenWord = (id) => {
5451
5443
  const open = placeOpen(id);
5452
5444
  return open ? OPEN_WORDS[open] : null;
5453
5445
  };
5446
+ var TWIN2 = /\+3s$/;
5447
+ function foldTwins(rows, placeOf, also = () => "") {
5448
+ const seen = new Set;
5449
+ const out = rows.filter((r) => {
5450
+ const key = `${placeOf(r).replace(TWIN2, "")}\x00${also(r)}`;
5451
+ if (seen.has(key))
5452
+ return false;
5453
+ seen.add(key);
5454
+ return true;
5455
+ });
5456
+ return { rows: out, folded: rows.length - out.length };
5457
+ }
5458
+ var foldedTwinsText = (n) => `${n} “${PLACE_KIND_WORDS.after3s}” ${n === 1 ? "twin" : "twins"} folded into ${n === 1 ? "its place" : "their places"}`;
5454
5459
  function placeIdShownName(id, o = {}) {
5455
5460
  const p = placeParts(id);
5456
5461
  if (!p)
@@ -5537,6 +5542,42 @@ function shortestPaths(files) {
5537
5542
  }
5538
5543
  return out;
5539
5544
  }
5545
+ // packages/present/dist/src/problems.js
5546
+ var FAR_OVER_BUDGET_WORD = "Far over budget";
5547
+ var PROBLEM_TAG_WORDS = { new: NEW_IN_PR_CHIP, main: "Also on main", none: NOT_COMPARED_WORD };
5548
+ var PROBLEM_TAG_LINE_WORDS = { new: "new in this PR", main: "also on main", none: "not compared with main" };
5549
+ var problems = (n) => `${n.toLocaleString("en-US")} ${n === 1 ? "problem" : "problems"}`;
5550
+ var places = (n) => `${n.toLocaleString("en-US")} ${n === 1 ? "place" : "places"}`;
5551
+ function problemCountsText(c, own) {
5552
+ const tags = c.tags;
5553
+ 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(", ")}` : "";
5554
+ return `${problems(c.count)} at ${places(c.places)} in ${own} own runs${split}${c.far_count ? `; ${c.far_count} of them ${FAR_OVER_BUDGET_WORD.toLowerCase()}` : ""}`;
5555
+ }
5556
+ var newProblems = (n) => `${n.toLocaleString("en-US")} new ${n === 1 ? "problem" : "problems"}`;
5557
+ var PROBLEMS_BLOCK_WORDS = {
5558
+ branch: {
5559
+ heading: "New problems in this branch",
5560
+ sub: "too slow on their own, whatever main does; they don’t block the merge",
5561
+ seeAll: (n) => `See all ${newProblems(n)}`,
5562
+ alsoLine: (n) => `${problems(n)} also on main`,
5563
+ notComparedLine: (n) => `${problems(n)} not compared with main`,
5564
+ choices: { new: PROBLEM_TAG_WORDS.new, also: PROBLEM_TAG_WORDS.main, not_compared: PROBLEM_TAG_WORDS.none },
5565
+ panelTitle: "Problems in this branch",
5566
+ panelNote: "Each one is too slow in this branch’s own runs, whatever main does. Problems never block.",
5567
+ filter: "Which problems"
5568
+ },
5569
+ main: {
5570
+ heading: "New problems in this session",
5571
+ sub: "too slow on their own, whatever earlier sessions did",
5572
+ seeAll: (n) => `See all ${newProblems(n)}`,
5573
+ alsoLine: (n) => `${problems(n)} also in earlier sessions`,
5574
+ notComparedLine: (n) => `${problems(n)} not compared with earlier sessions`,
5575
+ choices: { new: "New in this session", also: "Also in earlier sessions", not_compared: "Not compared with earlier sessions" },
5576
+ panelTitle: "Problems in this session",
5577
+ panelNote: "Each one is too slow in this session’s own runs, whatever earlier sessions did. Problems never block.",
5578
+ filter: "Which problems"
5579
+ }
5580
+ };
5540
5581
  // packages/present/dist/src/stability.js
5541
5582
  var UNSTABLE_WORDS = {
5542
5583
  short: "main varies too much here",
@@ -5544,8 +5585,34 @@ var UNSTABLE_WORDS = {
5544
5585
  line: "Main varies too much here to tell",
5545
5586
  ratioTip: "1.0 = main’s run-to-run noise alone would pass the warning step about once in 100 comparisons.",
5546
5587
  note: "This place varies a lot between runs. Worth checking why: it may be in the code.",
5547
- what: "Main’s own runs vary too much here to tell, so a comparison there would mostly measure noise. It is shown with its values, never judged, never blocks and is never counted. It is judged again once main settles."
5588
+ what: "Main’s own runs vary too much here to tell, so a comparison there would mostly measure noise. It is shown with its values, never judged, never blocks and is never counted. It is judged again once main settles.",
5589
+ hint: {
5590
+ work: "The work differs between runs or sessions ({count} {min}–{max}): likely the code or the test steps.",
5591
+ speed: "The same work ran at different speeds ({count} {n} in every run): likely the phone or the session.",
5592
+ unclear: "The stored facts don’t show whether the work or the speed varies."
5593
+ }
5548
5594
  };
5595
+ function breakdownParts(ref) {
5596
+ const [kind, ...rest] = ref.split(":");
5597
+ return kind === "b" && rest.length >= 3 ? { key: rest.slice(1, -1).join(":"), measure: rest[rest.length - 1] } : null;
5598
+ }
5599
+ var countText = (n) => String(Math.round(n * 10) / 10);
5600
+ function countName(count, ref) {
5601
+ if (count !== "component.renders" && count !== "component.mounts")
5602
+ return MEASURES[count].short;
5603
+ const key = (ref ? breakdownParts(ref)?.key : undefined) ?? "component";
5604
+ return `${key} ${BREAKDOWN_MEASURES[count === "component.renders" ? "renders" : "mounts"].label}`;
5605
+ }
5606
+ function unstableHintLine(hint, ref) {
5607
+ if (hint.kind === "unclear" || hint.count === null || hint.range === null)
5608
+ return UNSTABLE_WORDS.hint.unclear;
5609
+ const count = countName(hint.count, ref);
5610
+ if (hint.kind === "work")
5611
+ return UNSTABLE_WORDS.hint.work.replace("{count}", count).replace("{min}", countText(hint.range[0])).replace("{max}", countText(hint.range[1]));
5612
+ return hint.median === null ? UNSTABLE_WORDS.hint.unclear : UNSTABLE_WORDS.hint.speed.replace("{count}", count).replace("{n}", countText(hint.median));
5613
+ }
5614
+ var unstableNoteWords = (u) => u.hint ? `${UNSTABLE_WORDS.note} ${unstableHintLine(u.hint, u.stabilityRef)}` : UNSTABLE_WORDS.note;
5615
+ var mainUnstableWords = (reason) => reason.kind === "noisy_main" ? unstableNoteWords(reason) : "";
5549
5616
 
5550
5617
  // packages/present/dist/src/present.js
5551
5618
  var ASPECT_WORDS = {
@@ -5617,8 +5684,6 @@ function resultContextLines(c, verbose = false, short = false) {
5617
5684
  for (const [screen, rows] of screens) {
5618
5685
  const line = notInRunRowsText({ screens: rows.filter((p) => p.kind === "screen").length, places: rows.filter((p) => p.kind !== "screen").length }, c.page);
5619
5686
  out.push(`${screen ? `${screen}: ` : ""}${line.lead}${line.rest}`);
5620
- if (rows.length > 3)
5621
- out.push(` ${foldedText(rows.length, "places", c.page)}`);
5622
5687
  if (!short || rows.length <= 3)
5623
5688
  out.push(...rows.map((p) => ` ${windowQualified(p.place)}`));
5624
5689
  }
@@ -5628,8 +5693,6 @@ function resultContextLines(c, verbose = false, short = false) {
5628
5693
  for (const [place, rows] of removed) {
5629
5694
  const line = removedRowsText(rows.length, c.page);
5630
5695
  out.push(`${windowQualified(place)}: ${line.lead}${line.rest}`);
5631
- if (rows.length > 3)
5632
- out.push(` ${foldedText(rows.length, "components", c.page)}`);
5633
5696
  if (!short || rows.length <= 3)
5634
5697
  out.push(...rows.map((r) => ` ${r.ref.kind === "breakdown" ? componentReadName({ key: r.ref.key, name: r.ref.name }) : measureWords(r.ref.measure)}`));
5635
5698
  }
@@ -5684,6 +5747,10 @@ var REASON_WORDS = {
5684
5747
  "not-enough-main": "main does not have enough sessions on this phone and journey yet"
5685
5748
  };
5686
5749
  var waitingWords = (runs) => `${runs} main ${runs === 1 ? "run waits" : "runs wait"} for a reprocess`;
5750
+ var AT_MOST = "≤ ";
5751
+ var atMost = (text, isBound) => isBound ? `${AT_MOST}${text}` : text;
5752
+ var AT_LEAST = "≥ ";
5753
+ var atLeast = (text, isBound) => isBound ? `${AT_LEAST}${text}` : text;
5687
5754
  // packages/present/dist/src/main-history.js
5688
5755
  var MAIN_TIER_WORDS = {
5689
5756
  same: "In norm",
@@ -5700,11 +5767,13 @@ var MAIN_TIER_RESULT_WORDS = {
5700
5767
  worse: STATE_WORDS.worse
5701
5768
  };
5702
5769
  var MAIN_TIER_RANK = { worse: 0, slight: 1, cant: 2, better: 3, same: 4 };
5770
+ var earlierWords = (sessions, since, config) => `main has ${sessions}${config.baseline.sessions === undefined ? "" : ` of ${config.baseline.sessions}`} earlier sessions${since ? ` on ${since}` : ""}`;
5703
5771
  function mainCantWords(reason, config) {
5704
5772
  if (!reason)
5705
5773
  return "";
5706
- 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}` : ""}`;
5774
+ return reason.kind === "noisy_main" ? "main’s runs vary too much there" : earlierWords(String(reason.earlier_sessions), reason.since_label, config);
5707
5775
  }
5776
+ var mainCantRangeWords = (r, config) => earlierWords(`${r.min}–${r.max}`, r.since_label, config);
5708
5777
  var waitingSessionsWords = (sessions) => `${sessions} older main ${sessions === 1 ? "session waits" : "sessions wait"} for a reprocess`;
5709
5778
  function mainPlaceReasonWords(reason, config) {
5710
5779
  switch (reason.kind) {
@@ -5734,9 +5803,11 @@ var SHORT_ASPECT_WORDS = {
5734
5803
  driverVersion: "Driver",
5735
5804
  journeyVersion: "Journey"
5736
5805
  };
5806
+ var cantAt = (places) => `${STATE_WORDS["cant-tell"]} at ${places} place${places === 1 ? "" : "s"}`;
5737
5807
  function mainSessionCantWords(places, reason, config) {
5738
- return `${STATE_WORDS["cant-tell"]} at ${places} place${places === 1 ? "" : "s"}: ${mainCantWords(reason, config)}`;
5808
+ return `${cantAt(places)}: ${mainCantWords(reason, config)}`;
5739
5809
  }
5810
+ var mainSessionCantRangeWords = (places, r, config) => `${cantAt(places)}: ${mainCantRangeWords(r, config)}`;
5740
5811
  var KIND_PLURAL = {
5741
5812
  screen: "screens",
5742
5813
  press: "taps",
@@ -5761,6 +5832,495 @@ function mainCutWords(v) {
5761
5832
  return parts.length ? parts.join(" ") : "Measures alike; no baseline cut.";
5762
5833
  }
5763
5834
  var BY_RANK = Object.keys(MAIN_TIER_RANK).sort((a, b) => MAIN_TIER_RANK[a] - MAIN_TIER_RANK[b]);
5835
+ // packages/present/dist/src/verdict-section.js
5836
+ var places2 = (n) => `${n.toLocaleString("en-US")} ${n === 1 ? "place" : "places"}`;
5837
+ var of = (n, total) => `${n.toLocaleString("en-US")} of ${places2(total)}`;
5838
+ var runs = (n) => `${n} valid ${n === 1 ? "run" : "runs"}`;
5839
+ var VERDICT_SECTION_WORDS = {
5840
+ slower: (n, total, slight) => `${slight ? "A little slower" : "Slower"} at ${of(n, total)}`,
5841
+ blocksMerge: "blocks the merge",
5842
+ faster: (n, total) => `Faster at ${of(n, total)}, slower at none`,
5843
+ mixed: (slower, faster, total) => `Slower at ${of(slower, total)}, faster at ${faster.toLocaleString("en-US")}`,
5844
+ unpublishedSdk: "ran an unpublished SDK build",
5845
+ 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)})`,
5846
+ 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`,
5847
+ tooFewRuns: (valid, needed) => `Not compared: ${runs(valid)} of ${needed} needed`,
5848
+ tooFewMainRuns: (valid, needed) => `Not compared: main has ${runs(valid)} of ${needed} needed`,
5849
+ tooFewBefore: (valid) => `Not compared: the sessions before have only ${runs(valid)}`,
5850
+ noBaseline: { branch: "Nothing to compare with: no main runs on this phone yet", main: "Nothing to compare with: no earlier sessions on this phone" },
5851
+ noisy: "Not judged: main’s own runs vary too much at every place here",
5852
+ versionMismatch: "Not compared: the sides were computed with different analysis versions",
5853
+ unconfirmed: (n) => `Can’t confirm ${places2(n)}: a run has no value there`,
5854
+ notCompared: (reason) => `Not compared: ${reason}`,
5855
+ notJudgedYet: (why) => `Not judged yet: ${why} on this phone`,
5856
+ 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`,
5857
+ doesNotBlock: "doesn’t block",
5858
+ fixes: {
5859
+ branch: {
5860
+ "no-baseline": "Add this journey on this phone to main’s nightly runs.",
5861
+ "too-few-runs": "Run this branch again on this phone.",
5862
+ "judged-measures-missing": "Record the judged measures, then run again.",
5863
+ "runs-unknown": "Run this branch again; a full set of runs decides it.",
5864
+ "facts-version-mismatch": "Recompute both sides in Setup; no new runs are needed.",
5865
+ "platforms-differ": "Add this phone’s platform to main’s nightly runs."
5866
+ },
5867
+ main: {
5868
+ "no-baseline": "Nothing to do: main’s next 3 nightly sessions make a result possible.",
5869
+ "too-few-runs": "Main’s next nightly session decides it.",
5870
+ "runs-unknown": "Main’s next nightly session decides it.",
5871
+ "judged-measures-missing": "Record the judged measures on main; its next nightly session is compared.",
5872
+ "facts-version-mismatch": "Recompute both sides in Setup; no new runs are needed."
5873
+ }
5874
+ },
5875
+ waitForMain: { branch: "Nothing to do on this branch; main’s next nightly session decides it.", main: "Main’s next nightly session decides it." },
5876
+ sides: {
5877
+ branch: { left: "Main", right: "This branch", rightShort: "Branch" },
5878
+ main: { left: "Sessions before", right: "Newest session", rightShort: "Newest" },
5879
+ runSet: { left: "Main", right: "This run set", rightShort: "Run set" }
5880
+ },
5881
+ branchSides: (name) => ({ left: "Main", right: name || "This branch", rightShort: "Branch" }),
5882
+ inProgress: (arrived, expected) => `${arrived} of ${expected} runs arrived; the result comes with the last one`,
5883
+ whatChangedLabel: "What changed",
5884
+ forInformation: "for information",
5885
+ allFilter: "All",
5886
+ filter: {
5887
+ label: "Show places",
5888
+ choice: (tierWord) => upperFirst(tierWord),
5889
+ showing: (choice, n) => `Showing ${choice.toLowerCase()}: ${n}`
5890
+ },
5891
+ countsLabel: "Places by result",
5892
+ links: {
5893
+ openChart: "Open chart",
5894
+ seeAll: (n) => `See all ${places2(n)}`,
5895
+ whichPlaces: "Which places",
5896
+ whichChanges: "Which changes",
5897
+ howToFix: "How to fix",
5898
+ whatDiffers: "What differs",
5899
+ whatChanged: "What changed",
5900
+ openSetup: "Open setup",
5901
+ openScreen: "Open screen"
5902
+ }
5903
+ };
5904
+ // packages/present/dist/src/summary-message.js
5905
+ var SUMMARY_WORDS = {
5906
+ notComputed: "Not judged yet",
5907
+ notComputedWhy: "waiting for the judgement",
5908
+ placesOnPage: "the places are on the page",
5909
+ noResult: "No result",
5910
+ main: "Main",
5911
+ newestSlower: "newest session slower",
5912
+ journeys: (n) => `${n} journeys`,
5913
+ phones: (n) => `${n} phones`,
5914
+ heated: (n, of) => `${INFO_WORDS.heat.toLowerCase()} in ${n} of ${of} runs`,
5915
+ fewRuns: "few runs",
5916
+ measuredDifferently: "measured differently",
5917
+ namesChanged: (sdk, mainSessions, needed) => `Main: ${mainSessions} of ${needed} sessions on SDK ${sdkTag(sdk)}`,
5918
+ morePlaces: (n) => `+${n} more ${n === 1 ? "place" : "places"} changed`,
5919
+ compared: {
5920
+ branch: (sessions, days) => `vs main’s last ${sessions}${days}`,
5921
+ main: (sessions, days) => `newest session vs main’s ${sessions} before${days}`,
5922
+ mainSlower: (sessions, days) => `newest session slower than main’s ${sessions} before${days}`
5923
+ },
5924
+ sessions: (n) => `${n} ${n === 1 ? "session" : "sessions"}`,
5925
+ days: (days) => days ? ` (${days})` : "",
5926
+ runs: (runs, each) => each ? `${each} ${each === 1 ? "run" : "runs"} each` : `${runs} ${runs === 1 ? "run" : "runs"}`,
5927
+ unblock: (why, fix) => `${why} → ${fix}`,
5928
+ link: {
5929
+ comparison: "See the comparison",
5930
+ session: "See the session",
5931
+ branch: (branch) => `Open ${branch}`,
5932
+ runSet: "Open the run set"
5933
+ }
5934
+ };
5935
+ var ORDER = ["worse", "warning", "cant-tell", "not-computed", "better", "no-change"];
5936
+ var rank = (l) => ORDER.indexOf(l.state);
5937
+ var WORD = { ...STATE_WORDS, "not-computed": SUMMARY_WORDS.notComputed };
5938
+ var shownLane = (lanes) => [...lanes].sort((a, b) => rank(a) - rank(b) || b.places - a.places)[0];
5939
+ var clip = (s, n) => s.length > n ? `${s.slice(0, n - 1)}…` : s;
5940
+ var NAME = { name: 32, button: 30 };
5941
+ var TOP2 = 3;
5942
+ var MAX_LINES = 5;
5943
+ var upper = (s) => s ? `${s[0].toUpperCase()}${s.slice(1)}` : s;
5944
+ var fromTo = (a, b) => {
5945
+ const unit = (v) => / [^\d\s]+$/.exec(v)?.[0];
5946
+ return unit(a) && unit(a) === unit(b) ? `${a.slice(0, -unit(a).length)} → ${b}` : `${a} → ${b}`;
5947
+ };
5948
+ var days = (labels) => {
5949
+ const a = labels[0], b = labels.at(-1);
5950
+ if (!a || !b || a === b)
5951
+ return a ?? "";
5952
+ const [ma] = a.split(" ");
5953
+ const [mb, db] = b.split(" ");
5954
+ return ma === mb ? `${a}–${db}` : `${a}–${b}`;
5955
+ };
5956
+ var changeLine = (c, tier) => {
5957
+ const values = c.main !== null && c.now !== null ? `${fromTo(c.main, c.now)}${c.change ? ` (${c.change})` : ""}` : c.change;
5958
+ return [c.place ?? upper(c.measure), ...c.place ? [c.measure] : [], values, WORD[c.tier ?? tier]].filter(Boolean).join(" · ");
5959
+ };
5960
+ var wordReason = (word) => word?.includes(": ") ? word.slice(word.indexOf(": ") + 2) : null;
5961
+ var notJudgedWhy = (l) => l.state === "not-computed" && l.word?.startsWith(`${NOT_JUDGED_WORD}: `) ? upper(l.word.slice(NOT_JUDGED_WORD.length + 2)) : null;
5962
+ function why(l) {
5963
+ if (l.state === "not-computed")
5964
+ return notJudgedWhy(l) ?? upper(SUMMARY_WORDS.notComputedWhy);
5965
+ if (l.state === "cant-tell" && l.namesChanged)
5966
+ return SUMMARY_WORDS.namesChanged(l.namesChanged.sdk, l.namesChanged.mainSessions, l.namesChanged.needed);
5967
+ if (l.answer)
5968
+ return l.answer;
5969
+ if (l.state === "no-change")
5970
+ return l.places ? VERDICT_SECTION_WORDS.noneMoved(l.places) : noneMoved;
5971
+ if (l.state !== "cant-tell")
5972
+ return upper(SUMMARY_WORDS.placesOnPage);
5973
+ return upper(wordReason(l.word) ?? (l.word === NO_BASELINE_WORD ? l.word : REASON_WORDS["judged-measures-missing"]));
5974
+ }
5975
+ function unblock(l, pr) {
5976
+ const page = pr ? "branch" : "main";
5977
+ const reason = l.namesChanged ? "not-enough-main" : l.reason;
5978
+ if (!reason)
5979
+ return null;
5980
+ const fix = reason === "not-enough-main" ? VERDICT_SECTION_WORDS.waitForMain[page] : VERDICT_SECTION_WORDS.fixes[page][reason];
5981
+ return fix ? lowerFirst(fix.replace(/\.$/, "")) : null;
5982
+ }
5983
+ var noneMoved = VERDICT_SECTION_WORDS.noneMoved(1).replace(/ \(.*\)$/, "");
5984
+ function conditions(l, pr) {
5985
+ const moved = l.state === "worse" || l.state === "warning";
5986
+ return [
5987
+ pr && l.state === "worse" ? l.blocking ? VERDICT_SECTION_WORDS.blocksMerge : NOT_BLOCKING : "",
5988
+ moved && l.measuredDifferently ? SUMMARY_WORDS.measuredDifferently : "",
5989
+ (moved || l.state === "better") && l.fewRuns ? SUMMARY_WORDS.fewRuns : ""
5990
+ ].filter(Boolean);
5991
+ }
5992
+ function compared(l, pr, slower) {
5993
+ const judged = l.state === "worse" || l.state === "warning" || l.state === "better" || l.state === "no-change";
5994
+ if (!judged || !l.main?.sessions)
5995
+ return null;
5996
+ const args = [SUMMARY_WORDS.sessions(l.main.sessions), SUMMARY_WORDS.days(days(l.main.labels))];
5997
+ const what = pr ? SUMMARY_WORDS.compared.branch(...args) : slower ? SUMMARY_WORDS.compared.mainSlower(...args) : SUMMARY_WORDS.compared.main(...args);
5998
+ return `${what} · ${SUMMARY_WORDS.runs(l.main.runs, l.main.each)}`;
5999
+ }
6000
+ function summaryMessage(input) {
6001
+ const l = shownLane(input.lanes);
6002
+ if (!l) {
6003
+ return {
6004
+ state: "not-computed",
6005
+ newestSlower: false,
6006
+ lead: `${SUMMARY_WORDS.notComputed} · ${clip(input.branch, NAME.name)}`,
6007
+ ...withLines([upper(SUMMARY_WORDS.notComputedWhy)]),
6008
+ link: { label: SUMMARY_WORDS.link.branch(clip(input.branch, NAME.button - 5)), url: input.url }
6009
+ };
6010
+ }
6011
+ const journeys = new Set(input.lanes.map((x) => x.journey ?? "-")).size;
6012
+ const phones = new Set(input.lanes.map((x) => x.phone ?? "-")).size;
6013
+ const single = input.lanes.length === 1;
6014
+ const newestSlower = !input.pr && (l.state === "worse" || l.state === "warning");
6015
+ const lead = [
6016
+ notJudgedWhy(l) ? NOT_JUDGED_WORD : WORD[l.state],
6017
+ ...input.app ? [input.app] : [],
6018
+ journeys === 1 ? l.journey ?? "journey" : SUMMARY_WORDS.journeys(journeys),
6019
+ input.pr && input.prNumber ? `${input.branch} (#${input.prNumber})` : input.branch,
6020
+ phones === 1 ? l.phone ?? "unknown phone" : SUMMARY_WORDS.phones(phones)
6021
+ ].join(" · ");
6022
+ const lines = [];
6023
+ const basis = compared(l, input.pr, newestSlower);
6024
+ const extra = conditions(l, input.pr).join(", ");
6025
+ const heat = l.heatedRuns ? upper(SUMMARY_WORDS.heated(l.heatedRuns, l.runs)) : null;
6026
+ const tail = [...basis || extra ? [basis ? `${basis}${extra ? `; ${extra}` : ""}` : upper(extra)] : [], ...heat ? [heat] : []];
6027
+ if (l.state === "worse" || l.state === "warning" || l.state === "better") {
6028
+ const seen = new Set;
6029
+ const places = l.changes.filter((c) => {
6030
+ const key = c.key ?? c.place ?? c.measure;
6031
+ return seen.has(key) ? false : (seen.add(key), true);
6032
+ });
6033
+ const room = MAX_LINES - tail.length;
6034
+ const top = places.length > room ? places.slice(0, Math.min(TOP2, room - 1)) : places.slice(0, TOP2);
6035
+ lines.push(...top.map((c) => changeLine(c, l.state)));
6036
+ if (!top.length)
6037
+ lines.push(why(l));
6038
+ const more = l.places - top.length;
6039
+ if (top.length && more > 0)
6040
+ lines.push(SUMMARY_WORDS.morePlaces(more));
6041
+ } else {
6042
+ const fix = l.state === "cant-tell" ? unblock(l, input.pr) : null;
6043
+ lines.push(fix ? SUMMARY_WORDS.unblock(why(l), fix) : why(l));
6044
+ }
6045
+ lines.push(...tail);
6046
+ const label = input.pr ? SUMMARY_WORDS.link.comparison : SUMMARY_WORDS.link.session;
6047
+ return { state: l.state, newestSlower, lead, ...withLines(lines), link: { label, url: (single ? l.url : null) ?? input.url } };
6048
+ }
6049
+ var withLines = (lines) => ({ lines, detail: lines.join(`
6050
+ `) });
6051
+ function pendingMessage(branch, url) {
6052
+ 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 } };
6053
+ }
6054
+ function noResultMessage(branch, reason, url) {
6055
+ 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 };
6056
+ }
6057
+ // packages/present/dist/src/run-set-message.js
6058
+ var midLine = (label, metric) => !metric.startsWith("component:") && /^[A-Z][a-z]*(\s|$)/.test(label) ? `${label[0].toLowerCase()}${label.slice(1)}` : label;
6059
+ function movedItems(v, sc) {
6060
+ const flat = v.items.filter((i) => i.scenario === sc.scenario && (!sc.device || !i.device_id || i.device_id === sc.device.id));
6061
+ const h = sc.headline;
6062
+ if (h?.errors === undefined)
6063
+ return flat.filter((i) => i.state === "worse" || i.state === "better");
6064
+ const graded = [...h.errors, ...h.warnings ?? []].map((g) => {
6065
+ const same = flat.find((i) => i.step === g.window && i.metric === g.metric);
6066
+ return {
6067
+ ...same ?? { kind: "change", low_data: false, low_data_reason: null },
6068
+ scenario: sc.scenario,
6069
+ step: g.window,
6070
+ metric: g.metric,
6071
+ state: "worse",
6072
+ grade: g.grade,
6073
+ baseline: g.baseline ?? same?.baseline ?? null,
6074
+ ...(g.baseline != null ? g.baseline_is_bound : same?.baseline_is_bound) ? { baseline_is_bound: true } : {},
6075
+ ...(g.baseline != null ? g.change_is_bound : same?.change_is_bound) ? { change_is_bound: true } : {},
6076
+ candidate: g.candidate ?? same?.candidate ?? null,
6077
+ change_text: g.change,
6078
+ ...g.finding ? { finding: g.finding } : {},
6079
+ ...g.label ? { label_text: midLine(g.label, g.metric) } : {},
6080
+ ...sc.device ? { device_id: sc.device.id } : {}
6081
+ };
6082
+ });
6083
+ return [...graded, ...flat.filter((i) => i.state === "better" && (i.judged ?? "outcome") === "outcome")];
6084
+ }
6085
+ var placeCount = (rows) => new Set(rows.map((i) => `${i.scenario}|${i.device_id ?? ""}|${i.finding ?? placeRef(i.step ?? "*").stem}`)).size;
6086
+ var linkOf = (x, set) => x?.share_url || set?.share_url || x?.dashboard_url || set?.dashboard_url || null;
6087
+ var measureWord = (i) => {
6088
+ if (i.label_text)
6089
+ return i.label_text;
6090
+ if (i.metric === "dur_ms" && placeKindWord(i.step ?? "") !== "Tap")
6091
+ return "length";
6092
+ return measureDef(i.metric)?.short ?? i.metric.replace(/_/g, " ");
6093
+ };
6094
+ var changeWords = (i) => {
6095
+ if (i.change_text)
6096
+ return i.change_text.replace(/ \(.*\)$/, "");
6097
+ const { baseline: b, candidate: c } = i;
6098
+ if (b === null || c === null)
6099
+ return "";
6100
+ if (b === 0)
6101
+ return atLeast(`${c - b >= 0 ? "+" : ""}${fieldValueText(i.metric, c - b)}`, i.change_is_bound);
6102
+ const p = Math.round((c / b - 1) * 100);
6103
+ return atLeast(`${p >= 0 ? "+" : ""}${p} %`, i.change_is_bound);
6104
+ };
6105
+ var placeOf = (step) => !step || step === "flow" || step === "*" ? null : placeLabel(step);
6106
+ function stateOf(sc, errors) {
6107
+ if (sc.state === null)
6108
+ return sc.word === NO_BASELINE_WORD ? "cant-tell" : "not-computed";
6109
+ if (sc.state === "inconclusive")
6110
+ return "cant-tell";
6111
+ if (sc.state === "warn")
6112
+ return errors || sc.word?.startsWith(STATE_WORDS.worse) ? "worse" : "warning";
6113
+ return sc.state;
6114
+ }
6115
+ function laneOf(v, sc, set) {
6116
+ const moved = sc.state === "inconclusive" ? [] : movedItems(v, sc);
6117
+ const worse = moved.filter((i) => i.state === "worse").sort((a, b) => Number(a.grade === "warn") - Number(b.grade === "warn"));
6118
+ const errors = worse.filter((i) => i.grade !== "warn");
6119
+ const state = stateOf(sc, errors.length);
6120
+ const shown = state === "better" ? moved.filter((i) => i.state === "better") : worse;
6121
+ return {
6122
+ journey: sc.scenario,
6123
+ phone: sc.device?.model ?? null,
6124
+ state,
6125
+ answer: sc.headline?.answer ?? null,
6126
+ word: sc.word ?? null,
6127
+ changes: shown.map((i) => ({
6128
+ place: placeOf(i.step),
6129
+ measure: measureWord(i),
6130
+ key: i.finding ?? placeRef(i.step ?? "*").stem,
6131
+ tier: i.state === "better" ? "better" : i.grade === "warn" ? "warning" : "worse",
6132
+ main: i.baseline === null ? null : atMost(fieldValueText(i.metric, i.baseline), i.baseline_is_bound),
6133
+ now: i.candidate === null ? null : fieldValueText(i.metric, i.candidate),
6134
+ change: changeWords(i)
6135
+ })),
6136
+ places: placeCount(shown),
6137
+ blocking: sc.headline?.blocking ?? v.blocking ?? v.headline?.blocking ?? true,
6138
+ measuredDifferently: !!sc.headline?.measured_differently?.length,
6139
+ fewRuns: sc.low_data,
6140
+ heatedRuns: sc.heated_runs ?? 0,
6141
+ runs: sc.runs.valid + sc.runs.invalid,
6142
+ namesChanged: sc.context?.namesChanged ?? null,
6143
+ url: linkOf(sc, set),
6144
+ main: sc.main ?? null,
6145
+ reason: sc.headline?.reason ?? sc.headline?.reasons?.[0] ?? null
6146
+ };
6147
+ }
6148
+ var isPending = (s) => lanesOf(s).every((l) => l.state === "not-computed" && !l.word?.startsWith(`${NOT_JUDGED_WORD}: `));
6149
+ var lanesOf = (s) => s.verdicts ? s.verdicts.scenarios.map((sc) => laneOf(s.verdicts, sc, s)) : [];
6150
+ var isPr = (s) => !!s.verdicts?.scenarios.some((sc) => sc.role === "comparison");
6151
+ var runSetMessage = (s) => 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 });
6152
+ var MESSAGE_COLORS = { error: "#D6334A", warn: "#C47A00", better: "#238359", none: "#7C8492" };
6153
+ function messageTone(s) {
6154
+ const lanes = s ? lanesOf(s) : [];
6155
+ const pr = !!s && isPr(s);
6156
+ if (lanes.some((l) => l.state === "worse" && (l.blocking || !pr)))
6157
+ return { mark: "\uD83D\uDD34", color: MESSAGE_COLORS.error };
6158
+ if (lanes.some((l) => l.state === "warning"))
6159
+ return { mark: "\uD83D\uDFE1", color: MESSAGE_COLORS.warn };
6160
+ switch (shownLane(lanes)?.state) {
6161
+ case "better":
6162
+ return { mark: "\uD83D\uDFE2", color: MESSAGE_COLORS.better };
6163
+ case "cant-tell":
6164
+ return { mark: "❔", color: MESSAGE_COLORS.none };
6165
+ default:
6166
+ return { mark: "⚪", color: MESSAGE_COLORS.none };
6167
+ }
6168
+ }
6169
+ var esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
6170
+ function slackPayload(m, tone) {
6171
+ const lead = `${tone.mark} ${esc(m.lead)}`;
6172
+ return {
6173
+ attachments: [{
6174
+ color: tone.color,
6175
+ fallback: lead,
6176
+ blocks: [
6177
+ { type: "section", text: { type: "mrkdwn", text: `*${lead}*
6178
+ ${esc(m.detail)}` } },
6179
+ ...m.link ? [{ type: "actions", elements: [{ type: "button", text: { type: "plain_text", text: m.link.label }, url: m.link.url }] }] : []
6180
+ ]
6181
+ }]
6182
+ };
6183
+ }
6184
+ function resultSlackPayload(s, o = {}) {
6185
+ if (!s.verdicts)
6186
+ return null;
6187
+ const lanes = lanesOf(s);
6188
+ const moved = lanes.some((l) => l.state === "worse" || l.state === "warning");
6189
+ const heat = lanes.some((l) => l.heatedRuns > 0);
6190
+ const improved = lanes.some((l) => l.state === "better") || s.verdicts.state === "better";
6191
+ if (isPr(s) ? !moved && !heat : !moved && !improved && !heat && !o.always)
6192
+ return null;
6193
+ return slackPayload(runSetMessage(s), messageTone(s));
6194
+ }
6195
+ var noResultSlackPayload = (branch, reason, url) => slackPayload(noResultMessage(branch, reason, url), { mark: "⚪", color: MESSAGE_COLORS.none });
6196
+ var markdown = (m, mark) => `### ${mark} ${m.lead}
6197
+
6198
+ ${m.lines.join(`
6199
+ `)}
6200
+ ${m.link ? `
6201
+ [${m.link.label} ›](${m.link.url})
6202
+ ` : ""}`;
6203
+ var resultMarkdown = (s) => markdown(runSetMessage(s), messageTone(s).mark);
6204
+ var noResultMarkdown = (branch, reason, url) => markdown(noResultMessage(branch, reason, url), "⚪");
6205
+ var pendingMarkdown = (branch, url) => markdown(pendingMessage(branch, url), "⚪");
6206
+ // packages/present/dist/src/stale.js
6207
+ function staleNoteText(why) {
6208
+ const newer = why.includes("newer-run-set");
6209
+ const rules = why.some((w) => w === "core-version" || w === "config");
6210
+ if (newer && rules)
6211
+ return "Result from an older run set and older rules.";
6212
+ if (newer)
6213
+ return "Result from an older run set; a newer one has arrived.";
6214
+ if (rules)
6215
+ return "Result from older rules; it is not recomputed yet.";
6216
+ return "Result from older inputs.";
6217
+ }
6218
+ var staleFlagText = (why) => {
6219
+ const note = staleNoteText(why).replace(/\.$/, "");
6220
+ return `${note.charAt(0).toLowerCase()}${note.slice(1)}`;
6221
+ };
6222
+ var STALE_MARK = "stale result";
6223
+ // apps/agent/src/upload.ts
6224
+ import { join as join4 } from "node:path";
6225
+ var MAX_RATE_RETRIES = 5;
6226
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
6227
+ var pause = (ms) => sleep(ms);
6228
+
6229
+ class UploadError extends Error {
6230
+ status;
6231
+ constructor(message, status) {
6232
+ super(message);
6233
+ this.status = status;
6234
+ }
6235
+ }
6236
+ async function call(f, base, path, headers, body, method = "POST") {
6237
+ let res;
6238
+ for (let attempt = 0;; attempt++) {
6239
+ try {
6240
+ res = await f(`${base.replace(/\/+$/, "")}${path}`, {
6241
+ method,
6242
+ headers: method === "POST" ? { "content-type": "application/json", ...headers } : headers,
6243
+ ...method === "POST" ? { body: JSON.stringify(body ?? {}) } : {}
6244
+ });
6245
+ } catch (e) {
6246
+ throw new UploadError(`${method} ${path}: ${e.message}`, 0);
6247
+ }
6248
+ if (res.status !== 429 || attempt >= MAX_RATE_RETRIES)
6249
+ break;
6250
+ const wait = Number(res.headers.get("retry-after") ?? "1");
6251
+ await res.text().catch(() => "");
6252
+ await sleep(Math.min(Math.max(Number.isFinite(wait) ? wait : 1, 1), 60) * 1000);
6253
+ }
6254
+ const text = await res.text();
6255
+ if (res.status < 200 || res.status >= 300)
6256
+ throw new UploadError(`${method} ${path} -> ${res.status} ${text.slice(0, 200)}`, res.status);
6257
+ try {
6258
+ return text ? JSON.parse(text) : {};
6259
+ } catch {
6260
+ throw new UploadError(`${method} ${path} -> ${res.status}, the answer is not JSON (is the URL the KeepSnappy API?): ${text.slice(0, 200)}`);
6261
+ }
6262
+ }
6263
+ async function uploadRuns(deps, outDir, runs, opts) {
6264
+ const f = opts.fetch ?? fetch;
6265
+ const size = opts.batchSize ?? 200;
6266
+ const skipped = [];
6267
+ let uploaded = 0;
6268
+ for (const r of runs) {
6269
+ if (!r.valid) {
6270
+ skipped.push({ run_id: r.run_id, why: r.invalid_reason || "invalid" });
6271
+ continue;
6272
+ }
6273
+ const log = await deps.fs.readFile(join4(outDir, r.run_id, "keepsnappy.log"));
6274
+ const windows = windowsInRun(parseWindows(log), r.t0, r.t1);
6275
+ if (windows.length === 0) {
6276
+ skipped.push({ run_id: r.run_id, why: "no-windows" });
6277
+ continue;
6278
+ }
6279
+ const externalId = `${opts.externalPrefix}-${r.run_id}`;
6280
+ const created = await call(f, opts.ingestUrl, "/v1/runs", { "x-keepsnappy-key": opts.key }, {
6281
+ external_id: externalId,
6282
+ started_at: r.t0,
6283
+ ended_at: r.t1,
6284
+ device: { ...opts.device?.model ? { model: opts.device.model } : {}, ...opts.device?.os ? { os: opts.device.os } : {}, ...opts.device?.id ? { id: opts.device.id } : {}, exclusive: true },
6285
+ meta: {
6286
+ arm: r.arm,
6287
+ driver: opts.driver ?? "keepsnappy-run",
6288
+ ...opts.meta,
6289
+ ...r.scenario ? { scenario: r.scenario } : {},
6290
+ ...opts.runSetId ? { run_set_id: opts.runSetId } : {}
6291
+ }
6292
+ });
6293
+ const runId = String(created.run_id ?? "");
6294
+ const token = String(created.run_token ?? "");
6295
+ if (!runId || !token)
6296
+ throw new UploadError("POST /v1/runs: response has no run_id/run_token");
6297
+ const items = windows.map((w) => `${w.endWall} KEEPSNAPPY window ${JSON.stringify(w.raw)}`);
6298
+ for (let i = 0;i * size < items.length; i++) {
6299
+ await call(f, opts.ingestUrl, "/v1/windows", { "x-keepsnappy-key": opts.key, "x-keepsnappy-run-token": token }, {
6300
+ batch_id: `${externalId}-${String(i + 1).padStart(4, "0")}`,
6301
+ items: items.slice(i * size, (i + 1) * size)
6302
+ });
6303
+ }
6304
+ const samples = samplesInRun(parseSamples(log), r.t0, r.t1).map((x) => `${x.wall} KEEPSNAPPY sample ${JSON.stringify(x.raw)}`);
6305
+ for (let i = 0;i * size < samples.length; i++) {
6306
+ await call(f, opts.ingestUrl, "/v1/windows", { "x-keepsnappy-key": opts.key, "x-keepsnappy-run-token": token }, {
6307
+ batch_id: `${externalId}-s${String(i + 1).padStart(4, "0")}`,
6308
+ items: samples.slice(i * size, (i + 1) * size)
6309
+ });
6310
+ }
6311
+ let markers = [];
6312
+ try {
6313
+ markers = parseMarkersNdjson(await deps.fs.readFile(join4(outDir, r.run_id, "markers.ndjson")));
6314
+ } catch {}
6315
+ for (const m of markers) {
6316
+ await call(f, opts.ingestUrl, "/v1/markers", { authorization: `Bearer ${token}` }, { step: m.step, phase: m.phase, t_host: m.t_host });
6317
+ }
6318
+ await call(f, opts.ingestUrl, `/v1/runs/${runId}/finish`, { "x-keepsnappy-key": opts.key }, {});
6319
+ uploaded++;
6320
+ }
6321
+ return { uploaded, skipped };
6322
+ }
6323
+
5764
6324
  // apps/agent/src/summary.ts
5765
6325
  var fieldWord = (field) => measureDef(field)?.short ?? field;
5766
6326
  function plainMetric(metric) {
@@ -5799,7 +6359,6 @@ function summarize(input) {
5799
6359
 
5800
6360
  // apps/agent/src/remote.ts
5801
6361
  var isBlocking = (s) => !!s?.verdicts && (s.verdicts.blocking ?? s.verdicts.state === "worse");
5802
- var midLine = (label, metric) => !metric.startsWith("component:") && /^[A-Z][a-z]*(\s|$)/.test(label) ? `${label[0].toLowerCase()}${label.slice(1)}` : label;
5803
6362
  function heatNote(sc) {
5804
6363
  const n = sc.heated_runs ?? 0;
5805
6364
  if (!n)
@@ -5807,30 +6366,6 @@ function heatNote(sc) {
5807
6366
  const phone = sc.device ? `${sc.device.model}${sc.device.os ? ` · ${sc.device.os}` : ""}` : "unknown phone";
5808
6367
  return `${INFO_WORDS.heat} in ${n} of ${sc.runs.valid + sc.runs.invalid} runs · ${sc.scenario ?? "journey"} · ${phone}`;
5809
6368
  }
5810
- function movedItems(v, sc) {
5811
- const flat = v.items.filter((i) => i.scenario === sc.scenario && (!sc.device || !i.device_id || i.device_id === sc.device.id));
5812
- const h = sc.headline;
5813
- if (h?.errors === undefined)
5814
- return flat.filter((i) => i.state === "worse" || i.state === "better");
5815
- const graded = [...h.errors, ...h.warnings ?? []].map((g) => {
5816
- const same = flat.find((i) => i.step === g.window && i.metric === g.metric);
5817
- return {
5818
- ...same ?? { kind: "change", low_data: false, low_data_reason: null },
5819
- scenario: sc.scenario,
5820
- step: g.window,
5821
- metric: g.metric,
5822
- state: "worse",
5823
- grade: g.grade,
5824
- baseline: g.baseline ?? same?.baseline ?? null,
5825
- candidate: g.candidate ?? same?.candidate ?? null,
5826
- change_text: g.change,
5827
- ...g.finding ? { finding: g.finding } : {},
5828
- ...g.label ? { label_text: midLine(g.label, g.metric) } : {},
5829
- ...sc.device ? { device_id: sc.device.id } : {}
5830
- };
5831
- });
5832
- return [...graded, ...flat.filter((i) => i.state === "better" && (i.judged ?? "outcome") === "outcome")];
5833
- }
5834
6369
  var placeOfRow = (g) => g.finding ?? placeRef(g.window).stem;
5835
6370
  function movedCounts(sc) {
5836
6371
  const h = sc.headline;
@@ -5840,7 +6375,6 @@ function movedCounts(sc) {
5840
6375
  const slightly = new Set((h.warnings ?? []).map(placeOfRow).filter((p) => !worse.has(p)));
5841
6376
  return { worse: worse.size, warnings: slightly.size, better: h.counts.better, noChange: h.counts.no_change, inconclusive: h.counts.inconclusive };
5842
6377
  }
5843
- var placeCount = (rows) => new Set(rows.map((i) => `${i.scenario}|${i.device_id ?? ""}|${i.finding ?? placeRef(i.step ?? "*").stem}`)).size;
5844
6378
  var addCounts = (xs) => xs.reduce((a, c) => ({ worse: a.worse + c.worse, warnings: a.warnings + c.warnings, better: a.better + c.better, noChange: a.noChange + c.noChange, inconclusive: a.inconclusive + c.inconclusive }), { worse: 0, warnings: 0, better: 0, noChange: 0, inconclusive: 0 });
5845
6379
  var setupLine = (h) => {
5846
6380
  const parts = [
@@ -5856,13 +6390,35 @@ async function createRunSet(t, body) {
5856
6390
  throw new UploadError("POST /v1/run-sets: response has no run_set_id (is the URL the KeepSnappy API?)");
5857
6391
  return { run_set_id: r.run_set_id, dashboard_url: String(r.dashboard_url ?? "") };
5858
6392
  }
6393
+ var UNKNOWN_OUTCOME = new Set([0, 502, 503, 504]);
6394
+ var CONFIRM_READS = 6;
6395
+ var CONFIRM_WAIT_MS = 1e4;
5859
6396
  async function completeRunSet(t, id, verdicts) {
5860
6397
  const path = `/v1/run-sets/${encodeURIComponent(id)}/complete`;
5861
- const r = await call(t.fetch ?? fetch, t.url, path, keyHeader(t), { verdicts });
6398
+ let r;
6399
+ try {
6400
+ r = await call(t.fetch ?? fetch, t.url, path, keyHeader(t), { verdicts });
6401
+ } catch (e) {
6402
+ const closed = e instanceof UploadError && UNKNOWN_OUTCOME.has(e.status ?? -1) ? await confirmedClose(t, id) : null;
6403
+ if (closed)
6404
+ return closed;
6405
+ throw e;
6406
+ }
5862
6407
  if (!r.run_set_id)
5863
6408
  throw new UploadError(`POST ${path}: response has no run_set_id`);
5864
6409
  return r;
5865
6410
  }
6411
+ async function confirmedClose(t, id) {
6412
+ const path = `/v1/run-sets/${encodeURIComponent(id)}`;
6413
+ for (let i = 0;i < CONFIRM_READS; i++) {
6414
+ if (i)
6415
+ await pause(CONFIRM_WAIT_MS);
6416
+ const s = await call(t.fetch ?? fetch, t.url, path, keyHeader(t), undefined, "GET").catch(() => null);
6417
+ if (s?.run_set_id && (s.status === "processed" || s.status === "failed"))
6418
+ return s;
6419
+ }
6420
+ return null;
6421
+ }
5866
6422
  async function createIngestRun(t, body) {
5867
6423
  const r = await call(t.fetch ?? fetch, t.url, "/v1/runs", keyHeader(t), body);
5868
6424
  if (!r.run_id || !r.run_token)
@@ -5887,16 +6443,15 @@ var word = (s, server, blocking) => badgedWord(server || stateWord(s ?? "inconcl
5887
6443
  var setWord = (v) => word(v?.state ?? null, v?.word, v?.blocking ?? v?.headline?.blocking);
5888
6444
  var scenarioWord = (sc) => word(sc.state, sc.word, sc.headline?.blocking);
5889
6445
  var notComputed = (s) => !s.verdicts || s.verdicts.state === null && !s.verdicts.blocking;
5890
- var resultWord = (s) => setWord(s.verdicts);
5891
- var UNCHECKED_GATE = "Verdict not computed yet (computed on demand): --fail-on-worse checked nothing";
5892
- var uncheckedGate = (s) => notComputed(s) ? UNCHECKED_GATE : null;
6446
+ var UNCHECKED_GATE = `${NOT_COMPUTED_WORD}: --fail-on-worse checked nothing`;
6447
+ var uncheckedGate = (s) => notComputed(s) ? s.verdicts?.word && s.verdicts.word !== NOT_COMPUTED_WORD ? `${s.verdicts.word}: --fail-on-worse checked nothing` : UNCHECKED_GATE : null;
5893
6448
  var countsLine = (c) => countsWords(c);
5894
6449
  var noCounts = (c) => !c.worse && !c.warnings && !c.better && !c.noChange && !c.inconclusive;
5895
- var change = (metric, a, b) => {
6450
+ var change = (metric, a, b, bounds) => {
5896
6451
  if (a === null || b === null)
5897
6452
  return "";
5898
- const pct = a === 0 ? "" : ` ${b >= a ? "+" : "-"}${Math.round(Math.abs(b / a - 1) * 100)}%`;
5899
- return `${pct} (${fieldValueText(metric, a)} -> ${fieldValueText(metric, b)})`;
6453
+ const pct = a === 0 ? "" : ` ${atLeast(`${b >= a ? "+" : "-"}${Math.round(Math.abs(b / a - 1) * 100)}%`, bounds.change_is_bound)}`;
6454
+ return `${pct} (${atMost(fieldValueText(metric, a), bounds.baseline_is_bound)} -> ${fieldValueText(metric, b)})`;
5900
6455
  };
5901
6456
  function formatRunSet(s, top = 5) {
5902
6457
  const lines = [];
@@ -5926,11 +6481,12 @@ function formatRunSet(s, top = 5) {
5926
6481
  lines.push(...resultContextLines(sc.context).map((line) => ` ${line}`));
5927
6482
  if (heatNote(sc))
5928
6483
  lines.push(` ${heatNote(sc)}`);
6484
+ lines.push(...(sc.headline?.session_speed ?? []).map((line) => ` ${line}`));
5929
6485
  const allMoved = movedItems(v, sc).sort((a, b) => Number(b.state === "worse") - Number(a.state === "worse") || Number(a.grade === "warn") - Number(b.grade === "warn"));
5930
6486
  const moved = allMoved.slice(0, top);
5931
6487
  for (const i of moved) {
5932
6488
  const what = i.label_text ? `${i.step && i.step !== "*" ? `${placeLabel(i.step)}: ` : ""}${i.label_text}` : i.step ? `${placeLabel(i.step)}: ${fieldWord(i.metric)}` : plainMetric(i.metric);
5933
- lines.push(` ${stateWord(i.state === "worse" && i.grade === "warn" ? "warn" : i.state).padEnd(WORD_WIDTH)} ${what}${i.change_text ? `: ${i.change_text}` : change(i.metric, i.baseline, i.candidate)}`);
6489
+ lines.push(` ${stateWord(i.state === "worse" && i.grade === "warn" ? "warn" : i.state).padEnd(WORD_WIDTH)} ${what}${i.change_text ? `: ${i.change_text}` : change(i.metric, i.baseline, i.candidate, i)}`);
5934
6490
  }
5935
6491
  if (allMoved.length > moved.length)
5936
6492
  lines.push(` ${allMoved.length - moved.length} more`);
@@ -5947,35 +6503,12 @@ function formatRunSet(s, top = 5) {
5947
6503
  `);
5948
6504
  }
5949
6505
  function markdownRunSet(s, fallback) {
5950
- if (!s || !s.verdicts) {
6506
+ if (!s || !s.verdicts)
5951
6507
  return `### KeepSnappy
5952
6508
 
5953
6509
  Runs uploaded for \`${fallback.branch}\`. The result appears on the dashboard: [open KeepSnappy](${fallback.dashboardUrl})
5954
6510
  `;
5955
- }
5956
- const v = s.verdicts;
5957
- const heated = v.scenarios.flatMap((sc) => heatNote(sc) ?? []);
5958
- const contextLines = v.scenarios.flatMap((sc) => laneContextLines(sc.context, sc.scenario ?? "-", sc.device?.model ?? "-"));
5959
- const rows = v.scenarios.map((sc) => {
5960
- const c = movedCounts(sc);
5961
- return `| ${sc.scenario ?? "-"} | ${sc.device ? sc.device.model : "-"} | ${scenarioWord(sc)}${sc.low_data ? ` (${FEW_RUNS})` : ""} | ${noCounts(c) ? "–" : `${c.worse} / ${c.warnings} / ${c.better} / ${c.noChange} / ${c.inconclusive}`} | ${sc.dashboard_url ? `[open](${sc.dashboard_url})` : "-"} |`;
5962
- });
5963
- return [
5964
- `### KeepSnappy: ${setWord(v)}`,
5965
- "",
5966
- ...setupLine(v.headline) ? [`${setupLine(v.headline)}.`, ""] : [],
5967
- `\`${s.branch}\`${s.commit_sha ? ` @ \`${s.commit_sha.slice(0, 7)}\`` : ""}, ${s.runs.valid}/${s.runs.total} runs used.${v.low_data ? ` ${FEW_RUNS}.` : ""}`,
5968
- "",
5969
- `| Journey | Device | Result | ${STATE_WORDS.worse} / ${STATE_WORDS.warning} / ${STATE_WORDS.better} / ${STATE_WORDS["no-change"]} / ${STATE_WORDS["cant-tell"]} | Details |`,
5970
- "|---|---|---|---|---|",
5971
- ...rows,
5972
- ...contextLines.length ? ["", ...contextLines] : [],
5973
- ...heated.length ? ["", ...heated.map((note) => `${note}.`)] : [],
5974
- "",
5975
- `[Open the dashboard](${s.dashboard_url})`,
5976
- ""
5977
- ].join(`
5978
- `);
6511
+ return isPending(s) ? pendingMarkdown(s.branch, linkOf(s) ?? s.dashboard_url) : resultMarkdown(s);
5979
6512
  }
5980
6513
 
5981
6514
  // apps/agent/src/run.ts
@@ -9351,7 +9884,7 @@ function parseArms(spec) {
9351
9884
  return spec.split(",").map((part) => {
9352
9885
  const eq = part.indexOf("=");
9353
9886
  if (eq <= 0)
9354
- throw new Error(`bad --arms entry "${part}", expected NAME=<app-path-or-bundle-id>`);
9887
+ throw new ConfigError([`bad --arms entry "${part}", expected NAME=<app-path-or-bundle-id>`]);
9355
9888
  return { name: part.slice(0, eq).trim(), target: part.slice(eq + 1).trim() };
9356
9889
  });
9357
9890
  }
@@ -9361,10 +9894,10 @@ function parseArmEnv(specs, arms) {
9361
9894
  for (const spec of specs ?? []) {
9362
9895
  const m = /^([^:=]+):([A-Za-z_][A-Za-z0-9_]*)=(.*)$/s.exec(spec);
9363
9896
  if (!m)
9364
- throw new Error(`bad --arm-env "${spec}", expected ARM:KEY=VALUE`);
9897
+ throw new ConfigError([`bad --arm-env "${spec}", expected ARM:KEY=VALUE`]);
9365
9898
  const [, arm = "", key = "", value = ""] = m;
9366
9899
  if (!names.has(arm))
9367
- throw new Error(`--arm-env "${spec}": no arm named ${arm} (arms: ${[...names].join(", ")})`);
9900
+ throw new ConfigError([`--arm-env "${spec}": no arm named ${arm} (arms: ${[...names].join(", ")})`]);
9368
9901
  const e = env.get(arm) ?? {};
9369
9902
  e[key] = value;
9370
9903
  if (!key.startsWith("MAESTRO_"))
@@ -9375,12 +9908,15 @@ function parseArmEnv(specs, arms) {
9375
9908
  }
9376
9909
 
9377
9910
  // apps/agent/src/runner.ts
9911
+ var TEST_ERROR = "test-error";
9912
+ var TEST_FAILED = "test-failed";
9913
+ var failedTest = (reason) => reason.startsWith(`${TEST_FAILED}:`) || reason.startsWith(`${TEST_ERROR}:`);
9378
9914
  var isBuildPath = (target) => !!target && /\.(app|ipa)\/?$/.test(target);
9379
9915
  async function measure(deps, source, installer, opts) {
9380
9916
  const { fs, clock, spawner, log } = deps;
9381
9917
  const flows = opts.flows?.length ? opts.flows : [{ name: undefined, command: opts.testCommand }];
9382
9918
  if (flows.some((f) => !f.command[0]))
9383
- throw new Error("no test command given (put it after `--`)");
9919
+ throw new ConfigError(["no test command given (put it after `--`)"]);
9384
9920
  await fs.mkdir(opts.outDir);
9385
9921
  const runsPath = join7(opts.outDir, "runs.ndjson");
9386
9922
  await fs.writeFile(runsPath, "");
@@ -9421,9 +9957,9 @@ async function measure(deps, source, installer, opts) {
9421
9957
  --- stderr ---
9422
9958
  ${r.stderr}` : ""}`);
9423
9959
  if (r.error)
9424
- reason = `test-error: ${r.error}`;
9960
+ reason = `${TEST_ERROR}: ${r.error}`;
9425
9961
  else if (r.code !== 0)
9426
- reason = `test-failed: exit ${r.code}`;
9962
+ reason = `${TEST_FAILED}: exit ${r.code}`;
9427
9963
  }
9428
9964
  const t1 = clock.now();
9429
9965
  let windows = [];
@@ -9521,7 +10057,7 @@ async function replayRuns(deps, sources, outDir, scenario) {
9521
10057
  const runs = [];
9522
10058
  for (const src of sources) {
9523
10059
  const text = await deps.fs.readFile(join8(src.dir, "runs.ndjson")).catch(() => {
9524
- throw new Error(`--replay ${src.dir}: no runs.ndjson (not a keepsnappy out dir)`);
10060
+ throw new ConfigError([`--replay ${src.dir}: no runs.ndjson (not a keepsnappy out dir)`]);
9525
10061
  });
9526
10062
  for (const line of text.split(`
9527
10063
  `)) {
@@ -9541,7 +10077,7 @@ async function replayRuns(deps, sources, outDir, scenario) {
9541
10077
  }
9542
10078
  }
9543
10079
  if (runs.length === 0)
9544
- throw new Error(`--replay: no valid runs in ${sources.map((x) => x.dir + (x.arm ? `:${x.arm}` : "")).join(", ")}`);
10080
+ throw new ConfigError([`--replay: no valid runs in ${sources.map((x) => x.dir + (x.arm ? `:${x.arm}` : "")).join(", ")}`]);
9545
10081
  await deps.fs.writeFile(join8(outDir, "runs.ndjson"), runs.map((r) => JSON.stringify(r)).join(`
9546
10082
  `) + `
9547
10083
  `);
@@ -9555,7 +10091,7 @@ var DEFAULT_API_URL = "https://keepsnappy-api-922918702810.europe-west1.run.app"
9555
10091
  // apps/agent/src/journeys.ts
9556
10092
  import { isAbsolute, relative, resolve as resolve2, sep } from "node:path";
9557
10093
  var charCount = (s) => [...s].length;
9558
- function clip(s, max) {
10094
+ function clip2(s, max) {
9559
10095
  if (charCount(s) <= max)
9560
10096
  return s;
9561
10097
  const parts = typeof Intl.Segmenter === "function" ? Array.from(new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(s), (x) => x.segment) : [...s];
@@ -9589,7 +10125,7 @@ function flowHeader(text) {
9589
10125
  para.push(t);
9590
10126
  }
9591
10127
  const description = para.join(" ").replace(/\s+/g, " ").trim();
9592
- return { ...title ? { title: clip(title, TITLE_MAX) } : {}, ...description ? { description: clip(description, DESCRIPTION_MAX) } : {} };
10128
+ return { ...title ? { title: clip2(title, TITLE_MAX) } : {}, ...description ? { description: clip2(description, DESCRIPTION_MAX) } : {} };
9593
10129
  }
9594
10130
  async function repoRoot(deps) {
9595
10131
  const r = await deps.spawner.run("git", ["rev-parse", "--show-toplevel"]).catch(() => null);
@@ -9643,10 +10179,11 @@ function baseRefs(env) {
9643
10179
  return target ? [`origin/${target}`] : ["origin/main", "main"];
9644
10180
  }
9645
10181
  var SHA = /^[0-9a-f]{40,64}$/;
10182
+ var onMain = (env, branch) => !!branch && baseRefs(env).some((r) => !r.startsWith("-") && r.replace(REMOTE, "") === branch);
9646
10183
  var FIX = "fetch main and the commit, in CI with fetch-depth: 0, or pass --base-commit SHA";
9647
10184
  async function defaultBaseCommit(deps, env, o) {
9648
10185
  const refs = baseRefs(env).filter((r) => !r.startsWith("-"));
9649
- if (o.branch && refs.some((r) => r.replace(REMOTE, "") === o.branch))
10186
+ if (onMain(env, o.branch))
9650
10187
  return {};
9651
10188
  const tip = o.commit?.trim();
9652
10189
  if (!tip)
@@ -9682,6 +10219,34 @@ async function commitFields(deps, env, shas) {
9682
10219
  const pr = await pullRequestInfo(deps, env);
9683
10220
  return { ...commit ? { commit } : {}, ...base ? { base_commit: base } : {}, ...pr ? { pull_request: { ...pr } } : {} };
9684
10221
  }
10222
+ var MAIN_MERGES_LIMIT = 200;
10223
+ var SQUASH = /^(.*?)\s*\(#(\d+)\)$/;
10224
+ var MERGE = /^Merge pull request #(\d+) from \S+$/;
10225
+ function mergeOf(sha, subject, body) {
10226
+ const merge = MERGE.exec(subject);
10227
+ const squash = merge ? null : SQUASH.exec(subject);
10228
+ const number = Number(merge?.[1] ?? squash?.[2] ?? 0);
10229
+ const title = (merge ? body.split(`
10230
+ `).find((l) => l.trim())?.trim() : squash?.[1]) || subject.trim() || sha.slice(0, 7);
10231
+ return { sha, ...number > 0 ? { number } : {}, title: Array.from(title).slice(0, 300).join("") };
10232
+ }
10233
+ async function mainMerges(deps, env, o) {
10234
+ if (!onMain(env, o.branch))
10235
+ return {};
10236
+ const tip = o.commit?.trim();
10237
+ if (!tip || tip.startsWith("-"))
10238
+ return { warning: "main merges not recorded: no commit is recorded for the run set (pass --commit, or run in GitHub Actions)" };
10239
+ const r = await deps.spawner.run("git", ["log", "--first-parent", `--max-count=${MAIN_MERGES_LIMIT}`, "--format=%H%x1f%s%x1f%b%x1e", tip, "--"], { timeoutMs: 1e4 }).catch(() => null);
10240
+ const merges = r && r.code === 0 ? r.stdout.split("\x1E").map((x) => x.replace(/^\n/, "").split("\x1F")).filter(([sha]) => sha && SHA.test(sha)).map(([sha, s, b]) => mergeOf(sha, s ?? "", b ?? "")) : [];
10241
+ if (!merges.length)
10242
+ return { warning: `main merges not recorded: git log ${tip.slice(0, 12)} failed (fetch main and the commit, in CI with fetch-depth: 0)` };
10243
+ if (merges.length < MAIN_MERGES_LIMIT) {
10244
+ const shallow = await deps.spawner.run("git", ["rev-parse", "--is-shallow-repository"], { timeoutMs: 5000 }).catch(() => null);
10245
+ if (shallow?.code === 0 && shallow.stdout.trim() === "true")
10246
+ return { merges, warning: `main merges stop at the clone's depth (${merges.length}): fetch main with fetch-depth: 0 for the PRs of main changes` };
10247
+ }
10248
+ return { merges };
10249
+ }
9685
10250
 
9686
10251
  // apps/agent/src/run-command.ts
9687
10252
  var EXIT = { ok: 0, infra: 1, config: 2, worse: 3 };
@@ -9727,7 +10292,7 @@ async function runCommand(deps, o, positionals, rt = { env: process.env, print:
9727
10292
  api = { url, key, ...rt.fetch ? { fetch: rt.fetch } : {} };
9728
10293
  }
9729
10294
  if (o.wait && !o.upload)
9730
- throw new ConfigError(["--wait needs --upload (the verdict is computed by the server)"]);
10295
+ throw new ConfigError(["--wait needs --upload (it waits until the server has processed the uploaded runs)"]);
9731
10296
  if (o.wait && !git.branch)
9732
10297
  throw new ConfigError(["--wait needs the branch: pass --branch, or run in GitHub Actions (GITHUB_HEAD_REF / GITHUB_REF_NAME)"]);
9733
10298
  if (o["fail-on-worse"] && !o.wait)
@@ -9746,11 +10311,15 @@ async function runCommand(deps, o, positionals, rt = { env: process.env, print:
9746
10311
  let runSet;
9747
10312
  if (api && git.branch) {
9748
10313
  const base = await baseCommitFor(deps, rt.env, { explicit: o["base-commit"], branch: git.branch, commit: git.commit }, rt.print);
10314
+ const main = await mainMerges(deps, rt.env, { branch: git.branch, commit: git.commit });
10315
+ if (main.warning)
10316
+ rt.print(main.warning);
9749
10317
  runSet = await createRunSet(api, {
9750
10318
  branch: git.branch,
9751
10319
  ...git.commit ? { commit_sha: git.commit } : {},
9752
10320
  ...base ? { base_commit_sha: base } : {},
9753
10321
  ...await commitFields(deps, rt.env, { commit: git.commit, base }),
10322
+ ...main.merges ? { merges: main.merges } : {},
9754
10323
  device: { ...device, provider },
9755
10324
  scenarios: flows.map((f) => f.name),
9756
10325
  journeys: await journeysFor(flows, { readFile: (p) => deps.fs.readFile(p), root: await repoRoot(deps) }),
@@ -9792,8 +10361,14 @@ ${outcome.summary}`);
9792
10361
  const info = { device, ...git.branch ? { branch: git.branch } : {}, ...git.commit ? { commit: git.commit } : {} };
9793
10362
  await deps.fs.writeFile(join9(out, RUN_INFO), `${JSON.stringify({ ...info, ...runSet ? { run_set_id: runSet.run_set_id } : {} }, null, 2)}
9794
10363
  `);
9795
- if (!api)
10364
+ if (!api) {
10365
+ if (!runs.some((run) => run.valid)) {
10366
+ const reasons = [...new Set(runs.map((run) => run.invalid_reason).filter(Boolean))];
10367
+ 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(", ")})` : ""}`}.`);
10368
+ return EXIT.infra;
10369
+ }
9796
10370
  return EXIT.ok;
10371
+ }
9797
10372
  const up = await uploadRuns(deps, out, runs, {
9798
10373
  ingestUrl: api.url,
9799
10374
  key: api.key,
@@ -9808,7 +10383,9 @@ ${outcome.summary}`);
9808
10383
  if (up.uploaded === 0) {
9809
10384
  if (runSet)
9810
10385
  await completeRunSet(api, runSet.run_set_id, "on-read").catch(infra("no run was uploaded, and the run set could not be closed"));
9811
- rt.print("Blocked: no run sent any data. Check that the build carries the SDK and that the app reaches the API.");
10386
+ const failed = up.skipped.length > 0 && up.skipped.every((x) => failedTest(x.why));
10387
+ const why = [...new Set(up.skipped.map((x) => x.why))].join(", ");
10388
+ rt.print(`${BLOCKED_WORD}: no run sent any data. ${failed ? `Every run failed its test command (${why}); see test.log in each run folder of ${out}.` : "Check that the build carries the SDK and that the app reaches the API."}`);
9812
10389
  if (runSet)
9813
10390
  await writeResult(deps, out, rt, runSet, null, git.branch, true);
9814
10391
  return EXIT.infra;
@@ -9817,7 +10394,7 @@ ${outcome.summary}`);
9817
10394
  return EXIT.ok;
9818
10395
  let summary = null;
9819
10396
  if (o.wait) {
9820
- summary = await completeRunSet(api, runSet.run_set_id, "inline").catch(infra("the runs are uploaded, but the verdict could not be computed"));
10397
+ summary = await completeRunSet(api, runSet.run_set_id, "inline").catch(infra("the runs are uploaded, but the run set could not be closed"));
9821
10398
  rt.print(`
9822
10399
  ${formatRunSet(summary)}`);
9823
10400
  } else {
@@ -9854,10 +10431,7 @@ async function writeResult(deps, out, rt, runSet, summary, branch, blocked = fal
9854
10431
  `);
9855
10432
  const step = rt.env.GITHUB_STEP_SUMMARY;
9856
10433
  if (step)
9857
- await deps.fs.appendFile(step, blocked ? `### KeepSnappy: Blocked
9858
-
9859
- No run sent any data. [Open the dashboard](${url})
9860
- ` : markdownRunSet(summary, { dashboardUrl: url, branch }));
10434
+ await deps.fs.appendFile(step, blocked ? noResultMarkdown(branch, "No run sent any data.", url) : markdownRunSet(summary, { dashboardUrl: url, branch }));
9861
10435
  }
9862
10436
 
9863
10437
  // apps/agent/src/doctor.ts
@@ -10319,7 +10893,7 @@ import { existsSync, readFileSync, statSync } from "node:fs";
10319
10893
  import { dirname as dirname2, join as join11, relative as relative2, resolve as resolve4 } from "node:path";
10320
10894
  import { fileURLToPath } from "node:url";
10321
10895
  function driverVersion() {
10322
- const built = "0.1.0-alpha.17";
10896
+ const built = "0.1.0-alpha.19";
10323
10897
  if (built)
10324
10898
  return built;
10325
10899
  let dir = dirname2(fileURLToPath(import.meta.url));
@@ -10374,6 +10948,600 @@ var SDK_FLUSH_WORST_CASE_MS = 315000;
10374
10948
  var FLUSH_MARGIN_MS = 30000;
10375
10949
  var FLUSH_WAIT_MS = SDK_FLUSH_WORST_CASE_MS + FLUSH_MARGIN_MS;
10376
10950
  var FLUSH_STATUSES = new Set(["ok", "error", "off"]);
10951
+ // packages/verdict-core/dist/src/window-count.js
10952
+ var WINDOW_DEPENDENT_JOURNEY_MEASURES = ["sampler.cpu_ms", "sampler.frames.longest_gap_ms"];
10953
+ var OFF = new WeakSet;
10954
+ // packages/verdict-core/dist/src/clauses.js
10955
+ var row = (id, what, unit, cmp, ruling, uncalibrated, value) => ({ id, what, unit, cmp, ruling, uncalibrated, value });
10956
+ var NO_RULING = "no ruling: a starting value of the deciding config (L2 audit F-13)";
10957
+ var FLOOR = { rel: 0.3, abs: 5 };
10958
+ var COMPONENT_WARN_MS = { update: 5, work: 10 };
10959
+ var CLAUSES = {
10960
+ "G-MINRUNS": row("G-MINRUNS", `Valid runs a side needs to be compared at all; fewer is "Can't tell: too few runs".`, "runs", "≥", "no ruling for the deciding config (L2 audit G-MINRUNS)", true, { runs: 2 }),
10961
+ "B-BASELINE": row("B-BASELINE", "The baseline is main's newest sessions on the same phone, journey and renderer.", "sessions", "≥", "REQ-VER-33 (Q4); the number is a starting value", true, { sessions: 3 }),
10962
+ "M-LEVELSHIFT": row("M-LEVELSHIFT", "A change on main is confirmed when this many following main sessions agree.", "sessions", "≥", "REQ-VER-33, plan 63 §3 (controller 2026-10-17)", false, { confirmSessions: 2 }),
10963
+ "S-UNCERTAIN": row("S-UNCERTAIN", "A worse result is uncertain and does not block while main has fewer sessions than this.", "sessions", "≥", 'REQ-VER-38, Arek 2026-10-16 "Nie blokuj przy małej bazie"', false, { belowMainSessions: 3 }),
10964
+ "G-JUDGE-FROM": row("G-JUDGE-FROM", "A phone or simulator is judged only with at least this many main sessions.", "sessions", "≥", "REQ-VER-41, Arek 2026-10-18", false, { mainSessions: 3 }),
10965
+ "W-WORKKIND": row("W-WORKKIND", "The share of render time spent mounting from which a place is an in-screen mount, not an update.", "share", "≥", "REQ-MET-25; TraceSift MOUNT_DOMINANT_SHARE", true, { mountShareAtLeast: 0.8 }),
10966
+ "W-WINDOW-COUNT": row("W-WINDOW-COUNT", "A PR run that opened fewer windows on a screen than this share of main's median there leaves that screen (its taps, scrolls and steps) and the journey's window-dependent totals out of its comparisons.", "share", "≥", "plan 51 D2 floor (90 % of the usual window count), Arek 2026-10-22 ~07:20, per screen (plan 79 S8)", false, { below: 0.9 }),
10967
+ "T-FRAME": row("T-FRAME", "One frame, the unit of every frame limit, the same on every screen (a 120 Hz phone is not held to 8.3 ms).", "ms", ">", "REQ-MET-23, Arek 2026-10-16", false, { ms: LIMIT_FRAME_MS }),
10968
+ "C-DETAIL-MS": row("C-DETAIL-MS", "A component below this keeps key and ms only and is not listed as New or Removed.", "ms", "≥", "plan 79 S9 r2, Arek 2026-10-23 13:20; half the lowest component rule floor (judged and evidence)", false, { ms: Math.min(FLOOR.abs, COMPONENT_WARN_MS.update, COMPONENT_WARN_MS.work) / 2 }),
10969
+ "T-WARN-MS": row("T-WARN-MS", "A judged change counts as Slightly worse past at least this many ms (an update, or an entry / in-screen mount).", "ms", ">", "REQ-MET-26 (Arek 2026-10-16 ~14:30, ~17:30); followups [RULED 2026-10-18 ~09:40]", false, COMPONENT_WARN_MS),
10970
+ "TIER-ERROR": row("TIER-ERROR", "A judged change is Worse past at least this many ms (an update, or an entry / in-screen mount).", "ms", ">", "REQ-MET-26 (Arek 2026-10-16 ~17:30); over the 100 ms budget alone is only a warning (Arek 2026-10-17)", false, { update: 15, work: 30 }),
10971
+ "S-BETTER": row("S-BETTER", "Nothing worse and a judged improvement past the Worse line measured from main reads Better; the same magnitude must make a regression Worse. Applies to timing noise curves and memory thresholds.", "", null, "REQ-VER-47; Arek 2026-10-22 ~15:35, option 1 (standing-rulings.md): Better only past the Worse line; controller review ruling 2026-10-22: measure from main using the same timing noise and memory thresholds as Worse; no new number", false, { from: "error-tier" }),
10972
+ "T-WARN-NOISE": row("T-WARN-NOISE", "No-change noise curve per judged measure: Slightly worse past c x k95 x sqrt(main), Worse past c x k99 x sqrt(main).", "ms", ">", 'Arek 2026-10-16 "Ok" (the relative part adapts to size), 2026-10-18 ~09:40 (k95 warns, k99 errs); docs/stats/noise/2026-10-16-noise-fit.md "Result to encode"', false, {
10973
+ render: { c: 0.9244, k95: 1.5678, k99: 2.2255 },
10974
+ commit: { c: 0.8326, k95: 1.7675, k99: 2.8108 },
10975
+ component: { c: 0.6481, k95: 1.8075, k99: 2.6648 }
10976
+ }),
10977
+ "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, {
10978
+ jank: { c: 0.9159, k95: 2.5757, k99: 3.2072, zeroWarn: 10.38, zeroError: 12.92 }
10979
+ }),
10980
+ "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 }),
10981
+ "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 } }),
10982
+ "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 } }),
10983
+ "E-RENDER-UPPER": row("E-RENDER-UPPER", "Timer-carried render time is evidence past max(floor, % of main).", "ms", ">", "REQ-MEAS-32 option A (Arek 2026-10-17); the number has no ruling", true, { render_upper: FLOOR }),
10984
+ "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 }),
10985
+ "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 }),
10986
+ "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 }),
10987
+ "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 }),
10988
+ "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 } }),
10989
+ "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 } }),
10990
+ "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 } }),
10991
+ "E-MEMORY-JOURNEY": row("E-MEMORY-JOURNEY", "Memory at launch, end growth and peak growth are evidence past max(2 MB, +8 % of main).", "MB", ">", NO_RULING, true, { memory_launch: { rel: 0.08, abs: 2 }, memory_end_growth: { rel: 0.08, abs: 2 }, memory_peak_growth: { rel: 0.08, abs: 2 } }),
10992
+ "E-CPU-JOURNEY": row("E-CPU-JOURNEY", "Whole-journey CPU is evidence past +5 % of main.", "ms (relative part only)", ">", 'no ruling: "a +10 % whole-app CPU change must show as evidence"', true, { cpu: { rel: 0.05 } }),
10993
+ "E-WINDOW-LOAD": row("E-WINDOW-LOAD", "Window CPU, long tasks, GC time, requests and JS CPU are evidence past max(floor, % of main).", "ms (requests: count)", ">", NO_RULING, true, {
10994
+ window_cpu: { rel: 0.5, abs: 20 },
10995
+ long_tasks: { rel: 0.3, abs: 50 },
10996
+ gc: { rel: 0.5, abs: 2 },
10997
+ requests: { rel: 0.5, abs: 1 },
10998
+ js_cpu: { rel: 0.5, abs: 5 }
10999
+ }),
11000
+ "P-SLOW-COMMIT": row("P-SLOW-COMMIT", "Single problem: the slowest commit over this many frames in every PR run, on phones.", "frames", ">", "REQ-MET-27, Arek 2026-10-16 (a commit over one 60 Hz frame drops below 60 fps)", true, { frames: 1 }),
11001
+ "P-HEAVY-COMPONENT": row("P-HEAVY-COMPONENT", "Single problem: a component's self time over this many frames in every PR run, on phones.", "frames", ">", "REQ-MET-27, Arek 2026-10-16", true, { frames: 1 }),
11002
+ "P-HEAVY-ENTRY": row("P-HEAVY-ENTRY", "Single problem: React render time over this budget in every PR run at an entry or in-screen mount; also the budget of both kinds of work.", "ms", ">", "REQ-MET-25 ruling d (about 100 ms)", true, { ms: 100 }),
11003
+ "P-BUDGETS": row("P-BUDGETS", "The budget of an update, shown with a single problem, in frames.", "frames", ">", "REQ-MET-25 ruling d", true, { updateFrames: 1 }),
11004
+ "U-NOISY": row("U-NOISY", "A place is unstable on main above this ratio of spread to allowed, and stays so down to `below`; from this many main runs in this many sessions.", "ratio", ">", "REQ-MET-29, Arek 2026-10-18 ~12:00-13:20; thresholds provisional", false, { above: 1, below: 0.75, minMainRuns: 6, minSessions: 3 })
11005
+ };
11006
+ var CLAUSE_LIST = Object.values(CLAUSES);
11007
+ // packages/verdict-core/dist/src/config.js
11008
+ var WINDOWS = ["screen", "press", "drag", "step", "other"];
11009
+ var STEP = ["step"];
11010
+ var JUDGED_WINDOWS = WINDOWS.filter((k) => !STEP.includes(k));
11011
+ var BACKGROUND = ["background"];
11012
+ var LAUNCH = ["launch"];
11013
+ var SHOWN2 = [...WINDOWS, ...LAUNCH];
11014
+ var REACT_PLACES = [...SHOWN2, ...BACKGROUND];
11015
+ var JOURNEY = ["journey"];
11016
+ var DEVICE = ["device"];
11017
+ var ANY_DEVICE = ["device", "sim"];
11018
+ var ENTRY_BUDGET_MS = 100;
11019
+ var UNCERTAIN_BELOW_MAIN_SESSIONS = 3;
11020
+ var GUESS = (from) => ({ uncalibrated: true, source: `${from}; starting value carried from the plan 58 table (today's warn value), plan 57 calibrates it` });
11021
+ var evidenceRule = (id, on, places, threshold, from, more = {}) => ({
11022
+ id,
11023
+ role: "evidence",
11024
+ on,
11025
+ places,
11026
+ devices: DEVICE,
11027
+ threshold,
11028
+ ...GUESS(from),
11029
+ ...more
11030
+ });
11031
+ var MEMORY_MEASURES = Object.keys(MEASURES).filter((id) => MEASURES[id].unit.startsWith("MB"));
11032
+ var REACT_MEASURES = [
11033
+ "commit_max_ms",
11034
+ "render_ms",
11035
+ "commits",
11036
+ "renders",
11037
+ "mounts",
11038
+ "commits_over_budget",
11039
+ "mount_share",
11040
+ "self_ms_top",
11041
+ "component_ms",
11042
+ "dimension:component",
11043
+ "dimension:updater",
11044
+ "jank_ms"
11045
+ ];
11046
+ var WINDOW_MODEL_AFFECTS = [
11047
+ "commits",
11048
+ "renders",
11049
+ "mounts",
11050
+ "alloc_mb",
11051
+ "gc_count",
11052
+ "gc_ms",
11053
+ "render_ms",
11054
+ "js_cpu_ms",
11055
+ "cpu_ms",
11056
+ "commit_max_ms",
11057
+ "commits_over_budget",
11058
+ "longtask_ms",
11059
+ "longtask_max_ms",
11060
+ "touch_max_ms",
11061
+ "net_requests",
11062
+ "net_duplicates",
11063
+ "dur_ms",
11064
+ "sampler_frames_missed",
11065
+ "sampler_footprint_delta_mb",
11066
+ "memory_left_mb",
11067
+ "retained_mb_per_cycle",
11068
+ "dimension:component",
11069
+ "dimension:updater",
11070
+ "jank_ms"
11071
+ ];
11072
+ var MEASURES_AT = (kind) => [...Object.keys(MEASURES).filter((id) => MEASURES[id].at.includes(kind)), "dimension:component", "dimension:updater"];
11073
+ var SPLIT_AFFECTS = [
11074
+ ...REACT_MEASURES,
11075
+ ...Object.keys(MEASURES).filter((id) => /^(commits_over_|slow_commit_|mount_share_|unsettled_commit_)/.test(id))
11076
+ ];
11077
+ var GC_STEP_AFFECTS = [
11078
+ "gc_heap_before_mb",
11079
+ "gc_heap_kept_mb",
11080
+ "gc_heap_garbage_mb",
11081
+ "gc_memory_before_mb",
11082
+ "gc_memory_after_mb",
11083
+ "gc_memory_step_added_mb",
11084
+ "gc_memory_kept_mb",
11085
+ "gc_command_ms",
11086
+ "gc_queue_ms"
11087
+ ];
11088
+ var SAMPLER_EDGE_AFFECTS = [
11089
+ "cpu_ms",
11090
+ "sampler.cpu_ms",
11091
+ "sampler_frames_missed",
11092
+ "sampler_longest_gap_ms",
11093
+ "sampler.frames.longest_gap_ms",
11094
+ "sampler.frames.missed",
11095
+ "sampler.frames.presented",
11096
+ ...MEMORY_MEASURES.filter((m) => !GC_STEP_AFFECTS.includes(m))
11097
+ ];
11098
+ var DRAG_TIME_AND_CPU = ["dur_ms", "action_span_ms", "next_input_gap_ms", "action_uncovered_ms", "cpu_ms", "main_thread_ms", "sampler_cpu_cores", "js_cpu_ms", "js_thread_ms", "js_thread_app_ms"];
11099
+ var LOADED_MEMORY = Object.keys(MEASURES).filter((id) => /^(mem|sampler\.memory|ri\.memory)\./.test(id) || id === "memory_left_mb");
11100
+ var END_READ_DECLARES = [{ affects: ["mem.retained_growth_mb", "mem.end_after_gc_mb"], places: ["journey"] }];
11101
+ var END_READ_SOURCE = `plan 78 G4/G5, Arek 2026-10-21 (D5, "po GC powinniśmy robić manualny odczyt pamięci"): right after the end GC the driver asks the SDK for one memory reading (derivation 23, docs/design/endgc-manual-memory-read.md); memory kept is that reading minus launch, not the GC's own post record. REQ-FARM-35`;
11102
+ var PLACE_KEY_KINDS = ["screen", "launch", "press", "drag"];
11103
+ var SDK_A17_PLACES = ["screen", "press", "drag", "step"];
11104
+ var SDK_RELEASES = [
11105
+ {
11106
+ version: "0.1.0-alpha.9",
11107
+ previous: "0.1.0-alpha.8",
11108
+ declares: [{ affects: WINDOW_MODEL_AFFECTS, places: ["press", "drag"] }, { affects: ["mount_share", "mount_share_min", "mount_share_max"] }],
11109
+ uncalibrated: false,
11110
+ source: 'packages/react-native/CHANGELOG.md "window model 2" and "mountSelfMs"; git merge-base --is-ancestor 988945d / 5995729 v0.1.0-alpha.9 (yes), v0.1.0-alpha.8 (no)'
11111
+ },
11112
+ {
11113
+ version: "0.1.0-alpha.10",
11114
+ previous: "0.1.0-alpha.9",
11115
+ declares: [{ affects: SAMPLER_EDGE_AFFECTS }],
11116
+ uncalibrated: false,
11117
+ source: 'packages/react-native/CHANGELOG.md 0.1.0-alpha.10 "a sample at every window edge"; git merge-base --is-ancestor 7331c8f v0.1.0-alpha.10 (yes), v0.1.0-alpha.9 (no)'
11118
+ },
11119
+ {
11120
+ version: "0.1.0-alpha.11",
11121
+ previous: "0.1.0-alpha.10",
11122
+ declares: [
11123
+ { affects: MEASURES_AT("screen"), places: ["screen"] },
11124
+ { affects: SPLIT_AFFECTS, places: WINDOWS },
11125
+ { affects: MEASURES_AT("launch"), places: LAUNCH },
11126
+ { affects: GC_STEP_AFFECTS, places: ["step"] }
11127
+ ],
11128
+ uncalibrated: false,
11129
+ source: `pending release: window model 3, screen windows end at the next action with no 3 s cap (REQ-MEAS-12, packages/react-native/CHANGELOG.md; commits 741c36a and 62c0107 on main, not in v0.1.0-alpha.10); work split by its cause, every window's React measures are its judged part (REQ-MEAS-32, CHANGELOG "work is split by its cause", branch feat/action-causes-d16); the launch screen starts when the app starts a surface (REQ-MEAS-22, same branch); screen windows start at the action that caused them, with no time limits (REQ-MEAS-28, same branch); the GC command before a declared step adds the gc_* step rows (docs/plans/gc-before-action.md, branch feat/gc-before-action)`
11130
+ },
11131
+ {
11132
+ version: "0.1.0-alpha.12",
11133
+ previous: "0.1.0-alpha.11",
11134
+ declares: [{ affects: [...new Set([...MEASURES_AT("press"), ...MEASURES_AT("drag")])], places: ["press", "drag"] }],
11135
+ uncalibrated: false,
11136
+ source: 'packages/react-native/CHANGELOG.md 0.1.0-alpha.12 "Drag names that hold across runs, and flings on a moving list": back-to-back flings on a moving list are drags, not presses (`press:<row>` becomes `drag:<list>`), and generated list ids are no longer names; those press and drag places are not comparable across the change. Arek 2026-10-21 (A/A "Slightly worse" decision, Q-10): alpha.12 declares this rename; nothing else in it changes a measure (REQ-FARM-35).',
11137
+ renamed: { places: ["press", "drag"] }
11138
+ },
11139
+ {
11140
+ version: "0.1.0-alpha.13",
11141
+ previous: "0.1.0-alpha.12",
11142
+ declares: "nothing",
11143
+ uncalibrated: false,
11144
+ source: 'KeepSnappy native module, capture protocol and artifact naming; measurement calculations unchanged from alpha.12 (REQ-FARM-35, Arek 2026-10-19: declared "changes nothing"). Requires rebuilt native clients and coordinated server migration (docs/keepsnappy-migration.md).'
11145
+ },
11146
+ {
11147
+ version: "0.1.0-alpha.14",
11148
+ previous: "0.1.0-alpha.13",
11149
+ declares: END_READ_DECLARES,
11150
+ uncalibrated: false,
11151
+ source: `SDK answers the driver's memread command with one sample (reason endgc-read:<n>); ${END_READ_SOURCE}. Nothing else since alpha.13 changes a measure: the flush always ends and is acknowledged (#140), local builds carry +local (#147), each "Not an instrument change" in packages/react-native/CHANGELOG.md`
11152
+ },
11153
+ {
11154
+ version: "0.1.0-alpha.15",
11155
+ previous: "0.1.0-alpha.14",
11156
+ declares: "nothing",
11157
+ uncalibrated: false,
11158
+ source: "Shared release version only: git log v0.1.0-alpha.14..main -- packages/react-native is empty; SDK capture and measurement calculations are unchanged from alpha.14 (REQ-FARM-35)."
11159
+ },
11160
+ {
11161
+ version: "0.1.0-alpha.16",
11162
+ previous: "0.1.0-alpha.15",
11163
+ declares: [
11164
+ { affects: ["dimension:component", "dimension:updater", "self_ms_top", "component_ms"] },
11165
+ { affects: [...new Set(PLACE_KEY_KINDS.flatMap((k) => MEASURES_AT(k)))], places: PLACE_KEY_KINDS }
11166
+ ],
11167
+ renamed: { places: PLACE_KEY_KINDS, dimensions: ["component", "updater"] },
11168
+ uncalibrated: false,
11169
+ source: "plan 79 (docs/plans/79-ui-change-flexibility.md): client component keys with library time rolled up; places keyed by client components (D4, Arek 2026-10-21 ~19:15 and 2026-10-22 ~19:20); S9: every client component retained by value, compact detail below C-DETAIL-MS, SDK omission and derivation-26 completeness"
11170
+ },
11171
+ {
11172
+ version: "0.1.0-alpha.17",
11173
+ previous: "0.1.0-alpha.16",
11174
+ declares: [
11175
+ { affects: [...new Set(SDK_A17_PLACES.flatMap((k) => MEASURES_AT(k)))], places: SDK_A17_PLACES },
11176
+ { affects: [...WINDOW_DEPENDENT_JOURNEY_MEASURES], places: ["journey"] }
11177
+ ],
11178
+ renamed: { places: ["screen", "press", "drag"] },
11179
+ uncalibrated: false,
11180
+ source: "#277 (plan 74 step 4 SDK review): screen windows end at every touch delivered while open; late-mounting screens keep their component key (3-commit cap gone), so screen keys and the press/drag keys on them change; the run commit stream is cut at the flush, not inside a window (window times of long journeys and journey CPU change); a gesture whose touchEnd never reached JS moves press → drag; a late press takes its own open sample (press sampler measures). Flush answers lost data as error (validity only, no measure). Launch and journey memory stay compared."
11181
+ }
11182
+ ];
11183
+ var DRIVER_RELEASES = [
11184
+ {
11185
+ version: "0.1.0-alpha.12",
11186
+ previous: "0.1.0-alpha.11",
11187
+ declares: "nothing",
11188
+ uncalibrated: false,
11189
+ source: 'Host clock round trips each run (c1a430ed): the clock error falls from 126-197 ms on the S23, so step edges line up better; a stale element is looked up again; flingTimes, flingUntil, stableCentres and tapAt added for journeys. REQ-FARM-35, Arek 2026-10-19 ~21:40 (standing-rulings): declared "changes nothing" although it holds c1a430ed.'
11190
+ },
11191
+ {
11192
+ version: "0.1.0-alpha.13",
11193
+ previous: "0.1.0-alpha.12",
11194
+ declares: "nothing",
11195
+ uncalibrated: false,
11196
+ source: 'KeepSnappy link route, flush and GC answer element names and Android command name (the protocol cutover, docs/keepsnappy-migration.md); timing and touches unchanged. REQ-FARM-35, Arek 2026-10-19: declared "changes nothing".'
11197
+ },
11198
+ {
11199
+ version: "0.1.0-alpha.14",
11200
+ previous: "0.1.0-alpha.13",
11201
+ declares: END_READ_DECLARES,
11202
+ uncalibrated: false,
11203
+ source: `driver sends memread after the end GC's answer and waits for its answer before the flush; ${END_READ_SOURCE}. Nothing else since alpha.13 changes a measure: the flush is re-sent once and an unacknowledged flush fails the run (#140), driver-log.json is written as the run goes (#157), each "Not an instrument change" in packages/appium-driver/CHANGELOG.md`
11204
+ },
11205
+ {
11206
+ version: "0.1.0-alpha.15",
11207
+ previous: "0.1.0-alpha.14",
11208
+ declares: "nothing",
11209
+ uncalibrated: false,
11210
+ source: "Activation settles for 2 s before the journey relaunch (be050585, #167, ACTIVATION_SETTLE_MS); absent Android attributes read as null (3fed6c8c, #159). The delay is before the measured journey; attribute normalization is element lookup and post-window acknowledgement handling. No window edge, touch, GC, memory read or flush timing changes (REQ-FARM-35)."
11211
+ },
11212
+ {
11213
+ version: "0.1.0-alpha.16",
11214
+ previous: "0.1.0-alpha.15",
11215
+ declares: "nothing",
11216
+ uncalibrated: false,
11217
+ source: '#260 list and gesture helpers in appium-driver (not in the run path; adopting modules send identical gestures; CHANGELOG "Not an instrument change"); #211 driver.unit is evidence only (meta.device_unit). No window edge, touch, GC, memory read or flush timing changes (REQ-FARM-35).'
11218
+ },
11219
+ {
11220
+ version: "0.1.0-alpha.17",
11221
+ previous: "0.1.0-alpha.16",
11222
+ declares: [
11223
+ { affects: MEASURES_AT("drag"), places: ["drag"] },
11224
+ { affects: MEASURES_AT("step"), places: ["step"] },
11225
+ { affects: LOADED_MEMORY }
11226
+ ],
11227
+ uncalibrated: false,
11228
+ 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)."
11229
+ },
11230
+ {
11231
+ version: "0.1.0-alpha.18",
11232
+ previous: "0.1.0-alpha.17",
11233
+ declares: [{ affects: DRAG_TIME_AND_CPU, places: ["drag"], platforms: ["ios"] }],
11234
+ uncalibrated: false,
11235
+ 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)."
11236
+ },
11237
+ {
11238
+ version: "0.1.0-alpha.19",
11239
+ previous: "0.1.0-alpha.18",
11240
+ declares: [
11241
+ { affects: MEASURES_AT("drag"), places: ["drag"], platforms: ["ios"] },
11242
+ { affects: MEASURES_AT("step"), places: ["step"], platforms: ["ios"] }
11243
+ ],
11244
+ uncalibrated: false,
11245
+ 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)."
11246
+ }
11247
+ ];
11248
+ var UNSTABLE_NOISY = {
11249
+ reason: "noisy",
11250
+ spread: "sessions",
11251
+ robust: true,
11252
+ between: false,
11253
+ above: 1,
11254
+ below: 0.75,
11255
+ minMainRuns: 6,
11256
+ minSessions: 3,
11257
+ curve: { render: 0.9244, commit: 0.8326, component: 0.6481 },
11258
+ uncalibrated: false,
11259
+ 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"
11260
+ };
11261
+
11262
+ class ConfigError2 extends Error {
11263
+ problems;
11264
+ constructor(problems) {
11265
+ super(`invalid verdict config: ${problems.join("; ")}`);
11266
+ this.problems = problems;
11267
+ this.name = "ConfigError";
11268
+ }
11269
+ }
11270
+ var noRow = (id) => {
11271
+ throw new ConfigError2([`${id}: no row in the clause table`]);
11272
+ };
11273
+ var NOISE_FITS = { ...CLAUSES["T-WARN-NOISE"].value, ...CLAUSES["T-NOISE-JANK"].value };
11274
+ var TIER_REL = 0.3;
11275
+ var TIER_UPDATE_MS = { warnMs: 5, errorMs: 15 };
11276
+ var TIER_WORK_MS = { warnMs: 10, errorMs: 30 };
11277
+ var NOISE_SOURCE = 'Arek 2026-10-16 "Ok" (the relative part adapts to size, "przy małych pomiarach 50 %, przy coraz większych stopniowo coraz mniejszy"; sqrt form, one curve per measure and the two lifts chosen by the controller): ' + 'keepsnappy-measure scratchpad/measure/noise/fit.md "Result to encode" (scripts fit.py MODE=dedup-nomi, lifts.py). Data: S23 TapIn live-ui-appium 11 sessions x 3 (A/A 6073ac76, c86689e2, 4a784c05, f8d36522, 6814be6b; #1 b6d080ec, 52f47229, df55466a; #2 2a55a595, ab33b26e, 3042c714; changed places removed) and iPhone 15 Pro Appium A/A 6 x 3 (a18884dd, 9e364887, c775eab0, d45eb2c1, d3637624, b745fa1c), derivation 14; one place per window (+3s twins and one-window steps dropped), press:new-messages-indicator and its step left out (pushed chat, external work); 41 S23 and 40 iPhone places. ' + "R² of c x sqrt(m) before the dedup (fit.md §2): render 0.66, commit 0.44, component 0.42 (fitted on self_ms_top, the largest component); the dedup moves c by at most 0.03. Places above the lifted curve, S23 / iPhone: k95 render 0 z 41 / 4 z 40, commit 1 / 3, component 2 / 2; k99 render 0 / 1, commit 0 / 1, component 0 / 1. " + "Limits: the iPhone is not fully inside at k95; the iPhone set has 6 sessions (20 splits per place), SDK alpha.7 and window model 1; no split by kind of work";
11278
+ var CAPPED_SOURCE = 'Arek 2026-10-16 ~23:00 "Ok" (followups "[RULED 2026-10-16 ~23:00]"): REQ-MET-26 tiers, the relative part min(30 % of base, error tier ms) of the ~19:00 session-bar ruling, on all three judged measures (~17:50); the 30 % and the ms are not calibrated';
11279
+ var TIERS_SOURCE = 'REQ-MET-26: update error past 15 ms, entry and mount past 30 ms; going over the 100 ms limit is a warning, not an error (Arek 2026-10-17, followups 530fb5a: "Sam w sobie nie, to raczej ostrzeżenie"); same numbers as the pairs candidate (tiers2 BASE_LEVELS)';
11280
+ var MEMORY_PAIR_SOURCE = "REQ-MET-4, Arek 2026-10-20 ~08:00, standing-rulings.md pair B: Slightly worse above max(20 MB, 10 % of main), Worse above max(150 MB, 30 %); provisional, one rule for every device; docs/stats/memory/2026-10-20-memory-kept-two-thresholds.md";
11281
+ var COUNT_SOURCE = "REQ-MET-13, Arek 2026-10-20 ruling (docs/handoffs/2026-10-20-morning-rulings.md, Layer 2): counts are evidence only; commits above max(50 %, +2), renders above max(50 %, +10); provisional, not calibrated";
11282
+ var noiseJudged = (id, on) => {
11283
+ const fit = NOISE_FITS[id] ?? noRow(id);
11284
+ const noise = { a: 0, b: fit.c, power: 0.5, lift: fit.k95 };
11285
+ return {
11286
+ id,
11287
+ role: "judged",
11288
+ on,
11289
+ places: JUDGED_WINDOWS,
11290
+ devices: ANY_DEVICE,
11291
+ threshold: [{ abs: TIER_UPDATE_MS.warnMs, noise }],
11292
+ uncalibrated: false,
11293
+ source: `k95 lift ${fit.k95}, error tier k99 lift ${fit.k99}, c ${fit.c}; ${NOISE_SOURCE}`,
11294
+ thresholdByWork: { entry: [{ abs: TIER_WORK_MS.warnMs, noise }], mount: [{ abs: TIER_WORK_MS.warnMs, noise }] },
11295
+ errorNoise: { ...noise, lift: fit.k99 }
11296
+ };
11297
+ };
11298
+ var part = (b) => ({ ...b.rel !== undefined ? { rel: b.rel } : {}, ...b.abs !== undefined ? { abs: b.abs } : {} });
11299
+ var EVIDENCE_BARS = new Map(Object.values(CLAUSES).filter((c) => c.id.startsWith("E-")).flatMap((c) => Object.entries(c.value).map(([rule, bar]) => [rule, { bar, uncalibrated: c.uncalibrated }])));
11300
+ function withClauseValues(base) {
11301
+ if (!base.better)
11302
+ throw new ConfigError2(["better: missing from base config"]);
11303
+ const warnMs = CLAUSES["T-WARN-MS"].value;
11304
+ const errorMs = CLAUSES["TIER-ERROR"].value;
11305
+ const memory = CLAUSES["J-MEMORY-RETAINED"];
11306
+ const judgedByNoise = (r) => {
11307
+ const fit = NOISE_FITS[r.id] ?? noRow(r.id);
11308
+ const n = { a: 0, b: fit.c, power: 0.5, lift: fit.k95, ...fit.zeroWarn !== undefined ? { atZero: fit.zeroWarn } : {} };
11309
+ return {
11310
+ ...r,
11311
+ threshold: [{ abs: warnMs.update, noise: n }],
11312
+ thresholdByWork: { entry: [{ abs: warnMs.work, noise: n }], mount: [{ abs: warnMs.work, noise: n }] },
11313
+ errorNoise: { a: 0, b: fit.c, power: 0.5, lift: fit.k99, ...fit.zeroError !== undefined ? { atZero: fit.zeroError } : {} },
11314
+ uncalibrated: (r.id in CLAUSES["T-NOISE-JANK"].value ? CLAUSES["T-NOISE-JANK"] : CLAUSES["T-WARN-NOISE"]).uncalibrated
11315
+ };
11316
+ };
11317
+ const evidence = (r) => {
11318
+ const e = EVIDENCE_BARS.get(r.id) ?? noRow(r.id);
11319
+ 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)]]));
11320
+ return { ...r, threshold: [part(e.bar)], ...at ? { thresholdAt: at } : {}, uncalibrated: e.uncalibrated };
11321
+ };
11322
+ const valued = (r) => {
11323
+ if (r.id === "memory_retained")
11324
+ return { ...r, threshold: [part(memory.value.warn)], uncalibrated: memory.uncalibrated };
11325
+ return r.role === "judged" ? judgedByNoise(r) : evidence(r);
11326
+ };
11327
+ const rules = base.rules.map(valued);
11328
+ const frames = (id) => ({ above: { frames: CLAUSES[id].value.frames }, uncalibrated: CLAUSES[id].uncalibrated });
11329
+ const entry = CLAUSES["P-HEAVY-ENTRY"];
11330
+ const unstable = CLAUSES["U-NOISY"];
11331
+ const c = (id) => CLAUSES[id];
11332
+ return {
11333
+ ...base,
11334
+ better: { ...base.better, from: c("S-BETTER").value.from, uncalibrated: c("S-BETTER").uncalibrated },
11335
+ minRuns: c("G-MINRUNS").value.runs,
11336
+ baseline: { ...base.baseline, sessions: c("B-BASELINE").value.sessions, uncalibrated: c("B-BASELINE").uncalibrated },
11337
+ levelShift: { ...base.levelShift, confirmSessions: c("M-LEVELSHIFT").value.confirmSessions, uncalibrated: c("M-LEVELSHIFT").uncalibrated },
11338
+ uncertain: { ...base.uncertain, belowMainSessions: c("S-UNCERTAIN").value.belowMainSessions, uncalibrated: c("S-UNCERTAIN").uncalibrated },
11339
+ judgeFrom: { ...base.judgeFrom, mainSessions: c("G-JUDGE-FROM").value.mainSessions, uncalibrated: c("G-JUDGE-FROM").uncalibrated },
11340
+ work: { ...base.work, mountShareAtLeast: c("W-WORKKIND").value.mountShareAtLeast, uncalibrated: c("W-WORKKIND").uncalibrated },
11341
+ ...base.windowCount ? { windowCount: { ...base.windowCount, below: c("W-WINDOW-COUNT").value.below, uncalibrated: c("W-WINDOW-COUNT").uncalibrated } } : {},
11342
+ frame: { ...base.frame, ms: c("T-FRAME").value.ms, uncalibrated: c("T-FRAME").uncalibrated },
11343
+ tiers: {
11344
+ ...base.tiers,
11345
+ rel: c("T-REL").value.rel,
11346
+ update: { warnMs: warnMs.update, errorMs: errorMs.update },
11347
+ work: { warnMs: warnMs.work, errorMs: errorMs.work },
11348
+ uncalibrated: c("T-WARN-MS").uncalibrated || c("TIER-ERROR").uncalibrated || c("T-REL").uncalibrated,
11349
+ memory: { error: part(memory.value.error), uncalibrated: memory.uncalibrated }
11350
+ },
11351
+ rules,
11352
+ problems: base.problems.map((p) => {
11353
+ if (p.id === "slow-commit")
11354
+ return { ...p, ...frames("P-SLOW-COMMIT") };
11355
+ if (p.id === "heavy-component")
11356
+ return { ...p, ...frames("P-HEAVY-COMPONENT") };
11357
+ if (p.id === "heavy-entry")
11358
+ return { ...p, above: { value: entry.value.ms }, uncalibrated: entry.uncalibrated };
11359
+ return noRow(p.id);
11360
+ }),
11361
+ budgets: { ...base.budgets, entry: { value: entry.value.ms }, mount: { value: entry.value.ms }, update: { frames: c("P-BUDGETS").value.updateFrames }, uncalibrated: c("P-BUDGETS").uncalibrated },
11362
+ unstable: base.unstable.map((u) => ({
11363
+ ...u,
11364
+ above: unstable.value.above,
11365
+ below: unstable.value.below,
11366
+ minMainRuns: unstable.value.minMainRuns,
11367
+ minSessions: unstable.value.minSessions,
11368
+ uncalibrated: unstable.uncalibrated,
11369
+ curve: Object.fromEntries(Object.keys(u.curve).map((id) => [id, (NOISE_FITS[id] ?? noRow(`unstable curve ${id}`)).c])),
11370
+ ...u.reads ? { reads: u.reads.map(valued) } : {}
11371
+ }))
11372
+ };
11373
+ }
11374
+ var COMMIT_MAX_JUDGED = noiseJudged("commit", { measure: "commit_max_ms" });
11375
+ var RENDER_JUDGED = noiseJudged("render", { measure: "render_ms" });
11376
+ var COMPONENT_JUDGED = noiseJudged("component", { dimension: "component", measure: "self_ms" });
11377
+ var MEMORY_RETAINED = {
11378
+ id: "memory_retained",
11379
+ role: "judged",
11380
+ on: { measure: "mem.retained_growth_mb" },
11381
+ places: JOURNEY,
11382
+ devices: ANY_DEVICE,
11383
+ threshold: [{ rel: 0.1, abs: 20 }],
11384
+ uncalibrated: true,
11385
+ source: MEMORY_PAIR_SOURCE
11386
+ };
11387
+ 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)`;
11388
+ var SHAPE = {
11389
+ id: "simple-4.26+noise95-99",
11390
+ minRuns: 2,
11391
+ 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)") },
11392
+ repeats: { kind: "every-pr-beyond-every-main" },
11393
+ frame: { ms: LIMIT_FRAME_MS, uncalibrated: false, source: "Arek 2026-10-16: one frame is 16.7 ms on every screen; lag starts below 60 fps, so a 120 Hz phone is not held to 8.3 ms" },
11394
+ levelShift: {
11395
+ confirmSessions: 2,
11396
+ uncalibrated: false,
11397
+ source: "the smallest number of agreeing main sessions with zero noise confirmations on the data (plan 63 §3; controller 2026-10-17), reviewer scripts scratchpad/rev-p6-scripts on the S23 sessions with candidates only after 3 sessions in their bounded baseline: aa-shifts (no change): A/A all 120 orders 0 shifts at m = 1 and m = 2, 11 sessions x 60 random orders 1 of 60 at both; aa-inject (false later shifts after an injected real step): m = 1 2 of 40 sequences, m = 2 0 of 40. Limit: a component with two typical levels (AnimatedView at step:gia.history-first gave the 1 of 60) can fool any m"
11398
+ },
11399
+ uncertain: { belowMainSessions: UNCERTAIN_BELOW_MAIN_SESSIONS, uncalibrated: false, source: 'Arek 2026-10-16: "Nie blokuj przy małej bazie". With fewer than 3 main sessions a worse result is reported as uncertain and does not fail the check' },
11400
+ work: {
11401
+ mountShareAtLeast: 0.8,
11402
+ uncalibrated: true,
11403
+ source: "TraceSift MOUNT_DOMINANT_SHARE (0.8); the share is a lower-biased estimate (PR C review), so it only separates an in-screen mount from an update; an entry is decided by the route-change label alone"
11404
+ },
11405
+ instrument: {
11406
+ aspects: ["sdk", "driver", "driverVersion", "journeyVersion", "actionGc"],
11407
+ sdkReleases: SDK_RELEASES,
11408
+ uncalibrated: false,
11409
+ 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)',
11410
+ driverReleases: DRIVER_RELEASES,
11411
+ unknownDiffers: ["journeyVersion"]
11412
+ },
11413
+ 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)' },
11414
+ rules: [
11415
+ { ...COMMIT_MAX_JUDGED, id: "jank", on: { measure: "jank_ms" }, source: JANK_SOURCE },
11416
+ evidenceRule("commit_max", { measure: "commit_max_ms" }, JUDGED_WINDOWS, [], "W8 option A: the slowest commit is shown, jank time is judged", { devices: ANY_DEVICE }),
11417
+ RENDER_JUDGED,
11418
+ COMPONENT_JUDGED,
11419
+ { ...evidenceRule("commits", { measure: "commits" }, REACT_PLACES, [{ rel: 0.5, abs: 2 }], "render and commit counts are evidence only (ruling a)"), source: COUNT_SOURCE },
11420
+ { ...evidenceRule("renders", { measure: "renders" }, REACT_PLACES, [{ rel: 0.5, abs: 10 }], "render and commit counts are evidence only (ruling a)"), source: COUNT_SOURCE },
11421
+ 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"),
11422
+ evidenceRule("background_commit", { measure: "commit_max_ms" }, BACKGROUND, [{ rel: TIER_REL, abs: TIER_UPDATE_MS.warnMs }], "background places are evidence only (REQ-MEAS-32)"),
11423
+ evidenceRule("background_render", { measure: "render_ms" }, BACKGROUND, [{ rel: TIER_REL, abs: TIER_UPDATE_MS.warnMs }], "background places are evidence only (REQ-MEAS-32)"),
11424
+ evidenceRule("background_component", { dimension: "component", measure: "self_ms" }, BACKGROUND, [{ rel: TIER_REL, abs: TIER_UPDATE_MS.warnMs }], "background places are evidence only (REQ-MEAS-32)"),
11425
+ evidenceRule("launch_commit", { measure: "commit_max_ms" }, LAUNCH, [{ rel: TIER_REL, abs: TIER_UPDATE_MS.warnMs }], "the launch screen is evidence only (REQ-VER-15)"),
11426
+ evidenceRule("launch_render", { measure: "render_ms" }, LAUNCH, [{ rel: TIER_REL, abs: TIER_UPDATE_MS.warnMs }], "the launch screen is evidence only (REQ-VER-15)"),
11427
+ evidenceRule("launch_component", { dimension: "component", measure: "self_ms" }, LAUNCH, [{ rel: TIER_REL, abs: TIER_UPDATE_MS.warnMs }], "the launch screen is evidence only (REQ-VER-15)"),
11428
+ evidenceRule("step_commit", { measure: "commit_max_ms" }, STEP, [{ rel: TIER_REL, abs: TIER_UPDATE_MS.warnMs }], "a declared step is evidence only (REQ-MET-14)"),
11429
+ evidenceRule("step_render", { measure: "render_ms" }, STEP, [{ rel: TIER_REL, abs: TIER_UPDATE_MS.warnMs }], "a declared step is evidence only (REQ-MET-14)"),
11430
+ evidenceRule("step_component", { dimension: "component", measure: "self_ms" }, STEP, [{ rel: TIER_REL, abs: TIER_UPDATE_MS.warnMs }], "a declared step is evidence only (REQ-MET-14)"),
11431
+ evidenceRule("touch", { measure: "touch_max_ms" }, SHOWN2, [{ rel: 0.2, abs: 10 }], "touch response is not judged (ruling)"),
11432
+ evidenceRule("freeze", { measure: "sampler_longest_gap_ms" }, SHOWN2, [{ rel: 0.5, abs: 17 }], "v5.3 warn level"),
11433
+ evidenceRule("freeze_journey", { measure: "sampler.frames.longest_gap_ms" }, JOURNEY, [{ rel: 0.5, abs: 17 }], "v5.3 warn level"),
11434
+ evidenceRule("memory_added", { measure: "memory_left_mb" }, SHOWN2, [{ abs: 2 }], "v5.3 warn level", { thresholdAt: { screen: [{ abs: 10 }] }, devices: ANY_DEVICE }),
11435
+ evidenceRule("memory_launch", { measure: "mem.launch_mb" }, JOURNEY, [{ rel: 0.08, abs: 2 }], "v5.3 warn level"),
11436
+ evidenceRule("memory_end_growth", { measure: "mem.end_growth_mb" }, JOURNEY, [{ rel: 0.08, abs: 2 }], "v5.3 warn level"),
11437
+ evidenceRule("memory_peak_growth", { measure: "mem.peak_growth_mb" }, JOURNEY, [{ rel: 0.08, abs: 2 }], "v5.3 warn level"),
11438
+ evidenceRule("cpu", { measure: "sampler.cpu_ms" }, JOURNEY, [{ rel: 0.05 }], "a +10 % whole-app CPU change must show as evidence (hard case, with room for run noise on both sides); the prototype used 20 %"),
11439
+ evidenceRule("window_cpu", { measure: "cpu_ms" }, SHOWN2, [{ rel: 0.5, abs: 20 }], "v5.3 warn level"),
11440
+ evidenceRule("long_tasks", { measure: "longtask_ms" }, SHOWN2, [{ rel: 0.3, abs: 50 }], "v5.3 warn level"),
11441
+ evidenceRule("gc", { measure: "gc_ms" }, SHOWN2, [{ rel: 0.5, abs: 2 }], "v5.3 warn level"),
11442
+ evidenceRule("requests", { measure: "net_requests" }, SHOWN2, [{ rel: 0.5, abs: 1 }], "v5.3 warn level"),
11443
+ evidenceRule("js_cpu", { measure: "js_cpu_ms" }, SHOWN2, [{ rel: 0.5, abs: 5 }], "v5.3 warn level"),
11444
+ MEMORY_RETAINED
11445
+ ],
11446
+ problems: [
11447
+ {
11448
+ id: "slow-commit",
11449
+ on: { measure: "commit_max_ms" },
11450
+ places: JUDGED_WINDOWS,
11451
+ devices: DEVICE,
11452
+ above: { frames: 1 },
11453
+ inRuns: "all",
11454
+ uncalibrated: true,
11455
+ source: "Arek 2026-10-16: a commit over one 60 Hz frame (16.7 ms) drops below 60 fps, where lag starts; present in every PR run (TapIn Appium: 37 places, 9 of them at 17-22 ms in only some sessions)"
11456
+ },
11457
+ {
11458
+ id: "heavy-component",
11459
+ on: { dimension: "component", measure: "self_ms" },
11460
+ places: JUDGED_WINDOWS,
11461
+ devices: DEVICE,
11462
+ above: { frames: 1 },
11463
+ inRuns: "all",
11464
+ uncalibrated: true,
11465
+ source: "Arek 2026-10-16: one 60 Hz frame, as the slowest commit; a component with a long render in the PR's own runs; a component that exists only on the PR side lands here, never as a regression row (Q3)"
11466
+ },
11467
+ {
11468
+ id: "heavy-entry",
11469
+ on: { measure: "render_ms" },
11470
+ places: JUDGED_WINDOWS,
11471
+ devices: DEVICE,
11472
+ above: { value: ENTRY_BUDGET_MS },
11473
+ inRuns: "all",
11474
+ work: ["entry", "mount"],
11475
+ uncalibrated: true,
11476
+ source: 'ruling d: an entry or in-screen mount has a budget of about 100 ms of React work; its second half, "no dropped frames during the transition", is covered by `slow-commit` at one frame (the earlier `blocking-commit` at 2 frames only repeated it)'
11477
+ }
11478
+ ],
11479
+ budgets: {
11480
+ entry: { value: ENTRY_BUDGET_MS },
11481
+ mount: { value: ENTRY_BUDGET_MS },
11482
+ update: { frames: 1 },
11483
+ uncalibrated: true,
11484
+ 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)"
11485
+ },
11486
+ 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' },
11487
+ notJudgedLabels: ["launch"],
11488
+ unstable: [{
11489
+ ...UNSTABLE_NOISY,
11490
+ step: "own",
11491
+ reads: [COMMIT_MAX_JUDGED, RENDER_JUDGED, COMPONENT_JUDGED, MEMORY_RETAINED],
11492
+ 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`
11493
+ }],
11494
+ 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' },
11495
+ measuredWith: [
11496
+ { aspect: "renderer", affects: REACT_MEASURES },
11497
+ { aspect: "reactExport", affects: MEMORY_MEASURES },
11498
+ { aspect: "windowModel", affects: WINDOW_MODEL_AFFECTS, places: ["press", "drag"], marks: true },
11499
+ { aspect: "screenWindowModel", affects: WINDOW_MODEL_AFFECTS, places: ["screen"], marks: true },
11500
+ { aspect: "cpuSource", affects: ["sampler.cpu_ms"] },
11501
+ { aspect: "causeSplit", affects: REACT_MEASURES, places: REACT_PLACES, marks: true },
11502
+ { aspect: "endGc", affects: MEMORY_MEASURES }
11503
+ ],
11504
+ tiers: {
11505
+ rel: TIER_REL,
11506
+ update: TIER_UPDATE_MS,
11507
+ work: TIER_WORK_MS,
11508
+ uncalibrated: false,
11509
+ source: `${NOISE_SOURCE}; ${CAPPED_SOURCE}; ${TIERS_SOURCE}; ${MEMORY_PAIR_SOURCE}`,
11510
+ relAtMostErrorMs: true,
11511
+ memory: { error: { rel: 0.3, abs: 150 }, uncalibrated: true }
11512
+ },
11513
+ better: { from: CLAUSES["S-BETTER"].value.from, uncalibrated: false, source: CLAUSES["S-BETTER"].ruling },
11514
+ placeCoverage: {
11515
+ uncalibrated: false,
11516
+ source: "plan 79 S1/S2, Arek 2026-10-21 ~18:55 and 2026-10-22 ~16:45: removed never scores; list main-only places; count main sessions that have each place and value"
11517
+ },
11518
+ windowCount: { below: CLAUSES["W-WINDOW-COUNT"].value.below, uncalibrated: CLAUSES["W-WINDOW-COUNT"].uncalibrated, source: 'plan 51 D2 floor (90 % of the usual window count), Arek 2026-10-22 ~07:20 "a changed number of swipes/windows on one screen must affect only that screen"; per PR run, a decrease only (an increase is not marked)' },
11519
+ detail: { componentMs: CLAUSES["C-DETAIL-MS"].value.ms, censored: "certify" },
11520
+ sdkExclusionByPlace: {
11521
+ uncalibrated: false,
11522
+ source: `REQ-VER-35 amendment 2026-10-21 (Arek, "porównywać to co się da"), option (a): only the places an SDK step moved are measured differently; comparable places decide, Can't tell only when no comparable place is left (one test for instrument marks, window counts and SDK exclusions); an unstable place is never judged, so its left-out values are not counted (REQ-MET-29)`
11523
+ }
11524
+ };
11525
+ var CONFIG = withClauseValues(SHAPE);
11526
+ var P2A_SLIGHT = withClauseValues({
11527
+ ...CONFIG,
11528
+ id: `${CONFIG.id}+p2a-sw`,
11529
+ unstable: CONFIG.unstable.map((u) => ({
11530
+ ...u,
11531
+ judgeAt: { line: "error", tier: "warning" },
11532
+ source: `${u.source}; P2a candidate (plan 81, Arek 2026-10-23 ~15:45): judged only past the error line, as Slightly worse, never blocking`
11533
+ }))
11534
+ });
11535
+ var P4_EVEN = {
11536
+ ...CONFIG,
11537
+ id: `${CONFIG.id}+p4-even`,
11538
+ evenSlowdown: { maxExcess: 0.3, renders: 0.1, commits: 0.25, uncalibrated: true, source: "plan 81 P4 (Opus O2 guesses); Arek 2026-10-23 ~12:45 direction (4); Arek 2026-10-26 ctl-arek-p81-p3p4: render and commit counts may block a downgrade as a guard, never colour a verdict alone" }
11539
+ };
11540
+ var P3_COMMIT = withClauseValues({
11541
+ ...CONFIG,
11542
+ id: `${CONFIG.id}+p3-commit`,
11543
+ 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" }
11544
+ });
10377
11545
  // apps/agent/src/device-model.ts
10378
11546
  import { tmpdir } from "node:os";
10379
11547
  import { join as join12 } from "node:path";
@@ -10452,173 +11620,10 @@ async function detectSimulator(deps, udid) {
10452
11620
  }
10453
11621
 
10454
11622
  // apps/agent/src/slack.ts
10455
- var COLOR = { error: "#D6334A", warn: "#C47A00", better: "#238359", none: "#7C8492" };
10456
- var MAX_ROWS = 3;
10457
- var NAME_W = 30;
10458
- var COLUMN_GAP = 1;
10459
- function metricWord(item) {
10460
- if (item.label_text)
10461
- return item.label_text;
10462
- if (item.metric === "dur_ms" && placeKindWord(item.step ?? "") !== "Tap")
10463
- return "length";
10464
- return measureDef(item.metric)?.short ?? item.metric.replace(/_/g, " ");
10465
- }
10466
- function change2(item) {
10467
- if (item.change_text)
10468
- return item.change_text.replace(/ \(.*\)$/, "");
10469
- const { baseline: b, candidate: c } = item;
10470
- if (b === null || c === null)
10471
- return "";
10472
- if (b === 0)
10473
- return `${c - b >= 0 ? "+" : ""}${fieldValueText(item.metric, c - b)}`;
10474
- const p = Math.round((c / b - 1) * 100);
10475
- return `${p >= 0 ? "+" : ""}${p} %`;
10476
- }
10477
- var size = (i) => i.baseline === null || i.candidate === null ? 0 : i.baseline === 0 ? i.candidate === 0 ? 0 : Number.POSITIVE_INFINITY : Math.abs(Math.log(Math.max(i.candidate, 0.000000001) / i.baseline));
10478
- var bySize = (a, b) => size(a) === size(b) ? 0 : size(a) > size(b) ? -1 : 1;
10479
- var worseOrder = (a, b) => Number(a.grade === "warn") - Number(b.grade === "warn") || bySize(a, b);
10480
- var isWorse = (i) => i.state === "worse";
10481
- var isBetter = (i) => i.state === "better";
10482
- var differs = (sc) => sc?.headline?.measured_differently ?? [];
10483
- var scenarioBlocks = (sc) => sc?.headline?.blocking !== false;
10484
- var isRed = (i, sc) => i.grade !== "warn" && scenarioBlocks(sc);
10485
- var mark = (i, sc) => isBetter(i) ? "\uD83D\uDFE2" : isRed(i, sc) ? "\uD83D\uDD34" : i.grade !== "warn" ? "⚪" : "\uD83D\uDFE1";
10486
- var fit = (s, w) => s.length > w ? `${s.slice(0, w - 1)}…` : s.padEnd(w);
10487
- var right = (s, w) => s.padStart(w);
10488
- var phone = (sc) => sc?.device ? `${sc.device.model}${sc.device.os ? ` · ${sc.device.os}` : ""}` : "unknown phone";
10489
- function commitLine(ctx) {
10490
- const c = ctx.commit;
10491
- if (!c)
10492
- return null;
10493
- const short = c.sha.slice(0, 7);
10494
- return c.title ? `Commit: *${c.title}*${c.author ? ` · ${c.author}` : ""} (${short})` : `Commit ${short}`;
10495
- }
10496
- var section = (text) => ({ type: "section", text: { type: "mrkdwn", text } });
10497
- var context = (text) => ({ type: "context", elements: [{ type: "mrkdwn", text }] });
10498
- function prTable(items, sc) {
10499
- const cells = items.map((i) => ({ i, main: fieldValueText(i.metric, i.baseline), pr: fieldValueText(i.metric, i.candidate), change: change2(i) }));
10500
- const widest = (head, pick) => Math.max(head.length, ...cells.map((c) => pick(c).length)) + COLUMN_GAP;
10501
- const w = [NAME_W, widest("main", (c) => c.main), widest("PR", (c) => c.pr), widest("change", (c) => c.change)];
10502
- const head = `${" ".repeat(3)}${fit("", w[0])}${right("main", w[1])}${right("PR", w[2])}${right("change", w[3])}`;
10503
- const rows = cells.flatMap((c) => [
10504
- `${mark(c.i, sc)} ${fit(placeLabel(c.i.step ?? "*"), w[0])}${right(c.main, w[1])}${right(c.pr, w[2])}${right(c.change, w[3])}`,
10505
- ` ${metricWord(c.i)}`
10506
- ]);
10507
- return ["```", head, ...rows, "```"].join(`
10508
- `);
10509
- }
10510
- function mainCard(i) {
10511
- const label = (s) => ` ${s.padEnd(16)}`;
10512
- return [
10513
- "```",
10514
- placeLabel(i.step ?? "*"),
10515
- `${label(metricWord(i))}${right(fieldValueText(i.metric, i.candidate), 9)}`,
10516
- `${label("usually")}${right(fieldValueText(i.metric, i.baseline), 9)}`,
10517
- `${label("change")}${right(change2(i), 9)}`,
10518
- "```"
10519
- ].join(`
10520
- `);
10521
- }
10522
- function blockedMessage(s, ctx) {
10523
- const text = `⚪ No result for ${ctx.branch}: the measurement did not finish`;
10524
- const blocks = [
10525
- section(`*${text}*
10526
- ${ctx.blocked?.reason ?? "No iteration sent any data."}
10527
- Nothing was judged, so this is not a "${STATE_WORDS["no-change"].toLowerCase()}".`),
10528
- ...commitLine(ctx) ? [context(commitLine(ctx))] : [],
10529
- ...s ? [section(`\uD83D\uDC49 <${s.dashboard_url}|See what happened>`)] : []
10530
- ];
10531
- return { text, attachments: [{ color: COLOR.none, blocks }] };
10532
- }
10533
11623
  function slackMessage(s, ctx) {
10534
11624
  if (ctx.blocked)
10535
- return blockedMessage(s, ctx);
10536
- const v = s?.verdicts;
10537
- if (!s || !v)
10538
- return null;
10539
- const items = v.scenarios.flatMap((sc) => sc.state === "inconclusive" ? [] : movedItems(v, sc));
10540
- const scenarioOf = (i) => v.scenarios.find((sc) => sc.scenario === i.scenario && (!i.device_id || sc.device?.id === i.device_id)) ?? v.scenarios.find((sc) => sc.scenario === i.scenario);
10541
- const isPr = v.scenarios.some((sc) => sc.role === "comparison");
10542
- const worse = items.filter(isWorse).sort(worseOrder);
10543
- const better = items.filter(isBetter).sort(bySize);
10544
- const commit = commitLine(ctx);
10545
- const heated = v.scenarios.filter((sc) => heatNote(sc));
10546
- const contextLines = v.scenarios.flatMap((sc) => laneContextLines(sc.context, sc.scenario ?? "-", phone(sc))).map(context);
10547
- const heat = heated.map((sc) => context(heatNote(sc)));
10548
- const errorRows = worse.filter((i) => i.grade !== "warn");
10549
- const warningRows = worse.filter((i) => i.grade === "warn");
10550
- const errors = placeCount(errorRows);
10551
- const warns = placeCount(worse) - errors;
10552
- const red = errorRows.some((i) => isRed(i, scenarioOf(i)));
10553
- const notBlocking = errors > 0 && !red;
10554
- const worseMark = red ? "\uD83D\uDD34" : warns > 0 ? "\uD83D\uDFE1" : "⚪";
10555
- const worseColor = red ? COLOR.error : warns > 0 ? COLOR.warn : COLOR.none;
10556
- if (isPr) {
10557
- if (!worse.length) {
10558
- if (!heat.length)
10559
- return null;
10560
- const head = `⚪ ${ctx.branch}: ${heat.length ? INFO_WORDS.heat : v.word ?? STATE_WORDS["no-change"]}`;
10561
- const blocks = [section(`*${head}*`), ...heat, ...contextLines, ...commit ? [context(commit)] : [], section(`\uD83D\uDC49 <${heated[0]?.dashboard_url ?? s.dashboard_url}|View the comparison>`)];
10562
- return { text: head, attachments: [{ color: COLOR.none, blocks }] };
10563
- }
10564
- const graded = worse.some((i) => i.grade);
10565
- const places = (word, n) => `${word.toLowerCase()} in ${n} place${n > 1 ? "s" : ""}`;
10566
- const counts = graded ? [errors ? `${places(STATE_WORDS.worse, errors)}${notBlocking ? ` (${NOT_BLOCKING})` : ""}` : "", warns ? places(STATE_WORDS.warning, warns) : ""].filter(Boolean).join(", ") : `${places(STATE_WORDS.worse, placeCount(worse))}${notBlocking ? ` (${NOT_BLOCKING})` : ""}`;
10567
- const head = `${worseMark} ${ctx.branch}: ${counts}`;
10568
- const blocks = [section(`*${head}*`), ...contextLines];
10569
- const groups = new Map;
10570
- for (const i of worse)
10571
- groups.set(scenarioOf(i), [...groups.get(scenarioOf(i)) ?? [], i]);
10572
- let shown = 0;
10573
- for (const [sc, list] of groups) {
10574
- if (shown >= MAX_ROWS)
10575
- break;
10576
- const rows = list.slice(0, MAX_ROWS - shown);
10577
- shown += rows.length;
10578
- const nB = Math.max(...rows.map((r) => r.n_baseline ?? 0));
10579
- const nC = Math.max(...rows.map((r) => r.n_candidate ?? 0)) || (sc?.runs?.valid ?? 0);
10580
- const runs = nB && nC ? ` · ${nC} PR run${nC === 1 ? "" : "s"} vs ${nB} main run${nB === 1 ? "" : "s"}` : "";
10581
- const note = differs(sc).length ? `
10582
- _Measured differently (${differs(sc).join(", ")})${scenarioBlocks(sc) ? "" : ` — ${NOT_BLOCKING}`}_` : "";
10583
- const nonblocking = !scenarioBlocks(sc) && rows.some((i) => i.grade !== "warn") && !differs(sc).length ? ` · ${NOT_BLOCKING}` : "";
10584
- blocks.push(section(`*${sc?.scenario ?? "journey"}*${nonblocking} · ${phone(sc)}${runs}${note}
10585
- ${prTable(rows, sc)}`));
10586
- }
10587
- const more = worse.length - shown;
10588
- blocks.push(...heat);
10589
- if (commit)
10590
- blocks.push(context(commit));
10591
- blocks.push(section(`\uD83D\uDC49 <${groups.keys().next().value?.dashboard_url ?? s.dashboard_url}|View the comparison>${more > 0 ? ` · ${more} more` : ""}`));
10592
- return { text: head, attachments: [{ color: worseColor, blocks }] };
10593
- }
10594
- const improved = better.length > 0 || v.state === "better";
10595
- if (!worse.length && !improved && !ctx.always && !heat.length)
10596
- return null;
10597
- const parts = [errors ? `${errors} ${STATE_WORDS.worse.toLowerCase()}${notBlocking ? ` (${NOT_BLOCKING})` : ""}` : "", warns ? `${warns} ${STATE_WORDS.warning.toLowerCase()}` : "", better.length ? `${placeCount(better)} ${STATE_WORDS.better.toLowerCase()}` : ""].filter(Boolean);
10598
- const quiet = improved ? STATE_WORDS.better.toLowerCase() : (v.state === null || v.state === "inconclusive") && v.word ? v.word : STATE_WORDS["no-change"].toLowerCase();
10599
- const status = parts.length ? `${ctx.branch}: ${parts.join(", ")}` : !improved && heat.length && !ctx.always ? `${ctx.branch}: ${INFO_WORDS.heat}` : quiet.startsWith("Main: ") && ctx.branch === "main" ? quiet : `${ctx.branch}: ${quiet}`;
10600
- const head = `${worse.length ? worseMark : improved ? "\uD83D\uDFE2" : v.state === "inconclusive" && (ctx.always || !heat.length) ? "❔" : "⚪"} ${status}`;
10601
- const journeys = new Set(v.scenarios.map((sc) => `${sc.scenario}|${sc.device?.id}`)).size;
10602
- const blocks = [section(`*${head}*
10603
- ${journeys} journey${journeys === 1 ? "" : "s"} checked`), ...contextLines];
10604
- let shown = 0;
10605
- for (const [title, list] of [[STATE_WORDS.worse, errorRows], [STATE_WORDS.warning, warningRows], [STATE_WORDS.better, better]]) {
10606
- for (const i of list) {
10607
- if (shown >= MAX_ROWS)
10608
- break;
10609
- shown++;
10610
- const sc = scenarioOf(i);
10611
- blocks.push(section(`*${title}*${i.grade !== "warn" && isWorse(i) && !scenarioBlocks(sc) ? ` (${NOT_BLOCKING})` : ""} · *${sc?.scenario ?? "journey"}* · ${phone(sc)}
10612
- ${mainCard(i)}`));
10613
- }
10614
- }
10615
- const more = worse.length + better.length - shown;
10616
- blocks.push(...heat);
10617
- if (commit)
10618
- blocks.push(context(commit));
10619
- blocks.push(section(`\uD83D\uDC49 <${s.dashboard_url}|Open KeepSnappy>${more > 0 ? ` · ${more} more changes` : ""}`));
10620
- const color = worse.length ? worseColor : improved ? COLOR.better : COLOR.none;
10621
- return { text: head, attachments: [{ color, blocks }] };
11625
+ return noResultSlackPayload(ctx.branch, ctx.blocked.reason, s ? linkOf(s) : null);
11626
+ return s ? resultSlackPayload(s, { always: ctx.always }) : null;
10622
11627
  }
10623
11628
  async function postToSlack(url, payload, fetchImpl = fetch) {
10624
11629
  try {
@@ -10703,7 +11708,7 @@ function instrumentFlags(f) {
10703
11708
  return { ...driver ? { "driver-version": driver } : {}, ...journey ? { "journey-version": journey } : {} };
10704
11709
  }
10705
11710
  var TOKENS_STUB = "// Replaced by `keepsnappy farm open` for one upload. Never commit real tokens.\noutput.keepsnappyTokens = {};\n";
10706
- var JOURNEY = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
11711
+ var JOURNEY2 = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
10707
11712
  var DEFAULT_TTL = 21600;
10708
11713
  var POLL_MS = 5000;
10709
11714
  function tokensJs(api, runSetId, tokens, links = maestroLinkFields()) {
@@ -10790,7 +11795,7 @@ async function farmOpen(deps, f, rt) {
10790
11795
  if (journeys.length === 0)
10791
11796
  problems.push("--journeys a,b is required (the flow names the device runs, one history per journey)");
10792
11797
  for (const j of journeys)
10793
- if (!JOURNEY.test(j))
11798
+ if (!JOURNEY2.test(j))
10794
11799
  problems.push(`--journeys: "${j}" is not a journey name (letters, digits, . _ -)`);
10795
11800
  if (new Set(journeys).size !== journeys.length)
10796
11801
  problems.push("--journeys lists a journey twice");
@@ -10818,11 +11823,15 @@ async function farmOpen(deps, f, rt) {
10818
11823
  const withInstrument = { ...f, ...instrumentFlags(f) };
10819
11824
  const setup = Object.fromEntries(SETUP_FLAGS.flatMap(([flag, key]) => withInstrument[flag]?.trim() ? [[key, withInstrument[flag].trim().slice(0, 200)]] : []));
10820
11825
  const base = await baseCommitFor(deps, rt.env, { explicit: f["base-commit"], branch: git.branch, commit: git.commit }, rt.print);
11826
+ const main = await mainMerges(deps, rt.env, { branch: git.branch, commit: git.commit });
11827
+ if (main.warning)
11828
+ rt.print(main.warning);
10821
11829
  const rs = await createRunSet(api, {
10822
11830
  branch: git.branch,
10823
11831
  ...git.commit ? { commit_sha: git.commit } : {},
10824
11832
  ...base ? { base_commit_sha: base } : {},
10825
11833
  ...await commitFields(deps, rt.env, { commit: git.commit, base }),
11834
+ ...main.merges ? { merges: main.merges } : {},
10826
11835
  device: { model: device.model, ...device.os ? { os: device.os } : {}, provider: device.provider, ...f["device-id"]?.trim() ? { id: f["device-id"].trim() } : {} },
10827
11836
  scenarios: journeys,
10828
11837
  journeys: journeyInfo,
@@ -10939,7 +11948,7 @@ async function settle(api, runSetId, o) {
10939
11948
  }
10940
11949
  }
10941
11950
  var FAILED_SESSION = new Set(["failed", "error", "timedout", "stopped", "skipped"]);
10942
- var median5 = (xs) => {
11951
+ var median6 = (xs) => {
10943
11952
  const v = [...xs].sort((a, b) => a - b);
10944
11953
  return v.length % 2 ? v[(v.length - 1) / 2] : (v[v.length / 2 - 1] + v[v.length / 2]) / 2;
10945
11954
  };
@@ -10961,10 +11970,11 @@ function brokenRun(state, runs, session) {
10961
11970
  const others = ordered.filter((r) => r !== last && r.journey === last.journey && has(r)).map((r) => byId.get(r.run_id).windows ?? 0);
10962
11971
  const journey = typeof s.usual_windows === "number" && s.usual_windows > 0 ? s.usual_windows : null;
10963
11972
  const mine = ordered.filter((r) => r.journey === last.journey && has(r) && byId.get(r.run_id).complete !== false).map((r) => byId.get(r.run_id).windows ?? 0).filter((n) => n > 0);
10964
- const m = mine.length >= 2 ? median5(mine) : null;
10965
- const changed = m !== null && mine.every((n) => n >= 0.9 * m && 0.9 * n <= m) && (journey === null || m < 0.9 * journey);
10966
- const usual = changed ? m : journey ?? (others.length ? median5(others) : null);
10967
- if (usual !== null && (s.windows ?? 0) < 0.9 * usual)
11973
+ const m = mine.length >= 2 ? median6(mine) : null;
11974
+ const floor = CLAUSES["W-WINDOW-COUNT"].value.below;
11975
+ const changed = m !== null && mine.every((n) => n >= floor * m && floor * n <= m) && (journey === null || m < floor * journey);
11976
+ const usual = changed ? m : journey ?? (others.length ? median6(others) : null);
11977
+ if (usual !== null && (s.windows ?? 0) < floor * usual)
10968
11978
  return last;
10969
11979
  return next;
10970
11980
  }
@@ -11000,6 +12010,7 @@ async function farmClose(deps, f, rt) {
11000
12010
  await scrubTokens(deps, state.tokens_js, rt);
11001
12011
  }
11002
12012
  }
12013
+ var blockedReason = (session) => `No iteration sent any data${session.known ? ` (device session ${session.status})` : ""}.`;
11003
12014
  async function closeRunSet(deps, f, rt, state, statePath) {
11004
12015
  const settleSeconds = Number(f["settle-seconds"] ?? 60);
11005
12016
  const maxWaitSeconds = Number(f["max-wait-seconds"] ?? Math.max(300, settleSeconds * 3));
@@ -11054,7 +12065,7 @@ async function closeRunSet(deps, f, rt, state, statePath) {
11054
12065
  const blocked = summary.runs.valid === 0 && withData === 0;
11055
12066
  if (blocked) {
11056
12067
  rt.print("");
11057
- rt.print(`KeepSnappy result for ${where}: Blocked — no iteration sent any data${session.known && !session.passed ? ` (device session ${session.status})` : ""}.`);
12068
+ rt.print(`KeepSnappy result for ${where}: ${BLOCKED_WORD} — no iteration sent any data${session.known && !session.passed ? ` (device session ${session.status})` : ""}.`);
11058
12069
  rt.print(" Check that the build carries the SDK, the activation link ran, and the device reached the API.");
11059
12070
  rt.print(`Dashboard: ${summary.dashboard_url}`);
11060
12071
  } else if (f.wait) {
@@ -11075,36 +12086,14 @@ async function closeRunSet(deps, f, rt, state, statePath) {
11075
12086
  `);
11076
12087
  await githubOutputs(deps, rt, { state: state2.result.state ?? "none", dashboard_url: summary.dashboard_url, run_set_id: state.run_set_id });
11077
12088
  const step = rt.env.GITHUB_STEP_SUMMARY;
11078
- if (step) {
11079
- const head = blocked ? `### KeepSnappy: Blocked
11080
-
11081
- No iteration sent any data${session.known ? ` (device session ${session.status})` : ""}. [Open the dashboard](${summary.dashboard_url})
11082
- ` : markdownRunSet(f.wait ? summary : null, { dashboardUrl: summary.dashboard_url, branch: state.branch });
11083
- const noData = summary.runs.no_data?.length ?? 0;
11084
- const tail = [
11085
- session.known && !session.passed ? `Device session ${session.status}${session.message ? `: ${session.message}` : ""}.` : "",
11086
- noData ? `${noData} of ${state.runs.length} iterations sent no data.` : ""
11087
- ].filter(Boolean);
11088
- await deps.fs.appendFile(step, `${head}${tail.length ? `
11089
- ${tail.join(" ")}
11090
- ` : ""}`);
11091
- }
12089
+ if (step)
12090
+ await deps.fs.appendFile(step, blocked ? noResultMarkdown(state.branch, blockedReason(session), summary.dashboard_url) : markdownRunSet(f.wait ? summary : null, { dashboardUrl: summary.dashboard_url, branch: state.branch }));
11092
12091
  const hook = envValue(rt.env, "SLACK_WEBHOOK");
11093
- if (hook) {
11094
- const commit = state.commit ? await commitInfo(deps, state.commit) : undefined;
11095
- const msg = slackMessage(f.wait || blocked ? summary : null, {
11096
- branch: state.branch,
11097
- ...commit ? { commit } : {},
11098
- ...blocked ? { blocked: { reason: `No iteration sent any data${session.known ? ` (device session ${session.status})` : ""}.` } } : {},
11099
- always: envValue(rt.env, "SLACK_ALWAYS") === "1"
11100
- });
11101
- if (!msg) {
11102
- rt.print(!f.wait ? "Slack: skipped, the message needs --wait" : notComputed(summary) ? "Slack: nothing to report, the verdict is not computed yet" : `Slack: nothing to report (${resultWord(summary).toLowerCase()})`);
11103
- } else {
11104
- const r = await postToSlack(hook, msg, rt.fetch ?? fetch);
11105
- rt.print(r.ok ? "Slack: posted" : `Slack: not posted (${r.error}); the job is not affected`);
11106
- }
11107
- }
12092
+ if (hook && blocked) {
12093
+ const r = await postToSlack(hook, slackMessage(summary, { branch: state.branch, blocked: { reason: blockedReason(session) } }), rt.fetch ?? fetch);
12094
+ rt.print(r.ok ? "Slack: posted" : `Slack: not posted (${r.error}); the job is not affected`);
12095
+ } else if (hook)
12096
+ rt.print("Slack: results are posted after the verdict is computed");
11108
12097
  if (blocked)
11109
12098
  return EXIT.infra;
11110
12099
  if (f["fail-on-worse"] && isBlocking(summary)) {
@@ -11417,7 +12406,7 @@ function nodeFarmRunPorts(deps, rt) {
11417
12406
  }
11418
12407
  var TUNNEL_TARGET = /^(?:localhost|127\.0\.0\.1):(\d{1,5})$/;
11419
12408
  var ENV_PAIR = /^([A-Za-z_][A-Za-z0-9_]*)=([\s\S]*)$/;
11420
- var DEVICE = /^(.+)-([^-]+)$/;
12409
+ var DEVICE2 = /^(.+)-([^-]+)$/;
11421
12410
  var SECRETISH = /KEY|TOKEN|SECRET|PASSWORD/i;
11422
12411
  var RUN_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
11423
12412
  function planFarmRun(f) {
@@ -11427,7 +12416,7 @@ function planFarmRun(f) {
11427
12416
  const platform = f.platform ?? "ios";
11428
12417
  if (platform !== "ios" && platform !== "android")
11429
12418
  problems.push("--platform must be ios or android");
11430
- if (!f.device || !DEVICE.test(f.device))
12419
+ if (!f.device || !DEVICE2.test(f.device))
11431
12420
  problems.push('--device "<model>-<os>" is required, e.g. "iPhone 15 Pro-17"');
11432
12421
  const driver = f.driver ?? "maestro";
11433
12422
  if (driver !== "maestro" && driver !== "appium")
@@ -11436,7 +12425,7 @@ function planFarmRun(f) {
11436
12425
  if (journeys.length === 0)
11437
12426
  problems.push("--journeys a,b is required");
11438
12427
  for (const j of journeys)
11439
- if (!JOURNEY.test(j))
12428
+ if (!JOURNEY2.test(j))
11440
12429
  problems.push(`--journeys: "${j}" is not a journey name (letters, digits, . _ -)`);
11441
12430
  const repeat = Number(f.repeat ?? 5);
11442
12431
  if (!Number.isInteger(repeat) || repeat < 1 || repeat > 100)
@@ -11533,7 +12522,7 @@ function printPlan(p, f, rt) {
11533
12522
  line(` env ${k}=${substitute(v, {})}`);
11534
12523
  line(` 6. farm close${f.wait ? " --wait" : ""}${f["fail-on-worse"] ? " --fail-on-worse" : ""} with ${join16(p.out, `browserstack.${p.platform}.json`)}`);
11535
12524
  line(" 7. always: stop the tunnel, put the empty stub back over the tokens file");
11536
- line(" exit: 0 any verdict, 1 infrastructure / driver failed / Blocked, 2 configuration, 3 --fail-on-worse (a computed blocking Worse only; no computed verdict: nothing checked)");
12525
+ line(` exit: 0 any verdict, 1 infrastructure / driver failed / ${BLOCKED_WORD}, 2 configuration, 3 --fail-on-worse (a computed blocking Worse only; no computed verdict: nothing checked)`);
11537
12526
  }
11538
12527
  var ANNOTATION = /^::(error|warning|notice)( [^:]*)?::/;
11539
12528
  async function runDriver2(deps, rt, f, p, ports, ctx) {
@@ -11636,7 +12625,7 @@ async function farmRun(deps, f, rt, ports) {
11636
12625
  let driver = { ok: true, message: null };
11637
12626
  let closeCode = EXIT.ok;
11638
12627
  try {
11639
- const [model, os] = [DEVICE.exec(f.device)[1], DEVICE.exec(f.device)[2]];
12628
+ const [model, os] = [DEVICE2.exec(f.device)[1], DEVICE2.exec(f.device)[2]];
11640
12629
  const openCode = await farmOpen(deps, {
11641
12630
  ...f,
11642
12631
  journeys: p.journeys.join(","),
@@ -11957,6 +12946,7 @@ import { parseArgs as parseArgs3 } from "node:util";
11957
12946
  var SHORT_SESSIONS = 3;
11958
12947
  var SHORT_POINTS = 5;
11959
12948
  var SHORT_PLACES = 5;
12949
+ var SHORT_COMPARE_MEASURES = 3;
11960
12950
  var SHORT_FORM_NOTES = {
11961
12951
  list_findings: "the how-sure lines, the place lines of findings that are not Worse and the lanes that were judged",
11962
12952
  get_finding: "the why, seen and layer lines, the component lines, the memory evidence lines other than the judged measure and the prompt",
@@ -11966,7 +12956,8 @@ var SHORT_FORM_NOTES = {
11966
12956
  place_trend: `the sessions older than the newest ${SHORT_SESSIONS} and the history restarts among them (unknown restarts always stay)`,
11967
12957
  window_history: `the runs older than the newest ${SHORT_POINTS}`,
11968
12958
  pr_status: "the per-journey dashboard links and the heat note",
11969
- list_problems: `the work and component lines, the dashboard links and the problems after the first ${SHORT_PLACES}`
12959
+ list_problems: `the work and component lines, the dashboard links and the problems after the first ${SHORT_PLACES}`,
12960
+ compare_runs: `the component lines and the measures beyond each place's ${SHORT_COMPARE_MEASURES} times that moved most`
11970
12961
  };
11971
12962
  var andList = (xs) => xs.length < 2 ? xs[0] ?? "" : `${xs.slice(0, -1).join(", ")} and ${xs[xs.length - 1]}`;
11972
12963
 
@@ -11992,9 +12983,13 @@ class Rows {
11992
12983
  this.rows.push({ text: line, short: false, notice: false });
11993
12984
  return this;
11994
12985
  }
12986
+ twin(full, short) {
12987
+ this.rows.push({ text: full, short: false, notice: false }, { text: short, short: true, shortOnly: true });
12988
+ return this;
12989
+ }
11995
12990
  render(form, left = []) {
11996
12991
  if (form !== "short")
11997
- return this.rows.map((r) => r.text).join(`
12992
+ return this.rows.filter((r) => !r.shortOnly).map((r) => r.text).join(`
11998
12993
  `);
11999
12994
  const kept = [];
12000
12995
  for (const r of this.rows) {
@@ -12023,7 +13018,7 @@ function formatFinding(f, opts = {}) {
12023
13018
  r.both(...laneContextLines(f.lane.context, f.lane.journey, f.lane.phone_name, opts.verbose, opts.form === "short"));
12024
13019
  r.full(line("Why", f.answer.why));
12025
13020
  r.both(line("What to do", f.answer.what_to_do));
12026
- r.full(line("Seen", f.answer.lifecycle), line("Layer", `${f.layer.id} · ${f.layer.name} (${f.certificate ? "certified" : "uncertified"})${f.limits.length ? `; limits: ${f.limits.join("; ")}` : ""}`));
13021
+ r.full(line("Seen", f.answer.lifecycle), ...f.lifecycle.unstable_note ? [line("", f.lifecycle.unstable_note)] : [], line("Layer", `${f.layer.id} · ${f.layer.name} (${f.certificate ? "certified" : "uncertified"})${f.limits.length ? `; limits: ${f.limits.join("; ")}` : ""}`));
12027
13022
  r.both(line("Id", f.id));
12028
13023
  if (f.why.components.length) {
12029
13024
  r.full(line("Components", ""));
@@ -12051,7 +13046,7 @@ function formatFinding(f, opts = {}) {
12051
13046
  }
12052
13047
  var when = (iso) => iso.slice(0, 16).replace("T", " ");
12053
13048
  var LANES_SHOWN = 8;
12054
- var STATE_WORDS2 = { present: "still present", resolved: "resolved", "not-judged": "not judged in the newest run set" };
13049
+ var STATE_WORDS3 = { present: "still present", resolved: "resolved", "not-judged": "not judged in the newest run set" };
12055
13050
  function measuredLines(l, r, opts) {
12056
13051
  const f = l.filters;
12057
13052
  const scope = [f.branch ?? "all branches", f.journey, f.phone].filter(Boolean).join(" · ");
@@ -12069,15 +13064,19 @@ function measuredLines(l, r, opts) {
12069
13064
  };
12070
13065
  if (m.lanes.length === 1) {
12071
13066
  r.both(`${scope}: newest measured ${f.branch ? "" : `on ${n.branch}, `}${lane(n)}; ${plural2(m.run_sets, "run set")} measured.`);
13067
+ r.both(...(n.comparison_notes ?? []).map((line) => ` ${line}`));
13068
+ r.full(...(n.session_speed ?? []).map((line) => ` ${line}`));
12072
13069
  r.both(...laneContextLines(n.context, n.journey, n.phone_name, opts.verbose, opts.form === "short"));
12073
13070
  return [];
12074
13071
  }
12075
13072
  const shown = m.lanes.slice(0, LANES_SHOWN);
12076
13073
  r.add(shown.some((x) => !x.judged || !!x.context), `${scope}: ${plural2(m.run_sets, "run set")} measured; the newest of each journey × phone:`);
12077
13074
  for (const x of shown) {
12078
- const keep = !x.judged || !!x.context;
13075
+ const keep = !x.judged || !!x.context || !!x.comparison_notes;
12079
13076
  r.add(keep, ` ${x.journey} on ${x.phone_name}${f.branch ? "" : ` (${x.branch})`}: ${lane(x)}`);
12080
13077
  r.add(keep, ...resultContextLines(x.context, opts.verbose, opts.form === "short").map((line) => ` ${line}`));
13078
+ r.add(keep, ...(x.comparison_notes ?? []).map((line) => ` ${line}`));
13079
+ r.full(...(x.session_speed ?? []).map((line) => ` ${line}`));
12081
13080
  }
12082
13081
  if (m.lanes.length > shown.length)
12083
13082
  r.full(` … ${m.lanes.length - shown.length} more (--journey / --phone narrow it)`);
@@ -12096,7 +13095,7 @@ function countsLine2(l) {
12096
13095
  ].filter(Boolean);
12097
13096
  const state = l.filters.state;
12098
13097
  const unjudged = l.measured.lanes.filter((x) => !x.judged);
12099
- const head = parts.length ? `${parts.join(", ")}${state === "all" ? "" : `, ${STATE_WORDS2[state] ?? state}`}` : state !== "present" && state !== "all" ? `No finding ${STATE_WORDS2[state] ?? state}` : unjudged.length === l.measured.lanes.length ? `No finding, but nothing was compared yet (${[...new Set(unjudged.map((x) => x.word))].join(", ")}): measure again before calling it clean` : `Nothing ${STATE_WORDS.worse.toLowerCase()}, nothing ${STATE_WORDS.warning.toLowerCase()}, nothing ${STATE_WORDS.better.toLowerCase()}`;
13098
+ const head = parts.length ? `${parts.join(", ")}${state === "all" ? "" : `, ${STATE_WORDS3[state] ?? state}`}` : state !== "present" && state !== "all" ? `No finding ${STATE_WORDS3[state] ?? state}` : unjudged.length === l.measured.lanes.length ? `No finding, but nothing was compared yet (${[...new Set(unjudged.map((x) => x.word))].join(", ")}): measure again before calling it clean` : `Nothing ${STATE_WORDS.worse.toLowerCase()}, nothing ${STATE_WORDS.warning.toLowerCase()}, nothing ${STATE_WORDS.better.toLowerCase()}`;
12100
13099
  const heated = c.heat_runs ?? 0;
12101
13100
  const more = [
12102
13101
  heated ? `${INFO_WORDS.heat.toLowerCase()} in ${plural2(heated, "run")} (run health, layer 3)` : "",
@@ -12140,10 +13139,12 @@ function formatBranchStatus(s, opts = {}) {
12140
13139
  if (s.lanes.length)
12141
13140
  r.both("");
12142
13141
  for (const l of s.lanes) {
12143
- const shown = l.result === "blocks" ? `${l.word} (blocks)` : badgedWord(l.word, l.blocking);
13142
+ const shown = `${l.result === "blocks" ? `${l.word} (blocks)` : badgedWord(l.word, l.blocking)}${l.stale ? ` (${STALE_FLAG})` : ""}`;
12144
13143
  const sha = l.commit?.sha?.slice(0, 7);
12145
13144
  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)}` : ""}`);
12146
13145
  r.both(...resultContextLines(l.context, opts.verbose, opts.form === "short"));
13146
+ r.both(...(l.comparison_notes ?? []).map((line) => ` ${line}`));
13147
+ r.full(...(l.session_speed ?? []).map((line) => ` ${line}`));
12147
13148
  for (const f of l.worst)
12148
13149
  r.both(` ${f.id} ${f.headline}`);
12149
13150
  if (l.present_findings > l.worst.length)
@@ -12152,6 +13153,7 @@ function formatBranchStatus(s, opts = {}) {
12152
13153
  r.full(` ${l.link}`);
12153
13154
  }
12154
13155
  const links = s.lanes.filter((l) => l.link).length;
13156
+ const staleLanes = s.problems?.stale_lanes ?? 0;
12155
13157
  const heated = s.counts.heat_runs ?? 0;
12156
13158
  const more = [
12157
13159
  [false, heated ? `${INFO_WORDS.heat.toLowerCase()} in ${plural2(heated, "run")} (information, never blocks)` : ""],
@@ -12160,6 +13162,7 @@ function formatBranchStatus(s, opts = {}) {
12160
13162
  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` : ""
12161
13163
  ],
12162
13164
  [true, statusProblemsLine(s)],
13165
+ [true, staleLanes ? `${staleWords(staleLanes)}: ${staleLanes === 1 ? "its word and problems are" : "their words and problems are"} of older rules` : ""],
12163
13166
  [true, s.problems?.unread_lanes ? `${unreadWords(s.problems.unread_lanes)} for problems: ${staleResult(s.problems.unread_lanes)}` : ""],
12164
13167
  [true, s.links.overview ? `Dashboard: ${s.links.overview}` : ""],
12165
13168
  [true, s.worst.length ? "One finding with its evidence and a prompt: keepsnappy finding <id> --prompt" : ""]
@@ -12175,9 +13178,11 @@ function statusProblemsLine(s) {
12175
13178
  const p = s.problems;
12176
13179
  if (!p?.count)
12177
13180
  return "";
12178
- 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}`;
13181
+ return `${problemCountsText(p, "this branch’s")}. Not a regression, never blocks: list_problems, or keepsnappy problems --branch ${s.branch}`;
12179
13182
  }
12180
13183
  var unreadWords = (n) => `${n} journey × phone not read`;
13184
+ var STALE_FLAG = staleFlagText(["config"]);
13185
+ var staleWords = (n) => `${n} journey × phone from a stale result (${STALE_FLAG})`;
12181
13186
  var staleResult = (n) => `${n === 1 ? "its stored result is" : "their stored results are"} not current`;
12182
13187
  function formatProblemList(l, opts = {}) {
12183
13188
  const r = new Rows;
@@ -12194,7 +13199,7 @@ function formatProblemList(l, opts = {}) {
12194
13199
  return r.render(opts.form);
12195
13200
  }
12196
13201
  if (c.count)
12197
- 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)` : ""}`);
13202
+ r.both(`${b}: ${problemCountsText(c, b === l.baseline_branch ? "main’s" : "this branch’s")}`);
12198
13203
  else if (read)
12199
13204
  r.both(`${b}: no problem over budget in the newest run set (${read} journey × phone read)`);
12200
13205
  else
@@ -12202,10 +13207,12 @@ function formatProblemList(l, opts = {}) {
12202
13207
  r.both(l.note);
12203
13208
  if (read && unread)
12204
13209
  r.both(`${unreadWords(unread)}: ${staleResult(unread)}, so nothing is known about ${unread === 1 ? "its" : "their"} problems.`);
13210
+ if (c.stale_lanes)
13211
+ r.both(`${staleWords(c.stale_lanes)}: ${c.stale_lanes === 1 ? "its" : "their"} problems are listed, each marked “${STALE_MARK}”.`);
12205
13212
  l.problems.forEach((p, i) => {
12206
13213
  const keep = i < SHORT_PLACES;
12207
13214
  r.add(keep, "", `${l.offset + i + 1}. ${p.text}: ${p.place_name}${opts.verbose ? ` (${p.place})` : ""}${p.intermittent ? ` — ${p.note}, not counted` : ""}`);
12208
- r.add(keep, ` ${p.journey_title ?? p.journey} on ${p.phone_name}, ${p.runs_text}${p.tag_word ? `, ${p.tag_word}` : ""}`);
13215
+ 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}` : ""}`);
12209
13216
  const top = p.top_components[0];
12210
13217
  const topName = componentReadNames(p.top_components)[0];
12211
13218
  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);
@@ -12228,76 +13235,92 @@ function formatProblemList(l, opts = {}) {
12228
13235
  }
12229
13236
  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";
12230
13237
  function formatCompare(r, opts = {}) {
12231
- const out = [
12232
- `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 })}`,
12233
- `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 })}`,
12234
- `${r.same_journey ? "Same journey" : "Different journeys"}, ${r.same_phone ? "same phone" : "different phones"}. ${r.note}`
12235
- ];
13238
+ const rows = new Rows;
13239
+ 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}`);
12236
13240
  if (r.setup_differs.length)
12237
- out.push(`Measured differently: ${r.setup_differs.map((d) => `${d.field} ${JSON.stringify(d.a)} → ${JSON.stringify(d.b)}`).join(", ")}`);
13241
+ rows.both(`Measured differently: ${r.setup_differs.map((d) => `${d.field} ${JSON.stringify(d.a)} → ${JSON.stringify(d.b)}`).join(", ")}`);
12238
13242
  for (const w of r.windows) {
12239
13243
  const ms = w.metrics.filter((m) => m.a !== null || m.b !== null);
12240
13244
  const c = w.component_page ?? w.components;
12241
13245
  if (!ms.length && !c)
12242
13246
  continue;
12243
- out.push("", `${w.window_name}${opts.verbose ? ` (${w.window})` : ""}`);
13247
+ rows.both("", `${w.window_name}${opts.verbose ? ` (${w.window})` : ""}`);
12244
13248
  const singles = [...c?.added ?? [], ...c?.removed ?? [], ...w.component_page?.uncompared?.a ?? [], ...w.component_page?.uncompared?.b ?? []];
12245
13249
  const components = [...c?.rows ?? [], ...singles];
12246
13250
  const names = componentReadNames(components.map((row) => ({ key: row.key, name: row.component })));
12247
13251
  const shown = new Map(components.map((row, i) => [row.key, names[i]]));
12248
13252
  for (const row of c?.rows ?? [])
12249
- 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})` : ""}`);
12250
- for (const [label, rows] of [
13253
+ 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})` : ""}`);
13254
+ for (const [label, list] of [
12251
13255
  [READ_WORDS.onlyB, c?.added ?? []],
12252
13256
  [READ_WORDS.onlyA, c?.removed ?? []]
12253
13257
  ]) {
12254
- for (const row of rows)
12255
- out.push(` ${shown.get(row.key)}: ${label}; ${row.self_ms === null ? "–" : exactComponentMs(row.self_ms)}${opts.verbose ? ` (${row.key})` : ""}`);
13258
+ for (const row of list)
13259
+ rows.full(` ${shown.get(row.key)}: ${label}; ${row.self_ms === null ? "–" : exactComponentMs(row.self_ms)}${opts.verbose ? ` (${row.key})` : ""}`);
12256
13260
  }
12257
13261
  const page = w.component_page;
12258
13262
  if (page) {
12259
13263
  for (const side of ["a", "b"]) {
12260
13264
  if (!page.measured[side])
12261
- out.push(` ${notMeasuredText(side)}`);
13265
+ rows.full(` ${notMeasuredText(side)}`);
12262
13266
  for (const row of page.uncompared?.[side] ?? [])
12263
- 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})` : ""}`);
13267
+ 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})` : ""}`);
12264
13268
  }
12265
- out.push(` ${componentPageText(page.page, components.length)}`);
13269
+ rows.full(` ${componentPageText(page.page, components.length)}`);
12266
13270
  } else if (c?.totals)
12267
- out.push(` ${componentListsText(c.totals, { rows: c.rows.length, added: c.added?.length ?? 0, removed: c.removed?.length ?? 0 })}`);
13271
+ rows.full(` ${componentListsText(c.totals, { rows: c.rows.length, added: c.added?.length ?? 0, removed: c.removed?.length ?? 0 })}`);
12268
13272
  if (c?.omissions)
12269
13273
  for (const side of ["a", "b"]) {
12270
13274
  if (page && !page.measured[side])
12271
13275
  continue;
12272
13276
  const o = c.omissions[side];
12273
13277
  if (o.sdkOmitted === null || o.omitted === null || o.sdkOmitted > 0 || o.omitted > 0)
12274
- out.push(` ${omissionText({ run_id: side === "a" ? READ_WORDS.runA : READ_WORDS.runB, ...o })}`);
13278
+ rows.full(` ${omissionText({ run_id: side === "a" ? READ_WORDS.runA : READ_WORDS.runB, ...o })}`);
12275
13279
  }
13280
+ 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));
12276
13281
  for (const m of ms)
12277
- out.push(` ${m.label.padEnd(28)}${m.a_text.padStart(10)} → ${m.b_text}`);
13282
+ rows.add(top.has(m), ` ${m.label.padEnd(28)}${m.a_text.padStart(10)} → ${m.b_text}`);
12278
13283
  }
12279
- return out.join(`
12280
- `);
13284
+ const p = r.windows_page;
13285
+ if (p) {
13286
+ const order = p.order === "moved" ? ", the ones whose time moved most first" : "";
13287
+ 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`;
13288
+ rows.both("", p.next_offset !== null ? `${shownText}; the rest from offset ${p.next_offset} (--offset ${p.next_offset}).` : `${shownText}.`);
13289
+ }
13290
+ return rows.render(opts.form, ["the component lines", `the measures beyond each place's ${SHORT_COMPARE_MEASURES} times that moved most`]);
12281
13291
  }
12282
13292
  function formatHistory(h, opts = {}) {
12283
13293
  const r = new Rows;
12284
13294
  r.both(`${h.window_name}${opts.verbose ? ` (${h.window})` : ""} in ${h.journey_title ?? h.journey} on ${h.branch}: ${h.points.length ? `${plural2(h.points.length, "run")}, oldest first` : h.note ?? "no runs"}`);
12285
13295
  const ms = h.measures.slice(0, 4);
13296
+ const renamed = h.renamed;
13297
+ const renamedAt = renamed ? h.points.findIndex((p) => p.run_id === renamed.from_run_id) : -1;
13298
+ const renamedShown = renamedAt >= h.points.length - SHORT_POINTS;
12286
13299
  if (h.points.length) {
12287
13300
  r.both(` ${"when".padEnd(17)}${ms.map((m) => (m.label.length > 22 ? `${m.label.slice(0, 21)}…` : m.label).padStart(24)).join("")} commit`);
12288
13301
  h.points.forEach((p, i) => {
12289
- r.add(i >= h.points.length - SHORT_POINTS, ` ${(p.at ?? "").slice(0, 16).replace("T", " ").padEnd(17)}${ms.map((m) => fieldValueText(m.metric, p.values[m.metric]).padStart(24)).join("")} ${commitText(p.commit)}`);
13302
+ if (renamed && i === renamedAt) {
13303
+ const full = `${placeNamesChangedTitle(renamed.sdk)}: ${placeWasText(renamed.legacy_window)}`;
13304
+ if (renamedShown)
13305
+ r.twin(full, `${placeChartMarker(sdkTag(renamed.sdk))}: ${placeWasText(renamed.legacy_window)}`);
13306
+ else
13307
+ r.full(full);
13308
+ }
13309
+ r.add(i >= h.points.length - SHORT_POINTS, ` ${(p.at ?? "").slice(0, 16).replace("T", " ").padEnd(17)}${ms.map((m) => fieldValueText(m.metric, p.values[m.metric]).padStart(24)).join("")} ${commitText(p.commit)}${opts.verbose && p.window ? ` (${p.window})` : ""}`);
12290
13310
  });
12291
13311
  }
12292
13312
  if (h.findings.length)
12293
13313
  r.both("", "Findings on this window:", ...h.findings.map((f) => ` ${f.answer.headline} — ${f.where.branch} (${f.id})`));
12294
13314
  const older = Math.max(0, h.points.length - SHORT_POINTS);
12295
- return r.render(opts.form, older ? [plural2(older, "older run")] : []);
13315
+ return r.render(opts.form, [
13316
+ ...older ? [plural2(older, "older run")] : [],
13317
+ ...renamed && renamedAt >= 0 && !renamedShown ? [placeNamesChangedOmitted(sdkTag(renamed.sdk))] : []
13318
+ ]);
12296
13319
  }
12297
13320
  function formatCommits(c) {
12298
13321
  return [
12299
- `${plural2(c.commits.length, "measured commit")} (${plural2(c.sessions, "session")}) on ${c.branch}, oldest first:`,
12300
- ...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)}` : ""}`),
13322
+ `${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:`,
13323
+ ...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)}` : ""}`),
12301
13324
  c.note
12302
13325
  ].join(`
12303
13326
  `);
@@ -12333,7 +13356,7 @@ function formatSessionPlaces(p, opts = {}) {
12333
13356
  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);
12334
13357
  const by = p.filters.measure;
12335
13358
  const byLabel = p.places.flatMap((x) => x.measures).find((m) => m.measure === by)?.label ?? by;
12336
- 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` : ""}.`, "");
13359
+ 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)}` : ""}.`, "");
12337
13360
  let componentLines = 0;
12338
13361
  p.places.forEach((place, i) => {
12339
13362
  const m = place.measures.find((x) => x.measure === by);
@@ -12406,13 +13429,13 @@ function formatMainSessions(h, opts = {}) {
12406
13429
  if (!s.places)
12407
13430
  r.add(k, ` ${MAIN_SUMMARY_PENDING}${s.summary_reason ? ` (${s.summary_reason})` : ""}`);
12408
13431
  else
12409
- 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)}` : ""}`);
13432
+ 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)}` : ""}`);
12410
13433
  if (s.waiting_sessions)
12411
13434
  r.add(k, ` ${waitingSessionsWords(s.waiting_sessions)}`);
12412
13435
  if (s.memory)
12413
13436
  r.add(k, ` Memory: ${MAIN_TIER_WORDS[s.memory.tier]}, ${s.memory.median} MB (n ${s.memory.n})${s.memory.cant_reason ? `; ${mainCantWords(s.memory.cant_reason, baseline)}` : ""}`);
12414
13437
  for (const v of h.versions.filter((v) => v.from_run_set_id === s.run_set_id))
12415
- r.add(k, ` Version: ${v.label}; ${mainCutWords(v)}`);
13438
+ r.add(k, ` Version: ${v.label}; ${mainCutWords(v)}`, ...renamedReleaseLines(v.renamed));
12416
13439
  for (const c of h.changes.filter((c) => c.from_run_set_id === s.run_set_id)) {
12417
13440
  r.add(s === latest, ` Main change: ${windowQualified(c.place)}${opts.verbose ? ` (${c.place}; ${c.ref})` : ""} ${storedRefWords(c.ref, h.changes.filter((x) => x.place === c.place && x.from_run_set_id === c.from_run_set_id).map((x) => x.ref))}, ${c.before_median ?? "–"} → ${c.after_median ?? "–"}`);
12418
13441
  if (k && s !== latest)
@@ -12425,15 +13448,36 @@ function formatMainSessions(h, opts = {}) {
12425
13448
  ...otherChanges ? [plural2(otherChanges, "main change") + " of the other recent sessions"] : []
12426
13449
  ]);
12427
13450
  }
13451
+ function formatMainChanges(m) {
13452
+ const r = new Rows().both("Main changes: confirmed level shifts of main, newest first, with the commits from the session before to the first session at the new level.");
13453
+ if (!m.changes.length)
13454
+ r.both("No main changes recorded for this journey and phone.");
13455
+ const short = (sha) => sha?.slice(0, 7) ?? "–";
13456
+ for (const c of m.changes) {
13457
+ r.both(`${when(c.at)} ${windowQualified(c.place)} ${storedRefWords(c.measure, m.changes.filter((x) => x.place === c.place && x.at === c.at).map((x) => x.measure))}, ${c.from ?? "–"} → ${c.to ?? "–"}, ${MAIN_TIER_WORDS[c.direction]}`);
13458
+ r.both(` Commits ${short(c.commits[0])} → ${short(c.commits[1])}`);
13459
+ if (c.prs === null)
13460
+ r.both(" PRs not known: no stored merges reach the session before (an older agent, a shallow clone, or no session before)");
13461
+ else if (!c.prs.length)
13462
+ r.both(" No PRs in this range: both sessions ran on the same commit");
13463
+ for (const p of c.prs ?? [])
13464
+ r.both(` ${p.number === null ? `${short(p.sha)} ${p.title} (no PR)` : `#${p.number} ${p.title}`}`);
13465
+ }
13466
+ return r.render("full");
13467
+ }
12428
13468
  function formatMainSessionPlaces(p, opts = {}) {
12429
13469
  const r = new Rows;
12430
13470
  if (p.status !== "ok")
12431
13471
  return r.both(`${MAIN_SUMMARY_PENDING}${p.summary_reason ? ` (${p.summary_reason})` : ""}`).render(opts.form);
12432
13472
  const baselineSessions = p.baseline_sessions;
12433
13473
  r.both(`Main session places: tiers are verdicts vs ${p.baseline_sessions === undefined ? "earlier sessions" : `the ${p.baseline_sessions} sessions before`}.`);
12434
- for (const x of p.places)
12435
- 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 } })}` : ""}`);
12436
- const inNorm = p.places.filter((x) => x.tier === "same").length;
13474
+ 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);
13475
+ const places = p.places.filter((x) => kept.has(x));
13476
+ for (const x of places)
13477
+ 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)}` : ""}`);
13478
+ if (places.length < p.places.length)
13479
+ r.both(` ${foldedTwinsText(p.places.length - places.length)}`);
13480
+ const inNorm = places.filter((x) => x.tier === "same").length;
12437
13481
  return r.render(opts.form, inNorm ? [plural2(inNorm, "place") + " in norm"] : []);
12438
13482
  }
12439
13483
  var signed = (n) => `${n > 0 ? "+" : ""}${Number(n.toFixed(3))}`;
@@ -12443,7 +13487,7 @@ function formatMainVsPrevious(m, opts = {}) {
12443
13487
  if (m.cut === "unknown")
12444
13488
  r.both("History restart: unknown; current summaries unavailable.");
12445
13489
  else if (m.cut)
12446
- r.both(`History restart: ${m.cut.label}; ${m.cut.cuts.join(", ")}. Numbers cross this restart.`);
13490
+ r.both(`History restart: ${m.cut.label}; ${m.cut.cuts.join(", ")}. Numbers cross this restart.`, ...renamedReleaseLines(m.cut.renamed));
12447
13491
  if (m.not_computed.length)
12448
13492
  r.both(`not_computed: ${m.not_computed.join(", ")}`);
12449
13493
  if (m.stale.length)
@@ -12456,6 +13500,28 @@ function formatMainVsPrevious(m, opts = {}) {
12456
13500
  const smaller = m.places.filter((p, i) => i >= SHORT_PLACES && p.status === "compared").length;
12457
13501
  return r.render(opts.form, smaller ? [plural2(smaller, "smaller mover")] : []);
12458
13502
  }
13503
+ function renamedReleaseLines(renamed) {
13504
+ if (!renamed)
13505
+ return [];
13506
+ return [...renamed.places.length ? [` ${placeNamesChangedTitle(renamed.sdk)}`] : [], ...renamed.dimensions.includes("component") ? [` ${componentNotJoinedText(renamed.sdk)}`] : []];
13507
+ }
13508
+ function renamedLines(renamed, places) {
13509
+ if (!renamed)
13510
+ return [];
13511
+ const asked = places.filter((p) => p.legacy_window !== undefined);
13512
+ const windows = [...new Map(asked.map((p) => [p.window, p.legacy_window ?? null])).entries()];
13513
+ const component = renamed.dimensions.includes("component") && places.some((p) => p.ref.startsWith("b:component:"));
13514
+ return [
13515
+ ...windows.length ? [` ${placeNamesChangedTitle(renamed.sdk)}`, ...windows.map(([w, old]) => ` ${windowQualified(w)}: ${old ? placeWasText(old) : PLACE_NOT_JOINED_TEXT}`)] : [],
13516
+ ...component ? [` ${componentNotJoinedText(renamed.sdk)}`] : []
13517
+ ];
13518
+ }
13519
+ function storedValueText(ref, v, unit) {
13520
+ const rest = ref.slice(2);
13521
+ const field = ref.startsWith("b:") ? `${rest.slice(0, rest.lastIndexOf(":"))}.${rest.slice(rest.lastIndexOf(":") + 1)}` : rest;
13522
+ const text = fieldValueText(field, v);
13523
+ return unit && !/[^\d.,\s-]/.test(text) ? `${text} ${unit}` : text;
13524
+ }
12459
13525
  function formatPlaceTrend(t, opts = {}) {
12460
13526
  const r = new Rows().both(`${t.journey} · ${t.phone.model} · ${t.baseline_branch}: stored per-session statistics`);
12461
13527
  const newest = new Set(t.sessions.slice(-SHORT_SESSIONS).map((s) => s.run_set_id));
@@ -12467,6 +13533,8 @@ function formatPlaceTrend(t, opts = {}) {
12467
13533
  r.both(`History restarts unknown: ${session.run_set_id} (no current main summary in the history window)`);
12468
13534
  for (const restart of t.restarts.filter((x) => x.from_run_set_id === session.run_set_id)) {
12469
13535
  r.add(k, `History restarts: ${restart.label} (${restart.cuts.join(", ")})`);
13536
+ for (const line of renamedLines(restart.renamed, t.places))
13537
+ r.add(k, line);
12470
13538
  if (!k)
12471
13539
  olderRestarts++;
12472
13540
  }
@@ -12474,10 +13542,10 @@ function formatPlaceTrend(t, opts = {}) {
12474
13542
  const value = place.sessions.find((s) => s.run_set_id === session.run_set_id);
12475
13543
  if (!value)
12476
13544
  continue;
12477
- const unit = place.unit ? ` ${place.unit}` : "";
13545
+ const value_ = (v) => storedValueText(place.ref, v, place.unit);
12478
13546
  if (!k)
12479
13547
  olderSessions.add(session.run_set_id);
12480
- r.add(k, `${session.completed_at?.slice(0, 10) ?? "date unknown"} ${session.commit.sha?.slice(0, 7) ?? "commit unknown"} · ${windowQualified(place.window)}${opts.verbose ? ` (${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}`);
13548
+ 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}`);
12481
13549
  }
12482
13550
  }
12483
13551
  if (!t.sessions.length)
@@ -12534,6 +13602,8 @@ var branchStatus = (t, branch) => get(t, "v1/branches/status", { branch });
12534
13602
  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 });
12535
13603
  var getFinding = (t, id) => get(t, `v1/findings/${encodeURIComponent(id)}`);
12536
13604
  var compareRuns = (t, a, b, page = {}) => get(t, "v1/compare", { a, b, ...page });
13605
+ var SHORT_COMPARE_PLACES = 10;
13606
+ var compareQuery = (short, page) => short ? { order: "moved", limit: SHORT_COMPARE_PLACES, ...page } : page;
12537
13607
  var windowHistory = (t, q) => get(t, "v1/history", q);
12538
13608
  var commitsBetween = (t, q) => get(t, "v1/commits", q);
12539
13609
  var layers = (t) => get(t, "v1/layers");
@@ -12554,6 +13624,7 @@ var sessionPlaces = (t, f) => get(t, "v1/sessions/places", {
12554
13624
  });
12555
13625
  var SESSION_SORTS = ["median", "p90", "mad"];
12556
13626
  var mainSessions = (t, q) => get(t, "v1/main/sessions", { ...q });
13627
+ var mainChanges = (t, q) => get(t, "v1/main/changes", { ...q });
12557
13628
  var mainSessionPlaces = (t, runSetId, q) => get(t, `v1/main/sessions/${encodeURIComponent(runSetId)}/places`, { ...q });
12558
13629
  var mainVsPrevious = (t, q) => {
12559
13630
  const { run_set, ...query } = q;
@@ -12567,12 +13638,13 @@ var FINDINGS_USAGE = `keepsnappy findings [--branch NAME | --main] [--journey KE
12567
13638
  problems --branch NAME | --main [--journey KEY] [--phone MODEL] [--limit N] [--offset N] [--short] [--json]
12568
13639
  (the single problems "Over budget" / "Far over budget" in the branch's own runs, whatever main does: not a regression, never blocking)
12569
13640
  finding <id> [--prompt] [--short] [--json] (--prompt: only the hand-off for a coding agent; with --json the whole finding)
12570
- compare <runA> <runB> [--json] (run ids or external ids; one run against one, no statistics)
13641
+ 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)
12571
13642
  history <journey> <window> [--phone MODEL] [--short] [--json] (exact window id, e.g. "press:live.chat.send")
12572
13643
  commits <from> [<to>] [--branch NAME] [--json] (measured commits between two run sets or commit hashes)
12573
13644
  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]
12574
13645
  (every place of one session from its stored statistics: no verdict; the newest closed run set of the journey when --run-set is not given)
12575
13646
  main --journey KEY --phone MODEL [--sessions N | --session ID] [--vs-previous] [--measure REF] [--limit N] [--offset N] [--short] [--json]
13647
+ main --journey KEY --phone MODEL --changes [--limit N] [--json] (main's confirmed changes, newest first: commit range from the session before to the first session at the new level, and the PRs merged in it)
12576
13648
  layers [--json]
12577
13649
  trend <journey> <window words...> --phone MODEL [--window PLACE ...] [--ref REF ...] [--sessions N] [--short] [--json]
12578
13650
  (the words after the journey are one window name; more windows with repeated --window)
@@ -12582,7 +13654,7 @@ ${READ_USAGE.detailFlag}
12582
13654
  --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)
12583
13655
  (key: KEEPSNAPPY_KEY or --key-env NAME; API: --api URL or KEEPSNAPPY_API)`;
12584
13656
  var STATES = ["present", "not-judged", "resolved", "all"];
12585
- var SHORT_COMMANDS = new Set(["findings", "status", "problems", "finding", "history", "places", "main", "trend"]);
13657
+ var SHORT_COMMANDS = new Set(["findings", "status", "problems", "finding", "compare", "history", "places", "main", "trend"]);
12586
13658
  var FINDINGS_COMMANDS = new Set(["findings", "status", "problems", "finding", "compare", "history", "commits", "places", "layers", "main", "trend"]);
12587
13659
  async function findingsCommand(cmd, argv, rt) {
12588
13660
  const args = (() => {
@@ -12602,6 +13674,7 @@ async function findingsCommand(cmd, argv, rt) {
12602
13674
  state: { type: "string" },
12603
13675
  session: { type: "string" },
12604
13676
  "vs-previous": { type: "boolean" },
13677
+ changes: { type: "boolean" },
12605
13678
  limit: { type: "string" },
12606
13679
  offset: { type: "string" },
12607
13680
  "run-set": { type: "string" },
@@ -12662,6 +13735,11 @@ async function findingsCommand(cmd, argv, rt) {
12662
13735
  if (sessions !== undefined && (o.session || o["vs-previous"]))
12663
13736
  throw new UsageError(["--sessions is for the session list"]);
12664
13737
  const lane = { journey: o.journey, phone: o.phone };
13738
+ if (o.changes) {
13739
+ if (sessions !== undefined || o.session || o["vs-previous"] || o.measure || o.short || offset !== undefined)
13740
+ throw new UsageError(["--changes takes only --journey, --phone, --limit and --json"]);
13741
+ return out(await mainChanges(t, { ...lane, ...limit ? { limit } : {} }), formatMainChanges);
13742
+ }
12665
13743
  if (o["vs-previous"])
12666
13744
  return out(await mainVsPrevious(t, { ...lane, run_set: o.session, measure: o.measure, limit, offset }), formatMainVsPrevious);
12667
13745
  if (o.session)
@@ -12715,7 +13793,8 @@ async function findingsCommand(cmd, argv, rt) {
12715
13793
  const size = o["top-components"] === undefined ? 12 : Number(o["top-components"]);
12716
13794
  if (!Number.isInteger(size) || size < 1 || size > 200)
12717
13795
  throw new UsageError([READ_USAGE.compareTop]);
12718
- const query = page || o.all || o["top-components"] !== undefined ? { component_offset: ((page ?? 1) - 1) * size, component_limit: size } : {};
13796
+ const components = page || o.all || o["top-components"] !== undefined ? { component_offset: ((page ?? 1) - 1) * size, component_limit: size } : {};
13797
+ const query = compareQuery(o.short === true, { ...components, ...limit ? { limit } : {}, ...offset !== undefined ? { offset } : {} });
12719
13798
  const compared = await compareRuns(t, p[0], p[1], query);
12720
13799
  return out(o.all ? await allCompare(t, compared, size) : compared, formatCompare);
12721
13800
  }
@@ -12868,7 +13947,7 @@ var USAGE = `keepsnappy <command>
12868
13947
  [--device-model NAME --device-os VERSION] [--scenario NAME] [--replay <out dir>[:ARM] ...]
12869
13948
  [--no-markers] [-- <e2e command...>]
12870
13949
  (--fail-on-worse: exit 3 only when the verdict computed on that run set is Worse and blocking; closing never computes one,
12871
- so with no computed verdict it checks nothing: "Verdict not computed yet (computed on demand): --fail-on-worse checked nothing", exit 0)
13950
+ so with no computed verdict it checks nothing: "${UNCHECKED_GATE}", exit 0)
12872
13951
  measure --repeat N [--arms A=<app|bundle>,B=<app|bundle>] [--arm-env 'A:KEY=VAL' ...] [--bundle-id ID] [--platform ios-sim|ios-device]
12873
13952
  [--device UDID] [--out DIR] [--timeout SEC] [--ri-capture observations.ndjson] [--mem-window NAME]
12874
13953
  [--clock-offset-ms N] [--no-markers]
@@ -12898,7 +13977,7 @@ async function bundleIdOf(deps, target, explicit) {
12898
13977
  if (explicit)
12899
13978
  return explicit;
12900
13979
  if (!target)
12901
- throw new Error("give --bundle-id (or an arm whose target is a bundle id or .app)");
13980
+ throw new ConfigError(["give --bundle-id (or an arm whose target is a bundle id or .app)"]);
12902
13981
  if (!isBuildPath(target))
12903
13982
  return target;
12904
13983
  const r = await deps.spawner.run("plutil", ["-extract", "CFBundleIdentifier", "raw", `${target.replace(/\/$/, "")}/Info.plist`]);