tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

test_ShortcutRanker.js (54880B)


      1 "use strict";
      2 
      3 ChromeUtils.defineESModuleGetters(this, {
      4  sinon: "resource://testing-common/Sinon.sys.mjs",
      5 });
      6 
      7 add_task(async function test_weightedSampleTopSites_no_guid_last() {
      8  // Ranker are utilities we are testing
      9  const Ranker = ChromeUtils.importESModule(
     10    "resource://newtab/lib/SmartShortcutsRanker/RankShortcuts.mjs"
     11  );
     12  const provider = new Ranker.RankShortcutsProvider();
     13  // We are going to stub a database call
     14  const { NewTabUtils } = ChromeUtils.importESModule(
     15    "resource://gre/modules/NewTabUtils.sys.mjs"
     16  );
     17 
     18  await NewTabUtils.init();
     19 
     20  const sandbox = sinon.createSandbox();
     21 
     22  // Stub DB call
     23  sandbox
     24    .stub(NewTabUtils.activityStreamProvider, "executePlacesQuery")
     25    .resolves([
     26      ["a", 5, 10],
     27      ["b", 2, 10],
     28    ]);
     29 
     30  // First item here intentially has no guid
     31  const input = [
     32    { url: "no-guid.com" },
     33    { guid: "a", url: "a.com" },
     34    { guid: "b", url: "b.com" },
     35  ];
     36 
     37  const prefValues = {
     38    trainhopConfig: {
     39      smartShortcuts: {
     40        // fset: "custom", // uncomment iff your build defines a "custom" fset you want to use
     41        eta: 0,
     42        click_bonus: 10,
     43        positive_prior: 1,
     44        negative_prior: 1,
     45        fset: 1,
     46      },
     47    },
     48  };
     49 
     50  const result = await provider.rankTopSites(input, prefValues, {
     51    isStartup: false,
     52  });
     53 
     54  Assert.ok(Array.isArray(result), "returns an array");
     55  Assert.equal(
     56    result[result.length - 1].url,
     57    "no-guid.com",
     58    "top-site without GUID is last"
     59  );
     60 
     61  sandbox.restore();
     62 });
     63 
     64 add_task(async function test_sumNorm() {
     65  const Ranker = ChromeUtils.importESModule(
     66    "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs"
     67  );
     68  let vec = [1, 1];
     69  let result = Ranker.sumNorm(vec);
     70  Assert.ok(
     71    result.every((v, i) => Math.abs(v - [0.5, 0.5][i]) < 1e-6),
     72    "sum norm works as expected for dense array"
     73  );
     74 
     75  vec = [0, 0];
     76  result = Ranker.sumNorm(vec);
     77  Assert.ok(
     78    result.every((v, i) => Math.abs(v - [0.0, 0.0][i]) < 1e-6),
     79    "if sum is 0.0, it should return the original vector, input is zeros"
     80  );
     81 
     82  vec = [1, -1];
     83  result = Ranker.sumNorm(vec);
     84  Assert.ok(
     85    result.every((v, i) => Math.abs(v - [1.0, -1.0][i]) < 1e-6),
     86    "if sum is 0.0, it should return the original vector, input contains negatives"
     87  );
     88 });
     89 
     90 add_task(async function test_computeLinearScore() {
     91  const Ranker = ChromeUtils.importESModule(
     92    "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs"
     93  );
     94 
     95  let entry = { a: 1, b: 0, bias: 1 };
     96  let weights = { a: 1, b: 0, bias: 0 };
     97  let result = Ranker.computeLinearScore(entry, weights);
     98  Assert.equal(result, 1, "check linear score with one non-zero weight");
     99 
    100  entry = { a: 1, b: 1, bias: 1 };
    101  weights = { a: 1, b: 1, bias: 1 };
    102  result = Ranker.computeLinearScore(entry, weights);
    103  Assert.equal(result, 3, "check linear score with 1 everywhere");
    104 
    105  entry = { bias: 1 };
    106  weights = { a: 1, b: 1, bias: 1 };
    107  result = Ranker.computeLinearScore(entry, weights);
    108  Assert.equal(result, 1, "check linear score with empty entry, get bias");
    109 
    110  entry = { a: 1, b: 1, bias: 1 };
    111  weights = {};
    112  result = Ranker.computeLinearScore(entry, weights);
    113  Assert.equal(result, 0, "check linear score with empty weights");
    114 
    115  entry = { a: 1, b: 1, bias: 1 };
    116  weights = { a: 3 };
    117  result = Ranker.computeLinearScore(entry, weights);
    118  Assert.equal(result, 3, "check linear score with a missing weight");
    119 });
    120 
    121 add_task(async function test_interpolateWrappedHistogram() {
    122  const Ranker = ChromeUtils.importESModule(
    123    "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs"
    124  );
    125  let hist = [1, 2];
    126  let t = 0.5;
    127  let result = Ranker.interpolateWrappedHistogram(hist, t);
    128  Assert.equal(result, 1.5, "test linear interpolation square in the middle");
    129 
    130  hist = [1, 2, 5];
    131  t = 2.5;
    132  result = Ranker.interpolateWrappedHistogram(hist, t);
    133  Assert.equal(
    134    result,
    135    (hist[0] + hist[2]) / 2,
    136    "test linear interpolation correctly wraps around"
    137  );
    138 
    139  hist = [1, 2, 5];
    140  t = 5;
    141  result = Ranker.interpolateWrappedHistogram(hist, t);
    142  Assert.equal(
    143    result,
    144    hist[2],
    145    "linear interpolation will wrap around to the last index"
    146  );
    147 });
    148 
    149 add_task(async function test_bayesHist() {
    150  const Ranker = ChromeUtils.importESModule(
    151    "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs"
    152  );
    153 
    154  let vec = [1, 2];
    155  let pvec = [0.5, 0.5];
    156  let tau = 2;
    157  let result = Ranker.bayesHist(vec, pvec, tau);
    158  // checking the math
    159  // (1+2*.5)/(3+2)........... 2/5
    160  // (2+2*.5)/(3+2)........... 3/5
    161  Assert.ok(
    162    result.every((v, i) => Math.abs(v - [0.4, 0.6][i]) < 1e-6),
    163    "bayes histogram is expected for typical input"
    164  );
    165  vec = [1, 2];
    166  pvec = [0.5, 0.5];
    167  tau = 0.0;
    168  result = Ranker.bayesHist(vec, pvec, tau);
    169  Assert.ok(
    170    result.every((v, i) => Math.abs(v - vec[i] / 3) < 1e-6), // 3 is sum of vec
    171    "bayes histogram is a sum norming function if tau is 0"
    172  );
    173 });
    174 
    175 add_task(async function test_normSites() {
    176  const Ranker = ChromeUtils.importESModule(
    177    "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs"
    178  );
    179  let Y = {
    180    a: [2, 2],
    181    b: [3, 3],
    182  };
    183  let result = Ranker.normHistDict(Y);
    184  Assert.ok(
    185    result.a.every(
    186      (v, i) => Math.abs(v - [4 / (4 + 9), 4 / (4 + 9)][i]) < 1e-6
    187    ),
    188    "normSites, basic input, first array"
    189  );
    190  Assert.ok(
    191    result.b.every(
    192      (v, i) => Math.abs(v - [9 / (4 + 9), 9 / (4 + 9)][i]) < 1e-6
    193    ),
    194    "normSites, basic input, second array"
    195  );
    196 
    197  Y = [];
    198  result = Ranker.normHistDict(Y);
    199  Assert.deepEqual(result, [], "normSites handles empty array");
    200 });
    201 
    202 add_task(async function test_clampWeights() {
    203  const Ranker = ChromeUtils.importESModule(
    204    "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs"
    205  );
    206  let weights = { a: 0, b: 1000, bias: 0 };
    207  let result = Ranker.clampWeights(weights, 100);
    208  info("clampWeights clamps a big weight vector");
    209  Assert.equal(result.a, 0);
    210  Assert.equal(result.b, 100);
    211  Assert.equal(result.bias, 0);
    212 
    213  weights = { a: 1, b: 1, bias: 1 };
    214  result = Ranker.clampWeights(weights, 100);
    215  info("clampWeights ignores a small weight vector");
    216  Assert.equal(result.a, 1);
    217  Assert.equal(result.b, 1);
    218  Assert.equal(result.bias, 1);
    219 });
    220 
    221 add_task(async function test_updateWeights_batch() {
    222  // Import the module that exports updateWeights
    223  const Ranker = ChromeUtils.importESModule(
    224    "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs"
    225  );
    226 
    227  const eta = 1;
    228  const click_bonus = 1;
    229  const features = ["a", "b", "bias"];
    230 
    231  function sigmoid(x) {
    232    return 1 / (1 + Math.exp(-x));
    233  }
    234  function approxEqual(actual, expected, eps = 1e-12) {
    235    Assert.lessOrEqual(
    236      Math.abs(actual - expected),
    237      eps,
    238      `expected ${actual}${expected}`
    239    );
    240  }
    241 
    242  //  single click on guid_A updates all weights
    243  const initial1 = { a: 0, b: 1, bias: 0.1 };
    244  const scores1 = {
    245    guid_A: { final: 1.1, a: 1, b: 1, bias: 1 },
    246  };
    247 
    248  let updated = await Ranker.updateWeights(
    249    {
    250      data: { guid_A: { clicks: 1, impressions: 0 } },
    251      scores: scores1,
    252      features,
    253      weights: { ...initial1 },
    254      eta,
    255      click_bonus,
    256    },
    257    false
    258  );
    259 
    260  const delta = sigmoid(1.1) - 1; // gradient term for a positive click
    261  approxEqual(updated.a, 0 - Number(delta) * 1);
    262  approxEqual(updated.b, 1 - Number(delta) * 1);
    263  approxEqual(updated.bias, 0.1 - Number(delta) * 1);
    264 
    265  // missing guid -> no-op (weights unchanged)
    266  const initial2 = { a: 0, b: 1000, bias: 0 };
    267  const scores2 = {
    268    guid_A: { final: 1, a: 1, b: 1e-3, bias: 1 },
    269  };
    270 
    271  updated = await Ranker.updateWeights(
    272    {
    273      data: { guid_B: { clicks: 1, impressions: 0 } }, // guid_B not in scores
    274      scores: scores2,
    275      features,
    276      weights: { ...initial2 },
    277      eta,
    278      click_bonus,
    279    },
    280    false
    281  );
    282 
    283  Assert.equal(updated.a, 0);
    284  Assert.equal(updated.b, 1000);
    285  Assert.equal(updated.bias, 0);
    286 });
    287 
    288 add_task(async function test_buildFrecencyFeatures_shape_and_empty() {
    289  const Ranker = ChromeUtils.importESModule(
    290    "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs"
    291  );
    292 
    293  // empty inputs → empty feature maps
    294  let out = await Ranker.buildFrecencyFeatures({}, {});
    295  Assert.ok(
    296    out && "refre" in out && "rece" in out && "freq" in out,
    297    "returns transposed object with feature keys"
    298  );
    299  Assert.deepEqual(out.refre, {}, "refre empty");
    300  Assert.deepEqual(out.rece, {}, "rece  empty");
    301  Assert.deepEqual(out.freq, {}, "freq  empty");
    302 
    303  // single guid with no visits → zeros
    304  out = await Ranker.buildFrecencyFeatures({ guidA: [] }, { guidA: 42 });
    305  Assert.equal(out.refre.guidA, 0, "refre zero with no visits");
    306  Assert.equal(out.freq.guidA, 0, "freq  zero with no visits");
    307  Assert.equal(out.rece.guidA, 0, "rece  zero with no visits");
    308 });
    309 
    310 add_task(async function test_buildFrecencyFeatures_recency_monotonic() {
    311  const Ranker = ChromeUtils.importESModule(
    312    "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs"
    313  );
    314  const sandbox = sinon.createSandbox();
    315 
    316  // Fix time so the decay is deterministic
    317  const nowMs = Date.UTC(2025, 0, 1); // Jan 1, 2025
    318  const clock = sandbox.useFakeTimers({ now: nowMs });
    319 
    320  const dayMs = 864e5;
    321  // visits use microseconds since epoch; function computes age with visit_date_us / 1000
    322  const us = v => Math.round(v * 1000);
    323 
    324  const visitsByGuid = {
    325    // one very recent visit (age ~0d)
    326    recent: [{ visit_date_us: us(nowMs), visit_type: 2 /* any value */ }],
    327    // one older visit (age 10d)
    328    old: [{ visit_date_us: us(nowMs - 10 * dayMs), visit_type: 2 }],
    329    // both recent + old → should have larger rece (sum of decays)
    330    both: [
    331      { visit_date_us: us(nowMs), visit_type: 2 },
    332      { visit_date_us: us(nowMs - 10 * dayMs), visit_type: 2 },
    333    ],
    334  };
    335  const visitCounts = { recent: 10, old: 10, both: 10 };
    336 
    337  const out = await Ranker.buildFrecencyFeatures(visitsByGuid, visitCounts, {
    338    halfLifeDays: 28, // default
    339  });
    340 
    341  Assert.greater(
    342    out.rece.recent,
    343    out.rece.old,
    344    "more recent visit → larger recency score"
    345  );
    346  Assert.greater(
    347    out.rece.both,
    348    out.rece.recent,
    349    "two visits (recent+old) → larger recency sum than just recent"
    350  );
    351 
    352  clock.restore();
    353  sandbox.restore();
    354 });
    355 
    356 add_task(
    357  async function test_buildFrecencyFeatures_log_scaling_and_interaction() {
    358    const Ranker = ChromeUtils.importESModule(
    359      "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs"
    360    );
    361    const sandbox = sinon.createSandbox();
    362    const clock = sandbox.useFakeTimers({ now: Date.UTC(2025, 0, 1) });
    363 
    364    const nowMs = Date.now();
    365    const us = v => Math.round(v * 1000);
    366 
    367    // Same single visit (type held constant) for both GUIDs → same rece and same type sum.
    368    // Only visit_count differs → freq/refre should scale with log1p(visit_count).
    369    const visitsByGuid = {
    370      A: [{ visit_date_us: us(nowMs), visit_type: 2 /* 'typed' typically */ }],
    371      B: [{ visit_date_us: us(nowMs), visit_type: 2 }],
    372    };
    373    const visitCounts = { A: 9, B: 99 }; // different lifetime counts
    374 
    375    const out = await Ranker.buildFrecencyFeatures(visitsByGuid, visitCounts);
    376 
    377    // rece should be identical (same timestamps)
    378    Assert.equal(
    379      out.rece.A,
    380      out.rece.B,
    381      "recency is independent of visit_count"
    382    );
    383 
    384    // freq and refre should scale with log1p(total)
    385    const ratio = Math.log1p(visitCounts.B) / Math.log1p(visitCounts.A);
    386    const approxEqual = (a, b, eps = 1e-9) =>
    387      Assert.lessOrEqual(Math.abs(a - b), eps);
    388 
    389    approxEqual(out.freq.B / out.freq.A, ratio, 1e-9);
    390    approxEqual(out.refre.B / out.refre.A, ratio, 1e-9);
    391 
    392    clock.restore();
    393    sandbox.restore();
    394  }
    395 );
    396 
    397 add_task(async function test_buildFrecencyFeatures_halfLife_effect() {
    398  const Ranker = ChromeUtils.importESModule(
    399    "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs"
    400  );
    401  const sandbox = sinon.createSandbox();
    402  const nowMs = Date.UTC(2025, 0, 1);
    403  const clock = sandbox.useFakeTimers({ now: nowMs });
    404 
    405  const dayMs = 864e5;
    406  const us = v => Math.round(v * 1000);
    407 
    408  // Two visits: one now, one 28 days ago.
    409  const visits = [
    410    {
    411      visit_date_us: us(nowMs),
    412      visit_type: 1,
    413    },
    414    {
    415      visit_date_us: us(nowMs - 28 * dayMs),
    416      visit_type: 1,
    417    },
    418  ];
    419  const visitsByGuid = { X: visits };
    420  const visitCounts = { X: 10 };
    421 
    422  const outShort = await Ranker.buildFrecencyFeatures(
    423    visitsByGuid,
    424    visitCounts,
    425    {
    426      halfLifeDays: 7, // faster decay
    427    }
    428  );
    429  const outLong = await Ranker.buildFrecencyFeatures(
    430    visitsByGuid,
    431    visitCounts,
    432    {
    433      halfLifeDays: 56, // slower decay
    434    }
    435  );
    436 
    437  Assert.greater(
    438    outLong.rece.X,
    439    outShort.rece.X,
    440    "larger half-life → slower decay → bigger recency sum"
    441  );
    442 
    443  clock.restore();
    444  sandbox.restore();
    445 });
    446 
    447 add_task(
    448  async function test_buildFrecencyFeatures_unknown_types_are_zero_bonus() {
    449    const Ranker = ChromeUtils.importESModule(
    450      "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs"
    451    );
    452    const sandbox = sinon.createSandbox();
    453    const clock = sandbox.useFakeTimers({ now: Date.UTC(2025, 0, 1) });
    454 
    455    const nowMs = Date.now();
    456    const us = v => Math.round(v * 1000);
    457 
    458    // Use an unknown visit_type so TYPE_SCORE[visit_type] falls back to 0.
    459    const visitsByGuid = {
    460      U: [{ visit_date_us: us(nowMs), visit_type: 99999 }],
    461    };
    462    const visitCounts = { U: 100 };
    463 
    464    const out = await Ranker.buildFrecencyFeatures(visitsByGuid, visitCounts);
    465 
    466    Assert.equal(out.freq.U, 0, "unknown visit_type → zero frequency bonus");
    467    Assert.equal(out.refre.U, 0, "unknown visit_type → zero interaction");
    468    Assert.greater(out.rece.U, 0, "recency is still > 0 for a recent visit");
    469 
    470    clock.restore();
    471    sandbox.restore();
    472  }
    473 );
    474 
    475 add_task(
    476  async function test_weightedSampleTopSites_new_features_scores_and_norms() {
    477    const Ranker = ChromeUtils.importESModule(
    478      "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs"
    479    );
    480 
    481    // 2 GUIDs with simple, distinct raw values so ordering is obvious
    482    const guids = ["g1", "g2"];
    483    const input = {
    484      guid: guids,
    485      features: ["bmark", "rece", "freq", "refre", "bias"],
    486 
    487      // raw feature maps keyed by guid
    488      bmark_scores: { g1: 1, g2: 0 }, // bookmark flag/intensity
    489      rece_scores: { g1: 2.0, g2: 1.0 }, // recency-only raw
    490      freq_scores: { g1: 0.5, g2: 0.25 }, // frequency-only raw
    491      refre_scores: { g1: 10, g2: 5 }, // interaction raw
    492 
    493      // per-feature normalization state (whatever your normUpdate expects)
    494      norms: { bmark: null, rece: null, freq: null, refre: null },
    495 
    496      // weights: only new features (and bias) contribute to final
    497      weights: { bmark: 1, rece: 1, freq: 1, refre: 1, bias: 0 },
    498 
    499      // unused here (no "thom", "hour", "daily")
    500      clicks: [],
    501      impressions: [],
    502      alpha: 1,
    503      beta: 1,
    504      tau: 0,
    505      hourly_seasonality: {},
    506      daily_seasonality: {},
    507    };
    508 
    509    // Pre-compute what normUpdate will return for each raw vector,
    510    // so we can assert exact equality with weightedSampleTopSites output.
    511    const vecBmark = guids.map(g => input.bmark_scores[g]); // [1, 0]
    512    const vecRece = guids.map(g => input.rece_scores[g]); // [2, 1]
    513    const vecFreq = guids.map(g => input.freq_scores[g]); // [0.5, 0.25]
    514    const vecRefre = guids.map(g => input.refre_scores[g]); // [10, 5]
    515 
    516    const [expBmark, expBmarkNorm] = Ranker.normUpdate(
    517      vecBmark,
    518      input.norms.bmark
    519    );
    520    const [expRece, expReceNorm] = Ranker.normUpdate(vecRece, input.norms.rece);
    521    const [expFreq, expFreqNorm] = Ranker.normUpdate(vecFreq, input.norms.freq);
    522    const [expRefre, expRefreNorm] = Ranker.normUpdate(
    523      vecRefre,
    524      input.norms.refre
    525    );
    526 
    527    const result = await Ranker.weightedSampleTopSites(input);
    528 
    529    // shape
    530    Assert.ok(
    531      result && result.score_map && result.norms,
    532      "returns {score_map, norms}"
    533    );
    534 
    535    // per-feature, per-guid scores match normUpdate outputs
    536    Assert.equal(
    537      result.score_map.g1.bmark,
    538      expBmark[0],
    539      "bmark g1 normalized value"
    540    );
    541    Assert.equal(
    542      result.score_map.g2.bmark,
    543      expBmark[1],
    544      "bmark g2 normalized value"
    545    );
    546 
    547    Assert.equal(
    548      result.score_map.g1.rece,
    549      expRece[0],
    550      "rece g1 normalized value"
    551    );
    552    Assert.equal(
    553      result.score_map.g2.rece,
    554      expRece[1],
    555      "rece g2 normalized value"
    556    );
    557 
    558    Assert.equal(
    559      result.score_map.g1.freq,
    560      expFreq[0],
    561      "freq g1 normalized value"
    562    );
    563    Assert.equal(
    564      result.score_map.g2.freq,
    565      expFreq[1],
    566      "freq g2 normalized value"
    567    );
    568 
    569    Assert.equal(
    570      result.score_map.g1.refre,
    571      expRefre[0],
    572      "refre g1 normalized value"
    573    );
    574    Assert.equal(
    575      result.score_map.g2.refre,
    576      expRefre[1],
    577      "refre g2 normalized value"
    578    );
    579 
    580    // updated norms propagated back out
    581    Assert.deepEqual(result.norms.bmark, expBmarkNorm, "bmark norms updated");
    582    Assert.deepEqual(result.norms.rece, expReceNorm, "rece norms updated");
    583    Assert.deepEqual(result.norms.freq, expFreqNorm, "freq norms updated");
    584 
    585    // THIS WILL FAIL until you fix the bug in the refre block:
    586    //   updated_norms.rece = refre_norm;
    587    // should be:
    588    //   updated_norms.refre = refre_norm;
    589    Assert.deepEqual(result.norms.refre, expRefreNorm, "refre norms updated");
    590 
    591    // final score equals computeLinearScore with provided weights
    592    const expectedFinalG1 = Ranker.computeLinearScore(
    593      result.score_map.g1,
    594      input.weights
    595    );
    596    const expectedFinalG2 = Ranker.computeLinearScore(
    597      result.score_map.g2,
    598      input.weights
    599    );
    600    Assert.equal(
    601      result.score_map.g1.final,
    602      expectedFinalG1,
    603      "final matches linear score (g1)"
    604    );
    605    Assert.equal(
    606      result.score_map.g2.final,
    607      expectedFinalG2,
    608      "final matches linear score (g2)"
    609    );
    610 
    611    // sanity: g1 should outrank g2 with strictly larger raw vectors across all features
    612    Assert.greaterOrEqual(
    613      result.score_map.g1.final,
    614      result.score_map.g2.final,
    615      "g1 final ≥ g2 final as all contributing features favor g1"
    616    );
    617  }
    618 );
    619 
    620 add_task(async function test_rankshortcuts_queries_returning_nothing() {
    621  const Ranker = ChromeUtils.importESModule(
    622    "resource://newtab/lib/SmartShortcutsRanker/RankShortcuts.mjs"
    623  );
    624  const { NewTabUtils } = ChromeUtils.importESModule(
    625    "resource://gre/modules/NewTabUtils.sys.mjs"
    626  );
    627  await NewTabUtils.init();
    628 
    629  const sandbox = sinon.createSandbox();
    630  const stub = sandbox
    631    .stub(NewTabUtils.activityStreamProvider, "executePlacesQuery")
    632    .resolves([]); // <— mock: query returns nothing
    633 
    634  const topsites = [{ guid: "g1" }, { guid: "g2" }];
    635  const places = "moz_places";
    636  const hist = "moz_historyvisits";
    637 
    638  // fetchVisitCountsByGuid: returns {} if no rows come back
    639  {
    640    const out = await Ranker.fetchVisitCountsByGuid(topsites, places);
    641    Assert.deepEqual(out, {}, "visit counts: empty rows → empty map");
    642  }
    643 
    644  // fetchLast10VisitsByGuid: pre-seeds all guids → each should be []
    645  {
    646    const out = await Ranker.fetchLast10VisitsByGuid(topsites, hist, places);
    647    Assert.deepEqual(
    648      out,
    649      { g1: [], g2: [] },
    650      "last 10 visits: empty rows → each guid has []"
    651    );
    652  }
    653 
    654  // fetchBookmarkedFlags: ensures every guid present with false
    655  {
    656    const out = await Ranker.fetchBookmarkedFlags(
    657      topsites,
    658      "moz_bookmarks",
    659      places
    660    );
    661    Assert.deepEqual(
    662      out,
    663      { g1: false, g2: false },
    664      "bookmarks: empty rows → every guid is false"
    665    );
    666  }
    667 
    668  // fetchDailyVisitsSpecific: ensures every guid has a 7-bin zero hist
    669  {
    670    const out = await Ranker.fetchDailyVisitsSpecific(topsites, hist, places);
    671    Assert.ok(
    672      Array.isArray(out.g1) && out.g1.length === 7,
    673      "daily specific: 7 bins"
    674    );
    675    Assert.ok(
    676      Array.isArray(out.g2) && out.g2.length === 7,
    677      "daily specific: 7 bins"
    678    );
    679    Assert.ok(
    680      out.g1.every(v => v === 0) && out.g2.every(v => v === 0),
    681      "daily specific: all zeros"
    682    );
    683  }
    684 
    685  // fetchDailyVisitsAll: returns a 7-bin zero hist
    686  {
    687    const out = await Ranker.fetchDailyVisitsAll(hist);
    688    Assert.ok(Array.isArray(out) && out.length === 7, "daily all: 7 bins");
    689    Assert.ok(
    690      out.every(v => v === 0),
    691      "daily all: all zeros"
    692    );
    693  }
    694 
    695  // fetchHourlyVisitsSpecific: ensures every guid has a 24-bin zero hist
    696  {
    697    const out = await Ranker.fetchHourlyVisitsSpecific(topsites, hist, places);
    698    Assert.ok(
    699      Array.isArray(out.g1) && out.g1.length === 24,
    700      "hourly specific: 24 bins"
    701    );
    702    Assert.ok(
    703      Array.isArray(out.g2) && out.g2.length === 24,
    704      "hourly specific: 24 bins"
    705    );
    706    Assert.ok(
    707      out.g1.every(v => v === 0) && out.g2.every(v => v === 0),
    708      "hourly specific: all zeros"
    709    );
    710  }
    711 
    712  // fetchHourlyVisitsAll: returns a 24-bin zero hist
    713  {
    714    const out = await Ranker.fetchHourlyVisitsAll(hist);
    715    Assert.ok(Array.isArray(out) && out.length === 24, "hourly all: 24 bins");
    716    Assert.ok(
    717      out.every(v => v === 0),
    718      "hourly all: all zeros"
    719    );
    720  }
    721 
    722  // sanity: we actually exercised the stub at least once
    723  Assert.greater(stub.callCount, 0, "executePlacesQuery was called");
    724 
    725  sandbox.restore();
    726 });
    727 
    728 // cover the explicit "no topsites" early-return branches
    729 add_task(async function test_rankshortcuts_no_topsites_inputs() {
    730  const Ranker = ChromeUtils.importESModule(
    731    "resource://newtab/lib/SmartShortcutsRanker/RankShortcuts.mjs"
    732  );
    733  const { NewTabUtils } = ChromeUtils.importESModule(
    734    "resource://gre/modules/NewTabUtils.sys.mjs"
    735  );
    736  await NewTabUtils.init();
    737 
    738  const sandbox = sinon.createSandbox();
    739  const stub = sandbox
    740    .stub(NewTabUtils.activityStreamProvider, "executePlacesQuery")
    741    .resolves([]); // should not matter (early return)
    742 
    743  // empty topsites → {}
    744  Assert.deepEqual(
    745    await Ranker.fetchVisitCountsByGuid([], "moz_places"),
    746    {},
    747    "visit counts early return {}"
    748  );
    749  Assert.deepEqual(
    750    await Ranker.fetchLast10VisitsByGuid([], "moz_historyvisits", "moz_places"),
    751    {},
    752    "last10 early return {}"
    753  );
    754  Assert.deepEqual(
    755    await Ranker.fetchBookmarkedFlags([], "moz_bookmarks", "moz_places"),
    756    {},
    757    "bookmarks early return {}"
    758  );
    759  Assert.deepEqual(
    760    await Ranker.fetchDailyVisitsSpecific(
    761      [],
    762      "moz_historyvisits",
    763      "moz_places"
    764    ),
    765    {},
    766    "daily specific early return {}"
    767  );
    768  Assert.deepEqual(
    769    await Ranker.fetchHourlyVisitsSpecific(
    770      [],
    771      "moz_historyvisits",
    772      "moz_places"
    773    ),
    774    {},
    775    "hourly specific early return {}"
    776  );
    777 
    778  // note: the *All* variants don't take topsites and thus aren't part of this early-return set.
    779 
    780  // make sure we didn't query DB for early-return cases
    781  Assert.equal(stub.callCount, 0, "no DB calls for early-return functions");
    782 
    783  sandbox.restore();
    784 });
    785 
    786 add_task(async function test_rankTopSites_sql_pipeline_happy_path() {
    787  const Ranker = ChromeUtils.importESModule(
    788    "resource://newtab/lib/SmartShortcutsRanker/RankShortcuts.mjs"
    789  );
    790  const { NewTabUtils } = ChromeUtils.importESModule(
    791    "resource://gre/modules/NewTabUtils.sys.mjs"
    792  );
    793 
    794  await NewTabUtils.init();
    795  const sandbox = sinon.createSandbox();
    796 
    797  // freeze time so recency/seasonality are stable
    798  const nowFixed = Date.UTC(2025, 0, 1, 12, 0, 0); // Jan 1, 2025 @ noon UTC
    799  const clock = sandbox.useFakeTimers({ now: nowFixed });
    800 
    801  // inputs: one strong site (g1), one weaker (g2), and one without guid
    802  const topsites = [
    803    { guid: "g1", url: "https://g1.com", frecency: 1000 },
    804    { guid: "g2", url: "https://g2.com", frecency: 10 },
    805    { url: "https://no-guid.com" }, // should end up last
    806  ];
    807 
    808  // provider under test
    809  const provider = new Ranker.RankShortcutsProvider();
    810 
    811  // keep cache interactions in-memory + observable
    812  const initialWeights = {
    813    bmark: 1,
    814    rece: 1,
    815    freq: 1,
    816    refre: 1,
    817    hour: 1,
    818    daily: 1,
    819    bias: 0,
    820    frec: 0,
    821  };
    822  const fakeCache = {
    823    weights: { ...initialWeights },
    824    init_weights: { ...initialWeights },
    825    norms: null,
    826    // minimal score_map so updateWeights can run even if eta > 0 (we set eta=0 below)
    827    score_map: { g1: { final: 0 }, g2: { final: 0 } },
    828  };
    829  sandbox.stub(provider.sc_obj, "get").resolves(fakeCache);
    830  const setSpy = sandbox.stub(provider.sc_obj, "set").resolves();
    831 
    832  // stub DB: return realistic rows per SQL shape
    833  const execStub = sandbox
    834    .stub(NewTabUtils.activityStreamProvider, "executePlacesQuery")
    835    .callsFake(async sql => {
    836      const s = String(sql);
    837 
    838      // --- Bookmarks (fetchBookmarkedFlags)
    839      if (s.includes("FROM moz_bookmarks")) {
    840        // rows: [key, bookmark_count]
    841        return [
    842          ["g1", 2], // bookmarked
    843          ["g2", 0], // not bookmarked
    844        ];
    845      }
    846 
    847      // --- Visit counts (fetchVisitCountsByGuid)
    848      if (s.includes("COALESCE(p.visit_count")) {
    849        // rows: [guid, visit_count]
    850        return [
    851          ["g1", 42],
    852          ["g2", 3],
    853        ];
    854      }
    855 
    856      // --- Last 10 visits (fetchLast10VisitsByGuid)
    857      if (s.includes("LIMIT 10") && s.includes("ORDER BY vv.visit_date DESC")) {
    858        // rows: [guid, visit_date_us, visit_type], ordered by guid/date DESC
    859        const nowUs = nowFixed * 1000;
    860        const dayUs = 864e8;
    861        // g1: two recent visits (typed + link); g2: one older link
    862        return [
    863          ["g1", nowUs, 2], // TYPED
    864          ["g1", nowUs - 1 * Number(dayUs), 1], // LINK (yesterday)
    865          ["g2", nowUs - 10 * Number(dayUs), 1], // LINK (older)
    866        ];
    867      }
    868 
    869      // --- Daily specific (fetchDailyVisitsSpecific): rows [key, dow, count]
    870      if (
    871        s.includes("strftime('%w'") &&
    872        s.includes("GROUP BY place_ids.guid")
    873      ) {
    874        return [
    875          ["g1", 1, 3], // Mon
    876          ["g1", 2, 2], // Tue
    877          ["g2", 1, 1], // Mon
    878        ];
    879      }
    880 
    881      // --- Daily all (fetchDailyVisitsAll): rows [dow, count]
    882      if (s.includes("strftime('%w'") && !s.includes("place_ids.guid")) {
    883        return [
    884          [0, 10],
    885          [1, 20],
    886          [2, 15],
    887          [3, 12],
    888          [4, 8],
    889          [5, 5],
    890          [6, 4],
    891        ];
    892      }
    893 
    894      // --- Hourly specific (fetchHourlyVisitsSpecific): rows [key, hour, count]
    895      if (
    896        s.includes("strftime('%H'") &&
    897        s.includes("GROUP BY place_ids.guid")
    898      ) {
    899        return [
    900          ["g1", 9, 5],
    901          ["g1", 10, 3],
    902          ["g2", 9, 1],
    903        ];
    904      }
    905 
    906      // --- Hourly all (fetchHourlyVisitsAll): rows [hour, count]
    907      if (s.includes("strftime('%H'") && !s.includes("place_ids.guid")) {
    908        return Array.from({ length: 24 }, (_, h) => [
    909          h,
    910          h >= 8 && h <= 18 ? 10 : 2,
    911        ]);
    912      }
    913 
    914      // --- Shortcut interactions (fetchShortcutInteractions)
    915      // The query varies by implementation; if your helper hits SQL, fall back to "empty"
    916      if (
    917        s.toLowerCase().includes("shortcut") ||
    918        s.toLowerCase().includes("smartshortcuts")
    919      ) {
    920        return [];
    921      }
    922 
    923      // default: empty for any other SQL the provider might issue
    924      return [];
    925    });
    926 
    927  // prefs: set eta=0 so updateWeights is a no-op (deterministic)
    928  // NOTE: Feature set comes from the module's SHORTCUT_FSETS.
    929  // If your build's default fset already includes ["bmark","rece","freq","refre","hour","daily","bias","frec"],
    930  // this test will exercise all the SQL above. If not, it still passes the core assertions.
    931  const prefValues = {
    932    trainhopConfig: {
    933      smartShortcuts: {
    934        // fset: "custom", // uncomment iff your build defines a "custom" fset you want to use
    935        eta: 0,
    936        click_bonus: 10,
    937        positive_prior: 1,
    938        negative_prior: 1,
    939        fset: 8,
    940        telem: true,
    941      },
    942    },
    943  };
    944 
    945  // run
    946  const out = await provider.rankTopSites(topsites, prefValues, {
    947    isStartup: false,
    948  });
    949 
    950  // assertions
    951  Assert.ok(Array.isArray(out), "returns an array");
    952  Assert.ok(
    953    out.every(
    954      site =>
    955        Object.prototype.hasOwnProperty.call(site, "scores") &&
    956        Object.prototype.hasOwnProperty.call(site, "weights")
    957    ),
    958    "Every shortcut should expose scores and weights (even when null) because telem=true"
    959  );
    960  Assert.equal(
    961    out[out.length - 1].url,
    962    "https://no-guid.com",
    963    "no-guid item is last"
    964  );
    965 
    966  // If the active fset includes the “new” features, g1 should outrank g2 based on our SQL.
    967  const rankedGuids = out.filter(x => x.guid).map(x => x.guid);
    968  if (rankedGuids.length === 2) {
    969    // This is a soft assertion—kept as ok() instead of equal() so the test still passes
    970    // on builds where the active fset does not include those features.
    971    Assert.strictEqual(
    972      rankedGuids[0],
    973      "g1",
    974      `expected g1 to outrank g2 when feature set includes (bmark/rece/freq/refre/hour/daily); got ${rankedGuids}`
    975    );
    976  }
    977 
    978  // cache writes happened (norms + score_map)
    979  Assert.ok(setSpy.calledWith("norms"), "norms written to cache");
    980  Assert.ok(setSpy.calledWith("score_map"), "score_map written to cache");
    981 
    982  // sanity: DB stub exercised
    983  Assert.greater(execStub.callCount, 0, "executePlacesQuery was called");
    984 
    985  // cleanup
    986  clock.restore();
    987  sandbox.restore();
    988 });
    989 
    990 //
    991 // 2) weightedSampleTopSites: "open" feature sets per-guid scores and norms
    992 //
    993 add_task(async function test_weightedSampleTopSites_open_feature_basic() {
    994  const Worker = ChromeUtils.importESModule(
    995    "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs"
    996  );
    997 
    998  // Three guids; g1 and g3 are open, g2 is closed.
    999  const guid = ["g1", "g2", "g3"];
   1000  const input = {
   1001    features: ["open", "bias"],
   1002    guid,
   1003    // keep only "open" contributing to final; set bias to 0 for clarity
   1004    weights: { open: 1, bias: 0 },
   1005    // no prior norms → normUpdate will initialize from first value
   1006    norms: { open: null },
   1007    // provide the open_scores dict in the format rankTopSites will pass
   1008    open_scores: { g1: 1, g2: 0, g3: 1 },
   1009  };
   1010 
   1011  const out = await Worker.weightedSampleTopSites(input);
   1012 
   1013  // shape
   1014  Assert.ok(out && out.score_map && out.norms, "returns {score_map, norms}");
   1015  Assert.ok(out.norms.open, "norms include 'open'");
   1016 
   1017  // The normalized values are opaque, but ordering should reflect inputs:
   1018  const s1 = out.score_map.g1.open;
   1019  const s2 = out.score_map.g2.open;
   1020  const s3 = out.score_map.g3.open;
   1021 
   1022  Assert.greater(s1, s2, "an open guid should score higher than a closed one");
   1023  Assert.greater(s3, s2, "another open guid should score higher than closed");
   1024  Assert.equal(
   1025    Math.abs(s1 - s3) < 1e-12, // normalization treats identical inputs identically
   1026    true,
   1027    "two open guids with identical inputs get identical normalized scores"
   1028  );
   1029 
   1030  // final should reflect the 'open' score since bias has weight 0
   1031  Assert.equal(out.score_map.g1.final, s1, "final = open (g1)");
   1032  Assert.equal(out.score_map.g2.final, s2, "final = open (g2)");
   1033  Assert.equal(out.score_map.g3.final, s3, "final = open (g3)");
   1034 });
   1035 
   1036 //
   1037 // 3) weightedSampleTopSites: "open" feature honors existing running-norm state
   1038 //
   1039 add_task(async function test_weightedSampleTopSites_open_with_existing_norms() {
   1040  const Worker = ChromeUtils.importESModule(
   1041    "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs"
   1042  );
   1043 
   1044  const guid = ["A", "B", "C", "D"];
   1045  const open_scores = { A: 0, B: 1, C: 0, D: 1 };
   1046 
   1047  // Seed a prior norm object to ensure running mean/var is updated & used.
   1048  const prior = { beta: 1e-3, mean: 0.5, var: 1.0 };
   1049  const input = {
   1050    features: ["open", "bias"],
   1051    guid,
   1052    weights: { open: 1, bias: 0 },
   1053    norms: { open: prior },
   1054    open_scores,
   1055  };
   1056 
   1057  const out = await Worker.weightedSampleTopSites(input);
   1058 
   1059  // Norm object should be updated (mean or var shifts minutely due to beta)
   1060  Assert.ok(out.norms.open, "open norm returned");
   1061  Assert.notEqual(out.norms.open.mean, prior.mean, "running mean updated");
   1062  Assert.greater(out.norms.open.var, 0, "variance stays positive");
   1063 
   1064  // Basic ordering still holds
   1065  const finals = guid.map(g => out.score_map[g].final);
   1066  // open (1) items should outrank closed (0) items after normalization
   1067  const maxClosed = Math.max(finals[0], finals[2]); // A,C
   1068  const minOpen = Math.min(finals[1], finals[3]); // B,D
   1069  Assert.greater(minOpen, maxClosed, "open > closed after normalization");
   1070 });
   1071 
   1072 add_task(async function test_buildFrecencyFeatures_unid_counts_unique_days() {
   1073  const Ranker = ChromeUtils.importESModule(
   1074    "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs"
   1075  );
   1076  const sandbox = sinon.createSandbox();
   1077 
   1078  // Fix the clock so "now" is deterministic
   1079  const nowMs = Date.UTC(2025, 0, 10); // Jan 10, 2025
   1080  const clock = sandbox.useFakeTimers({ now: nowMs });
   1081  const us = ms => Math.round(ms * 1000);
   1082  const dayMs = 864e5;
   1083 
   1084  const visitsByGuid = {
   1085    g1: [
   1086      { visit_date_us: us(nowMs), visit_type: 1 }, // today
   1087      { visit_date_us: us(nowMs - 2 * dayMs), visit_type: 1 }, // 2 days ago
   1088      { visit_date_us: us(nowMs - 2 * dayMs), visit_type: 1 }, // same day as above
   1089    ],
   1090    g2: [
   1091      { visit_date_us: us(nowMs - 7 * dayMs), visit_type: 1 }, // 7 days ago
   1092    ],
   1093  };
   1094  const visitCounts = { g1: 3, g2: 1 };
   1095 
   1096  const out = await Ranker.buildFrecencyFeatures(visitsByGuid, visitCounts);
   1097 
   1098  Assert.equal(out.unid.g1, 2, "g1 has visits on 2 unique days");
   1099  Assert.equal(out.unid.g2, 1, "g2 has visits on 1 unique day");
   1100 
   1101  clock.restore();
   1102  sandbox.restore();
   1103 });
   1104 
   1105 add_task(async function test_weightedSampleTopSites_unid_feature() {
   1106  const Worker = ChromeUtils.importESModule(
   1107    "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs"
   1108  );
   1109 
   1110  const guids = ["a", "b"];
   1111  const input = {
   1112    features: ["unid", "bias"],
   1113    guid: guids,
   1114    // explicit scores: a=5 unique days, b=1
   1115    unid_scores: { a: 5, b: 1 },
   1116    norms: { unid: null },
   1117    weights: { unid: 1, bias: 0 },
   1118  };
   1119 
   1120  const result = await Worker.weightedSampleTopSites(input);
   1121 
   1122  Assert.ok(result.norms.unid, "norms include 'unid'");
   1123  const ua = result.score_map.a.unid;
   1124  const ub = result.score_map.b.unid;
   1125  Assert.greater(ua, ub, "site with more unique days visited scores higher");
   1126  Assert.equal(result.score_map.a.final, ua, "final equals unid score (a)");
   1127  Assert.equal(result.score_map.b.final, ub, "final equals unid score (b)");
   1128 });
   1129 
   1130 //
   1131 // 1) CTR feature: ordering + final uses normalized CTR when weighted
   1132 //
   1133 add_task(async function test_weightedSampleTopSites_ctr_basic() {
   1134  const Worker = ChromeUtils.importESModule(
   1135    "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs"
   1136  );
   1137 
   1138  // g1: 9/10 → .909; g2: 1/10 → .1; g3: 0/0 → smoothed to (0+1)/(0+1)=1
   1139  // After normalization, g3 should be highest, then g1, then g2.
   1140  const input = {
   1141    features: ["ctr", "bias"],
   1142    guid: ["g1", "g2", "g3"],
   1143    clicks: [9, 1, 0],
   1144    impressions: [10, 10, 0],
   1145    norms: { ctr: null },
   1146    weights: { ctr: 1, bias: 0 },
   1147  };
   1148 
   1149  const out = await Worker.weightedSampleTopSites(input);
   1150 
   1151  // shape
   1152  Assert.ok(out && out.score_map && out.norms, "returns {score_map, norms}");
   1153  Assert.ok(out.norms.ctr, "returns ctr norms");
   1154 
   1155  // ordering by normalized ctr (highest to lowest): g3 > g1 > g2
   1156  const s1 = out.score_map.g1.ctr;
   1157  const s2 = out.score_map.g2.ctr;
   1158  const s3 = out.score_map.g3.ctr;
   1159 
   1160  Assert.greater(s3, s1, "g3 (1.0 smoothed) > g1 (~0.909)");
   1161  Assert.greater(s1, s2, "g1 (~0.909) > g2 (0.1)");
   1162 
   1163  // finals reflect ctr weight only
   1164  Assert.equal(out.score_map.g1.final, s1, "final equals ctr (g1)");
   1165  Assert.equal(out.score_map.g2.final, s2, "final equals ctr (g2)");
   1166  Assert.equal(out.score_map.g3.final, s3, "final equals ctr (g3)");
   1167 });
   1168 
   1169 //
   1170 // 2) CTR smoothing edge cases: zero impressions produce finite values
   1171 //
   1172 add_task(async function test_weightedSampleTopSites_ctr_smoothing_zero_imps() {
   1173  const Worker = ChromeUtils.importESModule(
   1174    "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs"
   1175  );
   1176 
   1177  // gA: clicks=0, imps=0 → (0+1)/(0+1)=1.0
   1178  // gB: clicks=5, imps=0 → (5+1)/(0+1)=6.0  (still finite, larger than 1.0)
   1179  const input = {
   1180    features: ["ctr"],
   1181    guid: ["gA", "gB"],
   1182    clicks: [0, 5],
   1183    impressions: [0, 0],
   1184    norms: { ctr: null },
   1185    weights: { ctr: 1 },
   1186  };
   1187 
   1188  const out = await Worker.weightedSampleTopSites(input);
   1189 
   1190  const a = out.score_map.gA.ctr;
   1191  const b = out.score_map.gB.ctr;
   1192 
   1193  // normalized values are opaque, but ordering must follow raw_ctr (B > A)
   1194  Assert.greater(b, a, "higher smoothed CTR should score higher");
   1195 
   1196  // finiteness check
   1197  Assert.ok(Number.isFinite(a) && Number.isFinite(b), "scores are finite");
   1198 });
   1199 
   1200 //
   1201 // 3) CTR running norm: prior norm influences (mean,var) and persists
   1202 //
   1203 add_task(async function test_weightedSampleTopSites_ctr_with_prior_norm() {
   1204  const Worker = ChromeUtils.importESModule(
   1205    "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs"
   1206  );
   1207 
   1208  const prior = { beta: 1e-3, mean: 0.3, var: 1.0 }; // seeded running stats
   1209  const input = {
   1210    features: ["ctr"],
   1211    guid: ["x", "y"],
   1212    clicks: [3, 0],
   1213    impressions: [10, 10], // raw ctr: x=.364, y=.091 → with +1 smoothing: (.3~) but ordering same
   1214    norms: { ctr: prior },
   1215    weights: { ctr: 1 },
   1216  };
   1217 
   1218  const out = await Worker.weightedSampleTopSites(input);
   1219 
   1220  Assert.ok(out.norms.ctr, "ctr norm returned");
   1221  Assert.notEqual(out.norms.ctr.mean, prior.mean, "running mean updated");
   1222  Assert.greater(out.norms.ctr.var, 0, "variance positive");
   1223 
   1224  const x = out.score_map.x.ctr;
   1225  const y = out.score_map.y.ctr;
   1226  Assert.greater(x, y, "higher ctr ranks higher under prior normalization");
   1227 });
   1228 
   1229 // sticky clicks testing
   1230 
   1231 add_task(async function test_fetchShortcutLastClickPositions_empty() {
   1232  const Ranker = ChromeUtils.importESModule(
   1233    "resource://newtab/lib/SmartShortcutsRanker/RankShortcuts.mjs"
   1234  );
   1235  const out = await Ranker.fetchShortcutLastClickPositions(
   1236    [],
   1237    "smart_shortcuts",
   1238    "moz_places"
   1239  );
   1240  Assert.deepEqual(out, [], "empty guid list → empty result");
   1241 });
   1242 
   1243 add_task(
   1244  async function test_fetchShortcutLastClickPositions_happy_path_and_numImps() {
   1245    const Ranker = ChromeUtils.importESModule(
   1246      "resource://newtab/lib/SmartShortcutsRanker/RankShortcuts.mjs"
   1247    );
   1248    const { NewTabUtils } = ChromeUtils.importESModule(
   1249      "resource://gre/modules/NewTabUtils.sys.mjs"
   1250    );
   1251    await NewTabUtils.init();
   1252 
   1253    const sandbox = sinon.createSandbox();
   1254    const guids = ["g1", "g2", "g3"];
   1255    const numImps = 7;
   1256 
   1257    // Stub DB: we just need to return rows in the final SELECT shape:
   1258    // [guid, position|null]
   1259    const execStub = sandbox
   1260      .stub(NewTabUtils.activityStreamProvider, "executePlacesQuery")
   1261      .callsFake(async _sql => {
   1262        // Return positions for two guids; one missing (null)
   1263        return [
   1264          ["g1", 3],
   1265          ["g2", null],
   1266          // g3 absent → should default to null in the aligned output
   1267        ];
   1268      });
   1269 
   1270    const out = await Ranker.fetchShortcutLastClickPositions(
   1271      guids,
   1272      "smart_shortcuts",
   1273      "moz_places",
   1274      numImps
   1275    );
   1276 
   1277    Assert.deepEqual(
   1278      out,
   1279      [3, null, null],
   1280      "aligned array: g1=3, g2=null, g3=null (missing → null)"
   1281    );
   1282    Assert.greater(execStub.callCount, 0, "DB was queried");
   1283    sandbox.restore();
   1284  }
   1285 );
   1286 
   1287 add_task(async function test_placeGuidsByPositions_basic_and_collisions() {
   1288  const Ranker = ChromeUtils.importESModule(
   1289    "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs"
   1290  );
   1291 
   1292  const guids = ["a", "b", "c", "d"];
   1293  // a→1, b→1 (collision), c→3, d→null
   1294  const pos = new Map(Object.entries({ a: 1, b: 1, c: 3, d: null }));
   1295 
   1296  const out = Ranker.placeGuidsByPositions(guids, pos);
   1297  // Pass #1 (hard place): out[1] = "a", out[3] = "c"
   1298  // Pass #2 (fill holes left→right) with [b, d] in original order:
   1299  // holes are idx 0 then 2 → place "b" at 0, "d" at 2
   1300  Assert.deepEqual(
   1301    out,
   1302    ["b", "a", "d", "c"],
   1303    "collision resolved by first-come; others fill holes stably"
   1304  );
   1305 });
   1306 
   1307 add_task(async function test_placeGuidsByPositions_inferred_size() {
   1308  const Ranker = ChromeUtils.importESModule(
   1309    "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs"
   1310  );
   1311 
   1312  const guids = ["p", "q"];
   1313  const pos = new Map(Object.entries({ p: 0, q: null }));
   1314  const out = Ranker.placeGuidsByPositions(guids, pos);
   1315  Assert.equal(
   1316    out.length,
   1317    guids.length,
   1318    "default size inferred to guids.length"
   1319  );
   1320  Assert.deepEqual(out, ["p", "q"]);
   1321 });
   1322 
   1323 add_task(async function test_fetchShortcutLastClickPositions_empty() {
   1324  const Ranker = ChromeUtils.importESModule(
   1325    "resource://newtab/lib/SmartShortcutsRanker/RankShortcuts.mjs"
   1326  );
   1327  // Empty GUID list → empty aligned output; should not query DB.
   1328  const { NewTabUtils } = ChromeUtils.importESModule(
   1329    "resource://gre/modules/NewTabUtils.sys.mjs"
   1330  );
   1331  await NewTabUtils.init();
   1332 
   1333  const sandbox = sinon.createSandbox();
   1334  const stub = sandbox
   1335    .stub(NewTabUtils.activityStreamProvider, "executePlacesQuery")
   1336    .resolves([]);
   1337 
   1338  const out = await Ranker.fetchShortcutLastClickPositions(
   1339    [],
   1340    "smart_shortcuts",
   1341    "moz_places",
   1342    10
   1343  );
   1344  Assert.deepEqual(out, [], "empty guid list → empty result");
   1345  Assert.equal(stub.callCount, 0, "no DB query for empty input");
   1346  sandbox.restore();
   1347 });
   1348 
   1349 add_task(async function test_placeGuidsByPositions_empty_inputs() {
   1350  const Ranker = ChromeUtils.importESModule(
   1351    "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs"
   1352  );
   1353 
   1354  // Empty guids, empty positions
   1355  {
   1356    const out = Ranker.placeGuidsByPositions([], new Map());
   1357    Assert.deepEqual(out, [], "empty → empty");
   1358  }
   1359 
   1360  // Empty guids, non-empty positions map (should still produce empty)
   1361  {
   1362    const pos = new Map(Object.entries({ x: 5, y: 0 }));
   1363    const out = Ranker.placeGuidsByPositions([], pos);
   1364    Assert.deepEqual(out, [], "no guids to place → empty result");
   1365  }
   1366 });
   1367 
   1368 add_task(async function test_placeGuidsByPositions_all_null_positions() {
   1369  const Ranker = ChromeUtils.importESModule(
   1370    "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs"
   1371  );
   1372 
   1373  const guids = ["a", "b", "c"];
   1374  const pos = new Map(Object.entries({ a: null, b: null, c: null }));
   1375  const out = Ranker.placeGuidsByPositions(guids, pos);
   1376  Assert.deepEqual(out, guids, "all null → stable original order");
   1377 });
   1378 
   1379 add_task(async function test_placeGuidsByPositions_plain_object_positions() {
   1380  const Ranker = ChromeUtils.importESModule(
   1381    "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs"
   1382  );
   1383 
   1384  const guids = ["a", "b", "c"];
   1385  // Use a plain object (exercises normalization)
   1386  const pos = new Map(Object.entries({ a: 2, b: 0, c: 1 }));
   1387  const out = Ranker.placeGuidsByPositions(guids, pos);
   1388  Assert.deepEqual(out, ["b", "c", "a"], "plain object map works");
   1389 });
   1390 
   1391 add_task(async function test_placeGuidsByPositions_negative_and_noninteger() {
   1392  const Ranker = ChromeUtils.importESModule(
   1393    "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs"
   1394  );
   1395 
   1396  const guids = ["a", "b", "c", "d"];
   1397  // a→-1 (ignored), b→1.7 (non-integer → ignored), c→0 (valid), d→null
   1398  const pos = new Map(Object.entries({ a: -1, b: 1.7, c: 0, d: null }));
   1399  const out = Ranker.placeGuidsByPositions(guids, pos);
   1400  // Pass #1 puts c at 0. Others (a,b,d) fill holes in order → [c, a, b, d]
   1401  Assert.deepEqual(
   1402    out,
   1403    ["c", "a", "b", "d"],
   1404    "negative & non-integer treated as unpositioned"
   1405  );
   1406 });
   1407 
   1408 add_task(
   1409  async function test_placeGuidsByPositions_large_position_goes_lastish() {
   1410    const Ranker = ChromeUtils.importESModule(
   1411      "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs"
   1412    );
   1413 
   1414    const guids = ["a", "b", "c"];
   1415    // If your implementation infers size as max(guids.length, maxPos+1),
   1416    // this will create a 6-slot array. The fill pass will leave trailing nulls.
   1417    // We at least assert that ordering of real items is stable and includes "a" at its slot.
   1418    const pos = new Map(Object.entries({ a: 5, b: null, c: null }));
   1419    const out = Ranker.placeGuidsByPositions(guids, pos);
   1420 
   1421    // out length may be > guids.length depending on your impl; assert item order:
   1422    const nonNull = out.filter(x => x !== null);
   1423    // "a" should still appear once, and b/c follow in original order somewhere after
   1424    Assert.ok(nonNull.includes("a"), "contains 'a'");
   1425    // The fill order is stable for the unpositioned ones
   1426    const idxB = nonNull.indexOf("b");
   1427    const idxC = nonNull.indexOf("c");
   1428    Assert.greater(
   1429      idxC,
   1430      idxB,
   1431      "unpositioned items stay in input order (b before c)"
   1432    );
   1433  }
   1434 );
   1435 
   1436 add_task(
   1437  async function test_applyStickyClicks_preserves_null_and_clamps_negative() {
   1438    const Worker = ChromeUtils.importESModule(
   1439      "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs"
   1440    );
   1441 
   1442    // positions aligned with guids; pos[1] is null; pos[2] becomes negative after shift
   1443    const guids = ["g1", "g2", "g3"];
   1444    const positions = [3, null, 1]; // absolute
   1445    const numSponsored = 2; // shift by 2 → [1, null, -1] → clamp -1 → 0
   1446 
   1447    const out = Worker.applyStickyClicks(positions, guids, numSponsored);
   1448    // After building guid→pos: g1→1, g2→null, g3→0
   1449    // Hard place: out[1]=g1, out[0]=g3; fill hole(s) with g2 → [g3, g1, g2]
   1450    Assert.deepEqual(
   1451      out,
   1452      ["g3", "g1", "g2"],
   1453      "null preserved, negatives clamped to 0"
   1454    );
   1455  }
   1456 );
   1457 
   1458 add_task(
   1459  async function test_applyStickyClicks_undefined_numSponsored_defaults_zero() {
   1460    const Worker = ChromeUtils.importESModule(
   1461      "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs"
   1462    );
   1463 
   1464    const guids = ["g1", "g2"];
   1465    const positions = [4, null]; // shift by undefined → treated as 0 in robust version
   1466 
   1467    const out = Worker.applyStickyClicks(positions, guids, undefined);
   1468    // guid→pos: g1→4, g2→null → places g1 at 4 (if your placer grows size) or ignores (if not)
   1469    // We assert at least the unpositioned stays in order and g1 appears once.
   1470    Assert.ok(out.includes("g1") && out.includes("g2"), "both guids present");
   1471  }
   1472 );
   1473 
   1474 function prefsFor(features, extra = {}) {
   1475  // Map every feature in FEATURE_META to a weight; default 0, chosen ones 100.
   1476  const baseWeights = {
   1477    thom_weight: 0,
   1478    frec_weight: 0,
   1479    hour_weight: 0,
   1480    daily_weight: 0,
   1481    bmark_weight: 0,
   1482    rece_weight: 0,
   1483    freq_weight: 0,
   1484    refre_weight: 0,
   1485    open_weight: 0,
   1486    unid_weight: 0,
   1487    ctr_weight: 0,
   1488    bias_weight: 100,
   1489  };
   1490  for (const f of features) {
   1491    const key = {
   1492      thom: "thom_weight",
   1493      frec: "frec_weight",
   1494      hour: "hour_weight",
   1495      daily: "daily_weight",
   1496      bmark: "bmark_weight",
   1497      rece: "rece_weight",
   1498      freq: "freq_weight",
   1499      refre: "refre_weight",
   1500      open: "open_weight",
   1501      unid: "unid_weight",
   1502      ctr: "ctr_weight",
   1503      bias: "bias_weight",
   1504    }[f];
   1505    if (key) {
   1506      baseWeights[key] = 100;
   1507    }
   1508  }
   1509 
   1510  return {
   1511    trainhopConfig: {
   1512      smartShortcuts: {
   1513        features,
   1514        // make training a no-op for determinism
   1515        eta: 0,
   1516        click_bonus: 10,
   1517        positive_prior: 1,
   1518        negative_prior: 1,
   1519        sticky_numimps: 0, // off for matrix tests
   1520        ...baseWeights,
   1521        ...extra,
   1522      },
   1523    },
   1524  };
   1525 }
   1526 
   1527 function attachLocalWorker(provider, WorkerMod) {
   1528  provider._rankShortcutsWorker = {
   1529    async post(name, args) {
   1530      // name is a string like "weightedSampleTopSites"
   1531      // Worker functions are exported with same names.
   1532      // Some are standalone, some are exported in the class wrapper too.
   1533      const fn = WorkerMod[name] || new WorkerMod.RankShortcutsWorker()[name];
   1534      if (typeof fn === "function") {
   1535        // if it's a plain function, spread args; if method, pass as method
   1536        return Array.isArray(args) ? fn(...args) : fn(args);
   1537      }
   1538      throw new Error(`No worker function for ${name}`);
   1539    },
   1540  };
   1541 }
   1542 
   1543 function stubDB(sinon, NewTabUtils, nowFixed) {
   1544  return sinon
   1545    .stub(NewTabUtils.activityStreamProvider, "executePlacesQuery")
   1546    .callsFake(async sql => {
   1547      const s = String(sql);
   1548 
   1549      // Bookmarks
   1550      if (s.includes("FROM moz_bookmarks")) {
   1551        return [
   1552          ["g1", 1],
   1553          ["g2", 0],
   1554          ["g3", 0],
   1555        ];
   1556      }
   1557 
   1558      // Visit counts
   1559      if (s.includes("COALESCE(p.visit_count")) {
   1560        return [
   1561          ["g1", 20],
   1562          ["g2", 10],
   1563          ["g3", 5],
   1564        ];
   1565      }
   1566 
   1567      // Last 10 visits (guid, visit_date_us, visit_type)
   1568      if (s.includes("LIMIT 10") && s.includes("ORDER BY vv.visit_date DESC")) {
   1569        const us = ms => Math.round(ms * 1000);
   1570        const day = 864e5;
   1571        return [
   1572          ["g1", us(nowFixed), 2], // typed now
   1573          ["g1", us(nowFixed - day), 1], // link
   1574          ["g2", us(nowFixed - 2 * day), 1],
   1575          ["g3", us(nowFixed - 10 * day), 1],
   1576        ];
   1577      }
   1578 
   1579      // Daily specific (guid, dow, count)
   1580      if (
   1581        s.includes("strftime('%w'") &&
   1582        s.includes("GROUP BY place_ids.guid")
   1583      ) {
   1584        return [
   1585          ["g1", 1, 3],
   1586          ["g2", 1, 1],
   1587        ];
   1588      }
   1589 
   1590      // Daily all (dow, count)
   1591      if (s.includes("strftime('%w'") && !s.includes("place_ids.guid")) {
   1592        return [
   1593          [0, 10],
   1594          [1, 20],
   1595          [2, 15],
   1596          [3, 12],
   1597          [4, 8],
   1598          [5, 5],
   1599          [6, 4],
   1600        ];
   1601      }
   1602 
   1603      // Hourly specific (guid, hour, count)
   1604      if (
   1605        s.includes("strftime('%H'") &&
   1606        s.includes("GROUP BY place_ids.guid")
   1607      ) {
   1608        return [
   1609          ["g1", 9, 5],
   1610          ["g2", 10, 1],
   1611        ];
   1612      }
   1613 
   1614      // Hourly all (hour, count)
   1615      if (s.includes("strftime('%H'") && !s.includes("place_ids.guid")) {
   1616        return Array.from({ length: 24 }, (_, h) => [
   1617          h,
   1618          h >= 8 && h <= 18 ? 10 : 2,
   1619        ]);
   1620      }
   1621 
   1622      // Shortcut interactions (clicks/imps aggregation over 2 months)
   1623      if (
   1624        s.includes("moz_newtab_shortcuts_interaction") &&
   1625        s.includes("SUM(")
   1626      ) {
   1627        return [
   1628          ["g1", 5, 10], // clicks, imps
   1629          ["g2", 1, 10],
   1630          ["g3", 0, 0],
   1631        ];
   1632      }
   1633 
   1634      // Sticky-clicks helper (only in its dedicated test)
   1635      if (s.includes("ROW_NUMBER()") && s.includes("tile_position")) {
   1636        // Shape: [guid, position|null]
   1637        return [
   1638          ["g1", 3],
   1639          ["g2", null],
   1640          ["g3", null],
   1641        ];
   1642      }
   1643 
   1644      return [];
   1645    });
   1646 }
   1647 
   1648 add_task(async function test_rankTopSites_feature_matrix() {
   1649  const Ranker = ChromeUtils.importESModule(
   1650    "resource://newtab/lib/SmartShortcutsRanker/RankShortcuts.mjs"
   1651  );
   1652  const Worker = ChromeUtils.importESModule(
   1653    "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs"
   1654  );
   1655  const { NewTabUtils } = ChromeUtils.importESModule(
   1656    "resource://gre/modules/NewTabUtils.sys.mjs"
   1657  );
   1658  const { sinon } = ChromeUtils.importESModule(
   1659    "resource://testing-common/Sinon.sys.mjs"
   1660  );
   1661  await NewTabUtils.init();
   1662 
   1663  const sandbox = sinon.createSandbox();
   1664  const nowFixed = Date.UTC(2025, 0, 1, 12, 0, 0);
   1665  const clock = sandbox.useFakeTimers({ now: nowFixed });
   1666 
   1667  // provider under test
   1668  const provider = new Ranker.RankShortcutsProvider();
   1669  attachLocalWorker(provider, Worker);
   1670 
   1671  // cache stubs: keep simple but real-ish
   1672  const fakeCache = {
   1673    weights: {},
   1674    init_weights: {},
   1675    norms: null,
   1676    score_map: { g1: { final: 0 }, g2: { final: 0 }, g3: { final: 0 } },
   1677    time_last_update: 0,
   1678  };
   1679  sandbox.stub(provider.sc_obj, "get").resolves(fakeCache);
   1680  sandbox.stub(provider.sc_obj, "set").resolves();
   1681 
   1682  const dbStub = stubDB(sandbox, NewTabUtils, nowFixed);
   1683 
   1684  // input topsites
   1685  const topsites = [
   1686    { guid: "g1", url: "https://g1", frecency: 100 },
   1687    { guid: "g2", url: "https://g2", frecency: 10 },
   1688    { guid: "g3", url: "https://g3", frecency: 1 },
   1689    { url: "https://no-guid" }, // should remain last
   1690  ];
   1691 
   1692  // build feature lists
   1693  const ALL = [
   1694    "thom",
   1695    "frec",
   1696    "hour",
   1697    "daily",
   1698    "bmark",
   1699    "rece",
   1700    "freq",
   1701    "refre",
   1702    "unid",
   1703    "ctr",
   1704    "bias",
   1705  ];
   1706  const singles = ALL.map(f => [f]);
   1707  const pairs = [
   1708    ["frec", "bias"],
   1709    ["thom", "bias"],
   1710    ["ctr", "bias"],
   1711    ["bmark", "bias"],
   1712    ["rece", "freq"],
   1713  ];
   1714  const triples = [
   1715    ["frec", "thom", "bias"],
   1716    ["rece", "freq", "refre"],
   1717  ];
   1718 
   1719  const cases = [...singles, ...pairs, ...triples];
   1720 
   1721  for (const features of cases) {
   1722    const prefs = prefsFor(features, { sticky_numimps: 0 });
   1723    const out = await provider.rankTopSites(
   1724      topsites,
   1725      prefs,
   1726      { isStartup: true },
   1727      /*numSponsored*/ 0
   1728    );
   1729 
   1730    // basic shape assertions
   1731    Assert.ok(Array.isArray(out), `(${features}) returns array`);
   1732    Assert.equal(out.length, topsites.length, `(${features}) length stable`);
   1733    Assert.equal(
   1734      out[out.length - 1].url,
   1735      "https://no-guid",
   1736      `(${features}) no-guid item is last`
   1737    );
   1738 
   1739    // permutation check for guided items
   1740    const inGuids = topsites
   1741      .filter(x => x.guid)
   1742      .map(x => x.guid)
   1743      .sort();
   1744    const outGuids = out
   1745      .filter(x => x.guid)
   1746      .map(x => x.guid)
   1747      .sort();
   1748    Assert.deepEqual(outGuids, inGuids, `(${features}) guids preserved`);
   1749  }
   1750 
   1751  Assert.greater(dbStub.callCount, 0, "DB stub used at least once");
   1752  clock.restore();
   1753  sandbox.restore();
   1754 });
   1755 
   1756 add_task(function test_roundNum_precision_and_edge_cases() {
   1757  const { roundNum } = ChromeUtils.importESModule(
   1758    "resource://newtab/lib/SmartShortcutsRanker/RankShortcuts.mjs"
   1759  );
   1760 
   1761  Assert.equal(
   1762    roundNum(1.23456789),
   1763    1.235,
   1764    "roundNum rounds to four significant digits by default"
   1765  );
   1766  Assert.equal(
   1767    roundNum(987.65, 2),
   1768    990,
   1769    "roundNum respects the requested precision"
   1770  );
   1771  Assert.equal(
   1772    roundNum(5e-7),
   1773    0,
   1774    "roundNum clamps magnitudes smaller than eps to zero"
   1775  );
   1776  Assert.equal(
   1777    roundNum(-5e-7),
   1778    0,
   1779    "roundNum clamps small negative magnitudes to zero"
   1780  );
   1781  Assert.equal(
   1782    roundNum(5e-7, 4, 1e-9),
   1783    5e-7,
   1784    "roundNum uses the provided eps override when keeping small values"
   1785  );
   1786  Assert.strictEqual(
   1787    roundNum(Number.POSITIVE_INFINITY),
   1788    Number.POSITIVE_INFINITY,
   1789    "roundNum leaves non-finite numbers unchanged"
   1790  );
   1791  Assert.ok(Number.isNaN(roundNum(NaN)), "roundNum returns NaN for NaN");
   1792  const sentinel = { foo: "bar" };
   1793  Assert.strictEqual(
   1794    roundNum(sentinel),
   1795    sentinel,
   1796    "roundNum returns non-number inputs unchanged"
   1797  );
   1798 });