Repository: GoogleChrome/lighthouse (js)

Issue:
Redefine score curves with p90 rather than podr
It's come up a few times.. most recently here https://github.com/GoogleChrome/lighthouse/pull/10495#discussion_r396711449

> we should really consider reworking our math so we can define in terms of the 90 cutoff :) haha

It makes sense, since 90 is passing and a key threshold. Our source of truth should use them instead of numbers SO CLOSE to it.



Pull request description:
core(scoring): redefine log-normal curves with median and p10 points
In theory defining our score curves with a median and the point of diminishing returns is still a really good idea. The PoDR is a distinctive point that intuitively controls the *behavior* of the curve. It also allows us to set where to stop incentivizing improvements in a particular metric when other metrics should be prioritized instead.

However, in practice it's never been useful for communicating what our uses have wanted to know :) It doesn't correspond to a particular percentile (when the score curve is fundamentally about producing percentiles) which makes it difficult to explain. And whatever behavior might be vaguely incentivized in aggregate, when actually running Lighthouse or creating a new audit you really just want to know what's going to get you a "good" score.

This PR
- creates a new method in `statistics.js` for creating a performance curve from a median point and a 10th percentile point (which will correspond to a score of 0.5 and 0.9, respectively)
- updates all the currently scored metrics to use the new method, adjusted so the score curves don't change

None of the score curves have changed at all except CLS, which was adjusted slightly to have 0.1 exactly hit a score of 0.9. CLS is new for 6.0 so this won't change anything for end users.

fixes #10706

Patch:
diff --git a/lighthouse-core/audits/audit.js b/lighthouse-core/audits/audit.js
index ec2c20b1b..500659fd3 100644
--- a/lighthouse-core/audits/audit.js
+++ b/lighthouse-core/audits/audit.js
@@ -68,25 +68,17 @@ class Audit {
   /* eslint-enable no-unused-vars */
 
   /**
-   * Computes a clamped score between 0 and 1 based on the measured value. Score is determined by
-   * considering a log-normal distribution governed by the two control points, point of diminishing
-   * returns and the median value, and returning the percentage of sites that have higher value.
-   *
-   * @param {number} measuredValue
-   * @param {number} diminishingReturnsValue
-   * @param {number} medianValue
+   * Computes a score between 0 and 1 based on the measured `value`. Score is determined by
+   * considering a log-normal distribution governed by two control points (the 10th
+   * percentile value and the median value) and represents the percentage of sites that are
+   * greater than `value`.
+   * @param {{median: number, p10: number}} controlPoints
+   * @param {number} value
    * @return {number}
    */
-  static computeLogNormalScore(measuredValue, diminishingReturnsValue, medianValue) {
-    const distribution = statistics.getLogNormalDistribution(
-      medianValue,
-      diminishingReturnsValue
-    );
-
-    let score = distribution.computeComplementaryPercentile(measuredValue);
-    score = Math.min(1, score);
-    score = Math.max(0, score);
-    return clampTo2Decimals(score);
+  static computeLogNormalScore(controlPoints, value) {
+    const percentile = statistics.getLogNormalScore(controlPoints, value);
+    return clampTo2Decimals(percentile);
   }
 
   /**
diff --git a/lighthouse-core/audits/bootup-time.js b/lighthouse-core/audits/bootup-time.js
index 260eec8c2..a5fe0c118 100644
--- a/lighthouse-core/audits/bootup-time.js
+++ b/lighthouse-core/audits/bootup-time.js
@@ -66,10 +66,10 @@ class BootupTime extends Audit {
    */
   static get defaultOptions() {
     return {
-      // see https://www.desmos.com/calculator/rkphawothk
-      // <500ms ~= 100, >2s is yellow, >3.5s is red
-      scorePODR: 600,
-      scoreMedian: 3500,
+      // see https://www.desmos.com/calculator/ynl8fzh1wd
+      // <500ms ~= 100, >1.3s is yellow, >3.5s is red
+      p10: 1282,
+      median: 3500,
       thresholdInMs: 50,
     };
   }
@@ -200,9 +200,8 @@ class BootupTime extends Audit {
     const details = BootupTime.makeTableDetails(headings, results, summary);
 
     const score = Audit.computeLogNormalScore(
-      totalBootupTime,
-      context.options.scorePODR,
-      context.options.scoreMedian
+      {p10: context.options.p10, median: context.options.median},
+      totalBootupTime
     );
 
     return {
diff --git a/lighthouse-core/audits/byte-efficiency/total-byte-weight.js b/lighthouse-core/audits/byte-efficiency/total-byte-weight.js
index af492e31f..bbe391535 100644
--- a/lighthouse-core/audits/byte-efficiency/total-byte-weight.js
+++ b/lighthouse-core/audits/byte-efficiency/total-byte-weight.js
@@ -45,10 +45,11 @@ class TotalByteWeight extends ByteEfficiencyAudit {
    */
   static get defaultOptions() {
     return {
-      // see https://www.desmos.com/calculator/gpmjeykbwr
-      // ~75th and ~90th percentiles http://httparchive.org/interesting.php?a=All&l=Feb%201%202017&s=All#bytesTotal
-      scorePODR: 2500 * 1024,
-      scoreMedian: 4000 * 1024,
+      // see https://www.desmos.com/calculator/h7kfv68jre
+      // ~25th and ~10th percentiles, with resulting p10 computed.
+      // http://httparchive.org/interesting.php?a=All&l=Feb%201%202017&s=All#bytesTotal
+      p10: 2667 * 1024,
+      median: 4000 * 1024,
     };
   }
 
@@ -84,9 +85,8 @@ class TotalByteWeight extends ByteEfficiencyAudit {
     }).slice(0, 10);
 
     const score = ByteEfficiencyAudit.computeLogNormalScore(
-      totalBytes,
-      context.options.scorePODR,
-      context.options.scoreMedian
+      {p10: context.options.p10, median: context.options.median},
+      totalBytes
     );
 
     /** @type {LH.Audit.Details.Table['headings']} */
diff --git a/lighthouse-core/audits/byte-efficiency/uses-long-cache-ttl.js b/lighthouse-core/audits/byte-efficiency/uses-long-cache-ttl.js
index d53bbec08..516aa15b1 100644
--- a/lighthouse-core/audits/byte-efficiency/uses-long-cache-ttl.js
+++ b/lighthouse-core/audits/byte-efficiency/uses-long-cache-ttl.js
@@ -55,11 +55,11 @@ class CacheHeaders extends Audit {
    */
   static get defaultOptions() {
     return {
-      // 50th and 75th percentiles HTTPArchive -> 50 and 75
+      // 50th and 25th percentiles HTTPArchive -> 50 and 75, with p10 derived from them.
       // https://bigquery.cloud.google.com/table/httparchive:lighthouse.2018_04_01_mobile?pli=1
-      // see https://www.desmos.com/calculator/8meohdnjbl
-      scorePODR: 4 * 1024,
-      scoreMedian: 128 * 1024,
+      // see https://www.desmos.com/calculator/uzsyl2hbcb
+      p10: 28 * 1024,
+      median: 128 * 1024,
     };
   }
 
@@ -265,9 +265,8 @@ class CacheHeaders extends Audit {
       });
 
       const score = Audit.computeLogNormalScore(
-        totalWastedBytes,
-        context.options.scorePODR,
-        context.options.scoreMedian
+        {p10: context.options.p10, median: context.options.median},
+        totalWastedBytes
       );
 
       /** @type {LH.Audit.Details.Table['headings']} */
diff --git a/lighthouse-core/audits/dobetterweb/dom-size.js b/lighthouse-core/audits/dobetterweb/dom-size.js
index 47c14ce5e..b791ed8c7 100644
--- a/lighthouse-core/audits/dobetterweb/dom-size.js
+++ b/lighthouse-core/audits/dobetterweb/dom-size.js
@@ -64,11 +64,10 @@ class DOMSize extends Audit {
    */
   static get defaultOptions() {
     return {
-      // 25th and 50th percentiles HTTPArchive -> 50 and 75
       // https://bigquery.cloud.google.com/table/httparchive:lighthouse.2018_04_01_mobile?pli=1
-      // see https://www.desmos.com/calculator/vqot3wci4g
-      scorePODR: 700,
-      scoreMedian: 1400,
+      // see https://www.desmos.com/calculator/tsunbwqt3f
+      p10: 818,
+      median: 1400,
     };
   }
 
@@ -82,9 +81,8 @@ class DOMSize extends Audit {
     const stats = artifacts.DOMStats;
 
     const score = Audit.computeLogNormalScore(
-      stats.totalBodyElements,
-      context.options.scorePODR,
-      context.options.scoreMedian
+      {p10: context.options.p10, median: context.options.median},
+      stats.totalBodyElements
     );
 
     /** @type {LH.Audit.Details.Table['headings']} */
diff --git a/lighthouse-core/audits/mainthread-work-breakdown.js b/lighthouse-core/audits/mainthread-work-breakdown.js
index 6d547f4f9..69ccf22ea 100644
--- a/lighthouse-core/audits/mainthread-work-breakdown.js
+++ b/lighthouse-core/audits/mainthread-work-breakdown.js
@@ -52,9 +52,9 @@ class MainThreadWorkBreakdown extends Audit {
    */
   static get defaultOptions() {
     return {
-      // see https://www.desmos.com/calculator/s2eqcifkum
-      scorePODR: 1500,
-      scoreMedian: 4000,
+      // see https://www.desmos.com/calculator/vhglu1x8zv
+      p10: 2017,
+      median: 4000,
     };
   }
 
@@ -116,9 +116,8 @@ class MainThreadWorkBreakdown extends Audit {
     const tableDetails = MainThreadWorkBreakdown.makeTableDetails(headings, results);
 
     const score = Audit.computeLogNormalScore(
-      totalExecutionTime,
-      context.options.scorePODR,
-      context.options.scoreMedian
+      {p10: context.options.p10, median: context.options.median},
+      totalExecutionTime
     );
 
     return {
diff --git a/lighthouse-core/audits/metrics/cumulative-layout-shift.js b/lighthouse-core/audits/metrics/cumulative-layout-shift.js
index d985c713e..20c032094 100644
--- a/lighthouse-core/audits/metrics/cumulative-layout-shift.js
+++ b/lighthouse-core/audits/metrics/cumulative-layout-shift.js
@@ -39,11 +39,11 @@ class CumulativeLayoutShift extends Audit {
    */
   static get defaultOptions() {
     return {
-      // Calibrated to assure 0.1 gets a score of 0.9. https://web.dev/cls/#what-is-a-good-cls-score
+      // https://web.dev/cls/#what-is-a-good-cls-score
       // This 0.1 target score was determined through both manual evaluation and large-scale analysis.
-      // see https://www.desmos.com/calculator/1xtb5iz8iq
-      scorePODR: 0.054,
-      scoreMedian: 0.25,
+      // see https://www.desmos.com/calculator/ksp7q91nop
+      p10: 0.1,
+      median: 0.25,
     };
   }
 
@@ -64,9 +64,8 @@ class CumulativeLayoutShift extends Audit {
 
     return {
       score: Audit.computeLogNormalScore(
-        metricResult.value,
-        context.options.scorePODR,
-        context.options.scoreMedian
+        {p10: context.options.p10, median: context.options.median},
+        metricResult.value
       ),
       numericValue: metricResult.value,
       numericUnit: 'unitless',
diff --git a/lighthouse-core/audits/metrics/estimated-input-latency.js b/lighthouse-core/audits/metrics/estimated-input-latency.js
index 4b4c6a96e..9bb60d041 100644
--- a/lighthouse-core/audits/metrics/estimated-input-latency.js
+++ b/lighthouse-core/audits/metrics/estimated-input-latency.js
@@ -38,9 +38,9 @@ class EstimatedInputLatency extends Audit {
    */
   static get defaultOptions() {
     return {
-      // see https://www.desmos.com/calculator/srv0hqhf7d
-      scorePODR: 50,
-      scoreMedian: 100,
+      // see https://www.desmos.com/calculator/mgd0hjqnal
+      p10: 58,
+      median: 100,
     };
   }
 
@@ -60,9 +60,8 @@ class EstimatedInputLatency extends Audit {
 
     return {
       score: Audit.computeLogNormalScore(
-        metricResult.timing,
-        context.options.scorePODR,
-        context.options.scoreMedian
+        {p10: context.options.p10, median: context.options.median},
+        metricResult.timing
       ),
       numericValue: metricResult.timing,
       numericUnit: 'millisecond',
diff --git a/lighthouse-core/audits/metrics/first-contentful-paint-3g.js b/lighthouse-core/audits/metrics/first-contentful-paint-3g.js
index cfb9c1b88..0f846faf4 100644
--- a/lighthouse-core/audits/metrics/first-contentful-paint-3g.js
+++ b/lighthouse-core/audits/metrics/first-contentful-paint-3g.js
@@ -29,10 +29,12 @@ class FirstContentfulPaint3G extends Audit {
    */
   static get defaultOptions() {
     return {
-      // 75th and 95th percentiles HTTPArchive on Fast 3G -> multiply by 1.5 for RTT differential -> median and PODR
+      // 25th and 5th percentiles HTTPArchive on Fast 3G -> multiply by 1.5 for RTT differential -> median and PODR
+      // p10 is then derived from them.
       // https://bigquery.cloud.google.com/table/httparchive:lighthouse.2018_04_01_mobile?pli=1
-      scorePODR: 3000,
-      scoreMedian: 6000,
+      // https://www.desmos.com/calculator/fflcrsn9sj
+      p10: 3504,
+      median: 6000,
     };
   }
 
@@ -51,9 +53,8 @@ class FirstContentfulPaint3G extends Audit {
 
     return {
       score: Audit.computeLogNormalScore(
-        metricResult.timing,
-        context.options.scorePODR,
-        context.options.scoreMedian
+        {p10: context.options.p10, median: context.options.median},
+        metricResult.timing
       ),
       numericValue: metricResult.timing,
       numericUnit: 'millisecond',
diff --git a/lighthouse-core/audits/metrics/first-contentful-paint.js b/lighthouse-core/audits/metrics/first-contentful-paint.js
index 52142a856..39d0549e2 100644
--- a/lighthouse-core/audits/metrics/first-contentful-paint.js
+++ b/lighthouse-core/audits/metrics/first-contentful-paint.js
@@ -32,21 +32,25 @@ class FirstContentfulPaint extends Audit {
   }
 
   /**
-   * @return {{mobile: LH.Audit.ScoreOptions, desktop: LH.Audit.ScoreOptions}}
+   * @return {{mobile: {scoring: LH.Audit.ScoreOptions}, desktop: {scoring: LH.Audit.ScoreOptions}}}
    */
   static get defaultOptions() {
     return {
       mobile: {
-        // 75th and 95th percentiles HTTPArchive -> median and PODR
+        // 25th and 5th percentiles HTTPArchive -> median and PODR, then p10 is derived from them.
         // https://bigquery.cloud.google.com/table/httparchive:lighthouse.2018_04_01_mobile?pli=1
-        // see https://www.desmos.com/calculator/2t1ugwykrl
-        scorePODR: 2000,
-        scoreMedian: 4000,
+        // see https://www.desmos.com/calculator/oqlvmezbze
+        scoring: {
+          p10: 2336,
+          median: 4000,
+        },
       },
       desktop: {
         // SELECT QUANTILES(renderStart, 21) FROM [httparchive:summary_pages.2018_12_15_desktop] LIMIT 1000
-        scorePODR: 800,
-        scoreMedian: 1600,
+        scoring: {
+          p10: 934,
+          median: 1600,
+        },
       },
     };
   }
@@ -61,14 +65,13 @@ class FirstContentfulPaint extends Audit {
     const devtoolsLog = artifacts.devtoolsLogs[Audit.DEFAULT_PASS];
     const metricComputationData = {trace, devtoolsLog, settings: context.settings};
     const metricResult = await ComputedFcp.request(metricComputationData, context);
-    const scoreOptions =
-      context.options[artifacts.TestedAsMobileDevice === false ? 'desktop' : 'mobile'];
+    const isDesktop = artifacts.TestedAsMobileDevice === false;
+    const options = isDesktop ? context.options.desktop : context.options.mobile;
 
     return {
       score: Audit.computeLogNormalScore(
-        metricResult.timing,
-        scoreOptions.scorePODR,
-        scoreOptions.scoreMedian
+        options.scoring,
+        metricResult.timing
       ),
       numericValue: metricResult.timing,
       numericUnit: 'millisecond',
diff --git a/lighthouse-core/audits/metrics/first-cpu-idle.js b/lighthouse-core/audits/metrics/first-cpu-idle.js
index 8e74050a9..3791240f1 100644
--- a/lighthouse-core/audits/metrics/first-cpu-idle.js
+++ b/lighthouse-core/audits/metrics/first-cpu-idle.js
@@ -32,21 +32,25 @@ class FirstCPUIdle extends Audit {
   }
 
   /**
-   * @return {{mobile: LH.Audit.ScoreOptions, desktop: LH.Audit.ScoreOptions}}
+   * @return {{mobile: {scoring: LH.Audit.ScoreOptions}, desktop: {scoring: LH.Audit.ScoreOptions}}}
    */
   static get defaultOptions() {
     return {
       mobile: {
-        // 75th and 95th percentiles HTTPArchive -> median and PODR
+        // 25th and 5th percentiles HTTPArchive -> median and PODR, then p10 derived from them.
         // https://bigquery.cloud.google.com/table/httparchive:lighthouse.2018_04_01_mobile?pli=1
-        // see https://www.desmos.com/calculator/yv89gz2nwf
-        scorePODR: 2900,
-        scoreMedian: 6500,
+        // see https://www.desmos.com/calculator/yrwes8ruci
+        scoring: {
+          p10: 3572,
+          median: 6500,
+        },
       },
       desktop: {
         // SELECT QUANTILES(fullyLoaded, 21) FROM [httparchive:summary_pages.2018_12_15_desktop] LIMIT 1000
-        scorePODR: 2000,
-        scoreMedian: 4500,
+        scoring: {
+          p10: 2468,
+          median: 4500,
+        },
       },
     };
   }
@@ -64,14 +68,13 @@ class FirstCPUIdle extends Audit {
     const devtoolsLog = artifacts.devtoolsLogs[Audit.DEFAULT_PASS];
     const metricComputationData = {trace, devtoolsLog, settings: context.settings};
     const metricResult = await ComputedFci.request(metricComputationData, context);
-    const scoreOptions =
-      context.options[artifacts.TestedAsMobileDevice === false ? 'desktop' : 'mobile'];
+    const isDesktop = artifacts.TestedAsMobileDevice === false;
+    const options = isDesktop ? context.options.desktop : context.options.mobile;
 
     return {
       score: Audit.computeLogNormalScore(
-        metricResult.timing,
-        scoreOptions.scorePODR,
-        scoreOptions.scoreMedian
+        options.scoring,
+        metricResult.timing
       ),
       numericValue: metricResult.timing,
       numericUnit: 'millisecond',
diff --git a/lighthouse-core/audits/metrics/first-meaningful-paint.js b/lighthouse-core/audits/metrics/first-meaningful-paint.js
index f6bb4833b..241867055 100644
--- a/lighthouse-core/audits/metrics/first-meaningful-paint.js
+++ b/lighthouse-core/audits/metrics/first-meaningful-paint.js
@@ -32,21 +32,25 @@ class FirstMeaningfulPaint extends Audit {
   }
 
   /**
-   * @return {{mobile: LH.Audit.ScoreOptions, desktop: LH.Audit.ScoreOptions}}
+   * @return {{mobile: {scoring: LH.Audit.ScoreOptions}, desktop: {scoring: LH.Audit.ScoreOptions}}}
    */
   static get defaultOptions() {
     return {
       mobile: {
-        // 75th and 95th percentiles HTTPArchive -> median and PODR
+        // 25th and 5th percentiles HTTPArchive -> median and PODR, then p10 derived from them.
         // https://bigquery.cloud.google.com/table/httparchive:lighthouse.2018_04_01_mobile?pli=1
-        // see https://www.desmos.com/calculator/2t1ugwykrl
-        scorePODR: 2000,
-        scoreMedian: 4000,
+        // see https://www.desmos.com/calculator/i4znkdccut
+        scoring: {
+          p10: 2336,
+          median: 4000,
+        },
       },
       desktop: {
         // SELECT QUANTILES(renderStart, 21) FROM [httparchive:summary_pages.2018_12_15_desktop] LIMIT 1000
-        scorePODR: 800,
-        scoreMedian: 1600,
+        scoring: {
+          p10: 934,
+          median: 1600,
+        },
       },
     };
   }
@@ -64,14 +68,13 @@ class FirstMeaningfulPaint extends Audit {
     const devtoolsLog = artifacts.devtoolsLogs[Audit.DEFAULT_PASS];
     const metricComputationData = {trace, devtoolsLog, settings: context.settings};
     const metricResult = await ComputedFmp.request(metricComputationData, context);
-    const scoreOptions =
-      context.options[artifacts.TestedAsMobileDevice === false ? 'desktop' : 'mobile'];
+    const isDesktop = artifacts.TestedAsMobileDevice === false;
+    const options = isDesktop ? context.options.desktop : context.options.mobile;
 
     return {
       score: Audit.computeLogNormalScore(
-        metricResult.timing,
-        scoreOptions.scorePODR,
-        scoreOptions.scoreMedian
+        options.scoring,
+        metricResult.timing
       ),
       numericValue: metricResult.timing,
       numericUnit: 'millisecond',
diff --git a/lighthouse-core/audits/metrics/interactive.js b/lighthouse-core/audits/metrics/interactive.js
index 9a7c1657b..407b9598f 100644
--- a/lighthouse-core/audits/metrics/interactive.js
+++ b/lighthouse-core/audits/metrics/interactive.js
@@ -38,21 +38,25 @@ class InteractiveMetric extends Audit {
   }
 
   /**
-   * @return {{mobile: LH.Audit.ScoreOptions, desktop: LH.Audit.ScoreOptions}}
+   * @return {{mobile: {scoring: LH.Audit.ScoreOptions}, desktop: {scoring: LH.Audit.ScoreOptions}}}
    */
   static get defaultOptions() {
     return {
       mobile: {
-        // 75th and 95th percentiles HTTPArchive -> median and PODR
+        // 25th and 5th percentiles HTTPArchive -> median and PODR, then p10 derived from them.
         // https://bigquery.cloud.google.com/table/httparchive:lighthouse.2018_04_01_mobile?pli=1
-        // see https://www.desmos.com/calculator/5xgy0pyrbp
-        scorePODR: 2900,
-        scoreMedian: 7300,
+        // see https://www.desmos.com/calculator/o98tbeyt1t
+        scoring: {
+          p10: 3785,
+          median: 7300,
+        },
       },
       desktop: {
         // SELECT QUANTILES(fullyLoaded, 21) FROM [httparchive:summary_pages.2018_12_15_desktop] LIMIT 1000
-        scorePODR: 2000,
-        scoreMedian: 4500,
+        scoring: {
+          p10: 2468,
+          median: 4500,
+        },
       },
     };
   }
@@ -68,8 +72,8 @@ class InteractiveMetric extends Audit {
     const metricComputationData = {trace, devtoolsLog, settings: context.settings};
     const metricResult = await Interactive.request(metricComputationData, context);
     const timeInMs = metricResult.timing;
-    const scoreOptions =
-      context.options[artifacts.TestedAsMobileDevice === false ? 'desktop' : 'mobile'];
+    const isDesktop = artifacts.TestedAsMobileDevice === false;
+    const options = isDesktop ? context.options.desktop : context.options.mobile;
     const extendedInfo = {
       timeInMs,
       timestamp: metricResult.timestamp,
@@ -81,9 +85,8 @@ class InteractiveMetric extends Audit {
 
     return {
       score: Audit.computeLogNormalScore(
-        timeInMs,
-        scoreOptions.scorePODR,
-        scoreOptions.scoreMedian
+        options.scoring,
+        timeInMs
       ),
       numericValue: timeInMs,
       numericUnit: 'millisecond',
diff --git a/lighthouse-core/audits/metrics/largest-contentful-paint.js b/lighthouse-core/audits/metrics/largest-contentful-paint.js
index 770aa2262..f0264e529 100644
--- a/lighthouse-core/audits/metrics/largest-contentful-paint.js
+++ b/lighthouse-core/audits/metrics/largest-contentful-paint.js
@@ -36,12 +36,12 @@ class LargestContentfulPaint extends Audit {
    */
   static get defaultOptions() {
     return {
-      // 75th and 95th percentiles HTTPArchive -> median and PODR.
+      // 25th and 13th percentiles HTTPArchive -> median and p10 points.
       // https://bigquery.cloud.google.com/table/httparchive:lighthouse.2020_02_01_mobile?pli=1
-      // Gives 2.5s roughly a score of 0.9. https://web.dev/lcp/#what-is-a-good-lcp-score
-      // see https://www.desmos.com/calculator/brcfwyox6x
-      scorePODR: 2000,
-      scoreMedian: 4000,
+      // https://web.dev/lcp/#what-is-a-good-lcp-score
+      // see https://www.desmos.com/calculator/1etesp32kt
+      p10: 2500,
+      median: 4000,
     };
   }
 
@@ -58,9 +58,8 @@ class LargestContentfulPaint extends Audit {
 
     return {
       score: Audit.computeLogNormalScore(
-        metricResult.timing,
-        context.options.scorePODR,
-        context.options.scoreMedian
+        {p10: context.options.p10, median: context.options.median},
+        metricResult.timing
       ),
       numericValue: metricResult.timing,
       numericUnit: 'millisecond',
diff --git a/lighthouse-core/audits/metrics/max-potential-fid.js b/lighthouse-core/audits/metrics/max-potential-fid.js
index 0e1aa95ae..27738c2cc 100644
--- a/lighthouse-core/audits/metrics/max-potential-fid.js
+++ b/lighthouse-core/audits/metrics/max-potential-fid.js
@@ -41,9 +41,9 @@ class MaxPotentialFID extends Audit {
    */
   static get defaultOptions() {
     return {
-      // see https://www.desmos.com/calculator/g3nf1ehtnk
-      scorePODR: 100,
-      scoreMedian: 250,
+      // see https://www.desmos.com/calculator/onxmbblyqo
+      p10: 130,
+      median: 250,
     };
   }
 
@@ -60,9 +60,8 @@ class MaxPotentialFID extends Audit {
 
     return {
       score: Audit.computeLogNormalScore(
-        metricResult.timing,
-        context.options.scorePODR,
-        context.options.scoreMedian
+        {p10: context.options.p10, median: context.options.median},
+        metricResult.timing
       ),
       numericValue: metricResult.timing,
       numericUnit: 'millisecond',
diff --git a/lighthouse-core/audits/metrics/speed-index.js b/lighthouse-core/audits/metrics/speed-index.js
index c365e250b..6a209f4a1 100644
--- a/lighthouse-core/audits/metrics/speed-index.js
+++ b/lighthouse-core/audits/metrics/speed-index.js
@@ -32,21 +32,25 @@ class SpeedIndex extends Audit {
   }
 
   /**
-   * @return {{mobile: LH.Audit.ScoreOptions, desktop: LH.Audit.ScoreOptions}}
+   * @return {{mobile: {scoring: LH.Audit.ScoreOptions}, desktop: {scoring: LH.Audit.ScoreOptions}}}
    */
   static get defaultOptions() {
     return {
       mobile: {
-        // 75th and 95th percentiles HTTPArchive -> median and PODR
+        // 25th and 5th percentiles HTTPArchive -> median and PODR, then p10 derived from them.
         // https://bigquery.cloud.google.com/table/httparchive:lighthouse.2018_04_01_mobile?pli=1
-        // see https://www.desmos.com/calculator/orvoyu9ygq
-        scorePODR: 2900,
-        scoreMedian: 5800,
+        // see https://www.desmos.com/calculator/dvuzvpl7mi
+        scoring: {
+          p10: 3387,
+          median: 5800,
+        },
       },
       desktop: {
         // SELECT QUANTILES(SpeedIndex, 21) FROM [httparchive:summary_pages.2018_12_15_desktop] LIMIT 1000
-        scorePODR: 1100,
-        scoreMedian: 2300,
+        scoring: {
+          p10: 1311,
+          median: 2300,
+        },
       },
     };
   }
@@ -63,14 +67,13 @@ class SpeedIndex extends Audit {
     const devtoolsLog = artifacts.devtoolsLogs[Audit.DEFAULT_PASS];
     const metricComputationData = {trace, devtoolsLog, settings: context.settings};
     const metricResult = await ComputedSi.request(metricComputationData, context);
-    const scoreOptions =
-      context.options[artifacts.TestedAsMobileDevice === false ? 'desktop' : 'mobile'];
+    const isDesktop = artifacts.TestedAsMobileDevice === false;
+    const options = isDesktop ? context.options.desktop : context.options.mobile;
 
     return {
       score: Audit.computeLogNormalScore(
-        metricResult.timing,
-        scoreOptions.scorePODR,
-        scoreOptions.scoreMedian
+        options.scoring,
+        metricResult.timing
       ),
       numericValue: metricResult.timing,
       numericUnit: 'millisecond',
diff --git a/lighthouse-core/audits/metrics/total-blocking-time.js b/lighthouse-core/audits/metrics/total-blocking-time.js
index b0473842a..76a02f4ad 100644
--- a/lighthouse-core/audits/metrics/total-blocking-time.js
+++ b/lighthouse-core/audits/metrics/total-blocking-time.js
@@ -41,9 +41,9 @@ class TotalBlockingTime extends Audit {
       // thresholds according to our 25/75-th rule will be quite harsh scoring (a single 350ms task)
       // after FCP will yield a score of .5. The following coefficients are semi-arbitrarily picked
       // to give 600ms jank a score of .5 and 100ms jank a score of .999. We can tweak these numbers
-      // in the future. See https://www.desmos.com/calculator/a7ib75kq3g
-      scoreMedian: 600,
-      scorePODR: 200,
+      // in the future. See https://www.desmos.com/calculator/bbsv8fedg5
+      median: 600,
+      p10: 287,
     };
   }
 
@@ -67,9 +67,8 @@ class TotalBlockingTime extends Audit {
 
     return {
       score: Audit.computeLogNormalScore(
-        metricResult.timing,
-        context.options.scorePODR,
-        context.options.scoreMedian
+        {p10: context.options.p10, median: context.options.median},
+        metricResult.timing
       ),
       numericValue: metricResult.timing,
       numericUnit: 'millisecond',
diff --git a/lighthouse-core/audits/predictive-perf.js b/lighthouse-core/audits/predictive-perf.js
index d82f09505..e900b4913 100644
--- a/lighthouse-core/audits/predictive-perf.js
+++ b/lighthouse-core/audits/predictive-perf.js
@@ -17,8 +17,8 @@ const LanternEil = require('../computed/metrics/lantern-estimated-input-latency.
 const LanternLcp = require('../computed/metrics/lantern-largest-contentful-paint.js');
 
 // Parameters (in ms) for log-normal CDF scoring. To see the curve:
-//   https://www.desmos.com/calculator/rjp0lbit8y
-const SCORING_POINT_OF_DIMINISHING_RETURNS = 1700;
+//   https://www.desmos.com/calculator/bksgkihhj8
+const SCORING_P10 = 3651;
 const SCORING_MEDIAN = 10000;
 
 class PredictivePerf extends Audit {
@@ -87,9 +87,8 @@ class PredictivePerf extends Audit {
     };
 
     const score = Audit.computeLogNormalScore(
-      values.roughEstimateOfTTI,
-      SCORING_POINT_OF_DIMINISHING_RETURNS,
-      SCORING_MEDIAN
+      {p10: SCORING_P10, median: SCORING_MEDIAN},
+      values.roughEstimateOfTTI
     );
 
     const i18n = new I18n(context.settings.locale);
diff --git a/lighthouse-core/lib/statistics.js b/lighthouse-core/lib/statistics.js
index 0f1c36191..affd0a079 100644
--- a/lighthouse-core/lib/statistics.js
+++ b/lighthouse-core/lib/statistics.js
@@ -59,6 +59,43 @@ function getLogNormalDistribution(median, falloff) {
   };
 }
 
+/**
+ * Returns the score (1 - percentile) of `value` in a log-normal distribution
+ * specified by the `median` value, at which the score will be 0.5, and a 10th
+ * percentile value, at which the score will be 0.9. The score represents the
+ * amount of the distribution greater than `value`. All values should be in the
+ * same units (e.g. milliseconds). See
+ *   https://www.desmos.com/calculator/o98tbeyt1t
+ * for an interactive view of the relationship between these parameters and the
+ * typical parameterization (location and shape) of the log-normal distribution.
+ * @param {{median: number, p10: number}} parameters
+ * @param {number} value
+ * @return {number}
+ */
+function getLogNormalScore({median, p10}, value) {
+  // Required for the log-normal distribution.
+  if (median <= 0) throw new Error('median must be greater than zero');
+  if (p10 <= 0) throw new Error('p10 must be greater than zero');
+  // Not required, but if p10 > median, it flips around and becomes the p90 point.
+  if (p10 >= median) throw new Error('p10 must be less than the median');
+
+  // Non-positive values aren't in the distribution, so always 1.
+  if (value <= 0) return 1;
+
+  // Closest double to `erfc-1(2 * 1/10)`.
+  const INVERSE_ERFC_ONE_FIFTH = 0.9061938024368232;
+
+  // Shape (σ) is `log(p10/median) / (sqrt(2)*erfc^-1(2 * 1/10))` and
+  // standardizedX is `1/2 erfc(log(value/median) / (sqrt(2)*σ))`, so simplify a bit.
+  const xLogRatio = Math.log(value / median);
+  const p10LogRatio = -Math.log(p10 / median); // negate to keep σ positive.
+  const standardizedX = xLogRatio * INVERSE_ERFC_ONE_FIFTH / p10LogRatio;
+  const complementaryPercentile = (1 - erf(standardizedX)) / 2;
+
+  // Clamp to [0, 1] to avoid any floating-point out-of-bounds issues.
+  return Math.min(1, Math.max(0, complementaryPercentile));
+}
+
 /**
  * Interpolates the y value at a point x on the line defined by (x0, y0) and (x1, y1)
  * @param {number} x0
@@ -76,4 +113,5 @@ function linearInterpolation(x0, y0, x1, y1, x) {
 module.exports = {
   linearInterpolation,
   getLogNormalDistribution,
+  getLogNormalScore,
 };
diff --git a/readme.md b/readme.md
index 977d57821..4958c2d5c 100644
--- a/readme.md
+++ b/readme.md
@@ -443,7 +443,7 @@ Read more in our [guide to network throttling](./docs/throttling.md).
 
 ### Are results sent to a remote server?
 
-Nope. Lighthouse runs locally, auditing a page using a local version of the Chrome browser installed the
+Nope. Lighthouse runs locally, auditing a page using a local version of the Chrome browser installed on the
 machine. Report results are never processed or beaconed to a remote server.
 
 ### How do I author custom audits to extend Lighthouse?
diff --git a/types/audit.d.ts b/types/audit.d.ts
index c26310701..9d51c64d0 100644
--- a/types/audit.d.ts
+++ b/types/audit.d.ts
@@ -21,8 +21,8 @@ declare global {
     }>;
 
     export interface ScoreOptions {
-      scorePODR: number;
-      scoreMedian: number;
+      p10: number;
+      median: number;
     }
 
     export interface ScoreDisplayModes {


Test patch:
diff --git a/lighthouse-core/test/audits/audit-test.js b/lighthouse-core/test/audits/audit-test.js
index b060bec72..9a12a78fe 100644
--- a/lighthouse-core/test/audits/audit-test.js
+++ b/lighthouse-core/test/audits/audit-test.js
@@ -270,4 +270,20 @@ describe('Audit', () => {
       });
     });
   });
+
+  describe('#computeLogNormalScore', () => {
+    it('clamps the score to two decimal places', () => {
+      const params = {
+        median: 1000,
+        p10: 500,
+      };
+
+      assert.strictEqual(Audit.computeLogNormalScore(params, 0), 1);
+      assert.strictEqual(Audit.computeLogNormalScore(params, 250), 0.99);
+      assert.strictEqual(Audit.computeLogNormalScore(params, 1500), 0.23);
+      assert.strictEqual(Audit.computeLogNormalScore(params, 2500), 0.05);
+      assert.strictEqual(Audit.computeLogNormalScore(params, 4000), 0.01);
+      assert.strictEqual(Audit.computeLogNormalScore(params, 4100), 0);
+    });
+  });
 });
diff --git a/lighthouse-core/test/lib/statistics-test.js b/lighthouse-core/test/lib/statistics-test.js
index 477de25d6..8727e67a7 100644
--- a/lighthouse-core/test/lib/statistics-test.js
+++ b/lighthouse-core/test/lib/statistics-test.js
@@ -7,7 +7,6 @@
 
 /* eslint-env jest */
 
-const assert = require('assert').strict;
 const statistics = require('../../lib/statistics.js');
 
 describe('statistics', () => {
@@ -18,38 +17,97 @@ describe('statistics', () => {
 
       const median = 5000;
       const pODM = 3500;
-      const distribution = statistics.getLogNormalDistribution(median, pODM);
-
-      function getPct(distribution, value) {
-        return Number(distribution.computeComplementaryPercentile(value).toFixed(2));
-      }
-      assert.equal(typeof distribution.computeComplementaryPercentile, 'function');
-      assert.equal(getPct(distribution, 2000), 1.00, 'pct for 2000 does not match');
-      assert.equal(getPct(distribution, 3000), 0.98, 'pct for 3000 does not match');
-      assert.equal(getPct(distribution, 3500), 0.92, 'pct for 3500 does not match');
-      assert.equal(getPct(distribution, 4000), 0.81, 'pct for 4000 does not match');
-      assert.equal(getPct(distribution, 5000), 0.50, 'pct for 5000 does not match');
-      assert.equal(getPct(distribution, 6000), 0.24, 'pct for 6000 does not match');
-      assert.equal(getPct(distribution, 7000), 0.09, 'pct for 7000 does not match');
-      assert.equal(getPct(distribution, 8000), 0.03, 'pct for 8000 does not match');
-      assert.equal(getPct(distribution, 9000), 0.01, 'pct for 9000 does not match');
-      assert.equal(getPct(distribution, 10000), 0.00, 'pct for 10000 does not match');
+      const dist = statistics.getLogNormalDistribution(median, pODM);
+
+      expect(dist.computeComplementaryPercentile(2000)).toBeCloseTo(1.00);
+      expect(dist.computeComplementaryPercentile(3000)).toBeCloseTo(0.98);
+      expect(dist.computeComplementaryPercentile(3500)).toBeCloseTo(0.92);
+      expect(dist.computeComplementaryPercentile(4000)).toBeCloseTo(0.81);
+      expect(dist.computeComplementaryPercentile(5000)).toBeCloseTo(0.50);
+      expect(dist.computeComplementaryPercentile(6000)).toBeCloseTo(0.24);
+      expect(dist.computeComplementaryPercentile(7000)).toBeCloseTo(0.09);
+      expect(dist.computeComplementaryPercentile(8000)).toBeCloseTo(0.03);
+      expect(dist.computeComplementaryPercentile(9000)).toBeCloseTo(0.01);
+      expect(dist.computeComplementaryPercentile(10000)).toBeCloseTo(0.00);
+    });
+  });
+
+  describe('#getLogNormalScore', () => {
+    it('creates a log normal distribution', () => {
+      // This curve plotted with the below parameters.
+      // https://www.desmos.com/calculator/ywkivb78cd
+      const params = {
+        median: 7300,
+        p10: 3785,
+      };
+      const {getLogNormalScore} = statistics;
+
+      // Be stricter with the control point requirements.
+      expect(getLogNormalScore(params, 7300)).toEqual(0.5);
+      expect(getLogNormalScore(params, 3785)).toBeCloseTo(0.9, 6);
+
+      expect(getLogNormalScore(params, 0)).toEqual(1);
+      expect(getLogNormalScore(params, 1000)).toBeCloseTo(1.00);
+      expect(getLogNormalScore(params, 2500)).toBeCloseTo(0.98);
+      expect(getLogNormalScore(params, 5000)).toBeCloseTo(0.77);
+      expect(getLogNormalScore(params, 7500)).toBeCloseTo(0.48);
+      expect(getLogNormalScore(params, 10000)).toBeCloseTo(0.27);
+      expect(getLogNormalScore(params, 30000)).toBeCloseTo(0.00);
+      expect(getLogNormalScore(params, 1000000)).toEqual(0);
+    });
+
+    it('returns 1 for all non-positive values', () => {
+      const params = {
+        median: 1000,
+        p10: 500,
+      };
+      const {getLogNormalScore} = statistics;
+      expect(getLogNormalScore(params, -100000)).toEqual(1);
+      expect(getLogNormalScore(params, -1)).toEqual(1);
+      expect(getLogNormalScore(params, 0)).toEqual(1);
+    });
+
+    it('throws on a non-positive median parameter', () => {
+      expect(() => {
+        statistics.getLogNormalScore({median: 0, p10: 500}, 50);
+      }).toThrow('median must be greater than zero');
+      expect(() => {
+        statistics.getLogNormalScore({median: -100, p90: 500}, 50);
+      }).toThrow('median must be greater than zero');
+    });
+
+    it('throws on a non-positive p10 parameter', () => {
+      expect(() => {
+        statistics.getLogNormalScore({median: 500, p10: 0}, 50);
+      }).toThrow('p10 must be greater than zero');
+      expect(() => {
+        statistics.getLogNormalScore({median: 500, p10: -100}, 50);
+      }).toThrow('p10 must be greater than zero');
+    });
+
+    it('throws if p10 is not less than the median', () => {
+      expect(() => {
+        statistics.getLogNormalScore({median: 500, p10: 500}, 50);
+      }).toThrow('p10 must be less than the median');
+      expect(() => {
+        statistics.getLogNormalScore({median: 500, p10: 1000}, 50);
+      }).toThrow('p10 must be less than the median');
     });
   });
 
   describe('#linearInterpolation', () => {
     it('correctly interpolates when slope is 2', () => {
       const slopeOf2 = x => statistics.linearInterpolation(0, 0, 10, 20, x);
-      assert.equal(slopeOf2(-10), -20);
-      assert.equal(slopeOf2(5), 10);
-      assert.equal(slopeOf2(10), 20);
+      expect(slopeOf2(-10)).toEqual(-20);
+      expect(slopeOf2(5)).toEqual(10);
+      expect(slopeOf2(10)).toEqual(20);
     });
 
     it('correctly interpolates when slope is 0', () => {
       const slopeOf0 = x => statistics.linearInterpolation(0, 0, 10, 0, x);
-      assert.equal(slopeOf0(-10), 0);
-      assert.equal(slopeOf0(5), 0);
-      assert.equal(slopeOf0(10), 0);
+      expect(slopeOf0(-10)).toEqual(0);
+      expect(slopeOf0(5)).toEqual(0);
+      expect(slopeOf0(10)).toEqual(0);
     });
   });
 });
diff --git a/lighthouse-core/test/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json
index bea282eb2..33badec05 100644
--- a/lighthouse-core/test/results/sample_v2.json
+++ b/lighthouse-core/test/results/sample_v2.json
@@ -85,7 +85,7 @@
       "id": "largest-contentful-paint",
       "title": "Largest Contentful Paint",
       "description": "Largest Contentful Paint marks the time at which the largest text or image is painted. [Learn More](https://web.dev/lighthouse-largest-contentful-paint)",
-      "score": 0.31,
+      "score": 0.28,
       "scoreDisplayMode": "numeric",
       "numericValue": 4927.278,
       "numericUnit": "millisecond",