Cambodia countries/kh.yaml
access 1 → 0
no
Prison
Possession carries two to five years in prison, and a campaign since 2017 has brought mass arrests and detention
Changelog 0b03109
The five quick answers were read straight off the segment scores, and a score band holds places whose law reads differently. Cambodia, with no medical programme, was told it could buy on prescription (#121); the Netherlands was told possession is "not a crime" and sales go through "clubs or state outlets" (#122); Italy that use itself is an offence and being caught means a fine (#123). - Each band's generic answer now says only what holds for every profile in it, checked against all of them: no legal category or sales channel the band does not guarantee. - A step can depend on a status: no prescription answer without a medical programme. - A profile can state an answer itself under `answers`, worded from its own text: added for the Netherlands, Italy, Cambodia, India, Spain, Germany, Thailand, Jamaica, Malta, Uruguay, Antigua, Belize, Switzerland, Colombia, the United States, DC and Quebec. - scripts/answers.test.mjs checks every answer against its profile (and the three reported cases); it runs before every deploy. - Cambodia's access score goes from 1 to 0: its profile and the 2012 law describe no legal source (13 -> 12; the 1996 ban's step grows by one). Norway's access text now names the medical route its score of 1 stands for. - docs/DATA-MAINTENANCE.md: a Quick answers section. Claude-Session: https://claude.ai/code/session_015PFhJhHBpXsnUocs8vvYAd
1 score changed17 country profiles edited10 files of code, design or docs +319 −38 lines
access 1 → 0
no
Prison
Possession carries two to five years in prison, and a campaign since 2017 has brought mass arrests and detention
yes
Up to 15 g is not an offence; more is a crime
limited
Up to 10 g is not an offence, except at a school; more is a crime
yes
Only in the province's SQDC stores
limited
Up to 10 g is not punishable and cannot be confiscated
limited
Up to 20 g, or 5 g of hashish, cannot be punished; police may still confiscate it in some public places
limited
Only as a resident member of a non-profit cultivation association, or on prescription
limited
No shops; private cannabis social clubs supply their members in a tolerated grey zone
limited
Only bhang, in government-licensed shops in some states; flower and resin have no legal source
limited
Not a crime, but it can cost you your driving licence or passport for a few months
limited
Licence suspension
Your driving licence, passport or firearms licence can be suspended for one to three months, or a first small case can end in a formal warning
limited
From licensed sellers, with a doctor's recommendation; visitors can apply for a permit
yes
Up to 7 g in public and 50 g at home; more in public brings a fine
limited
Only as a resident member of a licensed non-profit association; tourists have no legal source
limited
Illegal, but up to 5 g is not prosecuted; police may still seize it
limited
In tolerated coffeeshops, up to 5 g; some towns sell to residents only, and many have no coffeeshop
No legal source for adults: section 231 of the Penal Code makes it an offence to acquire, store, import or hand over a narcotic unlawfully. The only lawful route is medical: two approved prescription medicines, plus case-by-case specialist applications for other products since 2018.
strl, wiki
limited
From licensed outlets, with a prescription from the practitioner on site
limited
No adult-use shops, but adults can self-certify as medical patients and buy from dispensaries
yes
In licensed dispensaries in the states that allow it, to adults 21+
limited
Only registered citizens and residents, at pharmacies or clubs; tourists are excluded
.github/workflows/deploy.yml +3 @@ -32,4 +32,7 @@ jobs: uses: actions/configure-pages@v6 - run: npm ci + # Every profile's quick answers ("Can I carry it?") against its own text: a contradiction + # stops the deploy instead of going live (scripts/answers.test.mjs). + - run: npm run answers:test # The social preview images (one per page) take minutes to draw and rarely change. Each is # kept under a hash of its own content, so restoring the folder redraws only what is new.
docs/DATA-MAINTENANCE.md +37 @@ -421,4 +421,41 @@ view follows from that. The profile block is written for each dataset in `src/components/CountryDatasets.astro`. +## Quick answers + +Every profile shows five plain answers ("Can I carry it?", "Can I buy it?", …) on its page, on +`/can-i/` and in the page's structured data for search engines. `src/lib/answers.js` reads them +off the segment scores, but a score band holds places whose law reads differently: possession 3 is +both "decriminalised" (Portugal) and "a crime that is not prosecuted" (the Netherlands), access 4–5 +is a pharmacy, a club, a state shop or a coffeeshop. So: + +- **The generic answer of a band says only what is true of every place in it.** Never a legal + category ("not a crime", "decriminalised") or a channel ("clubs or state outlets") that the band + does not guarantee. When you change a band's wording, read every profile in it: + `node scripts/answers.test.mjs --list carry`. +- **A step can depend on a status.** A place with `medical: none` is never told it can buy on + prescription, whatever its access score. +- **Where the band cannot say it right, the profile says it**, under `answers`, worded from its own + segment text and nothing else: + + ```yaml + answers: + carry: + answer: limited # yes | limited | no + text: Illegal, but up to 5 g is not prosecuted; police may still seize it + caught: + answer: limited + label: Licence suspension # "What if I'm caught?" needs its own label + text: Your driving licence or passport can be suspended for one to three months + ``` + + Questions: `carry` (possession), `grow` (cultivation), `buy` (access), `public` (consumption), + `caught` (enforcement). When the law changes, update the answer with the segment. +- **`npm run answers:test`** checks every answer against its profile: prescription without a + medical programme, "legal" where recreational cannabis is not, "use itself is a crime" where the + text says use is not an offence, a fine where the text says there is none, and so on. It runs + before every deploy. A failure means the answer, the text or the score is wrong: fix whichever + it is, never the check. If the score is what is wrong, change it with its timeline (see "The + score timeline must make sense"). + ## How a history entry works
package.json +1 @@ -13,4 +13,5 @@ "books:test": "node --test scripts/books-status.test.cjs", "history:test": "node --test scripts/history-scope.test.cjs", + "answers:test": "node --test scripts/answers.test.mjs", "articles:backlog": "node scripts/article-backlog.cjs", "scores:triage": "node scripts/score-triage.cjs",
scripts/answers.test.mjs +97 @@ -0,0 +1,97 @@ +// The quick answers ("Can I carry it?", src/lib/answers.js) of every country, state and province, +// checked against the profile's own statuses and text. An answer is read from a score band, and a +// band can hold places whose law reads differently; these checks catch an answer that says what +// the profile's text denies. Fix such a case with an `answers` entry in the profile, worded from +// its own rules (see "Quick answers" in docs/DATA-MAINTENANCE.md), or fix the score. +// npm run answers:test +// node scripts/answers.test.mjs --list [carry|grow|buy|public|caught] every answer, for review +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import yaml from "js-yaml"; +import { answersFor } from "../src/lib/answers.js"; +import { SEGMENT_IDS } from "../src/lib/segment-ids.js"; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "countries"); +// the codes countries.js gives the statuses +const REC = { legal: "legal", partial: "partial", decriminalised: "decrim", illegal: "illegal", death: "death" }; +const MED = { legal: "yes", limited: "limited", none: "no" }; + +const files = fs.readdirSync(root).flatMap((f) => { + const p = path.join(root, f); + return fs.statSync(p).isDirectory() ? fs.readdirSync(p).filter((g) => g.endsWith(".yaml")).map((g) => path.join(p, g)) : f.endsWith(".yaml") ? [p] : []; +}); +const places = files.map((file) => { + const d = yaml.load(fs.readFileSync(file, "utf8")); + const id = path.relative(root, file).replace(/(\/index)?\.yaml$/, ""); + // a country's segment is { text, sources }; a state's is its text alone + const text = Object.fromEntries(SEGMENT_IDS.map((seg) => [seg, String(d.segments[seg]?.text ?? d.segments[seg] ?? "").replace(/\s+/g, " ")])); + const place = { id, name: d.name, s: SEGMENT_IDS.map((seg) => d.scores[seg]), rec: REC[d.recreational], med: MED[d.medical], answers: d.answers ?? null }; + return { ...place, text, result: Object.fromEntries(answersFor(place).map((x) => [x.id, x])) }; +}); +const placeOf = (id) => places.find((p) => p.id === id); +const said = (x) => `${x.label} — ${x.text}`; + +// Each rule: a question, what about its answer and the profile cannot both be true, and why. +const RULES = [ + { q: "buy", why: "says prescription, but the profile has no medical programme", + bad: (p, x) => /prescription/i.test(x.text) && p.med === "no" }, + { q: "buy", why: "says prescription, but the access text says there is no legal source", + bad: (p, x) => /prescription/i.test(x.text) && /\bno (legal|lawful) (source|supply|route)\b/i.test(p.text.access) && !/prescri|medic|pharmac/i.test(p.text.access) }, + { q: "buy", why: "names licensed shops, but recreational cannabis is not legal", + bad: (p, x) => /^in licensed shops/i.test(x.text) && p.rec !== "legal" }, + { q: "carry", why: "says legal, but recreational cannabis is not legal", + bad: (p, x) => x.answer === "yes" && p.rec !== "legal" }, + { q: "carry", why: "says possession is not a crime, but the possession text calls it one", + bad: (p, x) => /not a crime/i.test(x.text) && /\b(remains|is still|is) (formally )?(illegal|a (criminal )?offen[cs]e|a crime)\b/i.test(p.text.possession) }, + { q: "carry", why: "says small amounts are not prosecuted, but the possession text says they are", + bad: (p, x) => /not prosecuted/i.test(x.text) && /\b(routinely|regularly|usually|still) (prosecuted|charged|jailed)\b/i.test(p.text.possession) }, + { q: "grow", why: "says growing is legal, but the cultivation text says it is not", + bad: (p, x) => x.answer === "yes" && /\b(not legal|illegal|is a crime|criminal offen[cs]e)\b/i.test(p.text.cultivation) && !/\b(legal|allowed|permitted|may grow)\b/i.test(p.text.cultivation.replace(/\b(not legal|illegal)\b/gi, "")) }, + { q: "public", why: "calls use itself an offence, but the consumption text says it is not", + bad: (p, x) => /use itself/i.test(x.text) && /\b(use|using|consumption)( itself)? is not (an? )?(criminal )?(offen[cs]e|crime|illegal)\b/i.test(p.text.consumption) }, + { q: "caught", why: "mentions a fine, but the profile says there is no fine", + bad: (p, x) => /\bfine\b/i.test(said(x)) && /\b(there is )?no fine\b/i.test(`${p.text.possession} ${p.text.enforcement}`) }, +]; + +if (process.argv.includes("--list")) { + const only = process.argv[process.argv.indexOf("--list") + 1]; + for (const p of places) for (const x of Object.values(p.result)) { + if (only && x.id !== only) continue; + console.log(`${p.id.padEnd(8)} ${x.id.padEnd(7)} ${String(p.s[SEGMENT_IDS.indexOf(x.seg)]).padEnd(3)} ${(p.rec + "/" + p.med).padEnd(14)} ${x.own ? "own " : ""}${said(x)}`); + } + process.exit(0); +} + +test("every answer agrees with its profile", () => { + const wrong = places.flatMap((p) => RULES.filter((r) => r.bad(p, p.result[r.q])).map((r) => `${p.id} (${p.name}), ${r.q}: "${said(p.result[r.q])}" ${r.why}`)); + assert.deepEqual(wrong, []); +}); + +test("a profile's own answers are used as written", () => { + for (const p of places.filter((x) => x.answers)) { + for (const [q, own] of Object.entries(p.answers)) { + assert.equal(p.result[q].text, own.text, `${p.id} ${q}`); + assert.equal(p.result[q].own, true, `${p.id} ${q}`); + } + } +}); + +// The cases reported in issues #121, #122 and #123. +test("Cambodia, with no medical programme, is not told it can buy on prescription", () => { + assert.equal(placeOf("kh").result.buy.answer, "no"); + assert.doesNotMatch(placeOf("kh").result.buy.text, /prescription/i); +}); +test("the Netherlands: possession is not called legal or decriminalised, and buying names coffeeshops", () => { + const nl = placeOf("nl").result; + assert.doesNotMatch(said(nl.carry), /not a crime|\blegal\b|decriminali/i); + assert.doesNotMatch(said(nl.buy), /clubs|state outlets/i); + assert.match(nl.buy.text, /coffeeshop/i); +}); +test("Italy: use is not called an offence, and being caught does not mean a fine", () => { + const it = placeOf("it").result; + assert.doesNotMatch(it.public.text, /use itself/i); + assert.doesNotMatch(said(it.caught), /\bfine\b/i); +});
src/components/CountryArticle.astro +2 @@ -73,5 +73,5 @@ const travel = sub ? null : [ ].filter(Boolean); const incbRef = travel ? ctxRefs.cite(INCB) : null; // the crossing-the-border note cites one guide -const answers = answersFor(v.s); +const answers = answersFor(v); // The methodology lives on the home page: its section, or the description of one segment there. const method = (segId) => `${base}#${segId ? `method-${segId}` : "methodology"}`; @@ -168,5 +168,5 @@ const culture = cultureOf(c); ))} </ul> - <p class="d-hint">A rough guide read from the scores below, not legal advice. The details and the exceptions are in each segment: select an answer to jump to it.</p> + <p class="d-hint">A rough guide read from the scores and the rules below, not legal advice. The details and the exceptions are in each segment: select an answer to jump to it.</p> </div>
src/content.config.ts +12 @@ -43,4 +43,14 @@ const status = { }; +// The quick answers a profile states itself, where its scores would say it wrong (src/lib/answers.js): +// { carry: { answer: limited, text: "…" }, … }. "What if I'm caught?" is not a yes/no question, +// so its answer also needs a `label` ("A penalty", "Prison"). +const answerOf = z.object({ answer: z.enum(["yes", "limited", "no"]), label: z.string().min(1).optional(), text: z.string().min(1) }).strict(); +const answers = z + .object({ carry: answerOf.optional(), grow: answerOf.optional(), buy: answerOf.optional(), public: answerOf.optional(), caught: answerOf.optional() }) + .strict() + .refine((a) => !a.caught || a.caught.label, { message: 'answers.caught needs a label ("Nothing", "A penalty", …)' }) + .optional(); + // countries/de.yaml, or countries/us/index.yaml for a federation → id "de", "us" const countries = defineCollection({ @@ -72,4 +82,5 @@ const countries = defineCollection({ summary: z.string().min(1), scores: perSegment(score), + answers, segments: perSegment(cited), // the four standard sections, plus `death_penalty` where the law provides for one and, @@ -118,4 +129,5 @@ const units = defineCollection({ summary: z.string().min(1), scores: perSegment(score), + answers, segments: perSegment(z.string().min(1)), sources: z.array(source),
src/lib/answers.js +56 @@ -1,5 +1,16 @@ -// Five plain-language answers per country, read straight off the segment scores. -// Scores are in the order of segments.yaml: possession, cultivation, enforcement, -// sharing, consumption, access. See segments.yaml for what each level means. +// Five plain-language answers per country, state or province, on its profile, on /can-i/ and in +// the structured data of its page. +// +// They are read off the segment scores (possession, cultivation, enforcement, sharing, consumption, +// access; see segments.yaml for what each level means), but a score is coarse: one band can hold +// places whose law reads quite differently. Possession 3, for instance, is both "decriminalised" +// (Portugal) and "a crime that is not prosecuted" (the Netherlands). So: +// - each step says only what is true of every place in its band, never a legal category the +// band does not guarantee; +// - a step can ask for a fact the score does not hold (`when`, on the place's statuses), and is +// skipped otherwise: a place with no medical programme is never told "on prescription"; +// - where the band still cannot say it right, the profile states the answer itself, under +// `answers` in its YAML file (see "Quick answers" in docs/DATA-MAINTENANCE.md). +// scripts/answers.test.mjs checks every profile's answers against its own text. import { SEGMENT_IDS } from "./segment-ids.js"; @@ -7,46 +18,61 @@ import { SEGMENT_IDS } from "./segment-ids.js"; export const ANSWER_LABEL = { yes: "Yes", limited: "Limited", no: "No" }; -const pick = (score, steps) => steps.find(([min]) => score >= min).slice(1); +// `p` in `when` is the place: { rec, med } as in countries.js ("legal", "partial", "decrim", +// "illegal", "death"; "yes", "limited", "no"). +const hasMedical = (p) => p.med !== "no"; export const QUESTIONS = [ { id: "carry", q: "Can I carry it?", seg: 0, steps: [ - [5, "yes", "Legal up to a limit"], - [3, "limited", "Not a crime, but you can be fined"], - [0, "no", "It is a crime"], + { min: 5, answer: "yes", text: "Legal up to a limit", when: (p) => p.rec === "legal" }, + // Decriminalised (an administrative fine, or a heavy one), legal at home only, or a crime that + // is not prosecuted: all of these sit here, so the answer names none of them. + { min: 3, answer: "limited", text: "Usually no criminal charge for a small amount, but it can still be seized or penalised" }, + // an administrative fine, a crime, or a judge deciding whether it was for personal use + { min: 2, answer: "no", text: "Even a small amount can bring a fine or a charge" }, + { min: 0, answer: "no", text: "It is a crime" }, ] }, { id: "grow", q: "Can I grow it?", seg: 1, steps: [ - [4, "yes", "A few plants at home are legal"], - [2, "limited", "A few plants are tolerated"], - [0, "no", "It is a crime"], + { min: 4, answer: "yes", text: "A few plants at home are legal" }, + // tolerated, an administrative fine, or a lesser crime: all of these sit here + { min: 2, answer: "limited", text: "A few plants are treated far more leniently than dealing" }, + { min: 0, answer: "no", text: "It is a crime" }, ] }, { id: "buy", q: "Can I buy it?", seg: 5, steps: [ - [6, "yes", "In licensed shops"], - [4, "yes", "Through clubs or state outlets"], - [3, "limited", "Tolerated shops or clubs, or on prescription"], - [1, "limited", "On prescription only"], - [0, "no", "There is no legal source"], + { min: 6, answer: "yes", text: "In licensed shops", when: (p) => p.rec === "legal" }, + // Clubs, pharmacies, state outlets or tolerated shops: which one is the profile's to say, in + // its own answer. A 3 is also a working medical route, or tolerated clubs (Spain). + { min: 4, answer: "yes", text: "From a legal or tolerated source, with limits" }, + { min: 3, answer: "limited", text: "On prescription, without great difficulty", when: hasMedical }, + { min: 1, answer: "limited", text: "On prescription only", when: hasMedical }, + { min: 0, answer: "no", text: "There is no legal source" }, ] }, { id: "public", q: "Can I use it in public?", seg: 4, steps: [ - [7, "yes", "Wherever smoking is allowed"], - [5, "limited", "In some places, with many restrictions"], - [3, "no", "In private only"], - [0, "no", "Use itself is an offence"], + { min: 7, answer: "yes", text: "Wherever smoking is allowed" }, + { min: 5, answer: "limited", text: "In some places, with many restrictions" }, + { min: 3, answer: "no", text: "In private only" }, + // Using it is not always an offence in itself (Italy), and private use is sometimes left alone + // (Belgium), but nowhere in this band does the law protect it. + { min: 1, answer: "no", text: "Not in public, and even at home the law does not protect it" }, + { min: 0, answer: "no", text: "Use itself is a crime" }, ] }, // Not a yes/no question, so each step carries its own short label. { id: "caught", q: "What if I'm caught?", seg: 2, steps: [ - [6, "yes", "If you stay within the limits", "Nothing"], - [4, "limited", "Or a referral to treatment", "A fine"], - [2, "limited", "Usually a fine; prison is possible", "Fine or prison"], - [1, "no", "Users are routinely jailed", "Prison"], - [0, "no", "Long sentences or worse", "Severe punishment"], + { min: 6, answer: "yes", text: "If you stay within the limits", label: "Nothing" }, + // fines, tickets, diversion, suspended documents, or nothing below a threshold: all sit here + { min: 4, answer: "limited", text: "For a small amount, usually a fine, a referral to treatment or nothing at all; prison is rare", label: "A light penalty" }, + { min: 2, answer: "limited", text: "Usually a fine; prison is possible", label: "Fine or prison" }, + { min: 1, answer: "no", text: "Users are routinely jailed", label: "Prison" }, + { min: 0, answer: "no", text: "Long sentences or worse", label: "Severe punishment" }, ] }, ]; -// `s` is the array of six segment scores of a country, state or province. -// `seg` in the result is the id of the segment the answer was read from. -export const answersFor = (s) => +// `place` is a country, state or province as countries.js builds it: its six scores `s`, `rec`, +// `med`, and `answers`, the answers its profile states itself ({ carry: { answer, text, label? } }). +// `seg` in the result is the id of the segment the answer was read from; `own` marks an answer +// that the profile states rather than one read from the score. +export const answersFor = (place) => QUESTIONS.map(({ id, q, seg, steps }) => { - const [answer, text, label = ANSWER_LABEL[answer]] = pick(s[seg], steps); - return { id, q, answer, text, label, seg: SEGMENT_IDS[seg] }; + const own = place.answers?.[id]; + const step = own ?? steps.find((x) => place.s[seg] >= x.min && (!x.when || x.when(place))); + return { id, q, answer: step.answer, text: step.text, label: step.label ?? ANSWER_LABEL[step.answer], seg: SEGMENT_IDS[seg], own: !!own }; }); -
src/lib/countries.js +2 @@ -98,4 +98,5 @@ const everyPlace = (await getCollection("countries")) rec: REC_CODE[data.recreational], med: MED_CODE[data.medical], + answers: data.answers ?? null, // quick answers the profile states itself (answers.js) note: data.summary, details: { @@ -158,4 +159,5 @@ export function subunitsOf(id) { rec: REC_CODE[data.recreational], med: MED_CODE[data.medical], + answers: data.answers ?? null, note: data.summary, reviewed: isoDate(data.updated),
src/lib/seo.js +1 @@ -37,5 +37,5 @@ export function countrySeo(c, site) { mainEntity: [ { q: `Is weed legal ${where}?`, a: `${REC_LABEL[c.rec]}: ${REC_HELP[c.rec].toLowerCase()}. ${c.note}` }, - ...answersFor(c.s).map((x) => ({ q: QUESTION[x.id](where), a: `${x.label}. ${x.text}. ${c.details.segments[SEGMENT_IDS.indexOf(x.seg)].t}` })), + ...answersFor(c).map((x) => ({ q: QUESTION[x.id](where), a: `${x.label}. ${x.text}. ${c.details.segments[SEGMENT_IDS.indexOf(x.seg)].t}` })), ].map(({ q, a }) => ({ "@type": "Question", name: q, acceptedAnswer: { "@type": "Answer", text: a } })), };
src/pages/can-i.astro +2 @@ -13,5 +13,5 @@ const rows = [...places] c, // Each answer also carries the explanation of the segment it was read from, for the tooltip. - answers: answersFor(c.s).map((x) => { + answers: answersFor(c).map((x) => { const i = SEGMENT_IDS.indexOf(x.seg); return { ...x, name: SEGMENTS[i].name, score: c.s[i], why: c.details?.segments[i]?.t ?? "" }; @@ -24,5 +24,5 @@ const rows = [...places] <p class="eyebrow">Can I?</p> <h1>Five plain answers for every country</h1> - <p class="lede">Can you carry it, grow it, buy it or use it in public, and what happens if you are caught? The answers are read from each country's scores, so they are a rough guide, not legal advice. Open a country for the details.</p> + <p class="lede">Can you carry it, grow it, buy it or use it in public, and what happens if you are caught? The answers are read from each country's scores and rules, so they are a rough guide, not legal advice. Open a country for the details.</p> </section>