{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "intro",
   "metadata": {},
   "source": [
    "# Reproduce the Cancellation Friction Index\n",
    "\n",
    "Cancel Atlas publishes a grade for every company in its index, and claims that anyone can\n",
    "recompute those grades from published weights and cited evidence. This notebook is that claim,\n",
    "written out so you can run it.\n",
    "\n",
    "It uses one file, `api/v1/companies.json`, which is public and CORS-open. It imports nothing from\n",
    "the Cancel Atlas codebase. If it needed our code, the claim would be unverifiable.\n",
    "\n",
    "The dataset is licensed CC BY-SA 4.0.\n"
   ]
  },
  {
   "cell_type": "code",
   "id": "load",
   "metadata": {},
   "execution_count": null,
   "outputs": [],
   "source": [
    "import json, os, urllib.request\n",
    "\n",
    "# Point this anywhere. The default is the live public API, so this notebook runs as-is for anyone.\n",
    "SOURCE = os.environ.get('CANCEL_ATLAS_SOURCE', 'https://www.cancelatlas.com/api/v1/companies.json')\n",
    "\n",
    "def load(src):\n",
    "    if src.startswith('http'):\n",
    "        req = urllib.request.Request(src, headers={'User-Agent': 'reproduce-the-index/1.0'})\n",
    "        with urllib.request.urlopen(req, timeout=60) as r:\n",
    "            return json.load(r)\n",
    "    with open(src, encoding='utf-8') as f:\n",
    "        return json.load(f)\n",
    "\n",
    "data = load(SOURCE)\n",
    "print('methodology', data['methodology_version'], '| companies', data['total_count'])\n",
    "print('licence    ', data['license'])\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "weights",
   "metadata": {},
   "source": [
    "## The weights are published, not asserted\n",
    "\n",
    "The five dimensions and their weights ship inside the same file as the scores. Nothing here is\n",
    "hard-coded from the site's prose.\n"
   ]
  },
  {
   "cell_type": "code",
   "id": "show-weights",
   "metadata": {},
   "execution_count": null,
   "outputs": [],
   "source": [
    "weights = {d['key']: d['weight'] for d in data['policy_dimensions']}\n",
    "for d in data['policy_dimensions']:\n",
    "    print(f\"  {d['weight']:>3}  {d['key']:<18} {d['label']}\")\n",
    "print('  ---  total', sum(weights.values()))\n",
    "print()\n",
    "print('formula:', data['score_formula'])\n",
    "print('bands  :', data['grade_rule'])\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "recompute",
   "metadata": {},
   "source": [
    "## Recompute every score\n",
    "\n",
    "The formula is a weighted mean of the five 0-100 dimension scores, rounded. Note that a plain\n",
    "unweighted mean gives a different answer: Netflix averages to 85 but weights to 86. The weights\n",
    "are the point.\n"
   ]
  },
  {
   "cell_type": "code",
   "id": "compute",
   "metadata": {},
   "execution_count": null,
   "outputs": [],
   "source": [
    "BANDS = [(85, 'A'), (70, 'B'), (55, 'C'), (40, 'D'), (0, 'F')]\n",
    "\n",
    "def score_of(subs):\n",
    "    return round(sum((weights[k] / 100) * v for k, v in subs.items() if k in weights))\n",
    "\n",
    "def grade_of(score):\n",
    "    return next(g for lo, g in BANDS if score >= lo)\n",
    "\n",
    "agree = disagree = 0\n",
    "problems = []\n",
    "for c in data['companies']:\n",
    "    p = c.get('policy') or {}\n",
    "    subs, published, published_grade = p.get('scores'), p.get('score'), p.get('grade')\n",
    "    if not subs or published is None:\n",
    "        problems.append((c['id'], 'no published sub-scores'))\n",
    "        continue\n",
    "    mine = score_of(subs)\n",
    "    if mine != published:\n",
    "        disagree += 1\n",
    "        problems.append((c['id'], f'score {published} published, {mine} recomputed'))\n",
    "    elif grade_of(mine) != published_grade:\n",
    "        disagree += 1\n",
    "        problems.append((c['id'], f'grade {published_grade} published, {grade_of(mine)} from bands'))\n",
    "    else:\n",
    "        agree += 1\n",
    "\n",
    "print(f'reproduced exactly : {agree}')\n",
    "print(f'disagreed          : {disagree}')\n",
    "for cid, why in problems[:10]:\n",
    "    print('   ', cid, why)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "worked",
   "metadata": {},
   "source": [
    "## One company, worked by hand\n",
    "\n",
    "So you can check the arithmetic yourself rather than trusting the loop.\n"
   ]
  },
  {
   "cell_type": "code",
   "id": "worked-example",
   "metadata": {},
   "execution_count": null,
   "outputs": [],
   "source": [
    "c = next(x for x in data['companies'] if x['id'] == 'netflix')\n",
    "subs = c['policy']['scores']\n",
    "total = 0.0\n",
    "for k, v in subs.items():\n",
    "    part = (weights[k] / 100) * v\n",
    "    total += part\n",
    "    print(f\"  {k:<18} {v:>3} x {weights[k]:>2}% = {part:6.2f}\")\n",
    "print(f\"  {'':<18} {'':>3}          {total:6.2f}  -> {round(total)}\")\n",
    "print()\n",
    "print('published:', c['policy']['score'], c['policy']['grade'])\n",
    "print('unweighted mean would be:', round(sum(subs.values()) / len(subs)))\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "evidence",
   "metadata": {},
   "source": [
    "## Every grade carries its evidence\n",
    "\n",
    "A score is only meaningful if you can check what it was read from. Each record cites the pages it\n",
    "was graded against, with the date they were read.\n"
   ]
  },
  {
   "cell_type": "code",
   "id": "sources",
   "metadata": {},
   "execution_count": null,
   "outputs": [],
   "source": [
    "for s in c['policy'].get('sources', []):\n",
    "    print(' -', s.get('title'))\n",
    "    print('  ', s.get('url'))\n",
    "print()\n",
    "print('checked:', c['policy'].get('policy_checked'))\n",
    "print('tier   :', c['policy'].get('evidence_tier'))\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "close",
   "metadata": {},
   "source": [
    "## What this does and does not show\n",
    "\n",
    "It shows the published grades follow from the published weights and the published sub-scores,\n",
    "arithmetically, with no hidden step.\n",
    "\n",
    "It does not show the sub-scores are the right reading of each company's policy page. That is a\n",
    "judgement made against cited, dated evidence, and the citations above are there so you can dispute\n",
    "any of it. The index grades what a company documents, not what cancelling actually feels like.\n"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python",
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "pygments_lexer": "python3"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
