Cannabis Freedom Index

Changelog 37f1291

Fix cannabis users in Northern Cyprus and Transnistria (110M and 120M, now 16,000 and 2,100)

extract-unodc.py took each place's population aged 15 to 64 from the World Bank by two-letter code, and the World Bank's income aggregates share codes with the site's disputed territories: XN "Lower middle income" became Northern Cyprus, XT "Upper middle income" Transnistria, about two billion people each. - The script now drops World Bank aggregates, and a disputed territory with no survey takes the figure of the country it is part of (new column estimate_from), applied to its own total population times that country's share aged 15 to 64, instead of the median of its region. Moldova surveyed 0.7%, where Europe's median, 5.75%, had stood in for Transnistria. - The four territories' rows are recomputed that way (the World Bank API cannot be reached from here to rerun the script): Transnistria 0.7% / 2,100, Northern Cyprus 6.0% / 16,000, Abkhazia 4.6% / 7,200, Somaliland 2.5% / 72,000. No other row changed. - The profile, the rankings tooltip and the map say whose survey a territory's figure comes from. - The build fails if a place has more users, or more people of an age band, than people. Claude-Session: https://claude.ai/code/session_015PFhJhHBpXsnUocs8vvYAd

1 dataset updated5 files of code, design or docs +612 −568 lines

Datasets 1

Code, design and documents 5 files

datasets/datasets.yaml +9 −3
@@ -58,5 +58,10 @@
     instead: "earlier" keeps the figure this site had before, taken from Wikipedia's list of
     older UNODC figures; "estimate" is the median of the measured countries in the same region,
-    computed here, not a regional total published by UNODC. `users` applies the share to a
+    computed here, not a regional total published by UNODC. A disputed territory with no survey
+    takes instead the figure of the country it is internationally regarded as part of
+    (`estimate_from`), whose survey covered nearly the same people, applied to its own population
+    aged 15 to 64: its total from datasets/population.csv times that country's share of those
+    ages. Corrected on 25 October 2026: the World Bank's regional and income aggregates had been
+    taken for Northern Cyprus (XN) and Transnistria (XT), which gave them some two billion people. `users` applies the share to a
     population of the survey's own age band, from the World Bank's five-year age groups (male
     and female summed, latest year). A band that cuts through a group is split evenly across
@@ -75,6 +80,7 @@
     period: { column: period, type: text, about: "Survey year or period as UNODC gives it" }
     ages: { column: ages, type: text, about: "The survey's age band, shown with the figure" }
-    source: { column: source, type: text, about: "wdr2026, earlier or estimate", values: { wdr2026: "UNODC 2026", earlier: "earlier figure, not in UNODC 2026", estimate: "median of surveyed countries in the region, computed here" } }
-    estimate: { column: estimate, type: boolean, about: "Marks a median computed here for a country with no survey (shown with ~)" }
+    source: { column: source, type: text, about: "wdr2026, earlier or estimate", values: { wdr2026: "UNODC 2026", earlier: "earlier figure, not in UNODC 2026", estimate: "computed here: the median of surveyed countries in the region, or the figure of the country a territory is part of" } }
+    estimate: { column: estimate, type: boolean, about: "Marks a figure computed here for a place with no survey (shown with ~)" }
+    estimate_from: { column: estimate_from, type: text, about: "For a disputed territory with no survey: the country whose figure it takes" }
     population_15_64: { column: population_15_64, about: "Population aged 15 to 64, kept for reference" }
     population_band: { column: population_band, about: "Population the user count is applied to" }

scripts/extract-unodc.py +39 −14
@@ -18,5 +18,8 @@ use       table 1.2, sheet "Cannabis": past-year cannabis use, every survey for
             source  "wdr2026" from the table; "earlier" a figure from before this table was
                     used (Wikipedia's list of older UNODC figures), kept where the table has
-                    none; "estimate" the median of the measured countries in the same region
+                    none; "estimate" the median of the measured countries in the same region,
+                    or, for a disputed territory, the figure of the country it is
+                    internationally regarded as part of (`estimate_from`), whose survey covers
+                    nearly the same people
             users   the share applied to a population of the same age band, from the World
                     Bank's five-year age groups (fetched when the script runs), to two
@@ -25,5 +28,7 @@ use       table 1.2, sheet "Cannabis": past-year cannabis use, every survey for
                     regional median, use ages 15 to 64. Where that population is missing,
                     the row has no user count.
-          Places the World Bank does not list keep the 15-to-64 population the file already had.
+          Places the World Bank does not list keep the 15-to-64 population the file already had;
+          a disputed territory takes its own total (datasets/population.csv) times the share
+          aged 15 to 64 in the country it is part of.
 
 youth     table 1.4, sheet "Cannabis": cannabis use among 15- and 16-year-olds, every survey
@@ -102,5 +107,5 @@ def norm(s):
 
 def country_files():
-    """id → (name, region) for every country file"""
+    """id → (name, region, part_of) for every country file; part_of for a disputed territory"""
     out = {}
     for path in glob.glob("countries/*.yaml") + glob.glob("countries/*/index.yaml"):
@@ -109,10 +114,11 @@ def country_files():
         name = re.search(r"^name: (.+)$", text, re.M).group(1).strip().strip("\"'")
         region = re.search(r"^region: (.+)$", text, re.M).group(1).strip()
-        out[cid] = (name, region)
+        part_of = re.search(r"^  part_of: ([a-z]{2})$", text, re.M)
+        out[cid] = (name, region, part_of and part_of.group(1))
     return out
 
 
 def matcher(files, missed):
-    by_name = {norm(name): cid for cid, (name, _) in files.items()}
+    by_name = {norm(name): cid for cid, (name, *_) in files.items()}
     def match(name):
         name = name.strip()
@@ -175,7 +181,12 @@ def world_bank_latest(code):
     with urllib.request.urlopen(url, timeout=120) as res:
         rows = json.load(res)[1] or []
+    # Regional and income aggregates come back too, with two-letter codes of their own, some of
+    # them the site's codes for disputed territories: XN "Lower middle income" is not Northern
+    # Cyprus, XT "Upper middle income" is not Transnistria. Keep countries only.
+    with urllib.request.urlopen("https://api.worldbank.org/v2/country?format=json&per_page=400", timeout=120) as res:
+        aggregates = {c["iso2Code"].lower() for c in json.load(res)[1] if c["region"]["id"] == "NA"}
     latest = {}
     for r in rows:
-        if r["value"] is None:
+        if r["value"] is None or r["country"]["id"].lower() in aggregates:
             continue
         latest[r["country"]["id"].lower()] = (int(r["date"]), int(round(r["value"])))
@@ -319,8 +330,21 @@ def use():
             row["population_year"], row["population_15_64"] = "", 0
         out[cid] = row
-    # no survey at all: the median of the measured countries in the same region.
-    # That median is computed here. It is not a regional total published by UNODC.
+    # no survey at all: a disputed territory takes the figure of the country it is part of, which
+    # surveyed nearly the same people; anywhere else, the median of the measured countries in the
+    # same region. That median is computed here. It is not a regional total published by UNODC.
+    totals = {}
+    for r in csv.DictReader(open("datasets/population.csv", encoding="utf-8")):
+        if int(r["year"]) >= totals.get(r["country"], (0, 0))[0]:
+            totals[r["country"]] = (int(r["year"]), int(r["population"]))
     for cid, row in out.items():
-        if row["source"] == "estimate":
+        if row["source"] != "estimate":
+            continue
+        parent = files[cid][2]
+        if parent and out.get(parent, {}).get("source") in ("wdr2026", "earlier"):
+            row["prevalence"], row["estimate_from"] = out[parent]["prevalence"], parent
+            if cid not in wb and cid in totals and out[parent].get("population_15_64") and parent in totals:
+                share = out[parent]["population_15_64"] / totals[parent][1]
+                row["population_year"], row["population_15_64"] = totals[cid][0], int(round(totals[cid][1] * share))
+        else:
             region = files[cid][1]
             row["prevalence"] = round(statistics.median(r["prevalence"] for k, r in out.items() if r["source"] != "estimate" and files[k][1] == region), 2)
@@ -332,5 +356,5 @@ def use():
             e = history[cid][year]
             if r["source"] == "wdr2026" and year < r["year"]:
-                lines.append([cid, files[cid][0], e["unodc_name"], e["prevalence"], year, e["period"], e["ages"], "", "wdr2026", "", "", "", "", "", ""])
+                lines.append([cid, files[cid][0], e["unodc_name"], e["prevalence"], year, e["period"], e["ages"], "", "", "wdr2026", "", "", "", "", "", ""])
         if not r["population_year"] and cid in groups:
             r["population_year"] = groups[cid][0]
@@ -340,8 +364,8 @@ def use():
             omitted.append(f"{cid} ({r.get('ages') or r['source']})")
         lines.append([cid, files[cid][0], r.get("unodc_name", ""), r["prevalence"], r.get("year", ""), r.get("period", ""), r.get("ages", ""),
-                      "true" if r["source"] == "estimate" else "", r["source"], r["population_15_64"] or "", r["population_year"],
+                      "true" if r["source"] == "estimate" else "", r.get("estimate_from", ""), r["source"], r["population_15_64"] or "", r["population_year"],
                       band or "", band_ages, "true" if approximate else "", users])
     write("datasets/unodc-cannabis-use.csv",
-          ["country", "name", "unodc_name", "prevalence", "year", "period", "ages", "estimate", "source",
+          ["country", "name", "unodc_name", "prevalence", "year", "period", "ages", "estimate", "estimate_from", "source",
            "population_15_64", "population_year", "population_band", "population_ages", "users_approximate", "users"], lines)
     counts = {s: sum(1 for r in out.values() if r["source"] == s) for s in ("wdr2026", "earlier", "estimate")}
@@ -365,6 +389,7 @@ def headcount(cid, row, groups):
         if not pop and cid in groups:
             pop, _ = band_population(groups[cid][1], 15, 64)
-        # the median has no survey age; an earlier figure with no age is only assumed to be 15–64
-        return (pop, "15-64", row["source"] != "estimate") if pop else (0, "", False)
+        # the median has no survey age; an earlier figure with no age is only assumed to be 15–64;
+        # a territory's 15–64 population is borrowed from the share in the country it is part of
+        return (pop, "15-64", row["source"] != "estimate" or bool(row.get("estimate_from"))) if pop else (0, "", False)
     lo, hi = ages
     if (lo, hi) == (15, 64) and pop:

src/components/CountryArticle.astro +7 −1
@@ -61,4 +61,6 @@ const people = (n) => (n >= 1e6 ? `${+(n / 1e6).toPrecision(2)} million` : n.toL
 const use = sub ? null : c.use;
 const unitUse = sub?.use ?? null; // a state's or province's own survey figure
+// a disputed territory with no survey takes the figure of the country it is part of
+const useFrom = use?.estimate_from ? countries.find((x) => x.id === use.estimate_from.toUpperCase()) : null;
 // Crossing a border with cannabis. This is general guidance, the same rule almost everywhere,
 // worded for the country's status; it is not country-specific research.
@@ -186,5 +188,7 @@ const culture = cultureOf(c);
           <dd>{use.estimate ? "about " : ""}{use.prevalence}%</dd>
         </div>
-        <p>{use.estimate
+        <p>{useFrom
+          ? `This site's figure: there is no survey of ${c.name} alone, so ${useFrom.name}'s rate from its ${useFrom.use.period ?? useFrom.use.year} survey is applied to an estimate of the people aged 15–64 here.`
+          : use.estimate
           ? `This site's figure: there is no national survey, so the median rate of surveyed countries in ${c.region} is applied to people aged 15–64.`
           : `People who used cannabis in the past year, from a ${use.period ?? use.year} survey${use.users != null ? `, applied to today's population aged ${useAges({ ages: use.population_ages || use.ages })}` : ""}${use.users_approximate ? ". The population is approximate: the survey ages cut through an age group, or were not recorded and ages 15–64 are used" : ""}${use.source === "earlier" ? ". An earlier figure: UNODC's 2026 table has none for this country" : ""}.`}</p>
@@ -193,4 +197,6 @@ const culture = cultureOf(c);
           : use.source === "earlier"
             ? <>An earlier UNODC figure, as collected in <a href="https://en.wikipedia.org/wiki/List_of_countries_by_annual_cannabis_use" target="_blank" rel="noopener noreferrer">Wikipedia's list of annual cannabis use by country</a></>
+            : useFrom
+            ? <>{useFrom.name}'s figure from the <a href="https://www.unodc.org/unodc/en/data-and-analysis/world-drug-report-2026-annex.html" target="_blank" rel="noopener noreferrer">UNODC World Drug Report 2026</a>; the population aged 15–64 is this place's total times {useFrom.name}'s share of those ages</>
             : <>A median computed on this site from surveyed countries in {c.region}, using <a href="https://www.unodc.org/unodc/en/data-and-analysis/world-drug-report-2026-annex.html" target="_blank" rel="noopener noreferrer">UNODC World Drug Report 2026</a> figures</>}{use.users != null && <>. Population from the <a href="https://data.worldbank.org/" target="_blank" rel="noopener noreferrer">World Bank</a>, aged {useAges({ ages: use.population_ages || use.ages })}</>}. <a href={`${base}sources/#dataset-cannabis_use`}>The whole dataset →</a></SourceTip>
       </dl>

src/components/HomePage.astro +5 −3
@@ -63,4 +63,6 @@ const count = (fn) => countries.filter(fn).length;
 const compact = (n) => (n >= 1e6 ? `${+(n / 1e6).toPrecision(2)}M` : n >= 1e3 ? `${+(n / 1e3).toPrecision(2)}k` : String(n));
 const useNote = (c) => {
+  const from = c.use.estimate_from && places.find((x) => x.id === c.use.estimate_from.toUpperCase());
+  if (from) return `This site's figure: no survey of ${c.name} alone, so ${from.name}'s rate, ${c.use.prevalence}% of people aged 15–64, is applied to an estimate of the people here of those ages.`;
   if (c.use.estimate) return `This site's figure: no national survey, so the median rate in ${c.region}, ${c.use.prevalence}% of people aged 15–64, is used.`;
   const ages = useAges(c.use), when = c.use.period ?? c.use.year;
@@ -102,5 +104,5 @@ const unitData = Object.fromEntries(countries.flatMap((c) => {
 const USE_SCALES = {
   users: { label: "Cannabis users", steps: [[0, "Under 10k"], [1e4, "10k–100k"], [1e5, "100k–1M"], [1e6, "1M–10M"], [1e7, "10M or more"]],
-    note: "Each survey rate applied to a population of the same age band. A ~ is this site's median for a country with no survey, or a count whose ages were estimated." },
+    note: "Each survey rate applied to a population of the same age band. A ~ is this site's estimate for a place with no survey (the regional median, or for a disputed territory the rate of the country it is part of), or a count whose ages were estimated." },
   prevalence: { label: "Share who use cannabis", steps: [[0, "Under 1%"], [1, "1–3%"], [3, "3–7%"], [7, "7–15%"], [15, "15% or more"]] },
   ratio: {
@@ -301,5 +303,5 @@ const columnGroups = [["Shown by default", BASE_COLUMNS], ...["Score segments",
             {scale.steps.map(([, text], i) => <span><i class="sw" style={`background:var(--u${i})`}></i>{text}</span>)}
             <span><i class="sw sw-nodata"></i>No data</span>
-            <span class="legend-note">{scale.note ?? "Adults who used cannabis in the past year, from each country's latest survey (most ask ages 15–64). A ~ is this site's median of surveyed countries in the region, used where that country has no survey."}</span>
+            <span class="legend-note">{scale.note ?? "Adults who used cannabis in the past year, from each country's latest survey (most ask ages 15–64). A ~ is this site's estimate where a place has no survey of its own: the median of surveyed countries in the region, or for a disputed territory the rate of the country it is part of."}</span>
           </div>
         ))}
@@ -940,5 +942,5 @@ const columnGroups = [["Shown by default", BASE_COLUMNS], ...["Score segments",
           return head(c.flag, c.name, metric === "users" ? usersText : `${rateMark}${c.use.prevalence}%`, useColor(c))
             + row("Users", usersText) + row(`Share aged ${c.use.ages}`, `${rateMark}${c.use.prevalence}%`)
-            + row(c.use.estimate ? "Source" : "Survey year", c.use.estimate ? "median computed here" : c.use.period ?? c.use.year)
+            + row(c.use.estimate ? "Source" : "Survey year", c.use.estimate_from ? `${byId.get(c.use.estimate_from.toUpperCase())?.name ?? c.use.estimate_from.toUpperCase()}'s rate` : c.use.estimate ? "median computed here" : c.use.period ?? c.use.year)
             + (c.use.source === "earlier" ? row("Source", "earlier figure, not in UNODC's 2026 table") : "") + foot(c);
         }

src/lib/countries.js +8 −3
@@ -55,8 +55,13 @@ const scoreList = (scores) => SEGMENTS.map((seg) => scores[seg.id]);
 // the age band a cannabis-use figure is for: the survey's own, or 15–64 where none is given
 export const useAges = (use) => (use.ages ? use.ages.replace("-", "–") : "15–64");
-// cannabis use comes from its dataset, which must have a row for every place
-const useOf = (slug) => {
+// cannabis use comes from its dataset, which must have a row for every place. A place cannot have
+// more people of an age band, or more users, than people: Northern Cyprus and Transnistria once
+// took the World Bank's figures for two income groups, some two billion people each.
+const useOf = (slug, population) => {
   const use = latestOf(slug).cannabis_use;
   if (!use) throw new Error(`datasets/unodc-cannabis-use.csv: no row for "${slug}"`);
+  for (const key of ["users", "population_band", "population_15_64"]) {
+    if (use[key] > population) throw new Error(`datasets/unodc-cannabis-use.csv: "${slug}" has ${key} ${use[key]}, more than its population of ${population}`);
+  }
   return use;
 };
@@ -91,5 +96,5 @@ const everyPlace = (await getCollection("countries"))
       populationByYear: populationOf(slug).map((r) => [r.year, r.population]),
       population: populationOf(slug).at(-1).population, // the latest figure
-      use: useOf(slug), // { prevalence, year?, estimate?, users }, from datasets/unodc-cannabis-use.csv
+      use: useOf(slug, populationOf(slug).at(-1).population), // { prevalence, year?, estimate?, users }, from datasets/unodc-cannabis-use.csv
       datasets: latestOf(slug), // the latest figures from the outside datasets in /datasets, see datasets.js
       series: datasetsOf(slug), // and every year of them

    ↑↓ to move, ↵ to open.