Cannabis Freedom Index

Changelog 54d3cc5tree, with Claude Opus 5.5

Profile audit: check penalty tables and context sections; sharper rules and fixes

- scripts/profile-audit.cjs (npm run profiles:audit): what the schema cannot see - tables on the wrong segment, minimums or caning left in notes, steps out of order, punishments on legal steps, one-line sections, statuses contradicting their text, Research without science, too many spotlights, uncited sentences, avoided words. - Penalty steps take fine_min; the Fine column shows ranges and minimums. Tables are allowed on possession, cultivation, sharing and consumption, never on enforcement or in context sections. The industry status gains "Hemp only". - DATA-MAINTENANCE.md and both expansion guides: every figure in its field, "prison or a fine" alternatives, what notes and terms are for, statuses must match their text and are never filled in bulk, and the audit before every commit. - Fixes: Bangladesh and Sri Lanka had penalty tables inside Driving, which broke the build (their texts state the figures); industry statuses corrected for Nigeria, Taiwan, Palestine and Northern Cyprus (none) and Belize (hemp); Singapore's cultivation minimums in fields and a consumption table with the LT1 and LT2 steps.

8 country profiles edited9 files of code, design or docs +229 −49 lines

Countries and states 8 files

Bangladesh countries/bd.yaml

context › driving › currency

BDT

context › driving › penalties

label Driving after intoxicants, Section 92(1) · treatment criminal · prison 3 months · fine 10000 · note Up to three months, up to Tk10,000, or both, plus one driver point

Belize countries/bz.yaml

context › production › status

medicalhemp

Sri Lanka countries/lk.yaml

context › driving › currency

LKR

context › driving › penalties

label Ordinary driving after consumption · treatment criminal · prison 3 months · fine 30000 · note LKR25,000–30,000 or up to three months or both, plus licence suspension up to twelve months, label Passenger vehicle after consumption · treatment criminal · prison 6 months · fine 30000 · note Same fine range or up to six months or both, plus licence cancellation

Nigeria countries/ng.yaml

context › production › status

medicalnone

Palestine countries/ps.yaml

context › production › status

medicalnone

Singapore countries/sg.yaml

segments › possession › penalties

upto 15 g · treatment criminal · term Offence under the Misuse of Drugs Act · prison 10 years · fine 20000 · note Using it is a separate offence, proved by a urine test, with at least a year in prison, over 15 g · upto 330 g · treatment criminal · term Presumed trafficking · prison min 5 yea … ue · note If the presumption is rebutted, possession carries 20–30 years and 10–15 strokes

segments › cultivation › penalties

over 0 plants · treatment criminal · term Cultivation · prison min 3 years · prison 20 years · fine min 5000 · fine 40000 · note At least three yearsPrison or a fine of at least S$5,000 or both

segments › consumption › currency

SGD

segments › consumption › penalties

label Using cannabis, a first conviction · treatment criminal · term Consumption, proved by a urine test · prison min 1 year · prison 10 years · fine 20000, label Repeat use after qualifying earlier convictions (LT1, section 33A) · treatment criminal · term Long-term imprisonment 1 · prison min 5 years · prison 7 years · caning 3, 6 · note Women and men over 50 are not caned, label Repeat use after an LT1 term (LT2, section 33A) · treatment criminal · term Long-term imprisonment 2 · prison min 7 years · prison 13 years · caning 6, 12

Taiwan countries/tw.yaml

context › production › status

medicalnone

Northern Cyprus countries/xn.yaml

context › production › status

medicalnone

Code, design and documents 9 files

docs/DATA-MAINTENANCE.md +60 −12
@@ -399,7 +399,9 @@ for overall 100%; full context coverage alone is insufficient. Run
 ### Statuses for hemp, industry and research
 
-`hemp`, `production` and `research` may carry a `status`, like `clubs` or `driving`. It shows as a
-chip on the country page, colours a world-map view and fills a rankings column. It is optional
-while countries are researched; set it only when the section's text states the fact it rests on.
+`hemp`, `production` and `research` carry a `status`, like `clubs` or `driving`: a filled section
+must have one. It shows as a chip on the country page, colours a world-map view and fills a
+rankings column. **The status must say what the section's text says.** When you are not sure, read
+the text again; don't pick the nearest-sounding value, and never fill statuses in bulk without
+reading each text.
 
 ```yaml
@@ -407,5 +409,5 @@ while countries are researched; set it only when the section's text states the f
     status: standard      # high (up to 1% THC) | standard (0.3%) | strict (below 0.3%) | none (no exception for hemp)
   production:
-    status: medical       # open (licensed adult market) | state (state-run) | pilot (trials only) | medical (medicine, research or export only) | none
+    status: medical       # open (licensed adult market) | state (state-run) | pilot (trials only) | medical (medicine, research or export only) | hemp (licensed low-THC hemp only) | none
   research:
     status: licensed      # open | licensed (special licence or permit) | restricted (synthetic or narrow routes only) | blocked
@@ -414,5 +416,8 @@ while countries are researched; set it only when the section's text states the f
 - `hemp` is the THC line in law, not how big the hemp industry is.
 - `production` describes who may produce for sale. A country with both a pilot for adults and a
-  medical industry is `pilot`; one with only medical licences is `medical`.
+  medical industry is `pilot`; one with only medical licences is `medical`; one where only
+  low-THC hemp may be grown or processed is `hemp`; one whose text begins "None" is `none`.
+  Licences that exist on paper but have never been issued still count as `medical` if the law
+  provides them; say so in the text.
 - `research` needs a stated route: a permit, a licence, a state agency that supplies researchers.
   If the text does not say how researchers get cannabis, leave the status out.
@@ -550,4 +555,14 @@ names. "Not documented" never means "does not exist", so leave the section out r
 writing "none".
 
+### Checking a profile
+
+`npm run profiles:audit` checks every profile (or `node scripts/profile-audit.cjs ke de` some of
+them) for what the schema cannot see: one-line sections ("None."), statuses that contradict their
+text, a Research section with no science in it, more than three spotlights, sentences without a
+citation in a text that uses marks, words WRITING.md asks to avoid, GOV.UK travel advice outside
+the UK. Errors must be fixed before committing; `--warnings` also shows the softer warnings. The
+one-line texts in the four original sections (`products`, `production`, `medical`,
+`consequences`) are old debt: rewrite them when you work on a country.
+
 ### YAML pitfalls
 
@@ -560,7 +575,19 @@ writing "none".
 ## Penalty steps
 
-Possession and cultivation may carry a small table of what happens at each amount, under the
+A segment may carry a small table of what happens at each amount or for each offence, under the
 segment's text. It never changes a score; it makes the text checkable and lets countries be
-compared (`src/lib/penalties.js`).
+compared (`src/lib/penalties.js`). Tables belong on four segments only:
+
+| Segment | What its table holds |
+|---|---|
+| `possession` | steps by amount held (grams), from legal or tolerated to criminal |
+| `cultivation` | steps by plants grown |
+| `sharing` | supply, sale and trafficking: by amount, or labelled steps ("supply to a minor") |
+| `consumption` | only where using cannabis is itself an offence (Japan, Singapore, the Philippines) |
+
+Never on `enforcement` or `access`: those segments describe practice and supply routes, not
+offences. A trafficking ladder goes on `sharing`. Never in a context section either (`driving`,
+`visitors` and the others): the schema rejects it there, and the section's text already states
+the penalty.
 
 ```yaml
@@ -582,4 +609,5 @@ segments:
         prison_min: 5 days      # a minimum sentence, where the law sets one; alone it reads "at least"
         fine: 5500              # the maximum, in `currency`
+        fine_min: 500           # a minimum fine, where the law sets one; alone it reads "at least"
       - label: Growing as a business      # a step that is not about an amount
         treatment: criminal
@@ -606,8 +634,21 @@ segments:
 - **Steps not about an amount** ("growing commercially", "a large quantity as set by the courts")
   get a `label` and no amount; they show in the table but don't count in the comparison figures.
-- **Minimums, caning and death.** Where the law sets a minimum sentence, give it as `prison_min`
-  ("6 months – 5 years"). Corporal punishment goes in `caning: [fewest, most]` strokes, and a step
-  that carries the death penalty gets `death: true` (no `prison` needed). They show in the
-  Punishment column, and `penalties.summary` records whether a ladder reaches caning or death.
+- **Every figure in its field, never only in the note.** Minimum prison: `prison_min`; minimum
+  fine: `fine_min`; strokes of the cane or lashes: `caning: [fewest, most]`; the death penalty:
+  `death: true` (no `prison` needed). The table then shows "6 months – 5 years", "At least
+  S$5,000", "and 3–6 strokes of the cane". `penalties.summary` records whether a ladder reaches
+  caning or death. The audit (below) fails on minimums or caning written only in a note.
+- **Prison or a fine.** When the law gives alternatives ("at least three years or a fine of at
+  least S$5,000"), fill both minimums and say in `note`: "Prison or a fine, or both" (or "Prison
+  or a fine"). Without that note the table reads as prison and a fine together.
+- **Notes are for conditions and exceptions** ("not prosecuted; police may seize it", "women and
+  men over 50 are not caned", "at home, over 60 g"), in one short line. Don't repeat figures the
+  fields already show; longer explanations go in the segment text.
+- **`term` is the law's own word** for the offence ("Ordnungswidrigkeit", "misdemeanor",
+  "Consumption, proved by a urine test"). Give it for every civil and criminal step; it is shown,
+  never compared.
+- **Steps run in order and join up.** Each amount step starts where the one before ends
+  (`over:` equals the previous `upto:`), treatments get no milder as amounts grow, and one ladder
+  counts either grams or plants, never both.
 - **Presumptions.** Where an amount is presumed to be for trafficking (Singapore above 15 g),
   give the trafficking punishment in the step, call it "Presumed trafficking" in `term`, and say
@@ -617,5 +658,12 @@ segments:
 - Write the steps for a first offender aged over the legal age. Different rules for young people
   or repeat offences go in a spotlight, in the text, or in rows with `repeat`.
-- The Netherlands, Czechia and Germany have them; use those as models.
+- **Repeat offences with their own punishment** (Singapore's LT1 and LT2) get labelled steps of
+  their own, as in Singapore's consumption table.
+- Models: the Netherlands, Czechia and Germany (amount ladders), Singapore (presumptions, caning,
+  the death penalty, a consumption table), Kenya and Tanzania once their notes are moved into
+  fields.
+- **Check before committing:** `npm run profiles:audit` (or `node scripts/profile-audit.cjs <id>`)
+  lists mistakes the schema cannot catch: tables on the wrong segment, figures left in notes,
+  steps out of order, a punishment on a legal step.
 
 ## Research scope beyond law

docs/EXPAND-COUNTRIES.md +2 −1
@@ -31,5 +31,6 @@ For each country in the batch:
 3. Work through `docs/EXPAND-COUNTRY.md` in its order: current law and penalty tables, history,
    articles, context sections, culture, links.
-4. Build (`npm run build`; wait if another build is running), then read the built page in
+4. Build (`npm run build`; wait if another build is running), run
+   `node scripts/profile-audit.cjs <id>` (no errors allowed), then read the built page in
    `dist/country/<id>/`.
 5. **Commit that country before starting the next**, with only your own files, and a message that

docs/EXPAND-COUNTRY.md +7 −5
@@ -44,7 +44,8 @@ today:
   changed, **change the score too**, calibrated against comparable countries (look at a peer with
   a similar law and its scores), and add the change as a history entry with `before`.
-- Add the **penalty tables** for possession and cultivation from the statute
-  (DATA-MAINTENANCE.md, "Penalty steps"): amounts, treatment, the law's term, minimum and maximum
-  prison, caning or death where they apply, and the fine.
+- Add the **penalty tables** from the statute (DATA-MAINTENANCE.md, "Penalty steps"): possession
+  and cultivation by amount, supply and trafficking on `sharing`, and `consumption` only where using
+  is itself an offence. Put every figure in its field (minimum and maximum prison, minimum and
+  maximum fine, caning, death), the law's word in `term`, and only conditions in `note`.
 
 ## 3. History
@@ -80,6 +81,6 @@ sections well"):
   `research` (science, not policy counting), `genetics` (real local plants only), `spotlights`
   (one to three things the scores do not show).
-- Set the statuses where the text states the fact: `hemp`, `production`, `research`, `clubs`,
-  `driving`, `visitors`.
+- Set the statuses: `hemp`, `production`, `research`, `clubs`, `driving`, `visitors`. Each must
+  say what its text says; read the text before choosing, and never fill statuses in bulk.
 
 How every section reads:
@@ -115,4 +116,5 @@ How every section reads:
 ```bash
 npm run build          # must pass; it checks schemas, citations and internal links
+npm run profiles:audit # must report no errors for your country (node scripts/profile-audit.cjs <id>)
 npm run seo:audit      # what the deploy runs after the build
 node scripts/country-gaps.cjs ke   # the list should be shorter

package.json +1 −0
@@ -15,4 +15,5 @@
     "research:test": "node --test scripts/context-coverage.test.mjs",
     "seo:audit": "node scripts/seo-audit.mjs",
+    "profiles:audit": "node scripts/profile-audit.cjs",
     "answers:test": "node --test scripts/answers.test.mjs scripts/profile-claims.test.mjs scripts/use-freedom-ratio.test.mjs",
     "survey:test": "node --test scripts/survey.test.mjs",

scripts/profile-audit.cjs +122 −0
@@ -0,0 +1,122 @@
+// Checks the penalty tables and context sections of every profile against the rules in
+// docs/DATA-MAINTENANCE.md ("Penalty steps", "Writing context sections well") for mistakes the
+// schema cannot catch. Prints each problem with its file; exits with 1 when it finds errors.
+//
+//   node scripts/profile-audit.cjs              every profile
+//   node scripts/profile-audit.cjs ke de        some
+//   node scripts/profile-audit.cjs --warnings   also the softer warnings (style, length)
+const fs = require("fs"), y = require("js-yaml");
+
+const args = process.argv.slice(2);
+const warnings = args.includes("--warnings");
+const only = args.filter((a) => !a.startsWith("--"));
+const fileOf = (id) => (fs.existsSync(`countries/${id}.yaml`) ? `countries/${id}.yaml` : `countries/${id}/index.yaml`);
+const ids = (only.length ? only : fs.readdirSync("countries").map((f) => f.replace(/\.yaml$/, ""))).filter((id) => fs.existsSync(fileOf(id)));
+
+const GRAMS = { g: 1, kg: 1000, oz: 28.3495, lb: 453.592 };
+const amount = (t) => { const m = /^(\d+(?:\.\d+)?)\s*(g|kg|oz|lb|plants?)$/.exec(String(t ?? "").trim()); return m ? { v: m[2].startsWith("plant") ? +m[1] : +m[1] * GRAMS[m[2]], unit: m[2].startsWith("plant") ? "plants" : "g" } : null; };
+const DAYS = { day: 1, week: 7, month: 30, year: 365 };
+const days = (t) => { const m = /^(\d+(?:\.\d+)?)\s*(day|week|month|year)s?$/.exec(String(t ?? "").trim()); return m ? +m[1] * DAYS[m[2]] : null; };
+const RANK = { legal: 0, tolerated: 1, civil: 2, criminal: 3 };
+// words from docs/WRITING.md, "Words to avoid"
+const AVOID = /\b(constitutes?|subsequently|thereby|in respect of|the instrument|the provision|operative (order|part)|establishes)\b/i;
+const SCIENCE = /research|stud(y|ies)|scien|universit|institut|trial|laborator|paper|journal|discover/i;
+
+const errors = [], warns = [];
+const err = (id, where, msg) => errors.push(`${fileOf(id)} ${where}: ${msg}`);
+const warn = (id, where, msg) => warns.push(`${fileOf(id)} ${where}: ${msg}`);
+const sentences = (t) => String(t).replace(/\[@[^\]]*\]/g, "").replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").split(/(?<=[.!?])\s+(?=[A-Z0-9"'‘“(])/).filter((s) => s.trim().length > 2);
+
+for (const id of ids) {
+  const p = y.load(fs.readFileSync(fileOf(id), "utf8"));
+  const unit = id.includes("/");
+
+  // ── penalty tables ──
+  for (const [seg, block] of Object.entries(p.segments ?? {})) {
+    const rows = block?.penalties;
+    if (!rows) continue;
+    const where = `segments.${seg}.penalties`;
+    if (!["possession", "cultivation", "sharing", "consumption"].includes(seg)) err(id, where, `penalty tables belong on possession, cultivation, sharing (supply and trafficking) or consumption (where using is itself an offence), not ${seg}`);
+    let prevUpto = null, prevRank = -1, units = new Set();
+    rows.forEach((r, i) => {
+      const at = `${where}[${i}]`;
+      const up = amount(r.upto), ov = amount(r.over);
+      [up, ov].filter(Boolean).forEach((a) => units.add(a.unit));
+      const first = !r.repeat || r.repeat === 1;
+      // amount ladder: each step starts where the last one ended
+      if (first && !r.label && (up || ov)) {
+        if (ov && up && ov.v >= up.v) err(id, at, `over (${r.over}) is not below upto (${r.upto})`);
+        if (prevUpto !== null && ov && Math.abs(ov.v - prevUpto) > 0.5) warn(id, at, `starts at ${r.over} but the step before ends at ${prevUpto} ${[...units][0] ?? ""}: a gap or an overlap`);
+        if (prevUpto !== null && !ov && up) err(id, at, `a step with only upto after another step: give it over: (the previous upto)`);
+        prevUpto = up ? up.v : null;
+      }
+      // treatment should not get milder as amounts grow
+      if (first && !r.label && RANK[r.treatment] !== undefined) {
+        if (RANK[r.treatment] < prevRank) warn(id, at, `${r.treatment} after a harsher step; check the order`);
+        prevRank = Math.max(prevRank, RANK[r.treatment]);
+      }
+      if ((r.treatment === "legal" || r.treatment === "tolerated") && (r.prison || r.prison_min || r.fine || r.fine_min || r.caning || r.death)) err(id, at, `${r.treatment} with a punishment; a punishment means civil or criminal`);
+      if (r.treatment === "criminal" && !r.prison && !r.prison_min && !r.fine && !r.fine_min && !r.death && !r.note) warn(id, at, "criminal step with no punishment and no note saying why");
+      if ((r.treatment === "criminal" || r.treatment === "civil") && !r.term) warn(id, at, "no term: give the law's own word for the offence");
+      if (r.prison_min && r.prison && days(r.prison_min) > days(r.prison)) err(id, at, `prison_min ${r.prison_min} is longer than prison ${r.prison}`);
+      if (r.caning && r.caning[0] > r.caning[1]) err(id, at, "caning range is reversed");
+      // facts that have a field of their own but were left in the note
+      const note = String(r.note ?? "");
+      // a minimum prison term or a minimum fine written in the note instead of its field
+      if (/\b(at least|minimum of|not less than)\s+(\w+\s+){0,2}(day|week|month|year)s?\b/i.test(note) && !r.prison_min) err(id, at, `the note gives a minimum prison term ("${note.slice(0, 60)}"); use prison_min`);
+      const CUR = "(shillings|dollars|euros|pounds|francs|rupees|rupiah|pesos|naira|cedis|dinars|dirhams|riyals|ringgit|baht|yen|won|yuan|[A-Z]{3}|[$€£¥₹]|[A-Z]{1,2}\\$)";
+      if (new RegExp(`\\b(at least|minimum of|not less than)\\s+(a fine of\\s+)?(${CUR}\\s?[\\d][\\d,.]*|[\\d][\\d,.]*(\\s?(million|thousand))?\\s*${CUR})`, "i").test(note) && !r.fine_min) err(id, at, `the note gives a minimum fine ("${note.slice(0, 60)}"); use fine_min`);
+      if (/\bstrokes?\b|\bcan(e|ing)\b|\blash(es)?\b/i.test(note) && !/\b(no|without|not)\b[^.;]{0,20}\b(can(e|ing)|lash|strokes?)/i.test(note) && !r.caning && !r.death) err(id, at, "the note mentions caning or lashes; use caning: [fewest, most]");
+      if (r.prison && r.prison_min && /\b\w+ (months?|years?) (to|and|–|-) \w+ (months?|years?)\b/i.test(note)) warn(id, at, "the note repeats the prison range the table already shows; keep notes for exceptions and conditions");
+      if (/\bdeath (penalty|sentence)\b|\bexecution\b/i.test(note) && !r.death) warn(id, at, "the note mentions the death penalty; if this step carries it, set death: true");
+      if (note.length > 160) warn(id, at, `long note (${note.length} characters): put the explanation in the segment text`);
+    });
+    if (units.size > 1) err(id, where, "mixes grams and plants in one ladder; possession counts weight, cultivation plants");
+    if (block.text && !/\[@/.test(block.text)) warn(id, `segments.${seg}`, "has a penalty table but its text has no inline citations; the figures need their source");
+  }
+
+  // ── context sections ──
+  const ctx = p.context ?? {};
+  if (!unit) {
+    if (ctx.products && ctx.recreational && ctx.hemp) warn(id, "context.products", "recreational and hemp exist: fold products into them and delete it");
+    for (const [key, b] of Object.entries(ctx)) {
+      const items = Array.isArray(b) ? b.map((x, i) => [`context.${key}[${i}]`, x]) : [[`context.${key}`, b]];
+      for (const [where, it] of items) {
+        const text = it?.text ?? "";
+        if (!text) continue;
+        const ss = sentences(text);
+        // a one-liner: an error in the newer sections, old debt (a warning) in the original four
+        const thin = /^none\.?$/i.test(text.trim()) || (ss.length === 1 && ss[0].trim().length < 40);
+        if (thin) (["products", "production", "medical", "consequences"].includes(key) ? warn : err)(id, where, `a one-line text ("${text.trim().slice(0, 50)}"); say what the situation is and cite it, or leave the section out`);
+        if (["recreational", "medical", "hemp", "production", "culture", "research", "genetics"].includes(key) && ss.length > 10) warn(id, where, `${ss.length} sentences; three to seven read best`);
+        if (/\[@/.test(text)) {
+          // with inline marks, every sentence should carry one (or share the next sentence's)
+          const raw = text.split(/(?<=[.!?](?:\[@[^\]]*\])?)\s+(?=[A-Z0-9"'‘“(\[])/);
+          const bare = raw.filter((s, i) => !/\[@/.test(s) && !(raw[i + 1] && /\[@/.test(raw[i + 1]) && s.length < 120)).length;
+          if (bare > 1) warn(id, where, `${bare} sentences without a citation mark`);
+        }
+        if (AVOID.test(text)) warn(id, where, `uses a word WRITING.md asks to avoid ("${text.match(AVOID)[0]}")`);
+        if (/gov\.uk\/foreign-travel-advice/.test(JSON.stringify(it.sources?.map((k) => p.sources?.[k])))) warn(id, where, "rests on GOV.UK travel advice, meant only for the UK and closed countries");
+      }
+    }
+    if (ctx.research?.text && !SCIENCE.test(ctx.research.text)) err(id, "context.research", "does not mention any science (studies, universities, trials, discoveries, research permits): it is about scientific research, not policy");
+    if (Array.isArray(ctx.spotlights)) {
+      if (ctx.spotlights.length > 3) err(id, "context.spotlights", `${ctx.spotlights.length} spotlights; at most three`);
+      ctx.spotlights.forEach((s, i) => { if (s.title.split(/\s+/).length > 8) warn(id, `context.spotlights[${i}]`, `long title ("${s.title}"): a few plain words`); });
+    }
+    if (ctx.culture?.text && !/\]\(\/(culture|history)\//.test(ctx.culture.text)) warn(id, "context.culture", "links no Culture or History page");
+    if (ctx.production?.status === "none" && /licen[cs]|permit|authori[sz]ed (grow|produc|cultiv)/i.test(ctx.production.text ?? "") && !/no (licen|permit)|not (licen|permit)|none/i.test(ctx.production.text ?? ""))
+      warn(id, "context.production", "status none, but the text mentions licences or permits; check whether it is medical");
+    const prod = (ctx.production?.text ?? "").trim();
+    if (ctx.production?.status && !["none", "hemp"].includes(ctx.production.status) && /^none\b|^no (legal|licensed|lawful) (cannabis )?(production|cultivation)/i.test(prod))
+      err(id, "context.production", `status ${ctx.production.status}, but the text says there is no legal production`);
+    if (ctx.production?.status === "medical" && /hemp/i.test(prod) && !/medic|pharma|patient|export|research/i.test(prod))
+      warn(id, "context.production", "status medical, but the text describes only hemp: is it hemp (Hemp only)?");
+    if (ctx.hemp?.status === "none" && /\b0[.,]\d+\s?%|\b1\s?%\s?THC|hemp (may|can) be grown|licensed hemp/i.test(ctx.hemp.text ?? "")) warn(id, "context.hemp", "status none, but the text gives a hemp THC limit or licensed hemp; check");
+  }
+}
+
+for (const e of errors) console.log("✗ " + e);
+if (warnings) for (const w of warns) console.log("· " + w);
+console.log(`\n${ids.length} profiles: ${errors.length} errors, ${warns.length} warnings${warnings ? "" : " (show them with --warnings)"}`);
+process.exit(errors.length ? 1 : 0);

src/components/CountryArticle.astro +2 −2
@@ -350,5 +350,5 @@ const culture = cultureOf(c);
               {seg.penalties && (
                 <table class="pen">
-                  <thead><tr><td>Amount</td><td>What happens</td><td>Punishment</td><td>Fine, at most</td></tr></thead>
+                  <thead><tr><td>Amount</td><td>What happens</td><td>Punishment</td><td>Fine</td></tr></thead>
                   <tbody>
                     {seg.penalties.rows.map((r) => (
@@ -357,5 +357,5 @@ const culture = cultureOf(c);
                         <td data-label="What happens"><span class={`pen-t s-${TREATMENT[r.treatment].tone}`} title={TREATMENT[r.treatment].help}>{TREATMENT[r.treatment].label}</span>{r.term && <span class="pen-note">{r.term}</span>}</td>
                         <td data-label="Punishment" class:list={{ "pen-death": r.death }}>{punishmentText(r) ?? "—"}{punishmentExtra(r) && <span class="pen-note">{punishmentExtra(r)}</span>}</td>
-                        <td data-label="Fine, at most">{r.fine ? money(r.fine, seg.penalties.currency) : "—"}</td>
+                        <td data-label="Fine">{r.fine && r.fineMin ? `${money(r.fineMin, seg.penalties.currency)} – ${money(r.fine, seg.penalties.currency)}` : r.fine ? `Up to ${money(r.fine, seg.penalties.currency)}` : r.fineMin ? `At least ${money(r.fineMin, seg.penalties.currency)}` : "—"}</td>
                       </tr>
                     ))}

src/content.config.ts +2 −1
@@ -41,9 +41,10 @@ const penalty = z.object({
   death: z.literal(true).optional(),
   fine: z.number().positive().optional(),
+  fine_min: z.number().positive().optional(), // a minimum fine; alone it reads "at least"
   note: z.string().min(1).optional(),
 }).strict().refine((r) => r.upto || r.over || r.label, { message: "a penalty step needs upto, over or a label" });
 // a segment: its cited text, and for possession and cultivation optionally the penalty steps
 const segment = cited.extend({ currency: z.string().regex(/^[A-Z]{3}$/).optional(), penalties: z.array(penalty).min(1).optional() })
-  .refine((s) => !s.penalties?.some((r) => r.fine) || s.currency, { message: "penalties with a fine need a currency" });
+  .refine((s) => !s.penalties?.some((r) => r.fine || r.fine_min) || s.currency, { message: "penalties with a fine need a currency" });
 // a country spotlight: one thing that makes cannabis different there, with a short title
 const spotlight = z.object({ title: z.string().min(1), text: z.string().min(1), sources: z.array(z.string()) }).strict();

src/lib/penalties.js +1 −0
@@ -48,4 +48,5 @@ export function penaltyRow(row) {
     death: row.death === true,
     fine: row.fine ?? null,
+    fineMin: row.fine_min ?? null,
     note: row.note ?? null,
   };

src/lib/segment-ids.js +3 −2
@@ -67,4 +67,5 @@ export const INDUSTRY_STATUS = {
   pilot: { label: "Pilots only", tone: "decrim", help: "Legal supply for adults exists only in trials or experiments; elsewhere only medical production is licensed" },
   medical: { label: "Medical only", tone: "partial", help: "Licensed production exists for medicine, research or export, but not for adults' recreational use" },
+  hemp: { label: "Hemp only", tone: "partial", help: "Only low-THC hemp may be grown or processed under licence; no cannabis is produced legally for medicine or adults" },
   none: { label: "None", tone: "illegal", help: "No legal cannabis production of any kind" },
 };
@@ -92,6 +93,6 @@ export const STATUS_SECTIONS = {
     sub: "Green means plants with up to 1% THC count as hemp, light green the usual 0.3% line, orange a stricter line, red no exception for hemp at all.",
     note: "The THC limit under which a cannabis plant counts as legal hemp." },
-  production: { name: "Cannabis industry", chip: "Industry", statuses: INDUSTRY_STATUS, tiers: { open: 5, state: 4, pilot: 3, medical: 2, none: 0 }, optional: true,
-    sub: "Green means a licensed market for adults, light green a state-run or pilot supply, orange licensed production for medicine only, red no legal production.",
+  production: { name: "Cannabis industry", chip: "Industry", statuses: INDUSTRY_STATUS, tiers: { open: 5, state: 4, pilot: 3, medical: 2, hemp: 1, none: 0 }, optional: true,
+    sub: "Green means a licensed market for adults, light green a state-run or pilot supply, orange licensed production for medicine only, darker orange licensed hemp only, red no legal production.",
     note: "Who may produce cannabis legally for sale." },
   research: { name: "Cannabis research", chip: "Research", statuses: RESEARCH_STATUS, tiers: { open: 5, licensed: 4, restricted: 2, blocked: 0 }, optional: true,

    ↑↓ to move, ↵ to open.