File size: 2,649 Bytes
50841a0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
import crypto from 'node:crypto';

/**
 * Per-engine weekly sampling. Each engine has a target coverage rate —
 * the fraction of a week's unique pages it should run on (axe 100%,
 * Alfa 30%, Lighthouse 10%, etc.). Rates are set in one place,
 * config/targets.yml under `sampling:`.
 *
 * The scan is incremental: pages are scanned in batches across the week,
 * so we cannot pick "30% of this week's pages" up front — we don't know
 * the full set until the week ends. Instead each page decides, at scan
 * time, whether it is in a given engine's sample, using a stable hash of
 * (pageId + engine + week). Because the hash is uniform over pageIds,
 * about `rate` of pages fall in the sample, and the decision is:
 *   - reproducible: re-running a week reproduces the same sample;
 *   - stable within a week: a page stays in (or out of) an engine's
 *     sample no matter which nightly run reaches it;
 *   - independent per engine: Alfa's 30% is not correlated with
 *     Lighthouse's 10%.
 */

/**
 * Normalize a configured rate to a fraction in [0,1]. Accepts a fraction
 * (0.3), a percentage (30), or a percent string ("30%"). Missing/invalid
 * rates default to 0 (engine off) so a typo never silently runs an
 * expensive engine everywhere.
 */
export function normalizeRate(raw) {
  if (raw == null) return 0;
  let n = typeof raw === 'string' ? parseFloat(raw.replace('%', '')) : Number(raw);
  if (!Number.isFinite(n) || n < 0) return 0;
  if (n > 1) n = n / 100; // a value above 1 is read as a percentage
  return Math.min(1, n);
}

/**
 * Deterministic membership test: is this page in `engine`'s sample for
 * `week`, given a coverage `rate` (fraction or percentage)?
 *
 * rate >= 1 -> always; rate <= 0 -> never. Otherwise compare a uniform
 * hash of (pageId|engine|week) in [0,1) against the rate.
 */
export function shouldRun(engine, pageId, week, rate) {
  const r = normalizeRate(rate);
  if (r >= 1) return true;
  if (r <= 0) return false;
  return hashUnit(`${pageId}|${engine}|${week}`) < r;
}

/** Map a string to a uniform value in [0,1) via the first 52 bits of SHA-256. */
function hashUnit(s) {
  const hex = crypto.createHash('sha256').update(s).digest('hex').slice(0, 13); // 52 bits
  return parseInt(hex, 16) / 2 ** 52;
}

/**
 * Resolve the effective sampling rates for a target: defaults merged with
 * any per-target overrides. Engines absent from the map default to 0
 * (off), except via `fallback` which callers can use for engines that
 * should run unless explicitly disabled.
 */
export function ratesFor(config, target) {
  return { ...(config.sampling ?? {}), ...(target.sampling ?? {}) };
}