Bug 1741868 - Add a scorer for scoring snapshots based on a relevancy score. r=mossop
authorMark Banner <standard8@mozilla.com>
Fri, 19 Nov 2021 16:48:25 +0000
changeset 599694 90063a429c82ed1de1fc434bc74a41be1d58d595
parent 599693 3accfa522abbe084ceb145d1f6b001e71ff07aa2
child 599695 2252db5b63f6764b8476c308d8a21377dd75eb1e
push id153388
push usermbanner@mozilla.com
push dateFri, 19 Nov 2021 16:50:50 +0000
treeherderautoland@90063a429c82 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmossop
bugs1741868
milestone96.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1741868 - Add a scorer for scoring snapshots based on a relevancy score. r=mossop Differential Revision: https://phabricator.services.mozilla.com/D131515
browser/app/profile/firefox.js
browser/components/places/SnapshotScorer.jsm
browser/components/places/SnapshotSelector.jsm
browser/components/places/Snapshots.jsm
browser/components/places/moz.build
browser/components/places/tests/unit/interactions/head_interactions.js
browser/components/places/tests/unit/interactions/test_snapshotscorer.js
browser/components/places/tests/unit/interactions/test_snapshotscorer_combining.js
browser/components/places/tests/unit/interactions/test_snapshotselection_adult.js
browser/components/places/tests/unit/interactions/test_snapshotselection_overlapping.js
browser/components/places/tests/unit/interactions/test_snapshotselection_recent.js
browser/components/places/tests/unit/interactions/test_snapshotselection_setUrlAndRebuildNow.js
browser/components/places/tests/unit/interactions/test_snapshotselection_typed.js
browser/components/places/tests/unit/interactions/xpcshell.ini
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -2647,8 +2647,18 @@ pref("first-startup.timeout", 30000);
 // are expected to go away once a standardized alternative becomes
 // available.
 pref("svg.context-properties.content.allowed-domains", "profile.accounts.firefox.com,profile.stage.mozaws.net");
 
 // Preference that allows individual users to disable Firefox Translations.
 #ifdef NIGHTLY_BUILD
   pref("extensions.translations.disabled", true);
 #endif
+
+// A set of scores for rating the relevancy of snapshots. The suffixes after the
+// last decimal are prefixed by `_score` and reference the functions called in
+// SnapshotScorer.
+pref("browser.snapshots.score.Visit", 1);
+pref("browser.snapshots.score.CurrentSession", 1);
+pref("browser.snapshots.score.InNavigation", 3);
+pref("browser.snapshots.score.IsOverlappingVisit", 3);
+pref("browser.snapshots.score.IsUserPersisted", 1);
+pref("browser.snapshots.score.IsUsedRemoved", -10);
new file mode 100644
--- /dev/null
+++ b/browser/components/places/SnapshotScorer.jsm
@@ -0,0 +1,209 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["SnapshotScorer"];
+
+const { XPCOMUtils } = ChromeUtils.import(
+  "resource://gre/modules/XPCOMUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+  this,
+  "Services",
+  "resource://gre/modules/Services.jsm"
+);
+
+XPCOMUtils.defineLazyGetter(this, "logConsole", function() {
+  return console.createInstance({
+    prefix: "SnapshotSelector",
+    maxLogLevel: Services.prefs.getBoolPref(
+      "browser.snapshots.scorer.log",
+      false
+    )
+      ? "Debug"
+      : "Warn",
+  });
+});
+
+/**
+ * The snapshot scorer receives sets of snapshots and scores them based on the
+ * expected relevancy to the user. This order is subsequently used to display
+ * the candidates.
+ */
+const SnapshotScorer = new (class SnapshotScorer {
+  /**
+   * @type {Map}
+   *   A map of function suffixes to relevancy points. The suffixes are prefixed
+   *   with `_score`. Each function will be called in turn to obtain the score
+   *   for that item with the result multiplied by the relevancy points.
+   *   This map is filled from the `browser.snapshots.score.` preferences.
+   */
+  #RELEVANCY_POINTS = new Map();
+
+  /**
+   * @type {Date|null}
+   *   Used to override the current date for tests.
+   */
+  #dateOverride = null;
+
+  constructor() {
+    XPCOMUtils.defineLazyPreferenceGetter(
+      this,
+      "snapshotThreshold",
+      "browser.places.snapshots.threshold",
+      4
+    );
+
+    let branch = Services.prefs.getBranch("browser.snapshots.score.");
+    for (let name of branch.getChildList("")) {
+      this.#RELEVANCY_POINTS.set(name, branch.getIntPref(name, 0));
+    }
+  }
+
+  /**
+   * Combines groups of snapshots into one group, and scoring their relevance.
+   * If snapshots are present in multiple groups, the snapshot with the highest
+   * score is used.
+   * A snapshot score must meet the `snapshotThreshold` to be included in the
+   * results.
+   *
+   * @param {Set} currentSessionUrls
+   *    A set of urls that are in the current session.
+   * @param {Snapshot[]} snapshotGroups
+   *    One or more arrays of snapshot groups to combine.
+   * @returns {Snapshot[]}
+   *    The combined snapshot array in descending order of relevancy.
+   */
+  combineAndScore(currentSessionUrls, ...snapshotGroups) {
+    let combined = new Map();
+    let currentDate = this.#dateOverride ?? Date.now();
+    for (let group of snapshotGroups) {
+      for (let snapshot of group) {
+        let existing = combined.get(snapshot.url);
+        let score = this.#score(snapshot, currentDate, currentSessionUrls);
+        logConsole.debug("Scored", score, "for", snapshot.url);
+        if (existing) {
+          if (score > existing.relevancyScore) {
+            snapshot.relevancyScore = score;
+            combined.set(snapshot.url, snapshot);
+          }
+        } else if (score >= this.snapshotThreshold) {
+          snapshot.relevancyScore = score;
+          combined.set(snapshot.url, snapshot);
+        }
+      }
+    }
+
+    return [...combined.values()].sort(
+      (a, b) => b.relevancyScore - a.relevancyScore
+    );
+  }
+
+  /**
+   * Test-only. Overrides the time used in the scoring algorithm with a
+   * specific time which allows for deterministic tests.
+   *
+   * @param {number} date
+   *   Epoch time to set the date to.
+   */
+  overrideCurrentTimeForTests(date) {
+    this.#dateOverride = date;
+  }
+
+  /**
+   * Scores a snapshot based on its relevancy.
+   *
+   * @param {Snapshot} snapshot
+   *   The snapshot to score.
+   * @param {number} currentDate
+   *   The current time in milliseconds from the epoch.
+   * @param {Set} currentSessionUrls
+   *   The urls of the current session.
+   * @returns {number}
+   *   The relevancy score for the snapshot.
+   */
+  #score(snapshot, currentDate, currentSessionUrls) {
+    let points = 0;
+    for (let [item, value] of this.#RELEVANCY_POINTS.entries()) {
+      let fnName = `_score${item}`;
+      if (!(fnName in this)) {
+        console.error("Could not find function", fnName, "in SnapshotScorer");
+        continue;
+      }
+      points += this[fnName](snapshot, currentSessionUrls) * value;
+    }
+
+    let timeAgo = currentDate - snapshot.lastInteractionAt;
+    timeAgo = timeAgo / (24 * 60 * 60 * 1000);
+
+    return points * Math.exp(timeAgo / -7);
+  }
+
+  /**
+   * Calculates points based on how many times the snapshot has been visited.
+   *
+   * @param {Snapshot} snapshot
+   * @returns {number}
+   */
+  _scoreVisit(snapshot) {
+    // Protect against cases where a bookmark was created without a visit.
+    if (snapshot.visitCount == 0) {
+      return 0;
+    }
+    return 2 - 1 / snapshot.visitCount;
+  }
+
+  /**
+   * Calculates points based on if the snapshot has already been visited in
+   * the current session.
+   *
+   * @param {Snapshot} snapshot
+   * @param {Set} currentSessionUrls
+   * @returns {number}
+   */
+  _scoreCurrentSession(snapshot, currentSessionUrls) {
+    return currentSessionUrls.has(snapshot.url) ? 1 : 0;
+  }
+
+  /**
+   * Not currently used.
+   *
+   * @param {Snapshot} snapshot
+   * @returns {number}
+   */
+  _scoreInNavigation(snapshot) {
+    // In Navigation is not currently implemented.
+    return 0;
+  }
+
+  /**
+   * Calculates points based on if the snapshot has been visited within a
+   * certain time period of another website.
+   *
+   * @param {Snapshot} snapshot
+   * @returns {number}
+   */
+  _scoreIsOverlappingVisit(snapshot) {
+    return snapshot.overlappingVisitScore ?? 0;
+  }
+
+  /**
+   * Calculates points based on if the user persisted the snapshot.
+   *
+   * @param {Snapshot} snapshot
+   * @returns {number}
+   */
+  _scoreIsUserPersisted(snapshot) {
+    return snapshot.userPersisted ? 1 : 0;
+  }
+
+  /**
+   * Calculates points based on if the user removed the snapshot.
+   *
+   * @param {Snapshot} snapshot
+   * @returns {number}
+   */
+  _scoreIsUsedRemoved(snapshot) {
+    return snapshot.removedAt ? 1 : 0;
+  }
+})();
--- a/browser/components/places/SnapshotSelector.jsm
+++ b/browser/components/places/SnapshotSelector.jsm
@@ -8,16 +8,17 @@ const { XPCOMUtils } = ChromeUtils.impor
 );
 
 XPCOMUtils.defineLazyModuleGetters(this, {
   EventEmitter: "resource://gre/modules/EventEmitter.jsm",
   DeferredTask: "resource://gre/modules/DeferredTask.jsm",
   FilterAdult: "resource://activity-stream/lib/FilterAdult.jsm",
   Services: "resource://gre/modules/Services.jsm",
   Snapshots: "resource:///modules/Snapshots.jsm",
+  SnapshotScorer: "resource:///modules/SnapshotScorer.jsm",
 });
 
 XPCOMUtils.defineLazyGetter(this, "logConsole", function() {
   return console.createInstance({
     prefix: "SnapshotSelector",
     maxLogLevel: Services.prefs.getBoolPref(
       "browser.places.interactions.log",
       false
@@ -82,39 +83,57 @@ class SnapshotSelector extends EventEmit
      * @type {string | undefined}
      */
     url: undefined,
     /**
      * The type of snapshots desired.
      * @type {PageDataCollector.DATA_TYPE | undefined}
      */
     type: undefined,
+
+    /**
+     * A function that returns a Set containing the urls for the current session.
+     * @type {function}
+     */
+    getCurrentSessionUrls: undefined,
   };
 
   /**
    * A DeferredTask that runs the task to generate snapshots.
    */
   #task = null;
 
   /**
-   * @param {number} count
+   * @param {object} options
+   * @param {number} [options.count]
    *   The maximum number of snapshots we ever need to generate. This should not
    *   affect the actual snapshots generated and their order but may speed up
    *   calculations.
-   * @param {boolean} filterAdult
+   * @param {boolean} [options.filterAdult]
    *   Whether adult sites should be filtered from the snapshots.
-   * @param {boolean} selectOverlappingVisits
+   * @param {boolean} [options.selectOverlappingVisits]
    *   Whether to select snapshots where visits overlapped the current context url
+   * @param {function} [options.getCurrentSessionUrls]
+   *   A function that returns a Set containing the urls for the current session.
    */
-  constructor(count = 5, filterAdult = false, selectOverlappingVisits = false) {
+  constructor({
+    count = 5,
+    filterAdult = false,
+    selectOverlappingVisits = false,
+    getCurrentSessionUrls = () => new Set(),
+  }) {
     super();
-    this.#task = new DeferredTask(() => this.#buildSnapshots(), 500);
+    this.#task = new DeferredTask(
+      () => this.#buildSnapshots().catch(console.error),
+      500
+    );
     this.#context.count = count;
     this.#context.filterAdult = filterAdult;
     this.#context.selectOverlappingVisits = selectOverlappingVisits;
+    this.#context.getCurrentSessionUrls = getCurrentSessionUrls;
     SnapshotSelector.#selectors.add(this);
   }
 
   /**
    * Call to destroy the selector.
    */
   destroy() {
     this.#task.disarm();
@@ -207,20 +226,30 @@ class SnapshotSelector extends EventEmit
     let context = { ...this.#context };
     logConsole.debug("Building overlapping snapshots", context);
 
     let snapshots = await Snapshots.queryOverlapping(context.url);
     snapshots = snapshots.filter(snapshot => {
       return !context.filterAdult || !FilterAdult.isAdultUrl(snapshot.url);
     });
 
+    logConsole.debug(
+      "Found overlapping snapshots:",
+      snapshots.map(s => s.url)
+    );
+
+    snapshots = SnapshotScorer.combineAndScore(
+      this.#context.getCurrentSessionUrls(),
+      snapshots
+    );
+
     snapshots = snapshots.slice(0, context.count);
 
     logConsole.debug(
-      "Found overlapping snapshots: ",
+      "Reduced final candidates:",
       snapshots.map(s => s.url)
     );
 
     this.#snapshotsGenerated(snapshots);
   }
 
   /**
    * Sets the current context's url for this selector.
--- a/browser/components/places/Snapshots.jsm
+++ b/browser/components/places/Snapshots.jsm
@@ -107,20 +107,21 @@ XPCOMUtils.defineLazyPreferenceGetter(
  * @property {Date} lastInteractionAt
  *   The date/time of the last interaction with the snapshot.
  * @property {Interactions.DOCUMENT_TYPE} documentType
  *   The document type of the snapshot.
  * @property {boolean} userPersisted
  *   True if the user created or persisted the snapshot in some way.
  * @property {Map<type, data>} pageData
  *   Collection of PageData by type. See PageDataService.jsm
-* @property {Number} overlappingVisitScore
+ * @property {Number} overlappingVisitScore
  *   Calculated score based on overlapping visits to the context url. In the range [0.0, 1.0]
-  
-*/
+ * @property {number} [relevancyScore]
+ *   The relevancy score associated with the snapshot.
+ */
 
 /**
  * Handles storing and retrieving of Snapshots in the Places database.
  *
  * Notifications of updates are sent via the observer service:
  * - places-snapshots-added, data: JSON encoded array of urls
  *     Sent when a new snapshot is added
  * - places-snapshots-deleted, data: JSON encoded array of urls
@@ -431,17 +432,18 @@ const Snapshots = new (class Snapshots {
       extraWhereCondition = " AND removed_at IS NULL";
     }
 
     let rows = await db.executeCached(
       `
       SELECT h.url AS url, h.title AS title, created_at, removed_at,
              document_type, first_interaction_at, last_interaction_at,
              user_persisted, description, site_name, preview_image_url,
-             group_concat('[' || e.type || ', ' || e.data || ']') AS page_data
+             group_concat('[' || e.type || ', ' || e.data || ']') AS page_data,
+             h.visit_count
              FROM moz_places_metadata_snapshots s
       JOIN moz_places h ON h.id = s.place_id
       LEFT JOIN moz_places_metadata_snapshots_extra e ON e.place_id = s.place_id
       WHERE h.url_hash = hash(:url) AND h.url = :url
        ${extraWhereCondition}
       GROUP BY s.place_id
     `,
       { url }
@@ -489,17 +491,18 @@ const Snapshots = new (class Snapshots {
 
     let whereStatement = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
 
     let rows = await db.executeCached(
       `
       SELECT h.url AS url, h.title AS title, created_at, removed_at,
              document_type, first_interaction_at, last_interaction_at,
              user_persisted, description, site_name, preview_image_url,
-             group_concat('[' || e.type || ', ' || e.data || ']') AS page_data
+             group_concat('[' || e.type || ', ' || e.data || ']') AS page_data,
+             h.visit_count
       FROM moz_places_metadata_snapshots s
       JOIN moz_places h ON h.id = s.place_id
       LEFT JOIN moz_places_metadata_snapshots_extra e ON e.place_id = s.place_id
       ${whereStatement}
       GROUP BY s.place_id
       ORDER BY last_interaction_at DESC
       LIMIT :limit
     `,
@@ -552,17 +555,18 @@ const Snapshots = new (class Snapshots {
       return [];
     }
 
     let db = await PlacesUtils.promiseDBConnection();
 
     let rows = await db.executeCached(
       `SELECT h.url AS url, h.title AS title, o.overlappingVisitScore, created_at, removed_at,
       document_type, first_interaction_at, last_interaction_at,
-      user_persisted, description, site_name, preview_image_url, group_concat(e.data, ",") AS page_data
+      user_persisted, description, site_name, preview_image_url, group_concat(e.data, ",") AS page_data,
+      h.visit_count
       FROM moz_places_metadata_snapshots s JOIN moz_places h ON h.id = s.place_id JOIN (
         SELECT place_id, 1.0 AS overlappingVisitScore
         FROM
           (SELECT created_at - :snapshot_overlap_limit AS page_start, updated_at + :snapshot_overlap_limit AS page_end FROM moz_places_metadata WHERE place_id = :current_id) AS current_page
           JOIN
           (SELECT place_id, created_at AS snapshot_start, updated_at AS snapshot_end FROM moz_places_metadata WHERE place_id != :current_id) AS suggestion
         WHERE
           snapshot_start BETWEEN page_start AND page_end
@@ -660,16 +664,17 @@ const Snapshots = new (class Snapshots {
       ),
       lastInteractionAt: this.#toDate(
         row.getResultByName("last_interaction_at")
       ),
       documentType: row.getResultByName("document_type"),
       userPersisted: !!row.getResultByName("user_persisted"),
       overlappingVisitScore,
       pageData: pageData ?? new Map(),
+      visitCount: row.getResultByName("visit_count"),
     };
 
     snapshot.commonName = CommonNames.getName(snapshot);
     return snapshot;
   }
 
   /**
    * Get the image that should represent the snapshot. The image URL
--- a/browser/components/places/moz.build
+++ b/browser/components/places/moz.build
@@ -17,16 +17,17 @@ BROWSER_CHROME_MANIFESTS += [
 JAR_MANIFESTS += ["jar.mn"]
 
 EXTRA_JS_MODULES += [
     "CommonNames.jsm",
     "Interactions.jsm",
     "InteractionsBlocklist.jsm",
     "PlacesUIUtils.jsm",
     "Snapshots.jsm",
+    "SnapshotScorer.jsm",
     "SnapshotSelector.jsm",
 ]
 
 FINAL_TARGET_FILES.actors += [
     "InteractionsChild.jsm",
     "InteractionsParent.jsm",
 ]
 
--- a/browser/components/places/tests/unit/interactions/head_interactions.js
+++ b/browser/components/places/tests/unit/interactions/head_interactions.js
@@ -8,16 +8,17 @@ const { XPCOMUtils } = ChromeUtils.impor
 
 XPCOMUtils.defineLazyModuleGetters(this, {
   Interactions: "resource:///modules/Interactions.jsm",
   PlacesTestUtils: "resource://testing-common/PlacesTestUtils.jsm",
   PlacesUtils: "resource://gre/modules/PlacesUtils.jsm",
   setTimeout: "resource://gre/modules/Timer.jsm",
   Services: "resource://gre/modules/Services.jsm",
   Snapshots: "resource:///modules/Snapshots.jsm",
+  SnapshotScorer: "resource:///modules/SnapshotScorer.jsm",
   SnapshotSelector: "resource:///modules/SnapshotSelector.jsm",
   TestUtils: "resource://testing-common/TestUtils.jsm",
 });
 
 // Initialize profile.
 var gProfD = do_get_profile(true);
 
 // Observer notifications.
@@ -277,8 +278,37 @@ async function assertSnapshotsWithContex
 }
 /**
  * Clears all data from the snapshots and metadata tables.
  */
 async function reset() {
   await Snapshots.reset();
   await Interactions.reset();
 }
+
+/**
+ * Asserts relevancy scores for snapshots are correct.
+ *
+ * @param {Snapshot[]} combinedSnapshots
+ *   The array of combined snapshots.
+ * @param {object[]} expectedSnapshots
+ *   An array of objects containing expected url and relevancyScore properties.
+ */
+function assertSnapshotScores(combinedSnapshots, expectedSnapshots) {
+  Assert.equal(
+    combinedSnapshots.length,
+    expectedSnapshots.length,
+    "Should have returned the correct amount of snapshots"
+  );
+
+  for (let i = 0; i < combinedSnapshots.length; i++) {
+    Assert.equal(
+      combinedSnapshots[i].url,
+      expectedSnapshots[i].url,
+      "Should have returned the expected URL for the snapshot"
+    );
+    Assert.equal(
+      combinedSnapshots[i].relevancyScore,
+      expectedSnapshots[i].score,
+      `Should have set the expected score for ${expectedSnapshots[i].url}`
+    );
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/components/places/tests/unit/interactions/test_snapshotscorer.js
@@ -0,0 +1,259 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests that snapshots are correctly scored.
+ */
+
+const SCORE_TESTS = [
+  {
+    testName: "Basic test",
+    lastVisit: 0,
+    visitCount: 1,
+    currentSession: false,
+    overlappingVisitScore: 0,
+    userPersisted: false,
+    userRemoved: false,
+    score: 1,
+  },
+  {
+    testName: "Last visit test 1",
+    lastVisit: 4,
+    visitCount: 1,
+    currentSession: false,
+    overlappingVisitScore: 0,
+    userPersisted: true,
+    userRemoved: false,
+    score: 1.1294362440155186,
+  },
+  {
+    testName: "Last visit test 2",
+    lastVisit: 6,
+    visitCount: 1,
+    currentSession: false,
+    overlappingVisitScore: 0,
+    userPersisted: true,
+    userRemoved: false,
+    score: 0.8487456913539,
+  },
+  {
+    testName: "Last visit test 3",
+    lastVisit: 7,
+    visitCount: 1,
+    currentSession: false,
+    userPersisted: true,
+    userRemoved: false,
+    score: 0.7357588823428847,
+  },
+  {
+    testName: "Large visit count test",
+    lastVisit: 0,
+    visitCount: 100,
+    currentSession: false,
+    overlappingVisitScore: 0,
+    userPersisted: false,
+    userRemoved: false,
+    score: 1.99,
+  },
+  {
+    testName: "Zero visit count test",
+    lastVisit: 0,
+    visitCount: 0,
+    currentSession: false,
+    overlappingVisitScore: 0,
+    userPersisted: true,
+    userRemoved: false,
+    score: 1,
+  },
+  {
+    testName: "In current session test",
+    lastVisit: 0,
+    visitCount: 1,
+    currentSession: true,
+    overlappingVisitScore: 0,
+    userPersisted: false,
+    userRemoved: false,
+    score: 2,
+  },
+  {
+    testName: "Overlapping visit score test 1",
+    lastVisit: 0,
+    visitCount: 1,
+    currentSession: false,
+    overlappingVisitScore: 1.0,
+    userPersisted: false,
+    userRemoved: false,
+    score: 4,
+  },
+  {
+    testName: "Overlapping visit score test 2",
+    lastVisit: 0,
+    visitCount: 1,
+    currentSession: false,
+    overlappingVisitScore: 0.5,
+    userPersisted: false,
+    userRemoved: false,
+    score: 2.5,
+  },
+  {
+    testName: "User persisted test",
+    lastVisit: 0,
+    visitCount: 1,
+    currentSession: false,
+    overlappingVisitScore: 0,
+    userPersisted: true,
+    userRemoved: false,
+    score: 2,
+  },
+  {
+    testName: "User removed test",
+    lastVisit: 0,
+    visitCount: 1,
+    currentSession: false,
+    overlappingVisitScore: 0,
+    userPersisted: false,
+    userRemoved: true,
+    score: -9, // 1 for the visit, -10 for removed
+  },
+];
+
+// Tests for ensuring the threshold works. Note that these need to be in reverse
+// score order.
+const THRESHOLD_TESTS = [
+  {
+    lastVisit: 0,
+    visitCount: 100,
+    currentSession: true,
+    overlappingVisitScore: 1.0,
+    userPersisted: true,
+    userRemoved: false,
+    score: 6.99,
+  },
+  {
+    lastVisit: 0,
+    visitCount: 100,
+    currentSession: false,
+    overlappingVisitScore: 0.35,
+    userPersisted: true,
+    userRemoved: false,
+    score: 4.04,
+  },
+  {
+    lastVisit: 0,
+    visitCount: 1,
+    currentSession: false,
+    overlappingVisitScore: 0.0,
+    userPersisted: false,
+    userRemoved: false,
+    score: 1,
+  },
+];
+
+add_task(async function setup() {
+  let now = Date.now();
+
+  SnapshotScorer.overrideCurrentTimeForTests(now);
+
+  for (let [i, data] of [...SCORE_TESTS, ...THRESHOLD_TESTS].entries()) {
+    let createdAt = now - data.lastVisit * 24 * 60 * 60 * 1000;
+    let url = `https://example.com/${i}`;
+    if (data.visitCount != 0) {
+      await addInteractions([{ url, created_at: createdAt }]);
+      if (data.visitCount > 2) {
+        let urls = new Array(data.visitCount - 1);
+        urls.fill(url);
+        await PlacesTestUtils.addVisits(urls);
+      }
+    }
+    await Snapshots.add({ url, userPersisted: data.userPersisted });
+
+    if (data.visitCount == 0) {
+      // For the last updated time for the snapshot to be "now", so that we can
+      // have a fixed value for the score in the test.
+      await PlacesUtils.withConnectionWrapper(
+        "test_snapshotscorer",
+        async db => {
+          await db.executeCached(
+            `UPDATE moz_places_metadata_snapshots
+             SET last_interaction_at = :lastInteractionAt
+             WHERE place_id = (SELECT id FROM moz_places WHERE url_hash = hash(:url) AND url = :url)`,
+            { lastInteractionAt: now, url }
+          );
+        }
+      );
+    }
+
+    if (data.userRemoved) {
+      await Snapshots.delete(url);
+    }
+  }
+});
+
+function handleSnapshotSetup(testData, snapshot, sessionUrls) {
+  if (testData.overlappingVisitScore) {
+    snapshot.overlappingVisitScore = testData.overlappingVisitScore;
+  }
+  if (testData.currentSession) {
+    sessionUrls.add(snapshot.url);
+  }
+}
+
+add_task(async function test_scores() {
+  // Set threshold to -10 as that's below the lowest we can get and we want
+  // to test the full score range.
+  Services.prefs.setIntPref("browser.places.snapshots.threshold", -10);
+
+  for (let [i, data] of SCORE_TESTS.entries()) {
+    info(`${data.testName}`);
+
+    let sessionUrls = new Set([`https://mochitest:8888/`]);
+
+    let url = `https://example.com/${i}`;
+    let snapshot = await Snapshots.get(url, true);
+
+    handleSnapshotSetup(data, snapshot, sessionUrls);
+
+    let snapshots = await SnapshotScorer.combineAndScore(sessionUrls, [
+      snapshot,
+    ]);
+
+    assertSnapshotScores(snapshots, [
+      {
+        url,
+        score: data.score,
+      },
+    ]);
+  }
+});
+
+add_task(async function test_score_threshold() {
+  const THRESHOLD = 4;
+  Services.prefs.setIntPref("browser.places.snapshots.threshold", THRESHOLD);
+
+  let sessionUrls = new Set([`https://mochitest:8888/`]);
+  let originalSnapshots = [];
+
+  for (let i = 0; i < THRESHOLD_TESTS.length; i++) {
+    let url = `https://example.com/${i + SCORE_TESTS.length}`;
+    let snapshot = await Snapshots.get(url, true);
+
+    handleSnapshotSetup(THRESHOLD_TESTS[i], snapshot, sessionUrls);
+    originalSnapshots.push(snapshot);
+  }
+
+  let snapshots = await SnapshotScorer.combineAndScore(
+    sessionUrls,
+    originalSnapshots
+  );
+
+  assertSnapshotScores(
+    snapshots,
+    // map before filter so that we get the url values correct.
+    THRESHOLD_TESTS.map((t, i) => {
+      return {
+        url: `https://example.com/${i + SCORE_TESTS.length}`,
+        score: t.score,
+      };
+    }).filter(t => t.score > THRESHOLD)
+  );
+});
new file mode 100644
--- /dev/null
+++ b/browser/components/places/tests/unit/interactions/test_snapshotscorer_combining.js
@@ -0,0 +1,77 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests that snapshots are correctly scored. Note this file does not test
+ * InNavigation and InTimeWindow.
+ */
+
+const TEST_URL1 = "https://example.com/";
+const TEST_URL2 = "https://invalid.com/";
+
+add_task(async function setup() {
+  let now = Date.now();
+  SnapshotScorer.overrideCurrentTimeForTests(now);
+
+  await addInteractions([{ url: TEST_URL1, created_at: now }]);
+  await Snapshots.add({ url: TEST_URL1, userPersisted: true });
+
+  await addInteractions([{ url: TEST_URL2, created_at: now }]);
+  await Snapshots.add({ url: TEST_URL2, userPersisted: true });
+});
+
+add_task(async function test_combining_throw_away_first() {
+  let snapshot1a = await Snapshots.get(TEST_URL1);
+  let snapshot1b = await Snapshots.get(TEST_URL1);
+  let snapshot2 = await Snapshots.get(TEST_URL2);
+
+  // Set up so that 1a will be thrown away.
+  snapshot1a.overlappingVisitScore = 0.5;
+  snapshot2.overlappingVisitScore = 0.5;
+  snapshot1b.overlappingVisitScore = 1.0;
+
+  let combined = SnapshotScorer.combineAndScore(
+    new Set([TEST_URL1, TEST_URL2]),
+    [snapshot1a],
+    [snapshot1b, snapshot2]
+  );
+
+  assertSnapshotScores(combined, [
+    {
+      url: TEST_URL1,
+      score: 6,
+    },
+    {
+      url: TEST_URL2,
+      score: 4.5,
+    },
+  ]);
+});
+
+add_task(async function test_combining_throw_away_second_and_sort() {
+  // We swap the snapshots around a bit here to additionally test the sort.
+  let snapshot1 = await Snapshots.get(TEST_URL1);
+  let snapshot2a = await Snapshots.get(TEST_URL2);
+  let snapshot2b = await Snapshots.get(TEST_URL2);
+
+  snapshot1.overlappingVisitScore = 0.5;
+  snapshot2a.overlappingVisitScore = 1.0;
+  snapshot2b.overlappingVisitScore = 0.5;
+
+  let combined = SnapshotScorer.combineAndScore(
+    new Set([TEST_URL1, TEST_URL2]),
+    [snapshot2a],
+    [snapshot1, snapshot2b]
+  );
+
+  assertSnapshotScores(combined, [
+    {
+      url: TEST_URL2,
+      score: 6,
+    },
+    {
+      url: TEST_URL1,
+      score: 4.5,
+    },
+  ]);
+});
--- a/browser/components/places/tests/unit/interactions/test_snapshotselection_adult.js
+++ b/browser/components/places/tests/unit/interactions/test_snapshotselection_adult.js
@@ -28,18 +28,21 @@ add_task(async function setup() {
 
   await addInteractions([{ url: TEST_URL1, created_at: now - 2000 }]);
   await Snapshots.add({ url: TEST_URL1 });
 
   await addSnapshotAndFilter(TEST_URL2);
 });
 
 add_task(async function test_interactions_adult_basic() {
-  let anySelector = new SnapshotSelector(2, false);
-  let adultFilterSelector = new SnapshotSelector(2, true);
+  let anySelector = new SnapshotSelector({ count: 2, filterAdult: false });
+  let adultFilterSelector = new SnapshotSelector({
+    count: 2,
+    filterAdult: true,
+  });
 
   let snapshotPromise = anySelector.once("snapshots-updated");
   anySelector.rebuild();
   let snapshots = await snapshotPromise;
 
   await assertSnapshotList(snapshots, [
     { url: TEST_URL2, userPersisted: true },
     { url: TEST_URL1 },
@@ -59,17 +62,20 @@ add_task(async function test_interaction
   // Add a couple more sites to the filter.
   await addSnapshotAndFilter(TEST_URL3);
   await addSnapshotAndFilter(TEST_URL4);
 
   // Add a second url to not be filtered.
   await addInteractions([{ url: TEST_URL5, created_at: Date.now() - 2000 }]);
   await Snapshots.add({ url: TEST_URL5 });
 
-  let adultFilterSelector = new SnapshotSelector(2, true);
+  let adultFilterSelector = new SnapshotSelector({
+    count: 2,
+    filterAdult: true,
+  });
 
   let snapshotPromise = adultFilterSelector.once("snapshots-updated");
   adultFilterSelector.rebuild();
   let snapshots = await snapshotPromise;
 
   await assertSnapshotList(snapshots, [{ url: TEST_URL5 }, { url: TEST_URL1 }]);
 
   adultFilterSelector.destroy();
--- a/browser/components/places/tests/unit/interactions/test_snapshotselection_overlapping.js
+++ b/browser/components/places/tests/unit/interactions/test_snapshotselection_overlapping.js
@@ -5,17 +5,24 @@
  * Test that the selectOverlappingVisits param only selects overlapping snapshots
  */
 
 const TEST_URL1 = "https://example.com/";
 const TEST_URL2 = "https://example.com/2";
 const TEST_URL3 = "https://example.com/3";
 const TEST_URL4 = "https://example.com/4";
 
-add_task(async function test_enable_overlapping() {
+let selector;
+let currentSessionUrls = new Set();
+
+function getCurrentSessionUrls() {
+  return currentSessionUrls;
+}
+
+add_task(async function test_setup() {
   const ONE_MINUTE = 1000 * 60;
   const ONE_HOUR = ONE_MINUTE * 60;
 
   let now = Date.now();
   await addInteractions([
     {
       url: TEST_URL1,
       created_at: now - ONE_MINUTE,
@@ -25,21 +32,27 @@ add_task(async function test_enable_over
       url: TEST_URL2,
       created_at: now - ONE_MINUTE * 2,
       updated_at: now + ONE_MINUTE * 2,
     },
     { url: TEST_URL3, created_at: now + ONE_HOUR, updated_at: now + ONE_HOUR },
     { url: TEST_URL4, created_at: now - ONE_HOUR, updated_at: now - ONE_HOUR },
   ]);
 
-  let selector = new SnapshotSelector(
-    5 /* count */,
-    false /* filter adult */,
-    true /* selectOverlappingVisits */
-  );
+  selector = new SnapshotSelector({
+    count: 5,
+    filterAdult: false,
+    selectOverlappingVisits: true,
+    getCurrentSessionUrls,
+  });
+});
+
+add_task(async function test_enable_overlapping() {
+  // Allow all snapshots regardless of their score.
+  Services.prefs.setIntPref("browser.places.snapshots.threshold", -10);
 
   let snapshotPromise = selector.once("snapshots-updated");
   selector.rebuild();
   let snapshots = await snapshotPromise;
 
   await assertSnapshotList(snapshots, []);
 
   snapshotPromise = selector.once("snapshots-updated");
@@ -49,8 +62,29 @@ add_task(async function test_enable_over
   await Snapshots.add({ url: TEST_URL4 });
 
   selector.setUrl(TEST_URL1);
   snapshots = await snapshotPromise;
 
   // Only snapshots with overlaping interactions should be selected
   await assertSnapshotList(snapshots, [{ url: TEST_URL2 }]);
 });
+
+add_task(async function test_overlapping_with_scoring() {
+  // Reset the threshold, the snapshot should be lower than the required score.
+  Services.prefs.clearUserPref("browser.places.snapshots.threshold");
+
+  let snapshotPromise = selector.once("snapshots-updated");
+  selector.rebuild();
+  let snapshots = await snapshotPromise;
+
+  await assertSnapshotList(snapshots, []);
+
+  // Boost the score of the expected snapshot by adding it to the current url
+  // set.
+  currentSessionUrls.add(TEST_URL2);
+
+  snapshotPromise = selector.once("snapshots-updated");
+  selector.rebuild();
+  snapshots = await snapshotPromise;
+
+  await assertSnapshotList(snapshots, [{ url: TEST_URL2 }]);
+});
--- a/browser/components/places/tests/unit/interactions/test_snapshotselection_recent.js
+++ b/browser/components/places/tests/unit/interactions/test_snapshotselection_recent.js
@@ -14,17 +14,17 @@ const TEST_URL4 = "https://example.com/1
 add_task(async function test_interactions_recent() {
   let now = Date.now();
   await addInteractions([
     { url: TEST_URL1, created_at: now - 2000 },
     { url: TEST_URL2, created_at: now - 1000 },
     { url: TEST_URL3, created_at: now - 3000 },
   ]);
 
-  let selector = new SnapshotSelector(2);
+  let selector = new SnapshotSelector({ count: 2 });
 
   let snapshotPromise = selector.once("snapshots-updated");
   selector.rebuild();
   let snapshots = await snapshotPromise;
 
   await assertSnapshotList(snapshots, []);
 
   snapshotPromise = selector.once("snapshots-updated");
--- a/browser/components/places/tests/unit/interactions/test_snapshotselection_setUrlAndRebuildNow.js
+++ b/browser/components/places/tests/unit/interactions/test_snapshotselection_setUrlAndRebuildNow.js
@@ -13,17 +13,17 @@ const TEST_URL3 = "https://example.com/1
 add_task(async function test_setUrlCoalescing() {
   let now = Date.now();
   await addInteractions([
     { url: TEST_URL1, created_at: now - 2000 },
     { url: TEST_URL2, created_at: now - 1000 },
     { url: TEST_URL3, created_at: now - 3000 },
   ]);
 
-  let selector = new SnapshotSelector(2);
+  let selector = new SnapshotSelector({ count: 2 });
 
   let snapshotPromise = selector.once("snapshots-updated");
   selector.rebuild();
   let snapshots = await snapshotPromise;
 
   await assertSnapshotList(snapshots, []);
 
   snapshotPromise = selector.once("snapshots-updated");
--- a/browser/components/places/tests/unit/interactions/test_snapshotselection_typed.js
+++ b/browser/components/places/tests/unit/interactions/test_snapshotselection_typed.js
@@ -34,17 +34,17 @@ add_task(async () => {
       },
     },
   });
 
   await Snapshots.add({ url: TEST_URL1 });
   await Snapshots.add({ url: TEST_URL2 });
   await Snapshots.add({ url: TEST_URL3 });
 
-  let selector = new SnapshotSelector(5);
+  let selector = new SnapshotSelector({ count: 5 });
 
   let snapshotPromise = selector.once("snapshots-updated");
   selector.setUrl(TEST_URL4);
   let snapshots = await snapshotPromise;
 
   // Finds any snapshot.
   await assertSnapshotList(snapshots, [
     { url: TEST_URL2 },
--- a/browser/components/places/tests/unit/interactions/xpcshell.ini
+++ b/browser/components/places/tests/unit/interactions/xpcshell.ini
@@ -1,24 +1,27 @@
 [DEFAULT]
 prefs =
   browser.places.interactions.enabled=true
   browser.places.interactions.log=true
+  browser.snapshots.scorer.log=true
   browser.pagedata.enabled=true
   browser.pagedata.log=true
 head = head_interactions.js
 firefox-appdir = browser
 skip-if = toolkit == 'android' # bug 1730213
 
 [test_commonNames.js]
 [test_snapshot_added_no_interaction.js]
 [test_snapshots_basics.js]
 [test_snapshots_create_allow_protocols.js]
 [test_snapshots_create_criteria.js]
 [test_snapshots_overlapping_queries.js]
 [test_snapshots_pagedata.js]
 [test_snapshots_page_image.js]
 [test_snapshots_queries.js]
+[test_snapshotscorer_combining.js]
+[test_snapshotscorer.js]
 [test_snapshotselection_adult.js]
 [test_snapshotselection_overlapping.js]
 [test_snapshotselection_recent.js]
 [test_snapshotselection_setUrlAndRebuildNow.js]
 [test_snapshotselection_typed.js]