#!/usr/bin/env python3 # --------------------------------------------------------------------------- # PUBLISHED COPY — supplementary material for: # "Does BMI Predict All 11 Food Addiction Criteria?" # https://creativetouchrotherham.co.uk/lab/insights/food-addiction-criteria-bmi # # Creative Touch, Rotherham. Released under CC BY 4.0 # (https://creativecommons.org/licenses/by/4.0/), the same licence as the # article and its figures. # # Published verbatim. This is the file as it was committed and run; no line has # been changed for publication. # # This script never touches the database. It reads only the aggregated results # JSON written by `analyse.py`, which is why every plotted coordinate in the # article traces back to a figure in that file rather than to a fresh # computation. Reading it alongside the figures is the most direct way to check # that no chart says anything the analysis did not. # # Requires Python 3.12 or newer (it uses backslashes inside f-string # expressions, PEP 701). Unlike `extract.py` and `analyse.py`, which ran in the # Python 3.10 venv recorded in their headers, this script ran under Python # 3.13. It has no statistical dependencies, so the split is immaterial to the # results; it is recorded here only so that a SyntaxError on an older # interpreter is not mistaken for a defect. # --------------------------------------------------------------------------- """Generate the Phase 3 article figures as self-contained inline SVG / HTML. Reads the canonical analysis JSON so every plotted coordinate traces back to the committed run rather than to a number typed out of the prose. python make_figures.py # writes ../figures/*.html python make_figures.py --check # re-generate and diff against what is on disk Palette: brand hues (plum 314.6 deg, gold 67.1 deg from _root.scss) snapped to the nearest steps that clear the dataviz skill's six checks against the site surface #fdfcfc: adjacent CVD dE 17.9 (target 8), normal-vision dE 18.1 (floor 15), contrast 8.3:1 and 6.6:1. Set identity is never carried by colour alone: marker shape, banding and direct labels all repeat it. Re-validate with (there is no Node.js here, so the bundled dataviz skill's .js validator will not run -- use the Python port): python .claude/skills/creative-touch-dataviz/scripts/validate_palette.py \ "#653c77,#845002" --mode light --surface "#fdfcfc" Palette parameters, figure conventions and the ordinal ramp are documented in the creative-touch-dataviz skill. """ from __future__ import annotations import argparse import hashlib import json import math import re import sys from pathlib import Path HERE = Path(__file__).resolve().parent CANONICAL = HERE / "results" / "analysis_20260726-121449.json" OUTDIR = HERE.parent / "figures" SVGDIR = HERE.parent / "svgs" # Standalone .svg files are FTP'd to the live site under this path, and contentUrl is # built from it. Keep the filenames stable once published: they become public URLs. LIVE_IMAGE_BASE = "https://creativetouchrotherham.co.uk/trongate_pages_module/images/uploads/195/" CREDIT_TEXT = "Creative Touch" COPYRIGHT_NOTICE = "© Creative Touch" CREATOR_NAME = "Creative Touch" # CC BY 4.0 on the figures only. This is only consistent because section 2 of the Image # Usage Policy carves research figures out of the site's default all-rights-reserved, # non-commercial stance; without that carve-out this property would grant in machine- # readable form exactly what the linked policy refuses. If the policy changes, change # this with it. LICENSE_URL = "https://creativecommons.org/licenses/by/4.0/" ACQUIRE_LICENSE_PAGE = "https://creativetouchrotherham.co.uk/image-policy" # The source article the standalone files point back to. Attribution under CC BY needs a # link to the source, so a redistributed .svg carries this internally as dc:source. ARTICLE_URL: str | None = "https://creativetouchrotherham.co.uk/lab/insights/food-addiction-criteria-bmi" # figure id -> published filename. fig-criteria-table is deliberately absent: it is an # HTML , not an image, so it gets no file and no ImageObject. PUBLISHED_SVG = { "fig-exclusions": "creative-touch-yfas-exclusion-funnel-v1.svg", "fig-bmi-gradient": "creative-touch-yfas-bmi-symptom-gradient-v1.svg", "fig-conditioning": "creative-touch-yfas-conditioning-slope-v1.svg", "fig-forest": "creative-touch-yfas-criterion-forest-v1.svg", "fig-c7-mapping": "creative-touch-yfas-criterion-7-mapping-v1.svg", } CC = "#653c77" # content-confounded (brand plum hue, snapped) CORE = "#845002" # pharmacological core (brand gold hue, snapped) NEITHER = "#6f6d6d" # reported, not adjudicating H1 INK = "#080808" # --flowColor MUTED = "#5a5858" RULE = "#c9c7c7" SURFACE = "#fdfcfc" FONT = "var(--flowFont, 'Figtree', system-ui, sans-serif)" SERIF = "var(--formalSerifFont, 'EB Garamond', Georgia, serif)" SET_STYLE = { "content_confounded": (CC, "circle", "Content-confounded"), "pharmacological_core": (CORE, "square", "Pharmacological core"), "neither": (NEITHER, "hollow", "Neither set"), } # Plain-English labels, kept consistent with the article body wording. LABELS = { 1: "Ate larger amounts, or for longer, than intended", 2: "Persistent desire, or repeated failed attempts, to cut down", 3: "A great deal of time spent obtaining, eating or recovering", 4: "Important activities given up because of eating", 5: "Continued eating despite knowing it was causing harm", 6: "Tolerance (needing more for the same effect)", 7: "Withdrawal", 8: "Interpersonal problems caused by how much you eat", 9: "Failure to fulfil role obligations because of overeating", 10: "Hazardous use (eating when it was physically dangerous)", 11: "Craving", } SHORT = { 1: "C1 larger amounts", 2: "C2 failed cut-down", 3: "C3 time spent", 4: "C4 activities given up", 5: "C5 use despite harm", 6: "C6 tolerance", 7: "C7 withdrawal", 8: "C8 interpersonal", 9: "C9 role failure", 10: "C10 hazardous use", 11: "C11 craving", } def esc(s: str) -> str: return (s.replace("&", "&").replace("<", "<").replace(">", ">") .replace('"', """).replace("'", "'")) def fmt(x: float, dp: int = 3) -> str: return f"{x:.{dp}f}" def fmt_q(q: float) -> str: if q < 0.0001: return "< 0.0001" return f"{q:.4f}".rstrip("0").rstrip(".") def load(): d = json.loads(CANONICAL.read_text(encoding="utf-8")) power = {c["criterion_id"]: c for c in d["a_power"]["criteria"]} uncond = {c["criterion_id"]: c for c in d["d_unconditioned_descriptive"]["criteria"]} rows = [] for c in d["c_secondary_per_criterion"]["criteria"]: i = c["criterion_id"] rows.append({ "id": i, "set": c["criterion_set"], "endorse": power[i]["endorsement_rate_pct"], "uncond_or": uncond[i]["or_per_5"], "or": c["or_per_5"], "lo": c["ci_lo_per_5"], "hi": c["ci_hi_per_5"], "q": c["q_value_bh"], "sig": c["significant_bh_q05"], }) return d, rows # ── SVG helpers ─────────────────────────────────────────────────────────────── def marker(x: float, y: float, kind: str, colour: str, r: float = 5.5, emphasis: bool = False) -> str: out = [] if emphasis: out.append(f'') if kind == "square": s = r * 1.78 out.append(f'') elif kind == "hollow": out.append(f'') else: out.append(f'') return "".join(out) def decollide(ys: list[float], min_gap: float, lo: float, hi: float) -> list[float]: """Nudge label positions apart so none overlap, staying as near the anchor as possible. Input must already be sorted top-to-bottom. Sweeps down enforcing the gap, then back up off the bottom bound, so the block stays inside [lo, hi] where it can. """ out = list(ys) for i in range(1, len(out)): if out[i] - out[i - 1] < min_gap: out[i] = out[i - 1] + min_gap if out and out[-1] > hi: out[-1] = hi for i in range(len(out) - 2, -1, -1): if out[i + 1] - out[i] < min_gap: out[i] = out[i + 1] - min_gap if out and out[0] < lo: shift = lo - out[0] out = [v + shift for v in out] return out def svg_open(w: int, h: int, title: str, desc: str, min_w: int) -> str: # Deterministic ids. Python's hash() is salted per process, so using it here # would change the markup on every run and defeat --check. key = hashlib.md5(title.encode("utf-8")).hexdigest()[:8] return ( f'' f'{esc(title)}' f'{esc(desc)}' ) def figure_wrap(inner: str, caption: str, fid: str) -> str: return ( f'
\n{inner}\n' f'
{caption}
\n
' ) def _svg_parts(fig_html: str): """Pull the svg element, its title, desc and viewBox out of a generated figure.""" svg = re.search(r"", fig_html, re.S) if not svg: return None body = svg.group(0) title = re.search(r"]*>(.*?)", body, re.S) desc = re.search(r"]*>(.*?)", body, re.S) vb = re.search(r'viewBox="0 0 (\d+) (\d+)"', body) return { "svg": body, "span": svg.span(), "title": title.group(1) if title else "", "desc": desc.group(1) if desc else "", "w": int(vb.group(1)) if vb else 0, "h": int(vb.group(2)) if vb else 0, } def standalone_svg(fig_html: str) -> str | None: """Turn an inline figure SVG into a self-contained .svg file. Three changes are needed. The inline version sizes itself with CSS and inherits the page's font custom property and background; a standalone file has none of those, so it gets explicit width/height, a concrete font stack in place of the var(), and an opaque background rect. Without the last one the 2px surface rings on overlapping marks read as near-white holes over whatever the host background happens to be. """ p = _svg_parts(fig_html) if not p: return None body = p["svg"] body = re.sub(r'\sstyle="[^"]*"', "", body, count=1) body = re.sub(r"{ARTICLE_URL}" if ARTICLE_URL else "") meta = ( '' '' f'\n ' f'\n {p["title"]}' f'\n {p["desc"]}' f'\n {CREATOR_NAME}' f'\n {CREATOR_NAME}' f'\n image/svg+xml' f'\n ' f'{src}' f'\n ' f'\n ' f'\n ' ) bg = f'' if "" in body: body = body.replace("", "" + meta + bg, 1) else: body = re.sub(r"(]*>)", r"\1" + meta + bg, body, count=1) return '\n' + body + "\n" def add_image_microdata(fig_html: str, fid: str) -> str: """Attach ImageObject microdata pointing at the published standalone file. Only for figures that really are images. fig-criteria-table is an HTML
; typing it as an ImageObject would be a category error, so it is left alone. """ filename = PUBLISHED_SVG.get(fid) if not filename: return fig_html p = _svg_parts(fig_html) if not p: return fig_html fig_html = fig_html.replace( f'
, not : microdata reads the value # of a as a plain string but the value of a as a resolved URL, so a URL # in a content attribute lands as a string literal where a URL node belongs. is # valid in the body wherever phrasing content is expected, given an itemprop. # Text- and number-typed properties (encodingFormat is a MIME string, width/height are # numbers) correctly stay as . meta = ( f'\n' f'\n' f'\n' f'\n' f'\n' f'\n' f'\n' f'\n' f'\n' f'\n' f'\n' f'' ) fig_html = re.sub(r"(]*>)", lambda m: m.group(1) + meta, fig_html, count=1) fig_html = fig_html.replace("
str: th = "".join(f'
' for h in headers) trs = [] for r in rows: tds = "".join(f'' for c in r) trs.append(f"{tds}") return ( f'
' f'{summary}' f'
{h}{c}
{th}{"".join(trs)}
' f'' ) # ── Figure 1 — criterion reference table ────────────────────────────────────── def fig1(rows) -> str: order = ([r for r in rows if r["set"] == "content_confounded"] + [r for r in rows if r["set"] == "pharmacological_core"] + [r for r in rows if r["set"] == "neither"]) body, current = [], None for r in order: if r["set"] != current: current = r["set"] _, _, name = SET_STYLE[current] note = { "content_confounded": "hypothesised stronger BMI association", "pharmacological_core": "hypothesised weaker", "neither": "reported for transparency, not adjudicating the hypothesis", }[current] body.append( f'{name} ' f'({note})' ) dagger = ' ' \ if r["id"] == 7 else "" strong = ' style="font-weight:600"' if r["id"] == 4 else "" body.append( f"" f'{r["id"]}{dagger}' f'{LABELS[r["id"]]}' f'{r["endorse"]:.1f}%' f'' f'{fmt(r["or"])} ({fmt(r["lo"])}–{fmt(r["hi"])})' f'{fmt_q(r["q"])}' f"" ) heads = ["", "Criterion", "Endorsed by", "Odds ratio per 5 BMI units (95% CI)", "q"] th = "".join( f'= 2 else "left"};' f'padding:0 0.9rem 0.4rem 0;border-bottom:1.5px solid {INK};font-weight:600">{h}' for i, h in enumerate(heads)) return ( f'
\n' f'\n' f'\n' f"{th}\n{''.join(body)}
The eleven criteria, their hypothesised set, and how each ' f'behaved once overall severity was held constant
\n" f'
' f'Odds ratios above 1 mean higher BMI predicted endorsing that criterion more often ' f'among respondents of equal overall severity; below 1, less often. q values are ' f'corrected for testing eleven criteria (Benjamini–Hochberg, q = 0.05). ' f'Criterion 4 is shown in bold because it ran significantly counter to its own ' f'hypothesised set. † Criterion 7's measurement was defective throughout ' f'the data collection period, so its row is not clean evidence. n = 1,796.' f"
\n
" ) # ── Figure 2 — forest plot ──────────────────────────────────────────────────── def fig2(rows, d) -> str: lo_ax, hi_ax = 0.78, 1.36 x0, x1 = 320.0, 640.0 sx = lambda v: x0 + (math.log(v) - math.log(lo_ax)) / (math.log(hi_ax) - math.log(lo_ax)) * (x1 - x0) hyp = sorted([r for r in rows if r["set"] != "neither"], key=lambda r: -r["or"]) nei = sorted([r for r in rows if r["set"] == "neither"], key=lambda r: -r["or"]) legend_y, axis_title_y, tick_y, axis_line = 20.0, 54.0, 72.0, 80.0 top, rh = 106.0, 30.0 y = top ylab = [] for r in hyp: ylab.append((r, y)) y += rh gap_y = y - rh + 22 band_label_y = gap_y + 17 y = gap_y + 34 for r in nei: ylab.append((r, y)) y += rh slopes_rule = y - rh + 24 slopes_y = slopes_rule + 22 grid_bottom = slopes_y + 34 height = int(grid_bottom + 34) w = 880 s = [svg_open(w, height, "Odds ratio for endorsing each criterion per 5 BMI units, holding overall severity constant", "Forest plot of eleven criteria ordered by effect size. The five content-confounded " "criteria and three pharmacological-core criteria are shown together at the top, and " "they interleave rather than separating cleanly. Criterion 10 hazardous use is highest " "at 1.187 and criterion 11 craving lowest at 0.870. Criterion 4, activities given up, " "was hypothesised to sit high but sits at 0.875, significantly below 1. The three " "criteria in neither set are shown separately below. Full values are in the " "accompanying data table.", 700)] # axis s.append(f'') for t in (0.8, 0.9, 1.0, 1.1, 1.2, 1.3): tx = sx(t) strong = abs(t - 1.0) < 1e-9 s.append(f'') s.append(f'{t:g}') s.append(f'odds ratio per 5 BMI units (log scale)') s.append(f'no association') for r, yy in ylab: colour, shape, _ = SET_STYLE[r["set"]] emph = r["id"] == 4 s.append(f'{esc(SHORT[r["id"]])}' f'{" †" if r["id"] == 7 else ""}') s.append(f'') for e in (r["lo"], r["hi"]): s.append(f'') s.append(marker(sx(r["or"]), yy, shape, colour, emphasis=emph)) s.append(f'{fmt(r["or"])} ' f'({fmt(r["lo"])}–{fmt(r["hi"])})') s.append(f'') s.append(f'' f'reported for transparency, not part of either hypothesised set') sl = d["i_criterion_heterogeneity"]["set_level_slopes_or_per_5"] s.append(f'') for i, (key, colour) in enumerate((("content_confounded", CC), ("pharmacological_core", CORE))): yy = slopes_y + i * 21 v = sl[key] name = "Content-confounded set slope" if i == 0 else "Pharmacological-core set slope" s.append(f'{name}') d_ = 6.5 cx = sx(v) s.append(f'') s.append(f'{fmt(v, 4)}') # legend lx = 40 s.append(f'') for i, (key, label) in enumerate((("content_confounded", "Content-confounded"), ("pharmacological_core", "Pharmacological core"), ("neither", "Neither set"))): colour, shape, _ = SET_STYLE[key] gx = i * 190 s.append(f'{marker(7, 0, shape, colour, r=5)}' f'{label}') s.append("") s.append("") tbl = data_table( ["Criterion", "Set", "OR per 5 BMI units", "95% CI", "q (BH)"], [[SHORT[r["id"]], SET_STYLE[r["set"]][2], fmt(r["or"]), f'{fmt(r["lo"])}–{fmt(r["hi"])}', fmt_q(r["q"])] for r in sorted(rows, key=lambda r: -r["or"])], "Show these figures as a table") b = d["b_primary_confirmatory"]["confirmatory_contrast"] cap = ( f'Each criterion's own fitted slope, ordered by effect size rather than by criterion ' f'number, so the sets can be seen to interleave. Neither set is homogeneous in the ' f'direction of its own average. Only 3 of the 5 content-confounded criteria sit ' f'above 1, and only 1 of the 3 pharmacological-core criteria does. Criterion 4 (ringed) was ' f'hypothesised to sit near the top and instead sits significantly below 1. The confirmatory ' f'test compared the two set-level slopes shown at the foot: ratio of odds ratios ' f'{fmt(b["or_per_5"], 4)} (95% CI {fmt(b["ci_lo_per_5"], 4)}–{fmt(b["ci_hi_per_5"], 4)}, ' f'p = {b["p_value"]:.4f}). That ratio is on a different scale from the axis above and is ' f'deliberately not plotted on it. † Criterion 7's result is compromised by the ' f'measurement defect described below. n = 1,773 respondents, ' f'14,184 stacked rows.' ) return figure_wrap("".join(s) + tbl, cap, "fig-forest") # ── Figure 3 — unconditioned to conditioned slope chart ─────────────────────── def fig3(rows) -> str: lo_ax, hi_ax = 0.82, 1.70 w, h = 780, 470 top, bot = 70.0, 380.0 xa, xb = 250.0, 520.0 sy = lambda v: bot - (math.log(v) - math.log(lo_ax)) / (math.log(hi_ax) - math.log(lo_ax)) * (bot - top) s = [svg_open(w, h, "Every criterion's BMI odds ratio before and after holding overall severity constant", "Slope chart. On the left, all eleven criteria show unconditioned odds ratios between " "1.24 and 1.61, every one above 1 and every one statistically significant. On the " "right, after conditioning on the respondent's score on the other ten criteria, the " "same eleven spread from 0.87 to 1.21 and straddle 1. Criterion 1 falls furthest, from " "1.399 to 1.014. Full values are in the accompanying data table.", 640)] for t in (0.9, 1.0, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6): ty = sy(t) strong = abs(t - 1.0) < 1e-9 s.append(f'') s.append(f'{t:g}') s.append(f'no assoc.') s.append(f'Unconditioned') s.append(f'BMI vs each criterion alone') s.append(f'Conditioned') s.append(f'among people equally symptomatic overall') ordered = sorted(rows, key=lambda r: -r["or"]) label_y = decollide([sy(r["or"]) for r in ordered], 16.5, top - 10, bot + 40) for r in sorted(rows, key=lambda r: -r["uncond_or"]): colour, shape, _ = SET_STYLE[r["set"]] ya, yb = sy(r["uncond_or"]), sy(r["or"]) emph = r["id"] == 1 s.append(f'') s.append(marker(xa, ya, shape, colour, r=4.5)) s.append(marker(xb, yb, shape, colour, r=4.5)) # Right-hand labels are nudged apart so none overlap, with a leader line back to # its own marker wherever the nudge moved it more than a couple of pixels. for r, ly in zip(ordered, label_y): colour, shape, _ = SET_STYLE[r["set"]] yb = sy(r["or"]) emph = r["id"] == 1 if abs(ly - yb) > 2: s.append(f'') s.append(f'' f'{esc(SHORT[r["id"]])} {fmt(r["or"], 2)}') s.append(f'') for i, (key, label) in enumerate((("content_confounded", "Content-confounded"), ("pharmacological_core", "Pharmacological core"), ("neither", "Neither set"))): colour, shape, _ = SET_STYLE[key] s.append(f'{marker(7, 0, shape, colour, r=5)}' f'{label}') s.append("") tbl = data_table( ["Criterion", "Unconditioned OR", "Conditioned OR", "Shift"], [[SHORT[r["id"]], fmt(r["uncond_or"]), fmt(r["or"]), f'−{fmt(r["uncond_or"] - r["or"])}'] for r in sorted(rows, key=lambda r: -r["uncond_or"])], "Show these figures as a table") cap = ( 'Left: every one of the eleven criteria shows a positive, statistically significant ' 'association with BMI when tested on its own. That looks like overwhelming evidence and is ' 'close to meaningless, because BMI predicts overall severity and overall severity mechanically ' 'drags each of its own components upward. Right: the same eleven criteria after asking the ' 'sharper question, whether BMI still predicts endorsing this criterion among people equally ' 'symptomatic overall. The uniform picture disappears and the criteria spread out on both ' 'sides of 1. Criterion 1 (bold) falls furthest, from 1.399 at p = 3.4 × 10⁻²² ' 'to 1.014 at p = 0.71. n = 1,796.' ) return figure_wrap("".join(s) + tbl, cap, "fig-conditioning") # ── Figure 4 — criterion 7 mis-mapping schematic ────────────────────────────── def fig4() -> str: w, h = 760, 560 ERR = "#A80000" OK = "#005239" IX, IW = 30.0, 250.0 # item boxes CX, CW = 480.0, 210.0 # criterion boxes BH = 36.0 s = [svg_open(w, h, "How criterion 7 was mis-wired, and how it is wired now", "Two panels. In the faulty version, item 1 (ate much more than planned) feeds both " "criterion 1 and criterion 7, and item 11 (felt irritable, nervous or sad when cutting " "down), the genuine withdrawal item, feeds no criterion at all and is discarded. In the " "corrected version, item 1 feeds only criterion 1 and item 11 feeds criterion 7 " "alongside items 12 to 15.", 660)] def panel(oy, title, sub, faulty): """One before/after panel. Items on the left, criteria on the right, annotations placed beneath their own box so they never sit on a connector curve.""" p = [f'{title}', f'{sub}'] iy1, iy2, iy3 = oy + 24, oy + 88, oy + 152 cy1, cy2 = oy + 24, oy + 152 items = [("Item 1", "“ate much more than planned”", iy1), ("Item 11", "“felt irritable, nervous or sad”", iy2), ("Items 12–15", "other withdrawal items", iy3)] for name, gloss, y in items: dead = faulty and name == "Item 11" col = ERR if dead else INK p.append(f'') p.append(f'{name}') p.append(f'{gloss}') for name, gloss, y in (("Criterion 1", "larger amounts / longer", cy1), ("Criterion 7", "withdrawal", cy2)): p.append(f'') p.append(f'{name}') p.append(f'{gloss}') def link(y1, y2, colour, width=1.6): x1, x2 = IX + IW, CX mid = (x1 + x2) / 2 return (f'') mid_i1, mid_i2, mid_i3 = iy1 + BH / 2, iy2 + BH / 2, iy3 + BH / 2 mid_c1, mid_c7 = cy1 + BH / 2, cy2 + BH / 2 p.append(link(mid_i1, mid_c1, INK)) p.append(link(mid_i3, mid_c7, INK)) if faulty: p.append(link(mid_i1, mid_c7, ERR, width=2.2)) p.append(f'also counted as criterion 7') stub = IX + IW p.append(f'') p.append(f'') p.append(f'') p.append(f'in no criterion at all, so never stored') else: p.append(link(mid_i2, mid_c7, OK, width=2.2)) p.append(f'now counted as criterion 7') return "".join(p) s.append(f'' f'' f'' f'' f'') s.append(panel(34, "How it was scored", "23 February 2025 to 27 July 2026", True)) s.append(f'') s.append(panel(310, "How it is scored now", "corrected 27 July 2026", False)) s.append(f'' f'A single mistyped digit, 11 written as 1, put a criterion-1 item into criterion 7' f'and left the real withdrawal item in no criterion at all.' f'') s.append("") cap = ( 'The scoring map assigns each criterion a set of questionnaire items, and a criterion counts as ' 'met if any one of its items crosses its own threshold. Criterion 7 should have drawn on the ' 'genuine withdrawal item (item 11). Instead it drew on item 1, which asks about amount and ' 'duration and was already counted correctly under criterion 1, whilst item 11 was scored against ' 'its threshold and then filed under no criterion and discarded. Because the calculator only ' 'stores a respondent's item answers when the criterion they belong to was met, item 11's ' 'answers were never written to the database for anyone. That is why criterion 7 cannot be ' 'rescored retrospectively: the data needed to do it was never captured. ' '191 of the 1,012 respondents counted as endorsing criterion 7 (18.9%) qualified solely through ' 'the misplaced item.' ) return figure_wrap("".join(s), cap, "fig-c7-mapping") # ── Figure 5 — STROBE-style exclusion funnel ────────────────────────────────── def fig5(cohort) -> str: c = cohort n_frozen = c["n_frozen"] n_cohort = c["n_complete_case_cohort"] n_18 = c["n_after_under18_exclusion"] n_analytic = c["n_analytic"] n_primary = c["n_primary_model_eligible"] # Each stage carries the exclusions applied on the way INTO it, so no check is # orphaned. The two that removed nobody are kept deliberately, not dropped. stages = [ (f"{n_frozen:,}", "Quiz submissions up to the freeze date", [], None), (f"{n_cohort:,}", "BMI, age band and sex all recorded", [(f"{c['n_malformed_result_json']}", "unreadable stored result"), (f"{n_frozen - n_cohort:,}", "one or more of the three missing"), (f"{c['n_removed_by_integrity_guard']}", "failed the integrity guard")], None), (f"{n_18:,}", "Aged 18 or over", [(f"{c['n_under18_in_cohort']}", "under 18, on ethics grounds")], None), (f"{n_analytic:,}", "Height and weight biologically plausible", [(f"{c['n_implausible_bmi']}", "implausible recomputed BMI")], "descriptive analytic sample"), (f"{n_primary:,}", "Sex recorded as female or male", [(f"{c['n_sex_other_or_prefer_not_to_say']}", "other / prefer not to say")], "primary confirmatory model"), ] w = 760 BX, BW, BHt = 30.0, 330.0, 54.0 EX, EW = 432.0, 300.0 gap = 44.0 top = 30.0 h = int(top + len(stages) * BHt + (len(stages) - 1) * gap + 46) s = [svg_open(w, h, "How the analytic sample was arrived at", f"Exclusion flow diagram. {n_frozen:,} submissions up to the freeze date. " f"{n_frozen - n_cohort:,} excluded for not recording BMI, age band and sex, leaving " f"{n_cohort:,}; none were removed by the integrity guard. " f"{c['n_under18_in_cohort']} under-18s excluded, leaving {n_18:,}. " f"{c['n_implausible_bmi']} implausible height and weight combinations excluded, " f"leaving the descriptive analytic sample of {n_analytic:,}. A further " f"{c['n_sex_other_or_prefer_not_to_say']} respondents recording sex as other or " f"prefer not to say were excluded from the primary model only, leaving {n_primary:,}.", 640)] y = top for i, (n, label, excluded, tag) in enumerate(stages): s.append(f'') s.append(f'n = {n}') s.append(f'{label}') if tag: s.append(f'{tag}') if i < len(stages) - 1: ay = y + BHt s.append(f'') # the callout sits level with the arrow leading into the next box, and lists # the exclusions that box records ey = y + BHt + 6 nxt = stages[i + 1][2] if i + 1 < len(stages) else None if nxt: ey = y + BHt + 6 - (17 * (len(nxt) - 1)) / 2 s.append(f'') lines = [] for cnt, why in nxt: zero = cnt == "0" col = MUTED if zero else INK lines.append((cnt, why, col)) by = ey - 6 bh = 16 + 17 * len(lines) s.append(f'') for j, (cnt, why, col) in enumerate(lines): ty = by + 20 + j * 17 s.append(f'' f'−{cnt} ' f'{why}') y += BHt + gap s.insert(1, f'' f'') s.append(f'' f'Freeze clause: {esc(c["freeze_clause"])}') s.append("") cap = ( f'The freeze was a genuine no-op rather than a convenient cut: the latest submission in the ' f'table was {c["max_date_create"][:10]}, before the boundary the analysis plan had already ' f'committed to. Two of the checks removed nobody, and both are shown rather than quietly ' f'dropped, because a stated check that turns out to bind on no one is still evidence the ' f'check was applied. The under-18 exclusion is a bright line taken on research-ethics ' f'grounds, not a sensitivity choice: an unsupervised online quiz has no route to parental ' f'consent. The final step applies to the primary confirmatory model only; every descriptive ' f'and secondary figure in this article uses the full {n_analytic:,}.' ) return figure_wrap("".join(s), cap, "fig-exclusions") # ── Figure 6 — BMI band vs mean symptom count ───────────────────────────────── # Source: food_addiction_analytics/database/storage/yfas_analysis.sqlite, # table demographic_bmi_distribution, last_updated 2026-07-26 05:00:01. These are the # site's LIVE analytics over the whole responding population, deliberately NOT the # frozen 1,796-respondent study cohort, because that is what the article's prose cites. # Frozen here so --check stays deterministic whilst the nightly cron keeps moving the # table; run with --verify-analytics to re-read the database and flag drift. BMI_GRADIENT_SOURCE = "2026-07-26 05:00:01" BMI_GRADIENT = [ ("Underweight", "under 18.5", 92, 3.8913043478261, 40.217391304348), ("Normal", "18.5 to 25", 1114, 3.3815080789946, 36.265709156194), ("Overweight", "25 to 30", 824, 4.6031553398058, 43.81067961165), ("Obese I", "30 to 35", 500, 5.73, 57.8), ("Obese II", "35 to 40", 272, 6.3860294117647, 65.073529411765), ("Obese III", "40 and over", 336, 6.8541666666667, 65.77380952381), ] BMI_RAMP = ["#c3a9cf", "#b08ec0", "#9d73b0", "#89599f", "#75408c", "#5e2d73"] def fig6() -> str: w = 760 LX, X0, X1 = 30.0, 250.0, 600.0 top, rh = 92.0, 42.0 AXMAX = 11.0 sx = lambda v: X0 + v / AXMAX * (X1 - X0) h = int(top + len(BMI_GRADIENT) * rh + 62) total = sum(r[2] for r in BMI_GRADIENT) s = [svg_open(w, h, "Mean food addiction symptom count by BMI category, across the whole responding population", "Bar chart. Mean symptom count out of a possible eleven rises from 3.38 in the normal " "BMI band to 6.85 in the highest obesity band. The underweight band sits at 3.89, above " "normal, on only 92 respondents. Overweight 4.60, obese class one 5.73, obese class two " "6.39. The proportion meeting the clinical significance criterion rises alongside it, " "from 36.3 per cent at normal BMI to 65.8 per cent at the highest band.", 660)] s.append(f'' f'mean number of criteria endorsed, out of a possible 11') for t in range(0, 12, 2): tx = sx(t) s.append(f'') s.append(f'{t}') s.append(f'' f'clinically') s.append(f'' f'significant') y = top for (name, rng, n, mean, clin), colour in zip(BMI_GRADIENT, BMI_RAMP): bh = 22.0 s.append(f'{name}') s.append(f'BMI {rng} · n = {n:,}') s.append(f'') s.append(f'{mean:.2f}') s.append(f'{clin:.1f}%') y += rh s.append(f'' f'Creative Touch live analytics, {total:,} respondents with a BMI recorded, ' f'as at {BMI_GRADIENT_SOURCE[:10]}.' f'Whole responding population, not the frozen study cohort.' f'') s.append("") cap = ( 'This is the well-established gradient the rest of the article is built against, and it is not ' 'in dispute: more symptoms are endorsed, on average, further up the BMI range. Two ' 'honest qualifications. The total on the horizontal axis includes criterion 7, whose ' 'measurement was defective throughout, and roughly a fifth of its positives came from a ' 'non-withdrawal item that is itself linked to BMI, so this gradient is very likely slightly ' 'overstated by an amount we have not tried to quantify. One criterion out of eleven, partly ' 'affected, does not produce or reverse a rise from 3.38 to 6.85. And the underweight band sits ' 'above normal on 92 respondents, which is a reminder that the relationship is not a ' 'clean straight line, a point the article returns to in the secondary checks. These are the ' 'site's live analytics across everyone who has taken the quiz, deliberately not the frozen ' 'study cohort, because this figure describes the background the study set out to interrogate.' ) return figure_wrap("".join(s), cap, "fig-bmi-gradient") def verify_analytics() -> int: """Re-read the live analytics table and report drift against the frozen copy above.""" import sqlite3 db = (HERE.parent.parent.parent / "food_addiction_analytics" / "database" / "storage" / "yfas_analysis.sqlite") if not db.exists(): print(f"analytics sqlite not found at {db}; nothing to verify") return 0 con = sqlite3.connect(f"file:{db}?mode=ro", uri=True) live = {r[0]: r[1:] for r in con.execute( "SELECT bmi_category, count, avg_symptom_count, clinical_significance_percent, " "last_updated FROM demographic_bmi_distribution")} con.close() key = {"Underweight": "underweight", "Normal": "normal", "Overweight": "overweight", "Obese I": "obese_class_1", "Obese II": "obese_class_2", "Obese III": "obese_class_3"} drift = [] for name, _, n, mean, clin in BMI_GRADIENT: row = live.get(key[name]) if row is None: drift.append(f"{name}: missing from the live table") elif row[0] != n or abs(row[1] - mean) > 5e-4 or abs(row[2] - clin) > 5e-4: drift.append(f"{name}: frozen n={n} mean={mean:.4f} clin={clin:.4f} " f"vs live n={row[0]} mean={row[1]:.4f} clin={row[2]:.4f}") stamp = next(iter(live.values()))[3] if live else "?" print(f"live table last_updated {stamp}; frozen copy taken {BMI_GRADIENT_SOURCE}") if drift: print("DRIFT (expected: the cron refreshes nightly). Figure 6 still shows the frozen " "values the article's prose cites:") for line in drift: print(" " + line) else: print("no drift: the live table still matches the frozen copy") return 0 def main(): ap = argparse.ArgumentParser() ap.add_argument("--check", action="store_true", help="regenerate and report whether on-disk output differs") ap.add_argument("--verify-analytics", action="store_true", help="re-read the live analytics sqlite and report drift against figure 6") args = ap.parse_args() if args.verify_analytics: return verify_analytics() d, rows = load() cohort = json.loads((HERE / "snapshots" / "cohort_summary.json").read_text(encoding="utf-8")) OUTDIR.mkdir(exist_ok=True) SVGDIR.mkdir(exist_ok=True) figs = { "fig1-criteria-table.html": ("fig-criteria-table", fig1(rows)), "fig2-forest.html": ("fig-forest", fig2(rows, d)), "fig3-conditioning.html": ("fig-conditioning", fig3(rows)), "fig4-c7-mapping.html": ("fig-c7-mapping", fig4()), "fig5-exclusions.html": ("fig-exclusions", fig5(cohort)), "fig6-bmi-gradient.html": ("fig-bmi-gradient", fig6()), } outputs = {} for name, (fid, html) in figs.items(): outputs[OUTDIR / name] = add_image_microdata(html, fid) + "\n" filename = PUBLISHED_SVG.get(fid) if filename: svg = standalone_svg(html) if svg: outputs[SVGDIR / filename] = svg changed = [] for p, content in outputs.items(): if args.check: if not p.exists() or p.read_text(encoding="utf-8") != content: changed.append(p.name) else: p.write_text(content, encoding="utf-8") print(f"wrote {p.parent.name}/{p.name} ({len(content):,} bytes)") if args.check: print("DIFFERS: " + ", ".join(changed) if changed else "all figures and SVGs match the canonical run") return 1 if changed else 0 print(f"\n{len(PUBLISHED_SVG)} standalone SVGs written to {SVGDIR.name}/ " f"for upload to {LIVE_IMAGE_BASE}") return 0 if __name__ == "__main__": sys.exit(main())