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 });