Transparency News

Source code

lib/scale.js

// The arithmetic that turns counts into 0 to 100 scores. Every step here is
// shown on each report's arithmetic page.

const R = require('./rules');

const clip = (x, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, x));
// Points that count: the reporter's own use of a scored (not experimental) word.
const counts = (h) => !h.c && !h.x;
const pts = (hits) => (hits || []).filter(counts).reduce((n, h) => n + h.s, 0);
// The 2.1.0 rule, kept for the before-and-after comparison: every match at its listed severity.
const ptsLegacy = (hits) => (hits || []).reduce((n, h) => n + (h.s0 != null ? h.s0 : h.s), 0);

// Rate per 1,000 words.
function rate(points, words) { return words > 0 ? (points / words) * 1000 : 0; }

// Score = 100 × clip((r_L − r) ÷ (r_L − r_P), 0, 1): a straight line from the
// plain end (100) to the loaded end (0).
function anchorScore(r, anchor) {
  const span = anchor.loaded - anchor.plain;
  if (span <= 0) return 100;
  return Math.round(100 * clip((anchor.loaded - r) / span));
}

// Shifted weighted geometric mean (3.1.0): each layer has 10 added before the
// product and 10 taken away after, so one layer at 0 lowers the total
// steeply but cannot collapse it.
//   overall = Π (s_i + c)^(w_i ÷ Σw) − c, with c = 10
// Layers that are not established are left out and the other weights rescaled.
function geometric(layers, weights, c = R.CONSTANTS.geoShift) {
  let logSum = 0;
  let wSum = 0;
  for (const [k, w] of Object.entries(weights)) {
    if (layers[k] == null) continue;
    logSum += w * Math.log(Math.max(0, layers[k]) + c);
    wSum += w;
  }
  return wSum ? Math.round(Math.max(0, Math.exp(logSum / wSum) - c)) : null;
}
// The 3.0.0 combination (each layer floored at 1, no shift), kept for the
// before-and-after ranking comparison.
function geometricFloor(layers, weights) {
  let logSum = 0;
  let wSum = 0;
  for (const [k, w] of Object.entries(weights)) {
    if (layers[k] == null) continue;
    logSum += w * Math.log(Math.max(1, layers[k]));
    wSum += w;
  }
  return wSum ? Math.round(Math.exp(logSum / wSum)) : null;
}

// The densest stretch of a story (3.1.0). Windows of 200 words start every 50
// words, plus one window ending at the last word. Returns the window with the
// most counted points, or null when the story is 200 words or shorter or when
// word positions were not recorded.
function worstWindow(hits, W, size = R.CONSTANTS.windowWords, step = R.CONSTANTS.windowStep) {
  if (!(W > size)) return null;
  const used = (hits || []).filter(counts);
  if (used.some((h) => h.w == null)) return null;
  const starts = [];
  for (let st = 0; st + size <= W; st += step) starts.push(st);
  if (starts[starts.length - 1] !== W - size) starts.push(W - size);
  let best = { start: 0, end: size, points: 0 };
  for (const st of starts) {
    const p = used.filter((h) => h.w >= st && h.w < st + size).reduce((n, h) => n + h.s, 0);
    if (p > best.points) best = { start: st, end: st + size, points: p };
  }
  return { ...best, r: rate(best.points, size), terms: used.filter((h) => h.w >= best.start && h.w < best.end).map((h) => h.t) };
}

// How much each layer lowered the overall score, in the same terms as the
// combination: w × (log(100 + c) − log(s + c)). The largest is the layer that
// lowered the score most.
function drag(layers, weights, c = R.CONSTANTS.geoShift) {
  const out = {};
  let wSum = 0;
  for (const [k, w] of Object.entries(weights)) if (layers[k] != null) wSum += w;
  for (const [k, w] of Object.entries(weights)) if (layers[k] != null) out[k] = (w / wSum) * (Math.log(100 + c) - Math.log(Math.max(0, layers[k]) + c));
  const worst = Object.entries(out).sort((a, b) => b[1] - a[1])[0];
  return { by: out, most: worst && worst[1] > 0.0005 ? worst[0] : null };
}
function arithmetic(layers, weights) {
  let s = 0; let wSum = 0;
  for (const [k, w] of Object.entries(weights)) if (layers[k] != null) { s += w * layers[k]; wSum += w; }
  return wSum ? Math.round(s / wSum) : null;
}

// Full scoring of one analyzed report, with every intermediate number.
// Method 3.0.0: headline words count only in the headline layer; the story's
// words count in word choice, temperature and attribution.
function scoreReport(f, opts = {}) {
  if (opts.legacy) return scoreLegacy(f, opts);
  const prev = opts.v300; // the 3.0.0 arithmetic, for the ranking comparison
  const A = opts.anchors || R.ANCHORS;
  const weights = opts.weights || R.WEIGHTS;
  const combine = opts.combine || (prev ? geometricFloor : geometric);
  const alpha = prev ? 1 : R.CONSTANTS.windowAlpha;
  const C = R.CONSTANTS;
  const W = f.ownWords;
  const b = f.body;
  const h = f.head;
  const steps = {};

  const layerHits = { words: b.words, temperature: [...(b.up || []), ...(b.down || [])], sourcing: b.src };
  for (const k of ['words', 'temperature', 'sourcing']) {
    const st = { points: pts(layerHits[k]), words: W };
    st.rWhole = rate(st.points, W);
    // Padding fix (3.1.0): the rate used is the average of the whole-story
    // rate and the rate in the story's densest 200 words, so neutral filler
    // cannot dilute a loaded passage away.
    st.window = alpha < 1 ? worstWindow(layerHits[k], W) : null;
    st.r = st.window ? alpha * st.rWhole + (1 - alpha) * st.window.r : st.rWhole;
    st.score = W > 0 ? anchorScore(st.r, A[k]) : null;
    steps[k] = st;
  }
  // Attribution is "not established" when the outlet's own text contains no
  // attribution at all and no flagged sourcing phrase. Since 3.1.0 this
  // applies at any length (3.0.0 applied it only from 150 words).
  const noAttribution = prev ? f.attrib === 0 && W >= C.minOwnWords && !(b.src || []).length : f.attrib === 0 && W > 0 && !(b.src || []).length;
  if (noAttribution) { steps.sourcing.score = null; steps.sourcing.status = 'not established'; }

  // Headline wording on its own scale, with a 25-word floor for short headlines.
  const hp = pts(h.words) + pts(h.up) + pts(h.down);
  const hw = Math.max(f.headWords || 0, C.headlineMinWords);
  const hr = rate(hp, hw);
  steps.headline = { points: hp, words: hw, r: hr, score: f.headWords ? Math.round(100 * clip(1 - hr / C.headlineAnchor)) : null };
  // Shown, not scored: how much hotter the headline is than the opening.
  const or = f.open ? rate(f.open.points, Math.max(f.open.words, 1)) : null;
  steps.headline.openRate = or;
  steps.headline.gap = or == null ? null : Math.max(0, rate(hp, Math.max(f.headWords || 1, 1)) - or);

  const layers = { words: steps.words.score, temperature: steps.temperature.score, sourcing: steps.sourcing.score, headline: steps.headline.score };
  const up = pts(b.up);
  const down = pts(b.down);
  const signedTemp = rate(up, W) - rate(down, W);
  // Which part of the text was read: the full page, or only the feed's
  // headline and summary. Only full-page reports enter the outlet ranking.
  const mode = f.source === 'page' ? 'full' : 'summary';
  return { layers, overall: combine(layers, weights), steps, signedTemp, scored: W >= C.minOwnWords, mode, ranked: mode === 'full' && W >= C.minOwnWords, drag: drag(layers, weights), method: prev ? '3.0.0' : R.VERSION };
}

// Method 2.1.0, kept only to publish how rankings moved between versions.
function scoreLegacy(f, opts = {}) {
  const A = opts.anchors || R.ANCHORS;
  const W = f.ownWords;
  const b = f.body;
  const h = f.head;
  const denom = W + f.headWords;
  const layers = {
    words: anchorScore(rate(ptsLegacy(b.words) + ptsLegacy(h.words), denom), A.words),
    temperature: anchorScore(rate(ptsLegacy(b.up) + ptsLegacy(b.down) + ptsLegacy(h.up) + ptsLegacy(h.down), denom), A.temperature),
    sourcing: anchorScore(rate(ptsLegacy(b.src), W), A.sourcing),
  };
  const hr = rate(ptsLegacy(h.words) + ptsLegacy(h.up) + ptsLegacy(h.down), f.headWords);
  const br = rate(ptsLegacy(b.words) + ptsLegacy(b.up) + ptsLegacy(b.down), W);
  layers.headline = W > 0 ? Math.round(100 * clip(1 - Math.max(0, hr - br) / 500)) : null;
  return { layers, overall: geometric(layers, R.WEIGHTS), scored: W >= R.CONSTANTS.minOwnWords, method: '2.1.0' };
}

// Pulls a small-sample average toward a prior:
// (n ÷ (n + k)) × average + (k ÷ (n + k)) × μ0
// Since 3.1.0, μ0 and k come from the data (see anchorFromData) and are
// recomputed every quarter; the published values carry their date.
function shrink(avg, n, m = R.CONSTANTS.shrinkM, C = R.ANCHORS.prior) {
  return (n / (n + m)) * avg + (m / (n + m)) * C;
}

// Data-based prior: μ0 is the average of the outlets' plain averages and k is
// the ratio of the spread of reports within an outlet to the spread between
// outlets (σ² within ÷ τ² between), kept between 2 and 30.
function anchorFromData(groups) {
  const g = Object.values(groups).filter((x) => x.length >= R.CONSTANTS.outletFloorReports);
  if (g.length < 3) return null;
  const means = g.map((x) => x.reduce((a, b) => a + b, 0) / x.length);
  const mu0 = means.reduce((a, b) => a + b, 0) / means.length;
  let within = 0; let dfw = 0;
  g.forEach((x, i) => { for (const v of x) within += (v - means[i]) ** 2; dfw += x.length - 1; });
  const s2w = dfw ? within / dfw : 0;
  const nbar = g.reduce((a, x) => a + x.length, 0) / g.length;
  const between = means.reduce((a, m) => a + (m - mu0) ** 2, 0) / (means.length - 1);
  const tau2 = Math.max(between - s2w / nbar, 1e-6);
  const k = Math.min(30, Math.max(2, s2w / tau2));
  return { mu0: Math.round(mu0 * 10) / 10, k: Math.round(k * 10) / 10, outlets: g.length, reports: g.reduce((a, x) => a + x.length, 0) };
}

// Two-sided 90% t values (0.95 quantile) by degrees of freedom, for the
// small-outlet range: mean ± t × sd ÷ √n.
const T90 = [null, 6.314, 2.920, 2.353, 2.132, 2.015, 1.943, 1.895, 1.860, 1.833, 1.812, 1.796, 1.782, 1.771, 1.761, 1.753, 1.746, 1.740, 1.734, 1.729, 1.725, 1.721, 1.717, 1.714, 1.711, 1.708, 1.706, 1.703, 1.701, 1.699];
function t90(df) { return T90[Math.min(df, T90.length - 1)] || 1.645; }

// Band index of a score: 0 for 85 and above, 1 for 70 to 84, 2 below 70.
function bandIndex(x) { const [a, b] = R.CONSTANTS.bands; return x >= a ? 0 : x >= b ? 1 : 2; }
// The pull toward the prior never moves an outlet into a better band than
// its own average (3.0.0): the adjusted score stops at the top of that band.
function bandSafe(adjusted, raw) {
  const [a, b] = R.CONSTANTS.bands;
  if (bandIndex(adjusted) < bandIndex(raw)) return bandIndex(raw) === 1 ? a - 1 : b - 1;
  return adjusted;
}

// A small, seeded random number generator so the 90% ranges are the same
// every time the same data is scored.
function rng(seed) {
  let s = 0;
  for (const c of String(seed)) s = (s * 31 + c.charCodeAt(0)) >>> 0;
  return () => { s ^= s << 13; s >>>= 0; s ^= s >>> 17; s ^= s << 5; s >>>= 0; return s / 4294967296; };
}

// Outlet score with shrinkage, the band rule, and a 90% range (3.1.0):
// - fewer than 30 reports: mean ± t × sd ÷ √n, each end pulled toward the prior;
// - 30 or more: 2,000 seeded re-draws of the reports, each averaged and pulled.
// The re-draws use a fixed seed made from the outlet's id, its number of
// reports and the method version, so the same data always gives the same range.
function outletScore(scores, seed, prior = {}) {
  const n = scores.length;
  if (!n) return null;
  const k = prior.k != null ? prior.k : R.CONSTANTS.shrinkM;
  const mu0 = prior.mu0 != null ? prior.mu0 : R.ANCHORS.prior;
  const pull = (x) => shrink(x, n, k, mu0);
  const avg = scores.reduce((a, b) => a + b, 0) / n;
  const pulled = pull(avg);
  const point = bandSafe(pulled, avg);
  let lo; let hi; let rangeMethod;
  if (n < R.CONSTANTS.smallSampleBelow) {
    const sd = n > 1 ? Math.sqrt(scores.reduce((a, x) => a + (x - avg) ** 2, 0) / (n - 1)) : 0;
    const half = n > 1 ? t90(n - 1) * sd / Math.sqrt(n) : 0;
    lo = pull(Math.max(0, avg - half)); hi = pull(Math.min(100, avg + half)); rangeMethod = 't';
  } else {
    const rand = rng(`${seed}|${n}|${R.VERSION}`);
    const runs = [];
    for (let i = 0; i < R.CONSTANTS.bootstrapRuns; i++) {
      let s = 0;
      for (let j = 0; j < n; j++) s += scores[Math.floor(rand() * n)];
      runs.push(pull(s / n));
    }
    runs.sort((a, b) => a - b);
    lo = runs[Math.floor(runs.length * 0.05)]; hi = runs[Math.floor(runs.length * 0.95)]; rangeMethod = 'bootstrap';
  }
  return { score: Math.round(point), raw: Math.round(avg), pulled: Math.round(pulled), capped: Math.round(point) !== Math.round(pulled), low: Math.round(lo), high: Math.round(hi), n, rangeMethod, k, mu0 };
}

function median(nums) {
  if (!nums.length) return 0;
  const s = [...nums].sort((a, b) => a - b);
  const m = Math.floor(s.length / 2);
  return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
}

function spearman(a, b) {
  const keys = Object.keys(a).filter((k) => b[k] != null);
  if (keys.length < 3) return null;
  const rank = (obj) => {
    const sorted = [...keys].sort((x, y) => obj[y] - obj[x]);
    const r = {};
    let i = 0;
    while (i < sorted.length) {
      let j = i;
      while (j + 1 < sorted.length && obj[sorted[j + 1]] === obj[sorted[i]]) j++;
      for (let k = i; k <= j; k++) r[sorted[k]] = (i + j) / 2 + 1;
      i = j + 1;
    }
    return r;
  };
  const ra = rank(a); const rb = rank(b);
  const ma = keys.reduce((s, k) => s + ra[k], 0) / keys.length;
  const mb = keys.reduce((s, k) => s + rb[k], 0) / keys.length;
  let num = 0; let da = 0; let db = 0;
  for (const k of keys) { num += (ra[k] - ma) * (rb[k] - mb); da += (ra[k] - ma) ** 2; db += (rb[k] - mb) ** 2; }
  return da && db ? num / Math.sqrt(da * db) : 1;
}

module.exports = { worstWindow, drag, anchorFromData, t90, geometricFloor, rate, anchorScore, geometric, arithmetic, scoreReport, scoreLegacy, shrink, bandSafe, bandIndex, outletScore, median, spearman, clip, pts };