tor-browser

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

test_InferredFeatureModel.js (17371B)


      1 "use strict";
      2 
      3 ChromeUtils.defineESModuleGetters(this, {
      4  FeatureModel: "resource://newtab/lib/InferredModel/FeatureModel.sys.mjs",
      5  dictAdd: "resource://newtab/lib/InferredModel/FeatureModel.sys.mjs",
      6  dictApply: "resource://newtab/lib/InferredModel/FeatureModel.sys.mjs",
      7  divideDict: "resource://newtab/lib/InferredModel/FeatureModel.sys.mjs",
      8  DayTimeWeighting: "resource://newtab/lib/InferredModel/FeatureModel.sys.mjs",
      9  InterestFeatures: "resource://newtab/lib/InferredModel/FeatureModel.sys.mjs",
     10  unaryEncodeDiffPrivacy:
     11    "resource://newtab/lib/InferredModel/FeatureModel.sys.mjs",
     12 });
     13 
     14 /**
     15 * Compares two dictionaries up to decimalPoints decimal points
     16 *
     17 * @param {object} a
     18 * @param {object} b
     19 * @param {number} decimalPoints
     20 * @returns {boolean} True if vectors are similar
     21 */
     22 function vectorLooseEquals(a, b, decimalPoints = 2) {
     23  return Object.entries(a).every(
     24    ([k, v]) => v.toFixed(decimalPoints) === b[k].toFixed(decimalPoints)
     25  );
     26 }
     27 
     28 add_task(function test_dictAdd() {
     29  let dict = {};
     30  dictAdd(dict, "a", 3);
     31  Assert.equal(dict.a, 3, "Should set value when key is missing");
     32 
     33  dictAdd(dict, "a", 2);
     34  Assert.equal(dict.a, 5, "Should add value when key exists");
     35 });
     36 
     37 add_task(function test_dictApply() {
     38  let input = { a: 1, b: 2 };
     39  let output = dictApply(input, x => x * 2);
     40  Assert.deepEqual(output, { a: 2, b: 4 }, "Should double all values");
     41 
     42  let identity = dictApply(input, x => x);
     43  Assert.deepEqual(
     44    identity,
     45    input,
     46    "Should return same values with identity function"
     47  );
     48 });
     49 
     50 add_task(function test_divideDict_basic() {
     51  const numerator = { a: 6, b: 4 };
     52  const denominator = { a: 2, b: 2 };
     53  const result = divideDict(numerator, denominator);
     54  Assert.deepEqual(
     55    result,
     56    { a: 3, b: 2 },
     57    "Basic division should correctly divide numerator by denominator"
     58  );
     59 });
     60 
     61 add_task(function test_divideDict_missingDenominator() {
     62  const numerator = { a: 6, b: 4 };
     63  const denominator = {};
     64  const result = divideDict(numerator, denominator);
     65  Assert.deepEqual(
     66    result,
     67    { a: 0, b: 0 },
     68    "Missing denominator keys should yield 0 for each numerator key"
     69  );
     70 });
     71 
     72 add_task(function test_divideDict_zeroDenominator() {
     73  const numerator = { a: 5, b: 10 };
     74  const denominator = { a: 0, b: 2 };
     75  const result = divideDict(numerator, denominator);
     76  Assert.deepEqual(
     77    result,
     78    { a: 0, b: 5 },
     79    "Zero denominator should produce 0. non-zero denominator should divide normally"
     80  );
     81 });
     82 
     83 add_task(function test_divideDict_missingNumerator() {
     84  const numerator = {};
     85  const denominator = { a: 3, b: 5 };
     86  const result = divideDict(numerator, denominator);
     87  Assert.deepEqual(
     88    result,
     89    { a: 0.0, b: 0.0 },
     90    "Denominator keys without numerator should yield 0.0 for each key"
     91  );
     92 });
     93 
     94 add_task(function test_DayTimeWeighting_getDateIntervals() {
     95  let weighting = new DayTimeWeighting([1, 2], [0.5, 0.2]);
     96  let now = Date.now();
     97  let intervals = weighting.getDateIntervals(now);
     98 
     99  Assert.equal(
    100    intervals.length,
    101    2,
    102    "Should return one interval per pastDay entry"
    103  );
    104  Assert.lessOrEqual(
    105    intervals[0].end,
    106    new Date(now),
    107    "Each interval end should be before or equal to now"
    108  );
    109  Assert.less(
    110    intervals[0].start,
    111    intervals[0].end,
    112    "Start should be before end"
    113  );
    114  Assert.lessOrEqual(
    115    intervals[1].end,
    116    new Date(now),
    117    "Each interval end should be before or equal to now"
    118  );
    119  Assert.less(
    120    intervals[1].start,
    121    intervals[0].end,
    122    "Start should be before end"
    123  );
    124 });
    125 
    126 add_task(function test_DayTimeWeighting_getRelativeWeight() {
    127  let weighting = new DayTimeWeighting([1, 2], [0.5, 0.2]);
    128 
    129  Assert.equal(
    130    weighting.getRelativeWeight(0),
    131    0.5,
    132    "Should return correct weight for index 0"
    133  );
    134  Assert.equal(
    135    weighting.getRelativeWeight(1),
    136    0.2,
    137    "Should return correct weight for index 1"
    138  );
    139  Assert.equal(
    140    weighting.getRelativeWeight(2),
    141    0,
    142    "Should return 0 for out-of-range index"
    143  );
    144 });
    145 
    146 add_task(function test_DayTimeWeighting_fromJSON() {
    147  const json = { days: [1, 2], relative_weight: [0.1, 0.3] };
    148  const weighting = DayTimeWeighting.fromJSON(json);
    149 
    150  Assert.ok(
    151    weighting instanceof DayTimeWeighting,
    152    "Should create instance from JSON"
    153  );
    154  Assert.deepEqual(
    155    weighting.pastDays,
    156    [1, 2],
    157    "Should correctly parse pastDays"
    158  );
    159  Assert.deepEqual(
    160    weighting.relativeWeight,
    161    [0.1, 0.3],
    162    "Should correctly parse relative weights"
    163  );
    164 });
    165 
    166 add_task(function test_InterestFeatures_applyThresholds() {
    167  let feature = new InterestFeatures("test", {}, [10, 20, 30]);
    168  // Note that number of output is 1 + the length of the input weights
    169  Assert.equal(
    170    feature.applyThresholds(5),
    171    0,
    172    "Value < first threshold returns 0"
    173  );
    174  Assert.equal(
    175    feature.applyThresholds(15),
    176    1,
    177    "Value < second threshold returns 1"
    178  );
    179  Assert.equal(
    180    feature.applyThresholds(25),
    181    2,
    182    "Value < third threshold returns 2"
    183  );
    184  Assert.equal(
    185    feature.applyThresholds(35),
    186    3,
    187    "Value >= all thresholds returns length of thresholds"
    188  );
    189 });
    190 
    191 add_task(function test_InterestFeatures_noThresholds() {
    192  let feature = new InterestFeatures("test", {});
    193  Assert.equal(
    194    feature.applyThresholds(42),
    195    42,
    196    "Without thresholds, should return input unchanged"
    197  );
    198 });
    199 
    200 add_task(function test_InterestFeatures_fromJSON() {
    201  const json = { features: { a: 1 }, thresholds: [1, 2] };
    202  const feature = InterestFeatures.fromJSON("f", json);
    203 
    204  Assert.ok(
    205    feature instanceof InterestFeatures,
    206    "Should create InterestFeatures from JSON"
    207  );
    208  Assert.equal(feature.name, "f", "Should set correct name");
    209  Assert.deepEqual(
    210    feature.featureWeights,
    211    { a: 1 },
    212    "Should set correct feature weights"
    213  );
    214  Assert.deepEqual(feature.thresholds, [1, 2], "Should set correct thresholds");
    215 });
    216 
    217 const SPECIAL_FEATURE_CLICK = "clicks";
    218 
    219 const AggregateResultKeys = {
    220  POSITION: "position",
    221  FEATURE: "feature",
    222  VALUE: "feature_value",
    223  SECTION_POSITION: "section_position",
    224  FORMAT_ENUM: "card_format_enum",
    225 };
    226 
    227 const SCHEMA = {
    228  [AggregateResultKeys.FEATURE]: 0,
    229  [AggregateResultKeys.FORMAT_ENUM]: 1,
    230  [AggregateResultKeys.VALUE]: 2,
    231 };
    232 
    233 const jsonModelData = {
    234  model_type: "clicks",
    235  day_time_weighting: {
    236    days: [3, 14, 45],
    237    relative_weight: [1, 0.5, 0.3],
    238  },
    239  interest_vector: {
    240    news_reader: {
    241      features: { pub_nytimes_com: 0.5, pub_cnn_com: 0.5 },
    242      thresholds: [0.3, 0.4],
    243      diff_p: 1,
    244      diff_q: 0,
    245    },
    246    parenting: {
    247      features: { parenting: 1 },
    248      thresholds: [0.3, 0.4],
    249      diff_p: 1,
    250      diff_q: 0,
    251    },
    252    [SPECIAL_FEATURE_CLICK]: {
    253      features: { click: 1 },
    254      thresholds: [10, 30],
    255      diff_p: 1,
    256      diff_q: 0,
    257    },
    258  },
    259 };
    260 
    261 const jsonModelDataNoCoarseSupport = {
    262  model_type: "clicks",
    263  day_time_weighting: {
    264    days: [3, 14, 45],
    265    relative_weight: [1, 0.5, 0.3],
    266  },
    267  interest_vector: {
    268    news_reader: {
    269      features: { pub_nytimes_com: 0.5, pub_cnn_com: 0.5 },
    270      thresholds: [],
    271      // MISSING thresholds
    272      diff_p: 1,
    273      diff_q: 0,
    274    },
    275    parenting: {
    276      features: { parenting: 1 },
    277      thresholds: [0.3, 0.4],
    278      // MISSING p,q values
    279    },
    280    [SPECIAL_FEATURE_CLICK]: {
    281      features: { click: 1 },
    282      thresholds: [10, 30],
    283      diff_p: 1,
    284      diff_q: 0,
    285    },
    286  },
    287 };
    288 
    289 add_task(function test_FeatureModel_fromJSON() {
    290  const model = FeatureModel.fromJSON(jsonModelData);
    291  const curTime = new Date();
    292  const intervals = model.getDateIntervals(curTime);
    293  Assert.equal(intervals.length, jsonModelData.day_time_weighting.days.length);
    294  for (const interval of intervals) {
    295    Assert.lessOrEqual(
    296      interval.start.getTime(),
    297      interval.end.getTime(),
    298      "Interval start and end are in correct order"
    299    );
    300    Assert.lessOrEqual(
    301      interval.end.getTime(),
    302      curTime.getTime(),
    303      "Interval end is not in future"
    304    );
    305  }
    306 });
    307 
    308 const SQL_RESULT_DATA = [
    309  [
    310    ["click", 0, 1],
    311    ["parenting", 0, 1],
    312  ],
    313  [
    314    ["click", 0, 2],
    315    ["parenting", 0, 1],
    316    ["pub_nytimes_com", 0, 1],
    317  ],
    318  [],
    319 ];
    320 
    321 add_task(function test_modelChecks() {
    322  const model = FeatureModel.fromJSON(jsonModelData);
    323  Assert.equal(
    324    model.supportsCoarseInterests(),
    325    true,
    326    "Supports coarse interests check yes "
    327  );
    328  Assert.equal(
    329    model.supportsCoarsePrivateInterests(),
    330    true,
    331    "Supports coarse private interests check yes "
    332  );
    333 
    334  const modelNoCoarse = FeatureModel.fromJSON(jsonModelDataNoCoarseSupport);
    335  Assert.equal(
    336    modelNoCoarse.supportsCoarseInterests(),
    337    false,
    338    "Supports coarse interests check no "
    339  );
    340  Assert.equal(
    341    modelNoCoarse.supportsCoarsePrivateInterests(),
    342    false,
    343    "Supports coarse private interests check no "
    344  );
    345 });
    346 
    347 add_task(function test_computeInterestVectorClickModel() {
    348  const modelData = { ...jsonModelData, rescale: true };
    349  const model = FeatureModel.fromJSON(modelData);
    350  const result = model.computeInterestVector({
    351    dataForIntervals: SQL_RESULT_DATA,
    352    indexSchema: SCHEMA,
    353    applyThresholding: false,
    354    applyPostProcessing: true,
    355  });
    356  Assert.ok("parenting" in result, "Result should contain parenting");
    357  Assert.ok("news_reader" in result, "Result should contain news_reader");
    358  Assert.equal(result.parenting, 1.0, "Vector is rescaled");
    359 
    360  Assert.equal(result[SPECIAL_FEATURE_CLICK], 2, "Should include raw click");
    361 });
    362 
    363 add_task(function test_computeThresholds() {
    364  const modelData = { ...jsonModelData, rescale: true };
    365  const model = FeatureModel.fromJSON(modelData);
    366  const result = model.computeInterestVector({
    367    dataForIntervals: SQL_RESULT_DATA,
    368    indexSchema: SCHEMA,
    369    applyThresholding: true,
    370  });
    371  Assert.equal(result.parenting, 2, "Threshold is applied");
    372  Assert.equal(
    373    result[SPECIAL_FEATURE_CLICK],
    374    0,
    375    "Should include thresholded raw click"
    376  );
    377 });
    378 
    379 add_task(function test_unaryEncoding() {
    380  const numValues = 4;
    381 
    382  Assert.equal(
    383    unaryEncodeDiffPrivacy(0, numValues, 1, 0),
    384    "1000",
    385    "Basic dp works with out of range p, q"
    386  );
    387  Assert.equal(
    388    unaryEncodeDiffPrivacy(1, numValues, 1, 0),
    389    "0100",
    390    "Basic dp works with out of range p, q"
    391  );
    392  Assert.equal(
    393    unaryEncodeDiffPrivacy(500, numValues, 0.75, 0.25).length,
    394    4,
    395    "Basic dp runs with unexpected input"
    396  );
    397  Assert.equal(
    398    unaryEncodeDiffPrivacy(-100, numValues, 0.75, 0.25).length,
    399    4,
    400    "Basic dp runs with unexpected input"
    401  );
    402  Assert.equal(
    403    unaryEncodeDiffPrivacy(1, numValues, 0.75, 0.25).length,
    404    4,
    405    "Basic dp runs with typical values"
    406  );
    407  Assert.equal(
    408    unaryEncodeDiffPrivacy(1, numValues, 0.8, 0.6).length,
    409    4,
    410    "Basic dp runs with typical values"
    411  );
    412 });
    413 
    414 add_task(function test_differentialPrivacy() {
    415  const modelData = { ...jsonModelData, rescale: true };
    416  const model = FeatureModel.fromJSON(modelData);
    417  const result = model.computeInterestVector({
    418    dataForIntervals: SQL_RESULT_DATA,
    419    indexSchema: SCHEMA,
    420    applyThresholding: true,
    421    applyDifferentialPrivacy: true,
    422  });
    423  Assert.equal(
    424    result.parenting,
    425    "001",
    426    "Threshold is applied with differential privacy"
    427  );
    428  Assert.equal(result[SPECIAL_FEATURE_CLICK].length, 3, "Apply DP to clicks");
    429 });
    430 
    431 add_task(function test_computeMultipleVectors() {
    432  const modelData = { ...jsonModelData, rescale: true };
    433  const model = FeatureModel.fromJSON(modelData);
    434  const result = model.computeInterestVectors({
    435    dataForIntervals: SQL_RESULT_DATA,
    436    indexSchema: SCHEMA,
    437    model_id: "test",
    438    condensePrivateValues: false,
    439  });
    440  Assert.equal(
    441    result.coarsePrivateInferredInterests.parenting,
    442    "001",
    443    "Threshold is applied with differential privacy"
    444  );
    445  Assert.ok(
    446    Number.isInteger(result.coarseInferredInterests.parenting),
    447    "Threshold is applied for coarse interest"
    448  );
    449  Assert.greater(
    450    result.inferredInterests.parenting,
    451    0,
    452    "Original inferred interest is returned"
    453  );
    454 });
    455 
    456 add_task(function test_computeMultipleVectorsCondensed() {
    457  const modelData = { ...jsonModelData, rescale: true };
    458  const model = FeatureModel.fromJSON(modelData);
    459  const result = model.computeInterestVectors({
    460    dataForIntervals: SQL_RESULT_DATA,
    461    indexSchema: SCHEMA,
    462    model_id: "test",
    463  });
    464  Assert.equal(
    465    result.coarsePrivateInferredInterests.values.length,
    466    3,
    467    "Items in an array"
    468  );
    469  Assert.equal(
    470    result.coarsePrivateInferredInterests.values[0].length,
    471    3,
    472    "One value in string per possible result"
    473  );
    474  Assert.ok(
    475    result.coarsePrivateInferredInterests.values[0]
    476      .split("")
    477      .every(a => a === "1" || a === "0"),
    478    "Combined coarse values are 1 and 0"
    479  );
    480  Assert.equal(
    481    result.coarsePrivateInferredInterests.model_id,
    482    "test",
    483    "Model id returned"
    484  );
    485  Assert.greater(
    486    result.inferredInterests.parenting,
    487    0,
    488    "Original inferred interest is returned"
    489  );
    490 });
    491 
    492 add_task(function test_computeMultipleVectorsNoPrivate() {
    493  const model = FeatureModel.fromJSON(jsonModelDataNoCoarseSupport);
    494  const result = model.computeInterestVectors({
    495    dataForIntervals: SQL_RESULT_DATA,
    496    indexSchema: SCHEMA,
    497    model_id: "test",
    498    condensePrivateValues: false,
    499  });
    500  Assert.ok(
    501    !result.coarsePrivateInferredInterests,
    502    "No coarse private interests available"
    503  );
    504  Assert.ok(!result.coarseInferredInterests, "No coarse interests available");
    505  Assert.greater(
    506    result.inferredInterests.parenting,
    507    0,
    508    "Original inferred interest is returned"
    509  );
    510 });
    511 
    512 const ctrModelDataNoDP = {
    513  model_type: "ctr",
    514  noise_scale: 0,
    515  day_time_weighting: {
    516    days: [3, 14, 45],
    517    relative_weight: [1, 0.5, 0.3],
    518  },
    519  interest_vector: {
    520    news_reader: {
    521      features: { pub_nytimes_com: 0.5, pub_cnn_com: 0.5 },
    522    },
    523    parenting: {
    524      features: { parenting: 1 },
    525    },
    526  },
    527 };
    528 
    529 const ctrModelData = {
    530  model_type: "ctr",
    531  noise_scale: 0,
    532  day_time_weighting: {
    533    days: [3, 14, 45],
    534    relative_weight: [1, 0.5, 0.3],
    535  },
    536  interest_vector: {
    537    news_reader: {
    538      features: { pub_nytimes_com: 0.5, pub_cnn_com: 0.5 },
    539      thresholds: [0.3, 0, 8],
    540      diff_p: 1,
    541      diff_q: 0,
    542    },
    543    parenting: {
    544      features: { parenting: 1 },
    545      thresholds: [0.3, 0, 8],
    546      diff_p: 1,
    547      diff_q: 0,
    548    },
    549  },
    550 };
    551 
    552 add_task(function test_postProcessing() {
    553  let model = FeatureModel.fromJSON({
    554    ...ctrModelDataNoDP,
    555    normalize_l1: true,
    556  });
    557  ok(
    558    vectorLooseEquals(model.applyPostProcessing({ a: 0.3, b: 0.5 }), {
    559      a: 0.3 / 0.8,
    560      b: 0.5 / 0.8,
    561    }),
    562    "L1 normalization"
    563  );
    564  model = FeatureModel.fromJSON({ ...ctrModelDataNoDP, normalize: true });
    565  ok(
    566    vectorLooseEquals(model.applyPostProcessing({ a: 1, b: 1 }), {
    567      a: Math.sqrt(2) / 2,
    568      b: Math.sqrt(2) / 2,
    569    }),
    570    "L2 normalization"
    571  );
    572  model = FeatureModel.fromJSON({ ...ctrModelDataNoDP, rescale: true });
    573  ok(
    574    vectorLooseEquals(model.applyPostProcessing({ a: 1.3, b: 1.3 }), {
    575      a: 1,
    576      b: 1,
    577    }),
    578    "Rescale"
    579  );
    580  ok(
    581    vectorLooseEquals(model.applyPostProcessing({ a: 0.0, b: 0.0 }), {
    582      a: 0.0,
    583      b: 0,
    584    }),
    585    "Rescale"
    586  );
    587  model = FeatureModel.fromJSON({ ...ctrModelDataNoDP, normalize: true });
    588  ok(
    589    vectorLooseEquals(model.applyPostProcessing({ a: 0.0, b: 0.0 }), {
    590      a: 0.0,
    591      b: 0,
    592    }),
    593    "L1 0 vector"
    594  );
    595  model = FeatureModel.fromJSON({ ...ctrModelDataNoDP, rescale: true });
    596  ok(
    597    vectorLooseEquals(model.applyPostProcessing({ a: 0.0, b: 0.0 }), {
    598      a: 0.0,
    599      b: 0,
    600    }),
    601    "Rescale 0 vector"
    602  );
    603 });
    604 
    605 add_task(function test_computeCTRInterestVectorsNoNoise() {
    606  const model = FeatureModel.fromJSON(ctrModelDataNoDP);
    607 
    608  // Note these are typically computed with the model.inferredInterests function and are not raw
    609  // per feature impressions
    610  const clickInferredInterests = { parenting: 1 };
    611  const impressionInferredInterests = { parenting: 2, news_reader: 4 };
    612 
    613  const result = model.computeCTRInterestVectors({
    614    clicks: clickInferredInterests,
    615    impressions: impressionInferredInterests,
    616    model_id: "test-ctr-model",
    617  });
    618  console.log(JSON.stringify(result));
    619  Assert.equal(
    620    result.inferredInterests.model_id,
    621    "test-ctr-model",
    622    "Model id is CTR"
    623  );
    624  Assert.equal(result.inferredInterests.parenting, 0.5);
    625  Assert.equal(result.inferredInterests.news_reader, 0);
    626  Assert.ok(!result.coarseInferredInterests, "No coarse inferred interests");
    627 });
    628 
    629 add_task(function test_computeCTRInterestReprocessing() {
    630  const model = FeatureModel.fromJSON({
    631    ...ctrModelData,
    632    normalize_l1: true,
    633  });
    634  // Note these are typically computed with the model.inferredInterests function and are not raw
    635  // per feature impressions
    636  const clickInferredInterests = { parenting: 1 };
    637  const impressionInferredInterests = { parenting: 2, news_reader: 4 };
    638  const result = model.computeCTRInterestVectors({
    639    clicks: clickInferredInterests,
    640    impressions: impressionInferredInterests,
    641    model_id: "test-ctr-model",
    642  });
    643  Assert.equal(result.inferredInterests.parenting, 0.5);
    644  Assert.equal(result.inferredInterests.news_reader, 0);
    645  Assert.equal(result.coarseInferredInterests.parenting, 2); // ctr of 0.5, with vector normalized to 1
    646  Assert.equal(result.coarseInferredInterests.news_reader, 0);
    647 });